Compare commits

..

84 Commits

Author SHA1 Message Date
yusing
f6dcc8f118 fix(idlewatcher): correct variable declaration for event data parsing in loading.js 2025-11-07 17:08:58 +08:00
yusing
4d6541c851 chore(deps): update Go version in Dockerfile and go mod tidy 2025-11-07 16:35:35 +08:00
yusing
c9db350cbc fix(route): add workaround for WebUI rule preset loading in validateRules function
- Introduced a temporary fix for loading the "webui.yml" rule preset when the container image is "godoxy-frontend".
- Added error handling for cases where the rule preset is not found.
- Marked the change with a FIXME comment to investigate the underlying issue in the future.
2025-11-07 16:16:58 +08:00
yusing
56374d595a fix(idlewatcher): correct target URL validation logic in NewWatcher function 2025-11-07 15:55:56 +08:00
yusing
d81521f293 refactor: improve HTTPS detection logic by using case-insensitive comparison for X-Forwarded-Proto header 2025-11-07 15:49:51 +08:00
yusing
e9ac3cd1a9 refactor: fix incorrect logic introduced in previous commits and improve error handling 2025-11-07 15:48:38 +08:00
yusing
d33ff2192a refactor(loadbalancer): implement sticky sessions and improve algorithm separation
- Refactor load balancer interface to separate server selection (ChooseServer) from request handling
- Add cookie-based sticky session support with configurable max-age and secure cookie handling
- Integrate idlewatcher requests with automatic sticky session assignment
- Improve algorithm implementations:
  * Replace fnv with xxhash3 for better performance in IP hash and server keys
  * Add proper bounds checking and error handling in all algorithms
  * Separate concerns between server selection and request processing
- Add Sticky and StickyMaxAge fields to LoadBalancerConfig
- Create dedicated sticky.go for session management utilities
2025-11-07 15:24:57 +08:00
yusing
910ef639a4 feat(idlewatcher): implement real-time SSE-based loading page with enhanced UX
This major overhaul of the idlewatcher system introduces a modern, real-time loading experience with Server-Sent Events (SSE) streaming and improved error handling.

- **Real-time Event Streaming**: New SSE endpoint (`/$godoxy/wake-events`) provides live updates during container wake process
- **Enhanced Loading Page**: Modern console-style interface with timestamped events and color-coded status messages
- **Improved Static Asset Management**: Dedicated paths for CSS, JS, and favicon to avoid conflicting with upstream assets
- **Event History Buffer**: Stores wake events for reconnecting clients and debugging

- Refactored HTTP request handling with cleaner static asset routing
- Added `WakeEvent` system with structured event types (starting, waking_dep, dep_ready, container_woke, waiting_ready, ready, error)
- Implemented thread-safe event broadcasting using xsync.Map for concurrent SSE connections
- Enhanced error handling with detailed logging and user-friendly error messages
- Simplified loading page template system with better asset path management
- Fixed race conditions in dependency waking and state management

- Removed `common.go` functions (canceled, waitStarted) - moved inline for better context
- Updated Waker interface to accept context parameter in Wake() method
- New static asset paths use `/$godoxy/` prefix to avoid conflicts

- Console-style output with Fira Code font for better readability
- Color-coded event types (yellow for starting, blue for dependencies, green for success, red for errors)
- Automatic page refresh when container becomes ready
- Improved visual design with better glassmorphism effects and responsive layout
- Real-time progress feedback during dependency wake and container startup

This change transforms the static loading page into a dynamic, informative experience that keeps users informed during the wake process while maintaining backward compatibility with existing routing behavior.
2025-11-07 14:58:33 +08:00
yusing
3cbd70f73a fix(health_check): correct agent routes health check logic 2025-11-07 10:21:15 +08:00
yusing
83d70d3bb2 fix(health_monitor): fix UpdateURL behavior in Docker and AgentProxied health monitor 2025-11-07 00:53:34 +08:00
yusing
bbb1b8497f fix(health_monitor): treat some errors as unhealth instead of actual error in RawHealthMonitor 2025-11-06 23:37:34 +08:00
yusing
d57d76dc65 feat(loading_page): move loading page css to style.css and serve as static asset 2025-11-06 20:32:40 +08:00
yusing
ef893974ea feat(route): implement PreferOver method for deterministic route replacement 2025-11-06 20:19:49 +08:00
yusing
b90f2409ab refactor(docker): set alias initially to have better debuggability 2025-11-06 20:15:03 +08:00
yusing
36e9b0d416 chore: upgrade go to 1.25.4 and dependencies 2025-11-06 20:13:01 +08:00
yusing
306cb7a20e fix(access_logger): fix stdout and path not working at the same time 2025-11-01 12:07:22 +08:00
yusing
e3915210aa fix(time): data race in DefaultTimeNow 2025-11-01 02:18:24 +08:00
yusing
e8fb202ea9 fix(docker): fix wildcard not working correctly with #N ref aliases 2025-11-01 02:10:09 +08:00
yusing
082b2f5da2 refactor(websocket): use only the first error and fix race condition 2025-11-01 01:28:12 +08:00
yusing
e670acb4b8 fix(access_logger): nil panic when stdout only, improve concurrency safety 2025-11-01 01:17:55 +08:00
yusing
77e486f4fe refactor(route): ensure validation and start only starts once, and lock error before finishing 2025-10-31 18:10:09 +08:00
yusing
3ccaba3163 fix(validation): prioritize pointer method for custom validation in serialization 2025-10-31 18:06:41 +08:00
yusing
705923960c feat(fileserver): add rules support for fileservers 2025-10-31 17:32:37 +08:00
yusing
ca737c8979 fix(modify-html): re-enable modifying HTML with chunked encoding 2025-10-31 17:30:23 +08:00
yusing
b6b5d4dbd7 fix(auth): handle nil defaultAuth to prevent nil panic before auth intializes 2025-10-31 17:15:03 +08:00
yusing
b2919fbaf6 feat(rules): supress some errors in rule execution 2025-10-31 17:13:09 +08:00
yusing
722c40d103 chore(examples): update example configurations with comments for certificate paths and lite variant 2025-10-30 11:45:06 +08:00
yusing
860d9c71b6 fix(pool,io): overlap memory on buffer splitting; hook in HookReadCloser should run after Close 2025-10-29 22:48:28 +08:00
yusing
e354d901c4 fix(monitor): safer approach to avoid nil panic in edge cases 2025-10-29 00:19:17 +08:00
yusing
921a8fb935 fix(monitor): handle missing container state in Docker health check 2025-10-28 23:49:59 +08:00
yusing
975354cdc1 chore(compose): comment out user for lite variant in example configuration 2025-10-28 23:15:54 +08:00
yusing
7d38bfd2d2 build: drop old image name support 2025-10-28 22:05:35 +08:00
yusing
5506cafa26 fix(rules): pages not loading correct for lite webui variant 2025-10-28 22:00:54 +08:00
yusing
9fd5bff81a fix(oidc): fix Webui OIDC loop 2025-10-28 21:54:46 +08:00
yusing
38041ca5b8 fix(pool): handle buffer capacity check in GetSized
- Added a check to ensure the buffer's capacity is sufficient before reusing it.
- Included a FIXME comment to address an unexpected condition in buffer allocation.
2025-10-28 21:48:27 +08:00
yusing
61be88c1d3 chore: upgrade dependencies 2025-10-28 21:40:23 +08:00
yusing
cb4dcb962e fix(http): nil panic in goutils http/intercept.go 2025-10-28 21:37:19 +08:00
yusing
1797a222cd fix(middlewares): correctly bypass middlewares with response rules 2025-10-28 20:44:46 +08:00
yusing
098fb7e62d fix(compose): update rootless compose example 2025-10-28 17:03:20 +08:00
yusing
d4dfec8293 refactor(http): proper ResponseWriter and headers handling across files 2025-10-28 14:43:10 +08:00
yusing
f29b69ff3b refactor(rules): remove Flush method and replace with http.NewResponseController in ResponseModifier 2025-10-27 17:46:23 +08:00
yusing
5e00e1c437 fix(middleware): correct and simplify HTML modification / buffer management logic, correct Accept-Encoding header 2025-10-27 15:08:29 +08:00
yusing
39c8cc2820 fix(auth): nil panic by handling in TryRefreshToken 2025-10-27 14:25:05 +08:00
yusing
56232dbd0e fix(monitor): nil panic in DockerHealthMonitor 2025-10-27 12:46:22 +08:00
yusing
baf774f927 fix(middleware): properly release buffer on error and not to reuse content for bytes.Buffer 2025-10-26 23:16:38 +08:00
yusing
a3c82209c6 refactor(api): disable caching completely 2025-10-26 21:33:58 +08:00
yusing
386d946bd2 feat(rules): support variables for error comand 2025-10-26 20:25:46 +08:00
yusing
ee9bf31d30 chore(compose): add comments for lite variant uid/gid configuration in example 2025-10-26 19:46:59 +08:00
yusing
2c87eebee3 chore(compose): remove host network_mode from example 2025-10-26 19:27:56 +08:00
yusing
5be784d567 chore(env): remove frontend port configuration from example files 2025-10-26 19:26:53 +08:00
yusing
a999c51bf8 fix(metrics): json marshaling 2025-10-26 16:57:16 +08:00
yusing
7ca722b256 fix(metrics): correct network data aggregation logic in system_info.go 2025-10-26 16:46:34 +08:00
yusing
51295be463 fix(json): ensure valid json 2025-10-26 16:38:08 +08:00
yusing
51fc5f017a feat(api): add sonic build tag in Makefile to let gin use sonic for json handling 2025-10-26 16:36:41 +08:00
yusing
e4996733fc fix(types): add placeholder field in VirtualMemoryStat for swagger 2025-10-26 16:04:28 +08:00
yusing
f76d86dfa2 feat(api): rules playground API
- updated swagger
2025-10-26 15:56:18 +08:00
yusing
8778f4ea73 fix(json): unmarshal error introduced in previous commit 2025-10-26 01:29:39 +08:00
yusing
6f75bb7593 refactor(api): replace apitypes module and fix swagger generation 2025-10-26 01:05:18 +08:00
yusing
964ba1eac1 chore: update dev environment configuration and base images
- Changed API_SECRET to API_JWT_SECRET in dev.compose.yml
- Updated base image from alpine to debian in dev.Dockerfile
- Upgraded golang version from 1.25.2 to 1.25.3 in Dockerfile
2025-10-25 23:31:53 +08:00
yusing
6e7b571946 feat(rules): add regex for image and font file paths in webui presets 2025-10-25 23:31:22 +08:00
yusing
fc7a81faf5 chore: upgrade dependencies 2025-10-25 23:27:35 +08:00
yusing
488ad160e7 fix(rules): ensure postform and form initialized, fix tests 2025-10-25 23:07:18 +08:00
yusing
1ec2872f3d feat(rules): replace go templates with custom variable expansion
- Replace template syntax ({{ .Request.Method }}) with $-prefixed variables ($req_method)
- Implement custom variable parser with static ($req_method, $status_code) and dynamic ($header(), $arg(), $form()) variables
- Replace templateOrStr interface with templateString struct and ExpandVars methods
- Add parser improvements for reliable quote handling
- Add new error types: ErrUnterminatedParenthesis, ErrUnexpectedVar, ErrExpectOneOrTwoArgs
- Update all tests and help text to use new variable syntax
- Add comprehensive unit and benchmark tests for variable expansion
2025-10-25 22:43:47 +08:00
yusing
9c3346dd9d fix(agent): correct usage of task in StartAgentServer and updated test expectations 2025-10-22 00:02:13 +08:00
yusing
203faa8e7e chore: update goutils 2025-10-22 00:01:27 +08:00
yusing
fbc853fa6a fix(script): incorrectly set DOCKER_SOCKET as rootless 2025-10-20 20:48:38 +08:00
yusing
3fefbdfded chore: update goutils 2025-10-20 20:46:31 +08:00
yusing
48be6def12 feat(autocert): add hostinger autocert provider
- upgraded dependencies and submodules
2025-10-19 10:48:00 +08:00
yusing
94d6b7a168 fix(pool): missing storeFullCap when allocating new buffer 2025-10-18 19:59:14 +08:00
yusing
1ca4b4939e perf(healthcheck): stop docker client from hogging resources in health checks 2025-10-18 19:35:32 +08:00
yusing
f8716d990e perf(pool): split bytes pool into tiered sized and unsized pools
- Remove BytesPoolWithMemory; split into UnsizedBytesPool and 11-tier SizedBytesPool
- Track buffer capacities with xsync Map to prevent capacity leaks
- Improve buffer reuse: split large buffers and put remainders back in pool
- Optimize small buffers to use unsized pool
- Expand test coverage and benchmarks for various allocation sizes
2025-10-18 17:38:01 +08:00
yusing
5a91db8d10 perf: use fasthttp for health checks; upgrade go to 1.25.3 2025-10-17 22:50:13 +08:00
yusing
3e73be60a1 fix(gotify): error if token not present 2025-10-16 10:25:38 +08:00
yusing
af9363209b fix(systeminfo): correct system info JSON format 2025-10-16 10:09:51 +08:00
yusing
ccc35b2a00 refactor: remove functional.Set wrapper 2025-10-16 10:08:25 +08:00
yusing
44536139c1 refactor: refine byte pools usage and fix memory leak in rules 2025-10-15 23:53:26 +08:00
yusing
2b4c39a79e perf(mem): reduced memory usage in metrics and task by string interning and deduplicating fields 2025-10-15 23:51:47 +08:00
yusing
ddf78aacba perf(logging): optimize multi-line message formatting
- Refactors the fmtMessage function to use strings.Builder
  - Simplifies multi-writer creation with a helper function
  - Updates the new console writer initialization pattern
  - Moves InitLogger function to the top
  - Fixed NewLoggerWithFixedLevel
2025-10-15 21:18:25 +08:00
yusing
f5a006ce81 refactor(task): fix onFinish not being called and simplify by replacing semaphore with channel 2025-10-15 15:07:19 +08:00
yusing
290af4e311 perf(mem): replace Scheme and ExcludedReason string with uint8 type to reduce mem usage 2025-10-15 14:35:44 +08:00
yusing
feafdf05f2 fix(validation): correct CustomValidator and strutils.Parser handling, add tests 2025-10-15 14:20:47 +08:00
yusing
b09bfd6c1e fix(serialization): use replace os.LookupEnv with env.LookupEnv 2025-10-15 00:12:17 +08:00
yusing
e13b18621d fix(favicon): add status code in error message 2025-10-15 00:11:38 +08:00
Yuzerion
53f3397b7a feat(rules): add post-request rules system with response manipulation (#160)
* Add comprehensive post-request rules support for response phase
* Enable response body, status, and header manipulation via set commands
* Refactor command handlers to support both request and response phases
* Implement response modifier system for post-request template execution
* Support response-based rule matching with status and header checks
* Add comprehensive benchmarks for matcher performance
* Refactor authentication and proxying commands for unified error handling
* Support negated conditions with !
* Enhance error handling, error formatting and validation
* Routes: add `rule_file` field with rule preset support
* Environment variable substitution: now supports variables without `GODOXY_` prefix

* new conditions:
  * `on resp_header <key> [<value>]`
  * `on status <status>`
* new commands:
  * `require_auth`
  * `set resp_header <key> <template>`
  * `set resp_body <template>`
  * `set status <code>`
  * `log <level> <path> <template>`
  * `notify <level> <provider> <title_template> <body_template>`
2025-10-14 23:53:06 +08:00
195 changed files with 10267 additions and 2059 deletions

View File

@@ -63,9 +63,6 @@ GODOXY_METRICS_DISABLE_DISK=false
GODOXY_METRICS_DISABLE_NETWORK=false
GODOXY_METRICS_DISABLE_SENSORS=false
# Frontend listening port
GODOXY_FRONTEND_PORT=3000
# Frontend aliases (subdomains / FQDNs, e.g. godoxy, godoxy.domain.com)
GODOXY_FRONTEND_ALIASES=godoxy

View File

@@ -10,7 +10,6 @@ jobs:
uses: ./.github/workflows/docker-image.yml
with:
image_name: ${{ github.repository_owner }}/godoxy
old_image_name: ${{ github.repository_owner }}/go-proxy
tag: latest
target: main
build-prod-agent:

View File

@@ -9,9 +9,6 @@ on:
image_name:
required: true
type: string
old_image_name:
required: false
type: string
target:
required: true
type: string
@@ -156,17 +153,6 @@ jobs:
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ env.REGISTRY }}/${{ inputs.image_name }}@sha256:%s ' *)
- name: Old image name
if: inputs.old_image_name != ''
run: |
docker buildx imagetools create -t ${{ env.REGISTRY }}/${{ inputs.old_image_name }}:${{ steps.meta.outputs.version }}\
${{ env.REGISTRY }}/${{ inputs.image_name }}:${{ steps.meta.outputs.version }}
- name: Inspect image
run: |
docker buildx imagetools inspect ${{ env.REGISTRY }}/${{ inputs.image_name }}:${{ steps.meta.outputs.version }}
- name: Inspect image (old)
if: inputs.old_image_name != ''
run: |
docker buildx imagetools inspect ${{ env.REGISTRY }}/${{ inputs.old_image_name }}:${{ steps.meta.outputs.version }}

View File

@@ -1,5 +1,5 @@
# Stage 1: deps
FROM golang:1.25.2-alpine AS deps
FROM golang:1.25.4-alpine AS deps
HEALTHCHECK NONE
# package version does not matter

View File

@@ -3,9 +3,10 @@ export VERSION ?= $(shell git describe --tags --abbrev=0)
export BUILD_DATE ?= $(shell date -u +'%Y%m%d-%H%M')
export GOOS = linux
WEBUI_DIR ?= ../godoxy-frontend
DOCS_DIR ?= ../godoxy-wiki
WEBUI_DIR ?= ../godoxy-webui
DOCS_DIR ?= ${WEBUI_DIR}/wiki
GO_TAGS = sonic
LDFLAGS = -X github.com/yusing/goutils/version.version=${VERSION} -checklinkname=0
ifeq ($(agent), 1)
@@ -28,23 +29,26 @@ endif
ifeq ($(race), 1)
CGO_ENABLED = 1
GODOXY_DEBUG = 1
BUILD_FLAGS += -tags debug -race
GO_TAGS += debug
BUILD_FLAGS += -race
else ifeq ($(debug), 1)
CGO_ENABLED = 1
GODOXY_DEBUG = 1
BUILD_FLAGS += -gcflags=all='-N -l' -tags debug -asan
GO_TAGS += debug
BUILD_FLAGS += -asan # FIXME: -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
BUILD_FLAGS += -tags pprof
GO_TAGS += pprof
VERSION := ${VERSION}-pprof
else
CGO_ENABLED = 0
LDFLAGS += -s -w
BUILD_FLAGS += -pgo=auto -tags production
GO_TAGS += production
BUILD_FLAGS += -pgo=auto
endif
BUILD_FLAGS += -ldflags='$(LDFLAGS)'
BUILD_FLAGS += -tags '$(GO_TAGS)' -ldflags='$(LDFLAGS)'
BIN_PATH := $(shell pwd)/bin/${NAME}
export NAME
@@ -143,15 +147,17 @@ push-github:
git push origin $(shell git rev-parse --abbrev-ref HEAD)
gen-swagger:
swag init --parseDependency --parseInternal -g handler.go -d internal/api -o internal/api/v1/docs
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
rm internal/api/v1/docs/docs.go
gen-swagger-markdown: gen-swagger
# brew tap go-swagger/go-swagger && brew install go-swagger
swagger generate markdown -f internal/api/v1/docs/swagger.yaml --skip-validation --output ${DOCS_DIR}/src/API.md
gen-api-types: gen-swagger
# --disable-throw-on-error
pnpx swagger-typescript-api generate --sort-types --generate-union-enums --axios --add-readonly --route-types \
bunx --bun swagger-typescript-api generate --sort-types --generate-union-enums --axios --add-readonly --route-types \
--responses -o ${WEBUI_DIR}/lib -n api.ts -p internal/api/v1/docs/swagger.json
bunx --bun prettier --config ${WEBUI_DIR}/.prettierrc --write ${WEBUI_DIR}/lib/api.ts

View File

