mirror of
https://github.com/yusing/godoxy.git
synced 2026-01-11 22:30:47 +01:00
Compare commits
290 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
136a2ec89f | ||
|
|
021c68f2a7 | ||
|
|
989a09274f | ||
|
|
39c5886d7a | ||
|
|
1a5f3735cf | ||
|
|
4d47eb0e91 | ||
|
|
af7c59b5c2 | ||
|
|
693bf68864 | ||
|
|
c9ddf3d165 | ||
|
|
1549b56866 | ||
|
|
2cd1f22e68 | ||
|
|
688f38943d | ||
|
|
043bbd7a11 | ||
|
|
f997423fd7 | ||
|
|
1871ef3d38 | ||
|
|
7c56c88dd4 | ||
|
|
4d7422dd90 | ||
|
|
eccabc0588 | ||
|
|
0c7b188587 | ||
|
|
4c97b79adf | ||
|
|
8ae9573b07 | ||
|
|
43fce6e739 | ||
|
|
78900772bb | ||
|
|
c16a0444ca | ||
|
|
0d518166ee | ||
|
|
6ae391a3c9 | ||
|
|
357897a0cd | ||
|
|
10a0a8fe09 | ||
|
|
98443be80c | ||
|
|
bf7f6e99c5 | ||
|
|
b6e468e54e | ||
|
|
dfc634a362 | ||
|
|
d9b6b82f07 | ||
|
|
4ad6257dab | ||
|
|
e3e3f1dfdc | ||
|
|
60f83bb7bf | ||
|
|
bbc10cb105 | ||
|
|
83ea19dd92 | ||
|
|
7ec42dce4d | ||
|
|
a9da7ce6fc | ||
|
|
1586610a44 | ||
|
|
254224c0e8 | ||
|
|
9b66772a12 | ||
|
|
322878b0b7 | ||
|
|
9e181d25ce | ||
|
|
4c311fd78e | ||
|
|
9936b3af5b | ||
|
|
648fd23a57 | ||
|
|
7dd00d2424 | ||
|
|
9e83fe7329 | ||
|
|
166c9c75e9 | ||
|
|
b9882f8985 | ||
|
|
37a166731d | ||
|
|
66db583432 | ||
|
|
f7eb80a6ea | ||
|
|
79f40f3d22 | ||
|
|
ed3b26653c | ||
|
|
2bb13129de | ||
|
|
fc29e8f9fa | ||
|
|
495c2c7390 | ||
|
|
b984386bab | ||
|
|
3781bb93e1 | ||
|
|
3a4dc3f876 | ||
|
|
2c43f1412e | ||
|
|
5d3a93f103 | ||
|
|
5faba1b5a9 | ||
|
|
4e7bd3579b | ||
|
|
49da8a31d2 | ||
|
|
dd2b8f600d | ||
|
|
8b1a3a31ff | ||
|
|
d429374924 | ||
|
|
dd0bbdc7b4 | ||
|
|
64e85c3076 | ||
|
|
68771ce399 | ||
|
|
bcc7faa8e5 | ||
|
|
fb0dc7dea0 | ||
|
|
0fad7b3411 | ||
|
|
1adba05065 | ||
|
|
fe7740f1b0 | ||
|
|
b253dce7e1 | ||
|
|
589b3a7a13 | ||
|
|
26d259b952 | ||
|
|
04e118c081 | ||
|
|
2af2346e35 | ||
|
|
7cd44b5ad3 | ||
|
|
81d96394b9 | ||
|
|
76fe5345d8 | ||
|
|
ef277ef57f | ||
|
|
9a12dab600 | ||
|
|
51f6391ded | ||
|
|
e10e6cfe4d | ||
|
|
d887a37f60 | ||
|
|
1abd1e257f | ||
|
|
137b0820b0 | ||
|
|
3f85d7f813 | ||
|
|
6b6dae129f | ||
|
|
2c3672a7ea | ||
|
|
645a58464c | ||
|
|
fcbb51dce7 | ||
|
|
c7c6a097f0 | ||
|
|
0ce7f29976 | ||
|
|
f2df756c17 | ||
|
|
28b5d44e11 | ||
|
|
e7bb6bc798 | ||
|
|
c572382f6a | ||
|
|
e28c4a1b4d | ||
|
|
f5708fd539 | ||
|
|
5769abb626 | ||
|
|
4ebe0abba0 | ||
|
|
8109c9ac4f | ||
|
|
2ce1ceb460 | ||
|
|
9d701ad671 | ||
|
|
4aee44fe11 | ||
|
|
adb41a80c5 | ||
|
|
642e6ebdc8 | ||
|
|
74828943a6 | ||
|
|
f906e04581 | ||
|
|
b3c47e759f | ||
|
|
8bbb5d2e09 | ||
|
|
7fe03be73f | ||
|
|
abb0124011 | ||
|
|
a98b2bb71a | ||
|
|
bc1702e6cf | ||
|
|
577a5366e8 | ||
|
|
7fedd5729e | ||
|
|
35c0463829 | ||
|
|
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 |
47
.env.example
47
.env.example
@@ -1,22 +1,49 @@
|
||||
# set timezone to get correct log timestamp
|
||||
TZ=ETC/UTC
|
||||
|
||||
# API/WebUI user password login credentials (optional)
|
||||
# These fields are not required for OIDC authentication
|
||||
GODOXY_API_USER=admin
|
||||
GODOXY_API_PASSWORD=password
|
||||
# generate secret with `openssl rand -base64 32`
|
||||
GOPROXY_API_JWT_SECRET=
|
||||
|
||||
GODOXY_API_JWT_SECRET=
|
||||
# the JWT token time-to-live
|
||||
GOPROXY_API_JWT_TOKEN_TTL=1h
|
||||
GODOXY_API_JWT_TOKEN_TTL=1h
|
||||
|
||||
# API/WebUI login credentials
|
||||
GOPROXY_API_USER=admin
|
||||
GOPROXY_API_PASSWORD=password
|
||||
# OIDC Configuration (optional)
|
||||
# Uncomment and configure these values to enable OIDC authentication.
|
||||
# GODOXY_OIDC_ISSUER_URL=https://accounts.google.com
|
||||
# GODOXY_OIDC_CLIENT_ID=your-client-id
|
||||
# GODOXY_OIDC_CLIENT_SECRET=your-client-secret
|
||||
# Keep /api/auth/callback as the redirect URL, change the domain to match your setup.
|
||||
# GODOXY_OIDC_REDIRECT_URL=https://your-domain/api/auth/callback
|
||||
# Comma-separated list of scopes
|
||||
# GODOXY_OIDC_SCOPES=openid, profile, email
|
||||
#
|
||||
# User definitions: Uncomment and configure these values to restrict access to specific users or groups.
|
||||
# These two fields act as a logical AND operator. For example, given the following membership:
|
||||
# user1, group1
|
||||
# user2, group1
|
||||
# user3, group2
|
||||
# user1, group2
|
||||
# You can allow access to user3 AND all users of group1 by providing:
|
||||
# # GODOXY_OIDC_ALLOWED_USERS=user3
|
||||
# # GODOXY_OIDC_ALLOWED_GROUPS=group1
|
||||
#
|
||||
# Comma-separated list of allowed users.
|
||||
# GODOXY_OIDC_ALLOWED_USERS=user1,user2
|
||||
# Optional: Comma-separated list of allowed groups.
|
||||
# GODOXY_OIDC_ALLOWED_GROUPS=group1,group2
|
||||
|
||||
# Proxy listening address
|
||||
GOPROXY_HTTP_ADDR=:80
|
||||
GOPROXY_HTTPS_ADDR=:443
|
||||
GODOXY_HTTP_ADDR=:80
|
||||
GODOXY_HTTPS_ADDR=:443
|
||||
|
||||
# API listening address
|
||||
GOPROXY_API_ADDR=127.0.0.1:8888
|
||||
GODOXY_API_ADDR=127.0.0.1:8888
|
||||
|
||||
# Prometheus Metrics
|
||||
GODOXY_PROMETHEUS_ENABLED=true
|
||||
|
||||
# Debug mode
|
||||
GOPROXY_DEBUG=false
|
||||
GODOXY_DEBUG=false
|
||||
15
.github/FUNDING.yml
vendored
Normal file
15
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: yusing # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
|
||||
patreon: # Replace with a single Patreon username
|
||||
open_collective: # Replace with a single Open Collective username
|
||||
ko_fi: # Replace with a single Ko-fi username
|
||||
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||
liberapay: # Replace with a single Liberapay username
|
||||
issuehunt: # Replace with a single IssueHunt username
|
||||
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
|
||||
polar: # Replace with a single Polar username
|
||||
buy_me_a_coffee: yusingwysq # Replace with a single Buy Me a Coffee username
|
||||
thanks_dev: # Replace with a single thanks.dev username
|
||||
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -4,9 +4,11 @@ compose.yml
|
||||
config
|
||||
certs
|
||||
config*/
|
||||
!schemas/**
|
||||
certs*/
|
||||
bin/
|
||||
error_pages/
|
||||
!examples/error_pages/
|
||||
|
||||
logs/
|
||||
log/
|
||||
@@ -25,3 +27,6 @@ todo.md
|
||||
mtrace.json
|
||||
.env
|
||||
test.Dockerfile
|
||||
|
||||
node_modules/
|
||||
tsconfig.tsbuildinfo
|
||||
@@ -9,9 +9,6 @@ linters-settings:
|
||||
- fieldalignment
|
||||
gocyclo:
|
||||
min-complexity: 14
|
||||
goconst:
|
||||
min-len: 3
|
||||
min-occurrences: 4
|
||||
misspell:
|
||||
locale: US
|
||||
funlen:
|
||||
@@ -102,13 +99,14 @@ linters:
|
||||
- depguard # Not relevant
|
||||
- nakedret # Too strict
|
||||
- lll # Not relevant
|
||||
- gocyclo # FIXME must be fixed
|
||||
- gocyclo # must be fixed
|
||||
- gocognit # Too strict
|
||||
- nestif # Too many false-positive.
|
||||
- prealloc # Too many false-positive.
|
||||
- makezero # Not relevant
|
||||
- dupl # Too strict
|
||||
- gci # I don't care
|
||||
- goconst # Too annoying
|
||||
- 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.9
|
||||
# Trunk provides extensibility via plugins. (https://docs.trunk.io/plugins)
|
||||
plugins:
|
||||
sources:
|
||||
- id: trunk
|
||||
ref: v1.6.3
|
||||
ref: v1.6.7
|
||||
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.7
|
||||
- checkov@3.2.360
|
||||
- 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.63.4
|
||||
- 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.4
|
||||
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.9/schemas/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.9/schemas/routes.schema.json": [
|
||||
"providers.example.yml"
|
||||
]
|
||||
}
|
||||
|
||||
30
Dockerfile
30
Dockerfile
@@ -1,6 +1,10 @@
|
||||
# Stage 1: Builder
|
||||
FROM golang:1.23.2-alpine AS builder
|
||||
RUN apk add --no-cache tzdata make
|
||||
FROM golang:1.23.5-alpine AS builder
|
||||
HEALTHCHECK NONE
|
||||
|
||||
# package version does not matter
|
||||
# trunk-ignore(hadolint/DL3018)
|
||||
RUN apk add --no-cache tzdata make libcap-setcap
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
@@ -13,20 +17,22 @@ RUN --mount=type=cache,target="/go/pkg/mod" \
|
||||
|
||||
ENV GOCACHE=/root/.cache/go-build
|
||||
|
||||
COPY Makefile /src/
|
||||
COPY cmd /src/cmd
|
||||
COPY internal /src/internal
|
||||
COPY pkg /src/pkg
|
||||
|
||||
ARG VERSION
|
||||
ENV VERSION=${VERSION}
|
||||
|
||||
COPY scripts /src/scripts
|
||||
COPY Makefile /src/
|
||||
ARG BUILD_FLAGS
|
||||
ENV BUILD_FLAGS=${BUILD_FLAGS}
|
||||
|
||||
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 +46,14 @@ 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
|
||||
|
||||
ENV DOCKER_HOST=unix:///var/run/docker.sock
|
||||
ENV GOPROXY_DEBUG=0
|
||||
ENV GODOXY_DEBUG=0
|
||||
|
||||
EXPOSE 80
|
||||
EXPOSE 8888
|
||||
@@ -55,4 +61,4 @@ EXPOSE 443
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
CMD ["/app/go-proxy"]
|
||||
CMD ["/app/godoxy"]
|
||||
146
Makefile
146
Makefile
@@ -1,63 +1,66 @@
|
||||
VERSION ?= $(shell git describe --tags --abbrev=0)
|
||||
BUILD_FLAGS ?= -s -w -X github.com/yusing/go-proxy/pkg.version=${VERSION}
|
||||
export VERSION
|
||||
export BUILD_FLAGS
|
||||
export CGO_ENABLED = 0
|
||||
export VERSION ?= $(shell git describe --tags --abbrev=0)
|
||||
export BUILD_DATE ?= $(shell date -u +'%Y%m%d-%H%M')
|
||||
export GOOS = linux
|
||||
|
||||
.PHONY: all setup build test up restart logs get debug run archive repush rapid-crash debug-list-containers
|
||||
LDFLAGS = -X github.com/yusing/go-proxy/pkg.version=${VERSION}
|
||||
|
||||
all: debug
|
||||
ifeq ($(trace), 1)
|
||||
debug = 1
|
||||
GODOXY_TRACE ?= 1
|
||||
endif
|
||||
|
||||
build:
|
||||
scripts/build.sh
|
||||
ifeq ($(debug), 1)
|
||||
CGO_ENABLED = 0
|
||||
GODOXY_DEBUG = 1
|
||||
BUILD_FLAGS = -tags production
|
||||
else ifeq ($(pprof), 1)
|
||||
CGO_ENABLED = 1
|
||||
GODEBUG = gctrace=1 inittrace=1 schedtrace=3000
|
||||
GORACE = log_path=logs/pprof strip_path_prefix=$(shell pwd)/
|
||||
BUILD_FLAGS = -race -gcflags=all='-N -l' -tags pprof
|
||||
DOCKER_TAG = pprof
|
||||
VERSION += -pprof
|
||||
else
|
||||
CGO_ENABLED = 0
|
||||
LDFLAGS += -s -w
|
||||
BUILD_FLAGS = -pgo=auto -tags production
|
||||
DOCKER_TAG = latest
|
||||
endif
|
||||
|
||||
BUILD_FLAGS += -ldflags='$(LDFLAGS)'
|
||||
|
||||
export CGO_ENABLED
|
||||
export GODOXY_DEBUG
|
||||
export GODOXY_TRACE
|
||||
export GODEBUG
|
||||
export GORACE
|
||||
export BUILD_FLAGS
|
||||
export DOCKER_TAG
|
||||
|
||||
test:
|
||||
GOPROXY_TEST=1 go test ./internal/...
|
||||
|
||||
up:
|
||||
docker compose up -d
|
||||
|
||||
restart:
|
||||
docker compose restart -t 0
|
||||
|
||||
logs:
|
||||
docker compose logs -f
|
||||
GODOXY_TEST=1 go test ./internal/...
|
||||
|
||||
get:
|
||||
go get -u ./cmd && go mod tidy
|
||||
|
||||
debug:
|
||||
make build
|
||||
sudo GOPROXY_DEBUG=1 bin/go-proxy
|
||||
|
||||
debug-trace:
|
||||
make build
|
||||
sudo GOPROXY_DEBUG=1 GOPROXY_TRACE=1 bin/go-proxy
|
||||
|
||||
profile:
|
||||
GODEBUG=gctrace=1 make build
|
||||
sudo GOPROXY_DEBUG=1 bin/go-proxy
|
||||
|
||||
mtrace:
|
||||
bin/go-proxy debug-ls-mtrace > mtrace.json
|
||||
build:
|
||||
mkdir -p bin
|
||||
go build ${BUILD_FLAGS} -o bin/godoxy ./cmd
|
||||
if [ $(shell id -u) -eq 0 ]; \
|
||||
then setcap CAP_NET_BIND_SERVICE=+eip bin/godoxy; \
|
||||
else sudo setcap CAP_NET_BIND_SERVICE=+eip bin/godoxy; \
|
||||
fi
|
||||
|
||||
run:
|
||||
make build && sudo bin/go-proxy
|
||||
[ -f .env ] && godotenv -f .env go run ${BUILD_FLAGS} ./cmd
|
||||
|
||||
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
|
||||
mtrace:
|
||||
bin/godoxy debug-ls-mtrace > mtrace.json
|
||||
|
||||
rapid-crash:
|
||||
sudo docker run --restart=always --name test_crash -p 80 debian:bookworm-slim /bin/cat &&\
|
||||
docker run --restart=always --name test_crash -p 80 debian:bookworm-slim /bin/cat &&\
|
||||
sleep 3 &&\
|
||||
sudo docker rm -f test_crash
|
||||
docker rm -f test_crash
|
||||
|
||||
debug-list-containers:
|
||||
bash -c 'echo -e "GET /containers/json HTTP/1.0\r\n" | sudo netcat -U /var/run/docker.sock | tail -n +9 | jq'
|
||||
@@ -67,4 +70,57 @@ ci-test:
|
||||
act -n --artifact-server-path /tmp/artifacts -s GITHUB_TOKEN="$$(gh auth token)"
|
||||
|
||||
cloc:
|
||||
cloc --not-match-f '_test.go$$' cmd internal pkg
|
||||
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:${DOCKER_TAG} \
|
||||
-t docker.io/yusing/godoxy-nightly:${VERSION}-${BUILD_DATE} \
|
||||
--build-arg VERSION="${VERSION}-nightly-${BUILD_DATE}" \
|
||||
--build-arg BUILD_FLAGS="${BUILD_FLAGS}" \
|
||||
--push .
|
||||
|
||||
build-docker:
|
||||
docker build -t godoxy-nightly \
|
||||
--build-arg VERSION="${VERSION}-nightly-${BUILD_DATE}" \
|
||||
--build-arg BUILD_FLAGS="${BUILD_FLAGS}" \
|
||||
.
|
||||
|
||||
# To generate schema
|
||||
# comment out this part from typescript-json-schema.js#L884
|
||||
#
|
||||
# if (indexType.flags !== ts.TypeFlags.Number && !isIndexedObject) {
|
||||
# throw new Error("Not supported: IndexSignatureDeclaration with index symbol other than a number or a string");
|
||||
# }
|
||||
|
||||
gen-schema-single:
|
||||
bun --bun run typescript-json-schema --noExtraProps --required --skipLibCheck --tsNodeRegister=true -o schemas/${OUT} schemas/${IN} ${CLASS}
|
||||
# minify
|
||||
python3 -c "import json; f=open('schemas/${OUT}', 'r'); j=json.load(f); f.close(); f=open('schemas/${OUT}', 'w'); json.dump(j, f, separators=(',', ':'));"
|
||||
|
||||
gen-schema:
|
||||
bun --bun tsc
|
||||
make IN=config/config.ts \
|
||||
CLASS=Config \
|
||||
OUT=config.schema.json \
|
||||
gen-schema-single
|
||||
make IN=providers/routes.ts \
|
||||
CLASS=Routes \
|
||||
OUT=routes.schema.json \
|
||||
gen-schema-single
|
||||
make IN=middlewares/middleware_compose.ts \
|
||||
CLASS=MiddlewareCompose \
|
||||
OUT=middleware_compose.schema.json \
|
||||
gen-schema-single
|
||||
make IN=docker.ts \
|
||||
CLASS=DockerRoutes \
|
||||
OUT=docker_routes.schema.json \
|
||||
gen-schema-single
|
||||
|
||||
update-schema-generator:
|
||||
pnpm up -g typescript-json-schema
|
||||
|
||||
push-github:
|
||||
git push origin $(shell git rev-parse --abbrev-ref HEAD)
|
||||
120
README.md
120
README.md
@@ -1,89 +1,88 @@
|
||||
# go-proxy
|
||||
<div align="center">
|
||||
|
||||
# GoDoxy
|
||||
|
||||
[](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy)
|
||||

