mirror of
https://github.com/yusing/godoxy.git
synced 2026-01-11 21:10:30 +01:00
Compare commits
206 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b40f81fcc | ||
|
|
afefd925ea | ||
|
|
0850562bf9 | ||
|
|
bc2335a54e | ||
|
|
5a9fc3ad18 | ||
|
|
29f85db022 | ||
|
|
6034908a95 | ||
|
|
ef3dbc217b | ||
|
|
01357617ae | ||
|
|
4775f4ea31 | ||
|
|
ae7b27e1c9 | ||
|
|
70c8c4b4aa | ||
|
|
b7802f4e3e | ||
|
|
6f35a6f5e9 | ||
|
|
5e2ce9e1e6 | ||
|
|
e04080bf1c | ||
|
|
55134c8426 | ||
|
|
0e886f5ddf | ||
|
|
1e97d1230a | ||
|
|
d44ce0ee6f | ||
|
|
c30d3f585f | ||
|
|
112859caa5 | ||
|
|
6b669fc540 | ||
|
|
cb9b7d55fd | ||
|
|
c506db1ef4 | ||
|
|
65afc73f25 | ||
|
|
7e60d1803c | ||
|
|
3ecc0f95bf | ||
|
|
c1db404c0d | ||
|
|
b38bff41d8 | ||
|
|
6e30d39b78 | ||
|
|
753e193d62 | ||
|
|
4819972399 | ||
|
|
ba8705fb84 | ||
|
|
9f71fc2dd5 | ||
|
|
a587ada170 | ||
|
|
320e29ba84 | ||
|
|
cd74b76483 | ||
|
|
2fe0b888bd | ||
|
|
af14966b09 | ||
|
|
5fa0d47c0d | ||
|
|
659ad29875 | ||
|
|
a0a81240ce | ||
|
|
89f08f0da7 | ||
|
|
85c1a48d3a | ||
|
|
846c1a104e | ||
|
|
4dda54c9e6 | ||
|
|
1ab34ed46f | ||
|
|
e7aaa95ec5 | ||
|
|
1042d12df6 | ||
|
|
751594860a | ||
|
|
84675b5c0f | ||
|
|
e7be27413c | ||
|
|
654194b274 | ||
|
|
36069cbe6d | ||
|
|
34d5edd6b9 | ||
|
|
57a7c04a4c | ||
|
|
87279688e6 | ||
|
|
783b352e3b | ||
|
|
f683ab64ab | ||
|
|
942651dc16 | ||
|
|
2e86f8e6d8 | ||
|
|
c66694aa32 | ||
|
|
f2a9ddd1a6 | ||
|
|
6aefe4d5d9 | ||
|
|
00f60a6e78 | ||
|
|
34858a1ba0 | ||
|
|
4ae3d5344c | ||
|
|
276684f076 | ||
|
|
2baeb6a572 | ||
|
|
adb067a57f | ||
|
|
0995c8b839 | ||
|
|
0aa00ab226 | ||
|
|
c5d96f96e1 | ||
|
|
4d94d12e9c | ||
|
|
d82594bf09 | ||
|
|
2f275ca81e | ||
|
|
59f4eaf3ea | ||
|
|
8a9cb2527e | ||
|
|
e53d6d216d | ||
|
|
ec78a92234 | ||
|
|
f948d05b90 | ||
|
|
48430fd9c3 | ||
|
|
843d7b2231 | ||
|
|
51b8806184 | ||
|
|
68b2d79700 | ||
|
|
17e8532e6f | ||
|
|
be81415a75 | ||
|
|
b6c806a789 | ||
|
|
32871a8a3c | ||
|
|
c6630a9f20 | ||
|
|
2cbee10527 | ||
|
|
aff8a3b401 | ||
|
|
a9f6c4eb20 | ||
|
|
28d4373f67 | ||
|
|
452bb0b0d7 | ||
|
|
eabdd3de00 | ||
|
|
fcfb7a0105 | ||
|
|
5d5c623f09 | ||
|
|
cebc0c5405 | ||
|
|
52d5e2f36d | ||
|
|
ef1863f810 | ||
|
|
cd749ac6a4 | ||
|
|
3f9d73d784 | ||
|
|
58cfba7695 | ||
|
|
d1cb7a5ce4 | ||
|
|
863bb3f474 | ||
|
|
a4f44348ef | ||
|
|
51f9afb471 | ||
|
|
f8bdc7044c | ||
|
|
796a4a693a | ||
|
|
1c1ba1b55e | ||
|
|
3af3a88f66 | ||
|
|
25eeabb9f9 | ||
|
|
fb9de4c4ad | ||
|
|
497879fb4b | ||
|
|
6e9b5cc113 | ||
|
|
edc1ad952d | ||
|
|
4188bbc5bd | ||
|
|
10591452e4 | ||
|
|
c269bd04d3 | ||
|
|
acdb324f7d | ||
|
|
d3842ec3c3 | ||
|
|
e1cac9f92f | ||
|
|
4533cc592f | ||
|
|
23614fe0d0 | ||
|
|
d723403b6b | ||
|
|
f3b21e6bd9 | ||
|
|
6a2638c70c | ||
|
|
b162dcbfbe | ||
|
|
25a2de2a90 | ||
|
|
67b2286df0 | ||
|
|
64728d10ad | ||
|
|
ae69019265 | ||
|
|
c07f2ed722 | ||
|
|
2951304647 | ||
|
|
d936e24692 | ||
|
|
ba26e6a5d6 | ||
|
|
6194bac4c4 | ||
|
|
a1d1325ad6 | ||
|
|
cceebff93a | ||
|
|
f97e3f65fe | ||
|
|
5214ae1760 | ||
|
|
6be3aef367 | ||
|
|
6712e9b109 | ||
|
|
50a0686648 | ||
|
|
d47afa3081 | ||
|
|
1ddfe2fb92 | ||
|
|
3ae3d18566 | ||
|
|
5fdb171d65 | ||
|
|
99e43fe340 | ||
|
|
cf1ecbc826 | ||
|
|
5ff27b9e3d | ||
|
|
291304af75 | ||
|
|
b63ebfcb3b | ||
|
|
c6a9a816f6 | ||
|
|
f5cf716a91 | ||
|
|
6dbee61742 | ||
|
|
d89d97b61f | ||
|
|
01b7ec2a99 | ||
|
|
ddbee9ec19 | ||
|
|
0bbadc6d6d | ||
|
|
64584c73b2 | ||
|
|
8df28628ec | ||
|
|
3bf520541b | ||
|
|
a531896bd6 | ||
|
|
e005b42d18 | ||
|
|
1f6573b6da | ||
|
|
73af381c4c | ||
|
|
625bf4dfdc | ||
|
|
46b4090629 | ||
|
|
91e012987e | ||
|
|
a86d316d07 | ||
|
|
76454df5e6 | ||
|
|
67b6e40f85 | ||
|
|
9889b5a8d3 | ||
|
|
00fc75b61b | ||
|
|
4ee93a1351 | ||
|
|
669d13b89a | ||
|
|
5fa86b5eb7 | ||
|
|
369cdf8c4f | ||
|
|
0397f69853 | ||
|
|
81177926ff | ||
|
|
e5bbb18414 | ||
|
|
cfa74d69ae | ||
|
|
bee26f43d4 | ||
|
|
a3ab32e9ab | ||
|
|
c847fe4747 | ||
|
|
a278711421 | ||
|
|
01ffe0d97c | ||
|
|
bd732dfa0a | ||
|
|
8b8e1773e8 | ||
|
|
b296fb2965 | ||
|
|
53557e38b6 | ||
|
|
c0c61709ca | ||
|
|
56b778f19c | ||
|
|
f4d532598c | ||
|
|
53fa28ae77 | ||
|
|
f38b3abdbc | ||
|
|
99207ae606 | ||
|
|
d3b8cb8cba | ||
|
|
51c6eb4597 | ||
|
|
d47b672aa5 | ||
|
|
64e30f59e8 | ||
|
|
cef7b3d396 | ||
|
|
7184c9cfe9 |
25
.env.example
Normal file
25
.env.example
Normal file
@@ -0,0 +1,25 @@
|
||||
# set timezone to get correct log timestamp
|
||||
TZ=ETC/UTC
|
||||
|
||||
# generate secret with `openssl rand -base64 32`
|
||||
GODOXY_API_JWT_SECRET=
|
||||
|
||||
# the JWT token time-to-live
|
||||
GODOXY_API_JWT_TOKEN_TTL=1h
|
||||
|
||||
# API/WebUI login credentials
|
||||
GODOXY_API_USER=admin
|
||||
GODOXY_API_PASSWORD=password
|
||||
|
||||
# Proxy listening address
|
||||
GODOXY_HTTP_ADDR=:80
|
||||
GODOXY_HTTPS_ADDR=:443
|
||||
|
||||
# API listening address
|
||||
GODOXY_API_ADDR=127.0.0.1:8888
|
||||
|
||||
# Prometheus Metrics listening address (uncomment to enable)
|
||||
#GODOXY_PROMETHEUS_ADDR=:8889
|
||||
|
||||
# Debug mode
|
||||
GODOXY_DEBUG=false
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,10 +1,13 @@
|
||||
compose.yml
|
||||
*.compose.yml
|
||||
|
||||
config
|
||||
certs
|
||||
config*/
|
||||
certs*/
|
||||
bin/
|
||||
error_pages/
|
||||
!examples/error_pages/
|
||||
|
||||
logs/
|
||||
log/
|
||||
@@ -22,3 +25,4 @@ todo.md
|
||||
.aider*
|
||||
mtrace.json
|
||||
.env
|
||||
test.Dockerfile
|
||||
|
||||
@@ -108,6 +108,7 @@ linters:
|
||||
- prealloc # Too many false-positive.
|
||||
- makezero # Not relevant
|
||||
- dupl # Too strict
|
||||
- gci # I don't care
|
||||
- gosec # Too strict
|
||||
- gochecknoinits
|
||||
- gochecknoglobals
|
||||
|
||||
@@ -2,36 +2,37 @@
|
||||
# To learn more about the format of this file, see https://docs.trunk.io/reference/trunk-yaml
|
||||
version: 0.1
|
||||
cli:
|
||||
version: 1.22.6
|
||||
version: 1.22.8
|
||||
# Trunk provides extensibility via plugins. (https://docs.trunk.io/plugins)
|
||||
plugins:
|
||||
sources:
|
||||
- id: trunk
|
||||
ref: v1.6.3
|
||||
ref: v1.6.6
|
||||
uri: https://github.com/trunk-io/plugins
|
||||
# Many linters and tools depend on runtimes - configure them here. (https://docs.trunk.io/runtimes)
|
||||
runtimes:
|
||||
enabled:
|
||||
- node@18.12.1
|
||||
- node@18.20.5
|
||||
- python@3.10.8
|
||||
- go@1.23.2
|
||||
# This is the section where you manage your linters. (https://docs.trunk.io/check/configuration)
|
||||
lint:
|
||||
disabled:
|
||||
- markdownlint
|
||||
- yamllint
|
||||
enabled:
|
||||
- hadolint@2.12.0
|
||||
- actionlint@1.7.3
|
||||
- checkov@3.2.257
|
||||
- hadolint@2.12.1-beta
|
||||
- actionlint@1.7.5
|
||||
- checkov@3.2.346
|
||||
- git-diff-check
|
||||
- gofmt@1.20.4
|
||||
- golangci-lint@1.61.0
|
||||
- markdownlint@0.42.0
|
||||
- osv-scanner@1.9.0
|
||||
- oxipng@9.1.2
|
||||
- prettier@3.3.3
|
||||
- golangci-lint@1.62.2
|
||||
- osv-scanner@1.9.2
|
||||
- oxipng@9.1.3
|
||||
- prettier@3.4.2
|
||||
- shellcheck@0.10.0
|
||||
- shfmt@3.6.0
|
||||
- trufflehog@3.82.7
|
||||
- yamllint@1.35.1
|
||||
- trufflehog@3.88.0
|
||||
actions:
|
||||
disabled:
|
||||
- trunk-announce
|
||||
|
||||
4
.vscode/settings.example.json
vendored
4
.vscode/settings.example.json
vendored
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"yaml.schemas": {
|
||||
"https://github.com/yusing/go-proxy/raw/main/schema/config.schema.json": [
|
||||
"https://github.com/yusing/go-proxy/raw/v0.8/schema/config.schema.json": [
|
||||
"config.example.yml",
|
||||
"config.yml"
|
||||
],
|
||||
"https://github.com/yusing/go-proxy/raw/main/schema/providers.schema.json": [
|
||||
"https://github.com/yusing/go-proxy/raw/v0.8/schema/providers.schema.json": [
|
||||
"providers.example.yml"
|
||||
]
|
||||
}
|
||||
|
||||
25
Dockerfile
25
Dockerfile
@@ -1,5 +1,9 @@
|
||||
# Stage 1: Builder
|
||||
FROM golang:1.23.2-alpine AS builder
|
||||
FROM golang:1.23.4-alpine AS builder
|
||||
HEALTHCHECK NONE
|
||||
|
||||
# package version does not matter
|
||||
# trunk-ignore(hadolint/DL3018)
|
||||
RUN apk add --no-cache tzdata make
|
||||
|
||||
WORKDIR /src
|
||||
@@ -18,15 +22,15 @@ ENV VERSION=${VERSION}
|
||||
|
||||
COPY scripts /src/scripts
|
||||
COPY Makefile /src/
|
||||
COPY cmd /src/cmd
|
||||
COPY internal /src/internal
|
||||
COPY pkg /src/pkg
|
||||
|
||||
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
|
||||
mv bin/godoxy /app/godoxy
|
||||
|
||||
# Stage 2: Final image
|
||||
FROM scratch
|
||||
@@ -40,14 +44,17 @@ COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo
|
||||
# copy binary
|
||||
COPY --from=builder /app /app
|
||||
|
||||
# copy schema directory
|
||||
COPY schema/ /app/schema/
|
||||
# copy example config
|
||||
COPY config.example.yml /app/config/config.yml
|
||||
|
||||
# copy certs
|
||||
COPY --from=builder /etc/ssl/certs /etc/ssl/certs
|
||||
|
||||
# copy schema
|
||||
COPY schema /app/schema
|
||||
|
||||
ENV DOCKER_HOST=unix:///var/run/docker.sock
|
||||
ENV GOPROXY_DEBUG=0
|
||||
ENV GODOXY_DEBUG=0
|
||||
|
||||
EXPOSE 80
|
||||
EXPOSE 8888
|
||||
@@ -55,4 +62,4 @@ EXPOSE 443
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
CMD ["/app/go-proxy"]
|
||||
CMD ["/app/godoxy"]
|
||||
54
Makefile
54
Makefile
@@ -1,5 +1,6 @@
|
||||
VERSION ?= $(shell git describe --tags --abbrev=0)
|
||||
BUILD_FLAGS ?= -s -w -X github.com/yusing/go-proxy/pkg.version=${VERSION}
|
||||
BUILD_FLAGS ?= -s -w
|
||||
BUILD_DATE ?= $(shell date -u +'%Y%m%d-%H%M')
|
||||
export VERSION
|
||||
export BUILD_FLAGS
|
||||
export CGO_ENABLED = 0
|
||||
@@ -13,7 +14,7 @@ build:
|
||||
scripts/build.sh
|
||||
|
||||
test:
|
||||
GOPROXY_TEST=1 go test ./internal/...
|
||||
GODOXY_TEST=1 go test ./internal/...
|
||||
|
||||
up:
|
||||
docker compose up -d
|
||||
@@ -28,28 +29,23 @@ get:
|
||||
go get -u ./cmd && go mod tidy
|
||||
|
||||
debug:
|
||||
make build && sudo GOPROXY_DEBUG=1 bin/go-proxy
|
||||
GODOXY_DEBUG=1 BUILD_FLAGS="" make run
|
||||
|
||||
debug-trace:
|
||||
GODOXY_TRACE=1 make debug
|
||||
|
||||
profile:
|
||||
GODEBUG=gctrace=1 make debug
|
||||
|
||||
run: build
|
||||
sudo setcap CAP_NET_BIND_SERVICE=+eip bin/godoxy
|
||||
bin/godoxy
|
||||
|
||||
mtrace:
|
||||
bin/go-proxy debug-ls-mtrace > mtrace.json
|
||||
|
||||
run-test:
|
||||
make build && sudo GOPROXY_TEST=1 bin/go-proxy
|
||||
|
||||
run:
|
||||
make build && sudo bin/go-proxy
|
||||
|
||||
archive:
|
||||
git archive HEAD -o ../go-proxy-$$(date +"%Y%m%d%H%M").zip
|
||||
|
||||
repush:
|
||||
git reset --soft HEAD^
|
||||
git add -A
|
||||
git commit -m "repush"
|
||||
git push gitlab dev --force
|
||||
bin/godoxy debug-ls-mtrace > mtrace.json
|
||||
|
||||
rapid-crash:
|
||||
sudo docker run --restart=always --name test_crash debian:bookworm-slim /bin/cat &&\
|
||||
sudo docker run --restart=always --name test_crash -p 80 debian:bookworm-slim /bin/cat &&\
|
||||
sleep 3 &&\
|
||||
sudo docker rm -f test_crash
|
||||
|
||||
@@ -58,4 +54,20 @@ debug-list-containers:
|
||||
|
||||
ci-test:
|
||||
mkdir -p /tmp/artifacts
|
||||
act -n --artifact-server-path /tmp/artifacts -s GITHUB_TOKEN="$$(gh auth token)"
|
||||
act -n --artifact-server-path /tmp/artifacts -s GITHUB_TOKEN="$$(gh auth token)"
|
||||
|
||||
cloc:
|
||||
cloc --not-match-f '_test.go$$' cmd internal pkg
|
||||
|
||||
push-docker-io:
|
||||
BUILDER=build docker buildx build \
|
||||
--platform linux/arm64,linux/amd64 \
|
||||
-f Dockerfile \
|
||||
-t docker.io/yusing/godoxy-nightly \
|
||||
-t docker.io/yusing/godoxy-nightly:${VERSION}-${BUILD_DATE} \
|
||||
--build-arg VERSION="${VERSION}-nightly-${BUILD_DATE}" \
|
||||
--push .
|
||||
|
||||
build-docker:
|
||||
docker build -t godoxy-nightly \
|
||||
--build-arg VERSION="${VERSION}-nightly-${BUILD_DATE}" .
|
||||
112
README.md
112
README.md
@@ -1,4 +1,4 @@
|
||||
# go-proxy
|
||||
# GoDoxy
|
||||
|
||||
[](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy)
|
||||
[](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy)
|
||||
@@ -19,11 +19,14 @@ _Join our [Discord](https://discord.gg/umReR62nRd) for help and discussions_
|
||||
|
||||
<!-- TOC -->
|
||||
|
||||
- [go-proxy](#go-proxy)
|
||||
- [GoDoxy](#godoxy)
|
||||
- [Table of content](#table-of-content)
|
||||
- [Key Features](#key-features)
|
||||
- [Getting Started](#getting-started)
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [Setup](#setup)
|
||||
- [Manual Setup](#manual-setup)
|
||||
- [Folder structrue](#folder-structrue)
|
||||
- [Use JSON Schema in VSCode](#use-json-schema-in-vscode)
|
||||
- [Screenshots](#screenshots)
|
||||
- [idlesleeper](#idlesleeper)
|
||||
@@ -31,58 +34,104 @@ _Join our [Discord](https://discord.gg/umReR62nRd) for help and discussions_
|
||||
|
||||
## Key Features
|
||||
|
||||
- Easy to use
|
||||
- Effortless configuration
|
||||
- Simple multi-node setup
|
||||
- Error messages is clear and detailed, easy troubleshooting
|
||||
- Auto SSL cert management (See [Supported DNS-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)**
|
||||
- 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 and config editor**
|
||||
- Supports linux/amd64, linux/arm64
|
||||
- Written in **[Go](https://go.dev)**
|
||||
|
||||
[🔼Back to top](#table-of-content)
|
||||
|
||||
## Getting Started
|
||||
|
||||
For full documentation, **[See Wiki](https://github.com/yusing/go-proxy/wiki)**
|
||||
|
||||
### Prerequisites
|
||||
|
||||
Setup DNS Records point to machine which runs `GoDoxy`, e.g.
|
||||
|
||||
- A Record: `*.y.z` -> `10.0.10.1`
|
||||
- AAAA Record: `*.y.z` -> `::ffff:a00:a01`
|
||||
|
||||
### Setup
|
||||
|
||||
1. Pull docker image
|
||||
|
||||
1. Pull the latest docker images
|
||||
|
||||
```shell
|
||||
docker pull ghcr.io/yusing/go-proxy:latest
|
||||
```
|
||||
|
||||
2. Create new directory, `cd` into it, then run setup
|
||||
2. Create new directory, `cd` into it, then run setup, or [set up manually](#manual-setup)
|
||||
|
||||
```shell
|
||||
docker run --rm -v .:/setup ghcr.io/yusing/go-proxy /app/go-proxy setup
|
||||
docker run --rm -v .:/setup ghcr.io/yusing/go-proxy /app/godoxy setup
|
||||
```
|
||||
|
||||
3. Setup DNS Records point to machine which runs `go-proxy`, e.g.
|
||||
3. _(Optional)_ setup WebUI login
|
||||
|
||||
- A Record: `*.y.z` -> `10.0.10.1`
|
||||
- AAAA Record: `*.y.z` -> `::ffff:a00:a01`
|
||||
- set random JWT secret
|
||||
|
||||
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`
|
||||
```shell
|
||||
sed -i "s|API_JWT_SECRET=.*|API_JWT_SECRET=$(openssl rand -base64 32)|g" .env
|
||||
```
|
||||
|
||||
5. Run go-proxy `docker compose up -d`
|
||||
then list all routes to see if further configurations are needed:
|
||||
`docker exec go-proxy /app/go-proxy ls-routes`
|
||||
- change username and password for WebUI authentication
|
||||
```shell
|
||||
sed -i "s|API_USERNAME=.*|API_USERNAME=admin|g" .env
|
||||
sed -i "s|API_PASSWORD=.*|API_PASSWORD=some-strong-password|g" .env
|
||||
```
|
||||
|
||||
4. _(Optional)_ setup `docker-socket-proxy` other docker nodes (see [Multi docker nodes setup](https://github.com/yusing/go-proxy/wiki/Configurations#multi-docker-nodes-setup)) then add them inside `config.yml`
|
||||
|
||||
5. Start the container `docker compose up -d`
|
||||
|
||||
6. You may now do some extra configuration
|
||||
- With text editor (e.g. Visual Studio Code)
|
||||
- With Web UI via `http://localhost:3000` or `https://gp.y.z`
|
||||
- For more info, [See Wiki]([wiki](https://github.com/yusing/go-proxy/wiki))
|
||||
- With text editor (e.g. Visual Studio Code)
|
||||
- With Web UI via `https://gp.y.z`
|
||||
|
||||
[🔼Back to top](#table-of-content)
|
||||
|
||||
### Manual Setup
|
||||
|
||||
1. Make `config` directory then grab `config.example.yml` into `config/config.yml`
|
||||
|
||||
`mkdir -p config && wget https://raw.githubusercontent.com/yusing/go-proxy/v0.8/config.example.yml -O config/config.yml`
|
||||
|
||||
2. Grab `.env.example` into `.env`
|
||||
|
||||
`wget https://raw.githubusercontent.com/yusing/go-proxy/v0.8/.env.example -O .env`
|
||||
|
||||
3. Grab `compose.example.yml` into `compose.yml`
|
||||
|
||||
`wget https://raw.githubusercontent.com/yusing/go-proxy/v0.8/compose.example.yml -O compose.yml`
|
||||
|
||||
### Folder structrue
|
||||
|
||||
```shell
|
||||
├── certs
|
||||
│ ├── cert.crt
|
||||
│ └── priv.key
|
||||
├── compose.yml
|
||||
├── config
|
||||
│ ├── config.yml
|
||||
│ ├── middlewares
|
||||
│ │ ├── middleware1.yml
|
||||
│ │ ├── middleware2.yml
|
||||
│ ├── provider1.yml
|
||||
│ └── provider2.yml
|
||||
└── .env
|
||||
```
|
||||
|
||||
### Use JSON Schema in VSCode
|
||||
|
||||
Copy [`.vscode/settings.example.json`](.vscode/settings.example.json) to `.vscode/settings.json` and modify it to fit your needs
|
||||
@@ -95,7 +144,6 @@ Copy [`.vscode/settings.example.json`](.vscode/settings.example.json) to `.vscod
|
||||
|
||||

|
||||
|
||||
|
||||
[🔼Back to top](#table-of-content)
|
||||
|
||||
## Build it yourself
|
||||
|
||||
181
README_CHT.md
181
README_CHT.md
@@ -1,4 +1,4 @@
|
||||
# go-proxy
|
||||
# GoDoxy
|
||||
|
||||
[](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy)
|
||||
[](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy)
|
||||
@@ -7,124 +7,155 @@
|
||||
[](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)))的反向代理和端口轉發工具
|
||||
[English Documentation](README.md)
|
||||
|
||||
一個輕量級、易於使用且[高效能](https://github.com/yusing/go-proxy/wiki/Benchmarks)的反向代理,具有網頁介面和儀表板。
|
||||
|
||||

|
||||
|
||||
_加入我們的 [Discord](https://discord.gg/umReR62nRd) 獲取幫助和討論_
|
||||
|
||||
## 目錄
|
||||
|
||||
<!-- TOC -->
|
||||
|
||||
- [go-proxy](#go-proxy)
|
||||
- [GoDoxy](#godoxy)
|
||||
- [目錄](#目錄)
|
||||
- [重點](#重點)
|
||||
- [主要特點](#主要特點)
|
||||
- [入門指南](#入門指南)
|
||||
- [前置需求](#前置需求)
|
||||
- [安裝](#安裝)
|
||||
- [命令行參數](#命令行參數)
|
||||
- [環境變量](#環境變量)
|
||||
- [VSCode 中使用 JSON Schema](#vscode-中使用-json-schema)
|
||||
- [展示](#展示)
|
||||
- [idlesleeper](#idlesleeper)
|
||||
- [源碼編譯](#源碼編譯)
|
||||
- [手動安裝](#手動安裝)
|
||||
- [資料夾結構](#資料夾結構)
|
||||
- [在 VSCode 中使用 JSON Schema](#在-vscode-中使用-json-schema)
|
||||
- [截圖](#截圖)
|
||||
- [閒置休眠](#閒置休眠)
|
||||
- [自行編譯](#自行編譯)
|
||||
|
||||
## 重點
|
||||
## 主要特點
|
||||
|
||||
- 易用
|
||||
- 不需花費太多時間就能輕鬆配置
|
||||
- 支持多個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)** 編寫
|
||||
- 容易使用
|
||||
- 輕鬆配置
|
||||
- 簡單的多節點設置
|
||||
- 錯誤訊息清晰詳細,易於排除故障
|
||||
- 自動 SSL 憑證管理(參見 [支援的 DNS-01 驗證提供商](https://github.com/yusing/go-proxy/wiki/Supported-DNS%E2%80%9001-Providers))
|
||||
- 自動配置 Docker 容器
|
||||
- 容器狀態/配置文件變更時自動熱重載
|
||||
- **閒置休眠**:在閒置時停止容器,有流量時喚醒(_可選,參見[截圖](#閒置休眠)_)
|
||||
- HTTP(s) 反向代理
|
||||
- [HTTP 中介軟體支援](https://github.com/yusing/go-proxy/wiki/Middlewares)
|
||||
- [自訂錯誤頁面支援](https://github.com/yusing/go-proxy/wiki/Middlewares#custom-error-pages)
|
||||
- TCP 和 UDP 埠轉發
|
||||
- **網頁介面,具有應用儀表板和配置編輯器**
|
||||
- 支援 linux/amd64、linux/arm64
|
||||
- 使用 **[Go](https://go.dev)** 編寫
|
||||
|
||||
[🔼 返回頂部](#目錄)
|
||||
[🔼回到頂部](#目錄)
|
||||
|
||||
## 入門指南
|
||||
|
||||
完整文檔請參見 **[Wiki](https://github.com/yusing/go-proxy/wiki)**
|
||||
|
||||
### 前置需求
|
||||
|
||||
設置 DNS 記錄指向運行 `GoDoxy` 的機器,例如:
|
||||
|
||||
- A 記錄:`*.y.z` -> `10.0.10.1`
|
||||
- AAAA 記錄:`*.y.z` -> `::ffff:a00:a01`
|
||||
|
||||
### 安裝
|
||||
|
||||
1. 抓取Docker鏡像
|
||||
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
|
||||
2. 建立新目錄,`cd` 進入後運行安裝,或[手動安裝](#手動安裝)
|
||||
|
||||
```shell
|
||||
docker run --rm -v .:/setup ghcr.io/yusing/go-proxy /app/godoxy setup
|
||||
```
|
||||
|
||||
3. 設置 DNS 記錄,例如:
|
||||
3. _(可選)_ 設置網頁介面登入
|
||||
|
||||
- A 記錄: `*.y.z` -> `10.0.10.1`
|
||||
- AAAA 記錄: `*.y.z` -> `::ffff:a00:a01`
|
||||
- 設置隨機 JWT 密鑰
|
||||
|
||||
4. 配置 `docker-socket-proxy` 其他 Docker 節點(如有) (參見 [範例](docs/docker_socket_proxy.md)) 然後加到 `config.yml` 中
|
||||
```shell
|
||||
sed -i "s|API_JWT_SECRET=.*|API_JWT_SECRET=$(openssl rand -base64 32)|g" .env
|
||||
```
|
||||
|
||||
5. 大功告成,你可以做一些額外的配置
|
||||
- 使用文本編輯器 (推薦 Visual Studio Code [參見 VSCode 使用 schema](#vscode-中使用-json-schema))
|
||||
- 或通過 `http://localhost:3000` 使用網頁配置編輯器
|
||||
- 詳情請參閱 [docker.md](docs/docker.md)
|
||||
- 更改網頁介面認證的使用者名稱和密碼
|
||||
```shell
|
||||
sed -i "s|API_USERNAME=.*|API_USERNAME=admin|g" .env
|
||||
sed -i "s|API_PASSWORD=.*|API_PASSWORD=some-strong-password|g" .env
|
||||
```
|
||||
|
||||
[🔼 返回頂部](#目錄)
|
||||
4. _(可選)_ 設置其他 Docker 節點的 `docker-socket-proxy`(參見 [多 Docker 節點設置](https://github.com/yusing/go-proxy/wiki/Configurations#multi-docker-nodes-setup)),然後在 `config.yml` 中添加它們
|
||||
|
||||
### 命令行參數
|
||||
5. 啟動容器 `docker compose up -d`
|
||||
|
||||
| 參數 | 描述 | 示例 |
|
||||
| ------------------------- | ------------------------------------------------------------------------------------- | ----------------------------------- |
|
||||
| 空 | 啟動代理服務器 | |
|
||||
| `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` |
|
||||
6. 現在您可以進行額外的配置
|
||||
- 使用文字編輯器(如 Visual Studio Code)
|
||||
- 通過網頁介面 `https://gp.y.z`
|
||||
|
||||
**使用 `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` |
|
||||
1. 建立 `config` 目錄,然後將 `config.example.yml` 下載到 `config/config.yml`
|
||||
|
||||
### VSCode 中使用 JSON Schema
|
||||
`mkdir -p config && wget https://raw.githubusercontent.com/yusing/go-proxy/v0.8/config.example.yml -O config/config.yml`
|
||||
|
||||
複製 [`.vscode/settings.example.json`](.vscode/settings.example.json) 到 `.vscode/settings.json` 並根據需求修改
|
||||
2. 將 `.env.example` 下載到 `.env`
|
||||
|
||||
[🔼 返回頂部](#目錄)
|
||||
`wget https://raw.githubusercontent.com/yusing/go-proxy/v0.8/.env.example -O .env`
|
||||
|
||||
3. 將 `compose.example.yml` 下載到 `compose.yml`
|
||||
|
||||
## 展示
|
||||
`wget https://raw.githubusercontent.com/yusing/go-proxy/v0.8/compose.example.yml -O compose.yml`
|
||||
|
||||
### idlesleeper
|
||||
### 資料夾結構
|
||||
|
||||

|
||||
```shell
|
||||
├── certs
|
||||
│ ├── cert.crt
|
||||
│ └── priv.key
|
||||
├── compose.yml
|
||||
├── config
|
||||
│ ├── config.yml
|
||||
│ ├── middlewares
|
||||
│ │ ├── middleware1.yml
|
||||
│ │ ├── middleware2.yml
|
||||
│ ├── provider1.yml
|
||||
│ └── provider2.yml
|
||||
└── .env
|
||||
```
|
||||
|
||||
[🔼 返回頂部](#目錄)
|
||||
### 在 VSCode 中使用 JSON Schema
|
||||
|
||||
## 源碼編譯
|
||||
複製 [`.vscode/settings.example.json`](.vscode/settings.example.json) 到 `.vscode/settings.json` 並根據需要修改
|
||||
|
||||
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` 編譯
|
||||
[🔼回到頂部](#目錄)
|
||||
|
||||
[🔼 返回頂部](#目錄)
|
||||
## 自行編譯
|
||||
|
||||
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` 編譯二進制檔案
|
||||
|
||||
[🔼回到頂部](#目錄)
|
||||
|
||||
215
cmd/main.go
215
cmd/main.go
@@ -1,75 +1,79 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/yusing/go-proxy/internal"
|
||||
"github.com/yusing/go-proxy/internal/api"
|
||||
"github.com/yusing/go-proxy/internal/api/v1/query"
|
||||
"github.com/yusing/go-proxy/internal/common"
|
||||
"github.com/yusing/go-proxy/internal/config"
|
||||
"github.com/yusing/go-proxy/internal/docker"
|
||||
"github.com/yusing/go-proxy/internal/docker/idlewatcher"
|
||||
"github.com/yusing/go-proxy/internal/entrypoint"
|
||||
E "github.com/yusing/go-proxy/internal/error"
|
||||
"github.com/yusing/go-proxy/internal/logging"
|
||||
"github.com/yusing/go-proxy/internal/metrics"
|
||||
"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/internal/net/http/server"
|
||||
"github.com/yusing/go-proxy/internal/task"
|
||||
"github.com/yusing/go-proxy/pkg"
|
||||
)
|
||||
|
||||
func main() {
|
||||
args := common.GetArgs()
|
||||
|
||||
if args.Command == common.CommandSetup {
|
||||
switch args.Command {
|
||||
case 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 {
|
||||
case common.CommandReload:
|
||||
if err := query.ReloadServer(); err != nil {
|
||||
E.LogFatal("server reload error", err)
|
||||
}
|
||||
logging.Info().Msg("ok")
|
||||
return
|
||||
case common.CommandListIcons:
|
||||
icons, err := internal.ListAvailableIcons()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
log.Print("ok")
|
||||
printJSON(icons)
|
||||
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")
|
||||
} else {
|
||||
printJSON(routes)
|
||||
return
|
||||
}
|
||||
case common.CommandDebugListMTrace:
|
||||
trace, err := query.ListMiddlewareTraces()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
printJSON(trace)
|
||||
return
|
||||
}
|
||||
|
||||
// exit if only validate config
|
||||
if args.Command == common.CommandStart {
|
||||
logging.Info().Msgf("GoDoxy version %s", pkg.GetVersion())
|
||||
logging.Trace().Msg("trace enabled")
|
||||
// logging.AddHook(notif.GetDispatcher())
|
||||
} else {
|
||||
logging.DiscardLogger()
|
||||
}
|
||||
|
||||
if args.Command == common.CommandValidate {
|
||||
data, err := os.ReadFile(common.ConfigPath)
|
||||
if err == nil {
|
||||
err = config.Validate(data).Error()
|
||||
err = config.Validate(data)
|
||||
}
|
||||
if err != nil {
|
||||
log.Fatal("config error: ", err)
|
||||
@@ -84,140 +88,93 @@ func main() {
|
||||
|
||||
middleware.LoadComposeFiles()
|
||||
|
||||
if err := config.Load(); err != nil {
|
||||
logrus.Warn(err)
|
||||
var cfg *config.Config
|
||||
var err E.Error
|
||||
if cfg, err = config.Load(); err != nil {
|
||||
E.LogWarn("errors in config", 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)
|
||||
}
|
||||
cfg.StartProxyProviders()
|
||||
printJSON(config.RoutesByAlias())
|
||||
return
|
||||
case common.CommandListIcons:
|
||||
icons, err := internal.ListAvailableIcons()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
printJSON(icons)
|
||||
case common.CommandListConfigs:
|
||||
printJSON(config.Value())
|
||||
return
|
||||
case common.CommandDebugListEntries:
|
||||
printJSON(cfg.DumpEntries())
|
||||
printJSON(config.DumpEntries())
|
||||
return
|
||||
case common.CommandDebugListProviders:
|
||||
printJSON(cfg.DumpProviders())
|
||||
printJSON(config.DumpProviders())
|
||||
return
|
||||
case common.CommandDebugListMTrace:
|
||||
trace, err := query.ListMiddlewareTraces()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
printJSON(trace)
|
||||
}
|
||||
|
||||
if common.APIJWTSecret == nil {
|
||||
logging.Warn().Msg("API JWT secret is empty, authentication is disabled")
|
||||
}
|
||||
|
||||
cfg.StartProxyProviders()
|
||||
cfg.WatchChanges()
|
||||
|
||||
onShutdown.Add(docker.CloseAllClients)
|
||||
onShutdown.Add(cfg.Dispose)
|
||||
config.WatchChanges()
|
||||
|
||||
sig := make(chan os.Signal, 1)
|
||||
signal.Notify(sig, syscall.SIGINT)
|
||||
signal.Notify(sig, syscall.SIGTERM)
|
||||
signal.Notify(sig, syscall.SIGHUP)
|
||||
|
||||
autocert := cfg.GetAutoCertProvider()
|
||||
autocert := config.GetAutoCertProvider()
|
||||
if autocert != nil {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
onShutdown.Add(cancel)
|
||||
if err := autocert.Setup(ctx); err != nil {
|
||||
l.Fatal(err)
|
||||
if err := autocert.Setup(); err != nil {
|
||||
E.LogFatal("autocert setup error", err)
|
||||
}
|
||||
} else {
|
||||
l.Info("autocert not configured")
|
||||
logging.Info().Msg("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,
|
||||
server.StartServer(server.Options{
|
||||
Name: "proxy",
|
||||
CertProvider: autocert,
|
||||
HTTPAddr: common.ProxyHTTPAddr,
|
||||
HTTPSAddr: common.ProxyHTTPSAddr,
|
||||
Handler: http.HandlerFunc(entrypoint.Handler),
|
||||
})
|
||||
apiServer := server.InitAPIServer(server.Options{
|
||||
Name: "api",
|
||||
CertProvider: autocert,
|
||||
HTTPAddr: common.APIHTTPAddr,
|
||||
Handler: api.NewHandler(cfg),
|
||||
RedirectToHTTPS: cfg.Value().RedirectToHTTPS,
|
||||
server.StartServer(server.Options{
|
||||
Name: "api",
|
||||
CertProvider: autocert,
|
||||
HTTPAddr: common.APIHTTPAddr,
|
||||
Handler: api.NewHandler(),
|
||||
})
|
||||
|
||||
proxyServer.Start()
|
||||
apiServer.Start()
|
||||
onShutdown.Add(proxyServer.Stop)
|
||||
onShutdown.Add(apiServer.Stop)
|
||||
|
||||
go idlewatcher.Start()
|
||||
onShutdown.Add(idlewatcher.Stop)
|
||||
if common.PrometheusEnabled {
|
||||
server.StartServer(server.Options{
|
||||
Name: "metrics",
|
||||
CertProvider: autocert,
|
||||
HTTPAddr: common.MetricsHTTPAddr,
|
||||
Handler: metrics.NewHandler(),
|
||||
})
|
||||
}
|
||||
|
||||
// wait for signal
|
||||
<-sig
|
||||
|
||||
// grafully shutdown
|
||||
logrus.Info("shutting down")
|
||||
done := make(chan struct{}, 1)
|
||||
currentIdx := 0
|
||||
|
||||
go func() {
|
||||
onShutdown.ForEach(func(f func()) {
|
||||
l.Debugf("waiting for %s to complete...", funcName(f))
|
||||
f()
|
||||
currentIdx++
|
||||
l.Debugf("%s done", funcName(f))
|
||||
})
|
||||
close(done)
|
||||
}()
|
||||
|
||||
timeout := time.After(time.Duration(cfg.Value().TimeoutShutdown) * time.Second)
|
||||
select {
|
||||
case <-done:
|
||||
logrus.Info("shutdown complete")
|
||||
case <-timeout:
|
||||
logrus.Info("timeout waiting for shutdown")
|
||||
for i := currentIdx; i < onShutdown.Size(); i++ {
|
||||
l.Warnf("%s() is still running", funcName(onShutdown.Get(i)))
|
||||
}
|
||||
}
|
||||
logging.Info().Msg("shutting down")
|
||||
_ = task.GracefulShutdown(time.Second * time.Duration(config.Value().TimeoutShutdown))
|
||||
}
|
||||
|
||||
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)
|
||||
if err = os.MkdirAll(dir, 0o755); err != nil {
|
||||
logging.Fatal().Msgf("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, "", " "))
|
||||
j, err := json.MarshalIndent(obj, "", " ")
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
logging.Fatal().Err(err).Send()
|
||||
}
|
||||
rawLogger := log.New(os.Stdout, "", 0)
|
||||
rawLogger.Printf("%s", j) // raw output for convenience using "jq"
|
||||
rawLogger.Print(string(j)) // raw output for convenience using "jq"
|
||||
}
|
||||
|
||||
@@ -1,45 +1,42 @@
|
||||
---
|
||||
services:
|
||||
frontend:
|
||||
image: ghcr.io/yusing/go-proxy-frontend:latest
|
||||
container_name: go-proxy-frontend
|
||||
restart: unless-stopped
|
||||
network_mode: host
|
||||
depends_on:
|
||||
- app
|
||||
# if you also want to proxy the WebUI and access it via gp.y.z
|
||||
# labels:
|
||||
# - proxy.aliases=gp
|
||||
# - proxy.gp.port=3000
|
||||
frontend:
|
||||
image: ghcr.io/yusing/go-proxy-frontend:latest
|
||||
container_name: godoxy-frontend
|
||||
restart: unless-stopped
|
||||
network_mode: host
|
||||
env_file: .env
|
||||
depends_on:
|
||||
- app
|
||||
# modify below to fit your needs
|
||||
labels:
|
||||
proxy.aliases: gp
|
||||
proxy.#1.port: 3000
|
||||
# proxy.#1.middlewares.cidr_whitelist.status: 403
|
||||
# proxy.#1.middlewares.cidr_whitelist.message: IP not allowed
|
||||
# proxy.#1.middlewares.cidr_whitelist.allow: |
|
||||
# - 127.0.0.1
|
||||
# - 10.0.0.0/8
|
||||
# - 192.168.0.0/16
|
||||
# - 172.16.0.0/12
|
||||
app:
|
||||
image: ghcr.io/yusing/go-proxy:latest
|
||||
container_name: godoxy
|
||||
restart: always
|
||||
network_mode: host
|
||||
env_file: .env
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- ./config:/app/config
|
||||
- ./error_pages:/app/error_pages
|
||||
|
||||
# Make sure the value is same as `GOPROXY_API_ADDR` below (if you have changed it)
|
||||
#
|
||||
# environment:
|
||||
# GOPROXY_API_ADDR: 127.0.0.1:8888
|
||||
app:
|
||||
image: ghcr.io/yusing/go-proxy:latest
|
||||
container_name: go-proxy
|
||||
restart: always
|
||||
network_mode: host
|
||||
environment:
|
||||
# (Optional) change this to your timezone to get correct log timestamp
|
||||
TZ: ETC/UTC
|
||||
# (Optional) choose one of below to enable https
|
||||
# 1. use existing certificate
|
||||
|
||||
# Change these if you need
|
||||
#
|
||||
# GOPROXY_HTTP_ADDR: :80
|
||||
# GOPROXY_HTTPS_ADDR: :443
|
||||
# GOPROXY_API_ADDR: 127.0.0.1:8888
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- ./config:/app/config
|
||||
# - /path/to/certs/cert.crt:/app/certs/cert.crt
|
||||
# - /path/to/certs/priv.key:/app/certs/priv.key
|
||||
|
||||
# (Optional) choose one of below to enable https
|
||||
# 1. use existing certificate
|
||||
# if your cert is not named `cert.crt` change `cert_path` in `config/config.yml`
|
||||
# if your cert key is not named `priv.key` change `key_path` in `config/config.yml`
|
||||
# 2. use autocert, certs will be stored in ./certs
|
||||
# you can also use a docker volume to store it
|
||||
|
||||
# - /path/to/certs:/app/certs
|
||||
|
||||
# 2. use autocert, certs will be stored in ./certs (or other path you specify)
|
||||
|
||||
# - ./certs:/app/certs
|
||||
# - ./certs:/app/certs
|
||||
|
||||
@@ -20,6 +20,60 @@
|
||||
#
|
||||
# 3. other providers, check docs/dns_providers.md for more
|
||||
|
||||
entrypoint:
|
||||
middlewares:
|
||||
# this part blocks all non-LAN HTTP traffic
|
||||
# remove if you don't want this
|
||||
- use: CIDRWhitelist
|
||||
allow:
|
||||
- "127.0.0.1"
|
||||
- "10.0.0.0/8"
|
||||
- "172.16.0.0/12"
|
||||
- "192.168.0.0/16"
|
||||
status: 403
|
||||
message: "Forbidden"
|
||||
# end of CIDRWhitelist
|
||||
|
||||
# this part redirects HTTP to HTTPS
|
||||
# remove if you don't want this
|
||||
- use: RedirectHTTP
|
||||
|
||||
# access_log:
|
||||
# buffer_size: 1024
|
||||
# path: /var/log/example.log
|
||||
# filters:
|
||||
# status_codes:
|
||||
# values:
|
||||
# - 200-299
|
||||
# - 101
|
||||
# method:
|
||||
# values:
|
||||
# - GET
|
||||
# host:
|
||||
# values:
|
||||
# - example.y.z
|
||||
# headers:
|
||||
# negative: true
|
||||
# values:
|
||||
# - foo=bar
|
||||
# - baz
|
||||
# cidr:
|
||||
# values:
|
||||
# - 192.168.10.0/24
|
||||
# fields:
|
||||
# headers:
|
||||
# default: keep
|
||||
# config:
|
||||
# foo: redact
|
||||
# query:
|
||||
# default: drop
|
||||
# config:
|
||||
# foo: keep
|
||||
# cookies:
|
||||
# default: redact
|
||||
# config:
|
||||
# foo: keep
|
||||
|
||||
providers:
|
||||
# include files are standalone yaml files under `config/` directory
|
||||
#
|
||||
@@ -41,6 +95,28 @@ providers:
|
||||
#
|
||||
# remote-1: tcp://10.0.2.1:2375
|
||||
# remote-2: ssh://root:1234@10.0.2.2
|
||||
|
||||
# notification providers (notify when service health changes)
|
||||
#
|
||||
# notification:
|
||||
# - name: gotify
|
||||
# provider: gotify
|
||||
# url: https://gotify.domain.tld
|
||||
# token: abcd
|
||||
# - name: discord
|
||||
# provider: webhook
|
||||
# url: https://discord.com/api/webhooks/...
|
||||
# template: discord
|
||||
# # payload: | # discord template implies the following
|
||||
# # {
|
||||
# # "embeds": [
|
||||
# # {
|
||||
# # "title": $title,
|
||||
# # "fields": $fields,
|
||||
# # "color": "$color"
|
||||
# # }
|
||||
# # ]
|
||||
# # }
|
||||
# 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
|
||||
@@ -57,13 +133,12 @@ providers:
|
||||
# - my.site
|
||||
# - node1.my.app
|
||||
|
||||
# homepage config
|
||||
homepage:
|
||||
# use default app categories detected from alias or docker image name
|
||||
use_default_categories: true
|
||||
|
||||
# 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
|
||||
timeout_shutdown: 5
|
||||
|
||||
27
examples/docker-compose/n8n.yml
Normal file
27
examples/docker-compose/n8n.yml
Normal file
@@ -0,0 +1,27 @@
|
||||
---
|
||||
services:
|
||||
n8n:
|
||||
image: n8nio/n8n
|
||||
container_name: n8n
|
||||
restart: always
|
||||
expose:
|
||||
- 5678
|
||||
labels:
|
||||
proxy.n8n.middlewares.request.set_headers: |
|
||||
SSLRedirect: true
|
||||
STSSeconds: 315360000
|
||||
browserXSSFilter: true
|
||||
contentTypeNosniff: true
|
||||
forceSTSHeader: true
|
||||
SSLHost: ${DOMAIN_NAME}
|
||||
STSIncludeSubdomains: true
|
||||
STSPreload: true
|
||||
environment:
|
||||
- N8N_HOST=${SUBDOMAIN}.${DOMAIN_NAME}
|
||||
- N8N_PORT=5678
|
||||
- N8N_PROTOCOL=https
|
||||
- NODE_ENV=production
|
||||
- WEBHOOK_URL=https://${SUBDOMAIN}.${DOMAIN_NAME}/
|
||||
- GENERIC_TIMEZONE=${GENERIC_TIMEZONE}
|
||||
volumes:
|
||||
- ./data:/home/node/.n8n
|
||||
288
examples/error_pages/404.css
Normal file
288
examples/error_pages/404.css
Normal file
@@ -0,0 +1,288 @@
|
||||
@import url("https://fonts.googleapis.com/css?family=Audiowide&display=swap");
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
div {
|
||||
position: absolute;
|
||||
top: 0%;
|
||||
left: 0%;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
margin: 0px;
|
||||
background: radial-gradient(circle, #240015 0%, #12000b 100%);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.wrap {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
h2 {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
margin-top: 150px;
|
||||
font-size: 32px;
|
||||
text-transform: uppercase;
|
||||
transform: translate(-50%, -50%);
|
||||
display: block;
|
||||
color: #12000a;
|
||||
font-weight: 300;
|
||||
font-family: Audiowide;
|
||||
text-shadow: 0px 0px 4px #12000a;
|
||||
animation: fadeInText 3s ease-in 3.5s forwards,
|
||||
flicker4 5s linear 7.5s infinite, hueRotate 6s ease-in-out 3s infinite;
|
||||
}
|
||||
|
||||
#svgWrap_1,
|
||||
#svgWrap_2 {
|
||||
position: absolute;
|
||||
height: auto;
|
||||
width: 600px;
|
||||
max-width: 100%;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
#svgWrap_1,
|
||||
#svgWrap_2,
|
||||
div {
|
||||
animation: hueRotate 6s ease-in-out 3s infinite;
|
||||
}
|
||||
|
||||
#id1_1,
|
||||
#id2_1,
|
||||
#id3_1 {
|
||||
stroke: #ff005d;
|
||||
stroke-width: 3px;
|
||||
fill: transparent;
|
||||
filter: url(#glow);
|
||||
}
|
||||
|
||||
#id1_2,
|
||||
#id2_2,
|
||||
#id3_2 {
|
||||
stroke: #12000a;
|
||||
stroke-width: 3px;
|
||||
fill: transparent;
|
||||
filter: url(#glow);
|
||||
}
|
||||
|
||||
#id3_1 {
|
||||
stroke-dasharray: 940px;
|
||||
stroke-dashoffset: -940px;
|
||||
animation: drawLine3 2.5s ease-in-out 0s forwards,
|
||||
flicker3 4s linear 4s infinite;
|
||||
}
|
||||
|
||||
#id2_1 {
|
||||
stroke-dasharray: 735px;
|
||||
stroke-dashoffset: -735px;
|
||||
animation: drawLine2 2.5s ease-in-out 0.5s forwards,
|
||||
flicker2 4s linear 4.5s infinite;
|
||||
}
|
||||
|
||||
#id1_1 {
|
||||
stroke-dasharray: 940px;
|
||||
stroke-dashoffset: -940px;
|
||||
animation: drawLine1 2.5s ease-in-out 1s forwards,
|
||||
flicker1 4s linear 5s infinite;
|
||||
}
|
||||
|
||||
@keyframes drawLine1 {
|
||||
0% {
|
||||
stroke-dashoffset: -940px;
|
||||
}
|
||||
100% {
|
||||
stroke-dashoffset: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes drawLine2 {
|
||||
0% {
|
||||
stroke-dashoffset: -735px;
|
||||
}
|
||||
100% {
|
||||
stroke-dashoffset: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes drawLine3 {
|
||||
0% {
|
||||
stroke-dashoffset: -940px;
|
||||
}
|
||||
100% {
|
||||
stroke-dashoffset: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes flicker1 {
|
||||
0% {
|
||||
stroke: #ff005d;
|
||||
}
|
||||
1% {
|
||||
stroke: transparent;
|
||||
}
|
||||
3% {
|
||||
stroke: transparent;
|
||||
}
|
||||
4% {
|
||||
stroke: #ff005d;
|
||||
}
|
||||
6% {
|
||||
stroke: #ff005d;
|
||||
}
|
||||
7% {
|
||||
stroke: transparent;
|
||||
}
|
||||
13% {
|
||||
stroke: transparent;
|
||||
}
|
||||
14% {
|
||||
stroke: #ff005d;
|
||||
}
|
||||
100% {
|
||||
stroke: #ff005d;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes flicker2 {
|
||||
0% {
|
||||
stroke: #ff005d;
|
||||
}
|
||||
50% {
|
||||
stroke: #ff005d;
|
||||
}
|
||||
51% {
|
||||
stroke: transparent;
|
||||
}
|
||||
61% {
|
||||
stroke: transparent;
|
||||
}
|
||||
62% {
|
||||
stroke: #ff005d;
|
||||
}
|
||||
100% {
|
||||
stroke: #ff005d;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes flicker3 {
|
||||
0% {
|
||||
stroke: #ff005d;
|
||||
}
|
||||
1% {
|
||||
stroke: transparent;
|
||||
}
|
||||
10% {
|
||||
stroke: transparent;
|
||||
}
|
||||
11% {
|
||||
stroke: #ff005d;
|
||||
}
|
||||
40% {
|
||||
stroke: #ff005d;
|
||||
}
|
||||
41% {
|
||||
stroke: transparent;
|
||||
}
|
||||
45% {
|
||||
stroke: transparent;
|
||||
}
|
||||
46% {
|
||||
stroke: #ff005d;
|
||||
}
|
||||
100% {
|
||||
stroke: #ff005d;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes flicker4 {
|
||||
0% {
|
||||
color: #ff005d;
|
||||
text-shadow: 0px 0px 4px #ff005d;
|
||||
}
|
||||
30% {
|
||||
color: #ff005d;
|
||||
text-shadow: 0px 0px 4px #ff005d;
|
||||
}
|
||||
31% {
|
||||
color: #12000a;
|
||||
text-shadow: 0px 0px 4px #12000a;
|
||||
}
|
||||
32% {
|
||||
color: #ff005d;
|
||||
text-shadow: 0px 0px 4px #ff005d;
|
||||
}
|
||||
36% {
|
||||
color: #ff005d;
|
||||
text-shadow: 0px 0px 4px #ff005d;
|
||||
}
|
||||
37% {
|
||||
color: #12000a;
|
||||
text-shadow: 0px 0px 4px #12000a;
|
||||
}
|
||||
41% {
|
||||
color: #12000a;
|
||||
text-shadow: 0px 0px 4px #12000a;
|
||||
}
|
||||
42% {
|
||||
color: #ff005d;
|
||||
text-shadow: 0px 0px 4px #ff005d;
|
||||
}
|
||||
85% {
|
||||
color: #ff005d;
|
||||
text-shadow: 0px 0px 4px #ff005d;
|
||||
}
|
||||
86% {
|
||||
color: #12000a;
|
||||
text-shadow: 0px 0px 4px #12000a;
|
||||
}
|
||||
95% {
|
||||
color: #12000a;
|
||||
text-shadow: 0px 0px 4px #12000a;
|
||||
}
|
||||
96% {
|
||||
color: #ff005d;
|
||||
text-shadow: 0px 0px 4px #ff005d;
|
||||
}
|
||||
100% {
|
||||
color: #ff005d;
|
||||
text-shadow: 0px 0px 4px #ff005d;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeInText {
|
||||
1% {
|
||||
color: #12000a;
|
||||
text-shadow: 0px 0px 4px #12000a;
|
||||
}
|
||||
70% {
|
||||
color: #ff005d;
|
||||
text-shadow: 0px 0px 14px #ff005d;
|
||||
}
|
||||
100% {
|
||||
color: #ff005d;
|
||||
text-shadow: 0px 0px 4px #ff005d;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes hueRotate {
|
||||
0% {
|
||||
filter: hue-rotate(0deg);
|
||||
}
|
||||
50% {
|
||||
filter: hue-rotate(-120deg);
|
||||
}
|
||||
100% {
|
||||
filter: hue-rotate(0deg);
|
||||
}
|
||||
}
|
||||
51
examples/error_pages/404.html
Normal file
51
examples/error_pages/404.html
Normal file
@@ -0,0 +1,51 @@
|
||||
{{/* Credit: https://codepen.io/code2rithik/pen/XWpVvYL */}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Page Not Found</title>
|
||||
<link rel="stylesheet" href="/$gperrorpage/404.css" type="text/css">
|
||||
<!-- <script src="/$gperrorpage/404.js"> </script> -->
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<script>0</script>
|
||||
<div></div>
|
||||
<svg id="svgWrap_2" xmlns="http://www.w3.org/2000/svg" x="0px" y="0px" viewBox="0 0 700 250">
|
||||
<g>
|
||||
<path id="id3_2"
|
||||
d="M195.7 232.67h-37.1V149.7H27.76c-2.64 0-5.1-.5-7.36-1.49-2.27-.99-4.23-2.31-5.88-3.96-1.65-1.65-2.95-3.61-3.89-5.88s-1.42-4.67-1.42-7.22V29.62h36.82v82.98H158.6V29.62h37.1v203.05z" />
|
||||
<path id="id2_2"
|
||||
d="M470.69 147.71c0 8.31-1.06 16.17-3.19 23.58-2.12 7.41-5.12 14.28-8.99 20.6-3.87 6.33-8.45 11.99-13.74 16.99-5.29 5-11.07 9.28-17.35 12.81a85.146 85.146 0 0 1-20.04 8.14 83.637 83.637 0 0 1-21.67 2.83H319.3c-7.46 0-14.73-.94-21.81-2.83-7.08-1.89-13.76-4.6-20.04-8.14a88.292 88.292 0 0 1-17.35-12.81c-5.29-5-9.84-10.67-13.66-16.99-3.82-6.32-6.8-13.19-8.92-20.6-2.12-7.41-3.19-15.27-3.19-23.58v-33.13c0-12.46 2.34-23.88 7.01-34.27 4.67-10.38 10.92-19.33 18.76-26.83 7.83-7.5 16.87-13.36 27.12-17.56 10.24-4.2 20.93-6.3 32.07-6.3h66.41c7.36 0 14.58.94 21.67 2.83 7.08 1.89 13.76 4.6 20.04 8.14a88.292 88.292 0 0 1 17.35 12.81c5.29 5 9.86 10.67 13.74 16.99 3.87 6.33 6.87 13.19 8.99 20.6 2.13 7.41 3.19 15.27 3.19 23.58v33.14zm-37.1-33.13c0-7.27-1.32-13.88-3.96-19.82-2.64-5.95-6.16-11.04-10.55-15.29-4.39-4.25-9.46-7.5-15.22-9.77-5.76-2.27-11.8-3.35-18.13-3.26h-66.41c-6.14-.09-12.11.97-17.91 3.19-5.81 2.22-10.95 5.43-15.44 9.63-4.48 4.2-8.07 9.3-10.76 15.29-2.69 6-4.04 12.67-4.04 20.04v33.13c0 7.36 1.32 14.02 3.96 19.97 2.64 5.95 6.18 11.02 10.62 15.22 4.44 4.2 9.56 7.43 15.36 9.7 5.8 2.27 11.87 3.35 18.2 3.26h66.41c7.27 0 13.85-1.2 19.75-3.61s10.93-5.73 15.08-9.98 7.36-9.32 9.63-15.22c2.27-5.9 3.4-12.34 3.4-19.33v-33.15zm-16-26.91a17.89 17.89 0 0 1 2.83 6.73c.47 2.41.47 4.77 0 7.08-.47 2.31-1.39 4.48-2.76 6.51-1.37 2.03-3.14 3.75-5.31 5.17l-99.4 66.41c-1.61 1.23-3.26 2.08-4.96 2.55-1.7.47-3.45.71-5.24.71-3.02 0-5.9-.71-8.64-2.12-2.74-1.42-4.96-3.44-6.66-6.09a17.89 17.89 0 0 1-2.83-6.73c-.47-2.41-.5-4.77-.07-7.08.43-2.31 1.3-4.48 2.62-6.51 1.32-2.03 3.07-3.75 5.24-5.17l99.69-66.41a17.89 17.89 0 0 1 6.73-2.83c2.41-.47 4.77-.47 7.08 0 2.31.47 4.48 1.37 6.51 2.69 2.03 1.32 3.75 3.02 5.17 5.09z" />
|
||||
<path id="id1_2"
|
||||
d="M688.33 232.67h-37.1V149.7H520.39c-2.64 0-5.1-.5-7.36-1.49-2.27-.99-4.23-2.31-5.88-3.96-1.65-1.65-2.95-3.61-3.89-5.88s-1.42-4.67-1.42-7.22V29.62h36.82v82.98h112.57V29.62h37.1v203.05z" />
|
||||
</g>
|
||||
</svg>
|
||||
<svg id="svgWrap_1" xmlns="http://www.w3.org/2000/svg" x="0px" y="0px" viewBox="0 0 700 250">
|
||||
<g>
|
||||
<path id="id3_1"
|
||||
d="M195.7 232.67h-37.1V149.7H27.76c-2.64 0-5.1-.5-7.36-1.49-2.27-.99-4.23-2.31-5.88-3.96-1.65-1.65-2.95-3.61-3.89-5.88s-1.42-4.67-1.42-7.22V29.62h36.82v82.98H158.6V29.62h37.1v203.05z" />
|
||||
<path id="id2_1"
|
||||
d="M470.69 147.71c0 8.31-1.06 16.17-3.19 23.58-2.12 7.41-5.12 14.28-8.99 20.6-3.87 6.33-8.45 11.99-13.74 16.99-5.29 5-11.07 9.28-17.35 12.81a85.146 85.146 0 0 1-20.04 8.14 83.637 83.637 0 0 1-21.67 2.83H319.3c-7.46 0-14.73-.94-21.81-2.83-7.08-1.89-13.76-4.6-20.04-8.14a88.292 88.292 0 0 1-17.35-12.81c-5.29-5-9.84-10.67-13.66-16.99-3.82-6.32-6.8-13.19-8.92-20.6-2.12-7.41-3.19-15.27-3.19-23.58v-33.13c0-12.46 2.34-23.88 7.01-34.27 4.67-10.38 10.92-19.33 18.76-26.83 7.83-7.5 16.87-13.36 27.12-17.56 10.24-4.2 20.93-6.3 32.07-6.3h66.41c7.36 0 14.58.94 21.67 2.83 7.08 1.89 13.76 4.6 20.04 8.14a88.292 88.292 0 0 1 17.35 12.81c5.29 5 9.86 10.67 13.74 16.99 3.87 6.33 6.87 13.19 8.99 20.6 2.13 7.41 3.19 15.27 3.19 23.58v33.14zm-37.1-33.13c0-7.27-1.32-13.88-3.96-19.82-2.64-5.95-6.16-11.04-10.55-15.29-4.39-4.25-9.46-7.5-15.22-9.77-5.76-2.27-11.8-3.35-18.13-3.26h-66.41c-6.14-.09-12.11.97-17.91 3.19-5.81 2.22-10.95 5.43-15.44 9.63-4.48 4.2-8.07 9.3-10.76 15.29-2.69 6-4.04 12.67-4.04 20.04v33.13c0 7.36 1.32 14.02 3.96 19.97 2.64 5.95 6.18 11.02 10.62 15.22 4.44 4.2 9.56 7.43 15.36 9.7 5.8 2.27 11.87 3.35 18.2 3.26h66.41c7.27 0 13.85-1.2 19.75-3.61s10.93-5.73 15.08-9.98 7.36-9.32 9.63-15.22c2.27-5.9 3.4-12.34 3.4-19.33v-33.15zm-16-26.91a17.89 17.89 0 0 1 2.83 6.73c.47 2.41.47 4.77 0 7.08-.47 2.31-1.39 4.48-2.76 6.51-1.37 2.03-3.14 3.75-5.31 5.17l-99.4 66.41c-1.61 1.23-3.26 2.08-4.96 2.55-1.7.47-3.45.71-5.24.71-3.02 0-5.9-.71-8.64-2.12-2.74-1.42-4.96-3.44-6.66-6.09a17.89 17.89 0 0 1-2.83-6.73c-.47-2.41-.5-4.77-.07-7.08.43-2.31 1.3-4.48 2.62-6.51 1.32-2.03 3.07-3.75 5.24-5.17l99.69-66.41a17.89 17.89 0 0 1 6.73-2.83c2.41-.47 4.77-.47 7.08 0 2.31.47 4.48 1.37 6.51 2.69 2.03 1.32 3.75 3.02 5.17 5.09z" />
|
||||
<path id="id1_1"
|
||||
d="M688.33 232.67h-37.1V149.7H520.39c-2.64 0-5.1-.5-7.36-1.49-2.27-.99-4.23-2.31-5.88-3.96-1.65-1.65-2.95-3.61-3.89-5.88s-1.42-4.67-1.42-7.22V29.62h36.82v82.98h112.57V29.62h37.1v203.05z" />
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<svg>
|
||||
<defs>
|
||||
<filter id="glow">
|
||||
<fegaussianblur class="blur" result="coloredBlur" stddeviation="4"></fegaussianblur>
|
||||
<femerge>
|
||||
<femergenode in="coloredBlur"></femergenode>
|
||||
<femergenode in="SourceGraphic"></femergenode>
|
||||
</femerge>
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
<h2>Page Not Found</h2>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
1002
examples/grafana_template.json
Normal file
1002
examples/grafana_template.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,16 +0,0 @@
|
||||
services:
|
||||
app:
|
||||
container_name: microbin
|
||||
cpu_shares: 10
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 256M
|
||||
env_file: .env
|
||||
image: danielszabo99/microbin:latest
|
||||
ports:
|
||||
- 8080
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./data:/app/microbin_data
|
||||
# microbin.domain.tld
|
||||
@@ -1,16 +0,0 @@
|
||||
services:
|
||||
main:
|
||||
image: b3log/siyuan:v3.1.0
|
||||
container_name: siyuan
|
||||
command:
|
||||
- --workspace=/siyuan/workspace/
|
||||
- --accessAuthCode=<some password>
|
||||
user: 1000:1000
|
||||
volumes:
|
||||
- ./workspace:/siyuan/workspace
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- TZ=Asia/Hong_Kong
|
||||
ports:
|
||||
- 6806
|
||||
# siyuan.domain.tld
|
||||
65
go.mod
65
go.mod
@@ -1,59 +1,76 @@
|
||||
module github.com/yusing/go-proxy
|
||||
|
||||
go 1.23.2
|
||||
go 1.23.4
|
||||
|
||||
require (
|
||||
github.com/coder/websocket v1.8.12
|
||||
github.com/docker/cli v27.3.1+incompatible
|
||||
github.com/docker/docker v27.3.1+incompatible
|
||||
github.com/fsnotify/fsnotify v1.7.0
|
||||
github.com/go-acme/lego/v4 v4.19.2
|
||||
github.com/docker/cli v27.4.1+incompatible
|
||||
github.com/docker/docker v27.4.1+incompatible
|
||||
github.com/fsnotify/fsnotify v1.8.0
|
||||
github.com/go-acme/lego/v4 v4.21.0
|
||||
github.com/go-playground/validator/v10 v10.23.0
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||
github.com/gotify/server/v2 v2.6.1
|
||||
github.com/prometheus/client_golang v1.20.5
|
||||
github.com/puzpuzpuz/xsync/v3 v3.4.0
|
||||
github.com/santhosh-tekuri/jsonschema v1.2.4
|
||||
github.com/sirupsen/logrus v1.9.3
|
||||
golang.org/x/net v0.30.0
|
||||
golang.org/x/text v0.19.0
|
||||
github.com/rs/zerolog v1.33.0
|
||||
golang.org/x/net v0.33.0
|
||||
golang.org/x/text v0.21.0
|
||||
golang.org/x/time v0.9.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||
github.com/cloudflare/cloudflare-go v0.107.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cloudflare/cloudflare-go v0.113.0 // indirect
|
||||
github.com/containerd/log v0.1.0 // indirect
|
||||
github.com/distribution/reference v0.6.0 // indirect
|
||||
github.com/docker/go-connections v0.5.0 // indirect
|
||||
github.com/docker/go-units v0.5.0 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 // 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/goccy/go-json v0.10.3 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/goccy/go-json v0.10.4 // 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/klauspost/compress v1.17.11 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // 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/morikuni/aec v1.0.0 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||
github.com/opencontainers/image-spec v1.1.0 // indirect
|
||||
github.com/ovh/go-ovh v1.6.0 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/rogpeppe/go-internal v1.12.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.55.0 // indirect
|
||||
go.opentelemetry.io/otel v1.30.0 // indirect
|
||||
github.com/prometheus/client_model v0.6.1 // indirect
|
||||
github.com/prometheus/common v0.61.0 // indirect
|
||||
github.com/prometheus/procfs v0.15.1 // indirect
|
||||
github.com/sirupsen/logrus v1.9.3 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 // indirect
|
||||
go.opentelemetry.io/otel v1.33.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.30.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.30.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.33.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk v1.30.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.30.0 // indirect
|
||||
golang.org/x/crypto v0.28.0 // indirect
|
||||
golang.org/x/mod v0.21.0 // indirect
|
||||
golang.org/x/oauth2 v0.23.0 // indirect
|
||||
golang.org/x/sync v0.8.0 // indirect
|
||||
golang.org/x/sys v0.26.0 // indirect
|
||||
golang.org/x/time v0.7.0 // indirect
|
||||
golang.org/x/tools v0.26.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.33.0 // indirect
|
||||
golang.org/x/crypto v0.31.0 // indirect
|
||||
golang.org/x/mod v0.22.0 // indirect
|
||||
golang.org/x/oauth2 v0.25.0 // indirect
|
||||
golang.org/x/sync v0.10.0 // indirect
|
||||
golang.org/x/sys v0.29.0 // indirect
|
||||
golang.org/x/tools v0.28.0 // indirect
|
||||
google.golang.org/protobuf v1.36.1 // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
gotest.tools/v3 v3.5.1 // indirect
|
||||
)
|
||||
|
||||
157
go.sum
157
go.sum
@@ -2,35 +2,41 @@ github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOEl
|
||||
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||
github.com/cloudflare/cloudflare-go v0.107.0 h1:cMDIw2tzt6TXCJyMFVyP+BPOVkIfMvcKjhMNSNvuEPc=
|
||||
github.com/cloudflare/cloudflare-go v0.107.0/go.mod h1:5cYGzVBqNTLxMYSLdVjuSs5LJL517wJDSvMPWUrzHzc=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cloudflare/cloudflare-go v0.113.0 h1:qnOXmA6RbgZ4rg5gNBK5QGk0Pzbv8pnUYV3C4+8CU6w=
|
||||
github.com/cloudflare/cloudflare-go v0.113.0/go.mod h1:Dlm4BAnycHc0i8yLxQZb9b+OlMwYOAoDJsUOEFgpVvo=
|
||||
github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo=
|
||||
github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
|
||||
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
|
||||
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||
github.com/docker/cli v27.3.1+incompatible h1:qEGdFBF3Xu6SCvCYhc7CzaQTlBmqDuzxPDpigSyeKQQ=
|
||||
github.com/docker/cli v27.3.1+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
|
||||
github.com/docker/docker v27.3.1+incompatible h1:KttF0XoteNTicmUtBO0L2tP+J7FGRFTjaEF4k6WdhfI=
|
||||
github.com/docker/docker v27.3.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||
github.com/docker/cli v27.4.1+incompatible h1:VzPiUlRJ/xh+otB75gva3r05isHMo5wXDfPRi5/b4hI=
|
||||
github.com/docker/cli v27.4.1+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
|
||||
github.com/docker/docker v27.4.1+incompatible h1:ZJvcY7gfwHn1JF48PfbyXg7Jyt9ZCWDW+GGXOIxEwp4=
|
||||
github.com/docker/docker v27.4.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/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
|
||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
|
||||
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
|
||||
github.com/go-acme/lego/v4 v4.19.2 h1:Y8hrmMvWETdqzzkRly7m98xtPJJivWFsgWi8fcvZo+Y=
|
||||
github.com/go-acme/lego/v4 v4.19.2/go.mod h1:wtDe3dDkmV4/oI2nydpNXSJpvV10J9RCyZ6MbYxNtlQ=
|
||||
github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
|
||||
github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
||||
github.com/go-acme/lego/v4 v4.21.0 h1:arEW+8o5p7VI8Bk1kr/PDlgD1DrxtTH1gJ4b7mehL8o=
|
||||
github.com/go-acme/lego/v4 v4.21.0/go.mod h1:HrSWzm3Ckj45Ie3i+p1zKVobbQoMOaGu9m4up0dUeDI=
|
||||
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=
|
||||
@@ -38,10 +44,21 @@ github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
||||
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/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/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.23.0 h1:/PwmTwZhS0dPkav3cdK9kV1FsAmrL8sThn8IHr/sO+o=
|
||||
github.com/go-playground/validator/v10 v10.23.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||
github.com/goccy/go-json v0.10.4 h1:JSwxQzIqKfmFX1swYPpUThQZp/Ka4wzJdK0LWVytLPM=
|
||||
github.com/goccy/go-json v0.10.4/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
@@ -49,16 +66,30 @@ github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD
|
||||
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/gotify/server/v2 v2.6.1 h1:Kf7v5fzBxzELzZa/jonWfwJMkqYqh1LBzBpCmt5QIAI=
|
||||
github.com/gotify/server/v2 v2.6.1/go.mod h1:Dk8HLyTVDqmXM8YEg6tjROBen6mxyHZFRggJFHTwZLc=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0 h1:asbCHRVmodnJTuQ3qamDwqVOIjwqUPTYmYuemVOx+Ys=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0/go.mod h1:ggCgvZ2r7uOoQjOyu2Y1NhHmEPPzzuhWgcza5M1Ji1I=
|
||||
github.com/jarcoal/httpmock v1.3.0 h1:2RJ8GP0IIaWwcC9Fp2BmVi8Kog3v2Hn7VXM3fTd+nuc=
|
||||
github.com/jarcoal/httpmock v1.3.0/go.mod h1:3yb8rc4BI7TCBhFY8ng0gjuLKJNquuDNiPaZjnENuYg=
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
|
||||
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
|
||||
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/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/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=
|
||||
@@ -69,102 +100,116 @@ 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/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
|
||||
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
|
||||
github.com/ovh/go-ovh v1.6.0 h1:ixLOwxQdzYDx296sXcgS35TOPEahJkpjMGtzPadCjQI=
|
||||
github.com/ovh/go-ovh v1.6.0/go.mod h1:cTVDnl94z4tl8pP1uZ/8jlVxntjSIf09bNcQ5TJSC7c=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0/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/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y=
|
||||
github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
|
||||
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
|
||||
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
|
||||
github.com/prometheus/common v0.61.0 h1:3gv/GThfX0cV2lpO7gkTUwZru38mxevy90Bj8YFSRQQ=
|
||||
github.com/prometheus/common v0.61.0/go.mod h1:zr29OCN/2BsJRaFwG8QOBr41D6kkchKbpeNH7pAjb/s=
|
||||
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
|
||||
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
|
||||
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/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
|
||||
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
|
||||
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
|
||||
github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8=
|
||||
github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
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/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.55.0 h1:ZIg3ZT/aQ7AfKqdwp7ECpOK6vHqquXXuyTjIO8ZdmPs=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.55.0/go.mod h1:DQAwmETtZV00skUwgD6+0U89g80NKsJE3DCKeLLPQMI=
|
||||
go.opentelemetry.io/otel v1.30.0 h1:F2t8sK4qf1fAmY9ua4ohFS/K+FUuOPemHUIXHtktrts=
|
||||
go.opentelemetry.io/otel v1.30.0/go.mod h1:tFw4Br9b7fOS+uEao81PJjVMjW/5fvNCbpsDIXqP0pc=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 h1:yd02MEjBdJkG3uabWP9apV+OuWRIXGDuJEUJbOHmCFU=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0/go.mod h1:umTcuxiv1n/s/S6/c2AT/g2CQ7u5C59sHDNmfSwgz7Q=
|
||||
go.opentelemetry.io/otel v1.33.0 h1:/FerN9bax5LoK51X/sI0SVYrjSE0/yUL7DpxW4K3FWw=
|
||||
go.opentelemetry.io/otel v1.33.0/go.mod h1:SUUkR6csvUQl+yjReHu5uM3EtVV7MBm5FHKRlNx4I8I=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.30.0 h1:lsInsfvhVIfOI6qHVyysXMNDnjO9Npvl7tlDPJFBVd4=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.30.0/go.mod h1:KQsVNh4OjgjTG0G6EiNi1jVpnaeeKsKMRwbLN+f1+8M=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.30.0 h1:umZgi92IyxfXd/l4kaDhnKgY8rnN/cZcF1LKc6I8OQ8=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.30.0/go.mod h1:4lVs6obhSVRb1EW5FhOuBTyiQhtRtAnnva9vD3yRfq8=
|
||||
go.opentelemetry.io/otel/metric v1.30.0 h1:4xNulvn9gjzo4hjg+wzIKG7iNFEaBMX00Qd4QIZs7+w=
|
||||
go.opentelemetry.io/otel/metric v1.30.0/go.mod h1:aXTfST94tswhWEb+5QjlSqG+cZlmyXy/u8jFpor3WqQ=
|
||||
go.opentelemetry.io/otel/metric v1.33.0 h1:r+JOocAyeRVXD8lZpjdQjzMadVZp2M4WmQ+5WtEnklQ=
|
||||
go.opentelemetry.io/otel/metric v1.33.0/go.mod h1:L9+Fyctbp6HFTddIxClbQkjtubW6O9QS3Ann/M82u6M=
|
||||
go.opentelemetry.io/otel/sdk v1.30.0 h1:cHdik6irO49R5IysVhdn8oaiR9m8XluDaJAs4DfOrYE=
|
||||
go.opentelemetry.io/otel/sdk v1.30.0/go.mod h1:p14X4Ok8S+sygzblytT1nqG98QG2KYKv++HE0LY/mhg=
|
||||
go.opentelemetry.io/otel/trace v1.30.0 h1:7UBkkYzeg3C7kQX8VAidWh2biiQbtAKjyIML8dQ9wmc=
|
||||
go.opentelemetry.io/otel/trace v1.30.0/go.mod h1:5EyKqTzzmyqB9bwtCCq6pDLktPK6fmGf/Dph+8VI02o=
|
||||
go.opentelemetry.io/otel/trace v1.33.0 h1:cCJuF7LRjUFso9LPnEAHJDB2pqzp+hbO8eu1qqW2d/s=
|
||||
go.opentelemetry.io/otel/trace v1.33.0/go.mod h1:uIcdVUZMpTAmz0tI1z04GoVSezK37CbGV4fr1f2nBck=
|
||||
go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0=
|
||||
go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
|
||||
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
|
||||
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0=
|
||||
golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
|
||||
golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4=
|
||||
golang.org/x/mod v0.22.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-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4=
|
||||
golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU=
|
||||
golang.org/x/oauth2 v0.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs=
|
||||
golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
||||
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/oauth2 v0.25.0 h1:CY4y7XT9v0cRI9oupztF8AgiIu99L/ksR/Xp/6jrZ70=
|
||||
golang.org/x/oauth2 v0.25.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-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
|
||||
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
|
||||
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
|
||||
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
|
||||
golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
|
||||
golang.org/x/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ=
|
||||
golang.org/x/time v0.7.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
|
||||
golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ=
|
||||
golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0=
|
||||
golang.org/x/tools v0.28.0 h1:WuB6qZ4RPCQo5aP3WdKZS7i595EdWqWR8vqJTlwTVK8=
|
||||
golang.org/x/tools v0.28.0/go.mod h1:dcIOrVd3mfQKTgrDVQHqCPMWy6lnhfhtX3hLXYVLfRw=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/genproto v0.0.0-20240903143218-8af14fe29dc1 h1:BulPr26Jqjnd4eYDVe+YvyR7Yc2vJGkO5/0UxD0/jZU=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20240903143218-8af14fe29dc1 h1:hjSy6tcFQZ171igDaN5QHOw2n6vx40juYbC/x67CEhc=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:qpvKtACPCQhAdu3PyQgV4l3LMXZEtft7y8QcarRsp9I=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 h1:pPJltXNxVzT4pK9yD8vR9X75DaWYYmLGMsEvBfFQZzQ=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU=
|
||||
google.golang.org/grpc v1.66.1 h1:hO5qAXR19+/Z44hmvIM4dQFMSYX9XcWsByfoxutBpAM=
|
||||
google.golang.org/grpc v1.66.1/go.mod h1:s3/l6xSSCURdVfAnL+TqCNMyTDAGN6+lZeVxnZR128Y=
|
||||
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
|
||||
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
|
||||
google.golang.org/genproto v0.0.0-20241021214115-324edc3d5d38 h1:Q3nlH8iSQSRUwOskjbcSMcF2jiYMNiQYZ0c2KEJLKKU=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20241118233622-e639e219e697 h1:pgr/4QbFyktUv9CtQ/Fq4gzEE6/Xs7iCXbktaGzLHbQ=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20241118233622-e639e219e697/go.mod h1:+D9ySVjN8nY8YCVjc5O7PZDIdZporIDY3KaGfJunh88=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576 h1:8ZmaLZE4XWrtU3MyClkYqqtl6Oegr3235h7jxsDyqCY=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576/go.mod h1:5uTbfoYQed2U9p3KIj2/Zzm02PYhndfdmML0qC3q3FU=
|
||||
google.golang.org/grpc v1.67.1 h1:zWnc1Vrcno+lHZCOofnIMvycFcc0QRGIzm9dhnDX68E=
|
||||
google.golang.org/grpc v1.67.1/go.mod h1:1gLDyUQU7CTLJI90u3nXZ9ekeghjeM7pTDZlqFNg2AA=
|
||||
google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk=
|
||||
google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
||||
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=
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
|
||||
v1 "github.com/yusing/go-proxy/internal/api/v1"
|
||||
"github.com/yusing/go-proxy/internal/api/v1/errorpage"
|
||||
"github.com/yusing/go-proxy/internal/api/v1/auth"
|
||||
. "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 }
|
||||
@@ -18,45 +17,42 @@ func NewServeMux() ServeMux {
|
||||
}
|
||||
|
||||
func (mux ServeMux) HandleFunc(method, endpoint string, handler http.HandlerFunc) {
|
||||
mux.ServeMux.HandleFunc(fmt.Sprintf("%s %s", method, endpoint), checkHost(handler))
|
||||
mux.ServeMux.HandleFunc(method+" "+endpoint, checkHost(handler))
|
||||
}
|
||||
|
||||
func NewHandler(cfg *config.Config) http.Handler {
|
||||
func NewHandler() http.Handler {
|
||||
mux := NewServeMux()
|
||||
mux.HandleFunc("GET", "/v1", v1.Index)
|
||||
mux.HandleFunc("GET", "/v1/version", v1.GetVersion)
|
||||
mux.HandleFunc("GET", "/v1/checkhealth", wrap(cfg, v1.CheckHealth))
|
||||
mux.HandleFunc("HEAD", "/v1/checkhealth", wrap(cfg, v1.CheckHealth))
|
||||
mux.HandleFunc("POST", "/v1/reload", wrap(cfg, v1.Reload))
|
||||
mux.HandleFunc("GET", "/v1/list", wrap(cfg, v1.List))
|
||||
mux.HandleFunc("GET", "/v1/list/{what}", wrap(cfg, v1.List))
|
||||
mux.HandleFunc("GET", "/v1/file", v1.GetFileContent)
|
||||
mux.HandleFunc("GET", "/v1/file/{filename...}", v1.GetFileContent)
|
||||
mux.HandleFunc("POST", "/v1/file/{filename...}", v1.SetFileContent)
|
||||
mux.HandleFunc("PUT", "/v1/file/{filename...}", v1.SetFileContent)
|
||||
mux.HandleFunc("GET", "/v1/stats", wrap(cfg, v1.Stats))
|
||||
mux.HandleFunc("GET", "/v1/stats/ws", wrap(cfg, v1.StatsWS))
|
||||
mux.HandleFunc("GET", "/v1/error_page", errorpage.GetHandleFunc())
|
||||
mux.HandleFunc("POST", "/v1/login", auth.LoginHandler)
|
||||
mux.HandleFunc("GET", "/v1/logout", auth.LogoutHandler)
|
||||
mux.HandleFunc("POST", "/v1/logout", auth.LogoutHandler)
|
||||
mux.HandleFunc("POST", "/v1/reload", v1.Reload)
|
||||
mux.HandleFunc("GET", "/v1/list", auth.RequireAuth(v1.List))
|
||||
mux.HandleFunc("GET", "/v1/list/{what}", auth.RequireAuth(v1.List))
|
||||
mux.HandleFunc("GET", "/v1/list/{what}/{which}", auth.RequireAuth(v1.List))
|
||||
mux.HandleFunc("GET", "/v1/file/{type}/{filename}", auth.RequireAuth(v1.GetFileContent))
|
||||
mux.HandleFunc("POST", "/v1/file/{type}/{filename}", auth.RequireAuth(v1.SetFileContent))
|
||||
mux.HandleFunc("PUT", "/v1/file/{type}/{filename}", auth.RequireAuth(v1.SetFileContent))
|
||||
mux.HandleFunc("GET", "/v1/schema/{filename...}", v1.GetSchemaFile)
|
||||
mux.HandleFunc("GET", "/v1/stats", v1.Stats)
|
||||
mux.HandleFunc("GET", "/v1/stats/ws", v1.StatsWS)
|
||||
return mux
|
||||
}
|
||||
|
||||
// allow only requests to API server with host matching common.APIHTTPAddr.
|
||||
// allow only requests to API server with localhost.
|
||||
func checkHost(f http.HandlerFunc) http.HandlerFunc {
|
||||
if common.IsDebug {
|
||||
return f
|
||||
}
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Host != common.APIHTTPAddr {
|
||||
Logger.Warnf("invalid request to API server with host: %s, expect %s", r.Host, common.APIHTTPAddr)
|
||||
http.Error(w, "invalid request", http.StatusForbidden)
|
||||
host, _, _ := net.SplitHostPort(r.RemoteAddr)
|
||||
if host != "127.0.0.1" && host != "localhost" && host != "[::1]" {
|
||||
LogWarn(r).Msgf("blocked API request from %s", host)
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
LogDebug(r).Interface("headers", r.Header).Msg("API request")
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
135
internal/api/v1/auth/auth.go
Normal file
135
internal/api/v1/auth/auth.go
Normal file
@@ -0,0 +1,135 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
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/utils/strutils"
|
||||
)
|
||||
|
||||
type (
|
||||
Credentials struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
Claims struct {
|
||||
Username string `json:"username"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidUsername = E.New("invalid username")
|
||||
ErrInvalidPassword = E.New("invalid password")
|
||||
)
|
||||
|
||||
func validatePassword(cred *Credentials) error {
|
||||
if cred.Username != common.APIUser {
|
||||
return ErrInvalidUsername.Subject(cred.Username)
|
||||
}
|
||||
if !bytes.Equal(common.HashPassword(cred.Password), common.APIPasswordHash) {
|
||||
return ErrInvalidPassword.Subject(cred.Password)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func LoginHandler(w http.ResponseWriter, r *http.Request) {
|
||||
var creds Credentials
|
||||
err := json.NewDecoder(r.Body).Decode(&creds)
|
||||
if err != nil {
|
||||
U.HandleErr(w, r, err, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := validatePassword(&creds); err != nil {
|
||||
U.HandleErr(w, r, err, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
expiresAt := time.Now().Add(common.APIJWTTokenTTL)
|
||||
claim := &Claims{
|
||||
Username: creds.Username,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(expiresAt),
|
||||
},
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS512, claim)
|
||||
tokenStr, err := token.SignedString(common.APIJWTSecret)
|
||||
if err != nil {
|
||||
U.HandleErr(w, r, err)
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "token",
|
||||
Value: tokenStr,
|
||||
Expires: expiresAt,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
Path: "/",
|
||||
})
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func LogoutHandler(w http.ResponseWriter, r *http.Request) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "token",
|
||||
Value: "",
|
||||
Expires: time.Unix(0, 0),
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
Path: "/",
|
||||
})
|
||||
w.Header().Set("location", "/login")
|
||||
w.WriteHeader(http.StatusTemporaryRedirect)
|
||||
}
|
||||
|
||||
func RequireAuth(next http.HandlerFunc) http.HandlerFunc {
|
||||
if common.IsDebugSkipAuth || common.APIJWTSecret == nil {
|
||||
return next
|
||||
}
|
||||
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if checkToken(w, r) {
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func checkToken(w http.ResponseWriter, r *http.Request) (ok bool) {
|
||||
tokenCookie, err := r.Cookie("token")
|
||||
if err != nil {
|
||||
U.RespondError(w, E.New("missing token"), http.StatusUnauthorized)
|
||||
return false
|
||||
}
|
||||
var claims Claims
|
||||
token, err := jwt.ParseWithClaims(tokenCookie.Value, &claims, func(t *jwt.Token) (interface{}, error) {
|
||||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
|
||||
}
|
||||
return common.APIJWTSecret, nil
|
||||
})
|
||||
|
||||
switch {
|
||||
case err != nil:
|
||||
break
|
||||
case !token.Valid:
|
||||
err = E.New("invalid token")
|
||||
case claims.Username != common.APIUser:
|
||||
err = E.New("username mismatch").Subject(claims.Username)
|
||||
case claims.ExpiresAt.Before(time.Now()):
|
||||
err = E.Errorf("token expired on %s", strutils.FormatTime(claims.ExpiresAt.Time))
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
U.RespondError(w, err, http.StatusForbidden)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
. "github.com/yusing/go-proxy/internal/api/v1/utils"
|
||||
"github.com/yusing/go-proxy/internal/config"
|
||||
R "github.com/yusing/go-proxy/internal/route"
|
||||
)
|
||||
|
||||
func CheckHealth(cfg *config.Config, w http.ResponseWriter, r *http.Request) {
|
||||
target := r.FormValue("target")
|
||||
if target == "" {
|
||||
HandleErr(w, r, ErrMissingKey("target"), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var ok bool
|
||||
route := cfg.FindRoute(target)
|
||||
|
||||
switch {
|
||||
case route == nil:
|
||||
HandleErr(w, r, ErrNotFound("target", target), http.StatusNotFound)
|
||||
return
|
||||
case route.Type() == R.RouteTypeReverseProxy:
|
||||
ok = IsSiteHealthy(route.URL().String())
|
||||
case route.Type() == R.RouteTypeStream:
|
||||
entry := route.Entry()
|
||||
ok = IsStreamHealthy(
|
||||
strings.Split(entry.Scheme, ":")[1], // target scheme
|
||||
fmt.Sprintf("%s:%v", entry.Host, strings.Split(entry.Port, ":")[1]),
|
||||
)
|
||||
}
|
||||
|
||||
if ok {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
} else {
|
||||
w.WriteHeader(http.StatusRequestTimeout)
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
package errorpage
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
. "github.com/yusing/go-proxy/internal/api/v1/utils"
|
||||
)
|
||||
|
||||
func GetHandleFunc() http.HandlerFunc {
|
||||
setup()
|
||||
return serveHTTP
|
||||
}
|
||||
|
||||
func serveHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/" {
|
||||
http.Error(w, "invalid path", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
content, ok := fileContentMap.Load(r.URL.Path)
|
||||
if !ok {
|
||||
http.Error(w, "404 not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if _, err := w.Write(content); err != nil {
|
||||
HandleErr(w, r, err)
|
||||
}
|
||||
}
|
||||
@@ -11,15 +11,63 @@ import (
|
||||
"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"
|
||||
"github.com/yusing/go-proxy/internal/net/http/middleware"
|
||||
"github.com/yusing/go-proxy/internal/route/provider"
|
||||
)
|
||||
|
||||
func GetFileContent(w http.ResponseWriter, r *http.Request) {
|
||||
filename := r.PathValue("filename")
|
||||
if filename == "" {
|
||||
filename = common.ConfigFileName
|
||||
type FileType string
|
||||
|
||||
const (
|
||||
FileTypeConfig FileType = "config"
|
||||
FileTypeProvider FileType = "provider"
|
||||
FileTypeMiddleware FileType = "middleware"
|
||||
)
|
||||
|
||||
func fileType(file string) FileType {
|
||||
switch {
|
||||
case strings.HasPrefix(path.Base(file), "config."):
|
||||
return FileTypeConfig
|
||||
case strings.HasPrefix(file, common.MiddlewareComposeBasePath):
|
||||
return FileTypeMiddleware
|
||||
}
|
||||
content, err := os.ReadFile(path.Join(common.ConfigBasePath, filename))
|
||||
return FileTypeProvider
|
||||
}
|
||||
|
||||
func (t FileType) IsValid() bool {
|
||||
switch t {
|
||||
case FileTypeConfig, FileTypeProvider, FileTypeMiddleware:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (t FileType) GetPath(filename string) string {
|
||||
if t == FileTypeMiddleware {
|
||||
return path.Join(common.MiddlewareComposeBasePath, filename)
|
||||
}
|
||||
return path.Join(common.ConfigBasePath, filename)
|
||||
}
|
||||
|
||||
func getArgs(r *http.Request) (fileType FileType, filename string, err error) {
|
||||
fileType = FileType(r.PathValue("type"))
|
||||
if !fileType.IsValid() {
|
||||
err = U.ErrInvalidKey("type")
|
||||
return
|
||||
}
|
||||
filename = r.PathValue("filename")
|
||||
if filename == "" {
|
||||
err = U.ErrMissingKey("filename")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func GetFileContent(w http.ResponseWriter, r *http.Request) {
|
||||
fileType, filename, err := getArgs(r)
|
||||
if err != nil {
|
||||
U.RespondError(w, err, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
content, err := os.ReadFile(fileType.GetPath(filename))
|
||||
if err != nil {
|
||||
U.HandleErr(w, r, err)
|
||||
return
|
||||
@@ -28,9 +76,9 @@ func GetFileContent(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func SetFileContent(w http.ResponseWriter, r *http.Request) {
|
||||
filename := r.PathValue("filename")
|
||||
if filename == "" {
|
||||
U.HandleErr(w, r, U.ErrMissingKey("filename"), http.StatusBadRequest)
|
||||
fileType, filename, err := getArgs(r)
|
||||
if err != nil {
|
||||
U.RespondError(w, err, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
content, err := io.ReadAll(r.Body)
|
||||
@@ -39,19 +87,24 @@ func SetFileContent(w http.ResponseWriter, r *http.Request) {
|
||||
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)
|
||||
var valErr E.Error
|
||||
switch fileType {
|
||||
case FileTypeConfig:
|
||||
valErr = config.Validate(content)
|
||||
case FileTypeMiddleware:
|
||||
errs := E.NewBuilder("middleware errors")
|
||||
middleware.BuildMiddlewaresFromYAML(filename, content, errs)
|
||||
valErr = errs.Error()
|
||||
default:
|
||||
valErr = provider.Validate(content)
|
||||
}
|
||||
|
||||
if validateErr != nil {
|
||||
U.RespondJSON(w, r, validateErr.JSONObject(), http.StatusBadRequest)
|
||||
if valErr != nil {
|
||||
U.RespondError(w, valErr, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
err = os.WriteFile(path.Join(common.ConfigBasePath, filename), content, 0o644)
|
||||
err = os.WriteFile(fileType.GetPath(filename), content, 0o644)
|
||||
if err != nil {
|
||||
U.HandleErr(w, r, err)
|
||||
return
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
|
||||
U "github.com/yusing/go-proxy/internal/api/v1/utils"
|
||||
"github.com/yusing/go-proxy/internal/common"
|
||||
)
|
||||
|
||||
func IsSiteHealthy(url string) bool {
|
||||
// try HEAD first
|
||||
// if HEAD is not allowed, try GET
|
||||
resp, err := U.Head(url)
|
||||
if resp != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
if err != nil && resp != nil && resp.StatusCode == http.StatusMethodNotAllowed {
|
||||
_, err = U.Get(url)
|
||||
}
|
||||
if resp != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func IsStreamHealthy(scheme, address string) bool {
|
||||
conn, err := net.DialTimeout(scheme, address, common.DialTimeout)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
conn.Close()
|
||||
return true
|
||||
}
|
||||
@@ -8,80 +8,94 @@ import (
|
||||
"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/route"
|
||||
"github.com/yusing/go-proxy/internal/task"
|
||||
"github.com/yusing/go-proxy/internal/utils"
|
||||
)
|
||||
|
||||
const (
|
||||
ListRoutes = "routes"
|
||||
ListConfigFiles = "config_files"
|
||||
ListMiddlewares = "middlewares"
|
||||
ListMiddlewareTrace = "middleware_trace"
|
||||
ListMatchDomains = "match_domains"
|
||||
ListHomepageConfig = "homepage_config"
|
||||
ListRoute = "route"
|
||||
ListRoutes = "routes"
|
||||
ListFiles = "files"
|
||||
ListMiddlewares = "middlewares"
|
||||
ListMiddlewareTraces = "middleware_trace"
|
||||
ListMatchDomains = "match_domains"
|
||||
ListHomepageConfig = "homepage_config"
|
||||
ListTasks = "tasks"
|
||||
)
|
||||
|
||||
func List(cfg *config.Config, w http.ResponseWriter, r *http.Request) {
|
||||
func List(w http.ResponseWriter, r *http.Request) {
|
||||
what := r.PathValue("what")
|
||||
if what == "" {
|
||||
what = ListRoutes
|
||||
}
|
||||
which := r.PathValue("which")
|
||||
|
||||
switch what {
|
||||
case ListRoute:
|
||||
if route := listRoute(which); route == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
} else {
|
||||
U.RespondJSON(w, r, route)
|
||||
}
|
||||
case ListRoutes:
|
||||
listRoutes(cfg, w, r)
|
||||
case ListConfigFiles:
|
||||
listConfigFiles(w, r)
|
||||
U.RespondJSON(w, r, config.RoutesByAlias(route.RouteType(r.FormValue("type"))))
|
||||
case ListFiles:
|
||||
listFiles(w, r)
|
||||
case ListMiddlewares:
|
||||
listMiddlewares(w, r)
|
||||
case ListMiddlewareTrace:
|
||||
listMiddlewareTrace(w, r)
|
||||
U.RespondJSON(w, r, middleware.All())
|
||||
case ListMiddlewareTraces:
|
||||
U.RespondJSON(w, r, middleware.GetAllTrace())
|
||||
case ListMatchDomains:
|
||||
listMatchDomains(cfg, w, r)
|
||||
U.RespondJSON(w, r, config.Value().MatchDomains)
|
||||
case ListHomepageConfig:
|
||||
listHomepageConfig(cfg, w, r)
|
||||
U.RespondJSON(w, r, config.HomepageConfig())
|
||||
case ListTasks:
|
||||
U.RespondJSON(w, r, task.DebugTaskList())
|
||||
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)
|
||||
}
|
||||
}
|
||||
func listRoute(which string) any {
|
||||
if which == "" || which == "all" {
|
||||
return config.RoutesByAlias()
|
||||
}
|
||||
|
||||
U.RespondJSON(w, r, routes)
|
||||
routes := config.RoutesByAlias()
|
||||
route, ok := routes[which]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return route
|
||||
}
|
||||
|
||||
func listConfigFiles(w http.ResponseWriter, r *http.Request) {
|
||||
files, err := utils.ListFiles(common.ConfigBasePath, 1)
|
||||
func listFiles(w http.ResponseWriter, r *http.Request) {
|
||||
files, err := utils.ListFiles(common.ConfigBasePath, 0)
|
||||
if err != nil {
|
||||
U.HandleErr(w, r, err)
|
||||
return
|
||||
}
|
||||
for i := range files {
|
||||
files[i] = strings.TrimPrefix(files[i], common.ConfigBasePath+"/")
|
||||
resp := map[FileType][]string{
|
||||
FileTypeConfig: make([]string, 0),
|
||||
FileTypeProvider: make([]string, 0),
|
||||
FileTypeMiddleware: make([]string, 0),
|
||||
}
|
||||
U.RespondJSON(w, r, files)
|
||||
}
|
||||
|
||||
func listMiddlewareTrace(w http.ResponseWriter, r *http.Request) {
|
||||
U.RespondJSON(w, r, middleware.GetAllTrace())
|
||||
}
|
||||
for _, file := range files {
|
||||
t := fileType(file)
|
||||
file = strings.TrimPrefix(file, common.ConfigBasePath+"/")
|
||||
resp[t] = append(resp[t], file)
|
||||
}
|
||||
|
||||
func listMiddlewares(w http.ResponseWriter, r *http.Request) {
|
||||
U.RespondJSON(w, r, middleware.All())
|
||||
}
|
||||
|
||||
func listMatchDomains(cfg *config.Config, w http.ResponseWriter, r *http.Request) {
|
||||
U.RespondJSON(w, r, cfg.Value().MatchDomains)
|
||||
}
|
||||
|
||||
func listHomepageConfig(cfg *config.Config, w http.ResponseWriter, r *http.Request) {
|
||||
U.RespondJSON(w, r, cfg.HomepageConfig())
|
||||
mids, err := utils.ListFiles(common.MiddlewareComposeBasePath, 0)
|
||||
if err != nil {
|
||||
U.HandleErr(w, r, err)
|
||||
return
|
||||
}
|
||||
for _, mid := range mids {
|
||||
mid = strings.TrimPrefix(mid, common.MiddlewareComposeBasePath+"/")
|
||||
resp[FileTypeMiddleware] = append(resp[FileTypeMiddleware], mid)
|
||||
}
|
||||
U.RespondJSON(w, r, resp)
|
||||
}
|
||||
|
||||
@@ -13,57 +13,52 @@ import (
|
||||
"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)
|
||||
func ReloadServer() E.Error {
|
||||
resp, err := U.Post(common.APIHTTPURL+"/v1/reload", "", 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)
|
||||
failure := E.Errorf("server reload status %v", resp.StatusCode)
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return failure.Extraf("unable to read response body: %s", err)
|
||||
return failure.With(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")
|
||||
reloadErr := string(body)
|
||||
return failure.Withf(reloadErr)
|
||||
}
|
||||
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))
|
||||
func List[T any](what string) (_ T, outErr E.Error) {
|
||||
resp, err := U.Get(fmt.Sprintf("%s/v1/list/%s", common.APIHTTPURL, what))
|
||||
if err != nil {
|
||||
return nil, E.From(err)
|
||||
outErr = E.From(err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, E.Failure("list routes").Extraf("status code: %v", resp.StatusCode)
|
||||
outErr = E.Errorf("list %s: failed, status %v", what, resp.StatusCode)
|
||||
return
|
||||
}
|
||||
var routes map[string]map[string]any
|
||||
err = json.NewDecoder(resp.Body).Decode(&routes)
|
||||
var res T
|
||||
err = json.NewDecoder(resp.Body).Decode(&res)
|
||||
if err != nil {
|
||||
return nil, E.From(err)
|
||||
outErr = E.From(err)
|
||||
return
|
||||
}
|
||||
return routes, nil
|
||||
return res, 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
|
||||
func ListRoutes() (map[string]map[string]any, E.Error) {
|
||||
return List[map[string]map[string]any](v1.ListRoutes)
|
||||
}
|
||||
|
||||
func ListMiddlewareTraces() (middleware.Traces, E.Error) {
|
||||
return List[middleware.Traces](v1.ListMiddlewareTraces)
|
||||
}
|
||||
|
||||
func DebugListTasks() (map[string]any, E.Error) {
|
||||
return List[map[string]any](v1.ListTasks)
|
||||
}
|
||||
|
||||
@@ -7,10 +7,10 @@ import (
|
||||
"github.com/yusing/go-proxy/internal/config"
|
||||
)
|
||||
|
||||
func Reload(cfg *config.Config, w http.ResponseWriter, r *http.Request) {
|
||||
if err := cfg.Reload(); err != nil {
|
||||
U.RespondJSON(w, r, err.JSONObject(), http.StatusInternalServerError)
|
||||
} else {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
func Reload(w http.ResponseWriter, r *http.Request) {
|
||||
if err := config.Reload(); err != nil {
|
||||
U.HandleErr(w, r, err)
|
||||
return
|
||||
}
|
||||
U.WriteBody(w, []byte("OK"))
|
||||
}
|
||||
|
||||
23
internal/api/v1/schema.go
Normal file
23
internal/api/v1/schema.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
|
||||
U "github.com/yusing/go-proxy/internal/api/v1/utils"
|
||||
"github.com/yusing/go-proxy/internal/common"
|
||||
)
|
||||
|
||||
func GetSchemaFile(w http.ResponseWriter, r *http.Request) {
|
||||
filename := r.PathValue("filename")
|
||||
if filename == "" {
|
||||
U.RespondError(w, U.ErrMissingKey("filename"), http.StatusBadRequest)
|
||||
}
|
||||
content, err := os.ReadFile(path.Join(common.SchemaBasePath, filename))
|
||||
if err != nil {
|
||||
U.HandleErr(w, r, err)
|
||||
return
|
||||
}
|
||||
U.WriteBody(w, content)
|
||||
}
|
||||
@@ -10,24 +10,25 @@ import (
|
||||
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/yusing/go-proxy/internal/utils/strutils"
|
||||
)
|
||||
|
||||
func Stats(cfg *config.Config, w http.ResponseWriter, r *http.Request) {
|
||||
U.RespondJSON(w, r, getStats(cfg))
|
||||
func Stats(w http.ResponseWriter, r *http.Request) {
|
||||
U.RespondJSON(w, r, getStats())
|
||||
}
|
||||
|
||||
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))
|
||||
func StatsWS(w http.ResponseWriter, r *http.Request) {
|
||||
var originPats []string
|
||||
|
||||
if len(originPats) == 0 {
|
||||
U.Logger.Warnf("no match domains configured, accepting websocket request from all origins")
|
||||
localAddresses := []string{"127.0.0.1", "10.0.*.*", "172.16.*.*", "192.168.*.*"}
|
||||
|
||||
if len(config.Value().MatchDomains) == 0 {
|
||||
U.LogWarn(r).Msg("no match domains configured, accepting websocket API request from all origins")
|
||||
originPats = []string{"*"}
|
||||
} else {
|
||||
for i, domain := range cfg.Value().MatchDomains {
|
||||
originPats[i] = "*." + domain
|
||||
originPats = make([]string, len(config.Value().MatchDomains))
|
||||
for i, domain := range config.Value().MatchDomains {
|
||||
originPats[i] = "*" + domain
|
||||
}
|
||||
originPats = append(originPats, localAddresses...)
|
||||
}
|
||||
@@ -38,7 +39,7 @@ func StatsWS(cfg *config.Config, w http.ResponseWriter, r *http.Request) {
|
||||
OriginPatterns: originPats,
|
||||
})
|
||||
if err != nil {
|
||||
U.Logger.Errorf("/stats/ws failed to upgrade websocket: %s", err)
|
||||
U.LogError(r).Err(err).Msg("failed to upgrade websocket")
|
||||
return
|
||||
}
|
||||
/* trunk-ignore(golangci-lint/errcheck) */
|
||||
@@ -51,17 +52,19 @@ func StatsWS(cfg *config.Config, w http.ResponseWriter, r *http.Request) {
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
stats := getStats(cfg)
|
||||
stats := getStats()
|
||||
if err := wsjson.Write(ctx, conn, stats); err != nil {
|
||||
U.Logger.Errorf("/stats/ws failed to write JSON: %s", err)
|
||||
U.LogError(r).Msg("failed to write JSON")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func getStats(cfg *config.Config) map[string]any {
|
||||
var startTime = time.Now()
|
||||
|
||||
func getStats() map[string]any {
|
||||
return map[string]any{
|
||||
"proxies": cfg.Statistics(),
|
||||
"uptime": utils.FormatDuration(server.GetProxyServer().Uptime()),
|
||||
"proxies": config.Statistics(),
|
||||
"uptime": strutils.FormatDuration(time.Since(startTime)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,37 +1,43 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
E "github.com/yusing/go-proxy/internal/error"
|
||||
"github.com/yusing/go-proxy/internal/utils/strutils/ansi"
|
||||
)
|
||||
|
||||
var Logger = logrus.WithField("module", "api")
|
||||
// HandleErr logs the error and returns an HTTP error response to the client.
|
||||
// If code is specified, it will be used as the HTTP status code; otherwise,
|
||||
// http.StatusInternalServerError is used.
|
||||
//
|
||||
// The error is only logged but not returned to the client.
|
||||
func HandleErr(w http.ResponseWriter, r *http.Request, err error, code ...int) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
LogError(r).Msg(err.Error())
|
||||
if len(code) == 0 {
|
||||
code = []int{http.StatusInternalServerError}
|
||||
}
|
||||
http.Error(w, http.StatusText(code[0]), code[0])
|
||||
}
|
||||
|
||||
func HandleErr(w http.ResponseWriter, r *http.Request, origErr error, code ...int) {
|
||||
if origErr == nil {
|
||||
return
|
||||
func RespondError(w http.ResponseWriter, err error, code ...int) {
|
||||
if len(code) == 0 {
|
||||
code = []int{http.StatusBadRequest}
|
||||
}
|
||||
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)
|
||||
http.Error(w, ansi.StripANSI(err.Error()), code[0])
|
||||
}
|
||||
|
||||
func ErrMissingKey(k string) error {
|
||||
return errors.New("missing key '" + k + "' in query or request body")
|
||||
return E.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")
|
||||
return E.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)
|
||||
return E.Errorf("key %q with value %q not found", k, v)
|
||||
}
|
||||
|
||||
@@ -9,12 +9,11 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
HTTPClient = &http.Client{
|
||||
httpClient = &http.Client{
|
||||
Timeout: common.ConnectionTimeout,
|
||||
Transport: &http.Transport{
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
DisableKeepAlives: true,
|
||||
ForceAttemptHTTP2: true,
|
||||
ForceAttemptHTTP2: false,
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: common.DialTimeout,
|
||||
KeepAlive: common.KeepAlive, // this is different from DisableKeepAlives
|
||||
@@ -23,7 +22,7 @@ var (
|
||||
},
|
||||
}
|
||||
|
||||
Get = HTTPClient.Get
|
||||
Post = HTTPClient.Post
|
||||
Head = HTTPClient.Head
|
||||
Get = httpClient.Get
|
||||
Post = httpClient.Post
|
||||
Head = httpClient.Head
|
||||
)
|
||||
|
||||
20
internal/api/v1/utils/logging.go
Normal file
20
internal/api/v1/utils/logging.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/yusing/go-proxy/internal/logging"
|
||||
)
|
||||
|
||||
func reqLogger(r *http.Request, level zerolog.Level) *zerolog.Event {
|
||||
return logging.WithLevel(level).
|
||||
Str("module", "api").
|
||||
Str("remote", r.RemoteAddr).
|
||||
Str("uri", r.Method+" "+r.RequestURI)
|
||||
}
|
||||
|
||||
func LogError(r *http.Request) *zerolog.Event { return reqLogger(r, zerolog.ErrorLevel) }
|
||||
func LogWarn(r *http.Request) *zerolog.Event { return reqLogger(r, zerolog.WarnLevel) }
|
||||
func LogInfo(r *http.Request) *zerolog.Event { return reqLogger(r, zerolog.InfoLevel) }
|
||||
func LogDebug(r *http.Request) *zerolog.Event { return reqLogger(r, zerolog.DebugLevel) }
|
||||
@@ -2,7 +2,11 @@ package utils
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/yusing/go-proxy/internal/logging"
|
||||
"github.com/yusing/go-proxy/internal/utils/strutils/ansi"
|
||||
)
|
||||
|
||||
func WriteBody(w http.ResponseWriter, body []byte) {
|
||||
@@ -11,16 +15,30 @@ func WriteBody(w http.ResponseWriter, body []byte) {
|
||||
}
|
||||
}
|
||||
|
||||
func RespondJSON(w http.ResponseWriter, r *http.Request, data any, code ...int) bool {
|
||||
func RespondJSON(w http.ResponseWriter, r *http.Request, data any, code ...int) (canProceed bool) {
|
||||
if len(code) > 0 {
|
||||
w.WriteHeader(code[0])
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
j, err := json.MarshalIndent(data, "", " ")
|
||||
var j []byte
|
||||
var err error
|
||||
|
||||
switch data := data.(type) {
|
||||
case string:
|
||||
j = []byte(fmt.Sprintf("%q", data))
|
||||
case []byte:
|
||||
j = data
|
||||
case error:
|
||||
j, err = json.Marshal(ansi.StripANSI(data.Error()))
|
||||
default:
|
||||
j, err = json.MarshalIndent(data, "", " ")
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
HandleErr(w, r, err)
|
||||
logging.Panic().Err(err).Msg("failed to marshal json")
|
||||
return false
|
||||
}
|
||||
|
||||
_, err = w.Write(j)
|
||||
if err != nil {
|
||||
HandleErr(w, r, err)
|
||||
|
||||
@@ -4,17 +4,32 @@ import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"os"
|
||||
|
||||
"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/logging"
|
||||
"github.com/yusing/go-proxy/internal/utils"
|
||||
"github.com/yusing/go-proxy/internal/utils/strutils"
|
||||
|
||||
"github.com/yusing/go-proxy/internal/types"
|
||||
"github.com/yusing/go-proxy/internal/config/types"
|
||||
)
|
||||
|
||||
type Config types.AutoCertConfig
|
||||
|
||||
var (
|
||||
ErrMissingDomain = E.New("missing field 'domains'")
|
||||
ErrMissingEmail = E.New("missing field 'email'")
|
||||
ErrMissingProvider = E.New("missing field 'provider'")
|
||||
ErrUnknownProvider = E.New("unknown provider")
|
||||
)
|
||||
|
||||
func NewConfig(cfg *types.AutoCertConfig) *Config {
|
||||
if cfg == nil {
|
||||
cfg = new(types.AutoCertConfig)
|
||||
}
|
||||
if cfg.CertPath == "" {
|
||||
cfg.CertPath = CertFileDefault
|
||||
}
|
||||
@@ -24,38 +39,53 @@ func NewConfig(cfg *types.AutoCertConfig) *Config {
|
||||
if cfg.Provider == "" {
|
||||
cfg.Provider = ProviderLocal
|
||||
}
|
||||
if cfg.ACMEKeyPath == "" {
|
||||
cfg.ACMEKeyPath = ACMEKeyFileDefault
|
||||
}
|
||||
return (*Config)(cfg)
|
||||
}
|
||||
|
||||
func (cfg *Config) GetProvider() (provider *Provider, res E.NestedError) {
|
||||
b := E.NewBuilder("unable to initialize autocert")
|
||||
defer b.To(&res)
|
||||
func (cfg *Config) GetProvider() (*Provider, E.Error) {
|
||||
b := E.NewBuilder("autocert errors")
|
||||
|
||||
if cfg.Provider != ProviderLocal {
|
||||
if len(cfg.Domains) == 0 {
|
||||
b.Addf("%s", "no domains specified")
|
||||
b.Add(ErrMissingDomain)
|
||||
}
|
||||
if cfg.Provider == "" {
|
||||
b.Addf("%s", "no provider specified")
|
||||
b.Add(ErrMissingProvider)
|
||||
}
|
||||
if cfg.Email == "" {
|
||||
b.Addf("%s", "no email specified")
|
||||
b.Add(ErrMissingEmail)
|
||||
}
|
||||
// check if provider is implemented
|
||||
_, ok := providersGenMap[cfg.Provider]
|
||||
if !ok {
|
||||
b.Addf("unknown provider: %q", cfg.Provider)
|
||||
b.Add(ErrUnknownProvider.
|
||||
Subject(cfg.Provider).
|
||||
Withf(strutils.DoYouMean(utils.NearestField(cfg.Provider, providersGenMap))))
|
||||
}
|
||||
}
|
||||
|
||||
if b.HasError() {
|
||||
return
|
||||
return nil, b.Error()
|
||||
}
|
||||
|
||||
privKey, err := E.Check(ecdsa.GenerateKey(elliptic.P256(), rand.Reader))
|
||||
if err.HasError() {
|
||||
b.Add(E.FailWith("generate private key", err))
|
||||
return
|
||||
var privKey *ecdsa.PrivateKey
|
||||
var err error
|
||||
|
||||
if cfg.Provider != ProviderLocal {
|
||||
if privKey, err = cfg.loadACMEKey(); err != nil {
|
||||
logging.Info().Err(err).Msg("load ACME private key failed")
|
||||
logging.Info().Msg("generate new ACME private key")
|
||||
privKey, err = ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
return nil, E.New("generate ACME private key").With(err)
|
||||
}
|
||||
if err = cfg.saveACMEKey(privKey); err != nil {
|
||||
return nil, E.New("save ACME private key").With(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
user := &User{
|
||||
@@ -66,11 +96,25 @@ func (cfg *Config) GetProvider() (provider *Provider, res E.NestedError) {
|
||||
legoCfg := lego.NewConfig(user)
|
||||
legoCfg.Certificate.KeyType = certcrypto.RSA2048
|
||||
|
||||
provider = &Provider{
|
||||
return &Provider{
|
||||
cfg: cfg,
|
||||
user: user,
|
||||
legoCfg: legoCfg,
|
||||
}
|
||||
|
||||
return
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (cfg *Config) loadACMEKey() (*ecdsa.PrivateKey, error) {
|
||||
data, err := os.ReadFile(cfg.ACMEKeyPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return x509.ParseECPrivateKey(data)
|
||||
}
|
||||
|
||||
func (cfg *Config) saveACMEKey(key *ecdsa.PrivateKey) error {
|
||||
data, err := x509.MarshalECPrivateKey(key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(cfg.ACMEKeyPath, data, 0o600)
|
||||
}
|
||||
|
||||
@@ -1,20 +1,17 @@
|
||||
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"
|
||||
certBasePath = "certs/"
|
||||
CertFileDefault = certBasePath + "cert.crt"
|
||||
KeyFileDefault = certBasePath + "priv.key"
|
||||
ACMEKeyFileDefault = certBasePath + "acme.key"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -32,9 +29,3 @@ var providersGenMap = map[string]ProviderGenerator{
|
||||
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")
|
||||
|
||||
5
internal/autocert/logger.go
Normal file
5
internal/autocert/logger.go
Normal file
@@ -0,0 +1,5 @@
|
||||
package autocert
|
||||
|
||||
import "github.com/yusing/go-proxy/internal/logging"
|
||||
|
||||
var logger = logging.With().Str("module", "autocert").Logger()
|
||||
@@ -1,9 +1,9 @@
|
||||
package autocert
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"os"
|
||||
"path"
|
||||
"reflect"
|
||||
@@ -14,9 +14,11 @@ import (
|
||||
"github.com/go-acme/lego/v4/challenge"
|
||||
"github.com/go-acme/lego/v4/lego"
|
||||
"github.com/go-acme/lego/v4/registration"
|
||||
"github.com/yusing/go-proxy/internal/config/types"
|
||||
E "github.com/yusing/go-proxy/internal/error"
|
||||
"github.com/yusing/go-proxy/internal/types"
|
||||
"github.com/yusing/go-proxy/internal/task"
|
||||
U "github.com/yusing/go-proxy/internal/utils"
|
||||
"github.com/yusing/go-proxy/internal/utils/strutils"
|
||||
)
|
||||
|
||||
type (
|
||||
@@ -26,14 +28,17 @@ type (
|
||||
legoCfg *lego.Config
|
||||
client *lego.Client
|
||||
|
||||
legoCert *certificate.Resource
|
||||
tlsCert *tls.Certificate
|
||||
certExpiries CertExpiries
|
||||
}
|
||||
ProviderGenerator func(types.AutocertProviderOpt) (challenge.Provider, E.NestedError)
|
||||
ProviderGenerator func(types.AutocertProviderOpt) (challenge.Provider, E.Error)
|
||||
|
||||
CertExpiries map[string]time.Time
|
||||
)
|
||||
|
||||
var ErrGetCertFailure = errors.New("get certificate failed")
|
||||
|
||||
func (p *Provider) GetCert(_ *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||
if p.tlsCert == nil {
|
||||
return nil, ErrGetCertFailure
|
||||
@@ -57,54 +62,60 @@ 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)
|
||||
|
||||
func (p *Provider) ObtainCert() E.Error {
|
||||
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 err := p.initClient(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if p.user.Registration == nil {
|
||||
if err := p.registerACME(); err.HasError() {
|
||||
b.Add(E.FailWith("register ACME", err))
|
||||
return
|
||||
if err := p.registerACME(); err != nil {
|
||||
return E.From(err)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
var cert *certificate.Resource
|
||||
var err error
|
||||
|
||||
if p.legoCert != nil {
|
||||
cert, err = p.client.Certificate.RenewWithOptions(*p.legoCert, &certificate.RenewOptions{
|
||||
Bundle: true,
|
||||
})
|
||||
if err != nil {
|
||||
p.legoCert = nil
|
||||
logger.Err(err).Msg("cert renew failed, fallback to obtain")
|
||||
} else {
|
||||
p.legoCert = cert
|
||||
}
|
||||
}
|
||||
|
||||
if err = p.saveCert(cert); err.HasError() {
|
||||
b.Add(E.FailWith("save certificate", err))
|
||||
return
|
||||
if cert == nil {
|
||||
cert, err = p.client.Certificate.Obtain(certificate.ObtainRequest{
|
||||
Domains: p.cfg.Domains,
|
||||
Bundle: true,
|
||||
})
|
||||
if err != nil {
|
||||
return E.From(err)
|
||||
}
|
||||
}
|
||||
|
||||
tlsCert, err := E.Check(tls.X509KeyPair(cert.Certificate, cert.PrivateKey))
|
||||
if err.HasError() {
|
||||
b.Add(E.FailWith("parse obtained certificate", err))
|
||||
return
|
||||
if err = p.saveCert(cert); err != nil {
|
||||
return E.From(err)
|
||||
}
|
||||
|
||||
tlsCert, err := tls.X509KeyPair(cert.Certificate, cert.PrivateKey)
|
||||
if err != nil {
|
||||
return E.From(err)
|
||||
}
|
||||
|
||||
expiries, err := getCertExpiries(&tlsCert)
|
||||
if err.HasError() {
|
||||
b.Add(E.FailWith("get certificate expiry", err))
|
||||
return
|
||||
if err != nil {
|
||||
return E.From(err)
|
||||
}
|
||||
p.tlsCert = &tlsCert
|
||||
p.certExpiries = expiries
|
||||
@@ -112,22 +123,23 @@ func (p *Provider) ObtainCert() (res E.NestedError) {
|
||||
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
|
||||
func (p *Provider) LoadCert() E.Error {
|
||||
cert, err := tls.LoadX509KeyPair(p.cfg.CertPath, p.cfg.KeyPath)
|
||||
if err != nil {
|
||||
return E.Errorf("load SSL certificate: %w", err)
|
||||
}
|
||||
expiries, err := getCertExpiries(&cert)
|
||||
if err.HasError() {
|
||||
return err
|
||||
if err != nil {
|
||||
return E.Errorf("parse SSL certificate: %w", err)
|
||||
}
|
||||
p.tlsCert = &cert
|
||||
p.certExpiries = expiries
|
||||
|
||||
logger.Infof("next renewal in %v", U.FormatDuration(time.Until(p.ShouldRenewOn())))
|
||||
logger.Info().Msgf("next renewal in %v", strutils.FormatDuration(time.Until(p.ShouldRenewOn())))
|
||||
return p.renewIfNeeded()
|
||||
}
|
||||
|
||||
// ShouldRenewOn returns the time at which the certificate should be renewed.
|
||||
func (p *Provider) ShouldRenewOn() time.Time {
|
||||
for _, expiry := range p.certExpiries {
|
||||
return expiry.AddDate(0, -1, 0) // 1 month before
|
||||
@@ -136,82 +148,94 @@ func (p *Provider) ShouldRenewOn() time.Time {
|
||||
panic("no certificate available")
|
||||
}
|
||||
|
||||
func (p *Provider) ScheduleRenewal(ctx context.Context) {
|
||||
func (p *Provider) ScheduleRenewal() {
|
||||
if p.GetName() == ProviderLocal {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
task := task.RootTask("cert-renew-scheduler", true)
|
||||
defer task.Finish(nil)
|
||||
|
||||
logger.Debug("started renewal scheduler")
|
||||
defer logger.Debug("renewal scheduler stopped")
|
||||
for {
|
||||
renewalTime := p.ShouldRenewOn()
|
||||
timer := time.NewTimer(time.Until(renewalTime))
|
||||
|
||||
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)
|
||||
select {
|
||||
case <-task.Context().Done():
|
||||
timer.Stop()
|
||||
return
|
||||
case <-timer.C:
|
||||
if err := p.renewIfNeeded(); err != nil {
|
||||
E.LogWarn("cert renew failed", err, &logger)
|
||||
// Retry after 1 hour on failure
|
||||
time.Sleep(time.Hour)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (p *Provider) initClient() E.NestedError {
|
||||
legoClient, err := E.Check(lego.NewClient(p.legoCfg))
|
||||
if err.HasError() {
|
||||
return E.FailWith("create lego client", err)
|
||||
func (p *Provider) initClient() E.Error {
|
||||
legoClient, err := lego.NewClient(p.legoCfg)
|
||||
if err != nil {
|
||||
return E.From(err)
|
||||
}
|
||||
|
||||
legoProvider, err := providersGenMap[p.cfg.Provider](p.cfg.Options)
|
||||
if err.HasError() {
|
||||
return E.FailWith("create lego provider", err)
|
||||
generator := providersGenMap[p.cfg.Provider]
|
||||
legoProvider, pErr := generator(p.cfg.Options)
|
||||
if pErr != nil {
|
||||
return pErr
|
||||
}
|
||||
|
||||
err = E.From(legoClient.Challenge.SetDNS01Provider(legoProvider))
|
||||
if err.HasError() {
|
||||
return E.FailWith("set challenge provider", err)
|
||||
err = legoClient.Challenge.SetDNS01Provider(legoProvider)
|
||||
if err != nil {
|
||||
return E.From(err)
|
||||
}
|
||||
|
||||
p.client = legoClient
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Provider) registerACME() E.NestedError {
|
||||
func (p *Provider) registerACME() error {
|
||||
if p.user.Registration != nil {
|
||||
return nil
|
||||
}
|
||||
reg, err := E.Check(p.client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true}))
|
||||
if err.HasError() {
|
||||
if reg, err := p.client.Registration.ResolveAccountByKey(); err == nil {
|
||||
p.user.Registration = reg
|
||||
logger.Info().Msg("reused acme registration from private key")
|
||||
return nil
|
||||
}
|
||||
|
||||
reg, err := p.client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.user.Registration = reg
|
||||
|
||||
logger.Info().Interface("reg", reg).Msg("acme registered")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Provider) saveCert(cert *certificate.Resource) E.NestedError {
|
||||
func (p *Provider) saveCert(cert *certificate.Resource) error {
|
||||
/* 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)
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
return E.FailWith("stat cert directory", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
err = os.WriteFile(p.cfg.KeyPath, cert.PrivateKey, 0o600) // -rw-------
|
||||
if err != nil {
|
||||
return E.FailWith("write key file", err)
|
||||
return err
|
||||
}
|
||||
|
||||
err = os.WriteFile(p.cfg.CertPath, cert.Certificate, 0o644) // -rw-r--r--
|
||||
if err != nil {
|
||||
return E.FailWith("write cert file", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -233,39 +257,36 @@ func (p *Provider) certState() CertState {
|
||||
sort.Strings(certDomains)
|
||||
|
||||
if !reflect.DeepEqual(certDomains, wantedDomains) {
|
||||
logger.Debugf("cert domains mismatch: %v != %v", certDomains, p.cfg.Domains)
|
||||
logger.Info().Msgf("cert domains mismatch: %v != %v", certDomains, p.cfg.Domains)
|
||||
return CertStateMismatch
|
||||
}
|
||||
|
||||
return CertStateValid
|
||||
}
|
||||
|
||||
func (p *Provider) renewIfNeeded() E.NestedError {
|
||||
func (p *Provider) renewIfNeeded() E.Error {
|
||||
if p.cfg.Provider == ProviderLocal {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch p.certState() {
|
||||
case CertStateExpired:
|
||||
logger.Info("certs expired, renewing")
|
||||
logger.Info().Msg("certs expired, renewing")
|
||||
case CertStateMismatch:
|
||||
logger.Info("cert domains mismatch with config, renewing")
|
||||
logger.Info().Msg("cert domains mismatch with config, renewing")
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := p.ObtainCert(); err.HasError() {
|
||||
return E.FailWith("renew certificate", err)
|
||||
}
|
||||
return nil
|
||||
return p.ObtainCert()
|
||||
}
|
||||
|
||||
func getCertExpiries(cert *tls.Certificate) (CertExpiries, E.NestedError) {
|
||||
func getCertExpiries(cert *tls.Certificate) (CertExpiries, error) {
|
||||
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)
|
||||
x509Cert, err := x509.ParseCertificate(cert)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if x509Cert.IsCA {
|
||||
continue
|
||||
@@ -282,16 +303,13 @@ 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) {
|
||||
return func(opt types.AutocertProviderOpt) (challenge.Provider, E.Error) {
|
||||
cfg := defaultCfg()
|
||||
err := U.Deserialize(opt, cfg)
|
||||
if err.HasError() {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p, err := E.Check(newProvider(cfg))
|
||||
if err.HasError() {
|
||||
return nil, err
|
||||
}
|
||||
return p, nil
|
||||
p, pErr := newProvider(cfg)
|
||||
return p, E.From(pErr)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,6 @@ oauth2_config:
|
||||
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())
|
||||
ExpectNoError(t, U.Deserialize(opt, cfg))
|
||||
ExpectDeepEqual(t, cfg, cfgExpected)
|
||||
}
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
package autocert
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
|
||||
E "github.com/yusing/go-proxy/internal/error"
|
||||
"github.com/yusing/go-proxy/internal/utils/strutils"
|
||||
)
|
||||
|
||||
func (p *Provider) Setup(ctx context.Context) (err E.NestedError) {
|
||||
func (p *Provider) Setup() (err E.Error) {
|
||||
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")
|
||||
logger.Debug().Msg("obtaining cert due to error loading cert")
|
||||
if err = p.ObtainCert(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
go p.ScheduleRenewal(ctx)
|
||||
p.ScheduleRenewal()
|
||||
|
||||
for _, expiry := range p.GetExpiries() {
|
||||
logger.Infof("certificate expire on %s", expiry)
|
||||
logger.Info().Msg("certificate expire on " + strutils.FormatTime(expiry))
|
||||
break
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,7 @@ package common
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
"log"
|
||||
)
|
||||
|
||||
type Args struct {
|
||||
@@ -42,7 +41,7 @@ func GetArgs() Args {
|
||||
flag.Parse()
|
||||
args.Command = flag.Arg(0)
|
||||
if err := validateArg(args.Command); err != nil {
|
||||
logrus.Fatal(err)
|
||||
log.Fatalf("invalid command: %s", err)
|
||||
}
|
||||
return args
|
||||
}
|
||||
@@ -53,5 +52,5 @@ func validateArg(arg string) error {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("invalid command: %s", arg)
|
||||
return fmt.Errorf("invalid command %q", arg)
|
||||
}
|
||||
|
||||
@@ -13,26 +13,25 @@ const (
|
||||
// file, folder structure
|
||||
|
||||
const (
|
||||
DotEnvPath = ".env"
|
||||
DotEnvExamplePath = ".env.example"
|
||||
|
||||
ConfigBasePath = "config"
|
||||
ConfigFileName = "config.yml"
|
||||
ConfigExampleFileName = "config.example.yml"
|
||||
ConfigPath = ConfigBasePath + "/" + ConfigFileName
|
||||
|
||||
MiddlewareComposeBasePath = ConfigBasePath + "/middlewares"
|
||||
)
|
||||
JWTKeyPath = ConfigBasePath + "/jwt.key"
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
@@ -46,8 +45,12 @@ var RequiredDirectories = []string{
|
||||
const DockerHostFromEnv = "$DOCKER_HOST"
|
||||
|
||||
const (
|
||||
IdleTimeoutDefault = "0"
|
||||
HealthCheckIntervalDefault = 5 * time.Second
|
||||
HealthCheckTimeoutDefault = 5 * time.Second
|
||||
|
||||
WakeTimeoutDefault = "30s"
|
||||
StopTimeoutDefault = "10s"
|
||||
StopMethodDefault = "stop"
|
||||
)
|
||||
|
||||
const HeaderCheckRedirect = "X-Goproxy-Check-Redirect"
|
||||
|
||||
25
internal/common/crypto.go
Normal file
25
internal/common/crypto.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"crypto/sha512"
|
||||
"encoding/base64"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
func HashPassword(pwd string) []byte {
|
||||
h := sha512.New()
|
||||
h.Write([]byte(pwd))
|
||||
return h.Sum(nil)
|
||||
}
|
||||
|
||||
func decodeJWTKey(key string) []byte {
|
||||
if key == "" {
|
||||
return nil
|
||||
}
|
||||
bytes, err := base64.StdEncoding.DecodeString(key)
|
||||
if err != nil {
|
||||
log.Panic().Err(err).Msg("failed to decode jwt key")
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
@@ -2,61 +2,89 @@ package common
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
var (
|
||||
NoSchemaValidation = GetEnvBool("GOPROXY_NO_SCHEMA_VALIDATION", true)
|
||||
IsTest = GetEnvBool("GOPROXY_TEST", false) || strings.HasSuffix(os.Args[0], ".test")
|
||||
IsDebug = GetEnvBool("GOPROXY_DEBUG", IsTest)
|
||||
prefixes = []string{"GODOXY_", "GOPROXY_", ""}
|
||||
|
||||
IsTest = GetEnvBool("TEST", false) || strings.HasSuffix(os.Args[0], ".test")
|
||||
IsDebug = GetEnvBool("DEBUG", IsTest)
|
||||
IsDebugSkipAuth = GetEnvBool("DEBUG_SKIP_AUTH", false)
|
||||
IsTrace = GetEnvBool("TRACE", false) && IsDebug
|
||||
IsProduction = !IsTest && !IsDebug
|
||||
|
||||
ProxyHTTPAddr,
|
||||
ProxyHTTPHost,
|
||||
ProxyHTTPPort,
|
||||
ProxyHTTPURL = GetAddrEnv("GOPROXY_HTTP_ADDR", ":80", "http")
|
||||
ProxyHTTPURL = GetAddrEnv("HTTP_ADDR", ":80", "http")
|
||||
|
||||
ProxyHTTPSAddr,
|
||||
ProxyHTTPSHost,
|
||||
ProxyHTTPSPort,
|
||||
ProxyHTTPSURL = GetAddrEnv("GOPROXY_HTTPS_ADDR", ":443", "https")
|
||||
ProxyHTTPSURL = GetAddrEnv("HTTPS_ADDR", ":443", "https")
|
||||
|
||||
APIHTTPAddr,
|
||||
APIHTTPHost,
|
||||
APIHTTPPort,
|
||||
APIHTTPURL = GetAddrEnv("GOPROXY_API_ADDR", "127.0.0.1:8888", "http")
|
||||
APIHTTPURL = GetAddrEnv("API_ADDR", "127.0.0.1:8888", "http")
|
||||
|
||||
MetricsHTTPAddr,
|
||||
MetricsHTTPHost,
|
||||
MetricsHTTPPort,
|
||||
MetricsHTTPURL = GetAddrEnv("PROMETHEUS_ADDR", "", "http")
|
||||
PrometheusEnabled = MetricsHTTPURL != ""
|
||||
|
||||
APIJWTSecret = decodeJWTKey(GetEnvString("API_JWT_SECRET", ""))
|
||||
APIJWTTokenTTL = GetDurationEnv("API_JWT_TOKEN_TTL", time.Hour)
|
||||
APIUser = GetEnvString("API_USER", "admin")
|
||||
APIPasswordHash = HashPassword(GetEnvString("API_PASSWORD", "password"))
|
||||
)
|
||||
|
||||
func GetEnvBool(key string, defaultValue bool) bool {
|
||||
value, ok := os.LookupEnv(key)
|
||||
func GetEnv[T any](key string, defaultValue T, parser func(string) (T, error)) T {
|
||||
var value string
|
||||
var ok bool
|
||||
for _, prefix := range prefixes {
|
||||
value, ok = os.LookupEnv(prefix + key)
|
||||
if ok && value != "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
if !ok || value == "" {
|
||||
return defaultValue
|
||||
}
|
||||
b, err := strconv.ParseBool(value)
|
||||
if err != nil {
|
||||
log.Fatalf("Invalid boolean value: %s", value)
|
||||
parsed, err := parser(value)
|
||||
if err == nil {
|
||||
return parsed
|
||||
}
|
||||
return b
|
||||
log.Fatal().Err(err).Msgf("env %s: invalid %T value: %s", key, parsed, value)
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
func GetEnv(key, defaultValue string) string {
|
||||
value, ok := os.LookupEnv(key)
|
||||
if !ok || value == "" {
|
||||
value = defaultValue
|
||||
}
|
||||
return value
|
||||
func GetEnvString(key string, defaultValue string) string {
|
||||
return GetEnv(key, defaultValue, func(s string) (string, error) {
|
||||
return s, nil
|
||||
})
|
||||
}
|
||||
|
||||
func GetEnvBool(key string, defaultValue bool) bool {
|
||||
return GetEnv(key, defaultValue, strconv.ParseBool)
|
||||
}
|
||||
|
||||
func GetAddrEnv(key, defaultValue, scheme string) (addr, host, port, fullURL string) {
|
||||
addr = GetEnv(key, defaultValue)
|
||||
addr = GetEnvString(key, defaultValue)
|
||||
if addr == "" {
|
||||
return
|
||||
}
|
||||
host, port, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
logrus.Fatalf("Invalid address: %s", addr)
|
||||
log.Fatal().Msgf("env %s: invalid address: %s", key, addr)
|
||||
}
|
||||
if host == "" {
|
||||
host = "localhost"
|
||||
@@ -64,3 +92,7 @@ func GetAddrEnv(key, defaultValue, scheme string) (addr, host, port, fullURL str
|
||||
fullURL = fmt.Sprintf("%s://%s:%s", scheme, host, port)
|
||||
return
|
||||
}
|
||||
|
||||
func GetDurationEnv(key string, defaultValue time.Duration) time.Duration {
|
||||
return GetEnv(key, defaultValue, time.ParseDuration)
|
||||
}
|
||||
|
||||
@@ -1,235 +1,243 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/yusing/go-proxy/internal/autocert"
|
||||
"github.com/yusing/go-proxy/internal/common"
|
||||
"github.com/yusing/go-proxy/internal/config/types"
|
||||
"github.com/yusing/go-proxy/internal/entrypoint"
|
||||
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"
|
||||
"github.com/yusing/go-proxy/internal/logging"
|
||||
"github.com/yusing/go-proxy/internal/notif"
|
||||
proxy "github.com/yusing/go-proxy/internal/route/provider"
|
||||
"github.com/yusing/go-proxy/internal/task"
|
||||
"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"
|
||||
"github.com/yusing/go-proxy/internal/watcher/events"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
value *types.Config
|
||||
proxyProviders F.Map[string, *PR.Provider]
|
||||
providers F.Map[string, *proxy.Provider]
|
||||
autocertProvider *autocert.Provider
|
||||
|
||||
l logrus.FieldLogger
|
||||
|
||||
watcher W.Watcher
|
||||
watcherCtx context.Context
|
||||
watcherCancel context.CancelFunc
|
||||
reloadReq chan struct{}
|
||||
task *task.Task
|
||||
}
|
||||
|
||||
var instance *Config
|
||||
var (
|
||||
instance *Config
|
||||
cfgWatcher watcher.Watcher
|
||||
logger = logging.With().Str("module", "config").Logger()
|
||||
reloadMu sync.Mutex
|
||||
)
|
||||
|
||||
const configEventFlushInterval = 500 * time.Millisecond
|
||||
|
||||
const (
|
||||
cfgRenameWarn = `Config file renamed, not reloading.
|
||||
Make sure you rename it back before next time you start.`
|
||||
cfgDeleteWarn = `Config file deleted, not reloading.
|
||||
You may run "ls-config" to show or dump the current config.`
|
||||
)
|
||||
|
||||
func GetInstance() *Config {
|
||||
return instance
|
||||
}
|
||||
|
||||
func Load() E.NestedError {
|
||||
if instance != nil {
|
||||
return nil
|
||||
func newConfig() *Config {
|
||||
return &Config{
|
||||
value: types.DefaultConfig(),
|
||||
providers: F.NewMapOf[string, *proxy.Provider](),
|
||||
task: task.RootTask("config", false),
|
||||
}
|
||||
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 Load() (*Config, E.Error) {
|
||||
if instance != nil {
|
||||
return instance, nil
|
||||
}
|
||||
instance = newConfig()
|
||||
cfgWatcher = watcher.NewConfigFileWatcher(common.ConfigFileName)
|
||||
return instance, instance.load()
|
||||
}
|
||||
|
||||
func Validate(data []byte) E.Error {
|
||||
var model types.Config
|
||||
return utils.DeserializeYAML(data, &model)
|
||||
}
|
||||
|
||||
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 WatchChanges() {
|
||||
t := task.RootTask("config_watcher", true)
|
||||
eventQueue := events.NewEventQueue(
|
||||
t,
|
||||
configEventFlushInterval,
|
||||
OnConfigChange,
|
||||
func(err E.Error) {
|
||||
E.LogError("config reload error", err, &logger)
|
||||
},
|
||||
)
|
||||
eventQueue.Start(cfgWatcher.Events(t.Context()))
|
||||
}
|
||||
|
||||
func (cfg *Config) GetAutoCertProvider() *autocert.Provider {
|
||||
if instance == nil {
|
||||
logrus.Panic("config has not been loaded, please check if there is any errors")
|
||||
func OnConfigChange(ev []events.Event) {
|
||||
// no matter how many events during the interval
|
||||
// just reload once and check the last event
|
||||
switch ev[len(ev)-1].Action {
|
||||
case events.ActionFileRenamed:
|
||||
logger.Warn().Msg(cfgRenameWarn)
|
||||
return
|
||||
case events.ActionFileDeleted:
|
||||
logger.Warn().Msg(cfgDeleteWarn)
|
||||
return
|
||||
}
|
||||
|
||||
if err := Reload(); err != nil {
|
||||
// recovered in event queue
|
||||
panic(err)
|
||||
}
|
||||
return cfg.autocertProvider
|
||||
}
|
||||
|
||||
func (cfg *Config) Dispose() {
|
||||
if cfg.watcherCancel != nil {
|
||||
cfg.watcherCancel()
|
||||
cfg.l.Debug("stopped watcher")
|
||||
func Reload() E.Error {
|
||||
// avoid race between config change and API reload request
|
||||
reloadMu.Lock()
|
||||
defer reloadMu.Unlock()
|
||||
|
||||
newCfg := newConfig()
|
||||
err := newCfg.load()
|
||||
if err != nil {
|
||||
newCfg.task.Finish(err)
|
||||
return err
|
||||
}
|
||||
cfg.stopProviders()
|
||||
|
||||
// cancel all current subtasks -> wait
|
||||
// -> replace config -> start new subtasks
|
||||
instance.task.Finish("config changed")
|
||||
instance = newCfg
|
||||
instance.StartProxyProviders()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cfg *Config) Reload() (err E.NestedError) {
|
||||
cfg.stopProviders()
|
||||
err = cfg.load()
|
||||
cfg.StartProxyProviders()
|
||||
return
|
||||
func Value() types.Config {
|
||||
return *instance.value
|
||||
}
|
||||
|
||||
func GetAutoCertProvider() *autocert.Provider {
|
||||
return instance.autocertProvider
|
||||
}
|
||||
|
||||
func (cfg *Config) Task() *task.Task {
|
||||
return cfg.task
|
||||
}
|
||||
|
||||
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)
|
||||
errs := cfg.providers.CollectErrorsParallel(
|
||||
func(_ string, p *proxy.Provider) error {
|
||||
return p.Start(cfg.task)
|
||||
})
|
||||
})
|
||||
|
||||
if err := E.Join(errs...); err != nil {
|
||||
E.LogError("route provider errors", err, &logger)
|
||||
}
|
||||
}
|
||||
|
||||
func (cfg *Config) load() (res E.NestedError) {
|
||||
b := E.NewBuilder("errors loading config")
|
||||
defer b.To(&res)
|
||||
func (cfg *Config) load() E.Error {
|
||||
const errMsg = "config load error"
|
||||
|
||||
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())
|
||||
}
|
||||
data, err := os.ReadFile(common.ConfigPath)
|
||||
if err != nil {
|
||||
E.LogFatal(errMsg, err, &logger)
|
||||
}
|
||||
|
||||
model := types.DefaultConfig()
|
||||
if err := E.From(yaml.Unmarshal(data, model)); err.HasError() {
|
||||
b.Add(E.FailWith("parse config", err))
|
||||
logrus.Fatal(b.Build())
|
||||
if err := utils.DeserializeYAML(data, model); err != nil {
|
||||
E.LogFatal(errMsg, err, &logger)
|
||||
}
|
||||
|
||||
// errors are non fatal below
|
||||
b.Add(cfg.initAutoCert(&model.AutoCert))
|
||||
b.Add(cfg.loadProviders(&model.Providers))
|
||||
errs := E.NewBuilder(errMsg)
|
||||
errs.Add(entrypoint.SetMiddlewares(model.Entrypoint.Middlewares))
|
||||
errs.Add(entrypoint.SetAccessLogger(cfg.task, model.Entrypoint.AccessLog))
|
||||
errs.Add(cfg.initNotification(model.Providers.Notification))
|
||||
errs.Add(cfg.initAutoCert(model.AutoCert))
|
||||
errs.Add(cfg.loadRouteProviders(&model.Providers))
|
||||
|
||||
cfg.value = model
|
||||
R.SetFindMuxDomains(model.MatchDomains)
|
||||
return
|
||||
for i, domain := range model.MatchDomains {
|
||||
if !strings.HasPrefix(domain, ".") {
|
||||
model.MatchDomains[i] = "." + domain
|
||||
}
|
||||
}
|
||||
entrypoint.SetFindRouteDomains(model.MatchDomains)
|
||||
return errs.Error()
|
||||
}
|
||||
|
||||
func (cfg *Config) initAutoCert(autocertCfg *types.AutoCertConfig) (err E.NestedError) {
|
||||
func (cfg *Config) initNotification(notifCfg []types.NotificationConfig) (err E.Error) {
|
||||
if len(notifCfg) == 0 {
|
||||
return
|
||||
}
|
||||
dispatcher := notif.StartNotifDispatcher(cfg.task)
|
||||
errs := E.NewBuilder("notification providers load errors")
|
||||
for i, notifier := range notifCfg {
|
||||
_, err := dispatcher.RegisterProvider(notifier)
|
||||
if err == nil {
|
||||
continue
|
||||
}
|
||||
errs.Add(err.Subjectf("[%d]", i))
|
||||
}
|
||||
return errs.Error()
|
||||
}
|
||||
|
||||
func (cfg *Config) initAutoCert(autocertCfg *types.AutoCertConfig) (err E.Error) {
|
||||
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)
|
||||
func (cfg *Config) loadRouteProviders(providers *types.Providers) E.Error {
|
||||
errs := E.NewBuilder("route provider errors")
|
||||
results := E.NewBuilder("loaded route providers")
|
||||
|
||||
lenLongestName := 0
|
||||
for _, filename := range providers.Files {
|
||||
p, err := PR.NewFileProvider(filename)
|
||||
p, err := proxy.NewFileProvider(filename)
|
||||
if err != nil {
|
||||
b.Add(err.Subject(filename))
|
||||
errs.Add(E.PrependSubject(filename, err))
|
||||
continue
|
||||
}
|
||||
cfg.proxyProviders.Store(p.GetName(), p)
|
||||
b.Add(p.LoadRoutes().Subject(filename))
|
||||
cfg.providers.Store(p.GetName(), p)
|
||||
if len(p.GetName()) > lenLongestName {
|
||||
lenLongestName = len(p.GetName())
|
||||
}
|
||||
}
|
||||
for name, dockerHost := range providers.Docker {
|
||||
p, err := PR.NewDockerProvider(name, dockerHost)
|
||||
p, err := proxy.NewDockerProvider(name, dockerHost)
|
||||
if err != nil {
|
||||
b.Add(err.Subjectf("%s (%s)", name, dockerHost))
|
||||
errs.Add(E.PrependSubject(name, err))
|
||||
continue
|
||||
}
|
||||
cfg.proxyProviders.Store(p.GetName(), p)
|
||||
b.Add(p.LoadRoutes().Subject(p.GetName()))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (cfg *Config) controlProviders(action string, do func(*PR.Provider) E.NestedError) {
|
||||
errors := E.NewBuilder("errors in %s these providers", action)
|
||||
|
||||
cfg.proxyProviders.RangeAllParallel(func(name string, p *PR.Provider) {
|
||||
if err := do(p); err.HasError() {
|
||||
errors.Add(err.Subject(p))
|
||||
cfg.providers.Store(p.GetName(), p)
|
||||
if len(p.GetName()) > lenLongestName {
|
||||
lenLongestName = len(p.GetName())
|
||||
}
|
||||
})
|
||||
|
||||
if err := errors.Build(); err.HasError() {
|
||||
cfg.l.Error(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (cfg *Config) stopProviders() {
|
||||
cfg.controlProviders("stop routes", (*PR.Provider).StopAllRoutes)
|
||||
cfg.providers.RangeAllParallel(func(_ string, p *proxy.Provider) {
|
||||
if err := p.LoadRoutes(); err != nil {
|
||||
errs.Add(err.Subject(p.String()))
|
||||
}
|
||||
results.Addf("%-"+strconv.Itoa(lenLongestName)+"s %d routes", p.GetName(), p.NumRoutes())
|
||||
})
|
||||
logger.Info().Msg(results.String())
|
||||
return errs.Error()
|
||||
}
|
||||
|
||||
@@ -1,71 +1,57 @@
|
||||
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"
|
||||
"github.com/yusing/go-proxy/internal/homepage"
|
||||
route "github.com/yusing/go-proxy/internal/route"
|
||||
"github.com/yusing/go-proxy/internal/route/entry"
|
||||
proxy "github.com/yusing/go-proxy/internal/route/provider"
|
||||
"github.com/yusing/go-proxy/internal/route/routes"
|
||||
"github.com/yusing/go-proxy/internal/route/types"
|
||||
"github.com/yusing/go-proxy/internal/utils/strutils"
|
||||
)
|
||||
|
||||
func (cfg *Config) DumpEntries() map[string]*types.RawEntry {
|
||||
func 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()
|
||||
instance.providers.RangeAll(func(_ string, p *proxy.Provider) {
|
||||
p.RangeRoutes(func(alias string, r *route.Route) {
|
||||
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) {
|
||||
func DumpProviders() map[string]*proxy.Provider {
|
||||
entries := make(map[string]*proxy.Provider)
|
||||
instance.providers.RangeAll(func(name string, p *proxy.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() {
|
||||
func HomepageConfig() homepage.Config {
|
||||
hpCfg := homepage.NewHomePageConfig()
|
||||
routes.GetHTTPRoutes().RangeAll(func(alias string, r types.HTTPRoute) {
|
||||
en := r.RawEntry()
|
||||
item := en.Homepage
|
||||
if item == nil {
|
||||
item = new(homepage.Item)
|
||||
item.Show = true
|
||||
}
|
||||
|
||||
if !item.Show || r.Type() != R.RouteTypeReverseProxy {
|
||||
if !item.IsEmpty() {
|
||||
item.Show = true
|
||||
}
|
||||
|
||||
if !item.Show {
|
||||
return
|
||||
}
|
||||
|
||||
item.Alias = alias
|
||||
|
||||
if item.Name == "" {
|
||||
item.Name = U.Title(
|
||||
item.Name = strutils.Title(
|
||||
strings.ReplaceAll(
|
||||
strings.ReplaceAll(alias, "-", " "),
|
||||
"_", " ",
|
||||
@@ -73,63 +59,76 @@ func (cfg *Config) HomepageConfig() H.HomePageConfig {
|
||||
)
|
||||
}
|
||||
|
||||
if p.GetType() == PR.ProviderTypeDocker {
|
||||
if instance.value.Homepage.UseDefaultCategories {
|
||||
if en.Container != nil && item.Category == "" {
|
||||
if category, ok := homepage.PredefinedCategories[en.Container.ImageName]; ok {
|
||||
item.Category = category
|
||||
}
|
||||
}
|
||||
|
||||
if item.Category == "" {
|
||||
if category, ok := homepage.PredefinedCategories[strings.ToLower(alias)]; ok {
|
||||
item.Category = category
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
switch {
|
||||
case entry.IsDocker(r):
|
||||
if item.Category == "" {
|
||||
item.Category = "Docker"
|
||||
}
|
||||
item.SourceType = string(PR.ProviderTypeDocker)
|
||||
} else if p.GetType() == PR.ProviderTypeFile {
|
||||
item.SourceType = string(proxy.ProviderTypeDocker)
|
||||
case entry.UseLoadBalance(r):
|
||||
if item.Category == "" {
|
||||
item.Category = "Load-balanced"
|
||||
}
|
||||
item.SourceType = "loadbalancer"
|
||||
default:
|
||||
if item.Category == "" {
|
||||
item.Category = "Others"
|
||||
}
|
||||
item.SourceType = string(PR.ProviderTypeFile)
|
||||
item.SourceType = string(proxy.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()
|
||||
|
||||
item.AltURL = r.TargetURL().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
|
||||
func RoutesByAlias(typeFilter ...route.RouteType) map[string]any {
|
||||
rts := make(map[string]any)
|
||||
if len(typeFilter) == 0 || typeFilter[0] == "" {
|
||||
typeFilter = []route.RouteType{route.RouteTypeReverseProxy, route.RouteTypeStream}
|
||||
}
|
||||
for _, t := range typeFilter {
|
||||
switch t {
|
||||
case route.RouteTypeReverseProxy:
|
||||
routes.GetHTTPRoutes().RangeAll(func(alias string, r types.HTTPRoute) {
|
||||
rts[alias] = r
|
||||
})
|
||||
case route.RouteTypeStream:
|
||||
routes.GetStreamRoutes().RangeAll(func(alias string, r types.StreamRoute) {
|
||||
rts[alias] = r
|
||||
})
|
||||
}
|
||||
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
|
||||
}
|
||||
return rts
|
||||
}
|
||||
|
||||
func (cfg *Config) Statistics() map[string]any {
|
||||
func Statistics() map[string]any {
|
||||
nTotalStreams := 0
|
||||
nTotalRPs := 0
|
||||
providerStats := make(map[string]PR.ProviderStats)
|
||||
providerStats := make(map[string]proxy.ProviderStats)
|
||||
|
||||
cfg.proxyProviders.RangeAll(func(name string, p *PR.Provider) {
|
||||
providerStats[name] = p.Statistics()
|
||||
})
|
||||
instance.providers.RangeAll(func(name string, p *proxy.Provider) {
|
||||
stats := p.Statistics()
|
||||
providerStats[name] = stats
|
||||
|
||||
for _, stats := range providerStats {
|
||||
nTotalRPs += stats.NumRPs
|
||||
nTotalStreams += stats.NumStreams
|
||||
}
|
||||
})
|
||||
|
||||
return map[string]any{
|
||||
"num_total_streams": nTotalStreams,
|
||||
@@ -137,14 +136,3 @@ func (cfg *Config) Statistics() map[string]any {
|
||||
"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
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
14
internal/config/types/autocert_config.go
Normal file
14
internal/config/types/autocert_config.go
Normal file
@@ -0,0 +1,14 @@
|
||||
package types
|
||||
|
||||
type (
|
||||
AutoCertConfig struct {
|
||||
Email string `json:"email,omitempty" validate:"email"`
|
||||
Domains []string `json:"domains,omitempty"`
|
||||
CertPath string `json:"cert_path,omitempty" validate:"omitempty,filepath"`
|
||||
KeyPath string `json:"key_path,omitempty" validate:"omitempty,filepath"`
|
||||
ACMEKeyPath string `json:"acme_key_path,omitempty" validate:"omitempty,filepath"`
|
||||
Provider string `json:"provider,omitempty"`
|
||||
Options AutocertProviderOpt `json:"options,omitempty"`
|
||||
}
|
||||
AutocertProviderOpt map[string]any
|
||||
)
|
||||
40
internal/config/types/config.go
Normal file
40
internal/config/types/config.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"github.com/yusing/go-proxy/internal/net/http/accesslog"
|
||||
"github.com/yusing/go-proxy/internal/utils"
|
||||
)
|
||||
|
||||
type (
|
||||
Config struct {
|
||||
AutoCert *AutoCertConfig `json:"autocert" validate:"omitempty"`
|
||||
Entrypoint Entrypoint `json:"entrypoint"`
|
||||
Providers Providers `json:"providers"`
|
||||
MatchDomains []string `json:"match_domains" validate:"dive,fqdn"`
|
||||
Homepage HomepageConfig `json:"homepage"`
|
||||
TimeoutShutdown int `json:"timeout_shutdown" validate:"gte=0"`
|
||||
}
|
||||
Providers struct {
|
||||
Files []string `json:"include" validate:"dive,filepath"`
|
||||
Docker map[string]string `json:"docker" validate:"dive,unix_addr|url"`
|
||||
Notification []NotificationConfig `json:"notification"`
|
||||
}
|
||||
Entrypoint struct {
|
||||
Middlewares []map[string]any `json:"middlewares"`
|
||||
AccessLog *accesslog.Config `json:"access_log" validate:"omitempty"`
|
||||
}
|
||||
NotificationConfig map[string]any
|
||||
)
|
||||
|
||||
func DefaultConfig() *Config {
|
||||
return &Config{
|
||||
TimeoutShutdown: 3,
|
||||
Homepage: HomepageConfig{
|
||||
UseDefaultCategories: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
utils.RegisterDefaultValueFactory(DefaultConfig)
|
||||
}
|
||||
5
internal/config/types/homepage_config.go
Normal file
5
internal/config/types/homepage_config.go
Normal file
@@ -0,0 +1,5 @@
|
||||
package types
|
||||
|
||||
type HomepageConfig struct {
|
||||
UseDefaultCategories bool `json:"use_default_categories"`
|
||||
}
|
||||
@@ -1,65 +1,60 @@
|
||||
package docker
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/docker/cli/cli/connhelper"
|
||||
"github.com/docker/docker/client"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/yusing/go-proxy/internal/common"
|
||||
E "github.com/yusing/go-proxy/internal/error"
|
||||
"github.com/yusing/go-proxy/internal/logging"
|
||||
"github.com/yusing/go-proxy/internal/task"
|
||||
U "github.com/yusing/go-proxy/internal/utils"
|
||||
F "github.com/yusing/go-proxy/internal/utils/functional"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
key string
|
||||
refCount *atomic.Int32
|
||||
*client.Client
|
||||
type (
|
||||
SharedClient struct {
|
||||
*client.Client
|
||||
|
||||
l logrus.FieldLogger
|
||||
key string
|
||||
refCount *U.RefCount
|
||||
|
||||
l zerolog.Logger
|
||||
}
|
||||
)
|
||||
|
||||
var (
|
||||
clientMap F.Map[string, *SharedClient] = F.NewMapOf[string, *SharedClient]()
|
||||
clientMapMu sync.Mutex
|
||||
|
||||
clientOptEnvHost = []client.Opt{
|
||||
client.WithHostFromEnv(),
|
||||
client.WithAPIVersionNegotiation(),
|
||||
}
|
||||
)
|
||||
|
||||
func init() {
|
||||
task.OnProgramExit("docker_clients_cleanup", func() {
|
||||
clientMap.RangeAllParallel(func(_ string, c *SharedClient) {
|
||||
if c.Connected() {
|
||||
c.Client.Close()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
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 *SharedClient) Connected() bool {
|
||||
return c != nil && c.Client != 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
|
||||
// if the client is still referenced, this is no-op.
|
||||
func (c *SharedClient) Close() {
|
||||
if c.Connected() {
|
||||
c.refCount.Sub()
|
||||
}
|
||||
|
||||
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.
|
||||
@@ -72,13 +67,13 @@ func (c *Client) Close() error {
|
||||
// Returns:
|
||||
// - Client: the Docker client connection.
|
||||
// - error: an error if the connection failed.
|
||||
func ConnectClient(host string) (Client, E.NestedError) {
|
||||
func ConnectClient(host string) (*SharedClient, error) {
|
||||
clientMapMu.Lock()
|
||||
defer clientMapMu.Unlock()
|
||||
|
||||
// check if client exists
|
||||
if client, ok := clientMap.Load(host); ok {
|
||||
client.refCount.Add(1)
|
||||
client.refCount.Add()
|
||||
return client, nil
|
||||
}
|
||||
|
||||
@@ -86,12 +81,14 @@ func ConnectClient(host string) (Client, E.NestedError) {
|
||||
var opt []client.Opt
|
||||
|
||||
switch host {
|
||||
case "":
|
||||
return nil, errors.New("empty docker host")
|
||||
case common.DockerHostFromEnv:
|
||||
opt = clientOptEnvHost
|
||||
default:
|
||||
helper, err := E.Check(connhelper.GetConnectionHelper(host))
|
||||
if err.HasError() {
|
||||
return Client{}, E.UnexpectedError(err.Error())
|
||||
helper, err := connhelper.GetConnectionHelper(host)
|
||||
if err != nil {
|
||||
logging.Panic().Err(err).Msg("failed to get connection helper")
|
||||
}
|
||||
if helper != nil {
|
||||
httpClient := &http.Client{
|
||||
@@ -113,41 +110,29 @@ func ConnectClient(host string) (Client, E.NestedError) {
|
||||
}
|
||||
}
|
||||
|
||||
client, err := E.Check(client.NewClientWithOpts(opt...))
|
||||
|
||||
if err.HasError() {
|
||||
return Client{}, err
|
||||
client, err := client.NewClientWithOpts(opt...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
c := Client{
|
||||
c := &SharedClient{
|
||||
Client: client,
|
||||
key: host,
|
||||
refCount: &atomic.Int32{},
|
||||
l: logger.WithField("docker_client", client.DaemonHost()),
|
||||
refCount: U.NewRefCounter(),
|
||||
l: logger.With().Str("address", client.DaemonHost()).Logger(),
|
||||
}
|
||||
c.refCount.Add(1)
|
||||
c.l.Debugf("client connected")
|
||||
c.l.Trace().Msg("client connected")
|
||||
|
||||
clientMap.Store(host, c)
|
||||
|
||||
go func() {
|
||||
<-c.refCount.Zero()
|
||||
clientMap.Delete(c.key)
|
||||
|
||||
if c.Connected() {
|
||||
c.Client.Close()
|
||||
c.l.Trace().Msg("client closed")
|
||||
}
|
||||
}()
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func CloseAllClients() {
|
||||
clientMap.RangeAllParallel(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")
|
||||
)
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
package docker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/client"
|
||||
E "github.com/yusing/go-proxy/internal/error"
|
||||
)
|
||||
|
||||
type ClientInfo struct {
|
||||
Client Client
|
||||
Containers []types.Container
|
||||
}
|
||||
|
||||
var listOptions = container.ListOptions{
|
||||
// Filters: filters.NewArgs(
|
||||
// filters.Arg("health", "healthy"),
|
||||
// filters.Arg("health", "none"),
|
||||
// filters.Arg("health", "starting"),
|
||||
// ),
|
||||
All: true,
|
||||
}
|
||||
|
||||
func GetClientInfo(clientHost string, getContainer bool) (*ClientInfo, E.NestedError) {
|
||||
dockerClient, err := ConnectClient(clientHost)
|
||||
if err.HasError() {
|
||||
return nil, E.FailWith("connect to docker", err)
|
||||
}
|
||||
defer dockerClient.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var containers []types.Container
|
||||
if getContainer {
|
||||
containers, err = E.Check(dockerClient.ContainerList(ctx, listOptions))
|
||||
if err.HasError() {
|
||||
return nil, E.FailWith("list containers", err)
|
||||
}
|
||||
}
|
||||
|
||||
return &ClientInfo{
|
||||
Client: dockerClient,
|
||||
Containers: containers,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func IsErrConnectionFailed(err error) bool {
|
||||
return client.IsErrConnectionFailed(err)
|
||||
}
|
||||
@@ -1,141 +1,144 @@
|
||||
package docker
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/docker/docker/api/types"
|
||||
U "github.com/yusing/go-proxy/internal/utils"
|
||||
"github.com/yusing/go-proxy/internal/utils/strutils"
|
||||
)
|
||||
|
||||
type Container struct {
|
||||
*types.Container
|
||||
*ProxyProperties
|
||||
}
|
||||
type (
|
||||
PortMapping = map[string]types.Port
|
||||
Container struct {
|
||||
_ U.NoCopy
|
||||
|
||||
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",
|
||||
DockerHost string `json:"docker_host"`
|
||||
ContainerName string `json:"container_name"`
|
||||
ContainerID string `json:"container_id"`
|
||||
ImageName string `json:"image_name"`
|
||||
|
||||
Labels map[string]string `json:"-"`
|
||||
|
||||
PublicPortMapping PortMapping `json:"public_ports"` // non-zero publicPort:types.Port
|
||||
PrivatePortMapping PortMapping `json:"private_ports"` // privatePort:types.Port
|
||||
PublicIP string `json:"public_ip"`
|
||||
PrivateIP string `json:"private_ip"`
|
||||
NetworkMode string `json:"network_mode"`
|
||||
|
||||
Aliases []string `json:"aliases"`
|
||||
IsExcluded bool `json:"is_excluded"`
|
||||
IsExplicit bool `json:"is_explicit"`
|
||||
IsDatabase bool `json:"is_database"`
|
||||
IdleTimeout string `json:"idle_timeout,omitempty"`
|
||||
WakeTimeout string `json:"wake_timeout,omitempty"`
|
||||
StopMethod string `json:"stop_method,omitempty"`
|
||||
StopTimeout string `json:"stop_timeout,omitempty"` // stop_method = "stop" only
|
||||
StopSignal string `json:"stop_signal,omitempty"` // stop_method = "stop" | "kill" only
|
||||
Running bool `json:"running"`
|
||||
}
|
||||
)
|
||||
|
||||
var DummyContainer = new(Container)
|
||||
|
||||
func FromDocker(c *types.Container, dockerHost string) (res *Container) {
|
||||
isExplicit := c.Labels[LabelAliases] != ""
|
||||
helper := containerHelper{c}
|
||||
res = &Container{
|
||||
DockerHost: dockerHost,
|
||||
ContainerName: helper.getName(),
|
||||
ContainerID: c.ID,
|
||||
ImageName: helper.getImageName(),
|
||||
|
||||
Labels: c.Labels,
|
||||
|
||||
PublicPortMapping: helper.getPublicPortMapping(),
|
||||
PrivatePortMapping: helper.getPrivatePortMapping(),
|
||||
NetworkMode: c.HostConfig.NetworkMode,
|
||||
|
||||
Aliases: helper.getAliases(),
|
||||
IsExcluded: strutils.ParseBool(helper.getDeleteLabel(LabelExclude)),
|
||||
IsExplicit: isExplicit,
|
||||
IsDatabase: helper.isDatabase(),
|
||||
IdleTimeout: helper.getDeleteLabel(LabelIdleTimeout),
|
||||
WakeTimeout: helper.getDeleteLabel(LabelWakeTimeout),
|
||||
StopMethod: helper.getDeleteLabel(LabelStopMethod),
|
||||
StopTimeout: helper.getDeleteLabel(LabelStopTimeout),
|
||||
StopSignal: helper.getDeleteLabel(LabelStopSignal),
|
||||
Running: c.Status == "running" || c.State == "running",
|
||||
}
|
||||
res.setPrivateIP(helper)
|
||||
res.setPublicIP()
|
||||
return
|
||||
}
|
||||
|
||||
func FromJSON(json types.ContainerJSON, dockerHost string) Container {
|
||||
func FromJSON(json types.ContainerJSON, dockerHost string) *Container {
|
||||
ports := make([]types.Port, 0)
|
||||
for k, bindings := range json.NetworkSettings.Ports {
|
||||
privPortStr, proto := k.Port(), k.Proto()
|
||||
privPort, _ := strconv.ParseUint(privPortStr, 10, 16)
|
||||
ports = append(ports, types.Port{
|
||||
PrivatePort: uint16(privPort),
|
||||
Type: proto,
|
||||
})
|
||||
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),
|
||||
Type: proto,
|
||||
})
|
||||
}
|
||||
}
|
||||
cont := FromDocker(&types.Container{
|
||||
ID: json.ID,
|
||||
Names: []string{json.Name},
|
||||
Names: []string{strings.TrimPrefix(json.Name, "/")},
|
||||
Image: json.Image,
|
||||
Ports: ports,
|
||||
Labels: json.Config.Labels,
|
||||
State: json.State.Status,
|
||||
Status: json.State.Status,
|
||||
Mounts: json.Mounts,
|
||||
NetworkSettings: &types.SummaryNetworkSettings{
|
||||
Networks: json.NetworkSettings.Networks,
|
||||
},
|
||||
}, 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
|
||||
func (c *Container) setPublicIP() {
|
||||
if !c.Running {
|
||||
return
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (c Container) getAliases() []string {
|
||||
if l := c.getDeleteLabel(LabelAliases); l != "" {
|
||||
return U.CommaSeperatedList(l)
|
||||
if strings.HasPrefix(c.DockerHost, "unix://") {
|
||||
c.PublicIP = "127.0.0.1"
|
||||
return
|
||||
}
|
||||
return []string{c.getName()}
|
||||
url, err := url.Parse(c.DockerHost)
|
||||
if err != nil {
|
||||
logger.Err(err).Msgf("invalid docker host %q, falling back to 127.0.0.1", c.DockerHost)
|
||||
c.PublicIP = "127.0.0.1"
|
||||
return
|
||||
}
|
||||
c.PublicIP = url.Hostname()
|
||||
}
|
||||
|
||||
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 {
|
||||
func (c *Container) setPrivateIP(helper containerHelper) {
|
||||
if !strings.HasPrefix(c.DockerHost, "unix://") {
|
||||
return
|
||||
}
|
||||
if helper.NetworkSettings == nil {
|
||||
return
|
||||
}
|
||||
for _, v := range helper.NetworkSettings.Networks {
|
||||
if v.IPAddress == "" {
|
||||
continue
|
||||
}
|
||||
res[U.PortString(v.PublicPort)] = v
|
||||
c.PrivateIP = v.IPAddress
|
||||
return
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func (c Container) getPrivatePortMapping() PortMapping {
|
||||
res := make(PortMapping)
|
||||
for _, v := range c.Ports {
|
||||
res[U.PortString(v.PrivatePort)] = v
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
var databaseMPs = map[string]struct{}{
|
||||
"/var/lib/postgresql/data": {},
|
||||
"/var/lib/mysql": {},
|
||||
"/var/lib/mongodb": {},
|
||||
"/var/lib/mariadb": {},
|
||||
"/var/lib/memcached": {},
|
||||
"/var/lib/rabbitmq": {},
|
||||
}
|
||||
|
||||
var databasePrivPorts = map[uint16]struct{}{
|
||||
5432: {}, // postgres
|
||||
3306: {}, // mysql, mariadb
|
||||
6379: {}, // redis
|
||||
11211: {}, // memcached
|
||||
27017: {}, // mongodb
|
||||
}
|
||||
|
||||
func (c 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
|
||||
}
|
||||
|
||||
90
internal/docker/container_helper.go
Normal file
90
internal/docker/container_helper.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package docker
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/yusing/go-proxy/internal/utils/strutils"
|
||||
)
|
||||
|
||||
type containerHelper struct {
|
||||
*types.Container
|
||||
}
|
||||
|
||||
// getDeleteLabel gets the value of a label and then deletes it from the container.
|
||||
// If the label does not exist, an empty string is returned.
|
||||
func (c containerHelper) getDeleteLabel(label string) string {
|
||||
if l, ok := c.Labels[label]; ok {
|
||||
delete(c.Labels, label)
|
||||
return l
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (c containerHelper) getAliases() []string {
|
||||
if l := c.getDeleteLabel(LabelAliases); l != "" {
|
||||
return strutils.CommaSeperatedList(l)
|
||||
}
|
||||
return []string{c.getName()}
|
||||
}
|
||||
|
||||
func (c containerHelper) getName() string {
|
||||
return strings.TrimPrefix(c.Names[0], "/")
|
||||
}
|
||||
|
||||
func (c containerHelper) getImageName() string {
|
||||
colonSep := strutils.SplitRune(c.Image, ':')
|
||||
slashSep := strutils.SplitRune(colonSep[0], '/')
|
||||
return slashSep[len(slashSep)-1]
|
||||
}
|
||||
|
||||
func (c containerHelper) getPublicPortMapping() PortMapping {
|
||||
res := make(PortMapping)
|
||||
for _, v := range c.Ports {
|
||||
if v.PublicPort == 0 {
|
||||
continue
|
||||
}
|
||||
res[strutils.PortString(v.PublicPort)] = v
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func (c containerHelper) getPrivatePortMapping() PortMapping {
|
||||
res := make(PortMapping)
|
||||
for _, v := range c.Ports {
|
||||
res[strutils.PortString(v.PrivatePort)] = v
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
var databaseMPs = map[string]struct{}{
|
||||
"/var/lib/postgresql/data": {},
|
||||
"/var/lib/mysql": {},
|
||||
"/var/lib/mongodb": {},
|
||||
"/var/lib/mariadb": {},
|
||||
"/var/lib/memcached": {},
|
||||
"/var/lib/rabbitmq": {},
|
||||
}
|
||||
|
||||
var databasePrivPorts = map[uint16]struct{}{
|
||||
5432: {}, // postgres
|
||||
3306: {}, // mysql, mariadb
|
||||
6379: {}, // redis
|
||||
11211: {}, // memcached
|
||||
27017: {}, // mongodb
|
||||
}
|
||||
|
||||
func (c containerHelper) isDatabase() bool {
|
||||
for _, m := range c.Mounts {
|
||||
if _, ok := databaseMPs[m.Destination]; ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
for _, v := range c.Ports {
|
||||
if _, ok := databasePrivPorts[v.PrivatePort]; ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -3,9 +3,10 @@ package idlewatcher
|
||||
import (
|
||||
"bytes"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
"github.com/yusing/go-proxy/internal/common"
|
||||
)
|
||||
|
||||
type templateData struct {
|
||||
@@ -18,18 +19,15 @@ type templateData struct {
|
||||
var loadingPage []byte
|
||||
var loadingPageTmpl = template.Must(template.New("loading_page").Parse(string(loadingPage)))
|
||||
|
||||
const headerCheckRedirect = "X-Goproxy-Check-Redirect"
|
||||
|
||||
func (w *Watcher) makeRespBody(format string, args ...any) []byte {
|
||||
msg := fmt.Sprintf(format, args...)
|
||||
func (w *Watcher) makeLoadingPageBody() []byte {
|
||||
msg := w.ContainerName + " is starting..."
|
||||
|
||||
data := new(templateData)
|
||||
data.CheckRedirectHeader = headerCheckRedirect
|
||||
data.CheckRedirectHeader = common.HeaderCheckRedirect
|
||||
data.Title = w.ContainerName
|
||||
data.Message = strings.ReplaceAll(msg, "\n", "<br>")
|
||||
data.Message = strings.ReplaceAll(data.Message, " ", " ")
|
||||
data.Message = strings.ReplaceAll(msg, " ", " ")
|
||||
|
||||
buf := bytes.NewBuffer(make([]byte, 128)) // more than enough
|
||||
buf := bytes.NewBuffer(make([]byte, len(loadingPage)+len(data.Title)+len(data.Message)+len(common.HeaderCheckRedirect)))
|
||||
err := loadingPageTmpl.Execute(buf, data)
|
||||
if err != nil { // should never happen in production
|
||||
panic(err)
|
||||
106
internal/docker/idlewatcher/types/config.go
Normal file
106
internal/docker/idlewatcher/types/config.go
Normal file
@@ -0,0 +1,106 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/yusing/go-proxy/internal/docker"
|
||||
E "github.com/yusing/go-proxy/internal/error"
|
||||
)
|
||||
|
||||
type (
|
||||
Config struct {
|
||||
IdleTimeout time.Duration `json:"idle_timeout,omitempty"`
|
||||
WakeTimeout time.Duration `json:"wake_timeout,omitempty"`
|
||||
StopTimeout int `json:"stop_timeout,omitempty"` // docker api takes integer seconds for timeout argument
|
||||
StopMethod StopMethod `json:"stop_method,omitempty"`
|
||||
StopSignal Signal `json:"stop_signal,omitempty"`
|
||||
|
||||
DockerHost string `json:"docker_host,omitempty"`
|
||||
ContainerName string `json:"container_name,omitempty"`
|
||||
ContainerID string `json:"container_id,omitempty"`
|
||||
ContainerRunning bool `json:"container_running,omitempty"`
|
||||
}
|
||||
StopMethod string
|
||||
Signal string
|
||||
)
|
||||
|
||||
const (
|
||||
StopMethodPause StopMethod = "pause"
|
||||
StopMethodStop StopMethod = "stop"
|
||||
StopMethodKill StopMethod = "kill"
|
||||
)
|
||||
|
||||
var validSignals = map[string]struct{}{
|
||||
"": {},
|
||||
"SIGINT": {}, "SIGTERM": {}, "SIGHUP": {}, "SIGQUIT": {},
|
||||
"INT": {}, "TERM": {}, "HUP": {}, "QUIT": {},
|
||||
}
|
||||
|
||||
func ValidateConfig(cont *docker.Container) (*Config, E.Error) {
|
||||
if cont == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if cont.IdleTimeout == "" {
|
||||
return &Config{
|
||||
DockerHost: cont.DockerHost,
|
||||
ContainerName: cont.ContainerName,
|
||||
ContainerID: cont.ContainerID,
|
||||
ContainerRunning: cont.Running,
|
||||
}, nil
|
||||
}
|
||||
|
||||
errs := E.NewBuilder("invalid idlewatcher config")
|
||||
|
||||
idleTimeout := E.Collect(errs, validateDurationPostitive, cont.IdleTimeout)
|
||||
wakeTimeout := E.Collect(errs, validateDurationPostitive, cont.WakeTimeout)
|
||||
stopTimeout := E.Collect(errs, validateDurationPostitive, cont.StopTimeout)
|
||||
stopMethod := E.Collect(errs, validateStopMethod, cont.StopMethod)
|
||||
signal := E.Collect(errs, validateSignal, cont.StopSignal)
|
||||
|
||||
if errs.HasError() {
|
||||
return nil, errs.Error()
|
||||
}
|
||||
|
||||
return &Config{
|
||||
IdleTimeout: idleTimeout,
|
||||
WakeTimeout: wakeTimeout,
|
||||
StopTimeout: int(stopTimeout.Seconds()),
|
||||
StopMethod: stopMethod,
|
||||
StopSignal: signal,
|
||||
|
||||
DockerHost: cont.DockerHost,
|
||||
ContainerName: cont.ContainerName,
|
||||
ContainerID: cont.ContainerID,
|
||||
ContainerRunning: cont.Running,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func validateDurationPostitive(value string) (time.Duration, error) {
|
||||
d, err := time.ParseDuration(value)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if d < 0 {
|
||||
return 0, errors.New("duration must be positive")
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func validateSignal(s string) (Signal, error) {
|
||||
if _, ok := validSignals[s]; ok {
|
||||
return Signal(s), nil
|
||||
}
|
||||
return "", errors.New("invalid signal " + s)
|
||||
}
|
||||
|
||||
func validateStopMethod(s string) (StopMethod, error) {
|
||||
sm := StopMethod(s)
|
||||
switch sm {
|
||||
case StopMethodPause, StopMethodStop, StopMethodKill:
|
||||
return sm, nil
|
||||
default:
|
||||
return "", errors.New("invalid stop method " + s)
|
||||
}
|
||||
}
|
||||
15
internal/docker/idlewatcher/types/waker.go
Normal file
15
internal/docker/idlewatcher/types/waker.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
net "github.com/yusing/go-proxy/internal/net/types"
|
||||
"github.com/yusing/go-proxy/internal/watcher/health"
|
||||
)
|
||||
|
||||
type Waker interface {
|
||||
health.HealthMonitor
|
||||
http.Handler
|
||||
net.Stream
|
||||
Wake() error
|
||||
}
|
||||
@@ -1,133 +1,163 @@
|
||||
package idlewatcher
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/yusing/go-proxy/internal/common"
|
||||
"github.com/yusing/go-proxy/internal/docker/idlewatcher/types"
|
||||
E "github.com/yusing/go-proxy/internal/error"
|
||||
"github.com/yusing/go-proxy/internal/metrics"
|
||||
gphttp "github.com/yusing/go-proxy/internal/net/http"
|
||||
net "github.com/yusing/go-proxy/internal/net/types"
|
||||
route "github.com/yusing/go-proxy/internal/route/types"
|
||||
"github.com/yusing/go-proxy/internal/task"
|
||||
U "github.com/yusing/go-proxy/internal/utils"
|
||||
"github.com/yusing/go-proxy/internal/watcher/health"
|
||||
"github.com/yusing/go-proxy/internal/watcher/health/monitor"
|
||||
)
|
||||
|
||||
type Waker struct {
|
||||
*Watcher
|
||||
type (
|
||||
Waker = types.Waker
|
||||
waker struct {
|
||||
_ U.NoCopy
|
||||
|
||||
client *http.Client
|
||||
rp *gphttp.ReverseProxy
|
||||
rp *gphttp.ReverseProxy
|
||||
stream net.Stream
|
||||
hc health.HealthChecker
|
||||
metric *metrics.Gauge
|
||||
|
||||
ready atomic.Bool
|
||||
}
|
||||
)
|
||||
|
||||
const (
|
||||
idleWakerCheckInterval = 100 * time.Millisecond
|
||||
idleWakerCheckTimeout = time.Second
|
||||
)
|
||||
|
||||
// TODO: support stream
|
||||
|
||||
func newWaker(parent task.Parent, entry route.Entry, rp *gphttp.ReverseProxy, stream net.Stream) (Waker, E.Error) {
|
||||
hcCfg := entry.RawEntry().HealthCheck
|
||||
hcCfg.Timeout = idleWakerCheckTimeout
|
||||
|
||||
waker := &waker{
|
||||
rp: rp,
|
||||
stream: stream,
|
||||
}
|
||||
task := parent.Subtask("idlewatcher." + entry.TargetName())
|
||||
watcher, err := registerWatcher(task, entry, waker)
|
||||
if err != nil {
|
||||
return nil, E.Errorf("register watcher: %w", err)
|
||||
}
|
||||
|
||||
switch {
|
||||
case rp != nil:
|
||||
waker.hc = monitor.NewHTTPHealthChecker(entry.TargetURL(), hcCfg)
|
||||
case stream != nil:
|
||||
waker.hc = monitor.NewRawHealthChecker(entry.TargetURL(), hcCfg)
|
||||
default:
|
||||
panic("both nil")
|
||||
}
|
||||
|
||||
if common.PrometheusEnabled {
|
||||
m := metrics.GetServiceMetrics()
|
||||
fqn := parent.Name() + "/" + entry.TargetName()
|
||||
waker.metric = m.HealthStatus.With(metrics.HealthMetricLabels(fqn))
|
||||
waker.metric.Set(float64(watcher.Status()))
|
||||
}
|
||||
return watcher, nil
|
||||
}
|
||||
|
||||
func NewWaker(w *Watcher, rp *gphttp.ReverseProxy) *Waker {
|
||||
orig := rp.ServeHTTP
|
||||
// workaround for stopped containers port become zero
|
||||
rp.ServeHTTP = func(rw http.ResponseWriter, r *http.Request) {
|
||||
if rp.TargetURL.Port() == "0" {
|
||||
port, ok := portHistoryMap.Load(w.Alias)
|
||||
if !ok {
|
||||
w.l.Errorf("port history not found for %s", w.Alias)
|
||||
http.Error(rw, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
rp.TargetURL.Host = fmt.Sprintf("%s:%v", rp.TargetURL.Hostname(), port)
|
||||
// lifetime should follow route provider.
|
||||
func NewHTTPWaker(parent task.Parent, entry route.Entry, rp *gphttp.ReverseProxy) (Waker, E.Error) {
|
||||
return newWaker(parent, entry, rp, nil)
|
||||
}
|
||||
|
||||
func NewStreamWaker(parent task.Parent, entry route.Entry, stream net.Stream) (Waker, E.Error) {
|
||||
return newWaker(parent, entry, nil, stream)
|
||||
}
|
||||
|
||||
// Start implements health.HealthMonitor.
|
||||
func (w *Watcher) Start(parent task.Parent) E.Error {
|
||||
w.task.OnCancel("route_cleanup", func() {
|
||||
parent.Finish(w.task.FinishCause())
|
||||
if w.metric != nil {
|
||||
w.metric.Reset()
|
||||
}
|
||||
orig(rw, r)
|
||||
}
|
||||
return &Waker{
|
||||
Watcher: w,
|
||||
client: &http.Client{
|
||||
Timeout: 1 * time.Second,
|
||||
Transport: rp.Transport,
|
||||
},
|
||||
rp: rp,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// Task implements health.HealthMonitor.
|
||||
func (w *Watcher) Task() *task.Task {
|
||||
return w.task
|
||||
}
|
||||
|
||||
// Finish implements health.HealthMonitor.
|
||||
func (w *Watcher) Finish(reason any) {
|
||||
if w.stream != nil {
|
||||
w.stream.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Waker) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
|
||||
w.wake(w.rp.ServeHTTP, rw, r)
|
||||
// Name implements health.HealthMonitor.
|
||||
func (w *Watcher) Name() string {
|
||||
return w.String()
|
||||
}
|
||||
|
||||
func (w *Waker) wake(next http.HandlerFunc, rw http.ResponseWriter, r *http.Request) {
|
||||
w.resetIdleTimer()
|
||||
// String implements health.HealthMonitor.
|
||||
func (w *Watcher) String() string {
|
||||
return w.ContainerName
|
||||
}
|
||||
|
||||
// Uptime implements health.HealthMonitor.
|
||||
func (w *Watcher) Uptime() time.Duration {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Status implements health.HealthMonitor.
|
||||
func (w *Watcher) Status() health.Status {
|
||||
status := w.getStatusUpdateReady()
|
||||
if w.metric != nil {
|
||||
w.metric.Set(float64(status))
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
func (w *Watcher) getStatusUpdateReady() health.Status {
|
||||
if !w.ContainerRunning {
|
||||
return health.StatusNapping
|
||||
}
|
||||
|
||||
// pass through if container is ready
|
||||
if w.ready.Load() {
|
||||
next(rw, r)
|
||||
return
|
||||
return health.StatusHealthy
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), w.WakeTimeout)
|
||||
defer cancel()
|
||||
|
||||
accept := gphttp.GetAccept(r.Header)
|
||||
acceptHTML := (r.Method == http.MethodGet && accept.AcceptHTML() || r.RequestURI == "/" && accept.IsEmpty())
|
||||
|
||||
isCheckRedirect := r.Header.Get(headerCheckRedirect) != ""
|
||||
if !isCheckRedirect && acceptHTML {
|
||||
// Send a loading response to the client
|
||||
body := w.makeRespBody("%s waking up...", w.ContainerName)
|
||||
rw.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
rw.Header().Set("Content-Length", strconv.Itoa(len(body)))
|
||||
rw.Header().Add("Cache-Control", "no-cache")
|
||||
rw.Header().Add("Cache-Control", "no-store")
|
||||
rw.Header().Add("Cache-Control", "must-revalidate")
|
||||
if _, err := rw.Write(body); err != nil {
|
||||
w.l.Errorf("error writing http response: %s", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// wake the container and reset idle timer
|
||||
// also wait for another wake request
|
||||
w.wakeCh <- struct{}{}
|
||||
|
||||
if <-w.wakeDone != nil {
|
||||
http.Error(rw, "Error sending wake request", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// maybe another request came in while we were waiting for the wake
|
||||
if w.ready.Load() {
|
||||
if isCheckRedirect {
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
} else {
|
||||
next(rw, r)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
http.Error(rw, "Waking timed out", http.StatusGatewayTimeout)
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
wakeReq, err := http.NewRequestWithContext(
|
||||
ctx,
|
||||
http.MethodHead,
|
||||
w.URL.String(),
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
w.l.Errorf("new request err to %s: %s", r.URL, err)
|
||||
http.Error(rw, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
wakeResp, err := w.client.Do(wakeReq)
|
||||
if err == nil && wakeResp.StatusCode != http.StatusServiceUnavailable {
|
||||
w.ready.Store(true)
|
||||
w.l.Debug("awaken")
|
||||
if isCheckRedirect {
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
} else {
|
||||
next(rw, r)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// retry until the container is ready or timeout
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
result, err := w.hc.CheckHealth()
|
||||
switch {
|
||||
case err != nil:
|
||||
w.ready.Store(false)
|
||||
return health.StatusError
|
||||
case result.Healthy:
|
||||
w.ready.Store(true)
|
||||
return health.StatusHealthy
|
||||
default:
|
||||
return health.StatusStarting
|
||||
}
|
||||
}
|
||||
|
||||
// MarshalJSON implements health.HealthMonitor.
|
||||
func (w *Watcher) MarshalJSON() ([]byte, error) {
|
||||
var url net.URL
|
||||
if w.hc.URL().Port() != "0" {
|
||||
url = w.hc.URL()
|
||||
}
|
||||
return (&monitor.JSONRepresentation{
|
||||
Name: w.Name(),
|
||||
Status: w.Status(),
|
||||
Config: w.hc.Config(),
|
||||
URL: url,
|
||||
}).MarshalJSON()
|
||||
}
|
||||
|
||||
108
internal/docker/idlewatcher/waker_http.go
Normal file
108
internal/docker/idlewatcher/waker_http.go
Normal file
@@ -0,0 +1,108 @@
|
||||
package idlewatcher
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/yusing/go-proxy/internal/common"
|
||||
gphttp "github.com/yusing/go-proxy/internal/net/http"
|
||||
"github.com/yusing/go-proxy/internal/watcher/health"
|
||||
)
|
||||
|
||||
// ServeHTTP implements http.Handler.
|
||||
func (w *Watcher) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
|
||||
shouldNext := w.wakeFromHTTP(rw, r)
|
||||
if !shouldNext {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
default:
|
||||
w.rp.ServeHTTP(rw, r)
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Watcher) wakeFromHTTP(rw http.ResponseWriter, r *http.Request) (shouldNext bool) {
|
||||
w.resetIdleTimer()
|
||||
|
||||
// pass through if container is already ready
|
||||
if w.ready.Load() {
|
||||
return true
|
||||
}
|
||||
|
||||
if r.Body != nil {
|
||||
defer r.Body.Close()
|
||||
}
|
||||
|
||||
accept := gphttp.GetAccept(r.Header)
|
||||
acceptHTML := (r.Method == http.MethodGet && accept.AcceptHTML() || r.RequestURI == "/" && accept.IsEmpty())
|
||||
|
||||
isCheckRedirect := r.Header.Get(common.HeaderCheckRedirect) != ""
|
||||
if !isCheckRedirect && acceptHTML {
|
||||
// Send a loading response to the client
|
||||
body := w.makeLoadingPageBody()
|
||||
rw.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
rw.Header().Set("Content-Length", strconv.Itoa(len(body)))
|
||||
rw.Header().Add("Cache-Control", "no-cache")
|
||||
rw.Header().Add("Cache-Control", "no-store")
|
||||
rw.Header().Add("Cache-Control", "must-revalidate")
|
||||
rw.Header().Add("Connection", "close")
|
||||
if _, err := rw.Write(body); err != nil {
|
||||
w.Err(err).Msg("error writing http response")
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeoutCause(r.Context(), w.WakeTimeout, errors.New("wake timeout"))
|
||||
defer cancel()
|
||||
|
||||
checkCanceled := func() (canceled bool) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
w.WakeDebug().Str("cause", context.Cause(ctx).Error()).Msg("canceled")
|
||||
return true
|
||||
case <-w.task.Context().Done():
|
||||
w.WakeDebug().Str("cause", w.task.FinishCause().Error()).Msg("canceled")
|
||||
http.Error(rw, "Service unavailable", http.StatusServiceUnavailable)
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if checkCanceled() {
|
||||
return false
|
||||
}
|
||||
|
||||
w.WakeTrace().Msg("signal received")
|
||||
err := w.wakeIfStopped()
|
||||
if err != nil {
|
||||
w.WakeError(err)
|
||||
http.Error(rw, "Error waking container", http.StatusInternalServerError)
|
||||
return false
|
||||
}
|
||||
|
||||
for {
|
||||
if checkCanceled() {
|
||||
return false
|
||||
}
|
||||
|
||||
if w.Status() == health.StatusHealthy {
|
||||
w.resetIdleTimer()
|
||||
if isCheckRedirect {
|
||||
w.Debug().Msgf("redirecting to %s ...", w.hc.URL())
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
return false
|
||||
}
|
||||
w.Debug().Msgf("passing through to %s ...", w.hc.URL())
|
||||
return true
|
||||
}
|
||||
|
||||
// retry until the container is ready or timeout
|
||||
time.Sleep(idleWakerCheckInterval)
|
||||
}
|
||||
}
|
||||
90
internal/docker/idlewatcher/waker_stream.go
Normal file
90
internal/docker/idlewatcher/waker_stream.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package idlewatcher
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"github.com/yusing/go-proxy/internal/net/types"
|
||||
"github.com/yusing/go-proxy/internal/watcher/health"
|
||||
)
|
||||
|
||||
// Setup implements types.Stream.
|
||||
func (w *Watcher) Addr() net.Addr {
|
||||
return w.stream.Addr()
|
||||
}
|
||||
|
||||
// Setup implements types.Stream.
|
||||
func (w *Watcher) Setup() error {
|
||||
return w.stream.Setup()
|
||||
}
|
||||
|
||||
// Accept implements types.Stream.
|
||||
func (w *Watcher) Accept() (conn types.StreamConn, err error) {
|
||||
conn, err = w.stream.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if wakeErr := w.wakeFromStream(); wakeErr != nil {
|
||||
w.WakeError(wakeErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Handle implements types.Stream.
|
||||
func (w *Watcher) Handle(conn types.StreamConn) error {
|
||||
if err := w.wakeFromStream(); err != nil {
|
||||
return err
|
||||
}
|
||||
return w.stream.Handle(conn)
|
||||
}
|
||||
|
||||
// Close implements types.Stream.
|
||||
func (w *Watcher) Close() error {
|
||||
return w.stream.Close()
|
||||
}
|
||||
|
||||
func (w *Watcher) wakeFromStream() error {
|
||||
w.resetIdleTimer()
|
||||
|
||||
// pass through if container is already ready
|
||||
if w.ready.Load() {
|
||||
return nil
|
||||
}
|
||||
|
||||
w.WakeDebug().Msg("wake signal received")
|
||||
wakeErr := w.wakeIfStopped()
|
||||
if wakeErr != nil {
|
||||
wakeErr = fmt.Errorf("%s failed: %w", w.String(), wakeErr)
|
||||
w.WakeError(wakeErr)
|
||||
return wakeErr
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeoutCause(w.task.Context(), w.WakeTimeout, errors.New("wake timeout"))
|
||||
defer cancel()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-w.task.Context().Done():
|
||||
cause := w.task.FinishCause()
|
||||
w.WakeDebug().Str("cause", cause.Error()).Msg("canceled")
|
||||
return cause
|
||||
case <-ctx.Done():
|
||||
cause := context.Cause(ctx)
|
||||
w.WakeDebug().Str("cause", cause.Error()).Msg("timeout")
|
||||
return cause
|
||||
default:
|
||||
}
|
||||
|
||||
if w.Status() == health.StatusHealthy {
|
||||
w.resetIdleTimer()
|
||||
w.Debug().Msg("container is ready, passing through to " + w.hc.URL().String())
|
||||
return nil
|
||||
}
|
||||
|
||||
// retry until the container is ready or timeout
|
||||
time.Sleep(idleWakerCheckInterval)
|
||||
}
|
||||
}
|
||||
@@ -2,281 +2,297 @@ package idlewatcher
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/rs/zerolog"
|
||||
D "github.com/yusing/go-proxy/internal/docker"
|
||||
idlewatcher "github.com/yusing/go-proxy/internal/docker/idlewatcher/types"
|
||||
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"
|
||||
"github.com/yusing/go-proxy/internal/logging"
|
||||
route "github.com/yusing/go-proxy/internal/route/types"
|
||||
"github.com/yusing/go-proxy/internal/task"
|
||||
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"
|
||||
"github.com/yusing/go-proxy/internal/watcher/events"
|
||||
)
|
||||
|
||||
type (
|
||||
Watcher struct {
|
||||
*P.ReverseProxyEntry
|
||||
_ U.NoCopy
|
||||
|
||||
client D.Client
|
||||
zerolog.Logger
|
||||
|
||||
ready atomic.Bool // whether the site is ready to accept connection
|
||||
*idlewatcher.Config
|
||||
*waker
|
||||
|
||||
client *D.SharedClient
|
||||
stopByMethod StopCallback // send a docker command w.r.t. `stop_method`
|
||||
|
||||
wakeCh chan struct{}
|
||||
wakeDone chan E.NestedError
|
||||
ticker *time.Ticker
|
||||
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
refCount *sync.WaitGroup
|
||||
|
||||
l logrus.FieldLogger
|
||||
ticker *time.Ticker
|
||||
task *task.Task
|
||||
}
|
||||
|
||||
WakeDone <-chan error
|
||||
WakeFunc func() WakeDone
|
||||
StopCallback func() E.NestedError
|
||||
StopCallback func() error
|
||||
)
|
||||
|
||||
var (
|
||||
mainLoopCtx context.Context
|
||||
mainLoopCancel context.CancelFunc
|
||||
mainLoopWg sync.WaitGroup
|
||||
|
||||
watcherMap = F.NewMapOf[string, *Watcher]()
|
||||
watcherMapMu sync.Mutex
|
||||
|
||||
portHistoryMap = F.NewMapOf[PT.Alias, string]()
|
||||
errShouldNotReachHere = errors.New("should not reach here")
|
||||
|
||||
newWatcherCh = make(chan *Watcher)
|
||||
|
||||
logger = logrus.WithField("module", "idle_watcher")
|
||||
logger = logging.With().Str("module", "idle_watcher").Logger()
|
||||
)
|
||||
|
||||
func Register(entry *P.ReverseProxyEntry) (*Watcher, E.NestedError) {
|
||||
failure := E.Failure("idle_watcher register")
|
||||
const dockerReqTimeout = 3 * time.Second
|
||||
|
||||
if entry.IdleTimeout == 0 {
|
||||
return nil, failure.With(E.Invalid("idle_timeout", 0))
|
||||
func registerWatcher(watcherTask *task.Task, entry route.Entry, waker *waker) (*Watcher, error) {
|
||||
cfg := entry.IdlewatcherConfig()
|
||||
|
||||
if cfg.IdleTimeout == 0 {
|
||||
panic(errShouldNotReachHere)
|
||||
}
|
||||
|
||||
watcherMapMu.Lock()
|
||||
defer watcherMapMu.Unlock()
|
||||
|
||||
key := entry.ContainerID
|
||||
|
||||
if entry.URL.Port() != "0" {
|
||||
portHistoryMap.Store(entry.Alias, entry.URL.Port())
|
||||
}
|
||||
key := cfg.ContainerID
|
||||
|
||||
if w, ok := watcherMap.Load(key); ok {
|
||||
w.refCount.Add(1)
|
||||
w.ReverseProxyEntry = entry
|
||||
w.Config = cfg
|
||||
w.waker = waker
|
||||
w.resetIdleTimer()
|
||||
watcherTask.Finish("used existing watcher")
|
||||
return w, nil
|
||||
}
|
||||
|
||||
client, err := D.ConnectClient(entry.DockerHost)
|
||||
if err.HasError() {
|
||||
return nil, failure.With(err)
|
||||
client, err := D.ConnectClient(cfg.DockerHost)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
w := &Watcher{
|
||||
ReverseProxyEntry: entry,
|
||||
client: client,
|
||||
refCount: &sync.WaitGroup{},
|
||||
wakeCh: make(chan struct{}, 1),
|
||||
wakeDone: make(chan E.NestedError),
|
||||
ticker: time.NewTicker(entry.IdleTimeout),
|
||||
l: logger.WithField("container", entry.ContainerName),
|
||||
Logger: logger.With().Str("name", cfg.ContainerName).Logger(),
|
||||
Config: cfg,
|
||||
waker: waker,
|
||||
client: client,
|
||||
task: watcherTask,
|
||||
ticker: time.NewTicker(cfg.IdleTimeout),
|
||||
}
|
||||
w.refCount.Add(1)
|
||||
w.stopByMethod = w.getStopCallback()
|
||||
|
||||
watcherMap.Store(key, w)
|
||||
|
||||
go func() {
|
||||
newWatcherCh <- w
|
||||
cause := w.watchUntilDestroy()
|
||||
|
||||
watcherMap.Delete(w.ContainerID)
|
||||
|
||||
w.ticker.Stop()
|
||||
w.client.Close()
|
||||
w.task.Finish(cause)
|
||||
}()
|
||||
|
||||
return w, nil
|
||||
}
|
||||
|
||||
func (w *Watcher) Unregister() {
|
||||
w.refCount.Add(-1)
|
||||
func (w *Watcher) Wake() error {
|
||||
return w.wakeIfStopped()
|
||||
}
|
||||
|
||||
func Start() {
|
||||
logger.Debug("started")
|
||||
defer logger.Debug("stopped")
|
||||
|
||||
mainLoopCtx, mainLoopCancel = context.WithCancel(context.Background())
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-mainLoopCtx.Done():
|
||||
return
|
||||
case w := <-newWatcherCh:
|
||||
w.l.Debug("registered")
|
||||
mainLoopWg.Add(1)
|
||||
go func() {
|
||||
w.watchUntilCancel()
|
||||
w.refCount.Wait() // wait for 0 ref count
|
||||
|
||||
watcherMap.Delete(w.ContainerID)
|
||||
w.l.Debug("unregistered")
|
||||
mainLoopWg.Done()
|
||||
}()
|
||||
}
|
||||
}
|
||||
// WakeDebug logs a debug message related to waking the container.
|
||||
func (w *Watcher) WakeDebug() *zerolog.Event {
|
||||
//nolint:zerologlint
|
||||
return w.Debug().Str("action", "wake")
|
||||
}
|
||||
|
||||
func Stop() {
|
||||
mainLoopCancel()
|
||||
mainLoopWg.Wait()
|
||||
func (w *Watcher) WakeTrace() *zerolog.Event {
|
||||
//nolint:zerologlint
|
||||
return w.Trace().Str("action", "wake")
|
||||
}
|
||||
|
||||
func (w *Watcher) containerStop() error {
|
||||
return w.client.ContainerStop(w.ctx, w.ContainerID, container.StopOptions{
|
||||
func (w *Watcher) WakeError(err error) {
|
||||
w.Err(err).Str("action", "wake").Msg("error")
|
||||
}
|
||||
|
||||
func (w *Watcher) LogReason(action, reason string) {
|
||||
w.Info().Str("reason", reason).Msg(action)
|
||||
}
|
||||
|
||||
func (w *Watcher) containerStop(ctx context.Context) error {
|
||||
return w.client.ContainerStop(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) containerPause(ctx context.Context) error {
|
||||
return w.client.ContainerPause(ctx, w.ContainerID)
|
||||
}
|
||||
|
||||
func (w *Watcher) containerKill() error {
|
||||
return w.client.ContainerKill(w.ctx, w.ContainerID, string(w.StopSignal))
|
||||
func (w *Watcher) containerKill(ctx context.Context) error {
|
||||
return w.client.ContainerKill(ctx, w.ContainerID, string(w.StopSignal))
|
||||
}
|
||||
|
||||
func (w *Watcher) containerUnpause() error {
|
||||
return w.client.ContainerUnpause(w.ctx, w.ContainerID)
|
||||
func (w *Watcher) containerUnpause(ctx context.Context) error {
|
||||
return w.client.ContainerUnpause(ctx, w.ContainerID)
|
||||
}
|
||||
|
||||
func (w *Watcher) containerStart() error {
|
||||
return w.client.ContainerStart(w.ctx, w.ContainerID, container.StartOptions{})
|
||||
func (w *Watcher) containerStart(ctx context.Context) error {
|
||||
return w.client.ContainerStart(ctx, w.ContainerID, container.StartOptions{})
|
||||
}
|
||||
|
||||
func (w *Watcher) containerStatus() (string, E.NestedError) {
|
||||
json, err := w.client.ContainerInspect(w.ctx, w.ContainerID)
|
||||
func (w *Watcher) containerStatus() (string, error) {
|
||||
if !w.client.Connected() {
|
||||
return "", errors.New("docker client not connected")
|
||||
}
|
||||
ctx, cancel := context.WithTimeoutCause(w.task.Context(), dockerReqTimeout, errors.New("docker request timeout"))
|
||||
defer cancel()
|
||||
json, err := w.client.ContainerInspect(ctx, w.ContainerID)
|
||||
if err != nil {
|
||||
return "", E.FailWith("inspect container", err)
|
||||
return "", err
|
||||
}
|
||||
return json.State.Status, nil
|
||||
}
|
||||
|
||||
func (w *Watcher) wakeIfStopped() E.NestedError {
|
||||
if w.ready.Load() || w.ContainerRunning {
|
||||
func (w *Watcher) wakeIfStopped() error {
|
||||
if w.ContainerRunning {
|
||||
return nil
|
||||
}
|
||||
|
||||
status, err := w.containerStatus()
|
||||
|
||||
if err.HasError() {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// "created", "running", "paused", "restarting", "removing", "exited", or "dead"
|
||||
|
||||
ctx, cancel := context.WithTimeout(w.task.Context(), w.WakeTimeout)
|
||||
defer cancel()
|
||||
|
||||
// !Hard coded here since theres no constants from Docker API
|
||||
switch status {
|
||||
case "exited", "dead":
|
||||
return E.From(w.containerStart())
|
||||
return w.containerStart(ctx)
|
||||
case "paused":
|
||||
return E.From(w.containerUnpause())
|
||||
return w.containerUnpause(ctx)
|
||||
case "running":
|
||||
return nil
|
||||
default:
|
||||
return E.Unexpected("container state", status)
|
||||
panic(errShouldNotReachHere)
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Watcher) getStopCallback() StopCallback {
|
||||
var cb func() error
|
||||
var cb func(context.Context) error
|
||||
switch w.StopMethod {
|
||||
case PT.StopMethodPause:
|
||||
case idlewatcher.StopMethodPause:
|
||||
cb = w.containerPause
|
||||
case PT.StopMethodStop:
|
||||
case idlewatcher.StopMethodStop:
|
||||
cb = w.containerStop
|
||||
case PT.StopMethodKill:
|
||||
case idlewatcher.StopMethodKill:
|
||||
cb = w.containerKill
|
||||
default:
|
||||
panic("should not reach here")
|
||||
panic(errShouldNotReachHere)
|
||||
}
|
||||
return func() E.NestedError {
|
||||
status, err := w.containerStatus()
|
||||
if err.HasError() {
|
||||
return err
|
||||
}
|
||||
if status != "running" {
|
||||
return nil
|
||||
}
|
||||
return E.From(cb())
|
||||
return func() error {
|
||||
ctx, cancel := context.WithTimeout(w.task.Context(), time.Duration(w.StopTimeout)*time.Second)
|
||||
defer cancel()
|
||||
return cb(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Watcher) resetIdleTimer() {
|
||||
w.Trace().Msg("reset idle timer")
|
||||
w.ticker.Reset(w.IdleTimeout)
|
||||
}
|
||||
|
||||
func (w *Watcher) watchUntilCancel() {
|
||||
defer close(w.wakeCh)
|
||||
|
||||
w.ctx, w.cancel = context.WithCancel(mainLoopCtx)
|
||||
|
||||
dockerWatcher := W.NewDockerWatcherWithClient(w.client)
|
||||
dockerEventCh, dockerEventErrCh := dockerWatcher.EventsWithOptions(w.ctx, W.DockerListOptions{
|
||||
Filters: W.NewDockerFilter(
|
||||
W.DockerFilterContainer,
|
||||
W.DockerrFilterContainer(w.ContainerID),
|
||||
W.DockerFilterStart,
|
||||
W.DockerFilterStop,
|
||||
W.DockerFilterDie,
|
||||
W.DockerFilterKill,
|
||||
W.DockerFilterPause,
|
||||
W.DockerFilterUnpause,
|
||||
func (w *Watcher) getEventCh(dockerWatcher watcher.DockerWatcher) (eventCh <-chan events.Event, errCh <-chan E.Error) {
|
||||
eventCh, errCh = dockerWatcher.EventsWithOptions(w.Task().Context(), watcher.DockerListOptions{
|
||||
Filters: watcher.NewDockerFilter(
|
||||
watcher.DockerFilterContainer,
|
||||
watcher.DockerFilterContainerNameID(w.ContainerID),
|
||||
watcher.DockerFilterStart,
|
||||
watcher.DockerFilterStop,
|
||||
watcher.DockerFilterDie,
|
||||
watcher.DockerFilterKill,
|
||||
watcher.DockerFilterDestroy,
|
||||
watcher.DockerFilterPause,
|
||||
watcher.DockerFilterUnpause,
|
||||
),
|
||||
})
|
||||
defer w.ticker.Stop()
|
||||
defer w.client.Close()
|
||||
return
|
||||
}
|
||||
|
||||
// watchUntilDestroy waits for the container to be created, started, or unpaused,
|
||||
// and then reset the idle timer.
|
||||
//
|
||||
// When the container is stopped, paused,
|
||||
// or killed, the idle timer is stopped and the ContainerRunning flag is set to false.
|
||||
//
|
||||
// When the idle timer fires, the container is stopped according to the
|
||||
// stop method.
|
||||
//
|
||||
// it exits only if the context is canceled, the container is destroyed,
|
||||
// errors occurred on docker client, or route provider died (mainly caused by config reload).
|
||||
func (w *Watcher) watchUntilDestroy() (returnCause error) {
|
||||
dockerWatcher := watcher.NewDockerWatcherWithClient(w.client)
|
||||
dockerEventCh, dockerEventErrCh := w.getEventCh(dockerWatcher)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-w.ctx.Done():
|
||||
w.l.Debug("stopped")
|
||||
return
|
||||
case <-w.task.Context().Done():
|
||||
return w.task.FinishCause()
|
||||
case err := <-dockerEventErrCh:
|
||||
if err != nil && err.IsNot(context.Canceled) {
|
||||
w.l.Error(E.FailWith("docker watcher", err))
|
||||
if !err.Is(context.Canceled) {
|
||||
E.LogError("idlewatcher error", err, &w.Logger)
|
||||
}
|
||||
return err
|
||||
case e := <-dockerEventCh:
|
||||
switch {
|
||||
case e.Action == events.ActionContainerDestroy:
|
||||
w.ContainerRunning = false
|
||||
w.ready.Store(false)
|
||||
w.LogReason("watcher stopped", "container destroyed")
|
||||
return errors.New("container destroyed")
|
||||
// create / start / unpause
|
||||
case e.Action.IsContainerWake():
|
||||
w.ContainerRunning = true
|
||||
w.resetIdleTimer()
|
||||
w.l.Info(e)
|
||||
default: // stop / pause / kil
|
||||
w.Info().Msg("awaken")
|
||||
case e.Action.IsContainerSleep(): // stop / pause / kil
|
||||
w.ContainerRunning = false
|
||||
w.ticker.Stop()
|
||||
w.ready.Store(false)
|
||||
w.l.Info(e)
|
||||
w.ticker.Stop()
|
||||
default:
|
||||
w.Error().Msg("unexpected docker event: " + e.String())
|
||||
}
|
||||
// container name changed should also change the container id
|
||||
if w.ContainerName != e.ActorName {
|
||||
w.Debug().Msgf("renamed %s -> %s", w.ContainerName, e.ActorName)
|
||||
w.ContainerName = e.ActorName
|
||||
}
|
||||
if w.ContainerID != e.ActorID {
|
||||
w.Debug().Msgf("id changed %s -> %s", w.ContainerID, e.ActorID)
|
||||
w.ContainerID = e.ActorID
|
||||
// recreate event stream
|
||||
dockerEventCh, dockerEventErrCh = w.getEventCh(dockerWatcher)
|
||||
}
|
||||
case <-w.ticker.C:
|
||||
w.l.Debug("idle timeout")
|
||||
w.ticker.Stop()
|
||||
if err := w.stopByMethod(); err != nil && err.IsNot(context.Canceled) {
|
||||
w.l.Error(E.FailWith("stop", err).Extraf("stop method: %s", w.StopMethod))
|
||||
if w.ContainerRunning {
|
||||
err := w.stopByMethod()
|
||||
switch {
|
||||
case errors.Is(err, context.Canceled):
|
||||
continue
|
||||
case err != nil:
|
||||
w.Err(err).Msgf("container stop with method %q failed", w.StopMethod)
|
||||
default:
|
||||
w.LogReason("container stopped", "idle timeout")
|
||||
}
|
||||
}
|
||||
case <-w.wakeCh:
|
||||
w.l.Debug("wake signal received")
|
||||
w.resetIdleTimer()
|
||||
err := w.wakeIfStopped()
|
||||
if err != nil {
|
||||
w.l.Error(E.FailWith("wake", err))
|
||||
}
|
||||
w.wakeDone <- err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,18 +2,27 @@ package docker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"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)
|
||||
func Inspect(dockerHost string, containerID string) (*Container, error) {
|
||||
client, err := ConnectClient(dockerHost)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer client.Close()
|
||||
return client.Inspect(containerID)
|
||||
}
|
||||
|
||||
func (c *SharedClient) Inspect(containerID string) (*Container, error) {
|
||||
ctx, cancel := context.WithTimeoutCause(context.Background(), 3*time.Second, errors.New("docker container inspect timeout"))
|
||||
defer cancel()
|
||||
|
||||
json, err := c.ContainerInspect(ctx, containerID)
|
||||
if err != nil {
|
||||
return Container{}, E.From(err)
|
||||
return nil, err
|
||||
}
|
||||
return FromJSON(json, c.key), nil
|
||||
}
|
||||
|
||||
@@ -1,115 +1,52 @@
|
||||
package docker
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
E "github.com/yusing/go-proxy/internal/error"
|
||||
U "github.com/yusing/go-proxy/internal/utils"
|
||||
"github.com/yusing/go-proxy/internal/utils/strutils"
|
||||
)
|
||||
|
||||
/*
|
||||
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
|
||||
)
|
||||
type LabelMap = map[string]any
|
||||
|
||||
func (l *Label) String() string {
|
||||
if l.Attribute == "" {
|
||||
return l.Namespace + "." + l.Target
|
||||
}
|
||||
return l.Namespace + "." + l.Target + "." + l.Attribute
|
||||
}
|
||||
var ErrInvalidLabel = E.New("invalid label")
|
||||
|
||||
// Apply applies the value of a Label to the corresponding field in the given object.
|
||||
//
|
||||
// Parameters:
|
||||
// - obj: a pointer to the object to which the Label value will be applied.
|
||||
// - l: a pointer to the Label containing the attribute and value to be applied.
|
||||
//
|
||||
// Returns:
|
||||
// - error: an error if the field does not exist.
|
||||
func ApplyLabel[T any](obj *T, l *Label) E.NestedError {
|
||||
if obj == nil {
|
||||
return E.Invalid("nil object", l)
|
||||
}
|
||||
switch nestedLabel := l.Value.(type) {
|
||||
case *Label:
|
||||
var field reflect.Value
|
||||
objType := reflect.TypeFor[T]()
|
||||
for i := range reflect.TypeFor[T]().NumField() {
|
||||
if objType.Field(i).Tag.Get("yaml") == l.Attribute {
|
||||
field = reflect.ValueOf(obj).Elem().Field(i)
|
||||
break
|
||||
}
|
||||
func ParseLabels(labels map[string]string) (LabelMap, E.Error) {
|
||||
nestedMap := make(LabelMap)
|
||||
errs := E.NewBuilder("labels error")
|
||||
|
||||
for lbl, value := range labels {
|
||||
parts := strutils.SplitRune(lbl, '.')
|
||||
if parts[0] != NSProxy {
|
||||
continue
|
||||
}
|
||||
if !field.IsValid() {
|
||||
return E.NotExist("field", l.Attribute)
|
||||
if len(parts) == 1 {
|
||||
errs.Add(ErrInvalidLabel.Subject(lbl))
|
||||
continue
|
||||
}
|
||||
dst, ok := field.Interface().(NestedLabelMap)
|
||||
if !ok {
|
||||
if field.Kind() == reflect.Ptr {
|
||||
if field.IsNil() {
|
||||
field.Set(reflect.New(field.Type().Elem()))
|
||||
}
|
||||
parts = parts[1:]
|
||||
currentMap := nestedMap
|
||||
|
||||
for i, k := range parts {
|
||||
if i == len(parts)-1 {
|
||||
// Last element, set the value
|
||||
currentMap[k] = value
|
||||
} else {
|
||||
field = field.Addr()
|
||||
// If the key doesn't exist, create a new map
|
||||
if _, exists := currentMap[k]; !exists {
|
||||
currentMap[k] = make(LabelMap)
|
||||
}
|
||||
// Move deeper into the nested map
|
||||
m, ok := currentMap[k].(LabelMap)
|
||||
if !ok && currentMap[k] != "" {
|
||||
errs.Add(E.Errorf("expect mapping, got %T", currentMap[k]).Subject(lbl))
|
||||
continue
|
||||
} else if !ok {
|
||||
m = make(LabelMap)
|
||||
currentMap[k] = m
|
||||
}
|
||||
currentMap = m
|
||||
}
|
||||
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
|
||||
|
||||
return nestedMap, errs.Error()
|
||||
}
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
package docker
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
U "github.com/yusing/go-proxy/internal/utils"
|
||||
. "github.com/yusing/go-proxy/internal/utils/testing"
|
||||
)
|
||||
|
||||
const (
|
||||
mName = "middleware1"
|
||||
mAttr = "prop1"
|
||||
v = "value1"
|
||||
)
|
||||
|
||||
func makeLabel(ns, name, attr string) string {
|
||||
return fmt.Sprintf("%s.%s.%s", ns, name, attr)
|
||||
}
|
||||
|
||||
func TestNestedLabel(t *testing.T) {
|
||||
mAttr := "prop1"
|
||||
pl, err := ParseLabel(makeLabel(NSProxy, "foo", makeLabel("middlewares", mName, mAttr)), v)
|
||||
ExpectNoError(t, err.Error())
|
||||
sGot := ExpectType[*Label](t, pl.Value)
|
||||
ExpectFalse(t, sGot == nil)
|
||||
ExpectEqual(t, sGot.Namespace, mName)
|
||||
ExpectEqual(t, sGot.Attribute, mAttr)
|
||||
}
|
||||
|
||||
func TestApplyNestedLabel(t *testing.T) {
|
||||
entry := new(struct {
|
||||
Middlewares NestedLabelMap `yaml:"middlewares"`
|
||||
})
|
||||
pl, err := ParseLabel(makeLabel(NSProxy, "foo", makeLabel("middlewares", mName, mAttr)), v)
|
||||
ExpectNoError(t, err.Error())
|
||||
err = ApplyLabel(entry, pl)
|
||||
ExpectNoError(t, err.Error())
|
||||
middleware1, ok := entry.Middlewares[mName]
|
||||
ExpectTrue(t, ok)
|
||||
got := ExpectType[string](t, middleware1[mAttr])
|
||||
ExpectEqual(t, got, v)
|
||||
}
|
||||
|
||||
func TestApplyNestedLabelExisting(t *testing.T) {
|
||||
checkAttr := "prop2"
|
||||
checkV := "value2"
|
||||
entry := new(struct {
|
||||
Middlewares NestedLabelMap `yaml:"middlewares"`
|
||||
})
|
||||
entry.Middlewares = make(NestedLabelMap)
|
||||
entry.Middlewares[mName] = make(U.SerializedObject)
|
||||
entry.Middlewares[mName][checkAttr] = checkV
|
||||
|
||||
pl, err := ParseLabel(makeLabel(NSProxy, "foo", makeLabel("middlewares", mName, mAttr)), v)
|
||||
ExpectNoError(t, err.Error())
|
||||
err = ApplyLabel(entry, pl)
|
||||
ExpectNoError(t, err.Error())
|
||||
middleware1, ok := entry.Middlewares[mName]
|
||||
ExpectTrue(t, ok)
|
||||
got := ExpectType[string](t, middleware1[mAttr])
|
||||
ExpectEqual(t, got, v)
|
||||
|
||||
// check if prop2 is affected
|
||||
ExpectFalse(t, middleware1[checkAttr] == nil)
|
||||
got = ExpectType[string](t, middleware1[checkAttr])
|
||||
ExpectEqual(t, got, checkV)
|
||||
}
|
||||
|
||||
func TestApplyNestedLabelNoAttr(t *testing.T) {
|
||||
entry := new(struct {
|
||||
Middlewares NestedLabelMap `yaml:"middlewares"`
|
||||
})
|
||||
entry.Middlewares = make(NestedLabelMap)
|
||||
entry.Middlewares[mName] = make(U.SerializedObject)
|
||||
|
||||
pl, err := ParseLabel(makeLabel(NSProxy, "foo", fmt.Sprintf("%s.%s", "middlewares", mName)), v)
|
||||
ExpectNoError(t, err.Error())
|
||||
err = ApplyLabel(entry, pl)
|
||||
ExpectNoError(t, err.Error())
|
||||
_, ok := entry.Middlewares[mName]
|
||||
ExpectTrue(t, ok)
|
||||
}
|
||||
@@ -3,8 +3,7 @@ package docker
|
||||
const (
|
||||
WildcardAlias = "*"
|
||||
|
||||
NSProxy = "proxy"
|
||||
NSHomePage = "homepage"
|
||||
NSProxy = "proxy"
|
||||
|
||||
LabelAliases = NSProxy + ".aliases"
|
||||
LabelExclude = NSProxy + ".exclude"
|
||||
|
||||
44
internal/docker/list_containers.go
Normal file
44
internal/docker/list_containers.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package docker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/client"
|
||||
)
|
||||
|
||||
var listOptions = container.ListOptions{
|
||||
// created|restarting|running|removing|paused|exited|dead
|
||||
// Filters: filters.NewArgs(
|
||||
// filters.Arg("status", "created"),
|
||||
// filters.Arg("status", "restarting"),
|
||||
// filters.Arg("status", "running"),
|
||||
// filters.Arg("status", "paused"),
|
||||
// filters.Arg("status", "exited"),
|
||||
// ),
|
||||
All: true,
|
||||
}
|
||||
|
||||
func ListContainers(clientHost string) ([]types.Container, error) {
|
||||
dockerClient, err := ConnectClient(clientHost)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer dockerClient.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeoutCause(context.Background(), 3*time.Second, errors.New("list containers timeout"))
|
||||
defer cancel()
|
||||
|
||||
containers, err := dockerClient.ContainerList(ctx, listOptions)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return containers, nil
|
||||
}
|
||||
|
||||
func IsErrConnectionFailed(err error) bool {
|
||||
return client.IsErrConnectionFailed(err)
|
||||
}
|
||||
7
internal/docker/logger.go
Normal file
7
internal/docker/logger.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package docker
|
||||
|
||||
import (
|
||||
"github.com/yusing/go-proxy/internal/logging"
|
||||
)
|
||||
|
||||
var logger = logging.With().Str("module", "docker").Logger()
|
||||
@@ -1,27 +0,0 @@
|
||||
package docker
|
||||
|
||||
import "github.com/docker/docker/api/types"
|
||||
|
||||
type (
|
||||
PortMapping = map[string]types.Port
|
||||
ProxyProperties struct {
|
||||
DockerHost string `json:"docker_host" yaml:"-"`
|
||||
ContainerName string `json:"container_name" yaml:"-"`
|
||||
ContainerID string `json:"container_id" yaml:"-"`
|
||||
ImageName string `json:"image_name" yaml:"-"`
|
||||
PublicPortMapping PortMapping `json:"public_ports" yaml:"-"` // non-zero publicPort:types.Port
|
||||
PrivatePortMapping PortMapping `json:"private_ports" yaml:"-"` // privatePort:types.Port
|
||||
NetworkMode string `json:"network_mode" yaml:"-"`
|
||||
|
||||
Aliases []string `json:"aliases" yaml:"-"`
|
||||
IsExcluded bool `json:"is_excluded" yaml:"-"`
|
||||
IsExplicit bool `json:"is_explicit" yaml:"-"`
|
||||
IsDatabase bool `json:"is_database" yaml:"-"`
|
||||
IdleTimeout string `json:"idle_timeout" yaml:"-"`
|
||||
WakeTimeout string `json:"wake_timeout" yaml:"-"`
|
||||
StopMethod string `json:"stop_method" yaml:"-"`
|
||||
StopTimeout string `json:"stop_timeout" yaml:"-"` // stop_method = "stop" only
|
||||
StopSignal string `json:"stop_signal" yaml:"-"` // stop_method = "stop" | "kill" only
|
||||
Running bool `json:"running" yaml:"-"`
|
||||
}
|
||||
)
|
||||
148
internal/entrypoint/entrypoint.go
Normal file
148
internal/entrypoint/entrypoint.go
Normal file
@@ -0,0 +1,148 @@
|
||||
package entrypoint
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
gphttp "github.com/yusing/go-proxy/internal/net/http"
|
||||
"github.com/yusing/go-proxy/internal/net/http/accesslog"
|
||||
"github.com/yusing/go-proxy/internal/net/http/middleware"
|
||||
"github.com/yusing/go-proxy/internal/net/http/middleware/errorpage"
|
||||
"github.com/yusing/go-proxy/internal/route/routes"
|
||||
route "github.com/yusing/go-proxy/internal/route/types"
|
||||
"github.com/yusing/go-proxy/internal/task"
|
||||
"github.com/yusing/go-proxy/internal/utils/strutils"
|
||||
)
|
||||
|
||||
var findRouteFunc = findRouteAnyDomain
|
||||
|
||||
var (
|
||||
epMiddleware *middleware.Middleware
|
||||
epMiddlewareMu sync.Mutex
|
||||
|
||||
epAccessLogger *accesslog.AccessLogger
|
||||
epAccessLoggerMu sync.Mutex
|
||||
)
|
||||
|
||||
var ErrNoSuchRoute = errors.New("no such route")
|
||||
|
||||
func SetFindRouteDomains(domains []string) {
|
||||
if len(domains) == 0 {
|
||||
findRouteFunc = findRouteAnyDomain
|
||||
} else {
|
||||
findRouteFunc = findRouteByDomains(domains)
|
||||
}
|
||||
}
|
||||
|
||||
func SetMiddlewares(mws []map[string]any) error {
|
||||
epMiddlewareMu.Lock()
|
||||
defer epMiddlewareMu.Unlock()
|
||||
|
||||
if len(mws) == 0 {
|
||||
epMiddleware = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
mid, err := middleware.BuildMiddlewareFromChainRaw("entrypoint", mws)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
epMiddleware = mid
|
||||
|
||||
logger.Debug().Msg("entrypoint middleware loaded")
|
||||
return nil
|
||||
}
|
||||
|
||||
func SetAccessLogger(parent task.Parent, cfg *accesslog.Config) (err error) {
|
||||
epAccessLoggerMu.Lock()
|
||||
defer epAccessLoggerMu.Unlock()
|
||||
|
||||
if cfg == nil {
|
||||
epAccessLogger = nil
|
||||
return
|
||||
}
|
||||
|
||||
epAccessLogger, err = accesslog.NewFileAccessLogger(parent, cfg)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
logger.Debug().Msg("entrypoint access logger created")
|
||||
return
|
||||
}
|
||||
|
||||
func Handler(w http.ResponseWriter, r *http.Request) {
|
||||
mux, err := findRouteFunc(r.Host)
|
||||
if err == nil {
|
||||
if epAccessLogger != nil {
|
||||
epMiddlewareMu.Lock()
|
||||
if epAccessLogger != nil {
|
||||
w = gphttp.NewModifyResponseWriter(w, r, func(resp *http.Response) error {
|
||||
epAccessLogger.Log(r, resp)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
epMiddlewareMu.Unlock()
|
||||
}
|
||||
if epMiddleware != nil {
|
||||
epMiddlewareMu.Lock()
|
||||
if epMiddleware != nil {
|
||||
mid := epMiddleware
|
||||
epMiddlewareMu.Unlock()
|
||||
mid.ServeHTTP(mux.ServeHTTP, w, r)
|
||||
return
|
||||
}
|
||||
epMiddlewareMu.Unlock()
|
||||
}
|
||||
mux.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
// Why use StatusNotFound instead of StatusBadRequest or StatusBadGateway?
|
||||
// On nginx, when route for domain does not exist, it returns StatusBadGateway.
|
||||
// Then scraper / scanners will know the subdomain is invalid.
|
||||
// With StatusNotFound, they won't know whether it's the path, or the subdomain that is invalid.
|
||||
if served := middleware.ServeStaticErrorPageFile(w, r); !served {
|
||||
logger.Err(err).Str("method", r.Method).Str("url", r.URL.String()).Msg("request")
|
||||
errorPage, ok := errorpage.GetErrorPageByStatus(http.StatusNotFound)
|
||||
if ok {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if _, err := w.Write(errorPage); err != nil {
|
||||
logger.Err(err).Msg("failed to write error page")
|
||||
}
|
||||
} else {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func findRouteAnyDomain(host string) (route.HTTPRoute, error) {
|
||||
hostSplit := strutils.SplitRune(host, '.')
|
||||
target := hostSplit[0]
|
||||
|
||||
if r, ok := routes.GetHTTPRouteOrExact(target, host); ok {
|
||||
return r, nil
|
||||
}
|
||||
return nil, fmt.Errorf("%w: %s", ErrNoSuchRoute, target)
|
||||
}
|
||||
|
||||
func findRouteByDomains(domains []string) func(host string) (route.HTTPRoute, error) {
|
||||
return func(host string) (route.HTTPRoute, error) {
|
||||
for _, domain := range domains {
|
||||
if strings.HasSuffix(host, domain) {
|
||||
target := strings.TrimSuffix(host, domain)
|
||||
if r, ok := routes.GetHTTPRoute(target); ok {
|
||||
return r, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fallback to exact match
|
||||
if r, ok := routes.GetHTTPRoute(host); ok {
|
||||
return r, nil
|
||||
}
|
||||
return nil, fmt.Errorf("%w: %s", ErrNoSuchRoute, host)
|
||||
}
|
||||
}
|
||||
120
internal/entrypoint/entrypoint_test.go
Normal file
120
internal/entrypoint/entrypoint_test.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package entrypoint
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/yusing/go-proxy/internal/route"
|
||||
"github.com/yusing/go-proxy/internal/route/routes"
|
||||
. "github.com/yusing/go-proxy/internal/utils/testing"
|
||||
)
|
||||
|
||||
var r route.HTTPRoute
|
||||
|
||||
func run(t *testing.T, match []string, noMatch []string) {
|
||||
t.Helper()
|
||||
t.Cleanup(routes.TestClear)
|
||||
t.Cleanup(func() {
|
||||
SetFindRouteDomains(nil)
|
||||
})
|
||||
|
||||
for _, test := range match {
|
||||
t.Run(test, func(t *testing.T) {
|
||||
found, err := findRouteFunc(test)
|
||||
ExpectNoError(t, err)
|
||||
ExpectTrue(t, found == &r)
|
||||
})
|
||||
}
|
||||
|
||||
for _, test := range noMatch {
|
||||
t.Run(test, func(t *testing.T) {
|
||||
_, err := findRouteFunc(test)
|
||||
ExpectError(t, ErrNoSuchRoute, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindRouteAnyDomain(t *testing.T) {
|
||||
routes.SetHTTPRoute("app1", &r)
|
||||
|
||||
tests := []string{
|
||||
"app1.com",
|
||||
"app1.domain.com",
|
||||
"app1.sub.domain.com",
|
||||
}
|
||||
testsNoMatch := []string{
|
||||
"sub.app1.com",
|
||||
"app2.com",
|
||||
"app2.domain.com",
|
||||
"app2.sub.domain.com",
|
||||
}
|
||||
|
||||
run(t, tests, testsNoMatch)
|
||||
}
|
||||
|
||||
func TestFindRouteExactHostMatch(t *testing.T) {
|
||||
tests := []string{
|
||||
"app2.com",
|
||||
"app2.domain.com",
|
||||
"app2.sub.domain.com",
|
||||
}
|
||||
testsNoMatch := []string{
|
||||
"sub.app2.com",
|
||||
"app1.com",
|
||||
"app1.domain.com",
|
||||
"app1.sub.domain.com",
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
routes.SetHTTPRoute(test, &r)
|
||||
}
|
||||
|
||||
run(t, tests, testsNoMatch)
|
||||
}
|
||||
|
||||
func TestFindRouteByDomains(t *testing.T) {
|
||||
SetFindRouteDomains([]string{
|
||||
".domain.com",
|
||||
".sub.domain.com",
|
||||
})
|
||||
|
||||
routes.SetHTTPRoute("app1", &r)
|
||||
|
||||
tests := []string{
|
||||
"app1.domain.com",
|
||||
"app1.sub.domain.com",
|
||||
}
|
||||
testsNoMatch := []string{
|
||||
"sub.app1.com",
|
||||
"app1.com",
|
||||
"app1.domain.co",
|
||||
"app1.domain.com.hk",
|
||||
"app1.sub.domain.co",
|
||||
"app2.domain.com",
|
||||
"app2.sub.domain.com",
|
||||
}
|
||||
|
||||
run(t, tests, testsNoMatch)
|
||||
}
|
||||
|
||||
func TestFindRouteByDomainsExactMatch(t *testing.T) {
|
||||
SetFindRouteDomains([]string{
|
||||
".domain.com",
|
||||
".sub.domain.com",
|
||||
})
|
||||
|
||||
routes.SetHTTPRoute("app1.foo.bar", &r)
|
||||
|
||||
tests := []string{
|
||||
"app1.foo.bar", // exact match
|
||||
"app1.foo.bar.domain.com",
|
||||
"app1.foo.bar.sub.domain.com",
|
||||
}
|
||||
testsNoMatch := []string{
|
||||
"sub.app1.foo.bar",
|
||||
"sub.app1.foo.bar.com",
|
||||
"app1.domain.com",
|
||||
"app1.sub.domain.com",
|
||||
}
|
||||
|
||||
run(t, tests, testsNoMatch)
|
||||
}
|
||||
7
internal/entrypoint/logger.go
Normal file
7
internal/entrypoint/logger.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package entrypoint
|
||||
|
||||
import (
|
||||
"github.com/yusing/go-proxy/internal/logging"
|
||||
)
|
||||
|
||||
var logger = logging.With().Str("module", "entrypoint").Logger()
|
||||
48
internal/error/base.go
Normal file
48
internal/error/base.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package err
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// baseError is an immutable wrapper around an error.
|
||||
//
|
||||
//nolint:recvcheck
|
||||
type baseError struct {
|
||||
Err error `json:"err"`
|
||||
}
|
||||
|
||||
func (err *baseError) Unwrap() error {
|
||||
return err.Err
|
||||
}
|
||||
|
||||
func (err *baseError) Is(other error) bool {
|
||||
if other, ok := other.(*baseError); ok {
|
||||
return errors.Is(err.Err, other.Err)
|
||||
}
|
||||
return errors.Is(err.Err, other)
|
||||
}
|
||||
|
||||
func (err baseError) Subject(subject string) Error {
|
||||
err.Err = PrependSubject(subject, err.Err)
|
||||
return &err
|
||||
}
|
||||
|
||||
func (err *baseError) Subjectf(format string, args ...any) Error {
|
||||
if len(args) > 0 {
|
||||
return err.Subject(fmt.Sprintf(format, args...))
|
||||
}
|
||||
return err.Subject(format)
|
||||
}
|
||||
|
||||
func (err baseError) With(extra error) Error {
|
||||
return &nestedError{&err, []error{extra}}
|
||||
}
|
||||
|
||||
func (err baseError) Withf(format string, args ...any) Error {
|
||||
return &nestedError{&err, []error{fmt.Errorf(format, args...)}}
|
||||
}
|
||||
|
||||
func (err *baseError) Error() string {
|
||||
return err.Err.Error()
|
||||
}
|
||||
@@ -1,82 +1,124 @@
|
||||
package error
|
||||
package err
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type Builder struct {
|
||||
*builder
|
||||
}
|
||||
|
||||
type builder struct {
|
||||
message string
|
||||
errors []NestedError
|
||||
about string
|
||||
errs []error
|
||||
sync.Mutex
|
||||
}
|
||||
|
||||
func NewBuilder(format string, args ...any) Builder {
|
||||
return Builder{&builder{message: fmt.Sprintf(format, args...)}}
|
||||
func NewBuilder(about string) *Builder {
|
||||
return &Builder{about: about}
|
||||
}
|
||||
|
||||
// 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()
|
||||
func (b *Builder) About() string {
|
||||
if !b.HasError() {
|
||||
return ""
|
||||
}
|
||||
return b
|
||||
return b.about
|
||||
}
|
||||
|
||||
func (b Builder) AddE(err error) Builder {
|
||||
return b.Add(From(err))
|
||||
//go:inline
|
||||
func (b *Builder) HasError() bool {
|
||||
return len(b.errs) > 0
|
||||
}
|
||||
|
||||
func (b Builder) Addf(format string, args ...any) Builder {
|
||||
return b.Add(errorf(format, args...))
|
||||
}
|
||||
|
||||
func (b Builder) AddRangeE(errs ...error) Builder {
|
||||
for _, err := range errs {
|
||||
b.AddE(err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// Build builds a NestedError based on the errors collected in the Builder.
|
||||
//
|
||||
// If there are no errors in the Builder, it returns a Nil() NestedError.
|
||||
// Otherwise, it returns a NestedError with the message and the errors collected.
|
||||
//
|
||||
// Returns:
|
||||
// - NestedError: the built NestedError.
|
||||
func (b Builder) Build() NestedError {
|
||||
if len(b.errors) == 0 {
|
||||
func (b *Builder) error() Error {
|
||||
if !b.HasError() {
|
||||
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...)
|
||||
return &nestedError{Err: New(b.about), Extras: b.errs}
|
||||
}
|
||||
|
||||
func (b Builder) To(ptr *NestedError) {
|
||||
switch {
|
||||
case ptr == nil:
|
||||
return
|
||||
case *ptr == nil:
|
||||
*ptr = b.Build()
|
||||
func (b *Builder) Error() Error {
|
||||
if len(b.errs) == 1 {
|
||||
return From(b.errs[0])
|
||||
}
|
||||
return b.error()
|
||||
}
|
||||
|
||||
func (b *Builder) String() string {
|
||||
err := b.error()
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
return err.Error()
|
||||
}
|
||||
|
||||
// Add adds an error to the Builder.
|
||||
//
|
||||
// adding nil is no-op.
|
||||
func (b *Builder) Add(err error) *Builder {
|
||||
if err == nil {
|
||||
return b
|
||||
}
|
||||
|
||||
b.Lock()
|
||||
defer b.Unlock()
|
||||
|
||||
switch err := From(err).(type) {
|
||||
case *baseError:
|
||||
b.errs = append(b.errs, err.Err)
|
||||
case *nestedError:
|
||||
if err.Err == nil {
|
||||
b.errs = append(b.errs, err.Extras...)
|
||||
} else {
|
||||
b.errs = append(b.errs, err)
|
||||
}
|
||||
default:
|
||||
(*ptr).extras = append((*ptr).extras, *b.Build())
|
||||
panic("bug: should not reach here")
|
||||
}
|
||||
|
||||
return b
|
||||
}
|
||||
|
||||
func (b Builder) String() string {
|
||||
return b.Build().String()
|
||||
func (b *Builder) Adds(err string) *Builder {
|
||||
b.Lock()
|
||||
defer b.Unlock()
|
||||
b.errs = append(b.errs, newError(err))
|
||||
return b
|
||||
}
|
||||
|
||||
func (b Builder) HasError() bool {
|
||||
return len(b.errors) > 0
|
||||
func (b *Builder) Addf(format string, args ...any) *Builder {
|
||||
if len(args) > 0 {
|
||||
b.Lock()
|
||||
defer b.Unlock()
|
||||
b.errs = append(b.errs, fmt.Errorf(format, args...))
|
||||
} else {
|
||||
b.Adds(format)
|
||||
}
|
||||
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *Builder) AddFrom(other *Builder, flatten bool) *Builder {
|
||||
if other == nil || !other.HasError() {
|
||||
return b
|
||||
}
|
||||
|
||||
b.Lock()
|
||||
defer b.Unlock()
|
||||
if flatten {
|
||||
b.errs = append(b.errs, other.errs...)
|
||||
} else {
|
||||
b.errs = append(b.errs, other.error())
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *Builder) AddRange(errs ...error) *Builder {
|
||||
b.Lock()
|
||||
defer b.Unlock()
|
||||
|
||||
for _, err := range errs {
|
||||
if err != nil {
|
||||
b.errs = append(b.errs, err)
|
||||
}
|
||||
}
|
||||
|
||||
return b
|
||||
}
|
||||
|
||||
@@ -1,51 +1,55 @@
|
||||
package error
|
||||
package err_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
. "github.com/yusing/go-proxy/internal/error"
|
||||
. "github.com/yusing/go-proxy/internal/utils/testing"
|
||||
)
|
||||
|
||||
func TestBuilderEmpty(t *testing.T) {
|
||||
eb := NewBuilder("qwer")
|
||||
ExpectTrue(t, eb.Build() == nil)
|
||||
ExpectTrue(t, eb.Build().NoError())
|
||||
eb := NewBuilder("foo")
|
||||
ExpectTrue(t, errors.Is(eb.Error(), nil))
|
||||
ExpectFalse(t, eb.HasError())
|
||||
}
|
||||
|
||||
func TestBuilderAddNil(t *testing.T) {
|
||||
eb := NewBuilder("asdf")
|
||||
var err NestedError
|
||||
eb := NewBuilder("foo")
|
||||
var err Error
|
||||
for range 3 {
|
||||
eb.Add(nil)
|
||||
}
|
||||
for range 3 {
|
||||
eb.Add(err)
|
||||
}
|
||||
ExpectTrue(t, eb.Build() == nil)
|
||||
ExpectTrue(t, eb.Build().NoError())
|
||||
eb.AddRange(nil, nil, err)
|
||||
ExpectFalse(t, eb.HasError())
|
||||
ExpectTrue(t, eb.Error() == nil)
|
||||
}
|
||||
|
||||
func TestBuilderIs(t *testing.T) {
|
||||
eb := NewBuilder("foo")
|
||||
eb.Add(context.Canceled)
|
||||
eb.Add(io.ErrShortBuffer)
|
||||
ExpectTrue(t, eb.HasError())
|
||||
ExpectError(t, io.ErrShortBuffer, eb.Error())
|
||||
ExpectError(t, context.Canceled, eb.Error())
|
||||
}
|
||||
|
||||
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")))
|
||||
eb := NewBuilder("action failed")
|
||||
eb.Add(New("Action 1").Withf("Inner: 1").Withf("Inner: 2"))
|
||||
eb.Add(New("Action 2").Withf("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)
|
||||
}
|
||||
got := eb.String()
|
||||
expected := `action failed
|
||||
• Action 1
|
||||
• Inner: 1
|
||||
• Inner: 2
|
||||
• Action 2
|
||||
• Inner: 3`
|
||||
ExpectEqual(t, got, expected)
|
||||
}
|
||||
|
||||
@@ -1,294 +1,33 @@
|
||||
package error
|
||||
package err
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
type Error interface {
|
||||
error
|
||||
|
||||
type (
|
||||
NestedError = *NestedErrorImpl
|
||||
NestedErrorImpl struct {
|
||||
subject string
|
||||
err error
|
||||
extras []NestedErrorImpl
|
||||
}
|
||||
JSONNestedError struct {
|
||||
Subject string `json:"subject"`
|
||||
Err string `json:"error"`
|
||||
Extras []JSONNestedError `json:"extras,omitempty"`
|
||||
}
|
||||
)
|
||||
|
||||
func From(err error) NestedError {
|
||||
if IsNil(err) {
|
||||
return nil
|
||||
}
|
||||
return &NestedErrorImpl{err: err}
|
||||
// Is is a wrapper for errors.Is when there is no sub-error.
|
||||
//
|
||||
// When there are sub-errors, they will also be checked.
|
||||
Is(other error) bool
|
||||
// With appends a sub-error to the error.
|
||||
With(extra error) Error
|
||||
// Withf is a wrapper for With(fmt.Errorf(format, args...)).
|
||||
Withf(format string, args ...any) Error
|
||||
// Subject prepends the given subject with a colon and space to the error message.
|
||||
//
|
||||
// If there is already a subject in the error message, the subject will be
|
||||
// prepended to the existing subject with " > ".
|
||||
//
|
||||
// Subject empty string is ignored.
|
||||
Subject(subject string) Error
|
||||
// Subjectf is a wrapper for Subject(fmt.Sprintf(format, args...)).
|
||||
Subjectf(format string, args ...any) Error
|
||||
}
|
||||
|
||||
func FromJSON(data []byte) (NestedError, bool) {
|
||||
var j JSONNestedError
|
||||
if err := json.Unmarshal(data, &j); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
if j.Err == "" {
|
||||
return nil, false
|
||||
}
|
||||
extras := make([]NestedErrorImpl, len(j.Extras))
|
||||
for i, e := range j.Extras {
|
||||
extra, ok := fromJSONObject(e)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
extras[i] = *extra
|
||||
}
|
||||
return &NestedErrorImpl{
|
||||
subject: j.Subject,
|
||||
err: errors.New(j.Err),
|
||||
extras: extras,
|
||||
}, true
|
||||
}
|
||||
|
||||
// Check is a helper function that
|
||||
// convert (T, error) to (T, NestedError).
|
||||
func Check[T any](obj T, err error) (T, NestedError) {
|
||||
return obj, From(err)
|
||||
}
|
||||
|
||||
func Join(message string, err ...NestedError) NestedError {
|
||||
extras := make([]NestedErrorImpl, len(err))
|
||||
nErr := 0
|
||||
for i, e := range err {
|
||||
if e == nil {
|
||||
continue
|
||||
}
|
||||
extras[i] = *e
|
||||
nErr++
|
||||
}
|
||||
if nErr == 0 {
|
||||
return nil
|
||||
}
|
||||
return &NestedErrorImpl{
|
||||
err: errors.New(message),
|
||||
extras: extras,
|
||||
}
|
||||
}
|
||||
|
||||
func JoinE(message string, err ...error) NestedError {
|
||||
b := NewBuilder("%s", message)
|
||||
for _, e := range err {
|
||||
b.AddE(e)
|
||||
}
|
||||
return b.Build()
|
||||
}
|
||||
|
||||
func IsNil(err error) bool {
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func IsNotNil(err error) bool {
|
||||
return err != nil
|
||||
}
|
||||
|
||||
func (ne NestedError) String() string {
|
||||
var buf strings.Builder
|
||||
ne.writeToSB(&buf, 0, "")
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func (ne NestedError) Is(err error) bool {
|
||||
if ne == nil {
|
||||
return err == nil
|
||||
}
|
||||
// return errors.Is(ne.err, err)
|
||||
if errors.Is(ne.err, err) {
|
||||
return true
|
||||
}
|
||||
for _, e := range ne.extras {
|
||||
if e.Is(err) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (ne NestedError) IsNot(err error) bool {
|
||||
return !ne.Is(err)
|
||||
}
|
||||
|
||||
func (ne NestedError) Error() error {
|
||||
if ne == nil {
|
||||
return nil
|
||||
}
|
||||
return ne.buildError(0, "")
|
||||
}
|
||||
|
||||
func (ne NestedError) With(s any) NestedError {
|
||||
if ne == nil {
|
||||
return ne
|
||||
}
|
||||
var msg string
|
||||
switch ss := s.(type) {
|
||||
case nil:
|
||||
return ne
|
||||
case NestedError:
|
||||
return ne.withError(ss)
|
||||
case error:
|
||||
return ne.withError(From(ss))
|
||||
case string:
|
||||
msg = ss
|
||||
case fmt.Stringer:
|
||||
return ne.appendMsg(ss.String())
|
||||
default:
|
||||
return ne.appendMsg(fmt.Sprint(s))
|
||||
}
|
||||
return ne.withError(From(errors.New(msg)))
|
||||
}
|
||||
|
||||
func (ne NestedError) Extraf(format string, args ...any) NestedError {
|
||||
return ne.With(errorf(format, args...))
|
||||
}
|
||||
|
||||
func (ne NestedError) Subject(s any, sep ...string) NestedError {
|
||||
if ne == nil {
|
||||
return ne
|
||||
}
|
||||
var subject string
|
||||
switch ss := s.(type) {
|
||||
case string:
|
||||
subject = ss
|
||||
case fmt.Stringer:
|
||||
subject = ss.String()
|
||||
default:
|
||||
subject = fmt.Sprint(s)
|
||||
}
|
||||
switch {
|
||||
case ne.subject == "":
|
||||
ne.subject = subject
|
||||
case len(sep) > 0:
|
||||
ne.subject = fmt.Sprintf("%s%s%s", subject, sep[0], ne.subject)
|
||||
default:
|
||||
ne.subject = fmt.Sprintf("%s > %s", subject, ne.subject)
|
||||
}
|
||||
return ne
|
||||
}
|
||||
|
||||
func (ne NestedError) Subjectf(format string, args ...any) NestedError {
|
||||
if ne == nil {
|
||||
return ne
|
||||
}
|
||||
return ne.Subject(fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
func (ne NestedError) JSONObject() JSONNestedError {
|
||||
extras := make([]JSONNestedError, len(ne.extras))
|
||||
for i, e := range ne.extras {
|
||||
extras[i] = e.JSONObject()
|
||||
}
|
||||
return JSONNestedError{
|
||||
Subject: ne.subject,
|
||||
Err: ne.err.Error(),
|
||||
Extras: extras,
|
||||
}
|
||||
}
|
||||
|
||||
func (ne NestedError) JSON() []byte {
|
||||
b, err := json.MarshalIndent(ne.JSONObject(), "", " ")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func (ne NestedError) NoError() bool {
|
||||
return ne == nil
|
||||
}
|
||||
|
||||
func (ne NestedError) HasError() bool {
|
||||
return ne != nil
|
||||
}
|
||||
|
||||
func errorf(format string, args ...any) NestedError {
|
||||
return From(fmt.Errorf(format, args...))
|
||||
}
|
||||
|
||||
func fromJSONObject(obj JSONNestedError) (NestedError, bool) {
|
||||
data, err := json.Marshal(obj)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return FromJSON(data)
|
||||
}
|
||||
|
||||
func (ne NestedError) withError(err NestedError) NestedError {
|
||||
if ne != nil && err != nil {
|
||||
ne.extras = append(ne.extras, *err)
|
||||
}
|
||||
return ne
|
||||
}
|
||||
|
||||
func (ne NestedError) appendMsg(msg string) NestedError {
|
||||
if ne == nil {
|
||||
return nil
|
||||
}
|
||||
ne.err = fmt.Errorf("%w %s", ne.err, msg)
|
||||
return ne
|
||||
}
|
||||
|
||||
func (ne NestedError) writeToSB(sb *strings.Builder, level int, prefix string) {
|
||||
for range level {
|
||||
sb.WriteString(" ")
|
||||
}
|
||||
sb.WriteString(prefix)
|
||||
|
||||
if ne.NoError() {
|
||||
sb.WriteString("nil")
|
||||
return
|
||||
}
|
||||
|
||||
sb.WriteString(ne.err.Error())
|
||||
if ne.subject != "" {
|
||||
sb.WriteString(fmt.Sprintf(" for %q", ne.subject))
|
||||
}
|
||||
if len(ne.extras) > 0 {
|
||||
sb.WriteRune(':')
|
||||
for _, extra := range ne.extras {
|
||||
sb.WriteRune('\n')
|
||||
extra.writeToSB(sb, level+1, "- ")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (ne NestedError) buildError(level int, prefix string) error {
|
||||
var res error
|
||||
var sb strings.Builder
|
||||
|
||||
for range level {
|
||||
sb.WriteString(" ")
|
||||
}
|
||||
sb.WriteString(prefix)
|
||||
|
||||
if ne.NoError() {
|
||||
sb.WriteString("nil")
|
||||
return errors.New(sb.String())
|
||||
}
|
||||
|
||||
res = fmt.Errorf("%s%w", sb.String(), ne.err)
|
||||
sb.Reset()
|
||||
|
||||
if ne.subject != "" {
|
||||
sb.WriteString(fmt.Sprintf(" for %q", ne.subject))
|
||||
}
|
||||
if len(ne.extras) > 0 {
|
||||
sb.WriteRune(':')
|
||||
res = fmt.Errorf("%w%s", res, sb.String())
|
||||
for _, extra := range ne.extras {
|
||||
res = errors.Join(res, extra.buildError(level+1, "- "))
|
||||
}
|
||||
} else {
|
||||
res = fmt.Errorf("%w%s", res, sb.String())
|
||||
}
|
||||
return res
|
||||
// this makes JSON marshaling work,
|
||||
// as the builtin one doesn't.
|
||||
//
|
||||
//nolint:errname
|
||||
type errStr string
|
||||
|
||||
func (err errStr) Error() string {
|
||||
return string(err)
|
||||
}
|
||||
|
||||
@@ -1,107 +1,157 @@
|
||||
package error
|
||||
package err
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
. "github.com/yusing/go-proxy/internal/utils/testing"
|
||||
)
|
||||
|
||||
func TestBaseString(t *testing.T) {
|
||||
ExpectEqual(t, New("error").Error(), "error")
|
||||
}
|
||||
|
||||
func TestBaseWithSubject(t *testing.T) {
|
||||
err := New("error")
|
||||
withSubject := err.Subject("foo")
|
||||
withSubjectf := err.Subjectf("%s %s", "foo", "bar")
|
||||
|
||||
ExpectError(t, err, withSubject)
|
||||
ExpectEqual(t, withSubject.Error(), "foo: error")
|
||||
ExpectTrue(t, withSubject.Is(err))
|
||||
|
||||
ExpectError(t, err, withSubjectf)
|
||||
ExpectEqual(t, withSubjectf.Error(), "foo bar: error")
|
||||
ExpectTrue(t, withSubjectf.Is(err))
|
||||
}
|
||||
|
||||
func TestBaseWithExtra(t *testing.T) {
|
||||
err := New("error")
|
||||
extra := New("bar").Subject("baz")
|
||||
withExtra := err.With(extra)
|
||||
|
||||
ExpectTrue(t, withExtra.Is(extra))
|
||||
ExpectTrue(t, withExtra.Is(err))
|
||||
|
||||
ExpectTrue(t, errors.Is(withExtra, extra))
|
||||
ExpectTrue(t, errors.Is(withExtra, err))
|
||||
|
||||
ExpectTrue(t, strings.Contains(withExtra.Error(), err.Error()))
|
||||
ExpectTrue(t, strings.Contains(withExtra.Error(), extra.Error()))
|
||||
ExpectTrue(t, strings.Contains(withExtra.Error(), "baz"))
|
||||
}
|
||||
|
||||
func TestBaseUnwrap(t *testing.T) {
|
||||
err := errors.New("err")
|
||||
wrapped := From(err)
|
||||
|
||||
ExpectError(t, err, errors.Unwrap(wrapped))
|
||||
}
|
||||
|
||||
func TestNestedUnwrap(t *testing.T) {
|
||||
err := errors.New("err")
|
||||
err2 := New("err2")
|
||||
wrapped := From(err).Subject("foo").With(err2.Subject("bar"))
|
||||
|
||||
unwrapper, ok := wrapped.(interface{ Unwrap() []error })
|
||||
ExpectTrue(t, ok)
|
||||
|
||||
ExpectError(t, err, wrapped)
|
||||
ExpectError(t, err2, wrapped)
|
||||
ExpectEqual(t, len(unwrapper.Unwrap()), 2)
|
||||
}
|
||||
|
||||
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))
|
||||
from := errors.New("error")
|
||||
err := From(from)
|
||||
ExpectError(t, from, err)
|
||||
|
||||
ExpectTrue(t, Invalid("foo", "bar").Is(ErrInvalid))
|
||||
ExpectFalse(t, Invalid("foo", "bar").Is(ErrFailure))
|
||||
ExpectTrue(t, err.Is(from))
|
||||
ExpectFalse(t, err.Is(New("error")))
|
||||
|
||||
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))
|
||||
ExpectTrue(t, errors.Is(err.Subject("foo"), from))
|
||||
ExpectTrue(t, errors.Is(err.Withf("foo"), from))
|
||||
ExpectTrue(t, errors.Is(err.Subject("foo").Withf("bar"), from))
|
||||
}
|
||||
|
||||
func TestErrorNestedIs(t *testing.T) {
|
||||
var err NestedError
|
||||
ExpectTrue(t, err.Is(nil))
|
||||
func TestErrorImmutability(t *testing.T) {
|
||||
err := New("err")
|
||||
err2 := New("err2")
|
||||
|
||||
err = Failure("some reason")
|
||||
ExpectTrue(t, err.Is(ErrFailure))
|
||||
ExpectFalse(t, err.Is(ErrDuplicated))
|
||||
for range 3 {
|
||||
// t.Logf("%d: %v %T %s", i, errors.Unwrap(err), err, err)
|
||||
_ = err.Subject("foo")
|
||||
ExpectFalse(t, strings.Contains(err.Error(), "foo"))
|
||||
|
||||
err.With(Duplicated("something", ""))
|
||||
ExpectTrue(t, err.Is(ErrFailure))
|
||||
ExpectTrue(t, err.Is(ErrDuplicated))
|
||||
ExpectFalse(t, err.Is(ErrInvalid))
|
||||
}
|
||||
_ = err.With(err2)
|
||||
ExpectFalse(t, strings.Contains(err.Error(), "extra"))
|
||||
ExpectFalse(t, err.Is(err2))
|
||||
|
||||
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()
|
||||
err = err.Subject("bar").Withf("baz")
|
||||
ExpectTrue(t, err != nil)
|
||||
}
|
||||
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")
|
||||
err1 := New("err1")
|
||||
err2 := New("err2")
|
||||
|
||||
err3 := err1.With(err2)
|
||||
|
||||
ExpectTrue(t, err3.Is(err1))
|
||||
ExpectTrue(t, err3.Is(err2))
|
||||
|
||||
_ = err2.Subject("foo")
|
||||
|
||||
ExpectTrue(t, err3.Is(err1))
|
||||
ExpectTrue(t, err3.Is(err2))
|
||||
|
||||
// check if err3 is affected by err2.Subject
|
||||
ExpectFalse(t, strings.Contains(err3.Error(), "foo"))
|
||||
}
|
||||
|
||||
func TestErrorNested(t *testing.T) {
|
||||
inner := Failure("inner").
|
||||
With("1").
|
||||
With("1")
|
||||
inner2 := Failure("inner2").
|
||||
func TestErrorStringSimple(t *testing.T) {
|
||||
errFailure := New("generic failure")
|
||||
ne := errFailure.Subject("foo bar")
|
||||
ExpectEqual(t, ne.Error(), "foo bar: generic failure")
|
||||
ne = ne.Subject("baz")
|
||||
ExpectEqual(t, ne.Error(), "baz > foo bar: generic failure")
|
||||
}
|
||||
|
||||
func TestErrorStringNested(t *testing.T) {
|
||||
errFailure := New("generic failure")
|
||||
inner := errFailure.Subject("inner").
|
||||
Withf("1").
|
||||
Withf("1")
|
||||
inner2 := errFailure.Subject("inner2").
|
||||
Subject("action 2").
|
||||
With("2").
|
||||
With("2")
|
||||
inner3 := Failure("inner3").
|
||||
Withf("2").
|
||||
Withf("2")
|
||||
inner3 := errFailure.Subject("inner3").
|
||||
Subject("action 3").
|
||||
With("3").
|
||||
With("3")
|
||||
ne := Failure("foo").
|
||||
With("bar").
|
||||
With("baz").
|
||||
Withf("3").
|
||||
Withf("3")
|
||||
ne := errFailure.
|
||||
Subject("foo").
|
||||
Withf("bar").
|
||||
Withf("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)
|
||||
want := `foo: generic failure
|
||||
• bar
|
||||
• baz
|
||||
• inner: generic failure
|
||||
• 1
|
||||
• 1
|
||||
• inner: generic failure
|
||||
• 1
|
||||
• 1
|
||||
• action 2 > inner2: generic failure
|
||||
• 2
|
||||
• 2
|
||||
• action 3 > inner3: generic failure
|
||||
• 3
|
||||
• 3`
|
||||
ExpectEqual(t, ne.Error(), want)
|
||||
}
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
package error
|
||||
|
||||
import (
|
||||
stderrors "errors"
|
||||
"reflect"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrFailure = stderrors.New("failed")
|
||||
ErrInvalid = stderrors.New("invalid")
|
||||
ErrUnsupported = stderrors.New("unsupported")
|
||||
ErrUnexpected = stderrors.New("unexpected")
|
||||
ErrNotExists = stderrors.New("does not exist")
|
||||
ErrMissing = stderrors.New("missing")
|
||||
ErrDuplicated = stderrors.New("duplicated")
|
||||
ErrOutOfRange = stderrors.New("out of range")
|
||||
ErrTypeError = stderrors.New("type error")
|
||||
ErrTypeMismatch = stderrors.New("type mismatch")
|
||||
)
|
||||
|
||||
const fmtSubjectWhat = "%w %v: %q"
|
||||
|
||||
func Failure(what string) NestedError {
|
||||
return errorf("%s %w", what, ErrFailure)
|
||||
}
|
||||
|
||||
func FailedWhy(what string, why string) NestedError {
|
||||
return Failure(what).With(why)
|
||||
}
|
||||
|
||||
func FailWith(what string, err any) NestedError {
|
||||
return Failure(what).With(err)
|
||||
}
|
||||
|
||||
func Invalid(subject, what any) NestedError {
|
||||
return errorf(fmtSubjectWhat, ErrInvalid, subject, what)
|
||||
}
|
||||
|
||||
func Unsupported(subject, what any) NestedError {
|
||||
return errorf(fmtSubjectWhat, ErrUnsupported, subject, what)
|
||||
}
|
||||
|
||||
func Unexpected(subject, what any) NestedError {
|
||||
return errorf(fmtSubjectWhat, ErrUnexpected, subject, what)
|
||||
}
|
||||
|
||||
func UnexpectedError(err error) NestedError {
|
||||
return errorf("%w error: %w", ErrUnexpected, err)
|
||||
}
|
||||
|
||||
func NotExist(subject, what any) NestedError {
|
||||
return errorf("%v %w: %v", subject, ErrNotExists, what)
|
||||
}
|
||||
|
||||
func Missing(subject any) NestedError {
|
||||
return errorf("%w %v", ErrMissing, subject)
|
||||
}
|
||||
|
||||
func Duplicated(subject, what any) NestedError {
|
||||
return errorf("%w %v: %v", ErrDuplicated, subject, what)
|
||||
}
|
||||
|
||||
func OutOfRange(subject any, value any) NestedError {
|
||||
return errorf("%v %w: %v", subject, ErrOutOfRange, value)
|
||||
}
|
||||
|
||||
func TypeError(subject any, from, to reflect.Type) NestedError {
|
||||
return errorf("%v %w: %s -> %s\n", subject, ErrTypeError, from, to)
|
||||
}
|
||||
|
||||
func TypeError2(subject any, from, to reflect.Value) NestedError {
|
||||
return TypeError(subject, from.Type(), to.Type())
|
||||
}
|
||||
|
||||
func TypeMismatch[Expect any](value any) NestedError {
|
||||
return errorf("%w: expect %s got %T", ErrTypeMismatch, reflect.TypeFor[Expect](), value)
|
||||
}
|
||||
43
internal/error/log.go
Normal file
43
internal/error/log.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package err
|
||||
|
||||
import (
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/yusing/go-proxy/internal/logging"
|
||||
)
|
||||
|
||||
func getLogger(logger ...*zerolog.Logger) *zerolog.Logger {
|
||||
if len(logger) > 0 {
|
||||
return logger[0]
|
||||
}
|
||||
return logging.GetLogger()
|
||||
}
|
||||
|
||||
//go:inline
|
||||
func LogFatal(msg string, err error, logger ...*zerolog.Logger) {
|
||||
getLogger(logger...).Fatal().Msg(err.Error())
|
||||
}
|
||||
|
||||
//go:inline
|
||||
func LogError(msg string, err error, logger ...*zerolog.Logger) {
|
||||
getLogger(logger...).Error().Msg(err.Error())
|
||||
}
|
||||
|
||||
//go:inline
|
||||
func LogWarn(msg string, err error, logger ...*zerolog.Logger) {
|
||||
getLogger(logger...).Warn().Msg(err.Error())
|
||||
}
|
||||
|
||||
//go:inline
|
||||
func LogPanic(msg string, err error, logger ...*zerolog.Logger) {
|
||||
getLogger(logger...).Panic().Msg(err.Error())
|
||||
}
|
||||
|
||||
//go:inline
|
||||
func LogInfo(msg string, err error, logger ...*zerolog.Logger) {
|
||||
getLogger(logger...).Info().Msg(err.Error())
|
||||
}
|
||||
|
||||
//go:inline
|
||||
func LogDebug(msg string, err error, logger ...*zerolog.Logger) {
|
||||
getLogger(logger...).Debug().Msg(err.Error())
|
||||
}
|
||||
115
internal/error/nested_error.go
Normal file
115
internal/error/nested_error.go
Normal file
@@ -0,0 +1,115 @@
|
||||
package err
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/yusing/go-proxy/internal/utils/strutils"
|
||||
)
|
||||
|
||||
//nolint:recvcheck
|
||||
type nestedError struct {
|
||||
Err error `json:"err"`
|
||||
Extras []error `json:"extras"`
|
||||
}
|
||||
|
||||
func (err nestedError) Subject(subject string) Error {
|
||||
if err.Err == nil {
|
||||
err.Err = newError(subject)
|
||||
} else {
|
||||
err.Err = PrependSubject(subject, err.Err)
|
||||
}
|
||||
return &err
|
||||
}
|
||||
|
||||
func (err *nestedError) Subjectf(format string, args ...any) Error {
|
||||
if len(args) > 0 {
|
||||
return err.Subject(fmt.Sprintf(format, args...))
|
||||
}
|
||||
return err.Subject(format)
|
||||
}
|
||||
|
||||
func (err nestedError) With(extra error) Error {
|
||||
if extra != nil {
|
||||
err.Extras = append(err.Extras, extra)
|
||||
}
|
||||
return &err
|
||||
}
|
||||
|
||||
func (err nestedError) Withf(format string, args ...any) Error {
|
||||
if len(args) > 0 {
|
||||
err.Extras = append(err.Extras, fmt.Errorf(format, args...))
|
||||
} else {
|
||||
err.Extras = append(err.Extras, newError(format))
|
||||
}
|
||||
return &err
|
||||
}
|
||||
|
||||
func (err *nestedError) Unwrap() []error {
|
||||
if err.Err == nil {
|
||||
if len(err.Extras) == 0 {
|
||||
return nil
|
||||
}
|
||||
return err.Extras
|
||||
}
|
||||
return append([]error{err.Err}, err.Extras...)
|
||||
}
|
||||
|
||||
func (err *nestedError) Is(other error) bool {
|
||||
if errors.Is(err.Err, other) {
|
||||
return true
|
||||
}
|
||||
for _, e := range err.Extras {
|
||||
if errors.Is(e, other) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (err *nestedError) Error() string {
|
||||
if err == nil {
|
||||
return makeLine("<nil>", 0)
|
||||
}
|
||||
|
||||
lines := make([]string, 0, 1+len(err.Extras))
|
||||
if err.Err != nil {
|
||||
lines = append(lines, makeLine(err.Err.Error(), 0))
|
||||
}
|
||||
if extras := makeLines(err.Extras, 1); len(extras) > 0 {
|
||||
lines = append(lines, extras...)
|
||||
}
|
||||
return strutils.JoinLines(lines)
|
||||
}
|
||||
|
||||
//go:inline
|
||||
func makeLine(err string, level int) string {
|
||||
const bulletPrefix = "• "
|
||||
const spaces = " "
|
||||
|
||||
if level == 0 {
|
||||
return err
|
||||
}
|
||||
return spaces[:2*level] + bulletPrefix + err
|
||||
}
|
||||
|
||||
func makeLines(errs []error, level int) []string {
|
||||
if len(errs) == 0 {
|
||||
return nil
|
||||
}
|
||||
lines := make([]string, 0, len(errs))
|
||||
for _, err := range errs {
|
||||
switch err := From(err).(type) {
|
||||
case *nestedError:
|
||||
if err.Err != nil {
|
||||
lines = append(lines, makeLine(err.Err.Error(), level))
|
||||
}
|
||||
if extras := makeLines(err.Extras, level+1); len(extras) > 0 {
|
||||
lines = append(lines, extras...)
|
||||
}
|
||||
default:
|
||||
lines = append(lines, makeLine(err.Error(), level))
|
||||
}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
56
internal/error/subject.go
Normal file
56
internal/error/subject.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package err
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/yusing/go-proxy/internal/utils/strutils/ansi"
|
||||
)
|
||||
|
||||
//nolint:errname
|
||||
type withSubject struct {
|
||||
Subject string `json:"subject"`
|
||||
Err error `json:"err"`
|
||||
}
|
||||
|
||||
const subjectSep = " > "
|
||||
|
||||
func highlight(subject string) string {
|
||||
return ansi.HighlightRed + subject + ansi.Reset
|
||||
}
|
||||
|
||||
func PrependSubject(subject string, err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
//nolint:errorlint
|
||||
switch err := err.(type) {
|
||||
case *withSubject:
|
||||
return err.Prepend(subject)
|
||||
case Error:
|
||||
return err.Subject(subject)
|
||||
}
|
||||
return &withSubject{subject, err}
|
||||
}
|
||||
|
||||
func (err *withSubject) Prepend(subject string) *withSubject {
|
||||
clone := *err
|
||||
if subject != "" {
|
||||
clone.Subject = subject + subjectSep + clone.Subject
|
||||
}
|
||||
return &clone
|
||||
}
|
||||
|
||||
func (err *withSubject) Is(other error) bool {
|
||||
return err.Err == other
|
||||
}
|
||||
|
||||
func (err *withSubject) Unwrap() error {
|
||||
return err.Err
|
||||
}
|
||||
|
||||
func (err *withSubject) Error() string {
|
||||
subjects := strings.Split(err.Subject, subjectSep)
|
||||
subjects[len(subjects)-1] = highlight(subjects[len(subjects)-1])
|
||||
return strings.Join(subjects, subjectSep) + ": " + err.Err.Error()
|
||||
}
|
||||
74
internal/error/utils.go
Normal file
74
internal/error/utils.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package err
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
func newError(message string) error {
|
||||
return errStr(message)
|
||||
}
|
||||
|
||||
func New(message string) Error {
|
||||
if message == "" {
|
||||
return nil
|
||||
}
|
||||
return &baseError{newError(message)}
|
||||
}
|
||||
|
||||
func Errorf(format string, args ...any) Error {
|
||||
return &baseError{fmt.Errorf(format, args...)}
|
||||
}
|
||||
|
||||
func From(err error) Error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
//nolint:errorlint
|
||||
switch err := err.(type) {
|
||||
case *baseError:
|
||||
return err
|
||||
case *nestedError:
|
||||
return err
|
||||
}
|
||||
return &baseError{err}
|
||||
}
|
||||
|
||||
func Must[T any](v T, err error) T {
|
||||
if err != nil {
|
||||
LogPanic("must failed", err)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func Join(errors ...error) Error {
|
||||
n := 0
|
||||
for _, err := range errors {
|
||||
if err != nil {
|
||||
n++
|
||||
}
|
||||
}
|
||||
if n == 0 {
|
||||
return nil
|
||||
}
|
||||
errs := make([]error, n)
|
||||
i := 0
|
||||
for _, err := range errors {
|
||||
if err != nil {
|
||||
errs[i] = err
|
||||
i++
|
||||
}
|
||||
}
|
||||
return &nestedError{Extras: errs}
|
||||
}
|
||||
|
||||
func Collect[T any, Err error, Arg any, Func func(Arg) (T, Err)](eb *Builder, fn Func, arg Arg) T {
|
||||
result, err := fn(arg)
|
||||
eb.Add(err)
|
||||
return result
|
||||
}
|
||||
|
||||
func Collect2[T any, Err error, Arg1 any, Arg2 any, Func func(Arg1, Arg2) (T, Err)](eb *Builder, fn Func, arg1 Arg1, arg2 Arg2) T {
|
||||
result, err := fn(arg1, arg2)
|
||||
eb.Add(err)
|
||||
return result
|
||||
}
|
||||
64
internal/homepage/categories.go
Normal file
64
internal/homepage/categories.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package homepage
|
||||
|
||||
// PredefinedCategories by alias or docker image name.
|
||||
var PredefinedCategories = map[string]string{
|
||||
"sonarr": "Torrenting",
|
||||
"radarr": "Torrenting",
|
||||
"bazarr": "Torrenting",
|
||||
"lidarr": "Torrenting",
|
||||
"readarr": "Torrenting",
|
||||
"prowlarr": "Torrenting",
|
||||
"watcharr": "Torrenting",
|
||||
"qbittorrent": "Torrenting",
|
||||
"qbit": "Torrenting",
|
||||
"qbt": "Torrenting",
|
||||
"transmission": "Torrenting",
|
||||
|
||||
"jellyfin": "Media",
|
||||
"jellyseerr": "Media",
|
||||
"emby": "Media",
|
||||
"plex": "Media",
|
||||
"navidrome": "Media",
|
||||
"immich": "Media",
|
||||
"tautulli": "Media",
|
||||
"nextcloud": "Media",
|
||||
"invidious": "Media",
|
||||
|
||||
"uptime": "Monitoring",
|
||||
"uptime-kuma": "Monitoring",
|
||||
"prometheus": "Monitoring",
|
||||
"grafana": "Monitoring",
|
||||
"netdata": "Monitoring",
|
||||
"changedetection.io": "Monitoring",
|
||||
"changedetection": "Monitoring",
|
||||
"influxdb": "Monitoring",
|
||||
"influx": "Monitoring",
|
||||
"dozzle": "Monitoring",
|
||||
|
||||
"adguardhome": "Networking",
|
||||
"adgh": "Networking",
|
||||
"adg": "Networking",
|
||||
"pihole": "Networking",
|
||||
"flaresolverr": "Networking",
|
||||
|
||||
"homebridge": "Home Automation",
|
||||
"home-assistant": "Home Automation",
|
||||
|
||||
"dockge": "Container Management",
|
||||
"portainer-ce": "Container Management",
|
||||
"portainer-be": "Container Management",
|
||||
|
||||
"rss": "RSS",
|
||||
"rsshub": "RSS",
|
||||
"rss-bridge": "RSS",
|
||||
"miniflux": "RSS",
|
||||
"freshrss": "RSS",
|
||||
|
||||
"paperless": "Documents",
|
||||
"paperless-ngx": "Documents",
|
||||
"s-pdf": "Documents",
|
||||
|
||||
"minio": "Storage",
|
||||
"filebrowser": "Storage",
|
||||
"rclone": "Storage",
|
||||
}
|
||||
@@ -1,24 +1,26 @@
|
||||
package homepage
|
||||
|
||||
type (
|
||||
HomePageConfig map[string]HomePageCategory
|
||||
HomePageCategory []*HomePageItem
|
||||
//nolint:recvcheck
|
||||
Config map[string]Category
|
||||
Category []*Item
|
||||
|
||||
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"`
|
||||
Item struct {
|
||||
Show bool `json:"show"`
|
||||
Name string `json:"name"` // display name
|
||||
Icon string `json:"icon"`
|
||||
URL string `json:"url"` // alias + domain
|
||||
Category string `json:"category"`
|
||||
Description string `json:"description" aliases:"desc"`
|
||||
WidgetConfig map[string]any `json:"widget_config" aliases:"widget"`
|
||||
|
||||
SourceType string `yaml:"-" json:"source_type"`
|
||||
AltURL string `yaml:"-" json:"alt_url"` // original proxy target
|
||||
Alias string `json:"alias"` // proxy alias
|
||||
SourceType string `json:"source_type"`
|
||||
AltURL string `json:"alt_url"` // original proxy target
|
||||
}
|
||||
)
|
||||
|
||||
func (item *HomePageItem) IsEmpty() bool {
|
||||
func (item *Item) IsEmpty() bool {
|
||||
return item == nil || (item.Name == "" &&
|
||||
item.Icon == "" &&
|
||||
item.URL == "" &&
|
||||
@@ -27,17 +29,17 @@ func (item *HomePageItem) IsEmpty() bool {
|
||||
len(item.WidgetConfig) == 0)
|
||||
}
|
||||
|
||||
func NewHomePageConfig() HomePageConfig {
|
||||
return HomePageConfig(make(map[string]HomePageCategory))
|
||||
func NewHomePageConfig() Config {
|
||||
return Config(make(map[string]Category))
|
||||
}
|
||||
|
||||
func (c *HomePageConfig) Clear() {
|
||||
*c = make(HomePageConfig)
|
||||
func (c *Config) Clear() {
|
||||
*c = make(Config)
|
||||
}
|
||||
|
||||
func (c HomePageConfig) Add(item *HomePageItem) {
|
||||
func (c Config) Add(item *Item) {
|
||||
if c[item.Category] == nil {
|
||||
c[item.Category] = make(HomePageCategory, 0)
|
||||
c[item.Category] = make(Category, 0)
|
||||
}
|
||||
c[item.Category] = append(c[item.Category], item)
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ func ListAvailableIcons() ([]string, error) {
|
||||
icons = append(icons, content.Path)
|
||||
}
|
||||
}
|
||||
err = utils.SaveJSON(iconsCachePath, &icons, 0o644).Error()
|
||||
err = utils.SaveJSON(iconsCachePath, &icons, 0o644)
|
||||
if err != nil {
|
||||
log.Print("error saving cache", err)
|
||||
}
|
||||
|
||||
72
internal/logging/logging.go
Normal file
72
internal/logging/logging.go
Normal file
@@ -0,0 +1,72 @@
|
||||
//nolint:zerologlint
|
||||
package logging
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/yusing/go-proxy/internal/common"
|
||||
"github.com/yusing/go-proxy/internal/utils/strutils"
|
||||
)
|
||||
|
||||
var logger zerolog.Logger
|
||||
|
||||
func init() {
|
||||
var timeFmt string
|
||||
var level zerolog.Level
|
||||
var exclude []string
|
||||
|
||||
switch {
|
||||
case common.IsTrace:
|
||||
timeFmt = "04:05"
|
||||
level = zerolog.TraceLevel
|
||||
case common.IsDebug:
|
||||
timeFmt = "01-02 15:04"
|
||||
level = zerolog.DebugLevel
|
||||
default:
|
||||
timeFmt = "01-02 15:04"
|
||||
level = zerolog.InfoLevel
|
||||
exclude = []string{"module"}
|
||||
}
|
||||
|
||||
prefixLength := len(timeFmt) + 5 // level takes 3 + 2 spaces
|
||||
prefix := strings.Repeat(" ", prefixLength)
|
||||
|
||||
logger = zerolog.New(
|
||||
zerolog.ConsoleWriter{
|
||||
Out: os.Stderr,
|
||||
TimeFormat: timeFmt,
|
||||
FieldsExclude: exclude,
|
||||
FormatMessage: func(msgI interface{}) string { // pad spaces for each line
|
||||
msg := msgI.(string)
|
||||
lines := strutils.SplitRune(msg, '\n')
|
||||
if len(lines) == 1 {
|
||||
return msg
|
||||
}
|
||||
for i := 1; i < len(lines); i++ {
|
||||
lines[i] = prefix + lines[i]
|
||||
}
|
||||
return strutils.JoinRune(lines, '\n')
|
||||
},
|
||||
},
|
||||
).Level(level).With().Timestamp().Logger()
|
||||
}
|
||||
|
||||
func DiscardLogger() { zerolog.SetGlobalLevel(zerolog.Disabled) }
|
||||
|
||||
func AddHook(h zerolog.Hook) { logger = logger.Hook(h) }
|
||||
|
||||
func GetLogger() *zerolog.Logger { return &logger }
|
||||
func With() zerolog.Context { return logger.With() }
|
||||
|
||||
func WithLevel(level zerolog.Level) *zerolog.Event { return logger.WithLevel(level) }
|
||||
|
||||
func Info() *zerolog.Event { return logger.Info() }
|
||||
func Warn() *zerolog.Event { return logger.Warn() }
|
||||
func Error() *zerolog.Event { return logger.Error() }
|
||||
func Err(err error) *zerolog.Event { return logger.Err(err) }
|
||||
func Debug() *zerolog.Event { return logger.Debug() }
|
||||
func Fatal() *zerolog.Event { return logger.Fatal() }
|
||||
func Panic() *zerolog.Event { return logger.Panic() }
|
||||
func Trace() *zerolog.Event { return logger.Trace() }
|
||||
13
internal/metrics/http_handler.go
Normal file
13
internal/metrics/http_handler.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
)
|
||||
|
||||
func NewHandler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("/metrics", promhttp.Handler())
|
||||
return mux
|
||||
}
|
||||
36
internal/metrics/labels.go
Normal file
36
internal/metrics/labels.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package metrics
|
||||
|
||||
import "github.com/prometheus/client_golang/prometheus"
|
||||
|
||||
type (
|
||||
HTTPRouteMetricLabels struct {
|
||||
Service, Method, Host, Visitor, Path string
|
||||
}
|
||||
StreamRouteMetricLabels struct {
|
||||
Service, Visitor string
|
||||
}
|
||||
HealthMetricLabels string
|
||||
)
|
||||
|
||||
func (lbl *HTTPRouteMetricLabels) toPromLabels() prometheus.Labels {
|
||||
return prometheus.Labels{
|
||||
"service": lbl.Service,
|
||||
"method": lbl.Method,
|
||||
"host": lbl.Host,
|
||||
"visitor": lbl.Visitor,
|
||||
"path": lbl.Path,
|
||||
}
|
||||
}
|
||||
|
||||
func (lbl *StreamRouteMetricLabels) toPromLabels() prometheus.Labels {
|
||||
return prometheus.Labels{
|
||||
"service": lbl.Service,
|
||||
"visitor": lbl.Visitor,
|
||||
}
|
||||
}
|
||||
|
||||
func (lbl HealthMetricLabels) toPromLabels() prometheus.Labels {
|
||||
return prometheus.Labels{
|
||||
"service": string(lbl),
|
||||
}
|
||||
}
|
||||
89
internal/metrics/metric.go
Normal file
89
internal/metrics/metric.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package metrics
|
||||
|
||||
import "github.com/prometheus/client_golang/prometheus"
|
||||
|
||||
type (
|
||||
Counter struct {
|
||||
mv *prometheus.CounterVec
|
||||
collector prometheus.Counter
|
||||
}
|
||||
Gauge struct {
|
||||
mv *prometheus.GaugeVec
|
||||
collector prometheus.Gauge
|
||||
}
|
||||
Labels interface {
|
||||
toPromLabels() prometheus.Labels
|
||||
}
|
||||
)
|
||||
|
||||
func NewCounter(opts prometheus.CounterOpts, labels ...string) *Counter {
|
||||
m := &Counter{
|
||||
mv: prometheus.NewCounterVec(opts, labels),
|
||||
}
|
||||
if len(labels) == 0 {
|
||||
m.collector = m.mv.WithLabelValues()
|
||||
m.collector.Add(0)
|
||||
}
|
||||
prometheus.MustRegister(m)
|
||||
return m
|
||||
}
|
||||
|
||||
func NewGauge(opts prometheus.GaugeOpts, labels ...string) *Gauge {
|
||||
m := &Gauge{
|
||||
mv: prometheus.NewGaugeVec(opts, labels),
|
||||
}
|
||||
if len(labels) == 0 {
|
||||
m.collector = m.mv.WithLabelValues()
|
||||
m.collector.Set(0)
|
||||
}
|
||||
prometheus.MustRegister(m)
|
||||
return m
|
||||
}
|
||||
|
||||
func (c *Counter) Collect(ch chan<- prometheus.Metric) {
|
||||
c.mv.Collect(ch)
|
||||
}
|
||||
|
||||
func (c *Counter) Describe(ch chan<- *prometheus.Desc) {
|
||||
c.mv.Describe(ch)
|
||||
}
|
||||
|
||||
func (c *Counter) Inc() {
|
||||
c.collector.Inc()
|
||||
}
|
||||
|
||||
func (c *Counter) With(l Labels) *Counter {
|
||||
return &Counter{mv: c.mv, collector: c.mv.With(l.toPromLabels())}
|
||||
}
|
||||
|
||||
func (c *Counter) Delete(l Labels) {
|
||||
c.mv.Delete(l.toPromLabels())
|
||||
}
|
||||
|
||||
func (c *Counter) Reset() {
|
||||
c.mv.Reset()
|
||||
}
|
||||
|
||||
func (g *Gauge) Collect(ch chan<- prometheus.Metric) {
|
||||
g.mv.Collect(ch)
|
||||
}
|
||||
|
||||
func (g *Gauge) Describe(ch chan<- *prometheus.Desc) {
|
||||
g.mv.Describe(ch)
|
||||
}
|
||||
|
||||
func (g *Gauge) Set(v float64) {
|
||||
g.collector.Set(v)
|
||||
}
|
||||
|
||||
func (g *Gauge) With(l Labels) *Gauge {
|
||||
return &Gauge{mv: g.mv, collector: g.mv.With(l.toPromLabels())}
|
||||
}
|
||||
|
||||
func (g *Gauge) Delete(l Labels) {
|
||||
g.mv.Delete(l.toPromLabels())
|
||||
}
|
||||
|
||||
func (g *Gauge) Reset() {
|
||||
g.mv.Reset()
|
||||
}
|
||||
105
internal/metrics/metrics.go
Normal file
105
internal/metrics/metrics.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/yusing/go-proxy/internal/common"
|
||||
)
|
||||
|
||||
type (
|
||||
RouteMetrics struct {
|
||||
HTTPReqTotal,
|
||||
HTTP2xx3xx,
|
||||
HTTP4xx,
|
||||
HTTP5xx *Counter
|
||||
HTTPReqElapsed *Gauge
|
||||
}
|
||||
|
||||
ServiceMetrics struct {
|
||||
HealthStatus *Gauge
|
||||
}
|
||||
)
|
||||
|
||||
var (
|
||||
rm RouteMetrics
|
||||
sm ServiceMetrics
|
||||
)
|
||||
|
||||
const (
|
||||
routerNamespace = "router"
|
||||
routerHTTPSubsystem = "http"
|
||||
|
||||
serviceNamespace = "service"
|
||||
)
|
||||
|
||||
func GetRouteMetrics() *RouteMetrics {
|
||||
return &rm
|
||||
}
|
||||
|
||||
func GetServiceMetrics() *ServiceMetrics {
|
||||
return &sm
|
||||
}
|
||||
|
||||
func (rm *RouteMetrics) UnregisterService(service string) {
|
||||
lbls := &HTTPRouteMetricLabels{Service: service}
|
||||
rm.HTTP2xx3xx.Delete(lbls)
|
||||
rm.HTTP4xx.Delete(lbls)
|
||||
rm.HTTP5xx.Delete(lbls)
|
||||
rm.HTTPReqElapsed.Delete(lbls)
|
||||
}
|
||||
|
||||
func init() {
|
||||
if !common.PrometheusEnabled {
|
||||
return
|
||||
}
|
||||
initRouteMetrics()
|
||||
initServiceMetrics()
|
||||
}
|
||||
|
||||
func initRouteMetrics() {
|
||||
lbls := []string{"service", "method", "host", "visitor", "path"}
|
||||
partitionsHelp := ", partitioned by " + strings.Join(lbls, ", ")
|
||||
rm = RouteMetrics{
|
||||
HTTPReqTotal: NewCounter(prometheus.CounterOpts{
|
||||
Namespace: routerNamespace,
|
||||
Subsystem: routerHTTPSubsystem,
|
||||
Name: "req_total",
|
||||
Help: "How many requests processed in total",
|
||||
}),
|
||||
HTTP2xx3xx: NewCounter(prometheus.CounterOpts{
|
||||
Namespace: routerNamespace,
|
||||
Subsystem: routerHTTPSubsystem,
|
||||
Name: "req_ok_count",
|
||||
Help: "How many 2xx-3xx requests processed" + partitionsHelp,
|
||||
}, lbls...),
|
||||
HTTP4xx: NewCounter(prometheus.CounterOpts{
|
||||
Namespace: routerNamespace,
|
||||
Subsystem: routerHTTPSubsystem,
|
||||
Name: "req_4xx_count",
|
||||
Help: "How many 4xx requests processed" + partitionsHelp,
|
||||
}, lbls...),
|
||||
HTTP5xx: NewCounter(prometheus.CounterOpts{
|
||||
Namespace: routerNamespace,
|
||||
Subsystem: routerHTTPSubsystem,
|
||||
Name: "req_5xx_count",
|
||||
Help: "How many 5xx requests processed" + partitionsHelp,
|
||||
}, lbls...),
|
||||
HTTPReqElapsed: NewGauge(prometheus.GaugeOpts{
|
||||
Namespace: routerNamespace,
|
||||
Subsystem: routerHTTPSubsystem,
|
||||
Name: "req_elapsed_ms",
|
||||
Help: "How long it took to process the request and respond a status code" + partitionsHelp,
|
||||
}, lbls...),
|
||||
}
|
||||
}
|
||||
|
||||
func initServiceMetrics() {
|
||||
sm = ServiceMetrics{
|
||||
HealthStatus: NewGauge(prometheus.GaugeOpts{
|
||||
Namespace: serviceNamespace,
|
||||
Name: "health_status",
|
||||
Help: "The health status of the router by service",
|
||||
}, "service"),
|
||||
}
|
||||
}
|
||||
26
internal/metrics/router_metrics.go
Normal file
26
internal/metrics/router_metrics.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/yusing/go-proxy/internal/common"
|
||||
)
|
||||
|
||||
func InitRouterMetrics(getRPsCount func() int, getStreamsCount func() int) {
|
||||
if !common.PrometheusEnabled {
|
||||
return
|
||||
}
|
||||
prometheus.MustRegister(prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
||||
Namespace: "entrypoint",
|
||||
Name: "num_reverse_proxies",
|
||||
Help: "The number of reverse proxies",
|
||||
}, func() float64 {
|
||||
return float64(getRPsCount())
|
||||
}))
|
||||
prometheus.MustRegister(prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
||||
Namespace: "entrypoint",
|
||||
Name: "num_streams",
|
||||
Help: "The number of streams",
|
||||
}, func() float64 {
|
||||
return float64(getStreamsCount())
|
||||
}))
|
||||
}
|
||||
174
internal/net/http/accesslog/access_logger.go
Normal file
174
internal/net/http/accesslog/access_logger.go
Normal file
@@ -0,0 +1,174 @@
|
||||
package accesslog
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
E "github.com/yusing/go-proxy/internal/error"
|
||||
"github.com/yusing/go-proxy/internal/logging"
|
||||
"github.com/yusing/go-proxy/internal/task"
|
||||
)
|
||||
|
||||
type (
|
||||
AccessLogger struct {
|
||||
task *task.Task
|
||||
cfg *Config
|
||||
io AccessLogIO
|
||||
|
||||
buf bytes.Buffer // buffer for non-flushed log
|
||||
bufMu sync.Mutex // protect buf
|
||||
bufPool sync.Pool // buffer pool for formatting a single log line
|
||||
|
||||
flushThreshold int
|
||||
|
||||
Formatter
|
||||
}
|
||||
|
||||
AccessLogIO interface {
|
||||
io.ReadWriteCloser
|
||||
io.ReadWriteSeeker
|
||||
io.ReaderAt
|
||||
sync.Locker
|
||||
Name() string // file name or path
|
||||
Truncate(size int64) error
|
||||
}
|
||||
|
||||
Formatter interface {
|
||||
// Format writes a log line to line without a trailing newline
|
||||
Format(line *bytes.Buffer, req *http.Request, res *http.Response)
|
||||
SetGetTimeNow(getTimeNow func() time.Time)
|
||||
}
|
||||
)
|
||||
|
||||
var logger = logging.With().Str("module", "accesslog").Logger()
|
||||
|
||||
func NewAccessLogger(parent task.Parent, io AccessLogIO, cfg *Config) *AccessLogger {
|
||||
l := &AccessLogger{
|
||||
task: parent.Subtask("accesslog"),
|
||||
cfg: cfg,
|
||||
io: io,
|
||||
}
|
||||
if cfg.BufferSize < 1024 {
|
||||
cfg.BufferSize = DefaultBufferSize
|
||||
}
|
||||
|
||||
fmt := CommonFormatter{cfg: &l.cfg.Fields, GetTimeNow: time.Now}
|
||||
switch l.cfg.Format {
|
||||
case FormatCommon:
|
||||
l.Formatter = &fmt
|
||||
case FormatCombined:
|
||||
l.Formatter = &CombinedFormatter{fmt}
|
||||
case FormatJSON:
|
||||
l.Formatter = &JSONFormatter{fmt}
|
||||
default: // should not happen, validation has done by validate tags
|
||||
panic("invalid access log format")
|
||||
}
|
||||
|
||||
l.flushThreshold = int(cfg.BufferSize * 4 / 5) // 80%
|
||||
l.buf.Grow(int(cfg.BufferSize))
|
||||
l.bufPool.New = func() any {
|
||||
return new(bytes.Buffer)
|
||||
}
|
||||
go l.start()
|
||||
return l
|
||||
}
|
||||
|
||||
func (l *AccessLogger) checkKeep(req *http.Request, res *http.Response) bool {
|
||||
if !l.cfg.Filters.StatusCodes.CheckKeep(req, res) ||
|
||||
!l.cfg.Filters.Method.CheckKeep(req, res) ||
|
||||
!l.cfg.Filters.Headers.CheckKeep(req, res) ||
|
||||
!l.cfg.Filters.CIDR.CheckKeep(req, res) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (l *AccessLogger) Log(req *http.Request, res *http.Response) {
|
||||
if !l.checkKeep(req, res) {
|
||||
return
|
||||
}
|
||||
|
||||
line := l.bufPool.Get().(*bytes.Buffer)
|
||||
l.Format(line, req, res)
|
||||
line.WriteRune('\n')
|
||||
|
||||
l.bufMu.Lock()
|
||||
l.buf.Write(line.Bytes())
|
||||
line.Reset()
|
||||
l.bufPool.Put(line)
|
||||
l.bufMu.Unlock()
|
||||
}
|
||||
|
||||
func (l *AccessLogger) LogError(req *http.Request, err error) {
|
||||
l.Log(req, &http.Response{StatusCode: http.StatusInternalServerError, Status: err.Error()})
|
||||
}
|
||||
|
||||
func (l *AccessLogger) Config() *Config {
|
||||
return l.cfg
|
||||
}
|
||||
|
||||
func (l *AccessLogger) Rotate() error {
|
||||
if l.cfg.Retention == nil {
|
||||
return nil
|
||||
}
|
||||
l.io.Lock()
|
||||
defer l.io.Unlock()
|
||||
|
||||
return l.cfg.Retention.rotateLogFile(l.io)
|
||||
}
|
||||
|
||||
func (l *AccessLogger) Flush(force bool) {
|
||||
if l.buf.Len() == 0 {
|
||||
return
|
||||
}
|
||||
if force || l.buf.Len() >= l.flushThreshold {
|
||||
l.bufMu.Lock()
|
||||
l.write(l.buf.Bytes())
|
||||
l.buf.Reset()
|
||||
l.bufMu.Unlock()
|
||||
logger.Debug().Msg("access log flushed to " + l.io.Name())
|
||||
}
|
||||
}
|
||||
|
||||
func (l *AccessLogger) handleErr(err error) {
|
||||
E.LogError("failed to write access log", err, &logger)
|
||||
}
|
||||
|
||||
func (l *AccessLogger) start() {
|
||||
defer func() {
|
||||
if l.buf.Len() > 0 { // flush last
|
||||
l.write(l.buf.Bytes())
|
||||
}
|
||||
l.io.Close()
|
||||
l.task.Finish(nil)
|
||||
}()
|
||||
|
||||
// periodic flush + threshold flush
|
||||
periodic := time.NewTicker(5 * time.Second)
|
||||
threshold := time.NewTicker(time.Second)
|
||||
defer periodic.Stop()
|
||||
defer threshold.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-l.task.Context().Done():
|
||||
return
|
||||
case <-periodic.C:
|
||||
l.Flush(true)
|
||||
case <-threshold.C:
|
||||
l.Flush(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (l *AccessLogger) write(data []byte) {
|
||||
l.io.Lock() // prevent concurrent write, i.e. log rotation, other access loggers
|
||||
_, err := l.io.Write(data)
|
||||
l.io.Unlock()
|
||||
if err != nil {
|
||||
l.handleErr(err)
|
||||
}
|
||||
}
|
||||
128
internal/net/http/accesslog/access_logger_test.go
Normal file
128
internal/net/http/accesslog/access_logger_test.go
Normal file
@@ -0,0 +1,128 @@
|
||||
package accesslog_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
E "github.com/yusing/go-proxy/internal/error"
|
||||
. "github.com/yusing/go-proxy/internal/net/http/accesslog"
|
||||
"github.com/yusing/go-proxy/internal/task"
|
||||
. "github.com/yusing/go-proxy/internal/utils/testing"
|
||||
)
|
||||
|
||||
const (
|
||||
remote = "192.168.1.1"
|
||||
host = "example.com"
|
||||
uri = "/?bar=baz&foo=bar"
|
||||
uriRedacted = "/?bar=" + RedactedValue + "&foo=" + RedactedValue
|
||||
referer = "https://www.google.com/"
|
||||
proto = "HTTP/1.1"
|
||||
ua = "Go-http-client/1.1"
|
||||
status = http.StatusOK
|
||||
contentLength = 100
|
||||
method = http.MethodGet
|
||||
)
|
||||
|
||||
var (
|
||||
testTask = task.RootTask("test", false)
|
||||
testURL = E.Must(url.Parse("http://" + host + uri))
|
||||
req = &http.Request{
|
||||
RemoteAddr: remote,
|
||||
Method: method,
|
||||
Proto: proto,
|
||||
Host: testURL.Host,
|
||||
URL: testURL,
|
||||
Header: http.Header{
|
||||
"User-Agent": []string{ua},
|
||||
"Referer": []string{referer},
|
||||
"Cookie": []string{
|
||||
"foo=bar",
|
||||
"bar=baz",
|
||||
},
|
||||
},
|
||||
}
|
||||
resp = &http.Response{
|
||||
StatusCode: status,
|
||||
ContentLength: contentLength,
|
||||
Header: http.Header{"Content-Type": []string{"text/plain"}},
|
||||
}
|
||||
)
|
||||
|
||||
func fmtLog(cfg *Config) (ts string, line string) {
|
||||
var buf bytes.Buffer
|
||||
|
||||
t := time.Now()
|
||||
logger := NewAccessLogger(testTask, nil, cfg)
|
||||
logger.Formatter.SetGetTimeNow(func() time.Time {
|
||||
return t
|
||||
})
|
||||
logger.Format(&buf, req, resp)
|
||||
return t.Format(LogTimeFormat), buf.String()
|
||||
}
|
||||
|
||||
func TestAccessLoggerCommon(t *testing.T) {
|
||||
config := DefaultConfig()
|
||||
config.Format = FormatCommon
|
||||
ts, log := fmtLog(config)
|
||||
ExpectEqual(t, log,
|
||||
fmt.Sprintf("%s %s - - [%s] \"%s %s %s\" %d %d",
|
||||
host, remote, ts, method, uri, proto, status, contentLength,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func TestAccessLoggerCombined(t *testing.T) {
|
||||
config := DefaultConfig()
|
||||
config.Format = FormatCombined
|
||||
ts, log := fmtLog(config)
|
||||
ExpectEqual(t, log,
|
||||
fmt.Sprintf("%s %s - - [%s] \"%s %s %s\" %d %d \"%s\" \"%s\"",
|
||||
host, remote, ts, method, uri, proto, status, contentLength, referer, ua,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func TestAccessLoggerRedactQuery(t *testing.T) {
|
||||
config := DefaultConfig()
|
||||
config.Format = FormatCommon
|
||||
config.Fields.Query.Default = FieldModeRedact
|
||||
ts, log := fmtLog(config)
|
||||
ExpectEqual(t, log,
|
||||
fmt.Sprintf("%s %s - - [%s] \"%s %s %s\" %d %d",
|
||||
host, remote, ts, method, uriRedacted, proto, status, contentLength,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func getJSONEntry(t *testing.T, config *Config) JSONLogEntry {
|
||||
t.Helper()
|
||||
config.Format = FormatJSON
|
||||
var entry JSONLogEntry
|
||||
_, log := fmtLog(config)
|
||||
err := json.Unmarshal([]byte(log), &entry)
|
||||
ExpectNoError(t, err)
|
||||
return entry
|
||||
}
|
||||
|
||||
func TestAccessLoggerJSON(t *testing.T) {
|
||||
config := DefaultConfig()
|
||||
entry := getJSONEntry(t, config)
|
||||
ExpectEqual(t, entry.IP, remote)
|
||||
ExpectEqual(t, entry.Method, method)
|
||||
ExpectEqual(t, entry.Scheme, "http")
|
||||
ExpectEqual(t, entry.Host, testURL.Host)
|
||||
ExpectEqual(t, entry.URI, testURL.RequestURI())
|
||||
ExpectEqual(t, entry.Protocol, proto)
|
||||
ExpectEqual(t, entry.Status, status)
|
||||
ExpectEqual(t, entry.ContentType, "text/plain")
|
||||
ExpectEqual(t, entry.Size, contentLength)
|
||||
ExpectEqual(t, entry.Referer, referer)
|
||||
ExpectEqual(t, entry.UserAgent, ua)
|
||||
ExpectEqual(t, len(entry.Headers), 0)
|
||||
ExpectEqual(t, len(entry.Cookies), 0)
|
||||
}
|
||||
57
internal/net/http/accesslog/config.go
Normal file
57
internal/net/http/accesslog/config.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package accesslog
|
||||
|
||||
import "github.com/yusing/go-proxy/internal/utils"
|
||||
|
||||
type (
|
||||
Format string
|
||||
Filters struct {
|
||||
StatusCodes LogFilter[*StatusCodeRange] `json:"status_codes"`
|
||||
Method LogFilter[HTTPMethod] `json:"method"`
|
||||
Host LogFilter[Host] `json:"host"`
|
||||
Headers LogFilter[*HTTPHeader] `json:"headers"` // header exists or header == value
|
||||
CIDR LogFilter[*CIDR] `json:"cidr"`
|
||||
}
|
||||
Fields struct {
|
||||
Headers FieldConfig `json:"headers"`
|
||||
Query FieldConfig `json:"query"`
|
||||
Cookies FieldConfig `json:"cookies"`
|
||||
}
|
||||
Config struct {
|
||||
BufferSize uint `json:"buffer_size" validate:"gte=1"`
|
||||
Format Format `json:"format" validate:"oneof=common combined json"`
|
||||
Path string `json:"path" validate:"required"`
|
||||
Filters Filters `json:"filters"`
|
||||
Fields Fields `json:"fields"`
|
||||
Retention *Retention `json:"retention"`
|
||||
}
|
||||
)
|
||||
|
||||
var (
|
||||
FormatCommon Format = "common"
|
||||
FormatCombined Format = "combined"
|
||||
FormatJSON Format = "json"
|
||||
)
|
||||
|
||||
const DefaultBufferSize = 64 * 1024 // 64KB
|
||||
|
||||
func DefaultConfig() *Config {
|
||||
return &Config{
|
||||
BufferSize: DefaultBufferSize,
|
||||
Format: FormatCombined,
|
||||
Fields: Fields{
|
||||
Headers: FieldConfig{
|
||||
Default: FieldModeDrop,
|
||||
},
|
||||
Query: FieldConfig{
|
||||
Default: FieldModeKeep,
|
||||
},
|
||||
Cookies: FieldConfig{
|
||||
Default: FieldModeDrop,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
utils.RegisterDefaultValueFactory(DefaultConfig)
|
||||
}
|
||||
53
internal/net/http/accesslog/config_test.go
Normal file
53
internal/net/http/accesslog/config_test.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package accesslog_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/yusing/go-proxy/internal/docker"
|
||||
. "github.com/yusing/go-proxy/internal/net/http/accesslog"
|
||||
"github.com/yusing/go-proxy/internal/utils"
|
||||
. "github.com/yusing/go-proxy/internal/utils/testing"
|
||||
)
|
||||
|
||||
func TestNewConfig(t *testing.T) {
|
||||
labels := map[string]string{
|
||||
"proxy.buffer_size": "10",
|
||||
"proxy.format": "combined",
|
||||
"proxy.path": "/tmp/access.log",
|
||||
"proxy.filters.status_codes.values": "200-299",
|
||||
"proxy.filters.method.values": "GET, POST",
|
||||
"proxy.filters.headers.values": "foo=bar, baz",
|
||||
"proxy.filters.headers.negative": "true",
|
||||
"proxy.filters.cidr.values": "192.168.10.0/24",
|
||||
"proxy.fields.headers.default": "keep",
|
||||
"proxy.fields.headers.config.foo": "redact",
|
||||
"proxy.fields.query.default": "drop",
|
||||
"proxy.fields.query.config.foo": "keep",
|
||||
"proxy.fields.cookies.default": "redact",
|
||||
"proxy.fields.cookies.config.foo": "keep",
|
||||
}
|
||||
parsed, err := docker.ParseLabels(labels)
|
||||
ExpectNoError(t, err)
|
||||
|
||||
var config Config
|
||||
err = utils.Deserialize(parsed, &config)
|
||||
ExpectNoError(t, err)
|
||||
|
||||
ExpectEqual(t, config.BufferSize, 10)
|
||||
ExpectEqual(t, config.Format, FormatCombined)
|
||||
ExpectEqual(t, config.Path, "/tmp/access.log")
|
||||
ExpectDeepEqual(t, config.Filters.StatusCodes.Values, []*StatusCodeRange{{Start: 200, End: 299}})
|
||||
ExpectEqual(t, len(config.Filters.Method.Values), 2)
|
||||
ExpectDeepEqual(t, config.Filters.Method.Values, []HTTPMethod{"GET", "POST"})
|
||||
ExpectEqual(t, len(config.Filters.Headers.Values), 2)
|
||||
ExpectDeepEqual(t, config.Filters.Headers.Values, []*HTTPHeader{{Key: "foo", Value: "bar"}, {Key: "baz", Value: ""}})
|
||||
ExpectTrue(t, config.Filters.Headers.Negative)
|
||||
ExpectEqual(t, len(config.Filters.CIDR.Values), 1)
|
||||
ExpectEqual(t, config.Filters.CIDR.Values[0].String(), "192.168.10.0/24")
|
||||
ExpectEqual(t, config.Fields.Headers.Default, FieldModeKeep)
|
||||
ExpectEqual(t, config.Fields.Headers.Config["foo"], FieldModeRedact)
|
||||
ExpectEqual(t, config.Fields.Query.Default, FieldModeDrop)
|
||||
ExpectEqual(t, config.Fields.Query.Config["foo"], FieldModeKeep)
|
||||
ExpectEqual(t, config.Fields.Cookies.Default, FieldModeRedact)
|
||||
ExpectEqual(t, config.Fields.Cookies.Config["foo"], FieldModeKeep)
|
||||
}
|
||||
103
internal/net/http/accesslog/fields.go
Normal file
103
internal/net/http/accesslog/fields.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package accesslog
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
type (
|
||||
FieldConfig struct {
|
||||
Default FieldMode `json:"default" validate:"oneof=keep drop redact"`
|
||||
Config map[string]FieldMode `json:"config" validate:"dive,oneof=keep drop redact"`
|
||||
}
|
||||
FieldMode string
|
||||
)
|
||||
|
||||
const (
|
||||
FieldModeKeep FieldMode = "keep"
|
||||
FieldModeDrop FieldMode = "drop"
|
||||
FieldModeRedact FieldMode = "redact"
|
||||
|
||||
RedactedValue = "REDACTED"
|
||||
)
|
||||
|
||||
func processMap[V any](cfg *FieldConfig, m map[string]V, redactedV V) map[string]V {
|
||||
if len(cfg.Config) == 0 {
|
||||
switch cfg.Default {
|
||||
case FieldModeKeep:
|
||||
return m
|
||||
case FieldModeDrop:
|
||||
return nil
|
||||
case FieldModeRedact:
|
||||
redacted := make(map[string]V)
|
||||
for k := range m {
|
||||
redacted[k] = redactedV
|
||||
}
|
||||
return redacted
|
||||
}
|
||||
}
|
||||
|
||||
if len(m) == 0 {
|
||||
return m
|
||||
}
|
||||
|
||||
newMap := make(map[string]V, len(m))
|
||||
for k := range m {
|
||||
var mode FieldMode
|
||||
var ok bool
|
||||
if mode, ok = cfg.Config[k]; !ok {
|
||||
mode = cfg.Default
|
||||
}
|
||||
switch mode {
|
||||
case FieldModeKeep:
|
||||
newMap[k] = m[k]
|
||||
case FieldModeRedact:
|
||||
newMap[k] = redactedV
|
||||
}
|
||||
}
|
||||
return newMap
|
||||
}
|
||||
|
||||
func processSlice[V any, VReturn any](cfg *FieldConfig, s []V, getKey func(V) string, convert func(V) VReturn, redact func(V) VReturn) map[string]VReturn {
|
||||
if len(s) == 0 ||
|
||||
len(cfg.Config) == 0 && cfg.Default == FieldModeDrop {
|
||||
return nil
|
||||
}
|
||||
newMap := make(map[string]VReturn, len(s))
|
||||
for _, v := range s {
|
||||
var mode FieldMode
|
||||
var ok bool
|
||||
k := getKey(v)
|
||||
if mode, ok = cfg.Config[k]; !ok {
|
||||
mode = cfg.Default
|
||||
}
|
||||
switch mode {
|
||||
case FieldModeKeep:
|
||||
newMap[k] = convert(v)
|
||||
case FieldModeRedact:
|
||||
newMap[k] = redact(v)
|
||||
}
|
||||
}
|
||||
return newMap
|
||||
}
|
||||
|
||||
func (cfg *FieldConfig) ProcessHeaders(headers http.Header) http.Header {
|
||||
return processMap(cfg, headers, []string{RedactedValue})
|
||||
}
|
||||
|
||||
func (cfg *FieldConfig) ProcessQuery(q url.Values) url.Values {
|
||||
return processMap(cfg, q, []string{RedactedValue})
|
||||
}
|
||||
|
||||
func (cfg *FieldConfig) ProcessCookies(cookies []*http.Cookie) map[string]string {
|
||||
return processSlice(cfg, cookies,
|
||||
func(c *http.Cookie) string {
|
||||
return c.Name
|
||||
},
|
||||
func(c *http.Cookie) string {
|
||||
return c.Value
|
||||
},
|
||||
func(c *http.Cookie) string {
|
||||
return RedactedValue
|
||||
})
|
||||
}
|
||||
96
internal/net/http/accesslog/fields_test.go
Normal file
96
internal/net/http/accesslog/fields_test.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package accesslog_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
. "github.com/yusing/go-proxy/internal/net/http/accesslog"
|
||||
. "github.com/yusing/go-proxy/internal/utils/testing"
|
||||
)
|
||||
|
||||
// Cookie header should be removed,
|
||||
// stored in JSONLogEntry.Cookies instead.
|
||||
func TestAccessLoggerJSONKeepHeaders(t *testing.T) {
|
||||
config := DefaultConfig()
|
||||
config.Fields.Headers.Default = FieldModeKeep
|
||||
entry := getJSONEntry(t, config)
|
||||
for k, v := range req.Header {
|
||||
if k != "Cookie" {
|
||||
ExpectDeepEqual(t, entry.Headers[k], v)
|
||||
}
|
||||
}
|
||||
|
||||
config.Fields.Headers.Config = map[string]FieldMode{
|
||||
"Referer": FieldModeRedact,
|
||||
"User-Agent": FieldModeDrop,
|
||||
}
|
||||
entry = getJSONEntry(t, config)
|
||||
ExpectDeepEqual(t, entry.Headers["Referer"], []string{RedactedValue})
|
||||
ExpectDeepEqual(t, entry.Headers["User-Agent"], nil)
|
||||
}
|
||||
|
||||
func TestAccessLoggerJSONDropHeaders(t *testing.T) {
|
||||
config := DefaultConfig()
|
||||
config.Fields.Headers.Default = FieldModeDrop
|
||||
entry := getJSONEntry(t, config)
|
||||
for k := range req.Header {
|
||||
ExpectDeepEqual(t, entry.Headers[k], nil)
|
||||
}
|
||||
|
||||
config.Fields.Headers.Config = map[string]FieldMode{
|
||||
"Referer": FieldModeKeep,
|
||||
"User-Agent": FieldModeRedact,
|
||||
}
|
||||
entry = getJSONEntry(t, config)
|
||||
ExpectDeepEqual(t, entry.Headers["Referer"], []string{req.Header.Get("Referer")})
|
||||
ExpectDeepEqual(t, entry.Headers["User-Agent"], []string{RedactedValue})
|
||||
}
|
||||
|
||||
func TestAccessLoggerJSONRedactHeaders(t *testing.T) {
|
||||
config := DefaultConfig()
|
||||
config.Fields.Headers.Default = FieldModeRedact
|
||||
entry := getJSONEntry(t, config)
|
||||
ExpectEqual(t, len(entry.Headers["Cookie"]), 0)
|
||||
for k := range req.Header {
|
||||
if k != "Cookie" {
|
||||
ExpectDeepEqual(t, entry.Headers[k], []string{RedactedValue})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccessLoggerJSONKeepCookies(t *testing.T) {
|
||||
config := DefaultConfig()
|
||||
config.Fields.Headers.Default = FieldModeKeep
|
||||
config.Fields.Cookies.Default = FieldModeKeep
|
||||
entry := getJSONEntry(t, config)
|
||||
ExpectEqual(t, len(entry.Headers["Cookie"]), 0)
|
||||
for _, cookie := range req.Cookies() {
|
||||
ExpectEqual(t, entry.Cookies[cookie.Name], cookie.Value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccessLoggerJSONRedactCookies(t *testing.T) {
|
||||
config := DefaultConfig()
|
||||
config.Fields.Headers.Default = FieldModeKeep
|
||||
config.Fields.Cookies.Default = FieldModeRedact
|
||||
entry := getJSONEntry(t, config)
|
||||
ExpectEqual(t, len(entry.Headers["Cookie"]), 0)
|
||||
for _, cookie := range req.Cookies() {
|
||||
ExpectEqual(t, entry.Cookies[cookie.Name], RedactedValue)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccessLoggerJSONDropQuery(t *testing.T) {
|
||||
config := DefaultConfig()
|
||||
config.Fields.Query.Default = FieldModeDrop
|
||||
entry := getJSONEntry(t, config)
|
||||
ExpectDeepEqual(t, entry.Query["foo"], nil)
|
||||
ExpectDeepEqual(t, entry.Query["bar"], nil)
|
||||
}
|
||||
|
||||
func TestAccessLoggerJSONRedactQuery(t *testing.T) {
|
||||
config := DefaultConfig()
|
||||
config.Fields.Query.Default = FieldModeRedact
|
||||
entry := getJSONEntry(t, config)
|
||||
ExpectDeepEqual(t, entry.Query["foo"], []string{RedactedValue})
|
||||
ExpectDeepEqual(t, entry.Query["bar"], []string{RedactedValue})
|
||||
}
|
||||
38
internal/net/http/accesslog/file_logger.go
Normal file
38
internal/net/http/accesslog/file_logger.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package accesslog
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
"github.com/yusing/go-proxy/internal/task"
|
||||
)
|
||||
|
||||
type File struct {
|
||||
*os.File
|
||||
sync.Mutex
|
||||
}
|
||||
|
||||
var (
|
||||
openedFiles = make(map[string]AccessLogIO)
|
||||
openedFilesMu sync.Mutex
|
||||
)
|
||||
|
||||
func NewFileAccessLogger(parent task.Parent, cfg *Config) (*AccessLogger, error) {
|
||||
openedFilesMu.Lock()
|
||||
|
||||
var io AccessLogIO
|
||||
if opened, ok := openedFiles[cfg.Path]; ok {
|
||||
io = opened
|
||||
} else {
|
||||
f, err := os.OpenFile(cfg.Path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("access log open error: %w", err)
|
||||
}
|
||||
io = &File{File: f}
|
||||
openedFiles[cfg.Path] = io
|
||||
}
|
||||
|
||||
openedFilesMu.Unlock()
|
||||
return NewAccessLogger(parent, io, cfg), nil
|
||||
}
|
||||
95
internal/net/http/accesslog/file_logger_test.go
Normal file
95
internal/net/http/accesslog/file_logger_test.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package accesslog
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
. "github.com/yusing/go-proxy/internal/utils/testing"
|
||||
|
||||
"github.com/yusing/go-proxy/internal/task"
|
||||
)
|
||||
|
||||
func TestConcurrentFileLoggersShareSameAccessLogIO(t *testing.T) {
|
||||
var wg sync.WaitGroup
|
||||
|
||||
cfg := DefaultConfig()
|
||||
cfg.Path = "test.log"
|
||||
parent := task.RootTask("test", false)
|
||||
|
||||
loggerCount := 10
|
||||
accessLogIOs := make([]AccessLogIO, loggerCount)
|
||||
|
||||
// make test log file
|
||||
file, err := os.Create(cfg.Path)
|
||||
ExpectNoError(t, err)
|
||||
file.Close()
|
||||
t.Cleanup(func() {
|
||||
ExpectNoError(t, os.Remove(cfg.Path))
|
||||
})
|
||||
|
||||
for i := range loggerCount {
|
||||
wg.Add(1)
|
||||
go func(index int) {
|
||||
defer wg.Done()
|
||||
logger, err := NewFileAccessLogger(parent, cfg)
|
||||
ExpectNoError(t, err)
|
||||
accessLogIOs[index] = logger.io
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
firstIO := accessLogIOs[0]
|
||||
for _, io := range accessLogIOs {
|
||||
ExpectEqual(t, io, firstIO)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentAccessLoggerLogAndFlush(t *testing.T) {
|
||||
var file MockFile
|
||||
|
||||
cfg := DefaultConfig()
|
||||
cfg.BufferSize = 1024
|
||||
parent := task.RootTask("test", false)
|
||||
|
||||
loggerCount := 5
|
||||
logCountPerLogger := 10
|
||||
loggers := make([]*AccessLogger, loggerCount)
|
||||
|
||||
for i := range loggerCount {
|
||||
loggers[i] = NewAccessLogger(parent, &file, cfg)
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
req, _ := http.NewRequest(http.MethodGet, "http://example.com", nil)
|
||||
resp := &http.Response{StatusCode: http.StatusOK}
|
||||
|
||||
for _, logger := range loggers {
|
||||
wg.Add(1)
|
||||
go func(l *AccessLogger) {
|
||||
defer wg.Done()
|
||||
parallelLog(l, req, resp, logCountPerLogger)
|
||||
l.Flush(true)
|
||||
}(logger)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
expected := loggerCount * logCountPerLogger
|
||||
actual := file.Count()
|
||||
ExpectEqual(t, actual, expected)
|
||||
}
|
||||
|
||||
func parallelLog(logger *AccessLogger, req *http.Request, resp *http.Response, n int) {
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(n)
|
||||
for range n {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
logger.Log(req, resp)
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
99
internal/net/http/accesslog/filter.go
Normal file
99
internal/net/http/accesslog/filter.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package accesslog
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
E "github.com/yusing/go-proxy/internal/error"
|
||||
"github.com/yusing/go-proxy/internal/net/types"
|
||||
"github.com/yusing/go-proxy/internal/utils/strutils"
|
||||
)
|
||||
|
||||
type (
|
||||
LogFilter[T Filterable] struct {
|
||||
Negative bool
|
||||
Values []T
|
||||
}
|
||||
Filterable interface {
|
||||
comparable
|
||||
Fulfill(req *http.Request, res *http.Response) bool
|
||||
}
|
||||
HTTPMethod string
|
||||
HTTPHeader struct {
|
||||
Key, Value string
|
||||
}
|
||||
Host string
|
||||
CIDR struct{ types.CIDR }
|
||||
)
|
||||
|
||||
var ErrInvalidHTTPHeaderFilter = E.New("invalid http header filter")
|
||||
|
||||
func (f *LogFilter[T]) CheckKeep(req *http.Request, res *http.Response) bool {
|
||||
if len(f.Values) == 0 {
|
||||
return !f.Negative
|
||||
}
|
||||
for _, check := range f.Values {
|
||||
if check.Fulfill(req, res) {
|
||||
return !f.Negative
|
||||
}
|
||||
}
|
||||
return f.Negative
|
||||
}
|
||||
|
||||
func (r *StatusCodeRange) Fulfill(req *http.Request, res *http.Response) bool {
|
||||
return r.Includes(res.StatusCode)
|
||||
}
|
||||
|
||||
func (method HTTPMethod) Fulfill(req *http.Request, res *http.Response) bool {
|
||||
return req.Method == string(method)
|
||||
}
|
||||
|
||||
// Parse implements strutils.Parser.
|
||||
func (k *HTTPHeader) Parse(v string) error {
|
||||
split := strutils.SplitRune(v, '=')
|
||||
switch len(split) {
|
||||
case 1:
|
||||
split = append(split, "")
|
||||
case 2:
|
||||
default:
|
||||
return ErrInvalidHTTPHeaderFilter.Subject(v)
|
||||
}
|
||||
k.Key = split[0]
|
||||
k.Value = split[1]
|
||||
return nil
|
||||
}
|
||||
|
||||
func (k *HTTPHeader) Fulfill(req *http.Request, res *http.Response) bool {
|
||||
wanted := k.Value
|
||||
// non canonical key matching
|
||||
got, ok := req.Header[k.Key]
|
||||
if wanted == "" {
|
||||
return ok
|
||||
}
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
for _, v := range got {
|
||||
if strings.EqualFold(v, wanted) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (h Host) Fulfill(req *http.Request, res *http.Response) bool {
|
||||
return req.Host == string(h)
|
||||
}
|
||||
|
||||
func (cidr CIDR) Fulfill(req *http.Request, res *http.Response) bool {
|
||||
ip, _, err := net.SplitHostPort(req.RemoteAddr)
|
||||
if err != nil {
|
||||
ip = req.RemoteAddr
|
||||
}
|
||||
netIP := net.ParseIP(ip)
|
||||
if netIP == nil {
|
||||
return false
|
||||
}
|
||||
return cidr.Contains(netIP)
|
||||
}
|
||||
188
internal/net/http/accesslog/filter_test.go
Normal file
188
internal/net/http/accesslog/filter_test.go
Normal file
@@ -0,0 +1,188 @@
|
||||
package accesslog_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
. "github.com/yusing/go-proxy/internal/net/http/accesslog"
|
||||
"github.com/yusing/go-proxy/internal/utils/strutils"
|
||||
. "github.com/yusing/go-proxy/internal/utils/testing"
|
||||
)
|
||||
|
||||
func TestStatusCodeFilter(t *testing.T) {
|
||||
values := []*StatusCodeRange{
|
||||
strutils.MustParse[*StatusCodeRange]("200-308"),
|
||||
}
|
||||
t.Run("positive", func(t *testing.T) {
|
||||
filter := &LogFilter[*StatusCodeRange]{}
|
||||
ExpectTrue(t, filter.CheckKeep(nil, nil))
|
||||
|
||||
// keep any 2xx 3xx (inclusive)
|
||||
filter.Values = values
|
||||
ExpectFalse(t, filter.CheckKeep(nil, &http.Response{
|
||||
StatusCode: http.StatusForbidden,
|
||||
}))
|
||||
ExpectTrue(t, filter.CheckKeep(nil, &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
}))
|
||||
ExpectTrue(t, filter.CheckKeep(nil, &http.Response{
|
||||
StatusCode: http.StatusMultipleChoices,
|
||||
}))
|
||||
ExpectTrue(t, filter.CheckKeep(nil, &http.Response{
|
||||
StatusCode: http.StatusPermanentRedirect,
|
||||
}))
|
||||
})
|
||||
|
||||
t.Run("negative", func(t *testing.T) {
|
||||
filter := &LogFilter[*StatusCodeRange]{
|
||||
Negative: true,
|
||||
}
|
||||
ExpectFalse(t, filter.CheckKeep(nil, nil))
|
||||
|
||||
// drop any 2xx 3xx (inclusive)
|
||||
filter.Values = values
|
||||
ExpectTrue(t, filter.CheckKeep(nil, &http.Response{
|
||||
StatusCode: http.StatusForbidden,
|
||||
}))
|
||||
ExpectFalse(t, filter.CheckKeep(nil, &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
}))
|
||||
ExpectFalse(t, filter.CheckKeep(nil, &http.Response{
|
||||
StatusCode: http.StatusMultipleChoices,
|
||||
}))
|
||||
ExpectFalse(t, filter.CheckKeep(nil, &http.Response{
|
||||
StatusCode: http.StatusPermanentRedirect,
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
func TestMethodFilter(t *testing.T) {
|
||||
t.Run("positive", func(t *testing.T) {
|
||||
filter := &LogFilter[HTTPMethod]{}
|
||||
ExpectTrue(t, filter.CheckKeep(&http.Request{
|
||||
Method: http.MethodGet,
|
||||
}, nil))
|
||||
ExpectTrue(t, filter.CheckKeep(&http.Request{
|
||||
Method: http.MethodPost,
|
||||
}, nil))
|
||||
|
||||
// keep get only
|
||||
filter.Values = []HTTPMethod{http.MethodGet}
|
||||
ExpectTrue(t, filter.CheckKeep(&http.Request{
|
||||
Method: http.MethodGet,
|
||||
}, nil))
|
||||
ExpectFalse(t, filter.CheckKeep(&http.Request{
|
||||
Method: http.MethodPost,
|
||||
}, nil))
|
||||
})
|
||||
|
||||
t.Run("negative", func(t *testing.T) {
|
||||
filter := &LogFilter[HTTPMethod]{
|
||||
Negative: true,
|
||||
}
|
||||
ExpectFalse(t, filter.CheckKeep(&http.Request{
|
||||
Method: http.MethodGet,
|
||||
}, nil))
|
||||
ExpectFalse(t, filter.CheckKeep(&http.Request{
|
||||
Method: http.MethodPost,
|
||||
}, nil))
|
||||
|
||||
// drop post only
|
||||
filter.Values = []HTTPMethod{http.MethodPost}
|
||||
ExpectFalse(t, filter.CheckKeep(&http.Request{
|
||||
Method: http.MethodPost,
|
||||
}, nil))
|
||||
ExpectTrue(t, filter.CheckKeep(&http.Request{
|
||||
Method: http.MethodGet,
|
||||
}, nil))
|
||||
})
|
||||
}
|
||||
|
||||
func TestHeaderFilter(t *testing.T) {
|
||||
fooBar := &http.Request{
|
||||
Header: http.Header{
|
||||
"Foo": []string{"bar"},
|
||||
},
|
||||
}
|
||||
fooBaz := &http.Request{
|
||||
Header: http.Header{
|
||||
"Foo": []string{"baz"},
|
||||
},
|
||||
}
|
||||
headerFoo := []*HTTPHeader{
|
||||
strutils.MustParse[*HTTPHeader]("Foo"),
|
||||
}
|
||||
ExpectEqual(t, headerFoo[0].Key, "Foo")
|
||||
ExpectEqual(t, headerFoo[0].Value, "")
|
||||
headerFooBar := []*HTTPHeader{
|
||||
strutils.MustParse[*HTTPHeader]("Foo=bar"),
|
||||
}
|
||||
ExpectEqual(t, headerFooBar[0].Key, "Foo")
|
||||
ExpectEqual(t, headerFooBar[0].Value, "bar")
|
||||
|
||||
t.Run("positive", func(t *testing.T) {
|
||||
filter := &LogFilter[*HTTPHeader]{}
|
||||
ExpectTrue(t, filter.CheckKeep(fooBar, nil))
|
||||
ExpectTrue(t, filter.CheckKeep(fooBaz, nil))
|
||||
|
||||
// keep any foo
|
||||
filter.Values = headerFoo
|
||||
ExpectTrue(t, filter.CheckKeep(fooBar, nil))
|
||||
ExpectTrue(t, filter.CheckKeep(fooBaz, nil))
|
||||
|
||||
// keep foo == bar
|
||||
filter.Values = headerFooBar
|
||||
ExpectTrue(t, filter.CheckKeep(fooBar, nil))
|
||||
ExpectFalse(t, filter.CheckKeep(fooBaz, nil))
|
||||
})
|
||||
t.Run("negative", func(t *testing.T) {
|
||||
filter := &LogFilter[*HTTPHeader]{
|
||||
Negative: true,
|
||||
}
|
||||
ExpectFalse(t, filter.CheckKeep(fooBar, nil))
|
||||
ExpectFalse(t, filter.CheckKeep(fooBaz, nil))
|
||||
|
||||
// drop any foo
|
||||
filter.Values = headerFoo
|
||||
ExpectFalse(t, filter.CheckKeep(fooBar, nil))
|
||||
ExpectFalse(t, filter.CheckKeep(fooBaz, nil))
|
||||
|
||||
// drop foo == bar
|
||||
filter.Values = headerFooBar
|
||||
ExpectFalse(t, filter.CheckKeep(fooBar, nil))
|
||||
ExpectTrue(t, filter.CheckKeep(fooBaz, nil))
|
||||
})
|
||||
}
|
||||
|
||||
func TestCIDRFilter(t *testing.T) {
|
||||
cidr := []*CIDR{
|
||||
strutils.MustParse[*CIDR]("192.168.10.0/24"),
|
||||
}
|
||||
ExpectEqual(t, cidr[0].String(), "192.168.10.0/24")
|
||||
inCIDR := &http.Request{
|
||||
RemoteAddr: "192.168.10.1",
|
||||
}
|
||||
notInCIDR := &http.Request{
|
||||
RemoteAddr: "192.168.11.1",
|
||||
}
|
||||
|
||||
t.Run("positive", func(t *testing.T) {
|
||||
filter := &LogFilter[*CIDR]{}
|
||||
ExpectTrue(t, filter.CheckKeep(inCIDR, nil))
|
||||
ExpectTrue(t, filter.CheckKeep(notInCIDR, nil))
|
||||
|
||||
filter.Values = cidr
|
||||
ExpectTrue(t, filter.CheckKeep(inCIDR, nil))
|
||||
ExpectFalse(t, filter.CheckKeep(notInCIDR, nil))
|
||||
})
|
||||
|
||||
t.Run("negative", func(t *testing.T) {
|
||||
filter := &LogFilter[*CIDR]{Negative: true}
|
||||
ExpectFalse(t, filter.CheckKeep(inCIDR, nil))
|
||||
ExpectFalse(t, filter.CheckKeep(notInCIDR, nil))
|
||||
|
||||
filter.Values = cidr
|
||||
ExpectFalse(t, filter.CheckKeep(inCIDR, nil))
|
||||
ExpectTrue(t, filter.CheckKeep(notInCIDR, nil))
|
||||
})
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user