@@ -1,6 +1,6 @@
module github.com/yusing/godoxy/agent
go 1.25.2
go 1.25.4
replace github.com/yusing/godoxy => ..
@@ -13,37 +13,39 @@ replace github.com/yusing/goutils => ../goutils
exclude github.com/containerd/nerdctl/mod/tigron v0.0.0
require (
github.com/bytedance/sonic v1.14.1
github.com/bytedance/sonic v1.14.2
github.com/gin-gonic/gin v1.11.0
github.com/gorilla/websocket v1.5.3
github.com/puzpuzpuz/xsync/v4 v4.2.0
github.com/rs/zerolog v1.34.0
github.com/stretchr/testify v1.11.1
github.com/yusing/godoxy v0.18.6
github.com/valyala/fasthttp v1.68.0
github.com/yusing/godoxy v0.20.2
github.com/yusing/godoxy/socketproxy v0.0.0-00010101000000-000000000000
github.com/yusing/goutils v0.6.1
github.com/yusing/goutils v0.7.0
)
require (
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/PuerkitoBio/goquery v1.10.3 // indirect
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/andybalholm/cascadia v1.3.3 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/bytedance/sonic/loader v0.4.0 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/cli v28.5.1+incompatible // indirect
github.com/docker/docker v28.5.1+incompatible // indirect
github.com/docker/cli v28.5.2+incompatible // indirect
github.com/docker/docker v28.5.2+incompatible // indirect
github.com/docker/go-connections v0.6.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/ebitengine/purego v0.9.0 // indirect
github.com/ebitengine/purego v0.9.1 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/gabriel-vasile/mimetype v1.4.10 // indirect
github.com/gabriel-vasile/mimetype v1.4.11 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
@@ -55,12 +57,13 @@ require (
github.com/goccy/go-yaml v1.18.0 // indirect
github.com/gorilla/mux v1.8.1 // indirect
github.com/gotify/server/v2 v2.7.3 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect
github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12 // indirect
github.com/klauspost/compress v1.18.1 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/lithammer/fuzzysearch v1.1.8 // indirect
github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54 // indirect
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
@@ -78,24 +81,25 @@ require (
github.com/quic-go/quic-go v0.55.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.7.3 // indirect
github.com/shirou/gopsutil/v4 v4.25.9 // indirect
github.com/samber/slog-zerolog/v2 v2.8.0 // indirect
github.com/shirou/gopsutil/v4 v4.25.10 // indirect
github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af // indirect
github.com/tklauser/go-sysconf v0.3.15 // indirect
github.com/tklauser/numcpus v0.10.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/vincent-petithory/dataurl v1.0.0 // indirect
github.com/yusing/ds v0.2.0 // indirect
github.com/yusing/ds v0.3.1 // indirect
github.com/yusing/gointernals v0.1.16 // 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.63.0 // indirect
go.opentelemetry.io/otel v1.38.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 // indirect
go.opentelemetry.io/otel/metric v1.38.0 // indirect
go.opentelemetry.io/otel/trace v1.38.0 // indirect
go.opentelemetry.io/proto/otlp v1.7.1 // indirect
go.opentelemetry.io/proto/otlp v1.8.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
golang.org/x/arch v0.22.0 // indirect
golang.org/x/crypto v0.43.0 // indirect

View File

@@ -4,18 +4,18 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERo
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo=
github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
github.com/buger/goterm v1.0.4 h1:Z9YvGmOih81P0FbVtEYTFF6YsSgxSUKEhf/f9bTMXbY=
github.com/buger/goterm v1.0.4/go.mod h1:HiFWV3xnkolgrBV3mY8m0X0Pumt4zg4QhbdOzQtB8tE=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.14.1 h1:FBMC0zVz5XUmE4z9wF4Jey0An5FueFvOsTKKKtwIl7w=
github.com/bytedance/sonic v1.14.1/go.mod h1:gi6uhQLMbTdeP0muCnrjHLeCUPyb70ujhnNlhOylAFc=
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/bytedance/sonic v1.14.2 h1:k1twIoe97C1DtYUo+fZQy865IuHia4PR5RPiuGPPIIE=
github.com/bytedance/sonic v1.14.2/go.mod h1:T80iDELeHiHKSc0C9tubFygiuXoGzrkjKzX2quAx980=
github.com/bytedance/sonic/loader v0.4.0 h1:olZ7lEqcxtZygCK9EKYKADnpQoYkRQxaeY2NYzevs+o=
github.com/bytedance/sonic/loader v0.4.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
@@ -39,28 +39,28 @@ github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5Qvfr
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c=
github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0=
github.com/docker/cli v28.5.1+incompatible h1:ESutzBALAD6qyCLqbQSEf1a/U8Ybms5agw59yGVc+yY=
github.com/docker/cli v28.5.1+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
github.com/docker/docker v28.5.1+incompatible h1:Bm8DchhSD2J6PsFzxC35TZo4TLGR2PdW/E69rU45NhM=
github.com/docker/docker v28.5.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/cli v28.5.2+incompatible h1:XmG99IHcBmIAoC1PPg9eLBZPlTrNUAijsHLm8PjhBlg=
github.com/docker/cli v28.5.2+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM=
github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/ebitengine/purego v0.9.0 h1:mh0zpKBIXDceC63hpvPuGLiJ8ZAa3DfrFTudmfi8A4k=
github.com/ebitengine/purego v0.9.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A=
github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0=
github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik=
github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
github.com/go-acme/lego/v4 v4.26.0 h1:521aEQxNstXvPQcFDDPrJiFfixcCQuvAvm35R4GbyYA=
github.com/go-acme/lego/v4 v4.26.0/go.mod h1:BQVAWgcyzW4IT9eIKHY/RxYlVhoyKyOMXOkq7jK1eEQ=
github.com/go-acme/lego/v4 v4.28.0 h1:URKsCcybo7SjqqZckeBcDN9Vl29/bKS///75tcNkMHQ=
github.com/go-acme/lego/v4 v4.28.0/go.mod h1:bzjilr03IgbaOwlH396hq5W56Bi0/uoRwW/JM8hP7m4=
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
@@ -100,12 +100,14 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN
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=
github.com/gotify/server/v2 v2.7.3/go.mod h1:VAtE1RIc/2j886PYs9WPQbMjqbFsoyQ0G8IdFtnAxU0=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnVTyacbefKhmbLhIhU=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3/go.mod h1:zQrxl1YP88HQlA6i9c63DSVPFklWpGX4OWAc9bFuaH4=
github.com/jinzhu/copier v0.4.0 h1:w3ciUoD19shMCRargcpm0cm91ytaBhDvuRpz1ODO/U8=
github.com/jinzhu/copier v0.4.0/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg=
github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12 h1:9Nu54bhS/H/Kgo2/7xNSUuC5G28VR8ljfrLKU2G4IjU=
github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12/go.mod h1:TBzl5BIHNXfS9+C35ZyJaklL7mLDbgUkcgXzSLa8Tk0=
github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co=
github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
@@ -116,8 +118,8 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4=
github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4=
github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54 h1:mFWunSatvkQQDhpdyuFAYwyAan3hzCuma+Pz8sqvOfg=
github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
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/magefile/mage v1.15.0 h1:BvGheCMAsG3bWUDbZ8AyXXpCNwU9u5CB6sM+HNb9HYg=
@@ -178,8 +180,8 @@ github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw=
github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
github.com/samber/slog-common v0.19.0 h1:fNcZb8B2uOLooeYwFpAlKjkQTUafdjfqKcwcC89G9YI=
github.com/samber/slog-common v0.19.0/go.mod h1:dTz+YOU76aH007YUU0DffsXNsGFQRQllPQh9XyNoA3M=
github.com/samber/slog-zerolog/v2 v2.7.3 h1:/MkPDl/tJhijN2GvB1MWwBn2FU8RiL3rQ8gpXkQm2EY=
github.com/samber/slog-zerolog/v2 v2.7.3/go.mod h1:oWU7WHof4Xp8VguiNO02r1a4VzkgoOyOZhY5CuRke60=
github.com/samber/slog-zerolog/v2 v2.8.0 h1:K3+PJieRyi2rX/eaJZ95EdmpY/pzdeDd3jRnIQZG6kU=
github.com/samber/slog-zerolog/v2 v2.8.0/go.mod h1:gnQW9VnCfM34v2pRMUIGMsZOVbYLqY/v0Wxu6atSVGc=
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/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
@@ -187,10 +189,12 @@ github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4=
@@ -199,13 +203,19 @@ github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfj
github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ=
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/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.68.0 h1:v12Nx16iepr8r9ySOwqI+5RBJ/DqTxhOy1HrHoDFnok=
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/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yusing/ds v0.2.0 h1:lPhDU5eA2uvquVrBrzLCrQXRJJgSXlUYA53TbuK2sQY=
github.com/yusing/ds v0.2.0/go.mod h1:XhKV4l7cZwBbbl7lRzNC9zX27zvCM0frIwiuD40ULRk=
github.com/yusing/ds v0.3.1 h1:mCqTgTQD8RhiBpcysvii5kZ7ZBmqcknVsFubNALGLbY=
github.com/yusing/ds v0.3.1/go.mod h1:XhKV4l7cZwBbbl7lRzNC9zX27zvCM0frIwiuD40ULRk=
github.com/yusing/gointernals v0.1.16 h1:GrhZZdxzA+jojLEqankctJrOuAYDb7kY1C93S1pVR34=
github.com/yusing/gointernals v0.1.16/go.mod h1:B/0FVXt4WPmgzVy3ynzkqKi+BSGaJVmwCJBRXYapo34=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
@@ -216,8 +226,8 @@ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg=
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 h1:Ahq7pZmv87yiyn3jeFz/LekZmPLLdKejuO3NcK9MssM=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0/go.mod h1:MJTqhM0im3mRLw1i8uGHnCvUEeS7VwRyxlLC78PA18M=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 h1:GqRJVj7UmLjCVyVJ3ZFLdPRmhDUp2zFmQe3RHIOsw24=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0/go.mod h1:ri3aaHSmCTVYu2AWv44YMauwAQc0aqI9gHKIcSbI1pU=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.37.0 h1:bDMKF3RUSxshZ5OjOTi8rsHGaPKsAt76FaqgvIUySLc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.37.0/go.mod h1:dDT67G/IkA46Mr2l9Uj7HsQVwsjASyV9SjGofsiUZDA=
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
@@ -228,8 +238,8 @@ go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6
go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
go.opentelemetry.io/proto/otlp v1.7.1 h1:gTOMpGDb0WTBOP8JaO72iL3auEZhVmAQg4ipjOVAtj4=
go.opentelemetry.io/proto/otlp v1.7.1/go.mod h1:b2rVh6rfI/s2pHWNlB7ILJcRALpcNDzKhACevjI+ZnE=
go.opentelemetry.io/proto/otlp v1.8.0 h1:fRAZQDcAFHySxpJ1TwlA1cJ4tvcrw7nXl9xWWC8N5CE=
go.opentelemetry.io/proto/otlp v1.8.0/go.mod h1:tIeYOeNBU4cvmPqpaji1P+KbB4Oloai8wN4rWzRrFF0=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
@@ -325,10 +335,10 @@ golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/genproto v0.0.0-20250908214217-97024824d090 h1:ywCL7vA2n3vVHyf+bx1ZV/knaTPRI8GIeKY0MEhEeOc=
google.golang.org/genproto/googleapis/api v0.0.0-20250826171959-ef028d996bc1 h1:APHvLLYBhtZvsbnpkfknDZ7NyH4z5+ub/I0u8L3Oz6g=
google.golang.org/genproto/googleapis/api v0.0.0-20250826171959-ef028d996bc1/go.mod h1:xUjFWUnWDpZ/C0Gu0qloASKFb6f8/QXiiXhSPFsD668=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251007200510-49b9836ed3ff h1:A90eA31Wq6HOMIQlLfzFwzqGKBTuaVztYu/g8sn+8Zc=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251007200510-49b9836ed3ff/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/genproto/googleapis/api v0.0.0-20250929231259-57b25ae835d4 h1:8XJ4pajGwOlasW+L13MnEGA8W4115jJySQtVfS2/IBU=
google.golang.org/genproto/googleapis/api v0.0.0-20250929231259-57b25ae835d4/go.mod h1:NnuHhy+bxcg30o7FnVAZbXsPHUDQ9qKWAQKCD7VxFtk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251103181224-f26f9409b101 h1:tRPGkdGHuewF4UisLzzHHr1spKw92qLM98nIzxbC0wY=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251103181224-f26f9409b101/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A=
google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=

View File

@@ -15,6 +15,7 @@ import (
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/valyala/fasthttp"
"github.com/yusing/godoxy/agent/pkg/certs"
"github.com/yusing/goutils/version"
)
@@ -22,13 +23,13 @@ import (
type AgentConfig struct {
Addr string `json:"addr"`
Name string `json:"name"`
Version version.Version `json:"version"`
Version version.Version `json:"version" swaggertype:"string"`
Runtime ContainerRuntime `json:"runtime"`
httpClient *http.Client
httpClientHealthCheck *http.Client
tlsConfig tls.Config
l zerolog.Logger
httpClient *http.Client
fasthttpClientHealthCheck *fasthttp.Client
tlsConfig tls.Config
l zerolog.Logger
} // @name Agent
const (
@@ -107,8 +108,7 @@ func (cfg *AgentConfig) StartWithCerts(ctx context.Context, ca, crt, key []byte)
cfg.httpClient = cfg.NewHTTPClient()
applyNormalTransportConfig(cfg.httpClient)
cfg.httpClientHealthCheck = cfg.NewHTTPClient()
applyHealthCheckTransportConfig(cfg.httpClientHealthCheck)
cfg.fasthttpClientHealthCheck = cfg.NewFastHTTPHealthCheckClient()
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
@@ -188,6 +188,25 @@ func (cfg *AgentConfig) NewHTTPClient() *http.Client {
}
}
func (cfg *AgentConfig) NewFastHTTPHealthCheckClient() *fasthttp.Client {
return &fasthttp.Client{
Dial: func(addr string) (net.Conn, error) {
if addr != AgentHost+":443" {
return nil, &net.AddrError{Err: "invalid address", Addr: addr}
}
return net.Dial("tcp", cfg.Addr)
},
TLSConfig: &cfg.tlsConfig,
ReadTimeout: 5 * time.Second,
WriteTimeout: 3 * time.Second,
DisableHeaderNamesNormalizing: true,
DisablePathNormalizing: true,
NoDefaultUserAgentHeader: true,
ReadBufferSize: 1024,
WriteBufferSize: 1024,
}
}
func (cfg *AgentConfig) Transport() *http.Transport {
return &http.Transport{
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
@@ -220,13 +239,3 @@ func applyNormalTransportConfig(client *http.Client) {
transport.ReadBufferSize = 16384
transport.WriteBufferSize = 16384
}
func applyHealthCheckTransportConfig(client *http.Client) {
transport := client.Transport.(*http.Transport)
transport.DisableKeepAlives = true
transport.DisableCompression = true
transport.MaxIdleConns = 1
transport.MaxIdleConnsPerHost = 1
transport.ReadBufferSize = 1024
transport.WriteBufferSize = 1024
}

View File

@@ -2,10 +2,14 @@ package agent
import (
"context"
"fmt"
"io"
"net/http"
"time"
"github.com/bytedance/sonic"
"github.com/gorilla/websocket"
"github.com/valyala/fasthttp"
httputils "github.com/yusing/goutils/http"
"github.com/yusing/goutils/http/reverseproxy"
)
@@ -30,15 +34,41 @@ func (cfg *AgentConfig) Forward(req *http.Request, endpoint string) (*http.Respo
return resp, nil
}
func (cfg *AgentConfig) DoHealthCheck(ctx context.Context, endpoint string) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, "GET", APIBaseURL+endpoint, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept-Encoding", "identity")
req.Header.Set("Connection", "close")
type HealthCheckResponse struct {
Healthy bool `json:"healthy"`
Detail string `json:"detail"`
Latency time.Duration `json:"latency"`
}
return cfg.httpClientHealthCheck.Do(req)
func (cfg *AgentConfig) DoHealthCheck(timeout time.Duration, query string) (ret HealthCheckResponse, err error) {
req := fasthttp.AcquireRequest()
defer fasthttp.ReleaseRequest(req)
resp := fasthttp.AcquireResponse()
defer fasthttp.ReleaseResponse(resp)
req.SetRequestURI(APIBaseURL + EndpointHealth + "?" + query)
req.Header.SetMethod(fasthttp.MethodGet)
req.Header.Set("Accept-Encoding", "identity")
req.SetConnectionClose()
start := time.Now()
err = cfg.fasthttpClientHealthCheck.DoTimeout(req, resp, timeout)
ret.Latency = time.Since(start)
if err != nil {
return ret, err
}
if status := resp.StatusCode(); status != http.StatusOK {
ret.Detail = fmt.Sprintf("HTTP %d %s", status, resp.Body())
return ret, nil
} else {
err = sonic.Unmarshal(resp.Body(), &ret)
if err != nil {
return ret, err
}
}
return ret, nil
}
func (cfg *AgentConfig) fetchString(ctx context.Context, endpoint string) (string, int, error) {

View File

@@ -39,5 +39,5 @@ func StartAgentServer(parent task.Parent, opt Options) {
TLSConfig: tlsConfig,
}
server.Start(parent, agentServer, server.WithLogger(&log.Logger))
server.Start(parent.Subtask("agent-server", false), agentServer, server.WithLogger(&log.Logger))
}

View File

@@ -22,26 +22,28 @@ services:
- ${SOCKET_PROXY_LISTEN_ADDR:-127.0.0.1:2375}:2375
frontend:
image: ghcr.io/yusing/godoxy-frontend:${TAG:-latest}
# lite variant
# image: ghcr.io/yusing/godoxy-frontend:${TAG:-latest}-lite
container_name: godoxy-frontend
restart: unless-stopped
network_mode: host # do not change this
env_file: .env
# comment out `user` for lite variant
user: ${GODOXY_UID:-1000}:${GODOXY_GID:-1000}
read_only: true
tmpfs:
- /app/.next/cache # next image caching
# for lite variant, do not change uid/gid
# - /var/cache/nginx:uid=101,gid=101
# - /run:uid=101,gid=101
security_opt:
- no-new-privileges:true
cap_drop:
- all
depends_on:
- app
environment:
HOSTNAME: 127.0.0.1
PORT: ${GODOXY_FRONTEND_PORT:-3000}
labels:
proxy.aliases: ${GODOXY_FRONTEND_ALIASES:-godoxy}
proxy.#1.port: ${GODOXY_FRONTEND_PORT:-3000}
# proxy.#1.middlewares.cidr_whitelist: |
# status: 403
# message: IP not allowed
@@ -74,10 +76,9 @@ services:
- ./error_pages:/app/error_pages:ro
- ./data:/app/data
# To use autocert, certs will be stored in "./certs".
# You can also use a docker volume to store it
# This path stores certs obtained from autocert and agent TLS client certs
- ./certs:/app/certs
# remove "./certs:/app/certs" and uncomment below to use existing certificate
# mount existing certificate
# - /path/to/certs/cert.crt:/app/certs/cert.crt
# - /path/to/certs/priv.key:/app/certs/priv.key

View File

@@ -4,6 +4,8 @@
# autocert:
# provider: local
# cert_path: /path/to/cert.crt # default: /app/certs/cert.crt
# key_path: /path/to/priv.key # default: /app/certs/priv.key
# 2. cloudflare
# autocert:

View File

@@ -1,6 +1,6 @@
FROM alpine:3.22
FROM debian:bookworm-slim
RUN apk add --no-cache ca-certificates
RUN apt-get update && apt-get install -y ca-certificates
WORKDIR /app

View File

@@ -16,7 +16,7 @@ services:
API_SKIP_ORIGIN_CHECK: true
API_JWT_TTL: 24h
DEBUG: true
API_SECRET: 1234567891234567
API_JWT_SECRET: 1234567891234567
labels:
proxy.exclude: true
proxy.#1.healthcheck.disable: true
@@ -42,6 +42,8 @@ services:
configs:
- source: parca
target: /parca.yaml
labels:
proxy.#1.port: "7070"
tinyauth:
image: ghcr.io/steveiliop56/tinyauth:v3
container_name: tinyauth

56
go.mod
View File

@@ -1,6 +1,6 @@
module github.com/yusing/godoxy
go 1.25.2
go 1.25.4
replace github.com/yusing/godoxy/agent => ./agent
@@ -15,10 +15,10 @@ replace github.com/yusing/goutils => ./goutils
require (
github.com/PuerkitoBio/goquery v1.10.3 // parsing HTML for extract fav icon
github.com/coreos/go-oidc/v3 v3.16.0 // oidc authentication
github.com/docker/docker v28.5.1+incompatible // docker daemon
github.com/docker/docker v28.5.2+incompatible // docker daemon
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.26.0 // acme client
github.com/go-acme/lego/v4 v4.28.0 // acme client
github.com/go-playground/validator/v10 v10.28.0 // 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
@@ -36,19 +36,19 @@ require (
)
require (
github.com/docker/cli v28.5.1+incompatible
github.com/docker/cli v28.5.2+incompatible
github.com/goccy/go-yaml v1.18.0 // yaml parsing for different config files
github.com/golang-jwt/jwt/v5 v5.3.0
github.com/luthermonson/go-proxmox v0.2.3
github.com/oschwald/maxminddb-golang v1.13.1
github.com/quic-go/quic-go v0.55.0 // indirect; http3 support
github.com/samber/slog-zerolog/v2 v2.7.3 // indirect
github.com/quic-go/quic-go v0.55.0 // http3 support
github.com/samber/slog-zerolog/v2 v2.8.0 // indirect
github.com/spf13/afero v1.15.0
github.com/stretchr/testify v1.11.1
github.com/yusing/ds v0.2.0
github.com/yusing/godoxy/agent v0.0.0-20251011032714-d1e403e16f1c
github.com/yusing/godoxy/internal/dnsproviders v0.0.0-20251011032714-d1e403e16f1c
github.com/yusing/goutils v0.6.1
github.com/yusing/ds v0.3.1
github.com/yusing/godoxy/agent v0.0.0-20251101040722-306cb7a20ef4
github.com/yusing/godoxy/internal/dnsproviders v0.0.0-20251101040722-306cb7a20ef4
github.com/yusing/goutils v0.7.0
)
require (
@@ -61,21 +61,20 @@ require (
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns v1.2.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/privatedns/armprivatedns v1.3.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph v0.9.0 // indirect
github.com/AzureAD/microsoft-authentication-library-for-go v1.5.0 // indirect
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/andybalholm/cascadia v1.3.3 // indirect
github.com/benbjohnson/clock v1.3.5 // indirect
github.com/buger/goterm v1.0.4 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/diskfs/go-diskfs v1.7.0 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/djherbis/times v1.6.0 // indirect
github.com/docker/go-connections v0.6.0
github.com/docker/go-units v0.5.0 // indirect
github.com/ebitengine/purego v0.9.0 // indirect
github.com/ebitengine/purego v0.9.1 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/gabriel-vasile/mimetype v1.4.10 // indirect
github.com/gabriel-vasile/mimetype v1.4.11 // indirect
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
@@ -84,7 +83,7 @@ require (
github.com/gofrs/flock v0.13.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.6 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.7 // indirect
github.com/googleapis/gax-go/v2 v2.15.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-retryablehttp v0.7.8 // indirect
@@ -117,7 +116,7 @@ require (
github.com/sony/gobreaker v1.0.0 // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0
go.opentelemetry.io/otel v1.38.0 // indirect
go.opentelemetry.io/otel/metric v1.38.0 // indirect
go.opentelemetry.io/otel/trace v1.38.0 // indirect
@@ -127,8 +126,8 @@ require (
golang.org/x/sys v0.37.0 // indirect
golang.org/x/text v0.30.0 // indirect
golang.org/x/tools v0.38.0 // indirect
google.golang.org/api v0.252.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251007200510-49b9836ed3ff // indirect
google.golang.org/api v0.255.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251103181224-f26f9409b101 // indirect
google.golang.org/grpc v1.76.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
@@ -137,15 +136,17 @@ require (
)
require (
github.com/bytedance/sonic v1.14.1
github.com/shirou/gopsutil/v4 v4.25.9
github.com/bytedance/gopkg v0.1.3
github.com/bytedance/sonic v1.14.2
github.com/shirou/gopsutil/v4 v4.25.10
github.com/valyala/fasthttp v1.68.0
github.com/yusing/gointernals v0.1.16
)
require (
github.com/akamai/AkamaiOPEN-edgegrid-golang/v11 v11.1.0 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/bytedance/sonic/loader v0.4.0 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
@@ -158,23 +159,24 @@ require (
github.com/go-resty/resty/v2 v2.16.5 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/klauspost/compress v1.18.1 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/linode/linodego v1.60.0 // indirect
github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54 // indirect
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 // indirect
github.com/moby/sys/atomicwriter v0.1.0 // indirect
github.com/moby/term v0.5.2 // indirect
github.com/morikuni/aec v1.0.0 // indirect
github.com/nrdcg/oci-go-sdk/common/v1065 v1065.102.0 // indirect
github.com/nrdcg/oci-go-sdk/dns/v1065 v1065.102.0 // indirect
github.com/nrdcg/oci-go-sdk/common/v1065 v1065.104.0 // indirect
github.com/nrdcg/oci-go-sdk/dns/v1065 v1065.104.0 // indirect
github.com/pierrec/lz4/v4 v4.1.21 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/stretchr/objx v0.5.3 // indirect
github.com/tklauser/go-sysconf v0.3.15 // indirect
github.com/tklauser/numcpus v0.10.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
github.com/ulikunitz/xz v0.5.14 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/vultr/govultr/v3 v3.24.0 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.37.0 // indirect

98
go.sum
View File

@@ -27,8 +27,8 @@ github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEK
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM=
github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE=
github.com/AzureAD/microsoft-authentication-library-for-go v1.5.0 h1:XkkQbfMyuH2jTSjQjSoihryI8GINRcs4xp8lNawg0FI=
github.com/AzureAD/microsoft-authentication-library-for-go v1.5.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk=
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs=
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo=
@@ -37,6 +37,8 @@ github.com/akamai/AkamaiOPEN-edgegrid-golang/v11 v11.1.0 h1:h/33OxYLqBk0BYmEbSUy
github.com/akamai/AkamaiOPEN-edgegrid-golang/v11 v11.1.0/go.mod h1:rvh3imDA6EaQi+oM/GQHkQAOHbXPKJ7EWJvfjuw141Q=
github.com/anchore/go-lzo v0.1.0 h1:NgAacnzqPeGH49Ky19QKLBZEuFRqtTG9cdaucc3Vncs=
github.com/anchore/go-lzo v0.1.0/go.mod h1:3kLx0bve2oN1iDwgM1U5zGku1Tfbdb0No5qp1eL1fIk=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg=
@@ -48,12 +50,10 @@ github.com/buger/goterm v1.0.4 h1:Z9YvGmOih81P0FbVtEYTFF6YsSgxSUKEhf/f9bTMXbY=
github.com/buger/goterm v1.0.4/go.mod h1:HiFWV3xnkolgrBV3mY8m0X0Pumt4zg4QhbdOzQtB8tE=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.14.1 h1:FBMC0zVz5XUmE4z9wF4Jey0An5FueFvOsTKKKtwIl7w=
github.com/bytedance/sonic v1.14.1/go.mod h1:gi6uhQLMbTdeP0muCnrjHLeCUPyb70ujhnNlhOylAFc=
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/bytedance/sonic v1.14.2 h1:k1twIoe97C1DtYUo+fZQy865IuHia4PR5RPiuGPPIIE=
github.com/bytedance/sonic v1.14.2/go.mod h1:T80iDELeHiHKSc0C9tubFygiuXoGzrkjKzX2quAx980=
github.com/bytedance/sonic/loader v0.4.0 h1:olZ7lEqcxtZygCK9EKYKADnpQoYkRQxaeY2NYzevs+o=
github.com/bytedance/sonic/loader v0.4.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
@@ -75,16 +75,16 @@ github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5Qvfr
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c=
github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0=
github.com/docker/cli v28.5.1+incompatible h1:ESutzBALAD6qyCLqbQSEf1a/U8Ybms5agw59yGVc+yY=
github.com/docker/cli v28.5.1+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
github.com/docker/docker v28.5.1+incompatible h1:Bm8DchhSD2J6PsFzxC35TZo4TLGR2PdW/E69rU45NhM=
github.com/docker/docker v28.5.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/cli v28.5.2+incompatible h1:XmG99IHcBmIAoC1PPg9eLBZPlTrNUAijsHLm8PjhBlg=
github.com/docker/cli v28.5.2+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM=
github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/ebitengine/purego v0.9.0 h1:mh0zpKBIXDceC63hpvPuGLiJ8ZAa3DfrFTudmfi8A4k=
github.com/ebitengine/purego v0.9.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A=
github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/elliotwutingfeng/asciiset v0.0.0-20230602022725-51bbb787efab h1:h1UgjJdAAhj+uPL68n7XASS6bU+07ZX1WJvVS2eyoeY=
github.com/elliotwutingfeng/asciiset v0.0.0-20230602022725-51bbb787efab/go.mod h1:GLo/8fDswSAniFG+BFIaiSPcK610jyzgEhWYPQwuQdw=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
@@ -93,14 +93,14 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0=
github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik=
github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
github.com/go-acme/lego/v4 v4.26.0 h1:521aEQxNstXvPQcFDDPrJiFfixcCQuvAvm35R4GbyYA=
github.com/go-acme/lego/v4 v4.26.0/go.mod h1:BQVAWgcyzW4IT9eIKHY/RxYlVhoyKyOMXOkq7jK1eEQ=
github.com/go-acme/lego/v4 v4.28.0 h1:URKsCcybo7SjqqZckeBcDN9Vl29/bKS///75tcNkMHQ=
github.com/go-acme/lego/v4 v4.28.0/go.mod h1:bzjilr03IgbaOwlH396hq5W56Bi0/uoRwW/JM8hP7m4=
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
@@ -149,16 +149,16 @@ 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.6 h1:GW/XbdyBFQ8Qe+YAmFU9uHLo7OnF5tL52HFAgMmyrf4=
github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
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/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=
github.com/gotify/server/v2 v2.7.3/go.mod h1:VAtE1RIc/2j886PYs9WPQbMjqbFsoyQ0G8IdFtnAxU0=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnVTyacbefKhmbLhIhU=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3/go.mod h1:zQrxl1YP88HQlA6i9c63DSVPFklWpGX4OWAc9bFuaH4=
github.com/h2non/gock v1.2.0 h1:K6ol8rfrRkUOefooBC8elXoaNGYkpp7y2qcxGG6BzUE=
github.com/h2non/gock v1.2.0/go.mod h1:tNhoxHYW2W42cYkYb1WqzdbYIieALC99kpYr7rH/BQk=
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw=
@@ -177,8 +177,8 @@ github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12 h1:9Nu54bhS/H/
github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12/go.mod h1:TBzl5BIHNXfS9+C35ZyJaklL7mLDbgUkcgXzSLa8Tk0=
github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU=
github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co=
github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
@@ -193,8 +193,8 @@ github.com/linode/linodego v1.60.0 h1:SgsebJFRCi+lSmYy+C40wmKZeJllGGm+W12Qw4+yVd
github.com/linode/linodego v1.60.0/go.mod h1:1+Bt0oTz5rBnDOJbGhccxn7LYVytXTIIfAy7QYmijDs=
github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4=
github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4=
github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54 h1:mFWunSatvkQQDhpdyuFAYwyAan3hzCuma+Pz8sqvOfg=
github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
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/magefile/mage v1.15.0 h1:BvGheCMAsG3bWUDbZ8AyXXpCNwU9u5CB6sM+HNb9HYg=
@@ -229,10 +229,10 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/nrdcg/goacmedns v0.2.0 h1:ADMbThobzEMnr6kg2ohs4KGa3LFqmgiBA22/6jUWJR0=
github.com/nrdcg/goacmedns v0.2.0/go.mod h1:T5o6+xvSLrQpugmwHvrSNkzWht0UGAwj2ACBMhh73Cg=
github.com/nrdcg/oci-go-sdk/common/v1065 v1065.102.0 h1:W28ZizQSS2aRWkFA3iAP9eiZS4OLFaiv35nXtq2lW/s=
github.com/nrdcg/oci-go-sdk/common/v1065 v1065.102.0/go.mod h1:cVbzGjRhtXgrduaQbR1GR1x+VDU60NcXPMZ3+eQuiiY=
github.com/nrdcg/oci-go-sdk/dns/v1065 v1065.102.0 h1:gAOs1dkE7LFoWflzqrDqAhOprc0kF1a0fyV8C4HUPj4=
github.com/nrdcg/oci-go-sdk/dns/v1065 v1065.102.0/go.mod h1:EUBSYwop1K40VpcKy1haIK6kFK/gPT1atEk89OkY0Kg=
github.com/nrdcg/oci-go-sdk/common/v1065 v1065.104.0 h1:Q+nfcttPQcZHNlSXiHptnFLf1UeDGk2NEHDYI/Cr8Ts=
github.com/nrdcg/oci-go-sdk/common/v1065 v1065.104.0/go.mod h1:SfDIKzNQ5AGNMMOA3LGqSPnn63F6Gc4E4bsKArqymvg=
github.com/nrdcg/oci-go-sdk/dns/v1065 v1065.104.0 h1:r1PHNdkYK2+cQlDAFvirra1u7VJ04Kjol4aTbiaWG0c=
github.com/nrdcg/oci-go-sdk/dns/v1065 v1065.104.0/go.mod h1:0dBUJS+h0TkCdytcRzIBT1B5q84k9O92Hcs5YwIVtAY=
github.com/nrdcg/porkbun v0.4.0 h1:rWweKlwo1PToQ3H+tEO9gPRW0wzzgmI/Ob3n2Guticw=
github.com/nrdcg/porkbun v0.4.0/go.mod h1:/QMskrHEIM0IhC/wY7iTCUgINsxdT2WcOphktJ9+Q54=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
@@ -275,8 +275,8 @@ github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw=
github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
github.com/samber/slog-common v0.19.0 h1:fNcZb8B2uOLooeYwFpAlKjkQTUafdjfqKcwcC89G9YI=
github.com/samber/slog-common v0.19.0/go.mod h1:dTz+YOU76aH007YUU0DffsXNsGFQRQllPQh9XyNoA3M=
github.com/samber/slog-zerolog/v2 v2.7.3 h1:/MkPDl/tJhijN2GvB1MWwBn2FU8RiL3rQ8gpXkQm2EY=
github.com/samber/slog-zerolog/v2 v2.7.3/go.mod h1:oWU7WHof4Xp8VguiNO02r1a4VzkgoOyOZhY5CuRke60=
github.com/samber/slog-zerolog/v2 v2.8.0 h1:K3+PJieRyi2rX/eaJZ95EdmpY/pzdeDd3jRnIQZG6kU=
github.com/samber/slog-zerolog/v2 v2.8.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/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af h1:Sp5TG9f7K39yfB+If0vjp97vuT74F72r8hfRpP8jLU0=
@@ -288,6 +288,7 @@ github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
@@ -295,7 +296,8 @@ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81P
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4=
@@ -304,19 +306,25 @@ github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfj
github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ=
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/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/ulikunitz/xz v0.5.14 h1:uv/0Bq533iFdnMHZdRBTOlaNMdb1+ZxXIlHDZHIHcvg=
github.com/ulikunitz/xz v0.5.14/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.68.0 h1:v12Nx16iepr8r9ySOwqI+5RBJ/DqTxhOy1HrHoDFnok=
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.24.0 h1:fTTTj0VBve+Miy+wGhlb90M2NMDfpGFi6Frlj3HVy6M=
github.com/vultr/govultr/v3 v3.24.0/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=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yusing/ds v0.2.0 h1:lPhDU5eA2uvquVrBrzLCrQXRJJgSXlUYA53TbuK2sQY=
github.com/yusing/ds v0.2.0/go.mod h1:XhKV4l7cZwBbbl7lRzNC9zX27zvCM0frIwiuD40ULRk=
github.com/yusing/ds v0.3.1 h1:mCqTgTQD8RhiBpcysvii5kZ7ZBmqcknVsFubNALGLbY=
github.com/yusing/ds v0.3.1/go.mod h1:XhKV4l7cZwBbbl7lRzNC9zX27zvCM0frIwiuD40ULRk=
github.com/yusing/gointernals v0.1.16 h1:GrhZZdxzA+jojLEqankctJrOuAYDb7kY1C93S1pVR34=
github.com/yusing/gointernals v0.1.16/go.mod h1:B/0FVXt4WPmgzVy3ynzkqKi+BSGaJVmwCJBRXYapo34=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
@@ -329,8 +337,8 @@ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg=
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 h1:Ahq7pZmv87yiyn3jeFz/LekZmPLLdKejuO3NcK9MssM=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0/go.mod h1:MJTqhM0im3mRLw1i8uGHnCvUEeS7VwRyxlLC78PA18M=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 h1:GqRJVj7UmLjCVyVJ3ZFLdPRmhDUp2zFmQe3RHIOsw24=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0/go.mod h1:ri3aaHSmCTVYu2AWv44YMauwAQc0aqI9gHKIcSbI1pU=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.37.0 h1:bDMKF3RUSxshZ5OjOTi8rsHGaPKsAt76FaqgvIUySLc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.37.0/go.mod h1:dDT67G/IkA46Mr2l9Uj7HsQVwsjASyV9SjGofsiUZDA=
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
@@ -341,8 +349,8 @@ go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6
go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
go.opentelemetry.io/proto/otlp v1.7.1 h1:gTOMpGDb0WTBOP8JaO72iL3auEZhVmAQg4ipjOVAtj4=
go.opentelemetry.io/proto/otlp v1.7.1/go.mod h1:b2rVh6rfI/s2pHWNlB7ILJcRALpcNDzKhACevjI+ZnE=
go.opentelemetry.io/proto/otlp v1.8.0 h1:fRAZQDcAFHySxpJ1TwlA1cJ4tvcrw7nXl9xWWC8N5CE=
go.opentelemetry.io/proto/otlp v1.8.0/go.mod h1:tIeYOeNBU4cvmPqpaji1P+KbB4Oloai8wN4rWzRrFF0=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
@@ -443,14 +451,14 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T
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.252.0 h1:xfKJeAJaMwb8OC9fesr369rjciQ704AjU/psjkKURSI=
google.golang.org/api v0.252.0/go.mod h1:dnHOv81x5RAmumZ7BWLShB/u7JZNeyalImxHmtTHxqw=
google.golang.org/api v0.255.0 h1:OaF+IbRwOottVCYV2wZan7KUq7UeNUQn1BcPc4K7lE4=
google.golang.org/api v0.255.0/go.mod h1:d1/EtvCLdtiWEV4rAEHDHGh2bCnqsWhw+M8y2ECN4a8=
google.golang.org/genproto v0.0.0-20250908214217-97024824d090 h1:ywCL7vA2n3vVHyf+bx1ZV/knaTPRI8GIeKY0MEhEeOc=
google.golang.org/genproto v0.0.0-20250908214217-97024824d090/go.mod h1:zwJI9HzbJJlw2KXy0wX+lmT2JuZoaKK9JC4ppqmxxjk=
google.golang.org/genproto/googleapis/api v0.0.0-20250826171959-ef028d996bc1 h1:APHvLLYBhtZvsbnpkfknDZ7NyH4z5+ub/I0u8L3Oz6g=
google.golang.org/genproto/googleapis/api v0.0.0-20250826171959-ef028d996bc1/go.mod h1:xUjFWUnWDpZ/C0Gu0qloASKFb6f8/QXiiXhSPFsD668=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251007200510-49b9836ed3ff h1:A90eA31Wq6HOMIQlLfzFwzqGKBTuaVztYu/g8sn+8Zc=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251007200510-49b9836ed3ff/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251103181224-f26f9409b101 h1:tRPGkdGHuewF4UisLzzHHr1spKw92qLM98nIzxbC0wY=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251103181224-f26f9409b101/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A=
google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=

Submodule goutils updated: 26146bd560...d2c3b1966a

View File

@@ -55,7 +55,7 @@ type config struct {
logAllowed bool
// will be nil if Log is nil
logger *accesslog.AccessLogger
logger accesslog.AccessLogger
// will never tick if Notify.To is empty
notifyTicker *time.Ticker

View File

@@ -2,13 +2,12 @@ package api
import (
"net/http"
"strconv"
"time"
"reflect"
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/codec/json"
"github.com/gorilla/websocket"
"github.com/rs/zerolog/log"
apitypes "github.com/yusing/godoxy/internal/api/types"
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"
@@ -20,6 +19,7 @@ import (
routeApi "github.com/yusing/godoxy/internal/api/v1/route"
"github.com/yusing/godoxy/internal/auth"
"github.com/yusing/godoxy/internal/common"
apitypes "github.com/yusing/goutils/apitypes"
gperr "github.com/yusing/goutils/errs"
)
@@ -45,6 +45,9 @@ func NewHandler() *gin.Engine {
r := gin.New()
r.Use(ErrorHandler())
r.Use(ErrorLoggingMiddleware())
r.Use(NoCache())
log.Debug().Msg("gin codec json.API: " + reflect.TypeOf(json.API).Name())
r.GET("/api/v1/version", apiV1.Version)
@@ -69,7 +72,7 @@ func NewHandler() *gin.Engine {
}
{
// enable cache for favicon
v1.GET("/favicon", apiV1.FavIcon).Use(Cache(time.Hour * 24))
v1.GET("/favicon", apiV1.FavIcon)
v1.GET("/health", apiV1.Health)
v1.GET("/icons", apiV1.Icons)
v1.POST("/reload", apiV1.Reload)
@@ -81,6 +84,7 @@ func NewHandler() *gin.Engine {
route.GET("/:which", routeApi.Route)
route.GET("/providers", routeApi.Providers)
route.GET("/by_provider", routeApi.ByProvider)
route.POST("/playground", routeApi.Playground)
}
file := v1.Group("/file")
@@ -139,15 +143,13 @@ func NewHandler() *gin.Engine {
}
}
// disable cache by default
r.Use(NoCache())
return r
}
func NoCache() gin.HandlerFunc {
return func(c *gin.Context) {
// skip cache if Cache-Control header is set or if caching is explicitly enabled
if !c.GetBool("cache_enabled") && c.Writer.Header().Get("Cache-Control") == "" {
// skip cache if Cache-Control header is set
if c.Writer.Header().Get("Cache-Control") == "" {
c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
c.Header("Pragma", "no-cache")
c.Header("Expires", "0")
@@ -156,20 +158,6 @@ func NoCache() gin.HandlerFunc {
}
}
func Cache(duration time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
// Signal to NoCache middleware that caching is intended
c.Set("cache_enabled", true)
// skip cache if Cache-Control header is set
if c.Writer.Header().Get("Cache-Control") == "" {
c.Header("Cache-Control", "public, max-age="+strconv.FormatFloat(duration.Seconds(), 'f', 0, 64)+", immutable")
c.Header("Pragma", "public")
c.Header("Expires", time.Now().Add(duration).Format(time.RFC1123))
}
c.Next()
}
}
func AuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
err := auth.GetDefaultAuth().CheckToken(c.Request)

View File

@@ -1,55 +0,0 @@
package apitypes
import (
"errors"
gperr "github.com/yusing/goutils/errs"
)
type ErrorResponse struct {
Message string `json:"message"`
Error string `json:"error,omitempty" extensions:"x-nullable"`
} // @name ErrorResponse
type serverError struct {
Message string
Err error
}
// Error returns a generic error response
func Error(message string, err ...error) ErrorResponse {
if len(err) > 0 {
var gpErr gperr.Error
if errors.As(err[0], &gpErr) {
return ErrorResponse{
Message: message,
Error: string(gpErr.Plain()),
}
}
return ErrorResponse{
Message: message,
Error: err[0].Error(),
}
}
return ErrorResponse{
Message: message,
}
}
func InternalServerError(err error, message string) error {
return serverError{
Message: message,
Err: err,
}
}
func (e serverError) Error() string {
if e.Err != nil {
return e.Message + ": " + e.Err.Error()
}
return e.Message
}
func (e serverError) Unwrap() error {
return e.Err
}

View File

@@ -1,29 +0,0 @@
package apitypes
type QueryOptions struct {
Limit int `binding:"required,min=1,max=20" form:"limit"`
Offset int `binding:"omitempty,min=0" form:"offset"`
OrderBy QueryOrder `binding:"omitempty,oneof=created_at updated_at" form:"order_by"`
Order QueryOrderDirection `binding:"omitempty,oneof=asc desc" form:"order"`
}
type QueryOrder string
const (
QueryOrderCreatedAt QueryOrder = "created_at"
QueryOrderUpdatedAt QueryOrder = "updated_at"
)
type QueryOrderDirection string
const (
QueryOrderDirectionAsc QueryOrderDirection = "asc"
QueryOrderDirectionDesc QueryOrderDirection = "desc"
)
type QueryResponse struct {
Total int64 `json:"total"`
Limit int `json:"limit"`
Offset int `json:"offset"`
HasMore bool `json:"has_more"`
}

View File

@@ -1,18 +0,0 @@
package apitypes
type SuccessResponse struct {
Message string `json:"message"`
Details map[string]any `json:"details,omitempty" extensions:"x-nullable"`
} // @name SuccessResponse
func Success(message string, extra ...map[string]any) SuccessResponse {
if len(extra) > 0 {
return SuccessResponse{
Message: message,
Details: extra[0],
}
}
return SuccessResponse{
Message: message,
}
}

View File

@@ -8,6 +8,8 @@ import (
"github.com/yusing/godoxy/agent/pkg/agent"
"github.com/yusing/goutils/http/httpheaders"
"github.com/yusing/goutils/http/websocket"
_ "github.com/yusing/goutils/apitypes"
)
// @x-id "list"
@@ -19,7 +21,6 @@ import (
// @Produce json
// @Success 200 {array} Agent
// @Failure 403 {object} apitypes.ErrorResponse
// @Failure 500 {object} apitypes.ErrorResponse
// @Router /agent/list [get]
func List(c *gin.Context) {
if httpheaders.IsWebsocket(c.Request.Header) {

View File

@@ -4,8 +4,8 @@ import (
"net/http"
"github.com/gin-gonic/gin"
apitypes "github.com/yusing/godoxy/internal/api/types"
"github.com/yusing/godoxy/internal/autocert"
apitypes "github.com/yusing/goutils/apitypes"
)
type CertInfo struct {

View File

@@ -6,9 +6,9 @@ import (
"github.com/gin-gonic/gin"
"github.com/rs/zerolog/log"
apitypes "github.com/yusing/godoxy/internal/api/types"
"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"
)

View File

@@ -4,8 +4,8 @@ import (
"net/http"
"github.com/gin-gonic/gin"
apitypes "github.com/yusing/godoxy/internal/api/types"
"github.com/yusing/godoxy/internal/docker"
apitypes "github.com/yusing/goutils/apitypes"
)
// @x-id "container"

View File

@@ -7,6 +7,8 @@ import (
"github.com/docker/docker/api/types/container"
"github.com/gin-gonic/gin"
gperr "github.com/yusing/goutils/errs"
_ "github.com/yusing/goutils/apitypes"
)
type ContainerState = container.ContainerState // @name ContainerState

View File

@@ -8,6 +8,8 @@ import (
"github.com/gin-gonic/gin"
gperr "github.com/yusing/goutils/errs"
strutils "github.com/yusing/goutils/strings"
_ "github.com/yusing/goutils/apitypes"
)
type containerStats struct {

View File

@@ -10,8 +10,8 @@ import (
"github.com/docker/docker/pkg/stdcopy"
"github.com/gin-gonic/gin"
"github.com/rs/zerolog/log"
apitypes "github.com/yusing/godoxy/internal/api/types"
"github.com/yusing/godoxy/internal/docker"
apitypes "github.com/yusing/goutils/apitypes"
"github.com/yusing/goutils/http/websocket"
"github.com/yusing/goutils/task"
)

View File

@@ -4,8 +4,8 @@ import (
"net/http"
"github.com/gin-gonic/gin"
apitypes "github.com/yusing/godoxy/internal/api/types"
"github.com/yusing/godoxy/internal/docker"
apitypes "github.com/yusing/goutils/apitypes"
)
// @x-id "restart"

View File

@@ -5,8 +5,8 @@ import (
"github.com/docker/docker/api/types/container"
"github.com/gin-gonic/gin"
apitypes "github.com/yusing/godoxy/internal/api/types"
"github.com/yusing/godoxy/internal/docker"
apitypes "github.com/yusing/goutils/apitypes"
)
type StartRequest struct {

View File

@@ -5,8 +5,8 @@ import (
"github.com/docker/docker/api/types/container"
"github.com/gin-gonic/gin"
apitypes "github.com/yusing/godoxy/internal/api/types"
"github.com/yusing/godoxy/internal/docker"
apitypes "github.com/yusing/goutils/apitypes"
)
type StopRequest struct {

View File

@@ -6,8 +6,8 @@ import (
"time"
"github.com/gin-gonic/gin"
apitypes "github.com/yusing/godoxy/internal/api/types"
"github.com/yusing/godoxy/internal/docker"
apitypes "github.com/yusing/goutils/apitypes"
gperr "github.com/yusing/goutils/errs"
"github.com/yusing/goutils/http/httpheaders"
"github.com/yusing/goutils/http/websocket"

View File

@@ -105,12 +105,6 @@
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
}
},
"x-id": "list",
@@ -2135,6 +2129,54 @@
"operationId": "routes"
}
},
"/route/playground": {
"post": {
"description": "Test rules against mock request/response",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"route"
],
"summary": "Rule Playground",
"parameters": [
{
"description": "Playground request",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/PlaygroundRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/PlaygroundResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"403": {
"description": "Forbidden",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
}
},
"x-id": "playground",
"operationId": "playground"
}
},
"/route/providers": {
"get": {
"description": "List route providers",
@@ -2726,6 +2768,83 @@
"x-nullable": false,
"x-omitempty": false
},
"FinalRequest": {
"type": "object",
"properties": {
"body": {
"type": "string",
"x-nullable": false,
"x-omitempty": false
},
"headers": {
"type": "object",
"additionalProperties": {
"type": "array",
"items": {
"type": "string"
}
},
"x-nullable": false,
"x-omitempty": false
},
"host": {
"type": "string",
"x-nullable": false,
"x-omitempty": false
},
"method": {
"type": "string",
"x-nullable": false,
"x-omitempty": false
},
"path": {
"type": "string",
"x-nullable": false,
"x-omitempty": false
},
"query": {
"type": "object",
"additionalProperties": {
"type": "array",
"items": {
"type": "string"
}
},
"x-nullable": false,
"x-omitempty": false
}
},
"x-nullable": false,
"x-omitempty": false
},
"FinalResponse": {
"type": "object",
"properties": {
"body": {
"type": "string",
"x-nullable": false,
"x-omitempty": false
},
"headers": {
"type": "object",
"additionalProperties": {
"type": "array",
"items": {
"type": "string"
}
},
"x-nullable": false,
"x-omitempty": false
},
"statusCode": {
"type": "integer",
"x-nullable": false,
"x-omitempty": false
}
},
"x-nullable": false,
"x-omitempty": false
},
"HTTPHeader": {
"type": "object",
"properties": {
@@ -2799,6 +2918,75 @@
"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": {
"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
},
"HealthJSON": {
"type": "object",
"properties": {
@@ -2882,7 +3070,7 @@
"HealthMap": {
"type": "object",
"additionalProperties": {
"$ref": "#/definitions/routes.HealthInfo"
"$ref": "#/definitions/HealthInfo"
},
"x-nullable": false,
"x-omitempty": false
@@ -3494,6 +3682,113 @@
"x-nullable": false,
"x-omitempty": false
},
"MockCookie": {
"type": "object",
"properties": {
"name": {
"type": "string",
"x-nullable": false,
"x-omitempty": false
},
"value": {
"type": "string",
"x-nullable": false,
"x-omitempty": false
}
},
"x-nullable": false,
"x-omitempty": false
},
"MockRequest": {
"type": "object",
"properties": {
"body": {
"type": "string",
"x-nullable": false,
"x-omitempty": false
},
"cookies": {
"type": "array",
"items": {
"$ref": "#/definitions/MockCookie"
},
"x-nullable": false,
"x-omitempty": false
},
"headers": {
"type": "object",
"additionalProperties": {
"type": "array",
"items": {
"type": "string"
}
},
"x-nullable": false,
"x-omitempty": false
},
"host": {
"type": "string",
"x-nullable": false,
"x-omitempty": false
},
"method": {
"type": "string",
"x-nullable": false,
"x-omitempty": false
},
"path": {
"type": "string",
"x-nullable": false,
"x-omitempty": false
},
"query": {
"type": "object",
"additionalProperties": {
"type": "array",
"items": {
"type": "string"
}
},
"x-nullable": false,
"x-omitempty": false
},
"remoteIP": {
"type": "string",
"x-nullable": false,
"x-omitempty": false
}
},
"x-nullable": false,
"x-omitempty": false
},
"MockResponse": {
"type": "object",
"properties": {
"body": {
"type": "string",
"x-nullable": false,
"x-omitempty": false
},
"headers": {
"type": "object",
"additionalProperties": {
"type": "array",
"items": {
"type": "string"
}
},
"x-nullable": false,
"x-omitempty": false
},
"statusCode": {
"type": "integer",
"x-nullable": false,
"x-omitempty": false
}
},
"x-nullable": false,
"x-omitempty": false
},
"NewAgentRequest": {
"type": "object",
"required": [
@@ -3589,6 +3884,120 @@
"x-nullable": false,
"x-omitempty": false
},
"ParsedRule": {
"type": "object",
"properties": {
"do": {
"type": "string",
"x-nullable": false,
"x-omitempty": false
},
"isResponseRule": {
"type": "boolean",
"x-nullable": false,
"x-omitempty": false
},
"name": {
"type": "string",
"x-nullable": false,
"x-omitempty": false
},
"on": {
"type": "string",
"x-nullable": false,
"x-omitempty": false
},
"validationError": {
"x-nullable": false,
"x-omitempty": false
}
},
"x-nullable": false,
"x-omitempty": false
},
"PlaygroundRequest": {
"type": "object",
"required": [
"rules"
],
"properties": {
"mockRequest": {
"$ref": "#/definitions/MockRequest"
},
"mockResponse": {
"$ref": "#/definitions/MockResponse"
},
"rules": {
"type": "array",
"items": {
"$ref": "#/definitions/routeApi.RawRule"
},
"x-nullable": false,
"x-omitempty": false
}
},
"x-nullable": false,
"x-omitempty": false
},
"PlaygroundResponse": {
"type": "object",
"properties": {
"executionError": {
"x-nullable": false,
"x-omitempty": false
},
"finalRequest": {
"$ref": "#/definitions/FinalRequest",
"x-nullable": false,
"x-omitempty": false
},
"finalResponse": {
"$ref": "#/definitions/FinalResponse",
"x-nullable": false,
"x-omitempty": false
},
"matchedRules": {
"type": "array",
"items": {
"type": "string"
},
"x-nullable": false,
"x-omitempty": false
},
"parsedRules": {
"type": "array",
"items": {
"$ref": "#/definitions/ParsedRule"
},
"x-nullable": false,
"x-omitempty": false
},
"upstreamCalled": {
"type": "boolean",
"x-nullable": false,
"x-omitempty": false
}
},
"x-nullable": false,
"x-omitempty": false
},
"Port": {
"type": "object",
"properties": {
"listening": {
"type": "integer",
"x-nullable": false,
"x-omitempty": false
},
"proxy": {
"type": "integer",
"x-nullable": false,
"x-omitempty": false
}
},
"x-nullable": false,
"x-omitempty": false
},
"ProviderStats": {
"type": "object",
"properties": {
@@ -3844,7 +4253,7 @@
"x-nullable": true
},
"port": {
"$ref": "#/definitions/github_com_yusing_go-proxy_internal_route_types.Port",
"$ref": "#/definitions/Port",
"x-nullable": false,
"x-omitempty": false
},
@@ -3868,9 +4277,12 @@
"x-nullable": false,
"x-omitempty": false
},
"rule_file": {
"type": "string",
"x-nullable": true
},
"rules": {
"type": "array",
"uniqueItems": true,
"items": {
"$ref": "#/definitions/rules.Rule"
},
@@ -3878,7 +4290,47 @@
"x-omitempty": false
},
"scheme": {
"$ref": "#/definitions/route.Scheme",
"type": "string",
"enum": [
"http",
"https",
"tcp",
"udp",
"fileserver"
],
"x-nullable": false,
"x-omitempty": false
},
"ssl_certificate": {
"description": "Path to client certificate",
"type": "string",
"x-nullable": false,
"x-omitempty": false
},
"ssl_certificate_key": {
"description": "Path to client certificate key",
"type": "string",
"x-nullable": false,
"x-omitempty": false
},
"ssl_protocols": {
"description": "Allowed TLS protocols",
"type": "array",
"items": {
"type": "string"
},
"x-nullable": false,
"x-omitempty": false
},
"ssl_server_name": {
"description": "SSL/TLS proxy options (nginx-like)",
"type": "string",
"x-nullable": false,
"x-omitempty": false
},
"ssl_trusted_certificate": {
"description": "Path to trusted CA certificates",
"type": "string",
"x-nullable": false,
"x-omitempty": false
}
@@ -3975,7 +4427,7 @@
"statuses": {
"type": "object",
"additionalProperties": {
"$ref": "#/definitions/routes.HealthInfo"
"$ref": "#/definitions/HealthInfoWithoutDetail"
},
"x-nullable": false,
"x-omitempty": false
@@ -4499,7 +4951,6 @@
"type": "object",
"properties": {
"iops": {
"description": "godoxy",
"type": "integer",
"x-nullable": false,
"x-omitempty": false
@@ -4522,7 +4973,6 @@
"x-omitempty": false
},
"read_speed": {
"description": "godoxy",
"type": "number",
"x-nullable": false,
"x-omitempty": false
@@ -4538,7 +4988,6 @@
"x-omitempty": false
},
"write_speed": {
"description": "godoxy",
"type": "number",
"x-nullable": false,
"x-omitempty": false
@@ -4566,7 +5015,7 @@
"x-omitempty": false
},
"total": {
"type": "integer",
"type": "number",
"x-nullable": false,
"x-omitempty": false
},
@@ -4628,31 +5077,9 @@
"x-nullable": false,
"x-omitempty": false
},
"github_com_yusing_go-proxy_internal_route_types.Port": {
"type": "object",
"properties": {
"listening": {
"type": "integer",
"x-nullable": false,
"x-omitempty": false
},
"proxy": {
"type": "integer",
"x-nullable": false,
"x-omitempty": false
}
},
"x-nullable": false,
"x-omitempty": false
},
"homepage.FetchResult": {
"type": "object",
"properties": {
"errMsg": {
"type": "string",
"x-nullable": false,
"x-omitempty": false
},
"icon": {
"type": "array",
"items": {
@@ -4739,15 +5166,9 @@
"x-nullable": false,
"x-omitempty": false
},
"free": {
"description": "This is the kernel's notion of free memory; RAM chips whose bits nobody\ncares about the value of right now. For a human consumable number,\nAvailable is what you really want.",
"type": "integer",
"x-nullable": false,
"x-omitempty": false
},
"total": {
"description": "Total amount of RAM on this system",
"type": "integer",
"type": "number",
"x-nullable": false,
"x-omitempty": false
},
@@ -4907,7 +5328,7 @@
"x-nullable": true
},
"port": {
"$ref": "#/definitions/github_com_yusing_go-proxy_internal_route_types.Port",
"$ref": "#/definitions/Port",
"x-nullable": false,
"x-omitempty": false
},
@@ -4931,9 +5352,12 @@
"x-nullable": false,
"x-omitempty": false
},
"rule_file": {
"type": "string",
"x-nullable": true
},
"rules": {
"type": "array",
"uniqueItems": true,
"items": {
"$ref": "#/definitions/rules.Rule"
},
@@ -4941,7 +5365,47 @@
"x-omitempty": false
},
"scheme": {
"$ref": "#/definitions/route.Scheme",
"type": "string",
"enum": [
"http",
"https",
"tcp",
"udp",
"fileserver"
],
"x-nullable": false,
"x-omitempty": false
},
"ssl_certificate": {
"description": "Path to client certificate",
"type": "string",
"x-nullable": false,
"x-omitempty": false
},
"ssl_certificate_key": {
"description": "Path to client certificate key",
"type": "string",
"x-nullable": false,
"x-omitempty": false
},
"ssl_protocols": {
"description": "Allowed TLS protocols",
"type": "array",
"items": {
"type": "string"
},
"x-nullable": false,
"x-omitempty": false
},
"ssl_server_name": {
"description": "SSL/TLS proxy options (nginx-like)",
"type": "string",
"x-nullable": false,
"x-omitempty": false
},
"ssl_trusted_certificate": {
"description": "Path to trusted CA certificates",
"type": "string",
"x-nullable": false,
"x-omitempty": false
}
@@ -4949,22 +5413,25 @@
"x-nullable": false,
"x-omitempty": false
},
"route.Scheme": {
"type": "string",
"enum": [
"http",
"https",
"tcp",
"udp",
"fileserver"
],
"x-enum-varnames": [
"SchemeHTTP",
"SchemeHTTPS",
"SchemeTCP",
"SchemeUDP",
"SchemeFileServer"
],
"routeApi.RawRule": {
"type": "object",
"properties": {
"do": {
"type": "string",
"x-nullable": false,
"x-omitempty": false
},
"name": {
"type": "string",
"x-nullable": false,
"x-omitempty": false
},
"on": {
"type": "string",
"x-nullable": false,
"x-omitempty": false
}
},
"x-nullable": false,
"x-omitempty": false
},
@@ -4979,43 +5446,6 @@
"x-nullable": false,
"x-omitempty": false
},
"routes.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
},
"rules.Rule": {
"type": "object",
"properties": {

View File

@@ -217,6 +217,42 @@ definitions:
- FileTypeConfig
- FileTypeProvider
- FileTypeMiddleware
FinalRequest:
properties:
body:
type: string
headers:
additionalProperties:
items:
type: string
type: array
type: object
host:
type: string
method:
type: string
path:
type: string
query:
additionalProperties:
items:
type: string
type: array
type: object
type: object
FinalResponse:
properties:
body:
type: string
headers:
additionalProperties:
items:
type: string
type: array
type: object
statusCode:
type: integer
type: object
HTTPHeader:
properties:
key:
@@ -248,6 +284,44 @@ 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:
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
HealthJSON:
properties:
config:
@@ -283,7 +357,7 @@ definitions:
type: object
HealthMap:
additionalProperties:
$ref: '#/definitions/routes.HealthInfo'
$ref: '#/definitions/HealthInfo'
type: object
HomepageCategory:
properties:
@@ -564,6 +638,55 @@ definitions:
- MetricsPeriod1h
- MetricsPeriod1d
- MetricsPeriod1mo
MockCookie:
properties:
name:
type: string
value:
type: string
type: object
MockRequest:
properties:
body:
type: string
cookies:
items:
$ref: '#/definitions/MockCookie'
type: array
headers:
additionalProperties:
items:
type: string
type: array
type: object
host:
type: string
method:
type: string
path:
type: string
query:
additionalProperties:
items:
type: string
type: array
type: object
remoteIP:
type: string
type: object
MockResponse:
properties:
body:
type: string
headers:
additionalProperties:
items:
type: string
type: array
type: object
statusCode:
type: integer
type: object
NewAgentRequest:
properties:
container_runtime:
@@ -612,6 +735,56 @@ definitions:
format: base64
type: string
type: object
ParsedRule:
properties:
do:
type: string
isResponseRule:
type: boolean
name:
type: string
"on":
type: string
validationError: {}
type: object
PlaygroundRequest:
properties:
mockRequest:
$ref: '#/definitions/MockRequest'
mockResponse:
$ref: '#/definitions/MockResponse'
rules:
items:
$ref: '#/definitions/routeApi.RawRule'
type: array
required:
- rules
type: object
PlaygroundResponse:
properties:
executionError: {}
finalRequest:
$ref: '#/definitions/FinalRequest'
finalResponse:
$ref: '#/definitions/FinalResponse'
matchedRules:
items:
type: string
type: array
parsedRules:
items:
$ref: '#/definitions/ParsedRule'
type: array
upstreamCalled:
type: boolean
type: object
Port:
properties:
listening:
type: integer
proxy:
type: integer
type: object
ProviderStats:
properties:
reverse_proxies:
@@ -738,7 +911,7 @@ definitions:
type: array
x-nullable: true
port:
$ref: '#/definitions/github_com_yusing_go-proxy_internal_route_types.Port'
$ref: '#/definitions/Port'
provider:
description: for backward compatibility
type: string
@@ -749,13 +922,38 @@ definitions:
type: integer
root:
type: string
rule_file:
type: string
x-nullable: true
rules:
items:
$ref: '#/definitions/rules.Rule'
type: array
uniqueItems: true
scheme:
$ref: '#/definitions/route.Scheme'
enum:
- http
- https
- tcp
- udp
- fileserver
type: string
ssl_certificate:
description: Path to client certificate
type: string
ssl_certificate_key:
description: Path to client certificate key
type: string
ssl_protocols:
description: Allowed TLS protocols
items:
type: string
type: array
ssl_server_name:
description: SSL/TLS proxy options (nginx-like)
type: string
ssl_trusted_certificate:
description: Path to trusted CA certificates
type: string
type: object
RouteProvider:
properties:
@@ -798,7 +996,7 @@ definitions:
properties:
statuses:
additionalProperties:
$ref: '#/definitions/routes.HealthInfo'
$ref: '#/definitions/HealthInfoWithoutDetail'
type: object
timestamp:
type: integer
@@ -1072,7 +1270,6 @@ definitions:
disk.IOCountersStat:
properties:
iops:
description: godoxy
type: integer
name:
description: |-
@@ -1096,14 +1293,12 @@ definitions:
read_count:
type: integer
read_speed:
description: godoxy
type: number
write_bytes:
type: integer
write_count:
type: integer
write_speed:
description: godoxy
type: number
type: object
disk.UsageStat:
@@ -1115,7 +1310,7 @@ definitions:
path:
type: string
total:
type: integer
type: number
used:
type: integer
used_percent:
@@ -1156,17 +1351,8 @@ definitions:
required:
- id
type: object
github_com_yusing_go-proxy_internal_route_types.Port:
properties:
listening:
type: integer
proxy:
type: integer
type: object
homepage.FetchResult:
properties:
errMsg:
type: string
icon:
items:
format: int32
@@ -1212,15 +1398,9 @@ definitions:
This value is computed from the kernel specific values.
type: integer
free:
description: |-
This is the kernel's notion of free memory; RAM chips whose bits nobody
cares about the value of right now. For a human consumable number,
Available is what you really want.
type: integer
total:
description: Total amount of RAM on this system
type: integer
type: number
used:
description: |-
RAM used by programs
@@ -1307,7 +1487,7 @@ definitions:
type: array
x-nullable: true
port:
$ref: '#/definitions/github_com_yusing_go-proxy_internal_route_types.Port'
$ref: '#/definitions/Port'
provider:
description: for backward compatibility
type: string
@@ -1318,54 +1498,54 @@ definitions:
type: integer
root:
type: string
rule_file:
type: string
x-nullable: true
rules:
items:
$ref: '#/definitions/rules.Rule'
type: array
uniqueItems: true
scheme:
$ref: '#/definitions/route.Scheme'
enum:
- http
- https
- tcp
- udp
- fileserver
type: string
ssl_certificate:
description: Path to client certificate
type: string
ssl_certificate_key:
description: Path to client certificate key
type: string
ssl_protocols:
description: Allowed TLS protocols
items:
type: string
type: array
ssl_server_name:
description: SSL/TLS proxy options (nginx-like)
type: string
ssl_trusted_certificate:
description: Path to trusted CA certificates
type: string
type: object
routeApi.RawRule:
properties:
do:
type: string
name:
type: string
"on":
type: string
type: object
route.Scheme:
enum:
- http
- https
- tcp
- udp
- fileserver
type: string
x-enum-varnames:
- SchemeHTTP
- SchemeHTTPS
- SchemeTCP
- SchemeUDP
- SchemeFileServer
routeApi.RoutesByProvider:
additionalProperties:
items:
$ref: '#/definitions/route.Route'
type: array
type: object
routes.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
rules.Rule:
properties:
do:
@@ -1494,10 +1674,6 @@ paths:
description: Forbidden
schema:
$ref: '#/definitions/ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/ErrorResponse'
summary: List agents
tags:
- agent
@@ -2878,6 +3054,37 @@ paths:
- route
- websocket
x-id: routes
/route/playground:
post:
consumes:
- application/json
description: Test rules against mock request/response
parameters:
- description: Playground request
in: body
name: request
required: true
schema:
$ref: '#/definitions/PlaygroundRequest'
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/PlaygroundResponse'
"400":
description: Bad Request
schema:
$ref: '#/definitions/ErrorResponse'
"403":
description: Forbidden
schema:
$ref: '#/definitions/ErrorResponse'
summary: Rule Playground
tags:
- route
x-id: playground
/route/providers:
get:
consumes:

View File

@@ -5,9 +5,9 @@ import (
"net/http"
"github.com/gin-gonic/gin"
apitypes "github.com/yusing/godoxy/internal/api/types"
"github.com/yusing/godoxy/internal/homepage"
"github.com/yusing/godoxy/internal/route/routes"
apitypes "github.com/yusing/goutils/apitypes"
_ "unsafe"
)

View File

@@ -7,8 +7,8 @@ import (
"strings"
"github.com/gin-gonic/gin"
apitypes "github.com/yusing/godoxy/internal/api/types"
"github.com/yusing/godoxy/internal/common"
apitypes "github.com/yusing/goutils/apitypes"
)
type FileType string // @name FileType

View File

@@ -5,9 +5,9 @@ import (
"strings"
"github.com/gin-gonic/gin"
apitypes "github.com/yusing/godoxy/internal/api/types"
"github.com/yusing/godoxy/internal/common"
"github.com/yusing/godoxy/internal/utils"
apitypes "github.com/yusing/goutils/apitypes"
)
type ListFilesResponse struct {

View File

@@ -5,7 +5,7 @@ import (
"os"
"github.com/gin-gonic/gin"
apitypes "github.com/yusing/godoxy/internal/api/types"
apitypes "github.com/yusing/goutils/apitypes"
)
type SetFileContentRequest GetFileContentRequest

View File

@@ -4,10 +4,10 @@ import (
"net/http"
"github.com/gin-gonic/gin"
apitypes "github.com/yusing/godoxy/internal/api/types"
config "github.com/yusing/godoxy/internal/config/types"
"github.com/yusing/godoxy/internal/net/gphttp/middleware"
"github.com/yusing/godoxy/internal/route/provider"
apitypes "github.com/yusing/goutils/apitypes"
gperr "github.com/yusing/goutils/errs"
)

View File

@@ -8,6 +8,8 @@ import (
"github.com/yusing/godoxy/internal/route/routes"
"github.com/yusing/goutils/http/httpheaders"
"github.com/yusing/goutils/http/websocket"
_ "github.com/yusing/goutils/apitypes"
)
type HealthMap = map[string]routes.HealthInfo // @name HealthMap

View File

@@ -6,6 +6,8 @@ import (
"github.com/gin-gonic/gin"
"github.com/yusing/godoxy/internal/homepage"
"github.com/yusing/godoxy/internal/route/routes"
_ "github.com/yusing/goutils/apitypes"
)
// @x-id "categories"

View File

@@ -4,8 +4,8 @@ import (
"net/http"
"github.com/gin-gonic/gin"
apitypes "github.com/yusing/godoxy/internal/api/types"
"github.com/yusing/godoxy/internal/homepage"
apitypes "github.com/yusing/goutils/apitypes"
)
type HomepageOverrideItemClickParams struct {

View File

@@ -10,9 +10,9 @@ import (
"github.com/gin-gonic/gin"
"github.com/lithammer/fuzzysearch/fuzzy"
apitypes "github.com/yusing/godoxy/internal/api/types"
"github.com/yusing/godoxy/internal/homepage"
"github.com/yusing/godoxy/internal/route/routes"
apitypes "github.com/yusing/goutils/apitypes"
"github.com/yusing/goutils/http/httpheaders"
"github.com/yusing/goutils/http/websocket"
)

View File

@@ -4,8 +4,8 @@ import (
"net/http"
"github.com/gin-gonic/gin"
apitypes "github.com/yusing/godoxy/internal/api/types"
"github.com/yusing/godoxy/internal/homepage"
apitypes "github.com/yusing/goutils/apitypes"
)
type (

View File

@@ -4,8 +4,8 @@ import (
"net/http"
"github.com/gin-gonic/gin"
apitypes "github.com/yusing/godoxy/internal/api/types"
"github.com/yusing/godoxy/internal/homepage"
apitypes "github.com/yusing/goutils/apitypes"
)
type ListIconsRequest struct {

View File

@@ -1,10 +1,8 @@
package metrics
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"sync"
"sync/atomic"
@@ -14,21 +12,17 @@ import (
"github.com/gin-gonic/gin"
"github.com/rs/zerolog/log"
"github.com/yusing/godoxy/agent/pkg/agent"
apitypes "github.com/yusing/godoxy/internal/api/types"
"github.com/yusing/godoxy/internal/metrics/period"
"github.com/yusing/godoxy/internal/metrics/systeminfo"
apitypes "github.com/yusing/goutils/apitypes"
gperr "github.com/yusing/goutils/errs"
httputils "github.com/yusing/goutils/http"
"github.com/yusing/goutils/http/httpheaders"
"github.com/yusing/goutils/http/websocket"
"github.com/yusing/goutils/synk"
)
var (
// for json marshaling (unknown size)
allSystemInfoBytesPool = synk.GetBytesPoolWithUniqueMemory()
// for storing http response body (known size)
allSystemInfoFixedSizePool = synk.GetBytesPool()
)
var bytesPool = synk.GetUnsizedBytesPool()
type AllSystemInfoRequest struct {
Period period.Filter `query:"period"`
@@ -38,6 +32,7 @@ type AllSystemInfoRequest struct {
type bytesFromPool struct {
json.RawMessage
release func([]byte)
}
// @x-id "all_system_info"
@@ -183,38 +178,26 @@ func AllSystemInfo(c *gin.Context) {
}
}
func getAgentSystemInfo(ctx context.Context, a *agent.AgentConfig, query string) (json.Marshaler, error) {
func getAgentSystemInfo(ctx context.Context, a *agent.AgentConfig, query string) (bytesFromPool, error) {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
path := agent.EndpointSystemInfo + "?" + query
resp, err := a.Do(ctx, http.MethodGet, path, nil)
if err != nil {
return nil, err
return bytesFromPool{}, err
}
defer resp.Body.Close()
// NOTE: buffer will be released by marshalSystemInfo once marshaling is done.
if resp.ContentLength >= 0 {
bytesBuf := allSystemInfoFixedSizePool.GetSized(int(resp.ContentLength))
_, err = io.ReadFull(resp.Body, bytesBuf)
if err != nil {
// prevent pool leak on error.
allSystemInfoFixedSizePool.Put(bytesBuf)
return nil, err
}
return bytesFromPool{json.RawMessage(bytesBuf)}, nil
}
// Fallback when content length is unknown (should not happen but just in case).
data, err := io.ReadAll(resp.Body)
bytesBuf, release, err := httputils.ReadAllBody(resp)
if err != nil {
return nil, err
return bytesFromPool{}, err
}
return json.RawMessage(data), nil
return bytesFromPool{json.RawMessage(bytesBuf), release}, nil
}
func getAgentSystemInfoWithRetry(ctx context.Context, a *agent.AgentConfig, query string) (json.Marshaler, error) {
func getAgentSystemInfoWithRetry(ctx context.Context, a *agent.AgentConfig, query string) (bytesFromPool, error) {
const maxRetries = 3
var lastErr error
@@ -224,7 +207,7 @@ func getAgentSystemInfoWithRetry(ctx context.Context, a *agent.AgentConfig, quer
delay := max((1<<attempt)*time.Second, 5*time.Second)
select {
case <-ctx.Done():
return nil, ctx.Err()
return bytesFromPool{}, ctx.Err()
case <-time.After(delay):
}
}
@@ -240,23 +223,22 @@ func getAgentSystemInfoWithRetry(ctx context.Context, a *agent.AgentConfig, quer
// Don't retry on context cancellation
if ctx.Err() != nil {
return nil, ctx.Err()
return bytesFromPool{}, ctx.Err()
}
}
return nil, lastErr
return bytesFromPool{}, lastErr
}
func marshalSystemInfo(ws *websocket.Manager, agentName string, systemInfo any) error {
bytesBuf := allSystemInfoBytesPool.Get()
defer allSystemInfoBytesPool.Put(bytesBuf)
buf := bytesPool.GetBuffer()
defer bytesPool.PutBuffer(buf)
// release the buffer retrieved from getAgentSystemInfo
if bufFromPool, ok := systemInfo.(bytesFromPool); ok {
defer allSystemInfoFixedSizePool.Put(bufFromPool.RawMessage)
defer bufFromPool.release(bufFromPool.RawMessage)
}
buf := bytes.NewBuffer(bytesBuf)
err := sonic.ConfigDefault.NewEncoder(buf).Encode(map[string]any{
agentName: systemInfo,
})

View File

@@ -7,9 +7,9 @@ import (
"github.com/gin-gonic/gin"
agentPkg "github.com/yusing/godoxy/agent/pkg/agent"
apitypes "github.com/yusing/godoxy/internal/api/types"
"github.com/yusing/godoxy/internal/metrics/period"
"github.com/yusing/godoxy/internal/metrics/systeminfo"
apitypes "github.com/yusing/goutils/apitypes"
"github.com/yusing/goutils/http/httpheaders"
"github.com/yusing/goutils/synk"
)
@@ -21,7 +21,7 @@ type SystemInfoRequest struct {
Period period.Filter `query:"period"`
} // @name SystemInfoRequest
type SystemInfoAggregate period.ResponseType[systeminfo.AggregatedJSON] // @name SystemInfoAggregate
type SystemInfoAggregate period.ResponseType[systeminfo.Aggregated] // @name SystemInfoAggregate
// @x-id "system_info"
// @BasePath /api/v1
@@ -70,12 +70,16 @@ func SystemInfo(c *gin.Context) {
maps.Copy(c.Writer.Header(), resp.Header)
c.Status(resp.StatusCode)
buf := pool.Get()
defer pool.Put(buf)
io.CopyBuffer(c.Writer, resp.Body, buf)
pool := synk.GetSizedBytesPool()
buf := pool.GetSized(16384)
_, err = io.CopyBuffer(c.Writer, resp.Body, buf)
pool.Put(buf)
if err != nil {
c.Error(apitypes.InternalServerError(err, "failed to copy response to client"))
return
}
} else {
agent.ReverseProxy(c.Writer, c.Request, agentPkg.EndpointSystemInfo)
}
}
var pool = synk.GetBytesPool()

View File

@@ -4,6 +4,8 @@ import (
"github.com/gin-gonic/gin"
"github.com/yusing/godoxy/internal/metrics/period"
"github.com/yusing/godoxy/internal/metrics/uptime"
_ "github.com/yusing/goutils/apitypes"
)
type UptimeRequest struct {

View File

@@ -4,8 +4,8 @@ import (
"net/http"
"github.com/gin-gonic/gin"
apitypes "github.com/yusing/godoxy/internal/api/types"
"github.com/yusing/godoxy/internal/config"
apitypes "github.com/yusing/goutils/apitypes"
)
// @x-id "reload"

View File

@@ -6,6 +6,8 @@ import (
"github.com/gin-gonic/gin"
"github.com/yusing/godoxy/internal/route"
"github.com/yusing/godoxy/internal/route/routes"
_ "github.com/yusing/goutils/apitypes"
)
type RoutesByProvider map[string][]route.Route

View File

@@ -0,0 +1,361 @@
package routeApi
import (
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"github.com/gin-gonic/gin"
"github.com/yusing/godoxy/internal/common"
"github.com/yusing/godoxy/internal/route/rules"
apitypes "github.com/yusing/goutils/apitypes"
gperr "github.com/yusing/goutils/errs"
)
type RawRule struct {
Name string `json:"name"`
On string `json:"on"`
Do string `json:"do"`
}
type PlaygroundRequest struct {
Rules []RawRule `json:"rules" binding:"required"`
MockRequest MockRequest `json:"mockRequest"`
MockResponse MockResponse `json:"mockResponse"`
} // @name PlaygroundRequest
type MockRequest struct {
Method string `json:"method"`
Path string `json:"path"`
Host string `json:"host"`
Headers map[string][]string `json:"headers"`
Query map[string][]string `json:"query"`
Cookies []MockCookie `json:"cookies"`
Body string `json:"body"`
RemoteIP string `json:"remoteIP"`
} // @name MockRequest
type MockCookie struct {
Name string `json:"name"`
Value string `json:"value"`
} // @name MockCookie
type MockResponse struct {
StatusCode int `json:"statusCode"`
Headers map[string][]string `json:"headers"`
Body string `json:"body"`
} // @name MockResponse
type PlaygroundResponse struct {
ParsedRules []ParsedRule `json:"parsedRules"`
MatchedRules []string `json:"matchedRules"`
FinalRequest FinalRequest `json:"finalRequest"`
FinalResponse FinalResponse `json:"finalResponse"`
ExecutionError gperr.Error `json:"executionError,omitempty"`
UpstreamCalled bool `json:"upstreamCalled"`
} // @name PlaygroundResponse
type ParsedRule struct {
Name string `json:"name"`
On string `json:"on"`
Do string `json:"do"`
ValidationError gperr.Error `json:"validationError,omitempty"`
IsResponseRule bool `json:"isResponseRule"`
} // @name ParsedRule
type FinalRequest struct {
Method string `json:"method"`
Path string `json:"path"`
Host string `json:"host"`
Headers map[string][]string `json:"headers"`
Query map[string][]string `json:"query"`
Body string `json:"body"`
} // @name FinalRequest
type FinalResponse struct {
StatusCode int `json:"statusCode"`
Headers map[string][]string `json:"headers"`
Body string `json:"body"`
} // @name FinalResponse
// @x-id "playground"
// @BasePath /api/v1
// @Summary Rule Playground
// @Description Test rules against mock request/response
// @Tags route
// @Accept json
// @Produce json
// @Param request body PlaygroundRequest true "Playground request"
// @Success 200 {object} PlaygroundResponse
// @Failure 400 {object} apitypes.ErrorResponse
// @Failure 403 {object} apitypes.ErrorResponse
// @Router /route/playground [post]
func Playground(c *gin.Context) {
var req PlaygroundRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, apitypes.Error("invalid request", err))
return
}
// Apply defaults
if req.MockRequest.Method == "" {
req.MockRequest.Method = "GET"
}
if req.MockRequest.Path == "" {
req.MockRequest.Path = "/"
}
if req.MockRequest.Host == "" {
req.MockRequest.Host = "localhost"
}
// Parse rules
parsedRules, rulesList, parseErr := parseRules(req.Rules)
// Create mock HTTP request
mockReq := createMockRequest(req.MockRequest)
// Create mock HTTP response writer
recorder := httptest.NewRecorder()
// Set initial mock response if provided
if req.MockResponse.StatusCode > 0 {
recorder.Code = req.MockResponse.StatusCode
}
if req.MockResponse.Headers != nil {
for k, values := range req.MockResponse.Headers {
for _, v := range values {
recorder.Header().Add(k, v)
}
}
}
if req.MockResponse.Body != "" {
recorder.Body.WriteString(req.MockResponse.Body)
}
// Execute rules
matchedRules := []string{}
upstreamCalled := false
var executionError gperr.Error
// Variables to capture modified request state
var finalReqMethod, finalReqPath, finalReqHost string
var finalReqHeaders http.Header
var finalReqQuery url.Values
if parseErr == nil && len(rulesList) > 0 {
// Create upstream handler that records if it was called and captures request state
upstreamHandler := func(w http.ResponseWriter, r *http.Request) {
upstreamCalled = true
// Capture the request state when upstream is called
finalReqMethod = r.Method
finalReqPath = r.URL.Path
finalReqHost = r.Host
finalReqHeaders = r.Header.Clone()
finalReqQuery = r.URL.Query()
// Debug: also check RequestURI
if r.URL.Path != r.URL.RawPath && r.URL.RawPath != "" {
finalReqPath = r.URL.RawPath
}
// If there's mock response body, write it during upstream call
if req.MockResponse.Body != "" && w.Header().Get("Content-Type") == "" {
w.Header().Set("Content-Type", "text/plain")
}
if req.MockResponse.StatusCode > 0 {
w.WriteHeader(req.MockResponse.StatusCode)
}
if req.MockResponse.Body != "" {
w.Write([]byte(req.MockResponse.Body))
}
}
// Build handler with rules
handler := rulesList.BuildHandler(upstreamHandler)
// Execute the handler
handlerWithRecover(recorder, mockReq, handler, &executionError)
// Track which rules matched
// Since we can't easily instrument the rules, we'll check each rule manually
matchedRules = checkMatchedRules(rulesList, recorder, mockReq)
} else if parseErr != nil {
executionError = parseErr
}
// Build final request state
// Use captured state if upstream was called, otherwise use current state
var finalRequest FinalRequest
if upstreamCalled {
finalRequest = FinalRequest{
Method: finalReqMethod,
Path: finalReqPath,
Host: finalReqHost,
Headers: finalReqHeaders,
Query: finalReqQuery,
Body: req.MockRequest.Body,
}
} else {
finalRequest = FinalRequest{
Method: mockReq.Method,
Path: mockReq.URL.Path,
Host: mockReq.Host,
Headers: mockReq.Header,
Query: mockReq.URL.Query(),
Body: req.MockRequest.Body,
}
}
// Build final response state
finalResponse := FinalResponse{
StatusCode: recorder.Code,
Headers: recorder.Header(),
Body: recorder.Body.String(),
}
// Ensure status code defaults to 200 if not set
if finalResponse.StatusCode == 0 {
finalResponse.StatusCode = http.StatusOK
}
// prevent null in response
if parsedRules == nil {
parsedRules = []ParsedRule{}
}
if matchedRules == nil {
matchedRules = []string{}
}
response := PlaygroundResponse{
ParsedRules: parsedRules,
MatchedRules: matchedRules,
FinalRequest: finalRequest,
FinalResponse: finalResponse,
ExecutionError: executionError,
UpstreamCalled: upstreamCalled,
}
if common.IsTest {
c.Set("response", response)
}
c.JSON(http.StatusOK, response)
}
func handlerWithRecover(w http.ResponseWriter, r *http.Request, h http.HandlerFunc, outErr *gperr.Error) {
defer func() {
if r := recover(); r != nil {
if outErr != nil {
*outErr = gperr.Errorf("panic during rule execution: %v", r)
}
}
}()
h(w, r)
}
func parseRules(rawRules []RawRule) ([]ParsedRule, rules.Rules, gperr.Error) {
var parsedRules []ParsedRule
var rulesList rules.Rules
// Parse each rule individually to capture per-rule errors
for _, rawRule := range rawRules {
var rule rules.Rule
// Extract fields
name := rawRule.Name
onStr := rawRule.On
doStr := rawRule.Do
rule.Name = name
// Parse On
var onErr error
if onStr != "" {
onErr = rule.On.Parse(onStr)
}
// Parse Do
var doErr error
if doStr != "" {
doErr = rule.Do.Parse(doStr)
}
// Determine if valid
isValid := onErr == nil && doErr == nil
validationErr := gperr.Join(gperr.PrependSubject("on", onErr), gperr.PrependSubject("do", doErr))
parsedRules = append(parsedRules, ParsedRule{
Name: name,
On: onStr,
Do: doStr,
ValidationError: validationErr,
IsResponseRule: rule.IsResponseRule(),
})
// Only add valid rules to execution list
if isValid {
rulesList = append(rulesList, rule)
}
}
return parsedRules, rulesList, nil
}
func createMockRequest(mock MockRequest) *http.Request {
// Create URL
urlStr := mock.Path
if len(mock.Query) > 0 {
query := url.Values(mock.Query)
urlStr = mock.Path + "?" + query.Encode()
}
// Create request
var body io.Reader
if mock.Body != "" {
body = strings.NewReader(mock.Body)
}
req := httptest.NewRequest(mock.Method, urlStr, body)
// Set host
req.Host = mock.Host
// Set headers
req.Header = mock.Headers
// Set cookies
if mock.Cookies != nil {
for _, cookie := range mock.Cookies {
req.AddCookie(&http.Cookie{
Name: cookie.Name,
Value: cookie.Value,
})
}
}
// Set remote address
if mock.RemoteIP != "" {
req.RemoteAddr = mock.RemoteIP + ":0"
} else {
req.RemoteAddr = "127.0.0.1:0"
}
return req
}
func checkMatchedRules(rulesList rules.Rules, w http.ResponseWriter, r *http.Request) []string {
var matched []string
// Create a ResponseModifier to properly check rules
rm := rules.NewResponseModifier(w)
for _, rule := range rulesList {
// Check if rule matches
if rule.Check(rm, r) {
matched = append(matched, rule.Name)
}
}
return matched
}

View File

@@ -0,0 +1,229 @@
package routeApi
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
)
func TestPlayground(t *testing.T) {
gin.SetMode(gin.TestMode)
tests := []struct {
name string
request PlaygroundRequest
wantStatusCode int
checkResponse func(t *testing.T, resp PlaygroundResponse)
}{
{
name: "simple path matching rule",
request: PlaygroundRequest{
Rules: []RawRule{
{
Name: "test rule",
On: "path /api",
Do: "pass",
},
},
MockRequest: MockRequest{
Method: "GET",
Path: "/api",
},
},
wantStatusCode: http.StatusOK,
checkResponse: func(t *testing.T, resp PlaygroundResponse) {
if len(resp.ParsedRules) != 1 {
t.Errorf("expected 1 parsed rule, got %d", len(resp.ParsedRules))
}
if resp.ParsedRules[0].ValidationError != nil {
t.Errorf("expected rule to be valid, got error: %v", resp.ParsedRules[0].ValidationError)
}
if len(resp.MatchedRules) != 1 || resp.MatchedRules[0] != "test rule" {
t.Errorf("expected matched rules to be ['test rule'], got %v", resp.MatchedRules)
}
if !resp.UpstreamCalled {
t.Error("expected upstream to be called")
}
},
},
{
name: "header matching rule",
request: PlaygroundRequest{
Rules: []RawRule{
{
Name: "check user agent",
On: "header User-Agent Chrome",
Do: "error 403 Forbidden",
},
},
MockRequest: MockRequest{
Method: "GET",
Path: "/",
Headers: map[string][]string{
"User-Agent": {"Chrome"},
},
},
},
wantStatusCode: http.StatusOK,
checkResponse: func(t *testing.T, resp PlaygroundResponse) {
if len(resp.ParsedRules) != 1 {
t.Errorf("expected 1 parsed rule, got %d", len(resp.ParsedRules))
}
if resp.ParsedRules[0].ValidationError != nil {
t.Errorf("expected rule to be valid, got error: %v", resp.ParsedRules[0].ValidationError)
}
if len(resp.MatchedRules) != 1 {
t.Errorf("expected 1 matched rule, got %d", len(resp.MatchedRules))
}
if resp.FinalResponse.StatusCode != 403 {
t.Errorf("expected status 403, got %d", resp.FinalResponse.StatusCode)
}
if resp.UpstreamCalled {
t.Error("expected upstream not to be called")
}
},
},
{
name: "invalid rule syntax",
request: PlaygroundRequest{
Rules: []RawRule{
{
Name: "bad rule",
On: "invalid_checker something",
Do: "pass",
},
},
MockRequest: MockRequest{
Method: "GET",
Path: "/",
},
},
wantStatusCode: http.StatusOK,
checkResponse: func(t *testing.T, resp PlaygroundResponse) {
if len(resp.ParsedRules) != 1 {
t.Errorf("expected 1 parsed rule, got %d", len(resp.ParsedRules))
}
if resp.ParsedRules[0].ValidationError == nil {
t.Error("expected validation error to be set")
}
},
},
{
name: "rewrite path rule",
request: PlaygroundRequest{
Rules: []RawRule{
{
Name: "rewrite rule",
On: "path glob(/api/*)",
Do: "rewrite /api/ /v1/",
},
},
MockRequest: MockRequest{
Method: "GET",
Path: "/api/users",
},
},
wantStatusCode: http.StatusOK,
checkResponse: func(t *testing.T, resp PlaygroundResponse) {
if len(resp.ParsedRules) != 1 {
t.Errorf("expected 1 parsed rule, got %d", len(resp.ParsedRules))
}
if resp.ParsedRules[0].ValidationError != nil {
t.Errorf("expected rule to be valid, got error: %v", resp.ParsedRules[0].ValidationError)
}
if !resp.UpstreamCalled {
t.Error("expected upstream to be called")
}
if resp.FinalRequest.Path != "/v1/users" {
t.Errorf("expected path to be rewritten to /v1/users, got %s", resp.FinalRequest.Path)
}
// Note: matched rules tracking has limitations with fresh ResponseModifier
// The important thing is that the rewrite actually worked
},
},
{
name: "method matching rule",
request: PlaygroundRequest{
Rules: []RawRule{
{
Name: "block POST",
On: "method POST",
Do: `error "405" "Method Not Allowed"`,
},
},
MockRequest: MockRequest{
Method: "POST",
Path: "/api",
},
},
wantStatusCode: http.StatusOK,
checkResponse: func(t *testing.T, resp PlaygroundResponse) {
if resp.ParsedRules[0].ValidationError != nil {
t.Errorf("expected rule to be valid, got error: %v", resp.ParsedRules[0].ValidationError)
}
if len(resp.MatchedRules) != 1 {
t.Errorf("expected 1 matched rule, got %d", len(resp.MatchedRules))
}
if resp.FinalResponse.StatusCode != 405 {
t.Errorf("expected status 405, got %d", resp.FinalResponse.StatusCode)
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create request
body, _ := json.Marshal(tt.request)
req := httptest.NewRequest("POST", "/api/v1/route/playground", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
// Create response recorder
w := httptest.NewRecorder()
// Create gin context
c, _ := gin.CreateTestContext(w)
c.Request = req
// Call handler
Playground(c)
// Check status code
if w.Code != tt.wantStatusCode {
t.Errorf("expected status code %d, got %d", tt.wantStatusCode, w.Code)
}
respAny, ok := c.Get("response")
if !ok {
t.Fatalf("expected response to be set")
}
resp := respAny.(PlaygroundResponse)
// Run custom checks
if tt.checkResponse != nil {
tt.checkResponse(t, resp)
}
})
}
}
func TestPlaygroundInvalidRequest(t *testing.T) {
gin.SetMode(gin.TestMode)
req := httptest.NewRequest("POST", "/api/v1/route/playground", bytes.NewReader([]byte(`{}`)))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = req
Playground(c)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status code %d, got %d", http.StatusBadRequest, w.Code)
}
}

View File

@@ -8,6 +8,8 @@ import (
statequery "github.com/yusing/godoxy/internal/config/query"
"github.com/yusing/goutils/http/httpheaders"
"github.com/yusing/goutils/http/websocket"
_ "github.com/yusing/goutils/apitypes"
)
// @x-id "providers"
@@ -17,7 +19,7 @@ import (
// @Tags route,websocket
// @Accept json
// @Produce json
// @Success 200 {array} config.RouteProviderListResponse
// @Success 200 {array} statequery.RouteProviderListResponse
// @Failure 403 {object} apitypes.ErrorResponse
// @Failure 500 {object} apitypes.ErrorResponse
// @Router /route/providers [get]

View File

@@ -4,9 +4,9 @@ import (
"net/http"
"github.com/gin-gonic/gin"
apitypes "github.com/yusing/godoxy/internal/api/types"
statequery "github.com/yusing/godoxy/internal/config/query"
"github.com/yusing/godoxy/internal/route/routes"
apitypes "github.com/yusing/goutils/apitypes"
)
type ListRouteRequest struct {

View File

@@ -51,6 +51,10 @@ func ProceedNext(w http.ResponseWriter, r *http.Request) {
}
func AuthCheckHandler(w http.ResponseWriter, r *http.Request) {
if defaultAuth == nil {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
err := defaultAuth.CheckToken(r)
if err != nil {
defaultAuth.LoginHandler(w, r)
@@ -58,3 +62,17 @@ func AuthCheckHandler(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}
}
func AuthOrProceed(w http.ResponseWriter, r *http.Request) (proceed bool) {
if defaultAuth == nil {
w.WriteHeader(http.StatusServiceUnavailable)
return false
}
err := defaultAuth.CheckToken(r)
if err != nil {
defaultAuth.LoginHandler(w, r)
return false
} else {
return true
}
}

View File

@@ -151,7 +151,11 @@ func (auth *OIDCProvider) TryRefreshToken(ctx context.Context, sessionJWT string
// verify the session cookie
claims, valid, err := auth.parseSessionJWT(sessionJWT)
if err != nil {
return nil, fmt.Errorf("session: %s - %w: %w", claims.SessionID, ErrInvalidSessionToken, err)
var sessionID sessionID
if claims != nil {
sessionID = claims.SessionID
}
return nil, fmt.Errorf("session: %s - %w: %w", sessionID, ErrInvalidSessionToken, err)
}
if !valid {
return nil, ErrInvalidSessionToken

View File

@@ -10,6 +10,7 @@ import (
"net/http"
"net/url"
"slices"
"strings"
"time"
"github.com/coreos/go-oidc/v3/oidc"
@@ -199,7 +200,7 @@ func (auth *OIDCProvider) HandleAuth(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "" {
r.URL.Path = OIDCAuthInitPath
}
if r.TLS == nil && r.Header.Get("X-Forwarded-Proto") != "https" {
if r.TLS == nil && strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") {
r.URL.Scheme = "https"
http.Redirect(w, r, r.URL.String(), http.StatusFound)
return

View File

@@ -100,7 +100,7 @@ func TestUserPassLoginCallbackHandler(t *testing.T) {
}
auth.PostAuthCallbackHandler(w, req)
if tt.wantErr {
expect.Equal(t, w.Code, http.StatusUnauthorized)
expect.Equal(t, w.Code, http.StatusBadRequest)
} else {
setCookie := expect.Must(http.ParseSetCookie(w.Header().Get("Set-Cookie")))
expect.True(t, setCookie.Name == auth.TokenCookieName())

View File

@@ -55,7 +55,7 @@ func NewState() config.State {
entrypoint: entrypoint.NewEntrypoint(),
task: task.RootTask("config", false),
tmpLogBuf: tmpLogBuf,
tmpLog: logging.NewLogger(tmpLogBuf),
tmpLog: logging.NewLoggerWithFixedLevel(zerolog.InfoLevel, tmpLogBuf),
}
}

View File

@@ -38,7 +38,7 @@ allowlist = [
"godaddy",
"googledomains",
"hetzner",
# "hostinger", # TODO: uncomment when v4.27.0 is released
"hostinger",
"httpreq",
"ionos",
"linode",

View File

@@ -1,12 +1,12 @@
module github.com/yusing/godoxy/internal/dnsproviders
go 1.25.2
go 1.25.4
replace github.com/yusing/godoxy => ../..
require (
github.com/go-acme/lego/v4 v4.26.0
github.com/yusing/godoxy v0.18.6
github.com/go-acme/lego/v4 v4.28.0
github.com/yusing/godoxy v0.20.6
)
require (
@@ -19,17 +19,17 @@ require (
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns v1.2.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/privatedns/armprivatedns v1.3.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph v0.9.0 // indirect
github.com/AzureAD/microsoft-authentication-library-for-go v1.5.0 // indirect
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 // indirect
github.com/akamai/AkamaiOPEN-edgegrid-golang/v11 v11.1.0 // indirect
github.com/benbjohnson/clock v1.3.5 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.14.1 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/bytedance/sonic v1.14.2 // indirect
github.com/bytedance/sonic/loader v0.4.0 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/gabriel-vasile/mimetype v1.4.10 // indirect
github.com/gabriel-vasile/mimetype v1.4.11 // indirect
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
@@ -44,7 +44,7 @@ require (
github.com/google/go-querystring v1.1.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.6 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.7 // indirect
github.com/googleapis/gax-go/v2 v2.15.0 // indirect
github.com/gotify/server/v2 v2.7.3 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
@@ -59,8 +59,8 @@ require (
github.com/miekg/dns v1.1.68 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/nrdcg/goacmedns v0.2.0 // indirect
github.com/nrdcg/oci-go-sdk/common/v1065 v1065.102.0 // indirect
github.com/nrdcg/oci-go-sdk/dns/v1065 v1065.102.0 // indirect
github.com/nrdcg/oci-go-sdk/common/v1065 v1065.104.0 // indirect
github.com/nrdcg/oci-go-sdk/dns/v1065 v1065.104.0 // indirect
github.com/nrdcg/porkbun v0.4.0 // indirect
github.com/ovh/go-ovh v1.9.0 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
@@ -75,7 +75,7 @@ require (
github.com/vultr/govultr/v3 v3.24.0 // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
github.com/yusing/gointernals v0.1.16 // indirect
github.com/yusing/goutils v0.6.1 // indirect
github.com/yusing/goutils v0.7.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect
go.opentelemetry.io/otel v1.38.0 // indirect
@@ -92,8 +92,8 @@ require (
golang.org/x/sys v0.37.0 // indirect
golang.org/x/text v0.30.0 // indirect
golang.org/x/tools v0.38.0 // indirect
google.golang.org/api v0.252.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251007200510-49b9836ed3ff // indirect
google.golang.org/api v0.255.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251103181224-f26f9409b101 // indirect
google.golang.org/grpc v1.76.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect

View File

@@ -25,8 +25,8 @@ github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0/go.mod h1:5kakwfW5CjC9KK+Q4wjXAg+ShuIm2mBMua0ZFj2C8PE=
github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM=
github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE=
github.com/AzureAD/microsoft-authentication-library-for-go v1.5.0 h1:XkkQbfMyuH2jTSjQjSoihryI8GINRcs4xp8lNawg0FI=
github.com/AzureAD/microsoft-authentication-library-for-go v1.5.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk=
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs=
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk=
github.com/akamai/AkamaiOPEN-edgegrid-golang/v11 v11.1.0 h1:h/33OxYLqBk0BYmEbSUy7MlvgQR/m1w1/7OJFKoPL1I=
github.com/akamai/AkamaiOPEN-edgegrid-golang/v11 v11.1.0/go.mod h1:rvh3imDA6EaQi+oM/GQHkQAOHbXPKJ7EWJvfjuw141Q=
github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg=
@@ -36,12 +36,12 @@ github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz
github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.14.1 h1:FBMC0zVz5XUmE4z9wF4Jey0An5FueFvOsTKKKtwIl7w=
github.com/bytedance/sonic v1.14.1/go.mod h1:gi6uhQLMbTdeP0muCnrjHLeCUPyb70ujhnNlhOylAFc=
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/bytedance/sonic v1.14.2 h1:k1twIoe97C1DtYUo+fZQy865IuHia4PR5RPiuGPPIIE=
github.com/bytedance/sonic v1.14.2/go.mod h1:T80iDELeHiHKSc0C9tubFygiuXoGzrkjKzX2quAx980=
github.com/bytedance/sonic/loader v0.4.0 h1:olZ7lEqcxtZygCK9EKYKADnpQoYkRQxaeY2NYzevs+o=
github.com/bytedance/sonic/loader v0.4.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
@@ -53,10 +53,10 @@ github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0=
github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/go-acme/lego/v4 v4.26.0 h1:521aEQxNstXvPQcFDDPrJiFfixcCQuvAvm35R4GbyYA=
github.com/go-acme/lego/v4 v4.26.0/go.mod h1:BQVAWgcyzW4IT9eIKHY/RxYlVhoyKyOMXOkq7jK1eEQ=
github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik=
github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/go-acme/lego/v4 v4.28.0 h1:URKsCcybo7SjqqZckeBcDN9Vl29/bKS///75tcNkMHQ=
github.com/go-acme/lego/v4 v4.28.0/go.mod h1:bzjilr03IgbaOwlH396hq5W56Bi0/uoRwW/JM8hP7m4=
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
@@ -94,8 +94,8 @@ 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.6 h1:GW/XbdyBFQ8Qe+YAmFU9uHLo7OnF5tL52HFAgMmyrf4=
github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
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/gotify/server/v2 v2.7.3 h1:nro/ZnxdlZFvxFcw9LREGA8zdk6CK744azwhuhX/A4g=
@@ -137,10 +137,10 @@ github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/nrdcg/goacmedns v0.2.0 h1:ADMbThobzEMnr6kg2ohs4KGa3LFqmgiBA22/6jUWJR0=
github.com/nrdcg/goacmedns v0.2.0/go.mod h1:T5o6+xvSLrQpugmwHvrSNkzWht0UGAwj2ACBMhh73Cg=
github.com/nrdcg/oci-go-sdk/common/v1065 v1065.102.0 h1:W28ZizQSS2aRWkFA3iAP9eiZS4OLFaiv35nXtq2lW/s=
github.com/nrdcg/oci-go-sdk/common/v1065 v1065.102.0/go.mod h1:cVbzGjRhtXgrduaQbR1GR1x+VDU60NcXPMZ3+eQuiiY=
github.com/nrdcg/oci-go-sdk/dns/v1065 v1065.102.0 h1:gAOs1dkE7LFoWflzqrDqAhOprc0kF1a0fyV8C4HUPj4=
github.com/nrdcg/oci-go-sdk/dns/v1065 v1065.102.0/go.mod h1:EUBSYwop1K40VpcKy1haIK6kFK/gPT1atEk89OkY0Kg=
github.com/nrdcg/oci-go-sdk/common/v1065 v1065.104.0 h1:Q+nfcttPQcZHNlSXiHptnFLf1UeDGk2NEHDYI/Cr8Ts=
github.com/nrdcg/oci-go-sdk/common/v1065 v1065.104.0/go.mod h1:SfDIKzNQ5AGNMMOA3LGqSPnn63F6Gc4E4bsKArqymvg=
github.com/nrdcg/oci-go-sdk/dns/v1065 v1065.104.0 h1:r1PHNdkYK2+cQlDAFvirra1u7VJ04Kjol4aTbiaWG0c=
github.com/nrdcg/oci-go-sdk/dns/v1065 v1065.104.0/go.mod h1:0dBUJS+h0TkCdytcRzIBT1B5q84k9O92Hcs5YwIVtAY=
github.com/nrdcg/porkbun v0.4.0 h1:rWweKlwo1PToQ3H+tEO9gPRW0wzzgmI/Ob3n2Guticw=
github.com/nrdcg/porkbun v0.4.0/go.mod h1:/QMskrHEIM0IhC/wY7iTCUgINsxdT2WcOphktJ9+Q54=
github.com/ovh/go-ovh v1.9.0 h1:6K8VoL3BYjVV3In9tPJUdT7qMx9h0GExN9EXx1r2kKE=
@@ -165,13 +165,15 @@ github.com/sony/gobreaker v1.0.0/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJ
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
@@ -182,8 +184,8 @@ github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zU
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
github.com/yusing/gointernals v0.1.16 h1:GrhZZdxzA+jojLEqankctJrOuAYDb7kY1C93S1pVR34=
github.com/yusing/gointernals v0.1.16/go.mod h1:B/0FVXt4WPmgzVy3ynzkqKi+BSGaJVmwCJBRXYapo34=
github.com/yusing/goutils v0.6.1 h1:PQmWQEBV+xkI6vnyreQ2uT1PFWTQNkZfHM7Oczuih/s=
github.com/yusing/goutils v0.6.1/go.mod h1:3dgYe/A3+8wT88/iAHwXdL44q5bP+qVo2WAOiPBqOrg=
github.com/yusing/goutils v0.7.0 h1:I5hd8GwZ+3WZqFPK0tWqek1Q5MY6Xg29hKZcwwQi4SY=
github.com/yusing/goutils v0.7.0/go.mod h1:CtF/KFH4q8jkr7cvBpkaExnudE0lLu8sLe43F73Bn5Q=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ=
@@ -231,14 +233,14 @@ golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs
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.252.0 h1:xfKJeAJaMwb8OC9fesr369rjciQ704AjU/psjkKURSI=
google.golang.org/api v0.252.0/go.mod h1:dnHOv81x5RAmumZ7BWLShB/u7JZNeyalImxHmtTHxqw=
google.golang.org/api v0.255.0 h1:OaF+IbRwOottVCYV2wZan7KUq7UeNUQn1BcPc4K7lE4=
google.golang.org/api v0.255.0/go.mod h1:d1/EtvCLdtiWEV4rAEHDHGh2bCnqsWhw+M8y2ECN4a8=
google.golang.org/genproto v0.0.0-20250908214217-97024824d090 h1:ywCL7vA2n3vVHyf+bx1ZV/knaTPRI8GIeKY0MEhEeOc=
google.golang.org/genproto v0.0.0-20250908214217-97024824d090/go.mod h1:zwJI9HzbJJlw2KXy0wX+lmT2JuZoaKK9JC4ppqmxxjk=
google.golang.org/genproto/googleapis/api v0.0.0-20250826171959-ef028d996bc1 h1:APHvLLYBhtZvsbnpkfknDZ7NyH4z5+ub/I0u8L3Oz6g=
google.golang.org/genproto/googleapis/api v0.0.0-20250826171959-ef028d996bc1/go.mod h1:xUjFWUnWDpZ/C0Gu0qloASKFb6f8/QXiiXhSPFsD668=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251007200510-49b9836ed3ff h1:A90eA31Wq6HOMIQlLfzFwzqGKBTuaVztYu/g8sn+8Zc=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251007200510-49b9836ed3ff/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251103181224-f26f9409b101 h1:tRPGkdGHuewF4UisLzzHHr1spKw92qLM98nIzxbC0wY=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251103181224-f26f9409b101/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A=
google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=

View File

@@ -15,6 +15,7 @@ import (
"github.com/go-acme/lego/v4/providers/dns/godaddy"
"github.com/go-acme/lego/v4/providers/dns/googledomains"
"github.com/go-acme/lego/v4/providers/dns/hetzner"
"github.com/go-acme/lego/v4/providers/dns/hostinger"
"github.com/go-acme/lego/v4/providers/dns/httpreq"
"github.com/go-acme/lego/v4/providers/dns/ionos"
"github.com/go-acme/lego/v4/providers/dns/linode"
@@ -53,6 +54,7 @@ func InitProviders() {
autocert.Providers["godaddy"] = autocert.DNSProvider(godaddy.NewDefaultConfig, godaddy.NewDNSProviderConfig)
autocert.Providers["googledomains"] = autocert.DNSProvider(googledomains.NewDefaultConfig, googledomains.NewDNSProviderConfig)
autocert.Providers["hetzner"] = autocert.DNSProvider(hetzner.NewDefaultConfig, hetzner.NewDNSProviderConfig)
autocert.Providers["hostinger"] = autocert.DNSProvider(hostinger.NewDefaultConfig, hostinger.NewDNSProviderConfig)
autocert.Providers["httpreq"] = autocert.DNSProvider(httpreq.NewDefaultConfig, httpreq.NewDNSProviderConfig)
autocert.Providers["ionos"] = autocert.DNSProvider(ionos.NewDefaultConfig, ionos.NewDNSProviderConfig)
autocert.Providers["linode"] = autocert.DNSProvider(linode.NewDefaultConfig, linode.NewDNSProviderConfig)

View File

@@ -7,16 +7,20 @@ import (
"maps"
"net"
"net/http"
"reflect"
"sync"
"sync/atomic"
"time"
"unsafe"
"github.com/docker/cli/cli/connhelper"
"github.com/docker/docker/client"
"github.com/rs/zerolog/log"
"github.com/yusing/godoxy/agent/pkg/agent"
"github.com/yusing/godoxy/internal/common"
httputils "github.com/yusing/goutils/http"
"github.com/yusing/goutils/task"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
)
// TODO: implement reconnect here.
@@ -30,6 +34,8 @@ type (
key string
addr string
dial func(ctx context.Context) (net.Conn, error)
unique bool
}
)
@@ -114,16 +120,23 @@ func Clients() map[string]*SharedClient {
// Returns:
// - Client: the Docker client connection.
// - error: an error if the connection failed.
func NewClient(host string) (*SharedClient, error) {
func NewClient(host string, unique ...bool) (*SharedClient, error) {
initClientCleanerOnce.Do(initClientCleaner)
clientMapMu.Lock()
defer clientMapMu.Unlock()
u := false
if len(unique) > 0 {
u = unique[0]
}
if client, ok := clientMap[host]; ok {
client.closedOn.Store(0)
client.refCount.Add(1)
return client, nil
if !u {
clientMapMu.Lock()
defer clientMapMu.Unlock()
if client, ok := clientMap[host]; ok {
client.closedOn.Store(0)
client.refCount.Add(1)
return client, nil
}
}
// create client
@@ -188,7 +201,9 @@ func NewClient(host string) (*SharedClient, error) {
addr: addr,
key: host,
dial: dial,
unique: u,
}
c.unotel()
c.refCount.Store(1)
// non-agent client
@@ -201,10 +216,28 @@ func NewClient(host string) (*SharedClient, error) {
defer log.Debug().Str("host", host).Msg("docker client initialized")
clientMap[c.Key()] = c
if !u {
clientMap[c.Key()] = c
}
return c, nil
}
func (c *SharedClient) GetHTTPClient() **http.Client {
return (**http.Client)(unsafe.Pointer(uintptr(unsafe.Pointer(c.Client)) + clientClientOffset))
}
func (c *SharedClient) InterceptHTTPClient(intercept httputils.InterceptFunc) {
httpClient := *c.GetHTTPClient()
httpClient.Transport = httputils.NewInterceptedTransport(httpClient.Transport, intercept)
}
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)
return c
}
func (c *SharedClient) Key() string {
return c.key
}
@@ -222,8 +255,41 @@ func (c *SharedClient) CheckConnection(ctx context.Context) error {
return nil
}
// if the client is still referenced, this is no-op.
// for shared clients, if the client is still referenced, this is no-op.
func (c *SharedClient) Close() {
if c.unique {
c.Client.Close()
return
}
c.closedOn.Store(time.Now().Unix())
c.refCount.Add(-1)
}
var clientClientOffset = func() uintptr {
field, ok := reflect.TypeFor[client.Client]().FieldByName("client")
if !ok {
panic("client.Client has no client field")
}
return field.Offset
}()
var otelRtOffset = func() uintptr {
field, ok := reflect.TypeFor[otelhttp.Transport]().FieldByName("rt")
if !ok {
panic("otelhttp.Transport has no rt field")
}
return field.Offset
}()
func (c *SharedClient) unotel() {
// we don't need and don't want otelhttp.Transport here.
httpClient := *c.GetHTTPClient()
otelTransport, ok := httpClient.Transport.(*otelhttp.Transport)
if !ok {
log.Debug().Str("host", c.DaemonHost()).Msgf("docker client transport is not an otelhttp.Transport: %T", httpClient.Transport)
return
}
transport := *(*http.RoundTripper)(unsafe.Pointer(uintptr(unsafe.Pointer(otelTransport)) + otelRtOffset))
httpClient.Transport = transport
}

View File

@@ -87,8 +87,8 @@ func ExpandWildcard(labels map[string]string, aliases ...string) {
wildcardLabels[parts[2]] = value
continue
}
// explicit alias label remember the alias
if _, ok := aliasSet[alias]; !ok {
// explicit alias label remember the alias (but not reference aliases like #1, #2)
if _, ok := aliasSet[alias]; !ok && !strings.HasPrefix(alias, "#") {
aliasSet[alias] = len(aliasSet)
}
}
@@ -100,10 +100,9 @@ func ExpandWildcard(labels map[string]string, aliases ...string) {
// expand collected wildcard labels for every alias
for suffix, v := range wildcardLabels {
for alias, i := range aliasSet {
// for FQDN aliases, use numeric index instead of the alias name
if strings.Contains(alias, ".") {
alias = fmt.Sprintf("#%d", i+1)
}
// use numeric index instead of the alias name
alias = fmt.Sprintf("#%d", i+1)
key := fmt.Sprintf("%s.%s.%s", NSProxy, alias, suffix)
if suffix == "" { // this should not happen (root wildcard handled earlier) but keep safe
key = fmt.Sprintf("%s.%s", NSProxy, alias)

View File

@@ -67,6 +67,21 @@ healthcheck:
}, labels)
}
func TestWildcardWithRefAliases(t *testing.T) {
labels := map[string]string{
"proxy.#1.host": "localhost",
"proxy.#1.port": "5555",
"proxy.*.middlewares.request.hide_headers": "X-Header1,X-Header2",
}
docker.ExpandWildcard(labels, "a.example.com", "b.example.com")
require.Equal(t, map[string]string{
"proxy.#1.host": "localhost",
"proxy.#1.port": "5555",
"proxy.#1.middlewares.request.hide_headers": "X-Header1,X-Header2",
"proxy.#2.middlewares.request.hide_headers": "X-Header1,X-Header2",
}, labels)
}
func BenchmarkParseLabels(b *testing.B) {
for b.Loop() {
_, _ = docker.ParseLabels(map[string]string{

View File

@@ -19,7 +19,7 @@ import (
type Entrypoint struct {
middleware *middleware.Middleware
notFoundHandler http.Handler
accessLogger *accesslog.AccessLogger
accessLogger accesslog.AccessLogger
findRouteFunc func(host string) types.HTTPRoute
}

View File

@@ -13,6 +13,7 @@ import (
. "github.com/yusing/godoxy/internal/entrypoint"
"github.com/yusing/godoxy/internal/route"
"github.com/yusing/godoxy/internal/route/routes"
routeTypes "github.com/yusing/godoxy/internal/route/types"
"github.com/yusing/godoxy/internal/types"
"github.com/yusing/goutils/task"
)
@@ -78,7 +79,7 @@ func BenchmarkEntrypointReal(b *testing.B) {
r := &route.Route{
Alias: "test",
Scheme: "http",
Scheme: routeTypes.SchemeHTTP,
Host: host,
Port: route.Port{Proxy: portInt},
HealthCheck: &types.HealthCheckConfig{Disable: true},
@@ -119,7 +120,7 @@ func BenchmarkEntrypoint(b *testing.B) {
r := &route.Route{
Alias: "test",
Scheme: "http",
Scheme: routeTypes.SchemeHTTP,
Host: "localhost",
Port: route.Port{
Proxy: 8080,

View File

@@ -15,8 +15,8 @@ import (
"github.com/PuerkitoBio/goquery"
"github.com/gin-gonic/gin"
"github.com/vincent-petithory/dataurl"
apitypes "github.com/yusing/godoxy/internal/api/types"
gphttp "github.com/yusing/godoxy/internal/net/gphttp"
apitypes "github.com/yusing/goutils/apitypes"
"github.com/yusing/goutils/cache"
httputils "github.com/yusing/goutils/http"
strutils "github.com/yusing/goutils/strings"
@@ -210,7 +210,7 @@ func findIconSlow(ctx context.Context, r httpRoute, uri string, stack []string)
return findIconSlow(ctx, r, loc, append(stack, newReq.URL.Path))
}
}
return FetchResultWithErrorf(c.status, "upstream error: %s", c.data)
return FetchResultWithErrorf(c.status, "upstream error: status %d, %s", c.status, c.data)
}
// return icon data
if !httputils.GetContentType(c.header).IsHTML() {

View File

@@ -3,7 +3,6 @@ package homepage
import (
"context"
"fmt"
"io"
"net/http"
"slices"
"strings"
@@ -14,6 +13,7 @@ import (
"github.com/rs/zerolog/log"
"github.com/yusing/godoxy/internal/common"
"github.com/yusing/godoxy/internal/serialization"
httputils "github.com/yusing/goutils/http"
strutils "github.com/yusing/goutils/strings"
"github.com/yusing/goutils/synk"
"github.com/yusing/goutils/task"
@@ -266,30 +266,26 @@ func updateIcons(m IconMap) error {
var httpGet = httpGetImpl
func MockHTTPGet(body []byte) {
httpGet = func(_ string) ([]byte, error) {
return body, nil
httpGet = func(_ string) ([]byte, func([]byte), error) {
return body, func([]byte) {}, nil
}
}
func httpGetImpl(url string) ([]byte, error) {
func httpGetImpl(url string) ([]byte, func([]byte), error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
return nil, nil, err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
return nil, nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
return body, nil
return httputils.ReadAllBody(resp)
}
/*
@@ -308,13 +304,14 @@ format:
}
*/
func UpdateWalkxCodeIcons(m IconMap) error {
body, err := httpGet(walkxcodeIcons)
body, release, err := httpGet(walkxcodeIcons)
if err != nil {
return err
}
data := make(map[string][]string)
err = sonic.Unmarshal(body, &data)
release(body)
if err != nil {
return err
}
@@ -379,13 +376,14 @@ func UpdateSelfhstIcons(m IconMap) error {
Tags string
}
body, err := httpGet(selfhstIcons)
body, release, err := httpGet(selfhstIcons)
if err != nil {
return err
}
data := make([]SelfhStIcon, 0)
err = sonic.Unmarshal(body, &data) //nolint:musttag
release(body)
if err != nil {
return err
}

View File

@@ -1,24 +0,0 @@
package idlewatcher
import (
"context"
)
func (w *Watcher) canceled(reqCtx context.Context) bool {
select {
case <-reqCtx.Done():
w.l.Debug().AnErr("cause", context.Cause(reqCtx)).Msg("wake canceled")
return true
default:
return false
}
}
func (w *Watcher) waitStarted(reqCtx context.Context) bool {
select {
case <-reqCtx.Done():
return false
case <-w.route.Started():
return true
}
}

View File

@@ -50,7 +50,7 @@ func (w *Watcher) newDepError(action string, dep *dependency, err error) error {
if dErr, ok := err.(*depError); ok { //nolint:errorlint
return dErr
}
return w.newWatcherError(&depError{action: action, dep: dep, err: convertError(err)})
return &depError{action: action, dep: dep, err: convertError(err)}
}
func convertError(err error) error {

View File

@@ -0,0 +1,76 @@
package idlewatcher
import (
"fmt"
"io"
"time"
"github.com/bytedance/sonic"
)
type WakeEvent struct {
Type string `json:"type"`
Message string `json:"message"`
Timestamp time.Time `json:"timestamp"`
Error string `json:"error,omitempty"`
}
type WakeEventType string
const (
WakeEventStarting WakeEventType = "starting"
WakeEventWakingDep WakeEventType = "waking_dep"
WakeEventDepReady WakeEventType = "dep_ready"
WakeEventContainerWoke WakeEventType = "container_woke"
WakeEventWaitingReady WakeEventType = "waiting_ready"
WakeEventReady WakeEventType = "ready"
WakeEventError WakeEventType = "error"
)
func (w *Watcher) newWakeEvent(eventType WakeEventType, message string, err error) *WakeEvent {
event := &WakeEvent{
Type: string(eventType),
Message: message,
Timestamp: time.Now(),
}
if err != nil {
event.Error = err.Error()
}
return event
}
func (e *WakeEvent) WriteSSE(w io.Writer) error {
data, err := sonic.Marshal(e)
if err != nil {
return err
}
_, err = fmt.Fprintf(w, "data: %s\n\n", data)
return err
}
func (w *Watcher) clearEventHistory() {
w.eventHistoryMu.Lock()
w.eventHistory = w.eventHistory[:0]
w.eventHistoryMu.Unlock()
}
func (w *Watcher) sendEvent(eventType WakeEventType, message string, err error) {
// NOTE: events will be cleared on stop/pause
event := w.newWakeEvent(eventType, message, err)
w.l.Debug().Str("event", string(eventType)).Str("message", message).Err(err).Msg("sending event")
// Store event in history
w.eventHistoryMu.Lock()
w.eventHistory = append(w.eventHistory, *event)
w.eventHistoryMu.Unlock()
// Broadcast to current subscribers
for ch := range w.eventChs.Range {
select {
case ch <- event:
default:
// channel full, drop event
}
}
}

View File

@@ -2,13 +2,15 @@ package idlewatcher
import (
"context"
"errors"
"fmt"
"net/http"
"strconv"
"github.com/yusing/godoxy/internal/homepage"
idlewatcher "github.com/yusing/godoxy/internal/idlewatcher/types"
gperr "github.com/yusing/goutils/errs"
httputils "github.com/yusing/goutils/http"
"github.com/yusing/goutils/http/httpheaders"
_ "unsafe"
)
@@ -43,16 +45,58 @@ func (w *Watcher) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
}
}
func isFaviconPath(path string) bool {
return path == "/favicon.ico"
}
func (w *Watcher) handleWakeEventsSSE(rw http.ResponseWriter, r *http.Request) {
// Create a dedicated channel for this SSE connection and register it
eventCh := make(chan *WakeEvent, 10)
w.eventChs.Store(eventCh, struct{}{})
// Clean up when done
defer func() {
w.eventChs.Delete(eventCh)
close(eventCh)
}()
func (w *Watcher) redirectToStartEndpoint(rw http.ResponseWriter, r *http.Request) {
uri := "/"
if w.cfg.StartEndpoint != "" {
uri = w.cfg.StartEndpoint
// Set SSE headers
rw.Header().Set("Content-Type", "text/event-stream")
rw.Header().Set("Cache-Control", "no-cache")
rw.Header().Set("Connection", "keep-alive")
rw.Header().Set("Access-Control-Allow-Origin", "*")
rw.Header().Set("Access-Control-Allow-Headers", "Cache-Control")
controller := http.NewResponseController(rw)
ctx := r.Context()
// Send historical events first
w.eventHistoryMu.RLock()
historicalEvents := make([]WakeEvent, len(w.eventHistory))
copy(historicalEvents, w.eventHistory)
w.eventHistoryMu.RUnlock()
for _, event := range historicalEvents {
select {
case <-ctx.Done():
return
default:
err := errors.Join(event.WriteSSE(rw), controller.Flush())
if err != nil {
gperr.LogError("Failed to write SSE event", err, &w.l)
return
}
}
}
// Listen for new events and send them to client
for {
select {
case event := <-eventCh:
err := errors.Join(event.WriteSSE(rw), controller.Flush())
if err != nil {
gperr.LogError("Failed to write SSE event", err, &w.l)
return
}
case <-ctx.Done():
return
}
}
http.Redirect(rw, r, uri, http.StatusTemporaryRedirect)
}
func (w *Watcher) getFavIcon(ctx context.Context) (result homepage.FetchResult, err error) {
@@ -74,26 +118,43 @@ func (w *Watcher) getFavIcon(ctx context.Context) (result homepage.FetchResult,
return result, err
}
func serveStaticContent(rw http.ResponseWriter, status int, contentType string, content []byte) {
rw.Header().Set("Content-Type", contentType)
rw.Header().Set("Content-Length", strconv.Itoa(len(content)))
rw.WriteHeader(status)
rw.Write(content)
}
func (w *Watcher) wakeFromHTTP(rw http.ResponseWriter, r *http.Request) (shouldNext bool) {
w.resetIdleTimer()
// pass through if container is already ready
if w.ready() {
return true
}
// handle favicon request
if isFaviconPath(r.URL.Path) {
// handle static files
switch r.URL.Path {
case idlewatcher.FavIconPath:
result, err := w.getFavIcon(r.Context())
if err != nil {
rw.WriteHeader(result.StatusCode)
fmt.Fprint(rw, err)
return false
}
rw.Header().Set("Content-Type", result.ContentType())
rw.WriteHeader(result.StatusCode)
rw.Write(result.Icon)
serveStaticContent(rw, result.StatusCode, result.ContentType(), result.Icon)
return false
case idlewatcher.LoadingPageCSSPath:
serveStaticContent(rw, http.StatusOK, "text/css", cssBytes)
return false
case idlewatcher.LoadingPageJSPath:
serveStaticContent(rw, http.StatusOK, "application/javascript", jsBytes)
return false
case idlewatcher.WakeEventsPath:
w.handleWakeEventsSSE(rw, r)
return false
}
// Allow request to proceed if the container is already ready.
// This check occurs after serving static files because a container can become ready quickly;
// otherwise, requests for assets may get a 404, leaving the user stuck on the loading screen.
if w.ready() {
return true
}
// Check if start endpoint is configured and request path matches
@@ -105,54 +166,26 @@ func (w *Watcher) wakeFromHTTP(rw http.ResponseWriter, r *http.Request) (shouldN
accept := httputils.GetAccept(r.Header)
acceptHTML := (r.Method == http.MethodGet && accept.AcceptHTML() || r.RequestURI == "/" && accept.IsEmpty())
isCheckRedirect := r.Header.Get(httpheaders.HeaderGoDoxyCheckRedirect) != ""
if !isCheckRedirect && acceptHTML {
// Send a loading response to the client
body := w.makeLoadingPageBody()
rw.Header().Set("Content-Type", "text/html; charset=utf-8")
rw.Header().Set("Content-Length", strconv.Itoa(len(body)))
rw.Header().Set("Cache-Control", "no-cache")
rw.Header().Add("Cache-Control", "no-store")
rw.Header().Add("Cache-Control", "must-revalidate")
rw.Header().Add("Connection", "close")
if _, err := rw.Write(body); err != nil {
err := w.Wake(r.Context())
if err != nil {
gperr.LogError("Failed to wake container", err, &w.l)
if !acceptHTML {
http.Error(rw, "Failed to wake container", http.StatusInternalServerError)
return false
}
}
if !acceptHTML {
serveStaticContent(rw, http.StatusOK, "text/plain", []byte("Container woken"))
return false
}
ctx := r.Context()
if w.canceled(ctx) {
w.redirectToStartEndpoint(rw, r)
return false
}
w.l.Trace().Msg("signal received")
err := w.Wake(ctx)
if err != nil {
http.Error(rw, "Internal Server Error", http.StatusInternalServerError)
httputils.LogError(r).Msg(fmt.Sprintf("failed to wake: %v", err))
return false
}
// Wait for route to be started
if !w.waitStarted(ctx) {
return false
}
// Wait for container to become ready
if !w.waitForReady(ctx) {
if w.canceled(ctx) {
w.redirectToStartEndpoint(rw, r)
}
return false
}
if isCheckRedirect {
w.l.Debug().Stringer("url", w.hc.URL()).Msg("container is ready, redirecting")
rw.WriteHeader(http.StatusOK)
return false
}
w.l.Debug().Stringer("url", w.hc.URL()).Msg("container is ready, passing through")
return true
// Send a loading response to the client
rw.Header().Set("Content-Type", "text/html; charset=utf-8")
rw.Header().Set("Cache-Control", "no-cache")
rw.Header().Add("Cache-Control", "no-store")
rw.Header().Add("Cache-Control", "must-revalidate")
rw.Header().Add("Connection", "close")
_ = w.writeLoadingPage(rw)
return false
}

View File

@@ -154,9 +154,12 @@ func (w *Watcher) checkUpdateState() (ready bool, err error) {
// log every 3 seconds
const everyN = int(3 * time.Second / idleWakerCheckInterval)
if newHealthTries%everyN == 0 {
url := w.hc.URL()
w.l.Debug().
Int("health_tries", newHealthTries).
Dur("elapsed", time.Since(state.startedAt)).
Str("url", url.String()).
Str("detail", res.Detail).
Msg("health check failed, still starting")
}

View File

@@ -0,0 +1,115 @@
let ready = false;
window.onload = async function () {
const consoleEl = document.getElementById("console");
const loadingDotsEl = document.getElementById("loading-dots");
if (!consoleEl || !loadingDotsEl) {
console.error("Required DOM elements not found");
return;
}
function formatTimestamp(timestamp) {
const date = new Date(timestamp);
return date.toLocaleTimeString("en-US", {
hour12: false,
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
fractionalSecondDigits: 3,
});
}
function addConsoleLine(type, message, timestamp) {
const line = document.createElement("div");
line.className = `console-line ${type}`;
const timestampEl = document.createElement("span");
timestampEl.className = "console-timestamp";
timestampEl.textContent = formatTimestamp(timestamp);
const messageEl = document.createElement("span");
messageEl.className = "console-message";
messageEl.textContent = message;
line.appendChild(timestampEl);
line.appendChild(messageEl);
consoleEl.appendChild(line);
consoleEl.scrollTop = consoleEl.scrollHeight;
}
if (typeof wakeEventsPath === "undefined" || !wakeEventsPath) {
addConsoleLine(
"error",
"Configuration error: wakeEventsPath not defined",
new Date().toISOString()
);
loadingDotsEl.style.display = "none";
return;
}
if (typeof EventSource === "undefined") {
addConsoleLine(
"error",
"Browser does not support Server-Sent Events",
new Date().toISOString()
);
loadingDotsEl.style.display = "none";
return;
}
// Connect to SSE endpoint
const eventSource = new EventSource(wakeEventsPath);
eventSource.onmessage = function (event) {
let data;
try {
data = JSON.parse(event.data);
} catch (error) {
addConsoleLine(
"error",
"Invalid event data: " + event.data,
new Date().toISOString()
);
return;
}
if (data.type === "ready") {
ready = true;
// Container is ready, hide loading dots and refresh
loadingDotsEl.style.display = "none";
addConsoleLine(
data.type,
"Container is ready, refreshing...",
data.timestamp
);
setTimeout(() => {
window.location.reload();
}, 200);
} else if (data.type === "error") {
// Show error message and hide loading dots
const errorMessage = data.error || data.message;
addConsoleLine(data.type, errorMessage, data.timestamp);
loadingDotsEl.style.display = "none";
eventSource.close();
} else {
// Show other message types
addConsoleLine(data.type, data.message, data.timestamp);
}
};
eventSource.onerror = function (event) {
if (ready) {
// event will be closed by the server
return;
}
addConsoleLine(
"error",
"Connection lost. Please refresh the page.",
new Date().toISOString()
);
loadingDotsEl.style.display = "none";
eventSource.close();
};
};

View File

@@ -1,138 +1,32 @@
<!doctype html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{.Title}}</title>
<style>
/* size variables */
:root {
--dot-size: 12px;
--logo-size: 100px;
}
/* Global Styles */
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family:
"Inter",
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
Roboto,
Oxygen,
Ubuntu,
Cantarell,
"Open Sans",
"Helvetica Neue",
sans-serif;
font-size: 16px;
line-height: 1.5;
color: #f8f9fa;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
gap: 32px;
background: linear-gradient(135deg, #121212 0%, #1e1e1e 100%);
}
/* Container */
.container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px;
border-radius: 16px;
background-color: rgba(30, 30, 30, 0.6);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(8px);
max-width: 90%;
transition: all 0.3s ease;
}
/* Spinner Styles */
.loading-dots {
display: flex;
justify-content: center;
align-items: center;
gap: 8px;
padding-top: 20px;
padding-bottom: 6px;
}
.dot {
width: var(--dot-size);
height: var(--dot-size);
background-color: #66d9ef;
border-radius: 50%;
animation: bounce 1.3s infinite ease-in-out;
}
.dot:nth-child(1) {
animation-delay: -0.32s;
}
.dot:nth-child(2) {
animation-delay: -0.16s;
}
@keyframes bounce {
0%,
80%,
100% {
transform: translateY(0);
}
40% {
transform: translateY(-10px);
}
}
/* Message Styles */
.message {
font-size: 20px;
font-weight: 500;
text-align: center;
color: #f8f9fa;
max-width: 500px;
letter-spacing: 0.3px;
white-space: nowrap;
}
/* Logo */
.logo {
width: var(--logo-size);
height: var(--logo-size);
}
</style>
<link rel="stylesheet" href="{{.LoadingPageCSSPath}}" />
<link rel="icon" href="{{.FavIconPath}}" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;500;600&display=swap"
rel="stylesheet"
/>
</head>
<body>
<script>
const wakeEventsPath = "{{.WakeEventsPath}}";
</script>
<script src="{{.LoadingPageJSPath}}" defer></script>
<div class="container">
<!-- icon handled by waker_http -->
<img class="logo" src="/favicon.ico" />
<img class="logo" src="{{.FavIconPath}}" />
<div id="loading-dots" class="loading-dots">
<div class="dot"></div>
<div class="dot"></div>
<div class="dot"></div>
</div>
<div id="message" class="message">{{.Message}}</div>
<div id="console" class="console"></div>
</div>
<script>
window.onload = async function () {
let resp = await fetch(window.location.href, {
headers: {
"{{.CheckRedirectHeader}}": "1",
},
});
if (resp.ok) {
window.location.href = resp.url;
} else {
document.getElementById("message").innerText = await resp.text();
document.getElementById("loading-dots").remove();
}
};
</script>
</body>
</html>

View File

@@ -0,0 +1,194 @@
/* size variables */
:root {
--dot-size: 12px;
--logo-size: 100px;
}
/* Global Styles */
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen,
Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
font-size: 16px;
line-height: 1.5;
color: #f8f9fa;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
background: linear-gradient(135deg, #121212 0%, #1e1e1e 100%);
}
/* Container */
.container {
display: flex;
flex-direction: column;
gap: 32px;
align-items: center;
justify-content: center;
padding: 32px;
border-radius: 16px;
background-color: rgba(30, 30, 30, 0.9);
box-shadow: 0 16px 64px rgba(0, 0, 0, 0.4), 0 4px 16px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(12px);
max-width: 90%;
transition: all 0.3s ease;
min-width: 600px;
min-height: 400px;
}
@media (max-width: 768px) {
.container {
min-width: auto;
max-width: 90%;
}
}
/* Spinner Styles */
.loading-dots {
display: flex;
justify-content: center;
align-items: center;
gap: 8px;
}
.dot {
width: var(--dot-size);
height: var(--dot-size);
background-color: #66d9ef;
border-radius: 50%;
animation: bounce 1.3s infinite ease-in-out;
}
.dot:nth-child(1) {
animation-delay: -0.32s;
}
.dot:nth-child(2) {
animation-delay: -0.16s;
}
@keyframes bounce {
0%,
80%,
100% {
transform: translateY(0);
}
40% {
transform: translateY(-10px);
}
}
/* Message Styles */
.message {
font-size: 18px;
font-weight: 500;
text-align: left;
color: #f8f9fa;
max-width: 100%;
letter-spacing: 0.3px;
}
/* Console Styles */
.console {
font-family: "Fira Code", "Consolas", "Monaco", "Courier New", monospace;
font-size: 14px;
background-color: #1a1a1a;
border: 1px solid #333;
border-radius: 8px;
padding: 16px;
color: #e0e0e0;
overflow-y: auto;
max-height: 300px;
min-height: 200px;
width: 100%;
box-shadow: inset 0 2px 8px rgba(0, 0, 0, 0.3);
}
.console-line {
display: flex;
align-items: flex-start;
margin-bottom: 4px;
padding: 2px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.console-line:last-child {
border-bottom: none;
margin-bottom: 0;
}
.console-timestamp {
color: #66d9ef;
margin-right: 12px;
font-weight: 500;
flex-shrink: 0;
min-width: 80px;
}
.console-message {
flex: 1;
word-break: break-word;
white-space: pre-wrap;
}
.console-line.starting .console-message {
color: #f9f871;
}
.console-line.waking_dep .console-message {
color: #66d9ef;
}
.console-line.dep_ready .console-message {
color: #a6e22e;
}
.console-line.container_woke .console-message {
color: #a6e22e;
}
.console-line.waiting_ready .console-message {
color: #fd971f;
}
.console-line.ready .console-message {
color: #a6e22e;
font-weight: bold;
}
.console-line.error .console-message {
color: #f92672;
font-weight: bold;
}
/* Loading dots in console */
.console-loading {
display: flex;
align-items: center;
gap: 4px;
margin-left: 8px;
}
.console-loading-dot {
width: 6px;
height: 6px;
background-color: #66d9ef;
border-radius: 50%;
animation: bounce 1.3s infinite ease-in-out;
}
.console-loading-dot:nth-child(1) {
animation-delay: -0.32s;
}
.console-loading-dot:nth-child(2) {
animation-delay: -0.16s;
}
/* Logo */
.logo {
width: var(--logo-size);
height: var(--logo-size);
}

View File

@@ -1,35 +1,49 @@
package idlewatcher
import (
"bytes"
_ "embed"
"text/template"
"html/template"
"net/http"
"github.com/yusing/goutils/http/httpheaders"
idlewatcher "github.com/yusing/godoxy/internal/idlewatcher/types"
)
type templateData struct {
CheckRedirectHeader string
Title string
Message string
Title string
Message string
FavIconPath string
LoadingPageCSSPath string
LoadingPageJSPath string
WakeEventsPath string
}
//go:embed html/loading_page.html
var loadingPage []byte
var loadingPageTmpl = template.Must(template.New("loading_page").Parse(string(loadingPage)))
func (w *Watcher) makeLoadingPageBody() []byte {
//go:embed html/style.css
var cssBytes []byte
//go:embed html/loading.js
var jsBytes []byte
func (w *Watcher) writeLoadingPage(rw http.ResponseWriter) error {
msg := w.cfg.ContainerName() + " is starting..."
data := new(templateData)
data.CheckRedirectHeader = httpheaders.HeaderGoDoxyCheckRedirect
data.Title = w.cfg.ContainerName()
data.Message = msg
data.FavIconPath = idlewatcher.FavIconPath
data.LoadingPageCSSPath = idlewatcher.LoadingPageCSSPath
data.LoadingPageJSPath = idlewatcher.LoadingPageJSPath
data.WakeEventsPath = idlewatcher.WakeEventsPath
buf := bytes.NewBuffer(make([]byte, len(loadingPage)+len(data.Title)+len(data.Message)+len(httpheaders.HeaderGoDoxyCheckRedirect)))
err := loadingPageTmpl.Execute(buf, data)
if err != nil { // should never happen in production
panic(err)
}
return buf.Bytes()
rw.Header().Set("Content-Type", "text/html; charset=utf-8")
rw.Header().Set("Cache-Control", "no-cache")
rw.Header().Add("Cache-Control", "no-store")
rw.Header().Add("Cache-Control", "must-revalidate")
rw.Header().Add("Connection", "close")
err := loadingPageTmpl.Execute(rw, data)
return err
}

View File

@@ -60,11 +60,11 @@ func (p *DockerProvider) ContainerStatus(ctx context.Context) (idlewatcher.Conta
return idlewatcher.ContainerStatusError, err
}
switch status.State.Status {
case "running":
case container.StateRunning:
return idlewatcher.ContainerStatusRunning, nil
case "exited", "dead", "restarting":
case container.StateExited, container.StateDead, container.StateRestarting:
return idlewatcher.ContainerStatusStopped, nil
case "paused":
case container.StatePaused:
return idlewatcher.ContainerStatusPaused, nil
}
return idlewatcher.ContainerStatusError, idlewatcher.ErrUnexpectedContainerStatus.Subject(status.State.Status)

View File

@@ -24,6 +24,8 @@ func (w *Watcher) setReady() {
status: idlewatcher.ContainerStatusRunning,
ready: true,
})
// Send ready event via SSE
w.sendEvent(WakeEventReady, w.cfg.ContainerName()+" is ready!", nil)
// Notify waiting handlers that container is ready
select {
case w.readyNotifyCh <- struct{}{}:
@@ -42,6 +44,7 @@ func (w *Watcher) setStarting() {
}
func (w *Watcher) setNapping(status idlewatcher.ContainerStatus) {
w.clearEventHistory() // Clear events on stop/pause
w.state.Store(&containerState{
status: status,
ready: false,
@@ -51,6 +54,7 @@ func (w *Watcher) setNapping(status idlewatcher.ContainerStatus) {
}
func (w *Watcher) setError(err error) {
w.sendEvent(WakeEventError, "Container error", err)
w.state.Store(&containerState{
status: idlewatcher.ContainerStatusError,
ready: false,
@@ -76,3 +80,12 @@ func (w *Watcher) waitForReady(ctx context.Context) bool {
return false
}
}
func (w *Watcher) waitStarted(reqCtx context.Context) bool {
select {
case <-reqCtx.Done():
return false
case <-w.route.Started():
return true
}
}

View File

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

View File

@@ -1,6 +1,7 @@
package idlewatcher
import (
"context"
"net/http"
nettypes "github.com/yusing/godoxy/internal/net/types"
@@ -11,5 +12,5 @@ type Waker interface {
types.HealthMonitor
http.Handler
nettypes.Stream
Wake() error
Wake(ctx context.Context) error
}

View File

@@ -8,6 +8,7 @@ import (
"sync"
"time"
"github.com/puzpuzpuz/xsync/v4"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/yusing/ds/ordered"
@@ -23,6 +24,7 @@ import (
"github.com/yusing/godoxy/internal/watcher/health/monitor"
gperr "github.com/yusing/goutils/errs"
"github.com/yusing/goutils/http/reverseproxy"
strutils "github.com/yusing/goutils/strings"
"github.com/yusing/goutils/synk"
"github.com/yusing/goutils/task"
"golang.org/x/sync/errgroup"
@@ -53,7 +55,7 @@ type (
cfg *types.IdlewatcherConfig
provider idlewatcher.Provider
provider synk.Value[idlewatcher.Provider]
state synk.Value[*containerState]
lastReset synk.Value[time.Time]
@@ -63,6 +65,12 @@ type (
readyNotifyCh chan struct{} // notifies when container becomes ready
task *task.Task
// SSE event broadcasting, HTTP routes only
eventChs *xsync.Map[chan *WakeEvent, struct{}]
eventHistory []WakeEvent // Global event history buffer
eventHistoryMu sync.RWMutex // Mutex for event history
// FIXME: missing dependencies
dependsOn []*dependency
}
@@ -77,6 +85,8 @@ type (
const ContextKey = "idlewatcher.watcher"
var _ idlewatcher.Waker = (*Watcher)(nil)
var (
watcherMap = make(map[string]*Watcher)
watcherMapMu sync.RWMutex
@@ -115,11 +125,16 @@ func NewWatcher(parent task.Parent, r types.Route, cfg *types.IdlewatcherConfig)
}
cfg = w.cfg
w.resetIdleTimer()
// Update health monitor URL with current route info on reload
if targetURL := r.TargetURL(); targetURL != nil {
w.hc.UpdateURL(&targetURL.URL)
}
} else {
w = &Watcher{
idleTicker: time.NewTicker(cfg.IdleTimeout),
healthTicker: time.NewTicker(idleWakerCheckInterval),
readyNotifyCh: make(chan struct{}, 1), // buffered to avoid blocking
eventChs: xsync.NewMap[chan *WakeEvent, struct{}](),
cfg: cfg,
routeHelper: routeHelper{
hc: monitor.NewMonitor(r),
@@ -223,8 +238,8 @@ func NewWatcher(parent task.Parent, r types.Route, cfg *types.IdlewatcherConfig)
})
}
if w.provider != nil { // it's a reload, close the old provider
w.provider.Close()
if pOld := w.provider.Load(); pOld != nil { // it's a reload, close the old provider
pOld.Close()
}
if depErrors.HasError() {
@@ -247,19 +262,24 @@ func NewWatcher(parent task.Parent, r types.Route, cfg *types.IdlewatcherConfig)
p, err = provider.NewProxmoxProvider(cfg.Proxmox.Node, cfg.Proxmox.VMID)
kind = "proxmox"
}
targetURL := r.TargetURL()
if targetURL == nil {
return nil, errors.New("target URL is not set")
}
w.l = log.With().
Str("kind", kind).
Str("container", cfg.ContainerName()).
Str("url", targetURL.String()).
Logger()
if cfg.IdleTimeout != neverTick {
w.l = w.l.With().Stringer("idle_timeout", cfg.IdleTimeout).Logger()
w.l = w.l.With().Str("idle_timeout", strutils.FormatDuration(cfg.IdleTimeout)).Logger()
}
if err != nil {
return nil, err
}
w.provider = p
w.provider.Store(p)
switch r := r.(type) {
case types.ReverseProxyRoute:
@@ -267,22 +287,22 @@ func NewWatcher(parent task.Parent, r types.Route, cfg *types.IdlewatcherConfig)
case types.StreamRoute:
w.stream = r.Stream()
default:
w.provider.Close()
p.Close()
return nil, w.newWatcherError(gperr.Errorf("unexpected route type: %T", r))
}
w.route = r
ctx, cancel := context.WithTimeout(parent.Context(), reqTimeout)
defer cancel()
status, err := w.provider.ContainerStatus(ctx)
status, err := p.ContainerStatus(ctx)
if err != nil {
w.provider.Close()
p.Close()
return nil, w.newWatcherError(err)
}
w.state.Store(&containerState{status: status})
// when more providers are added, we need to add a new case here.
switch p := w.provider.(type) { //nolint:gocritic
switch p := p.(type) { //nolint:gocritic
case *provider.ProxmoxProvider:
shutdownTimeout := max(time.Second, cfg.StopTimeout-idleWakerCheckTimeout)
err = p.LXCSetShutdownTimeout(ctx, cfg.Proxmox.VMID, shutdownTimeout)
@@ -302,16 +322,17 @@ func NewWatcher(parent task.Parent, r types.Route, cfg *types.IdlewatcherConfig)
delete(watcherMap, key)
watcherMapMu.Unlock()
if errors.Is(cause, causeContainerDestroy) || errors.Is(cause, task.ErrProgramExiting) || errors.Is(cause, config.ErrConfigChanged) {
if errors.Is(cause, causeReload) {
// no log
} else if errors.Is(cause, causeContainerDestroy) || errors.Is(cause, task.ErrProgramExiting) || errors.Is(cause, config.ErrConfigChanged) {
w.l.Info().Msg("idlewatcher stopped")
} else if !errors.Is(cause, causeReload) {
} else {
gperr.LogError("idlewatcher stopped unexpectedly", cause, &w.l)
}
w.idleTicker.Stop()
w.healthTicker.Stop()
close(w.readyNotifyCh)
w.provider.Close()
w.task.Finish(cause)
}()
}
@@ -353,13 +374,27 @@ func (w *Watcher) Key() string {
func (w *Watcher) Wake(ctx context.Context) error {
// wake dependencies first.
if err := w.wakeDependencies(ctx); err != nil {
w.sendEvent(WakeEventError, "Failed to wake dependencies", err)
return w.newWatcherError(err)
}
if w.wakeInProgress() {
w.l.Debug().Msg("already starting, ignoring duplicate start event")
return nil
}
// wake itself.
// use container name instead of Key() here as the container id will change on restart (docker).
_, err, _ := singleFlight.Do(w.cfg.ContainerName(), func() (any, error) {
return nil, w.wakeIfStopped(ctx)
containerName := w.cfg.ContainerName()
_, err, _ := singleFlight.Do(containerName, func() (any, error) {
err := w.wakeIfStopped(ctx)
if err != nil {
w.sendEvent(WakeEventError, "Failed to start "+containerName, err)
} else {
w.sendEvent(WakeEventContainerWoke, containerName+" started successfully", nil)
w.sendEvent(WakeEventWaitingReady, "Waiting for "+containerName+" to be ready...", nil)
}
return nil, err
})
if err != nil {
return w.newWatcherError(err)
@@ -368,6 +403,14 @@ func (w *Watcher) Wake(ctx context.Context) error {
return nil
}
func (w *Watcher) wakeInProgress() bool {
state := w.state.Load()
if state == nil {
return false
}
return !state.startedAt.IsZero()
}
func (w *Watcher) wakeDependencies(ctx context.Context) error {
if len(w.dependsOn) == 0 {
return nil
@@ -375,10 +418,16 @@ func (w *Watcher) wakeDependencies(ctx context.Context) error {
errs := errgroup.Group{}
for _, dep := range w.dependsOn {
if dep.wakeInProgress() {
w.l.Debug().Str("dep", dep.cfg.ContainerName()).Msg("dependency already starting, ignoring duplicate start event")
continue
}
errs.Go(func() error {
w.sendEvent(WakeEventWakingDep, "Waking dependency: "+dep.cfg.ContainerName(), nil)
if err := dep.Wake(ctx); err != nil {
return err
}
w.sendEvent(WakeEventDepReady, "Dependency woke: "+dep.cfg.ContainerName(), nil)
if dep.waitHealthy {
// initial health check before starting the ticker
if h, err := dep.hc.CheckHealth(); err != nil {
@@ -417,13 +466,17 @@ func (w *Watcher) wakeIfStopped(ctx context.Context) error {
ctx, cancel := context.WithTimeout(ctx, w.cfg.WakeTimeout)
defer cancel()
p := w.provider.Load()
if p == nil {
return gperr.Errorf("provider not set")
}
switch state.status {
case idlewatcher.ContainerStatusStopped:
w.l.Info().Msg("starting container")
return w.provider.ContainerStart(ctx)
w.sendEvent(WakeEventStarting, w.cfg.ContainerName()+" is starting...", nil)
return p.ContainerStart(ctx)
case idlewatcher.ContainerStatusPaused:
w.l.Info().Msg("unpausing container")
return w.provider.ContainerUnpause(ctx)
w.sendEvent(WakeEventStarting, w.cfg.ContainerName()+" is unpausing...", nil)
return p.ContainerUnpause(ctx)
default:
return gperr.Errorf("unexpected container status: %s", state.status)
}
@@ -458,13 +511,17 @@ func (w *Watcher) stopByMethod() error {
// stop itself first.
var err error
p := w.provider.Load()
if p == nil {
return gperr.New("provider not set")
}
switch cfg.StopMethod {
case types.ContainerStopMethodPause:
err = w.provider.ContainerPause(ctx)
err = p.ContainerPause(ctx)
case types.ContainerStopMethodStop:
err = w.provider.ContainerStop(ctx, cfg.StopSignal, int(math.Ceil(cfg.StopTimeout.Seconds())))
err = p.ContainerStop(ctx, cfg.StopSignal, int(math.Ceil(cfg.StopTimeout.Seconds())))
case types.ContainerStopMethodKill:
err = w.provider.ContainerKill(ctx, cfg.StopSignal)
err = p.ContainerKill(ctx, cfg.StopSignal)
default:
err = w.newWatcherError(gperr.Errorf("unexpected stop method: %q", cfg.StopMethod))
}
@@ -506,7 +563,12 @@ func (w *Watcher) expires() time.Time {
// it exits only if the context is canceled, the container is destroyed,
// errors occurred on docker client, or route provider died (mainly caused by config reload).
func (w *Watcher) watchUntilDestroy() (returnCause error) {
eventCh, errCh := w.provider.Watch(w.Task().Context())
p := w.provider.Load()
if p == nil {
return gperr.Errorf("provider not set")
}
defer p.Close()
eventCh, errCh := p.Watch(w.Task().Context())
for {
select {

View File

@@ -2,7 +2,6 @@ package jsonstore
import (
"encoding/json"
"maps"
"os"
"path/filepath"
"reflect"
@@ -114,7 +113,7 @@ func (s *MapStore[VT]) Initialize() {
}
func (s MapStore[VT]) MarshalJSON() ([]byte, error) {
return sonic.Marshal(maps.Collect(s.Range))
return sonic.Marshal(xsync.ToPlainMap(s.Map))
}
func (s *MapStore[VT]) UnmarshalJSON(data []byte) error {

View File

@@ -7,6 +7,7 @@ import (
"sync/atomic"
"time"
"github.com/puzpuzpuz/xsync/v4"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
maxmind "github.com/yusing/godoxy/internal/maxmind/types"
@@ -19,15 +20,24 @@ import (
)
type (
AccessLogger struct {
AccessLogger interface {
Log(req *http.Request, res *http.Response)
LogError(req *http.Request, err error)
LogACL(info *maxmind.IPInfo, blocked bool)
Config() *Config
Flush()
Close() error
}
accessLogger struct {
task *task.Task
cfg *Config
rawWriter io.Writer
closer io.Closer
supportRotate supportRotate
writer *ioutils.BufferedWriter
writeLock sync.Mutex
writer BufferedWriter
supportRotate SupportRotate
writeLock *sync.Mutex
closed bool
writeCount int64
@@ -41,8 +51,9 @@ type (
ACLFormatter
}
WriterWithName interface {
Writer interface {
io.WriteCloser
ShouldBeBuffered() bool
Name() string // file name or path
}
@@ -52,6 +63,10 @@ type (
Name() string
}
AccessLogRotater interface {
Rotate(result *RotateResult) (rotated bool, err error)
}
RequestFormatter interface {
// AppendRequestLog appends a log line to line with or without a trailing newline
AppendRequestLog(line []byte, req *http.Request, res *http.Response) []byte
@@ -62,6 +77,8 @@ type (
}
)
var writerLocks = xsync.NewMap[string, *sync.Mutex]()
const (
InitialBufferSize = 4 * kilobyte
MaxBufferSize = 8 * megabyte
@@ -76,43 +93,45 @@ const (
errBurst = 5
)
var lineBufPool = synk.GetBytesPoolWithUniqueMemory()
var bytesPool = synk.GetUnsizedBytesPool()
func NewAccessLogger(parent task.Parent, cfg AnyConfig) (*AccessLogger, error) {
io, err := cfg.IO()
func NewAccessLogger(parent task.Parent, cfg AnyConfig) (AccessLogger, error) {
writers, err := cfg.Writers()
if err != nil {
return nil, err
}
return NewAccessLoggerWithIO(parent, io, cfg), nil
return NewMultiAccessLogger(parent, cfg, writers), nil
}
func NewMockAccessLogger(parent task.Parent, cfg *RequestLoggerConfig) *AccessLogger {
return NewAccessLoggerWithIO(parent, NewMockFile(), cfg)
func NewMockAccessLogger(parent task.Parent, cfg *RequestLoggerConfig) AccessLogger {
return NewAccessLoggerWithIO(parent, NewMockFile(true), cfg)
}
func NewAccessLoggerWithIO(parent task.Parent, writer WriterWithName, anyCfg AnyConfig) *AccessLogger {
func NewAccessLoggerWithIO(parent task.Parent, writer Writer, anyCfg AnyConfig) AccessLogger {
cfg := anyCfg.ToConfig()
if cfg.RotateInterval == 0 {
cfg.RotateInterval = defaultRotateInterval
}
l := &AccessLogger{
l := &accessLogger{
task: parent.Subtask("accesslog."+writer.Name(), true),
cfg: cfg,
rawWriter: writer,
bufSize: InitialBufferSize,
errRateLimiter: rate.NewLimiter(rate.Every(errRateLimit), errBurst),
logger: log.With().Str("file", writer.Name()).Logger(),
}
if writer != nil {
l.writeLock, _ = writerLocks.LoadOrStore(writer.Name(), &sync.Mutex{})
if writer.ShouldBeBuffered() {
l.writer = ioutils.NewBufferedWriter(writer, InitialBufferSize)
if supportRotate, ok := writer.(SupportRotate); ok {
l.supportRotate = supportRotate
}
if closer, ok := writer.(io.Closer); ok {
l.closer = closer
}
} else {
l.writer = NewUnbufferedWriter(writer)
}
if supportRotate, ok := writer.(SupportRotate); ok {
l.supportRotate = supportRotate
}
if cfg.req != nil {
@@ -131,17 +150,15 @@ func NewAccessLoggerWithIO(parent task.Parent, writer WriterWithName, anyCfg Any
l.ACLFormatter = ACLLogFormatter{}
}
if l.writer != nil {
go l.start()
} // otherwise stdout only
go l.start()
return l
}
func (l *AccessLogger) Config() *Config {
func (l *accessLogger) Config() *Config {
return l.cfg
}
func (l *AccessLogger) shouldLog(req *http.Request, res *http.Response) bool {
func (l *accessLogger) shouldLog(req *http.Request, res *http.Response) bool {
if !l.cfg.req.Filters.StatusCodes.CheckKeep(req, res) ||
!l.cfg.req.Filters.Method.CheckKeep(req, res) ||
!l.cfg.req.Filters.Headers.CheckKeep(req, res) ||
@@ -151,44 +168,44 @@ func (l *AccessLogger) shouldLog(req *http.Request, res *http.Response) bool {
return true
}
func (l *AccessLogger) Log(req *http.Request, res *http.Response) {
func (l *accessLogger) Log(req *http.Request, res *http.Response) {
if !l.shouldLog(req, res) {
return
}
line := lineBufPool.Get()
defer lineBufPool.Put(line)
line := bytesPool.Get()
line = l.AppendRequestLog(line, req, res)
if line[len(line)-1] != '\n' {
line = append(line, '\n')
}
l.write(line)
bytesPool.Put(line)
}
func (l *AccessLogger) LogError(req *http.Request, err error) {
func (l *accessLogger) LogError(req *http.Request, err error) {
l.Log(req, &http.Response{StatusCode: http.StatusInternalServerError, Status: err.Error()})
}
func (l *AccessLogger) LogACL(info *maxmind.IPInfo, blocked bool) {
line := lineBufPool.Get()
defer lineBufPool.Put(line)
func (l *accessLogger) LogACL(info *maxmind.IPInfo, blocked bool) {
line := bytesPool.Get()
line = l.AppendACLLog(line, info, blocked)
if line[len(line)-1] != '\n' {
line = append(line, '\n')
}
l.write(line)
bytesPool.Put(line)
}
func (l *AccessLogger) ShouldRotate() bool {
func (l *accessLogger) ShouldRotate() bool {
return l.supportRotate != nil && l.cfg.Retention.IsValid()
}
func (l *AccessLogger) Rotate(result *RotateResult) (rotated bool, err error) {
func (l *accessLogger) Rotate(result *RotateResult) (rotated bool, err error) {
if !l.ShouldRotate() {
return false, nil
}
l.writer.Flush()
l.Flush()
l.writeLock.Lock()
defer l.writeLock.Unlock()
@@ -196,7 +213,7 @@ func (l *AccessLogger) Rotate(result *RotateResult) (rotated bool, err error) {
return
}
func (l *AccessLogger) handleErr(err error) {
func (l *accessLogger) handleErr(err error) {
if l.errRateLimiter.Allow() {
gperr.LogError("failed to write access log", err, &l.logger)
} else {
@@ -205,7 +222,7 @@ func (l *AccessLogger) handleErr(err error) {
}
}
func (l *AccessLogger) start() {
func (l *accessLogger) start() {
defer func() {
l.Flush()
l.Close()
@@ -241,52 +258,42 @@ func (l *AccessLogger) start() {
}
}
func (l *AccessLogger) Close() error {
func (l *accessLogger) Close() error {
l.writeLock.Lock()
defer l.writeLock.Unlock()
if l.closed {
return nil
}
if l.closer != nil {
l.closer.Close()
}
l.writer.Release()
l.writer.Flush()
l.closed = true
return nil
return l.writer.Close()
}
func (l *AccessLogger) Flush() {
func (l *accessLogger) Flush() {
l.writeLock.Lock()
defer l.writeLock.Unlock()
if l.closed {
return
}
if err := l.writer.Flush(); err != nil {
l.writer.Flush()
}
func (l *accessLogger) write(data []byte) {
l.writeLock.Lock()
defer l.writeLock.Unlock()
if l.closed {
return
}
n, err := l.writer.Write(data)
if err != nil {
l.handleErr(err)
} else if n < len(data) {
l.handleErr(gperr.Errorf("%w, writing %d bytes, only %d written", io.ErrShortWrite, len(data), n))
}
atomic.AddInt64(&l.writeCount, int64(n))
}
func (l *AccessLogger) write(data []byte) {
if l.writer != nil {
l.writeLock.Lock()
defer l.writeLock.Unlock()
if l.closed {
return
}
n, err := l.writer.Write(data)
if err != nil {
l.handleErr(err)
} else if n < len(data) {
l.handleErr(gperr.Errorf("%w, writing %d bytes, only %d written", io.ErrShortWrite, len(data), n))
}
atomic.AddInt64(&l.writeCount, int64(n))
}
if l.cfg.Stdout {
log.Logger.Write(data) // write to stdout immediately
}
}
func (l *AccessLogger) adjustBuffer() {
func (l *accessLogger) adjustBuffer() {
wps := int(atomic.SwapInt64(&l.writeCount, 0)) / int(bufferAdjustInterval.Seconds())
origBufSize := l.bufSize
newBufSize := origBufSize

View File

@@ -58,7 +58,7 @@ func fmtLog(cfg *RequestLoggerConfig) (ts string, line string) {
t := time.Now()
logger := NewMockAccessLogger(testTask, cfg)
utils.MockTimeNow(t)
buf = logger.AppendRequestLog(buf, req, resp)
buf = logger.(RequestFormatter).AppendRequestLog(buf, req, resp)
return t.Format(LogTimeFormat), string(buf)
}

View File

@@ -61,7 +61,7 @@ func TestBackScanner(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Setup mock file
mockFile := NewMockFile()
mockFile := NewMockFile(false)
_, err := mockFile.Write([]byte(tt.input))
if err != nil {
t.Fatalf("failed to write to mock file: %v", err)
@@ -103,7 +103,7 @@ func TestBackScannerWithVaryingChunkSizes(t *testing.T) {
for _, chunkSize := range chunkSizes {
t.Run(fmt.Sprintf("chunk_size_%d", chunkSize), func(t *testing.T) {
mockFile := NewMockFile()
mockFile := NewMockFile(false)
_, err := mockFile.Write([]byte(input))
if err != nil {
t.Fatalf("failed to write to mock file: %v", err)
@@ -149,7 +149,7 @@ func logEntry() []byte {
res := httptest.NewRecorder()
// server the request
srv.Config.Handler.ServeHTTP(res, req)
b := accesslog.AppendRequestLog(nil, req, res.Result())
b := accesslog.(RequestFormatter).AppendRequestLog(nil, req, res.Result())
if b[len(b)-1] != '\n' {
b = append(b, '\n')
}
@@ -197,7 +197,7 @@ func TestReset(t *testing.T) {
// 100000 log entries.
func BenchmarkBackScanner(b *testing.B) {
mockFile := NewMockFile()
mockFile := NewMockFile(false)
line := logEntry()
for range 100000 {
_, _ = mockFile.Write(line)

View File

@@ -32,7 +32,7 @@ type (
}
AnyConfig interface {
ToConfig() *Config
IO() (WriterWithName, error)
Writers() ([]Writer, error)
}
Format string
@@ -65,17 +65,20 @@ func (cfg *ConfigBase) Validate() gperr.Error {
return nil
}
// IO returns a writer for the config.
// If only stdout is enabled, it returns nil, nil.
func (cfg *ConfigBase) IO() (WriterWithName, error) {
// Writers returns a list of writers for the config.
func (cfg *ConfigBase) Writers() ([]Writer, error) {
writers := make([]Writer, 0, 2)
if cfg.Path != "" {
io, err := newFileIO(cfg.Path)
io, err := NewFileIO(cfg.Path)
if err != nil {
return nil, err
}
return io, nil
writers = append(writers, io)
}
return nil, nil
if cfg.Stdout {
writers = append(writers, NewStdout())
}
return writers, nil
}
func (cfg *ACLLoggerConfig) ToConfig() *Config {

View File

@@ -26,12 +26,22 @@ var (
openedFilesMu sync.Mutex
)
func newFileIO(path string) (WriterWithName, error) {
// NewFileIO creates a new file writer with cleaned path.
//
// If the file is already opened, it will be returned.
func NewFileIO(path string) (Writer, error) {
openedFilesMu.Lock()
defer openedFilesMu.Unlock()
var file *File
path = filepath.Clean(path)
var err error
// make it absolute path, so that we can use it as key of `openedFiles` and shared lock
path, err = filepath.Abs(path)
if err != nil {
return nil, fmt.Errorf("access log path error: %w", err)
}
if opened, ok := openedFiles[path]; ok {
opened.refCount.Add()
return opened, nil
@@ -51,8 +61,13 @@ func newFileIO(path string) (WriterWithName, error) {
return file, nil
}
// Name returns the absolute path of the file.
func (f *File) Name() string {
return f.f.Name()
return f.path
}
func (f *File) ShouldBeBuffered() bool {
return true
}
func (f *File) Write(p []byte) (n int, err error) {

View File

@@ -1,89 +1,96 @@
package accesslog
import (
"fmt"
"math/rand/v2"
"net/http"
"os"
"runtime"
"sync"
"testing"
"github.com/stretchr/testify/assert"
"github.com/yusing/goutils/task"
expect "github.com/yusing/goutils/testing"
)
func TestConcurrentFileLoggersShareSameAccessLogIO(t *testing.T) {
var wg sync.WaitGroup
cfg := DefaultRequestLoggerConfig()
cfg.Path = "test.log"
loggerCount := 10
accessLogIOs := make([]WriterWithName, loggerCount)
loggerCount := runtime.GOMAXPROCS(0)
accessLogIOs := make([]Writer, loggerCount)
// make test log file
file, err := os.Create(cfg.Path)
expect.NoError(t, err)
assert.NoError(t, err)
file.Close()
t.Cleanup(func() {
expect.NoError(t, os.Remove(cfg.Path))
assert.NoError(t, os.Remove(cfg.Path))
})
var wg sync.WaitGroup
for i := range loggerCount {
wg.Add(1)
go func(index int) {
defer wg.Done()
file, err := newFileIO(cfg.Path)
expect.NoError(t, err)
accessLogIOs[index] = file
}(i)
wg.Go(func() {
file, err := NewFileIO(cfg.Path)
assert.NoError(t, err)
accessLogIOs[i] = file
})
}
wg.Wait()
firstIO := accessLogIOs[0]
for _, io := range accessLogIOs {
expect.Equal(t, io, firstIO)
assert.Equal(t, firstIO, io)
}
}
func TestConcurrentAccessLoggerLogAndFlush(t *testing.T) {
file := NewMockFile()
for _, buffered := range []bool{false, true} {
t.Run(fmt.Sprintf("buffered=%t", buffered), func(t *testing.T) {
file := NewMockFile(buffered)
cfg := DefaultRequestLoggerConfig()
parent := task.RootTask("test", false)
cfg := DefaultRequestLoggerConfig()
parent := task.RootTask("test", false)
loggerCount := 5
logCountPerLogger := 10
loggers := make([]*AccessLogger, loggerCount)
loggerCount := runtime.GOMAXPROCS(0)
logCountPerLogger := 10
loggers := make([]AccessLogger, loggerCount)
for i := range loggerCount {
loggers[i] = NewAccessLoggerWithIO(parent, file, cfg)
for i := range loggerCount {
loggers[i] = NewAccessLoggerWithIO(parent, file, cfg)
}
req, _ := http.NewRequest(http.MethodGet, "http://example.com", nil)
resp := &http.Response{StatusCode: http.StatusOK}
var wg sync.WaitGroup
for _, logger := range loggers {
wg.Go(func() {
concurrentLog(logger, req, resp, logCountPerLogger)
})
}
wg.Wait()
for _, logger := range loggers {
logger.Close()
}
expected := loggerCount * logCountPerLogger
actual := file.NumLines()
assert.Equal(t, expected, actual)
})
}
var wg sync.WaitGroup
req, _ := http.NewRequest(http.MethodGet, "http://example.com", nil)
resp := &http.Response{StatusCode: http.StatusOK}
wg.Add(len(loggers))
for _, logger := range loggers {
go func(l *AccessLogger) {
defer wg.Done()
parallelLog(l, req, resp, logCountPerLogger)
l.Flush()
}(logger)
}
wg.Wait()
expected := loggerCount * logCountPerLogger
actual := file.NumLines()
expect.Equal(t, actual, expected)
}
func parallelLog(logger *AccessLogger, req *http.Request, resp *http.Response, n int) {
func concurrentLog(logger AccessLogger, req *http.Request, resp *http.Response, n int) {
var wg sync.WaitGroup
for range n {
wg.Go(func() {
logger.Log(req, resp)
if rand.IntN(2) == 0 {
logger.Flush()
}
})
}
wg.Wait()

View File

@@ -7,26 +7,27 @@ import (
"github.com/spf13/afero"
)
type noLock struct{}
func (noLock) Lock() {}
func (noLock) Unlock() {}
type MockFile struct {
afero.File
noLock
buffered bool
}
var _ SupportRotate = (*MockFile)(nil)
func NewMockFile() *MockFile {
func NewMockFile(buffered bool) *MockFile {
f, _ := afero.TempFile(afero.NewMemMapFs(), "", "")
f.Seek(0, io.SeekEnd)
return &MockFile{
File: f,
File: f,
buffered: buffered,
}
}
func (m *MockFile) ShouldBeBuffered() bool {
return m.buffered
}
func (m *MockFile) Len() int64 {
filesize, _ := m.Seek(0, io.SeekEnd)
_, _ = m.Seek(0, io.SeekStart)
@@ -60,3 +61,7 @@ func (m *MockFile) MustSize() int64 {
size, _ := m.Size()
return size
}
func (m *MockFile) Close() error {
return nil
}

View File

@@ -0,0 +1,63 @@
package accesslog
import (
"net/http"
maxmind "github.com/yusing/godoxy/internal/maxmind/types"
"github.com/yusing/goutils/task"
)
type MultiAccessLogger struct {
accessLoggers []AccessLogger
}
// NewMultiAccessLogger creates a new AccessLogger that writes to multiple writers.
//
// If there is only one writer, it will return a single AccessLogger.
// Otherwise, it will return a MultiAccessLogger that writes to all the writers.
func NewMultiAccessLogger(parent task.Parent, cfg AnyConfig, writers []Writer) AccessLogger {
if len(writers) == 1 {
return NewAccessLoggerWithIO(parent, writers[0], cfg)
}
accessLoggers := make([]AccessLogger, len(writers))
for i, writer := range writers {
accessLoggers[i] = NewAccessLoggerWithIO(parent, writer, cfg)
}
return &MultiAccessLogger{accessLoggers}
}
func (m *MultiAccessLogger) Config() *Config {
return m.accessLoggers[0].Config()
}
func (m *MultiAccessLogger) Log(req *http.Request, res *http.Response) {
for _, accessLogger := range m.accessLoggers {
accessLogger.Log(req, res)
}
}
func (m *MultiAccessLogger) LogError(req *http.Request, err error) {
for _, accessLogger := range m.accessLoggers {
accessLogger.LogError(req, err)
}
}
func (m *MultiAccessLogger) LogACL(info *maxmind.IPInfo, blocked bool) {
for _, accessLogger := range m.accessLoggers {
accessLogger.LogACL(info, blocked)
}
}
func (m *MultiAccessLogger) Flush() {
for _, accessLogger := range m.accessLoggers {
accessLogger.Flush()
}
}
func (m *MultiAccessLogger) Close() error {
for _, accessLogger := range m.accessLoggers {
accessLogger.Close()
}
return nil
}

View File

@@ -0,0 +1,261 @@
package accesslog
import (
"errors"
"net"
"net/http"
"net/url"
"testing"
maxmind "github.com/yusing/godoxy/internal/maxmind/types"
"github.com/yusing/goutils/task"
expect "github.com/yusing/goutils/testing"
)
func TestNewMultiAccessLogger(t *testing.T) {
testTask := task.RootTask("test", false)
cfg := DefaultRequestLoggerConfig()
writers := []Writer{
NewMockFile(true),
NewMockFile(true),
}
logger := NewMultiAccessLogger(testTask, cfg, writers)
expect.NotNil(t, logger)
}
func TestMultiAccessLoggerConfig(t *testing.T) {
testTask := task.RootTask("test", false)
cfg := DefaultRequestLoggerConfig()
cfg.Format = FormatCommon
writers := []Writer{
NewMockFile(true),
NewMockFile(true),
}
logger := NewMultiAccessLogger(testTask, cfg, writers)
retrievedCfg := logger.Config()
expect.Equal(t, retrievedCfg.req.Format, FormatCommon)
}
func TestMultiAccessLoggerLog(t *testing.T) {
testTask := task.RootTask("test", false)
cfg := DefaultRequestLoggerConfig()
cfg.Format = FormatCommon
writer1 := NewMockFile(true)
writer2 := NewMockFile(true)
writers := []Writer{writer1, writer2}
logger := NewMultiAccessLogger(testTask, cfg, writers)
testURL, _ := url.Parse("http://example.com/test")
req := &http.Request{
RemoteAddr: "192.168.1.1",
Method: http.MethodGet,
Proto: "HTTP/1.1",
Host: "example.com",
URL: testURL,
Header: http.Header{
"User-Agent": []string{"test-agent"},
},
}
resp := &http.Response{
StatusCode: http.StatusOK,
ContentLength: 100,
}
logger.Log(req, resp)
logger.Flush()
expect.Equal(t, writer1.NumLines(), 1)
expect.Equal(t, writer2.NumLines(), 1)
}
func TestMultiAccessLoggerLogError(t *testing.T) {
testTask := task.RootTask("test", false)
cfg := DefaultRequestLoggerConfig()
writer1 := NewMockFile(true)
writer2 := NewMockFile(true)
writers := []Writer{writer1, writer2}
logger := NewMultiAccessLogger(testTask, cfg, writers)
testURL, _ := url.Parse("http://example.com/test")
req := &http.Request{
RemoteAddr: "192.168.1.1",
Method: http.MethodGet,
URL: testURL,
}
testErr := errors.New("test error")
logger.LogError(req, testErr)
logger.Flush()
expect.Equal(t, writer1.NumLines(), 1)
expect.Equal(t, writer2.NumLines(), 1)
}
func TestMultiAccessLoggerLogACL(t *testing.T) {
testTask := task.RootTask("test", false)
cfg := DefaultACLLoggerConfig()
cfg.LogAllowed = true
writer1 := NewMockFile(true)
writer2 := NewMockFile(true)
writers := []Writer{writer1, writer2}
logger := NewMultiAccessLogger(testTask, cfg, writers)
info := &maxmind.IPInfo{
IP: net.ParseIP("192.168.1.1"),
Str: "192.168.1.1",
}
logger.LogACL(info, false)
logger.Flush()
expect.Equal(t, writer1.NumLines(), 1)
expect.Equal(t, writer2.NumLines(), 1)
}
func TestMultiAccessLoggerFlush(t *testing.T) {
testTask := task.RootTask("test", false)
cfg := DefaultRequestLoggerConfig()
writer1 := NewMockFile(true)
writer2 := NewMockFile(true)
writers := []Writer{writer1, writer2}
logger := NewMultiAccessLogger(testTask, cfg, writers)
testURL, _ := url.Parse("http://example.com/test")
req := &http.Request{
RemoteAddr: "192.168.1.1",
Method: http.MethodGet,
URL: testURL,
}
resp := &http.Response{
StatusCode: http.StatusOK,
}
logger.Log(req, resp)
logger.Flush()
expect.Equal(t, writer1.NumLines(), 1)
expect.Equal(t, writer2.NumLines(), 1)
}
func TestMultiAccessLoggerClose(t *testing.T) {
testTask := task.RootTask("test", false)
cfg := DefaultRequestLoggerConfig()
writer1 := NewMockFile(true)
writer2 := NewMockFile(true)
writers := []Writer{writer1, writer2}
logger := NewMultiAccessLogger(testTask, cfg, writers)
err := logger.Close()
expect.Nil(t, err)
}
func TestMultiAccessLoggerMultipleLogs(t *testing.T) {
testTask := task.RootTask("test", false)
cfg := DefaultRequestLoggerConfig()
writer1 := NewMockFile(true)
writer2 := NewMockFile(true)
writers := []Writer{writer1, writer2}
logger := NewMultiAccessLogger(testTask, cfg, writers)
testURL, _ := url.Parse("http://example.com/test")
for range 3 {
req := &http.Request{
RemoteAddr: "192.168.1.1",
Method: http.MethodGet,
URL: testURL,
}
resp := &http.Response{
StatusCode: http.StatusOK,
}
logger.Log(req, resp)
}
logger.Flush()
expect.Equal(t, writer1.NumLines(), 3)
expect.Equal(t, writer2.NumLines(), 3)
}
func TestMultiAccessLoggerSingleWriter(t *testing.T) {
testTask := task.RootTask("test", false)
cfg := DefaultRequestLoggerConfig()
writer := NewMockFile(true)
writers := []Writer{writer}
logger := NewMultiAccessLogger(testTask, cfg, writers)
expect.NotNil(t, logger)
testURL, _ := url.Parse("http://example.com/test")
req := &http.Request{
RemoteAddr: "192.168.1.1",
Method: http.MethodGet,
URL: testURL,
}
resp := &http.Response{
StatusCode: http.StatusOK,
}
logger.Log(req, resp)
logger.Flush()
expect.Equal(t, writer.NumLines(), 1)
}
func TestMultiAccessLoggerMixedOperations(t *testing.T) {
testTask := task.RootTask("test", false)
cfg := DefaultRequestLoggerConfig()
writer1 := NewMockFile(true)
writer2 := NewMockFile(true)
writers := []Writer{writer1, writer2}
logger := NewMultiAccessLogger(testTask, cfg, writers)
testURL, _ := url.Parse("http://example.com/test")
req := &http.Request{
RemoteAddr: "192.168.1.1",
Method: http.MethodGet,
URL: testURL,
}
resp := &http.Response{
StatusCode: http.StatusOK,
}
logger.Log(req, resp)
logger.Flush()
info := &maxmind.IPInfo{
IP: net.ParseIP("192.168.1.1"),
Str: "192.168.1.1",
}
cfg2 := DefaultACLLoggerConfig()
cfg2.LogAllowed = true
aclLogger := NewMultiAccessLogger(testTask, cfg2, writers)
aclLogger.LogACL(info, false)
logger.Flush()
expect.Equal(t, writer1.NumLines(), 1)
expect.Equal(t, writer2.NumLines(), 1)
}

View File

@@ -4,13 +4,13 @@ import (
"bytes"
"errors"
"io"
"slices"
"time"
"github.com/rs/zerolog"
"github.com/yusing/godoxy/internal/utils"
gperr "github.com/yusing/goutils/errs"
strutils "github.com/yusing/goutils/strings"
"github.com/yusing/goutils/synk"
)
type supportRotate interface {
@@ -58,8 +58,6 @@ type lineInfo struct {
Size int64 // Size of this line
}
var rotateBytePool = synk.GetBytesPoolWithUniqueMemory()
// rotateLogFile rotates the log file based on the retention policy.
// It writes to the result and returns an error if any.
//
@@ -166,15 +164,17 @@ func rotateLogFileByPolicy(file supportRotate, config *Retention, result *Rotate
// Read each line and write it to the beginning of the file
writePos := int64(0)
buf := rotateBytePool.Get()
defer rotateBytePool.Put(buf)
buf := bytesPool.Get()
defer func() {
bytesPool.Put(buf)
}()
// in reverse order to keep the order of the lines (from old to new)
for i := len(linesToKeep) - 1; i >= 0; i-- {
line := linesToKeep[i]
n := line.Size
if cap(buf) < int(n) {
buf = make([]byte, n)
buf = slices.Grow(buf, int(n)-cap(buf))
}
buf = buf[:n]

View File

@@ -55,7 +55,7 @@ func TestParseLogTime(t *testing.T) {
func TestRotateKeepLast(t *testing.T) {
for _, format := range ReqLoggerFormats {
t.Run(string(format)+" keep last", func(t *testing.T) {
file := NewMockFile()
file := NewMockFile(true)
utils.MockTimeNow(testTime)
logger := NewAccessLoggerWithIO(task.RootTask("test", false), file, &RequestLoggerConfig{
Format: format,
@@ -77,7 +77,7 @@ func TestRotateKeepLast(t *testing.T) {
logger.Config().Retention = retention
var result RotateResult
rotated, err := logger.Rotate(&result)
rotated, err := logger.(AccessLogRotater).Rotate(&result)
expect.NoError(t, err)
expect.Equal(t, rotated, true)
expect.Equal(t, file.NumLines(), int(retention.Last))
@@ -86,7 +86,7 @@ func TestRotateKeepLast(t *testing.T) {
})
t.Run(string(format)+" keep days", func(t *testing.T) {
file := NewMockFile()
file := NewMockFile(true)
logger := NewAccessLoggerWithIO(task.RootTask("test", false), file, &RequestLoggerConfig{
Format: format,
})
@@ -107,7 +107,7 @@ func TestRotateKeepLast(t *testing.T) {
utils.MockTimeNow(testTime)
var result RotateResult
rotated, err := logger.Rotate(&result)
rotated, err := logger.(AccessLogRotater).Rotate(&result)
expect.NoError(t, err)
expect.Equal(t, rotated, true)
expect.Equal(t, file.NumLines(), int(retention.Days))
@@ -132,7 +132,7 @@ func TestRotateKeepLast(t *testing.T) {
func TestRotateKeepFileSize(t *testing.T) {
for _, format := range ReqLoggerFormats {
t.Run(string(format)+" keep size no rotation", func(t *testing.T) {
file := NewMockFile()
file := NewMockFile(true)
logger := NewAccessLoggerWithIO(task.RootTask("test", false), file, &RequestLoggerConfig{
Format: format,
})
@@ -153,7 +153,7 @@ func TestRotateKeepFileSize(t *testing.T) {
utils.MockTimeNow(testTime)
var result RotateResult
rotated, err := logger.Rotate(&result)
rotated, err := logger.(AccessLogRotater).Rotate(&result)
expect.NoError(t, err)
// file should be untouched as 100KB > 10 lines * bytes per line
@@ -164,7 +164,7 @@ func TestRotateKeepFileSize(t *testing.T) {
}
t.Run("keep size with rotation", func(t *testing.T) {
file := NewMockFile()
file := NewMockFile(true)
logger := NewAccessLoggerWithIO(task.RootTask("test", false), file, &RequestLoggerConfig{
Format: FormatJSON,
})
@@ -185,7 +185,7 @@ func TestRotateKeepFileSize(t *testing.T) {
utils.MockTimeNow(testTime)
var result RotateResult
rotated, err := logger.Rotate(&result)
rotated, err := logger.(AccessLogRotater).Rotate(&result)
expect.NoError(t, err)
expect.Equal(t, rotated, true)
expect.Equal(t, result.NumBytesKeep, int64(retention.KeepSize))
@@ -198,7 +198,7 @@ func TestRotateKeepFileSize(t *testing.T) {
func TestRotateSkipInvalidTime(t *testing.T) {
for _, format := range ReqLoggerFormats {
t.Run(string(format), func(t *testing.T) {
file := NewMockFile()
file := NewMockFile(true)
logger := NewAccessLoggerWithIO(task.RootTask("test", false), file, &RequestLoggerConfig{
Format: format,
})
@@ -221,7 +221,7 @@ func TestRotateSkipInvalidTime(t *testing.T) {
logger.Config().Retention = retention
var result RotateResult
rotated, err := logger.Rotate(&result)
rotated, err := logger.(AccessLogRotater).Rotate(&result)
expect.NoError(t, err)
expect.Equal(t, rotated, true)
// should read one invalid line after every valid line
@@ -240,7 +240,7 @@ func BenchmarkRotate(b *testing.B) {
}
for _, retention := range tests {
b.Run(fmt.Sprintf("retention_%s", retention.String()), func(b *testing.B) {
file := NewMockFile()
file := NewMockFile(true)
logger := NewAccessLoggerWithIO(task.RootTask("test", false), file, &RequestLoggerConfig{
ConfigBase: ConfigBase{
Retention: retention,
@@ -256,11 +256,11 @@ func BenchmarkRotate(b *testing.B) {
b.ResetTimer()
for b.Loop() {
b.StopTimer()
file = NewMockFile()
file = NewMockFile(true)
_, _ = file.Write(content)
b.StartTimer()
var result RotateResult
_, _ = logger.Rotate(&result)
_, _ = logger.(AccessLogRotater).Rotate(&result)
}
})
}
@@ -274,7 +274,7 @@ func BenchmarkRotateWithInvalidTime(b *testing.B) {
}
for _, retention := range tests {
b.Run(fmt.Sprintf("retention_%s", retention.String()), func(b *testing.B) {
file := NewMockFile()
file := NewMockFile(true)
logger := NewAccessLoggerWithIO(task.RootTask("test", false), file, &RequestLoggerConfig{
ConfigBase: ConfigBase{
Retention: retention,
@@ -293,11 +293,11 @@ func BenchmarkRotateWithInvalidTime(b *testing.B) {
b.ResetTimer()
for b.Loop() {
b.StopTimer()
file = NewMockFile()
file = NewMockFile(true)
_, _ = file.Write(content)
b.StartTimer()
var result RotateResult
_, _ = logger.Rotate(&result)
_, _ = logger.(AccessLogRotater).Rotate(&result)
}
})
}

View File

@@ -0,0 +1,32 @@
package accesslog
import (
"os"
"github.com/rs/zerolog"
"github.com/yusing/godoxy/internal/logging"
)
type Stdout struct {
logger zerolog.Logger
}
func NewStdout() Writer {
return &Stdout{logger: logging.NewLoggerWithFixedLevel(zerolog.InfoLevel, os.Stdout)}
}
func (s Stdout) Name() string {
return "stdout"
}
func (s Stdout) ShouldBeBuffered() bool {
return false
}
func (s Stdout) Write(p []byte) (n int, err error) {
return s.logger.Write(p)
}
func (s Stdout) Close() error {
return nil
}

View File

@@ -0,0 +1,47 @@
package accesslog
import (
"io"
)
type BufferedWriter interface {
io.Writer
io.Closer
Flush() error
Resize(size int) error
}
type unbufferedWriter struct {
w io.Writer
}
func NewUnbufferedWriter(w io.Writer) BufferedWriter {
return unbufferedWriter{w: w}
}
func (w unbufferedWriter) Write(p []byte) (n int, err error) {
return w.w.Write(p)
}
func (w unbufferedWriter) Close() error {
if closer, ok := w.w.(io.Closer); ok {
return closer.Close()
}
return nil
}
func (w unbufferedWriter) Flush() error {
if flusher, ok := w.w.(interface{ Flush() }); ok {
flusher.Flush()
} else if errFlusher, ok := w.w.(interface{ FlushError() error }); ok {
return errFlusher.FlushError()
} else if errFlusher2, ok := w.w.(interface{ Flush() error }); ok {
return errFlusher2.Flush()
}
return nil
}
func (w unbufferedWriter) Resize(size int) error {
// No-op for unbuffered writer
return nil
}

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