Compare commits

...

66 Commits

Author SHA1 Message Date
yusing
1b9cfa6540 fix(autocert): forceRenewalDoneCh was never closed 2026-01-04 20:40:38 +08:00
yusing
f1d906ac11 fix(test): update test expectations 2026-01-04 20:31:11 +08:00
yusing
2835fd5fb0 fix(autocert): ensure extra certificate registration and renewal scheduling
Extra providers were not being properly initialized during NewProvider(),
causing certificate registration and renewal scheduling to be skipped.

- Add ConfigExtra type with idx field for provider indexing
- Add MergeExtraConfig() for inheriting main provider settings
- Add setupExtraProviders() for recursive extra provider initialization
- Refactor NewProvider to return error and call setupExtraProviders()
- Add provider-scoped logger with "main" or "extra[N]" name
- Add batch operations: ObtainCertIfNotExistsAll(), ObtainCertAll()
- Add ForceExpiryAll() with completion tracking via WaitRenewalDone()
- Add RenewMode (force/ifNeeded) for controlling renewal behavior
- Add PrintCertExpiriesAll() for logging all provider certificate expiries

Summary of staged changes:
- config.go: Added ConfigExtra type, MergeExtraConfig(), recursive validation with path uniqueness checking
- provider.go: Added provider indexing, scoped logger, batch cert operations, force renewal with completion tracking, RenewMode control
- setup.go: New file with setupExtraProviders() for proper extra provider initialization
- setup_test.go: New tests for extra provider setup
- multi_cert_test.go: New tests for multi-certificate functionality
- renew.go: Updated to use new provider API with error handling
- state.go: Updated to handle NewProvider error return
2026-01-04 20:30:58 +08:00
yusing
11d0c61b9c refactor(state): replace Entrypoint method with ShortLinkMatcher interface
- Cleaned up agent go.mod by removing unused indirect dependencies.
2026-01-04 12:43:05 +08:00
Yuzerion
c00854a124 feat(autocert): add multi-certificate support (#185)
Multi-certificate, SNI matching with exact map and suffix tree

Add support for multiple TLS certificates with SNI-based selection. The
root provider maintains a single centralized SNI matcher that uses an
exact match map for O(1) lookups, falling back to a suffix tree for
wildcard matching.

Key features:
- Add `Extra []Config` field to autocert.Config for additional certificates
- Each extra entry must specify unique `cert_path` and `key_path`
- Extra certs inherit main config (except `email` and `extra` fields)
- Extra certs participate in ACME obtain/renew cycles independently
- SNI selection precedence: exact match > wildcard match, main > extra
- Single centralized SNI matcher on root provider rebuilt after cert changes

The SNI matcher structure:
- Exact match map: O(1) lookup for exact domain matches
- Suffix tree: Efficient wildcard matching (e.g., *.example.com)

Implementation details:
- Provider.GetCert() now uses SNI from ClientHelloInfo for selection
- Main cert is returned as fallback when no SNI match is found
- Extra providers are created as child providers with merged configs
- SNI matcher is rebuilt after Setup() and after ObtainCert() completes
2026-01-04 00:37:26 +08:00
yusing
117dbb62f4 refactor(docker): accept unix and ssh scheme for providers 2026-01-03 20:06:31 +08:00
yusing
2c28bc116c fix(h2c_test_server): correct listening on message 2026-01-03 12:58:14 +08:00
yusing
1d90bec9ed refactor(benchmark): restart bench server after each run 2026-01-03 12:54:18 +08:00
yusing
b2df749cd1 refactor(io,reverseproxy): suppress "client disconnected" error; optimize CopyClose method 2026-01-03 12:41:11 +08:00
yusing
1916f73e78 refactor(route): modernize code with unsafe.Add 2026-01-03 12:40:55 +08:00
yusing
99ab9beb4a refactor(http/transport): increase MaxIdleConnsPerHost to 1000 2026-01-03 12:40:28 +08:00
yusing
5de064aa47 refactor(benchmark): replace whoami service with bench server
- Updated dev.compose.yml to define a new bench service that serves 4096 bytes of random data.
- Modified configurations for Traefik, Caddy, and Nginx to route traffic to the new bench service.
- Added Dockerfile and Go application for the bench server, including necessary Go modules.
- Updated benchmark script to target the new bench service endpoint.
2026-01-03 12:40:10 +08:00
yusing
880e11c414 refactor(http/reverseproxy): performance improvement
- Replaced req.Clone with req.WithContext and url/header/trailer cloning.
- Added conditional handling for "Expect" headers to manage 1xx responses with appropriate tracing.
2026-01-03 02:30:15 +08:00
yusing
0dfce823bf refactor(http): performance improvement
- Introduced a sync.Pool for ResponseRecorder to optimize memory usage.
- Updated ServeHTTP method to utilize the new GetResponseRecorder and PutResponseRecorder functions.
- Adjusted NewResponseRecorder to leverage the pooling mechanism.
2026-01-03 02:20:01 +08:00
yusing
c2583fc756 refactor(benchmark): update whoami service configuration to use FQDN alias 2026-01-03 02:10:00 +08:00
yusing
cf6246d58a refactor(benchmark): remove unused Docker socket configuration from benchmark service 2026-01-03 02:04:49 +08:00
yusing
fb040afe90 refactor(benchmark): benchmark script functionality and fairness 2026-01-03 00:57:50 +08:00
yusing
dc8abe943d feat(benchmark): enhance dev.compose.yml with benchmark services and scripts
- Added benchmark services (whoami, godoxy, traefik, caddy, nginx) to dev.compose.yml.
- Introduced a new benchmark.sh script for load testing using wrk and h2load.
- Updated Makefile to include a benchmark target for easy execution of the new script.
2026-01-03 00:28:59 +08:00
yusing
587b83cf14 fix(idlewatcher): pass context to ProxmoxProvider 2026-01-02 22:17:40 +08:00
yusing
a4658caf02 refactor(config): correct logic in InitFromFile 2026-01-02 21:56:34 +08:00
yusing
ef9ee0e169 feat(websocket): update goutils - deduplicate data to avoid unnecessary traffic 2026-01-02 18:04:08 +08:00
yusing
7eadec9752 chore: remove unused utils/deep_equal.go 2026-01-02 18:03:13 +08:00
yusing
dd35a4159f refactor(api/health): simplify health info type
- Updated health-related functions to return simplified health information.
- Introduced HealthStatusString type for correct swagger and schema generation.
- Refactored HealthJSON structure to utilize the new HealthStatusString type.
2026-01-02 18:02:49 +08:00
yusing
f28667e23e refactor: add context handling in various functions
- Modified functions to accept context.Context as a parameter for better context management.
- Updated Init methods in Proxmox and Config to use the provided context.
- Adjusted UpdatePorts and NewProxmoxProvider to utilize the context for operations.
2026-01-02 17:41:36 +08:00
yusing
8009da9e4d chore: go mod tidy 2026-01-02 15:49:03 +08:00
yusing
590743f1ef feat(entrypoint): implement short link #177
- Added ShortLinkMatcher to handle short link routing.
- Integrated short link handling in Entrypoint.
- Introduced tests for short link matching and dispatching.
- Configured default domain suffix for subdomain aliases.
2026-01-02 15:42:15 +08:00
yusing
1f4c30a48e fix(docker): update scheme validation to include 'tcp' in DockerProviderConfigDetailed 2026-01-02 10:55:42 +08:00
yusing
bae7387a5d feat(dev): add jotty and postgres-test services to dev.compose.yml 2026-01-02 01:20:05 +08:00
yusing
67fc48383d refactor(monitor): include detail in service down notification log 2026-01-02 01:17:47 +08:00
yusing
1406881071 feat(http/h2c): h2c test server with a Dockerfile
- Implemented a basic HTTP/2 server that responds with "ok" to requests.
- Updated dev.compose.yml to include a service for it
2026-01-02 01:17:28 +08:00
yusing
7976befda4 feat(http): enable HTTP/2 support in server configuration
- Added NextProtos to TLSConfig to prefer HTTP/2 and fallback to HTTP/1.1.
- Configured the server to handle HTTP/2 connections, with error logging for configuration failures.
2026-01-02 01:11:07 +08:00
yusing
8139311074 feat(healthcheck/http): implement h2c health check support and refactor request handling
- Added support for health checks using the h2c scheme.
- Refactored common header setting into a dedicated function.
- Updated CheckHealth method to differentiate between HTTP and h2c checks.
2026-01-02 00:46:48 +08:00
yusing
2690bf548d chore: update swagger add h2c scheme type 2026-01-01 18:56:11 +08:00
yusing
d3358ebd89 feat(http/reverseproxy): h2c support with scheme: h2c 2026-01-01 18:54:49 +08:00
yusing
fd74bfedf0 fix(agent): improve url handling to not break urls with encoded characters 2026-01-01 18:25:27 +08:00
yusing
a47170da39 feat(metrics): add IsExcluded field to RouteUptimeAggregate for enhanced status tracking
- updated swagger
2026-01-01 13:20:17 +08:00
yusing
89a4ca767d fix(homepage): improve alphabetical sorting by normalizing item names (#181)
- Updated the sorting function to use Title case for item names to ensure consistent alphabetical ordering.
2026-01-01 12:31:36 +08:00
yusing
3dbbde164b fix(route): enhance host parsing with port suffix support
- Added logic to strip the trailing :port from the host when searching for routes.
- Updated findRouteByDomains function to ensure consistent host formatting.
- Added related tests
2025-12-30 22:46:38 +08:00
yusing
e75eede332 chore(goutils): update subproject commit reference to 51a75d68 2025-12-30 22:01:01 +08:00
yusing
e4658a8f09 fix(route): update health monitor initialization to use implementation instance 2025-12-30 21:59:43 +08:00
yusing
e25ccdbd24 chore: upgrade dependencies 2025-12-30 21:56:54 +08:00
yusing
5087800fd7 fix(tests/metrics): correct syntax error 2025-12-30 21:53:10 +08:00
yusing
d7f33b7390 chore(.gitignore): add dev-data directory to ignore list 2025-12-30 21:52:04 +08:00
yusing
1978329314 feat(route): add CommandRoute for routing requests to specified routes
- Introduced CommandRoute to handle routing requests to other defined routes.
- Added validation to ensure a single argument is provided for the route.
- Implemented command handler to serve the specified route or return a 404 error if not found.
2025-12-30 21:49:47 +08:00
yusing
dba8441e8a refactor(routes): add excluded routes to health check and route list
- Updated route iteration to include all routes, including excluded ones.
- Renamed existing functions for clarity.
- Adjusted health info retrieval to reflect changes in route iteration.
- Improved route management by adding health monitoring capabilities for excluded routes.
2025-12-30 12:39:58 +08:00
yusing
44fc678496 refactor(docker): simplify docker host parsing 2025-12-29 10:38:43 +08:00
yusing
0b410311da fix(oidc): add trailing slash to OIDCAuthBasePath to work with paths like /authorize 2025-12-29 10:22:38 +08:00
yusing
dc39f0cb6e chore(swagger): add installation instruction for swaggo in Makefile 2025-12-23 17:18:59 +08:00
yusing
e232b9d122 chore(swagger): update swagger regarding new docker config structure 2025-12-23 17:18:13 +08:00
yusing
41f8d3cfc0 refactor(docker): update TLS config validation to require both CertFile and KeyFile exists or both empty 2025-12-23 12:25:26 +08:00
Yuzerion
5ab0392cd3 feat: docker over tls (#178) 2025-12-23 12:01:11 +08:00
yusing
09702266a9 feat(debug): implement debug server for development environment
- Added `listenDebugServer` function to handle debug requests.
- Introduced table based debug page with different functionalities.
- Updated Makefile to use `scc` for code analysis instead of `cloc`.
2025-12-22 16:57:47 +08:00
yusing
14f3ed95ea feat(auth): modernize block page styling 2025-12-22 15:48:55 +08:00
yusing
eb3aa21e37 fix(healthcheck): fix fileserver health check by removing zero port check 2025-12-22 12:04:09 +08:00
yusing
a6e86ea420 fix(auth): correct logic in AuthOrProceed when auth is disabled 2025-12-22 12:00:14 +08:00
yusing
dd96e09a7a refactor(docker): streamline label loading in loadDeleteIdlewatcherLabels function 2025-12-22 11:54:37 +08:00
yusing
4d08efbd4f chore(deps): upgrade dependencies 2025-12-22 11:54:13 +08:00
yusing
f67480d085 feat(oidc): make rate limit customizable; per oidc instance rate limit
- add env variables OIDC_RATE_LIMIT and OIDC_RATE_LIMIT_PERIOD
- default rate limit changed to 10 rps from 1 rps
- rate limit is no longer applied globally
2025-12-22 10:43:41 +08:00
yusing
736985b79d fix(auth): enforce HTML acceptance in OIDC login handler 2025-12-22 10:35:43 +08:00
yusing
1fb1ee0279 refactor(auth): enhance error handling in OIDC login and callback handlers with user-friendly pages 2025-12-22 10:35:07 +08:00
yusing
4b2a6023bb refactor(auth): update WriteBlockPage function to include action text and URL 2025-12-22 10:27:48 +08:00
yusing
5852053ef9 fix(config): remove duplicated reload error 2025-12-21 11:23:42 +08:00
yusing
c687795cd8 refactor(docker): remove unnecessary http client in NewClient method 2025-12-21 11:23:21 +08:00
yusing
93af695e95 refactor(list_icons): interning app category names to save memory 2025-12-20 20:43:21 +08:00
yusing
58325e60b4 refactor(docker): remove deprecated client.WithAPIVersionNegotiation() 2025-12-20 19:51:43 +08:00
yusing
b134b92704 feat(fileserver): implement spa support; add spa and index fields to config 2025-12-20 19:24:39 +08:00
102 changed files with 4333 additions and 1351 deletions

1
.gitignore vendored
View File

@@ -40,3 +40,4 @@ tsconfig.tsbuildinfo
!agent.compose.yml
!agent/pkg/**
dev-data/

View File

@@ -35,7 +35,7 @@ else ifeq ($(debug), 1)
CGO_ENABLED = 1
GODOXY_DEBUG = 1
GO_TAGS += debug
BUILD_FLAGS += -asan # FIXME: -gcflags=all='-N -l'
# FIXME: BUILD_FLAGS += -asan -gcflags=all='-N -l'
else ifeq ($(pprof), 1)
CGO_ENABLED = 0
GORACE = log_path=logs/pprof strip_path_prefix=$(shell pwd)/ halt_on_error=1
@@ -123,6 +123,15 @@ dev:
dev-build: build
docker compose -f dev.compose.yml up -t 0 -d app --force-recreate
benchmark:
@if [ -z "$(TARGET)" ]; then \
docker compose -f dev.compose.yml up -d --force-recreate godoxy traefik caddy nginx; \
else \
docker compose -f dev.compose.yml up -d --force-recreate $(TARGET); \
fi
sleep 1
@./scripts/benchmark.sh
dev-run: build
cd dev-data && ${BIN_PATH}
@@ -142,12 +151,13 @@ ci-test:
act -n --artifact-server-path /tmp/artifacts -s GITHUB_TOKEN="$$(gh auth token)"
cloc:
cloc --include-lang=Go --not-match-f '_test.go$$' .
scc -w -i go --not-match '_test.go$$'
push-github:
git push origin $(shell git rev-parse --abbrev-ref HEAD)
gen-swagger:
# go install github.com/swaggo/swag/cmd/swag@latest
swag init --parseDependency --parseInternal --parseFuncBody -g handler.go -d internal/api -o internal/api/v1/docs
python3 scripts/fix-swagger-json.py
# we don't need this

View File

@@ -22,11 +22,11 @@ require (
github.com/rs/zerolog v1.34.0
github.com/stretchr/testify v1.11.1
github.com/valyala/fasthttp v1.68.0
github.com/yusing/godoxy v0.20.10
github.com/yusing/godoxy v0.0.0-00010101000000-000000000000
github.com/yusing/godoxy/socketproxy v0.0.0-00010101000000-000000000000
github.com/yusing/goutils v0.7.0
github.com/yusing/goutils/http/reverseproxy v0.0.0-20251216064709-6c698b1d55d0
github.com/yusing/goutils/server v0.0.0-20251216064709-6c698b1d55d0
github.com/yusing/goutils/http/reverseproxy v0.0.0-20251217162119-cb0f79b51ce2
github.com/yusing/goutils/server v0.0.0-20251217162119-cb0f79b51ce2
)
require (
@@ -60,10 +60,10 @@ require (
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.29.0 // indirect
github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/gobwas/glob v0.2.3 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.0 // indirect
github.com/goccy/go-yaml v1.19.1 // indirect
github.com/gorilla/mux v1.8.1 // indirect
github.com/gotify/server/v2 v2.7.3 // indirect
github.com/jinzhu/copier v0.4.0 // indirect
@@ -73,7 +73,7 @@ require (
github.com/leodido/go-urn v1.4.0 // indirect
github.com/lithammer/fuzzysearch v1.1.8 // indirect
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 // indirect
github.com/luthermonson/go-proxmox v0.2.3 // indirect
github.com/luthermonson/go-proxmox v0.2.4 // indirect
github.com/magefile/mage v1.15.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
@@ -91,7 +91,7 @@ require (
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.57.1 // indirect
github.com/quic-go/quic-go v0.58.0 // indirect
github.com/samber/lo v1.52.0 // indirect
github.com/samber/slog-common v0.19.0 // indirect
github.com/samber/slog-zerolog/v2 v2.9.0 // indirect
@@ -106,7 +106,7 @@ require (
github.com/vincent-petithory/dataurl v1.0.0 // indirect
github.com/yusing/ds v0.3.1 // indirect
github.com/yusing/gointernals v0.1.16 // indirect
github.com/yusing/goutils/http/websocket v0.0.0-20251216064709-6c698b1d55d0 // indirect
github.com/yusing/goutils/http/websocket v0.0.0-20251217162119-cb0f79b51ce2 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 // indirect

View File

@@ -77,16 +77,16 @@ 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.29.0 h1:lQlF5VNJWNlRbRZNeOIkWElR+1LL/OuHcc0Kp14w1xk=
github.com/go-playground/validator/v10 v10.29.0/go.mod h1:D6QxqeMlgIPuT02L66f2ccrZ7AGgHkzKmmTMZhk/Kc4=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
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/goccy/go-yaml v1.19.0 h1:EmkZ9RIsX+Uq4DYFowegAuJo8+xdX3T/2dwNPXbxEYE=
github.com/goccy/go-yaml v1.19.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/goccy/go-yaml v1.19.1 h1:3rG3+v8pkhRqoQ/88NYNMHYVGYztCOCIZ7UQhu7H+NE=
github.com/goccy/go-yaml v1.19.1/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
@@ -124,8 +124,8 @@ github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8
github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4=
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 h1:PwQumkgq4/acIiZhtifTV5OUqqiP82UAl0h87xj/l9k=
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/luthermonson/go-proxmox v0.2.3 h1:NAjUJ5Jd1ynIK6UHMGd/VLGgNZWpGXhfL+DBmAVSEaA=
github.com/luthermonson/go-proxmox v0.2.3/go.mod h1:oyFgg2WwTEIF0rP6ppjiixOHa5ebK1p8OaRiFhvICBQ=
github.com/luthermonson/go-proxmox v0.2.4 h1:XQ6YNUTVvHS7N4EJxWpuqWLW2s1VPtsIblxLV/rGHLw=
github.com/luthermonson/go-proxmox v0.2.4/go.mod h1:oyFgg2WwTEIF0rP6ppjiixOHa5ebK1p8OaRiFhvICBQ=
github.com/magefile/mage v1.15.0 h1:BvGheCMAsG3bWUDbZ8AyXXpCNwU9u5CB6sM+HNb9HYg=
github.com/magefile/mage v1.15.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
@@ -172,8 +172,8 @@ github.com/puzpuzpuz/xsync/v4 v4.2.0 h1:dlxm77dZj2c3rxq0/XNvvUKISAmovoXF4a4qM6Wv
github.com/puzpuzpuz/xsync/v4 v4.2.0/go.mod h1:VJDmTCJMBt8igNxnkQd86r+8KUeN1quSfNKu5bLYFQo=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.57.1 h1:25KAAR9QR8KZrCZRThWMKVAwGoiHIrNbT72ULHTuI10=
github.com/quic-go/quic-go v0.57.1/go.mod h1:ly4QBAjHA2VhdnxhojRsCUOeJwKYg+taDlos92xb1+s=
github.com/quic-go/quic-go v0.58.0 h1:ggY2pvZaVdB9EyojxL1p+5mptkuHyX5MOSv4dgWF4Ug=
github.com/quic-go/quic-go v0.58.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=

View File

@@ -4,6 +4,7 @@ import (
"fmt"
"net/http"
"net/http/httputil"
"strings"
"time"
"github.com/yusing/godoxy/agent/pkg/agent"
@@ -43,10 +44,22 @@ func ProxyHTTP(w http.ResponseWriter, r *http.Request) {
return
}
r.URL.Scheme = ""
r.URL.Host = ""
r.URL.Path = r.URL.Path[agent.HTTPProxyURLPrefixLen:] // strip the {API_BASE}/proxy/http prefix
r.RequestURI = r.URL.String()
// Strip the {API_BASE}/proxy/http prefix while preserving URL escaping.
//
// NOTE: `r.URL.Path` is decoded. If we rewrite it without keeping `RawPath`
// in sync, Go may re-escape the path (e.g. turning "%5B" into "%255B"),
// which breaks urls with percent-encoded characters, like Next.js static chunk URLs.
prefix := agent.APIEndpointBase + agent.EndpointProxyHTTP
r.URL.Path = strings.TrimPrefix(r.URL.Path, prefix)
if r.URL.RawPath != "" {
if after, ok := strings.CutPrefix(r.URL.RawPath, prefix); ok {
r.URL.RawPath = after
} else {
// RawPath is no longer a valid encoding for Path; force Go to re-derive it.
r.URL.RawPath = ""
}
}
r.RequestURI = ""
rp := &httputil.ReverseProxy{
Director: func(r *http.Request) {

View File

@@ -0,0 +1,18 @@
FROM golang:1.25.5-alpine AS builder
HEALTHCHECK NONE
WORKDIR /src
COPY go.mod go.sum ./
COPY main.go ./
RUN go build -o bench_server main.go
FROM scratch
COPY --from=builder /src/bench_server /app/run
USER 1001:1001
CMD ["/app/run"]

3
cmd/bench_server/go.mod Normal file
View File

@@ -0,0 +1,3 @@
module github.com/yusing/godoxy/cmd/bench_server
go 1.25.5

0
cmd/bench_server/go.sum Normal file
View File

34
cmd/bench_server/main.go Normal file
View File

@@ -0,0 +1,34 @@
package main
import (
"log"
"net/http"
"math/rand/v2"
)
var printables = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
var random = make([]byte, 4096)
func init() {
for i := range random {
random[i] = printables[rand.IntN(len(printables))]
}
}
func main() {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write(random)
})
server := &http.Server{
Addr: ":80",
Handler: handler,
}
log.Println("Bench server listening on :80")
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("ListenAndServe: %v", err)
}
}

257
cmd/debug_page.go Normal file
View File

@@ -0,0 +1,257 @@
//go:build !production
package main
import (
"fmt"
"net/http"
"github.com/gin-gonic/gin"
"github.com/yusing/godoxy/internal/api"
apiV1 "github.com/yusing/godoxy/internal/api/v1"
agentApi "github.com/yusing/godoxy/internal/api/v1/agent"
authApi "github.com/yusing/godoxy/internal/api/v1/auth"
certApi "github.com/yusing/godoxy/internal/api/v1/cert"
dockerApi "github.com/yusing/godoxy/internal/api/v1/docker"
fileApi "github.com/yusing/godoxy/internal/api/v1/file"
homepageApi "github.com/yusing/godoxy/internal/api/v1/homepage"
metricsApi "github.com/yusing/godoxy/internal/api/v1/metrics"
routeApi "github.com/yusing/godoxy/internal/api/v1/route"
"github.com/yusing/godoxy/internal/auth"
"github.com/yusing/godoxy/internal/idlewatcher"
idlewatcherTypes "github.com/yusing/godoxy/internal/idlewatcher/types"
)
type debugMux struct {
endpoints []debugEndpoint
mux http.ServeMux
}
type debugEndpoint struct {
name string
method string
path string
}
func newDebugMux() *debugMux {
return &debugMux{
endpoints: make([]debugEndpoint, 0),
mux: *http.NewServeMux(),
}
}
func (mux *debugMux) registerEndpoint(name, method, path string) {
mux.endpoints = append(mux.endpoints, debugEndpoint{name: name, method: method, path: path})
}
func (mux *debugMux) HandleFunc(name, method, path string, handler http.HandlerFunc) {
mux.registerEndpoint(name, method, path)
mux.mux.HandleFunc(method+" "+path, handler)
}
func (mux *debugMux) Finalize() {
mux.mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
fmt.Fprintln(w, `
<!DOCTYPE html>
<html>
<head>
<style>
body {
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, Apple Color Emoji, Segoe UI Emoji;
font-size: 16px;
line-height: 1.5;
color: #f8f9fa;
background-color: #121212;
margin: 0;
padding: 0;
}
table {
border-collapse: collapse;
width: 100%;
margin-top: 20px;
}
th, td {
padding: 12px;
text-align: left;
border-bottom: 1px solid #333;
}
th {
background-color: #1e1e1e;
font-weight: 600;
color: #f8f9fa;
}
td {
color: #e9ecef;
}
.link {
color: #007bff;
text-decoration: none;
}
.link:hover {
text-decoration: underline;
}
.method {
color: #6c757d;
font-family: monospace;
}
.path {
color: #6c757d;
font-family: monospace;
}
</style>
</head>
<body>
<table>
<thead>
<tr>
<th>Name</th>
<th>Method</th>
<th>Path</th>
</tr>
</thead>
<tbody>`)
for _, endpoint := range mux.endpoints {
fmt.Fprintf(w, "<tr><td><a class='link' href=%q>%s</a></td><td class='method'>%s</td><td class='path'>%s</td></tr>", endpoint.path, endpoint.name, endpoint.method, endpoint.path)
}
fmt.Fprintln(w, `
</tbody>
</table>
</body>
</html>`)
})
}
func listenDebugServer() {
mux := newDebugMux()
mux.mux.HandleFunc("/favicon.ico", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/svg+xml")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text x="50" y="50" text-anchor="middle" dominant-baseline="middle">🐙</text></svg>`))
})
mux.HandleFunc("Auth block page", "GET", "/auth/block", AuthBlockPageHandler)
mux.HandleFunc("Idlewatcher loading page", "GET", idlewatcherTypes.PathPrefix, idlewatcher.DebugHandler)
apiHandler := newApiHandler(mux)
mux.mux.HandleFunc("/api/v1/", apiHandler.ServeHTTP)
mux.Finalize()
go http.ListenAndServe(":7777", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Pragma", "no-cache")
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
w.Header().Set("Expires", "0")
mux.mux.ServeHTTP(w, r)
}))
}
func newApiHandler(debugMux *debugMux) *gin.Engine {
r := gin.New()
r.Use(api.ErrorHandler())
r.Use(api.ErrorLoggingMiddleware())
r.Use(api.NoCache())
registerGinRoute := func(router gin.IRouter, method, name string, path string, handler gin.HandlerFunc) {
if group, ok := router.(*gin.RouterGroup); ok {
debugMux.registerEndpoint(name, method, group.BasePath()+path)
} else {
debugMux.registerEndpoint(name, method, path)
}
router.Handle(method, path, handler)
}
registerGinRoute(r, "GET", "App version", "/api/v1/version", apiV1.Version)
v1 := r.Group("/api/v1")
if auth.IsEnabled() {
v1Auth := v1.Group("/auth")
{
registerGinRoute(v1Auth, "HEAD", "Auth check", "/check", authApi.Check)
registerGinRoute(v1Auth, "POST", "Auth login", "/login", authApi.Login)
registerGinRoute(v1Auth, "GET", "Auth callback", "/callback", authApi.Callback)
registerGinRoute(v1Auth, "POST", "Auth callback", "/callback", authApi.Callback)
registerGinRoute(v1Auth, "POST", "Auth logout", "/logout", authApi.Logout)
registerGinRoute(v1Auth, "GET", "Auth logout", "/logout", authApi.Logout)
}
}
{
// enable cache for favicon
registerGinRoute(v1, "GET", "Route favicon", "/favicon", apiV1.FavIcon)
registerGinRoute(v1, "GET", "Route health", "/health", apiV1.Health)
registerGinRoute(v1, "GET", "List icons", "/icons", apiV1.Icons)
registerGinRoute(v1, "POST", "Config reload", "/reload", apiV1.Reload)
registerGinRoute(v1, "GET", "Route stats", "/stats", apiV1.Stats)
route := v1.Group("/route")
{
registerGinRoute(route, "GET", "List routes", "/list", routeApi.Routes)
registerGinRoute(route, "GET", "Get route", "/:which", routeApi.Route)
registerGinRoute(route, "GET", "List providers", "/providers", routeApi.Providers)
registerGinRoute(route, "GET", "List routes by provider", "/by_provider", routeApi.ByProvider)
registerGinRoute(route, "POST", "Playground", "/playground", routeApi.Playground)
}
file := v1.Group("/file")
{
registerGinRoute(file, "GET", "List files", "/list", fileApi.List)
registerGinRoute(file, "GET", "Get file", "/content", fileApi.Get)
registerGinRoute(file, "PUT", "Set file", "/content", fileApi.Set)
registerGinRoute(file, "POST", "Set file", "/content", fileApi.Set)
registerGinRoute(file, "POST", "Validate file", "/validate", fileApi.Validate)
}
homepage := v1.Group("/homepage")
{
registerGinRoute(homepage, "GET", "List categories", "/categories", homepageApi.Categories)
registerGinRoute(homepage, "GET", "List items", "/items", homepageApi.Items)
registerGinRoute(homepage, "POST", "Set item", "/set/item", homepageApi.SetItem)
registerGinRoute(homepage, "POST", "Set items batch", "/set/items_batch", homepageApi.SetItemsBatch)
registerGinRoute(homepage, "POST", "Set item visible", "/set/item_visible", homepageApi.SetItemVisible)
registerGinRoute(homepage, "POST", "Set item favorite", "/set/item_favorite", homepageApi.SetItemFavorite)
registerGinRoute(homepage, "POST", "Set item sort order", "/set/item_sort_order", homepageApi.SetItemSortOrder)
registerGinRoute(homepage, "POST", "Set item all sort order", "/set/item_all_sort_order", homepageApi.SetItemAllSortOrder)
registerGinRoute(homepage, "POST", "Set item fav sort order", "/set/item_fav_sort_order", homepageApi.SetItemFavSortOrder)
registerGinRoute(homepage, "POST", "Set category order", "/set/category_order", homepageApi.SetCategoryOrder)
registerGinRoute(homepage, "POST", "Item click", "/item_click", homepageApi.ItemClick)
}
cert := v1.Group("/cert")
{
registerGinRoute(cert, "GET", "Get cert info", "/info", certApi.Info)
registerGinRoute(cert, "GET", "Renew cert", "/renew", certApi.Renew)
}
agent := v1.Group("/agent")
{
registerGinRoute(agent, "GET", "List agents", "/list", agentApi.List)
registerGinRoute(agent, "POST", "Create agent", "/create", agentApi.Create)
registerGinRoute(agent, "POST", "Verify agent", "/verify", agentApi.Verify)
}
metrics := v1.Group("/metrics")
{
registerGinRoute(metrics, "GET", "Get system info", "/system_info", metricsApi.SystemInfo)
registerGinRoute(metrics, "GET", "Get all system info", "/all_system_info", metricsApi.AllSystemInfo)
registerGinRoute(metrics, "GET", "Get uptime", "/uptime", metricsApi.Uptime)
}
docker := v1.Group("/docker")
{
registerGinRoute(docker, "GET", "Get container", "/container/:id", dockerApi.GetContainer)
registerGinRoute(docker, "GET", "List containers", "/containers", dockerApi.Containers)
registerGinRoute(docker, "GET", "Get docker info", "/info", dockerApi.Info)
registerGinRoute(docker, "GET", "Get docker logs", "/logs/:id", dockerApi.Logs)
registerGinRoute(docker, "POST", "Start docker container", "/start", dockerApi.Start)
registerGinRoute(docker, "POST", "Stop docker container", "/stop", dockerApi.Stop)
registerGinRoute(docker, "POST", "Restart docker container", "/restart", dockerApi.Restart)
}
}
return r
}
func AuthBlockPageHandler(w http.ResponseWriter, r *http.Request) {
auth.WriteBlockPage(w, http.StatusForbidden, "Forbidden", "Login", "/login")
}

7
cmd/debug_page_prod.go Normal file
View File

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

View File

@@ -0,0 +1,18 @@
FROM golang:1.25.5-alpine AS builder
HEALTHCHECK NONE
WORKDIR /src
COPY go.mod go.sum ./
COPY main.go ./
RUN go build -o h2c_test_server main.go
FROM scratch
COPY --from=builder /src/h2c_test_server /app/run
USER 1001:1001
CMD ["/app/run"]

View File

@@ -0,0 +1,7 @@
module github.com/yusing/godoxy/cmd/h2c_test_server
go 1.25.5
require golang.org/x/net v0.48.0
require golang.org/x/text v0.32.0 // indirect

View File

@@ -0,0 +1,4 @@
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=

View File

@@ -0,0 +1,26 @@
package main
import (
"log"
"net/http"
"golang.org/x/net/http2"
"golang.org/x/net/http2/h2c"
)
func main() {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
server := &http.Server{
Addr: ":80",
Handler: h2c.NewHandler(handler, &http2.Server{}),
}
log.Println("H2C server listening on :80")
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("ListenAndServe: %v", err)
}
}

View File

@@ -72,6 +72,8 @@ func main() {
Handler: api.NewHandler(),
})
listenDebugServer()
uptime.Poller.Start()
config.WatchChanges()

View File

@@ -1,3 +1,8 @@
x-benchmark: &benchmark
restart: no
labels:
proxy.exclude: true
proxy.#1.healthcheck.disable: true
services:
app:
image: godoxy-dev
@@ -54,7 +59,190 @@ services:
- USERS=user:$$2a$$10$$UdLYoJ5lgPsC0RKqYH/jMua7zIn0g9kPqWmhYayJYLaZQ/FTmH2/u # user:password
labels:
proxy.tinyauth.port: "3000"
jotty: # issue #182
image: ghcr.io/fccview/jotty:latest
container_name: jotty
user: "1000:1000"
tmpfs:
- /app/data:rw,uid=1000,gid=1000
- /app/config:rw,uid=1000,gid=1000
- /app/.next/cache:rw,uid=1000,gid=1000
restart: unless-stopped
environment:
- NODE_ENV=production
labels:
proxy.aliases: "jotty.my.app"
postgres-test:
image: postgres:18-alpine
container_name: postgres-test
restart: unless-stopped
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
- POSTGRES_DB=postgres
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
h2c_test_server:
build:
context: cmd/h2c_test_server
dockerfile: Dockerfile
container_name: h2c_test
restart: unless-stopped
labels:
proxy.#1.scheme: h2c
proxy.#1.port: 80
bench: # returns 4096 bytes of random data
<<: *benchmark
build:
context: cmd/bench_server
dockerfile: Dockerfile
container_name: bench
godoxy:
<<: *benchmark
build: .
container_name: godoxy-benchmark
ports:
- 8080:80
configs:
- source: godoxy_config
target: /app/config/config.yml
- source: godoxy_provider
target: /app/config/providers.yml
traefik:
<<: *benchmark
image: traefik:latest
container_name: traefik
command:
- --api.insecure=true
- --entrypoints.web.address=:8081
- --providers.file.directory=/etc/traefik/dynamic
- --providers.file.watch=true
- --log.level=ERROR
ports:
- 8081:8081
configs:
- source: traefik_config
target: /etc/traefik/dynamic/routes.yml
caddy:
<<: *benchmark
image: caddy:latest
container_name: caddy
ports:
- 8082:80
configs:
- source: caddy_config
target: /etc/caddy/Caddyfile
tmpfs:
- /data
- /config
nginx:
<<: *benchmark
image: nginx:latest
container_name: nginx
command: nginx -g 'daemon off;' -c /etc/nginx/nginx.conf
ports:
- 8083:80
configs:
- source: nginx_config
target: /etc/nginx/nginx.conf
configs:
godoxy_config:
content: |
providers:
include:
- providers.yml
godoxy_provider:
content: |
bench.domain.com:
host: bench
traefik_config:
content: |
http:
routers:
bench:
rule: "Host(`bench.domain.com`)"
entryPoints:
- web
service: bench
services:
bench:
loadBalancer:
servers:
- url: "http://bench:80"
caddy_config:
content: |
{
admin off
auto_https off
default_bind 0.0.0.0
servers {
protocols h1 h2c
}
}
http://bench.domain.com {
reverse_proxy bench:80
}
nginx_config:
content: |
worker_processes auto;
worker_rlimit_nofile 65535;
error_log /dev/null;
pid /var/run/nginx.pid;
events {
worker_connections 10240;
multi_accept on;
use epoll;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
access_log off;
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
keepalive_requests 10000;
upstream backend {
server bench:80;
keepalive 128;
}
server {
listen 80 default_server;
server_name _;
http2 on;
return 404;
}
server {
listen 80;
server_name bench.domain.com;
http2 on;
location / {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $$host;
proxy_set_header X-Real-IP $$remote_addr;
proxy_set_header X-Forwarded-For $$proxy_add_x_forwarded_for;
proxy_buffering off;
}
}
}
parca:
content: |
object_storage:

34
go.mod
View File

@@ -19,7 +19,7 @@ require (
github.com/fsnotify/fsnotify v1.9.0 // file watcher
github.com/gin-gonic/gin v1.11.0 // api server
github.com/go-acme/lego/v4 v4.30.1 // acme client
github.com/go-playground/validator/v10 v10.29.0 // validator
github.com/go-playground/validator/v10 v10.30.1 // validator
github.com/gobwas/glob v0.2.3 // glob matcher for route rules
github.com/gorilla/websocket v1.5.3 // websocket for API and agent
github.com/gotify/server/v2 v2.7.3 // reference the Message struct for json response
@@ -39,25 +39,25 @@ require (
github.com/bytedance/gopkg v0.1.3 // xxhash64 for fast hash
github.com/bytedance/sonic v1.14.2 // fast json parsing
github.com/docker/cli v29.1.3+incompatible // needs docker/cli/cli/connhelper connection helper for docker client
github.com/goccy/go-yaml v1.19.0 // yaml parsing for different config files
github.com/goccy/go-yaml v1.19.1 // yaml parsing for different config files
github.com/golang-jwt/jwt/v5 v5.3.0 // jwt authentication
github.com/luthermonson/go-proxmox v0.2.3 // proxmox API client
github.com/luthermonson/go-proxmox v0.2.4 // proxmox API client
github.com/moby/moby/api v1.52.0 // docker API
github.com/moby/moby/client v0.2.1 // docker client
github.com/oschwald/maxminddb-golang v1.13.1 // maxminddb for geoip database
github.com/quic-go/quic-go v0.57.1 // http3 support
github.com/quic-go/quic-go v0.58.0 // http3 support
github.com/shirou/gopsutil/v4 v4.25.11 // system information
github.com/spf13/afero v1.15.0 // afero for file system operations
github.com/stretchr/testify v1.11.1 // testing framework
github.com/valyala/fasthttp v1.68.0 // fast http for health check
github.com/yusing/ds v0.3.1 // data structures and algorithms
github.com/yusing/godoxy/agent v0.0.0-20251217034652-88d7255c7adc
github.com/yusing/godoxy/internal/dnsproviders v0.0.0-20251217034652-88d7255c7adc
github.com/yusing/godoxy/agent v0.0.0-20251230135310-5087800fd763
github.com/yusing/godoxy/internal/dnsproviders v0.0.0-20251230043958-dba8441e8a5d
github.com/yusing/gointernals v0.1.16
github.com/yusing/goutils v0.7.0
github.com/yusing/goutils/http/reverseproxy v0.0.0-20251216064709-6c698b1d55d0
github.com/yusing/goutils/http/websocket v0.0.0-20251216064709-6c698b1d55d0
github.com/yusing/goutils/server v0.0.0-20251216064709-6c698b1d55d0
github.com/yusing/goutils/http/reverseproxy v0.0.0-20251217162119-cb0f79b51ce2
github.com/yusing/goutils/http/websocket v0.0.0-20251217162119-cb0f79b51ce2
github.com/yusing/goutils/server v0.0.0-20251217162119-cb0f79b51ce2
)
require (
@@ -93,7 +93,7 @@ require (
github.com/google/s2a-go v0.1.9 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.7 // indirect
github.com/googleapis/gax-go/v2 v2.15.0 // indirect
github.com/googleapis/gax-go/v2 v2.16.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-retryablehttp v0.7.8 // indirect
github.com/jinzhu/copier v0.4.0 // indirect
@@ -120,7 +120,7 @@ require (
github.com/samber/lo v1.52.0 // indirect
github.com/samber/slog-common v0.19.0 // indirect
github.com/samber/slog-zerolog/v2 v2.9.0 // indirect
github.com/scaleway/scaleway-sdk-go v1.0.0-beta.35 // indirect
github.com/scaleway/scaleway-sdk-go v1.0.0-beta.36 // indirect
github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af // indirect
github.com/sony/gobreaker v1.0.0 // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
@@ -135,9 +135,9 @@ require (
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect
golang.org/x/tools v0.40.0 // indirect
google.golang.org/api v0.257.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 // indirect
google.golang.org/grpc v1.77.0 // indirect
google.golang.org/api v0.258.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
google.golang.org/grpc v1.78.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
@@ -159,7 +159,7 @@ require (
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 // indirect
github.com/go-resty/resty/v2 v2.17.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/google/go-querystring v1.2.0 // indirect
github.com/klauspost/compress v1.18.2 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/linode/linodego v1.63.0 // indirect
@@ -175,9 +175,7 @@ require (
github.com/ugorji/go/codec v1.3.1 // indirect
github.com/ulikunitz/xz v0.5.15 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/vultr/govultr/v3 v3.26.0 // indirect
github.com/vultr/govultr/v3 v3.26.1 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
golang.org/x/arch v0.23.0 // indirect
google.golang.org/genproto v0.0.0-20251111163417-95abcf5c77ba // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20251111163417-95abcf5c77ba // indirect
)

54
go.sum
View File

@@ -115,8 +115,8 @@ 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.29.0 h1:lQlF5VNJWNlRbRZNeOIkWElR+1LL/OuHcc0Kp14w1xk=
github.com/go-playground/validator/v10 v10.29.0/go.mod h1:D6QxqeMlgIPuT02L66f2ccrZ7AGgHkzKmmTMZhk/Kc4=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/go-resty/resty/v2 v2.17.1 h1:x3aMpHK1YM9e4va/TMDRlusDDoZiQ+ViDu/WpA6xTM4=
github.com/go-resty/resty/v2 v2.17.1/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2mLtQrOyQlVA=
github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
@@ -125,8 +125,8 @@ 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/goccy/go-yaml v1.19.0 h1:EmkZ9RIsX+Uq4DYFowegAuJo8+xdX3T/2dwNPXbxEYE=
github.com/goccy/go-yaml v1.19.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/goccy/go-yaml v1.19.1 h1:3rG3+v8pkhRqoQ/88NYNMHYVGYztCOCIZ7UQhu7H+NE=
github.com/goccy/go-yaml v1.19.1/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw=
github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0=
@@ -134,12 +134,11 @@ github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9v
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0=
github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
@@ -147,8 +146,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/enterprise-certificate-proxy v0.3.7 h1:zrn2Ee/nWmHulBx5sAVrGgAa0f2/R35S4DJwfFaUPFQ=
github.com/googleapis/enterprise-certificate-proxy v0.3.7/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo=
github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc=
github.com/googleapis/gax-go/v2 v2.16.0 h1:iHbQmKLLZrexmb0OSsNGTeSTS0HO4YvFOG8g5E4Zd0Y=
github.com/googleapis/gax-go/v2 v2.16.0/go.mod h1:o1vfQjjNZn4+dPnRdl/4ZD7S9414Y4xA+a/6Icj6l14=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gotify/server/v2 v2.7.3 h1:nro/ZnxdlZFvxFcw9LREGA8zdk6CK744azwhuhX/A4g=
@@ -189,8 +188,8 @@ github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8
github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4=
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 h1:PwQumkgq4/acIiZhtifTV5OUqqiP82UAl0h87xj/l9k=
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/luthermonson/go-proxmox v0.2.3 h1:NAjUJ5Jd1ynIK6UHMGd/VLGgNZWpGXhfL+DBmAVSEaA=
github.com/luthermonson/go-proxmox v0.2.3/go.mod h1:oyFgg2WwTEIF0rP6ppjiixOHa5ebK1p8OaRiFhvICBQ=
github.com/luthermonson/go-proxmox v0.2.4 h1:XQ6YNUTVvHS7N4EJxWpuqWLW2s1VPtsIblxLV/rGHLw=
github.com/luthermonson/go-proxmox v0.2.4/go.mod h1:oyFgg2WwTEIF0rP6ppjiixOHa5ebK1p8OaRiFhvICBQ=
github.com/magefile/mage v1.15.0 h1:BvGheCMAsG3bWUDbZ8AyXXpCNwU9u5CB6sM+HNb9HYg=
github.com/magefile/mage v1.15.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
@@ -253,8 +252,8 @@ github.com/puzpuzpuz/xsync/v4 v4.2.0 h1:dlxm77dZj2c3rxq0/XNvvUKISAmovoXF4a4qM6Wv
github.com/puzpuzpuz/xsync/v4 v4.2.0/go.mod h1:VJDmTCJMBt8igNxnkQd86r+8KUeN1quSfNKu5bLYFQo=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.57.1 h1:25KAAR9QR8KZrCZRThWMKVAwGoiHIrNbT72ULHTuI10=
github.com/quic-go/quic-go v0.57.1/go.mod h1:ly4QBAjHA2VhdnxhojRsCUOeJwKYg+taDlos92xb1+s=
github.com/quic-go/quic-go v0.58.0 h1:ggY2pvZaVdB9EyojxL1p+5mptkuHyX5MOSv4dgWF4Ug=
github.com/quic-go/quic-go v0.58.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
@@ -266,8 +265,8 @@ github.com/samber/slog-common v0.19.0 h1:fNcZb8B2uOLooeYwFpAlKjkQTUafdjfqKcwcC89
github.com/samber/slog-common v0.19.0/go.mod h1:dTz+YOU76aH007YUU0DffsXNsGFQRQllPQh9XyNoA3M=
github.com/samber/slog-zerolog/v2 v2.9.0 h1:6LkOabJmZdNLaUWkTC3IVVA+dq7b/V0FM6lz6/7+THI=
github.com/samber/slog-zerolog/v2 v2.9.0/go.mod h1:gnQW9VnCfM34v2pRMUIGMsZOVbYLqY/v0Wxu6atSVGc=
github.com/scaleway/scaleway-sdk-go v1.0.0-beta.35 h1:8xfn1RzeI9yoCUuEwDy08F+No6PcKZGEDOQ6hrRyLts=
github.com/scaleway/scaleway-sdk-go v1.0.0-beta.35/go.mod h1:47B1d/YXmSAxlJxUJxClzHR6b3T4M1WyCvwENPQNBWc=
github.com/scaleway/scaleway-sdk-go v1.0.0-beta.36 h1:ObX9hZmK+VmijreZO/8x9pQ8/P/ToHD/bdSb4Eg4tUo=
github.com/scaleway/scaleway-sdk-go v1.0.0-beta.36/go.mod h1:LEsDu4BubxK7/cWhtlQWfuxwL4rf/2UEpxXz1o1EMtM=
github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af h1:Sp5TG9f7K39yfB+If0vjp97vuT74F72r8hfRpP8jLU0=
github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/sony/gobreaker v1.0.0 h1:feX5fGGXSl3dYd4aHZItw+FpHLvvoaqkawKjVNiFMNQ=
@@ -305,8 +304,8 @@ github.com/valyala/fasthttp v1.68.0 h1:v12Nx16iepr8r9ySOwqI+5RBJ/DqTxhOy1HrHoDFn
github.com/valyala/fasthttp v1.68.0/go.mod h1:5EXiRfYQAoiO/khu4oU9VISC/eVY6JqmSpPJoHCKsz4=
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/vultr/govultr/v3 v3.26.0 h1:pm/GM+RZo9T1JLQzrUti5HiNAIFZFEHcPFMOWGvvNIY=
github.com/vultr/govultr/v3 v3.26.0/go.mod h1:9WwnWGCKnwDlNjHjtt+j+nP+0QWq6hQXzaHgddqrLWY=
github.com/vultr/govultr/v3 v3.26.1 h1:G/M0rMQKwVSmL+gb0UgETbW5mcQi0Vf/o/ZSGdBCxJw=
github.com/vultr/govultr/v3 v3.26.1/go.mod h1:9WwnWGCKnwDlNjHjtt+j+nP+0QWq6hQXzaHgddqrLWY=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
@@ -431,19 +430,18 @@ golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxb
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/api v0.257.0 h1:8Y0lzvHlZps53PEaw+G29SsQIkuKrumGWs9puiexNAA=
google.golang.org/api v0.257.0/go.mod h1:4eJrr+vbVaZSqs7vovFd1Jb/A6ml6iw2e6FBYf3GAO4=
google.golang.org/genproto v0.0.0-20251111163417-95abcf5c77ba h1:Ze6qXW0j37YCqZdCD2LkzVSxgEWez0cO4NUyd44DiDY=
google.golang.org/genproto v0.0.0-20251111163417-95abcf5c77ba/go.mod h1:4FLPzLA8eGAktPOTemJGDgDYRpLYwrNu4u2JtWINhnI=
google.golang.org/genproto/googleapis/api v0.0.0-20251111163417-95abcf5c77ba h1:B14OtaXuMaCQsl2deSvNkyPKIzq3BjfxQp8d00QyWx4=
google.golang.org/genproto/googleapis/api v0.0.0-20251111163417-95abcf5c77ba/go.mod h1:G5IanEx8/PgI9w6CFcYQf7jMtHQhZruvfM1i3qOqk5U=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 h1:2I6GHUeJ/4shcDpoUlLs/2WPnhg7yJwvXtqcMJt9liA=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
google.golang.org/api v0.258.0 h1:IKo1j5FBlN74fe5isA2PVozN3Y5pwNKriEgAXPOkDAc=
google.golang.org/api v0.258.0/go.mod h1:qhOMTQEZ6lUps63ZNq9jhODswwjkjYYguA7fA3TBFww=
google.golang.org/genproto v0.0.0-20251202230838-ff82c1b0f217 h1:GvESR9BIyHUahIb0NcTum6itIWtdoglGX+rnGxm2934=
google.golang.org/genproto v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:yJ2HH4EHEDTd3JiLmhds6NkJ17ITVYOdV3m3VKOnws0=
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls=
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

Submodule goutils updated: cb0f79b51c...785deb23bd

View File

@@ -9,7 +9,6 @@ import (
"github.com/yusing/godoxy/internal/autocert"
"github.com/yusing/godoxy/internal/logging/memlogger"
apitypes "github.com/yusing/goutils/apitypes"
gperr "github.com/yusing/goutils/errs"
"github.com/yusing/goutils/http/websocket"
)
@@ -40,33 +39,33 @@ func Renew(c *gin.Context) {
logs, cancel := memlogger.Events()
defer cancel()
done := make(chan struct{})
go func() {
defer close(done)
// Stream logs until WebSocket connection closes (renewal runs in background)
for {
select {
case <-manager.Context().Done():
return
case l := <-logs:
if err != nil {
return
}
err = autocert.ObtainCert()
if err != nil {
gperr.LogError("failed to obtain cert", err)
_ = manager.WriteData(websocket.TextMessage, []byte(err.Error()), 10*time.Second)
} else {
log.Info().Msg("cert obtained successfully")
err = manager.WriteData(websocket.TextMessage, l, 10*time.Second)
if err != nil {
return
}
}
}
}()
for {
select {
case l := <-logs:
if err != nil {
return
}
err = manager.WriteData(websocket.TextMessage, l, 10*time.Second)
if err != nil {
return
}
case <-done:
return
}
// renewal happens in background
ok := autocert.ForceExpiryAll()
if !ok {
log.Error().Msg("cert renewal already in progress")
time.Sleep(1 * time.Second) // wait for the log above to be sent
return
}
log.Info().Msg("cert force renewal requested")
autocert.WaitRenewalDone(manager.Context())
}

View File

@@ -29,13 +29,13 @@ func GetContainer(c *gin.Context) {
return
}
dockerHost, ok := docker.GetDockerHostByContainerID(id)
dockerCfg, ok := docker.GetDockerCfgByContainerID(id)
if !ok {
c.JSON(http.StatusNotFound, apitypes.Error("container not found"))
return
}
dockerClient, err := docker.NewClient(dockerHost)
dockerClient, err := docker.NewClient(dockerCfg)
if err != nil {
c.Error(apitypes.InternalServerError(err, "failed to create docker client"))
return
@@ -55,7 +55,7 @@ func GetContainer(c *gin.Context) {
}
c.JSON(http.StatusOK, &Container{
Server: dockerHost,
Server: dockerCfg.URL,
Name: cont.Container.Name,
ID: cont.Container.ID,
Image: cont.Container.Image,

View File

@@ -57,13 +57,13 @@ func Logs(c *gin.Context) {
}
// TODO: implement levels
dockerHost, ok := docker.GetDockerHostByContainerID(id)
dockerCfg, ok := docker.GetDockerCfgByContainerID(id)
if !ok {
c.JSON(http.StatusNotFound, apitypes.Error(fmt.Sprintf("container %s not found", id)))
return
}
dockerClient, err := docker.NewClient(dockerHost)
dockerClient, err := docker.NewClient(dockerCfg)
if err != nil {
c.Error(apitypes.InternalServerError(err, "failed to get docker client"))
return
@@ -105,7 +105,7 @@ func Logs(c *gin.Context) {
return
}
log.Err(err).
Str("server", dockerHost).
Str("server", dockerCfg.URL).
Str("container", id).
Msg("failed to de-multiplex logs")
}

View File

@@ -34,13 +34,13 @@ func Restart(c *gin.Context) {
return
}
dockerHost, ok := docker.GetDockerHostByContainerID(req.ID)
dockerCfg, ok := docker.GetDockerCfgByContainerID(req.ID)
if !ok {
c.JSON(http.StatusNotFound, apitypes.Error("container not found"))
return
}
client, err := docker.NewClient(dockerHost)
client, err := docker.NewClient(dockerCfg)
if err != nil {
c.Error(apitypes.InternalServerError(err, "failed to create docker client"))
return

View File

@@ -34,13 +34,13 @@ func Start(c *gin.Context) {
return
}
dockerHost, ok := docker.GetDockerHostByContainerID(req.ID)
dockerCfg, ok := docker.GetDockerCfgByContainerID(req.ID)
if !ok {
c.JSON(http.StatusNotFound, apitypes.Error("container not found"))
return
}
client, err := docker.NewClient(dockerHost)
client, err := docker.NewClient(dockerCfg)
if err != nil {
c.Error(apitypes.InternalServerError(err, "failed to create docker client"))
return

View File

@@ -34,13 +34,13 @@ func Stop(c *gin.Context) {
return
}
dockerHost, ok := docker.GetDockerHostByContainerID(req.ID)
dockerCfg, ok := docker.GetDockerCfgByContainerID(req.ID)
if !ok {
c.JSON(http.StatusNotFound, apitypes.Error("container not found"))
return
}
client, err := docker.NewClient(dockerHost)
client, err := docker.NewClient(dockerCfg)
if err != nil {
c.Error(apitypes.InternalServerError(err, "failed to create docker client"))
return

View File

@@ -2458,8 +2458,8 @@
"x-nullable": false,
"x-omitempty": false
},
"docker_host": {
"type": "string",
"docker_cfg": {
"$ref": "#/definitions/DockerProviderConfig",
"x-nullable": false,
"x-omitempty": false
},
@@ -2715,7 +2715,7 @@
"required": [
"container_id",
"container_name",
"docker_host"
"docker_cfg"
],
"properties": {
"container_id": {
@@ -2728,7 +2728,24 @@
"x-nullable": false,
"x-omitempty": false
},
"docker_host": {
"docker_cfg": {
"$ref": "#/definitions/DockerProviderConfig",
"x-nullable": false,
"x-omitempty": false
}
},
"x-nullable": false,
"x-omitempty": false
},
"DockerProviderConfig": {
"type": "object",
"properties": {
"tls": {
"$ref": "#/definitions/DockerTLSConfig",
"x-nullable": false,
"x-omitempty": false
},
"url": {
"type": "string",
"x-nullable": false,
"x-omitempty": false
@@ -2737,6 +2754,27 @@
"x-nullable": false,
"x-omitempty": false
},
"DockerTLSConfig": {
"type": "object",
"required": [
"ca_file"
],
"properties": {
"ca_file": {
"type": "string",
"x-nullable": false,
"x-omitempty": false
},
"cert_file": {
"type": "string"
},
"key_file": {
"type": "string"
}
},
"x-nullable": false,
"x-omitempty": false
},
"ErrorResponse": {
"type": "object",
"properties": {
@@ -2918,43 +2956,6 @@
"x-nullable": false,
"x-omitempty": false
},
"HealthInfo": {
"type": "object",
"properties": {
"detail": {
"type": "string",
"x-nullable": false,
"x-omitempty": false
},
"latency": {
"description": "latency in microseconds",
"type": "number",
"x-nullable": false,
"x-omitempty": false
},
"status": {
"type": "string",
"enum": [
"healthy",
"unhealthy",
"napping",
"starting",
"error",
"unknown"
],
"x-nullable": false,
"x-omitempty": false
},
"uptime": {
"description": "uptime in milliseconds",
"type": "number",
"x-nullable": false,
"x-omitempty": false
}
},
"x-nullable": false,
"x-omitempty": false
},
"HealthInfoWithoutDetail": {
"type": "object",
"properties": {
@@ -3009,22 +3010,14 @@
"x-nullable": true
},
"lastSeen": {
"description": "unix timestamp in seconds",
"type": "integer",
"x-nullable": false,
"x-omitempty": false
},
"lastSeenStr": {
"type": "string",
"x-nullable": false,
"x-omitempty": false
},
"latency": {
"type": "number",
"x-nullable": false,
"x-omitempty": false
},
"latencyStr": {
"type": "string",
"description": "latency in milliseconds",
"type": "integer",
"x-nullable": false,
"x-omitempty": false
},
@@ -3034,30 +3027,22 @@
"x-omitempty": false
},
"started": {
"description": "unix timestamp in seconds",
"type": "integer",
"x-nullable": false,
"x-omitempty": false
},
"startedStr": {
"type": "string",
"x-nullable": false,
"x-omitempty": false
},
"status": {
"type": "string",
"$ref": "#/definitions/HealthStatusString",
"x-nullable": false,
"x-omitempty": false
},
"uptime": {
"description": "uptime in seconds",
"type": "number",
"x-nullable": false,
"x-omitempty": false
},
"uptimeStr": {
"type": "string",
"x-nullable": false,
"x-omitempty": false
},
"url": {
"type": "string",
"x-nullable": false,
@@ -3070,11 +3055,32 @@
"HealthMap": {
"type": "object",
"additionalProperties": {
"$ref": "#/definitions/HealthInfo"
"$ref": "#/definitions/HealthStatusString"
},
"x-nullable": false,
"x-omitempty": false
},
"HealthStatusString": {
"type": "string",
"enum": [
"unknown",
"healthy",
"napping",
"starting",
"unhealthy",
"error"
],
"x-enum-varnames": [
"StatusUnknownStr",
"StatusHealthyStr",
"StatusNappingStr",
"StatusStartingStr",
"StatusUnhealthyStr",
"StatusErrorStr"
],
"x-nullable": false,
"x-omitempty": false
},
"HomepageCategory": {
"type": "object",
"properties": {
@@ -3430,6 +3436,11 @@
"x-nullable": false,
"x-omitempty": false
},
"no_loading_page": {
"type": "boolean",
"x-nullable": false,
"x-omitempty": false
},
"proxmox": {
"$ref": "#/definitions/ProxmoxConfig",
"x-nullable": false,
@@ -4234,6 +4245,12 @@
],
"x-nullable": true
},
"index": {
"description": "Index file to serve for single-page app mode",
"type": "string",
"x-nullable": false,
"x-omitempty": false
},
"load_balance": {
"allOf": [
{
@@ -4308,6 +4325,7 @@
"enum": [
"http",
"https",
"h2c",
"tcp",
"udp",
"fileserver"
@@ -4315,6 +4333,12 @@
"x-nullable": false,
"x-omitempty": false
},
"spa": {
"description": "Single-page app mode: serves index for non-existent paths",
"type": "boolean",
"x-nullable": false,
"x-omitempty": false
},
"ssl_certificate": {
"description": "Path to client certificate",
"type": "string",
@@ -4500,6 +4524,11 @@
"x-nullable": false,
"x-omitempty": false
},
"is_excluded": {
"type": "boolean",
"x-nullable": false,
"x-omitempty": false
},
"statuses": {
"type": "array",
"items": {
@@ -5354,6 +5383,12 @@
],
"x-nullable": true
},
"index": {
"description": "Index file to serve for single-page app mode",
"type": "string",
"x-nullable": false,
"x-omitempty": false
},
"load_balance": {
"allOf": [
{
@@ -5428,6 +5463,7 @@
"enum": [
"http",
"https",
"h2c",
"tcp",
"udp",
"fileserver"
@@ -5435,6 +5471,12 @@
"x-nullable": false,
"x-omitempty": false
},
"spa": {
"description": "Single-page app mode: serves index for non-existent paths",
"type": "boolean",
"x-nullable": false,
"x-omitempty": false
},
"ssl_certificate": {
"description": "Path to client certificate",
"type": "string",

View File

@@ -57,8 +57,8 @@ definitions:
type: string
container_name:
type: string
docker_host:
type: string
docker_cfg:
$ref: '#/definitions/DockerProviderConfig'
errors:
type: string
idlewatcher_config:
@@ -192,12 +192,30 @@ definitions:
type: string
container_name:
type: string
docker_host:
type: string
docker_cfg:
$ref: '#/definitions/DockerProviderConfig'
required:
- container_id
- container_name
- docker_host
- docker_cfg
type: object
DockerProviderConfig:
properties:
tls:
$ref: '#/definitions/DockerTLSConfig'
url:
type: string
type: object
DockerTLSConfig:
properties:
ca_file:
type: string
cert_file:
type: string
key_file:
type: string
required:
- ca_file
type: object
ErrorResponse:
properties:
@@ -284,26 +302,6 @@ definitions:
additionalProperties: {}
type: object
type: object
HealthInfo:
properties:
detail:
type: string
latency:
description: latency in microseconds
type: number
status:
enum:
- healthy
- unhealthy
- napping
- starting
- error
- unknown
type: string
uptime:
description: uptime in milliseconds
type: number
type: object
HealthInfoWithoutDetail:
properties:
latency:
@@ -333,32 +331,44 @@ definitions:
- $ref: '#/definitions/HealthExtra'
x-nullable: true
lastSeen:
description: unix timestamp in seconds
type: integer
lastSeenStr:
type: string
latency:
type: number
latencyStr:
type: string
description: latency in milliseconds
type: integer
name:
type: string
started:
description: unix timestamp in seconds
type: integer
startedStr:
type: string
status:
type: string
$ref: '#/definitions/HealthStatusString'
uptime:
description: uptime in seconds
type: number
uptimeStr:
type: string
url:
type: string
type: object
HealthMap:
additionalProperties:
$ref: '#/definitions/HealthInfo'
$ref: '#/definitions/HealthStatusString'
type: object
HealthStatusString:
enum:
- unknown
- healthy
- napping
- starting
- unhealthy
- error
type: string
x-enum-varnames:
- StatusUnknownStr
- StatusHealthyStr
- StatusNappingStr
- StatusStartingStr
- StatusUnhealthyStr
- StatusErrorStr
HomepageCategory:
properties:
items:
@@ -517,6 +527,8 @@ definitions:
description: "0: no idle watcher.\nPositive: idle watcher with idle timeout.\nNegative:
idle watcher as a dependency.\tIdleTimeout time.Duration `json:\"idle_timeout\"
json_ext:\"duration\"`"
no_loading_page:
type: boolean
proxmox:
$ref: '#/definitions/ProxmoxConfig'
start_endpoint:
@@ -897,6 +909,9 @@ definitions:
allOf:
- $ref: '#/definitions/IdlewatcherConfig'
x-nullable: true
index:
description: Index file to serve for single-page app mode
type: string
load_balance:
allOf:
- $ref: '#/definitions/LoadBalancerConfig'
@@ -940,10 +955,14 @@ definitions:
enum:
- http
- https
- h2c
- tcp
- udp
- fileserver
type: string
spa:
description: 'Single-page app mode: serves index for non-existent paths'
type: boolean
ssl_certificate:
description: Path to client certificate
type: string
@@ -1030,6 +1049,8 @@ definitions:
type: number
is_docker:
type: boolean
is_excluded:
type: boolean
statuses:
items:
$ref: '#/definitions/RouteStatus'
@@ -1504,6 +1525,9 @@ definitions:
allOf:
- $ref: '#/definitions/IdlewatcherConfig'
x-nullable: true
index:
description: Index file to serve for single-page app mode
type: string
load_balance:
allOf:
- $ref: '#/definitions/LoadBalancerConfig'
@@ -1547,10 +1571,14 @@ definitions:
enum:
- http
- https
- h2c
- tcp
- udp
- fileserver
type: string
spa:
description: 'Single-page app mode: serves index for non-existent paths'
type: boolean
ssl_certificate:
description: Path to client certificate
type: string

View File

@@ -12,8 +12,6 @@ import (
_ "github.com/yusing/goutils/apitypes"
)
type HealthMap = map[string]routes.HealthInfo // @name HealthMap
// @x-id "health"
// @BasePath /api/v1
// @Summary Get routes health info
@@ -21,16 +19,16 @@ type HealthMap = map[string]routes.HealthInfo // @name HealthMap
// @Tags v1,websocket
// @Accept json
// @Produce json
// @Success 200 {object} HealthMap "Health info by route name"
// @Success 200 {object} routes.HealthMap "Health info by route name"
// @Failure 403 {object} apitypes.ErrorResponse
// @Failure 500 {object} apitypes.ErrorResponse
// @Router /health [get]
func Health(c *gin.Context) {
if httpheaders.IsWebsocket(c.Request.Header) {
websocket.PeriodicWrite(c, 1*time.Second, func() (any, error) {
return routes.GetHealthInfo(), nil
return routes.GetHealthInfoSimple(), nil
})
} else {
c.JSON(http.StatusOK, routes.GetHealthInfo())
c.JSON(http.StatusOK, routes.GetHealthInfoSimple())
}
}

View File

@@ -34,12 +34,12 @@ func Routes(c *gin.Context) {
provider := c.Query("provider")
if provider == "" {
c.JSON(http.StatusOK, slices.Collect(routes.Iter))
c.JSON(http.StatusOK, slices.Collect(routes.IterAll))
return
}
rts := make([]types.Route, 0, routes.NumRoutes())
for r := range routes.Iter {
rts := make([]types.Route, 0, routes.NumAllRoutes())
for r := range routes.IterAll {
if r.ProviderName() == provider {
rts = append(rts, r)
}
@@ -51,14 +51,14 @@ func RoutesWS(c *gin.Context) {
provider := c.Query("provider")
if provider == "" {
websocket.PeriodicWrite(c, 3*time.Second, func() (any, error) {
return slices.Collect(routes.Iter), nil
return slices.Collect(routes.IterAll), nil
})
return
}
websocket.PeriodicWrite(c, 3*time.Second, func() (any, error) {
rts := make([]types.Route, 0, routes.NumRoutes())
for r := range routes.Iter {
rts := make([]types.Route, 0, routes.NumAllRoutes())
for r := range routes.IterAll {
if r.ProviderName() == provider {
rts = append(rts, r)
}

View File

@@ -65,14 +65,12 @@ func AuthCheckHandler(w http.ResponseWriter, r *http.Request) {
func AuthOrProceed(w http.ResponseWriter, r *http.Request) (proceed bool) {
if defaultAuth == nil {
w.WriteHeader(http.StatusServiceUnavailable)
return false
return true
}
err := defaultAuth.CheckToken(r)
if err != nil {
defaultAuth.LoginHandler(w, r)
return false
} else {
return true
}
return true
}

View File

@@ -12,11 +12,12 @@ var blockPageHTML string
var blockPageTemplate = template.Must(template.New("block_page").Parse(blockPageHTML))
func WriteBlockPage(w http.ResponseWriter, status int, error string, logoutURL string) {
func WriteBlockPage(w http.ResponseWriter, status int, errorMessage, actionText, actionURL string) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
blockPageTemplate.Execute(w, map[string]string{
"StatusText": http.StatusText(status),
"Error": error,
"LogoutURL": logoutURL,
"Error": errorMessage,
"ActionURL": actionURL,
"ActionText": actionText,
})
}

View File

@@ -1,14 +1,231 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Access Denied</title>
</head>
<body>
<h1>{{.StatusText}}</h1>
<p>{{.Error}}</p>
<a href="{{.LogoutURL}}">Logout</a>
</body>
<meta name="color-scheme" content="dark" />
<style>
:root {
color-scheme: dark;
--bg0: #070a12;
--bg1: #0b1020;
--card: rgba(255, 255, 255, 0.055);
--card2: rgba(255, 255, 255, 0.05);
--text: rgba(255, 255, 255, 0.92);
--muted: rgba(255, 255, 255, 0.68);
--border: rgba(255, 255, 255, 0.12);
--borderSoft: rgba(255, 255, 255, 0.08);
--borderStrong: rgba(255, 255, 255, 0.14);
--borderHover: rgba(255, 255, 255, 0.22);
--shadow: 0 22px 60px rgba(0, 0, 0, 0.55);
--shadowCard: 0 22px 60px rgba(0, 0, 0, 0.58);
--shadowButton: 0 12px 28px rgba(0, 0, 0, 0.35);
--insetHighlight: inset 0 1px 0 rgba(255, 255, 255, 0.04);
--ring: rgba(120, 160, 210, 0.42);
--accent0: #7aa3c8;
--accent1: #9a8bc7;
--btn: rgba(255, 255, 255, 0.06);
--btnHover: rgba(255, 255, 255, 0.08);
}
* {
box-sizing: border-box;
}
html,
body {
height: 100%;
}
body {
margin: 0;
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto,
Helvetica, Arial, Apple Color Emoji, Segoe UI Emoji;
color: var(--text);
background-color: var(--bg1);
background-image: none;
}
.wrap {
min-height: 100%;
display: grid;
place-items: center;
padding: 28px 16px;
}
.card {
width: min(720px, 100%);
background: var(--card);
border: 1px solid var(--border);
border-radius: 16px;
box-shadow: var(--shadowCard), var(--insetHighlight);
overflow: hidden;
}
.topbar {
display: flex;
align-items: center;
gap: 12px;
padding: 18px 18px 12px;
border-bottom: 1px solid var(--borderSoft);
background: var(--card2);
}
.badge {
width: 38px;
height: 38px;
border-radius: 12px;
display: grid;
place-items: center;
border: 1px solid var(--borderStrong);
background: var(--card2);
}
.badge svg {
opacity: 0.95;
}
.badge .bang {
font-size: 22px;
line-height: 1;
font-weight: 700;
color: rgba(255, 255, 255, 0.9);
transform: translateY(-1px);
}
h1 {
margin: 0;
font-size: 18px;
line-height: 1.25;
letter-spacing: 0.2px;
}
.sub {
margin: 2px 0 0;
font-size: 13px;
color: var(--muted);
}
.content {
padding: 18px;
}
.error {
margin: 0;
padding: 14px 14px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(0, 0, 0, 0.25);
color: rgba(255, 255, 255, 0.8);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
Liberation Mono, Courier New, monospace;
font-size: 13px;
line-height: 1.55;
white-space: pre-wrap;
word-break: break-word;
text-transform: capitalize;
}
.actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: center;
margin-top: 14px;
}
a.button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 8px 12px;
border-radius: 10px;
font-size: 14px;
text-decoration: none;
color: rgba(255, 255, 255, 0.92);
border: 1px solid var(--borderStrong);
background: var(--btn);
transition: transform 120ms ease, border-color 120ms ease,
background 120ms ease, box-shadow 120ms ease;
box-shadow: var(--shadowButton);
}
a.button:hover {
transform: translateY(-1px);
border-color: var(--borderHover);
background: var(--btnHover);
}
a.button:focus-visible {
outline: 0;
box-shadow: 0 0 0 3px var(--ring), var(--shadowButton);
}
.hint {
color: var(--muted);
font-size: 12px;
line-height: 1.4;
}
.hint kbd {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
Liberation Mono, Courier New, monospace;
font-size: 11px;
padding: 2px 4px;
border-radius: 6px;
border: 1px solid var(--borderStrong);
background: var(--btn);
color: rgba(255, 255, 255, 0.86);
}
kbd {
font-weight: 500;
}
.kbd-container {
display: inline-flex;
gap: 2px;
align-items: center;
}
</style>
</head>
<body>
<div class="wrap">
<main class="card" role="main" aria-labelledby="title">
<header class="topbar">
<div class="badge" aria-hidden="true">
<span class="bang">!</span>
</div>
<div>
<h1 id="title">{{.StatusText}}</h1>
<p class="sub">
You dont have permission to access this resource.
</p>
</div>
</header>
<section class="content">
<pre class="error">{{.Error}}</pre>
<div class="actions">
<a class="button" href="{{.ActionURL}}">
<span>{{.ActionText}}</span>
<span aria-hidden="true"></span>
</a>
<div class="hint">
If you just signed in, try refreshing the page.
<span aria-hidden="true"> </span>
<div class="kbd-container">
<kbd>Ctrl</kbd>
<span>+</span>
<kbd>R</kbd>
</div>
</div>
</div>
</section>
</main>
</div>
</body>
</html>

View File

@@ -32,6 +32,8 @@ type (
allowedUsers []string
allowedGroups []string
rateLimit *rate.Limiter
onUnknownPathHandler http.HandlerFunc
}
@@ -66,9 +68,9 @@ func (auth *OIDCProvider) getAppScopedCookieName(baseName string) string {
const (
OIDCAuthInitPath = "/"
OIDCAuthBasePath = "/auth"
OIDCPostAuthPath = OIDCAuthBasePath + "/callback"
OIDCLogoutPath = OIDCAuthBasePath + "/logout"
OIDCAuthBasePath = "/auth/"
OIDCPostAuthPath = OIDCAuthBasePath + "callback"
OIDCLogoutPath = OIDCAuthBasePath + "logout"
)
var (
@@ -123,6 +125,7 @@ func NewOIDCProvider(issuerURL, clientID, clientSecret string, allowedUsers, all
endSessionURL: endSessionURL,
allowedUsers: allowedUsers,
allowedGroups: allowedGroups,
rateLimit: rate.NewLimiter(rate.Every(common.OIDCRateLimitPeriod), common.OIDCRateLimit),
}, nil
}
@@ -165,6 +168,7 @@ func NewOIDCProviderWithCustomClient(baseProvider *OIDCProvider, clientID, clien
endSessionURL: baseProvider.endSessionURL,
allowedUsers: baseProvider.allowedUsers,
allowedGroups: baseProvider.allowedGroups,
rateLimit: baseProvider.rateLimit,
}, nil
}
@@ -228,9 +232,12 @@ func (auth *OIDCProvider) HandleAuth(w http.ResponseWriter, r *http.Request) {
}
}
var rateLimit = rate.NewLimiter(rate.Every(time.Second), 1)
func (auth *OIDCProvider) LoginHandler(w http.ResponseWriter, r *http.Request) {
if !httputils.GetAccept(r.Header).AcceptHTML() {
http.Error(w, "authentication is required", http.StatusForbidden)
return
}
// check for session token
sessionToken, err := r.Cookie(auth.getAppScopedCookieName(CookieOauthSessionToken))
if err == nil { // session token exists
@@ -250,8 +257,8 @@ func (auth *OIDCProvider) LoginHandler(w http.ResponseWriter, r *http.Request) {
return
}
if !rateLimit.Allow() {
http.Error(w, "auth rate limit exceeded", http.StatusTooManyRequests)
if !auth.rateLimit.Allow() {
WriteBlockPage(w, http.StatusTooManyRequests, "auth rate limit exceeded", "Try again", OIDCAuthInitPath)
return
}
@@ -318,34 +325,39 @@ func (auth *OIDCProvider) PostAuthCallbackHandler(w http.ResponseWriter, r *http
// verify state
state, err := r.Cookie(auth.getAppScopedCookieName(CookieOauthState))
if err != nil {
http.Error(w, "missing state cookie", http.StatusBadRequest)
auth.clearCookie(w, r)
WriteBlockPage(w, http.StatusBadRequest, "missing state cookie", "Back to Login", OIDCAuthInitPath)
return
}
if r.URL.Query().Get("state") != state.Value {
http.Error(w, "invalid oauth state", http.StatusBadRequest)
auth.clearCookie(w, r)
WriteBlockPage(w, http.StatusBadRequest, "invalid oauth state", "Back to Login", OIDCAuthInitPath)
return
}
code := r.URL.Query().Get("code")
oauth2Token, err := auth.oauthConfig.Exchange(r.Context(), code, optRedirectPostAuth(r))
if err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
httputils.LogError(r).Msg(fmt.Sprintf("failed to exchange token: %v", err))
auth.clearCookie(w, r)
WriteBlockPage(w, http.StatusInternalServerError, "failed to exchange token", "Try again", OIDCAuthInitPath)
httputils.LogError(r).Msgf("failed to exchange token: %v", err)
return
}
idTokenJWT, idToken, err := auth.getIDToken(r.Context(), oauth2Token)
if err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
httputils.LogError(r).Msg(fmt.Sprintf("failed to get ID token: %v", err))
auth.clearCookie(w, r)
WriteBlockPage(w, http.StatusInternalServerError, "failed to get ID token", "Try again", OIDCAuthInitPath)
httputils.LogError(r).Msgf("failed to get ID token: %v", err)
return
}
if oauth2Token.RefreshToken != "" {
claims, err := parseClaims(idToken)
if err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
httputils.LogError(r).Msg(fmt.Sprintf("failed to parse claims: %v", err))
auth.clearCookie(w, r)
WriteBlockPage(w, http.StatusInternalServerError, "failed to parse claims", "Try again", OIDCAuthInitPath)
httputils.LogError(r).Msgf("failed to parse claims: %v", err)
return
}
session := newSession(claims.Username, claims.Groups)

View File

@@ -15,6 +15,7 @@ import (
"github.com/golang-jwt/jwt/v5"
"github.com/yusing/godoxy/internal/common"
"golang.org/x/oauth2"
"golang.org/x/time/rate"
expect "github.com/yusing/goutils/testing"
)
@@ -42,6 +43,7 @@ func setupMockOIDC(t *testing.T) {
}),
allowedUsers: []string{"test-user"},
allowedGroups: []string{"test-group1", "test-group2"},
rateLimit: rate.NewLimiter(rate.Every(common.OIDCRateLimitPeriod), common.OIDCRateLimit),
}
}

View File

@@ -5,6 +5,7 @@ import (
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"fmt"
"net/http"
"os"
"regexp"
@@ -19,12 +20,14 @@ import (
strutils "github.com/yusing/goutils/strings"
)
type ConfigExtra Config
type Config 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"`
Extra []ConfigExtra `json:"extra,omitempty"`
ACMEKeyPath string `json:"acme_key_path,omitempty"` // shared by all extra providers
Provider string `json:"provider,omitempty"`
Options map[string]strutils.Redacted `json:"options,omitempty"`
@@ -41,13 +44,13 @@ type Config struct {
HTTPClient *http.Client `json:"-"` // for tests only
challengeProvider challenge.Provider
idx int // 0: main, 1+: extra[i]
}
var (
ErrMissingDomain = gperr.New("missing field 'domains'")
ErrMissingEmail = gperr.New("missing field 'email'")
ErrMissingProvider = gperr.New("missing field 'provider'")
ErrMissingCADirURL = gperr.New("missing field 'ca_dir_url'")
ErrMissingField = gperr.New("missing field")
ErrDuplicatedPath = gperr.New("duplicated path")
ErrInvalidDomain = gperr.New("invalid domain")
ErrUnknownProvider = gperr.New("unknown provider")
)
@@ -62,69 +65,22 @@ var domainOrWildcardRE = regexp.MustCompile(`^\*?([^.]+\.)+[^.]+$`)
// Validate implements the utils.CustomValidator interface.
func (cfg *Config) Validate() gperr.Error {
if cfg == nil {
return nil
}
seenPaths := make(map[string]int) // path -> provider idx (0 for main, 1+ for extras)
return cfg.validate(seenPaths)
}
func (cfg *ConfigExtra) Validate() gperr.Error {
return nil // done by main config's validate
}
func (cfg *ConfigExtra) AsConfig() *Config {
return (*Config)(cfg)
}
func (cfg *Config) validate(seenPaths map[string]int) gperr.Error {
if cfg.Provider == "" {
cfg.Provider = ProviderLocal
return nil
}
b := gperr.NewBuilder("autocert errors")
if cfg.Provider == ProviderCustom && cfg.CADirURL == "" {
b.Add(ErrMissingCADirURL)
}
if cfg.Provider != ProviderLocal && cfg.Provider != ProviderPseudo {
if len(cfg.Domains) == 0 {
b.Add(ErrMissingDomain)
}
if cfg.Email == "" {
b.Add(ErrMissingEmail)
}
if cfg.Provider != ProviderCustom {
for i, d := range cfg.Domains {
if !domainOrWildcardRE.MatchString(d) {
b.Add(ErrInvalidDomain.Subjectf("domains[%d]", i))
}
}
}
// check if provider is implemented
providerConstructor, ok := Providers[cfg.Provider]
if !ok {
if cfg.Provider != ProviderCustom {
b.Add(ErrUnknownProvider.
Subject(cfg.Provider).
With(gperr.DoYouMeanField(cfg.Provider, Providers)))
}
} else {
provider, err := providerConstructor(cfg.Options)
if err != nil {
b.Add(err)
} else {
cfg.challengeProvider = provider
}
}
}
if cfg.challengeProvider == nil {
cfg.challengeProvider, _ = Providers[ProviderLocal](nil)
}
return b.Error()
}
func (cfg *Config) dns01Options() []dns01.ChallengeOption {
return []dns01.ChallengeOption{
dns01.CondOption(len(cfg.Resolvers) > 0, dns01.AddRecursiveNameservers(cfg.Resolvers)),
}
}
func (cfg *Config) GetLegoConfig() (*User, *lego.Config, gperr.Error) {
if err := cfg.Validate(); err != nil {
return nil, nil, err
}
if cfg.CertPath == "" {
cfg.CertPath = CertFileDefault
}
@@ -135,6 +91,83 @@ func (cfg *Config) GetLegoConfig() (*User, *lego.Config, gperr.Error) {
cfg.ACMEKeyPath = ACMEKeyFileDefault
}
b := gperr.NewBuilder("certificate error")
// check if cert_path is unique
if first, ok := seenPaths[cfg.CertPath]; ok {
b.Add(ErrDuplicatedPath.Subjectf("cert_path %s", cfg.CertPath).Withf("first seen in %s", fmt.Sprintf("extra[%d]", first)))
} else {
seenPaths[cfg.CertPath] = cfg.idx
}
// check if key_path is unique
if first, ok := seenPaths[cfg.KeyPath]; ok {
b.Add(ErrDuplicatedPath.Subjectf("key_path %s", cfg.KeyPath).Withf("first seen in %s", fmt.Sprintf("extra[%d]", first)))
} else {
seenPaths[cfg.KeyPath] = cfg.idx
}
if cfg.Provider == ProviderCustom && cfg.CADirURL == "" {
b.Add(ErrMissingField.Subject("ca_dir_url"))
}
if cfg.Provider != ProviderLocal && cfg.Provider != ProviderPseudo {
if len(cfg.Domains) == 0 {
b.Add(ErrMissingField.Subject("domains"))
}
if cfg.Email == "" {
b.Add(ErrMissingField.Subject("email"))
}
if cfg.Provider != ProviderCustom {
for i, d := range cfg.Domains {
if !domainOrWildcardRE.MatchString(d) {
b.Add(ErrInvalidDomain.Subjectf("domains[%d]", i))
}
}
}
}
// check if provider is implemented
providerConstructor, ok := Providers[cfg.Provider]
if !ok {
if cfg.Provider != ProviderCustom {
b.Add(ErrUnknownProvider.
Subject(cfg.Provider).
With(gperr.DoYouMeanField(cfg.Provider, Providers)))
}
} else {
provider, err := providerConstructor(cfg.Options)
if err != nil {
b.Add(err)
} else {
cfg.challengeProvider = provider
}
}
if cfg.challengeProvider == nil {
cfg.challengeProvider, _ = Providers[ProviderLocal](nil)
}
if len(cfg.Extra) > 0 {
for i := range cfg.Extra {
cfg.Extra[i] = MergeExtraConfig(cfg, &cfg.Extra[i])
cfg.Extra[i].AsConfig().idx = i + 1
err := cfg.Extra[i].AsConfig().validate(seenPaths)
if err != nil {
b.Add(err.Subjectf("extra[%d]", i))
}
}
}
return b.Error()
}
func (cfg *Config) dns01Options() []dns01.ChallengeOption {
return []dns01.ChallengeOption{
dns01.CondOption(len(cfg.Resolvers) > 0, dns01.AddRecursiveNameservers(cfg.Resolvers)),
}
}
func (cfg *Config) GetLegoConfig() (*User, *lego.Config, error) {
var privKey *ecdsa.PrivateKey
var err error
@@ -178,6 +211,46 @@ func (cfg *Config) GetLegoConfig() (*User, *lego.Config, gperr.Error) {
return user, legoCfg, nil
}
func MergeExtraConfig(mainCfg *Config, extraCfg *ConfigExtra) ConfigExtra {
merged := ConfigExtra(*mainCfg)
merged.Extra = nil
merged.CertPath = extraCfg.CertPath
merged.KeyPath = extraCfg.KeyPath
// NOTE: Using same ACME key as main provider
if extraCfg.Provider != "" {
merged.Provider = extraCfg.Provider
}
if extraCfg.Email != "" {
merged.Email = extraCfg.Email
}
if len(extraCfg.Domains) > 0 {
merged.Domains = extraCfg.Domains
}
if len(extraCfg.Options) > 0 {
merged.Options = extraCfg.Options
}
if len(extraCfg.Resolvers) > 0 {
merged.Resolvers = extraCfg.Resolvers
}
if extraCfg.CADirURL != "" {
merged.CADirURL = extraCfg.CADirURL
}
if len(extraCfg.CACerts) > 0 {
merged.CACerts = extraCfg.CACerts
}
if extraCfg.EABKid != "" {
merged.EABKid = extraCfg.EABKid
}
if extraCfg.EABHmac != "" {
merged.EABHmac = extraCfg.EABHmac
}
if extraCfg.HTTPClient != nil {
merged.HTTPClient = extraCfg.HTTPClient
}
return merged
}
func (cfg *Config) LoadACMEKey() (*ecdsa.PrivateKey, error) {
if common.IsTest {
return nil, os.ErrNotExist

View File

@@ -1,27 +1,32 @@
package autocert
package autocert_test
import (
"fmt"
"testing"
"github.com/stretchr/testify/require"
"github.com/yusing/godoxy/internal/autocert"
"github.com/yusing/godoxy/internal/dnsproviders"
"github.com/yusing/godoxy/internal/serialization"
)
func TestEABConfigRequired(t *testing.T) {
dnsproviders.InitProviders()
tests := []struct {
name string
cfg *Config
cfg *autocert.Config
wantErr bool
}{
{name: "Missing EABKid", cfg: &Config{EABHmac: "1234567890"}, wantErr: true},
{name: "Missing EABHmac", cfg: &Config{EABKid: "1234567890"}, wantErr: true},
{name: "Valid EAB", cfg: &Config{EABKid: "1234567890", EABHmac: "1234567890"}, wantErr: false},
{name: "Missing EABKid", cfg: &autocert.Config{EABHmac: "1234567890"}, wantErr: true},
{name: "Missing EABHmac", cfg: &autocert.Config{EABKid: "1234567890"}, wantErr: true},
{name: "Valid EAB", cfg: &autocert.Config{EABKid: "1234567890", EABHmac: "1234567890"}, wantErr: false},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
yaml := fmt.Appendf(nil, "eab_kid: %s\neab_hmac: %s", test.cfg.EABKid, test.cfg.EABHmac)
cfg := Config{}
cfg := autocert.Config{}
err := serialization.UnmarshalValidateYAML(yaml, &cfg)
if (err != nil) != test.wantErr {
t.Errorf("Validate() error = %v, wantErr %v", err, test.wantErr)
@@ -29,3 +34,27 @@ func TestEABConfigRequired(t *testing.T) {
})
}
}
func TestExtraCertKeyPathsUnique(t *testing.T) {
t.Run("duplicate cert_path rejected", func(t *testing.T) {
cfg := &autocert.Config{
Provider: autocert.ProviderLocal,
Extra: []autocert.ConfigExtra{
{CertPath: "a.crt", KeyPath: "a.key"},
{CertPath: "a.crt", KeyPath: "b.key"},
},
}
require.Error(t, cfg.Validate())
})
t.Run("duplicate key_path rejected", func(t *testing.T) {
cfg := &autocert.Config{
Provider: autocert.ProviderLocal,
Extra: []autocert.ConfigExtra{
{CertPath: "a.crt", KeyPath: "a.key"},
{CertPath: "b.crt", KeyPath: "a.key"},
},
}
require.Error(t, cfg.Validate())
})
}

View File

@@ -5,5 +5,4 @@ const (
CertFileDefault = certBasePath + "cert.crt"
KeyFileDefault = certBasePath + "priv.key"
ACMEKeyFileDefault = certBasePath + "acme.key"
LastFailureFile = certBasePath + ".last_failure"
)

View File

@@ -1,15 +1,19 @@
package autocert
import (
"context"
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"errors"
"fmt"
"io/fs"
"maps"
"os"
"path"
"path/filepath"
"slices"
"strings"
"sync"
"sync/atomic"
"time"
@@ -27,21 +31,34 @@ import (
type (
Provider struct {
logger zerolog.Logger
cfg *Config
user *User
legoCfg *lego.Config
client *lego.Client
lastFailure time.Time
lastFailureFile string
legoCert *certificate.Resource
tlsCert *tls.Certificate
certExpiries CertExpiries
extraProviders []*Provider
sniMatcher sniMatcher
forceRenewalCh chan struct{}
forceRenewalDoneCh atomic.Value // chan struct{}
scheduleRenewalOnce sync.Once
}
CertExpiries map[string]time.Time
RenewMode uint8
)
var ErrGetCertFailure = errors.New("get certificate failed")
var ErrNoCertificate = errors.New("no certificate found")
const (
// renew failed for whatever reason, 1 hour cooldown
@@ -50,26 +67,57 @@ const (
requestCooldownDuration = 15 * time.Second
)
const (
renewModeForce = iota
renewModeIfNeeded
)
// could be nil
var ActiveProvider atomic.Pointer[Provider]
func NewProvider(cfg *Config, user *User, legoCfg *lego.Config) *Provider {
return &Provider{
cfg: cfg,
user: user,
legoCfg: legoCfg,
func NewProvider(cfg *Config, user *User, legoCfg *lego.Config) (*Provider, error) {
p := &Provider{
cfg: cfg,
user: user,
legoCfg: legoCfg,
lastFailureFile: lastFailureFileFor(cfg.CertPath, cfg.KeyPath),
forceRenewalCh: make(chan struct{}, 1),
}
p.forceRenewalDoneCh.Store(emptyForceRenewalDoneCh)
if cfg.idx == 0 {
p.logger = log.With().Str("provider", "main").Logger()
} else {
p.logger = log.With().Str("provider", fmt.Sprintf("extra[%d]", cfg.idx)).Logger()
}
if err := p.setupExtraProviders(); err != nil {
return nil, err
}
return p, nil
}
func (p *Provider) GetCert(_ *tls.ClientHelloInfo) (*tls.Certificate, error) {
func (p *Provider) GetCert(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
if p.tlsCert == nil {
return nil, ErrGetCertFailure
return nil, ErrNoCertificate
}
if hello == nil || hello.ServerName == "" {
return p.tlsCert, nil
}
if prov := p.sniMatcher.match(hello.ServerName); prov != nil && prov.tlsCert != nil {
return prov.tlsCert, nil
}
return p.tlsCert, nil
}
func (p *Provider) GetName() string {
return p.cfg.Provider
if p.cfg.idx == 0 {
return "main"
}
return fmt.Sprintf("extra[%d]", p.cfg.idx)
}
func (p *Provider) fmtError(err error) error {
return gperr.PrependSubject(fmt.Sprintf("provider: %s", p.GetName()), err)
}
func (p *Provider) GetCertPath() string {
@@ -90,7 +138,7 @@ func (p *Provider) GetLastFailure() (time.Time, error) {
}
if p.lastFailure.IsZero() {
data, err := os.ReadFile(LastFailureFile)
data, err := os.ReadFile(p.lastFailureFile)
if err != nil {
if !os.IsNotExist(err) {
return time.Time{}, err
@@ -108,7 +156,7 @@ func (p *Provider) UpdateLastFailure() error {
}
t := time.Now()
p.lastFailure = t
return os.WriteFile(LastFailureFile, t.AppendFormat(nil, time.RFC3339), 0o600)
return os.WriteFile(p.lastFailureFile, t.AppendFormat(nil, time.RFC3339), 0o600)
}
func (p *Provider) ClearLastFailure() error {
@@ -116,29 +164,88 @@ func (p *Provider) ClearLastFailure() error {
return nil
}
p.lastFailure = time.Time{}
return os.Remove(LastFailureFile)
err := os.Remove(p.lastFailureFile)
if err != nil && !errors.Is(err, fs.ErrNotExist) {
return err
}
return nil
}
// allProviders returns all providers including this provider and all extra providers.
func (p *Provider) allProviders() []*Provider {
return append([]*Provider{p}, p.extraProviders...)
}
// ObtainCertIfNotExistsAll obtains a new certificate for this provider and all extra providers if they do not exist.
func (p *Provider) ObtainCertIfNotExistsAll() error {
errs := gperr.NewGroup("obtain cert error")
for _, provider := range p.allProviders() {
errs.Go(func() error {
if err := provider.obtainCertIfNotExists(); err != nil {
return fmt.Errorf("failed to obtain cert for %s: %w", provider.GetName(), err)
}
return nil
})
}
p.rebuildSNIMatcher()
return errs.Wait().Error()
}
// obtainCertIfNotExists obtains a new certificate for this provider if it does not exist.
func (p *Provider) obtainCertIfNotExists() error {
err := p.LoadCert()
if err == nil {
return nil
}
if !errors.Is(err, fs.ErrNotExist) {
return err
}
// check last failure
lastFailure, err := p.GetLastFailure()
if err != nil {
return fmt.Errorf("failed to get last failure: %w", err)
}
if !lastFailure.IsZero() && time.Since(lastFailure) < requestCooldownDuration {
return fmt.Errorf("still in cooldown until %s", strutils.FormatTime(lastFailure.Add(requestCooldownDuration).Local()))
}
p.logger.Info().Msg("cert not found, obtaining new cert")
return p.ObtainCert()
}
// ObtainCertAll renews existing certificates or obtains new certificates for this provider and all extra providers.
func (p *Provider) ObtainCertAll() error {
errs := gperr.NewGroup("obtain cert error")
for _, provider := range p.allProviders() {
errs.Go(func() error {
if err := provider.obtainCertIfNotExists(); err != nil {
return fmt.Errorf("failed to obtain cert for %s: %w", provider.GetName(), err)
}
return nil
})
}
return errs.Wait().Error()
}
// ObtainCert renews existing certificate or obtains a new certificate for this provider.
func (p *Provider) ObtainCert() error {
if p.cfg.Provider == ProviderLocal {
return nil
}
if p.cfg.Provider == ProviderPseudo {
log.Info().Msg("init client for pseudo provider")
p.logger.Info().Msg("init client for pseudo provider")
<-time.After(time.Second)
log.Info().Msg("registering acme for pseudo provider")
p.logger.Info().Msg("registering acme for pseudo provider")
<-time.After(time.Second)
log.Info().Msg("obtained cert for pseudo provider")
p.logger.Info().Msg("obtained cert for pseudo provider")
return nil
}
if lastFailure, err := p.GetLastFailure(); err != nil {
return err
} else if time.Since(lastFailure) < requestCooldownDuration {
return fmt.Errorf("%w: still in cooldown until %s", ErrGetCertFailure, strutils.FormatTime(lastFailure.Add(requestCooldownDuration).Local()))
}
if p.client == nil {
if err := p.initClient(); err != nil {
return err
@@ -198,6 +305,7 @@ func (p *Provider) ObtainCert() error {
}
p.tlsCert = &tlsCert
p.certExpiries = expiries
p.rebuildSNIMatcher()
if err := p.ClearLastFailure(); err != nil {
return fmt.Errorf("failed to clear last failure: %w", err)
@@ -206,19 +314,37 @@ func (p *Provider) ObtainCert() error {
}
func (p *Provider) LoadCert() error {
var errs gperr.Builder
cert, err := tls.LoadX509KeyPair(p.cfg.CertPath, p.cfg.KeyPath)
if err != nil {
return fmt.Errorf("load SSL certificate: %w", err)
errs.Addf("load SSL certificate: %w", p.fmtError(err))
}
expiries, err := getCertExpiries(&cert)
if err != nil {
return fmt.Errorf("parse SSL certificate: %w", err)
errs.Addf("parse SSL certificate: %w", p.fmtError(err))
}
p.tlsCert = &cert
p.certExpiries = expiries
log.Info().Msgf("next cert renewal in %s", strutils.FormatDuration(time.Until(p.ShouldRenewOn())))
return p.renewIfNeeded()
for _, ep := range p.extraProviders {
if err := ep.LoadCert(); err != nil {
errs.Add(err)
}
}
p.rebuildSNIMatcher()
return errs.Error()
}
// PrintCertExpiriesAll prints the certificate expiries for this provider and all extra providers.
func (p *Provider) PrintCertExpiriesAll() {
for _, provider := range p.allProviders() {
for domain, expiry := range provider.certExpiries {
p.logger.Info().Str("domain", domain).Msgf("certificate expire on %s", strutils.FormatTime(expiry))
}
}
}
// ShouldRenewOn returns the time at which the certificate should be renewed.
@@ -226,59 +352,126 @@ func (p *Provider) ShouldRenewOn() time.Time {
for _, expiry := range p.certExpiries {
return expiry.AddDate(0, -1, 0) // 1 month before
}
// this line should never be reached
panic("no certificate available")
// this line should never be reached in production, but will be useful for testing
return time.Now().AddDate(0, 1, 0) // 1 month after
}
func (p *Provider) ScheduleRenewal(parent task.Parent) {
// ForceExpiryAll triggers immediate certificate renewal for this provider and all extra providers.
// Returns true if the renewal was triggered, false if the renewal was dropped.
//
// If at least one renewal is triggered, returns true.
func (p *Provider) ForceExpiryAll() (ok bool) {
doneCh := make(chan struct{})
if swapped := p.forceRenewalDoneCh.CompareAndSwap(emptyForceRenewalDoneCh, doneCh); !swapped { // already in progress
close(doneCh)
return false
}
select {
case p.forceRenewalCh <- struct{}{}:
ok = true
default:
}
for _, ep := range p.extraProviders {
if ep.ForceExpiryAll() {
ok = true
}
}
return ok
}
// WaitRenewalDone waits for the renewal to complete.
// Returns false if the renewal was dropped.
func (p *Provider) WaitRenewalDone(ctx context.Context) bool {
done, ok := p.forceRenewalDoneCh.Load().(chan struct{})
if !ok || done == nil {
return false
}
select {
case <-done:
case <-ctx.Done():
return false
}
for _, ep := range p.extraProviders {
if !ep.WaitRenewalDone(ctx) {
return false
}
}
return true
}
// ScheduleRenewalAll schedules the renewal of the certificate for this provider and all extra providers.
func (p *Provider) ScheduleRenewalAll(parent task.Parent) {
p.scheduleRenewalOnce.Do(func() {
p.scheduleRenewal(parent)
})
for _, ep := range p.extraProviders {
ep.scheduleRenewalOnce.Do(func() {
ep.scheduleRenewal(parent)
})
}
}
var emptyForceRenewalDoneCh any = chan struct{}(nil)
// scheduleRenewal schedules the renewal of the certificate for this provider.
func (p *Provider) scheduleRenewal(parent task.Parent) {
if p.GetName() == ProviderLocal || p.GetName() == ProviderPseudo {
return
}
go func() {
renewalTime := p.ShouldRenewOn()
timer := time.NewTimer(time.Until(renewalTime))
defer timer.Stop()
task := parent.Subtask("cert-renew-scheduler", true)
timer := time.NewTimer(time.Until(p.ShouldRenewOn()))
task := parent.Subtask("cert-renew-scheduler:"+filepath.Base(p.cfg.CertPath), true)
renew := func(renewMode RenewMode) {
defer func() {
if done, ok := p.forceRenewalDoneCh.Swap(emptyForceRenewalDoneCh).(chan struct{}); ok && done != nil {
close(done)
}
}()
renewed, err := p.renew(renewMode)
if err != nil {
gperr.LogWarn("autocert: cert renew failed", p.fmtError(err))
notif.Notify(&notif.LogMessage{
Level: zerolog.ErrorLevel,
Title: fmt.Sprintf("SSL certificate renewal failed for %s", p.GetName()),
Body: notif.MessageBody(err.Error()),
})
return
}
if renewed {
p.rebuildSNIMatcher()
notif.Notify(&notif.LogMessage{
Level: zerolog.InfoLevel,
Title: fmt.Sprintf("SSL certificate renewed for %s", p.GetName()),
Body: notif.ListBody(p.cfg.Domains),
})
// Reset on success
if err := p.ClearLastFailure(); err != nil {
gperr.LogWarn("autocert: failed to clear last failure", p.fmtError(err))
}
timer.Reset(time.Until(p.ShouldRenewOn()))
}
}
go func() {
defer timer.Stop()
defer task.Finish(nil)
for {
select {
case <-task.Context().Done():
return
case <-p.forceRenewalCh:
renew(renewModeForce)
case <-timer.C:
// Retry after 1 hour on failure
lastFailure, err := p.GetLastFailure()
if err != nil {
gperr.LogWarn("autocert: failed to get last failure", err)
continue
}
if !lastFailure.IsZero() && time.Since(lastFailure) < renewalCooldownDuration {
continue
}
if err := p.renewIfNeeded(); err != nil {
gperr.LogWarn("autocert: cert renew failed", err)
if err := p.UpdateLastFailure(); err != nil {
gperr.LogWarn("autocert: failed to update last failure", err)
}
notif.Notify(&notif.LogMessage{
Level: zerolog.ErrorLevel,
Title: "SSL certificate renewal failed",
Body: notif.MessageBody(err.Error()),
})
continue
}
notif.Notify(&notif.LogMessage{
Level: zerolog.InfoLevel,
Title: "SSL certificate renewed",
Body: notif.ListBody(p.cfg.Domains),
})
// Reset on success
if err := p.ClearLastFailure(); err != nil {
gperr.LogWarn("autocert: failed to clear last failure", err)
}
renewalTime = p.ShouldRenewOn()
timer.Reset(time.Until(renewalTime))
renew(renewModeIfNeeded)
}
}
}()
@@ -334,10 +527,10 @@ func (p *Provider) saveCert(cert *certificate.Resource) error {
}
/* This should have been done in setup
but double check is always a good choice.*/
_, err := os.Stat(path.Dir(p.cfg.CertPath))
_, err := os.Stat(filepath.Dir(p.cfg.CertPath))
if err != nil {
if os.IsNotExist(err) {
if err = os.MkdirAll(path.Dir(p.cfg.CertPath), 0o755); err != nil {
if err = os.MkdirAll(filepath.Dir(p.cfg.CertPath), 0o755); err != nil {
return err
}
} else {
@@ -377,21 +570,42 @@ func (p *Provider) certState() CertState {
return CertStateValid
}
func (p *Provider) renewIfNeeded() error {
func (p *Provider) renew(mode RenewMode) (renewed bool, err error) {
if p.cfg.Provider == ProviderLocal {
return nil
return false, nil
}
switch p.certState() {
case CertStateExpired:
log.Info().Msg("certs expired, renewing")
case CertStateMismatch:
log.Info().Msg("cert domains mismatch with config, renewing")
default:
return nil
if mode != renewModeForce {
// Retry after 1 hour on failure
lastFailure, err := p.GetLastFailure()
if err != nil {
return false, fmt.Errorf("failed to get last failure: %w", err)
}
if !lastFailure.IsZero() && time.Since(lastFailure) < renewalCooldownDuration {
until := lastFailure.Add(renewalCooldownDuration).Local()
return false, fmt.Errorf("still in cooldown until %s", strutils.FormatTime(until))
}
}
return p.ObtainCert()
if mode == renewModeIfNeeded {
switch p.certState() {
case CertStateExpired:
log.Info().Msg("certs expired, renewing")
case CertStateMismatch:
log.Info().Msg("cert domains mismatch with config, renewing")
default:
return false, nil
}
}
if mode == renewModeForce {
log.Info().Msg("force renewing cert by user request")
}
if err := p.ObtainCert(); err != nil {
return false, err
}
return true, nil
}
func getCertExpiries(cert *tls.Certificate) (CertExpiries, error) {
@@ -411,3 +625,21 @@ func getCertExpiries(cert *tls.Certificate) (CertExpiries, error) {
}
return r, nil
}
func lastFailureFileFor(certPath, keyPath string) string {
dir := filepath.Dir(certPath)
sum := sha256.Sum256([]byte(certPath + "|" + keyPath))
return filepath.Join(dir, fmt.Sprintf(".last_failure-%x", sum[:6]))
}
func (p *Provider) rebuildSNIMatcher() {
if p.cfg.idx != 0 { // only main provider has extra providers
return
}
p.sniMatcher = sniMatcher{}
p.sniMatcher.addProvider(p)
for _, ep := range p.extraProviders {
p.sniMatcher.addProvider(ep)
}
}

View File

@@ -10,12 +10,15 @@ import (
"encoding/base64"
"encoding/json"
"encoding/pem"
"fmt"
"io"
"math/big"
"net"
"net/http"
"net/http/httptest"
"sort"
"strings"
"sync"
"testing"
"time"
@@ -24,6 +27,368 @@ import (
"github.com/yusing/godoxy/internal/dnsproviders"
)
// TestACMEServer implements a minimal ACME server for testing with request tracking.
type TestACMEServer struct {
server *httptest.Server
caCert *x509.Certificate
caKey *rsa.PrivateKey
clientCSRs map[string]*x509.CertificateRequest
orderDomains map[string][]string
authzDomains map[string]string
orderSeq int
certRequestCount map[string]int
renewalRequestCount map[string]int
mu sync.Mutex
}
func newTestACMEServer(t *testing.T) *TestACMEServer {
t.Helper()
// Generate CA certificate and key
caKey, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)
caTemplate := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{
Organization: []string{"Test CA"},
Country: []string{"US"},
Province: []string{""},
Locality: []string{"Test"},
StreetAddress: []string{""},
PostalCode: []string{""},
},
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
NotBefore: time.Now(),
NotAfter: time.Now().Add(365 * 24 * time.Hour),
IsCA: true,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth},
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign,
BasicConstraintsValid: true,
}
caCertDER, err := x509.CreateCertificate(rand.Reader, caTemplate, caTemplate, &caKey.PublicKey, caKey)
require.NoError(t, err)
caCert, err := x509.ParseCertificate(caCertDER)
require.NoError(t, err)
acme := &TestACMEServer{
caCert: caCert,
caKey: caKey,
clientCSRs: make(map[string]*x509.CertificateRequest),
orderDomains: make(map[string][]string),
authzDomains: make(map[string]string),
orderSeq: 0,
certRequestCount: make(map[string]int),
renewalRequestCount: make(map[string]int),
}
mux := http.NewServeMux()
acme.setupRoutes(mux)
acme.server = httptest.NewUnstartedServer(mux)
acme.server.TLS = &tls.Config{
Certificates: []tls.Certificate{
{
Certificate: [][]byte{caCert.Raw},
PrivateKey: caKey,
},
},
MinVersion: tls.VersionTLS12,
}
acme.server.StartTLS()
return acme
}
func (s *TestACMEServer) Close() {
s.server.Close()
}
func (s *TestACMEServer) URL() string {
return s.server.URL
}
func (s *TestACMEServer) httpClient() *http.Client {
certPool := x509.NewCertPool()
certPool.AddCert(s.caCert)
return &http.Client{
Transport: &http.Transport{
DialContext: (&net.Dialer{
Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
TLSHandshakeTimeout: 30 * time.Second,
ResponseHeaderTimeout: 30 * time.Second,
TLSClientConfig: &tls.Config{
RootCAs: certPool,
MinVersion: tls.VersionTLS12,
},
},
}
}
func (s *TestACMEServer) setupRoutes(mux *http.ServeMux) {
mux.HandleFunc("/acme/acme/directory", s.handleDirectory)
mux.HandleFunc("/acme/new-nonce", s.handleNewNonce)
mux.HandleFunc("/acme/new-account", s.handleNewAccount)
mux.HandleFunc("/acme/new-order", s.handleNewOrder)
mux.HandleFunc("/acme/authz/", s.handleAuthorization)
mux.HandleFunc("/acme/chall/", s.handleChallenge)
mux.HandleFunc("/acme/order/", s.handleOrder)
mux.HandleFunc("/acme/cert/", s.handleCertificate)
}
func (s *TestACMEServer) handleDirectory(w http.ResponseWriter, r *http.Request) {
directory := map[string]any{
"newNonce": s.server.URL + "/acme/new-nonce",
"newAccount": s.server.URL + "/acme/new-account",
"newOrder": s.server.URL + "/acme/new-order",
"keyChange": s.server.URL + "/acme/key-change",
"meta": map[string]any{
"termsOfService": s.server.URL + "/terms",
},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(directory)
}
func (s *TestACMEServer) handleNewNonce(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Replay-Nonce", "test-nonce-12345")
w.WriteHeader(http.StatusOK)
}
func (s *TestACMEServer) handleNewAccount(w http.ResponseWriter, r *http.Request) {
account := map[string]any{
"status": "valid",
"contact": []string{"mailto:test@example.com"},
"orders": s.server.URL + "/acme/orders",
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Location", s.server.URL+"/acme/account/1")
w.Header().Set("Replay-Nonce", "test-nonce-67890")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(account)
}
func (s *TestACMEServer) handleNewOrder(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
var jws struct {
Payload string `json:"payload"`
}
json.Unmarshal(body, &jws)
payloadBytes, _ := base64.RawURLEncoding.DecodeString(jws.Payload)
var orderReq struct {
Identifiers []map[string]string `json:"identifiers"`
}
json.Unmarshal(payloadBytes, &orderReq)
domains := []string{}
for _, id := range orderReq.Identifiers {
domains = append(domains, id["value"])
}
sort.Strings(domains)
domainKey := strings.Join(domains, ",")
s.mu.Lock()
s.orderSeq++
orderID := fmt.Sprintf("test-order-%d", s.orderSeq)
authzID := fmt.Sprintf("test-authz-%d", s.orderSeq)
s.orderDomains[orderID] = domains
if len(domains) > 0 {
s.authzDomains[authzID] = domains[0]
}
s.certRequestCount[domainKey]++
s.mu.Unlock()
order := map[string]any{
"status": "ready",
"expires": time.Now().Add(24 * time.Hour).Format(time.RFC3339),
"identifiers": orderReq.Identifiers,
"authorizations": []string{s.server.URL + "/acme/authz/" + authzID},
"finalize": s.server.URL + "/acme/order/" + orderID + "/finalize",
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Location", s.server.URL+"/acme/order/"+orderID)
w.Header().Set("Replay-Nonce", "test-nonce-order")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(order)
}
func (s *TestACMEServer) handleAuthorization(w http.ResponseWriter, r *http.Request) {
authzID := strings.TrimPrefix(r.URL.Path, "/acme/authz/")
domain := s.authzDomains[authzID]
if domain == "" {
domain = "test.example.com"
}
authz := map[string]any{
"status": "valid",
"expires": time.Now().Add(24 * time.Hour).Format(time.RFC3339),
"identifier": map[string]string{"type": "dns", "value": domain},
"challenges": []map[string]any{
{
"type": "dns-01",
"status": "valid",
"url": s.server.URL + "/acme/chall/test-chall-789",
"token": "test-token-abc123",
},
},
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Replay-Nonce", "test-nonce-authz")
json.NewEncoder(w).Encode(authz)
}
func (s *TestACMEServer) handleChallenge(w http.ResponseWriter, r *http.Request) {
challenge := map[string]any{
"type": "dns-01",
"status": "valid",
"url": r.URL.String(),
"token": "test-token-abc123",
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Replay-Nonce", "test-nonce-chall")
json.NewEncoder(w).Encode(challenge)
}
func (s *TestACMEServer) handleOrder(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/finalize") {
s.handleFinalize(w, r)
return
}
orderID := strings.TrimPrefix(r.URL.Path, "/acme/order/")
domains := s.orderDomains[orderID]
if len(domains) == 0 {
domains = []string{"test.example.com"}
}
certURL := s.server.URL + "/acme/cert/" + orderID
order := map[string]any{
"status": "valid",
"expires": time.Now().Add(24 * time.Hour).Format(time.RFC3339),
"identifiers": func() []map[string]string {
out := make([]map[string]string, 0, len(domains))
for _, d := range domains {
out = append(out, map[string]string{"type": "dns", "value": d})
}
return out
}(),
"certificate": certURL,
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Replay-Nonce", "test-nonce-order-get")
json.NewEncoder(w).Encode(order)
}
func (s *TestACMEServer) handleFinalize(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Failed to read request", http.StatusBadRequest)
return
}
csr, err := s.extractCSRFromJWS(body)
if err != nil {
http.Error(w, "Invalid CSR: "+err.Error(), http.StatusBadRequest)
return
}
orderID := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/acme/order/"), "/finalize")
s.mu.Lock()
s.clientCSRs[orderID] = csr
// Detect renewal: if we already have a certificate for these domains, it's a renewal
domains := csr.DNSNames
sort.Strings(domains)
domainKey := strings.Join(domains, ",")
if s.certRequestCount[domainKey] > 1 {
s.renewalRequestCount[domainKey]++
}
s.mu.Unlock()
certURL := s.server.URL + "/acme/cert/" + orderID
order := map[string]any{
"status": "valid",
"expires": time.Now().Add(24 * time.Hour).Format(time.RFC3339),
"identifiers": func() []map[string]string {
out := make([]map[string]string, 0, len(domains))
for _, d := range domains {
out = append(out, map[string]string{"type": "dns", "value": d})
}
return out
}(),
"certificate": certURL,
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Location", strings.TrimSuffix(r.URL.String(), "/finalize"))
w.Header().Set("Replay-Nonce", "test-nonce-finalize")
json.NewEncoder(w).Encode(order)
}
func (s *TestACMEServer) extractCSRFromJWS(jwsData []byte) (*x509.CertificateRequest, error) {
var jws struct {
Payload string `json:"payload"`
}
if err := json.Unmarshal(jwsData, &jws); err != nil {
return nil, err
}
payloadBytes, err := base64.RawURLEncoding.DecodeString(jws.Payload)
if err != nil {
return nil, err
}
var finalizeReq struct {
CSR string `json:"csr"`
}
if err := json.Unmarshal(payloadBytes, &finalizeReq); err != nil {
return nil, err
}
csrBytes, err := base64.RawURLEncoding.DecodeString(finalizeReq.CSR)
if err != nil {
return nil, err
}
return x509.ParseCertificateRequest(csrBytes)
}
func (s *TestACMEServer) handleCertificate(w http.ResponseWriter, r *http.Request) {
orderID := strings.TrimPrefix(r.URL.Path, "/acme/cert/")
csr, exists := s.clientCSRs[orderID]
if !exists {
http.Error(w, "No CSR found for order", http.StatusBadRequest)
return
}
template := &x509.Certificate{
SerialNumber: big.NewInt(2),
Subject: pkix.Name{
Organization: []string{"Test Cert"},
Country: []string{"US"},
},
DNSNames: csr.DNSNames,
NotBefore: time.Now(),
NotAfter: time.Now().Add(90 * 24 * time.Hour),
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
}
certDER, err := x509.CreateCertificate(rand.Reader, template, s.caCert, csr.PublicKey, s.caKey)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
caPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: s.caCert.Raw})
w.Header().Set("Content-Type", "application/pem-certificate-chain")
w.Header().Set("Replay-Nonce", "test-nonce-cert")
w.Write(append(certPEM, caPEM...))
}
func TestMain(m *testing.M) {
dnsproviders.InitProviders()
m.Run()
@@ -41,7 +406,7 @@ func TestCustomProvider(t *testing.T) {
ACMEKeyPath: "certs/custom-acme.key",
}
err := cfg.Validate()
err := error(cfg.Validate())
require.NoError(t, err)
user, legoCfg, err := cfg.GetLegoConfig()
@@ -62,7 +427,8 @@ func TestCustomProvider(t *testing.T) {
err := cfg.Validate()
require.Error(t, err)
require.Contains(t, err.Error(), "missing field 'ca_dir_url'")
require.Contains(t, err.Error(), "missing field")
require.Contains(t, err.Error(), "ca_dir_url")
})
t.Run("custom provider with step-ca internal CA", func(t *testing.T) {
@@ -76,7 +442,7 @@ func TestCustomProvider(t *testing.T) {
ACMEKeyPath: "certs/internal-acme.key",
}
err := cfg.Validate()
err := error(cfg.Validate())
require.NoError(t, err)
user, legoCfg, err := cfg.GetLegoConfig()
@@ -86,9 +452,10 @@ func TestCustomProvider(t *testing.T) {
require.Equal(t, "https://step-ca.internal:443/acme/acme/directory", legoCfg.CADirURL)
require.Equal(t, "admin@internal.com", user.Email)
provider := autocert.NewProvider(cfg, user, legoCfg)
provider, err := autocert.NewProvider(cfg, user, legoCfg)
require.NoError(t, err)
require.NotNil(t, provider)
require.Equal(t, autocert.ProviderCustom, provider.GetName())
require.Equal(t, "main", provider.GetName())
require.Equal(t, "certs/internal.crt", provider.GetCertPath())
require.Equal(t, "certs/internal.key", provider.GetKeyPath())
})
@@ -119,7 +486,8 @@ func TestObtainCertFromCustomProvider(t *testing.T) {
require.NotNil(t, user)
require.NotNil(t, legoCfg)
provider := autocert.NewProvider(cfg, user, legoCfg)
provider, err := autocert.NewProvider(cfg, user, legoCfg)
require.NoError(t, err)
require.NotNil(t, provider)
// Test obtaining certificate
@@ -161,7 +529,8 @@ func TestObtainCertFromCustomProvider(t *testing.T) {
require.NotNil(t, user)
require.NotNil(t, legoCfg)
provider := autocert.NewProvider(cfg, user, legoCfg)
provider, err := autocert.NewProvider(cfg, user, legoCfg)
require.NoError(t, err)
require.NotNil(t, provider)
err = provider.ObtainCert()
@@ -178,330 +547,3 @@ func TestObtainCertFromCustomProvider(t *testing.T) {
require.True(t, time.Now().After(x509Cert.NotBefore))
})
}
// testACMEServer implements a minimal ACME server for testing.
type testACMEServer struct {
server *httptest.Server
caCert *x509.Certificate
caKey *rsa.PrivateKey
clientCSRs map[string]*x509.CertificateRequest
orderID string
}
func newTestACMEServer(t *testing.T) *testACMEServer {
t.Helper()
// Generate CA certificate and key
caKey, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)
caTemplate := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{
Organization: []string{"Test CA"},
Country: []string{"US"},
Province: []string{""},
Locality: []string{"Test"},
StreetAddress: []string{""},
PostalCode: []string{""},
},
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
NotBefore: time.Now(),
NotAfter: time.Now().Add(365 * 24 * time.Hour),
IsCA: true,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth},
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign,
BasicConstraintsValid: true,
}
caCertDER, err := x509.CreateCertificate(rand.Reader, caTemplate, caTemplate, &caKey.PublicKey, caKey)
require.NoError(t, err)
caCert, err := x509.ParseCertificate(caCertDER)
require.NoError(t, err)
acme := &testACMEServer{
caCert: caCert,
caKey: caKey,
clientCSRs: make(map[string]*x509.CertificateRequest),
orderID: "test-order-123",
}
mux := http.NewServeMux()
acme.setupRoutes(mux)
acme.server = httptest.NewUnstartedServer(mux)
acme.server.TLS = &tls.Config{
Certificates: []tls.Certificate{
{
Certificate: [][]byte{caCert.Raw},
PrivateKey: caKey,
},
},
MinVersion: tls.VersionTLS12,
}
acme.server.StartTLS()
return acme
}
func (s *testACMEServer) Close() {
s.server.Close()
}
func (s *testACMEServer) URL() string {
return s.server.URL
}
func (s *testACMEServer) httpClient() *http.Client {
certPool := x509.NewCertPool()
certPool.AddCert(s.caCert)
return &http.Client{
Transport: &http.Transport{
DialContext: (&net.Dialer{
Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
TLSHandshakeTimeout: 30 * time.Second,
ResponseHeaderTimeout: 30 * time.Second,
TLSClientConfig: &tls.Config{
RootCAs: certPool,
MinVersion: tls.VersionTLS12,
},
},
}
}
func (s *testACMEServer) setupRoutes(mux *http.ServeMux) {
// ACME directory endpoint
mux.HandleFunc("/acme/acme/directory", s.handleDirectory)
// ACME endpoints
mux.HandleFunc("/acme/new-nonce", s.handleNewNonce)
mux.HandleFunc("/acme/new-account", s.handleNewAccount)
mux.HandleFunc("/acme/new-order", s.handleNewOrder)
mux.HandleFunc("/acme/authz/", s.handleAuthorization)
mux.HandleFunc("/acme/chall/", s.handleChallenge)
mux.HandleFunc("/acme/order/", s.handleOrder)
mux.HandleFunc("/acme/cert/", s.handleCertificate)
}
func (s *testACMEServer) handleDirectory(w http.ResponseWriter, r *http.Request) {
directory := map[string]interface{}{
"newNonce": s.server.URL + "/acme/new-nonce",
"newAccount": s.server.URL + "/acme/new-account",
"newOrder": s.server.URL + "/acme/new-order",
"keyChange": s.server.URL + "/acme/key-change",
"meta": map[string]interface{}{
"termsOfService": s.server.URL + "/terms",
},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(directory)
}
func (s *testACMEServer) handleNewNonce(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Replay-Nonce", "test-nonce-12345")
w.WriteHeader(http.StatusOK)
}
func (s *testACMEServer) handleNewAccount(w http.ResponseWriter, r *http.Request) {
account := map[string]interface{}{
"status": "valid",
"contact": []string{"mailto:test@example.com"},
"orders": s.server.URL + "/acme/orders",
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Location", s.server.URL+"/acme/account/1")
w.Header().Set("Replay-Nonce", "test-nonce-67890")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(account)
}
func (s *testACMEServer) handleNewOrder(w http.ResponseWriter, r *http.Request) {
authzID := "test-authz-456"
order := map[string]interface{}{
"status": "ready", // Skip pending state for simplicity
"expires": time.Now().Add(24 * time.Hour).Format(time.RFC3339),
"identifiers": []map[string]string{{"type": "dns", "value": "test.example.com"}},
"authorizations": []string{s.server.URL + "/acme/authz/" + authzID},
"finalize": s.server.URL + "/acme/order/" + s.orderID + "/finalize",
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Location", s.server.URL+"/acme/order/"+s.orderID)
w.Header().Set("Replay-Nonce", "test-nonce-order")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(order)
}
func (s *testACMEServer) handleAuthorization(w http.ResponseWriter, r *http.Request) {
authz := map[string]interface{}{
"status": "valid", // Skip challenge validation for simplicity
"expires": time.Now().Add(24 * time.Hour).Format(time.RFC3339),
"identifier": map[string]string{"type": "dns", "value": "test.example.com"},
"challenges": []map[string]interface{}{
{
"type": "dns-01",
"status": "valid",
"url": s.server.URL + "/acme/chall/test-chall-789",
"token": "test-token-abc123",
},
},
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Replay-Nonce", "test-nonce-authz")
json.NewEncoder(w).Encode(authz)
}
func (s *testACMEServer) handleChallenge(w http.ResponseWriter, r *http.Request) {
challenge := map[string]interface{}{
"type": "dns-01",
"status": "valid",
"url": r.URL.String(),
"token": "test-token-abc123",
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Replay-Nonce", "test-nonce-chall")
json.NewEncoder(w).Encode(challenge)
}
func (s *testACMEServer) handleOrder(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/finalize") {
s.handleFinalize(w, r)
return
}
certURL := s.server.URL + "/acme/cert/" + s.orderID
order := map[string]interface{}{
"status": "valid",
"expires": time.Now().Add(24 * time.Hour).Format(time.RFC3339),
"identifiers": []map[string]string{{"type": "dns", "value": "test.example.com"}},
"certificate": certURL,
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Replay-Nonce", "test-nonce-order-get")
json.NewEncoder(w).Encode(order)
}
func (s *testACMEServer) handleFinalize(w http.ResponseWriter, r *http.Request) {
// Read the JWS payload
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Failed to read request", http.StatusBadRequest)
return
}
// Extract CSR from JWS payload
csr, err := s.extractCSRFromJWS(body)
if err != nil {
http.Error(w, "Invalid CSR: "+err.Error(), http.StatusBadRequest)
return
}
// Store the CSR for certificate generation
s.clientCSRs[s.orderID] = csr
certURL := s.server.URL + "/acme/cert/" + s.orderID
order := map[string]interface{}{
"status": "valid",
"expires": time.Now().Add(24 * time.Hour).Format(time.RFC3339),
"identifiers": []map[string]string{{"type": "dns", "value": "test.example.com"}},
"certificate": certURL,
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Location", strings.TrimSuffix(r.URL.String(), "/finalize"))
w.Header().Set("Replay-Nonce", "test-nonce-finalize")
json.NewEncoder(w).Encode(order)
}
func (s *testACMEServer) extractCSRFromJWS(jwsData []byte) (*x509.CertificateRequest, error) {
// Parse the JWS structure
var jws struct {
Protected string `json:"protected"`
Payload string `json:"payload"`
Signature string `json:"signature"`
}
if err := json.Unmarshal(jwsData, &jws); err != nil {
return nil, err
}
// Decode the payload
payloadBytes, err := base64.RawURLEncoding.DecodeString(jws.Payload)
if err != nil {
return nil, err
}
// Parse the finalize request
var finalizeReq struct {
CSR string `json:"csr"`
}
if err := json.Unmarshal(payloadBytes, &finalizeReq); err != nil {
return nil, err
}
// Decode the CSR
csrBytes, err := base64.RawURLEncoding.DecodeString(finalizeReq.CSR)
if err != nil {
return nil, err
}
// Parse the CSR
csr, err := x509.ParseCertificateRequest(csrBytes)
if err != nil {
return nil, err
}
return csr, nil
}
func (s *testACMEServer) handleCertificate(w http.ResponseWriter, r *http.Request) {
// Extract order ID from URL
orderID := strings.TrimPrefix(r.URL.Path, "/acme/cert/")
// Get the CSR for this order
csr, exists := s.clientCSRs[orderID]
if !exists {
http.Error(w, "No CSR found for order", http.StatusBadRequest)
return
}
// Create certificate using the public key from the client's CSR
template := &x509.Certificate{
SerialNumber: big.NewInt(2),
Subject: pkix.Name{
Organization: []string{"Test Cert"},
Country: []string{"US"},
},
DNSNames: csr.DNSNames,
NotBefore: time.Now(),
NotAfter: time.Now().Add(90 * 24 * time.Hour),
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
}
// Use the public key from the CSR and sign with CA key
certDER, err := x509.CreateCertificate(rand.Reader, template, s.caCert, csr.PublicKey, s.caKey)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Return certificate chain
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
caPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: s.caCert.Raw})
w.Header().Set("Content-Type", "application/pem-certificate-chain")
w.Header().Set("Replay-Nonce", "test-nonce-cert")
w.Write(append(certPEM, caPEM...))
}

View File

@@ -0,0 +1,90 @@
//nolint:errchkjson,errcheck
package provider_test
import (
"fmt"
"os"
"testing"
"github.com/stretchr/testify/require"
"github.com/yusing/godoxy/internal/autocert"
"github.com/yusing/godoxy/internal/serialization"
"github.com/yusing/goutils/task"
)
func buildMultiCertYAML(serverURL string) []byte {
return fmt.Appendf(nil, `
email: main@example.com
domains: [main.example.com]
provider: custom
ca_dir_url: %s/acme/acme/directory
cert_path: certs/main.crt
key_path: certs/main.key
extra:
- email: extra1@example.com
domains: [extra1.example.com]
cert_path: certs/extra1.crt
key_path: certs/extra1.key
- email: extra2@example.com
domains: [extra2.example.com]
cert_path: certs/extra2.crt
key_path: certs/extra2.key
`, serverURL)
}
func TestMultipleCertificatesLifecycle(t *testing.T) {
acmeServer := newTestACMEServer(t)
defer acmeServer.Close()
yamlConfig := buildMultiCertYAML(acmeServer.URL())
var cfg autocert.Config
cfg.HTTPClient = acmeServer.httpClient()
/* unmarshal yaml config with multiple certs */
err := error(serialization.UnmarshalValidateYAML(yamlConfig, &cfg))
require.NoError(t, err)
require.Equal(t, []string{"main.example.com"}, cfg.Domains)
require.Len(t, cfg.Extra, 2)
require.Equal(t, []string{"extra1.example.com"}, cfg.Extra[0].Domains)
require.Equal(t, []string{"extra2.example.com"}, cfg.Extra[1].Domains)
var provider *autocert.Provider
/* initialize autocert with multi-cert config */
user, legoCfg, gerr := cfg.GetLegoConfig()
require.NoError(t, gerr)
provider, err = autocert.NewProvider(&cfg, user, legoCfg)
require.NoError(t, err)
require.NotNil(t, provider)
// Start renewal scheduler
root := task.RootTask("test", false)
defer root.Finish(nil)
provider.ScheduleRenewalAll(root)
require.Equal(t, "custom", cfg.Provider)
require.Equal(t, "custom", cfg.Extra[0].Provider)
require.Equal(t, "custom", cfg.Extra[1].Provider)
/* track cert requests for all configs */
os.MkdirAll("certs", 0755)
defer os.RemoveAll("certs")
err = provider.ObtainCertIfNotExistsAll()
require.NoError(t, err)
require.Equal(t, 1, acmeServer.certRequestCount["main.example.com"])
require.Equal(t, 1, acmeServer.certRequestCount["extra1.example.com"])
require.Equal(t, 1, acmeServer.certRequestCount["extra2.example.com"])
/* track renewal scheduling and requests */
// force renewal for all providers and wait for completion
ok := provider.ForceExpiryAll()
require.True(t, ok)
provider.WaitRenewalDone(t.Context())
require.Equal(t, 1, acmeServer.renewalRequestCount["main.example.com"])
require.Equal(t, 1, acmeServer.renewalRequestCount["extra1.example.com"])
require.Equal(t, 1, acmeServer.renewalRequestCount["extra2.example.com"])
}

View File

@@ -0,0 +1,416 @@
package provider_test
import (
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/yusing/godoxy/internal/autocert"
)
func writeSelfSignedCert(t *testing.T, dir string, dnsNames []string) (string, string) {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)
serial, err := rand.Int(rand.Reader, big.NewInt(1<<62))
require.NoError(t, err)
cn := ""
if len(dnsNames) > 0 {
cn = dnsNames[0]
}
template := &x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{
CommonName: cn,
},
NotBefore: time.Now().Add(-time.Minute),
NotAfter: time.Now().Add(24 * time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
DNSNames: dnsNames,
}
der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
require.NoError(t, err)
certPath := filepath.Join(dir, "cert.pem")
keyPath := filepath.Join(dir, "key.pem")
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)})
require.NoError(t, os.WriteFile(certPath, certPEM, 0o644))
require.NoError(t, os.WriteFile(keyPath, keyPEM, 0o600))
return certPath, keyPath
}
func TestGetCertBySNI(t *testing.T) {
t.Run("extra cert used when main does not match", func(t *testing.T) {
mainDir := t.TempDir()
mainCert, mainKey := writeSelfSignedCert(t, mainDir, []string{"*.example.com"})
extraDir := t.TempDir()
extraCert, extraKey := writeSelfSignedCert(t, extraDir, []string{"*.internal.example.com"})
cfg := &autocert.Config{
Provider: autocert.ProviderLocal,
CertPath: mainCert,
KeyPath: mainKey,
Extra: []autocert.ConfigExtra{
{CertPath: extraCert, KeyPath: extraKey},
},
}
require.NoError(t, cfg.Validate())
p, err := autocert.NewProvider(cfg, nil, nil)
require.NoError(t, err)
err = p.LoadCert()
require.NoError(t, err)
cert, err := p.GetCert(&tls.ClientHelloInfo{ServerName: "a.internal.example.com"})
require.NoError(t, err)
leaf, err := x509.ParseCertificate(cert.Certificate[0])
require.NoError(t, err)
require.Contains(t, leaf.DNSNames, "*.internal.example.com")
})
t.Run("exact match wins over wildcard match", func(t *testing.T) {
mainDir := t.TempDir()
mainCert, mainKey := writeSelfSignedCert(t, mainDir, []string{"*.example.com"})
extraDir := t.TempDir()
extraCert, extraKey := writeSelfSignedCert(t, extraDir, []string{"foo.example.com"})
cfg := &autocert.Config{
Provider: autocert.ProviderLocal,
CertPath: mainCert,
KeyPath: mainKey,
Extra: []autocert.ConfigExtra{
{CertPath: extraCert, KeyPath: extraKey},
},
}
require.NoError(t, cfg.Validate())
p, err := autocert.NewProvider(cfg, nil, nil)
require.NoError(t, err)
err = p.LoadCert()
require.NoError(t, err)
cert, err := p.GetCert(&tls.ClientHelloInfo{ServerName: "foo.example.com"})
require.NoError(t, err)
leaf, err := x509.ParseCertificate(cert.Certificate[0])
require.NoError(t, err)
require.Contains(t, leaf.DNSNames, "foo.example.com")
})
t.Run("main cert fallback when no match", func(t *testing.T) {
mainDir := t.TempDir()
mainCert, mainKey := writeSelfSignedCert(t, mainDir, []string{"*.example.com"})
extraDir := t.TempDir()
extraCert, extraKey := writeSelfSignedCert(t, extraDir, []string{"*.test.com"})
cfg := &autocert.Config{
Provider: autocert.ProviderLocal,
CertPath: mainCert,
KeyPath: mainKey,
Extra: []autocert.ConfigExtra{
{CertPath: extraCert, KeyPath: extraKey},
},
}
require.NoError(t, cfg.Validate())
p, err := autocert.NewProvider(cfg, nil, nil)
require.NoError(t, err)
err = p.LoadCert()
require.NoError(t, err)
cert, err := p.GetCert(&tls.ClientHelloInfo{ServerName: "unknown.domain.com"})
require.NoError(t, err)
leaf, err := x509.ParseCertificate(cert.Certificate[0])
require.NoError(t, err)
require.Contains(t, leaf.DNSNames, "*.example.com")
})
t.Run("nil ServerName returns main cert", func(t *testing.T) {
mainDir := t.TempDir()
mainCert, mainKey := writeSelfSignedCert(t, mainDir, []string{"*.example.com"})
cfg := &autocert.Config{
Provider: autocert.ProviderLocal,
CertPath: mainCert,
KeyPath: mainKey,
}
require.NoError(t, cfg.Validate())
p, err := autocert.NewProvider(cfg, nil, nil)
require.NoError(t, err)
err = p.LoadCert()
require.NoError(t, err)
cert, err := p.GetCert(nil)
require.NoError(t, err)
leaf, err := x509.ParseCertificate(cert.Certificate[0])
require.NoError(t, err)
require.Contains(t, leaf.DNSNames, "*.example.com")
})
t.Run("empty ServerName returns main cert", func(t *testing.T) {
mainDir := t.TempDir()
mainCert, mainKey := writeSelfSignedCert(t, mainDir, []string{"*.example.com"})
cfg := &autocert.Config{
Provider: autocert.ProviderLocal,
CertPath: mainCert,
KeyPath: mainKey,
}
require.NoError(t, cfg.Validate())
p, err := autocert.NewProvider(cfg, nil, nil)
require.NoError(t, err)
err = p.LoadCert()
require.NoError(t, err)
cert, err := p.GetCert(&tls.ClientHelloInfo{ServerName: ""})
require.NoError(t, err)
leaf, err := x509.ParseCertificate(cert.Certificate[0])
require.NoError(t, err)
require.Contains(t, leaf.DNSNames, "*.example.com")
})
t.Run("case insensitive matching", func(t *testing.T) {
mainDir := t.TempDir()
mainCert, mainKey := writeSelfSignedCert(t, mainDir, []string{"*.example.com"})
extraDir := t.TempDir()
extraCert, extraKey := writeSelfSignedCert(t, extraDir, []string{"Foo.Example.COM"})
cfg := &autocert.Config{
Provider: autocert.ProviderLocal,
CertPath: mainCert,
KeyPath: mainKey,
Extra: []autocert.ConfigExtra{
{CertPath: extraCert, KeyPath: extraKey},
},
}
require.NoError(t, cfg.Validate())
p, err := autocert.NewProvider(cfg, nil, nil)
require.NoError(t, err)
err = p.LoadCert()
require.NoError(t, err)
cert, err := p.GetCert(&tls.ClientHelloInfo{ServerName: "FOO.EXAMPLE.COM"})
require.NoError(t, err)
leaf, err := x509.ParseCertificate(cert.Certificate[0])
require.NoError(t, err)
require.Contains(t, leaf.DNSNames, "Foo.Example.COM")
})
t.Run("normalization with trailing dot and whitespace", func(t *testing.T) {
mainDir := t.TempDir()
mainCert, mainKey := writeSelfSignedCert(t, mainDir, []string{"*.example.com"})
extraDir := t.TempDir()
extraCert, extraKey := writeSelfSignedCert(t, extraDir, []string{"foo.example.com"})
cfg := &autocert.Config{
Provider: autocert.ProviderLocal,
CertPath: mainCert,
KeyPath: mainKey,
Extra: []autocert.ConfigExtra{
{CertPath: extraCert, KeyPath: extraKey},
},
}
require.NoError(t, cfg.Validate())
p, err := autocert.NewProvider(cfg, nil, nil)
require.NoError(t, err)
err = p.LoadCert()
require.NoError(t, err)
cert, err := p.GetCert(&tls.ClientHelloInfo{ServerName: " foo.example.com. "})
require.NoError(t, err)
leaf, err := x509.ParseCertificate(cert.Certificate[0])
require.NoError(t, err)
require.Contains(t, leaf.DNSNames, "foo.example.com")
})
t.Run("longest wildcard match wins", func(t *testing.T) {
mainDir := t.TempDir()
mainCert, mainKey := writeSelfSignedCert(t, mainDir, []string{"*.example.com"})
extraDir1 := t.TempDir()
extraCert1, extraKey1 := writeSelfSignedCert(t, extraDir1, []string{"*.a.example.com"})
cfg := &autocert.Config{
Provider: autocert.ProviderLocal,
CertPath: mainCert,
KeyPath: mainKey,
Extra: []autocert.ConfigExtra{
{CertPath: extraCert1, KeyPath: extraKey1},
},
}
require.NoError(t, cfg.Validate())
p, err := autocert.NewProvider(cfg, nil, nil)
require.NoError(t, err)
err = p.LoadCert()
require.NoError(t, err)
cert, err := p.GetCert(&tls.ClientHelloInfo{ServerName: "foo.a.example.com"})
require.NoError(t, err)
leaf, err := x509.ParseCertificate(cert.Certificate[0])
require.NoError(t, err)
require.Contains(t, leaf.DNSNames, "*.a.example.com")
})
t.Run("main cert wildcard match", func(t *testing.T) {
mainDir := t.TempDir()
mainCert, mainKey := writeSelfSignedCert(t, mainDir, []string{"*.example.com"})
cfg := &autocert.Config{
Provider: autocert.ProviderLocal,
CertPath: mainCert,
KeyPath: mainKey,
}
require.NoError(t, cfg.Validate())
p, err := autocert.NewProvider(cfg, nil, nil)
require.NoError(t, err)
err = p.LoadCert()
require.NoError(t, err)
cert, err := p.GetCert(&tls.ClientHelloInfo{ServerName: "bar.example.com"})
require.NoError(t, err)
leaf, err := x509.ParseCertificate(cert.Certificate[0])
require.NoError(t, err)
require.Contains(t, leaf.DNSNames, "*.example.com")
})
t.Run("multiple extra certs", func(t *testing.T) {
mainDir := t.TempDir()
mainCert, mainKey := writeSelfSignedCert(t, mainDir, []string{"*.example.com"})
extraDir1 := t.TempDir()
extraCert1, extraKey1 := writeSelfSignedCert(t, extraDir1, []string{"*.test.com"})
extraDir2 := t.TempDir()
extraCert2, extraKey2 := writeSelfSignedCert(t, extraDir2, []string{"*.dev.com"})
cfg := &autocert.Config{
Provider: autocert.ProviderLocal,
CertPath: mainCert,
KeyPath: mainKey,
Extra: []autocert.ConfigExtra{
{CertPath: extraCert1, KeyPath: extraKey1},
{CertPath: extraCert2, KeyPath: extraKey2},
},
}
require.NoError(t, cfg.Validate())
p, err := autocert.NewProvider(cfg, nil, nil)
require.NoError(t, err)
err = p.LoadCert()
require.NoError(t, err)
cert1, err := p.GetCert(&tls.ClientHelloInfo{ServerName: "foo.test.com"})
require.NoError(t, err)
leaf1, err := x509.ParseCertificate(cert1.Certificate[0])
require.NoError(t, err)
require.Contains(t, leaf1.DNSNames, "*.test.com")
cert2, err := p.GetCert(&tls.ClientHelloInfo{ServerName: "bar.dev.com"})
require.NoError(t, err)
leaf2, err := x509.ParseCertificate(cert2.Certificate[0])
require.NoError(t, err)
require.Contains(t, leaf2.DNSNames, "*.dev.com")
})
t.Run("multiple DNSNames in cert", func(t *testing.T) {
mainDir := t.TempDir()
mainCert, mainKey := writeSelfSignedCert(t, mainDir, []string{"*.example.com"})
extraDir := t.TempDir()
extraCert, extraKey := writeSelfSignedCert(t, extraDir, []string{"foo.example.com", "bar.example.com", "*.test.com"})
cfg := &autocert.Config{
Provider: autocert.ProviderLocal,
CertPath: mainCert,
KeyPath: mainKey,
Extra: []autocert.ConfigExtra{
{CertPath: extraCert, KeyPath: extraKey},
},
}
require.NoError(t, cfg.Validate())
p, err := autocert.NewProvider(cfg, nil, nil)
require.NoError(t, err)
err = p.LoadCert()
require.NoError(t, err)
cert1, err := p.GetCert(&tls.ClientHelloInfo{ServerName: "foo.example.com"})
require.NoError(t, err)
leaf1, err := x509.ParseCertificate(cert1.Certificate[0])
require.NoError(t, err)
require.Contains(t, leaf1.DNSNames, "foo.example.com")
cert2, err := p.GetCert(&tls.ClientHelloInfo{ServerName: "bar.example.com"})
require.NoError(t, err)
leaf2, err := x509.ParseCertificate(cert2.Certificate[0])
require.NoError(t, err)
require.Contains(t, leaf2.DNSNames, "bar.example.com")
cert3, err := p.GetCert(&tls.ClientHelloInfo{ServerName: "baz.test.com"})
require.NoError(t, err)
leaf3, err := x509.ParseCertificate(cert3.Certificate[0])
require.NoError(t, err)
require.Contains(t, leaf3.DNSNames, "*.test.com")
})
}

View File

@@ -1,28 +1,30 @@
package autocert
import (
"errors"
"os"
"github.com/rs/zerolog/log"
strutils "github.com/yusing/goutils/strings"
gperr "github.com/yusing/goutils/errs"
)
func (p *Provider) Setup() (err error) {
if err = p.LoadCert(); err != nil {
if !errors.Is(err, os.ErrNotExist) { // ignore if cert doesn't exist
return err
}
log.Debug().Msg("obtaining cert due to error loading cert")
if err = p.ObtainCert(); err != nil {
return err
}
func (p *Provider) setupExtraProviders() gperr.Error {
p.sniMatcher = sniMatcher{}
if len(p.cfg.Extra) == 0 {
return nil
}
for _, expiry := range p.GetExpiries() {
log.Info().Msg("certificate expire on " + strutils.FormatTime(expiry))
break
}
p.extraProviders = make([]*Provider, 0, len(p.cfg.Extra))
return nil
errs := gperr.NewBuilder("setup extra providers error")
for _, extra := range p.cfg.Extra {
user, legoCfg, err := extra.AsConfig().GetLegoConfig()
if err != nil {
errs.Add(p.fmtError(err))
continue
}
ep, err := NewProvider(extra.AsConfig(), user, legoCfg)
if err != nil {
errs.Add(p.fmtError(err))
continue
}
p.extraProviders = append(p.extraProviders, ep)
}
return errs.Error()
}

View File

@@ -0,0 +1,82 @@
package autocert_test
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/yusing/godoxy/internal/autocert"
"github.com/yusing/godoxy/internal/dnsproviders"
"github.com/yusing/godoxy/internal/serialization"
strutils "github.com/yusing/goutils/strings"
)
func TestSetupExtraProviders(t *testing.T) {
dnsproviders.InitProviders()
cfgYAML := `
email: test@example.com
domains: [example.com]
provider: custom
ca_dir_url: https://ca.example.com:9000/acme/acme/directory
cert_path: certs/test.crt
key_path: certs/test.key
options: {key: value}
resolvers: [8.8.8.8]
ca_certs: [ca.crt]
eab_kid: eabKid
eab_hmac: eabHmac
extra:
- cert_path: certs/extra.crt
key_path: certs/extra.key
- cert_path: certs/extra2.crt
key_path: certs/extra2.key
email: override@example.com
provider: pseudo
domains: [override.com]
ca_dir_url: https://ca2.example.com/directory
options: {opt2: val2}
resolvers: [1.1.1.1]
ca_certs: [ca2.crt]
eab_kid: eabKid2
eab_hmac: eabHmac2
`
var cfg autocert.Config
err := error(serialization.UnmarshalValidateYAML([]byte(cfgYAML), &cfg))
require.NoError(t, err)
// Test: extra[0] inherits all fields from main except CertPath and KeyPath.
merged0 := cfg.Extra[0]
require.Equal(t, "certs/extra.crt", merged0.CertPath)
require.Equal(t, "certs/extra.key", merged0.KeyPath)
// Inherited fields from main config:
require.Equal(t, "test@example.com", merged0.Email) // inherited
require.Equal(t, "custom", merged0.Provider) // inherited
require.Equal(t, []string{"example.com"}, merged0.Domains) // inherited
require.Equal(t, "https://ca.example.com:9000/acme/acme/directory", merged0.CADirURL) // inherited
require.Equal(t, map[string]strutils.Redacted{"key": "value"}, merged0.Options) // inherited
require.Equal(t, []string{"8.8.8.8"}, merged0.Resolvers) // inherited
require.Equal(t, []string{"ca.crt"}, merged0.CACerts) // inherited
require.Equal(t, "eabKid", merged0.EABKid) // inherited
require.Equal(t, "eabHmac", merged0.EABHmac) // inherited
require.Equal(t, cfg.HTTPClient, merged0.HTTPClient) // inherited
require.Nil(t, merged0.Extra)
// Test: extra[1] overrides some fields, and inherits others.
merged1 := cfg.Extra[1]
require.Equal(t, "certs/extra2.crt", merged1.CertPath)
require.Equal(t, "certs/extra2.key", merged1.KeyPath)
// Overridden fields:
require.Equal(t, "override@example.com", merged1.Email) // overridden
require.Equal(t, "pseudo", merged1.Provider) // overridden
require.Equal(t, []string{"override.com"}, merged1.Domains) // overridden
require.Equal(t, "https://ca2.example.com/directory", merged1.CADirURL) // overridden
require.Equal(t, map[string]strutils.Redacted{"opt2": "val2"}, merged1.Options) // overridden
require.Equal(t, []string{"1.1.1.1"}, merged1.Resolvers) // overridden
require.Equal(t, []string{"ca2.crt"}, merged1.CACerts) // overridden
require.Equal(t, "eabKid2", merged1.EABKid) // overridden
require.Equal(t, "eabHmac2", merged1.EABHmac) // overridden
// Inherited field:
require.Equal(t, cfg.HTTPClient, merged1.HTTPClient) // inherited
require.Nil(t, merged1.Extra)
}

View File

@@ -0,0 +1,129 @@
package autocert
import (
"crypto/x509"
"strings"
)
type sniMatcher struct {
exact map[string]*Provider
root sniTreeNode
}
type sniTreeNode struct {
children map[string]*sniTreeNode
wildcard *Provider
}
func (m *sniMatcher) match(serverName string) *Provider {
if m == nil {
return nil
}
serverName = normalizeServerName(serverName)
if serverName == "" {
return nil
}
if m.exact != nil {
if p, ok := m.exact[serverName]; ok {
return p
}
}
return m.matchSuffixTree(serverName)
}
func (m *sniMatcher) matchSuffixTree(serverName string) *Provider {
n := &m.root
labels := strings.Split(serverName, ".")
var best *Provider
for i := len(labels) - 1; i >= 0; i-- {
if n.children == nil {
break
}
next := n.children[labels[i]]
if next == nil {
break
}
n = next
consumed := len(labels) - i
remaining := len(labels) - consumed
if remaining == 1 && n.wildcard != nil {
best = n.wildcard
}
}
return best
}
func normalizeServerName(s string) string {
s = strings.TrimSpace(s)
s = strings.TrimSuffix(s, ".")
return strings.ToLower(s)
}
func (m *sniMatcher) addProvider(p *Provider) {
if p == nil || p.tlsCert == nil || len(p.tlsCert.Certificate) == 0 {
return
}
leaf, err := x509.ParseCertificate(p.tlsCert.Certificate[0])
if err != nil {
return
}
addName := func(name string) {
name = normalizeServerName(name)
if name == "" {
return
}
if after, ok := strings.CutPrefix(name, "*."); ok {
suffix := after
if suffix == "" {
return
}
m.insertWildcardSuffix(suffix, p)
return
}
m.insertExact(name, p)
}
if leaf.Subject.CommonName != "" {
addName(leaf.Subject.CommonName)
}
for _, n := range leaf.DNSNames {
addName(n)
}
}
func (m *sniMatcher) insertExact(name string, p *Provider) {
if name == "" || p == nil {
return
}
if m.exact == nil {
m.exact = make(map[string]*Provider)
}
if _, exists := m.exact[name]; !exists {
m.exact[name] = p
}
}
func (m *sniMatcher) insertWildcardSuffix(suffix string, p *Provider) {
if suffix == "" || p == nil {
return
}
n := &m.root
labels := strings.Split(suffix, ".")
for i := len(labels) - 1; i >= 0; i-- {
if n.children == nil {
n.children = make(map[string]*sniTreeNode)
}
next := n.children[labels[i]]
if next == nil {
next = &sniTreeNode{}
n.children[labels[i]] = next
}
n = next
}
if n.wildcard == nil {
n.wildcard = p
}
}

View File

@@ -0,0 +1,104 @@
package autocert
import (
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"math/big"
"testing"
"time"
)
func createTLSCert(dnsNames []string) (*tls.Certificate, error) {
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return nil, err
}
serial, err := rand.Int(rand.Reader, big.NewInt(1<<62))
if err != nil {
return nil, err
}
cn := ""
if len(dnsNames) > 0 {
cn = dnsNames[0]
}
template := &x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{
CommonName: cn,
},
NotBefore: time.Now().Add(-time.Minute),
NotAfter: time.Now().Add(24 * time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
DNSNames: dnsNames,
}
der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
if err != nil {
return nil, err
}
return &tls.Certificate{
Certificate: [][]byte{der},
PrivateKey: key,
}, nil
}
func BenchmarkSNIMatcher(b *testing.B) {
matcher := sniMatcher{}
wildcard1Cert, err := createTLSCert([]string{"*.example.com"})
if err != nil {
b.Fatal(err)
}
wildcard1 := &Provider{tlsCert: wildcard1Cert}
wildcard2Cert, err := createTLSCert([]string{"*.test.com"})
if err != nil {
b.Fatal(err)
}
wildcard2 := &Provider{tlsCert: wildcard2Cert}
wildcard3Cert, err := createTLSCert([]string{"*.foo.com"})
if err != nil {
b.Fatal(err)
}
wildcard3 := &Provider{tlsCert: wildcard3Cert}
exact1Cert, err := createTLSCert([]string{"bar.example.com"})
if err != nil {
b.Fatal(err)
}
exact1 := &Provider{tlsCert: exact1Cert}
exact2Cert, err := createTLSCert([]string{"baz.test.com"})
if err != nil {
b.Fatal(err)
}
exact2 := &Provider{tlsCert: exact2Cert}
matcher.addProvider(wildcard1)
matcher.addProvider(wildcard2)
matcher.addProvider(wildcard3)
matcher.addProvider(exact1)
matcher.addProvider(exact2)
b.Run("MatchWildcard", func(b *testing.B) {
for b.Loop() {
_ = matcher.match("sub.example.com")
}
})
b.Run("MatchExact", func(b *testing.B) {
for b.Loop() {
_ = matcher.match("bar.example.com")
}
})
}

View File

@@ -9,6 +9,6 @@ import (
type Provider interface {
Setup() error
GetCert(*tls.ClientHelloInfo) (*tls.Certificate, error)
ScheduleRenewal(task.Parent)
ObtainCert() error
ScheduleRenewalAll(task.Parent)
ObtainCertAll() error
}

View File

@@ -13,6 +13,8 @@ var (
IsDebug = env.GetEnvBool("DEBUG", IsTest)
IsTrace = env.GetEnvBool("TRACE", false) && IsDebug
ShortLinkPrefix = env.GetEnvString("SHORTLINK_PREFIX", "go")
ProxyHTTPAddr,
ProxyHTTPHost,
ProxyHTTPPort,
@@ -39,12 +41,14 @@ var (
DebugDisableAuth = env.GetEnvBool("DEBUG_DISABLE_AUTH", false)
// OIDC Configuration.
OIDCIssuerURL = env.GetEnvString("OIDC_ISSUER_URL", "")
OIDCClientID = env.GetEnvString("OIDC_CLIENT_ID", "")
OIDCClientSecret = env.GetEnvString("OIDC_CLIENT_SECRET", "")
OIDCScopes = env.GetEnvCommaSep("OIDC_SCOPES", "openid, profile, email, groups")
OIDCAllowedUsers = env.GetEnvCommaSep("OIDC_ALLOWED_USERS", "")
OIDCAllowedGroups = env.GetEnvCommaSep("OIDC_ALLOWED_GROUPS", "")
OIDCIssuerURL = env.GetEnvString("OIDC_ISSUER_URL", "")
OIDCClientID = env.GetEnvString("OIDC_CLIENT_ID", "")
OIDCClientSecret = env.GetEnvString("OIDC_CLIENT_SECRET", "")
OIDCScopes = env.GetEnvCommaSep("OIDC_SCOPES", "openid, profile, email, groups")
OIDCAllowedUsers = env.GetEnvCommaSep("OIDC_ALLOWED_USERS", "")
OIDCAllowedGroups = env.GetEnvCommaSep("OIDC_ALLOWED_GROUPS", "")
OIDCRateLimit = env.GetEnvInt("OIDC_RATE_LIMIT", 10)
OIDCRateLimitPeriod = env.GetEnvDuation("OIDC_RATE_LIMIT_PERIOD", time.Second)
// metrics configuration
MetricsDisableCPU = env.GetEnvBool("METRICS_DISABLE_CPU", false)

View File

@@ -3,12 +3,10 @@ package config
import (
"errors"
"fmt"
"io/fs"
"sync"
"time"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/yusing/godoxy/internal/common"
config "github.com/yusing/godoxy/internal/config/types"
"github.com/yusing/godoxy/internal/notif"
@@ -62,11 +60,6 @@ func Load() error {
cfgWatcher = watcher.NewConfigFileWatcher(common.ConfigFileName)
initErr := state.InitFromFile(common.ConfigPath)
if errors.Is(initErr, fs.ErrNotExist) {
// log only
log.Warn().Msg("config file not found, using default config")
initErr = nil
}
err := errors.Join(initErr, state.StartProviders())
if err != nil {
logNotifyError("init", err)
@@ -90,8 +83,7 @@ func Reload() gperr.Error {
if err != nil {
newState.Task().FinishAndWait(err)
config.WorkingState.Store(GetState())
logNotifyError("reload", err)
return gperr.New(ansi.Warning("using last config")).With(err)
return gperr.Wrap(err, ansi.Warning("using last config"))
}
// flush temporary log
@@ -117,7 +109,7 @@ func WatchChanges() {
configEventFlushInterval,
OnConfigChange,
func(err gperr.Error) {
logNotifyError("config reload", err)
logNotifyError("reload", err)
},
)
eventQueue.Start(cfgWatcher.Events(t.Context()))

View File

@@ -3,7 +3,11 @@ package config
import (
"bytes"
"context"
"crypto/tls"
"crypto/x509"
"errors"
"fmt"
"io/fs"
"iter"
"net/http"
"os"
@@ -17,7 +21,6 @@ import (
"github.com/yusing/godoxy/agent/pkg/agent"
"github.com/yusing/godoxy/internal/acl"
"github.com/yusing/godoxy/internal/autocert"
"github.com/yusing/godoxy/internal/common"
config "github.com/yusing/godoxy/internal/config/types"
"github.com/yusing/godoxy/internal/entrypoint"
homepage "github.com/yusing/godoxy/internal/homepage/types"
@@ -90,10 +93,13 @@ func Value() *config.Config {
}
func (state *state) InitFromFile(filename string) error {
data, err := os.ReadFile(common.ConfigPath)
data, err := os.ReadFile(filename)
if err != nil {
state.Config = config.DefaultConfig()
return err
if errors.Is(err, fs.ErrNotExist) {
state.Config = config.DefaultConfig()
} else {
return err
}
}
return state.Init(data)
}
@@ -134,6 +140,10 @@ func (state *state) EntrypointHandler() http.Handler {
return &state.entrypoint
}
func (state *state) ShortLinkMatcher() config.ShortLinkMatcher {
return state.entrypoint.ShortLinkMatcher()
}
// AutoCertProvider returns the autocert provider.
//
// If the autocert provider is not configured, it returns nil.
@@ -191,18 +201,52 @@ func (state *state) initAccessLogger() error {
}
func (state *state) initEntrypoint() error {
epCfg := state.Entrypoint
epCfg := state.Config.Entrypoint
matchDomains := state.MatchDomains
state.entrypoint.SetFindRouteDomains(matchDomains)
state.entrypoint.SetNotFoundRules(epCfg.Rules.NotFound)
if len(matchDomains) > 0 {
state.entrypoint.ShortLinkMatcher().SetDefaultDomainSuffix(matchDomains[0])
}
if state.autocertProvider != nil {
if domain := getAutoCertDefaultDomain(state.autocertProvider); domain != "" {
state.entrypoint.ShortLinkMatcher().SetDefaultDomainSuffix("." + domain)
}
}
errs := gperr.NewBuilder("entrypoint error")
errs.Add(state.entrypoint.SetMiddlewares(epCfg.Middlewares))
errs.Add(state.entrypoint.SetAccessLogger(state.task, epCfg.AccessLog))
return errs.Error()
}
func getAutoCertDefaultDomain(p *autocert.Provider) string {
if p == nil {
return ""
}
cert, err := tls.LoadX509KeyPair(p.GetCertPath(), p.GetKeyPath())
if err != nil || len(cert.Certificate) == 0 {
return ""
}
x509Cert, err := x509.ParseCertificate(cert.Certificate[0])
if err != nil {
return ""
}
domain := x509Cert.Subject.CommonName
if domain == "" && len(x509Cert.DNSNames) > 0 {
domain = x509Cert.DNSNames[0]
}
domain = strings.TrimSpace(domain)
if after, ok := strings.CutPrefix(domain, "*."); ok {
domain = after
}
return strings.ToLower(domain)
}
func (state *state) initMaxMind() error {
maxmindCfg := state.Providers.MaxMind
if maxmindCfg != nil {
@@ -228,6 +272,7 @@ func (state *state) initAutoCert() error {
autocertCfg := state.AutoCert
if autocertCfg == nil {
autocertCfg = new(autocert.Config)
_ = autocertCfg.Validate()
}
user, legoCfg, err := autocertCfg.GetLegoConfig()
@@ -235,12 +280,19 @@ func (state *state) initAutoCert() error {
return err
}
state.autocertProvider = autocert.NewProvider(autocertCfg, user, legoCfg)
if err := state.autocertProvider.Setup(); err != nil {
return fmt.Errorf("autocert error: %w", err)
} else {
state.autocertProvider.ScheduleRenewal(state.task)
p, err := autocert.NewProvider(autocertCfg, user, legoCfg)
if err != nil {
return err
}
if err := p.ObtainCertIfNotExistsAll(); err != nil {
return err
}
p.ScheduleRenewalAll(state.task)
p.PrintCertExpiriesAll()
state.autocertProvider = p
return nil
}
@@ -252,7 +304,7 @@ func (state *state) initProxmox() error {
errs := gperr.NewBuilder()
for _, cfg := range proxmoxCfg {
if err := cfg.Init(); err != nil {
if err := cfg.Init(state.task.Context()); err != nil {
errs.Add(err.Subject(cfg.URL))
}
}
@@ -318,9 +370,9 @@ func (state *state) loadRouteProviders() error {
})
}
for name, dockerHost := range providers.Docker {
for name, dockerCfg := range providers.Docker {
providersProducer.Go(func() {
providersCh <- route.NewDockerProvider(name, dockerHost)
providersCh <- route.NewDockerProvider(name, dockerCfg)
})
}

View File

@@ -32,12 +32,12 @@ type (
HealthCheck types.HealthCheckConfig `json:"healthcheck"`
}
Providers struct {
Files []string `json:"include" yaml:"include,omitempty" validate:"dive,filepath"`
Docker map[string]string `json:"docker" yaml:"docker,omitempty" validate:"non_empty_docker_keys,dive,unix_addr|url"`
Agents []*agent.AgentConfig `json:"agents" yaml:"agents,omitempty"`
Notification []*notif.NotificationConfig `json:"notification" yaml:"notification,omitempty"`
Proxmox []proxmox.Config `json:"proxmox" yaml:"proxmox,omitempty"`
MaxMind *maxmind.Config `json:"maxmind" yaml:"maxmind,omitempty"`
Files []string `json:"include" yaml:"include,omitempty" validate:"dive,filepath"`
Docker map[string]types.DockerProviderConfig `json:"docker" yaml:"docker,omitempty" validate:"non_empty_docker_keys"`
Agents []*agent.AgentConfig `json:"agents" yaml:"agents,omitempty"`
Notification []*notif.NotificationConfig `json:"notification" yaml:"notification,omitempty"`
Proxmox []proxmox.Config `json:"proxmox" yaml:"proxmox,omitempty"`
MaxMind *maxmind.Config `json:"maxmind" yaml:"maxmind,omitempty"`
}
)
@@ -68,7 +68,7 @@ func init() {
return true
})
serialization.MustRegisterValidation("non_empty_docker_keys", func(fl validator.FieldLevel) bool {
m := fl.Field().Interface().(map[string]string)
m := fl.Field().Interface().(map[string]types.DockerProviderConfig)
for k := range m {
if k == "" {
return false

View File

@@ -22,6 +22,7 @@ type State interface {
Value() *Config
EntrypointHandler() http.Handler
ShortLinkMatcher() ShortLinkMatcher
AutoCertProvider() server.CertProvider
LoadOrStoreProvider(key string, value types.RouteProvider) (actual types.RouteProvider, loaded bool)
@@ -33,6 +34,12 @@ type State interface {
FlushTmpLog()
}
type ShortLinkMatcher interface {
AddRoute(alias string)
DelRoute(alias string)
ServeHTTP(w http.ResponseWriter, r *http.Request)
}
// could be nil before first call on Load
var ActiveState synk.Value[State]

View File

@@ -1,7 +1,7 @@
package dnsproviders
type (
DummyConfig struct{}
DummyConfig map[string]any
DummyProvider struct{}
)

View File

@@ -6,7 +6,7 @@ replace github.com/yusing/godoxy => ../..
require (
github.com/go-acme/lego/v4 v4.30.1
github.com/yusing/godoxy v0.21.0
github.com/yusing/godoxy v0.21.3
)
require (
@@ -37,16 +37,16 @@ require (
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.29.0 // indirect
github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/go-resty/resty/v2 v2.17.1 // indirect
github.com/goccy/go-yaml v1.19.0 // indirect
github.com/goccy/go-yaml v1.19.1 // indirect
github.com/gofrs/flock v0.13.0 // indirect
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/google/go-querystring v1.2.0 // indirect
github.com/google/s2a-go v0.1.9 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.7 // indirect
github.com/googleapis/gax-go/v2 v2.15.0 // indirect
github.com/googleapis/gax-go/v2 v2.16.0 // indirect
github.com/gotify/server/v2 v2.7.3 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-retryablehttp v0.7.8 // indirect
@@ -68,12 +68,12 @@ require (
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/puzpuzpuz/xsync/v4 v4.2.0 // indirect
github.com/rs/zerolog v1.34.0 // indirect
github.com/scaleway/scaleway-sdk-go v1.0.0-beta.35 // indirect
github.com/scaleway/scaleway-sdk-go v1.0.0-beta.36 // indirect
github.com/sony/gobreaker v1.0.0 // indirect
github.com/stretchr/objx v0.5.3 // indirect
github.com/stretchr/testify v1.11.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/vultr/govultr/v3 v3.26.0 // indirect
github.com/vultr/govultr/v3 v3.26.1 // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
github.com/yusing/gointernals v0.1.16 // indirect
github.com/yusing/goutils v0.7.0 // indirect
@@ -92,9 +92,9 @@ require (
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect
golang.org/x/tools v0.40.0 // indirect
google.golang.org/api v0.257.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 // indirect
google.golang.org/grpc v1.77.0 // indirect
google.golang.org/api v0.258.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
google.golang.org/grpc v1.78.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect

View File

@@ -74,12 +74,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.29.0 h1:lQlF5VNJWNlRbRZNeOIkWElR+1LL/OuHcc0Kp14w1xk=
github.com/go-playground/validator/v10 v10.29.0/go.mod h1:D6QxqeMlgIPuT02L66f2ccrZ7AGgHkzKmmTMZhk/Kc4=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/go-resty/resty/v2 v2.17.1 h1:x3aMpHK1YM9e4va/TMDRlusDDoZiQ+ViDu/WpA6xTM4=
github.com/go-resty/resty/v2 v2.17.1/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2mLtQrOyQlVA=
github.com/goccy/go-yaml v1.19.0 h1:EmkZ9RIsX+Uq4DYFowegAuJo8+xdX3T/2dwNPXbxEYE=
github.com/goccy/go-yaml v1.19.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/goccy/go-yaml v1.19.1 h1:3rG3+v8pkhRqoQ/88NYNMHYVGYztCOCIZ7UQhu7H+NE=
github.com/goccy/go-yaml v1.19.1/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw=
github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0=
@@ -87,19 +87,19 @@ github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9v
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0=
github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU=
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/enterprise-certificate-proxy v0.3.7 h1:zrn2Ee/nWmHulBx5sAVrGgAa0f2/R35S4DJwfFaUPFQ=
github.com/googleapis/enterprise-certificate-proxy v0.3.7/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo=
github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc=
github.com/googleapis/gax-go/v2 v2.16.0 h1:iHbQmKLLZrexmb0OSsNGTeSTS0HO4YvFOG8g5E4Zd0Y=
github.com/googleapis/gax-go/v2 v2.16.0/go.mod h1:o1vfQjjNZn4+dPnRdl/4ZD7S9414Y4xA+a/6Icj6l14=
github.com/gotify/server/v2 v2.7.3 h1:nro/ZnxdlZFvxFcw9LREGA8zdk6CK744azwhuhX/A4g=
github.com/gotify/server/v2 v2.7.3/go.mod h1:VAtE1RIc/2j886PYs9WPQbMjqbFsoyQ0G8IdFtnAxU0=
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
@@ -160,8 +160,8 @@ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
github.com/scaleway/scaleway-sdk-go v1.0.0-beta.35 h1:8xfn1RzeI9yoCUuEwDy08F+No6PcKZGEDOQ6hrRyLts=
github.com/scaleway/scaleway-sdk-go v1.0.0-beta.35/go.mod h1:47B1d/YXmSAxlJxUJxClzHR6b3T4M1WyCvwENPQNBWc=
github.com/scaleway/scaleway-sdk-go v1.0.0-beta.36 h1:ObX9hZmK+VmijreZO/8x9pQ8/P/ToHD/bdSb4Eg4tUo=
github.com/scaleway/scaleway-sdk-go v1.0.0-beta.36/go.mod h1:LEsDu4BubxK7/cWhtlQWfuxwL4rf/2UEpxXz1o1EMtM=
github.com/sony/gobreaker v1.0.0 h1:feX5fGGXSl3dYd4aHZItw+FpHLvvoaqkawKjVNiFMNQ=
github.com/sony/gobreaker v1.0.0/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@@ -180,8 +180,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/vultr/govultr/v3 v3.26.0 h1:pm/GM+RZo9T1JLQzrUti5HiNAIFZFEHcPFMOWGvvNIY=
github.com/vultr/govultr/v3 v3.26.0/go.mod h1:9WwnWGCKnwDlNjHjtt+j+nP+0QWq6hQXzaHgddqrLWY=
github.com/vultr/govultr/v3 v3.26.1 h1:G/M0rMQKwVSmL+gb0UgETbW5mcQi0Vf/o/ZSGdBCxJw=
github.com/vultr/govultr/v3 v3.26.1/go.mod h1:9WwnWGCKnwDlNjHjtt+j+nP+0QWq6hQXzaHgddqrLWY=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
github.com/yusing/gointernals v0.1.16 h1:GrhZZdxzA+jojLEqankctJrOuAYDb7kY1C93S1pVR34=
@@ -232,19 +232,18 @@ golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/api v0.257.0 h1:8Y0lzvHlZps53PEaw+G29SsQIkuKrumGWs9puiexNAA=
google.golang.org/api v0.257.0/go.mod h1:4eJrr+vbVaZSqs7vovFd1Jb/A6ml6iw2e6FBYf3GAO4=
google.golang.org/genproto v0.0.0-20251111163417-95abcf5c77ba h1:Ze6qXW0j37YCqZdCD2LkzVSxgEWez0cO4NUyd44DiDY=
google.golang.org/genproto v0.0.0-20251111163417-95abcf5c77ba/go.mod h1:4FLPzLA8eGAktPOTemJGDgDYRpLYwrNu4u2JtWINhnI=
google.golang.org/genproto/googleapis/api v0.0.0-20251111163417-95abcf5c77ba h1:B14OtaXuMaCQsl2deSvNkyPKIzq3BjfxQp8d00QyWx4=
google.golang.org/genproto/googleapis/api v0.0.0-20251111163417-95abcf5c77ba/go.mod h1:G5IanEx8/PgI9w6CFcYQf7jMtHQhZruvfM1i3qOqk5U=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 h1:2I6GHUeJ/4shcDpoUlLs/2WPnhg7yJwvXtqcMJt9liA=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
google.golang.org/api v0.258.0 h1:IKo1j5FBlN74fe5isA2PVozN3Y5pwNKriEgAXPOkDAc=
google.golang.org/api v0.258.0/go.mod h1:qhOMTQEZ6lUps63ZNq9jhODswwjkjYYguA7fA3TBFww=
google.golang.org/genproto v0.0.0-20251202230838-ff82c1b0f217 h1:GvESR9BIyHUahIb0NcTum6itIWtdoglGX+rnGxm2934=
google.golang.org/genproto v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:yJ2HH4EHEDTd3JiLmhds6NkJ17ITVYOdV3m3VKOnws0=
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls=
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@@ -2,7 +2,6 @@ package docker
import (
"context"
"errors"
"fmt"
"maps"
"net"
@@ -17,7 +16,7 @@ import (
"github.com/moby/moby/client"
"github.com/rs/zerolog/log"
"github.com/yusing/godoxy/agent/pkg/agent"
"github.com/yusing/godoxy/internal/common"
"github.com/yusing/godoxy/internal/types"
httputils "github.com/yusing/goutils/http"
"github.com/yusing/goutils/task"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
@@ -28,6 +27,8 @@ type (
SharedClient struct {
*client.Client
cfg types.DockerProviderConfig
refCount atomic.Int32
closedOn atomic.Int64
@@ -110,19 +111,17 @@ func Clients() map[string]*SharedClient {
return clients
}
var versionArg = client.WithAPIVersionNegotiation()
// NewClient creates a new Docker client connection to the specified host.
//
// Returns existing client if available.
//
// Parameters:
// - host: the host to connect to (either a URL or common.DockerHostFromEnv).
// - host: the host to connect to (either a URL or client.DefaultDockerHost).
//
// Returns:
// - Client: the Docker client connection.
// - error: an error if the connection failed.
func NewClient(host string, unique ...bool) (*SharedClient, error) {
func NewClient(cfg types.DockerProviderConfig, unique ...bool) (*SharedClient, error) {
initClientCleanerOnce.Do(initClientCleaner)
u := false
@@ -130,6 +129,8 @@ func NewClient(host string, unique ...bool) (*SharedClient, error) {
u = unique[0]
}
host := cfg.URL
if !u {
clientMapMu.Lock()
defer clientMapMu.Unlock()
@@ -154,45 +155,30 @@ func NewClient(host string, unique ...bool) (*SharedClient, error) {
opt = []client.Opt{
client.WithHost(agent.DockerHost),
client.WithHTTPClient(cfg.NewHTTPClient()),
versionArg,
}
addr = "tcp://" + cfg.Addr
dial = cfg.DialContext
} else {
switch host {
case "":
return nil, errors.New("empty docker host")
case common.DockerHostFromEnv:
helper, err := connhelper.GetConnectionHelper(host)
if err != nil {
log.Panic().Err(err).Msg("failed to get connection helper")
}
if helper != nil {
opt = []client.Opt{
client.WithHostFromEnv(),
versionArg,
client.WithHost(helper.Host),
client.WithDialContext(helper.Dialer),
}
default:
helper, err := connhelper.GetConnectionHelper(host)
if err != nil {
log.Panic().Err(err).Msg("failed to get connection helper")
}
if helper != nil {
httpClient := &http.Client{
Transport: &http.Transport{
DialContext: helper.Dialer,
},
}
opt = []client.Opt{
client.WithHTTPClient(httpClient),
client.WithHost(helper.Host),
versionArg,
client.WithDialContext(helper.Dialer),
}
} else {
opt = []client.Opt{
client.WithHost(host),
versionArg,
}
} else {
opt = []client.Opt{
client.WithHost(host),
}
}
}
if cfg.TLS != nil {
opt = append(opt, client.WithTLSClientConfig(cfg.TLS.CAFile, cfg.TLS.CertFile, cfg.TLS.KeyFile))
}
client, err := client.New(opt...)
if err != nil {
return nil, err
@@ -200,6 +186,7 @@ func NewClient(host string, unique ...bool) (*SharedClient, error) {
c := &SharedClient{
Client: client,
cfg: cfg,
addr: addr,
key: host,
dial: dial,
@@ -236,7 +223,7 @@ func (c *SharedClient) InterceptHTTPClient(intercept httputils.InterceptFunc) {
func (c *SharedClient) CloneUnique() *SharedClient {
// there will be no error here
// since we are using the same host from a valid client.
c, _ = NewClient(c.key, true)
c, _ = NewClient(c.cfg, true)
return c
}

View File

@@ -29,7 +29,7 @@ var (
ErrNoNetwork = errors.New("no network found")
)
func FromDocker(c *container.Summary, dockerHost string) (res *types.Container) {
func FromDocker(c *container.Summary, dockerCfg types.DockerProviderConfig) (res *types.Container) {
actualLabels := maps.Clone(c.Labels)
_, isExplicit := c.Labels[LabelAliases]
@@ -47,7 +47,7 @@ func FromDocker(c *container.Summary, dockerHost string) (res *types.Container)
isExcluded, _ := strconv.ParseBool(helper.getDeleteLabel(LabelExclude))
res = &types.Container{
DockerHost: dockerHost,
DockerCfg: dockerCfg,
Image: helper.parseImage(),
ContainerName: helper.getName(),
ContainerID: c.ID,
@@ -69,11 +69,11 @@ func FromDocker(c *container.Summary, dockerHost string) (res *types.Container)
State: c.State,
}
if agent.IsDockerHostAgent(dockerHost) {
if agent.IsDockerHostAgent(dockerCfg.URL) {
var ok bool
res.Agent, ok = agent.GetAgent(dockerHost)
res.Agent, ok = agent.GetAgent(dockerCfg.URL)
if !ok {
addError(res, fmt.Errorf("agent %q not found", dockerHost))
addError(res, fmt.Errorf("agent %q not found", dockerCfg.URL))
}
}
@@ -91,14 +91,14 @@ func IsBlacklisted(c *types.Container) bool {
return IsBlacklistedImage(c.Image) || isDatabase(c)
}
func UpdatePorts(c *types.Container) error {
dockerClient, err := NewClient(c.DockerHost)
func UpdatePorts(ctx context.Context, c *types.Container) error {
dockerClient, err := NewClient(c.DockerCfg)
if err != nil {
return err
}
defer dockerClient.Close()
inspect, err := dockerClient.ContainerInspect(context.Background(), c.ContainerID, client.ContainerInspectOptions{})
inspect, err := dockerClient.ContainerInspect(ctx, c.ContainerID, client.ContainerInspectOptions{})
if err != nil {
return err
}
@@ -163,14 +163,14 @@ func isDatabase(c *types.Container) bool {
}
func isLocal(c *types.Container) bool {
if strings.HasPrefix(c.DockerHost, "unix://") {
if strings.HasPrefix(c.DockerCfg.URL, "unix://") {
return true
}
// treat it as local if the docker host is the same as the environment variable
if c.DockerHost == EnvDockerHost {
if c.DockerCfg.URL == EnvDockerHost {
return true
}
url, err := url.Parse(c.DockerHost)
url, err := url.Parse(c.DockerCfg.URL)
if err != nil {
return false
}
@@ -190,7 +190,7 @@ func setPublicHostname(c *types.Container) {
c.PublicHostname = "127.0.0.1"
return
}
url, err := url.Parse(c.DockerHost)
url, err := url.Parse(c.DockerCfg.URL)
if err != nil {
c.PublicHostname = "127.0.0.1"
return
@@ -241,9 +241,11 @@ func loadDeleteIdlewatcherLabels(c *types.Container, helper containerHelper) {
hasIdleTimeout := false
cfg := make(map[string]any, len(idlewatcherLabels))
for lbl, key := range idlewatcherLabels {
if value := helper.getDeleteLabel(lbl); value != "" {
cfg[key] = value
value := helper.getDeleteLabel(lbl)
if value == "" {
continue
}
cfg[key] = value
switch lbl {
case LabelIdleTimeout:
hasIdleTimeout = true
@@ -252,14 +254,11 @@ func loadDeleteIdlewatcherLabels(c *types.Container, helper containerHelper) {
}
}
// ensure it's deleted from labels
helper.getDeleteLabel(LabelDependsOn)
// set only if idlewatcher is enabled
if hasIdleTimeout {
idwCfg := new(types.IdlewatcherConfig)
idwCfg.Docker = &types.DockerConfig{
DockerHost: c.DockerHost,
DockerCfg: c.DockerCfg,
ContainerID: c.ContainerID,
ContainerName: c.ContainerName,
}

View File

@@ -4,6 +4,7 @@ import (
"testing"
"github.com/moby/moby/api/types/container"
"github.com/yusing/godoxy/internal/types"
expect "github.com/yusing/goutils/testing"
)
@@ -36,7 +37,7 @@ func TestContainerExplicit(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := FromDocker(&container.Summary{Names: []string{"test"}, State: "test", Labels: tt.labels}, "")
c := FromDocker(&container.Summary{Names: []string{"test"}, State: "test", Labels: tt.labels}, types.DockerProviderConfig{})
expect.Equal(t, c.IsExplicit, tt.isExplicit)
})
}
@@ -73,7 +74,7 @@ func TestContainerHostNetworkMode(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := FromDocker(tt.container, "")
c := FromDocker(tt.container, types.DockerProviderConfig{})
expect.Equal(t, c.IsHostNetworkMode, tt.isHostNetworkMode)
})
}

View File

@@ -2,18 +2,19 @@ package docker
import (
"github.com/puzpuzpuz/xsync/v4"
"github.com/yusing/godoxy/internal/types"
)
var idDockerHostMap = xsync.NewMap[string, string](xsync.WithPresize(100))
var idDockerCfgMap = xsync.NewMap[string, types.DockerProviderConfig](xsync.WithPresize(100))
func GetDockerHostByContainerID(id string) (string, bool) {
return idDockerHostMap.Load(id)
func GetDockerCfgByContainerID(id string) (types.DockerProviderConfig, bool) {
return idDockerCfgMap.Load(id)
}
func SetDockerHostByContainerID(id, host string) {
idDockerHostMap.Store(id, host)
func SetDockerCfgByContainerID(id string, cfg types.DockerProviderConfig) {
idDockerCfgMap.Store(id, cfg)
}
func DeleteDockerHostByContainerID(id string) {
idDockerHostMap.Delete(id)
func DeleteDockerCfgByContainerID(id string) {
idDockerCfgMap.Delete(id)
}

View File

@@ -5,6 +5,7 @@ import (
"github.com/moby/moby/api/types/container"
"github.com/moby/moby/client"
"github.com/yusing/godoxy/internal/types"
)
var listOptions = client.ContainerListOptions{
@@ -19,8 +20,8 @@ var listOptions = client.ContainerListOptions{
All: true,
}
func ListContainers(ctx context.Context, clientHost string) ([]container.Summary, error) {
dockerClient, err := NewClient(clientHost)
func ListContainers(ctx context.Context, dockerCfg types.DockerProviderConfig) ([]container.Summary, error) {
dockerClient, err := NewClient(dockerCfg)
if err != nil {
return nil, err
}

View File

@@ -6,6 +6,7 @@ import (
"sync/atomic"
"github.com/rs/zerolog/log"
"github.com/yusing/godoxy/internal/common"
entrypoint "github.com/yusing/godoxy/internal/entrypoint/types"
"github.com/yusing/godoxy/internal/logging/accesslog"
"github.com/yusing/godoxy/internal/net/gphttp/middleware"
@@ -21,6 +22,7 @@ type Entrypoint struct {
notFoundHandler http.Handler
accessLogger accesslog.AccessLogger
findRouteFunc func(host string) types.HTTPRoute
shortLinkTree *ShortLinkMatcher
}
// nil-safe
@@ -34,9 +36,14 @@ func init() {
func NewEntrypoint() Entrypoint {
return Entrypoint{
findRouteFunc: findRouteAnyDomain,
shortLinkTree: newShortLinkTree(),
}
}
func (ep *Entrypoint) ShortLinkMatcher() *ShortLinkMatcher {
return ep.shortLinkTree
}
func (ep *Entrypoint) SetFindRouteDomains(domains []string) {
if len(domains) == 0 {
ep.findRouteFunc = findRouteAnyDomain
@@ -90,9 +97,12 @@ func (ep *Entrypoint) FindRoute(s string) types.HTTPRoute {
func (ep *Entrypoint) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if ep.accessLogger != nil {
rec := accesslog.NewResponseRecorder(w)
rec := accesslog.GetResponseRecorder(w)
w = rec
defer ep.accessLogger.Log(r, rec.Response())
defer func() {
ep.accessLogger.Log(r, rec.Response())
accesslog.PutResponseRecorder(rec)
}()
}
route := ep.findRouteFunc(r.Host)
@@ -104,6 +114,8 @@ func (ep *Entrypoint) ServeHTTP(w http.ResponseWriter, r *http.Request) {
} else {
route.ServeHTTP(w, r)
}
case ep.tryHandleShortLink(w, r):
return
case ep.notFoundHandler != nil:
ep.notFoundHandler.ServeHTTP(w, r)
default:
@@ -111,6 +123,22 @@ func (ep *Entrypoint) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
}
func (ep *Entrypoint) tryHandleShortLink(w http.ResponseWriter, r *http.Request) (handled bool) {
host := r.Host
if before, _, ok := strings.Cut(host, ":"); ok {
host = before
}
if strings.EqualFold(host, common.ShortLinkPrefix) {
if ep.middleware != nil {
ep.middleware.ServeHTTP(ep.shortLinkTree.ServeHTTP, w, r)
} else {
ep.shortLinkTree.ServeHTTP(w, r)
}
return true
}
return false
}
func (ep *Entrypoint) serveNotFound(w http.ResponseWriter, r *http.Request) {
// Why use StatusNotFound instead of StatusBadRequest or StatusBadGateway?
// On nginx, when route for domain does not exist, it returns StatusBadGateway.
@@ -146,11 +174,18 @@ func findRouteAnyDomain(host string) types.HTTPRoute {
if r, ok := routes.HTTP.Get(host); ok {
return r
}
// try striping the trailing :port from the host
if before, _, ok := strings.Cut(host, ":"); ok {
if r, ok := routes.HTTP.Get(before); ok {
return r
}
}
return nil
}
func findRouteByDomains(domains []string) func(host string) types.HTTPRoute {
return func(host string) types.HTTPRoute {
host, _, _ = strings.Cut(host, ":") // strip the trailing :port
for _, domain := range domains {
if target, ok := strings.CutSuffix(host, domain); ok {
if r, ok := routes.HTTP.Get(target); ok {

View File

@@ -128,3 +128,47 @@ func TestFindRouteByDomainsExactMatch(t *testing.T) {
run(t, tests, testsNoMatch)
}
func TestFindRouteWithPort(t *testing.T) {
t.Run("AnyDomain", func(t *testing.T) {
addRoute("app1")
addRoute("app2.com")
tests := []string{
"app1:8080",
"app1.domain.com:8080",
"app2.com:8080",
}
testsNoMatch := []string{
"app11",
"app2.co",
"app2.co:8080",
}
run(t, tests, testsNoMatch)
})
t.Run("ByDomains", func(t *testing.T) {
ep.SetFindRouteDomains([]string{
".domain.com",
})
addRoute("app1")
addRoute("app2")
addRoute("app3.domain.com")
tests := []string{
"app1.domain.com:8080",
"app2:8080", // exact match fallback
"app3.domain.com:8080",
}
testsNoMatch := []string{
"app11",
"app1.domain.co",
"app1.domain.co:8080",
"app2.co",
"app2.co:8080",
"app3.domain.co",
"app3.domain.co:8080",
}
run(t, tests, testsNoMatch)
})
}

View File

@@ -0,0 +1,110 @@
package entrypoint
import (
"net/http"
"strings"
"github.com/puzpuzpuz/xsync/v4"
)
type ShortLinkMatcher struct {
defaultDomainSuffix string // e.g. ".example.com"
fqdnRoutes *xsync.Map[string, string] // "app" -> "app.example.com"
subdomainRoutes *xsync.Map[string, struct{}]
}
func newShortLinkTree() *ShortLinkMatcher {
return &ShortLinkMatcher{
fqdnRoutes: xsync.NewMap[string, string](),
subdomainRoutes: xsync.NewMap[string, struct{}](),
}
}
func (st *ShortLinkMatcher) SetDefaultDomainSuffix(suffix string) {
if !strings.HasPrefix(suffix, ".") {
suffix = "." + suffix
}
st.defaultDomainSuffix = suffix
}
func (st *ShortLinkMatcher) AddRoute(alias string) {
alias = strings.TrimSpace(alias)
if alias == "" {
return
}
if strings.Contains(alias, ".") { // FQDN alias
st.fqdnRoutes.Store(alias, alias)
key, _, _ := strings.Cut(alias, ".")
if key != "" {
if _, ok := st.subdomainRoutes.Load(key); !ok {
if _, ok := st.fqdnRoutes.Load(key); !ok {
st.fqdnRoutes.Store(key, alias)
}
}
}
return
}
// subdomain alias + defaultDomainSuffix
if st.defaultDomainSuffix == "" {
return
}
st.subdomainRoutes.Store(alias, struct{}{})
}
func (st *ShortLinkMatcher) DelRoute(alias string) {
alias = strings.TrimSpace(alias)
if alias == "" {
return
}
if strings.Contains(alias, ".") {
st.fqdnRoutes.Delete(alias)
key, _, _ := strings.Cut(alias, ".")
if key != "" {
if target, ok := st.fqdnRoutes.Load(key); ok && target == alias {
st.fqdnRoutes.Delete(key)
}
}
return
}
st.subdomainRoutes.Delete(alias)
}
func (st *ShortLinkMatcher) ServeHTTP(w http.ResponseWriter, r *http.Request) {
path := r.URL.EscapedPath()
trim := strings.TrimPrefix(path, "/")
key, rest, _ := strings.Cut(trim, "/")
if key == "" {
http.Error(w, "short link key is required", http.StatusBadRequest)
return
}
if rest != "" {
rest = "/" + rest
} else {
rest = "/"
}
targetHost := ""
if strings.Contains(key, ".") {
targetHost, _ = st.fqdnRoutes.Load(key)
} else if target, ok := st.fqdnRoutes.Load(key); ok {
targetHost = target
} else if _, ok := st.subdomainRoutes.Load(key); ok && st.defaultDomainSuffix != "" {
targetHost = key + st.defaultDomainSuffix
}
if targetHost == "" {
http.Error(w, "short link not found", http.StatusNotFound)
return
}
targetURL := "https://" + targetHost + rest
if q := r.URL.RawQuery; q != "" {
targetURL += "?" + q
}
http.Redirect(w, r, targetURL, http.StatusTemporaryRedirect)
}

View File

@@ -0,0 +1,194 @@
package entrypoint_test
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/yusing/godoxy/internal/common"
. "github.com/yusing/godoxy/internal/entrypoint"
)
func TestShortLinkMatcher_FQDNAlias(t *testing.T) {
ep := NewEntrypoint()
matcher := ep.ShortLinkMatcher()
matcher.AddRoute("app.domain.com")
t.Run("exact path", func(t *testing.T) {
req := httptest.NewRequest("GET", "/app", nil)
w := httptest.NewRecorder()
matcher.ServeHTTP(w, req)
assert.Equal(t, http.StatusTemporaryRedirect, w.Code)
assert.Equal(t, "https://app.domain.com/", w.Header().Get("Location"))
})
t.Run("with path remainder", func(t *testing.T) {
req := httptest.NewRequest("GET", "/app/foo/bar", nil)
w := httptest.NewRecorder()
matcher.ServeHTTP(w, req)
assert.Equal(t, http.StatusTemporaryRedirect, w.Code)
assert.Equal(t, "https://app.domain.com/foo/bar", w.Header().Get("Location"))
})
t.Run("with query", func(t *testing.T) {
req := httptest.NewRequest("GET", "/app/foo?x=y&z=1", nil)
w := httptest.NewRecorder()
matcher.ServeHTTP(w, req)
assert.Equal(t, http.StatusTemporaryRedirect, w.Code)
assert.Equal(t, "https://app.domain.com/foo?x=y&z=1", w.Header().Get("Location"))
})
}
func TestShortLinkMatcher_SubdomainAlias(t *testing.T) {
ep := NewEntrypoint()
matcher := ep.ShortLinkMatcher()
matcher.SetDefaultDomainSuffix(".example.com")
matcher.AddRoute("app")
t.Run("exact path", func(t *testing.T) {
req := httptest.NewRequest("GET", "/app", nil)
w := httptest.NewRecorder()
matcher.ServeHTTP(w, req)
assert.Equal(t, http.StatusTemporaryRedirect, w.Code)
assert.Equal(t, "https://app.example.com/", w.Header().Get("Location"))
})
t.Run("with path remainder", func(t *testing.T) {
req := httptest.NewRequest("GET", "/app/foo/bar", nil)
w := httptest.NewRecorder()
matcher.ServeHTTP(w, req)
assert.Equal(t, http.StatusTemporaryRedirect, w.Code)
assert.Equal(t, "https://app.example.com/foo/bar", w.Header().Get("Location"))
})
}
func TestShortLinkMatcher_NotFound(t *testing.T) {
ep := NewEntrypoint()
matcher := ep.ShortLinkMatcher()
matcher.SetDefaultDomainSuffix(".example.com")
matcher.AddRoute("app")
t.Run("missing key", func(t *testing.T) {
req := httptest.NewRequest("GET", "/", nil)
w := httptest.NewRecorder()
matcher.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
})
t.Run("unknown key", func(t *testing.T) {
req := httptest.NewRequest("GET", "/unknown", nil)
w := httptest.NewRecorder()
matcher.ServeHTTP(w, req)
assert.Equal(t, http.StatusNotFound, w.Code)
})
}
func TestShortLinkMatcher_AddDelRoute(t *testing.T) {
ep := NewEntrypoint()
matcher := ep.ShortLinkMatcher()
matcher.SetDefaultDomainSuffix(".example.com")
matcher.AddRoute("app1")
matcher.AddRoute("app2.domain.com")
t.Run("both routes work", func(t *testing.T) {
req := httptest.NewRequest("GET", "/app1", nil)
w := httptest.NewRecorder()
matcher.ServeHTTP(w, req)
assert.Equal(t, http.StatusTemporaryRedirect, w.Code)
assert.Equal(t, "https://app1.example.com/", w.Header().Get("Location"))
req = httptest.NewRequest("GET", "/app2.domain.com", nil)
w = httptest.NewRecorder()
matcher.ServeHTTP(w, req)
assert.Equal(t, http.StatusTemporaryRedirect, w.Code)
assert.Equal(t, "https://app2.domain.com/", w.Header().Get("Location"))
})
t.Run("delete route", func(t *testing.T) {
matcher.DelRoute("app1")
req := httptest.NewRequest("GET", "/app1", nil)
w := httptest.NewRecorder()
matcher.ServeHTTP(w, req)
assert.Equal(t, http.StatusNotFound, w.Code)
req = httptest.NewRequest("GET", "/app2.domain.com", nil)
w = httptest.NewRecorder()
matcher.ServeHTTP(w, req)
assert.Equal(t, http.StatusTemporaryRedirect, w.Code)
assert.Equal(t, "https://app2.domain.com/", w.Header().Get("Location"))
})
}
func TestShortLinkMatcher_NoDefaultDomainSuffix(t *testing.T) {
ep := NewEntrypoint()
matcher := ep.ShortLinkMatcher()
// no SetDefaultDomainSuffix called
t.Run("subdomain alias ignored", func(t *testing.T) {
matcher.AddRoute("app")
req := httptest.NewRequest("GET", "/app", nil)
w := httptest.NewRecorder()
matcher.ServeHTTP(w, req)
assert.Equal(t, http.StatusNotFound, w.Code)
})
t.Run("FQDN alias still works", func(t *testing.T) {
matcher.AddRoute("app.domain.com")
req := httptest.NewRequest("GET", "/app.domain.com", nil)
w := httptest.NewRecorder()
matcher.ServeHTTP(w, req)
assert.Equal(t, http.StatusTemporaryRedirect, w.Code)
assert.Equal(t, "https://app.domain.com/", w.Header().Get("Location"))
})
}
func TestEntrypoint_ShortLinkDispatch(t *testing.T) {
ep := NewEntrypoint()
ep.ShortLinkMatcher().SetDefaultDomainSuffix(".example.com")
ep.ShortLinkMatcher().AddRoute("app")
t.Run("shortlink host", func(t *testing.T) {
req := httptest.NewRequest("GET", "/app", nil)
req.Host = common.ShortLinkPrefix
w := httptest.NewRecorder()
ep.ServeHTTP(w, req)
assert.Equal(t, http.StatusTemporaryRedirect, w.Code)
assert.Equal(t, "https://app.example.com/", w.Header().Get("Location"))
})
t.Run("shortlink host with port", func(t *testing.T) {
req := httptest.NewRequest("GET", "/app", nil)
req.Host = common.ShortLinkPrefix + ":8080"
w := httptest.NewRecorder()
ep.ServeHTTP(w, req)
assert.Equal(t, http.StatusTemporaryRedirect, w.Code)
assert.Equal(t, "https://app.example.com/", w.Header().Get("Location"))
})
t.Run("normal host", func(t *testing.T) {
req := httptest.NewRequest("GET", "/app", nil)
req.Host = "app.example.com"
w := httptest.NewRecorder()
ep.ServeHTTP(w, req)
// Should not redirect, should try normal route lookup (which will 404)
assert.NotEqual(t, http.StatusTemporaryRedirect, w.Code)
})
}

View File

@@ -7,6 +7,7 @@ import (
"github.com/yusing/ds/ordered"
"github.com/yusing/godoxy/internal/homepage/widgets"
"github.com/yusing/godoxy/internal/serialization"
strutils "github.com/yusing/goutils/strings"
)
type (
@@ -146,13 +147,13 @@ func (c *Category) sortByClicks() {
return 1
}
// fallback to alphabetical
return strings.Compare(a.Name, b.Name)
return strings.Compare(strutils.Title(a.Name), strutils.Title(b.Name))
})
}
func (c *Category) sortByAlphabetical() {
slices.SortStableFunc(c.Items, func(a, b *Item) int {
return strings.Compare(a.Name, b.Name)
return strings.Compare(strutils.Title(a.Name), strutils.Title(b.Name))
})
}

View File

@@ -14,6 +14,7 @@ import (
"github.com/yusing/godoxy/internal/common"
"github.com/yusing/godoxy/internal/serialization"
httputils "github.com/yusing/goutils/http"
"github.com/yusing/goutils/intern"
strutils "github.com/yusing/goutils/strings"
"github.com/yusing/goutils/synk"
"github.com/yusing/goutils/task"
@@ -402,7 +403,7 @@ func UpdateSelfhstIcons(m IconMap) error {
}
icon := &IconMeta{
DisplayName: item.Name,
Tag: tag,
Tag: intern.Make(tag).Value(),
SVG: item.SVG == "Yes",
PNG: item.PNG == "Yes",
WebP: item.WebP == "Yes",

View File

@@ -0,0 +1,73 @@
//go:build !production
package idlewatcher
import (
"math/rand/v2"
"net/http"
"time"
"github.com/puzpuzpuz/xsync/v4"
idlewatcher "github.com/yusing/godoxy/internal/idlewatcher/types"
"github.com/yusing/godoxy/internal/types"
)
func DebugHandler(rw http.ResponseWriter, r *http.Request) {
w := &Watcher{
eventChs: xsync.NewMap[chan *WakeEvent, struct{}](),
cfg: &types.IdlewatcherConfig{
IdlewatcherProviderConfig: types.IdlewatcherProviderConfig{
Docker: &types.DockerConfig{
ContainerName: "test",
},
},
},
}
switch r.URL.Path {
case idlewatcher.LoadingPageCSSPath:
serveStaticContent(rw, http.StatusOK, "text/css", cssBytes)
case idlewatcher.LoadingPageJSPath:
serveStaticContent(rw, http.StatusOK, "application/javascript", jsBytes)
case idlewatcher.WakeEventsPath:
go w.handleWakeEventsSSE(rw, r)
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
events := []WakeEventType{
WakeEventStarting,
WakeEventWakingDep,
WakeEventDepReady,
WakeEventContainerWoke,
WakeEventWaitingReady,
WakeEventError,
WakeEventReady,
}
messages := []string{
"Starting",
"Waking dependency",
"Dependency ready",
"Container woke",
"Waiting for ready",
"Error",
"Ready",
}
for {
select {
case <-r.Context().Done():
return
case <-ticker.C:
idx := rand.IntN(len(events))
for ch := range w.eventChs.Range {
ch <- &WakeEvent{
Type: string(events[idx]),
Message: messages[idx],
Timestamp: time.Now(),
}
}
}
}
default:
w.writeLoadingPage(rw)
}
}

View File

@@ -20,14 +20,14 @@ type DockerProvider struct {
var startOptions = client.ContainerStartOptions{}
func NewDockerProvider(dockerHost, containerID string) (idlewatcher.Provider, error) {
client, err := docker.NewClient(dockerHost)
func NewDockerProvider(dockerCfg types.DockerProviderConfig, containerID string) (idlewatcher.Provider, error) {
client, err := docker.NewClient(dockerCfg)
if err != nil {
return nil, err
}
return &DockerProvider{
client: client,
watcher: watcher.NewDockerWatcher(dockerHost),
watcher: watcher.NewDockerWatcher(dockerCfg),
containerID: containerID,
}, nil
}

View File

@@ -25,14 +25,14 @@ const proxmoxStateCheckInterval = 1 * time.Second
var ErrNodeNotFound = gperr.New("node not found in pool")
func NewProxmoxProvider(nodeName string, vmid int) (idlewatcher.Provider, error) {
func NewProxmoxProvider(ctx context.Context, nodeName string, vmid int) (idlewatcher.Provider, error) {
node, ok := proxmox.Nodes.Get(nodeName)
if !ok {
return nil, ErrNodeNotFound.Subject(nodeName).
Withf("available nodes: %s", proxmox.AvailableNodeNames())
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
lxcName, err := node.LXCName(ctx, vmid)

View File

@@ -2,7 +2,8 @@ package idlewatcher
const (
FavIconPath = "/favicon.ico"
LoadingPageCSSPath = "/$godoxy/style.css"
LoadingPageJSPath = "/$godoxy/loading.js"
WakeEventsPath = "/$godoxy/wake-events"
PathPrefix = "/$godoxy/"
LoadingPageCSSPath = PathPrefix + "style.css"
LoadingPageJSPath = PathPrefix + "loading.js"
WakeEventsPath = PathPrefix + "wake-events"
)

View File

@@ -209,7 +209,7 @@ func NewWatcher(parent task.Parent, r types.Route, cfg *types.IdlewatcherConfig)
depCont := depRoute.ContainerInfo()
if depCont != nil {
depCfg.Docker = &types.DockerConfig{
DockerHost: depCont.DockerHost,
DockerCfg: depCont.DockerCfg,
ContainerID: depCont.ContainerID,
ContainerName: depCont.ContainerName,
}
@@ -256,10 +256,10 @@ func NewWatcher(parent task.Parent, r types.Route, cfg *types.IdlewatcherConfig)
var kind string
switch {
case cfg.Docker != nil:
p, err = provider.NewDockerProvider(cfg.Docker.DockerHost, cfg.Docker.ContainerID)
p, err = provider.NewDockerProvider(cfg.Docker.DockerCfg, cfg.Docker.ContainerID)
kind = "docker"
default:
p, err = provider.NewProxmoxProvider(cfg.Proxmox.Node, cfg.Proxmox.VMID)
p, err = provider.NewProxmoxProvider(parent.Context(), cfg.Proxmox.Node, cfg.Proxmox.VMID)
kind = "proxmox"
}
targetURL := r.TargetURL()

View File

@@ -5,6 +5,7 @@ import (
"fmt"
"net"
"net/http"
"sync"
)
type ResponseRecorder struct {
@@ -13,14 +14,30 @@ type ResponseRecorder struct {
resp http.Response
}
func NewResponseRecorder(w http.ResponseWriter) *ResponseRecorder {
return &ResponseRecorder{
w: w,
resp: http.Response{
StatusCode: http.StatusOK,
Header: w.Header(),
},
var recorderPool = sync.Pool{
New: func() any {
return &ResponseRecorder{}
},
}
func GetResponseRecorder(w http.ResponseWriter) *ResponseRecorder {
r := recorderPool.Get().(*ResponseRecorder)
r.w = w
r.resp = http.Response{
StatusCode: http.StatusOK,
Header: w.Header(),
}
return r
}
func PutResponseRecorder(r *ResponseRecorder) {
r.w = nil
r.resp = http.Response{}
recorderPool.Put(r)
}
func NewResponseRecorder(w http.ResponseWriter) *ResponseRecorder {
return GetResponseRecorder(w)
}
func (w *ResponseRecorder) Unwrap() http.ResponseWriter {

View File

@@ -11,7 +11,7 @@ import (
"github.com/shirou/gopsutil/v4/mem"
"github.com/shirou/gopsutil/v4/net"
"github.com/shirou/gopsutil/v4/sensors"
"github.com/yusing/goutils/num"
"github.com/yusing/goutils/intern"
expect "github.com/yusing/goutils/testing"
)
@@ -22,32 +22,26 @@ var (
Timestamp: 123456,
CPUAverage: &cpuAvg,
Memory: mem.VirtualMemoryStat{
Total: 16000000000,
Available: 8000000000,
Used: 8000000000,
UsedPercent: 50.0,
Available: 8000000000,
Used: 8000000000,
},
Disks: map[string]disk.UsageStat{
"sda": {
Path: "/",
Fstype: "ext4",
Total: 500000000000,
Free: 250000000000,
Used: 250000000000,
UsedPercent: 50.0,
Path: intern.Make("/"),
Fstype: intern.Make("ext4"),
Free: 250000000000,
Used: 250000000000,
},
"nvme0n1": {
Path: "/",
Fstype: "zfs",
Total: 500000000000,
Free: 250000000000,
Used: 250000000000,
UsedPercent: 50.0,
Path: intern.Make("/"),
Fstype: intern.Make("zfs"),
Free: 250000000000,
Used: 250000000000,
},
},
DisksIO: map[string]*disk.IOCountersStat{
"media": {
Name: "media",
Name: intern.Make("media"),
ReadBytes: 1000000,
WriteBytes: 2000000,
IOCountersStatExtra: disk.IOCountersStatExtra{
@@ -57,7 +51,7 @@ var (
},
},
"nvme0n1": {
Name: "nvme0n1",
Name: intern.Make("nvme0n1"),
ReadBytes: 1000000,
WriteBytes: 2000000,
IOCountersStatExtra: disk.IOCountersStatExtra{
@@ -75,16 +69,12 @@ var (
},
Sensors: []sensors.TemperatureStat{
{
SensorKey: "cpu_temp",
Temperature: num.NewPercentage(30.0),
High: num.NewPercentage(40.0),
Critical: num.NewPercentage(50.0),
SensorKey: intern.Make("cpu_temp"),
Temperature: 30.0,
},
{
SensorKey: "gpu_temp",
Temperature: num.NewPercentage(40.0),
High: num.NewPercentage(50.0),
Critical: num.NewPercentage(60.0),
SensorKey: intern.Make("gpu_temp"),
Temperature: 40.0,
},
},
}
@@ -141,10 +131,10 @@ func TestSerialize(t *testing.T) {
expect.NoError(t, err)
var v []map[string]any
expect.NoError(t, json.Unmarshal(s, &v))
expect.Equal(t, len(v), len(result.Entries))
expect.Equal(t, len(v), len(result))
for i, m := range v {
for k, v := range m {
vv := reflect.ValueOf(result.Entries[i][k])
vv := reflect.ValueOf(result[i][k])
expect.Equal(t, reflect.ValueOf(v).Interface(), vv.Interface())
}
}

View File

@@ -8,7 +8,6 @@ import (
"github.com/bytedance/sonic"
"github.com/lithammer/fuzzysearch/fuzzy"
statequery "github.com/yusing/godoxy/internal/config/query"
"github.com/yusing/godoxy/internal/metrics/period"
metricsutils "github.com/yusing/godoxy/internal/metrics/utils"
"github.com/yusing/godoxy/internal/route/routes"
@@ -34,6 +33,7 @@ type (
Idle float32 `json:"idle"`
AvgLatency float32 `json:"avg_latency"`
IsDocker bool `json:"is_docker"`
IsExcluded bool `json:"is_excluded"`
CurrentStatus types.HealthStatus `json:"current_status" swaggertype:"string" enums:"healthy,unhealthy,unknown,napping,starting"`
Statuses []Status `json:"statuses"`
} // @name RouteUptimeAggregate
@@ -133,7 +133,7 @@ func (rs RouteStatuses) aggregate(limit int, offset int) Aggregated {
r, ok := routes.Get(alias)
if !ok {
// also search for excluded routes
r = statequery.SearchRoute(alias)
r, ok = routes.Excluded.Get(alias)
}
if r != nil {
displayName = r.DisplayName()
@@ -157,6 +157,7 @@ func (rs RouteStatuses) aggregate(limit int, offset int) Aggregated {
CurrentStatus: status,
Statuses: statuses,
IsDocker: r != nil && r.IsDocker(),
IsExcluded: r == nil || r.ShouldExclude(),
}
}
return result

View File

@@ -106,7 +106,7 @@ func TestReverseProxyBypass(t *testing.T) {
rp := reverseproxy.NewReverseProxy("test", url, fakeRoundTripper{})
err = PatchReverseProxy(rp, map[string]OptionsRaw{
"response": {
"bypass": "path glob(/test/*) | path /api",
"bypass": []string{"path glob(/test/*)", "path /api"},
"set_headers": map[string]string{
"Test-Header": "test-value",
},

View File

@@ -32,6 +32,9 @@ func setup() {
}
func GetStaticFile(filename string) ([]byte, bool) {
if common.IsTest {
return nil, false
}
setupOnce.Do(setup)
return fileContentMap.Load(filename)
}

View File

@@ -117,7 +117,7 @@ func (amw *oidcMiddleware) before(w http.ResponseWriter, r *http.Request) (proce
case errors.Is(err, auth.ErrMissingOAuthToken):
amw.auth.HandleAuth(w, r)
default:
auth.WriteBlockPage(w, http.StatusForbidden, err.Error(), auth.OIDCLogoutPath)
auth.WriteBlockPage(w, http.StatusForbidden, err.Error(), "Logout", auth.OIDCLogoutPath)
}
return false
}

View File

@@ -16,7 +16,7 @@ func NewTransport() *http.Transport {
Proxy: http.ProxyFromEnvironment,
DialContext: DefaultDialer.DialContext,
ForceAttemptHTTP2: true,
MaxIdleConnsPerHost: 100,
MaxIdleConnsPerHost: 1000,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,

View File

@@ -32,7 +32,7 @@ func (c *Config) Client() *Client {
return c.client
}
func (c *Config) Init() gperr.Error {
func (c *Config) Init(ctx context.Context) gperr.Error {
var tr *http.Transport
if c.NoTLSVerify {
// user specified
@@ -56,7 +56,7 @@ func (c *Config) Init() gperr.Error {
}
c.client = NewClient(c.URL, opts...)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
if err := c.client.UpdateClusterInfo(ctx); err != nil {

View File

@@ -2,9 +2,11 @@ package route
import (
"net/http"
"os"
"path"
"path/filepath"
config "github.com/yusing/godoxy/internal/config/types"
"github.com/yusing/godoxy/internal/logging/accesslog"
gphttp "github.com/yusing/godoxy/internal/net/gphttp"
"github.com/yusing/godoxy/internal/net/gphttp/middleware"
@@ -27,8 +29,25 @@ type (
var _ types.FileServerRoute = (*FileServer)(nil)
func handler(root string) http.Handler {
return http.FileServer(http.Dir(root))
func handler(root string, spa bool, index string) http.Handler {
if !spa {
return http.FileServer(http.Dir(root))
}
indexPath := filepath.Join(root, index)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
urlPath := path.Clean(r.URL.Path)
if urlPath == "/" {
http.ServeFile(w, r, indexPath)
return
}
fullPath := filepath.Join(root, filepath.FromSlash(urlPath))
stat, err := os.Stat(fullPath)
if err == nil && !stat.IsDir() {
http.ServeFile(w, r, fullPath)
return
}
http.ServeFile(w, r, indexPath)
})
}
func NewFileServer(base *Route) (*FileServer, gperr.Error) {
@@ -39,7 +58,12 @@ func NewFileServer(base *Route) (*FileServer, gperr.Error) {
return nil, gperr.New("`root` must be an absolute path")
}
s.handler = handler(s.Root)
if s.Index == "" {
s.Index = "/index.html"
} else if s.Index[0] != '/' {
s.Index = "/" + s.Index
}
s.handler = handler(s.Root, s.SPA, s.Index)
if len(s.Middlewares) > 0 {
mid, err := middleware.BuildMiddlewareFromMap(s.Alias, s.Middlewares)
@@ -101,8 +125,14 @@ func (s *FileServer) Start(parent task.Parent) gperr.Error {
}
routes.HTTP.Add(s)
if state := config.WorkingState.Load(); state != nil {
state.ShortLinkMatcher().AddRoute(s.Alias)
}
s.task.OnFinished("remove_route_from_http", func() {
routes.HTTP.Del(s)
if state := config.WorkingState.Load(); state != nil {
state.ShortLinkMatcher().DelRoute(s.Alias)
}
})
return nil
}

View File

@@ -18,8 +18,9 @@ import (
)
type DockerProvider struct {
name, dockerHost string
l zerolog.Logger
name string
dockerCfg types.DockerProviderConfig
l zerolog.Logger
}
const (
@@ -29,10 +30,10 @@ const (
var ErrAliasRefIndexOutOfRange = gperr.New("index out of range")
func DockerProviderImpl(name, dockerHost string) ProviderImpl {
func DockerProviderImpl(name string, dockerCfg types.DockerProviderConfig) ProviderImpl {
return &DockerProvider{
name,
dockerHost,
dockerCfg,
log.With().Str("type", "docker").Str("name", name).Logger(),
}
}
@@ -54,14 +55,14 @@ func (p *DockerProvider) Logger() *zerolog.Logger {
}
func (p *DockerProvider) NewWatcher() watcher.Watcher {
return watcher.NewDockerWatcher(p.dockerHost)
return watcher.NewDockerWatcher(p.dockerCfg)
}
func (p *DockerProvider) loadRoutesImpl() (route.Routes, gperr.Error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
containers, err := docker.ListContainers(ctx, p.dockerHost)
containers, err := docker.ListContainers(ctx, p.dockerCfg)
if err != nil {
return nil, gperr.Wrap(err)
}
@@ -70,7 +71,7 @@ func (p *DockerProvider) loadRoutesImpl() (route.Routes, gperr.Error) {
routes := make(route.Routes)
for _, c := range containers {
container := docker.FromDocker(&c, p.dockerHost)
container := docker.FromDocker(&c, p.dockerCfg)
if container.Errors != nil {
errs.Add(container.Errors)
@@ -78,7 +79,7 @@ func (p *DockerProvider) loadRoutesImpl() (route.Routes, gperr.Error) {
}
if container.IsHostNetworkMode {
err := docker.UpdatePorts(container)
err := docker.UpdatePorts(ctx, container)
if err != nil {
errs.Add(gperr.PrependSubject(container.ContainerName, err))
continue

View File

@@ -6,6 +6,7 @@ import (
"github.com/goccy/go-yaml"
"github.com/moby/moby/api/types/container"
"github.com/yusing/godoxy/internal/docker"
"github.com/yusing/godoxy/internal/types"
expect "github.com/yusing/goutils/testing"
_ "embed"
@@ -28,7 +29,7 @@ func TestParseDockerLabels(t *testing.T) {
Ports: []container.PortSummary{
{Type: "tcp", PrivatePort: 1234, PublicPort: 1234},
},
}, "/var/run/docker.sock"),
}, types.DockerProviderConfig{URL: "unix:///var/run/docker.sock"}),
)
expect.NoError(t, err)
expect.True(t, routes.Contains("app"))

View File

@@ -11,6 +11,7 @@ import (
D "github.com/yusing/godoxy/internal/docker"
"github.com/yusing/godoxy/internal/route"
routeTypes "github.com/yusing/godoxy/internal/route/types"
"github.com/yusing/godoxy/internal/types"
expect "github.com/yusing/goutils/testing"
)
@@ -31,7 +32,7 @@ func makeRoutes(cont *container.Summary, dockerHostIP ...string) route.Routes {
}
cont.ID = "test"
p.name = "test"
routes := expect.Must(p.routesFromContainerLabels(D.FromDocker(cont, host)))
routes := expect.Must(p.routesFromContainerLabels(D.FromDocker(cont, types.DockerProviderConfig{URL: host})))
for _, r := range routes {
r.Finalize()
}
@@ -39,7 +40,7 @@ func makeRoutes(cont *container.Summary, dockerHostIP ...string) route.Routes {
}
func TestExplicitOnly(t *testing.T) {
p := NewDockerProvider("a!", "")
p := NewDockerProvider("a!", types.DockerProviderConfig{})
expect.True(t, p.IsExplicitOnly())
}
@@ -199,7 +200,7 @@ func TestApplyLabelWithRefIndexError(t *testing.T) {
"proxy.*.port": "4444",
"proxy.#4.scheme": "https",
},
}, "")
}, types.DockerProviderConfig{})
var p DockerProvider
_, err := p.routesFromContainerLabels(c)
expect.ErrorIs(t, ErrAliasRefIndexOutOfRange, err)
@@ -211,7 +212,7 @@ func TestApplyLabelWithRefIndexError(t *testing.T) {
D.LabelAliases: "a,b",
"proxy.#0.host": "localhost",
},
}, "")
}, types.DockerProviderConfig{})
_, err = p.routesFromContainerLabels(c)
expect.ErrorIs(t, ErrAliasRefIndexOutOfRange, err)
}

View File

@@ -8,17 +8,14 @@ import (
"sync"
"time"
"github.com/moby/moby/client"
"github.com/rs/zerolog"
"github.com/yusing/godoxy/agent/pkg/agent"
"github.com/yusing/godoxy/internal/common"
"github.com/yusing/godoxy/internal/docker"
"github.com/yusing/godoxy/internal/route"
provider "github.com/yusing/godoxy/internal/route/provider/types"
"github.com/yusing/godoxy/internal/types"
W "github.com/yusing/godoxy/internal/watcher"
"github.com/yusing/godoxy/internal/watcher/events"
"github.com/yusing/goutils/env"
gperr "github.com/yusing/goutils/errs"
"github.com/yusing/goutils/task"
)
@@ -69,13 +66,9 @@ func NewFileProvider(filename string) (p *Provider, err error) {
return p, err
}
func NewDockerProvider(name string, dockerHost string) *Provider {
if dockerHost == common.DockerHostFromEnv {
dockerHost = env.GetEnvString("DOCKER_HOST", client.DefaultDockerHost)
}
func NewDockerProvider(name string, dockerCfg types.DockerProviderConfig) *Provider {
p := newProvider(provider.ProviderTypeDocker)
p.ProviderImpl = DockerProviderImpl(name, dockerHost)
p.ProviderImpl = DockerProviderImpl(name, dockerCfg)
p.watcher = p.NewWatcher()
return p
}
@@ -84,7 +77,9 @@ func NewAgentProvider(cfg *agent.AgentConfig) *Provider {
p := newProvider(provider.ProviderTypeAgent)
agent := &AgentProvider{
AgentConfig: cfg,
docker: DockerProviderImpl(cfg.Name, cfg.FakeDockerHost()),
docker: DockerProviderImpl(cfg.Name, types.DockerProviderConfig{
URL: cfg.FakeDockerHost(),
}),
}
p.ProviderImpl = agent
p.watcher = p.NewWatcher()

View File

@@ -6,6 +6,7 @@ import (
"github.com/yusing/godoxy/agent/pkg/agent"
"github.com/yusing/godoxy/agent/pkg/agentproxy"
config "github.com/yusing/godoxy/internal/config/types"
"github.com/yusing/godoxy/internal/idlewatcher"
"github.com/yusing/godoxy/internal/logging/accesslog"
gphttp "github.com/yusing/godoxy/internal/net/gphttp"
@@ -64,23 +65,25 @@ func NewReverseProxyRoute(base *Route) (*ReveseProxyRoute, gperr.Error) {
scheme := base.Scheme
retried := false
retryLock := sync.Mutex{}
rp.OnSchemeMisMatch = func() (retry bool) { // switch scheme and retry
retryLock.Lock()
defer retryLock.Unlock()
if scheme == route.SchemeHTTP || scheme == route.SchemeHTTPS {
rp.OnSchemeMisMatch = func() (retry bool) { // switch scheme and retry
retryLock.Lock()
defer retryLock.Unlock()
if retried {
return false
if retried {
return false
}
retried = true
if scheme == route.SchemeHTTP {
rp.TargetURL.Scheme = "https"
} else {
rp.TargetURL.Scheme = "http"
}
rp.Info().Msgf("scheme mismatch detected, retrying with %s", rp.TargetURL.Scheme)
return true
}
retried = true
if scheme == route.SchemeHTTP {
rp.TargetURL.Scheme = "https"
} else {
rp.TargetURL.Scheme = "http"
}
rp.Info().Msgf("scheme mismatch detected, retrying with %s", rp.TargetURL.Scheme)
return true
}
if len(base.Middlewares) > 0 {
@@ -164,8 +167,14 @@ func (r *ReveseProxyRoute) Start(parent task.Parent) gperr.Error {
r.addToLoadBalancer(parent)
} else {
routes.HTTP.Add(r)
r.task.OnCancel("remove_route_from_http", func() {
if state := config.WorkingState.Load(); state != nil {
state.ShortLinkMatcher().AddRoute(r.Alias)
}
r.task.OnCancel("remove_route", func() {
routes.HTTP.Del(r)
if state := config.WorkingState.Load(); state != nil {
state.ShortLinkMatcher().DelRoute(r.Alias)
}
})
}
return nil
@@ -206,8 +215,14 @@ func (r *ReveseProxyRoute) addToLoadBalancer(parent task.Parent) {
}
linked.SetHealthMonitor(lb)
routes.HTTP.AddKey(cfg.Link, linked)
if state := config.WorkingState.Load(); state != nil {
state.ShortLinkMatcher().AddRoute(cfg.Link)
}
r.task.OnFinished("remove_loadbalancer_route", func() {
routes.HTTP.DelKey(cfg.Link)
if state := config.WorkingState.Load(); state != nil {
state.ShortLinkMatcher().DelRoute(cfg.Link)
}
})
lbLock.Unlock()
}

View File

@@ -24,12 +24,14 @@ import (
"github.com/yusing/godoxy/internal/proxmox"
"github.com/yusing/godoxy/internal/serialization"
"github.com/yusing/godoxy/internal/types"
"github.com/yusing/godoxy/internal/watcher/health/monitor"
gperr "github.com/yusing/goutils/errs"
strutils "github.com/yusing/goutils/strings"
"github.com/yusing/goutils/task"
"github.com/yusing/godoxy/internal/common"
"github.com/yusing/godoxy/internal/logging/accesslog"
"github.com/yusing/godoxy/internal/route/routes"
"github.com/yusing/godoxy/internal/route/rules"
rulepresets "github.com/yusing/godoxy/internal/route/rules/presets"
route "github.com/yusing/godoxy/internal/route/types"
@@ -41,10 +43,13 @@ type (
_ utils.NoCopy
Alias string `json:"alias"`
Scheme route.Scheme `json:"scheme,omitempty" swaggertype:"string" enums:"http,https,tcp,udp,fileserver"`
Scheme route.Scheme `json:"scheme,omitempty" swaggertype:"string" enums:"http,https,h2c,tcp,udp,fileserver"`
Host string `json:"host,omitempty"`
Port route.Port `json:"port"`
Root string `json:"root,omitempty"`
Root string `json:"root,omitempty"`
SPA bool `json:"spa,omitempty"` // Single-page app mode: serves index for non-existent paths
Index string `json:"index,omitempty"` // Index file to serve for single-page app mode
route.HTTPConfig
PathPatterns []string `json:"path_patterns,omitempty" extensions:"x-nullable"`
@@ -266,7 +271,7 @@ func (r *Route) validate() gperr.Error {
r.ProxyURL = gperr.Collect(&errs, nettypes.ParseURL, "file://"+r.Root)
r.Host = ""
r.Port.Proxy = 0
case route.SchemeHTTP, route.SchemeHTTPS:
case route.SchemeHTTP, route.SchemeHTTPS, route.SchemeH2C:
if r.Port.Listening != 0 {
errs.Addf("unexpected listening port for %s scheme", r.Scheme)
}
@@ -289,7 +294,7 @@ func (r *Route) validate() gperr.Error {
switch r.Scheme {
case route.SchemeFileServer:
impl, err = NewFileServer(r)
case route.SchemeHTTP, route.SchemeHTTPS:
case route.SchemeHTTP, route.SchemeHTTPS, route.SchemeH2C:
impl, err = NewReverseProxyRoute(r)
case route.SchemeTCP, route.SchemeUDP:
impl, err = NewStreamRoute(r)
@@ -387,22 +392,31 @@ func (r *Route) start(parent task.Parent) gperr.Error {
}
if cont := r.ContainerInfo(); cont != nil {
docker.SetDockerHostByContainerID(cont.ContainerID, cont.DockerHost)
docker.SetDockerCfgByContainerID(cont.ContainerID, cont.DockerCfg)
}
if !excluded {
if err := r.impl.Start(parent); err != nil {
return err
}
} else { // required by idlewatcher
r.task = parent.Subtask("excluded."+r.Name(), false)
} else {
r.task = parent.Subtask("excluded."+r.Name(), true)
routes.Excluded.Add(r.impl)
r.task.OnCancel("remove_route_from_excluded", func() {
routes.Excluded.Del(r.impl)
})
if r.UseHealthCheck() {
r.HealthMon = monitor.NewMonitor(r.impl)
err := r.HealthMon.Start(r.task)
return err
}
}
return nil
}
func (r *Route) Finish(reason any) {
if cont := r.ContainerInfo(); cont != nil {
docker.DeleteDockerHostByContainerID(cont.ContainerID)
docker.DeleteDockerCfgByContainerID(cont.ContainerID)
}
r.FinishAndWait(reason)
}
@@ -774,6 +788,15 @@ func (r *Route) Finalize() {
}
r.Port.Listening, r.Port.Proxy = lp, pp
workingState := config.WorkingState.Load()
if workingState == nil {
if common.IsTest { // in tests, working state might be nil
return
}
panic("bug: working state is nil")
}
r.HealthCheck.ApplyDefaults(config.WorkingState.Load().Value().Defaults.HealthCheck)
}

View File

@@ -30,7 +30,7 @@ func (r *RouteContext) Value(key any) any {
func WithRouteContext(r *http.Request, route types.HTTPRoute) *http.Request {
// we don't want to copy the request object every fucking requests
// return r.WithContext(context.WithValue(r.Context(), routeContextKey, route))
ctxFieldPtr := (*context.Context)(unsafe.Pointer(uintptr(unsafe.Pointer(r)) + ctxFieldOffset))
ctxFieldPtr := (*context.Context)(unsafe.Add(unsafe.Pointer(r), ctxFieldOffset))
*ctxFieldPtr = &RouteContext{
Context: r.Context(),
Route: route,

View File

@@ -17,22 +17,38 @@ type HealthInfoWithoutDetail struct {
Latency time.Duration `json:"latency" swaggertype:"number"` // latency in microseconds
} // @name HealthInfoWithoutDetail
type HealthMap = map[string]types.HealthStatusString // @name HealthMap
// GetHealthInfo returns a map of route name to health info.
//
// The health info is for all routes, including excluded routes.
func GetHealthInfo() map[string]HealthInfo {
healthMap := make(map[string]HealthInfo, NumRoutes())
for r := range Iter {
healthMap := make(map[string]HealthInfo, NumAllRoutes())
for r := range IterAll {
healthMap[r.Name()] = getHealthInfo(r)
}
return healthMap
}
// GetHealthInfoWithoutDetail returns a map of route name to health info without detail.
//
// The health info is for all routes, including excluded routes.
func GetHealthInfoWithoutDetail() map[string]HealthInfoWithoutDetail {
healthMap := make(map[string]HealthInfoWithoutDetail, NumRoutes())
for r := range Iter {
healthMap := make(map[string]HealthInfoWithoutDetail, NumAllRoutes())
for r := range IterAll {
healthMap[r.Name()] = getHealthInfoWithoutDetail(r)
}
return healthMap
}
func GetHealthInfoSimple() map[string]types.HealthStatus {
healthMap := make(map[string]types.HealthStatus, NumAllRoutes())
for r := range IterAll {
healthMap[r.Name()] = getHealthInfoSimple(r)
}
return healthMap
}
func getHealthInfo(r types.Route) HealthInfo {
mon := r.HealthMonitor()
if mon == nil {
@@ -67,9 +83,20 @@ func getHealthInfoWithoutDetail(r types.Route) HealthInfoWithoutDetail {
}
}
func getHealthInfoSimple(r types.Route) types.HealthStatus {
mon := r.HealthMonitor()
if mon == nil {
return types.StatusUnknown
}
return mon.Status()
}
// ByProvider returns a map of provider name to routes.
//
// The routes are all routes, including excluded routes.
func ByProvider() map[string][]types.Route {
rts := make(map[string][]types.Route)
for r := range Iter {
for r := range IterAll {
rts[r.ProviderName()] = append(rts[r.ProviderName()], r)
}
return rts

View File

@@ -8,9 +8,11 @@ import (
var (
HTTP = pool.New[types.HTTPRoute]("http_routes")
Stream = pool.New[types.StreamRoute]("stream_routes")
Excluded = pool.New[types.Route]("excluded_routes")
)
func Iter(yield func(r types.Route) bool) {
func IterActive(yield func(r types.Route) bool) {
for _, r := range HTTP.Iter {
if !yield(r) {
break
@@ -23,26 +25,36 @@ func Iter(yield func(r types.Route) bool) {
}
}
func IterKV(yield func(alias string, r types.Route) bool) {
for k, r := range HTTP.Iter {
if !yield(k, r) {
func IterAll(yield func(r types.Route) bool) {
for _, r := range HTTP.Iter {
if !yield(r) {
break
}
}
for k, r := range Stream.Iter {
if !yield(k, r) {
for _, r := range Stream.Iter {
if !yield(r) {
break
}
}
for _, r := range Excluded.Iter {
if !yield(r) {
break
}
}
}
func NumRoutes() int {
func NumActiveRoutes() int {
return HTTP.Size() + Stream.Size()
}
func NumAllRoutes() int {
return HTTP.Size() + Stream.Size() + Excluded.Size()
}
func Clear() {
HTTP.Clear()
Stream.Clear()
Excluded.Clear()
}
func GetHTTPRouteOrExact(alias, host string) (types.HTTPRoute, bool) {
@@ -54,6 +66,9 @@ func GetHTTPRouteOrExact(alias, host string) (types.HTTPRoute, bool) {
return HTTP.Get(host)
}
// Get returns the route with the given alias.
//
// It does not return excluded routes.
func Get(alias string) (types.Route, bool) {
if r, ok := HTTP.Get(alias); ok {
return r, true

View File

@@ -15,6 +15,7 @@ import (
nettypes "github.com/yusing/godoxy/internal/net/types"
"github.com/yusing/godoxy/internal/notif"
"github.com/yusing/godoxy/internal/route/routes"
"github.com/yusing/godoxy/internal/types"
gperr "github.com/yusing/goutils/errs"
httputils "github.com/yusing/goutils/http"
"github.com/yusing/goutils/http/reverseproxy"
@@ -38,6 +39,7 @@ const (
CommandServe = "serve"
CommandProxy = "proxy"
CommandRedirect = "redirect"
CommandRoute = "route"
CommandError = "error"
CommandRequireBasicAuth = "require_basic_auth"
CommandSet = "set"
@@ -171,6 +173,42 @@ var commands = map[string]struct {
})
},
},
CommandRoute: {
help: Help{
command: CommandRoute,
description: makeLines(
"Route the request to another route, e.g.:",
helpExample(CommandRoute, "route1"),
),
args: map[string]string{
"route": "the route to route to",
},
},
validate: func(args []string) (any, gperr.Error) {
if len(args) != 1 {
return nil, ErrExpectOneArg
}
return args[0], nil
},
build: func(args any) CommandHandler {
route := args.(string)
return TerminatingCommand(func(w http.ResponseWriter, req *http.Request) error {
r, ok := routes.HTTP.Get(route)
if !ok {
excluded, has := routes.Excluded.Get(route)
if has {
r, ok = excluded.(types.HTTPRoute)
}
}
if ok {
r.ServeHTTP(w, req)
} else {
http.Error(w, fmt.Sprintf("Route %q not found", route), http.StatusNotFound)
}
return nil
})
},
},
CommandError: {
help: Help{
command: CommandError,

View File

@@ -270,7 +270,7 @@ func TestLogCommand_ConditionalLogging(t *testing.T) {
errorContent, err := os.ReadFile(errorFile.Name())
require.NoError(t, err)
errorLines := strings.Split(strings.TrimSpace(string(errorContent)), "\n")
assert.Len(t, errorLines, 2)
require.Len(t, errorLines, 2)
assert.Equal(t, "ERROR: GET /notfound 404", errorLines[0])
assert.Equal(t, "ERROR: POST /error 500", errorLines[1])
}
@@ -368,7 +368,7 @@ func TestLogCommand_FilePermissions(t *testing.T) {
logContent := strings.TrimSpace(string(content))
lines := strings.Split(logContent, "\n")
assert.Len(t, lines, 2)
require.Len(t, lines, 2)
assert.Equal(t, "GET 200", lines[0])
assert.Equal(t, "POST 200", lines[1])
}

View File

@@ -14,16 +14,18 @@ var ErrInvalidScheme = gperr.New("invalid scheme")
const (
SchemeHTTP Scheme = 1 << iota
SchemeHTTPS
SchemeH2C
SchemeTCP
SchemeUDP
SchemeFileServer
SchemeNone Scheme = 0
schemeReverseProxy = SchemeHTTP | SchemeHTTPS
schemeReverseProxy = SchemeHTTP | SchemeHTTPS | SchemeH2C
schemeStream = SchemeTCP | SchemeUDP
schemeStrHTTP = "http"
schemeStrHTTPS = "https"
schemeStrH2C = "h2c"
schemeStrTCP = "tcp"
schemeStrUDP = "udp"
schemeStrFileServer = "fileserver"
@@ -36,6 +38,8 @@ func (s Scheme) String() string {
return schemeStrHTTP
case SchemeHTTPS:
return schemeStrHTTPS
case SchemeH2C:
return schemeStrH2C
case SchemeTCP:
return schemeStrTCP
case SchemeUDP:
@@ -66,6 +70,8 @@ func (s *Scheme) Parse(v string) error {
*s = SchemeHTTP
case schemeStrHTTPS:
*s = SchemeHTTPS
case schemeStrH2C:
*s = SchemeH2C
case schemeStrTCP:
*s = SchemeTCP
case schemeStrUDP:

View File

@@ -13,10 +13,10 @@ type (
PortMapping = map[int]container.PortSummary
Container struct {
DockerHost string `json:"docker_host"`
Image *ContainerImage `json:"image"`
ContainerName string `json:"container_name"`
ContainerID string `json:"container_id"`
DockerCfg DockerProviderConfig `json:"docker_cfg"`
Image *ContainerImage `json:"image"`
ContainerName string `json:"container_name"`
ContainerID string `json:"container_id"`
State container.ContainerState `json:"state"`

View File

@@ -0,0 +1,90 @@
package types
import (
"encoding/json"
"fmt"
"net"
"net/url"
"os"
"strconv"
"github.com/yusing/godoxy/internal/common"
"github.com/yusing/godoxy/internal/serialization"
"github.com/yusing/goutils/env"
gperr "github.com/yusing/goutils/errs"
)
type DockerProviderConfig struct {
URL string `json:"url,omitempty"`
TLS *DockerTLSConfig `json:"tls,omitempty"`
} // @name DockerProviderConfig
type DockerProviderConfigDetailed struct {
Scheme string `json:"scheme,omitempty" validate:"required,oneof=http https tcp tls unix ssh"`
Host string `json:"host,omitempty" validate:"required,hostname|ip"`
Port int `json:"port,omitempty" validate:"required,min=1,max=65535"`
TLS *DockerTLSConfig `json:"tls" validate:"omitempty"`
}
type DockerTLSConfig struct {
CAFile string `json:"ca_file,omitempty" validate:"required"`
CertFile string `json:"cert_file,omitempty" validate:"required_with=KeyFile"`
KeyFile string `json:"key_file,omitempty" validate:"required_with=CertFile"`
} // @name DockerTLSConfig
func (cfg *DockerProviderConfig) MarshalJSON() ([]byte, error) {
return json.Marshal(cfg.URL)
}
func (cfg *DockerProviderConfig) Parse(value string) error {
if value == common.DockerHostFromEnv {
cfg.URL = env.GetEnvString("DOCKER_HOST", "unix:///var/run/docker.sock")
return nil
}
u, err := url.Parse(value)
if err != nil {
return err
}
switch u.Scheme {
case "http", "https", "tcp", "tls":
cfg.URL = u.String()
case "unix", "ssh":
cfg.URL = value
default:
return fmt.Errorf("invalid scheme: %s", u.Scheme)
}
return nil
}
func (cfg *DockerProviderConfig) UnmarshalMap(m map[string]any) gperr.Error {
var tmp DockerProviderConfigDetailed
var err = serialization.MapUnmarshalValidate(m, &tmp)
if err != nil {
return err
}
cfg.URL = fmt.Sprintf("%s://%s", tmp.Scheme, net.JoinHostPort(tmp.Host, strconv.Itoa(tmp.Port)))
cfg.TLS = tmp.TLS
if cfg.TLS != nil {
if err := checkFilesOk(cfg.TLS.CAFile, cfg.TLS.CertFile, cfg.TLS.KeyFile); err != nil {
return gperr.Wrap(err)
}
}
return nil
}
func checkFilesOk(files ...string) error {
if common.IsTest {
return nil
}
var errs gperr.Builder
for _, file := range files {
if _, err := os.Stat(file); err != nil {
errs.Add(err)
}
}
return errs.Error()
}

View File

@@ -0,0 +1,142 @@
package types
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/yusing/godoxy/internal/serialization"
)
func TestDockerProviderConfigUnmarshalMap(t *testing.T) {
t.Run("string", func(t *testing.T) {
var cfg map[string]*DockerProviderConfig
err := serialization.UnmarshalValidateYAML([]byte("test: http://localhost:2375"), &cfg)
assert.NoError(t, err)
assert.Equal(t, &DockerProviderConfig{URL: "http://localhost:2375"}, cfg["test"])
})
t.Run("detailed", func(t *testing.T) {
var cfg map[string]*DockerProviderConfig
err := serialization.UnmarshalValidateYAML([]byte(`
test:
scheme: http
host: localhost
port: 2375
tls:
ca_file: /etc/ssl/ca.crt
cert_file: /etc/ssl/cert.crt
key_file: /etc/ssl/key.crt`), &cfg)
assert.NoError(t, err)
assert.Equal(t, &DockerProviderConfig{URL: "http://localhost:2375", TLS: &DockerTLSConfig{CAFile: "/etc/ssl/ca.crt", CertFile: "/etc/ssl/cert.crt", KeyFile: "/etc/ssl/key.crt"}}, cfg["test"])
})
}
func TestDockerProviderConfigValidation(t *testing.T) {
tests := []struct {
name string
yamlStr string
wantErr bool
}{
{name: "valid url (http)", yamlStr: "test: http://localhost:2375", wantErr: false},
{name: "valid url (https)", yamlStr: "test: https://localhost:2375", wantErr: false},
{name: "valid url (tcp)", yamlStr: "test: tcp://localhost:2375", wantErr: false},
{name: "valid url (tls)", yamlStr: "test: tls://localhost:2375", wantErr: false},
{name: "valid url (unix)", yamlStr: "test: unix:///var/run/docker.sock", wantErr: false},
{name: "valid url (ssh)", yamlStr: "test: ssh://localhost:2375", wantErr: false},
{name: "invalid url", yamlStr: "test: ftp://localhost/2375", wantErr: true},
{name: "valid scheme", yamlStr: `
test:
scheme: http
host: localhost
port: 2375
`, wantErr: false},
{name: "invalid scheme", yamlStr: `
test:
scheme: invalid
host: localhost
port: 2375
`, wantErr: true},
{name: "valid host (ipv4)", yamlStr: `
test:
scheme: http
host: 127.0.0.1
port: 2375
`, wantErr: false},
{name: "valid host (ipv6)", yamlStr: `
test:
scheme: http
host: ::1
port: 2375
`, wantErr: false},
{name: "valid host (hostname)", yamlStr: `
test:
scheme: http
host: example.com
port: 2375
`, wantErr: false},
{name: "invalid host", yamlStr: `
test:
scheme: http
host: invalid:1234
port: 2375
`, wantErr: true},
{name: "valid port", yamlStr: `
test:
scheme: http
host: localhost
port: 2375
`, wantErr: false},
{name: "invalid port", yamlStr: `
test:
scheme: http
host: localhost
port: 65536
`, wantErr: true},
{name: "valid tls", yamlStr: `
test:
scheme: tls
host: localhost
port: 2375
tls:
ca_file: /etc/ssl/ca.crt
cert_file: /etc/ssl/cert.crt
key_file: /etc/ssl/key.crt
`, wantErr: false},
{name: "valid tls (only ca file)", yamlStr: `
test:
scheme: tls
host: localhost
port: 2375
tls:
ca_file: /etc/ssl/ca.crt
`, wantErr: false},
{name: "invalid tls (missing cert file)", yamlStr: `
test:
scheme: tls
host: localhost
port: 2375
tls:
key_file: /etc/ssl/key.crt
`, wantErr: true},
{name: "invalid tls (missing key file)", yamlStr: `
test:
scheme: tls
host: localhost
port: 2375
tls:
cert_file: /etc/ssl/cert.crt
`, wantErr: true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
var cfg map[string]*DockerProviderConfig
err := serialization.UnmarshalValidateYAML([]byte(test.yamlStr), &cfg)
if test.wantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
}

View File

@@ -8,12 +8,12 @@ import (
"time"
"github.com/bytedance/sonic"
strutils "github.com/yusing/goutils/strings"
"github.com/yusing/goutils/task"
)
type (
HealthStatus uint8
HealthStatus uint8 // @name HealthStatus
HealthStatusString string // @name HealthStatusString
HealthCheckResult struct {
Healthy bool `json:"healthy"`
@@ -45,20 +45,16 @@ type (
HealthChecker
}
HealthJSON struct {
Name string `json:"name"`
Config *HealthCheckConfig `json:"config"`
Started int64 `json:"started"`
StartedStr string `json:"startedStr"`
Status string `json:"status"`
Uptime float64 `json:"uptime"`
UptimeStr string `json:"uptimeStr"`
Latency float64 `json:"latency"`
LatencyStr string `json:"latencyStr"`
LastSeen int64 `json:"lastSeen"`
LastSeenStr string `json:"lastSeenStr"`
Detail string `json:"detail"`
URL string `json:"url"`
Extra *HealthExtra `json:"extra,omitempty" extensions:"x-nullable"`
Name string `json:"name"`
Config *HealthCheckConfig `json:"config"`
Started int64 `json:"started"` // unix timestamp in seconds
Status HealthStatusString `json:"status"`
Uptime float64 `json:"uptime"` // uptime in seconds
Latency int64 `json:"latency"` // latency in milliseconds
LastSeen int64 `json:"lastSeen"` // unix timestamp in seconds
Detail string `json:"detail"`
URL string `json:"url"`
Extra *HealthExtra `json:"extra,omitempty" extensions:"x-nullable"`
} // @name HealthJSON
HealthJSONRepr struct {
@@ -88,12 +84,12 @@ const (
StatusUnhealthy
StatusError
StatusUnknownStr = "unknown"
StatusHealthyStr = "healthy"
StatusNappingStr = "napping"
StatusStartingStr = "starting"
StatusUnhealthyStr = "unhealthy"
StatusErrorStr = "error"
StatusUnknownStr HealthStatusString = "unknown"
StatusHealthyStr HealthStatusString = "healthy"
StatusNappingStr HealthStatusString = "napping"
StatusStartingStr HealthStatusString = "starting"
StatusUnhealthyStr HealthStatusString = "unhealthy"
StatusErrorStr HealthStatusString = "error"
NumStatuses int = iota - 1
@@ -102,15 +98,15 @@ const (
)
var (
StatusHealthyStr2 = strconv.Itoa(int(StatusHealthy))
StatusNappingStr2 = strconv.Itoa(int(StatusNapping))
StatusStartingStr2 = strconv.Itoa(int(StatusStarting))
StatusUnhealthyStr2 = strconv.Itoa(int(StatusUnhealthy))
StatusErrorStr2 = strconv.Itoa(int(StatusError))
StatusHealthyStr2 HealthStatusString = HealthStatusString(strconv.Itoa(int(StatusHealthy)))
StatusNappingStr2 HealthStatusString = HealthStatusString(strconv.Itoa(int(StatusNapping)))
StatusStartingStr2 HealthStatusString = HealthStatusString(strconv.Itoa(int(StatusStarting)))
StatusUnhealthyStr2 HealthStatusString = HealthStatusString(strconv.Itoa(int(StatusUnhealthy)))
StatusErrorStr2 HealthStatusString = HealthStatusString(strconv.Itoa(int(StatusError)))
)
func NewHealthStatusFromString(s string) HealthStatus {
switch s {
switch HealthStatusString(s) {
case StatusHealthyStr, StatusHealthyStr2:
return StatusHealthy
case StatusUnhealthyStr, StatusUnhealthyStr2:
@@ -126,7 +122,7 @@ func NewHealthStatusFromString(s string) HealthStatus {
}
}
func (s HealthStatus) String() string {
func (s HealthStatus) StatusString() HealthStatusString {
switch s {
case StatusHealthy:
return StatusHealthyStr
@@ -143,6 +139,11 @@ func (s HealthStatus) String() string {
}
}
// String implements fmt.Stringer.
func (s HealthStatus) String() string {
return string(s.StatusString())
}
func (s HealthStatus) Good() bool {
return s&HealthyMask != 0
}
@@ -178,19 +179,15 @@ func (jsonRepr *HealthJSONRepr) MarshalJSON() ([]byte, error) {
url = ""
}
return sonic.Marshal(HealthJSON{
Name: jsonRepr.Name,
Config: jsonRepr.Config,
Started: jsonRepr.Started.Unix(),
StartedStr: strutils.FormatTime(jsonRepr.Started),
Status: jsonRepr.Status.String(),
Uptime: jsonRepr.Uptime.Seconds(),
UptimeStr: strutils.FormatDuration(jsonRepr.Uptime),
Latency: jsonRepr.Latency.Seconds(),
LatencyStr: strconv.Itoa(int(jsonRepr.Latency.Milliseconds())) + " ms",
LastSeen: jsonRepr.LastSeen.Unix(),
LastSeenStr: strutils.FormatLastSeen(jsonRepr.LastSeen),
Detail: jsonRepr.Detail,
URL: url,
Extra: jsonRepr.Extra,
Name: jsonRepr.Name,
Config: jsonRepr.Config,
Started: jsonRepr.Started.Unix(),
Status: HealthStatusString(jsonRepr.Status.String()),
Uptime: jsonRepr.Uptime.Seconds(),
Latency: jsonRepr.Latency.Milliseconds(),
LastSeen: jsonRepr.LastSeen.Unix(),
Detail: jsonRepr.Detail,
URL: url,
Extra: jsonRepr.Extra,
})
}

View File

@@ -38,9 +38,9 @@ type (
ContainerSignal string // @name ContainerSignal
DockerConfig struct {
DockerHost string `json:"docker_host" validate:"required"`
ContainerID string `json:"container_id" validate:"required"`
ContainerName string `json:"container_name" validate:"required"`
DockerCfg DockerProviderConfig `json:"docker_cfg" validate:"required"`
ContainerID string `json:"container_id" validate:"required"`
ContainerName string `json:"container_name" validate:"required"`
} // @name DockerConfig
ProxmoxConfig struct {
Node string `json:"node" validate:"required"`

View File

@@ -1,243 +0,0 @@
package utils
import (
"reflect"
"unsafe"
)
// DeepEqual reports whether x and y are deeply equal.
// It supports numerics, strings, maps, slices, arrays, and structs (exported fields only).
// It's optimized for performance by avoiding reflection for common types and
// adaptively choosing between BFS and DFS traversal strategies.
func DeepEqual(x, y any) bool {
if x == nil || y == nil {
return x == y
}
v1 := reflect.ValueOf(x)
v2 := reflect.ValueOf(y)
if v1.Type() != v2.Type() {
return false
}
return deepEqual(v1, v2, make(map[visit]bool), 0)
}
// visit represents a visit to a pair of values during comparison
type visit struct {
a1, a2 unsafe.Pointer
typ reflect.Type
}
// deepEqual performs the actual deep comparison with cycle detection
func deepEqual(v1, v2 reflect.Value, visited map[visit]bool, depth int) bool {
if !v1.IsValid() || !v2.IsValid() {
return v1.IsValid() == v2.IsValid()
}
if v1.Type() != v2.Type() {
return false
}
// Handle cycle detection for pointer-like types
if v1.CanAddr() && v2.CanAddr() {
addr1 := unsafe.Pointer(v1.UnsafeAddr())
addr2 := unsafe.Pointer(v2.UnsafeAddr())
typ := v1.Type()
v := visit{addr1, addr2, typ}
if visited[v] {
return true // already visiting, assume equal
}
visited[v] = true
defer delete(visited, v)
}
switch v1.Kind() {
case reflect.Bool:
return v1.Bool() == v2.Bool()
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return v1.Int() == v2.Int()
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
return v1.Uint() == v2.Uint()
case reflect.Float32, reflect.Float64:
return floatEqual(v1.Float(), v2.Float())
case reflect.Complex64, reflect.Complex128:
c1, c2 := v1.Complex(), v2.Complex()
return floatEqual(real(c1), real(c2)) && floatEqual(imag(c1), imag(c2))
case reflect.String:
return v1.String() == v2.String()
case reflect.Array:
return deepEqualArray(v1, v2, visited, depth)
case reflect.Slice:
return deepEqualSlice(v1, v2, visited, depth)
case reflect.Map:
return deepEqualMap(v1, v2, visited, depth)
case reflect.Struct:
return deepEqualStruct(v1, v2, visited, depth)
case reflect.Ptr:
if v1.IsNil() || v2.IsNil() {
return v1.IsNil() && v2.IsNil()
}
return deepEqual(v1.Elem(), v2.Elem(), visited, depth+1)
case reflect.Interface:
if v1.IsNil() || v2.IsNil() {
return v1.IsNil() && v2.IsNil()
}
return deepEqual(v1.Elem(), v2.Elem(), visited, depth+1)
default:
// For unsupported types (func, chan, etc.), fall back to basic equality
return v1.Interface() == v2.Interface()
}
}
// floatEqual handles NaN cases properly
func floatEqual(f1, f2 float64) bool {
return f1 == f2 || (f1 != f1 && f2 != f2) // NaN == NaN
}
// deepEqualArray compares arrays using DFS (since arrays have fixed size)
func deepEqualArray(v1, v2 reflect.Value, visited map[visit]bool, depth int) bool {
for i := range v1.Len() {
if !deepEqual(v1.Index(i), v2.Index(i), visited, depth+1) {
return false
}
}
return true
}
// deepEqualSlice compares slices, choosing strategy based on size and depth
func deepEqualSlice(v1, v2 reflect.Value, visited map[visit]bool, depth int) bool {
if v1.IsNil() != v2.IsNil() {
return false
}
if v1.Len() != v2.Len() {
return false
}
if v1.IsNil() {
return true
}
// Use BFS for large slices at shallow depth to improve cache locality
// Use DFS for small slices or deep nesting to reduce memory overhead
if shouldUseBFS(v1.Len(), depth) {
return deepEqualSliceBFS(v1, v2, visited, depth)
}
return deepEqualSliceDFS(v1, v2, visited, depth)
}
// deepEqualSliceDFS uses depth-first traversal
func deepEqualSliceDFS(v1, v2 reflect.Value, visited map[visit]bool, depth int) bool {
for i := range v1.Len() {
if !deepEqual(v1.Index(i), v2.Index(i), visited, depth+1) {
return false
}
}
return true
}
// deepEqualSliceBFS uses breadth-first traversal for better cache locality
func deepEqualSliceBFS(v1, v2 reflect.Value, visited map[visit]bool, depth int) bool {
length := v1.Len()
// First, check all direct elements
for i := range length {
elem1, elem2 := v1.Index(i), v2.Index(i)
// For simple types, compare directly
if isSimpleType(elem1.Kind()) {
if !deepEqual(elem1, elem2, visited, depth+1) {
return false
}
}
}
// Then, recursively check complex elements
for i := range length {
elem1, elem2 := v1.Index(i), v2.Index(i)
if !isSimpleType(elem1.Kind()) {
if !deepEqual(elem1, elem2, visited, depth+1) {
return false
}
}
}
return true
}
// deepEqualMap compares maps
func deepEqualMap(v1, v2 reflect.Value, visited map[visit]bool, depth int) bool {
if v1.IsNil() != v2.IsNil() {
return false
}
if v1.Len() != v2.Len() {
return false
}
if v1.IsNil() {
return true
}
// Check all keys and values
for _, key := range v1.MapKeys() {
val1 := v1.MapIndex(key)
val2 := v2.MapIndex(key)
if !val2.IsValid() {
return false // key doesn't exist in v2
}
if !deepEqual(val1, val2, visited, depth+1) {
return false
}
}
return true
}
// deepEqualStruct compares structs (exported fields only)
func deepEqualStruct(v1, v2 reflect.Value, visited map[visit]bool, depth int) bool {
typ := v1.Type()
for i := range typ.NumField() {
field := typ.Field(i)
// Skip unexported fields
if !field.IsExported() {
continue
}
if !deepEqual(v1.Field(i), v2.Field(i), visited, depth+1) {
return false
}
}
return true
}
// shouldUseBFS determines whether to use BFS or DFS based on slice size and depth
func shouldUseBFS(length, depth int) bool {
// Use BFS for large slices at shallow depth (better cache locality)
// Use DFS for small slices or deep nesting (lower memory overhead)
return length > 100 && depth < 3
}
// isSimpleType checks if a type can be compared without deep recursion
func isSimpleType(kind reflect.Kind) bool {
if kind >= reflect.Bool && kind <= reflect.Complex128 {
return true
}
return kind == reflect.String
}

View File

@@ -9,12 +9,15 @@ import (
"github.com/moby/moby/client"
"github.com/rs/zerolog/log"
"github.com/yusing/godoxy/internal/docker"
"github.com/yusing/godoxy/internal/types"
"github.com/yusing/godoxy/internal/watcher/events"
gperr "github.com/yusing/goutils/errs"
)
type (
DockerWatcher string
DockerWatcher struct {
cfg types.DockerProviderConfig
}
DockerListOptions = client.EventsListOptions
DockerFilters = client.Filters
)
@@ -73,8 +76,10 @@ func DockerFilterContainerNameID(nameOrID string) DockerFilter {
return NewDockerFilter("container", nameOrID)
}
func NewDockerWatcher(host string) DockerWatcher {
return DockerWatcher(host)
func NewDockerWatcher(dockerCfg types.DockerProviderConfig) DockerWatcher {
return DockerWatcher{
cfg: dockerCfg,
}
}
func (w DockerWatcher) Events(ctx context.Context) (<-chan Event, <-chan gperr.Error) {
@@ -86,7 +91,7 @@ func (w DockerWatcher) EventsWithOptions(ctx context.Context, options DockerList
errCh := make(chan gperr.Error)
go func() {
client, err := docker.NewClient(string(w))
client, err := docker.NewClient(w.cfg)
if err != nil {
errCh <- gperr.Wrap(err, "docker watcher: failed to initialize client")
return

View File

@@ -1,14 +1,18 @@
package monitor
import (
"context"
"crypto/tls"
"errors"
"net"
"net/http"
"net/url"
"time"
"github.com/valyala/fasthttp"
"github.com/yusing/godoxy/internal/types"
"github.com/yusing/goutils/version"
"golang.org/x/net/http2"
)
type HTTPHealthMonitor struct {
@@ -17,8 +21,6 @@ type HTTPHealthMonitor struct {
}
var pinger = &fasthttp.Client{
ReadTimeout: 5 * time.Second,
WriteTimeout: 3 * time.Second,
MaxConnDuration: 0,
DisableHeaderNamesNormalizing: true,
DisablePathNormalizing: true,
@@ -42,41 +44,30 @@ func NewHTTPHealthMonitor(url *url.URL, config types.HealthCheckConfig) *HTTPHea
var userAgent = "GoDoxy/" + version.Get().String()
func (mon *HTTPHealthMonitor) CheckHealth() (types.HealthCheckResult, error) {
req := fasthttp.AcquireRequest()
defer fasthttp.ReleaseRequest(req)
func setCommonHeaders(setHeader func(key, value string)) {
setHeader("User-Agent", userAgent)
setHeader("Accept", "text/plain,text/html,*/*;q=0.8")
setHeader("Accept-Encoding", "identity")
setHeader("Cache-Control", "no-cache")
setHeader("Pragma", "no-cache")
}
resp := fasthttp.AcquireResponse()
defer fasthttp.ReleaseResponse(resp)
req.SetRequestURI(mon.url.Load().JoinPath(mon.config.Path).String())
req.Header.SetMethod(mon.method)
req.Header.Set("User-Agent", userAgent)
req.Header.Set("Accept", "text/plain,text/html,*/*;q=0.8")
req.Header.Set("Accept-Encoding", "identity")
req.Header.Set("Cache-Control", "no-cache")
req.Header.Set("Pragma", "no-cache")
req.SetConnectionClose()
start := time.Now()
respErr := pinger.DoTimeout(req, resp, mon.config.Timeout)
lat := time.Since(start)
if respErr != nil {
// treat TLS error as healthy
func processHealthResponse(lat time.Duration, err error, getStatusCode func() int) (types.HealthCheckResult, error) {
if err != nil {
var tlsErr *tls.CertificateVerificationError
if ok := errors.As(respErr, &tlsErr); !ok {
if ok := errors.As(err, &tlsErr); !ok {
return types.HealthCheckResult{
Latency: lat,
Detail: respErr.Error(),
Detail: err.Error(),
}, nil
}
}
if status := resp.StatusCode(); status >= 500 && status < 600 {
statusCode := getStatusCode()
if statusCode >= 500 && statusCode < 600 {
return types.HealthCheckResult{
Latency: lat,
Detail: fasthttp.StatusMessage(resp.StatusCode()),
Detail: http.StatusText(statusCode),
}, nil
}
@@ -85,3 +76,73 @@ func (mon *HTTPHealthMonitor) CheckHealth() (types.HealthCheckResult, error) {
Healthy: true,
}, nil
}
var h2cClient = &http.Client{
Transport: &http2.Transport{
AllowHTTP: true,
DialTLSContext: func(ctx context.Context, network, addr string, _ *tls.Config) (net.Conn, error) {
var d net.Dialer
return d.DialContext(ctx, network, addr)
},
},
}
func (mon *HTTPHealthMonitor) CheckHealth() (types.HealthCheckResult, error) {
if mon.url.Load().Scheme == "h2c" {
return mon.CheckHealthH2C()
}
return mon.CheckHealthHTTP()
}
func (mon *HTTPHealthMonitor) CheckHealthHTTP() (types.HealthCheckResult, error) {
req := fasthttp.AcquireRequest()
defer fasthttp.ReleaseRequest(req)
resp := fasthttp.AcquireResponse()
defer fasthttp.ReleaseResponse(resp)
req.SetRequestURI(mon.url.Load().JoinPath(mon.config.Path).String())
req.Header.SetMethod(mon.method)
setCommonHeaders(req.Header.Set)
req.SetConnectionClose()
start := time.Now()
respErr := pinger.DoTimeout(req, resp, mon.config.Timeout)
lat := time.Since(start)
return processHealthResponse(lat, respErr, resp.StatusCode)
}
func (mon *HTTPHealthMonitor) CheckHealthH2C() (types.HealthCheckResult, error) {
u := mon.url.Load()
u = u.JoinPath(mon.config.Path) // JoinPath returns a copy of the URL with the path joined
u.Scheme = "http"
ctx, cancel := mon.ContextWithTimeout("h2c health check timed out")
defer cancel()
var req *http.Request
var err error
if mon.method == fasthttp.MethodGet {
req, err = http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
} else {
req, err = http.NewRequestWithContext(ctx, http.MethodHead, u.String(), nil)
}
if err != nil {
return types.HealthCheckResult{
Detail: err.Error(),
}, nil
}
setCommonHeaders(req.Header.Set)
start := time.Now()
resp, err := h2cClient.Do(req)
lat := time.Since(start)
if resp != nil {
defer resp.Body.Close()
}
return processHealthResponse(lat, err, func() int { return resp.StatusCode })
}

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