Compare commits

...

127 Commits

Author SHA1 Message Date
yusing
ddc3b8575e fix startup panic when no notification provider is set 2025-02-07 03:07:21 +08:00
yusing
136a2ec89f remove some debug logging 2025-02-07 01:08:42 +08:00
yusing
021c68f2a7 update README 2025-02-06 18:31:49 +08:00
yusing
989a09274f restore notification 2025-02-06 18:25:39 +08:00
yusing
39c5886d7a make rules.name optional 2025-02-06 18:25:39 +08:00
Yuzerion
1a5f3735cf Feat/fileserver (#60)
* cleanup code for URL type

* fix makefile for trace mode

* refactor, merge Entry, RawEntry and Route into one. 

* Implement fileserver.

* refactor: rename HTTPRoute to ReverseProxyRoute to avoid confusion

* refactor: move metrics logger to middleware package

- fix prometheus metrics for load balanced routes
  - route will now fail when health monitor fail to start

* fix extra output of ls-* commands by defer initializaing stuff, speed up start time

* add test for path traversal attack, small fix on FileServer.Start method

* rename rule.on.bypass to pass

* refactor and fixed map-to-map  deserialization

* updated route loading logic

* schemas: add "add_prefix" option to modify_request middleware


* updated route JSONMarshalling

---------

Co-authored-by: yusing <yusing@6uo.me>
2025-02-06 18:23:10 +08:00
yusing
4d47eb0e91 update compose example 2025-02-06 05:59:21 +08:00
yusing
af7c59b5c2 add tests for rules.on 2025-02-06 05:50:03 +08:00
yusing
693bf68864 rules: updated help message, make values optional, fixes tests 2025-02-06 05:13:47 +08:00
Yuzerion
c9ddf3d165 Create FUNDING.yml 2025-02-06 04:44:19 +08:00
yusing
1549b56866 README: move auth docs to wiki 2025-02-06 03:12:34 +08:00
yusing
2cd1f22e68 add test for the previous commit 2025-02-06 02:33:30 +08:00
yusing
688f38943d fix single line yaml list treated as comma seperated list 2025-02-06 01:58:45 +08:00
yusing
043bbd7a11 readme and docker compose example amendment 2025-02-06 00:56:11 +08:00
yusing
f997423fd7 fix error formatting 2025-02-04 07:04:49 +08:00
yusing
1871ef3d38 clearer error message when config reload failed 2025-02-04 07:04:27 +08:00
yusing
7c56c88dd4 fix server not being restarted after config reload 2025-02-04 07:04:15 +08:00
yusing
4d7422dd90 adjusted and simplified default config and compose.yml 2025-02-04 07:04:05 +08:00
yusing
eccabc0588 remove incorrectly added pnpn lockfile 2025-02-04 07:04:05 +08:00
yusing
0c7b188587 api: fix search icon returning null when no match 2025-02-02 03:31:52 +08:00
yusing
4c97b79adf log prometheus enabled 2025-02-02 03:21:39 +08:00
yusing
8ae9573b07 add timeout to notification context 2025-02-01 14:42:21 +08:00
yusing
43fce6e739 fix two tests 2025-02-01 14:41:22 +08:00
Yuzerion
78900772bb Feat/ntfy (#57)
* implement ntfy notification

* fix notification fields order

* fix schema for ntfy

---------

Co-authored-by: yusing <yusing@6uo.me>
2025-02-01 13:07:44 +08:00
yusing
c16a0444ca fix main.go and update next release doc 2025-02-01 12:51:52 +08:00
yusing
0d518166ee api: move prometheus handler inside api handler /v1/metrics 2025-02-01 02:09:43 +08:00
yusing
6ae391a3c9 make POST and JSON as notification defaults 2025-01-31 14:56:55 +08:00
yusing
357897a0cd remove schema stuff from code 2025-01-31 05:21:32 +08:00
yusing
10a0a8fe09 update readme 2025-01-31 03:33:20 +08:00
yusing
98443be80c fix OIDC not working when ISSUE_URL points to GoDoxy itself 2025-01-30 10:39:26 +08:00
yusing
bf7f6e99c5 updated next release docs 2025-01-30 10:34:47 +08:00
yusing
b6e468e54e remove schema from dockerfile and code, dependencies upgrade 2025-01-30 00:43:25 +08:00
yusing
dfc634a362 http: increase default response header timeout to 60s, add option to customize it, schema update 2025-01-30 00:41:03 +08:00
yusing
d9b6b82f07 api: response error in json instead of html for better rendering flexibility 2025-01-29 11:50:08 +08:00
yusing
4ad6257dab fix deserialization 2025-01-29 11:49:28 +08:00
yusing
e3e3f1dfdc fixed some tests 2025-01-29 09:40:37 +08:00
yusing
60f83bb7bf rules: remove the requirement of "path must start with /" 2025-01-29 08:57:42 +08:00
yusing
bbc10cb105 fix serialization, added benchmark tests, updated next release docs 2025-01-26 15:08:10 +08:00
yusing
83ea19dd92 api: added validation endpoint 2025-01-26 14:47:33 +08:00
yusing
7ec42dce4d improved implementation of converting ANSI color to HTML 2025-01-26 14:46:43 +08:00
yusing
a9da7ce6fc small fix on Makefile and update dependencies 2025-01-26 14:45:19 +08:00
yusing
1586610a44 Cleaned up some validation code, stricter validation 2025-01-26 14:43:48 +08:00
yusing
254224c0e8 fix error formatting 2025-01-26 05:26:18 +08:00
yusing
9b66772a12 fix schemas 2025-01-25 12:50:16 +08:00
yusing
322878b0b7 fix schemas 2025-01-25 07:04:01 +08:00
yusing
9e181d25ce fix npm package 2025-01-25 02:36:22 +08:00
yusing
4c311fd78e fixed some schemas, packed it as a npm package 2025-01-24 10:42:50 +08:00
yusing
9936b3af5b improved homepage config implementation 2025-01-24 05:11:35 +08:00
yusing
648fd23a57 feat: oidc support OIDC_LOGOUT_URL 2025-01-24 00:34:50 +08:00
Peter Olds
7dd00d2424 feat: add a add_prefix middleware (#51)
This will allow you to translate:

`foo.mydomain.com` => `192.168.1.99:8000/foo` (for example)
2025-01-24 00:34:26 +08:00
Yuzerion
9e83fe7329 Update README.md 2025-01-24 00:28:38 +08:00
Yuzerion
166c9c75e9 Update next-release.md
added some screenshots
2025-01-24 00:25:36 +08:00
yusing
b9882f8985 updated implementation of (un)hiding items 2025-01-23 12:52:15 +08:00
yusing
37a166731d fixes some tests 2025-01-23 05:24:13 +08:00
yusing
66db583432 fix notification dispatcher panic when dispatching on program exit 2025-01-23 04:41:10 +08:00
yusing
f7eb80a6ea fix dashboard filter not working for edited apps 2025-01-23 04:29:39 +08:00
yusing
79f40f3d22 implement icon cache expiry, cleanup code and upgrade deps 2025-01-23 04:16:06 +08:00
yusing
ed3b26653c fix log wrapped incorrectly in WebUI, implement log SSR 2025-01-23 00:08:19 +08:00
yusing
2bb13129de fix: autocert scheduler using too high cpu usage 2025-01-22 10:45:57 +08:00
yusing
fc29e8f9fa fix typo 2025-01-22 08:32:51 +08:00
yusing
495c2c7390 fix makefile 2025-01-22 06:14:02 +08:00
yusing
b984386bab fix: high cpu usage 2025-01-22 05:44:04 +08:00
yusing
3781bb93e1 cleanup makefile and remove script, allow running as non-root user 2025-01-22 05:42:56 +08:00
yusing
3a4dc3f876 fixed dashboard not showing all apps 2025-01-21 12:56:21 +08:00
yusing
2c43f1412e fix OIDC middleware callback URL 2025-01-21 12:42:56 +08:00
yusing
5d3a93f103 idlewatcher: fix visiting unhealthy idle watched container causes panic 2025-01-21 10:37:09 +08:00
yusing
5faba1b5a9 fix svg content type 2025-01-21 10:07:53 +08:00
yusing
4e7bd3579b fix favicon content type 2025-01-21 09:36:17 +08:00
yusing
49da8a31d2 api: fix not getting correct icon 2025-01-21 09:31:51 +08:00
yusing
dd2b8f600d api: allow favicon endpoint to use url instead of alias 2025-01-21 06:48:56 +08:00
yusing
8b1a3a31ff simplify icon caching and homepage item override 2025-01-21 06:16:00 +08:00
yusing
d429374924 fix deserialization: reflect: indirection through nil pointer to embedded struct 2025-01-21 04:09:46 +08:00
yusing
dd0bbdc7b4 fix logs not printing correctly, removed unneccessary loggers 2025-01-20 17:42:54 +08:00
yusing
64e85c3076 feat: support selfh.st icons, support homepage config overriding 2025-01-20 17:42:17 +08:00
yusing
68771ce399 api: added some endpoints for dashboard filter to work 2025-01-20 06:17:18 +08:00
yusing
bcc7faa8e5 api: updated response message on invalid credential, add auth check endpoint 2025-01-20 02:14:21 +08:00
Yuzerion
fb0dc7dea0 Feat/OIDC middleware (#50)
* implement OIDC middleware

* auth code cleanup

* allow override allowed_user in middleware, fix typos

* fix tests and callbackURL

* update next release docs

* fix OIDC middleware not working with Authentik

* feat: add groups support for OIDC claims (#41)

Allow users to specify allowed groups in the env and use it to inspect the claims.

This performs a logical AND of users and groups (additive).

* merge feat/oidc-middleware (#49)

* api: enrich provider statistifcs

* fix: docker monitor now uses container status

* Feat/auto schemas (#48)

* use auto generated schemas

* go version bump and dependencies upgrade

* clarify some error messages

---------

Co-authored-by: yusing <yusing@6uo.me>

* cleanup some loadbalancer code

* api: cleanup websocket code

* api: add /v1/health/ws for health bubbles on dashboard

* feat: experimental memory logger and logs api for WebUI

---------

Co-authored-by: yusing <yusing@6uo.me>

---------

Co-authored-by: yusing <yusing@6uo.me>
Co-authored-by: Peter Olds <peter@olds.co>
2025-01-19 13:48:52 +08:00
yusing
0fad7b3411 feat: experimental memory logger and logs api for WebUI 2025-01-19 13:45:16 +08:00
yusing
1adba05065 api: add /v1/health/ws for health bubbles on dashboard 2025-01-19 04:34:20 +08:00
yusing
fe7740f1b0 api: cleanup websocket code 2025-01-19 04:33:55 +08:00
yusing
b253dce7e1 cleanup some loadbalancer code 2025-01-19 04:32:50 +08:00
Yuzerion
589b3a7a13 Feat/auto schemas (#48)
* use auto generated schemas

* go version bump and dependencies upgrade

* clarify some error messages

---------

Co-authored-by: yusing <yusing@6uo.me>
2025-01-19 00:37:17 +08:00
yusing
26d259b952 fix: docker monitor now uses container status 2025-01-15 09:16:36 +08:00
yusing
04e118c081 api: enrich provider statistifcs 2025-01-15 09:16:29 +08:00
yusing
2af2346e35 fix auth redirect 2025-01-13 08:41:09 +08:00
yusing
7cd44b5ad3 rename cookies to prevent conflict 2025-01-13 08:33:56 +08:00
yusing
81d96394b9 allow customizing OICD scopes 2025-01-13 08:30:46 +08:00
yusing
76fe5345d8 cleanup code, redirect to auth page when need 2025-01-13 07:15:29 +08:00
yusing
ef277ef57f fix: docker test and golangci-lint 2025-01-13 05:37:29 +08:00
Peter Olds
9a12dab600 fix: allow oauth_state token to be cross-domain (#40)
External OIDC providers won’t work with the current setup.
2025-01-13 05:27:06 +08:00
Yuzerion
51f6391ded feat: Add optional OIDC support (#39)
This allows the API to trigger an OAuth workflow to create the JWT for authentication. For now the workflow is triggered by manually visiting `/api/login/oidc` on the frontend app until the UI repo is updated to add support.

Co-authored-by: Peter Olds <peter@olds.co>
2025-01-13 04:49:46 +08:00
yusing
e10e6cfe4d updated ls-icon and icon fetching mechanism 2025-01-13 02:21:52 +08:00
yusing
d887a37f60 fix favicon on non http 200 2025-01-13 00:52:07 +08:00
yusing
1abd1e257f fix favicon path and try dashboard icon first then fallback to html parsing 2025-01-13 00:15:10 +08:00
yusing
137b0820b0 reset favicon cache on route reload 2025-01-12 22:32:17 +08:00
yusing
3f85d7f813 container now consider explicit if any proxy label defined 2025-01-12 22:31:43 +08:00
yusing
6b6dae129f fix route provider name 2025-01-12 13:49:47 +08:00
yusing
2c3672a7ea idlewatfcher: add proper Cache-Control Headers to response 2025-01-12 13:16:58 +08:00
yusing
645a58464c fix favicon redirection path 2025-01-12 13:14:31 +08:00
yusing
fcbb51dce7 fixed and improved favicon retrieving 2025-01-12 12:02:40 +08:00
yusing
c7c6a097f0 server side favicon retrieving and caching 2025-01-12 10:30:37 +08:00
yusing
0ce7f29976 fix proxy rules behavior and implemented a few more rules and commands, dependencies upgrade 2025-01-11 12:22:42 +08:00
yusing
f2df756c17 fix rule parser 2025-01-11 02:14:22 +08:00
yusing
28b5d44e11 fix: slice deserialization should return all errors 2025-01-11 01:39:03 +08:00
yusing
e7bb6bc798 fix bypass command 2025-01-10 06:48:41 +08:00
yusing
c572382f6a refactor query.go 2025-01-10 06:48:17 +08:00
yusing
e28c4a1b4d fix: rules escaped backslash 2025-01-09 19:59:53 +08:00
yusing
f5708fd539 add rule.on directives "cookie", "form", "postform" 2025-01-09 19:05:18 +08:00
yusing
5769abb626 fix: File.closeOnZero remove unnecessary for loop 2025-01-09 18:42:51 +08:00
yusing
4ebe0abba0 fix: bypass rules should not check first 2025-01-09 18:17:05 +08:00
yusing
8109c9ac4f small refactor 2025-01-09 14:09:48 +08:00
yusing
2ce1ceb460 remove old unused code 2025-01-09 14:09:48 +08:00
yusing
9d701ad671 add help messages to rules, updat url validation 2025-01-09 14:09:48 +08:00
yusing
4aee44fe11 fix rewrite omitting trailing slash, error msg update 2025-01-09 14:09:48 +08:00
yusing
adb41a80c5 support middleware cross referencing 2025-01-09 05:15:18 +08:00
yusing
642e6ebdc8 fix panic: Bad field name provided name 2025-01-09 04:44:55 +08:00
yusing
74828943a6 updated route rules implementation 2025-01-09 04:27:02 +08:00
yusing
f906e04581 fix access logger write on closed file after config reload 2025-01-09 04:26:31 +08:00
yusing
b3c47e759f fix incorrect reload behaviors, further organize code 2025-01-09 04:26:00 +08:00
yusing
8bbb5d2e09 fix fields not being validated (introduced in 577a536), drop support of list string not starting with hyphen 2025-01-09 04:21:32 +08:00
yusing
7fe03be73f fix: cert renewal failure cause scheduler stuck forver 2025-01-09 02:53:04 +08:00
yusing
abb0124011 readme and next release update 2025-01-08 14:03:40 +08:00
yusing
a98b2bb71a updated implementation of rules 2025-01-08 13:50:34 +08:00
yusing
bc1702e6cf refactoring: moved reverse_proxy to separate package to avoid import cycle 2025-01-08 13:50:34 +08:00
yusing
577a5366e8 remove unused old code 2025-01-08 13:50:34 +08:00
Peter Olds
7fedd5729e feat: Add optional StartEndpoint support for idle watcher
Optionally allow a user to specify a “warm-up” endpoint to start the container, returning a 403 if the endpoint isn’t hit and the container has been stopped.

This can help prevent bots from starting random containers, or allow health check systems to run some probes.
2025-01-08 11:01:10 +08:00
yusing
35c0463829 naive implementation of caddy like route rules, dependencies upgrade 2025-01-08 07:18:09 +08:00
280 changed files with 11812 additions and 4270 deletions

View File

@@ -1,15 +1,39 @@
# 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`
GODOXY_API_JWT_SECRET=
# the JWT token time-to-live
GODOXY_API_JWT_TOKEN_TTL=1h
# API/WebUI login credentials
GODOXY_API_USER=admin
GODOXY_API_PASSWORD=password
# 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
GODOXY_HTTP_ADDR=:80
@@ -18,8 +42,8 @@ GODOXY_HTTPS_ADDR=:443
# API listening address
GODOXY_API_ADDR=127.0.0.1:8888
# Prometheus Metrics listening address (uncomment to enable)
#GODOXY_PROMETHEUS_ADDR=:8889
# Prometheus Metrics
GODOXY_PROMETHEUS_ENABLED=true
# Debug mode
GODOXY_DEBUG=false
GODOXY_DEBUG=false

15
.github/FUNDING.yml vendored Normal file
View 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']

4
.gitignore vendored
View File

@@ -4,6 +4,7 @@ compose.yml
config
certs
config*/
!schemas/**
certs*/
bin/
error_pages/
@@ -26,3 +27,6 @@ todo.md
mtrace.json
.env
test.Dockerfile
node_modules/
tsconfig.tsbuildinfo

View File

@@ -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

View File

@@ -2,12 +2,12 @@
# To learn more about the format of this file, see https://docs.trunk.io/reference/trunk-yaml
version: 0.1
cli:
version: 1.22.8
version: 1.22.9
# Trunk provides extensibility via plugins. (https://docs.trunk.io/plugins)
plugins:
sources:
- id: trunk
ref: v1.6.6
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:
@@ -22,17 +22,17 @@ lint:
- yamllint
enabled:
- hadolint@2.12.1-beta
- actionlint@1.7.5
- checkov@3.2.346
- actionlint@1.7.7
- checkov@3.2.360
- git-diff-check
- gofmt@1.20.4
- golangci-lint@1.62.2
- 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.88.0
- trufflehog@3.88.4
actions:
disabled:
- trunk-announce

View File

@@ -1,10 +1,10 @@
{
"yaml.schemas": {
"https://github.com/yusing/go-proxy/raw/v0.8/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/v0.8/schema/providers.schema.json": [
"https://github.com/yusing/go-proxy/raw/v0.9/schemas/routes.schema.json": [
"providers.example.yml"
]
}

View File

@@ -1,10 +1,10 @@
# Stage 1: Builder
FROM golang:1.23.4-alpine AS builder
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
RUN apk add --no-cache tzdata make libcap-setcap
WORKDIR /src
@@ -17,15 +17,17 @@ RUN --mount=type=cache,target="/go/pkg/mod" \
ENV GOCACHE=/root/.cache/go-build
ARG VERSION
ENV VERSION=${VERSION}
COPY scripts /src/scripts
COPY Makefile /src/
COPY cmd /src/cmd
COPY internal /src/internal
COPY pkg /src/pkg
ARG VERSION
ENV VERSION=${VERSION}
ARG BUILD_FLAGS
ENV BUILD_FLAGS=${BUILD_FLAGS}
RUN --mount=type=cache,target="/go/pkg/mod" \
--mount=type=cache,target="/root/.cache/go-build" \
make build && \
@@ -50,9 +52,6 @@ COPY config.example.yml /app/config/config.yml
# copy certs
COPY --from=builder /etc/ssl/certs /etc/ssl/certs
# copy schema
COPY schema /app/schema
ENV DOCKER_HOST=unix:///var/run/docker.sock
ENV GODOXY_DEBUG=0

121
Makefile
View File

@@ -1,53 +1,66 @@
VERSION ?= $(shell git describe --tags --abbrev=0)
BUILD_FLAGS ?= -s -w
BUILD_DATE ?= $(shell date -u +'%Y%m%d-%H%M')
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:
GODOXY_TEST=1 go test ./internal/...
up:
docker compose up -d
restart:
docker compose restart -t 0
logs:
docker compose logs -f
get:
go get -u ./cmd && go mod tidy
debug:
GODOXY_DEBUG=1 BUILD_FLAGS="" make run
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
debug-trace:
GODOXY_TRACE=1 make debug
profile:
GODEBUG=gctrace=1 make debug
run: build
sudo setcap CAP_NET_BIND_SERVICE=+eip bin/godoxy
bin/godoxy
run:
[ -f .env ] && godotenv -f .env go run ${BUILD_FLAGS} ./cmd
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'
@@ -63,11 +76,51 @@ push-docker-io:
BUILDER=build docker buildx build \
--platform linux/arm64,linux/amd64 \
-f Dockerfile \
-t docker.io/yusing/godoxy-nightly \
-t docker.io/yusing/godoxy-nightly:${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 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)

View File

@@ -1,19 +1,25 @@
<div align="center">
# GoDoxy
[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=yusing_go-proxy&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy)
![GitHub last commit](https://img.shields.io/github/last-commit/yusing/go-proxy)
[![Lines of Code](https://sonarcloud.io/api/project_badges/measure?project=yusing_go-proxy&metric=ncloc)](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy)
[![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=yusing_go-proxy&metric=security_rating)](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy)
[![](https://dcbadge.limes.pink/api/server/umReR62nRd?style=flat)](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>
<!-- [![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=yusing_go-proxy&metric=security_rating)](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy)
[![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=yusing_go-proxy&metric=sqale_rating)](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy)
[![Vulnerabilities](https://sonarcloud.io/api/project_badges/measure?project=yusing_go-proxy&metric=vulnerabilities)](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy)
[![](https://dcbadge.limes.pink/api/server/umReR62nRd)](https://discord.gg/umReR62nRd)
[![Vulnerabilities](https://sonarcloud.io/api/project_badges/measure?project=yusing_go-proxy&metric=vulnerabilities)](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.
![Screenshot](screenshots/webui.png)
_Join our [Discord](https://discord.gg/umReR62nRd) for help and discussions_
</div>
## Table of content
@@ -22,12 +28,10 @@ _Join our [Discord](https://discord.gg/umReR62nRd) for help and discussions_
- [GoDoxy](#godoxy)
- [Table of content](#table-of-content)
- [Key Features](#key-features)
- [Getting Started](#getting-started)
- [Prerequisites](#prerequisites)
- [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)
@@ -43,6 +47,7 @@ _Join our [Discord](https://discord.gg/umReR62nRd) for help and discussions_
- 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
@@ -52,18 +57,14 @@ _Join our [Discord](https://discord.gg/umReR62nRd) for help and discussions_
[🔼Back to top](#table-of-content)
## Getting Started
For full documentation, **[See Wiki](https://github.com/yusing/go-proxy/wiki)**
### Prerequisites
## Prerequisites
Setup DNS Records point to machine which runs `GoDoxy`, e.g.
- A Record: `*.y.z` -> `10.0.10.1`
- AAAA Record: `*.y.z` -> `::ffff:a00:a01`
### Setup
## Setup
1. Pull the latest docker images
@@ -77,27 +78,11 @@ Setup DNS Records point to machine which runs `GoDoxy`, e.g.
docker run --rm -v .:/setup ghcr.io/yusing/go-proxy /app/godoxy setup
```
3. _(Optional)_ setup WebUI login
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`
- set random JWT secret
4. Start the container `docker compose up -d`
```shell
sed -i "s|API_JWT_SECRET=.*|API_JWT_SECRET=$(openssl rand -base64 32)|g" .env
```
- change username and password for WebUI authentication
```shell
sed -i "s|API_USERNAME=.*|API_USERNAME=admin|g" .env
sed -i "s|API_PASSWORD=.*|API_PASSWORD=some-strong-password|g" .env
```
4. _(Optional)_ setup `docker-socket-proxy` other docker nodes (see [Multi docker nodes setup](https://github.com/yusing/go-proxy/wiki/Configurations#multi-docker-nodes-setup)) then add them inside `config.yml`
5. Start the container `docker compose up -d`
6. You may now do some extra configuration
- With text editor (e.g. Visual Studio Code)
- With Web UI via `https://gp.y.z`
5. You may now do some extra configuration on WebUI `https://godoxy.domain.com`
[🔼Back to top](#table-of-content)
@@ -105,15 +90,15 @@ Setup DNS Records point to machine which runs `GoDoxy`, e.g.
1. Make `config` directory then grab `config.example.yml` into `config/config.yml`
`mkdir -p config && wget https://raw.githubusercontent.com/yusing/go-proxy/v0.8/config.example.yml -O config/config.yml`
`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.8/.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.8/compose.example.yml -O compose.yml`
`wget https://raw.githubusercontent.com/yusing/go-proxy/v0.9/compose.example.yml -O compose.yml`
### Folder structrue
@@ -132,12 +117,6 @@ Setup DNS Records point to machine which runs `GoDoxy`, e.g.
└── .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

View File

@@ -1,19 +1,25 @@
<div align="center">
# GoDoxy
[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=yusing_go-proxy&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy)
![GitHub last commit](https://img.shields.io/github/last-commit/yusing/go-proxy)
[![Lines of Code](https://sonarcloud.io/api/project_badges/measure?project=yusing_go-proxy&metric=ncloc)](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy)
[![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=yusing_go-proxy&metric=security_rating)](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy)
[![](https://dcbadge.limes.pink/api/server/umReR62nRd?style=flat)](https://discord.gg/umReR62nRd)
輕量、易用、 [高效能](https://github.com/yusing/go-proxy/wiki/Benchmarks),且帶有主頁和配置面板的反向代理
完整文檔請查閱 **[Wiki](https://github.com/yusing/go-proxy/wiki)**(暫未有中文翻譯)
<!-- [![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=yusing_go-proxy&metric=security_rating)](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy)
[![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=yusing_go-proxy&metric=sqale_rating)](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy)
[![Vulnerabilities](https://sonarcloud.io/api/project_badges/measure?project=yusing_go-proxy&metric=vulnerabilities)](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy)
[![](https://dcbadge.limes.pink/api/server/umReR62nRd)](https://discord.gg/umReR62nRd)
[![Vulnerabilities](https://sonarcloud.io/api/project_badges/measure?project=yusing_go-proxy&metric=vulnerabilities)](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy) -->
[English Documentation](README.md)
<a href="README.md">EN</a> | **中文**
一個輕量級、易於使用且[高效能](https://github.com/yusing/go-proxy/wiki/Benchmarks)的反向代理,具有網頁介面和儀表板。
<img src="https://github.com/user-attachments/assets/4bb371f4-6e4c-425c-89b2-b9e962bdd46f" style="max-width: 650">
![截圖](screenshots/webui.png)
_加入我們的 [Discord](https://discord.gg/umReR62nRd) 獲取幫助和討論_
</div>
## 目錄
@@ -22,12 +28,10 @@ _加入我們的 [Discord](https://discord.gg/umReR62nRd) 獲取幫助和討論_
- [GoDoxy](#godoxy)
- [目錄](#目錄)
- [主要特點](#主要特點)
- [入門指南](#入門指南)
- [前置需求](#前置需求)
- [安裝](#安裝)
- [前置需求](#前置需求)
- [安裝](#安裝)
- [手動安裝](#手動安裝)
- [資料夾結構](#資料夾結構)
- [在 VSCode 中使用 JSON Schema](#在-vscode-中使用-json-schema)
- [截圖](#截圖)
- [閒置休眠](#閒置休眠)
- [自行編譯](#自行編譯)
@@ -43,6 +47,7 @@ _加入我們的 [Discord](https://discord.gg/umReR62nRd) 獲取幫助和討論_
- 容器狀態/配置文件變更時自動熱重載
- **閒置休眠**在閒置時停止容器有流量時喚醒_可選參見[截圖](#閒置休眠)_
- 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 埠轉發
@@ -52,18 +57,14 @@ _加入我們的 [Discord](https://discord.gg/umReR62nRd) 獲取幫助和討論_
[🔼回到頂部](#目錄)
## 入門指南
完整文檔請參見 **[Wiki](https://github.com/yusing/go-proxy/wiki)**
### 前置需求
## 前置需求
設置 DNS 記錄指向運行 `GoDoxy` 的機器,例如:
- A 記錄:`*.y.z` -> `10.0.10.1`
- AAAA 記錄:`*.y.z` -> `::ffff:a00:a01`
### 安裝
## 安裝
1. 拉取最新的 Docker 映像
@@ -77,27 +78,11 @@ _加入我們的 [Discord](https://discord.gg/umReR62nRd) 獲取幫助和討論_
docker run --rm -v .:/setup ghcr.io/yusing/go-proxy /app/godoxy setup
```
3. _可選_ 設置網頁介面登入
3. _可選_ 設置其他 Docker 節點的 `docker-socket-proxy`(參見 [多 Docker 節點設置](https://github.com/yusing/go-proxy/wiki/Configurations#multi-docker-nodes-setup)),然後在 `config.yml` 中添加它們
- 設置隨機 JWT 密鑰
4. 啟動容器 `docker compose up -d`
```shell
sed -i "s|API_JWT_SECRET=.*|API_JWT_SECRET=$(openssl rand -base64 32)|g" .env
```
- 更改網頁介面認證的使用者名稱和密碼
```shell
sed -i "s|API_USERNAME=.*|API_USERNAME=admin|g" .env
sed -i "s|API_PASSWORD=.*|API_PASSWORD=some-strong-password|g" .env
```
4. _可選_ 設置其他 Docker 節點的 `docker-socket-proxy`(參見 [多 Docker 節點設置](https://github.com/yusing/go-proxy/wiki/Configurations#multi-docker-nodes-setup)),然後在 `config.yml` 中添加它們
5. 啟動容器 `docker compose up -d`
6. 現在您可以進行額外的配置
- 使用文字編輯器(如 Visual Studio Code
- 通過網頁介面 `https://gp.y.z`
5. 大功告成!可前往WebUI `https://gp.domain.com` 進行額外的配置
[🔼回到頂部](#目錄)
@@ -105,15 +90,15 @@ _加入我們的 [Discord](https://discord.gg/umReR62nRd) 獲取幫助和討論_
1. 建立 `config` 目錄,然後將 `config.example.yml` 下載到 `config/config.yml`
`mkdir -p config && wget https://raw.githubusercontent.com/yusing/go-proxy/v0.8/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. 將 `.env.example` 下載到 `.env`
`wget https://raw.githubusercontent.com/yusing/go-proxy/v0.8/.env.example -O .env`
`wget https://raw.githubusercontent.com/yusing/go-proxy/v0.9/.env.example -O .env`
3. 將 `compose.example.yml` 下載到 `compose.yml`
`wget https://raw.githubusercontent.com/yusing/go-proxy/v0.8/compose.example.yml -O compose.yml`
`wget https://raw.githubusercontent.com/yusing/go-proxy/v0.9/compose.example.yml -O compose.yml`
### 資料夾結構
@@ -132,12 +117,6 @@ _加入我們的 [Discord](https://discord.gg/umReR62nRd) 獲取幫助和討論_
└── .env
```
### 在 VSCode 中使用 JSON Schema
複製 [`.vscode/settings.example.json`](.vscode/settings.example.json) 到 `.vscode/settings.json` 並根據需要修改
[🔼回到頂部](#目錄)
## 截圖
### 閒置休眠

120
bun.lock Normal file
View 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=="],
}
}

View File

@@ -2,29 +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"
"github.com/yusing/go-proxy/internal/entrypoint"
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/metrics"
"github.com/yusing/go-proxy/internal/net/http/middleware"
"github.com/yusing/go-proxy/internal/net/http/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 {
@@ -35,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
@@ -97,24 +111,34 @@ func main() {
switch args.Command {
case common.CommandListRoutes:
cfg.StartProxyProviders()
printJSON(config.RoutesByAlias())
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
}
if common.APIJWTSecret == nil {
logging.Warn().Msg("API JWT secret is empty, authentication is disabled")
}
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,
})
cfg.StartProxyProviders()
config.WatchChanges()
sig := make(chan os.Signal, 1)
@@ -122,44 +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")
}
server.StartServer(server.Options{
Name: "proxy",
CertProvider: autocert,
HTTPAddr: common.ProxyHTTPAddr,
HTTPSAddr: common.ProxyHTTPSAddr,
Handler: http.HandlerFunc(entrypoint.Handler),
})
server.StartServer(server.Options{
Name: "api",
CertProvider: autocert,
HTTPAddr: common.APIHTTPAddr,
Handler: api.NewHandler(),
})
if common.PrometheusEnabled {
server.StartServer(server.Options{
Name: "metrics",
CertProvider: autocert,
HTTPAddr: common.MetricsHTTPAddr,
Handler: metrics.NewHandler(),
})
}
// wait for signal
<-sig
// grafully shutdown
// gracefully shutdown
logging.Info().Msg("shutting down")
_ = task.GracefulShutdown(time.Second * time.Duration(config.Value().TimeoutShutdown))
_ = task.GracefulShutdown(time.Second * time.Duration(cfg.Value().TimeoutShutdown))
}
func prepareDirectory(dir string) {
@@ -175,6 +167,5 @@ func printJSON(obj any) {
if err != nil {
logging.Fatal().Err(err).Send()
}
rawLogger := log.New(os.Stdout, "", 0)
rawLogger.Print(string(j)) // raw output for convenience using "jq"
}

7
cmd/main_production.go Normal file
View File

@@ -0,0 +1,7 @@
//go:build production
package main
func initProfiling() {
// no profiling in production
}

20
cmd/main_prof.go Normal file
View 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))
}()
}

View File

@@ -10,11 +10,12 @@ services:
- app
# modify below to fit your needs
labels:
proxy.aliases: gp
proxy.#1.port: 3000
# proxy.#1.middlewares.cidr_whitelist.status: 403
# proxy.#1.middlewares.cidr_whitelist.message: IP not allowed
# proxy.#1.middlewares.cidr_whitelist.allow: |
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
@@ -28,15 +29,13 @@ services:
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
# To use autocert, certs will be stored in "./certs".
# You can also use a docker volume to store it
- ./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
# 2. use autocert, certs will be stored in ./certs
# you can also use a docker volume to store it
# - ./certs:/app/certs

View File

@@ -1,78 +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:
middlewares:
# this part blocks all non-LAN HTTP traffic
# remove if you don't want this
- use: CIDRWhitelist
allow:
- "127.0.0.1"
- "10.0.0.0/8"
- "172.16.0.0/12"
- "192.168.0.0/16"
status: 403
message: "Forbidden"
# end of CIDRWhitelist
# 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
# this part redirects HTTP to HTTPS
# remove if you don't want this
- use: RedirectHTTP
# access_log:
# buffer_size: 1024
# path: /var/log/example.log
# filters:
# status_codes:
# values:
# - 200-299
# - 101
# method:
# values:
# - GET
# host:
# values:
# - example.y.z
# headers:
# negative: true
# values:
# - foo=bar
# - baz
# cidr:
# values:
# - 192.168.10.0/24
# fields:
# headers:
# default: keep
# config:
# foo: redact
# query:
# default: drop
# config:
# foo: keep
# cookies:
# default: redact
# config:
# foo: keep
# below enables access log
access_log:
format: combined
path: /app/logs/entrypoint.log
providers:
# include files are standalone yaml files under `config/` directory
@@ -84,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
@@ -106,28 +71,10 @@ providers:
# - name: discord
# provider: webhook
# url: https://discord.com/api/webhooks/...
# template: discord
# # payload: | # discord template implies the following
# # {
# # "embeds": [
# # {
# # "title": $title,
# # "fields": $fields,
# # "color": "$color"
# # }
# # ]
# # }
# if match_domains not defined
# any host = alias+[any domain] will match
# i.e. https://app1.y.z will match alias app1 for any domain y.z
# 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
#
# 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

44
go.mod
View File

@@ -1,20 +1,27 @@
module github.com/yusing/go-proxy
go 1.23.4
go 1.23.5
require (
github.com/PuerkitoBio/goquery v1.10.1
github.com/coder/websocket v1.8.12
github.com/docker/cli v27.4.1+incompatible
github.com/docker/docker v27.4.1+incompatible
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.23.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.6.1
github.com/lithammer/fuzzysearch v1.1.8
github.com/prometheus/client_golang v1.20.5
github.com/puzpuzpuz/xsync/v3 v3.4.0
github.com/puzpuzpuz/xsync/v3 v3.5.0
github.com/rs/zerolog v1.33.0
golang.org/x/net v0.33.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
@@ -22,10 +29,11 @@ require (
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/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudflare/cloudflare-go v0.113.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
@@ -37,14 +45,14 @@ require (
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/goccy/go-json v0.10.4 // indirect
github.com/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/klauspost/compress v1.17.11 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-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
@@ -54,23 +62,21 @@ require (
github.com/ovh/go-ovh v1.6.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.61.0 // 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/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 // indirect
go.opentelemetry.io/otel v1.33.0 // indirect
go.opentelemetry.io/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.33.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.33.0 // indirect
golang.org/x/crypto v0.31.0 // indirect
go.opentelemetry.io/otel/trace v1.34.0 // indirect
golang.org/x/mod v0.22.0 // indirect
golang.org/x/oauth2 v0.25.0 // indirect
golang.org/x/sync v0.10.0 // indirect
golang.org/x/sys v0.29.0 // indirect
golang.org/x/tools v0.28.0 // indirect
google.golang.org/protobuf v1.36.1 // indirect
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
)

127
go.sum
View File

@@ -2,18 +2,24 @@ 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/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudflare/cloudflare-go v0.113.0 h1:qnOXmA6RbgZ4rg5gNBK5QGk0Pzbv8pnUYV3C4+8CU6w=
github.com/cloudflare/cloudflare-go v0.113.0/go.mod h1:Dlm4BAnycHc0i8yLxQZb9b+OlMwYOAoDJsUOEFgpVvo=
github.com/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/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=
@@ -21,10 +27,10 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
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.4.1+incompatible h1:VzPiUlRJ/xh+otB75gva3r05isHMo5wXDfPRi5/b4hI=
github.com/docker/cli v27.4.1+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
github.com/docker/docker v27.4.1+incompatible h1:ZJvcY7gfwHn1JF48PfbyXg7Jyt9ZCWDW+GGXOIxEwp4=
github.com/docker/docker v27.4.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/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=
@@ -50,10 +56,12 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.23.0 h1:/PwmTwZhS0dPkav3cdK9kV1FsAmrL8sThn8IHr/sO+o=
github.com/go-playground/validator/v10 v10.23.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/goccy/go-json v0.10.4 h1:JSwxQzIqKfmFX1swYPpUThQZp/Ka4wzJdK0LWVytLPM=
github.com/goccy/go-json v0.10.4/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/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=
@@ -84,16 +92,19 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/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=
@@ -117,12 +128,12 @@ github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+
github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
github.com/prometheus/common v0.61.0 h1:3gv/GThfX0cV2lpO7gkTUwZru38mxevy90Bj8YFSRQQ=
github.com/prometheus/common v0.61.0/go.mod h1:zr29OCN/2BsJRaFwG8QOBr41D6kkchKbpeNH7pAjb/s=
github.com/prometheus/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.4.0 h1:DuVBAdXuGFHv8adVXjWWZ63pJq+NRXOWVXlKDBZ+mJ4=
github.com/puzpuzpuz/xsync/v3 v3.4.0/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA=
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=
@@ -134,59 +145,109 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
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=
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.58.0 h1:yd02MEjBdJkG3uabWP9apV+OuWRIXGDuJEUJbOHmCFU=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0/go.mod h1:umTcuxiv1n/s/S6/c2AT/g2CQ7u5C59sHDNmfSwgz7Q=
go.opentelemetry.io/otel v1.33.0 h1:/FerN9bax5LoK51X/sI0SVYrjSE0/yUL7DpxW4K3FWw=
go.opentelemetry.io/otel v1.33.0/go.mod h1:SUUkR6csvUQl+yjReHu5uM3EtVV7MBm5FHKRlNx4I8I=
go.opentelemetry.io/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.33.0 h1:r+JOocAyeRVXD8lZpjdQjzMadVZp2M4WmQ+5WtEnklQ=
go.opentelemetry.io/otel/metric v1.33.0/go.mod h1:L9+Fyctbp6HFTddIxClbQkjtubW6O9QS3Ann/M82u6M=
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.33.0 h1:cCJuF7LRjUFso9LPnEAHJDB2pqzp+hbO8eu1qqW2d/s=
go.opentelemetry.io/otel/trace v1.33.0/go.mod h1:uIcdVUZMpTAmz0tI1z04GoVSezK37CbGV4fr1f2nBck=
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.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
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.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.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
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.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.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.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=
@@ -195,8 +256,12 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm
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.28.0 h1:WuB6qZ4RPCQo5aP3WdKZS7i595EdWqWR8vqJTlwTVK8=
golang.org/x/tools v0.28.0/go.mod h1:dcIOrVd3mfQKTgrDVQHqCPMWy6lnhfhtX3hLXYVLfRw=
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=
@@ -208,8 +273,8 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576 h1:
google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576/go.mod h1:5uTbfoYQed2U9p3KIj2/Zzm02PYhndfdmML0qC3q3FU=
google.golang.org/grpc v1.67.1 h1:zWnc1Vrcno+lHZCOofnIMvycFcc0QRGIzm9dhnDX68E=
google.golang.org/grpc v1.67.1/go.mod h1:1gLDyUQU7CTLJI90u3nXZ9ekeghjeM7pTDZlqFNg2AA=
google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk=
google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
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=

View File

@@ -1,58 +1,70 @@
package api
import (
"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(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("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", "/v1/file/{type}/{filename}", auth.RequireAuth(v1.SetFileContent))
mux.HandleFunc("PUT", "/v1/file/{type}/{filename}", auth.RequireAuth(v1.SetFileContent))
mux.HandleFunc("GET", "/v1/schema/{filename...}", v1.GetSchemaFile)
mux.HandleFunc("GET", "/v1/stats", v1.Stats)
mux.HandleFunc("GET", "/v1/stats/ws", v1.StatsWS)
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
}
LogDebug(r).Interface("headers", r.Header).Msg("API request")
f(w, r)
handler(cfg, w, r)
}
}

View File

@@ -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 || common.APIJWTSecret == nil {
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.RespondError(w, E.New("missing token"), http.StatusUnauthorized)
return false
}
var claims Claims
token, err := jwt.ParseWithClaims(tokenCookie.Value, &claims, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
}
return common.APIJWTSecret, nil
})
switch {
case err != nil:
break
case !token.Valid:
err = E.New("invalid token")
case claims.Username != common.APIUser:
err = E.New("username mismatch").Subject(claims.Username)
case claims.ExpiresAt.Before(time.Now()):
err = E.Errorf("token expired on %s", strutils.FormatTime(claims.ExpiresAt.Time))
}
if err != nil {
U.RespondError(w, err, http.StatusForbidden)
return false
}
return true
return next
}

View 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)
}

View 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)
}
})
}
}

View 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)
}

View 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
}

View 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)
}
}
}

View 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)
}

View 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
}

View 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")
}

View 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))
}
}

View File

@@ -9,7 +9,7 @@ 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"
@@ -75,6 +75,38 @@ 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) {
fileType, filename, err := getArgs(r)
if err != nil {
@@ -87,19 +119,7 @@ func SetFileContent(w http.ResponseWriter, r *http.Request) {
return
}
var valErr E.Error
switch fileType {
case FileTypeConfig:
valErr = config.Validate(content)
case FileTypeMiddleware:
errs := E.NewBuilder("middleware errors")
middleware.BuildMiddlewaresFromYAML(filename, content, errs)
valErr = errs.Error()
default:
valErr = provider.Validate(content)
}
if valErr != nil {
if valErr := validateFile(fileType, content); valErr != nil {
U.RespondError(w, valErr, http.StatusBadRequest)
return
}

18
internal/api/v1/health.go Normal file
View 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())
})
}

View 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, &params); err != nil {
utils.RespondError(w, err, http.StatusBadRequest)
return
}
overrides.OverrideItem(params.Which, &params.Value)
case HomepageOverrideItemsBatch:
var params HomepageOverrideItemsBatchParams
if err := json.Unmarshal(data, &params); 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, &params); 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, &params); 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)
}

View File

@@ -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"
ListFiles = "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
@@ -40,7 +46,7 @@ func List(w http.ResponseWriter, r *http.Request) {
U.RespondJSON(w, r, route)
}
case ListRoutes:
U.RespondJSON(w, r, config.RoutesByAlias(route.RouteType(r.FormValue("type"))))
U.RespondJSON(w, r, routequery.RoutesByAlias(route.RouteType(r.FormValue("type"))))
case ListFiles:
listFiles(w, r)
case ListMiddlewares:
@@ -48,9 +54,27 @@ func List(w http.ResponseWriter, r *http.Request) {
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.DebugTaskList())
default:
@@ -58,11 +82,13 @@ func List(w http.ResponseWriter, r *http.Request) {
}
}
// 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" {
return config.RoutesByAlias()
return routequery.RoutesByAlias()
}
routes := config.RoutesByAlias()
routes := routequery.RoutesByAlias()
route, ok := routes[which]
if !ok {
return nil
@@ -71,7 +97,7 @@ func listRoute(which string) any {
}
func listFiles(w http.ResponseWriter, r *http.Request) {
files, err := utils.ListFiles(common.ConfigBasePath, 0)
files, err := utils.ListFiles(common.ConfigBasePath, 0, true)
if err != nil {
U.HandleErr(w, r, err)
return
@@ -88,7 +114,7 @@ func listFiles(w http.ResponseWriter, r *http.Request) {
resp[t] = append(resp[t], file)
}
mids, err := utils.ListFiles(common.MiddlewareComposeBasePath, 0)
mids, err := utils.ListFiles(common.MiddlewareComposeBasePath, 0, true)
if err != nil {
U.HandleErr(w, r, err)
return

View 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
}
}
}
}

View File

@@ -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
}

View File

@@ -1,23 +0,0 @@
package v1
import (
"net/http"
"os"
"path"
U "github.com/yusing/go-proxy/internal/api/v1/utils"
"github.com/yusing/go-proxy/internal/common"
)
func GetSchemaFile(w http.ResponseWriter, r *http.Request) {
filename := r.PathValue("filename")
if filename == "" {
U.RespondError(w, U.ErrMissingKey("filename"), http.StatusBadRequest)
}
content, err := os.ReadFile(path.Join(common.SchemaBasePath, filename))
if err != nil {
U.HandleErr(w, r, err)
return
}
U.WriteBody(w, content)
}

View File

@@ -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"
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...)
}
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
}
}
}
var startTime = time.Now()
func getStats() map[string]any {
func getStats(cfg config.ConfigInstance) map[string]any {
return map[string]any{
"proxies": config.Statistics(),
"proxies": cfg.Statistics(),
"uptime": strutils.FormatDuration(time.Since(startTime)),
}
}

View File

@@ -1,13 +1,14 @@
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.
//
@@ -23,11 +24,22 @@ func HandleErr(w http.ResponseWriter, r *http.Request, err error, code ...int) {
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}
}
http.Error(w, ansi.StripANSI(err.Error()), code[0])
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 {

View File

@@ -11,6 +11,7 @@ func reqLogger(r *http.Request, level zerolog.Level) *zerolog.Event {
return logging.WithLevel(level).
Str("module", "api").
Str("remote", r.RemoteAddr).
Str("host", r.Host).
Str("uri", r.Method+" "+r.RequestURI)
}

View File

@@ -11,7 +11,7 @@ import (
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")
}
}

View 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
}
}
}
}

View File

@@ -6,6 +6,7 @@ import (
"crypto/rand"
"crypto/x509"
"os"
"regexp"
"github.com/go-acme/lego/v4/certcrypto"
"github.com/go-acme/lego/v4/lego"
@@ -13,63 +14,89 @@ import (
"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 {
cfg = new(types.AutoCertConfig)
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")
if cfg.Provider != ProviderLocal {
if len(cfg.Domains) == 0 {
b.Add(ErrMissingDomain)
}
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()
}
var privKey *ecdsa.PrivateKey
var err error
@@ -103,7 +130,7 @@ func (cfg *Config) GetProvider() (*Provider, E.Error) {
}, nil
}
func (cfg *Config) loadACMEKey() (*ecdsa.PrivateKey, error) {
func (cfg *AutocertConfig) loadACMEKey() (*ecdsa.PrivateKey, error) {
data, err := os.ReadFile(cfg.ACMEKeyPath)
if err != nil {
return nil, err
@@ -111,7 +138,7 @@ func (cfg *Config) loadACMEKey() (*ecdsa.PrivateKey, error) {
return x509.ParseECPrivateKey(data)
}
func (cfg *Config) saveACMEKey(key *ecdsa.PrivateKey) error {
func (cfg *AutocertConfig) saveACMEKey(key *ecdsa.PrivateKey) error {
data, err := x509.MarshalECPrivateKey(key)
if err != nil {
return err

View File

@@ -1,5 +0,0 @@
package autocert
import "github.com/yusing/go-proxy/internal/logging"
var logger = logging.With().Str("module", "autocert").Logger()

View File

@@ -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,7 +23,7 @@ import (
type (
Provider struct {
cfg *Config
cfg *AutocertConfig
user *User
legoCfg *lego.Config
client *lego.Client
@@ -32,7 +32,7 @@ type (
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
)
@@ -88,7 +88,7 @@ func (p *Provider) ObtainCert() E.Error {
})
if err != nil {
p.legoCert = nil
logger.Err(err).Msg("cert renew failed, fallback to obtain")
logging.Err(err).Msg("cert renew failed, fallback to obtain")
} else {
p.legoCert = cert
}
@@ -135,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()
}
@@ -148,28 +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.RootTask("cert-renew-scheduler", true)
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 {
renewalTime := p.ShouldRenewOn()
timer := time.NewTimer(time.Until(renewalTime))
select {
case <-task.Context().Done():
timer.Stop()
return
case <-timer.C:
if err := p.renewIfNeeded(); err != nil {
E.LogWarn("cert renew failed", err, &logger)
// Retry after 1 hour on failure
time.Sleep(time.Hour)
// 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))
}
}
}()
@@ -202,7 +211,7 @@ func (p *Provider) registerACME() error {
}
if reg, err := p.client.Registration.ResolveAccountByKey(); err == nil {
p.user.Registration = reg
logger.Info().Msg("reused acme registration from private key")
logging.Info().Msg("reused acme registration from private key")
return nil
}
@@ -211,7 +220,7 @@ func (p *Provider) registerACME() error {
return err
}
p.user.Registration = reg
logger.Info().Interface("reg", reg).Msg("acme registered")
logging.Info().Interface("reg", reg).Msg("acme registered")
return nil
}
@@ -257,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
}
@@ -271,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
}
@@ -303,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
}

View File

@@ -4,6 +4,7 @@ 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"
)
@@ -12,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 " + strutils.FormatTime(expiry))
logging.Info().Msg("certificate expire on " + strutils.FormatTime(expiry))
break
}

View File

@@ -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
}

View File

@@ -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"
)

View File

@@ -1,18 +1,11 @@
package common
import (
"crypto/sha512"
"encoding/base64"
"github.com/rs/zerolog/log"
)
func HashPassword(pwd string) []byte {
h := sha512.New()
h.Write([]byte(pwd))
return h.Sum(nil)
}
func decodeJWTKey(key string) []byte {
if key == "" {
return nil

View File

@@ -9,16 +9,19 @@ import (
"time"
"github.com/rs/zerolog/log"
"github.com/yusing/go-proxy/internal/utils/strutils"
)
var (
prefixes = []string{"GODOXY_", "GOPROXY_", ""}
IsTest = GetEnvBool("TEST", false) || strings.HasSuffix(os.Args[0], ".test")
IsDebug = GetEnvBool("DEBUG", IsTest)
IsDebugSkipAuth = GetEnvBool("DEBUG_SKIP_AUTH", false)
IsTrace = GetEnvBool("TRACE", false) && IsDebug
IsProduction = !IsTest && !IsDebug
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,
@@ -35,16 +38,22 @@ var (
APIHTTPPort,
APIHTTPURL = GetAddrEnv("API_ADDR", "127.0.0.1:8888", "http")
MetricsHTTPAddr,
MetricsHTTPHost,
MetricsHTTPPort,
MetricsHTTPURL = GetAddrEnv("PROMETHEUS_ADDR", "", "http")
PrometheusEnabled = MetricsHTTPURL != ""
PrometheusEnabled = GetEnvBool("PROMETHEUS_ENABLED", false)
APIJWTSecret = decodeJWTKey(GetEnvString("API_JWT_SECRET", ""))
APIJWTTokenTTL = GetDurationEnv("API_JWT_TOKEN_TTL", time.Hour)
APIUser = GetEnvString("API_USER", "admin")
APIPasswordHash = HashPassword(GetEnvString("API_PASSWORD", "password"))
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 GetEnv[T any](key string, defaultValue T, parser func(string) (T, error)) T {
@@ -96,3 +105,7 @@ func GetAddrEnv(key, defaultValue, scheme string) (addr, host, port, fullURL str
func GetDurationEnv(key string, defaultValue time.Duration) time.Duration {
return GetEnv(key, defaultValue, time.ParseDuration)
}
func GetCommaSepEnv(key string, defaultValue string) []string {
return strutils.CommaSeperatedList(GetEnvString(key, defaultValue))
}

View File

@@ -1,18 +1,21 @@
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"
proxy "github.com/yusing/go-proxy/internal/route/provider"
"github.com/yusing/go-proxy/internal/task"
@@ -26,13 +29,14 @@ 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
)
@@ -45,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.RootTask("config", false),
value: types.DefaultConfig(),
providers: F.NewMapOf[string, *proxy.Provider](),
entrypoint: entrypoint.NewEntrypoint(),
task: task.RootTask("config", false),
}
}
@@ -66,11 +73,6 @@ func Load() (*Config, E.Error) {
return instance, instance.load()
}
func Validate(data []byte) E.Error {
var model types.Config
return utils.DeserializeYAML(data, &model)
}
func MatchDomains() []string {
return instance.value.MatchDomains
}
@@ -82,7 +84,7 @@ func WatchChanges() {
configEventFlushInterval,
OnConfigChange,
func(err E.Error) {
E.LogError("config reload error", err, &logger)
E.LogError("config reload error", err)
},
)
eventQueue.Start(cfgWatcher.Events(t.Context()))
@@ -93,10 +95,10 @@ func OnConfigChange(ev []events.Event) {
// 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
}
@@ -115,22 +117,26 @@ func Reload() E.Error {
err := newCfg.load()
if err != nil {
newCfg.task.Finish(err)
return 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 = newCfg
instance.StartProxyProviders()
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
}
@@ -138,6 +144,30 @@ 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 {
@@ -145,7 +175,37 @@ func (cfg *Config) StartProxyProviders() {
})
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),
})
}
}
@@ -154,19 +214,19 @@ func (cfg *Config) load() E.Error {
data, err := os.ReadFile(common.ConfigPath)
if err != nil {
E.LogFatal(errMsg, err, &logger)
E.LogFatal(errMsg, err)
}
model := types.DefaultConfig()
if err := utils.DeserializeYAML(data, model); err != nil {
E.LogFatal(errMsg, err, &logger)
E.LogFatal(errMsg, err)
}
// errors are non fatal below
errs := E.NewBuilder(errMsg)
errs.Add(entrypoint.SetMiddlewares(model.Entrypoint.Middlewares))
errs.Add(entrypoint.SetAccessLogger(cfg.task, model.Entrypoint.AccessLog))
errs.Add(cfg.initNotification(model.Providers.Notification))
errs.Add(cfg.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))
@@ -176,32 +236,27 @@ func (cfg *Config) load() E.Error {
model.MatchDomains[i] = "." + domain
}
}
entrypoint.SetFindRouteDomains(model.MatchDomains)
cfg.entrypoint.SetFindRouteDomains(model.MatchDomains)
return errs.Error()
}
func (cfg *Config) initNotification(notifCfg []types.NotificationConfig) (err E.Error) {
func (cfg *Config) initNotification(notifCfg []notif.NotificationConfig) {
if len(notifCfg) == 0 {
return
}
dispatcher := notif.StartNotifDispatcher(cfg.task)
errs := E.NewBuilder("notification providers load errors")
for i, notifier := range notifCfg {
_, err := dispatcher.RegisterProvider(notifier)
if err == nil {
continue
}
errs.Add(err.Subjectf("[%d]", i))
for _, notifier := range notifCfg {
dispatcher.RegisterProvider(&notifier)
}
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
}
@@ -216,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 {
@@ -227,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()
}

View File

@@ -1,138 +1,53 @@
package config
import (
"strings"
"github.com/yusing/go-proxy/internal/homepage"
route "github.com/yusing/go-proxy/internal/route"
"github.com/yusing/go-proxy/internal/route/entry"
proxy "github.com/yusing/go-proxy/internal/route/provider"
"github.com/yusing/go-proxy/internal/route/routes"
"github.com/yusing/go-proxy/internal/route/types"
"github.com/yusing/go-proxy/internal/utils/strutils"
"github.com/yusing/go-proxy/internal/route"
"github.com/yusing/go-proxy/internal/route/provider"
)
func DumpEntries() map[string]*types.RawEntry {
entries := make(map[string]*types.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 {
hpCfg := homepage.NewHomePageConfig()
routes.GetHTTPRoutes().RangeAll(func(alias string, r types.HTTPRoute) {
en := r.RawEntry()
item := en.Homepage
if item == nil {
item = new(homepage.Item)
item.Show = true
}
if !item.IsEmpty() {
item.Show = true
}
if !item.Show {
return
}
item.Alias = alias
if item.Name == "" {
item.Name = strutils.Title(
strings.ReplaceAll(
strings.ReplaceAll(alias, "-", " "),
"_", " ",
),
)
}
if instance.value.Homepage.UseDefaultCategories {
if en.Container != nil && item.Category == "" {
if category, ok := homepage.PredefinedCategories[en.Container.ImageName]; ok {
item.Category = category
}
}
if item.Category == "" {
if category, ok := homepage.PredefinedCategories[strings.ToLower(alias)]; ok {
item.Category = category
}
}
}
switch {
case entry.IsDocker(r):
if item.Category == "" {
item.Category = "Docker"
}
item.SourceType = string(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)
}
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 {
rts := make(map[string]any)
if len(typeFilter) == 0 || typeFilter[0] == "" {
typeFilter = []route.RouteType{route.RouteTypeReverseProxy, route.RouteTypeStream}
}
for _, t := range typeFilter {
switch t {
case route.RouteTypeReverseProxy:
routes.GetHTTPRoutes().RangeAll(func(alias string, r types.HTTPRoute) {
rts[alias] = r
})
case route.RouteTypeStream:
routes.GetStreamRoutes().RangeAll(func(alias string, r types.StreamRoute) {
rts[alias] = r
})
}
}
return rts
}
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) {
cfg.providers.RangeAll(func(_ string, p *provider.Provider) {
stats := p.Statistics()
providerStats[name] = stats
nTotalRPs += stats.NumRPs
nTotalStreams += stats.NumStreams
providerStats[p.ShortName()] = stats
rps.AddOther(stats.RPs)
streams.AddOther(stats.Streams)
total += stats.RPs.Total + stats.Streams.Total
})
return map[string]any{
"num_total_streams": nTotalStreams,
"num_total_reverse_proxies": nTotalRPs,
"providers": providerStats,
"total": total,
"reverse_proxies": rps,
"streams": streams,
"providers": providerStats,
}
}

View File

@@ -1,14 +0,0 @@
package types
type (
AutoCertConfig struct {
Email string `json:"email,omitempty" validate:"email"`
Domains []string `json:"domains,omitempty"`
CertPath string `json:"cert_path,omitempty" validate:"omitempty,filepath"`
KeyPath string `json:"key_path,omitempty" validate:"omitempty,filepath"`
ACMEKeyPath string `json:"acme_key_path,omitempty" validate:"omitempty,filepath"`
Provider string `json:"provider,omitempty"`
Options AutocertProviderOpt `json:"options,omitempty"`
}
AutocertProviderOpt map[string]any
)

View File

@@ -1,29 +1,44 @@
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 {
AutoCert *AutoCertConfig `json:"autocert" validate:"omitempty"`
Entrypoint Entrypoint `json:"entrypoint"`
Providers Providers `json:"providers"`
MatchDomains []string `json:"match_domains" validate:"dive,fqdn"`
Homepage HomepageConfig `json:"homepage"`
TimeoutShutdown int `json:"timeout_shutdown" validate:"gte=0"`
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" validate:"dive,filepath"`
Docker map[string]string `json:"docker" validate:"dive,unix_addr|url"`
Notification []NotificationConfig `json:"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"`
}
NotificationConfig map[string]any
ConfigInstance interface {
Value() *Config
Reload() E.Error
Statistics() map[string]any
RouteProviderList() []string
Context() context.Context
}
)
func DefaultConfig() *Config {
@@ -35,6 +50,22 @@ func DefaultConfig() *Config {
}
}
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
})
}

View File

@@ -12,7 +12,6 @@ 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 (
@@ -27,7 +26,7 @@ type (
)
var (
clientMap F.Map[string, *SharedClient] = F.NewMapOf[string, *SharedClient]()
clientMap = make(map[string]*SharedClient, 5)
clientMapMu sync.Mutex
clientOptEnvHost = []client.Opt{
@@ -38,11 +37,14 @@ var (
func init() {
task.OnProgramExit("docker_clients_cleanup", func() {
clientMap.RangeAllParallel(func(_ string, c *SharedClient) {
clientMapMu.Lock()
defer clientMapMu.Unlock()
for _, c := range clientMap {
if c.Connected() {
c.Client.Close()
}
})
}
})
}
@@ -71,8 +73,7 @@ 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
}
@@ -119,15 +120,17 @@ func ConnectClient(host string) (*SharedClient, 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()

View File

@@ -6,12 +6,13 @@ 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
@@ -28,24 +29,31 @@ type (
PrivateIP string `json:"private_ip"`
NetworkMode string `json:"network_mode"`
Aliases []string `json:"aliases"`
IsExcluded bool `json:"is_excluded"`
IsExplicit bool `json:"is_explicit"`
IsDatabase bool `json:"is_database"`
IdleTimeout string `json:"idle_timeout,omitempty"`
WakeTimeout string `json:"wake_timeout,omitempty"`
StopMethod string `json:"stop_method,omitempty"`
StopTimeout string `json:"stop_timeout,omitempty"` // stop_method = "stop" only
StopSignal string `json:"stop_signal,omitempty"` // stop_method = "stop" | "kill" only
Running bool `json:"running"`
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
}

View File

@@ -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
}
}

View 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)
})
}
}

View File

@@ -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"`
@@ -58,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,
@@ -104,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
}

View 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)
}
})
}
}

View File

@@ -8,7 +8,7 @@ import (
"github.com/yusing/go-proxy/internal/docker/idlewatcher/types"
E "github.com/yusing/go-proxy/internal/error"
"github.com/yusing/go-proxy/internal/metrics"
gphttp "github.com/yusing/go-proxy/internal/net/http"
"github.com/yusing/go-proxy/internal/net/http/reverseproxy"
net "github.com/yusing/go-proxy/internal/net/types"
route "github.com/yusing/go-proxy/internal/route/types"
"github.com/yusing/go-proxy/internal/task"
@@ -22,7 +22,7 @@ type (
waker struct {
_ U.NoCopy
rp *gphttp.ReverseProxy
rp *reverseproxy.ReverseProxy
stream net.Stream
hc health.HealthChecker
metric *metrics.Gauge
@@ -38,32 +38,32 @@ const (
// TODO: support stream
func newWaker(parent task.Parent, entry route.Entry, rp *gphttp.ReverseProxy, stream net.Stream) (Waker, E.Error) {
hcCfg := entry.RawEntry().HealthCheck
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,
}
task := parent.Subtask("idlewatcher." + entry.TargetName())
watcher, err := registerWatcher(task, 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 = monitor.NewHTTPHealthChecker(entry.TargetURL(), hcCfg)
waker.hc = monitor.NewHTTPHealthChecker(route.TargetURL(), hcCfg)
case stream != nil:
waker.hc = monitor.NewRawHealthChecker(entry.TargetURL(), hcCfg)
waker.hc = monitor.NewRawHealthChecker(route.TargetURL(), hcCfg)
default:
panic("both nil")
}
if common.PrometheusEnabled {
m := metrics.GetServiceMetrics()
fqn := parent.Name() + "/" + entry.TargetName()
fqn := parent.Name() + "/" + route.TargetName()
waker.metric = m.HealthStatus.With(metrics.HealthMetricLabels(fqn))
waker.metric.Set(float64(watcher.Status()))
}
@@ -71,12 +71,12 @@ func newWaker(parent task.Parent, entry route.Entry, rp *gphttp.ReverseProxy, st
}
// lifetime should follow route provider.
func NewHTTPWaker(parent task.Parent, entry route.Entry, rp *gphttp.ReverseProxy) (Waker, E.Error) {
return newWaker(parent, entry, rp, nil)
func NewHTTPWaker(parent task.Parent, route route.Route, rp *reverseproxy.ReverseProxy) (Waker, E.Error) {
return newWaker(parent, route, rp, nil)
}
func NewStreamWaker(parent task.Parent, entry route.Entry, stream net.Stream) (Waker, E.Error) {
return newWaker(parent, 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.
@@ -117,6 +117,11 @@ 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()
@@ -150,7 +155,7 @@ func (w *Watcher) getStatusUpdateReady() 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()
}

View File

@@ -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")

View File

@@ -32,6 +32,7 @@ type (
client *D.SharedClient
stopByMethod StopCallback // send a docker command w.r.t. `stop_method`
ticker *time.Ticker
lastReset time.Time
task *task.Task
}
@@ -45,14 +46,12 @@ var (
watcherMapMu sync.Mutex
errShouldNotReachHere = errors.New("should not reach here")
logger = logging.With().Str("module", "idle_watcher").Logger()
)
const dockerReqTimeout = 3 * time.Second
func registerWatcher(watcherTask *task.Task, entry route.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(errShouldNotReachHere)
@@ -77,7 +76,7 @@ func registerWatcher(watcherTask *task.Task, entry route.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,
@@ -181,7 +180,7 @@ func (w *Watcher) wakeIfStopped() error {
case "running":
return nil
default:
panic(errShouldNotReachHere)
return E.Errorf("unexpected container status: %s", status)
}
}
@@ -207,6 +206,11 @@ 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) expires() time.Time {
return w.lastReset.Add(w.IdleTimeout)
}
func (w *Watcher) getEventCh(dockerWatcher watcher.DockerWatcher) (eventCh <-chan events.Event, errCh <-chan E.Error) {
@@ -288,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")

View File

@@ -0,0 +1,18 @@
package docker_test
import (
"testing"
"github.com/yusing/go-proxy/internal/docker"
)
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",
})
}
}

View File

@@ -5,11 +5,12 @@ const (
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"
)

View File

@@ -1,7 +0,0 @@
package docker
import (
"github.com/yusing/go-proxy/internal/logging"
)
var logger = logging.With().Str("module", "docker").Logger()

View File

@@ -5,8 +5,8 @@ import (
"fmt"
"net/http"
"strings"
"sync"
"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"
@@ -17,32 +17,31 @@ import (
"github.com/yusing/go-proxy/internal/utils/strutils"
)
var findRouteFunc = findRouteAnyDomain
var (
epMiddleware *middleware.Middleware
epMiddlewareMu sync.Mutex
epAccessLogger *accesslog.AccessLogger
epAccessLoggerMu sync.Mutex
)
type Entrypoint struct {
middleware *middleware.Middleware
accessLogger *accesslog.AccessLogger
findRouteFunc func(host string) (route.HTTPRoute, error)
}
var ErrNoSuchRoute = errors.New("no such route")
func SetFindRouteDomains(domains []string) {
if len(domains) == 0 {
findRouteFunc = findRouteAnyDomain
} else {
findRouteFunc = findRouteByDomains(domains)
func NewEntrypoint() *Entrypoint {
return &Entrypoint{
findRouteFunc: findRouteAnyDomain,
}
}
func SetMiddlewares(mws []map[string]any) error {
epMiddlewareMu.Lock()
defer epMiddlewareMu.Unlock()
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 {
epMiddleware = nil
ep.middleware = nil
return nil
}
@@ -50,51 +49,38 @@ func SetMiddlewares(mws []map[string]any) error {
if err != nil {
return err
}
epMiddleware = mid
ep.middleware = mid
logger.Debug().Msg("entrypoint middleware loaded")
logging.Debug().Msg("entrypoint middleware loaded")
return nil
}
func SetAccessLogger(parent task.Parent, cfg *accesslog.Config) (err error) {
epAccessLoggerMu.Lock()
defer epAccessLoggerMu.Unlock()
func (ep *Entrypoint) SetAccessLogger(parent task.Parent, cfg *accesslog.Config) (err error) {
if cfg == nil {
epAccessLogger = nil
ep.accessLogger = nil
return
}
epAccessLogger, err = accesslog.NewFileAccessLogger(parent, cfg)
ep.accessLogger, err = accesslog.NewFileAccessLogger(parent, cfg)
if err != nil {
return
}
logger.Debug().Msg("entrypoint access logger created")
logging.Debug().Msg("entrypoint access logger created")
return
}
func Handler(w http.ResponseWriter, r *http.Request) {
mux, err := findRouteFunc(r.Host)
func (ep *Entrypoint) ServeHTTP(w http.ResponseWriter, r *http.Request) {
mux, err := ep.findRouteFunc(r.Host)
if err == nil {
if epAccessLogger != nil {
epMiddlewareMu.Lock()
if epAccessLogger != nil {
w = gphttp.NewModifyResponseWriter(w, r, func(resp *http.Response) error {
epAccessLogger.Log(r, resp)
return nil
})
}
epMiddlewareMu.Unlock()
if ep.accessLogger != nil {
w = gphttp.NewModifyResponseWriter(w, r, func(resp *http.Response) error {
ep.accessLogger.Log(r, resp)
return nil
})
}
if epMiddleware != nil {
epMiddlewareMu.Lock()
if epMiddleware != nil {
mid := epMiddleware
epMiddlewareMu.Unlock()
mid.ServeHTTP(mux.ServeHTTP, w, r)
return
}
epMiddlewareMu.Unlock()
if ep.middleware != nil {
ep.middleware.ServeHTTP(mux.ServeHTTP, w, r)
return
}
mux.ServeHTTP(w, r)
return
@@ -104,13 +90,17 @@ func Handler(w http.ResponseWriter, r *http.Request) {
// Then scraper / scanners will know the subdomain is invalid.
// With StatusNotFound, they won't know whether it's the path, or the subdomain that is invalid.
if served := middleware.ServeStaticErrorPageFile(w, r); !served {
logger.Err(err).Str("method", r.Method).Str("url", r.URL.String()).Msg("request")
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 {
logger.Err(err).Msg("failed to write error page")
logging.Err(err).Msg("failed to write error page")
}
} else {
http.Error(w, err.Error(), http.StatusNotFound)

View File

@@ -8,18 +8,19 @@ import (
. "github.com/yusing/go-proxy/internal/utils/testing"
)
var r route.HTTPRoute
var (
r route.ReveseProxyRoute
ep = NewEntrypoint()
)
func run(t *testing.T, match []string, noMatch []string) {
t.Helper()
t.Cleanup(routes.TestClear)
t.Cleanup(func() {
SetFindRouteDomains(nil)
})
t.Cleanup(func() { ep.SetFindRouteDomains(nil) })
for _, test := range match {
t.Run(test, func(t *testing.T) {
found, err := findRouteFunc(test)
found, err := ep.findRouteFunc(test)
ExpectNoError(t, err)
ExpectTrue(t, found == &r)
})
@@ -27,7 +28,7 @@ func run(t *testing.T, match []string, noMatch []string) {
for _, test := range noMatch {
t.Run(test, func(t *testing.T) {
_, err := findRouteFunc(test)
_, err := ep.findRouteFunc(test)
ExpectError(t, ErrNoSuchRoute, err)
})
}
@@ -72,7 +73,7 @@ func TestFindRouteExactHostMatch(t *testing.T) {
}
func TestFindRouteByDomains(t *testing.T) {
SetFindRouteDomains([]string{
ep.SetFindRouteDomains([]string{
".domain.com",
".sub.domain.com",
})
@@ -97,7 +98,7 @@ func TestFindRouteByDomains(t *testing.T) {
}
func TestFindRouteByDomainsExactMatch(t *testing.T) {
SetFindRouteDomains([]string{
ep.SetFindRouteDomains([]string{
".domain.com",
".sub.domain.com",
})

View File

@@ -1,7 +0,0 @@
package entrypoint
import (
"github.com/yusing/go-proxy/internal/logging"
)
var logger = logging.With().Str("module", "entrypoint").Logger()

View File

@@ -1,6 +1,7 @@
package err
import (
"encoding/json"
"errors"
"fmt"
)
@@ -46,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())
}
}

View File

@@ -75,9 +75,9 @@ func (err *nestedError) Error() string {
lines := make([]string, 0, 1+len(err.Extras))
if err.Err != nil {
lines = append(lines, makeLine(err.Err.Error(), 0))
}
if extras := makeLines(err.Extras, 1); len(extras) > 0 {
lines = append(lines, extras...)
lines = append(lines, makeLines(err.Extras, 1)...)
} else {
lines = append(lines, makeLines(err.Extras, 0)...)
}
return strutils.JoinLines(lines)
}
@@ -104,9 +104,7 @@ func makeLines(errs []error, level int) []string {
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))
}

View File

@@ -1,6 +1,7 @@
package err
import (
"encoding/json"
"strings"
"github.com/yusing/go-proxy/internal/utils/strutils/ansi"
@@ -8,8 +9,10 @@ import (
//nolint:errname
type withSubject struct {
Subject string `json:"subject"`
Err error `json:"err"`
Subjects []string
Err error
pendingSubject string
}
const subjectSep = " > "
@@ -30,13 +33,27 @@ func PrependSubject(subject string, err error) error {
case Error:
return err.Subject(subject)
}
return &withSubject{subject, err}
return &withSubject{[]string{subject}, err, ""}
}
func (err *withSubject) Prepend(subject string) *withSubject {
if subject == "" {
return err
}
clone := *err
if subject != "" {
clone.Subject = subject + subjectSep + clone.Subject
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
}
@@ -50,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)
}

View File

@@ -19,6 +19,13 @@ 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
@@ -33,13 +40,6 @@ func From(err error) Error {
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 {

View File

@@ -33,7 +33,6 @@ var PredefinedCategories = map[string]string{
"changedetection": "Monitoring",
"influxdb": "Monitoring",
"influx": "Monitoring",
"dozzle": "Monitoring",
"adguardhome": "Networking",
"adgh": "Networking",
@@ -47,6 +46,8 @@ var PredefinedCategories = map[string]string{
"dockge": "Container Management",
"portainer-ce": "Container Management",
"portainer-be": "Container Management",
"logs": "Container Management",
"dozzle": "Container Management",
"rss": "RSS",
"rsshub": "RSS",
@@ -57,6 +58,7 @@ var PredefinedCategories = map[string]string{
"paperless": "Documents",
"paperless-ngx": "Documents",
"s-pdf": "Documents",
"stirling-pdf": "Documents",
"minio": "Storage",
"filebrowser": "Storage",

View File

@@ -1,43 +1,72 @@
package homepage
import (
"github.com/yusing/go-proxy/internal/utils"
)
type (
//nolint:recvcheck
Config map[string]Category
Category []*Item
Categories map[string]Category
Category []*Item
Item struct {
ItemConfig struct {
Show bool `json:"show"`
Name string `json:"name"` // display name
Icon string `json:"icon"`
URL string `json:"url"` // alias + domain
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 {
*ItemConfig
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)
}

View 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)
}

View 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))
}

View 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)
}
})
}
}

View 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
}
}

View File

@@ -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
View 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, "&middot;"...)
case '&':
*buf = append(*buf, "&amp;"...)
case '<':
*buf = append(*buf, "&lt;"...)
case '>':
*buf = append(*buf, "&gt;"...)
case '\t':
*buf = append(*buf, "&#9;"...)
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
}

View 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)
}
}

View File

@@ -2,7 +2,7 @@
package logging
import (
"os"
"io"
"strings"
"github.com/rs/zerolog"
@@ -10,13 +10,15 @@ import (
"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"
@@ -27,29 +29,38 @@ 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("&nbsp;", 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 := 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')
},
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()
}

View File

@@ -19,8 +19,8 @@ type (
io AccessLogIO
buf bytes.Buffer // buffer for non-flushed log
bufMu sync.Mutex // protect buf
bufPool sync.Pool // buffer pool for formatting a single log line
bufMu sync.RWMutex
bufPool sync.Pool // buffer pool for formatting a single log line
flushThreshold int
@@ -43,8 +43,6 @@ type (
}
)
var logger = logging.With().Str("module", "accesslog").Logger()
func NewAccessLogger(parent task.Parent, io AccessLogIO, cfg *Config) *AccessLogger {
l := &AccessLogger{
task: parent.Subtask("accesslog"),
@@ -125,16 +123,15 @@ func (l *AccessLogger) Flush(force bool) {
return
}
if force || l.buf.Len() >= l.flushThreshold {
l.bufMu.Lock()
l.bufMu.RLock()
l.write(l.buf.Bytes())
l.buf.Reset()
l.bufMu.Unlock()
logger.Debug().Msg("access log flushed to " + l.io.Name())
l.bufMu.RUnlock()
}
}
func (l *AccessLogger) handleErr(err error) {
E.LogError("failed to write access log", err, &logger)
E.LogError("failed to write access log", err)
}
func (l *AccessLogger) start() {
@@ -170,5 +167,7 @@ func (l *AccessLogger) write(data []byte) {
l.io.Unlock()
if err != nil {
l.handleErr(err)
} else {
logging.Debug().Msg("access log flushed to " + l.io.Name())
}
}

View File

@@ -9,7 +9,6 @@ import (
"testing"
"time"
E "github.com/yusing/go-proxy/internal/error"
. "github.com/yusing/go-proxy/internal/net/http/accesslog"
"github.com/yusing/go-proxy/internal/task"
. "github.com/yusing/go-proxy/internal/utils/testing"
@@ -30,7 +29,7 @@ const (
var (
testTask = task.RootTask("test", false)
testURL = E.Must(url.Parse("http://" + host + uri))
testURL = Must(url.Parse("http://" + host + uri))
req = &http.Request{
RemoteAddr: remote,
Method: method,

View File

@@ -3,36 +3,67 @@ package accesslog
import (
"fmt"
"os"
"path"
"sync"
"github.com/yusing/go-proxy/internal/logging"
"github.com/yusing/go-proxy/internal/task"
"github.com/yusing/go-proxy/internal/utils"
)
type File struct {
*os.File
sync.Mutex
// os.File.Name() may not equal to key of `openedFiles`.
// Store it for later delete from `openedFiles`.
path string
refCount *utils.RefCount
}
var (
openedFiles = make(map[string]AccessLogIO)
openedFiles = make(map[string]*File)
openedFilesMu sync.Mutex
)
func NewFileAccessLogger(parent task.Parent, cfg *Config) (*AccessLogger, error) {
openedFilesMu.Lock()
var io AccessLogIO
if opened, ok := openedFiles[cfg.Path]; ok {
io = opened
var file *File
path := path.Clean(cfg.Path)
if opened, ok := openedFiles[path]; ok {
opened.refCount.Add()
file = opened
} else {
f, err := os.OpenFile(cfg.Path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
f, err := os.OpenFile(cfg.Path, os.O_APPEND|os.O_CREATE|os.O_RDWR, 0o644)
if err != nil {
openedFilesMu.Unlock()
return nil, fmt.Errorf("access log open error: %w", err)
}
io = &File{File: f}
openedFiles[cfg.Path] = io
file = &File{File: f, path: path, refCount: utils.NewRefCounter()}
openedFiles[path] = file
go file.closeOnZero()
}
openedFilesMu.Unlock()
return NewAccessLogger(parent, io, cfg), nil
return NewAccessLogger(parent, file, cfg), nil
}
func (f *File) Close() error {
f.refCount.Sub()
return nil
}
func (f *File) closeOnZero() {
defer logging.Debug().
Str("path", f.path).
Msg("access log closed")
<-f.refCount.Zero()
openedFilesMu.Lock()
delete(openedFiles, f.path)
openedFilesMu.Unlock()
f.File.Close()
}

View File

@@ -8,6 +8,8 @@ import (
"net/url"
"strconv"
"time"
"github.com/yusing/go-proxy/internal/logging"
)
type (
@@ -137,6 +139,6 @@ func (f *JSONFormatter) Format(line *bytes.Buffer, req *http.Request, res *http.
marshaller := json.NewEncoder(line)
err := marshaller.Encode(entry)
if err != nil {
logger.Err(err).Msg("failed to marshal json log")
logging.Err(err).Msg("failed to marshal json log")
}
}

View File

@@ -20,7 +20,7 @@ var (
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
DisableCompression: true, // Prevent double compression
ResponseHeaderTimeout: 30 * time.Second,
ResponseHeaderTimeout: 60 * time.Second,
WriteBufferSize: 16 * 1024, // 16KB
ReadBufferSize: 16 * 1024, // 16KB
}

View File

@@ -2,6 +2,10 @@ package http
import (
"net/http"
"net/textproto"
"github.com/yusing/go-proxy/internal/utils/strutils"
"golang.org/x/net/http/httpguts"
)
const (
@@ -22,6 +26,48 @@ const (
HeaderContentLength = "Content-Length"
)
// Hop-by-hop headers. These are removed when sent to the backend.
// As of RFC 7230, hop-by-hop headers are required to appear in the
// Connection header field. These are the headers defined by the
// obsoleted RFC 2616 (section 13.5.1) and are used for backward
// compatibility.
var hopHeaders = []string{
"Connection",
"Proxy-Connection", // non-standard but still sent by libcurl and rejected by e.g. google
"Keep-Alive",
"Proxy-Authenticate",
"Proxy-Authorization",
"Te", // canonicalized version of "TE"
"Trailer", // not Trailers per URL above; https://www.rfc-editor.org/errata_search.php?eid=4522
"Transfer-Encoding",
"Upgrade",
}
func UpgradeType(h http.Header) string {
if !httpguts.HeaderValuesContainsToken(h["Connection"], "Upgrade") {
return ""
}
return h.Get("Upgrade")
}
// RemoveHopByHopHeaders removes hop-by-hop headers.
func RemoveHopByHopHeaders(h http.Header) {
// RFC 7230, section 6.1: Remove headers listed in the "Connection" header.
for _, f := range h["Connection"] {
for _, sf := range strutils.SplitComma(f) {
if sf = textproto.TrimString(sf); sf != "" {
h.Del(sf)
}
}
}
// RFC 2616, section 13.5.1: Remove a set of known hop-by-hop headers.
// This behavior is superseded by the RFC 7230 Connection header, but
// preserve it for backwards compatibility.
for _, f := range hopHeaders {
h.Del(f)
}
}
func RemoveHop(h http.Header) {
reqUpType := UpgradeType(h)
RemoveHopByHopHeaders(h)

View File

@@ -31,7 +31,7 @@ func (lb *LoadBalancer) newIPHash() impl {
return impl
}
func (impl *ipHash) OnAddServer(srv *Server) {
func (impl *ipHash) OnAddServer(srv Server) {
impl.mu.Lock()
defer impl.mu.Unlock()
@@ -48,7 +48,7 @@ func (impl *ipHash) OnAddServer(srv *Server) {
impl.pool = append(impl.pool, srv)
}
func (impl *ipHash) OnRemoveServer(srv *Server) {
func (impl *ipHash) OnRemoveServer(srv Server) {
impl.mu.Lock()
defer impl.mu.Unlock()

View File

@@ -9,21 +9,21 @@ import (
type leastConn struct {
*LoadBalancer
nConn F.Map[*Server, *atomic.Int64]
nConn F.Map[Server, *atomic.Int64]
}
func (lb *LoadBalancer) newLeastConn() impl {
return &leastConn{
LoadBalancer: lb,
nConn: F.NewMapOf[*Server, *atomic.Int64](),
nConn: F.NewMapOf[Server, *atomic.Int64](),
}
}
func (impl *leastConn) OnAddServer(srv *Server) {
func (impl *leastConn) OnAddServer(srv Server) {
impl.nConn.Store(srv, new(atomic.Int64))
}
func (impl *leastConn) OnRemoveServer(srv *Server) {
func (impl *leastConn) OnRemoveServer(srv Server) {
impl.nConn.Delete(srv)
}
@@ -31,14 +31,14 @@ func (impl *leastConn) ServeHTTP(srvs Servers, rw http.ResponseWriter, r *http.R
srv := srvs[0]
minConn, ok := impl.nConn.Load(srv)
if !ok {
impl.l.Error().Msgf("[BUG] server %s not found", srv.Name)
impl.l.Error().Msgf("[BUG] server %s not found", srv.Name())
http.Error(rw, "Internal error", http.StatusInternalServerError)
}
for i := 1; i < len(srvs); i++ {
nConn, ok := impl.nConn.Load(srvs[i])
if !ok {
impl.l.Error().Msgf("[BUG] server %s not found", srv.Name)
impl.l.Error().Msgf("[BUG] server %s not found", srv.Name())
http.Error(rw, "Internal error", http.StatusInternalServerError)
}
if nConn.Load() < minConn.Load() {

View File

@@ -8,6 +8,7 @@ import (
"github.com/rs/zerolog"
"github.com/yusing/go-proxy/internal/common"
E "github.com/yusing/go-proxy/internal/error"
"github.com/yusing/go-proxy/internal/logging"
"github.com/yusing/go-proxy/internal/net/http/loadbalancer/types"
"github.com/yusing/go-proxy/internal/route/routes"
"github.com/yusing/go-proxy/internal/task"
@@ -20,8 +21,8 @@ import (
type (
impl interface {
ServeHTTP(srvs Servers, rw http.ResponseWriter, r *http.Request)
OnAddServer(srv *Server)
OnRemoveServer(srv *Server)
OnAddServer(srv Server)
OnRemoveServer(srv Server)
}
LoadBalancer struct {
@@ -46,7 +47,7 @@ func New(cfg *Config) *LoadBalancer {
lb := &LoadBalancer{
Config: new(Config),
pool: types.NewServerPool(),
l: logger.With().Str("name", cfg.Link).Logger(),
l: logging.With().Str("name", cfg.Link).Logger(),
}
lb.UpdateConfigIfNeeded(cfg)
return lb
@@ -61,7 +62,7 @@ func (lb *LoadBalancer) Start(parent task.Parent) E.Error {
})
lb.task.OnFinished("cleanup", func() {
if lb.impl != nil {
lb.pool.RangeAll(func(k string, v *Server) {
lb.pool.RangeAll(func(k string, v Server) {
lb.impl.OnRemoveServer(v)
})
}
@@ -90,7 +91,7 @@ func (lb *LoadBalancer) updateImpl() {
default: // should happen in test only
lb.impl = lb.newRoundRobin()
}
lb.pool.RangeAll(func(_ string, srv *Server) {
lb.pool.RangeAll(func(_ string, srv Server) {
lb.impl.OnAddServer(srv)
})
}
@@ -120,44 +121,44 @@ func (lb *LoadBalancer) UpdateConfigIfNeeded(cfg *Config) {
}
}
func (lb *LoadBalancer) AddServer(srv *Server) {
func (lb *LoadBalancer) AddServer(srv Server) {
lb.poolMu.Lock()
defer lb.poolMu.Unlock()
if lb.pool.Has(srv.Name) {
old, _ := lb.pool.Load(srv.Name)
lb.sumWeight -= old.Weight
if lb.pool.Has(srv.Name()) {
old, _ := lb.pool.Load(srv.Name())
lb.sumWeight -= old.Weight()
lb.impl.OnRemoveServer(old)
}
lb.pool.Store(srv.Name, srv)
lb.sumWeight += srv.Weight
lb.pool.Store(srv.Name(), srv)
lb.sumWeight += srv.Weight()
lb.rebalance()
lb.impl.OnAddServer(srv)
lb.l.Debug().
Str("action", "add").
Str("server", srv.Name).
Str("server", srv.Name()).
Msgf("%d servers available", lb.pool.Size())
}
func (lb *LoadBalancer) RemoveServer(srv *Server) {
func (lb *LoadBalancer) RemoveServer(srv Server) {
lb.poolMu.Lock()
defer lb.poolMu.Unlock()
if !lb.pool.Has(srv.Name) {
if !lb.pool.Has(srv.Name()) {
return
}
lb.pool.Delete(srv.Name)
lb.pool.Delete(srv.Name())
lb.sumWeight -= srv.Weight
lb.sumWeight -= srv.Weight()
lb.rebalance()
lb.impl.OnRemoveServer(srv)
lb.l.Debug().
Str("action", "remove").
Str("server", srv.Name).
Str("server", srv.Name()).
Msgf("%d servers left", lb.pool.Size())
if lb.pool.Size() == 0 {
@@ -178,13 +179,14 @@ func (lb *LoadBalancer) rebalance() {
if lb.sumWeight == 0 { // distribute evenly
weightEach := maxWeight / Weight(poolSize)
remainder := maxWeight % Weight(poolSize)
lb.pool.RangeAll(func(_ string, s *Server) {
s.Weight = weightEach
lb.pool.RangeAll(func(_ string, s Server) {
w := weightEach
lb.sumWeight += weightEach
if remainder > 0 {
s.Weight++
w++
remainder--
}
s.SetWeight(w)
})
return
}
@@ -193,25 +195,25 @@ func (lb *LoadBalancer) rebalance() {
scaleFactor := float64(maxWeight) / float64(lb.sumWeight)
lb.sumWeight = 0
lb.pool.RangeAll(func(_ string, s *Server) {
s.Weight = Weight(float64(s.Weight) * scaleFactor)
lb.sumWeight += s.Weight
lb.pool.RangeAll(func(_ string, s Server) {
s.SetWeight(Weight(float64(s.Weight()) * scaleFactor))
lb.sumWeight += s.Weight()
})
delta := maxWeight - lb.sumWeight
if delta == 0 {
return
}
lb.pool.Range(func(_ string, s *Server) bool {
lb.pool.Range(func(_ string, s Server) bool {
if delta == 0 {
return false
}
if delta > 0 {
s.Weight++
s.SetWeight(s.Weight() + 1)
lb.sumWeight++
delta--
} else {
s.Weight--
s.SetWeight(s.Weight() - 1)
lb.sumWeight--
delta++
}
@@ -229,22 +231,20 @@ func (lb *LoadBalancer) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
// wake all servers
for _, srv := range srvs {
if err := srv.TryWake(); err != nil {
lb.l.Warn().Err(err).Str("server", srv.Name).Msg("failed to wake server")
lb.l.Warn().Err(err).
Str("server", srv.Name()).
Msg("failed to wake server")
}
}
}
lb.impl.ServeHTTP(srvs, rw, r)
}
func (lb *LoadBalancer) Uptime() time.Duration {
return time.Since(lb.startTime)
}
// MarshalJSON implements health.HealthMonitor.
func (lb *LoadBalancer) MarshalJSON() ([]byte, error) {
extra := make(map[string]any)
lb.pool.RangeAll(func(k string, v *Server) {
extra[v.Name] = v.HealthMonitor()
lb.pool.RangeAll(func(k string, v Server) {
extra[v.Name()] = v
})
return (&monitor.JSONRepresentation{
@@ -269,20 +269,43 @@ func (lb *LoadBalancer) Status() health.Status {
if lb.pool.Size() == 0 {
return health.StatusUnknown
}
if len(lb.availServers()) == 0 {
isHealthy := true
lb.pool.Range(func(_ string, srv Server) bool {
if srv.Status().Bad() {
isHealthy = false
return false
}
return true
})
if !isHealthy {
return health.StatusUnhealthy
}
return health.StatusHealthy
}
// Uptime implements health.HealthMonitor.
func (lb *LoadBalancer) Uptime() time.Duration {
return time.Since(lb.startTime)
}
// Latency implements health.HealthMonitor.
func (lb *LoadBalancer) Latency() time.Duration {
var sum time.Duration
lb.pool.RangeAll(func(_ string, srv Server) {
sum += srv.Latency()
})
return sum
}
// String implements health.HealthMonitor.
func (lb *LoadBalancer) String() string {
return lb.Name()
}
func (lb *LoadBalancer) availServers() []*Server {
avail := make([]*Server, 0, lb.pool.Size())
lb.pool.RangeAll(func(_ string, srv *Server) {
func (lb *LoadBalancer) availServers() []Server {
avail := make([]Server, 0, lb.pool.Size())
lb.pool.RangeAll(func(_ string, srv Server) {
if srv.Status().Good() {
avail = append(avail, srv)
}

View File

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

View File

@@ -1,5 +0,0 @@
package loadbalancer
import "github.com/yusing/go-proxy/internal/logging"
var logger = logging.With().Str("module", "load_balancer").Logger()

View File

@@ -9,9 +9,9 @@ type roundRobin struct {
index atomic.Uint32
}
func (*LoadBalancer) newRoundRobin() impl { return &roundRobin{} }
func (lb *roundRobin) OnAddServer(srv *Server) {}
func (lb *roundRobin) OnRemoveServer(srv *Server) {}
func (*LoadBalancer) newRoundRobin() impl { return &roundRobin{} }
func (lb *roundRobin) OnAddServer(srv Server) {}
func (lb *roundRobin) OnRemoveServer(srv Server) {}
func (lb *roundRobin) ServeHTTP(srvs Servers, rw http.ResponseWriter, r *http.Request) {
index := lb.index.Add(1) % uint32(len(srvs))

View File

@@ -6,7 +6,7 @@ import (
type (
Server = types.Server
Servers = types.Servers
Servers = []types.Server
Pool = types.Pool
Weight = types.Weight
Config = types.Config

View File

@@ -2,61 +2,81 @@ package types
import (
"net/http"
"time"
idlewatcher "github.com/yusing/go-proxy/internal/docker/idlewatcher/types"
"github.com/yusing/go-proxy/internal/net/types"
net "github.com/yusing/go-proxy/internal/net/types"
U "github.com/yusing/go-proxy/internal/utils"
F "github.com/yusing/go-proxy/internal/utils/functional"
"github.com/yusing/go-proxy/internal/watcher/health"
)
type (
Server struct {
server struct {
_ U.NoCopy
Name string
URL types.URL
Weight Weight
name string
url *net.URL
weight Weight
handler http.Handler
healthMon health.HealthMonitor
http.Handler `json:"-"`
health.HealthMonitor
}
Servers = []*Server
Pool = F.Map[string, *Server]
Server interface {
http.Handler
health.HealthMonitor
Name() string
URL() *net.URL
Weight() Weight
SetWeight(weight Weight)
TryWake() error
}
Pool = F.Map[string, Server]
)
var NewServerPool = F.NewMap[Pool]
func NewServer(name string, url types.URL, weight Weight, handler http.Handler, healthMon health.HealthMonitor) *Server {
srv := &Server{
Name: name,
URL: url,
Weight: weight,
handler: handler,
healthMon: healthMon,
func NewServer(name string, url *net.URL, weight Weight, handler http.Handler, healthMon health.HealthMonitor) Server {
srv := &server{
name: name,
url: url,
weight: weight,
Handler: handler,
HealthMonitor: healthMon,
}
return srv
}
func (srv *Server) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
srv.handler.ServeHTTP(rw, r)
func TestNewServer[T ~int | ~float32 | ~float64](weight T) Server {
srv := &server{
weight: Weight(weight),
}
return srv
}
func (srv *Server) String() string {
return srv.Name
func (srv *server) Name() string {
return srv.name
}
func (srv *Server) Status() health.Status {
return srv.healthMon.Status()
func (srv *server) URL() *net.URL {
return srv.url
}
func (srv *Server) Uptime() time.Duration {
return srv.healthMon.Uptime()
func (srv *server) Weight() Weight {
return srv.weight
}
func (srv *Server) TryWake() error {
waker, ok := srv.handler.(idlewatcher.Waker)
func (srv *server) SetWeight(weight Weight) {
srv.weight = weight
}
func (srv *server) String() string {
return srv.name
}
func (srv *server) TryWake() error {
waker, ok := srv.Handler.(idlewatcher.Waker)
if ok {
if err := waker.Wake(); err != nil {
return err
@@ -64,7 +84,3 @@ func (srv *Server) TryWake() error {
}
return nil
}
func (srv *Server) HealthMonitor() health.HealthMonitor {
return srv.healthMon
}

View File

@@ -1,5 +0,0 @@
package http
import "github.com/yusing/go-proxy/internal/logging"
var logger = logging.With().Str("module", "http").Logger()

View File

@@ -0,0 +1,20 @@
package http
import "net/http"
func IsMethodValid(method string) bool {
switch method {
case http.MethodGet,
http.MethodHead,
http.MethodPost,
http.MethodPut,
http.MethodPatch,
http.MethodDelete,
http.MethodConnect,
http.MethodOptions,
http.MethodTrace:
return true
default:
return false
}
}

View File

@@ -4,7 +4,10 @@ import (
"net"
"net/http"
"github.com/go-playground/validator/v10"
gphttp "github.com/yusing/go-proxy/internal/net/http"
"github.com/yusing/go-proxy/internal/net/types"
"github.com/yusing/go-proxy/internal/utils"
F "github.com/yusing/go-proxy/internal/utils/functional"
)
@@ -16,7 +19,7 @@ type (
}
CIDRWhitelistOpts struct {
Allow []*types.CIDR `validate:"min=1"`
StatusCode int `json:"status_code" aliases:"status" validate:"omitempty,gte=400,lte=599"`
StatusCode int `json:"status_code" aliases:"status" validate:"omitempty,status_code"`
Message string
}
)
@@ -30,6 +33,13 @@ var (
}
)
func init() {
utils.MustRegisterValidation("status_code", func(fl validator.FieldLevel) bool {
statusCode := fl.Field().Int()
return gphttp.IsStatusCodeValid(int(statusCode))
})
}
// setup implements MiddlewareWithSetup.
func (wl *cidrWhitelist) setup() {
wl.CIDRWhitelistOpts = cidrWhitelistDefaults

View File

@@ -24,6 +24,18 @@ func TestCIDRWhitelistValidation(t *testing.T) {
"message": testMessage,
})
ExpectNoError(t, err)
_, err = CIDRWhiteList.New(OptionsRaw{
"allow": []string{"192.168.2.100/32"},
"message": testMessage,
"status": 403,
})
ExpectNoError(t, err)
_, err = CIDRWhiteList.New(OptionsRaw{
"allow": []string{"192.168.2.100/32"},
"message": testMessage,
"status_code": 403,
})
ExpectNoError(t, err)
})
t.Run("missing allow", func(t *testing.T) {
_, err := CIDRWhiteList.New(OptionsRaw{

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