|
||||
[](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy)
|
||||
[](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy)
|
||||
[](https://discord.gg/umReR62nRd)
|
||||
|
||||
A lightweight, simple, and [performant](https://github.com/yusing/go-proxy/wiki/Benchmarks) reverse proxy with WebUI.
|
||||
|
||||
For full documentation, check out **[Wiki](https://github.com/yusing/go-proxy/wiki)**
|
||||
|
||||
**EN** | <a href="README_CHT.md">中文</a>
|
||||
|
||||
<!-- [](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy)
|
||||
[](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy)
|
||||
[](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy)
|
||||
[](https://discord.gg/umReR62nRd)
|
||||
[](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy) -->
|
||||
|
||||
[繁體中文文檔請看此](README_CHT.md)
|
||||
<img src="https://github.com/user-attachments/assets/4bb371f4-6e4c-425c-89b2-b9e962bdd46f" style="max-width: 650">
|
||||
|
||||
A lightweight, easy-to-use, and [performant](https://github.com/yusing/go-proxy/wiki/Benchmarks) reverse proxy with a Web UI and dashboard.
|
||||
|
||||

|
||||
|
||||
_Join our [Discord](https://discord.gg/umReR62nRd) for help and discussions_
|
||||
</div>
|
||||
|
||||
## Table of content
|
||||
|
||||
<!-- TOC -->
|
||||
|
||||
- [go-proxy](#go-proxy)
|
||||
- [GoDoxy](#godoxy)
|
||||
- [Table of content](#table-of-content)
|
||||
- [Key Features](#key-features)
|
||||
- [Getting Started](#getting-started)
|
||||
- [Setup](#setup)
|
||||
- [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)
|
||||
- [Build it yourself](#build-it-yourself)
|
||||
|
||||
## 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
|
||||
- OpenID Connect support
|
||||
- [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
|
||||
## Prerequisites
|
||||
|
||||
### Setup
|
||||
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 the latest docker images
|
||||
|
||||
1. Pull docker image
|
||||
|
||||
```shell
|
||||
docker pull ghcr.io/yusing/go-proxy:latest
|
||||
```
|
||||
|
||||
2. Create new directory, `cd` into it, then run setup, or [set up manually](#manual-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
|
||||
# Then set the JWT secret
|
||||
sed -i "s|GOPROXY_API_JWT_SECRET=.*|GOPROXY_API_JWT_SECRET=$(openssl rand -base64 32)|g" .env
|
||||
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 `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`
|
||||
|
||||
- A Record: `*.y.z` -> `10.0.10.1`
|
||||
- AAAA Record: `*.y.z` -> `::ffff:a00:a01`
|
||||
4. Start the container `docker compose up -d`
|
||||
|
||||
4. Setup `docker-socket-proxy` other docker nodes _(if any)_ (see [Multi docker nodes setup](https://github.com/yusing/go-proxy/wiki/Configurations#multi-docker-nodes-setup)) and then them inside `config.yml`
|
||||
|
||||
5. Run go-proxy `docker compose up -d`
|
||||
then list all routes to see if further configurations are needed:
|
||||
`docker exec go-proxy /app/go-proxy ls-routes`
|
||||
|
||||
6. You may now do some extra configuration
|
||||
- With text editor (e.g. Visual Studio Code)
|
||||
- With Web UI via `http://localhost:3000` or `https://gp.y.z`
|
||||
- For more info, [See Wiki]([wiki](https://github.com/yusing/go-proxy/wiki))
|
||||
5. You may now do some extra configuration on WebUI `https://godoxy.domain.com`
|
||||
|
||||
[🔼Back to top](#table-of-content)
|
||||
|
||||
@@ -91,21 +90,15 @@ _Join our [Discord](https://discord.gg/umReR62nRd) for help and discussions_
|
||||
|
||||
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.7/config.example.yml -O config/config.yml`
|
||||
`mkdir -p config && wget https://raw.githubusercontent.com/yusing/go-proxy/v0.9/config.example.yml -O config/config.yml`
|
||||
|
||||
2. Grab `.env.example` into `.env`
|
||||
|
||||
`wget https://raw.githubusercontent.com/yusing/go-proxy/v0.7/.env.example -O .env`
|
||||
|
||||
`wget https://raw.githubusercontent.com/yusing/go-proxy/v0.9/.env.example -O .env`
|
||||
|
||||
3. Grab `compose.example.yml` into `compose.yml`
|
||||
|
||||
`wget https://raw.githubusercontent.com/yusing/go-proxy/v0.7/compose.example.yml -O compose.yml`
|
||||
|
||||
4. Set the JWT secret
|
||||
|
||||
`sed -i "s|GOPROXY_API_JWT_SECRET=.*|GOPROXY_API_JWT_SECRET=$(openssl rand -base64 32)|g" .env`
|
||||
|
||||
5. Start the container `docker compose up -d`
|
||||
`wget https://raw.githubusercontent.com/yusing/go-proxy/v0.9/compose.example.yml -O compose.yml`
|
||||
|
||||
### Folder structrue
|
||||
|
||||
@@ -124,19 +117,12 @@ _Join our [Discord](https://discord.gg/umReR62nRd) for help and discussions_
|
||||
└── .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
|
||||
|
||||
[🔼Back to top](#table-of-content)
|
||||
|
||||
## Screenshots
|
||||
|
||||
### idlesleeper
|
||||
|
||||

|
||||
|
||||
|
||||
[🔼Back to top](#table-of-content)
|
||||
|
||||
## Build it yourself
|
||||
|
||||
178
README_CHT.md
178
README_CHT.md
@@ -1,130 +1,140 @@
|
||||
# go-proxy
|
||||
<div align="center">
|
||||
|
||||
# GoDoxy
|
||||
|
||||
[](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy)
|
||||

|
||||
[](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy)
|
||||
[](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy)
|
||||
[](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy)
|
||||
[](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy)
|
||||
[](https://discord.gg/umReR62nRd)
|
||||
[](https://discord.gg/umReR62nRd)
|
||||
|
||||
一個輕量化、易用且[高效]([docs/benchmark_result.md](https://github.com/yusing/go-proxy/wiki/Benchmarks)))的反向代理和端口轉發工具
|
||||
輕量、易用、 [高效能](https://github.com/yusing/go-proxy/wiki/Benchmarks),且帶有主頁和配置面板的反向代理
|
||||
|
||||
完整文檔請查閱 **[Wiki](https://github.com/yusing/go-proxy/wiki)**(暫未有中文翻譯)
|
||||
|
||||
<!-- [](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy)
|
||||
[](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy)
|
||||
[](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy) -->
|
||||
|
||||
<a href="README.md">EN</a> | **中文**
|
||||
|
||||
<img src="https://github.com/user-attachments/assets/4bb371f4-6e4c-425c-89b2-b9e962bdd46f" style="max-width: 650">
|
||||
|
||||
</div>
|
||||
|
||||
## 目錄
|
||||
|
||||
<!-- TOC -->
|
||||
|
||||
- [go-proxy](#go-proxy)
|
||||
- [GoDoxy](#godoxy)
|
||||
- [目錄](#目錄)
|
||||
- [重點](#重點)
|
||||
- [入門指南](#入門指南)
|
||||
- [安裝](#安裝)
|
||||
- [命令行參數](#命令行參數)
|
||||
- [環境變量](#環境變量)
|
||||
- [VSCode 中使用 JSON Schema](#vscode-中使用-json-schema)
|
||||
- [展示](#展示)
|
||||
- [idlesleeper](#idlesleeper)
|
||||
- [源碼編譯](#源碼編譯)
|
||||
- [主要特點](#主要特點)
|
||||
- [前置需求](#前置需求)
|
||||
- [安裝](#安裝)
|
||||
- [手動安裝](#手動安裝)
|
||||
- [資料夾結構](#資料夾結構)
|
||||
- [截圖](#截圖)
|
||||
- [閒置休眠](#閒置休眠)
|
||||
- [自行編譯](#自行編譯)
|
||||
|
||||
## 重點
|
||||
## 主要特點
|
||||
|
||||
- 易用
|
||||
- 不需花費太多時間就能輕鬆配置
|
||||
- 支持多個docker節點
|
||||
- 除錯簡單
|
||||
- 自動配置 SSL 證書(參見[可用的 DNS 供應商](https://github.com/yusing/go-proxy/wiki/Supported-DNS%E2%80%9001-Providers))
|
||||
- 透過 Docker 容器自動配置
|
||||
- 容器狀態變更時自動熱重載
|
||||
- **idlesleeper** 容器閒置時自動暫停/停止,入站時自動喚醒 (可選, 參見 [展示](#idlesleeper))
|
||||
- HTTP(s) 反向代理
|
||||
- [HTTP middleware](https://github.com/yusing/go-proxy/wiki/Middlewares)
|
||||
- [自訂 error pages](https://github.com/yusing/go-proxy/wiki/Middlewares#custom-error-pages)
|
||||
- TCP/UDP 端口轉發
|
||||
- Web 面板 (內置App dashboard)
|
||||
- 支持 linux/amd64、linux/arm64 平台
|
||||
- 使用 **[Go](https://go.dev)** 編寫
|
||||
- 容易使用
|
||||
- 輕鬆配置
|
||||
- 簡單的多節點設置
|
||||
- 錯誤訊息清晰詳細,易於排除故障
|
||||
- 自動 SSL 憑證管理(參見 [支援的 DNS-01 驗證提供商](https://github.com/yusing/go-proxy/wiki/Supported-DNS%E2%80%9001-Providers))
|
||||
- 自動配置 Docker 容器
|
||||
- 容器狀態/配置文件變更時自動熱重載
|
||||
- **閒置休眠**:在閒置時停止容器,有流量時喚醒(_可選,參見[截圖](#閒置休眠)_)
|
||||
- HTTP(s) 反向代理
|
||||
- OpenID Connect 支持
|
||||
- [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)** 編寫
|
||||
|
||||
[🔼 返回頂部](#目錄)
|
||||
[🔼回到頂部](#目錄)
|
||||
|
||||
## 入門指南
|
||||
## 前置需求
|
||||
|
||||
### 安裝
|
||||
設置 DNS 記錄指向運行 `GoDoxy` 的機器,例如:
|
||||
|
||||
1. 抓取Docker鏡像
|
||||
- A 記錄:`*.y.z` -> `10.0.10.1`
|
||||
- AAAA 記錄:`*.y.z` -> `::ffff:a00:a01`
|
||||
|
||||
## 安裝
|
||||
|
||||
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. _(可選)_ 設置其他 Docker 節點的 `docker-socket-proxy`(參見 [多 Docker 節點設置](https://github.com/yusing/go-proxy/wiki/Configurations#multi-docker-nodes-setup)),然後在 `config.yml` 中添加它們
|
||||
|
||||
- A 記錄: `*.y.z` -> `10.0.10.1`
|
||||
- AAAA 記錄: `*.y.z` -> `::ffff:a00:a01`
|
||||
4. 啟動容器 `docker compose up -d`
|
||||
|
||||
4. 配置 `docker-socket-proxy` 其他 Docker 節點(如有) (參見 [範例](docs/docker_socket_proxy.md)) 然後加到 `config.yml` 中
|
||||
5. 大功告成!可前往WebUI `https://gp.domain.com` 進行額外的配置
|
||||
|
||||
5. 大功告成,你可以做一些額外的配置
|
||||
- 使用文本編輯器 (推薦 Visual Studio Code [參見 VSCode 使用 schema](#vscode-中使用-json-schema))
|
||||
- 或通過 `http://localhost:3000` 使用網頁配置編輯器
|
||||
- 詳情請參閱 [docker.md](docs/docker.md)
|
||||
[🔼回到頂部](#目錄)
|
||||
|
||||
[🔼 返回頂部](#目錄)
|
||||
### 手動安裝
|
||||
|
||||
### 命令行參數
|
||||
1. 建立 `config` 目錄,然後將 `config.example.yml` 下載到 `config/config.yml`
|
||||
|
||||
| 參數 | 描述 | 示例 |
|
||||
| ------------------------- | ------------------------------------------------------------------------------------- | ----------------------------------- |
|
||||
| 空 | 啟動代理服務器 | |
|
||||
| `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` |
|
||||
`mkdir -p config && wget https://raw.githubusercontent.com/yusing/go-proxy/v0.9/config.example.yml -O config/config.yml`
|
||||
|
||||
**使用 `docker exec go-proxy /app/go-proxy <參數>` 運行**
|
||||
2. 將 `.env.example` 下載到 `.env`
|
||||
|
||||
### 環境變量
|
||||
`wget https://raw.githubusercontent.com/yusing/go-proxy/v0.9/.env.example -O .env`
|
||||
|
||||
| 環境變量 | 描述 | 默認 | 格式 |
|
||||
| ------------------------------ | ---------------- | ---------------- | ------------- |
|
||||
| `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` |
|
||||
3. 將 `compose.example.yml` 下載到 `compose.yml`
|
||||
|
||||
### VSCode 中使用 JSON Schema
|
||||
`wget https://raw.githubusercontent.com/yusing/go-proxy/v0.9/compose.example.yml -O compose.yml`
|
||||
|
||||
複製 [`.vscode/settings.example.json`](.vscode/settings.example.json) 到 `.vscode/settings.json` 並根據需求修改
|
||||
### 資料夾結構
|
||||
|
||||
[🔼 返回頂部](#目錄)
|
||||
```shell
|
||||
├── certs
|
||||
│ ├── cert.crt
|
||||
│ └── priv.key
|
||||
├── compose.yml
|
||||
├── config
|
||||
│ ├── config.yml
|
||||
│ ├── middlewares
|
||||
│ │ ├── middleware1.yml
|
||||
│ │ ├── middleware2.yml
|
||||
│ ├── provider1.yml
|
||||
│ └── provider2.yml
|
||||
└── .env
|
||||
```
|
||||
|
||||
## 截圖
|
||||
|
||||
## 展示
|
||||
### 閒置休眠
|
||||
|
||||
### idlesleeper
|
||||

|
||||
|
||||

|
||||
[🔼回到頂部](#目錄)
|
||||
|
||||
[🔼 返回頂部](#目錄)
|
||||
## 自行編譯
|
||||
|
||||
## 源碼編譯
|
||||
1. 克隆儲存庫 `git clone https://github.com/yusing/go-proxy --depth=1`
|
||||
|
||||
1. 獲取源碼 `git clone https://github.com/yusing/go-proxy --depth=1`
|
||||
2. 如果尚未安裝,請安裝/升級 [go (>=1.22)](https://go.dev/doc/install) 和 `make`
|
||||
|
||||
2. 安裝/升級 [go 版本 (>=1.22)](https://go.dev/doc/install) 和 `make`(如果尚未安裝)
|
||||
3. 如果之前編譯過(go < 1.22),請使用 `go clean -cache` 清除快取
|
||||
|
||||
3. 如果之前編譯過(go 版本 < 1.22),請使用 `go clean -cache` 清除緩存
|
||||
4. 使用 `make get` 獲取依賴
|
||||
|
||||
4. 使用 `make get` 獲取依賴項
|
||||
5. 使用 `make build` 編譯二進制檔案
|
||||
|
||||
5. 使用 `make build` 編譯
|
||||
|
||||
[🔼 返回頂部](#目錄)
|
||||
[🔼回到頂部](#目錄)
|
||||
|
||||
120
bun.lock
Normal file
120
bun.lock
Normal file
@@ -0,0 +1,120 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "godoxy-types",
|
||||
"devDependencies": {
|
||||
"prettier": "^3.4.2",
|
||||
"typescript": "^5.7.3",
|
||||
"typescript-json-schema": "^0.65.1",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="],
|
||||
|
||||
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
|
||||
|
||||
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="],
|
||||
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="],
|
||||
|
||||
"@tsconfig/node10": ["@tsconfig/node10@1.0.11", "", {}, "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw=="],
|
||||
|
||||
"@tsconfig/node12": ["@tsconfig/node12@1.0.11", "", {}, "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag=="],
|
||||
|
||||
"@tsconfig/node14": ["@tsconfig/node14@1.0.3", "", {}, "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow=="],
|
||||
|
||||
"@tsconfig/node16": ["@tsconfig/node16@1.0.4", "", {}, "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA=="],
|
||||
|
||||
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
|
||||
|
||||
"@types/node": ["@types/node@18.19.74", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-HMwEkkifei3L605gFdV+/UwtpxP6JSzM+xFk2Ia6DNFSwSVBRh9qp5Tgf4lNFOMfPVuU0WnkcWpXZpgn5ufO4A=="],
|
||||
|
||||
"acorn": ["acorn@8.14.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA=="],
|
||||
|
||||
"acorn-walk": ["acorn-walk@8.3.4", "", { "dependencies": { "acorn": "^8.11.0" } }, "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g=="],
|
||||
|
||||
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||
|
||||
"arg": ["arg@4.1.3", "", {}, "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA=="],
|
||||
|
||||
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||
|
||||
"brace-expansion": ["brace-expansion@1.1.11", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA=="],
|
||||
|
||||
"cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
|
||||
|
||||
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
||||
|
||||
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
||||
|
||||
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
|
||||
|
||||
"create-require": ["create-require@1.1.1", "", {}, "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ=="],
|
||||
|
||||
"diff": ["diff@4.0.2", "", {}, "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A=="],
|
||||
|
||||
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||
|
||||
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||
|
||||
"fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="],
|
||||
|
||||
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
|
||||
|
||||
"glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
|
||||
|
||||
"inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="],
|
||||
|
||||
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
||||
|
||||
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
|
||||
|
||||
"make-error": ["make-error@1.3.6", "", {}, "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw=="],
|
||||
|
||||
"minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
|
||||
|
||||
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
|
||||
|
||||
"path-equal": ["path-equal@1.2.5", "", {}, "sha512-i73IctDr3F2W+bsOWDyyVm/lqsXO47aY9nsFZUjTT/aljSbkxHxxCoyZ9UUrM8jK0JVod+An+rl48RCsvWM+9g=="],
|
||||
|
||||
"path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="],
|
||||
|
||||
"prettier": ["prettier@3.4.2", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-e9MewbtFo+Fevyuxn/4rrcDAaq0IYxPGLvObpQjiZBMAzB9IGmzlnG9RZy3FFas+eBMu2vA0CszMeduow5dIuQ=="],
|
||||
|
||||
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
|
||||
|
||||
"safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="],
|
||||
|
||||
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||
|
||||
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"ts-node": ["ts-node@10.9.2", "", { "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", "@tsconfig/node12": "^1.0.7", "@tsconfig/node14": "^1.0.0", "@tsconfig/node16": "^1.0.2", "acorn": "^8.4.1", "acorn-walk": "^8.1.1", "arg": "^4.1.0", "create-require": "^1.1.0", "diff": "^4.0.1", "make-error": "^1.1.1", "v8-compile-cache-lib": "^3.0.1", "yn": "3.1.1" }, "peerDependencies": { "@swc/core": ">=1.2.50", "@swc/wasm": ">=1.2.50", "@types/node": "*", "typescript": ">=2.7" }, "optionalPeers": ["@swc/core", "@swc/wasm"], "bin": { "ts-node": "dist/bin.js", "ts-script": "dist/bin-script-deprecated.js", "ts-node-cwd": "dist/bin-cwd.js", "ts-node-esm": "dist/bin-esm.js", "ts-node-script": "dist/bin-script.js", "ts-node-transpile-only": "dist/bin-transpile.js" } }, "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ=="],
|
||||
|
||||
"typescript": ["typescript@5.7.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw=="],
|
||||
|
||||
"typescript-json-schema": ["typescript-json-schema@0.65.1", "", { "dependencies": { "@types/json-schema": "^7.0.9", "@types/node": "^18.11.9", "glob": "^7.1.7", "path-equal": "^1.2.5", "safe-stable-stringify": "^2.2.0", "ts-node": "^10.9.1", "typescript": "~5.5.0", "yargs": "^17.1.1" }, "bin": { "typescript-json-schema": "bin/typescript-json-schema" } }, "sha512-tuGH7ff2jPaUYi6as3lHyHcKpSmXIqN7/mu50x3HlYn0EHzLpmt3nplZ7EuhUkO0eqDRc9GqWNkfjgBPIS9kxg=="],
|
||||
|
||||
"undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
|
||||
|
||||
"v8-compile-cache-lib": ["v8-compile-cache-lib@3.0.1", "", {}, "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg=="],
|
||||
|
||||
"wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
|
||||
|
||||
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
|
||||
|
||||
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
|
||||
|
||||
"yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
|
||||
|
||||
"yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
|
||||
|
||||
"yn": ["yn@3.1.1", "", {}, "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q=="],
|
||||
|
||||
"typescript-json-schema/typescript": ["typescript@5.5.4", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q=="],
|
||||
}
|
||||
}
|
||||
94
cmd/main.go
94
cmd/main.go
@@ -2,28 +2,43 @@ package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/yusing/go-proxy/internal"
|
||||
"github.com/yusing/go-proxy/internal/api"
|
||||
v1 "github.com/yusing/go-proxy/internal/api/v1"
|
||||
"github.com/yusing/go-proxy/internal/api/v1/auth"
|
||||
"github.com/yusing/go-proxy/internal/api/v1/favicon"
|
||||
"github.com/yusing/go-proxy/internal/api/v1/query"
|
||||
"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/homepage"
|
||||
"github.com/yusing/go-proxy/internal/logging"
|
||||
"github.com/yusing/go-proxy/internal/net/http/middleware"
|
||||
R "github.com/yusing/go-proxy/internal/route"
|
||||
"github.com/yusing/go-proxy/internal/server"
|
||||
"github.com/yusing/go-proxy/internal/route/routes/routequery"
|
||||
"github.com/yusing/go-proxy/internal/task"
|
||||
"github.com/yusing/go-proxy/pkg"
|
||||
)
|
||||
|
||||
var rawLogger = log.New(os.Stdout, "", 0)
|
||||
|
||||
func init() {
|
||||
var out io.Writer = os.Stderr
|
||||
if common.EnableLogStreaming {
|
||||
out = zerolog.MultiLevelWriter(out, v1.GetMemLogger())
|
||||
}
|
||||
logging.InitLogger(out)
|
||||
// logging.AddHook(v1.GetMemLogger())
|
||||
}
|
||||
|
||||
func main() {
|
||||
initProfiling()
|
||||
args := common.GetArgs()
|
||||
|
||||
switch args.Command {
|
||||
@@ -34,12 +49,12 @@ func main() {
|
||||
if err := query.ReloadServer(); err != nil {
|
||||
E.LogFatal("server reload error", err)
|
||||
}
|
||||
logging.Info().Msg("ok")
|
||||
rawLogger.Println("ok")
|
||||
return
|
||||
case common.CommandListIcons:
|
||||
icons, err := internal.ListAvailableIcons()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
rawLogger.Fatal(err)
|
||||
}
|
||||
printJSON(icons)
|
||||
return
|
||||
@@ -48,11 +63,10 @@ func main() {
|
||||
if err != nil {
|
||||
log.Printf("failed to connect to api server: %s", err)
|
||||
log.Printf("falling back to config file")
|
||||
printJSON(config.RoutesByAlias())
|
||||
} else {
|
||||
printJSON(routes)
|
||||
return
|
||||
}
|
||||
return
|
||||
case common.CommandDebugListMTrace:
|
||||
trace, err := query.ListMiddlewareTraces()
|
||||
if err != nil {
|
||||
@@ -63,7 +77,7 @@ func main() {
|
||||
}
|
||||
|
||||
if args.Command == common.CommandStart {
|
||||
logging.Info().Msgf("go-proxy version %s", pkg.GetVersion())
|
||||
logging.Info().Msgf("GoDoxy version %s", pkg.GetVersion())
|
||||
logging.Trace().Msg("trace enabled")
|
||||
// logging.AddHook(notif.GetDispatcher())
|
||||
} else {
|
||||
@@ -95,18 +109,36 @@ func main() {
|
||||
}
|
||||
|
||||
switch args.Command {
|
||||
case common.CommandListRoutes:
|
||||
cfg.StartProxyProviders()
|
||||
printJSON(routequery.RoutesByAlias())
|
||||
return
|
||||
case common.CommandListConfigs:
|
||||
printJSON(config.Value())
|
||||
printJSON(cfg.Value())
|
||||
return
|
||||
case common.CommandDebugListEntries:
|
||||
printJSON(config.DumpEntries())
|
||||
printJSON(cfg.DumpRoutes())
|
||||
return
|
||||
case common.CommandDebugListProviders:
|
||||
printJSON(config.DumpProviders())
|
||||
printJSON(cfg.DumpRouteProviders())
|
||||
return
|
||||
}
|
||||
|
||||
cfg.StartProxyProviders()
|
||||
go internal.InitIconListCache()
|
||||
go homepage.InitOverridesConfig()
|
||||
go favicon.InitIconCache()
|
||||
|
||||
cfg.Start(&config.StartServersOptions{
|
||||
Proxy: true,
|
||||
})
|
||||
if err := auth.Initialize(); err != nil {
|
||||
logging.Fatal().Err(err).Msg("failed to initialize authentication")
|
||||
}
|
||||
// API Handler needs to start after auth is initialized.
|
||||
cfg.StartServers(&config.StartServersOptions{
|
||||
API: true,
|
||||
})
|
||||
|
||||
config.WatchChanges()
|
||||
|
||||
sig := make(chan os.Signal, 1)
|
||||
@@ -114,41 +146,12 @@ func main() {
|
||||
signal.Notify(sig, syscall.SIGTERM)
|
||||
signal.Notify(sig, syscall.SIGHUP)
|
||||
|
||||
autocert := config.GetAutoCertProvider()
|
||||
if autocert != nil {
|
||||
if err := autocert.Setup(); err != nil {
|
||||
E.LogFatal("autocert setup error", err)
|
||||
}
|
||||
} else {
|
||||
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: config.Value().RedirectToHTTPS,
|
||||
})
|
||||
apiServer := server.InitAPIServer(server.Options{
|
||||
Name: "api",
|
||||
CertProvider: autocert,
|
||||
HTTPAddr: common.APIHTTPAddr,
|
||||
Handler: api.NewHandler(),
|
||||
RedirectToHTTPS: config.Value().RedirectToHTTPS,
|
||||
})
|
||||
|
||||
proxyServer.Start()
|
||||
apiServer.Start()
|
||||
|
||||
// wait for signal
|
||||
<-sig
|
||||
|
||||
// grafully shutdown
|
||||
// gracefully shutdown
|
||||
logging.Info().Msg("shutting down")
|
||||
task.CancelGlobalContext()
|
||||
task.GlobalContextWait(time.Second * time.Duration(config.Value().TimeoutShutdown))
|
||||
_ = task.GracefulShutdown(time.Second * time.Duration(cfg.Value().TimeoutShutdown))
|
||||
}
|
||||
|
||||
func prepareDirectory(dir string) {
|
||||
@@ -164,6 +167,5 @@ func printJSON(obj any) {
|
||||
if err != nil {
|
||||
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"
|
||||
}
|
||||
|
||||
7
cmd/main_production.go
Normal file
7
cmd/main_production.go
Normal file
@@ -0,0 +1,7 @@
|
||||
//go:build production
|
||||
|
||||
package main
|
||||
|
||||
func initProfiling() {
|
||||
// no profiling in production
|
||||
}
|
||||
20
cmd/main_prof.go
Normal file
20
cmd/main_prof.go
Normal file
@@ -0,0 +1,20 @@
|
||||
//go:build pprof
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
_ "net/http/pprof"
|
||||
"runtime"
|
||||
"runtime/debug"
|
||||
)
|
||||
|
||||
func initProfiling() {
|
||||
runtime.GOMAXPROCS(2)
|
||||
debug.SetMemoryLimit(100 * 1024 * 1024)
|
||||
debug.SetMaxStack(15 * 1024 * 1024)
|
||||
go func() {
|
||||
log.Println(http.ListenAndServe(":7777", nil))
|
||||
}()
|
||||
}
|
||||
@@ -1,40 +1,41 @@
|
||||
---
|
||||
services:
|
||||
frontend:
|
||||
image: ghcr.io/yusing/go-proxy-frontend:latest
|
||||
container_name: go-proxy-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_code: 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: go-proxy
|
||||
restart: always
|
||||
network_mode: host
|
||||
env_file: .env
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- ./config:/app/config
|
||||
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: godoxy
|
||||
proxy.godoxy.port: 3000
|
||||
# proxy.godoxy.middlewares.cidr_whitelist: |
|
||||
# status: 403
|
||||
# message: IP not allowed
|
||||
# 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
|
||||
- ./logs:/app/logs
|
||||
- ./error_pages:/app/error_pages
|
||||
|
||||
# (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`
|
||||
# To use autocert, certs will be stored in "./certs".
|
||||
# You can also use a docker volume to store it
|
||||
- ./certs:/app/certs
|
||||
|
||||
# - /path/to/certs:/app/certs
|
||||
|
||||
# 2. use autocert, certs will be stored in ./certs (or other path you specify)
|
||||
|
||||
# - ./certs:/app/certs
|
||||
# remove "./certs:/app/certs" and uncomment below to use existing certificate
|
||||
# - /path/to/certs/cert.crt:/app/certs/cert.crt
|
||||
# - /path/to/certs/priv.key:/app/certs/priv.key
|
||||
|
||||
@@ -1,24 +1,42 @@
|
||||
# Autocert (choose one below and uncomment to enable)
|
||||
#
|
||||
# 1. use existing cert
|
||||
#
|
||||
|
||||
# autocert:
|
||||
# provider: local
|
||||
#
|
||||
# cert_path: certs/cert.crt # optional, uncomment only if you need to change it
|
||||
# key_path: certs/priv.key # optional, uncomment only if you need to change it
|
||||
#
|
||||
|
||||
# 2. cloudflare
|
||||
#
|
||||
# autocert:
|
||||
# provider: cloudflare
|
||||
# email: abc@gmail.com # ACME Email
|
||||
# domains: # a list of domains for cert registration
|
||||
# - "*.y.z" # remember to use double quotes to surround wildcard domain
|
||||
# email: abc@gmail.com # ACME Email
|
||||
# domains: # a list of domains for cert registration
|
||||
# - "*.domain.com"
|
||||
# - "domain.com"
|
||||
# options:
|
||||
# auth_token: c1234565789-abcdefghijklmnopqrst # your zone API token
|
||||
#
|
||||
# 3. other providers, check docs/dns_providers.md for more
|
||||
# auth_token: c1234565789-abcdefghijklmnopqrst # your zone API token
|
||||
|
||||
# 3. other providers, see https://github.com/yusing/go-proxy/wiki/Supported-DNS%E2%80%9001-Providers#supported-dns-01-providers
|
||||
|
||||
entrypoint:
|
||||
# Below define an example of middleware config
|
||||
# 1. block non local IP connections
|
||||
# 2. redirect HTTP to HTTPS
|
||||
#
|
||||
# middlewares:
|
||||
# - 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"
|
||||
# - use: RedirectHTTP
|
||||
|
||||
# below enables access log
|
||||
access_log:
|
||||
format: combined
|
||||
path: /app/logs/entrypoint.log
|
||||
|
||||
providers:
|
||||
# include files are standalone yaml files under `config/` directory
|
||||
@@ -30,6 +48,7 @@ providers:
|
||||
docker:
|
||||
# $DOCKER_HOST implies environment variable `DOCKER_HOST` or unix:///var/run/docker.sock by default
|
||||
local: $DOCKER_HOST
|
||||
|
||||
# explicit only mode
|
||||
# only containers with explicit aliases will be proxied
|
||||
# add "!" after provider name to enable explicit only mode
|
||||
@@ -41,29 +60,32 @@ providers:
|
||||
#
|
||||
# remote-1: tcp://10.0.2.1:2375
|
||||
# remote-2: ssh://root:1234@10.0.2.2
|
||||
# if match_domains not defined
|
||||
# any host = alias+[any domain] will match
|
||||
# i.e. https://app1.y.z will match alias app1 for any domain y.z
|
||||
# but https://app1.node1.y.z will only match alias "app.node1"
|
||||
#
|
||||
# if match_domains defined
|
||||
# only host = alias+[one of match_domains] will match
|
||||
# i.e. match_domains = [node1.my.app, my.site]
|
||||
# https://app1.my.app, https://app1.my.net, etc. will not match even if app1 exists
|
||||
# only https://*.node1.my.app and https://*.my.site will match
|
||||
#
|
||||
|
||||
# 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 # this means use payload template from internal/notif/templates/discord.json
|
||||
|
||||
# Check https://github.com/yusing/go-proxy/wiki/Certificates-and-domain-matching#domain-matching
|
||||
# for explaination of `match_domains`
|
||||
#
|
||||
# match_domains:
|
||||
# - 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
|
||||
72
go.mod
72
go.mod
@@ -1,64 +1,82 @@
|
||||
module github.com/yusing/go-proxy
|
||||
|
||||
go 1.23.2
|
||||
go 1.23.5
|
||||
|
||||
require (
|
||||
github.com/PuerkitoBio/goquery v1.10.1
|
||||
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/coreos/go-oidc/v3 v3.12.0
|
||||
github.com/docker/cli v27.5.1+incompatible
|
||||
github.com/docker/docker v27.5.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.24.0
|
||||
github.com/gobwas/glob v0.2.3
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||
github.com/gotify/server/v2 v2.5.0
|
||||
github.com/puzpuzpuz/xsync/v3 v3.4.0
|
||||
github.com/gotify/server/v2 v2.6.1
|
||||
github.com/lithammer/fuzzysearch v1.1.8
|
||||
github.com/prometheus/client_golang v1.20.5
|
||||
github.com/puzpuzpuz/xsync/v3 v3.5.0
|
||||
github.com/rs/zerolog v1.33.0
|
||||
github.com/santhosh-tekuri/jsonschema v1.2.4
|
||||
golang.org/x/net v0.30.0
|
||||
golang.org/x/text v0.19.0
|
||||
github.com/vincent-petithory/dataurl v1.0.0
|
||||
golang.org/x/crypto v0.32.0
|
||||
golang.org/x/net v0.34.0
|
||||
golang.org/x/oauth2 v0.25.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/andybalholm/cascadia v1.3.3 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||
github.com/cloudflare/cloudflare-go v0.108.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cloudflare/cloudflare-go v0.115.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.5 // 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/mattn/go-colorable v0.1.13 // 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.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/miekg/dns v1.1.62 // indirect
|
||||
github.com/miekg/dns v1.1.63 // 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.13.1 // indirect
|
||||
github.com/prometheus/client_model v0.6.1 // indirect
|
||||
github.com/prometheus/common v0.62.0 // indirect
|
||||
github.com/prometheus/procfs v0.15.1 // indirect
|
||||
github.com/sirupsen/logrus v1.9.3 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0 // indirect
|
||||
go.opentelemetry.io/otel v1.31.0 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 // indirect
|
||||
go.opentelemetry.io/otel v1.34.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.30.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.31.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.34.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk v1.30.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.31.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.34.0 // indirect
|
||||
golang.org/x/mod v0.22.0 // indirect
|
||||
golang.org/x/sync v0.10.0 // indirect
|
||||
golang.org/x/sys v0.29.0 // indirect
|
||||
golang.org/x/tools v0.29.0 // indirect
|
||||
google.golang.org/protobuf v1.36.4 // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
gotest.tools/v3 v3.5.1 // indirect
|
||||
)
|
||||
|
||||
214
go.sum
214
go.sum
@@ -2,36 +2,47 @@ 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/PuerkitoBio/goquery v1.10.1 h1:Y8JGYUkXWTGRB6Ars3+j3kN0xg1YqqlwvdTV8WTFQcU=
|
||||
github.com/PuerkitoBio/goquery v1.10.1/go.mod h1:IYiHrOMps66ag56LEH7QYDDupKXyo5A8qrjIx3ZtujY=
|
||||
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
|
||||
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
|
||||
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.108.0 h1:C4Skfjd8I8X3uEOGmQUT4/iGyZcWdkIU7HwvMoLkEE0=
|
||||
github.com/cloudflare/cloudflare-go v0.108.0/go.mod h1:m492eNahT/9MsN7Ppnoge8AaI7QhVFtEgVm3I9HJFeU=
|
||||
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.115.0 h1:84/dxeeXweCc0PN5Cto44iTA8AkG1fyT11yPO5ZB7sM=
|
||||
github.com/cloudflare/cloudflare-go v0.115.0/go.mod h1:Ds6urDwn/TF2uIU24mu7H91xkKP8gSAHxQ44DSZgVmU=
|
||||
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/coreos/go-oidc/v3 v3.12.0 h1:sJk+8G2qq94rDI6ehZ71Bol3oUHy63qNYmkiSjrc/Jo=
|
||||
github.com/coreos/go-oidc/v3 v3.12.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0=
|
||||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/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.5.1+incompatible h1:JB9cieUT9YNiMITtIsguaN55PLOHhBSz3LKVc6cqWaY=
|
||||
github.com/docker/cli v27.5.1+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
|
||||
github.com/docker/docker v27.5.1+incompatible h1:4PYU5dnBYqRQi0294d1FBECqT9ECWeQAIfE8q4YnPY8=
|
||||
github.com/docker/docker v27.5.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=
|
||||
@@ -39,8 +50,18 @@ 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.24.0 h1:KHQckvo8G6hlWnrPX4NJJ+aBfWNAE/HH+qdL2cBpCmg=
|
||||
github.com/go-playground/validator/v10 v10.24.0/go.mod h1:GGzBIJMuE98Ic/kJsBXbz1x/7cByt++cQ+YOuDM5wus=
|
||||
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
|
||||
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/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=
|
||||
@@ -53,136 +74,207 @@ 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.5.0 h1:tJd+a5bb17X52f0EV2KxqLuyjQFKmVK1+t/iNUkP16Y=
|
||||
github.com/gotify/server/v2 v2.5.0/go.mod h1:DKPMQI/FZ69iKbZvrOL6VWwRaoB9O+HDvJWVd/kiGbc=
|
||||
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/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
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/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4=
|
||||
github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
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=
|
||||
github.com/miekg/dns v1.1.62/go.mod h1:mvDlcItzm+br7MToIKqkglaGhlFMHJ9DTNNWONWXbNQ=
|
||||
github.com/miekg/dns v1.1.63 h1:8M5aAw6OMZfFXTT7K5V0Eu5YiiL8l7nUAkyN6C9YwaY=
|
||||
github.com/miekg/dns v1.1.63/go.mod h1:6NGHfjhpmr5lt3XPLuyfDJi5AXbNIPM9PY6H6sF1Nfs=
|
||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
|
||||
github.com/moby/term v0.5.0/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/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/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.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io=
|
||||
github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I=
|
||||
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.5.0 h1:i+cMcpEDY1BkNm7lPDkCtE4oElsYLn+EKF8kAu2vXT4=
|
||||
github.com/puzpuzpuz/xsync/v3 v3.5.0/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA=
|
||||
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/santhosh-tekuri/jsonschema v1.2.4 h1:hNhW8e7t+H1vgY+1QeEQpveR6D4+OwKPXCfD2aieJis=
|
||||
github.com/santhosh-tekuri/jsonschema v1.2.4/go.mod h1:TEAUOeZSmIxTTuHatJzrvARHiuO9LYd+cIxzgEHCQI4=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/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/vincent-petithory/dataurl v1.0.0 h1:cXw+kPto8NLuJtlMsI152irrVw9fRDX8AbShPRpg2CI=
|
||||
github.com/vincent-petithory/dataurl v1.0.0/go.mod h1:FHafX5vmDzyP+1CQATJn7WFKc9CvnvxyvZy6I1MrG/U=
|
||||
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.56.0 h1:UP6IpuHFkUgOQL9FFQFrZ+5LiwhhYRbi7VZSIx6Nj5s=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0/go.mod h1:qxuZLtbq5QDtdeSHsS7bcf6EH6uO6jUAgk764zd3rhM=
|
||||
go.opentelemetry.io/otel v1.31.0 h1:NsJcKPIW0D0H3NgzPDHmo0WW6SptzPdqg/L1zsIm2hY=
|
||||
go.opentelemetry.io/otel v1.31.0/go.mod h1:O0C14Yl9FgkjqcCZAsE053C13OaddMYr/hz6clDkEJE=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
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.59.0 h1:CV7UdSGJt/Ao6Gp4CXckLxVRRsRgDHoI8XjbL3PDl8s=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0/go.mod h1:FRmFuRJfag1IZ2dPkHnEoSFVgTVPUd2qf5Vi69hLb8I=
|
||||
go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY=
|
||||
go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI=
|
||||
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.31.0 h1:FSErL0ATQAmYHUIzSezZibnyVlft1ybhy4ozRPcF2fE=
|
||||
go.opentelemetry.io/otel/metric v1.31.0/go.mod h1:C3dEloVbLuYoX41KpmAhOqNriGbA+qqH6PQ5E5mUfnY=
|
||||
go.opentelemetry.io/otel/metric v1.34.0 h1:+eTR3U0MyfWjRDhmFMxe2SsW64QrZ84AOhvqS7Y+PoQ=
|
||||
go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE=
|
||||
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.31.0 h1:ffjsj1aRouKewfr85U2aGagJ46+MvodynlQ1HYdmJys=
|
||||
go.opentelemetry.io/otel/trace v1.31.0/go.mod h1:TXZkRk7SM2ZQLtR6eoAWQFIHPvzQ06FJAsO1tJg480A=
|
||||
go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k=
|
||||
go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE=
|
||||
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.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
|
||||
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
|
||||
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.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
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.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
|
||||
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
|
||||
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.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.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-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/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.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
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/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||
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.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
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.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/tools v0.29.0 h1:Xx0h3TtM9rzQpQuR4dKLrdglAmCEN5Oi+P74JdhdzXE=
|
||||
golang.org/x/tools v0.29.0/go.mod h1:KMQVMRsVxU6nHCFXrBPhDB8XncLNLM0lIy/F14RP588=
|
||||
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.4 h1:6A3ZDJHn/eNqc1i+IdefRzy/9PokBTPvcqMySR7NNIM=
|
||||
google.golang.org/protobuf v1.36.4/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,58 +1,70 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
v1 "github.com/yusing/go-proxy/internal/api/v1"
|
||||
"github.com/yusing/go-proxy/internal/api/v1/auth"
|
||||
. "github.com/yusing/go-proxy/internal/api/v1/utils"
|
||||
"github.com/yusing/go-proxy/internal/api/v1/favicon"
|
||||
"github.com/yusing/go-proxy/internal/common"
|
||||
config "github.com/yusing/go-proxy/internal/config/types"
|
||||
"github.com/yusing/go-proxy/internal/logging"
|
||||
"github.com/yusing/go-proxy/internal/utils/strutils"
|
||||
)
|
||||
|
||||
type ServeMux struct{ *http.ServeMux }
|
||||
|
||||
func NewServeMux() ServeMux {
|
||||
return ServeMux{http.NewServeMux()}
|
||||
func (mux ServeMux) HandleFunc(methods, endpoint string, handler http.HandlerFunc) {
|
||||
for _, m := range strutils.CommaSeperatedList(methods) {
|
||||
mux.ServeMux.HandleFunc(m+" "+endpoint, handler)
|
||||
}
|
||||
}
|
||||
|
||||
func (mux ServeMux) HandleFunc(method, endpoint string, handler http.HandlerFunc) {
|
||||
mux.ServeMux.HandleFunc(fmt.Sprintf("%s %s", method, endpoint), checkHost(handler))
|
||||
}
|
||||
|
||||
func NewHandler() http.Handler {
|
||||
mux := NewServeMux()
|
||||
func NewHandler(cfg config.ConfigInstance) http.Handler {
|
||||
mux := ServeMux{http.NewServeMux()}
|
||||
mux.HandleFunc("GET", "/v1", v1.Index)
|
||||
mux.HandleFunc("GET", "/v1/version", v1.GetVersion)
|
||||
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", auth.RequireAuth(v1.GetFileContent))
|
||||
mux.HandleFunc("GET", "/v1/file/{filename...}", auth.RequireAuth(v1.GetFileContent))
|
||||
mux.HandleFunc("POST", "/v1/file/{filename...}", auth.RequireAuth(v1.SetFileContent))
|
||||
mux.HandleFunc("PUT", "/v1/file/{filename...}", auth.RequireAuth(v1.SetFileContent))
|
||||
mux.HandleFunc("GET", "/v1/stats", v1.Stats)
|
||||
mux.HandleFunc("GET", "/v1/stats/ws", v1.StatsWS)
|
||||
mux.HandleFunc("POST", "/v1/reload", useCfg(cfg, v1.Reload))
|
||||
mux.HandleFunc("GET", "/v1/list", auth.RequireAuth(useCfg(cfg, v1.List)))
|
||||
mux.HandleFunc("GET", "/v1/list/{what}", auth.RequireAuth(useCfg(cfg, v1.List)))
|
||||
mux.HandleFunc("GET", "/v1/list/{what}/{which}", auth.RequireAuth(useCfg(cfg, v1.List)))
|
||||
mux.HandleFunc("GET", "/v1/file/{type}/{filename}", auth.RequireAuth(v1.GetFileContent))
|
||||
mux.HandleFunc("POST,PUT", "/v1/file/{type}/{filename}", auth.RequireAuth(v1.SetFileContent))
|
||||
mux.HandleFunc("POST", "/v1/file/validate/{type}", auth.RequireAuth(v1.ValidateFile))
|
||||
mux.HandleFunc("GET", "/v1/stats", useCfg(cfg, v1.Stats))
|
||||
mux.HandleFunc("GET", "/v1/stats/ws", useCfg(cfg, v1.StatsWS))
|
||||
mux.HandleFunc("GET", "/v1/health/ws", auth.RequireAuth(useCfg(cfg, v1.HealthWS)))
|
||||
mux.HandleFunc("GET", "/v1/logs/ws", auth.RequireAuth(useCfg(cfg, v1.LogsWS())))
|
||||
mux.HandleFunc("GET", "/v1/favicon", auth.RequireAuth(favicon.GetFavIcon))
|
||||
mux.HandleFunc("POST", "/v1/homepage/set", auth.RequireAuth(v1.SetHomePageOverrides))
|
||||
|
||||
if common.PrometheusEnabled {
|
||||
mux.Handle("GET /v1/metrics", promhttp.Handler())
|
||||
logging.Info().Msg("prometheus metrics enabled")
|
||||
}
|
||||
|
||||
defaultAuth := auth.GetDefaultAuth()
|
||||
if defaultAuth != nil {
|
||||
mux.HandleFunc("GET", "/v1/auth/redirect", defaultAuth.RedirectLoginPage)
|
||||
mux.HandleFunc("GET", "/v1/auth/check", func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := defaultAuth.CheckToken(r); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
})
|
||||
mux.HandleFunc("GET,POST", "/v1/auth/callback", defaultAuth.LoginCallbackHandler)
|
||||
mux.HandleFunc("GET,POST", "/v1/auth/logout", defaultAuth.LogoutCallbackHandler)
|
||||
} else {
|
||||
mux.HandleFunc("GET", "/v1/auth/check", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
}
|
||||
return mux
|
||||
}
|
||||
|
||||
// allow only requests to API server with localhost.
|
||||
func checkHost(f http.HandlerFunc) http.HandlerFunc {
|
||||
if common.IsDebug {
|
||||
return f
|
||||
}
|
||||
func useCfg(cfg config.ConfigInstance, handler func(cfg config.ConfigInstance, w http.ResponseWriter, r *http.Request)) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
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
|
||||
}
|
||||
f(w, r)
|
||||
handler(cfg, w, r)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,135 +1,54 @@
|
||||
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"
|
||||
"github.com/yusing/go-proxy/internal/logging"
|
||||
)
|
||||
|
||||
type (
|
||||
Credentials struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
Claims struct {
|
||||
Username string `json:"username"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
)
|
||||
var defaultAuth Provider
|
||||
|
||||
var (
|
||||
ErrInvalidUsername = E.New("invalid username")
|
||||
ErrInvalidPassword = E.New("invalid password")
|
||||
)
|
||||
// Initialize sets up authentication providers.
|
||||
func Initialize() error {
|
||||
if !IsEnabled() {
|
||||
logging.Warn().Msg("authentication is disabled, please set API_JWT_SECRET or OIDC_* to enable authentication")
|
||||
return nil
|
||||
}
|
||||
|
||||
func validatePassword(cred *Credentials) error {
|
||||
if cred.Username != common.APIUser {
|
||||
return ErrInvalidUsername.Subject(cred.Username)
|
||||
var err error
|
||||
// Initialize OIDC if configured.
|
||||
if common.OIDCIssuerURL != "" {
|
||||
defaultAuth, err = NewOIDCProviderFromEnv()
|
||||
} else {
|
||||
defaultAuth, err = NewUserPassAuthFromEnv()
|
||||
}
|
||||
if !bytes.Equal(common.HashPassword(cred.Password), common.APIPasswordHash) {
|
||||
return ErrInvalidPassword.Subject(cred.Password)
|
||||
}
|
||||
return nil
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
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 GetDefaultAuth() Provider {
|
||||
return defaultAuth
|
||||
}
|
||||
|
||||
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 IsEnabled() bool {
|
||||
return common.APIJWTSecret != nil || IsOIDCEnabled()
|
||||
}
|
||||
|
||||
func IsOIDCEnabled() bool {
|
||||
return common.OIDCIssuerURL != ""
|
||||
}
|
||||
|
||||
func RequireAuth(next http.HandlerFunc) http.HandlerFunc {
|
||||
if common.IsDebugSkipAuth {
|
||||
return next
|
||||
}
|
||||
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if checkToken(w, r) {
|
||||
next(w, r)
|
||||
if IsEnabled() {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := defaultAuth.CheckToken(r); err != nil {
|
||||
U.RespondError(w, err, http.StatusUnauthorized)
|
||||
} else {
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func checkToken(w http.ResponseWriter, r *http.Request) (ok bool) {
|
||||
tokenCookie, err := r.Cookie("token")
|
||||
if err != nil {
|
||||
U.HandleErr(w, r, E.PrependSubject("token", err), 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.HandleErr(w, r, err, http.StatusForbidden)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
return next
|
||||
}
|
||||
|
||||
274
internal/api/v1/auth/oidc.go
Normal file
274
internal/api/v1/auth/oidc.go
Normal file
@@ -0,0 +1,274 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
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"
|
||||
CE "github.com/yusing/go-proxy/internal/utils"
|
||||
"github.com/yusing/go-proxy/internal/utils/strutils"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
type OIDCProvider struct {
|
||||
oauthConfig *oauth2.Config
|
||||
oidcProvider *oidc.Provider
|
||||
oidcVerifier *oidc.IDTokenVerifier
|
||||
oidcLogoutURL *url.URL
|
||||
allowedUsers []string
|
||||
allowedGroups []string
|
||||
isMiddleware bool
|
||||
}
|
||||
|
||||
const CookieOauthState = "godoxy_oidc_state"
|
||||
|
||||
const (
|
||||
OIDCMiddlewareCallbackPath = "/auth/callback"
|
||||
OIDCLogoutPath = "/auth/logout"
|
||||
)
|
||||
|
||||
func NewOIDCProvider(issuerURL, clientID, clientSecret, redirectURL, logoutURL string, allowedUsers, allowedGroups []string) (*OIDCProvider, error) {
|
||||
if len(allowedUsers)+len(allowedGroups) == 0 {
|
||||
return nil, errors.New("OIDC users, groups, or both must not be empty")
|
||||
}
|
||||
|
||||
var logout *url.URL
|
||||
var err error
|
||||
if logoutURL != "" {
|
||||
logout, err = url.Parse(logoutURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse logout URL: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
provider, err := oidc.NewProvider(context.Background(), issuerURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize OIDC provider: %w", err)
|
||||
}
|
||||
|
||||
return &OIDCProvider{
|
||||
oauthConfig: &oauth2.Config{
|
||||
ClientID: clientID,
|
||||
ClientSecret: clientSecret,
|
||||
RedirectURL: redirectURL,
|
||||
Endpoint: provider.Endpoint(),
|
||||
Scopes: strutils.CommaSeperatedList(common.OIDCScopes),
|
||||
},
|
||||
oidcProvider: provider,
|
||||
oidcVerifier: provider.Verifier(&oidc.Config{
|
||||
ClientID: clientID,
|
||||
}),
|
||||
oidcLogoutURL: logout,
|
||||
allowedUsers: allowedUsers,
|
||||
allowedGroups: allowedGroups,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// NewOIDCProviderFromEnv creates a new OIDCProvider from environment variables.
|
||||
func NewOIDCProviderFromEnv() (*OIDCProvider, error) {
|
||||
return NewOIDCProvider(
|
||||
common.OIDCIssuerURL,
|
||||
common.OIDCClientID,
|
||||
common.OIDCClientSecret,
|
||||
common.OIDCRedirectURL,
|
||||
common.OIDCLogoutURL,
|
||||
common.OIDCAllowedUsers,
|
||||
common.OIDCAllowedGroups,
|
||||
)
|
||||
}
|
||||
|
||||
func (auth *OIDCProvider) TokenCookieName() string {
|
||||
return "godoxy_oidc_token"
|
||||
}
|
||||
|
||||
func (auth *OIDCProvider) SetIsMiddleware(enabled bool) {
|
||||
auth.isMiddleware = enabled
|
||||
auth.oauthConfig.RedirectURL = ""
|
||||
}
|
||||
|
||||
func (auth *OIDCProvider) SetAllowedUsers(users []string) {
|
||||
auth.allowedUsers = users
|
||||
}
|
||||
|
||||
func (auth *OIDCProvider) SetAllowedGroups(groups []string) {
|
||||
auth.allowedGroups = groups
|
||||
}
|
||||
|
||||
func (auth *OIDCProvider) CheckToken(r *http.Request) error {
|
||||
token, err := r.Cookie(auth.TokenCookieName())
|
||||
if err != nil {
|
||||
return ErrMissingToken
|
||||
}
|
||||
|
||||
// checks for Expiry, Audience == ClientID, Issuer, etc.
|
||||
idToken, err := auth.oidcVerifier.Verify(r.Context(), token.Value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to verify ID token: %w: %w", ErrInvalidToken, err)
|
||||
}
|
||||
|
||||
if len(idToken.Audience) == 0 {
|
||||
return ErrInvalidToken
|
||||
}
|
||||
|
||||
var claims struct {
|
||||
Email string `json:"email"`
|
||||
Username string `json:"preferred_username"`
|
||||
Groups []string `json:"groups"`
|
||||
}
|
||||
if err := idToken.Claims(&claims); err != nil {
|
||||
return fmt.Errorf("failed to parse claims: %w", err)
|
||||
}
|
||||
|
||||
// Logical AND between allowed users and groups.
|
||||
allowedUser := slices.Contains(auth.allowedUsers, claims.Username)
|
||||
allowedGroup := len(CE.Intersect(claims.Groups, auth.allowedGroups)) > 0
|
||||
if !allowedUser && !allowedGroup {
|
||||
return ErrUserNotAllowed.Subject(claims.Username)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateState generates a random string for OIDC state.
|
||||
const oidcStateLength = 32
|
||||
|
||||
func generateState() (string, error) {
|
||||
b := make([]byte, oidcStateLength)
|
||||
_, err := rand.Read(b)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.URLEncoding.EncodeToString(b)[:oidcStateLength], nil
|
||||
}
|
||||
|
||||
// RedirectOIDC initiates the OIDC login flow.
|
||||
func (auth *OIDCProvider) RedirectLoginPage(w http.ResponseWriter, r *http.Request) {
|
||||
state, err := generateState()
|
||||
if err != nil {
|
||||
U.HandleErr(w, r, err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: CookieOauthState,
|
||||
Value: state,
|
||||
MaxAge: 300,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Secure: true,
|
||||
Path: "/",
|
||||
})
|
||||
|
||||
redirURL := auth.oauthConfig.AuthCodeURL(state)
|
||||
if auth.isMiddleware {
|
||||
u, err := r.URL.Parse(redirURL)
|
||||
if err != nil {
|
||||
U.HandleErr(w, r, err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
q := u.Query()
|
||||
q.Set("redirect_uri", "https://"+r.Host+OIDCMiddlewareCallbackPath+q.Get("redirect_uri"))
|
||||
u.RawQuery = q.Encode()
|
||||
redirURL = u.String()
|
||||
}
|
||||
http.Redirect(w, r, redirURL, http.StatusTemporaryRedirect)
|
||||
}
|
||||
|
||||
func (auth *OIDCProvider) exchange(r *http.Request) (*oauth2.Token, error) {
|
||||
if auth.isMiddleware {
|
||||
cfg := *auth.oauthConfig
|
||||
cfg.RedirectURL = "https://" + r.Host + OIDCMiddlewareCallbackPath
|
||||
return cfg.Exchange(r.Context(), r.URL.Query().Get("code"))
|
||||
}
|
||||
return auth.oauthConfig.Exchange(r.Context(), r.URL.Query().Get("code"))
|
||||
}
|
||||
|
||||
// OIDCCallbackHandler handles the OIDC callback.
|
||||
func (auth *OIDCProvider) LoginCallbackHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// For testing purposes, skip provider verification
|
||||
if common.IsTest {
|
||||
auth.handleTestCallback(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
state, err := r.Cookie(CookieOauthState)
|
||||
if err != nil {
|
||||
U.HandleErr(w, r, E.New("missing state cookie"), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
query := r.URL.Query()
|
||||
if query.Get("state") != state.Value {
|
||||
U.HandleErr(w, r, E.New("invalid oauth state"), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
oauth2Token, err := auth.exchange(r)
|
||||
if err != nil {
|
||||
U.HandleErr(w, r, fmt.Errorf("failed to exchange token: %w", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
rawIDToken, ok := oauth2Token.Extra("id_token").(string)
|
||||
if !ok {
|
||||
U.HandleErr(w, r, E.New("missing id_token"), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
idToken, err := auth.oidcVerifier.Verify(r.Context(), rawIDToken)
|
||||
if err != nil {
|
||||
U.HandleErr(w, r, fmt.Errorf("failed to verify ID token: %w", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
setTokenCookie(w, r, auth.TokenCookieName(), rawIDToken, time.Until(idToken.Expiry))
|
||||
|
||||
// Redirect to home page
|
||||
http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
|
||||
}
|
||||
|
||||
func (auth *OIDCProvider) LogoutCallbackHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if auth.oidcLogoutURL == nil {
|
||||
DefaultLogoutCallbackHandler(auth, w, r)
|
||||
return
|
||||
}
|
||||
|
||||
token, err := r.Cookie(auth.TokenCookieName())
|
||||
if err != nil {
|
||||
U.HandleErr(w, r, E.New("missing token cookie"), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
clearTokenCookie(w, r, auth.TokenCookieName())
|
||||
|
||||
logoutURL := *auth.oidcLogoutURL
|
||||
logoutURL.Query().Add("id_token_hint", token.Value)
|
||||
|
||||
http.Redirect(w, r, logoutURL.String(), http.StatusFound)
|
||||
}
|
||||
|
||||
// handleTestCallback handles OIDC callback in test environment.
|
||||
func (auth *OIDCProvider) handleTestCallback(w http.ResponseWriter, r *http.Request) {
|
||||
state, err := r.Cookie(CookieOauthState)
|
||||
if err != nil {
|
||||
U.HandleErr(w, r, E.New("missing state cookie"), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if r.URL.Query().Get("state") != state.Value {
|
||||
U.HandleErr(w, r, E.New("invalid oauth state"), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Create test JWT token
|
||||
setTokenCookie(w, r, auth.TokenCookieName(), "test", time.Hour)
|
||||
|
||||
http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
|
||||
}
|
||||
454
internal/api/v1/auth/oidc_test.go
Normal file
454
internal/api/v1/auth/oidc_test.go
Normal file
@@ -0,0 +1,454 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/yusing/go-proxy/internal/common"
|
||||
"golang.org/x/oauth2"
|
||||
|
||||
. "github.com/yusing/go-proxy/internal/utils/testing"
|
||||
)
|
||||
|
||||
// setupMockOIDC configures mock OIDC provider for testing.
|
||||
func setupMockOIDC(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
provider := (&oidc.ProviderConfig{}).NewProvider(context.TODO())
|
||||
defaultAuth = &OIDCProvider{
|
||||
oauthConfig: &oauth2.Config{
|
||||
ClientID: "test-client",
|
||||
ClientSecret: "test-secret",
|
||||
RedirectURL: "http://localhost/callback",
|
||||
Endpoint: oauth2.Endpoint{
|
||||
AuthURL: "http://mock-provider/auth",
|
||||
TokenURL: "http://mock-provider/token",
|
||||
},
|
||||
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
|
||||
},
|
||||
oidcProvider: provider,
|
||||
oidcVerifier: provider.Verifier(&oidc.Config{
|
||||
ClientID: "test-client",
|
||||
}),
|
||||
allowedUsers: []string{"test-user"},
|
||||
allowedGroups: []string{"test-group1", "test-group2"},
|
||||
}
|
||||
}
|
||||
|
||||
// discoveryDocument returns a mock OIDC discovery document.
|
||||
func discoveryDocument(t *testing.T, server *httptest.Server) map[string]any {
|
||||
t.Helper()
|
||||
|
||||
discovery := map[string]any{
|
||||
"issuer": server.URL,
|
||||
"authorization_endpoint": server.URL + "/auth",
|
||||
"token_endpoint": server.URL + "/token",
|
||||
}
|
||||
|
||||
return discovery
|
||||
}
|
||||
|
||||
const (
|
||||
keyID = "test-key-id"
|
||||
clientID = "test-client-id"
|
||||
)
|
||||
|
||||
type provider struct {
|
||||
ts *httptest.Server
|
||||
key *rsa.PrivateKey
|
||||
verifier *oidc.IDTokenVerifier
|
||||
}
|
||||
|
||||
func (j *provider) SignClaims(t *testing.T, claims jwt.Claims) string {
|
||||
t.Helper()
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
|
||||
token.Header["kid"] = keyID
|
||||
signed, err := token.SignedString(j.key)
|
||||
ExpectNoError(t, err)
|
||||
return signed
|
||||
}
|
||||
|
||||
func setupProvider(t *testing.T) *provider {
|
||||
t.Helper()
|
||||
|
||||
// Generate an RSA key pair for the test.
|
||||
privKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
ExpectNoError(t, err)
|
||||
|
||||
// Build the matching public JWK that will be served by the endpoint.
|
||||
jwk := buildRSAJWK(t, &privKey.PublicKey, keyID)
|
||||
|
||||
// Start a test server that serves the JWKS endpoint.
|
||||
ts := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/.well-known/jwks.json":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"keys": []any{jwk},
|
||||
})
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(ts.Close)
|
||||
|
||||
// Create a test OIDCProvider.
|
||||
providerCtx := oidc.ClientContext(context.Background(), ts.Client())
|
||||
keySet := oidc.NewRemoteKeySet(providerCtx, ts.URL+"/.well-known/jwks.json")
|
||||
|
||||
return &provider{
|
||||
ts: ts,
|
||||
key: privKey,
|
||||
verifier: oidc.NewVerifier(ts.URL, keySet, &oidc.Config{
|
||||
ClientID: clientID, // matches audience in the token
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
// buildRSAJWK is a helper to construct a minimal JWK for the JWKS endpoint.
|
||||
func buildRSAJWK(t *testing.T, pub *rsa.PublicKey, kid string) map[string]any {
|
||||
t.Helper()
|
||||
|
||||
nBytes := pub.N.Bytes()
|
||||
eBytes := []byte{0x01, 0x00, 0x01} // Usually 65537
|
||||
|
||||
return map[string]any{
|
||||
"kty": "RSA",
|
||||
"alg": "RS256",
|
||||
"use": "sig",
|
||||
"kid": kid,
|
||||
"n": base64.RawURLEncoding.EncodeToString(nBytes),
|
||||
"e": base64.RawURLEncoding.EncodeToString(eBytes),
|
||||
}
|
||||
}
|
||||
|
||||
func cleanup() {
|
||||
defaultAuth = nil
|
||||
}
|
||||
|
||||
func TestOIDCLoginHandler(t *testing.T) {
|
||||
// Setup
|
||||
common.APIJWTSecret = []byte("test-secret")
|
||||
t.Cleanup(cleanup)
|
||||
setupMockOIDC(t)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
wantStatus int
|
||||
wantRedirect bool
|
||||
}{
|
||||
{
|
||||
name: "Success - Redirects to provider",
|
||||
wantStatus: http.StatusTemporaryRedirect,
|
||||
wantRedirect: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/auth/redirect", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
defaultAuth.RedirectLoginPage(w, req)
|
||||
|
||||
if got := w.Code; got != tt.wantStatus {
|
||||
t.Errorf("OIDCLoginHandler() status = %v, want %v", got, tt.wantStatus)
|
||||
}
|
||||
|
||||
if tt.wantRedirect {
|
||||
if loc := w.Header().Get("Location"); loc == "" {
|
||||
t.Error("OIDCLoginHandler() missing redirect location")
|
||||
}
|
||||
|
||||
cookie := w.Header().Get("Set-Cookie")
|
||||
if cookie == "" {
|
||||
t.Error("OIDCLoginHandler() missing state cookie")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOIDCCallbackHandler(t *testing.T) {
|
||||
// Setup
|
||||
common.APIJWTSecret = []byte("test-secret")
|
||||
t.Cleanup(cleanup)
|
||||
tests := []struct {
|
||||
name string
|
||||
state string
|
||||
code string
|
||||
setupMocks bool
|
||||
wantStatus int
|
||||
}{
|
||||
{
|
||||
name: "Success - Valid callback",
|
||||
state: "valid-state",
|
||||
code: "valid-code",
|
||||
setupMocks: true,
|
||||
wantStatus: http.StatusTemporaryRedirect,
|
||||
},
|
||||
{
|
||||
name: "Failure - Missing state",
|
||||
code: "valid-code",
|
||||
setupMocks: true,
|
||||
wantStatus: http.StatusBadRequest,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.setupMocks {
|
||||
setupMockOIDC(t)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/auth/callback?code="+tt.code+"&state="+tt.state, nil)
|
||||
if tt.state != "" {
|
||||
req.AddCookie(&http.Cookie{
|
||||
Name: CookieOauthState,
|
||||
Value: tt.state,
|
||||
})
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
defaultAuth.LoginCallbackHandler(w, req)
|
||||
|
||||
if got := w.Code; got != tt.wantStatus {
|
||||
t.Errorf("OIDCCallbackHandler() status = %v, want %v", got, tt.wantStatus)
|
||||
}
|
||||
|
||||
if tt.wantStatus == http.StatusTemporaryRedirect {
|
||||
setCookie := Must(http.ParseSetCookie(w.Header().Get("Set-Cookie")))
|
||||
ExpectEqual(t, setCookie.Name, defaultAuth.TokenCookieName())
|
||||
ExpectTrue(t, setCookie.Value != "")
|
||||
ExpectEqual(t, setCookie.Path, "/")
|
||||
ExpectEqual(t, setCookie.SameSite, http.SameSiteLaxMode)
|
||||
ExpectEqual(t, setCookie.HttpOnly, true)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitOIDC(t *testing.T) {
|
||||
setupMockOIDC(t)
|
||||
// Create a test server that serves the discovery document
|
||||
var server *httptest.Server
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
ExpectNoError(t, json.NewEncoder(w).Encode(discoveryDocument(t, server)))
|
||||
})
|
||||
server = httptest.NewServer(mux)
|
||||
t.Cleanup(server.Close)
|
||||
t.Cleanup(cleanup)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
issuerURL string
|
||||
clientID string
|
||||
clientSecret string
|
||||
redirectURL string
|
||||
logoutURL string
|
||||
allowedUsers []string
|
||||
allowedGroups []string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "Fail - Empty configuration",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "Success - Valid configuration with users",
|
||||
issuerURL: server.URL,
|
||||
clientID: "client_id",
|
||||
clientSecret: "client_secret",
|
||||
redirectURL: "https://example.com/callback",
|
||||
allowedUsers: []string{"user1", "user2"},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "Success - Valid configuration with groups",
|
||||
issuerURL: server.URL,
|
||||
clientID: "client_id",
|
||||
clientSecret: "client_secret",
|
||||
redirectURL: "https://example.com/callback",
|
||||
allowedGroups: []string{"group1", "group2"},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "Success - Valid configuration with users, groups and logout URL",
|
||||
issuerURL: server.URL,
|
||||
clientID: "client_id",
|
||||
clientSecret: "client_secret",
|
||||
redirectURL: "https://example.com/callback",
|
||||
logoutURL: "https://example.com/logout",
|
||||
allowedUsers: []string{"user1", "user2"},
|
||||
allowedGroups: []string{"group1", "group2"},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "Fail - No allowed users or allowed groups",
|
||||
issuerURL: "https://example.com",
|
||||
clientID: "client_id",
|
||||
clientSecret: "client_secret",
|
||||
redirectURL: "https://example.com/callback",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := NewOIDCProvider(tt.issuerURL, tt.clientID, tt.clientSecret, tt.redirectURL, tt.logoutURL, tt.allowedUsers, tt.allowedGroups)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("InitOIDC() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckToken(t *testing.T) {
|
||||
provider := setupProvider(t)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
allowedUsers []string
|
||||
allowedGroups []string
|
||||
claims jwt.Claims
|
||||
wantErr error
|
||||
}{
|
||||
{
|
||||
name: "Success - Valid token with allowed user",
|
||||
allowedUsers: []string{"user1"},
|
||||
claims: jwt.MapClaims{
|
||||
"iss": provider.ts.URL,
|
||||
"aud": clientID,
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
"preferred_username": "user1",
|
||||
"groups": []string{"group1"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Success - Valid token with allowed group",
|
||||
allowedGroups: []string{"group1"},
|
||||
claims: jwt.MapClaims{
|
||||
"iss": provider.ts.URL,
|
||||
"aud": clientID,
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
"preferred_username": "user1",
|
||||
"groups": []string{"group1"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Success - Server omits groups, but user is allowed",
|
||||
allowedUsers: []string{"user1"},
|
||||
claims: jwt.MapClaims{
|
||||
"iss": provider.ts.URL,
|
||||
"aud": clientID,
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
"preferred_username": "user1",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Success - Server omits preferred_username, but group is allowed",
|
||||
allowedGroups: []string{"group1"},
|
||||
claims: jwt.MapClaims{
|
||||
"iss": provider.ts.URL,
|
||||
"aud": clientID,
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
"groups": []string{"group1"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Success - Valid token with allowed user and group",
|
||||
allowedUsers: []string{"user1"},
|
||||
allowedGroups: []string{"group1"},
|
||||
claims: jwt.MapClaims{
|
||||
"iss": provider.ts.URL,
|
||||
"aud": clientID,
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
"preferred_username": "user1",
|
||||
"groups": []string{"group1"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Error - User not allowed",
|
||||
allowedUsers: []string{"user2", "user3"},
|
||||
allowedGroups: []string{"group2", "group3"},
|
||||
claims: jwt.MapClaims{
|
||||
"iss": provider.ts.URL,
|
||||
"aud": clientID,
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
"preferred_username": "user1",
|
||||
"groups": []string{"group1"},
|
||||
},
|
||||
wantErr: ErrUserNotAllowed,
|
||||
},
|
||||
{
|
||||
name: "Error - Server returns incorrect issuer",
|
||||
claims: jwt.MapClaims{
|
||||
"iss": "https://example.com",
|
||||
"aud": clientID,
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
"preferred_username": "user1",
|
||||
"groups": []string{"group1"},
|
||||
},
|
||||
wantErr: ErrInvalidToken,
|
||||
},
|
||||
{
|
||||
name: "Error - Server returns incorrect audience",
|
||||
claims: jwt.MapClaims{
|
||||
"iss": provider.ts.URL,
|
||||
"aud": "some-other-audience",
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
"preferred_username": "user1",
|
||||
"groups": []string{"group1"},
|
||||
},
|
||||
wantErr: ErrInvalidToken,
|
||||
},
|
||||
{
|
||||
name: "Error - Server returns expired token",
|
||||
claims: jwt.MapClaims{
|
||||
"iss": provider.ts.URL,
|
||||
"aud": clientID,
|
||||
"exp": time.Now().Add(-time.Hour).Unix(),
|
||||
"preferred_username": "user1",
|
||||
"groups": []string{"group1"},
|
||||
},
|
||||
wantErr: ErrInvalidToken,
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// Create the Auth Provider.
|
||||
auth := &OIDCProvider{
|
||||
oidcVerifier: provider.verifier,
|
||||
allowedUsers: tc.allowedUsers,
|
||||
allowedGroups: tc.allowedGroups,
|
||||
}
|
||||
// Sign the claims to create a token.
|
||||
signedToken := provider.SignClaims(t, tc.claims)
|
||||
// Craft a test HTTP request that includes the token as a cookie.
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.AddCookie(&http.Cookie{
|
||||
Name: auth.TokenCookieName(),
|
||||
Value: signedToken,
|
||||
})
|
||||
|
||||
// Call CheckToken and verify the result.
|
||||
err := auth.CheckToken(req)
|
||||
if tc.wantErr == nil {
|
||||
ExpectNoError(t, err)
|
||||
} else {
|
||||
ExpectError(t, tc.wantErr, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
13
internal/api/v1/auth/provider.go
Normal file
13
internal/api/v1/auth/provider.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type Provider interface {
|
||||
TokenCookieName() string
|
||||
CheckToken(r *http.Request) error
|
||||
RedirectLoginPage(w http.ResponseWriter, r *http.Request)
|
||||
LoginCallbackHandler(w http.ResponseWriter, r *http.Request)
|
||||
LogoutCallbackHandler(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
143
internal/api/v1/auth/userpass.go
Normal file
143
internal/api/v1/auth/userpass.go
Normal file
@@ -0,0 +1,143 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"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"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidUsername = E.New("invalid username")
|
||||
ErrInvalidPassword = E.New("invalid password")
|
||||
)
|
||||
|
||||
type (
|
||||
UserPassAuth struct {
|
||||
username string
|
||||
pwdHash []byte
|
||||
secret []byte
|
||||
tokenTTL time.Duration
|
||||
}
|
||||
UserPassClaims struct {
|
||||
Username string `json:"username"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
)
|
||||
|
||||
func NewUserPassAuth(username, password string, secret []byte, tokenTTL time.Duration) (*UserPassAuth, error) {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &UserPassAuth{
|
||||
username: username,
|
||||
pwdHash: hash,
|
||||
secret: secret,
|
||||
tokenTTL: tokenTTL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func NewUserPassAuthFromEnv() (*UserPassAuth, error) {
|
||||
return NewUserPassAuth(
|
||||
common.APIUser,
|
||||
common.APIPassword,
|
||||
common.APIJWTSecret,
|
||||
common.APIJWTTokenTTL,
|
||||
)
|
||||
}
|
||||
|
||||
func (auth *UserPassAuth) TokenCookieName() string {
|
||||
return "godoxy_token"
|
||||
}
|
||||
|
||||
func (auth *UserPassAuth) NewToken() (token string, err error) {
|
||||
claim := &UserPassClaims{
|
||||
Username: auth.username,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(auth.tokenTTL)),
|
||||
},
|
||||
}
|
||||
tok := jwt.NewWithClaims(jwt.SigningMethodHS512, claim)
|
||||
token, err = tok.SignedString(auth.secret)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func (auth *UserPassAuth) CheckToken(r *http.Request) error {
|
||||
jwtCookie, err := r.Cookie(auth.TokenCookieName())
|
||||
if err != nil {
|
||||
return ErrMissingToken
|
||||
}
|
||||
var claims UserPassClaims
|
||||
token, err := jwt.ParseWithClaims(jwtCookie.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 auth.secret, nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch {
|
||||
case !token.Valid:
|
||||
return ErrInvalidToken
|
||||
case claims.Username != auth.username:
|
||||
return ErrUserNotAllowed.Subject(claims.Username)
|
||||
case claims.ExpiresAt.Before(time.Now()):
|
||||
return E.Errorf("token expired on %s", strutils.FormatTime(claims.ExpiresAt.Time))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (auth *UserPassAuth) RedirectLoginPage(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/login", http.StatusTemporaryRedirect)
|
||||
}
|
||||
|
||||
func (auth *UserPassAuth) LoginCallbackHandler(w http.ResponseWriter, r *http.Request) {
|
||||
var creds struct {
|
||||
User string `json:"username"`
|
||||
Pass string `json:"password"`
|
||||
}
|
||||
err := json.NewDecoder(r.Body).Decode(&creds)
|
||||
if err != nil {
|
||||
U.HandleErr(w, r, err, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := auth.validatePassword(creds.User, creds.Pass); err != nil {
|
||||
U.LogError(r).Err(err).Msg("auth: invalid credentials")
|
||||
U.RespondError(w, E.New("invalid credentials"), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
token, err := auth.NewToken()
|
||||
if err != nil {
|
||||
U.HandleErr(w, r, err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
setTokenCookie(w, r, auth.TokenCookieName(), token, auth.tokenTTL)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func (auth *UserPassAuth) LogoutCallbackHandler(w http.ResponseWriter, r *http.Request) {
|
||||
DefaultLogoutCallbackHandler(auth, w, r)
|
||||
}
|
||||
|
||||
func (auth *UserPassAuth) validatePassword(user, pass string) error {
|
||||
if user != auth.username {
|
||||
return ErrInvalidUsername.Subject(user)
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword(auth.pwdHash, []byte(pass)); err != nil {
|
||||
return ErrInvalidPassword.With(err).Subject(pass)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
115
internal/api/v1/auth/userpass_test.go
Normal file
115
internal/api/v1/auth/userpass_test.go
Normal file
@@ -0,0 +1,115 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
. "github.com/yusing/go-proxy/internal/utils/testing"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func newMockUserPassAuth() *UserPassAuth {
|
||||
return &UserPassAuth{
|
||||
username: "username",
|
||||
pwdHash: Must(bcrypt.GenerateFromPassword([]byte("password"), bcrypt.DefaultCost)),
|
||||
secret: []byte("abcdefghijklmnopqrstuvwxyz"),
|
||||
tokenTTL: time.Hour,
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserPassValidateCredentials(t *testing.T) {
|
||||
auth := newMockUserPassAuth()
|
||||
err := auth.validatePassword("username", "password")
|
||||
ExpectNoError(t, err)
|
||||
err = auth.validatePassword("username", "wrong-password")
|
||||
ExpectError(t, ErrInvalidPassword, err)
|
||||
err = auth.validatePassword("wrong-username", "password")
|
||||
ExpectError(t, ErrInvalidUsername, err)
|
||||
}
|
||||
|
||||
func TestUserPassCheckToken(t *testing.T) {
|
||||
auth := newMockUserPassAuth()
|
||||
token, err := auth.NewToken()
|
||||
ExpectNoError(t, err)
|
||||
tests := []struct {
|
||||
token string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
token: token,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
token: "invalid-token",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
token: "",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
req := &http.Request{Header: http.Header{}}
|
||||
if tt.token != "" {
|
||||
req.Header.Set("Cookie", auth.TokenCookieName()+"="+tt.token)
|
||||
}
|
||||
err = auth.CheckToken(req)
|
||||
if tt.wantErr {
|
||||
ExpectTrue(t, err != nil)
|
||||
} else {
|
||||
ExpectNoError(t, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserPassLoginCallbackHandler(t *testing.T) {
|
||||
type cred struct {
|
||||
User string `json:"username"`
|
||||
Pass string `json:"password"`
|
||||
}
|
||||
auth := newMockUserPassAuth()
|
||||
tests := []struct {
|
||||
creds cred
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
creds: cred{
|
||||
User: "username",
|
||||
Pass: "password",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
creds: cred{
|
||||
User: "username",
|
||||
Pass: "wrong-password",
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
w := httptest.NewRecorder()
|
||||
req := &http.Request{
|
||||
Host: "app.example.com",
|
||||
Body: io.NopCloser(bytes.NewReader(Must(json.Marshal(tt.creds)))),
|
||||
}
|
||||
auth.LoginCallbackHandler(w, req)
|
||||
if tt.wantErr {
|
||||
ExpectEqual(t, w.Code, http.StatusUnauthorized)
|
||||
} else {
|
||||
setCookie := Must(http.ParseSetCookie(w.Header().Get("Set-Cookie")))
|
||||
ExpectTrue(t, setCookie.Name == auth.TokenCookieName())
|
||||
ExpectTrue(t, setCookie.Value != "")
|
||||
ExpectEqual(t, setCookie.Domain, "example.com")
|
||||
ExpectEqual(t, setCookie.Path, "/")
|
||||
ExpectEqual(t, setCookie.SameSite, http.SameSiteLaxMode)
|
||||
ExpectEqual(t, setCookie.HttpOnly, true)
|
||||
ExpectEqual(t, w.Code, http.StatusOK)
|
||||
}
|
||||
}
|
||||
}
|
||||
69
internal/api/v1/auth/utils.go
Normal file
69
internal/api/v1/auth/utils.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
E "github.com/yusing/go-proxy/internal/error"
|
||||
"github.com/yusing/go-proxy/internal/utils/strutils"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrMissingToken = E.New("missing token")
|
||||
ErrInvalidToken = E.New("invalid token")
|
||||
ErrUserNotAllowed = E.New("user not allowed")
|
||||
)
|
||||
|
||||
// cookieFQDN returns the fully qualified domain name of the request host
|
||||
// with subdomain stripped.
|
||||
//
|
||||
// If the request host does not have a subdomain,
|
||||
// an empty string is returned
|
||||
//
|
||||
// "abc.example.com" -> "example.com"
|
||||
// "example.com" -> ""
|
||||
func cookieFQDN(r *http.Request) string {
|
||||
host, _, err := net.SplitHostPort(r.Host)
|
||||
if err != nil {
|
||||
host = r.Host
|
||||
}
|
||||
parts := strutils.SplitRune(host, '.')
|
||||
if len(parts) < 2 {
|
||||
return ""
|
||||
}
|
||||
parts[0] = ""
|
||||
return strutils.JoinRune(parts, '.')
|
||||
}
|
||||
|
||||
func setTokenCookie(w http.ResponseWriter, r *http.Request, name, value string, ttl time.Duration) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: name,
|
||||
Value: value,
|
||||
MaxAge: int(ttl.Seconds()),
|
||||
Domain: cookieFQDN(r),
|
||||
HttpOnly: true,
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Path: "/",
|
||||
})
|
||||
}
|
||||
|
||||
func clearTokenCookie(w http.ResponseWriter, r *http.Request, name string) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: name,
|
||||
Value: "",
|
||||
MaxAge: -1,
|
||||
Domain: cookieFQDN(r),
|
||||
HttpOnly: true,
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Path: "/",
|
||||
})
|
||||
}
|
||||
|
||||
// DefaultLogoutCallbackHandler clears the token cookie and redirects to the login page..
|
||||
func DefaultLogoutCallbackHandler(auth Provider, w http.ResponseWriter, r *http.Request) {
|
||||
clearTokenCookie(w, r, auth.TokenCookieName())
|
||||
auth.RedirectLoginPage(w, r)
|
||||
}
|
||||
136
internal/api/v1/favicon/cache.go
Normal file
136
internal/api/v1/favicon/cache.go
Normal file
@@ -0,0 +1,136 @@
|
||||
package favicon
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/yusing/go-proxy/internal/common"
|
||||
"github.com/yusing/go-proxy/internal/logging"
|
||||
route "github.com/yusing/go-proxy/internal/route/types"
|
||||
"github.com/yusing/go-proxy/internal/task"
|
||||
"github.com/yusing/go-proxy/internal/utils"
|
||||
)
|
||||
|
||||
type cacheEntry struct {
|
||||
Icon []byte `json:"icon"`
|
||||
LastAccess time.Time `json:"lastAccess"`
|
||||
}
|
||||
|
||||
// cache key can be absolute url or route name.
|
||||
var (
|
||||
iconCache = make(map[string]*cacheEntry)
|
||||
iconCacheMu sync.RWMutex
|
||||
)
|
||||
|
||||
const (
|
||||
iconCacheTTL = 24 * time.Hour
|
||||
cleanUpInterval = time.Hour
|
||||
)
|
||||
|
||||
func InitIconCache() {
|
||||
iconCacheMu.Lock()
|
||||
defer iconCacheMu.Unlock()
|
||||
|
||||
err := utils.LoadJSONIfExist(common.IconCachePath, &iconCache)
|
||||
if err != nil {
|
||||
logging.Error().Err(err).Msg("failed to load icon cache")
|
||||
} else {
|
||||
logging.Info().Msgf("icon cache loaded (%d icons)", len(iconCache))
|
||||
}
|
||||
|
||||
go func() {
|
||||
cleanupTicker := time.NewTicker(cleanUpInterval)
|
||||
defer cleanupTicker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-task.RootContextCanceled():
|
||||
return
|
||||
case <-cleanupTicker.C:
|
||||
pruneExpiredIconCache()
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
task.OnProgramExit("save_favicon_cache", func() {
|
||||
iconCacheMu.Lock()
|
||||
defer iconCacheMu.Unlock()
|
||||
|
||||
if len(iconCache) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
if err := utils.SaveJSON(common.IconCachePath, &iconCache, 0o644); err != nil {
|
||||
logging.Error().Err(err).Msg("failed to save icon cache")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func pruneExpiredIconCache() {
|
||||
iconCacheMu.Lock()
|
||||
defer iconCacheMu.Unlock()
|
||||
|
||||
nPruned := 0
|
||||
for key, icon := range iconCache {
|
||||
if icon.IsExpired() {
|
||||
delete(iconCache, key)
|
||||
nPruned++
|
||||
}
|
||||
}
|
||||
logging.Info().Int("pruned", nPruned).Msg("pruned expired icon cache")
|
||||
}
|
||||
|
||||
func routeKey(r route.HTTPRoute) string {
|
||||
return r.ProviderName() + ":" + r.TargetName()
|
||||
}
|
||||
|
||||
func PruneRouteIconCache(route route.HTTPRoute) {
|
||||
iconCacheMu.Lock()
|
||||
defer iconCacheMu.Unlock()
|
||||
delete(iconCache, routeKey(route))
|
||||
}
|
||||
|
||||
func loadIconCache(key string) *fetchResult {
|
||||
iconCacheMu.RLock()
|
||||
defer iconCacheMu.RUnlock()
|
||||
|
||||
icon, ok := iconCache[key]
|
||||
if ok && icon != nil {
|
||||
logging.Debug().
|
||||
Str("key", key).
|
||||
Msg("icon found in cache")
|
||||
icon.LastAccess = time.Now()
|
||||
return &fetchResult{icon: icon.Icon}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func storeIconCache(key string, icon []byte) {
|
||||
iconCacheMu.Lock()
|
||||
defer iconCacheMu.Unlock()
|
||||
iconCache[key] = &cacheEntry{Icon: icon, LastAccess: time.Now()}
|
||||
}
|
||||
|
||||
func (e *cacheEntry) IsExpired() bool {
|
||||
return time.Since(e.LastAccess) > iconCacheTTL
|
||||
}
|
||||
|
||||
func (e *cacheEntry) UnmarshalJSON(data []byte) error {
|
||||
attempt := struct {
|
||||
Icon []byte `json:"icon"`
|
||||
LastAccess time.Time `json:"lastAccess"`
|
||||
}{}
|
||||
err := json.Unmarshal(data, &attempt)
|
||||
if err == nil {
|
||||
e.Icon = attempt.Icon
|
||||
e.LastAccess = attempt.LastAccess
|
||||
return nil
|
||||
}
|
||||
// fallback to bytes
|
||||
err = json.Unmarshal(data, &e.Icon)
|
||||
if err == nil {
|
||||
e.LastAccess = time.Now()
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
37
internal/api/v1/favicon/content.go
Normal file
37
internal/api/v1/favicon/content.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package favicon
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type content struct {
|
||||
header http.Header
|
||||
data []byte
|
||||
status int
|
||||
}
|
||||
|
||||
func newContent() *content {
|
||||
return &content{
|
||||
header: make(http.Header),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *content) Header() http.Header {
|
||||
return c.header
|
||||
}
|
||||
|
||||
func (c *content) Write(data []byte) (int, error) {
|
||||
c.data = append(c.data, data...)
|
||||
return len(data), nil
|
||||
}
|
||||
|
||||
func (c *content) WriteHeader(statusCode int) {
|
||||
c.status = statusCode
|
||||
}
|
||||
|
||||
func (c *content) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
||||
return nil, nil, errors.New("not supported")
|
||||
}
|
||||
284
internal/api/v1/favicon/favicon.go
Normal file
284
internal/api/v1/favicon/favicon.go
Normal file
@@ -0,0 +1,284 @@
|
||||
package favicon
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
"github.com/vincent-petithory/dataurl"
|
||||
U "github.com/yusing/go-proxy/internal/api/v1/utils"
|
||||
"github.com/yusing/go-proxy/internal/homepage"
|
||||
"github.com/yusing/go-proxy/internal/logging"
|
||||
gphttp "github.com/yusing/go-proxy/internal/net/http"
|
||||
"github.com/yusing/go-proxy/internal/route/routes"
|
||||
route "github.com/yusing/go-proxy/internal/route/types"
|
||||
)
|
||||
|
||||
type fetchResult struct {
|
||||
icon []byte
|
||||
contentType string
|
||||
statusCode int
|
||||
errMsg string
|
||||
}
|
||||
|
||||
func (res *fetchResult) OK() bool {
|
||||
return res.icon != nil
|
||||
}
|
||||
|
||||
func (res *fetchResult) ContentType() string {
|
||||
if res.contentType == "" {
|
||||
if bytes.HasPrefix(res.icon, []byte("<svg")) || bytes.HasPrefix(res.icon, []byte("<?xml")) {
|
||||
return "image/svg+xml"
|
||||
} else {
|
||||
return "image/x-icon"
|
||||
}
|
||||
}
|
||||
return res.contentType
|
||||
}
|
||||
|
||||
// GetFavIcon returns the favicon of the route
|
||||
//
|
||||
// Returns:
|
||||
// - 200 OK: if icon found
|
||||
// - 400 Bad Request: if alias is empty or route is not HTTPRoute
|
||||
// - 404 Not Found: if route or icon not found
|
||||
// - 500 Internal Server Error: if internal error
|
||||
// - others: depends on route handler response
|
||||
func GetFavIcon(w http.ResponseWriter, req *http.Request) {
|
||||
url, alias := req.FormValue("url"), req.FormValue("alias")
|
||||
if url == "" && alias == "" {
|
||||
U.RespondError(w, U.ErrMissingKey("url or alias"), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if url != "" && alias != "" {
|
||||
U.RespondError(w, U.ErrInvalidKey("url and alias are mutually exclusive"), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// try with url
|
||||
if url != "" {
|
||||
var iconURL homepage.IconURL
|
||||
if err := iconURL.Parse(url); err != nil {
|
||||
U.RespondError(w, err, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
fetchResult := getFavIconFromURL(&iconURL)
|
||||
if !fetchResult.OK() {
|
||||
http.Error(w, fetchResult.errMsg, fetchResult.statusCode)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", fetchResult.ContentType())
|
||||
U.WriteBody(w, fetchResult.icon)
|
||||
return
|
||||
}
|
||||
|
||||
// try with route.Homepage.Icon
|
||||
r, ok := routes.GetHTTPRoute(alias)
|
||||
if !ok {
|
||||
U.RespondError(w, errors.New("no such route"), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
var result *fetchResult
|
||||
hp := r.HomepageConfig().GetOverride()
|
||||
if !hp.IsEmpty() && hp.Icon != nil {
|
||||
if hp.Icon.IconSource == homepage.IconSourceRelative {
|
||||
result = findIcon(r, req, hp.Icon.Value)
|
||||
} else {
|
||||
result = getFavIconFromURL(hp.Icon)
|
||||
}
|
||||
} else {
|
||||
// try extract from "link[rel=icon]"
|
||||
result = findIcon(r, req, "/")
|
||||
}
|
||||
if result.statusCode == 0 {
|
||||
result.statusCode = http.StatusOK
|
||||
}
|
||||
if !result.OK() {
|
||||
http.Error(w, result.errMsg, result.statusCode)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", result.ContentType())
|
||||
U.WriteBody(w, result.icon)
|
||||
}
|
||||
|
||||
func getFavIconFromURL(iconURL *homepage.IconURL) *fetchResult {
|
||||
switch iconURL.IconSource {
|
||||
case homepage.IconSourceAbsolute:
|
||||
return fetchIconAbsolute(iconURL.URL())
|
||||
case homepage.IconSourceRelative:
|
||||
return &fetchResult{statusCode: http.StatusBadRequest, errMsg: "unexpected relative icon"}
|
||||
case homepage.IconSourceWalkXCode, homepage.IconSourceSelfhSt:
|
||||
return fetchKnownIcon(iconURL)
|
||||
}
|
||||
return &fetchResult{statusCode: http.StatusBadRequest, errMsg: "invalid icon source"}
|
||||
}
|
||||
|
||||
func fetchIconAbsolute(url string) *fetchResult {
|
||||
if result := loadIconCache(url); result != nil {
|
||||
return result
|
||||
}
|
||||
|
||||
resp, err := U.Get(url)
|
||||
if err != nil || resp.StatusCode != http.StatusOK {
|
||||
if err == nil {
|
||||
err = errors.New(resp.Status)
|
||||
}
|
||||
logging.Error().Err(err).
|
||||
Str("url", url).
|
||||
Msg("failed to get icon")
|
||||
return &fetchResult{statusCode: http.StatusBadGateway, errMsg: "connection error"}
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
icon, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
logging.Error().Err(err).
|
||||
Str("url", url).
|
||||
Msg("failed to read icon")
|
||||
return &fetchResult{statusCode: http.StatusInternalServerError, errMsg: "internal error"}
|
||||
}
|
||||
|
||||
storeIconCache(url, icon)
|
||||
return &fetchResult{icon: icon}
|
||||
}
|
||||
|
||||
var nameSanitizer = strings.NewReplacer(
|
||||
"_", "-",
|
||||
" ", "-",
|
||||
"(", "",
|
||||
")", "",
|
||||
)
|
||||
|
||||
func sanitizeName(name string) string {
|
||||
return strings.ToLower(nameSanitizer.Replace(name))
|
||||
}
|
||||
|
||||
func fetchKnownIcon(url *homepage.IconURL) *fetchResult {
|
||||
// if icon isn't in the list, no need to fetch
|
||||
if !url.HasIcon() {
|
||||
logging.Debug().
|
||||
Str("value", url.String()).
|
||||
Str("url", url.URL()).
|
||||
Msg("no such icon")
|
||||
return &fetchResult{statusCode: http.StatusNotFound, errMsg: "no such icon"}
|
||||
}
|
||||
|
||||
return fetchIconAbsolute(url.URL())
|
||||
}
|
||||
|
||||
func fetchIcon(filetype, filename string) *fetchResult {
|
||||
result := fetchKnownIcon(homepage.NewSelfhStIconURL(filename, filetype))
|
||||
if result.icon == nil {
|
||||
return result
|
||||
}
|
||||
return fetchKnownIcon(homepage.NewWalkXCodeIconURL(filename, filetype))
|
||||
}
|
||||
|
||||
func findIcon(r route.HTTPRoute, req *http.Request, uri string) *fetchResult {
|
||||
key := routeKey(r)
|
||||
if result := loadIconCache(key); result != nil {
|
||||
return result
|
||||
}
|
||||
|
||||
result := fetchIcon("png", sanitizeName(r.TargetName()))
|
||||
cont := r.ContainerInfo()
|
||||
if !result.OK() && cont != nil {
|
||||
result = fetchIcon("png", sanitizeName(cont.ImageName))
|
||||
}
|
||||
if !result.OK() {
|
||||
// fallback to parse html
|
||||
result = findIconSlow(r, req, uri)
|
||||
}
|
||||
if result.OK() {
|
||||
storeIconCache(key, result.icon)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func findIconSlow(r route.HTTPRoute, req *http.Request, uri string) *fetchResult {
|
||||
ctx, cancel := context.WithTimeoutCause(req.Context(), 3*time.Second, errors.New("favicon request timeout"))
|
||||
defer cancel()
|
||||
newReq := req.WithContext(ctx)
|
||||
newReq.Header.Set("Accept-Encoding", "identity") // disable compression
|
||||
if !strings.HasPrefix(uri, "/") {
|
||||
uri = "/" + uri
|
||||
}
|
||||
u, err := url.ParseRequestURI(uri)
|
||||
if err != nil {
|
||||
logging.Error().Err(err).
|
||||
Str("route", r.TargetName()).
|
||||
Str("path", uri).
|
||||
Msg("failed to parse uri")
|
||||
return &fetchResult{statusCode: http.StatusInternalServerError, errMsg: "cannot parse uri"}
|
||||
}
|
||||
newReq.URL.Path = u.Path
|
||||
newReq.URL.RawPath = u.RawPath
|
||||
newReq.URL.RawQuery = u.RawQuery
|
||||
newReq.RequestURI = u.String()
|
||||
|
||||
c := newContent()
|
||||
r.ServeHTTP(c, newReq)
|
||||
if c.status != http.StatusOK {
|
||||
switch c.status {
|
||||
case 0:
|
||||
return &fetchResult{statusCode: http.StatusBadGateway, errMsg: "connection error"}
|
||||
default:
|
||||
if loc := c.Header().Get("Location"); loc != "" {
|
||||
loc = path.Clean(loc)
|
||||
if !strings.HasPrefix(loc, "/") {
|
||||
loc = "/" + loc
|
||||
}
|
||||
if loc == newReq.URL.Path {
|
||||
return &fetchResult{statusCode: http.StatusBadGateway, errMsg: "circular redirect"}
|
||||
}
|
||||
return findIconSlow(r, req, loc)
|
||||
}
|
||||
}
|
||||
return &fetchResult{statusCode: c.status, errMsg: "upstream error: " + string(c.data)}
|
||||
}
|
||||
// return icon data
|
||||
if !gphttp.GetContentType(c.header).IsHTML() {
|
||||
return &fetchResult{icon: c.data, contentType: c.header.Get("Content-Type")}
|
||||
}
|
||||
// try extract from "link[rel=icon]" from path "/"
|
||||
doc, err := goquery.NewDocumentFromReader(bytes.NewBuffer(c.data))
|
||||
if err != nil {
|
||||
logging.Error().Err(err).
|
||||
Str("route", r.TargetName()).
|
||||
Msg("failed to parse html")
|
||||
return &fetchResult{statusCode: http.StatusInternalServerError, errMsg: "internal error"}
|
||||
}
|
||||
ele := doc.Find("head > link[rel=icon]").First()
|
||||
if ele.Length() == 0 {
|
||||
return &fetchResult{statusCode: http.StatusNotFound, errMsg: "icon element not found"}
|
||||
}
|
||||
href := ele.AttrOr("href", "")
|
||||
if href == "" {
|
||||
return &fetchResult{statusCode: http.StatusNotFound, errMsg: "icon href not found"}
|
||||
}
|
||||
// https://en.wikipedia.org/wiki/Data_URI_scheme
|
||||
if strings.HasPrefix(href, "data:image/") {
|
||||
dataURI, err := dataurl.DecodeString(href)
|
||||
if err != nil {
|
||||
logging.Error().Err(err).
|
||||
Str("route", r.TargetName()).
|
||||
Msg("failed to decode favicon")
|
||||
return &fetchResult{statusCode: http.StatusInternalServerError, errMsg: "internal error"}
|
||||
}
|
||||
return &fetchResult{icon: dataURI.Data, contentType: dataURI.ContentType()}
|
||||
}
|
||||
switch {
|
||||
case strings.HasPrefix(href, "http://"), strings.HasPrefix(href, "https://"):
|
||||
return fetchIconAbsolute(href)
|
||||
default:
|
||||
return findIconSlow(r, req, path.Clean(href))
|
||||
}
|
||||
}
|
||||
@@ -9,17 +9,65 @@ 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"
|
||||
config "github.com/yusing/go-proxy/internal/config/types"
|
||||
E "github.com/yusing/go-proxy/internal/error"
|
||||
"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
|
||||
@@ -27,10 +75,42 @@ func GetFileContent(w http.ResponseWriter, r *http.Request) {
|
||||
U.WriteBody(w, content)
|
||||
}
|
||||
|
||||
func validateFile(fileType FileType, content []byte) error {
|
||||
switch fileType {
|
||||
case FileTypeConfig:
|
||||
return config.Validate(content)
|
||||
case FileTypeMiddleware:
|
||||
errs := E.NewBuilder("middleware errors")
|
||||
middleware.BuildMiddlewaresFromYAML("", content, errs)
|
||||
return errs.Error()
|
||||
}
|
||||
return provider.Validate(content)
|
||||
}
|
||||
|
||||
func ValidateFile(w http.ResponseWriter, r *http.Request) {
|
||||
fileType := FileType(r.PathValue("type"))
|
||||
if !fileType.IsValid() {
|
||||
U.RespondError(w, U.ErrInvalidKey("type"), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
content, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
U.HandleErr(w, r, err)
|
||||
return
|
||||
}
|
||||
r.Body.Close()
|
||||
err = validateFile(fileType, content)
|
||||
if err != nil {
|
||||
U.RespondError(w, err, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
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,20 +119,12 @@ func SetFileContent(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
var valErr E.Error
|
||||
if filename == common.ConfigFileName {
|
||||
valErr = config.Validate(content)
|
||||
} else if !strings.HasPrefix(filename, path.Base(common.MiddlewareComposeBasePath)) {
|
||||
valErr = provider.Validate(content)
|
||||
}
|
||||
// no validation for include files
|
||||
|
||||
if valErr != nil {
|
||||
U.RespondJSON(w, r, valErr, http.StatusBadRequest)
|
||||
if valErr := validateFile(fileType, content); 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
|
||||
|
||||
18
internal/api/v1/health.go
Normal file
18
internal/api/v1/health.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
"github.com/coder/websocket/wsjson"
|
||||
U "github.com/yusing/go-proxy/internal/api/v1/utils"
|
||||
config "github.com/yusing/go-proxy/internal/config/types"
|
||||
"github.com/yusing/go-proxy/internal/route/routes/routequery"
|
||||
)
|
||||
|
||||
func HealthWS(cfg config.ConfigInstance, w http.ResponseWriter, r *http.Request) {
|
||||
U.PeriodicWS(cfg, w, r, 1*time.Second, func(conn *websocket.Conn) error {
|
||||
return wsjson.Write(r.Context(), conn, routequery.HealthMap())
|
||||
})
|
||||
}
|
||||
90
internal/api/v1/homepage_overrides.go
Normal file
90
internal/api/v1/homepage_overrides.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/yusing/go-proxy/internal/api/v1/utils"
|
||||
"github.com/yusing/go-proxy/internal/homepage"
|
||||
)
|
||||
|
||||
const (
|
||||
HomepageOverrideItem = "item"
|
||||
HomepageOverrideItemsBatch = "items_batch"
|
||||
HomepageOverrideCategoryOrder = "category_order"
|
||||
HomepageOverrideItemVisible = "item_visible"
|
||||
)
|
||||
|
||||
type (
|
||||
HomepageOverrideItemParams struct {
|
||||
Which string `json:"which"`
|
||||
Value homepage.ItemConfig `json:"value"`
|
||||
}
|
||||
HomepageOverrideItemsBatchParams struct {
|
||||
Value map[string]*homepage.ItemConfig `json:"value"`
|
||||
}
|
||||
HomepageOverrideCategoryOrderParams struct {
|
||||
Which string `json:"which"`
|
||||
Value int `json:"value"`
|
||||
}
|
||||
HomepageOverrideItemVisibleParams struct {
|
||||
Which []string `json:"which"`
|
||||
Value bool `json:"value"`
|
||||
}
|
||||
)
|
||||
|
||||
func SetHomePageOverrides(w http.ResponseWriter, r *http.Request) {
|
||||
what := r.FormValue("what")
|
||||
if what == "" {
|
||||
http.Error(w, "missing what or which", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
utils.RespondError(w, err, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
r.Body.Close()
|
||||
|
||||
overrides := homepage.GetOverrideConfig()
|
||||
switch what {
|
||||
case HomepageOverrideItem:
|
||||
var params HomepageOverrideItemParams
|
||||
if err := json.Unmarshal(data, ¶ms); err != nil {
|
||||
utils.RespondError(w, err, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
overrides.OverrideItem(params.Which, ¶ms.Value)
|
||||
case HomepageOverrideItemsBatch:
|
||||
var params HomepageOverrideItemsBatchParams
|
||||
if err := json.Unmarshal(data, ¶ms); err != nil {
|
||||
utils.RespondError(w, err, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
overrides.OverrideItems(params.Value)
|
||||
case HomepageOverrideItemVisible: // POST /v1/item_visible [a,b,c], false => hide a, b, c
|
||||
var params HomepageOverrideItemVisibleParams
|
||||
if err := json.Unmarshal(data, ¶ms); err != nil {
|
||||
utils.RespondError(w, err, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if params.Value {
|
||||
overrides.UnhideItems(params.Which...)
|
||||
} else {
|
||||
overrides.HideItems(params.Which...)
|
||||
}
|
||||
case HomepageOverrideCategoryOrder:
|
||||
var params HomepageOverrideCategoryOrderParams
|
||||
if err := json.Unmarshal(data, ¶ms); err != nil {
|
||||
utils.RespondError(w, err, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
overrides.SetCategoryOrder(params.Which, params.Value)
|
||||
default:
|
||||
http.Error(w, "invalid what", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
@@ -2,29 +2,35 @@ package v1
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/yusing/go-proxy/internal"
|
||||
U "github.com/yusing/go-proxy/internal/api/v1/utils"
|
||||
"github.com/yusing/go-proxy/internal/common"
|
||||
"github.com/yusing/go-proxy/internal/config"
|
||||
config "github.com/yusing/go-proxy/internal/config/types"
|
||||
"github.com/yusing/go-proxy/internal/net/http/middleware"
|
||||
"github.com/yusing/go-proxy/internal/route"
|
||||
"github.com/yusing/go-proxy/internal/route/routes/routequery"
|
||||
route "github.com/yusing/go-proxy/internal/route/types"
|
||||
"github.com/yusing/go-proxy/internal/task"
|
||||
"github.com/yusing/go-proxy/internal/utils"
|
||||
)
|
||||
|
||||
const (
|
||||
ListRoute = "route"
|
||||
ListRoutes = "routes"
|
||||
ListConfigFiles = "config_files"
|
||||
ListMiddlewares = "middlewares"
|
||||
ListMiddlewareTraces = "middleware_trace"
|
||||
ListMatchDomains = "match_domains"
|
||||
ListHomepageConfig = "homepage_config"
|
||||
ListTasks = "tasks"
|
||||
ListRoute = "route"
|
||||
ListRoutes = "routes"
|
||||
ListFiles = "files"
|
||||
ListMiddlewares = "middlewares"
|
||||
ListMiddlewareTraces = "middleware_trace"
|
||||
ListMatchDomains = "match_domains"
|
||||
ListHomepageConfig = "homepage_config"
|
||||
ListRouteProviders = "route_providers"
|
||||
ListHomepageCategories = "homepage_categories"
|
||||
ListIcons = "icons"
|
||||
ListTasks = "tasks"
|
||||
)
|
||||
|
||||
func List(w http.ResponseWriter, r *http.Request) {
|
||||
func List(cfg config.ConfigInstance, w http.ResponseWriter, r *http.Request) {
|
||||
what := r.PathValue("what")
|
||||
if what == "" {
|
||||
what = ListRoutes
|
||||
@@ -34,38 +40,55 @@ func List(w http.ResponseWriter, r *http.Request) {
|
||||
switch what {
|
||||
case ListRoute:
|
||||
if route := listRoute(which); route == nil {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
} else {
|
||||
U.RespondJSON(w, r, route)
|
||||
}
|
||||
case ListRoutes:
|
||||
U.RespondJSON(w, r, config.RoutesByAlias(route.RouteType(r.FormValue("type"))))
|
||||
case ListConfigFiles:
|
||||
listConfigFiles(w, r)
|
||||
U.RespondJSON(w, r, routequery.RoutesByAlias(route.RouteType(r.FormValue("type"))))
|
||||
case ListFiles:
|
||||
listFiles(w, r)
|
||||
case ListMiddlewares:
|
||||
U.RespondJSON(w, r, middleware.All())
|
||||
case ListMiddlewareTraces:
|
||||
U.RespondJSON(w, r, middleware.GetAllTrace())
|
||||
case ListMatchDomains:
|
||||
U.RespondJSON(w, r, config.Value().MatchDomains)
|
||||
U.RespondJSON(w, r, cfg.Value().MatchDomains)
|
||||
case ListHomepageConfig:
|
||||
U.RespondJSON(w, r, config.HomepageConfig())
|
||||
U.RespondJSON(w, r, routequery.HomepageConfig(cfg.Value().Homepage.UseDefaultCategories, r.FormValue("category"), r.FormValue("provider")))
|
||||
case ListRouteProviders:
|
||||
U.RespondJSON(w, r, cfg.RouteProviderList())
|
||||
case ListHomepageCategories:
|
||||
U.RespondJSON(w, r, routequery.HomepageCategories())
|
||||
case ListIcons:
|
||||
limit, err := strconv.Atoi(r.FormValue("limit"))
|
||||
if err != nil {
|
||||
limit = 0
|
||||
}
|
||||
icons, err := internal.SearchIcons(r.FormValue("keyword"), limit)
|
||||
if err != nil {
|
||||
U.RespondError(w, err)
|
||||
return
|
||||
}
|
||||
if icons == nil {
|
||||
icons = []string{}
|
||||
}
|
||||
U.RespondJSON(w, r, icons)
|
||||
case ListTasks:
|
||||
U.RespondJSON(w, r, task.DebugTaskMap())
|
||||
U.RespondJSON(w, r, task.DebugTaskList())
|
||||
default:
|
||||
U.HandleErr(w, r, U.ErrInvalidKey("what"), http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
|
||||
// if which is "all" or empty, return map[string]Route of all routes
|
||||
// otherwise, return a single Route with alias which or nil if not found.
|
||||
func listRoute(which string) any {
|
||||
if which == "" {
|
||||
which = "all"
|
||||
if which == "" || which == "all" {
|
||||
return routequery.RoutesByAlias()
|
||||
}
|
||||
if which == "all" {
|
||||
return config.RoutesByAlias()
|
||||
}
|
||||
routes := config.RoutesByAlias()
|
||||
routes := routequery.RoutesByAlias()
|
||||
route, ok := routes[which]
|
||||
if !ok {
|
||||
return nil
|
||||
@@ -73,14 +96,32 @@ func listRoute(which string) any {
|
||||
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, true)
|
||||
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)
|
||||
|
||||
for _, file := range files {
|
||||
t := fileType(file)
|
||||
file = strings.TrimPrefix(file, common.ConfigBasePath+"/")
|
||||
resp[t] = append(resp[t], file)
|
||||
}
|
||||
|
||||
mids, err := utils.ListFiles(common.MiddlewareComposeBasePath, 0, true)
|
||||
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)
|
||||
}
|
||||
|
||||
233
internal/api/v1/mem_logger.go
Normal file
233
internal/api/v1/mem_logger.go
Normal file
@@ -0,0 +1,233 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/yusing/go-proxy/internal/api/v1/utils"
|
||||
"github.com/yusing/go-proxy/internal/common"
|
||||
config "github.com/yusing/go-proxy/internal/config/types"
|
||||
"github.com/yusing/go-proxy/internal/logging"
|
||||
"github.com/yusing/go-proxy/internal/task"
|
||||
F "github.com/yusing/go-proxy/internal/utils/functional"
|
||||
)
|
||||
|
||||
type logEntryRange struct {
|
||||
Start, End int
|
||||
}
|
||||
|
||||
type memLogger struct {
|
||||
bytes.Buffer
|
||||
sync.RWMutex
|
||||
notifyLock sync.RWMutex
|
||||
connChans F.Map[chan *logEntryRange, struct{}]
|
||||
|
||||
bufPool sync.Pool // used in hook mode
|
||||
}
|
||||
|
||||
type MemLogger interface {
|
||||
io.Writer
|
||||
// TODO: hook does not pass in fields, looking for a workaround to do server side log rendering
|
||||
zerolog.Hook
|
||||
}
|
||||
|
||||
type buffer struct {
|
||||
data []byte
|
||||
}
|
||||
|
||||
const (
|
||||
maxMemLogSize = 16 * 1024
|
||||
truncateSize = maxMemLogSize / 2
|
||||
initialWriteChunkSize = 4 * 1024
|
||||
hookModeBufSize = 256
|
||||
)
|
||||
|
||||
var memLoggerInstance = &memLogger{
|
||||
connChans: F.NewMapOf[chan *logEntryRange, struct{}](),
|
||||
bufPool: sync.Pool{
|
||||
New: func() any {
|
||||
return &buffer{
|
||||
data: make([]byte, 0, hookModeBufSize),
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
if !common.EnableLogStreaming {
|
||||
return
|
||||
}
|
||||
memLoggerInstance.Grow(maxMemLogSize)
|
||||
|
||||
if common.DebugMemLogger {
|
||||
ticker := time.NewTicker(1 * time.Second)
|
||||
|
||||
go func() {
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-task.RootContextCanceled():
|
||||
return
|
||||
case <-ticker.C:
|
||||
logging.Info().Msgf("mem logger size: %d, active conns: %d",
|
||||
memLoggerInstance.Len(),
|
||||
memLoggerInstance.connChans.Size())
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func LogsWS() func(config config.ConfigInstance, w http.ResponseWriter, r *http.Request) {
|
||||
return memLoggerInstance.ServeHTTP
|
||||
}
|
||||
|
||||
func GetMemLogger() MemLogger {
|
||||
return memLoggerInstance
|
||||
}
|
||||
|
||||
func (m *memLogger) truncateIfNeeded(n int) {
|
||||
m.RLock()
|
||||
needTruncate := m.Len()+n > maxMemLogSize
|
||||
m.RUnlock()
|
||||
|
||||
if needTruncate {
|
||||
m.Lock()
|
||||
defer m.Unlock()
|
||||
needTruncate = m.Len()+n > maxMemLogSize
|
||||
if !needTruncate {
|
||||
return
|
||||
}
|
||||
|
||||
m.Truncate(truncateSize)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *memLogger) notifyWS(pos, n int) {
|
||||
if m.connChans.Size() > 0 {
|
||||
timeout := time.NewTimer(1 * time.Second)
|
||||
defer timeout.Stop()
|
||||
|
||||
m.notifyLock.RLock()
|
||||
defer m.notifyLock.RUnlock()
|
||||
m.connChans.Range(func(ch chan *logEntryRange, _ struct{}) bool {
|
||||
select {
|
||||
case ch <- &logEntryRange{pos, pos + n}:
|
||||
return true
|
||||
case <-timeout.C:
|
||||
logging.Warn().Msg("mem logger: timeout logging to channel")
|
||||
return false
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (m *memLogger) writeBuf(b []byte) (pos int, err error) {
|
||||
m.Lock()
|
||||
defer m.Unlock()
|
||||
pos = m.Len()
|
||||
_, err = m.Buffer.Write(b)
|
||||
return
|
||||
}
|
||||
|
||||
// Run implements zerolog.Hook.
|
||||
func (m *memLogger) Run(e *zerolog.Event, level zerolog.Level, message string) {
|
||||
bufStruct := m.bufPool.Get().(*buffer)
|
||||
buf := bufStruct.data
|
||||
defer func() {
|
||||
bufStruct.data = bufStruct.data[:0]
|
||||
m.bufPool.Put(bufStruct)
|
||||
}()
|
||||
|
||||
buf = logging.FormatLogEntryHTML(level, message, buf)
|
||||
n := len(buf)
|
||||
|
||||
m.truncateIfNeeded(n)
|
||||
|
||||
pos, err := m.writeBuf(buf)
|
||||
if err != nil {
|
||||
// not logging the error here, it will cause Run to be called again = infinite loop
|
||||
return
|
||||
}
|
||||
|
||||
m.notifyWS(pos, n)
|
||||
}
|
||||
|
||||
// Write implements io.Writer.
|
||||
func (m *memLogger) Write(p []byte) (n int, err error) {
|
||||
n = len(p)
|
||||
m.truncateIfNeeded(n)
|
||||
|
||||
pos, err := m.writeBuf(p)
|
||||
if err != nil {
|
||||
// not logging the error here, it will cause Run to be called again = infinite loop
|
||||
return
|
||||
}
|
||||
|
||||
m.notifyWS(pos, n)
|
||||
return
|
||||
}
|
||||
|
||||
func (m *memLogger) ServeHTTP(config config.ConfigInstance, w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := utils.InitiateWS(config, w, r)
|
||||
if err != nil {
|
||||
utils.HandleErr(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
logCh := make(chan *logEntryRange)
|
||||
m.connChans.Store(logCh, struct{}{})
|
||||
|
||||
/* trunk-ignore(golangci-lint/errcheck) */
|
||||
defer func() {
|
||||
_ = conn.CloseNow()
|
||||
|
||||
m.notifyLock.Lock()
|
||||
m.connChans.Delete(logCh)
|
||||
close(logCh)
|
||||
m.notifyLock.Unlock()
|
||||
}()
|
||||
|
||||
if err := m.wsInitial(r.Context(), conn); err != nil {
|
||||
utils.HandleErr(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
m.wsStreamLog(r.Context(), conn, logCh)
|
||||
}
|
||||
|
||||
func (m *memLogger) writeBytes(ctx context.Context, conn *websocket.Conn, b []byte) error {
|
||||
return conn.Write(ctx, websocket.MessageText, b)
|
||||
}
|
||||
|
||||
func (m *memLogger) wsInitial(ctx context.Context, conn *websocket.Conn) error {
|
||||
m.Lock()
|
||||
defer m.Unlock()
|
||||
|
||||
return m.writeBytes(ctx, conn, m.Buffer.Bytes())
|
||||
}
|
||||
|
||||
func (m *memLogger) wsStreamLog(ctx context.Context, conn *websocket.Conn, ch <-chan *logEntryRange) {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case logRange := <-ch:
|
||||
m.RLock()
|
||||
msg := m.Buffer.Bytes()[logRange.Start:logRange.End]
|
||||
err := m.writeBytes(ctx, conn, msg)
|
||||
m.RUnlock()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,11 @@ import (
|
||||
"net/http"
|
||||
|
||||
U "github.com/yusing/go-proxy/internal/api/v1/utils"
|
||||
"github.com/yusing/go-proxy/internal/config"
|
||||
config "github.com/yusing/go-proxy/internal/config/types"
|
||||
)
|
||||
|
||||
func Reload(w http.ResponseWriter, r *http.Request) {
|
||||
if err := config.Reload(); err != nil {
|
||||
func Reload(cfg config.ConfigInstance, w http.ResponseWriter, r *http.Request) {
|
||||
if err := cfg.Reload(); err != nil {
|
||||
U.HandleErr(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,70 +1,31 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
"github.com/coder/websocket/wsjson"
|
||||
U "github.com/yusing/go-proxy/internal/api/v1/utils"
|
||||
"github.com/yusing/go-proxy/internal/common"
|
||||
"github.com/yusing/go-proxy/internal/config"
|
||||
"github.com/yusing/go-proxy/internal/server"
|
||||
config "github.com/yusing/go-proxy/internal/config/types"
|
||||
"github.com/yusing/go-proxy/internal/utils/strutils"
|
||||
)
|
||||
|
||||
func Stats(w http.ResponseWriter, r *http.Request) {
|
||||
U.RespondJSON(w, r, getStats())
|
||||
func Stats(cfg config.ConfigInstance, w http.ResponseWriter, r *http.Request) {
|
||||
U.RespondJSON(w, r, getStats(cfg))
|
||||
}
|
||||
|
||||
func StatsWS(w http.ResponseWriter, r *http.Request) {
|
||||
var originPats []string
|
||||
|
||||
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 {
|
||||
originPats = make([]string, len(config.Value().MatchDomains))
|
||||
for i, domain := range config.Value().MatchDomains {
|
||||
originPats[i] = "*" + domain
|
||||
}
|
||||
originPats = append(originPats, localAddresses...)
|
||||
}
|
||||
U.LogInfo(r).Msgf("websocket API request from origins: %s", originPats)
|
||||
if common.IsDebug {
|
||||
originPats = []string{"*"}
|
||||
}
|
||||
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
|
||||
OriginPatterns: originPats,
|
||||
func StatsWS(cfg config.ConfigInstance, w http.ResponseWriter, r *http.Request) {
|
||||
U.PeriodicWS(cfg, w, r, 1*time.Second, func(conn *websocket.Conn) error {
|
||||
return wsjson.Write(r.Context(), conn, getStats(cfg))
|
||||
})
|
||||
if err != nil {
|
||||
U.LogError(r).Err(err).Msg("failed to upgrade websocket")
|
||||
return
|
||||
}
|
||||
/* trunk-ignore(golangci-lint/errcheck) */
|
||||
defer conn.CloseNow()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
ticker := time.NewTicker(1 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
stats := getStats()
|
||||
if err := wsjson.Write(ctx, conn, stats); err != nil {
|
||||
U.LogError(r).Msg("failed to write JSON")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func getStats() map[string]any {
|
||||
var startTime = time.Now()
|
||||
|
||||
func getStats(cfg config.ConfigInstance) map[string]any {
|
||||
return map[string]any{
|
||||
"proxies": config.Statistics(),
|
||||
"uptime": strutils.FormatDuration(server.GetProxyServer().Uptime()),
|
||||
"proxies": cfg.Statistics(),
|
||||
"uptime": strutils.FormatDuration(time.Since(startTime)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +1,45 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
E "github.com/yusing/go-proxy/internal/error"
|
||||
"github.com/yusing/go-proxy/internal/utils/strutils/ansi"
|
||||
)
|
||||
|
||||
// HandleErr logs the error and returns an HTTP error response to the client.
|
||||
// HandleErr logs the error and returns an error code 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, origErr error, code ...int) {
|
||||
if origErr == nil {
|
||||
func HandleErr(w http.ResponseWriter, r *http.Request, err error, code ...int) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
LogError(r).Msg(origErr.Error())
|
||||
statusCode := http.StatusInternalServerError
|
||||
if len(code) > 0 {
|
||||
statusCode = code[0]
|
||||
LogError(r).Msg(err.Error())
|
||||
if len(code) == 0 {
|
||||
code = []int{http.StatusInternalServerError}
|
||||
}
|
||||
http.Error(w, http.StatusText(statusCode), statusCode)
|
||||
http.Error(w, http.StatusText(code[0]), code[0])
|
||||
}
|
||||
|
||||
// RespondError returns error details to the client.
|
||||
// If code is specified, it will be used as the HTTP status code; otherwise,
|
||||
// http.StatusBadRequest is used.
|
||||
func RespondError(w http.ResponseWriter, err error, code ...int) {
|
||||
if len(code) == 0 {
|
||||
code = []int{http.StatusBadRequest}
|
||||
}
|
||||
buf, err := json.Marshal(err)
|
||||
if err != nil { // just in case
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
http.Error(w, ansi.StripANSI(err.Error()), code[0])
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(code[0])
|
||||
_, _ = w.Write(buf)
|
||||
}
|
||||
|
||||
func ErrMissingKey(k string) error {
|
||||
|
||||
@@ -8,11 +8,14 @@ import (
|
||||
)
|
||||
|
||||
func reqLogger(r *http.Request, level zerolog.Level) *zerolog.Event {
|
||||
return logging.WithLevel(level).Str("module", "api").
|
||||
Str("method", r.Method).
|
||||
Str("path", r.RequestURI)
|
||||
return logging.WithLevel(level).
|
||||
Str("module", "api").
|
||||
Str("remote", r.RemoteAddr).
|
||||
Str("host", r.Host).
|
||||
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) }
|
||||
|
||||
@@ -6,11 +6,12 @@ import (
|
||||
"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) {
|
||||
if _, err := w.Write(body); err != nil {
|
||||
HandleErr(w, nil, err)
|
||||
logging.Err(err).Msg("failed to write body")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,13 +28,17 @@ func RespondJSON(w http.ResponseWriter, r *http.Request, data any, code ...int)
|
||||
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 {
|
||||
logging.Panic().Err(err).Msg("failed to marshal json")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
logging.Panic().Err(err).Msg("failed to marshal json")
|
||||
return false
|
||||
}
|
||||
|
||||
_, err = w.Write(j)
|
||||
if err != nil {
|
||||
HandleErr(w, r, err)
|
||||
|
||||
68
internal/api/v1/utils/ws.go
Normal file
68
internal/api/v1/utils/ws.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
"github.com/yusing/go-proxy/internal/common"
|
||||
config "github.com/yusing/go-proxy/internal/config/types"
|
||||
"github.com/yusing/go-proxy/internal/logging"
|
||||
)
|
||||
|
||||
func warnNoMatchDomains() {
|
||||
logging.Warn().Msg("no match domains configured, accepting websocket API request from all origins")
|
||||
}
|
||||
|
||||
var warnNoMatchDomainOnce sync.Once
|
||||
|
||||
func InitiateWS(cfg config.ConfigInstance, w http.ResponseWriter, r *http.Request) (*websocket.Conn, error) {
|
||||
var originPats []string
|
||||
|
||||
localAddresses := []string{"127.0.0.1", "10.0.*.*", "172.16.*.*", "192.168.*.*"}
|
||||
|
||||
if len(cfg.Value().MatchDomains) == 0 {
|
||||
warnNoMatchDomainOnce.Do(warnNoMatchDomains)
|
||||
originPats = []string{"*"}
|
||||
} else {
|
||||
originPats = make([]string, len(cfg.Value().MatchDomains))
|
||||
for i, domain := range cfg.Value().MatchDomains {
|
||||
originPats[i] = "*" + domain
|
||||
}
|
||||
originPats = append(originPats, localAddresses...)
|
||||
}
|
||||
if common.IsDebug {
|
||||
originPats = []string{"*"}
|
||||
}
|
||||
return websocket.Accept(w, r, &websocket.AcceptOptions{
|
||||
OriginPatterns: originPats,
|
||||
})
|
||||
}
|
||||
|
||||
func PeriodicWS(cfg config.ConfigInstance, w http.ResponseWriter, r *http.Request, interval time.Duration, do func(conn *websocket.Conn) error) {
|
||||
conn, err := InitiateWS(cfg, w, r)
|
||||
if err != nil {
|
||||
HandleErr(w, r, err)
|
||||
return
|
||||
}
|
||||
/* trunk-ignore(golangci-lint/errcheck) */
|
||||
defer conn.CloseNow()
|
||||
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-cfg.Context().Done():
|
||||
return
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
if err := do(conn); err != nil {
|
||||
LogError(r).Msg(err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,68 +4,115 @@ import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"os"
|
||||
"regexp"
|
||||
|
||||
"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/config/types"
|
||||
)
|
||||
|
||||
type Config types.AutoCertConfig
|
||||
type (
|
||||
AutocertConfig struct {
|
||||
Email string `json:"email,omitempty"`
|
||||
Domains []string `json:"domains,omitempty"`
|
||||
CertPath string `json:"cert_path,omitempty"`
|
||||
KeyPath string `json:"key_path,omitempty"`
|
||||
ACMEKeyPath string `json:"acme_key_path,omitempty"`
|
||||
Provider string `json:"provider,omitempty"`
|
||||
Options ProviderOpt `json:"options,omitempty"`
|
||||
}
|
||||
ProviderOpt map[string]any
|
||||
)
|
||||
|
||||
var (
|
||||
ErrMissingDomain = E.New("missing field 'domains'")
|
||||
ErrMissingEmail = E.New("missing field 'email'")
|
||||
ErrMissingProvider = E.New("missing field 'provider'")
|
||||
ErrInvalidDomain = E.New("invalid domain")
|
||||
ErrUnknownProvider = E.New("unknown provider")
|
||||
)
|
||||
|
||||
func NewConfig(cfg *types.AutoCertConfig) *Config {
|
||||
var domainOrWildcardRE = regexp.MustCompile(`^\*?([^.]+\.)+[^.]+$`)
|
||||
|
||||
// Validate implements the utils.CustomValidator interface.
|
||||
func (cfg *AutocertConfig) Validate() E.Error {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if cfg.Provider == "" {
|
||||
cfg.Provider = ProviderLocal
|
||||
return nil
|
||||
}
|
||||
|
||||
b := E.NewBuilder("autocert errors")
|
||||
if cfg.Provider != ProviderLocal {
|
||||
if len(cfg.Domains) == 0 {
|
||||
b.Add(ErrMissingDomain)
|
||||
}
|
||||
if cfg.Email == "" {
|
||||
b.Add(ErrMissingEmail)
|
||||
}
|
||||
for i, d := range cfg.Domains {
|
||||
if !domainOrWildcardRE.MatchString(d) {
|
||||
b.Add(ErrInvalidDomain.Subjectf("domains[%d]", i))
|
||||
}
|
||||
}
|
||||
// check if provider is implemented
|
||||
providerConstructor, ok := providersGenMap[cfg.Provider]
|
||||
if !ok {
|
||||
b.Add(ErrUnknownProvider.
|
||||
Subject(cfg.Provider).
|
||||
Withf(strutils.DoYouMean(utils.NearestField(cfg.Provider, providersGenMap))))
|
||||
} else {
|
||||
_, err := providerConstructor(cfg.Options)
|
||||
if err != nil {
|
||||
b.Add(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return b.Error()
|
||||
}
|
||||
|
||||
func (cfg *AutocertConfig) GetProvider() (*Provider, E.Error) {
|
||||
if cfg == nil {
|
||||
cfg = new(AutocertConfig)
|
||||
}
|
||||
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if cfg.CertPath == "" {
|
||||
cfg.CertPath = CertFileDefault
|
||||
}
|
||||
if cfg.KeyPath == "" {
|
||||
cfg.KeyPath = KeyFileDefault
|
||||
}
|
||||
if cfg.Provider == "" {
|
||||
cfg.Provider = ProviderLocal
|
||||
if cfg.ACMEKeyPath == "" {
|
||||
cfg.ACMEKeyPath = ACMEKeyFileDefault
|
||||
}
|
||||
return (*Config)(cfg)
|
||||
}
|
||||
|
||||
func (cfg *Config) GetProvider() (*Provider, E.Error) {
|
||||
b := E.NewBuilder("autocert errors")
|
||||
var privKey *ecdsa.PrivateKey
|
||||
var err error
|
||||
|
||||
if cfg.Provider != ProviderLocal {
|
||||
if len(cfg.Domains) == 0 {
|
||||
b.Add(ErrMissingDomain)
|
||||
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)
|
||||
}
|
||||
}
|
||||
if cfg.Provider == "" {
|
||||
b.Add(ErrMissingProvider)
|
||||
}
|
||||
if cfg.Email == "" {
|
||||
b.Add(ErrMissingEmail)
|
||||
}
|
||||
// check if provider is implemented
|
||||
_, ok := providersGenMap[cfg.Provider]
|
||||
if !ok {
|
||||
b.Add(ErrUnknownProvider.
|
||||
Subject(cfg.Provider).
|
||||
Withf(strutils.DoYouMean(utils.NearestField(cfg.Provider, providersGenMap))))
|
||||
}
|
||||
}
|
||||
|
||||
if b.HasError() {
|
||||
return nil, b.Error()
|
||||
}
|
||||
|
||||
privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
b.Addf("generate private key: %w", err)
|
||||
return nil, b.Error()
|
||||
}
|
||||
|
||||
user := &User{
|
||||
@@ -82,3 +129,19 @@ func (cfg *Config) GetProvider() (*Provider, E.Error) {
|
||||
legoCfg: legoCfg,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (cfg *AutocertConfig) loadACMEKey() (*ecdsa.PrivateKey, error) {
|
||||
data, err := os.ReadFile(cfg.ACMEKeyPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return x509.ParseECPrivateKey(data)
|
||||
}
|
||||
|
||||
func (cfg *AutocertConfig) saveACMEKey(key *ecdsa.PrivateKey) error {
|
||||
data, err := x509.MarshalECPrivateKey(key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(cfg.ACMEKeyPath, data, 0o600)
|
||||
}
|
||||
|
||||
@@ -8,10 +8,10 @@ import (
|
||||
)
|
||||
|
||||
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 (
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
package autocert
|
||||
|
||||
import "github.com/yusing/go-proxy/internal/logging"
|
||||
|
||||
var logger = logging.With().Str("module", "autocert").Logger()
|
||||
@@ -14,8 +14,8 @@ 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/logging"
|
||||
"github.com/yusing/go-proxy/internal/task"
|
||||
U "github.com/yusing/go-proxy/internal/utils"
|
||||
"github.com/yusing/go-proxy/internal/utils/strutils"
|
||||
@@ -23,15 +23,16 @@ import (
|
||||
|
||||
type (
|
||||
Provider struct {
|
||||
cfg *Config
|
||||
cfg *AutocertConfig
|
||||
user *User
|
||||
legoCfg *lego.Config
|
||||
client *lego.Client
|
||||
|
||||
legoCert *certificate.Resource
|
||||
tlsCert *tls.Certificate
|
||||
certExpiries CertExpiries
|
||||
}
|
||||
ProviderGenerator func(types.AutocertProviderOpt) (challenge.Provider, E.Error)
|
||||
ProviderGenerator func(ProviderOpt) (challenge.Provider, E.Error)
|
||||
|
||||
CertExpiries map[string]time.Time
|
||||
)
|
||||
@@ -78,14 +79,29 @@ func (p *Provider) ObtainCert() E.Error {
|
||||
}
|
||||
}
|
||||
|
||||
client := p.client
|
||||
req := certificate.ObtainRequest{
|
||||
Domains: p.cfg.Domains,
|
||||
Bundle: true,
|
||||
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
|
||||
logging.Err(err).Msg("cert renew failed, fallback to obtain")
|
||||
} else {
|
||||
p.legoCert = cert
|
||||
}
|
||||
}
|
||||
cert, err := client.Certificate.Obtain(req)
|
||||
if err != nil {
|
||||
return E.From(err)
|
||||
|
||||
if cert == nil {
|
||||
cert, err = p.client.Certificate.Obtain(certificate.ObtainRequest{
|
||||
Domains: p.cfg.Domains,
|
||||
Bundle: true,
|
||||
})
|
||||
if err != nil {
|
||||
return E.From(err)
|
||||
}
|
||||
}
|
||||
|
||||
if err = p.saveCert(cert); err != nil {
|
||||
@@ -119,7 +135,7 @@ func (p *Provider) LoadCert() E.Error {
|
||||
p.tlsCert = &cert
|
||||
p.certExpiries = expiries
|
||||
|
||||
logger.Info().Msgf("next renewal in %v", strutils.FormatDuration(time.Until(p.ShouldRenewOn())))
|
||||
logging.Info().Msgf("next renewal in %v", strutils.FormatDuration(time.Until(p.ShouldRenewOn())))
|
||||
return p.renewIfNeeded()
|
||||
}
|
||||
|
||||
@@ -132,23 +148,37 @@ func (p *Provider) ShouldRenewOn() time.Time {
|
||||
panic("no certificate available")
|
||||
}
|
||||
|
||||
func (p *Provider) ScheduleRenewal() {
|
||||
func (p *Provider) ScheduleRenewal(parent task.Parent) {
|
||||
if p.GetName() == ProviderLocal {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
task := task.GlobalTask("cert renew scheduler")
|
||||
ticker := time.NewTicker(5 * time.Second)
|
||||
defer ticker.Stop()
|
||||
defer task.Finish("cert renew scheduler stopped")
|
||||
lastErrOn := time.Time{}
|
||||
renewalTime := p.ShouldRenewOn()
|
||||
timer := time.NewTimer(time.Until(renewalTime))
|
||||
defer timer.Stop()
|
||||
|
||||
task := parent.Subtask("cert-renew-scheduler")
|
||||
defer task.Finish(nil)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-task.Context().Done():
|
||||
return
|
||||
case <-ticker.C: // check every 5 seconds
|
||||
if err := p.renewIfNeeded(); err != nil {
|
||||
E.LogWarn("cert renew failed", err, &logger)
|
||||
case <-timer.C:
|
||||
// Retry after 1 hour on failure
|
||||
if !lastErrOn.IsZero() && time.Now().Before(lastErrOn.Add(time.Hour)) {
|
||||
continue
|
||||
}
|
||||
if err := p.renewIfNeeded(); err != nil {
|
||||
E.LogWarn("cert renew failed", err)
|
||||
lastErrOn = time.Now()
|
||||
continue
|
||||
}
|
||||
// Reset on success
|
||||
lastErrOn = time.Time{}
|
||||
renewalTime = p.ShouldRenewOn()
|
||||
timer.Reset(time.Until(renewalTime))
|
||||
}
|
||||
}
|
||||
}()
|
||||
@@ -179,12 +209,18 @@ func (p *Provider) registerACME() error {
|
||||
if p.user.Registration != nil {
|
||||
return nil
|
||||
}
|
||||
if reg, err := p.client.Registration.ResolveAccountByKey(); err == nil {
|
||||
p.user.Registration = reg
|
||||
logging.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
|
||||
|
||||
logging.Info().Interface("reg", reg).Msg("acme registered")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -230,7 +266,7 @@ func (p *Provider) certState() CertState {
|
||||
sort.Strings(certDomains)
|
||||
|
||||
if !reflect.DeepEqual(certDomains, wantedDomains) {
|
||||
logger.Info().Msgf("cert domains mismatch: %v != %v", certDomains, p.cfg.Domains)
|
||||
logging.Info().Msgf("cert domains mismatch: %v != %v", certDomains, p.cfg.Domains)
|
||||
return CertStateMismatch
|
||||
}
|
||||
|
||||
@@ -244,9 +280,9 @@ func (p *Provider) renewIfNeeded() E.Error {
|
||||
|
||||
switch p.certState() {
|
||||
case CertStateExpired:
|
||||
logger.Info().Msg("certs expired, renewing")
|
||||
logging.Info().Msg("certs expired, renewing")
|
||||
case CertStateMismatch:
|
||||
logger.Info().Msg("cert domains mismatch with config, renewing")
|
||||
logging.Info().Msg("cert domains mismatch with config, renewing")
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
@@ -276,9 +312,9 @@ func providerGenerator[CT any, PT challenge.Provider](
|
||||
defaultCfg func() *CT,
|
||||
newProvider func(*CT) (PT, error),
|
||||
) ProviderGenerator {
|
||||
return func(opt types.AutocertProviderOpt) (challenge.Provider, E.Error) {
|
||||
return func(opt ProviderOpt) (challenge.Provider, E.Error) {
|
||||
cfg := defaultCfg()
|
||||
err := U.Deserialize(opt, cfg)
|
||||
err := U.Deserialize(opt, &cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"os"
|
||||
|
||||
E "github.com/yusing/go-proxy/internal/error"
|
||||
"github.com/yusing/go-proxy/internal/logging"
|
||||
"github.com/yusing/go-proxy/internal/utils/strutils"
|
||||
)
|
||||
|
||||
func (p *Provider) Setup() (err E.Error) {
|
||||
@@ -11,16 +13,14 @@ func (p *Provider) Setup() (err E.Error) {
|
||||
if !err.Is(os.ErrNotExist) { // ignore if cert doesn't exist
|
||||
return err
|
||||
}
|
||||
logger.Debug().Msg("obtaining cert due to error loading cert")
|
||||
logging.Debug().Msg("obtaining cert due to error loading cert")
|
||||
if err = p.ObtainCert(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
p.ScheduleRenewal()
|
||||
|
||||
for _, expiry := range p.GetExpiries() {
|
||||
logger.Info().Msg("certificate expire on " + expiry.String())
|
||||
logging.Info().Msg("certificate expire on " + strutils.FormatTime(expiry))
|
||||
break
|
||||
}
|
||||
|
||||
|
||||
@@ -15,9 +15,11 @@ type User struct {
|
||||
func (u *User) GetEmail() string {
|
||||
return u.Email
|
||||
}
|
||||
|
||||
func (u *User) GetRegistration() *registration.Resource {
|
||||
return u.Registration
|
||||
}
|
||||
|
||||
func (u *User) GetPrivateKey() crypto.PrivateKey {
|
||||
return u.key
|
||||
}
|
||||
|
||||
@@ -16,19 +16,16 @@ const (
|
||||
DotEnvPath = ".env"
|
||||
DotEnvExamplePath = ".env.example"
|
||||
|
||||
ConfigBasePath = "config"
|
||||
ConfigFileName = "config.yml"
|
||||
ConfigExampleFileName = "config.example.yml"
|
||||
ConfigPath = ConfigBasePath + "/" + ConfigFileName
|
||||
|
||||
JWTKeyPath = ConfigBasePath + "/jwt.key"
|
||||
ConfigBasePath = "config"
|
||||
ConfigFileName = "config.yml"
|
||||
ConfigExampleFileName = "config.example.yml"
|
||||
ConfigPath = ConfigBasePath + "/" + ConfigFileName
|
||||
HomepageJSONConfigPath = ConfigBasePath + "/.homepage.json"
|
||||
IconListCachePath = ConfigBasePath + "/.icon_list_cache.json"
|
||||
IconCachePath = ConfigBasePath + "/.icon_cache.json"
|
||||
|
||||
MiddlewareComposeBasePath = ConfigBasePath + "/middlewares"
|
||||
|
||||
SchemaBasePath = "schema"
|
||||
ConfigSchemaPath = SchemaBasePath + "/config.schema.json"
|
||||
FileProviderSchemaPath = SchemaBasePath + "/providers.schema.json"
|
||||
|
||||
ComposeFileName = "compose.yml"
|
||||
ComposeExampleFileName = "compose.example.yml"
|
||||
|
||||
@@ -37,7 +34,6 @@ const (
|
||||
|
||||
var RequiredDirectories = []string{
|
||||
ConfigBasePath,
|
||||
SchemaBasePath,
|
||||
ErrorPagesBasePath,
|
||||
MiddlewareComposeBasePath,
|
||||
}
|
||||
@@ -49,7 +45,7 @@ const (
|
||||
HealthCheckTimeoutDefault = 5 * time.Second
|
||||
|
||||
WakeTimeoutDefault = "30s"
|
||||
StopTimeoutDefault = "10s"
|
||||
StopTimeoutDefault = "30s"
|
||||
StopMethodDefault = "stop"
|
||||
)
|
||||
|
||||
|
||||
@@ -1,28 +1,15 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"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 generateJWTKey(size int) string {
|
||||
bytes := make([]byte, size)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
log.Panic().Err(err).Msg("failed to generate jwt key")
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(bytes)
|
||||
}
|
||||
|
||||
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")
|
||||
|
||||
@@ -9,58 +9,88 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/yusing/go-proxy/internal/utils/strutils"
|
||||
)
|
||||
|
||||
var (
|
||||
NoSchemaValidation = GetEnvBool("GOPROXY_NO_SCHEMA_VALIDATION", true)
|
||||
IsTest = GetEnvBool("GOPROXY_TEST", false) || strings.HasSuffix(os.Args[0], ".test")
|
||||
IsDebug = GetEnvBool("GOPROXY_DEBUG", IsTest)
|
||||
IsDebugSkipAuth = GetEnvBool("GOPROXY_DEBUG_SKIP_AUTH", false)
|
||||
IsTrace = GetEnvBool("GOPROXY_TRACE", false) && IsDebug
|
||||
prefixes = []string{"GODOXY_", "GOPROXY_", ""}
|
||||
|
||||
IsTest = GetEnvBool("TEST", false) || strings.HasSuffix(os.Args[0], ".test")
|
||||
IsDebug = GetEnvBool("DEBUG", IsTest)
|
||||
IsTrace = GetEnvBool("TRACE", false) && IsDebug
|
||||
IsProduction = !IsTest && !IsDebug
|
||||
|
||||
EnableLogStreaming = GetEnvBool("LOG_STREAMING", true)
|
||||
DebugMemLogger = GetEnvBool("DEBUG_MEM_LOGGER", false) && EnableLogStreaming
|
||||
|
||||
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")
|
||||
|
||||
APIJWTSecret = decodeJWTKey(GetEnv("GOPROXY_API_JWT_SECRET", generateJWTKey(32)))
|
||||
APIJWTTokenTTL = GetDurationEnv("GOPROXY_API_JWT_TOKEN_TTL", time.Hour)
|
||||
APIUser = GetEnv("GOPROXY_API_USER", "admin")
|
||||
APIPasswordHash = HashPassword(GetEnv("GOPROXY_API_PASSWORD", "password"))
|
||||
PrometheusEnabled = GetEnvBool("PROMETHEUS_ENABLED", false)
|
||||
|
||||
APIJWTSecret = decodeJWTKey(GetEnvString("API_JWT_SECRET", ""))
|
||||
APIJWTTokenTTL = GetDurationEnv("API_JWT_TOKEN_TTL", time.Hour)
|
||||
APIUser = GetEnvString("API_USER", "admin")
|
||||
APIPassword = GetEnvString("API_PASSWORD", "password")
|
||||
|
||||
// OIDC Configuration.
|
||||
OIDCIssuerURL = GetEnvString("OIDC_ISSUER_URL", "")
|
||||
OIDCLogoutURL = GetEnvString("OIDC_LOGOUT_URL", "")
|
||||
OIDCClientID = GetEnvString("OIDC_CLIENT_ID", "")
|
||||
OIDCClientSecret = GetEnvString("OIDC_CLIENT_SECRET", "")
|
||||
OIDCRedirectURL = GetEnvString("OIDC_REDIRECT_URL", "")
|
||||
OIDCScopes = GetEnvString("OIDC_SCOPES", "openid, profile, email")
|
||||
OIDCAllowedUsers = GetCommaSepEnv("OIDC_ALLOWED_USERS", "")
|
||||
OIDCAllowedGroups = GetCommaSepEnv("OIDC_ALLOWED_GROUPS", "")
|
||||
)
|
||||
|
||||
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.Fatal().Msgf("env %s: invalid boolean value: %s", key, 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 {
|
||||
log.Fatal().Msgf("env %s: invalid address: %s", key, addr)
|
||||
@@ -73,13 +103,9 @@ func GetAddrEnv(key, defaultValue, scheme string) (addr, host, port, fullURL str
|
||||
}
|
||||
|
||||
func GetDurationEnv(key string, defaultValue time.Duration) time.Duration {
|
||||
value, ok := os.LookupEnv(key)
|
||||
if !ok || value == "" {
|
||||
return defaultValue
|
||||
}
|
||||
d, err := time.ParseDuration(value)
|
||||
if err != nil {
|
||||
log.Fatal().Msgf("env %s: invalid duration value: %s", key, value)
|
||||
}
|
||||
return d
|
||||
return GetEnv(key, defaultValue, time.ParseDuration)
|
||||
}
|
||||
|
||||
func GetCommaSepEnv(key string, defaultValue string) []string {
|
||||
return strutils.CommaSeperatedList(GetEnvString(key, defaultValue))
|
||||
}
|
||||
|
||||
@@ -1,39 +1,42 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/yusing/go-proxy/internal/api"
|
||||
"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"
|
||||
"github.com/yusing/go-proxy/internal/logging"
|
||||
"github.com/yusing/go-proxy/internal/net/http/server"
|
||||
"github.com/yusing/go-proxy/internal/notif"
|
||||
"github.com/yusing/go-proxy/internal/route"
|
||||
proxy "github.com/yusing/go-proxy/internal/route/provider"
|
||||
"github.com/yusing/go-proxy/internal/task"
|
||||
U "github.com/yusing/go-proxy/internal/utils"
|
||||
"github.com/yusing/go-proxy/internal/utils"
|
||||
F "github.com/yusing/go-proxy/internal/utils/functional"
|
||||
"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
|
||||
providers F.Map[string, *proxy.Provider]
|
||||
autocertProvider *autocert.Provider
|
||||
task task.Task
|
||||
entrypoint *entrypoint.Entrypoint
|
||||
|
||||
task *task.Task
|
||||
}
|
||||
|
||||
var (
|
||||
instance *Config
|
||||
cfgWatcher watcher.Watcher
|
||||
logger = logging.With().Str("module", "config").Logger()
|
||||
reloadMu sync.Mutex
|
||||
)
|
||||
|
||||
@@ -46,15 +49,18 @@ Make sure you rename it back before next time you start.`
|
||||
You may run "ls-config" to show or dump the current config.`
|
||||
)
|
||||
|
||||
var Validate = types.Validate
|
||||
|
||||
func GetInstance() *Config {
|
||||
return instance
|
||||
}
|
||||
|
||||
func newConfig() *Config {
|
||||
return &Config{
|
||||
value: types.DefaultConfig(),
|
||||
providers: F.NewMapOf[string, *proxy.Provider](),
|
||||
task: task.GlobalTask("config"),
|
||||
value: types.DefaultConfig(),
|
||||
providers: F.NewMapOf[string, *proxy.Provider](),
|
||||
entrypoint: entrypoint.NewEntrypoint(),
|
||||
task: task.RootTask("config", false),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,38 +73,32 @@ func Load() (*Config, E.Error) {
|
||||
return instance, instance.load()
|
||||
}
|
||||
|
||||
func Validate(data []byte) E.Error {
|
||||
return U.ValidateYaml(U.GetSchema(common.ConfigSchemaPath), data)
|
||||
}
|
||||
|
||||
func MatchDomains() []string {
|
||||
return instance.value.MatchDomains
|
||||
}
|
||||
|
||||
func WatchChanges() {
|
||||
task := task.GlobalTask("Config watcher")
|
||||
t := task.RootTask("config_watcher", true)
|
||||
eventQueue := events.NewEventQueue(
|
||||
task,
|
||||
t,
|
||||
configEventFlushInterval,
|
||||
OnConfigChange,
|
||||
func(err E.Error) {
|
||||
E.LogError("config reload error", err, &logger)
|
||||
E.LogError("config reload error", err)
|
||||
},
|
||||
)
|
||||
eventQueue.Start(cfgWatcher.Events(task.Context()))
|
||||
eventQueue.Start(cfgWatcher.Events(t.Context()))
|
||||
}
|
||||
|
||||
func OnConfigChange(flushTask task.Task, ev []events.Event) {
|
||||
defer flushTask.Finish("config reload complete")
|
||||
|
||||
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)
|
||||
logging.Warn().Msg(cfgRenameWarn)
|
||||
return
|
||||
case events.ActionFileDeleted:
|
||||
logger.Warn().Msg(cfgDeleteWarn)
|
||||
logging.Warn().Msg(cfgDeleteWarn)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -116,39 +116,96 @@ func Reload() E.Error {
|
||||
newCfg := newConfig()
|
||||
err := newCfg.load()
|
||||
if err != nil {
|
||||
return err
|
||||
newCfg.task.Finish(err)
|
||||
return E.New("using last config").With(err)
|
||||
}
|
||||
|
||||
// cancel all current subtasks -> wait
|
||||
// -> replace config -> start new subtasks
|
||||
instance.task.Finish("config changed")
|
||||
instance.task.Wait()
|
||||
*instance = *newCfg
|
||||
instance.StartProxyProviders()
|
||||
instance = newCfg
|
||||
instance.Start(StartAllServers)
|
||||
return nil
|
||||
}
|
||||
|
||||
func Value() types.Config {
|
||||
return *instance.value
|
||||
func (cfg *Config) Value() *types.Config {
|
||||
return instance.value
|
||||
}
|
||||
|
||||
func GetAutoCertProvider() *autocert.Provider {
|
||||
func (cfg *Config) Reload() E.Error {
|
||||
return Reload()
|
||||
}
|
||||
|
||||
func (cfg *Config) AutoCertProvider() *autocert.Provider {
|
||||
return instance.autocertProvider
|
||||
}
|
||||
|
||||
func (cfg *Config) Task() task.Task {
|
||||
func (cfg *Config) Task() *task.Task {
|
||||
return cfg.task
|
||||
}
|
||||
|
||||
func (cfg *Config) Context() context.Context {
|
||||
return cfg.task.Context()
|
||||
}
|
||||
|
||||
func (cfg *Config) Start(opts ...*StartServersOptions) {
|
||||
cfg.StartAutoCert()
|
||||
cfg.StartProxyProviders()
|
||||
cfg.StartServers(opts...)
|
||||
}
|
||||
|
||||
func (cfg *Config) StartAutoCert() {
|
||||
autocert := cfg.autocertProvider
|
||||
if autocert == nil {
|
||||
logging.Info().Msg("autocert not configured")
|
||||
return
|
||||
}
|
||||
|
||||
if err := autocert.Setup(); err != nil {
|
||||
E.LogFatal("autocert setup error", err)
|
||||
} else {
|
||||
autocert.ScheduleRenewal(cfg.task)
|
||||
}
|
||||
}
|
||||
|
||||
func (cfg *Config) StartProxyProviders() {
|
||||
errs := cfg.providers.CollectErrorsParallel(
|
||||
func(_ string, p *proxy.Provider) error {
|
||||
subtask := cfg.task.Subtask(p.String())
|
||||
return p.Start(subtask)
|
||||
return p.Start(cfg.task)
|
||||
})
|
||||
|
||||
if err := E.Join(errs...); err != nil {
|
||||
E.LogError("route provider errors", err, &logger)
|
||||
E.LogError("route provider errors", err)
|
||||
}
|
||||
}
|
||||
|
||||
type StartServersOptions struct {
|
||||
Proxy, API bool
|
||||
}
|
||||
|
||||
var StartAllServers = &StartServersOptions{true, true}
|
||||
|
||||
func (cfg *Config) StartServers(opts ...*StartServersOptions) {
|
||||
if len(opts) == 0 {
|
||||
opts = append(opts, &StartServersOptions{})
|
||||
}
|
||||
opt := opts[0]
|
||||
if opt.Proxy {
|
||||
server.StartServer(cfg.task, server.Options{
|
||||
Name: "proxy",
|
||||
CertProvider: cfg.AutoCertProvider(),
|
||||
HTTPAddr: common.ProxyHTTPAddr,
|
||||
HTTPSAddr: common.ProxyHTTPSAddr,
|
||||
Handler: cfg.entrypoint,
|
||||
})
|
||||
}
|
||||
if opt.API {
|
||||
server.StartServer(cfg.task, server.Options{
|
||||
Name: "api",
|
||||
CertProvider: cfg.AutoCertProvider(),
|
||||
HTTPAddr: common.APIHTTPAddr,
|
||||
Handler: api.NewHandler(cfg),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,24 +214,20 @@ func (cfg *Config) load() E.Error {
|
||||
|
||||
data, err := os.ReadFile(common.ConfigPath)
|
||||
if err != nil {
|
||||
E.LogFatal(errMsg, err, &logger)
|
||||
}
|
||||
|
||||
if !common.NoSchemaValidation {
|
||||
if err := Validate(data); err != nil {
|
||||
E.LogFatal(errMsg, err, &logger)
|
||||
}
|
||||
E.LogFatal(errMsg, err)
|
||||
}
|
||||
|
||||
model := types.DefaultConfig()
|
||||
if err := E.From(yaml.Unmarshal(data, model)); err != nil {
|
||||
E.LogFatal(errMsg, err, &logger)
|
||||
if err := utils.DeserializeYAML(data, model); err != nil {
|
||||
E.LogFatal(errMsg, err)
|
||||
}
|
||||
|
||||
// errors are non fatal below
|
||||
errs := E.NewBuilder(errMsg)
|
||||
errs.Add(cfg.initNotification(model.Providers.Notification))
|
||||
errs.Add(cfg.initAutoCert(&model.AutoCert))
|
||||
errs.Add(cfg.entrypoint.SetMiddlewares(model.Entrypoint.Middlewares))
|
||||
errs.Add(cfg.entrypoint.SetAccessLogger(cfg.task, model.Entrypoint.AccessLog))
|
||||
cfg.initNotification(model.Providers.Notification)
|
||||
errs.Add(cfg.initAutoCert(model.AutoCert))
|
||||
errs.Add(cfg.loadRouteProviders(&model.Providers))
|
||||
|
||||
cfg.value = model
|
||||
@@ -183,35 +236,31 @@ func (cfg *Config) load() E.Error {
|
||||
model.MatchDomains[i] = "." + domain
|
||||
}
|
||||
}
|
||||
route.SetFindMuxDomains(model.MatchDomains)
|
||||
cfg.entrypoint.SetFindRouteDomains(model.MatchDomains)
|
||||
|
||||
return errs.Error()
|
||||
}
|
||||
|
||||
func (cfg *Config) initNotification(notifCfgMap types.NotificationConfigMap) (err E.Error) {
|
||||
if len(notifCfgMap) == 0 {
|
||||
func (cfg *Config) initNotification(notifCfg []notif.NotificationConfig) {
|
||||
if len(notifCfg) == 0 {
|
||||
return
|
||||
}
|
||||
errs := E.NewBuilder("notification providers load errors")
|
||||
for name, notifCfg := range notifCfgMap {
|
||||
_, err := notif.RegisterProvider(cfg.task.Subtask(name), notifCfg)
|
||||
errs.Add(err)
|
||||
dispatcher := notif.StartNotifDispatcher(cfg.task)
|
||||
for _, notifier := range notifCfg {
|
||||
dispatcher.RegisterProvider(¬ifier)
|
||||
}
|
||||
return errs.Error()
|
||||
}
|
||||
|
||||
func (cfg *Config) initAutoCert(autocertCfg *types.AutoCertConfig) (err E.Error) {
|
||||
func (cfg *Config) initAutoCert(autocertCfg *autocert.AutocertConfig) (err E.Error) {
|
||||
if cfg.autocertProvider != nil {
|
||||
return
|
||||
}
|
||||
|
||||
cfg.autocertProvider, err = autocert.NewConfig(autocertCfg).GetProvider()
|
||||
cfg.autocertProvider, err = autocertCfg.GetProvider()
|
||||
return
|
||||
}
|
||||
|
||||
func (cfg *Config) loadRouteProviders(providers *types.Providers) E.Error {
|
||||
subtask := cfg.task.Subtask("load route providers")
|
||||
defer subtask.Finish("done")
|
||||
|
||||
errs := E.NewBuilder("route provider errors")
|
||||
results := E.NewBuilder("loaded route providers")
|
||||
|
||||
@@ -222,9 +271,9 @@ func (cfg *Config) loadRouteProviders(providers *types.Providers) E.Error {
|
||||
errs.Add(E.PrependSubject(filename, err))
|
||||
continue
|
||||
}
|
||||
cfg.providers.Store(p.GetName(), p)
|
||||
if len(p.GetName()) > lenLongestName {
|
||||
lenLongestName = len(p.GetName())
|
||||
cfg.providers.Store(p.String(), p)
|
||||
if len(p.String()) > lenLongestName {
|
||||
lenLongestName = len(p.String())
|
||||
}
|
||||
}
|
||||
for name, dockerHost := range providers.Docker {
|
||||
@@ -233,17 +282,17 @@ func (cfg *Config) loadRouteProviders(providers *types.Providers) E.Error {
|
||||
errs.Add(E.PrependSubject(name, err))
|
||||
continue
|
||||
}
|
||||
cfg.providers.Store(p.GetName(), p)
|
||||
if len(p.GetName()) > lenLongestName {
|
||||
lenLongestName = len(p.GetName())
|
||||
cfg.providers.Store(p.String(), p)
|
||||
if len(p.String()) > lenLongestName {
|
||||
lenLongestName = len(p.String())
|
||||
}
|
||||
}
|
||||
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())
|
||||
results.Addf("%-"+strconv.Itoa(lenLongestName)+"s %d routes", p.String(), p.NumRoutes())
|
||||
})
|
||||
logger.Info().Msg(results.String())
|
||||
logging.Info().Msg(results.String())
|
||||
return errs.Error()
|
||||
}
|
||||
|
||||
@@ -1,152 +1,53 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/yusing/go-proxy/internal/common"
|
||||
"github.com/yusing/go-proxy/internal/homepage"
|
||||
"github.com/yusing/go-proxy/internal/proxy/entry"
|
||||
"github.com/yusing/go-proxy/internal/route"
|
||||
proxy "github.com/yusing/go-proxy/internal/route/provider"
|
||||
F "github.com/yusing/go-proxy/internal/utils/functional"
|
||||
"github.com/yusing/go-proxy/internal/utils/strutils"
|
||||
"github.com/yusing/go-proxy/internal/route/provider"
|
||||
)
|
||||
|
||||
func DumpEntries() map[string]*entry.RawEntry {
|
||||
entries := make(map[string]*entry.RawEntry)
|
||||
instance.providers.RangeAll(func(_ string, p *proxy.Provider) {
|
||||
func (cfg *Config) DumpRoutes() map[string]*route.Route {
|
||||
entries := make(map[string]*route.Route)
|
||||
cfg.providers.RangeAll(func(_ string, p *provider.Provider) {
|
||||
p.RangeRoutes(func(alias string, r *route.Route) {
|
||||
entries[alias] = r.Entry
|
||||
entries[alias] = r
|
||||
})
|
||||
})
|
||||
return entries
|
||||
}
|
||||
|
||||
func DumpProviders() map[string]*proxy.Provider {
|
||||
entries := make(map[string]*proxy.Provider)
|
||||
instance.providers.RangeAll(func(name string, p *proxy.Provider) {
|
||||
entries[name] = p
|
||||
func (cfg *Config) DumpRouteProviders() map[string]*provider.Provider {
|
||||
entries := make(map[string]*provider.Provider)
|
||||
cfg.providers.RangeAll(func(_ string, p *provider.Provider) {
|
||||
entries[p.ShortName()] = p
|
||||
})
|
||||
return entries
|
||||
}
|
||||
|
||||
func HomepageConfig() homepage.Config {
|
||||
var proto, port string
|
||||
domains := instance.value.MatchDomains
|
||||
cert, _ := instance.autocertProvider.GetCert(nil)
|
||||
if cert != nil {
|
||||
proto = "https"
|
||||
port = common.ProxyHTTPSPort
|
||||
} else {
|
||||
proto = "http"
|
||||
port = common.ProxyHTTPPort
|
||||
}
|
||||
|
||||
hpCfg := homepage.NewHomePageConfig()
|
||||
route.GetReverseProxies().RangeAll(func(alias string, r *route.HTTPRoute) {
|
||||
en := r.Raw
|
||||
item := en.Homepage
|
||||
if item == nil {
|
||||
item = new(homepage.Item)
|
||||
item.Show = true
|
||||
}
|
||||
|
||||
if !item.IsEmpty() {
|
||||
item.Show = true
|
||||
}
|
||||
|
||||
if !item.Show {
|
||||
return
|
||||
}
|
||||
|
||||
if item.Name == "" {
|
||||
item.Name = strutils.Title(
|
||||
strings.ReplaceAll(
|
||||
strings.ReplaceAll(alias, "-", " "),
|
||||
"_", " ",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
switch {
|
||||
case entry.IsDocker(r):
|
||||
if item.Category == "" {
|
||||
item.Category = "Docker"
|
||||
}
|
||||
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(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.TargetURL().String()
|
||||
|
||||
hpCfg.Add(item)
|
||||
func (cfg *Config) RouteProviderList() []string {
|
||||
var list []string
|
||||
cfg.providers.RangeAll(func(_ string, p *provider.Provider) {
|
||||
list = append(list, p.ShortName())
|
||||
})
|
||||
return hpCfg
|
||||
return list
|
||||
}
|
||||
|
||||
func RoutesByAlias(typeFilter ...route.RouteType) map[string]any {
|
||||
routes := 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:
|
||||
route.GetReverseProxies().RangeAll(func(alias string, r *route.HTTPRoute) {
|
||||
routes[alias] = r
|
||||
})
|
||||
case route.RouteTypeStream:
|
||||
route.GetStreamProxies().RangeAll(func(alias string, r *route.StreamRoute) {
|
||||
routes[alias] = r
|
||||
})
|
||||
}
|
||||
}
|
||||
return routes
|
||||
}
|
||||
func (cfg *Config) Statistics() map[string]any {
|
||||
var rps, streams provider.RouteStats
|
||||
var total uint16
|
||||
providerStats := make(map[string]provider.ProviderStats)
|
||||
|
||||
func Statistics() map[string]any {
|
||||
nTotalStreams := 0
|
||||
nTotalRPs := 0
|
||||
providerStats := make(map[string]proxy.ProviderStats)
|
||||
|
||||
instance.providers.RangeAll(func(name string, p *proxy.Provider) {
|
||||
providerStats[name] = p.Statistics()
|
||||
cfg.providers.RangeAll(func(_ string, p *provider.Provider) {
|
||||
stats := p.Statistics()
|
||||
providerStats[p.ShortName()] = stats
|
||||
rps.AddOther(stats.RPs)
|
||||
streams.AddOther(stats.Streams)
|
||||
total += stats.RPs.Total + stats.Streams.Total
|
||||
})
|
||||
|
||||
for _, stats := range providerStats {
|
||||
nTotalRPs += stats.NumRPs
|
||||
nTotalStreams += stats.NumStreams
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"num_total_streams": nTotalStreams,
|
||||
"num_total_reverse_proxies": nTotalRPs,
|
||||
"providers": providerStats,
|
||||
"total": total,
|
||||
"reverse_proxies": rps,
|
||||
"streams": streams,
|
||||
"providers": providerStats,
|
||||
}
|
||||
}
|
||||
|
||||
func FindRoute(alias string) *route.Route {
|
||||
return F.MapFind(instance.providers,
|
||||
func(p *proxy.Provider) (*route.Route, bool) {
|
||||
if route, ok := p.GetRoute(alias); ok {
|
||||
return route, true
|
||||
}
|
||||
return nil, false
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
package types
|
||||
|
||||
type (
|
||||
AutoCertConfig struct {
|
||||
Email string `json:"email,omitempty" yaml:"email"`
|
||||
Domains []string `json:"domains,omitempty" yaml:",flow"`
|
||||
CertPath string `json:"cert_path,omitempty" yaml:"cert_path"`
|
||||
KeyPath string `json:"key_path,omitempty" yaml:"key_path"`
|
||||
Provider string `json:"provider,omitempty" yaml:"provider"`
|
||||
Options AutocertProviderOpt `json:"options,omitempty" yaml:",flow"`
|
||||
}
|
||||
AutocertProviderOpt map[string]any
|
||||
)
|
||||
@@ -1,25 +1,71 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"context"
|
||||
"regexp"
|
||||
|
||||
"github.com/go-playground/validator/v10"
|
||||
"github.com/yusing/go-proxy/internal/autocert"
|
||||
"github.com/yusing/go-proxy/internal/net/http/accesslog"
|
||||
"github.com/yusing/go-proxy/internal/notif"
|
||||
"github.com/yusing/go-proxy/internal/utils"
|
||||
|
||||
E "github.com/yusing/go-proxy/internal/error"
|
||||
)
|
||||
|
||||
type (
|
||||
Config struct {
|
||||
Providers Providers `json:"providers" yaml:",flow"`
|
||||
AutoCert AutoCertConfig `json:"autocert" yaml:",flow"`
|
||||
ExplicitOnly bool `json:"explicit_only" yaml:"explicit_only"`
|
||||
MatchDomains []string `json:"match_domains" yaml:"match_domains"`
|
||||
TimeoutShutdown int `json:"timeout_shutdown" yaml:"timeout_shutdown"`
|
||||
RedirectToHTTPS bool `json:"redirect_to_https" yaml:"redirect_to_https"`
|
||||
AutoCert *autocert.AutocertConfig `json:"autocert"`
|
||||
Entrypoint Entrypoint `json:"entrypoint"`
|
||||
Providers Providers `json:"providers"`
|
||||
MatchDomains []string `json:"match_domains" validate:"domain_name"`
|
||||
Homepage HomepageConfig `json:"homepage"`
|
||||
TimeoutShutdown int `json:"timeout_shutdown" validate:"gte=0"`
|
||||
}
|
||||
Providers struct {
|
||||
Files []string `json:"include" yaml:"include"`
|
||||
Docker map[string]string `json:"docker" yaml:"docker"`
|
||||
Notification NotificationConfigMap `json:"notification" yaml:"notification"`
|
||||
Files []string `json:"include" validate:"dive,filepath"`
|
||||
Docker map[string]string `json:"docker" validate:"dive,unix_addr|url"`
|
||||
Notification []notif.NotificationConfig `json:"notification"`
|
||||
}
|
||||
Entrypoint struct {
|
||||
Middlewares []map[string]any `json:"middlewares"`
|
||||
AccessLog *accesslog.Config `json:"access_log" validate:"omitempty"`
|
||||
}
|
||||
|
||||
ConfigInstance interface {
|
||||
Value() *Config
|
||||
Reload() E.Error
|
||||
Statistics() map[string]any
|
||||
RouteProviderList() []string
|
||||
Context() context.Context
|
||||
}
|
||||
)
|
||||
|
||||
func DefaultConfig() *Config {
|
||||
return &Config{
|
||||
Providers: Providers{},
|
||||
TimeoutShutdown: 3,
|
||||
RedirectToHTTPS: false,
|
||||
Homepage: HomepageConfig{
|
||||
UseDefaultCategories: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func Validate(data []byte) E.Error {
|
||||
var model Config
|
||||
return utils.DeserializeYAML(data, &model)
|
||||
}
|
||||
|
||||
var matchDomainsRegex = regexp.MustCompile(`^[^\.]?([\w\d\-_]\.?)+[^\.]?$`)
|
||||
|
||||
func init() {
|
||||
utils.RegisterDefaultValueFactory(DefaultConfig)
|
||||
utils.MustRegisterValidation("domain_name", func(fl validator.FieldLevel) bool {
|
||||
domains := fl.Field().Interface().([]string)
|
||||
for _, domain := range domains {
|
||||
if !matchDomainsRegex.MatchString(domain) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
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,5 +0,0 @@
|
||||
package types
|
||||
|
||||
import "github.com/yusing/go-proxy/internal/notif"
|
||||
|
||||
type NotificationConfigMap map[string]notif.ProviderConfig
|
||||
@@ -12,11 +12,9 @@ import (
|
||||
"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 = *SharedClient
|
||||
SharedClient struct {
|
||||
*client.Client
|
||||
|
||||
@@ -28,7 +26,7 @@ type (
|
||||
)
|
||||
|
||||
var (
|
||||
clientMap F.Map[string, Client] = F.NewMapOf[string, Client]()
|
||||
clientMap = make(map[string]*SharedClient, 5)
|
||||
clientMapMu sync.Mutex
|
||||
|
||||
clientOptEnvHost = []client.Opt{
|
||||
@@ -38,12 +36,15 @@ var (
|
||||
)
|
||||
|
||||
func init() {
|
||||
task.GlobalTask("close docker clients").OnFinished("", func() {
|
||||
clientMap.RangeAllParallel(func(_ string, c Client) {
|
||||
task.OnProgramExit("docker_clients_cleanup", func() {
|
||||
clientMapMu.Lock()
|
||||
defer clientMapMu.Unlock()
|
||||
|
||||
for _, c := range clientMap {
|
||||
if c.Connected() {
|
||||
c.Client.Close()
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -68,12 +69,11 @@ func (c *SharedClient) Close() {
|
||||
// Returns:
|
||||
// - Client: the Docker client connection.
|
||||
// - error: an error if the connection failed.
|
||||
func ConnectClient(host string) (Client, error) {
|
||||
func ConnectClient(host string) (*SharedClient, error) {
|
||||
clientMapMu.Lock()
|
||||
defer clientMapMu.Unlock()
|
||||
|
||||
// check if client exists
|
||||
if client, ok := clientMap.Load(host); ok {
|
||||
if client, ok := clientMap[host]; ok {
|
||||
client.refCount.Add()
|
||||
return client, nil
|
||||
}
|
||||
@@ -120,15 +120,17 @@ func ConnectClient(host string) (Client, error) {
|
||||
Client: client,
|
||||
key: host,
|
||||
refCount: U.NewRefCounter(),
|
||||
l: logger.With().Str("address", client.DaemonHost()).Logger(),
|
||||
l: logging.With().Str("address", client.DaemonHost()).Logger(),
|
||||
}
|
||||
c.l.Trace().Msg("client connected")
|
||||
|
||||
clientMap.Store(host, c)
|
||||
clientMap[host] = c
|
||||
|
||||
go func() {
|
||||
<-c.refCount.Zero()
|
||||
clientMap.Delete(c.key)
|
||||
clientMapMu.Lock()
|
||||
delete(clientMap, c.key)
|
||||
clientMapMu.Unlock()
|
||||
|
||||
if c.Connected() {
|
||||
c.Client.Close()
|
||||
|
||||
@@ -6,46 +6,54 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/yusing/go-proxy/internal/logging"
|
||||
U "github.com/yusing/go-proxy/internal/utils"
|
||||
"github.com/yusing/go-proxy/internal/utils/strutils"
|
||||
)
|
||||
|
||||
type (
|
||||
PortMapping = map[string]types.Port
|
||||
PortMapping = map[int]types.Port
|
||||
Container struct {
|
||||
_ U.NoCopy
|
||||
|
||||
DockerHost string `json:"docker_host" yaml:"-"`
|
||||
ContainerName string `json:"container_name" yaml:"-"`
|
||||
ContainerID string `json:"container_id" yaml:"-"`
|
||||
ImageName string `json:"image_name" yaml:"-"`
|
||||
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:"labels" yaml:"-"`
|
||||
Labels map[string]string `json:"-"`
|
||||
|
||||
PublicPortMapping PortMapping `json:"public_ports" yaml:"-"` // non-zero publicPort:types.Port
|
||||
PrivatePortMapping PortMapping `json:"private_ports" yaml:"-"` // privatePort:types.Port
|
||||
PublicIP string `json:"public_ip" yaml:"-"`
|
||||
PrivateIP string `json:"private_ip" yaml:"-"`
|
||||
NetworkMode string `json:"network_mode" yaml:"-"`
|
||||
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" yaml:"-"`
|
||||
IsExcluded bool `json:"is_excluded" yaml:"-"`
|
||||
IsExplicit bool `json:"is_explicit" yaml:"-"`
|
||||
IsDatabase bool `json:"is_database" yaml:"-"`
|
||||
IdleTimeout string `json:"idle_timeout,omitempty" yaml:"-"`
|
||||
WakeTimeout string `json:"wake_timeout,omitempty" yaml:"-"`
|
||||
StopMethod string `json:"stop_method,omitempty" yaml:"-"`
|
||||
StopTimeout string `json:"stop_timeout,omitempty" yaml:"-"` // stop_method = "stop" only
|
||||
StopSignal string `json:"stop_signal,omitempty" yaml:"-"` // stop_method = "stop" | "kill" only
|
||||
Running bool `json:"running" yaml:"-"`
|
||||
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
|
||||
StartEndpoint string `json:"start_endpoint,omitempty"`
|
||||
Running bool `json:"running"`
|
||||
}
|
||||
)
|
||||
|
||||
var DummyContainer = new(Container)
|
||||
|
||||
func FromDocker(c *types.Container, dockerHost string) (res *Container) {
|
||||
isExplicit := c.Labels[LabelAliases] != ""
|
||||
isExplicit := false
|
||||
helper := containerHelper{c}
|
||||
for lbl := range c.Labels {
|
||||
if strings.HasPrefix(lbl, NSProxy+".") {
|
||||
isExplicit = true
|
||||
break
|
||||
}
|
||||
}
|
||||
res = &Container{
|
||||
DockerHost: dockerHost,
|
||||
ContainerName: helper.getName(),
|
||||
@@ -58,16 +66,17 @@ func FromDocker(c *types.Container, dockerHost string) (res *Container) {
|
||||
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",
|
||||
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),
|
||||
StartEndpoint: helper.getDeleteLabel(LabelStartEndpoint),
|
||||
Running: c.Status == "running" || c.State == "running",
|
||||
}
|
||||
res.setPrivateIP(helper)
|
||||
res.setPublicIP()
|
||||
@@ -120,7 +129,7 @@ func (c *Container) setPublicIP() {
|
||||
}
|
||||
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)
|
||||
logging.Err(err).Msgf("invalid docker host %q, falling back to 127.0.0.1", c.DockerHost)
|
||||
c.PublicIP = "127.0.0.1"
|
||||
return
|
||||
}
|
||||
|
||||
@@ -33,8 +33,8 @@ func (c containerHelper) getName() string {
|
||||
}
|
||||
|
||||
func (c containerHelper) getImageName() string {
|
||||
colonSep := strings.Split(c.Image, ":")
|
||||
slashSep := strings.Split(colonSep[0], "/")
|
||||
colonSep := strutils.SplitRune(c.Image, ':')
|
||||
slashSep := strutils.SplitRune(colonSep[0], '/')
|
||||
return slashSep[len(slashSep)-1]
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ func (c containerHelper) getPublicPortMapping() PortMapping {
|
||||
if v.PublicPort == 0 {
|
||||
continue
|
||||
}
|
||||
res[strutils.PortString(v.PublicPort)] = v
|
||||
res[int(v.PublicPort)] = v
|
||||
}
|
||||
return res
|
||||
}
|
||||
@@ -52,7 +52,7 @@ func (c containerHelper) getPublicPortMapping() PortMapping {
|
||||
func (c containerHelper) getPrivatePortMapping() PortMapping {
|
||||
res := make(PortMapping)
|
||||
for _, v := range c.Ports {
|
||||
res[strutils.PortString(v.PrivatePort)] = v
|
||||
res[int(v.PrivatePort)] = v
|
||||
}
|
||||
return res
|
||||
}
|
||||
@@ -66,14 +66,6 @@ var databaseMPs = map[string]struct{}{
|
||||
"/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 {
|
||||
@@ -82,7 +74,9 @@ func (c containerHelper) isDatabase() bool {
|
||||
}
|
||||
|
||||
for _, v := range c.Ports {
|
||||
if _, ok := databasePrivPorts[v.PrivatePort]; ok {
|
||||
switch v.PrivatePort {
|
||||
// postgres, mysql or mariadb, redis, memcached, mongodb
|
||||
case 5432, 3306, 6379, 11211, 27017:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
43
internal/docker/container_test.go
Normal file
43
internal/docker/container_test.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package docker
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/docker/docker/api/types"
|
||||
. "github.com/yusing/go-proxy/internal/utils/testing"
|
||||
)
|
||||
|
||||
func TestContainerExplicit(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
labels map[string]string
|
||||
isExplicit bool
|
||||
}{
|
||||
{
|
||||
name: "explicit",
|
||||
labels: map[string]string{
|
||||
"proxy.aliases": "foo",
|
||||
},
|
||||
isExplicit: true,
|
||||
},
|
||||
{
|
||||
name: "explicit2",
|
||||
labels: map[string]string{
|
||||
"proxy.idle_timeout": "1s",
|
||||
},
|
||||
isExplicit: true,
|
||||
},
|
||||
{
|
||||
name: "not explicit",
|
||||
labels: map[string]string{},
|
||||
isExplicit: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
c := FromDocker(&types.Container{Names: []string{"test"}, State: "test", Labels: tt.labels}, "")
|
||||
ExpectEqual(t, c.IsExplicit, tt.isExplicit)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@ package types
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/yusing/go-proxy/internal/docker"
|
||||
@@ -10,11 +12,12 @@ import (
|
||||
|
||||
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"`
|
||||
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"`
|
||||
StartEndpoint string `json:"start_endpoint,omitempty"` // Optional path that must be hit to start container
|
||||
|
||||
DockerHost string `json:"docker_host,omitempty"`
|
||||
ContainerName string `json:"container_name,omitempty"`
|
||||
@@ -31,6 +34,12 @@ const (
|
||||
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
|
||||
@@ -52,17 +61,19 @@ func ValidateConfig(cont *docker.Container) (*Config, E.Error) {
|
||||
stopTimeout := E.Collect(errs, validateDurationPostitive, cont.StopTimeout)
|
||||
stopMethod := E.Collect(errs, validateStopMethod, cont.StopMethod)
|
||||
signal := E.Collect(errs, validateSignal, cont.StopSignal)
|
||||
startEndpoint := E.Collect(errs, validateStartEndpoint, cont.StartEndpoint)
|
||||
|
||||
if errs.HasError() {
|
||||
return nil, errs.Error()
|
||||
}
|
||||
|
||||
return &Config{
|
||||
IdleTimeout: idleTimeout,
|
||||
WakeTimeout: wakeTimeout,
|
||||
StopTimeout: int(stopTimeout.Seconds()),
|
||||
StopMethod: stopMethod,
|
||||
StopSignal: signal,
|
||||
IdleTimeout: idleTimeout,
|
||||
WakeTimeout: wakeTimeout,
|
||||
StopTimeout: int(stopTimeout.Seconds()),
|
||||
StopMethod: stopMethod,
|
||||
StopSignal: signal,
|
||||
StartEndpoint: startEndpoint,
|
||||
|
||||
DockerHost: cont.DockerHost,
|
||||
ContainerName: cont.ContainerName,
|
||||
@@ -83,12 +94,9 @@ func validateDurationPostitive(value string) (time.Duration, error) {
|
||||
}
|
||||
|
||||
func validateSignal(s string) (Signal, error) {
|
||||
switch s {
|
||||
case "", "SIGINT", "SIGTERM", "SIGHUP", "SIGQUIT",
|
||||
"INT", "TERM", "HUP", "QUIT":
|
||||
if _, ok := validSignals[s]; ok {
|
||||
return Signal(s), nil
|
||||
}
|
||||
|
||||
return "", errors.New("invalid signal " + s)
|
||||
}
|
||||
|
||||
@@ -101,3 +109,21 @@ func validateStopMethod(s string) (StopMethod, error) {
|
||||
return "", errors.New("invalid stop method " + s)
|
||||
}
|
||||
}
|
||||
|
||||
func validateStartEndpoint(s string) (string, error) {
|
||||
if s == "" {
|
||||
return "", nil
|
||||
}
|
||||
// checks needed as of Go 1.6 because of change https://github.com/golang/go/commit/617c93ce740c3c3cc28cdd1a0d712be183d0b328#diff-6c2d018290e298803c0c9419d8739885L195
|
||||
// emulate browser and strip the '#' suffix prior to validation. see issue-#237
|
||||
if i := strings.Index(s, "#"); i > -1 {
|
||||
s = s[:i]
|
||||
}
|
||||
if len(s) == 0 {
|
||||
return "", errors.New("start endpoint must not be empty if defined")
|
||||
}
|
||||
if _, err := url.ParseRequestURI(s); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
47
internal/docker/idlewatcher/types/config_test.go
Normal file
47
internal/docker/idlewatcher/types/config_test.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
. "github.com/yusing/go-proxy/internal/utils/testing"
|
||||
)
|
||||
|
||||
func TestValidateStartEndpoint(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid",
|
||||
input: "/start",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "invalid",
|
||||
input: "../foo",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "single fragment",
|
||||
input: "#",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "empty",
|
||||
input: "",
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
s, err := validateStartEndpoint(tc.input)
|
||||
if err == nil {
|
||||
ExpectEqual(t, s, tc.input)
|
||||
}
|
||||
if (err != nil) != tc.wantErr {
|
||||
t.Errorf("validateStartEndpoint() error = %v, wantErr %t", err, tc.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -11,4 +11,5 @@ type Waker interface {
|
||||
health.HealthMonitor
|
||||
http.Handler
|
||||
net.Stream
|
||||
Wake() error
|
||||
}
|
||||
|
||||
@@ -4,25 +4,32 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
. "github.com/yusing/go-proxy/internal/docker/idlewatcher/types"
|
||||
"github.com/yusing/go-proxy/internal/common"
|
||||
"github.com/yusing/go-proxy/internal/docker/idlewatcher/types"
|
||||
E "github.com/yusing/go-proxy/internal/error"
|
||||
gphttp "github.com/yusing/go-proxy/internal/net/http"
|
||||
"github.com/yusing/go-proxy/internal/metrics"
|
||||
"github.com/yusing/go-proxy/internal/net/http/reverseproxy"
|
||||
net "github.com/yusing/go-proxy/internal/net/types"
|
||||
"github.com/yusing/go-proxy/internal/proxy/entry"
|
||||
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 {
|
||||
_ U.NoCopy
|
||||
type (
|
||||
Waker = types.Waker
|
||||
waker struct {
|
||||
_ U.NoCopy
|
||||
|
||||
rp *gphttp.ReverseProxy
|
||||
stream net.Stream
|
||||
hc health.HealthChecker
|
||||
rp *reverseproxy.ReverseProxy
|
||||
stream net.Stream
|
||||
hc health.HealthChecker
|
||||
metric *metrics.Gauge
|
||||
|
||||
ready atomic.Bool
|
||||
}
|
||||
ready atomic.Bool
|
||||
}
|
||||
)
|
||||
|
||||
const (
|
||||
idleWakerCheckInterval = 100 * time.Millisecond
|
||||
@@ -31,49 +38,63 @@ const (
|
||||
|
||||
// TODO: support stream
|
||||
|
||||
func newWaker(providerSubTask task.Task, entry entry.Entry, rp *gphttp.ReverseProxy, stream net.Stream) (Waker, E.Error) {
|
||||
hcCfg := entry.HealthCheckConfig()
|
||||
func newWaker(parent task.Parent, route route.Route, rp *reverseproxy.ReverseProxy, stream net.Stream) (Waker, E.Error) {
|
||||
hcCfg := route.HealthCheckConfig()
|
||||
hcCfg.Timeout = idleWakerCheckTimeout
|
||||
|
||||
waker := &waker{
|
||||
rp: rp,
|
||||
stream: stream,
|
||||
}
|
||||
|
||||
watcher, err := registerWatcher(providerSubTask, entry, waker)
|
||||
task := parent.Subtask("idlewatcher." + route.TargetName())
|
||||
watcher, err := registerWatcher(task, route, waker)
|
||||
if err != nil {
|
||||
return nil, E.Errorf("register watcher: %w", err)
|
||||
}
|
||||
|
||||
switch {
|
||||
case rp != nil:
|
||||
waker.hc = health.NewHTTPHealthChecker(entry.TargetURL(), hcCfg, rp.Transport)
|
||||
waker.hc = monitor.NewHTTPHealthChecker(route.TargetURL(), hcCfg)
|
||||
case stream != nil:
|
||||
waker.hc = health.NewRawHealthChecker(entry.TargetURL(), hcCfg)
|
||||
waker.hc = monitor.NewRawHealthChecker(route.TargetURL(), hcCfg)
|
||||
default:
|
||||
panic("both nil")
|
||||
}
|
||||
|
||||
if common.PrometheusEnabled {
|
||||
m := metrics.GetServiceMetrics()
|
||||
fqn := parent.Name() + "/" + route.TargetName()
|
||||
waker.metric = m.HealthStatus.With(metrics.HealthMetricLabels(fqn))
|
||||
waker.metric.Set(float64(watcher.Status()))
|
||||
}
|
||||
return watcher, nil
|
||||
}
|
||||
|
||||
// lifetime should follow route provider.
|
||||
func NewHTTPWaker(providerSubTask task.Task, entry entry.Entry, rp *gphttp.ReverseProxy) (Waker, E.Error) {
|
||||
return newWaker(providerSubTask, entry, rp, nil)
|
||||
func NewHTTPWaker(parent task.Parent, route route.Route, rp *reverseproxy.ReverseProxy) (Waker, E.Error) {
|
||||
return newWaker(parent, route, rp, nil)
|
||||
}
|
||||
|
||||
func NewStreamWaker(providerSubTask task.Task, entry entry.Entry, stream net.Stream) (Waker, E.Error) {
|
||||
return newWaker(providerSubTask, entry, nil, stream)
|
||||
func NewStreamWaker(parent task.Parent, route route.Route, stream net.Stream) (Waker, E.Error) {
|
||||
return newWaker(parent, route, nil, stream)
|
||||
}
|
||||
|
||||
// Start implements health.HealthMonitor.
|
||||
func (w *Watcher) Start(routeSubTask task.Task) E.Error {
|
||||
routeSubTask.Finish("ignored")
|
||||
w.task.OnCancel("stop route", func() {
|
||||
routeSubTask.Parent().Finish(w.task.FinishCause())
|
||||
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()
|
||||
}
|
||||
})
|
||||
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 {
|
||||
@@ -96,8 +117,21 @@ func (w *Watcher) Uptime() time.Duration {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Latency implements health.HealthMonitor.
|
||||
func (w *Watcher) Latency() 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
|
||||
}
|
||||
@@ -106,12 +140,12 @@ func (w *Watcher) Status() health.Status {
|
||||
return health.StatusHealthy
|
||||
}
|
||||
|
||||
healthy, _, err := w.hc.CheckHealth()
|
||||
result, err := w.hc.CheckHealth()
|
||||
switch {
|
||||
case err != nil:
|
||||
w.ready.Store(false)
|
||||
return health.StatusError
|
||||
case healthy:
|
||||
case result.Healthy:
|
||||
w.ready.Store(true)
|
||||
return health.StatusHealthy
|
||||
default:
|
||||
@@ -121,11 +155,11 @@ func (w *Watcher) Status() health.Status {
|
||||
|
||||
// MarshalJSON implements health.HealthMonitor.
|
||||
func (w *Watcher) MarshalJSON() ([]byte, error) {
|
||||
var url net.URL
|
||||
var url *net.URL
|
||||
if w.hc.URL().Port() != "0" {
|
||||
url = w.hc.URL()
|
||||
}
|
||||
return (&health.JSONRepresentation{
|
||||
return (&monitor.JSONRepresentation{
|
||||
Name: w.Name(),
|
||||
Status: w.Status(),
|
||||
Config: w.hc.Config(),
|
||||
|
||||
@@ -12,6 +12,21 @@ import (
|
||||
"github.com/yusing/go-proxy/internal/watcher/health"
|
||||
)
|
||||
|
||||
type ForceCacheControl struct {
|
||||
expires string
|
||||
http.ResponseWriter
|
||||
}
|
||||
|
||||
func (f *ForceCacheControl) WriteHeader(code int) {
|
||||
f.ResponseWriter.Header().Set("Cache-Control", "must-revalidate")
|
||||
f.ResponseWriter.Header().Set("Expires", f.expires)
|
||||
f.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func (f *ForceCacheControl) Unwrap() http.ResponseWriter {
|
||||
return f.ResponseWriter
|
||||
}
|
||||
|
||||
// ServeHTTP implements http.Handler.
|
||||
func (w *Watcher) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
|
||||
shouldNext := w.wakeFromHTTP(rw, r)
|
||||
@@ -22,7 +37,8 @@ func (w *Watcher) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
default:
|
||||
w.rp.ServeHTTP(rw, r)
|
||||
f := &ForceCacheControl{expires: w.expires().Format(http.TimeFormat), ResponseWriter: rw}
|
||||
w.rp.ServeHTTP(f, r)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +50,12 @@ func (w *Watcher) wakeFromHTTP(rw http.ResponseWriter, r *http.Request) (shouldN
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if start endpoint is configured and request path matches
|
||||
if w.StartEndpoint != "" && r.URL.Path != w.StartEndpoint {
|
||||
http.Error(rw, "Forbidden: Container can only be started via configured start endpoint", http.StatusForbidden)
|
||||
return false
|
||||
}
|
||||
|
||||
if r.Body != nil {
|
||||
defer r.Body.Close()
|
||||
}
|
||||
@@ -47,7 +69,7 @@ func (w *Watcher) wakeFromHTTP(rw http.ResponseWriter, r *http.Request) (shouldN
|
||||
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().Set("Cache-Control", "no-cache")
|
||||
rw.Header().Add("Cache-Control", "no-store")
|
||||
rw.Header().Add("Cache-Control", "must-revalidate")
|
||||
rw.Header().Add("Connection", "close")
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
idlewatcher "github.com/yusing/go-proxy/internal/docker/idlewatcher/types"
|
||||
E "github.com/yusing/go-proxy/internal/error"
|
||||
"github.com/yusing/go-proxy/internal/logging"
|
||||
"github.com/yusing/go-proxy/internal/proxy/entry"
|
||||
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"
|
||||
@@ -29,10 +29,11 @@ type (
|
||||
*idlewatcher.Config
|
||||
*waker
|
||||
|
||||
client D.Client
|
||||
client *D.SharedClient
|
||||
stopByMethod StopCallback // send a docker command w.r.t. `stop_method`
|
||||
ticker *time.Ticker
|
||||
task task.Task
|
||||
lastReset time.Time
|
||||
task *task.Task
|
||||
}
|
||||
|
||||
WakeDone <-chan error
|
||||
@@ -44,16 +45,16 @@ var (
|
||||
watcherMap = F.NewMapOf[string, *Watcher]()
|
||||
watcherMapMu sync.Mutex
|
||||
|
||||
logger = logging.With().Str("module", "idle_watcher").Logger()
|
||||
errShouldNotReachHere = errors.New("should not reach here")
|
||||
)
|
||||
|
||||
const dockerReqTimeout = 3 * time.Second
|
||||
|
||||
func registerWatcher(providerSubtask task.Task, entry entry.Entry, waker *waker) (*Watcher, error) {
|
||||
cfg := entry.IdlewatcherConfig()
|
||||
func registerWatcher(watcherTask *task.Task, route route.Route, waker *waker) (*Watcher, error) {
|
||||
cfg := route.IdlewatcherConfig()
|
||||
|
||||
if cfg.IdleTimeout == 0 {
|
||||
panic("should not reach here")
|
||||
panic(errShouldNotReachHere)
|
||||
}
|
||||
|
||||
watcherMapMu.Lock()
|
||||
@@ -65,7 +66,7 @@ func registerWatcher(providerSubtask task.Task, entry entry.Entry, waker *waker)
|
||||
w.Config = cfg
|
||||
w.waker = waker
|
||||
w.resetIdleTimer()
|
||||
providerSubtask.Finish("used existing watcher")
|
||||
watcherTask.Finish("used existing watcher")
|
||||
return w, nil
|
||||
}
|
||||
|
||||
@@ -75,11 +76,11 @@ func registerWatcher(providerSubtask task.Task, entry entry.Entry, waker *waker)
|
||||
}
|
||||
|
||||
w := &Watcher{
|
||||
Logger: logger.With().Str("name", cfg.ContainerName).Logger(),
|
||||
Logger: logging.With().Str("name", cfg.ContainerName).Logger(),
|
||||
Config: cfg,
|
||||
waker: waker,
|
||||
client: client,
|
||||
task: providerSubtask,
|
||||
task: watcherTask,
|
||||
ticker: time.NewTicker(cfg.IdleTimeout),
|
||||
}
|
||||
w.stopByMethod = w.getStopCallback()
|
||||
@@ -98,12 +99,18 @@ func registerWatcher(providerSubtask task.Task, entry entry.Entry, waker *waker)
|
||||
return w, nil
|
||||
}
|
||||
|
||||
func (w *Watcher) Wake() error {
|
||||
return w.wakeIfStopped()
|
||||
}
|
||||
|
||||
// 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 (w *Watcher) WakeTrace() *zerolog.Event {
|
||||
//nolint:zerologlint
|
||||
return w.Trace().Str("action", "wake")
|
||||
}
|
||||
|
||||
@@ -173,7 +180,7 @@ func (w *Watcher) wakeIfStopped() error {
|
||||
case "running":
|
||||
return nil
|
||||
default:
|
||||
panic("should not reach here")
|
||||
return E.Errorf("unexpected container status: %s", status)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,7 +194,7 @@ func (w *Watcher) getStopCallback() StopCallback {
|
||||
case idlewatcher.StopMethodKill:
|
||||
cb = w.containerKill
|
||||
default:
|
||||
panic("should not reach here")
|
||||
panic(errShouldNotReachHere)
|
||||
}
|
||||
return func() error {
|
||||
ctx, cancel := context.WithTimeout(w.task.Context(), time.Duration(w.StopTimeout)*time.Second)
|
||||
@@ -199,11 +206,15 @@ func (w *Watcher) getStopCallback() StopCallback {
|
||||
func (w *Watcher) resetIdleTimer() {
|
||||
w.Trace().Msg("reset idle timer")
|
||||
w.ticker.Reset(w.IdleTimeout)
|
||||
w.lastReset = time.Now()
|
||||
}
|
||||
|
||||
func (w *Watcher) getEventCh(dockerWatcher watcher.DockerWatcher) (eventTask task.Task, eventCh <-chan events.Event, errCh <-chan E.Error) {
|
||||
eventTask = w.task.Subtask("docker event watcher")
|
||||
eventCh, errCh = dockerWatcher.EventsWithOptions(eventTask.Context(), watcher.DockerListOptions{
|
||||
func (w *Watcher) expires() time.Time {
|
||||
return w.lastReset.Add(w.IdleTimeout)
|
||||
}
|
||||
|
||||
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),
|
||||
@@ -232,8 +243,7 @@ func (w *Watcher) getEventCh(dockerWatcher watcher.DockerWatcher) (eventTask tas
|
||||
// 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)
|
||||
eventTask, dockerEventCh, dockerEventErrCh := w.getEventCh(dockerWatcher)
|
||||
defer eventTask.Finish("stopped")
|
||||
dockerEventCh, dockerEventErrCh := w.getEventCh(dockerWatcher)
|
||||
|
||||
for {
|
||||
select {
|
||||
@@ -272,8 +282,7 @@ func (w *Watcher) watchUntilDestroy() (returnCause error) {
|
||||
w.Debug().Msgf("id changed %s -> %s", w.ContainerID, e.ActorID)
|
||||
w.ContainerID = e.ActorID
|
||||
// recreate event stream
|
||||
eventTask.Finish("recreate event stream")
|
||||
eventTask, dockerEventCh, dockerEventErrCh = w.getEventCh(dockerWatcher)
|
||||
dockerEventCh, dockerEventErrCh = w.getEventCh(dockerWatcher)
|
||||
}
|
||||
case <-w.ticker.C:
|
||||
w.ticker.Stop()
|
||||
@@ -283,6 +292,9 @@ func (w *Watcher) watchUntilDestroy() (returnCause error) {
|
||||
case errors.Is(err, context.Canceled):
|
||||
continue
|
||||
case err != nil:
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
err = errors.New("timeout waiting for container to stop, please set a higher value for `stop_timeout`")
|
||||
}
|
||||
w.Err(err).Msgf("container stop with method %q failed", w.StopMethod)
|
||||
default:
|
||||
w.LogReason("container stopped", "idle timeout")
|
||||
|
||||
@@ -8,16 +8,15 @@ import (
|
||||
|
||||
func Inspect(dockerHost string, containerID string) (*Container, error) {
|
||||
client, err := ConnectClient(dockerHost)
|
||||
defer client.Close()
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer client.Close()
|
||||
return client.Inspect(containerID)
|
||||
}
|
||||
|
||||
func (c Client) Inspect(containerID string) (*Container, error) {
|
||||
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()
|
||||
|
||||
|
||||
@@ -1,125 +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
|
||||
|
||||
var (
|
||||
ErrApplyToNil = E.New("label value is nil")
|
||||
ErrFieldNotExist = E.New("field does not exist")
|
||||
)
|
||||
var ErrInvalidLabel = E.New("invalid label")
|
||||
|
||||
func (l *Label) String() string {
|
||||
if l.Attribute == "" {
|
||||
return l.Namespace + "." + l.Target
|
||||
}
|
||||
return l.Namespace + "." + l.Target + "." + l.Attribute
|
||||
}
|
||||
func ParseLabels(labels map[string]string) (LabelMap, E.Error) {
|
||||
nestedMap := make(LabelMap)
|
||||
errs := E.NewBuilder("labels error")
|
||||
|
||||
// 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.Error {
|
||||
if obj == nil {
|
||||
return ErrApplyToNil.Subject(l.String())
|
||||
}
|
||||
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
|
||||
}
|
||||
for lbl, value := range labels {
|
||||
parts := strutils.SplitRune(lbl, '.')
|
||||
if parts[0] != NSProxy {
|
||||
continue
|
||||
}
|
||||
if !field.IsValid() {
|
||||
return ErrFieldNotExist.Subject(l.Attribute).Subject(l.String())
|
||||
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
|
||||
}
|
||||
err := U.Deserialize(U.SerializedObject{nestedLabel.Namespace: nestedLabel.Value}, field.Interface())
|
||||
if err != nil {
|
||||
return err.Subject(l.String())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
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:
|
||||
err := U.Deserialize(U.SerializedObject{l.Attribute: l.Value}, obj)
|
||||
if err != nil {
|
||||
return err.Subject(l.String())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func ParseLabel(label string, value string) *Label {
|
||||
parts := strings.Split(label, ".")
|
||||
|
||||
if len(parts) < 2 {
|
||||
return &Label{
|
||||
Namespace: label,
|
||||
Value: value,
|
||||
}
|
||||
}
|
||||
|
||||
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 := ParseLabel(strings.Join(parts[3:], "."), value)
|
||||
l.Value = nestedLabel
|
||||
}
|
||||
|
||||
return l
|
||||
|
||||
return nestedMap, errs.Error()
|
||||
}
|
||||
|
||||
@@ -1,79 +1,18 @@
|
||||
package docker
|
||||
package docker_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
U "github.com/yusing/go-proxy/internal/utils"
|
||||
. "github.com/yusing/go-proxy/internal/utils/testing"
|
||||
"github.com/yusing/go-proxy/internal/docker"
|
||||
)
|
||||
|
||||
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"
|
||||
lbl := ParseLabel(makeLabel(NSProxy, "foo", makeLabel("middlewares", mName, mAttr)), v)
|
||||
sGot := ExpectType[*Label](t, lbl.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"`
|
||||
})
|
||||
lbl := ParseLabel(makeLabel(NSProxy, "foo", makeLabel("middlewares", mName, mAttr)), v)
|
||||
err := ApplyLabel(entry, lbl)
|
||||
ExpectNoError(t, err)
|
||||
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
|
||||
|
||||
lbl := ParseLabel(makeLabel(NSProxy, "foo", makeLabel("middlewares", mName, mAttr)), v)
|
||||
err := ApplyLabel(entry, lbl)
|
||||
ExpectNoError(t, err)
|
||||
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)
|
||||
|
||||
lbl := ParseLabel(makeLabel(NSProxy, "foo", fmt.Sprintf("%s.%s", "middlewares", mName)), v)
|
||||
err := ApplyLabel(entry, lbl)
|
||||
ExpectNoError(t, err)
|
||||
_, ok := entry.Middlewares[mName]
|
||||
ExpectTrue(t, ok)
|
||||
func BenchmarkParseLabels(b *testing.B) {
|
||||
for range b.N {
|
||||
_, _ = docker.ParseLabels(map[string]string{
|
||||
"proxy.a.host": "localhost",
|
||||
"proxy.a.port": "4444",
|
||||
"proxy.a.scheme": "http",
|
||||
"proxy.a.middlewares.request.hide_headers": "X-Header1,X-Header2",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,14 +3,14 @@ package docker
|
||||
const (
|
||||
WildcardAlias = "*"
|
||||
|
||||
NSProxy = "proxy"
|
||||
NSHomePage = "homepage"
|
||||
NSProxy = "proxy"
|
||||
|
||||
LabelAliases = NSProxy + ".aliases"
|
||||
LabelExclude = NSProxy + ".exclude"
|
||||
LabelIdleTimeout = NSProxy + ".idle_timeout"
|
||||
LabelWakeTimeout = NSProxy + ".wake_timeout"
|
||||
LabelStopMethod = NSProxy + ".stop_method"
|
||||
LabelStopTimeout = NSProxy + ".stop_timeout"
|
||||
LabelStopSignal = NSProxy + ".stop_signal"
|
||||
LabelAliases = NSProxy + ".aliases"
|
||||
LabelExclude = NSProxy + ".exclude"
|
||||
LabelIdleTimeout = NSProxy + ".idle_timeout"
|
||||
LabelWakeTimeout = NSProxy + ".wake_timeout"
|
||||
LabelStopMethod = NSProxy + ".stop_method"
|
||||
LabelStopTimeout = NSProxy + ".stop_timeout"
|
||||
LabelStopSignal = NSProxy + ".stop_signal"
|
||||
LabelStartEndpoint = NSProxy + ".start_endpoint"
|
||||
)
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
package docker
|
||||
|
||||
import (
|
||||
"github.com/yusing/go-proxy/internal/logging"
|
||||
)
|
||||
|
||||
var logger = logging.With().Str("module", "docker").Logger()
|
||||
138
internal/entrypoint/entrypoint.go
Normal file
138
internal/entrypoint/entrypoint.go
Normal file
@@ -0,0 +1,138 @@
|
||||
package entrypoint
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/yusing/go-proxy/internal/logging"
|
||||
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"
|
||||
)
|
||||
|
||||
type Entrypoint struct {
|
||||
middleware *middleware.Middleware
|
||||
accessLogger *accesslog.AccessLogger
|
||||
findRouteFunc func(host string) (route.HTTPRoute, error)
|
||||
}
|
||||
|
||||
var ErrNoSuchRoute = errors.New("no such route")
|
||||
|
||||
func NewEntrypoint() *Entrypoint {
|
||||
return &Entrypoint{
|
||||
findRouteFunc: findRouteAnyDomain,
|
||||
}
|
||||
}
|
||||
|
||||
func (ep *Entrypoint) SetFindRouteDomains(domains []string) {
|
||||
if len(domains) == 0 {
|
||||
ep.findRouteFunc = findRouteAnyDomain
|
||||
} else {
|
||||
ep.findRouteFunc = findRouteByDomains(domains)
|
||||
}
|
||||
}
|
||||
|
||||
func (ep *Entrypoint) SetMiddlewares(mws []map[string]any) error {
|
||||
if len(mws) == 0 {
|
||||
ep.middleware = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
mid, err := middleware.BuildMiddlewareFromChainRaw("entrypoint", mws)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ep.middleware = mid
|
||||
|
||||
logging.Debug().Msg("entrypoint middleware loaded")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ep *Entrypoint) SetAccessLogger(parent task.Parent, cfg *accesslog.Config) (err error) {
|
||||
if cfg == nil {
|
||||
ep.accessLogger = nil
|
||||
return
|
||||
}
|
||||
|
||||
ep.accessLogger, err = accesslog.NewFileAccessLogger(parent, cfg)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
logging.Debug().Msg("entrypoint access logger created")
|
||||
return
|
||||
}
|
||||
|
||||
func (ep *Entrypoint) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
mux, err := ep.findRouteFunc(r.Host)
|
||||
if err == nil {
|
||||
if ep.accessLogger != nil {
|
||||
w = gphttp.NewModifyResponseWriter(w, r, func(resp *http.Response) error {
|
||||
ep.accessLogger.Log(r, resp)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
if ep.middleware != nil {
|
||||
ep.middleware.ServeHTTP(mux.ServeHTTP, w, r)
|
||||
return
|
||||
}
|
||||
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 {
|
||||
logging.Err(err).
|
||||
Str("method", r.Method).
|
||||
Str("url", r.URL.String()).
|
||||
Str("remote", r.RemoteAddr).
|
||||
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 {
|
||||
logging.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)
|
||||
}
|
||||
}
|
||||
121
internal/entrypoint/entrypoint_test.go
Normal file
121
internal/entrypoint/entrypoint_test.go
Normal file
@@ -0,0 +1,121 @@
|
||||
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.ReveseProxyRoute
|
||||
ep = NewEntrypoint()
|
||||
)
|
||||
|
||||
func run(t *testing.T, match []string, noMatch []string) {
|
||||
t.Helper()
|
||||
t.Cleanup(routes.TestClear)
|
||||
t.Cleanup(func() { ep.SetFindRouteDomains(nil) })
|
||||
|
||||
for _, test := range match {
|
||||
t.Run(test, func(t *testing.T) {
|
||||
found, err := ep.findRouteFunc(test)
|
||||
ExpectNoError(t, err)
|
||||
ExpectTrue(t, found == &r)
|
||||
})
|
||||
}
|
||||
|
||||
for _, test := range noMatch {
|
||||
t.Run(test, func(t *testing.T) {
|
||||
_, err := ep.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) {
|
||||
ep.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) {
|
||||
ep.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)
|
||||
}
|
||||
@@ -1,11 +1,14 @@
|
||||
package error
|
||||
package err
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// baseError is an immutable wrapper around an error.
|
||||
//
|
||||
//nolint:recvcheck
|
||||
type baseError struct {
|
||||
Err error `json:"err"`
|
||||
}
|
||||
@@ -44,3 +47,18 @@ func (err baseError) Withf(format string, args ...any) Error {
|
||||
func (err *baseError) Error() string {
|
||||
return err.Err.Error()
|
||||
}
|
||||
|
||||
// MarshalJSON implements the json.Marshaler interface.
|
||||
func (err *baseError) MarshalJSON() ([]byte, error) {
|
||||
//nolint:errorlint
|
||||
switch err := err.Err.(type) {
|
||||
case Error, *withSubject:
|
||||
return json.Marshal(err)
|
||||
case json.Marshaler:
|
||||
return err.MarshalJSON()
|
||||
case interface{ MarshalText() ([]byte, error) }:
|
||||
return err.MarshalText()
|
||||
default:
|
||||
return json.Marshal(err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package error
|
||||
package err
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -60,7 +60,7 @@ func (b *Builder) Add(err error) *Builder {
|
||||
b.Lock()
|
||||
defer b.Unlock()
|
||||
|
||||
switch err := err.(type) {
|
||||
switch err := From(err).(type) {
|
||||
case *baseError:
|
||||
b.errs = append(b.errs, err.Err)
|
||||
case *nestedError:
|
||||
@@ -70,7 +70,7 @@ func (b *Builder) Add(err error) *Builder {
|
||||
b.errs = append(b.errs, err)
|
||||
}
|
||||
default:
|
||||
b.errs = append(b.errs, err)
|
||||
panic("bug: should not reach here")
|
||||
}
|
||||
|
||||
return b
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package error_test
|
||||
package err_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package error
|
||||
package err
|
||||
|
||||
type Error interface {
|
||||
error
|
||||
@@ -24,6 +24,8 @@ type Error interface {
|
||||
|
||||
// this makes JSON marshaling work,
|
||||
// as the builtin one doesn't.
|
||||
//
|
||||
//nolint:errname
|
||||
type errStr string
|
||||
|
||||
func (err errStr) Error() string {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package error
|
||||
package err
|
||||
|
||||
import (
|
||||
"errors"
|
||||
@@ -18,11 +18,11 @@ func TestBaseWithSubject(t *testing.T) {
|
||||
withSubjectf := err.Subjectf("%s %s", "foo", "bar")
|
||||
|
||||
ExpectError(t, err, withSubject)
|
||||
ExpectStrEqual(t, withSubject.Error(), "foo: error")
|
||||
ExpectEqual(t, withSubject.Error(), "foo: error")
|
||||
ExpectTrue(t, withSubject.Is(err))
|
||||
|
||||
ExpectError(t, err, withSubjectf)
|
||||
ExpectStrEqual(t, withSubjectf.Error(), "foo bar: error")
|
||||
ExpectEqual(t, withSubjectf.Error(), "foo bar: error")
|
||||
ExpectTrue(t, withSubjectf.Is(err))
|
||||
}
|
||||
|
||||
@@ -81,10 +81,10 @@ func TestErrorImmutability(t *testing.T) {
|
||||
|
||||
for range 3 {
|
||||
// t.Logf("%d: %v %T %s", i, errors.Unwrap(err), err, err)
|
||||
err.Subject("foo")
|
||||
_ = err.Subject("foo")
|
||||
ExpectFalse(t, strings.Contains(err.Error(), "foo"))
|
||||
|
||||
err.With(err2)
|
||||
_ = err.With(err2)
|
||||
ExpectFalse(t, strings.Contains(err.Error(), "extra"))
|
||||
ExpectFalse(t, err.Is(err2))
|
||||
|
||||
@@ -102,7 +102,7 @@ func TestErrorWith(t *testing.T) {
|
||||
ExpectTrue(t, err3.Is(err1))
|
||||
ExpectTrue(t, err3.Is(err2))
|
||||
|
||||
err2.Subject("foo")
|
||||
_ = err2.Subject("foo")
|
||||
|
||||
ExpectTrue(t, err3.Is(err1))
|
||||
ExpectTrue(t, err3.Is(err2))
|
||||
@@ -114,9 +114,9 @@ func TestErrorWith(t *testing.T) {
|
||||
func TestErrorStringSimple(t *testing.T) {
|
||||
errFailure := New("generic failure")
|
||||
ne := errFailure.Subject("foo bar")
|
||||
ExpectStrEqual(t, ne.Error(), "foo bar: generic failure")
|
||||
ExpectEqual(t, ne.Error(), "foo bar: generic failure")
|
||||
ne = ne.Subject("baz")
|
||||
ExpectStrEqual(t, ne.Error(), "baz > foo bar: generic failure")
|
||||
ExpectEqual(t, ne.Error(), "baz > foo bar: generic failure")
|
||||
}
|
||||
|
||||
func TestErrorStringNested(t *testing.T) {
|
||||
@@ -153,5 +153,5 @@ func TestErrorStringNested(t *testing.T) {
|
||||
• action 3 > inner3: generic failure
|
||||
• 3
|
||||
• 3`
|
||||
ExpectStrEqual(t, ne.Error(), want)
|
||||
ExpectEqual(t, ne.Error(), want)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package error
|
||||
package err
|
||||
|
||||
import (
|
||||
"github.com/rs/zerolog"
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
package error
|
||||
package err
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/yusing/go-proxy/internal/utils/strutils"
|
||||
)
|
||||
|
||||
//nolint:recvcheck
|
||||
type nestedError struct {
|
||||
Err error `json:"err"`
|
||||
Extras []error `json:"extras"`
|
||||
@@ -66,7 +68,18 @@ func (err *nestedError) Is(other error) bool {
|
||||
}
|
||||
|
||||
func (err *nestedError) Error() string {
|
||||
return buildError(err, 0)
|
||||
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))
|
||||
lines = append(lines, makeLines(err.Extras, 1)...)
|
||||
} else {
|
||||
lines = append(lines, makeLines(err.Extras, 0)...)
|
||||
}
|
||||
return strutils.JoinLines(lines)
|
||||
}
|
||||
|
||||
//go:inline
|
||||
@@ -86,35 +99,15 @@ func makeLines(errs []error, level int) []string {
|
||||
}
|
||||
lines := make([]string, 0, len(errs))
|
||||
for _, err := range errs {
|
||||
switch err := err.(type) {
|
||||
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...)
|
||||
}
|
||||
lines = append(lines, makeLines(err.Extras, level+1)...)
|
||||
default:
|
||||
lines = append(lines, makeLine(err.Error(), level))
|
||||
}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func buildError(err error, level int) string {
|
||||
switch err := err.(type) {
|
||||
case nil:
|
||||
return makeLine("<nil>", level)
|
||||
case *nestedError:
|
||||
lines := make([]string, 0, 1+len(err.Extras))
|
||||
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...)
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
default:
|
||||
return makeLine(err.Error(), level)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
package error
|
||||
package err
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/yusing/go-proxy/internal/utils/strutils/ansi"
|
||||
)
|
||||
|
||||
//nolint:errname
|
||||
type withSubject struct {
|
||||
Subject string `json:"subject"`
|
||||
Err error `json:"err"`
|
||||
Subjects []string
|
||||
Err error
|
||||
|
||||
pendingSubject string
|
||||
}
|
||||
|
||||
const subjectSep = " > "
|
||||
@@ -18,23 +22,40 @@ func highlight(subject string) string {
|
||||
}
|
||||
|
||||
func PrependSubject(subject string, err error) error {
|
||||
switch err := err.(type) {
|
||||
case nil:
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
//nolint:errorlint
|
||||
switch err := err.(type) {
|
||||
case *withSubject:
|
||||
return err.Prepend(subject)
|
||||
case Error:
|
||||
return err.Subject(subject)
|
||||
default:
|
||||
return &withSubject{subject, err}
|
||||
}
|
||||
return &withSubject{[]string{subject}, err, ""}
|
||||
}
|
||||
|
||||
func (err withSubject) Prepend(subject string) *withSubject {
|
||||
if subject != "" {
|
||||
err.Subject = subject + subjectSep + err.Subject
|
||||
func (err *withSubject) Prepend(subject string) *withSubject {
|
||||
if subject == "" {
|
||||
return err
|
||||
}
|
||||
return &err
|
||||
|
||||
clone := *err
|
||||
switch subject[0] {
|
||||
case '[', '(', '{':
|
||||
// since prepend is called in depth-first order,
|
||||
// the subject of the index is not yet seen
|
||||
// add it when the next subject is seen
|
||||
clone.pendingSubject += subject
|
||||
default:
|
||||
clone.Subjects = append(clone.Subjects, subject)
|
||||
if clone.pendingSubject != "" {
|
||||
clone.Subjects[len(clone.Subjects)-1] = subject + clone.pendingSubject
|
||||
clone.pendingSubject = ""
|
||||
}
|
||||
}
|
||||
return &clone
|
||||
}
|
||||
|
||||
func (err *withSubject) Is(other error) bool {
|
||||
@@ -46,7 +67,39 @@ func (err *withSubject) Unwrap() error {
|
||||
}
|
||||
|
||||
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()
|
||||
// subject is in reversed order
|
||||
n := len(err.Subjects)
|
||||
size := 0
|
||||
errStr := err.Err.Error()
|
||||
var sb strings.Builder
|
||||
for _, s := range err.Subjects {
|
||||
size += len(s)
|
||||
}
|
||||
sb.Grow(size + 2 + n*len(subjectSep) + len(errStr) + len(highlight("")))
|
||||
|
||||
for i := n - 1; i > 0; i-- {
|
||||
sb.WriteString(err.Subjects[i])
|
||||
sb.WriteString(subjectSep)
|
||||
}
|
||||
sb.WriteString(highlight(err.Subjects[0]))
|
||||
sb.WriteString(": ")
|
||||
sb.WriteString(errStr)
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// MarshalJSON implements the json.Marshaler interface.
|
||||
func (err *withSubject) MarshalJSON() ([]byte, error) {
|
||||
subjects := make([]string, len(err.Subjects))
|
||||
for i, s := range err.Subjects {
|
||||
subjects[len(err.Subjects)-i-1] = s
|
||||
}
|
||||
reversed := struct {
|
||||
Subjects []string `json:"subjects"`
|
||||
Err error `json:"err"`
|
||||
}{
|
||||
Subjects: subjects,
|
||||
Err: err.Err,
|
||||
}
|
||||
|
||||
return json.Marshal(reversed)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package error
|
||||
package err
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -19,23 +19,27 @@ func Errorf(format string, args ...any) Error {
|
||||
return &baseError{fmt.Errorf(format, args...)}
|
||||
}
|
||||
|
||||
func Wrap(err error, message ...string) Error {
|
||||
if len(message) == 0 || message[0] == "" {
|
||||
return From(err)
|
||||
}
|
||||
return Errorf("%w: %s", err, message[0])
|
||||
}
|
||||
|
||||
func From(err error) Error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if err, ok := err.(Error); ok {
|
||||
//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 {
|
||||
@@ -46,10 +50,12 @@ func Join(errors ...error) Error {
|
||||
if n == 0 {
|
||||
return nil
|
||||
}
|
||||
errs := make([]error, 0, n)
|
||||
errs := make([]error, n)
|
||||
i := 0
|
||||
for _, err := range errors {
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
errs[i] = err
|
||||
i++
|
||||
}
|
||||
}
|
||||
return &nestedError{Extras: errs}
|
||||
|
||||
66
internal/homepage/categories.go
Normal file
66
internal/homepage/categories.go
Normal file
@@ -0,0 +1,66 @@
|
||||
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",
|
||||
|
||||
"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",
|
||||
"logs": "Container Management",
|
||||
"dozzle": "Container Management",
|
||||
|
||||
"rss": "RSS",
|
||||
"rsshub": "RSS",
|
||||
"rss-bridge": "RSS",
|
||||
"miniflux": "RSS",
|
||||
"freshrss": "RSS",
|
||||
|
||||
"paperless": "Documents",
|
||||
"paperless-ngx": "Documents",
|
||||
"s-pdf": "Documents",
|
||||
"stirling-pdf": "Documents",
|
||||
|
||||
"minio": "Storage",
|
||||
"filebrowser": "Storage",
|
||||
"rclone": "Storage",
|
||||
}
|
||||
@@ -1,41 +1,72 @@
|
||||
package homepage
|
||||
|
||||
import (
|
||||
"github.com/yusing/go-proxy/internal/utils"
|
||||
)
|
||||
|
||||
type (
|
||||
Config map[string]Category
|
||||
Category []*Item
|
||||
//nolint:recvcheck
|
||||
Categories map[string]Category
|
||||
Category []*Item
|
||||
|
||||
ItemConfig struct {
|
||||
Show bool `json:"show"`
|
||||
Name string `json:"name"` // display name
|
||||
Icon *IconURL `json:"icon"`
|
||||
Category string `json:"category"`
|
||||
Description string `json:"description" aliases:"desc"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
WidgetConfig map[string]any `json:"widget_config" aliases:"widget"`
|
||||
URL string `json:"url"` // alias + domain
|
||||
}
|
||||
|
||||
Item struct {
|
||||
Show bool `json:"show" yaml:"show"`
|
||||
Name string `json:"name" yaml:"name"`
|
||||
Icon string `json:"icon" yaml:"icon"`
|
||||
URL string `json:"url" yaml:"url"` // alias + domain
|
||||
Category string `json:"category" yaml:"category"`
|
||||
Description string `json:"description" yaml:"description"`
|
||||
WidgetConfig map[string]any `json:"widget_config" yaml:",flow"`
|
||||
*ItemConfig
|
||||
|
||||
SourceType string `json:"source_type" yaml:"-"`
|
||||
AltURL string `json:"alt_url" yaml:"-"` // original proxy target
|
||||
Alias string `json:"alias"` // proxy alias
|
||||
SourceType string `json:"source_type"`
|
||||
AltURL string `json:"alt_url"` // original proxy target
|
||||
Provider string `json:"provider"`
|
||||
|
||||
IsUnset bool `json:"-"`
|
||||
}
|
||||
)
|
||||
|
||||
func init() {
|
||||
utils.RegisterDefaultValueFactory(func() *ItemConfig {
|
||||
return &ItemConfig{
|
||||
Show: true,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func NewItem(alias string) *Item {
|
||||
return &Item{
|
||||
ItemConfig: &ItemConfig{
|
||||
Show: true,
|
||||
},
|
||||
Alias: alias,
|
||||
IsUnset: true,
|
||||
}
|
||||
}
|
||||
|
||||
func NewHomePageConfig() Categories {
|
||||
return Categories(make(map[string]Category))
|
||||
}
|
||||
|
||||
func (item *Item) IsEmpty() bool {
|
||||
return item == nil || (item.Name == "" &&
|
||||
item.Icon == "" &&
|
||||
item.URL == "" &&
|
||||
item.Category == "" &&
|
||||
item.Description == "" &&
|
||||
len(item.WidgetConfig) == 0)
|
||||
return item == nil || item.IsUnset || item.ItemConfig == nil
|
||||
}
|
||||
|
||||
func NewHomePageConfig() Config {
|
||||
return Config(make(map[string]Category))
|
||||
func (item *Item) GetOverride() *Item {
|
||||
return overrideConfigInstance.GetOverride(item)
|
||||
}
|
||||
|
||||
func (c *Config) Clear() {
|
||||
*c = make(Config)
|
||||
func (c *Categories) Clear() {
|
||||
*c = make(Categories)
|
||||
}
|
||||
|
||||
func (c Config) Add(item *Item) {
|
||||
func (c Categories) Add(item *Item) {
|
||||
if c[item.Category] == nil {
|
||||
c[item.Category] = make(Category, 0)
|
||||
}
|
||||
|
||||
36
internal/homepage/homepage_test.go
Normal file
36
internal/homepage/homepage_test.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package homepage
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
. "github.com/yusing/go-proxy/internal/utils/testing"
|
||||
)
|
||||
|
||||
func TestOverrideItem(t *testing.T) {
|
||||
InitOverridesConfig()
|
||||
a := &Item{
|
||||
Alias: "foo",
|
||||
ItemConfig: &ItemConfig{
|
||||
Show: false,
|
||||
Name: "Foo",
|
||||
Icon: &IconURL{
|
||||
Value: "/favicon.ico",
|
||||
IconSource: IconSourceRelative,
|
||||
},
|
||||
Category: "App",
|
||||
},
|
||||
}
|
||||
override := &ItemConfig{
|
||||
Show: true,
|
||||
Name: "Bar",
|
||||
Category: "Test",
|
||||
Icon: &IconURL{
|
||||
Value: "@walkxcode/example.png",
|
||||
IconSource: IconSourceWalkXCode,
|
||||
},
|
||||
}
|
||||
overrides := GetOverrideConfig()
|
||||
overrides.OverrideItem(a.Alias, override)
|
||||
overridden := a.GetOverride()
|
||||
ExpectDeepEqual(t, overridden.ItemConfig, override)
|
||||
}
|
||||
164
internal/homepage/icon_url.go
Normal file
164
internal/homepage/icon_url.go
Normal file
@@ -0,0 +1,164 @@
|
||||
package homepage
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/yusing/go-proxy/internal"
|
||||
E "github.com/yusing/go-proxy/internal/error"
|
||||
)
|
||||
|
||||
type (
|
||||
IconURL struct {
|
||||
Value string `json:"value"`
|
||||
FullValue string `json:"full_value"`
|
||||
IconSource `json:"source"`
|
||||
Extra *IconExtra `json:"extra"`
|
||||
}
|
||||
|
||||
IconExtra struct {
|
||||
FileType string `json:"file_type"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
IconSource int
|
||||
)
|
||||
|
||||
const (
|
||||
IconSourceAbsolute IconSource = iota
|
||||
IconSourceRelative
|
||||
IconSourceWalkXCode
|
||||
IconSourceSelfhSt
|
||||
)
|
||||
|
||||
var ErrInvalidIconURL = E.New("invalid icon url")
|
||||
|
||||
func NewSelfhStIconURL(reference, format string) *IconURL {
|
||||
return &IconURL{
|
||||
Value: reference + "." + format,
|
||||
FullValue: fmt.Sprintf("@selfhst/%s.%s", reference, format),
|
||||
IconSource: IconSourceSelfhSt,
|
||||
Extra: &IconExtra{
|
||||
FileType: format,
|
||||
Name: reference,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func NewWalkXCodeIconURL(name, format string) *IconURL {
|
||||
return &IconURL{
|
||||
Value: name + "." + format,
|
||||
FullValue: fmt.Sprintf("@walkxcode/%s.%s", name, format),
|
||||
IconSource: IconSourceWalkXCode,
|
||||
Extra: &IconExtra{
|
||||
FileType: format,
|
||||
Name: name,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// HasIcon checks if the icon referenced by the IconURL exists in the cache based on its source.
|
||||
// Returns false if the icon does not exist for IconSourceSelfhSt or IconSourceWalkXCode,
|
||||
// otherwise returns true.
|
||||
func (u *IconURL) HasIcon() bool {
|
||||
if u.IconSource == IconSourceSelfhSt {
|
||||
return internal.HasSelfhstIcon(u.Extra.Name, u.Extra.FileType)
|
||||
}
|
||||
if u.IconSource == IconSourceWalkXCode {
|
||||
return internal.HasWalkxCodeIcon(u.Extra.Name, u.Extra.FileType)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Parse implements strutils.Parser.
|
||||
func (u *IconURL) Parse(v string) error {
|
||||
if v == "" {
|
||||
return ErrInvalidIconURL
|
||||
}
|
||||
slashIndex := strings.Index(v, "/")
|
||||
if slashIndex == -1 {
|
||||
return ErrInvalidIconURL
|
||||
}
|
||||
u.FullValue = v
|
||||
beforeSlash := v[:slashIndex]
|
||||
switch beforeSlash {
|
||||
case "http:", "https:":
|
||||
u.Value = v
|
||||
u.IconSource = IconSourceAbsolute
|
||||
case "@target", "": // @target/favicon.ico, /favicon.ico
|
||||
u.Value = v[slashIndex:]
|
||||
u.IconSource = IconSourceRelative
|
||||
if u.Value == "/" {
|
||||
return ErrInvalidIconURL.Withf("%s", "empty path")
|
||||
}
|
||||
case "png", "svg", "webp": // walkxcode Icons
|
||||
u.Value = v
|
||||
u.IconSource = IconSourceWalkXCode
|
||||
u.Extra = &IconExtra{
|
||||
FileType: beforeSlash,
|
||||
Name: strings.TrimSuffix(v[slashIndex+1:], "."+beforeSlash),
|
||||
}
|
||||
case "@selfhst", "@walkxcode": // selfh.st / walkxcode Icons, @selfhst/<reference>.<format>
|
||||
u.Value = v[slashIndex+1:]
|
||||
if beforeSlash == "@selfhst" {
|
||||
u.IconSource = IconSourceSelfhSt
|
||||
} else {
|
||||
u.IconSource = IconSourceWalkXCode
|
||||
}
|
||||
parts := strings.Split(u.Value, ".")
|
||||
if len(parts) != 2 {
|
||||
return ErrInvalidIconURL.Withf("expect @%s/<reference>.<format>, e.g. @%s/adguard-home.webp", beforeSlash, beforeSlash)
|
||||
}
|
||||
reference, format := parts[0], strings.ToLower(parts[1])
|
||||
if reference == "" || format == "" {
|
||||
return ErrInvalidIconURL
|
||||
}
|
||||
switch format {
|
||||
case "svg", "png", "webp":
|
||||
default:
|
||||
return ErrInvalidIconURL.Withf("%s", "invalid image format, expect svg/png/webp")
|
||||
}
|
||||
u.Extra = &IconExtra{
|
||||
FileType: format,
|
||||
Name: reference,
|
||||
}
|
||||
default:
|
||||
return ErrInvalidIconURL.Withf("%s", v)
|
||||
}
|
||||
|
||||
if u.Value == "" {
|
||||
return ErrInvalidIconURL.Withf("%s", "empty")
|
||||
}
|
||||
|
||||
if !u.HasIcon() {
|
||||
return ErrInvalidIconURL.Withf("no such icon %s from %s", u.Value, beforeSlash)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *IconURL) URL() string {
|
||||
switch u.IconSource {
|
||||
case IconSourceAbsolute:
|
||||
return u.Value
|
||||
case IconSourceRelative:
|
||||
return "/" + u.Value
|
||||
case IconSourceWalkXCode:
|
||||
return fmt.Sprintf("https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/%s/%s.%s", u.Extra.FileType, u.Extra.Name, u.Extra.FileType)
|
||||
case IconSourceSelfhSt:
|
||||
return fmt.Sprintf("https://cdn.jsdelivr.net/gh/selfhst/icons/%s/%s.%s", u.Extra.FileType, u.Extra.Name, u.Extra.FileType)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (u *IconURL) String() string {
|
||||
return u.FullValue
|
||||
}
|
||||
|
||||
func (u *IconURL) MarshalText() ([]byte, error) {
|
||||
return []byte(u.String()), nil
|
||||
}
|
||||
|
||||
// UnmarshalText implements encoding.TextUnmarshaler.
|
||||
func (u *IconURL) UnmarshalText(data []byte) error {
|
||||
return u.Parse(string(data))
|
||||
}
|
||||
125
internal/homepage/icon_url_test.go
Normal file
125
internal/homepage/icon_url_test.go
Normal file
@@ -0,0 +1,125 @@
|
||||
package homepage
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
. "github.com/yusing/go-proxy/internal/utils/testing"
|
||||
)
|
||||
|
||||
func TestIconURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantValue *IconURL
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "absolute",
|
||||
input: "http://example.com/icon.png",
|
||||
wantValue: &IconURL{
|
||||
Value: "http://example.com/icon.png",
|
||||
IconSource: IconSourceAbsolute,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "relative",
|
||||
input: "@target/icon.png",
|
||||
wantValue: &IconURL{
|
||||
Value: "/icon.png",
|
||||
IconSource: IconSourceRelative,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "relative2",
|
||||
input: "/icon.png",
|
||||
wantValue: &IconURL{
|
||||
Value: "/icon.png",
|
||||
IconSource: IconSourceRelative,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "relative_empty_path",
|
||||
input: "@target/",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "relative_empty_path2",
|
||||
input: "/",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "walkxcode",
|
||||
input: "png/adguard-home.png",
|
||||
wantValue: &IconURL{
|
||||
Value: "png/adguard-home.png",
|
||||
IconSource: IconSourceWalkXCode,
|
||||
Extra: &IconExtra{
|
||||
FileType: "png",
|
||||
Name: "adguard-home",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "walkxcode_alt",
|
||||
input: "@walkxcode/adguard-home.png",
|
||||
wantValue: &IconURL{
|
||||
Value: "adguard-home.png",
|
||||
IconSource: IconSourceWalkXCode,
|
||||
Extra: &IconExtra{
|
||||
FileType: "png",
|
||||
Name: "adguard-home",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "walkxcode_invalid_format",
|
||||
input: "foo/walkxcode.png",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "selfh.st_valid",
|
||||
input: "@selfhst/adguard-home.png",
|
||||
wantValue: &IconURL{
|
||||
Value: "adguard-home.png",
|
||||
IconSource: IconSourceSelfhSt,
|
||||
Extra: &IconExtra{
|
||||
FileType: "png",
|
||||
Name: "adguard-home",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "selfh.st_invalid",
|
||||
input: "@selfhst/foo",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "selfh.st_invalid_format",
|
||||
input: "@selfhst/foo.bar",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid",
|
||||
input: "invalid",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "empty",
|
||||
input: "",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
u := &IconURL{}
|
||||
err := u.Parse(tc.input)
|
||||
if tc.wantErr {
|
||||
ExpectError(t, ErrInvalidIconURL, err)
|
||||
} else {
|
||||
tc.wantValue.FullValue = tc.input
|
||||
ExpectNoError(t, err)
|
||||
ExpectDeepEqual(t, u, tc.wantValue)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
107
internal/homepage/override_config.go
Normal file
107
internal/homepage/override_config.go
Normal file
@@ -0,0 +1,107 @@
|
||||
package homepage
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/yusing/go-proxy/internal/common"
|
||||
"github.com/yusing/go-proxy/internal/logging"
|
||||
"github.com/yusing/go-proxy/internal/task"
|
||||
"github.com/yusing/go-proxy/internal/utils"
|
||||
)
|
||||
|
||||
type OverrideConfig struct {
|
||||
ItemOverrides map[string]*ItemConfig `json:"item_overrides"`
|
||||
DisplayOrder map[string]int `json:"display_order"` // TODO: implement this
|
||||
CategoryOrder map[string]int `json:"category_order"` // TODO: implement this
|
||||
ItemVisibility map[string]bool `json:"item_visibility"`
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
var overrideConfigInstance = &OverrideConfig{
|
||||
ItemOverrides: make(map[string]*ItemConfig),
|
||||
DisplayOrder: make(map[string]int),
|
||||
CategoryOrder: make(map[string]int),
|
||||
ItemVisibility: make(map[string]bool),
|
||||
}
|
||||
|
||||
func InitOverridesConfig() {
|
||||
overrideConfigInstance.mu.Lock()
|
||||
defer overrideConfigInstance.mu.Unlock()
|
||||
|
||||
err := utils.LoadJSONIfExist(common.HomepageJSONConfigPath, overrideConfigInstance)
|
||||
if err != nil {
|
||||
logging.Error().Err(err).Msg("failed to load homepage overrides config")
|
||||
} else {
|
||||
logging.Info().Msgf("homepage overrides config loaded, %d items", len(overrideConfigInstance.ItemOverrides))
|
||||
}
|
||||
task.OnProgramExit("save_homepage_json_config", func() {
|
||||
if len(overrideConfigInstance.ItemOverrides) == 0 {
|
||||
return
|
||||
}
|
||||
if err := utils.SaveJSON(common.HomepageJSONConfigPath, overrideConfigInstance, 0o644); err != nil {
|
||||
logging.Error().Err(err).Msg("failed to save homepage overrides config")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func GetOverrideConfig() *OverrideConfig {
|
||||
return overrideConfigInstance
|
||||
}
|
||||
|
||||
func (c *OverrideConfig) OverrideItem(alias string, override *ItemConfig) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.ItemOverrides[alias] = override
|
||||
}
|
||||
|
||||
func (c *OverrideConfig) OverrideItems(items map[string]*ItemConfig) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
for key, value := range items {
|
||||
c.ItemOverrides[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
func (c *OverrideConfig) GetOverride(item *Item) *Item {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
itemOverride, hasOverride := c.ItemOverrides[item.Alias]
|
||||
if hasOverride {
|
||||
clone := *item
|
||||
clone.ItemConfig = itemOverride
|
||||
clone.IsUnset = false
|
||||
item = &clone
|
||||
}
|
||||
if show, ok := c.ItemVisibility[item.Alias]; ok {
|
||||
if !hasOverride {
|
||||
clone := *item
|
||||
clone.Show = show
|
||||
item = &clone
|
||||
} else {
|
||||
item.Show = show
|
||||
}
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
func (c *OverrideConfig) SetCategoryOrder(key string, value int) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.CategoryOrder[key] = value
|
||||
}
|
||||
|
||||
func (c *OverrideConfig) UnhideItems(keys ...string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
for _, key := range keys {
|
||||
c.ItemVisibility[key] = true
|
||||
}
|
||||
}
|
||||
|
||||
func (c *OverrideConfig) HideItems(keys ...string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
for _, key := range keys {
|
||||
c.ItemVisibility[key] = false
|
||||
}
|
||||
}
|
||||
@@ -2,14 +2,17 @@ package internal
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/lithammer/fuzzysearch/fuzzy"
|
||||
"github.com/yusing/go-proxy/internal/common"
|
||||
"github.com/yusing/go-proxy/internal/logging"
|
||||
"github.com/yusing/go-proxy/internal/utils"
|
||||
"github.com/yusing/go-proxy/internal/utils/strutils"
|
||||
)
|
||||
|
||||
type GitHubContents struct { //! keep this, may reuse in future
|
||||
@@ -20,82 +23,275 @@ type GitHubContents struct { //! keep this, may reuse in future
|
||||
Size int `json:"size"`
|
||||
}
|
||||
|
||||
const (
|
||||
iconsCachePath = "/tmp/icons_cache.json"
|
||||
updateInterval = 1 * time.Hour
|
||||
type (
|
||||
IconsMap map[string]map[string]struct{}
|
||||
IconList []string
|
||||
Cache struct {
|
||||
WalkxCode, Selfhst IconsMap
|
||||
DisplayNames ReferenceDisplayNameMap
|
||||
IconList IconList // combined into a single list
|
||||
}
|
||||
ReferenceDisplayNameMap map[string]string
|
||||
)
|
||||
|
||||
func ListAvailableIcons() ([]string, error) {
|
||||
owner := "walkxcode"
|
||||
repo := "dashboard-icons"
|
||||
ref := "main"
|
||||
func (icons *Cache) needUpdate() bool {
|
||||
return len(icons.WalkxCode) == 0 || len(icons.Selfhst) == 0 || len(icons.IconList) == 0 || len(icons.DisplayNames) == 0
|
||||
}
|
||||
|
||||
var lastUpdate time.Time
|
||||
const updateInterval = 2 * time.Hour
|
||||
|
||||
icons := make([]string, 0)
|
||||
info, err := os.Stat(iconsCachePath)
|
||||
if err == nil {
|
||||
lastUpdate = info.ModTime().Local()
|
||||
var (
|
||||
iconsCache *Cache
|
||||
iconsCahceMu sync.RWMutex
|
||||
lastUpdate time.Time
|
||||
)
|
||||
|
||||
const (
|
||||
walkxcodeIcons = "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/tree.json"
|
||||
selfhstIcons = "https://cdn.selfh.st/directory/icons.json"
|
||||
)
|
||||
|
||||
func InitIconListCache() {
|
||||
iconsCahceMu.Lock()
|
||||
defer iconsCahceMu.Unlock()
|
||||
|
||||
iconsCache = &Cache{
|
||||
WalkxCode: make(IconsMap),
|
||||
Selfhst: make(IconsMap),
|
||||
DisplayNames: make(ReferenceDisplayNameMap),
|
||||
IconList: []string{},
|
||||
}
|
||||
err := utils.LoadJSONIfExist(common.IconListCachePath, iconsCache)
|
||||
if err != nil {
|
||||
logging.Error().Err(err).Msg("failed to load icon list cache config")
|
||||
} else if stats, err := os.Stat(common.IconListCachePath); err == nil {
|
||||
lastUpdate = stats.ModTime()
|
||||
logging.Info().Msgf("icon list cache loaded (%d icons, %d display names), last updated at %s",
|
||||
len(iconsCache.IconList),
|
||||
len(iconsCache.DisplayNames),
|
||||
strutils.FormatTime(lastUpdate))
|
||||
}
|
||||
}
|
||||
|
||||
func ListAvailableIcons() (*Cache, error) {
|
||||
iconsCahceMu.RLock()
|
||||
if time.Since(lastUpdate) < updateInterval {
|
||||
err := utils.LoadJSON(iconsCachePath, &icons)
|
||||
if err == nil {
|
||||
return icons, nil
|
||||
if !iconsCache.needUpdate() {
|
||||
iconsCahceMu.RUnlock()
|
||||
return iconsCache, nil
|
||||
}
|
||||
}
|
||||
iconsCahceMu.RUnlock()
|
||||
|
||||
contents, err := getRepoContents(http.DefaultClient, owner, repo, ref, "")
|
||||
iconsCahceMu.Lock()
|
||||
defer iconsCahceMu.Unlock()
|
||||
|
||||
icons, err := fetchIconData()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, content := range contents {
|
||||
if content.Type != "dir" {
|
||||
icons = append(icons, content.Path)
|
||||
}
|
||||
}
|
||||
err = utils.SaveJSON(iconsCachePath, &icons, 0o644)
|
||||
|
||||
logging.Info().Msg("icons list updated")
|
||||
|
||||
iconsCache = icons
|
||||
lastUpdate = time.Now()
|
||||
|
||||
err = utils.SaveJSON(common.IconListCachePath, iconsCache, 0o644)
|
||||
if err != nil {
|
||||
log.Print("error saving cache", err)
|
||||
logging.Warn().Err(err).Msg("failed to save icon list cache")
|
||||
}
|
||||
return icons, nil
|
||||
}
|
||||
|
||||
func getRepoContents(client *http.Client, owner string, repo string, ref string, path string) ([]GitHubContents, error) {
|
||||
req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("https://api.github.com/repos/%s/%s/contents/%s?ref=%s", owner, repo, path, ref), nil)
|
||||
func SearchIcons(keyword string, limit int) ([]string, error) {
|
||||
icons, err := ListAvailableIcons()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if keyword == "" {
|
||||
return utils.Slice(icons.IconList, limit), nil
|
||||
}
|
||||
return utils.Slice(fuzzy.Find(keyword, icons.IconList), limit), nil
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
func HasWalkxCodeIcon(name string, filetype string) bool {
|
||||
icons, err := ListAvailableIcons()
|
||||
if err != nil {
|
||||
logging.Error().Err(err).Msg("failed to list icons")
|
||||
return false
|
||||
}
|
||||
if _, ok := icons.WalkxCode[filetype]; !ok {
|
||||
return false
|
||||
}
|
||||
_, ok := icons.WalkxCode[filetype][name+"."+filetype]
|
||||
return ok
|
||||
}
|
||||
|
||||
func HasSelfhstIcon(name string, filetype string) bool {
|
||||
icons, err := ListAvailableIcons()
|
||||
if err != nil {
|
||||
logging.Error().Err(err).Msg("failed to list icons")
|
||||
return false
|
||||
}
|
||||
if _, ok := icons.Selfhst[filetype]; !ok {
|
||||
return false
|
||||
}
|
||||
_, ok := icons.Selfhst[filetype][name+"."+filetype]
|
||||
return ok
|
||||
}
|
||||
|
||||
func GetDisplayName(reference string) (string, bool) {
|
||||
icons, err := ListAvailableIcons()
|
||||
if err != nil {
|
||||
logging.Error().Err(err).Msg("failed to list icons")
|
||||
return "", false
|
||||
}
|
||||
displayName, ok := icons.DisplayNames[reference]
|
||||
return displayName, ok
|
||||
}
|
||||
|
||||
func fetchIconData() (*Cache, error) {
|
||||
walkxCodeIconMap, walkxCodeIconList, err := fetchWalkxCodeIcons()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
n := 0
|
||||
for _, items := range walkxCodeIconMap {
|
||||
n += len(items)
|
||||
}
|
||||
|
||||
selfhstIconMap, selfhstIconList, referenceToNames, err := fetchSelfhstIcons()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Cache{
|
||||
WalkxCode: walkxCodeIconMap,
|
||||
Selfhst: selfhstIconMap,
|
||||
DisplayNames: referenceToNames,
|
||||
IconList: append(walkxCodeIconList, selfhstIconList...),
|
||||
}, nil
|
||||
}
|
||||
|
||||
/*
|
||||
format:
|
||||
|
||||
{
|
||||
"png": [
|
||||
"*.png",
|
||||
],
|
||||
"svg": [
|
||||
"*.svg",
|
||||
],
|
||||
"webp": [
|
||||
"*.webp",
|
||||
]
|
||||
}
|
||||
*/
|
||||
func fetchWalkxCodeIcons() (IconsMap, IconList, error) {
|
||||
req, err := http.NewRequest(http.MethodGet, walkxcodeIcons, nil)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
var contents []GitHubContents
|
||||
err = json.Unmarshal(body, &contents)
|
||||
data := make(map[string][]string)
|
||||
err = json.Unmarshal(body, &data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
filesAndDirs := make([]GitHubContents, 0)
|
||||
for _, content := range contents {
|
||||
if content.Type == "dir" {
|
||||
subContents, err := getRepoContents(client, owner, repo, ref, content.Path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
filesAndDirs = append(filesAndDirs, subContents...)
|
||||
} else {
|
||||
filesAndDirs = append(filesAndDirs, content)
|
||||
icons := make(IconsMap, len(data))
|
||||
iconList := make(IconList, 0, 2000)
|
||||
for fileType, files := range data {
|
||||
icons[fileType] = make(map[string]struct{}, len(files))
|
||||
for _, icon := range files {
|
||||
icons[fileType][icon] = struct{}{}
|
||||
iconList = append(iconList, "@walkxcode/"+icon)
|
||||
}
|
||||
}
|
||||
|
||||
return filesAndDirs, nil
|
||||
return icons, iconList, nil
|
||||
}
|
||||
|
||||
/*
|
||||
format:
|
||||
|
||||
{
|
||||
"Name": "2FAuth",
|
||||
"Reference": "2fauth",
|
||||
"SVG": "Yes",
|
||||
"PNG": "Yes",
|
||||
"WebP": "Yes",
|
||||
"Light": "Yes",
|
||||
"Category": "Self-Hosted",
|
||||
"CreatedAt": "2024-08-16 00:27:23+00:00"
|
||||
}
|
||||
*/
|
||||
func fetchSelfhstIcons() (IconsMap, IconList, ReferenceDisplayNameMap, error) {
|
||||
type SelfhStIcon struct {
|
||||
Name string `json:"Name"`
|
||||
Reference string `json:"Reference"`
|
||||
SVG string `json:"SVG"`
|
||||
PNG string `json:"PNG"`
|
||||
WebP string `json:"WebP"`
|
||||
// Light string
|
||||
// Category string
|
||||
// CreatedAt string
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, selfhstIcons, nil)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
data := make([]SelfhStIcon, 0, 2000)
|
||||
err = json.Unmarshal(body, &data)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
iconList := make(IconList, 0, len(data)*3)
|
||||
icons := make(IconsMap)
|
||||
icons["svg"] = make(map[string]struct{}, len(data))
|
||||
icons["png"] = make(map[string]struct{}, len(data))
|
||||
icons["webp"] = make(map[string]struct{}, len(data))
|
||||
|
||||
referenceToNames := make(ReferenceDisplayNameMap, len(data))
|
||||
|
||||
for _, item := range data {
|
||||
if item.SVG == "Yes" {
|
||||
icons["svg"][item.Reference+".svg"] = struct{}{}
|
||||
iconList = append(iconList, "@selfhst/"+item.Reference+".svg")
|
||||
}
|
||||
if item.PNG == "Yes" {
|
||||
icons["png"][item.Reference+".png"] = struct{}{}
|
||||
iconList = append(iconList, "@selfhst/"+item.Reference+".png")
|
||||
}
|
||||
if item.WebP == "Yes" {
|
||||
icons["webp"][item.Reference+".webp"] = struct{}{}
|
||||
iconList = append(iconList, "@selfhst/"+item.Reference+".webp")
|
||||
}
|
||||
referenceToNames[item.Reference] = item.Name
|
||||
}
|
||||
|
||||
return icons, iconList, referenceToNames, nil
|
||||
}
|
||||
|
||||
159
internal/logging/html.go
Normal file
159
internal/logging/html.go
Normal file
@@ -0,0 +1,159 @@
|
||||
package logging
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/yusing/go-proxy/internal/common"
|
||||
)
|
||||
|
||||
var levelHTMLFormats = [][]byte{
|
||||
[]byte(` <span class="log-trace">TRC</span> `),
|
||||
[]byte(` <span class="log-debug">DBG</span> `),
|
||||
[]byte(` <span class="log-info">INF</span> `),
|
||||
[]byte(` <span class="log-warn">WRN</span> `),
|
||||
[]byte(` <span class="log-error">ERR</span> `),
|
||||
[]byte(` <span class="log-fatal">FTL</span> `),
|
||||
[]byte(` <span class="log-panic">PAN</span> `),
|
||||
}
|
||||
|
||||
var colorToClass = map[string]string{
|
||||
"1": "log-bold",
|
||||
"3": "log-italic",
|
||||
"4": "log-underline",
|
||||
"30": "log-black",
|
||||
"31": "log-red",
|
||||
"32": "log-green",
|
||||
"33": "log-yellow",
|
||||
"34": "log-blue",
|
||||
"35": "log-magenta",
|
||||
"36": "log-cyan",
|
||||
"37": "log-white",
|
||||
"90": "log-bright-black",
|
||||
"91": "log-red",
|
||||
"92": "log-bright-green",
|
||||
"93": "log-bright-yellow",
|
||||
"94": "log-bright-blue",
|
||||
"95": "log-bright-magenta",
|
||||
"96": "log-bright-cyan",
|
||||
"97": "log-bright-white",
|
||||
}
|
||||
|
||||
// FormatMessageToHTMLBytes converts text with ANSI color codes to HTML with class names.
|
||||
// ANSI codes are mapped to classes via a static map, and reset codes ([0m) close all spans.
|
||||
// Time complexity is O(n) with minimal allocations.
|
||||
func FormatMessageToHTMLBytes(msg string, buf []byte) ([]byte, error) {
|
||||
buf = append(buf, "<span class=\"log-message\">"...)
|
||||
var stack []string
|
||||
lastPos := 0
|
||||
|
||||
for i := 0; i < len(msg); {
|
||||
if msg[i] == '\x1b' && i+1 < len(msg) && msg[i+1] == '[' {
|
||||
if lastPos < i {
|
||||
escapeAndAppend(msg[lastPos:i], &buf)
|
||||
}
|
||||
i += 2 // Skip \x1b[
|
||||
|
||||
start := i
|
||||
for ; i < len(msg) && msg[i] != 'm'; i++ {
|
||||
if !isANSICodeChar(msg[i]) {
|
||||
return nil, fmt.Errorf("invalid ANSI char: %c", msg[i])
|
||||
}
|
||||
}
|
||||
|
||||
if i >= len(msg) {
|
||||
return nil, errors.New("unterminated ANSI sequence")
|
||||
}
|
||||
|
||||
codeStr := msg[start:i]
|
||||
i++ // Skip 'm'
|
||||
lastPos = i
|
||||
|
||||
startPart := 0
|
||||
for j := 0; j <= len(codeStr); j++ {
|
||||
if j == len(codeStr) || codeStr[j] == ';' {
|
||||
part := codeStr[startPart:j]
|
||||
if part == "" {
|
||||
return nil, errors.New("empty code part")
|
||||
}
|
||||
|
||||
if part == "0" {
|
||||
for range stack {
|
||||
buf = append(buf, "</span>"...)
|
||||
}
|
||||
stack = stack[:0]
|
||||
} else {
|
||||
className, ok := colorToClass[part]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid ANSI code: %s", part)
|
||||
}
|
||||
stack = append(stack, className)
|
||||
buf = append(buf, `<span class="`...)
|
||||
buf = append(buf, className...)
|
||||
buf = append(buf, `">`...)
|
||||
}
|
||||
startPart = j + 1
|
||||
}
|
||||
}
|
||||
} else {
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
if lastPos < len(msg) {
|
||||
escapeAndAppend(msg[lastPos:], &buf)
|
||||
}
|
||||
|
||||
for range stack {
|
||||
buf = append(buf, "</span>"...)
|
||||
}
|
||||
|
||||
buf = append(buf, "</span>"...)
|
||||
return buf, nil
|
||||
}
|
||||
|
||||
func isANSICodeChar(c byte) bool {
|
||||
return (c >= '0' && c <= '9') || c == ';'
|
||||
}
|
||||
|
||||
func escapeAndAppend(s string, buf *[]byte) {
|
||||
for i, r := range s {
|
||||
switch r {
|
||||
case '•':
|
||||
*buf = append(*buf, "·"...)
|
||||
case '&':
|
||||
*buf = append(*buf, "&"...)
|
||||
case '<':
|
||||
*buf = append(*buf, "<"...)
|
||||
case '>':
|
||||
*buf = append(*buf, ">"...)
|
||||
case '\t':
|
||||
*buf = append(*buf, "	"...)
|
||||
case '\n':
|
||||
*buf = append(*buf, "<br>"...)
|
||||
*buf = append(*buf, prefixHTML...)
|
||||
default:
|
||||
*buf = append(*buf, s[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func timeNowHTML() []byte {
|
||||
if !common.IsTest {
|
||||
return []byte(time.Now().Format(timeFmt))
|
||||
}
|
||||
return []byte(time.Date(2024, 1, 1, 1, 1, 1, 1, time.UTC).Format(timeFmt))
|
||||
}
|
||||
|
||||
func FormatLogEntryHTML(level zerolog.Level, message string, buf []byte) []byte {
|
||||
buf = append(buf, []byte(`<pre class="log-entry">`)...)
|
||||
buf = append(buf, timeNowHTML()...)
|
||||
if level < zerolog.NoLevel {
|
||||
buf = append(buf, levelHTMLFormats[level+1]...)
|
||||
}
|
||||
buf, _ = FormatMessageToHTMLBytes(message, buf)
|
||||
buf = append(buf, []byte("</pre>")...)
|
||||
return buf
|
||||
}
|
||||
30
internal/logging/html_test.go
Normal file
30
internal/logging/html_test.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package logging
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
. "github.com/yusing/go-proxy/internal/utils/testing"
|
||||
)
|
||||
|
||||
func TestFormatHTML(t *testing.T) {
|
||||
buf := make([]byte, 0, 100)
|
||||
buf = FormatLogEntryHTML(zerolog.InfoLevel, "This is a test.\nThis is a new line.", buf)
|
||||
ExpectEqual(t, string(buf), `<pre class="log-entry">01-01 01:01 <span class="log-info">INF</span> <span class="log-message">This is a test.<br>`+prefix+`This is a new line.</span></pre>`)
|
||||
}
|
||||
|
||||
func TestFormatHTMLANSI(t *testing.T) {
|
||||
buf := make([]byte, 0, 100)
|
||||
buf = FormatLogEntryHTML(zerolog.InfoLevel, "This is \x1b[91m\x1b[1ma test.\x1b[0mOK!.", buf)
|
||||
ExpectEqual(t, string(buf), `<pre class="log-entry">01-01 01:01 <span class="log-info">INF</span> <span class="log-message">This is <span class="log-red"><span class="log-bold">a test.</span></span>OK!.</span></pre>`)
|
||||
buf = buf[:0]
|
||||
buf = FormatLogEntryHTML(zerolog.InfoLevel, "This is \x1b[91ma \x1b[1mtest.\x1b[0mOK!.", buf)
|
||||
ExpectEqual(t, string(buf), `<pre class="log-entry">01-01 01:01 <span class="log-info">INF</span> <span class="log-message">This is <span class="log-red">a <span class="log-bold">test.</span></span>OK!.</span></pre>`)
|
||||
}
|
||||
|
||||
func BenchmarkFormatLogEntryHTML(b *testing.B) {
|
||||
buf := make([]byte, 0, 250)
|
||||
for range b.N {
|
||||
FormatLogEntryHTML(zerolog.InfoLevel, "This is \x1b[91ma \x1b[1mtest.\x1b[0mOK!.", buf)
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,24 @@
|
||||
//nolint:zerologlint
|
||||
package logging
|
||||
|
||||
import (
|
||||
"os"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/yusing/go-proxy/internal/common"
|
||||
"github.com/yusing/go-proxy/internal/utils/strutils"
|
||||
)
|
||||
|
||||
var logger zerolog.Logger
|
||||
var (
|
||||
logger zerolog.Logger
|
||||
timeFmt string
|
||||
level zerolog.Level
|
||||
prefix string
|
||||
prefixHTML []byte
|
||||
)
|
||||
|
||||
func init() {
|
||||
var timeFmt string
|
||||
var level zerolog.Level
|
||||
var exclude []string
|
||||
|
||||
switch {
|
||||
case common.IsTrace:
|
||||
timeFmt = "04:05"
|
||||
@@ -25,33 +29,42 @@ func init() {
|
||||
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)
|
||||
prefix = strings.Repeat(" ", prefixLength)
|
||||
// prefixHTML = []byte(strings.Repeat(" ", prefixLength))
|
||||
prefixHTML = []byte(prefix)
|
||||
|
||||
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 := strings.Split(msg, "\n")
|
||||
if len(lines) == 1 {
|
||||
return msg
|
||||
}
|
||||
for i := 1; i < len(lines); i++ {
|
||||
lines[i] = prefix + lines[i]
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
},
|
||||
if zerolog.TraceLevel != -1 && zerolog.NoLevel != 6 {
|
||||
panic("zerolog implementation changed")
|
||||
}
|
||||
}
|
||||
|
||||
func fmtMessage(msg string) 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')
|
||||
}
|
||||
|
||||
func InitLogger(out io.Writer) {
|
||||
writer := zerolog.ConsoleWriter{
|
||||
Out: out,
|
||||
TimeFormat: timeFmt,
|
||||
FormatMessage: func(msgI interface{}) string { // pad spaces for each line
|
||||
return fmtMessage(msgI.(string))
|
||||
},
|
||||
}
|
||||
logger = zerolog.New(
|
||||
writer,
|
||||
).Level(level).With().Timestamp().Logger()
|
||||
}
|
||||
|
||||
func DiscardLogger() { logger = zerolog.Nop() }
|
||||
func DiscardLogger() { zerolog.SetGlobalLevel(zerolog.Disabled) }
|
||||
|
||||
func AddHook(h zerolog.Hook) { logger = logger.Hook(h) }
|
||||
|
||||
|
||||
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()
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user