From e4e6f6b3e82d1167761ed5a39eaf0720f6dd5dc4 Mon Sep 17 00:00:00 2001 From: yusing Date: Mon, 16 Feb 2026 08:59:01 +0800 Subject: [PATCH] v0.26.0 --- .gitignore | 16 +- .golangci.yml | 24 +- .trunk/configs/.markdownlint.yaml | 2 + .trunk/configs/.yamllint.yaml | 7 + .trunk/trunk.yaml | 35 +- Dockerfile | 9 +- Makefile | 40 +- agent/cmd/main.go | 14 +- agent/go.mod | 38 +- agent/go.sum | 100 +-- agent/pkg/agent/config.go | 6 +- cmd/bench_server/Dockerfile | 2 +- cmd/bench_server/go.mod | 2 +- cmd/debug_page.go | 1 - cmd/h2c_test_server/Dockerfile | 2 +- cmd/h2c_test_server/go.mod | 6 +- cmd/h2c_test_server/go.sum | 8 +- cmd/main.go | 31 +- compose.example.yml | 4 +- go.mod | 78 +-- go.sum | 144 ++--- goutils | 2 +- internal/acl/README.md | 16 +- internal/acl/config.go | 63 +- internal/acl/matcher.go | 16 +- internal/acl/tcp_listener.go | 3 + internal/acl/types/acl.go | 9 + internal/acl/types/context.go | 16 + internal/acl/udp_listener.go | 4 + internal/api/handler.go | 16 +- internal/api/v1/agent/verify.go | 12 +- internal/api/v1/cert/info.go | 3 +- internal/api/v1/cert/renew.go | 10 +- internal/api/v1/docker/containers.go | 17 +- internal/api/v1/docker/info.go | 4 +- internal/api/v1/docker/stats.go | 4 +- internal/api/v1/docker/utils.go | 3 +- internal/api/v1/docs/swagger.json | 148 +++-- internal/api/v1/docs/swagger.yaml | 94 ++- internal/api/v1/events.go | 44 ++ internal/api/v1/favicon.go | 8 +- internal/api/v1/file/validate.go | 2 +- internal/api/v1/health.go | 19 +- internal/api/v1/homepage/categories.go | 16 +- internal/api/v1/homepage/items.go | 19 +- internal/api/v1/metrics/all_system_info.go | 6 +- internal/api/v1/proxmox/common.go | 2 +- internal/api/v1/proxmox/stats.go | 5 +- internal/api/v1/reload.go | 28 - internal/api/v1/route/by_provider.go | 11 +- internal/api/v1/route/playground.go | 35 +- internal/api/v1/route/playground_test.go | 8 +- internal/api/v1/route/route.go | 10 +- internal/api/v1/route/routes.go | 18 +- internal/auth/oauth_refresh.go | 2 +- internal/auth/oidc.go | 5 +- internal/auth/userpass.go | 25 +- internal/auth/userpass_test.go | 2 +- internal/auth/utils.go | 8 +- internal/autocert/config.go | 16 +- internal/autocert/provider.go | 33 +- internal/autocert/providers.go | 7 +- internal/autocert/setup.go | 2 +- internal/autocert/types/cert_info.go | 10 + internal/autocert/types/context.go | 16 + internal/autocert/types/provider.go | 9 +- internal/config/README.md | 18 +- internal/config/events.go | 88 +-- internal/config/state.go | 96 ++- internal/config/types/config.go | 5 +- internal/config/types/state.go | 6 +- internal/dnsproviders/go.mod | 54 +- internal/dnsproviders/go.sum | 116 ++-- internal/docker/label.go | 28 +- internal/entrypoint/README.md | 582 +++++++++++------- internal/entrypoint/{types => }/config.go | 4 +- internal/entrypoint/entrypoint.go | 205 +++--- .../entrypoint/entrypoint_benchmark_test.go | 78 ++- internal/entrypoint/entrypoint_test.go | 206 ++++++- internal/entrypoint/http_pool_adapter.go | 51 ++ internal/entrypoint/http_server.go | 175 ++++++ internal/entrypoint/query.go | 92 +++ internal/entrypoint/routes.go | 146 +++++ internal/entrypoint/shortlink.go | 2 +- internal/entrypoint/shortlink_test.go | 56 +- internal/entrypoint/types/context.go | 18 + internal/entrypoint/types/entrypoint.go | 58 ++ internal/health/check/docker.go | 14 +- internal/health/check/http.go | 4 +- internal/health/monitor/README.md | 2 +- internal/health/monitor/monitor.go | 17 +- internal/health/monitor/new.go | 34 +- internal/homepage/icons/README.md | 2 +- internal/homepage/icons/fetch/fetch.go | 12 +- internal/homepage/icons/provider.go | 9 +- internal/homepage/icons/url.go | 13 +- .../integrations/qbittorrent/client.go | 4 +- internal/homepage/widgets/README.md | 3 +- internal/homepage/widgets/http.go | 5 +- internal/homepage/widgets/widgets.go | 10 +- internal/idlewatcher/README.md | 4 +- internal/idlewatcher/errors.go | 4 +- internal/idlewatcher/events.go | 57 +- internal/idlewatcher/handle_http.go | 33 +- internal/idlewatcher/handle_http_debug.go | 16 +- internal/idlewatcher/handle_stream.go | 4 +- internal/idlewatcher/health.go | 6 +- internal/idlewatcher/html/loading.js | 73 ++- internal/idlewatcher/loading_page.go | 2 +- internal/idlewatcher/provider/README.md | 2 +- internal/idlewatcher/provider/docker.go | 6 +- internal/idlewatcher/provider/proxmox.go | 28 +- .../idlewatcher/types/container_status.go | 4 +- internal/idlewatcher/types/provider.go | 5 +- internal/idlewatcher/watcher.go | 53 +- internal/logging/accesslog/config.go | 6 +- internal/logging/accesslog/console_logger.go | 4 +- .../logging/accesslog/file_access_logger.go | 10 +- internal/logging/accesslog/filter.go | 7 +- internal/logging/accesslog/formatter.go | 6 +- .../logging/accesslog/multi_access_logger.go | 4 +- .../accesslog/multi_access_logger_test.go | 4 +- internal/logging/accesslog/retention.go | 10 +- .../logging/accesslog/status_code_range.go | 8 +- internal/logging/accesslog/types.go | 6 +- internal/logging/logging.go | 22 +- internal/maxmind/README.md | 6 +- internal/maxmind/instance.go | 3 +- internal/maxmind/maxmind.go | 13 +- internal/maxmind/types/config.go | 3 +- internal/metrics/period/poller.go | 7 +- internal/metrics/uptime/README.md | 2 +- internal/metrics/uptime/uptime.go | 27 +- internal/net/gphttp/loadbalancer/README.md | 2 +- internal/net/gphttp/loadbalancer/ip_hash.go | 5 +- .../net/gphttp/loadbalancer/loadbalancer.go | 6 +- internal/net/gphttp/middleware/bypass_test.go | 39 +- .../net/gphttp/middleware/captcha/README.md | 2 +- .../net/gphttp/middleware/captcha/provider.go | 5 +- .../net/gphttp/middleware/cidr_whitelist.go | 2 + .../gphttp/middleware/cloudflare_real_ip.go | 2 +- internal/net/gphttp/middleware/crowdsec.go | 15 +- .../gphttp/middleware/errorpage/error_page.go | 11 +- internal/net/gphttp/middleware/forwardauth.go | 23 +- internal/net/gphttp/middleware/middleware.go | 19 +- .../gphttp/middleware/middleware_builder.go | 13 +- .../net/gphttp/middleware/middleware_chain.go | 3 +- internal/net/gphttp/middleware/middlewares.go | 15 +- internal/net/gphttp/middleware/oidc.go | 32 +- internal/net/gphttp/middleware/test_utils.go | 7 +- internal/net/gphttp/middleware/themed.go | 5 +- internal/net/types/stream.go | 2 +- internal/notif/README.md | 6 +- internal/notif/base.go | 12 +- internal/notif/config.go | 16 +- internal/notif/gotify.go | 11 +- internal/notif/ntfy.go | 15 +- internal/notif/providers.go | 5 +- internal/notif/webhook.go | 30 +- internal/proxmox/README.md | 4 +- internal/proxmox/client.go | 10 +- internal/proxmox/config.go | 17 +- internal/proxmox/lxc.go | 14 +- internal/proxmox/lxc_stats.go | 5 +- internal/proxmox/node.go | 6 +- internal/route/README.md | 54 +- internal/route/common.go | 21 +- internal/route/fileserver.go | 38 +- internal/route/provider/README.md | 18 +- internal/route/provider/agent.go | 3 +- internal/route/provider/all_fields.yaml | 67 +- internal/route/provider/docker.go | 18 +- internal/route/provider/docker_labels.yaml | 2 - internal/route/provider/event_handler.go | 6 +- internal/route/provider/file.go | 15 +- internal/route/provider/provider.go | 46 +- internal/route/reverse_proxy.go | 86 +-- internal/route/reverse_proxy_test.go | 39 ++ internal/route/route.go | 179 ++++-- internal/route/route_test.go | 160 +++-- internal/route/routes/README.md | 307 --------- internal/route/routes/query.go | 103 ---- internal/route/routes/routes.go | 91 --- internal/route/rules/do.go | 45 +- internal/route/rules/do_log_test.go | 35 +- internal/route/rules/do_set.go | 7 +- internal/route/rules/do_set_test.go | 18 +- internal/route/rules/error_format_test.go | 4 +- internal/route/rules/help.go | 2 +- internal/route/rules/http_flow_test.go | 210 ++++--- internal/route/rules/io.go | 3 +- internal/route/rules/on.go | 12 +- internal/route/rules/parser.go | 2 +- internal/route/rules/parser_test.go | 15 +- internal/route/rules/presets/embed.go | 5 +- internal/route/rules/presets/webui.yml | 11 +- internal/route/rules/presets/webui_dev.yml | 26 + internal/route/rules/rules.go | 3 +- internal/route/rules/validate.go | 41 +- internal/route/rules/var_bench_test.go | 5 +- internal/route/rules/vars_test.go | 30 +- internal/route/stream.go | 54 +- internal/route/stream/README.md | 3 +- internal/route/stream/debug_debug.go | 2 +- internal/route/stream/errors.go | 4 +- internal/route/stream/tcp_tcp.go | 23 +- internal/route/stream/udp_udp.go | 14 +- internal/route/test_route.go | 32 + internal/route/types/http_config.go | 19 +- internal/route/types/port.go | 11 +- internal/route/types/scheme.go | 5 +- internal/serialization/README.md | 24 +- internal/serialization/gin_binding_test.go | 12 +- internal/serialization/reader.go | 7 +- internal/serialization/reader_bench_test.go | 34 +- internal/serialization/reader_test.go | 45 +- internal/serialization/serialization.go | 121 ++-- .../serialization_benchmark_test.go | 2 +- internal/serialization/validation.go | 8 +- .../serialization/validation_mismatch_test.go | 17 +- .../validation_string_ptr_test.go | 11 +- .../serialization/validation_string_test.go | 9 +- .../validation_struct_ptr_test.go | 11 +- .../serialization/validation_struct_test.go | 9 +- internal/types/docker_provider_config.go | 4 +- internal/types/health.go | 11 + internal/types/idlewatcher.go | 30 +- internal/types/routes.go | 9 +- internal/watcher/README.md | 68 +- internal/watcher/config_file_watcher.go | 2 +- internal/watcher/directory_watcher.go | 28 +- internal/watcher/docker_watcher.go | 38 +- internal/watcher/events/README.md | 444 ------------- internal/watcher/events/event_queue.go | 106 ---- internal/watcher/events/events.go | 25 +- internal/watcher/file_watcher.go | 9 +- internal/watcher/watcher.go | 7 +- scripts/benchmark.sh | 0 scripts/update-wiki/main.ts | 56 +- socket-proxy.Dockerfile | 2 +- socket-proxy/go.mod | 8 +- socket-proxy/go.sum | 12 +- 242 files changed, 3953 insertions(+), 3502 deletions(-) create mode 100644 .trunk/configs/.markdownlint.yaml create mode 100644 .trunk/configs/.yamllint.yaml create mode 100644 internal/acl/types/acl.go create mode 100644 internal/acl/types/context.go create mode 100644 internal/api/v1/events.go delete mode 100644 internal/api/v1/reload.go create mode 100644 internal/autocert/types/cert_info.go create mode 100644 internal/autocert/types/context.go rename internal/entrypoint/{types => }/config.go (63%) create mode 100644 internal/entrypoint/http_pool_adapter.go create mode 100644 internal/entrypoint/http_server.go create mode 100644 internal/entrypoint/query.go create mode 100644 internal/entrypoint/routes.go create mode 100644 internal/entrypoint/types/context.go create mode 100644 internal/entrypoint/types/entrypoint.go create mode 100644 internal/route/reverse_proxy_test.go delete mode 100644 internal/route/routes/README.md delete mode 100644 internal/route/routes/query.go delete mode 100644 internal/route/routes/routes.go create mode 100644 internal/route/rules/presets/webui_dev.yml create mode 100644 internal/route/test_route.go delete mode 100644 internal/watcher/events/README.md delete mode 100644 internal/watcher/events/event_queue.go mode change 100644 => 100755 scripts/benchmark.sh diff --git a/.gitignore b/.gitignore index a8262858..f8ac4ac3 100755 --- a/.gitignore +++ b/.gitignore @@ -14,34 +14,30 @@ data/ debug/ logs/ -log/ .vscode/settings.json -go.work.sum - !cmd/**/ !internal/**/ todo.md .*.swp -.aider* mtrace.json .env *.env -.cursorrules .cursor/ -.windsurfrules test.Dockerfile -node_modules/ -tsconfig.tsbuildinfo - !agent.compose.yml !agent/pkg/** dev-data/ RELEASE_NOTES.md CLAUDE.md -.kilocode/** \ No newline at end of file +.kilocode/** + +!.trunk/configs + +# minified files +**/*-min.* \ No newline at end of file diff --git a/.golangci.yml b/.golangci.yml index 075f1e1d..9a2c59a6 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -47,6 +47,7 @@ linters: errcheck: exclude-functions: - fmt.Fprintln + - (*gin.Context).Error # gin context error handler forbidigo: forbid: - pattern: ^print(ln)?$ @@ -55,21 +56,15 @@ linters: statements: 120 gocyclo: min-complexity: 14 + godoclint: + ignore: internal/api/v1/.+ godox: keywords: - FIXME - gomoddirectives: - replace-allow-list: - - github.com/abbot/go-http-auth - - github.com/gorilla/mux - - github.com/mailgun/minheap - - github.com/mailgun/multibuf - - github.com/jaguilar/vt100 - - github.com/cucumber/godog - - github.com/http-wasm/http-wasm-host-go govet: disable: - shadow + - fieldalignment enable-all: true misspell: locale: US @@ -106,8 +101,7 @@ linters: checks: - all - -SA1019 - dot-import-whitelist: - - github.com/yusing/godoxy/internal/utils/testing + - -QF1008 # keep embedded field selector for clarity tagalign: align: false sort: true @@ -135,9 +129,8 @@ linters: - legacy - std-error-handling paths: - - third_party$ - - builtin$ - examples$ + - internal/api/v1/.+ formatters: enable: - gofmt @@ -146,6 +139,7 @@ formatters: exclusions: generated: lax paths: - - third_party$ - - builtin$ - examples$ + - internal/api/v1/.+ +run: + tests: false diff --git a/.trunk/configs/.markdownlint.yaml b/.trunk/configs/.markdownlint.yaml new file mode 100644 index 00000000..b40ee9d7 --- /dev/null +++ b/.trunk/configs/.markdownlint.yaml @@ -0,0 +1,2 @@ +# Prettier friendly markdownlint config (all formatting rules disabled) +extends: markdownlint/style/prettier diff --git a/.trunk/configs/.yamllint.yaml b/.trunk/configs/.yamllint.yaml new file mode 100644 index 00000000..184e251f --- /dev/null +++ b/.trunk/configs/.yamllint.yaml @@ -0,0 +1,7 @@ +rules: + quoted-strings: + required: only-when-needed + extra-allowed: ["{|}"] + key-duplicates: {} + octal-values: + forbid-implicit-octal: true diff --git a/.trunk/trunk.yaml b/.trunk/trunk.yaml index a2b26287..58e556c4 100644 --- a/.trunk/trunk.yaml +++ b/.trunk/trunk.yaml @@ -7,36 +7,45 @@ cli: plugins: sources: - id: trunk - ref: v1.7.2 + ref: v1.7.4 uri: https://github.com/trunk-io/plugins # Many linters and tools depend on runtimes - configure them here. (https://docs.trunk.io/runtimes) runtimes: enabled: - node@22.16.0 - python@3.10.8 - - go@1.24.3 + - go@1.26.0 # This is the section where you manage your linters. (https://docs.trunk.io/check/configuration) lint: disabled: - - markdownlint - - yamllint + - bandit + - black + - isort + - ruff enabled: - - checkov@3.2.471 - - golangci-lint2@2.5.0 + - yamllint@1.38.0 + - markdownlint@0.47.0 + - checkov@3.2.501 + - golangci-lint2@2.9.0 - hadolint@2.14.0 - - actionlint@1.7.7 + - actionlint@1.7.10 - git-diff-check - gofmt@1.20.4 - - osv-scanner@2.2.2 - - oxipng@9.1.5 - - prettier@3.6.2 + - osv-scanner@2.3.3 + - oxipng@10.1.0 + - prettier@3.8.1 - shellcheck@0.11.0 - shfmt@3.6.0 - - trufflehog@3.90.8 + - trufflehog@3.93.3 + ignore: + - linters: [ALL] + paths: + - internal/api/v1/docs/** + actions: disabled: - trunk-announce - - trunk-check-pre-push - - trunk-fmt-pre-commit enabled: - trunk-upgrade-available + - trunk-check-pre-push + - trunk-fmt-pre-commit diff --git a/Dockerfile b/Dockerfile index a202a03b..bc958667 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,11 @@ # Stage 1: deps -FROM golang:1.25.6-alpine AS deps +FROM golang:1.26.0-alpine AS deps HEALTHCHECK NONE # package version does not matter +# libgcc and libstdc++ are needed for bun # trunk-ignore(hadolint/DL3018) -RUN apk add --no-cache tzdata make libcap-setcap +RUN apk add --no-cache tzdata make libcap-setcap libgcc libstdc++ ENV GOPATH=/root/go ENV GOCACHE=/root/.cache/go-build @@ -17,6 +18,10 @@ COPY internal/gopsutil/go.mod internal/gopsutil/go.sum ./internal/gopsutil/ COPY internal/go-proxmox/go.mod internal/go-proxmox/go.sum ./internal/go-proxmox/ COPY go.mod go.sum ./ +# for minify-js +COPY --from=oven/bun:1.3.9-alpine /usr/local/bin/bun /usr/local/bin/bun +COPY --from=oven/bun:1.3.9-alpine /usr/local/bin/bunx /usr/local/bin/bunx + # remove godoxy stuff from go.mod first RUN --mount=type=cache,target=/root/.cache/go-build \ --mount=type=cache,target=/root/go/pkg/mod \ diff --git a/Makefile b/Makefile index 65c56388..2195cb23 100755 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ shell := /bin/sh -export VERSION ?= $(shell git describe --tags --abbrev=0) +export VERSION ?= $(shell git describe --tags --abbrev=0 2>/dev/null) export BRANCH ?= $(shell git rev-parse --abbrev-ref HEAD) export BUILD_DATE ?= $(shell date -u +'%Y%m%d-%H%M') export GOOS = linux @@ -117,12 +117,27 @@ mod-tidy: cd ${PWD}/$$path && go mod tidy; \ done -build: +minify-js: + @if [ "${agent}" = "1" ]; then \ + echo "minify-js: skipped for agent"; \ + elif [ "${socket-proxy}" = "1" ]; then \ + echo "minify-js: skipped for socket-proxy"; \ + else \ + for file in $$(find internal/ -name '*.js' | grep -v -- '-min\.js$$'); do \ + ext="$${file##*.}"; \ + base="$${file%.*}"; \ + min_file="$${base}-min.$$ext"; \ + echo "minifying $$file -> $$min_file"; \ + bunx --bun uglify-js $$file --compress --mangle --output $$min_file; \ + done \ + fi + +build: minify-js mkdir -p $(shell dirname ${BIN_PATH}) go build -C ${PWD} ${BUILD_FLAGS} -o ${BIN_PATH} ./cmd ${POST_BUILD} -run: +run: minify-js cd ${PWD} && [ -f .env ] && godotenv -f .env go run ${BUILD_FLAGS} ./cmd dev: @@ -132,16 +147,12 @@ dev-build: build docker compose -f dev.compose.yml up -t 0 -d app --force-recreate benchmark: - @if [ -z "$(TARGET)" ]; then \ - docker compose -f dev.compose.yml up -d --force-recreate godoxy traefik caddy nginx; \ - else \ - docker compose -f dev.compose.yml up -d --force-recreate $(TARGET); \ - fi - sleep 1 - @./scripts/benchmark.sh - -dev-run: build - cd dev-data && ${BIN_PATH} + @TARGETS="$(TARGET)"; \ + if [ -z "$$TARGETS" ]; then TARGETS="godoxy traefik caddy nginx"; fi; \ + trap 'docker compose -f dev.compose.yml down $$TARGETS' EXIT; \ + docker compose -f dev.compose.yml up -d --force-recreate $$TARGETS; \ + sleep 1; \ + ./scripts/benchmark.sh rapid-crash: docker run --restart=always --name test_crash -p 80 debian:bookworm-slim /bin/cat &&\ @@ -175,8 +186,7 @@ gen-swagger-markdown: gen-swagger gen-api-types: gen-swagger # --disable-throw-on-error 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 + --responses -o ${WEBUI_DIR}/src/lib -n api.ts -p internal/api/v1/docs/swagger.json .PHONY: update-wiki update-wiki: diff --git a/agent/cmd/main.go b/agent/cmd/main.go index bc9bf335..684de430 100644 --- a/agent/cmd/main.go +++ b/agent/cmd/main.go @@ -19,7 +19,6 @@ import ( "github.com/yusing/godoxy/agent/pkg/handler" "github.com/yusing/godoxy/internal/metrics/systeminfo" socketproxy "github.com/yusing/godoxy/socketproxy/pkg" - gperr "github.com/yusing/goutils/errs" strutils "github.com/yusing/goutils/strings" "github.com/yusing/goutils/task" "github.com/yusing/goutils/version" @@ -72,7 +71,7 @@ Tips: // - Otherwise: route to HTTPS API handler tcpListener, err := net.ListenTCP("tcp", &net.TCPAddr{Port: env.AgentPort}) if err != nil { - gperr.LogFatal("failed to listen on port", err) + log.Fatal().Err(err).Msg("failed to listen on port") } caCertPool := x509.NewCertPool() @@ -148,7 +147,7 @@ Tips: log.Info().Msgf("%s socket listening on: %s", runtime, socketproxy.ListenAddr) l, err := net.Listen("tcp", socketproxy.ListenAddr) if err != nil { - gperr.LogFatal("failed to listen on port", err) + log.Fatal().Err(err).Msg("failed to listen on port") } errLog := log.Logger.With().Str("level", "error").Str("component", "socketproxy").Logger() srv := http.Server{ @@ -158,10 +157,15 @@ Tips: }, ErrorLog: stdlog.New(&errLog, "", 0), } - srv.Serve(l) + go func() { + err := srv.Serve(l) + if err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Error().Err(err).Msg("socket proxy server stopped with error") + } + }() } - systeminfo.Poller.Start() + systeminfo.Poller.Start(t) task.WaitExit(3) } diff --git a/agent/go.mod b/agent/go.mod index e3a58cc3..2f13ae2f 100644 --- a/agent/go.mod +++ b/agent/go.mod @@ -1,6 +1,6 @@ module github.com/yusing/godoxy/agent -go 1.25.6 +go 1.26.0 replace ( github.com/shirou/gopsutil/v4 => ../internal/gopsutil @@ -19,11 +19,11 @@ exclude github.com/yusing/godoxy/internal/utils v0.0.0-20250927032450-e2aeef3a86 require ( github.com/gin-gonic/gin v1.11.0 github.com/gorilla/websocket v1.5.3 - github.com/pion/dtls/v3 v3.0.10 + github.com/pion/dtls/v3 v3.1.2 github.com/pion/transport/v3 v3.1.1 github.com/rs/zerolog v1.34.0 github.com/stretchr/testify v1.11.1 - github.com/yusing/godoxy v0.25.2 + github.com/yusing/godoxy v0.26.0 github.com/yusing/godoxy/socketproxy v0.0.0-00010101000000-000000000000 github.com/yusing/goutils v0.7.0 ) @@ -40,13 +40,13 @@ require ( 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 v29.2.0+incompatible // indirect + github.com/docker/cli v29.2.1+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.1 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect - github.com/gabriel-vasile/mimetype v1.4.12 // indirect + github.com/gabriel-vasile/mimetype v1.4.13 // 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 @@ -58,7 +58,7 @@ require ( github.com/goccy/go-yaml v1.19.2 // indirect github.com/gorilla/mux v1.8.1 // indirect github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12 // indirect - github.com/klauspost/compress v1.18.3 // indirect + github.com/klauspost/compress v1.18.4 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 // indirect @@ -81,7 +81,7 @@ require ( github.com/puzpuzpuz/xsync/v4 v4.4.0 // indirect github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/quic-go v0.59.0 // indirect - github.com/shirou/gopsutil/v4 v4.25.12 // indirect + github.com/shirou/gopsutil/v4 v4.26.1 // indirect github.com/sirupsen/logrus v1.9.4 // indirect github.com/tklauser/go-sysconf v0.3.16 // indirect github.com/tklauser/numcpus v0.11.0 // indirect @@ -90,21 +90,21 @@ require ( github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasthttp v1.69.0 // indirect github.com/yusing/ds v0.4.1 // indirect - github.com/yusing/gointernals v0.1.16 // indirect - github.com/yusing/goutils/http/reverseproxy v0.0.0-20260129081554-24e52ede7468 // indirect - github.com/yusing/goutils/http/websocket v0.0.0-20260129081554-24e52ede7468 // indirect + github.com/yusing/gointernals v0.2.0 // indirect + github.com/yusing/goutils/http/reverseproxy v0.0.0-20260215081811-494ab85a33ae // indirect + github.com/yusing/goutils/http/websocket v0.0.0-20260215081811-494ab85a33ae // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 // indirect - go.opentelemetry.io/otel v1.39.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 // indirect + go.opentelemetry.io/otel v1.40.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 // indirect - go.opentelemetry.io/otel/metric v1.39.0 // indirect - go.opentelemetry.io/otel/trace v1.39.0 // indirect - golang.org/x/arch v0.23.0 // indirect - golang.org/x/crypto v0.47.0 // indirect - golang.org/x/net v0.49.0 // indirect - golang.org/x/sys v0.40.0 // indirect - golang.org/x/text v0.33.0 // indirect + go.opentelemetry.io/otel/metric v1.40.0 // indirect + go.opentelemetry.io/otel/trace v1.40.0 // indirect + golang.org/x/arch v0.24.0 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/net v0.50.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20251222181119-0a764e51fe1b // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/agent/go.sum b/agent/go.sum index 37b2c8ca..8533695e 100644 --- a/agent/go.sum +++ b/agent/go.sum @@ -41,8 +41,8 @@ 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 v29.2.0+incompatible h1:9oBd9+YM7rxjZLfyMGxjraKBKE4/nVyvVfN4qNl9XRM= -github.com/docker/cli v29.2.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= +github.com/docker/cli v29.2.1+incompatible h1:n3Jt0QVCN65eiVBoUTZQM9mcQICCJt3akW4pKAbKdJg= +github.com/docker/cli v29.2.1+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= @@ -55,8 +55,8 @@ 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.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= -github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= +github.com/gabriel-vasile/mimetype v1.4.13/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= @@ -99,16 +99,16 @@ github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= 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.8.0 h1:E3UDDn/3rFZi1sjZfbuhXNnxJP3ACZhdcw/iySegPRA= -github.com/gotify/server/v2 v2.8.0/go.mod h1:6ci5adxcE2hf1v+2oowKiQmixOxXV8vU+CRLKP6sqZA= +github.com/gotify/server/v2 v2.9.0 h1:2zRCl28wkq0oc6YNbyJS2n0dDOOVvOS3Oez5AG2ij54= +github.com/gotify/server/v2 v2.9.0/go.mod h1:249wwlUqHTr0QsiKARGtFVqds0pNLIMjYLinHyMACdQ= 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.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw= -github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= +github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= 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= @@ -159,16 +159,16 @@ github.com/oschwald/maxminddb-golang v1.13.1 h1:G3wwjdN9JmIK2o/ermkHM+98oX5fS+k5 github.com/oschwald/maxminddb-golang v1.13.1/go.mod h1:K4pgV9N/GcK694KSTmVSDTODk4IsCNThNdTmnaBZ/F8= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= -github.com/pion/dtls/v3 v3.0.10 h1:k9ekkq1kaZoxnNEbyLKI8DI37j/Nbk1HWmMuywpQJgg= -github.com/pion/dtls/v3 v3.0.10/go.mod h1:YEmmBYIoBsY3jmG56dsziTv/Lca9y4Om83370CXfqJ8= +github.com/pion/dtls/v3 v3.1.2 h1:gqEdOUXLtCGW+afsBLO0LtDD8GnuBBjEy6HRtyofZTc= +github.com/pion/dtls/v3 v3.1.2/go.mod h1:Hw/igcX4pdY69z1Hgv5x7wJFrUkdgHwAn/Q/uo7YHRo= github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8= github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so= github.com/pion/transport/v3 v3.1.1 h1:Tr684+fnnKlhPceU+ICdrw6KKkTms+5qHMgw6bIkYOM= github.com/pion/transport/v3 v3.1.1/go.mod h1:+c2eewC5WJQHiAA46fkMMzoYZSuGzA/7E2FPrOYHctQ= github.com/pion/transport/v4 v4.0.1 h1:sdROELU6BZ63Ab7FrOLn13M6YdJLY20wldXW2Cu2k8o= github.com/pion/transport/v4 v4.0.1/go.mod h1:nEuEA4AD5lPdcIegQDpVLgNoDGreqM/YqmEx3ovP4jM= -github.com/pires/go-proxyproto v0.9.2 h1:H1UdHn695zUVVmB0lQ354lOWHOy6TZSpzBl3tgN0s1U= -github.com/pires/go-proxyproto v0.9.2/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU= +github.com/pires/go-proxyproto v0.11.0 h1:gUQpS85X/VJMdUsYyEgyn59uLJvGqPhJV5YvG68wXH4= +github.com/pires/go-proxyproto v0.11.0/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -189,10 +189,10 @@ github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= 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.9.0 h1:6LkOabJmZdNLaUWkTC3IVVA+dq7b/V0FM6lz6/7+THI= -github.com/samber/slog-zerolog/v2 v2.9.0/go.mod h1:gnQW9VnCfM34v2pRMUIGMsZOVbYLqY/v0Wxu6atSVGc= +github.com/samber/slog-common v0.20.0 h1:WaLnm/aCvBJSk5nR5aXZTFBaV0B47A+AEaEOiZDeUnc= +github.com/samber/slog-common v0.20.0/go.mod h1:+Ozat1jgnnE59UAlmNX1IF3IByHsODnnwf9jUcBZ+m8= +github.com/samber/slog-zerolog/v2 v2.9.1 h1:RMOq8XqzfuGx1X0TEIlS9OXbbFmqLY2/wJppghz66YY= +github.com/samber/slog-zerolog/v2 v2.9.1/go.mod h1:DQYYve14WgCRN/XnKeHl4266jXK0DgYkYXkfZ4Fp98k= github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= @@ -225,44 +225,44 @@ github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZ github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/yusing/ds v0.4.1 h1:syMCh7hO6Yw8xfcFkEaln3W+lVeWB/U/meYv6Wf2/Ig= github.com/yusing/ds v0.4.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/yusing/gointernals v0.2.0 h1:jyWB3kdUPkuU6s0r8QY/sS5h2WNBF4Kfisly8dtSVvg= +github.com/yusing/gointernals v0.2.0/go.mod h1:xGzNbPGMm5Z8kG0t4JYISMscw+gMQlgghkLxlgRZv5Y= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= 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/net/http/otelhttp v0.64.0 h1:ssfIgGNANqpVFCndZvcuyKbl0g+UAVcbBcqGkG28H0Y= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0/go.mod h1:GQ/474YrbE4Jx8gZ4q5I4hrhUzM6UPzyrqJYV2AqPoQ= -go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= -go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 h1:7iP2uCb7sGddAr30RRS6xjKy7AZ2JtTOPA3oolgVSw8= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0/go.mod h1:c7hN3ddxs/z6q9xwvfLPk+UHlWRQyaeR1LdgfL/66l0= +go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= +go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= 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.38.0 h1:aTL7F04bJHUlztTsNGJ2l+6he8c+y/b//eR0jjjemT4= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0/go.mod h1:kldtb7jDTeol0l3ewcmd8SDvx3EmIE7lyvqbasU3QC4= -go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= -go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= -go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= -go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= -go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= -go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= -go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= -go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= +go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= +go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= +go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= +go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE= +go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw= +go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg= +go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= +go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= 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.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= -golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg= -golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= -golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= -golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= -golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= -golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= -golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= -golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= -golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= -golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/arch v0.24.0 h1:qlJ3M9upxvFfwRM51tTg3Yl+8CP9vCC1E7vlFpgv99Y= +golang.org/x/arch v0.24.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= +golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= +golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= +golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -271,20 +271,20 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= -golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= -golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= -golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= -golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= google.golang.org/genproto/googleapis/api v0.0.0-20251222181119-0a764e51fe1b h1:uA40e2M6fYRBf0+8uN5mLlqUtV192iiksiICIBkYJ1E= google.golang.org/genproto/googleapis/api v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:Xa7le7qx2vmqB/SzWUBa7KdMjpdpAHlh5QCSnjessQk= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= -google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= -google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 h1:mWPCjDEyshlQYzBpMNHaEof6UX1PmHcaUODUywQ0uac= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= +google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/agent/pkg/agent/config.go b/agent/pkg/agent/config.go index 404554e4..8b5308c2 100644 --- a/agent/pkg/agent/config.go +++ b/agent/pkg/agent/config.go @@ -4,6 +4,7 @@ import ( "context" "crypto/tls" "crypto/x509" + "encoding/json" "encoding/pem" "errors" "fmt" @@ -15,7 +16,6 @@ import ( "strings" "time" - "github.com/bytedance/sonic" "github.com/rs/zerolog" "github.com/rs/zerolog/log" "github.com/yusing/godoxy/agent/pkg/agent/common" @@ -216,7 +216,7 @@ func (cfg *AgentConfig) InitWithCerts(ctx context.Context, ca, crt, key []byte) cfg.l = log.With().Str("agent", cfg.Name).Logger() if err := streamUnsupportedErrs.Error(); err != nil { - gperr.LogWarn("agent has limited/no stream tunneling support, TCP and UDP routes via agent will not work", err, &cfg.l) + cfg.l.Warn().Err(err).Msg("agent has limited/no stream tunneling support, TCP and UDP routes via agent will not work") } if serverVersion.IsNewerThanMajor(cfg.Version) { @@ -366,7 +366,7 @@ func (cfg *AgentConfig) fetchJSON(ctx context.Context, endpoint string, out any) return resp.StatusCode, nil } - err = sonic.Unmarshal(data, out) + err = json.Unmarshal(data, out) if err != nil { return 0, err } diff --git a/cmd/bench_server/Dockerfile b/cmd/bench_server/Dockerfile index 0d2daa8e..14aa852a 100644 --- a/cmd/bench_server/Dockerfile +++ b/cmd/bench_server/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.25.6-alpine AS builder +FROM golang:1.26.0-alpine AS builder HEALTHCHECK NONE diff --git a/cmd/bench_server/go.mod b/cmd/bench_server/go.mod index 5ab3499c..8b0a8abe 100644 --- a/cmd/bench_server/go.mod +++ b/cmd/bench_server/go.mod @@ -1,3 +1,3 @@ module github.com/yusing/godoxy/cmd/bench_server -go 1.25.6 +go 1.26.0 diff --git a/cmd/debug_page.go b/cmd/debug_page.go index 00d44b80..607ee5ca 100644 --- a/cmd/debug_page.go +++ b/cmd/debug_page.go @@ -181,7 +181,6 @@ func newApiHandler(debugMux *debugMux) *gin.Engine { registerGinRoute(v1, "GET", "Route favicon", "/favicon", apiV1.FavIcon) registerGinRoute(v1, "GET", "Route health", "/health", apiV1.Health) registerGinRoute(v1, "GET", "List icons", "/icons", apiV1.Icons) - registerGinRoute(v1, "POST", "Config reload", "/reload", apiV1.Reload) registerGinRoute(v1, "GET", "Route stats", "/stats", apiV1.Stats) route := v1.Group("/route") diff --git a/cmd/h2c_test_server/Dockerfile b/cmd/h2c_test_server/Dockerfile index 7ce1583a..3cfa4024 100644 --- a/cmd/h2c_test_server/Dockerfile +++ b/cmd/h2c_test_server/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.25.6-alpine AS builder +FROM golang:1.26.0-alpine AS builder HEALTHCHECK NONE diff --git a/cmd/h2c_test_server/go.mod b/cmd/h2c_test_server/go.mod index e8fd0336..9176dc29 100644 --- a/cmd/h2c_test_server/go.mod +++ b/cmd/h2c_test_server/go.mod @@ -1,7 +1,7 @@ module github.com/yusing/godoxy/cmd/h2c_test_server -go 1.25.6 +go 1.26.0 -require golang.org/x/net v0.49.0 +require golang.org/x/net v0.50.0 -require golang.org/x/text v0.33.0 // indirect +require golang.org/x/text v0.34.0 // indirect diff --git a/cmd/h2c_test_server/go.sum b/cmd/h2c_test_server/go.sum index ed513d9d..f5d54c3d 100644 --- a/cmd/h2c_test_server/go.sum +++ b/cmd/h2c_test_server/go.sum @@ -1,4 +1,4 @@ -golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= -golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= -golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= -golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= +golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= diff --git a/cmd/main.go b/cmd/main.go index 3b32ad80..2d2fc2e4 100755 --- a/cmd/main.go +++ b/cmd/main.go @@ -1,12 +1,12 @@ package main import ( + "errors" "os" "sync" "time" "github.com/rs/zerolog/log" - "github.com/yusing/godoxy/internal/api" "github.com/yusing/godoxy/internal/auth" "github.com/yusing/godoxy/internal/common" "github.com/yusing/godoxy/internal/config" @@ -14,12 +14,8 @@ import ( iconlist "github.com/yusing/godoxy/internal/homepage/icons/list" "github.com/yusing/godoxy/internal/logging" "github.com/yusing/godoxy/internal/logging/memlogger" - "github.com/yusing/godoxy/internal/metrics/systeminfo" - "github.com/yusing/godoxy/internal/metrics/uptime" "github.com/yusing/godoxy/internal/net/gphttp/middleware" "github.com/yusing/godoxy/internal/route/rules" - gperr "github.com/yusing/goutils/errs" - "github.com/yusing/goutils/server" "github.com/yusing/goutils/task" "github.com/yusing/goutils/version" ) @@ -51,7 +47,6 @@ func main() { parallel( dnsproviders.InitProviders, iconlist.InitCache, - systeminfo.Poller.Start, middleware.LoadComposeFiles, ) @@ -66,35 +61,19 @@ func main() { err := config.Load() if err != nil { - gperr.LogWarn("errors in config", err) + if criticalErr, ok := errors.AsType[config.CriticalError](err); ok { + log.Fatal().Err(criticalErr).Msg("critical error in config") + } + log.Warn().Err(err).Msg("errors in config") } - config.StartProxyServers() - if err := auth.Initialize(); err != nil { log.Fatal().Err(err).Msg("failed to initialize authentication") } rules.InitAuthHandler(auth.AuthOrProceed) - // API Handler needs to start after auth is initialized. - server.StartServer(task.RootTask("api_server", false), server.Options{ - Name: "api", - HTTPAddr: common.APIHTTPAddr, - Handler: api.NewHandler(true), - }) - - // Local API Handler is used for unauthenticated access. - if common.LocalAPIHTTPAddr != "" { - server.StartServer(task.RootTask("local_api_server", false), server.Options{ - Name: "local_api", - HTTPAddr: common.LocalAPIHTTPAddr, - Handler: api.NewHandler(false), - }) - } - listenDebugServer() - uptime.Poller.Start() config.WatchChanges() close(done) diff --git a/compose.example.yml b/compose.example.yml index 62f9320b..e4ed368b 100755 --- a/compose.example.yml +++ b/compose.example.yml @@ -31,8 +31,8 @@ services: user: ${GODOXY_UID:-1000}:${GODOXY_GID:-1000} read_only: true tmpfs: - - /app/.next/cache # next image caching - + - /tmp:rw + - /app/node_modules/.cache:rw # for lite variant, do not change uid/gid # - /var/cache/nginx:uid=101,gid=101 # - /run:uid=101,gid=101 diff --git a/go.mod b/go.mod index 18d9959b..d1c62989 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/yusing/godoxy -go 1.25.6 +go 1.26.0 replace ( github.com/coreos/go-oidc/v3 => ./internal/go-oidc @@ -26,15 +26,15 @@ require ( github.com/go-playground/validator/v10 v10.30.1 // validator github.com/gobwas/glob v0.2.3 // glob matcher for route rules github.com/gorilla/websocket v1.5.3 // websocket for API and agent - github.com/gotify/server/v2 v2.8.0 // reference the Message struct for json response + github.com/gotify/server/v2 v2.9.0 // reference the Message struct for json response github.com/lithammer/fuzzysearch v1.1.8 // fuzzy search for searching icons and filtering metrics - github.com/pires/go-proxyproto v0.9.2 // proxy protocol support + github.com/pires/go-proxyproto v0.11.0 // proxy protocol support github.com/puzpuzpuz/xsync/v4 v4.4.0 // lock free map for concurrent operations github.com/rs/zerolog v1.34.0 // logging github.com/vincent-petithory/dataurl v1.0.0 // data url for fav icon - golang.org/x/crypto v0.47.0 // encrypting password with bcrypt - golang.org/x/net v0.49.0 // HTTP header utilities - golang.org/x/oauth2 v0.34.0 // oauth2 authentication + golang.org/x/crypto v0.48.0 // encrypting password with bcrypt + golang.org/x/net v0.50.0 // HTTP header utilities + golang.org/x/oauth2 v0.35.0 // oauth2 authentication golang.org/x/sync v0.19.0 // errgroup and singleflight for concurrent operations golang.org/x/time v0.14.0 // time utilities ) @@ -42,28 +42,28 @@ require ( require ( github.com/bytedance/gopkg v0.1.3 // xxhash64 for fast hash github.com/bytedance/sonic v1.15.0 // indirect; fast json parsing - github.com/docker/cli v29.2.0+incompatible // needs docker/cli/cli/connhelper connection helper for docker client + github.com/docker/cli v29.2.1+incompatible // needs docker/cli/cli/connhelper connection helper for docker client github.com/goccy/go-yaml v1.19.2 // yaml parsing for different config files github.com/golang-jwt/jwt/v5 v5.3.1 github.com/luthermonson/go-proxmox v0.3.2 github.com/oschwald/maxminddb-golang v1.13.1 github.com/quic-go/quic-go v0.59.0 // http3 support - github.com/shirou/gopsutil/v4 v4.25.12 // system information + github.com/shirou/gopsutil/v4 v4.26.1 // system information github.com/spf13/afero v1.15.0 // afero for file system operations github.com/stretchr/testify v1.11.1 // testing framework github.com/valyala/fasthttp v1.69.0 // fast http for health check github.com/yusing/ds v0.4.1 // data structures and algorithms - github.com/yusing/godoxy/agent v0.0.0-20260129101716-0f13004ad6ba - github.com/yusing/godoxy/internal/dnsproviders v0.0.0-20260129101716-0f13004ad6ba - github.com/yusing/gointernals v0.1.16 + github.com/yusing/godoxy/agent v0.0.0-20260216003355-b4a9f44f4ee9 + github.com/yusing/godoxy/internal/dnsproviders v0.0.0-20260216003355-b4a9f44f4ee9 + github.com/yusing/gointernals v0.2.0 github.com/yusing/goutils v0.7.0 - github.com/yusing/goutils/http/reverseproxy v0.0.0-20260129081554-24e52ede7468 - github.com/yusing/goutils/http/websocket v0.0.0-20260129081554-24e52ede7468 - github.com/yusing/goutils/server v0.0.0-20260129081554-24e52ede7468 + github.com/yusing/goutils/http/reverseproxy v0.0.0-20260215081811-494ab85a33ae + github.com/yusing/goutils/http/websocket v0.0.0-20260215081811-494ab85a33ae + github.com/yusing/goutils/server v0.0.0-20260215081811-494ab85a33ae ) require ( - cloud.google.com/go/auth v0.18.1 // indirect + cloud.google.com/go/auth v0.18.2 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/compute/metadata v0.9.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 // indirect @@ -85,7 +85,7 @@ require ( github.com/docker/go-units v0.5.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.12 // indirect + github.com/gabriel-vasile/mimetype v1.4.13 // 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 @@ -94,8 +94,8 @@ 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.11 // indirect - github.com/googleapis/gax-go/v2 v2.16.0 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.12 // indirect + github.com/googleapis/gax-go/v2 v2.17.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-retryablehttp v0.7.8 // indirect github.com/jinzhu/copier v0.4.0 // indirect @@ -121,25 +121,25 @@ require ( github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/quic-go/qpack v0.6.0 // indirect github.com/samber/lo v1.52.0 // indirect - github.com/samber/slog-common v0.19.0 // indirect + github.com/samber/slog-common v0.20.0 // indirect github.com/scaleway/scaleway-sdk-go v1.0.0-beta.36 // indirect github.com/sirupsen/logrus v1.9.4 // indirect 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.64.0 - go.opentelemetry.io/otel v1.39.0 // indirect - go.opentelemetry.io/otel/metric v1.39.0 // indirect - go.opentelemetry.io/otel/trace v1.39.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 + go.opentelemetry.io/otel v1.40.0 // indirect + go.opentelemetry.io/otel/metric v1.40.0 // indirect + go.opentelemetry.io/otel/trace v1.40.0 // indirect go.uber.org/atomic v1.11.0 go.uber.org/ratelimit v0.3.1 // indirect - golang.org/x/mod v0.32.0 // indirect - golang.org/x/sys v0.40.0 // indirect - golang.org/x/text v0.33.0 // indirect - golang.org/x/tools v0.41.0 // indirect - google.golang.org/api v0.263.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect - google.golang.org/grpc v1.78.0 // indirect + golang.org/x/mod v0.33.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.0 // indirect + golang.org/x/tools v0.42.0 // indirect + google.golang.org/api v0.266.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 // indirect + google.golang.org/grpc v1.79.1 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/ini.v1 v1.67.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect @@ -165,37 +165,37 @@ require ( github.com/gin-contrib/sse v1.1.0 // indirect github.com/go-ole/go-ole v1.3.0 // indirect github.com/go-ozzo/ozzo-validation/v4 v4.3.0 // indirect - github.com/go-resty/resty/v2 v2.17.1 // indirect + github.com/go-resty/resty/v2 v2.17.2 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/goccy/go-json v0.10.5 // indirect github.com/google/go-querystring v1.2.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect - github.com/klauspost/compress v1.18.3 // indirect + github.com/klauspost/compress v1.18.4 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/kolo/xmlrpc v0.0.0-20220921171641-a4b6fa1dd06b // indirect - github.com/linode/linodego v1.64.0 // indirect + github.com/linode/linodego v1.65.0 // indirect github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 // indirect github.com/moby/sys/atomicwriter v0.1.0 // indirect github.com/morikuni/aec v1.0.0 // indirect github.com/nrdcg/goinwx v0.12.0 // indirect - github.com/nrdcg/oci-go-sdk/common/v1065 v1065.107.0 // indirect - github.com/nrdcg/oci-go-sdk/dns/v1065 v1065.107.0 // indirect + github.com/nrdcg/oci-go-sdk/common/v1065 v1065.108.1 // indirect + github.com/nrdcg/oci-go-sdk/dns/v1065 v1065.108.1 // indirect github.com/pierrec/lz4/v4 v4.1.21 // indirect - github.com/pion/dtls/v3 v3.0.10 // indirect + github.com/pion/dtls/v3 v3.1.2 // indirect github.com/pion/logging v0.2.4 // indirect github.com/pion/transport/v4 v4.0.1 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/pquerna/otp v1.5.0 // indirect - github.com/samber/slog-zerolog/v2 v2.9.0 // indirect + github.com/samber/slog-zerolog/v2 v2.9.1 // indirect github.com/stretchr/objx v0.5.3 // indirect github.com/tklauser/go-sysconf v0.3.16 // indirect github.com/tklauser/numcpus v0.11.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect - github.com/vultr/govultr/v3 v3.26.1 // indirect + github.com/vultr/govultr/v3 v3.27.0 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0 // indirect go.opentelemetry.io/proto/otlp v1.9.0 // indirect - golang.org/x/arch v0.23.0 // indirect + golang.org/x/arch v0.24.0 // indirect ) diff --git a/go.sum b/go.sum index f0bfad99..b5945b34 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -cloud.google.com/go/auth v0.18.1 h1:IwTEx92GFUo2pJ6Qea0EU3zYvKnTAeRCODxfA/G5UWs= -cloud.google.com/go/auth v0.18.1/go.mod h1:GfTYoS9G3CWpRA3Va9doKN9mjPGRS+v41jmZAhBzbrA= +cloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM= +cloud.google.com/go/auth v0.18.2/go.mod h1:xD+oY7gcahcu7G2SG2DsBerfFxgPAJz17zz2joOFF3M= cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= @@ -80,8 +80,8 @@ 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 v29.2.0+incompatible h1:9oBd9+YM7rxjZLfyMGxjraKBKE4/nVyvVfN4qNl9XRM= -github.com/docker/cli v29.2.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= +github.com/docker/cli v29.2.1+incompatible h1:n3Jt0QVCN65eiVBoUTZQM9mcQICCJt3akW4pKAbKdJg= +github.com/docker/cli v29.2.1+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= @@ -100,8 +100,8 @@ 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.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= -github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= +github.com/gabriel-vasile/mimetype v1.4.13/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= @@ -128,8 +128,8 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= -github.com/go-resty/resty/v2 v2.17.1 h1:x3aMpHK1YM9e4va/TMDRlusDDoZiQ+ViDu/WpA6xTM4= -github.com/go-resty/resty/v2 v2.17.1/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2mLtQrOyQlVA= +github.com/go-resty/resty/v2 v2.17.2 h1:FQW5oHYcIlkCNrMD2lloGScxcHJ0gkjshV3qcQAyHQk= +github.com/go-resty/resty/v2 v2.17.2/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2mLtQrOyQlVA= github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= @@ -157,14 +157,14 @@ 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.11 h1:vAe81Msw+8tKUxi2Dqh/NZMz7475yUvmRIkXr4oN2ao= -github.com/googleapis/enterprise-certificate-proxy v0.3.11/go.mod h1:RFV7MUdlb7AgEq2v7FmMCfeSMCllAzWxFgRdusoGks8= -github.com/googleapis/gax-go/v2 v2.16.0 h1:iHbQmKLLZrexmb0OSsNGTeSTS0HO4YvFOG8g5E4Zd0Y= -github.com/googleapis/gax-go/v2 v2.16.0/go.mod h1:o1vfQjjNZn4+dPnRdl/4ZD7S9414Y4xA+a/6Icj6l14= +github.com/googleapis/enterprise-certificate-proxy v0.3.12 h1:Fg+zsqzYEs1ZnvmcztTYxhgCBsx3eEhEwQ1W/lHq/sQ= +github.com/googleapis/enterprise-certificate-proxy v0.3.12/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= +github.com/googleapis/gax-go/v2 v2.17.0 h1:RksgfBpxqff0EZkDWYuz9q/uWsTVz+kf43LsZ1J6SMc= +github.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOIDp5YKHhb9PAgDY= 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.8.0 h1:E3UDDn/3rFZi1sjZfbuhXNnxJP3ACZhdcw/iySegPRA= -github.com/gotify/server/v2 v2.8.0/go.mod h1:6ci5adxcE2hf1v+2oowKiQmixOxXV8vU+CRLKP6sqZA= +github.com/gotify/server/v2 v2.9.0 h1:2zRCl28wkq0oc6YNbyJS2n0dDOOVvOS3Oez5AG2ij54= +github.com/gotify/server/v2 v2.9.0/go.mod h1:249wwlUqHTr0QsiKARGtFVqds0pNLIMjYLinHyMACdQ= 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= @@ -185,8 +185,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.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw= -github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= +github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= 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/kolo/xmlrpc v0.0.0-20220921171641-a4b6fa1dd06b h1:udzkj9S/zlT5X367kqJis0QP7YMxobob6zhzq6Yre00= @@ -199,8 +199,8 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0 github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= -github.com/linode/linodego v1.64.0 h1:If6pULIwHuQytgogtpQaBdVLX7z2TTHUF5u1tj2TPiY= -github.com/linode/linodego v1.64.0/go.mod h1:GoiwLVuLdBQcAebxAVKVL3mMYUgJZR/puOUSla04xBE= +github.com/linode/linodego v1.65.0 h1:SdsuGD8VSsPWeShXpE7ihl5vec+fD3MgwhnfYC/rj7k= +github.com/linode/linodego v1.65.0/go.mod h1:tOFiTErdjkbVnV+4S0+NmIE9dqqZUEM2HsJaGu8wMh8= 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-20251013123823-9fd1530e3ec3 h1:PwQumkgq4/acIiZhtifTV5OUqqiP82UAl0h87xj/l9k= @@ -241,10 +241,10 @@ 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/goinwx v0.12.0 h1:ujdUqDBnaRSFwzVnImvPHYw3w3m9XgmGImNUw1GyMb4= github.com/nrdcg/goinwx v0.12.0/go.mod h1:IrVKd3ZDbFiMjdPgML4CSxZAY9wOoqLvH44zv3NodJ0= -github.com/nrdcg/oci-go-sdk/common/v1065 v1065.107.0 h1:eMzyN+jGJbxG4ut278uwIsUo9XacXc711lFjhKnaUso= -github.com/nrdcg/oci-go-sdk/common/v1065 v1065.107.0/go.mod h1:Gcs8GCaZXL3FdiDWgdnMxlOLEdRprJJnPYB22TX1jw8= -github.com/nrdcg/oci-go-sdk/dns/v1065 v1065.107.0 h1:t34IpOa+8NfmjkU8bdWtYrLrmr346/FGhu8FlpJDQok= -github.com/nrdcg/oci-go-sdk/dns/v1065 v1065.107.0/go.mod h1:p95/OxVsdx71I2Qrck1GtIS87sRxcTRKXzUi5nWm9NY= +github.com/nrdcg/oci-go-sdk/common/v1065 v1065.108.1 h1:3oOIAQ9Fd2qTKTS/VlWmvKyBPKKhXBcCXjRZqOUypI4= +github.com/nrdcg/oci-go-sdk/common/v1065 v1065.108.1/go.mod h1:Gcs8GCaZXL3FdiDWgdnMxlOLEdRprJJnPYB22TX1jw8= +github.com/nrdcg/oci-go-sdk/dns/v1065 v1065.108.1 h1:2H75475moAv1hVVYlOk815KfqeiFCiQ7ovqn3OnN6FY= +github.com/nrdcg/oci-go-sdk/dns/v1065 v1065.108.1/go.mod h1:9HGOXiiQxcsG+4amgdr4xBIMq6IchdLW/nQDyZz07IE= 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= @@ -259,14 +259,14 @@ github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0 github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ= github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= -github.com/pion/dtls/v3 v3.0.10 h1:k9ekkq1kaZoxnNEbyLKI8DI37j/Nbk1HWmMuywpQJgg= -github.com/pion/dtls/v3 v3.0.10/go.mod h1:YEmmBYIoBsY3jmG56dsziTv/Lca9y4Om83370CXfqJ8= +github.com/pion/dtls/v3 v3.1.2 h1:gqEdOUXLtCGW+afsBLO0LtDD8GnuBBjEy6HRtyofZTc= +github.com/pion/dtls/v3 v3.1.2/go.mod h1:Hw/igcX4pdY69z1Hgv5x7wJFrUkdgHwAn/Q/uo7YHRo= github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8= github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so= github.com/pion/transport/v4 v4.0.1 h1:sdROELU6BZ63Ab7FrOLn13M6YdJLY20wldXW2Cu2k8o= github.com/pion/transport/v4 v4.0.1/go.mod h1:nEuEA4AD5lPdcIegQDpVLgNoDGreqM/YqmEx3ovP4jM= -github.com/pires/go-proxyproto v0.9.2 h1:H1UdHn695zUVVmB0lQ354lOWHOy6TZSpzBl3tgN0s1U= -github.com/pires/go-proxyproto v0.9.2/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU= +github.com/pires/go-proxyproto v0.11.0 h1:gUQpS85X/VJMdUsYyEgyn59uLJvGqPhJV5YvG68wXH4= +github.com/pires/go-proxyproto v0.11.0/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= @@ -293,10 +293,10 @@ github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= 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.9.0 h1:6LkOabJmZdNLaUWkTC3IVVA+dq7b/V0FM6lz6/7+THI= -github.com/samber/slog-zerolog/v2 v2.9.0/go.mod h1:gnQW9VnCfM34v2pRMUIGMsZOVbYLqY/v0Wxu6atSVGc= +github.com/samber/slog-common v0.20.0 h1:WaLnm/aCvBJSk5nR5aXZTFBaV0B47A+AEaEOiZDeUnc= +github.com/samber/slog-common v0.20.0/go.mod h1:+Ozat1jgnnE59UAlmNX1IF3IByHsODnnwf9jUcBZ+m8= +github.com/samber/slog-zerolog/v2 v2.9.1 h1:RMOq8XqzfuGx1X0TEIlS9OXbbFmqLY2/wJppghz66YY= +github.com/samber/slog-zerolog/v2 v2.9.1/go.mod h1:DQYYve14WgCRN/XnKeHl4266jXK0DgYkYXkfZ4Fp98k= github.com/scaleway/scaleway-sdk-go v1.0.0-beta.36 h1:ObX9hZmK+VmijreZO/8x9pQ8/P/ToHD/bdSb4Eg4tUo= github.com/scaleway/scaleway-sdk-go v1.0.0-beta.36/go.mod h1:LEsDu4BubxK7/cWhtlQWfuxwL4rf/2UEpxXz1o1EMtM= github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= @@ -335,8 +335,8 @@ github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZy github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw= github.com/vincent-petithory/dataurl v1.0.0 h1:cXw+kPto8NLuJtlMsI152irrVw9fRDX8AbShPRpg2CI= github.com/vincent-petithory/dataurl v1.0.0/go.mod h1:FHafX5vmDzyP+1CQATJn7WFKc9CvnvxyvZy6I1MrG/U= -github.com/vultr/govultr/v3 v3.26.1 h1:G/M0rMQKwVSmL+gb0UgETbW5mcQi0Vf/o/ZSGdBCxJw= -github.com/vultr/govultr/v3 v3.26.1/go.mod h1:9WwnWGCKnwDlNjHjtt+j+nP+0QWq6hQXzaHgddqrLWY= +github.com/vultr/govultr/v3 v3.27.0 h1:J8etMyu/Jh5+idMsu2YZpOWmDXXHeW4VZnkYXmJYHx8= +github.com/vultr/govultr/v3 v3.27.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= @@ -344,30 +344,30 @@ github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfS github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yusing/ds v0.4.1 h1:syMCh7hO6Yw8xfcFkEaln3W+lVeWB/U/meYv6Wf2/Ig= github.com/yusing/ds v0.4.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/yusing/gointernals v0.2.0 h1:jyWB3kdUPkuU6s0r8QY/sS5h2WNBF4Kfisly8dtSVvg= +github.com/yusing/gointernals v0.2.0/go.mod h1:xGzNbPGMm5Z8kG0t4JYISMscw+gMQlgghkLxlgRZv5Y= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= 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= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 h1:ssfIgGNANqpVFCndZvcuyKbl0g+UAVcbBcqGkG28H0Y= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0/go.mod h1:GQ/474YrbE4Jx8gZ4q5I4hrhUzM6UPzyrqJYV2AqPoQ= -go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= -go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 h1:7iP2uCb7sGddAr30RRS6xjKy7AZ2JtTOPA3oolgVSw8= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0/go.mod h1:c7hN3ddxs/z6q9xwvfLPk+UHlWRQyaeR1LdgfL/66l0= +go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= +go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= 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.38.0 h1:aTL7F04bJHUlztTsNGJ2l+6he8c+y/b//eR0jjjemT4= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0/go.mod h1:kldtb7jDTeol0l3ewcmd8SDvx3EmIE7lyvqbasU3QC4= -go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= -go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= -go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= -go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= -go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= -go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= -go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= -go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= +go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= +go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= +go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= +go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE= +go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw= +go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg= +go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= +go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= @@ -376,23 +376,23 @@ go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= go.uber.org/ratelimit v0.3.1 h1:K4qVE+byfv/B3tC+4nYWP7v/6SimcO7HzHekoMNBma0= go.uber.org/ratelimit v0.3.1/go.mod h1:6euWsTB6U/Nb3X++xEUXA8ciPJvr19Q/0h1+oDcJhRk= -golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg= -golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= +golang.org/x/arch v0.24.0 h1:qlJ3M9upxvFfwRM51tTg3Yl+8CP9vCC1E7vlFpgv99Y= +golang.org/x/arch v0.24.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= -golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= -golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= -golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= @@ -402,10 +402,10 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= -golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= -golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= -golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= -golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= +golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= +golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= +golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -434,8 +434,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= -golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -454,8 +454,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= -golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -464,21 +464,21 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= -golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= -golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/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.263.0 h1:UFs7qn8gInIdtk1ZA6eXRXp5JDAnS4x9VRsRVCeKdbk= -google.golang.org/api v0.263.0/go.mod h1:fAU1xtNNisHgOF5JooAs8rRaTkl2rT3uaoNGo9NS3R8= -google.golang.org/genproto v0.0.0-20251202230838-ff82c1b0f217 h1:GvESR9BIyHUahIb0NcTum6itIWtdoglGX+rnGxm2934= -google.golang.org/genproto v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:yJ2HH4EHEDTd3JiLmhds6NkJ17ITVYOdV3m3VKOnws0= -google.golang.org/genproto/googleapis/api v0.0.0-20251222181119-0a764e51fe1b h1:uA40e2M6fYRBf0+8uN5mLlqUtV192iiksiICIBkYJ1E= -google.golang.org/genproto/googleapis/api v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:Xa7le7qx2vmqB/SzWUBa7KdMjpdpAHlh5QCSnjessQk= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= -google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= -google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= +google.golang.org/api v0.266.0 h1:hco+oNCf9y7DmLeAtHJi/uBAY7n/7XC9mZPxu1ROiyk= +google.golang.org/api v0.266.0/go.mod h1:Jzc0+ZfLnyvXma3UtaTl023TdhZu6OMBP9tJ+0EmFD0= +google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 h1:VQZ/yAbAtjkHgH80teYd2em3xtIkkHd7ZhqfH2N9CsM= +google.golang.org/genproto v0.0.0-20260128011058-8636f8732409/go.mod h1:rxKD3IEILWEu3P44seeNOAwZN4SaoKaQ/2eTg4mM6EM= +google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M= +google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 h1:mWPCjDEyshlQYzBpMNHaEof6UX1PmHcaUODUywQ0uac= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= +google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/goutils b/goutils index e5fba769..494ab85a 160000 --- a/goutils +++ b/goutils @@ -1 +1 @@ -Subproject commit e5fba76994e449570fe8a9221226d03cfeaf1c45 +Subproject commit 494ab85a33aea48b8be5bcd08a506596727248f4 diff --git a/internal/acl/README.md b/internal/acl/README.md index 91426367..b01c2722 100644 --- a/internal/acl/README.md +++ b/internal/acl/README.md @@ -54,13 +54,13 @@ type Matchers []Matcher ### Exported functions and methods ```go -func (c *Config) Validate() gperr.Error +func (c *Config) Validate() error ``` Validates configuration and sets defaults. Must be called before `Start`. ```go -func (c *Config) Start(parent task.Parent) gperr.Error +func (c *Config) Start(parent task.Parent) error ``` Initializes the ACL, starts the logger and notification goroutines. @@ -169,14 +169,14 @@ Configuration is loaded from `config/config.yml` under the `acl` key. ```yaml acl: - default: "allow" # "allow" or "deny" - allow_local: true # Allow private/loopback IPs + default: "allow" # "allow" or "deny" + allow_local: true # Allow private/loopback IPs log: - log_allowed: false # Log allowed connections + log_allowed: false # Log allowed connections notify: - to: ["gotify"] # Notification providers - interval: "1m" # Notification interval - include_allowed: false # Include allowed in notifications + to: ["gotify"] # Notification providers + interval: "1m" # Notification interval + include_allowed: false # Include allowed in notifications ``` ### Hot-reloading diff --git a/internal/acl/config.go b/internal/acl/config.go index b608a621..84aed723 100644 --- a/internal/acl/config.go +++ b/internal/acl/config.go @@ -14,6 +14,7 @@ import ( "github.com/yusing/godoxy/internal/maxmind" "github.com/yusing/godoxy/internal/notif" gperr "github.com/yusing/goutils/errs" + aclevents "github.com/yusing/goutils/events/acl" strutils "github.com/yusing/goutils/strings" "github.com/yusing/goutils/task" ) @@ -66,16 +67,16 @@ type config struct { type checkCache struct { *maxmind.IPInfo allow bool + reason string created time.Time } type ipLog struct { info *maxmind.IPInfo allowed bool + reason string } -type ContextKey struct{} - const cacheTTL = 1 * time.Minute func (c *checkCache) Expired() bool { @@ -89,7 +90,7 @@ const ( ACLDeny = "deny" ) -func (c *Config) Validate() gperr.Error { +func (c *Config) Validate() error { switch c.Default { case "", ACLAllow: c.defaultAllow = true @@ -133,7 +134,10 @@ func (c *Config) Valid() bool { return c != nil && c.valErr == nil } -func (c *Config) Start(parent task.Parent) gperr.Error { +func (c *Config) Start(parent task.Parent) error { + if c.valErr != nil { + return c.valErr + } if c.Log != nil { logger, err := accesslog.NewAccessLogger(parent, c.Log) if err != nil { @@ -141,9 +145,6 @@ func (c *Config) Start(parent task.Parent) gperr.Error { } c.logger = logger } - if c.valErr != nil { - return c.valErr - } if c.needLogOrNotify() { c.logNotifyCh = make(chan ipLog, 100) @@ -170,13 +171,14 @@ func (c *Config) Start(parent task.Parent) gperr.Error { return nil } -func (c *Config) cacheRecord(info *maxmind.IPInfo, allow bool) { +func (c *Config) cacheRecord(info *maxmind.IPInfo, allow bool, reason string) { if common.ForceResolveCountry && info.City == nil { maxmind.LookupCity(info) } c.ipCache.Store(info.Str, &checkCache{ IPInfo: info, allow: allow, + reason: reason, created: time.Now(), }) } @@ -213,23 +215,26 @@ func (c *Config) logNotifyLoop(parent task.Parent) { select { case <-parent.Context().Done(): return - case log := <-c.logNotifyCh: + case req := <-c.logNotifyCh: if c.logger != nil { - if !log.allowed || c.logAllowed { - c.logger.LogACL(log.info, !log.allowed) + if !req.allowed || c.logAllowed { + c.logger.LogACL(req.info, !req.allowed, req.reason) } } if c.needNotify() { - if log.allowed { + if req.allowed { if c.notifyAllowed { - c.allowedCount[log.info.Str]++ + c.allowedCount[req.info.Str]++ c.totalAllowedCount++ } } else { - c.blockedCount[log.info.Str]++ + c.blockedCount[req.info.Str]++ c.totalBlockedCount++ } } + if !req.allowed { + aclevents.Blocked(req.info.Str, req.reason) + } case <-c.notifyTicker.C: // will never tick when notify is disabled total := len(c.allowedCount) + len(c.blockedCount) if total == 0 { @@ -261,9 +266,9 @@ func (c *Config) logNotifyLoop(parent task.Parent) { } // log and notify if needed -func (c *Config) logAndNotify(info *maxmind.IPInfo, allowed bool) { +func (c *Config) logAndNotify(info *maxmind.IPInfo, allowed bool, reason string) { if c.logNotifyCh != nil { - c.logNotifyCh <- ipLog{info: info, allowed: allowed} + c.logNotifyCh <- ipLog{info: info, allowed: allowed, reason: reason} } } @@ -278,30 +283,36 @@ func (c *Config) IPAllowed(ip net.IP) bool { } if c.allowLocal && ip.IsPrivate() { - c.logAndNotify(&maxmind.IPInfo{IP: ip, Str: ip.String()}, true) + c.logAndNotify(&maxmind.IPInfo{IP: ip, Str: ip.String()}, true, "allowed by allow_local rule") return true } ipStr := ip.String() record, ok := c.ipCache.Load(ipStr) if ok && !record.Expired() { - c.logAndNotify(record.IPInfo, record.allow) + c.logAndNotify(record.IPInfo, record.allow, record.reason) return record.allow } ipAndStr := &maxmind.IPInfo{IP: ip, Str: ipStr} - if c.Deny.Match(ipAndStr) { - c.logAndNotify(ipAndStr, false) - c.cacheRecord(ipAndStr, false) + if index := c.Deny.MatchedIndex(ipAndStr); index != -1 { + reason := "blocked by deny rule: " + c.Deny[index].raw + c.logAndNotify(ipAndStr, false, reason) + c.cacheRecord(ipAndStr, false, reason) return false } - if c.Allow.Match(ipAndStr) { - c.logAndNotify(ipAndStr, true) - c.cacheRecord(ipAndStr, true) + if index := c.Allow.MatchedIndex(ipAndStr); index != -1 { + reason := "allowed by allow rule: " + c.Allow[index].raw + c.logAndNotify(ipAndStr, true, reason) + c.cacheRecord(ipAndStr, true, reason) return true } - c.logAndNotify(ipAndStr, c.defaultAllow) - c.cacheRecord(ipAndStr, c.defaultAllow) + reason := "denied by default" + if c.defaultAllow { + reason = "allowed by default" + } + c.logAndNotify(ipAndStr, c.defaultAllow, reason) + c.cacheRecord(ipAndStr, c.defaultAllow, reason) return c.defaultAllow } diff --git a/internal/acl/matcher.go b/internal/acl/matcher.go index e5cc3ae2..5c390f4e 100644 --- a/internal/acl/matcher.go +++ b/internal/acl/matcher.go @@ -2,6 +2,7 @@ package acl import ( "bytes" + "errors" "net" "strings" @@ -38,9 +39,9 @@ var errMatcherFormat = gperr.Multiline().AddLines( ) var ( - errSyntax = gperr.New("syntax error") - errInvalidIP = gperr.New("invalid IP") - errInvalidCIDR = gperr.New("invalid CIDR") + errSyntax = errors.New("syntax error") + errInvalidIP = errors.New("invalid IP") + errInvalidCIDR = errors.New("invalid CIDR") ) func (matcher *Matcher) Parse(s string) error { @@ -82,6 +83,15 @@ func (matchers Matchers) Match(ip *maxmind.IPInfo) bool { return false } +func (matchers Matchers) MatchedIndex(ip *maxmind.IPInfo) int { + for i, m := range matchers { + if m.match(ip) { + return i + } + } + return -1 +} + func (matchers Matchers) MarshalText() ([]byte, error) { if len(matchers) == 0 { return []byte("[]"), nil diff --git a/internal/acl/tcp_listener.go b/internal/acl/tcp_listener.go index 0597211c..d64461a1 100644 --- a/internal/acl/tcp_listener.go +++ b/internal/acl/tcp_listener.go @@ -5,6 +5,8 @@ import ( "io" "net" "time" + + "github.com/rs/zerolog/log" ) type TCPListener struct { @@ -44,6 +46,7 @@ func (s *TCPListener) Accept() (net.Conn, error) { } addr, ok := c.RemoteAddr().(*net.TCPAddr) if !ok { + log.Error().Msgf("unexpected remote address type: %T, addr: %s", c.RemoteAddr(), c.RemoteAddr().String()) // Not a TCPAddr, drop c.Close() return noConn{}, nil diff --git a/internal/acl/types/acl.go b/internal/acl/types/acl.go new file mode 100644 index 00000000..414821ec --- /dev/null +++ b/internal/acl/types/acl.go @@ -0,0 +1,9 @@ +package acl + +import "net" + +type ACL interface { + IPAllowed(ip net.IP) bool + WrapTCP(l net.Listener) net.Listener + WrapUDP(l net.PacketConn) net.PacketConn +} diff --git a/internal/acl/types/context.go b/internal/acl/types/context.go new file mode 100644 index 00000000..fe65b807 --- /dev/null +++ b/internal/acl/types/context.go @@ -0,0 +1,16 @@ +package acl + +import "context" + +type ContextKey struct{} + +func SetCtx(ctx interface{ SetValue(any, any) }, acl ACL) { + ctx.SetValue(ContextKey{}, acl) +} + +func FromCtx(ctx context.Context) ACL { + if acl, ok := ctx.Value(ContextKey{}).(ACL); ok { + return acl + } + return nil +} diff --git a/internal/acl/udp_listener.go b/internal/acl/udp_listener.go index 7818bd41..76d1c588 100644 --- a/internal/acl/udp_listener.go +++ b/internal/acl/udp_listener.go @@ -4,6 +4,8 @@ import ( "errors" "net" "time" + + "github.com/rs/zerolog/log" ) type UDPListener struct { @@ -33,6 +35,7 @@ func (s *UDPListener) ReadFrom(p []byte) (int, net.Addr, error) { } udpAddr, ok := addr.(*net.UDPAddr) if !ok { + log.Error().Msgf("unexpected remote address type: %T, addr: %s", addr, addr.String()) // Not a UDPAddr, drop continue } @@ -52,6 +55,7 @@ func (s *UDPListener) WriteTo(p []byte, addr net.Addr) (int, error) { } udpAddr, ok := addr.(*net.UDPAddr) if !ok { + log.Error().Msgf("unexpected remote address type: %T, addr: %s", addr, addr.String()) // Not a UDPAddr, drop continue } diff --git a/internal/api/handler.go b/internal/api/handler.go index 9eaba538..00c435ac 100644 --- a/internal/api/handler.go +++ b/internal/api/handler.go @@ -19,22 +19,23 @@ import ( "github.com/yusing/godoxy/internal/auth" "github.com/yusing/godoxy/internal/common" apitypes "github.com/yusing/goutils/apitypes" - gperr "github.com/yusing/goutils/errs" ) +// NewHandler creates a new Gin engine for the API. +// // @title GoDoxy API // @version 1.0 // @description GoDoxy API // @termsOfService https://github.com/yusing/godoxy/blob/main/LICENSE - +// // @contact.name Yusing // @contact.url https://github.com/yusing/godoxy/issues - +// // @license.name MIT // @license.url https://github.com/yusing/godoxy/blob/main/LICENSE - +// // @BasePath /api/v1 - +// // @externalDocs.description GoDoxy Docs // @externalDocs.url https://docs.godoxy.dev func NewHandler(requireAuth bool) *gin.Engine { @@ -72,8 +73,8 @@ func NewHandler(requireAuth bool) *gin.Engine { v1.GET("/favicon", apiV1.FavIcon) v1.GET("/health", apiV1.Health) v1.GET("/icons", apiV1.Icons) - v1.POST("/reload", apiV1.Reload) v1.GET("/stats", apiV1.Stats) + v1.GET("/events", apiV1.Events) route := v1.Group("/route") { @@ -200,9 +201,8 @@ func ErrorHandler() gin.HandlerFunc { return func(c *gin.Context) { c.Next() if len(c.Errors) > 0 { - logger := log.With().Str("uri", c.Request.RequestURI).Logger() for _, err := range c.Errors { - gperr.LogError("Internal error", err.Err, &logger) + log.Err(err.Err).Str("uri", c.Request.RequestURI).Msg("Internal error") } if !c.IsWebsocket() { c.JSON(http.StatusInternalServerError, apitypes.Error("Internal server error")) diff --git a/internal/api/v1/agent/verify.go b/internal/api/v1/agent/verify.go index 7dbcc2d2..3dd42b67 100644 --- a/internal/api/v1/agent/verify.go +++ b/internal/api/v1/agent/verify.go @@ -2,6 +2,7 @@ package agentapi import ( "context" + "errors" "fmt" "net/http" "os" @@ -13,7 +14,6 @@ import ( config "github.com/yusing/godoxy/internal/config/types" "github.com/yusing/godoxy/internal/route/provider" apitypes "github.com/yusing/goutils/apitypes" - gperr "github.com/yusing/goutils/errs" ) type VerifyNewAgentRequest struct { @@ -84,9 +84,9 @@ func Verify(c *gin.Context) { c.JSON(http.StatusOK, apitypes.Success(fmt.Sprintf("Added %d routes", nRoutesAdded))) } -var errAgentAlreadyExists = gperr.New("agent already exists") +var errAgentAlreadyExists = errors.New("agent already exists") -func verifyNewAgent(ctx context.Context, host string, ca agent.PEMPair, client agent.PEMPair, containerRuntime agent.ContainerRuntime) (int, gperr.Error) { +func verifyNewAgent(ctx context.Context, host string, ca agent.PEMPair, client agent.PEMPair, containerRuntime agent.ContainerRuntime) (int, error) { var agentCfg agent.AgentConfig agentCfg.Addr = host agentCfg.Runtime = containerRuntime @@ -105,12 +105,12 @@ func verifyNewAgent(ctx context.Context, host string, ca agent.PEMPair, client a err := agentCfg.InitWithCerts(ctx, ca.Cert, client.Cert, client.Key) if err != nil { - return 0, gperr.Wrap(err, "failed to initialize agent config") + return 0, fmt.Errorf("failed to initialize agent config: %w", err) } provider := provider.NewAgentProvider(&agentCfg) if _, loaded := cfgState.LoadOrStoreProvider(provider.String(), provider); loaded { - return 0, gperr.Errorf("provider %s already exists", provider.String()) + return 0, fmt.Errorf("provider %s already exists", provider.String()) } // agent must be added before loading routes @@ -122,7 +122,7 @@ func verifyNewAgent(ctx context.Context, host string, ca agent.PEMPair, client a if err != nil { cfgState.DeleteProvider(provider.String()) agentpool.Remove(&agentCfg) - return 0, gperr.Wrap(err, "failed to load routes") + return 0, fmt.Errorf("failed to load routes: %w", err) } return provider.NumRoutes(), nil diff --git a/internal/api/v1/cert/info.go b/internal/api/v1/cert/info.go index 412642e7..8e7ffffc 100644 --- a/internal/api/v1/cert/info.go +++ b/internal/api/v1/cert/info.go @@ -6,6 +6,7 @@ import ( "github.com/gin-gonic/gin" "github.com/yusing/godoxy/internal/autocert" + autocertctx "github.com/yusing/godoxy/internal/autocert/types" apitypes "github.com/yusing/goutils/apitypes" ) @@ -21,7 +22,7 @@ import ( // @Failure 500 {object} apitypes.ErrorResponse "Internal server error" // @Router /cert/info [get] func Info(c *gin.Context) { - provider := autocert.ActiveProvider.Load() + provider := autocertctx.FromCtx(c.Request.Context()) if provider == nil { c.JSON(http.StatusNotFound, apitypes.Error("autocert is not enabled")) return diff --git a/internal/api/v1/cert/renew.go b/internal/api/v1/cert/renew.go index d81e2562..33232aeb 100644 --- a/internal/api/v1/cert/renew.go +++ b/internal/api/v1/cert/renew.go @@ -6,7 +6,7 @@ import ( "github.com/gin-gonic/gin" "github.com/rs/zerolog/log" - "github.com/yusing/godoxy/internal/autocert" + autocertctx "github.com/yusing/godoxy/internal/autocert/types" "github.com/yusing/godoxy/internal/logging/memlogger" apitypes "github.com/yusing/goutils/apitypes" "github.com/yusing/goutils/http/websocket" @@ -23,8 +23,8 @@ import ( // @Failure 500 {object} apitypes.ErrorResponse // @Router /cert/renew [get] func Renew(c *gin.Context) { - autocert := autocert.ActiveProvider.Load() - if autocert == nil { + provider := autocertctx.FromCtx(c.Request.Context()) + if provider == nil { c.JSON(http.StatusNotFound, apitypes.Error("autocert is not enabled")) return } @@ -59,7 +59,7 @@ func Renew(c *gin.Context) { }() // renewal happens in background - ok := autocert.ForceExpiryAll() + ok := provider.ForceExpiryAll() if !ok { log.Error().Msg("cert renewal already in progress") time.Sleep(1 * time.Second) // wait for the log above to be sent @@ -67,5 +67,5 @@ func Renew(c *gin.Context) { } log.Info().Msg("cert force renewal requested") - autocert.WaitRenewalDone(manager.Context()) + provider.WaitRenewalDone(manager.Context()) } diff --git a/internal/api/v1/docker/containers.go b/internal/api/v1/docker/containers.go index 4d6c1d05..c6ddfa56 100644 --- a/internal/api/v1/docker/containers.go +++ b/internal/api/v1/docker/containers.go @@ -6,6 +6,8 @@ import ( "github.com/docker/docker/api/types/container" "github.com/gin-gonic/gin" + "github.com/moby/moby/api/types/container" + "github.com/rs/zerolog/log" gperr "github.com/yusing/goutils/errs" _ "github.com/yusing/goutils/apitypes" @@ -35,18 +37,18 @@ func Containers(c *gin.Context) { serveHTTP[Container](c, GetContainers) } -func GetContainers(ctx context.Context, dockerClients DockerClients) ([]Container, gperr.Error) { +func GetContainers(ctx context.Context, dockerClients DockerClients) ([]Container, error) { errs := gperr.NewBuilder("failed to get containers") containers := make([]Container, 0) for server, dockerClient := range dockerClients { conts, err := dockerClient.ContainerList(ctx, container.ListOptions{All: true}) if err != nil { - errs.Add(err) + errs.AddSubject(err, name) continue } for _, cont := range conts { containers = append(containers, Container{ - Server: server, + Server: name, Name: cont.Names[0], ID: cont.ID, Image: cont.Image, @@ -58,11 +60,10 @@ func GetContainers(ctx context.Context, dockerClients DockerClients) ([]Containe return containers[i].Name < containers[j].Name }) if err := errs.Error(); err != nil { - gperr.LogError("failed to get containers", err) - if len(containers) == 0 { - return nil, err + if len(containers) > 0 { + log.Err(err).Msg("failed to get containers from some servers") + return containers, nil } - return containers, nil } - return containers, nil + return containers, errs.Error() } diff --git a/internal/api/v1/docker/info.go b/internal/api/v1/docker/info.go index f5f79248..baf19086 100644 --- a/internal/api/v1/docker/info.go +++ b/internal/api/v1/docker/info.go @@ -58,7 +58,7 @@ func Info(c *gin.Context) { serveHTTP[dockerInfo](c, GetDockerInfo) } -func GetDockerInfo(ctx context.Context, dockerClients DockerClients) ([]dockerInfo, gperr.Error) { +func GetDockerInfo(ctx context.Context, dockerClients DockerClients) ([]dockerInfo, error) { errs := gperr.NewBuilder("failed to get docker info") dockerInfos := make([]dockerInfo, len(dockerClients)) @@ -66,7 +66,7 @@ func GetDockerInfo(ctx context.Context, dockerClients DockerClients) ([]dockerIn for name, dockerClient := range dockerClients { info, err := dockerClient.Info(ctx) if err != nil { - errs.Add(err) + errs.AddSubject(err, name) continue } info.Name = name diff --git a/internal/api/v1/docker/stats.go b/internal/api/v1/docker/stats.go index 3e5acd51..cb47f5da 100644 --- a/internal/api/v1/docker/stats.go +++ b/internal/api/v1/docker/stats.go @@ -9,7 +9,7 @@ import ( "github.com/gin-gonic/gin" "github.com/moby/moby/api/types/container" "github.com/yusing/godoxy/internal/docker" - "github.com/yusing/godoxy/internal/route/routes" + entrypoint "github.com/yusing/godoxy/internal/entrypoint/types" "github.com/yusing/godoxy/internal/types" apitypes "github.com/yusing/goutils/apitypes" "github.com/yusing/goutils/http/httpheaders" @@ -43,7 +43,7 @@ func Stats(c *gin.Context) { dockerCfg, ok := docker.GetDockerCfgByContainerID(id) if !ok { var route types.Route - route, ok = routes.GetIncludeExcluded(id) + route, ok = entrypoint.FromCtx(c.Request.Context()).GetRoute(id) if ok { cont := route.ContainerInfo() if cont == nil { diff --git a/internal/api/v1/docker/utils.go b/internal/api/v1/docker/utils.go index 328d19e4..657848d9 100644 --- a/internal/api/v1/docker/utils.go +++ b/internal/api/v1/docker/utils.go @@ -8,7 +8,6 @@ import ( "github.com/gin-gonic/gin" "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" ) @@ -39,7 +38,7 @@ func handleResult[V any, T ResultType[V]](c *gin.Context, errs error, result T) c.JSON(http.StatusOK, result) } -func serveHTTP[V any, T ResultType[V]](c *gin.Context, getResult func(ctx context.Context, dockerClients DockerClients) (T, gperr.Error)) { +func serveHTTP[V any, T ResultType[V]](c *gin.Context, getResult func(ctx context.Context, dockerClients DockerClients) (T, error)) { dockerClients := docker.Clients() defer closeAllClients(dockerClients) diff --git a/internal/api/v1/docs/swagger.json b/internal/api/v1/docs/swagger.json index dd6544c7..6964777a 100644 --- a/internal/api/v1/docs/swagger.json +++ b/internal/api/v1/docs/swagger.json @@ -837,6 +837,45 @@ "operationId": "stop" } }, + "/events": { + "get": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "v1" + ], + "summary": "Get events history", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/Event" + } + } + }, + "403": { + "description": "Forbidden: unauthorized", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error: internal error", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + }, + "x-id": "events", + "operationId": "events" + } + }, "/favicon": { "get": { "description": "Get favicon", @@ -1219,6 +1258,12 @@ "schema": { "$ref": "#/definitions/ErrorResponse" } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } } }, "x-id": "categories", @@ -1337,6 +1382,12 @@ "schema": { "$ref": "#/definitions/ErrorResponse" } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } } }, "x-id": "items", @@ -2820,43 +2871,6 @@ "operationId": "tail" } }, - "/reload": { - "post": { - "description": "Reload config", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "v1" - ], - "summary": "Reload config", - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/SuccessResponse" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/ErrorResponse" - } - } - }, - "x-id": "reload", - "operationId": "reload" - } - }, "/route/by_provider": { "get": { "description": "List routes by provider", @@ -3810,6 +3824,42 @@ "x-nullable": false, "x-omitempty": false }, + "Event": { + "type": "object", + "properties": { + "action": { + "type": "string", + "x-nullable": false, + "x-omitempty": false + }, + "category": { + "type": "string", + "x-nullable": false, + "x-omitempty": false + }, + "data": { + "x-nullable": false, + "x-omitempty": false + }, + "level": { + "$ref": "#/definitions/events.Level", + "x-nullable": false, + "x-omitempty": false + }, + "timestamp": { + "type": "string", + "x-nullable": false, + "x-omitempty": false + }, + "uuid": { + "type": "string", + "x-nullable": false, + "x-omitempty": false + } + }, + "x-nullable": false, + "x-omitempty": false + }, "FileType": { "type": "string", "enum": [ @@ -3979,7 +4029,6 @@ "type": "object", "properties": { "latency": { - "description": "latency in microseconds", "type": "number", "x-nullable": false, "x-omitempty": false @@ -3998,7 +4047,6 @@ "x-omitempty": false }, "uptime": { - "description": "uptime in milliseconds", "type": "number", "x-nullable": false, "x-omitempty": false @@ -4074,7 +4122,7 @@ "HealthMap": { "type": "object", "additionalProperties": { - "$ref": "#/definitions/HealthStatusString" + "$ref": "#/definitions/HealthInfoWithoutDetail" }, "x-nullable": false, "x-omitempty": false @@ -5059,6 +5107,7 @@ "x-omitempty": false }, "validationError": { + "description": "we need the structured error, not the plain string", "x-nullable": false, "x-omitempty": false } @@ -5094,6 +5143,7 @@ "type": "object", "properties": { "executionError": { + "description": "we need the structured error, not the plain string", "x-nullable": false, "x-omitempty": false }, @@ -5329,7 +5379,6 @@ "x-omitempty": false }, "bind": { - "description": "for TCP and UDP routes, bind address to listen on", "type": "string", "x-nullable": true }, @@ -6691,6 +6740,23 @@ "x-nullable": false, "x-omitempty": false }, + "events.Level": { + "type": "string", + "enum": [ + "debug", + "info", + "warn", + "error" + ], + "x-enum-varnames": [ + "LevelDebug", + "LevelInfo", + "LevelWarn", + "LevelError" + ], + "x-nullable": false, + "x-omitempty": false + }, "icons.Source": { "type": "string", "enum": [ diff --git a/internal/api/v1/docs/swagger.yaml b/internal/api/v1/docs/swagger.yaml index 0bacaada..8e5b39ff 100644 --- a/internal/api/v1/docs/swagger.yaml +++ b/internal/api/v1/docs/swagger.yaml @@ -295,6 +295,20 @@ definitions: message: type: string type: object + Event: + properties: + action: + type: string + category: + type: string + data: {} + level: + $ref: '#/definitions/events.Level' + timestamp: + type: string + uuid: + type: string + type: object FileType: enum: - config @@ -375,7 +389,6 @@ definitions: HealthInfoWithoutDetail: properties: latency: - description: latency in microseconds type: number status: enum: @@ -387,7 +400,6 @@ definitions: - unknown type: string uptime: - description: uptime in milliseconds type: number type: object HealthJSON: @@ -421,7 +433,7 @@ definitions: type: object HealthMap: additionalProperties: - $ref: '#/definitions/HealthStatusString' + $ref: '#/definitions/HealthInfoWithoutDetail' type: object HealthStatusString: enum: @@ -882,7 +894,8 @@ definitions: type: string "on": type: string - validationError: {} + validationError: + description: we need the structured error, not the plain string type: object PlaygroundRequest: properties: @@ -899,7 +912,8 @@ definitions: type: object PlaygroundResponse: properties: - executionError: {} + executionError: + description: we need the structured error, not the plain string finalRequest: $ref: '#/definitions/FinalRequest' finalResponse: @@ -1007,7 +1021,6 @@ definitions: alias: type: string bind: - description: for TCP and UDP routes, bind address to listen on type: string x-nullable: true container: @@ -1746,6 +1759,18 @@ definitions: required: - id type: object + events.Level: + enum: + - debug + - info + - warn + - error + type: string + x-enum-varnames: + - LevelDebug + - LevelInfo + - LevelWarn + - LevelError icons.Source: enum: - https:// @@ -2452,6 +2477,31 @@ paths: tags: - docker x-id: stop + /events: + get: + consumes: + - application/json + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/Event' + type: array + "403": + description: 'Forbidden: unauthorized' + schema: + $ref: '#/definitions/ErrorResponse' + "500": + description: 'Internal Server Error: internal error' + schema: + $ref: '#/definitions/ErrorResponse' + summary: Get events history + tags: + - v1 + x-id: events /favicon: get: consumes: @@ -2707,6 +2757,10 @@ paths: description: Forbidden schema: $ref: '#/definitions/ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/ErrorResponse' summary: List homepage categories tags: - homepage @@ -2784,6 +2838,10 @@ paths: description: Forbidden schema: $ref: '#/definitions/ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/ErrorResponse' summary: Homepage items tags: - homepage @@ -3790,30 +3848,6 @@ paths: - proxmox - websocket x-id: tail - /reload: - post: - consumes: - - application/json - description: Reload config - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/SuccessResponse' - "403": - description: Forbidden - schema: - $ref: '#/definitions/ErrorResponse' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/ErrorResponse' - summary: Reload config - tags: - - v1 - x-id: reload /route/{which}: get: consumes: diff --git a/internal/api/v1/events.go b/internal/api/v1/events.go new file mode 100644 index 00000000..35cff7c8 --- /dev/null +++ b/internal/api/v1/events.go @@ -0,0 +1,44 @@ +package v1 + +import ( + "context" + "errors" + "net/http" + + "github.com/gin-gonic/gin" + apitypes "github.com/yusing/goutils/apitypes" + "github.com/yusing/goutils/events" + "github.com/yusing/goutils/http/httpheaders" + "github.com/yusing/goutils/http/websocket" +) + +// @x-id "events" +// @BasePath /api/v1 +// @Summary Get events history +// @Tags v1 +// @Accept json +// @Produce json +// @Success 200 {array} events.Event +// @Failure 403 {object} apitypes.ErrorResponse "Forbidden: unauthorized" +// @Failure 500 {object} apitypes.ErrorResponse "Internal Server Error: internal error" +// @Router /events [get] +func Events(c *gin.Context) { + if !httpheaders.IsWebsocket(c.Request.Header) { + c.JSON(http.StatusOK, events.Global.Get()) + return + } + + manager, err := websocket.NewManagerWithUpgrade(c) + if err != nil { + c.Error(apitypes.InternalServerError(err, "failed to upgrade to websocket")) + return + } + defer manager.Close() + + writer := manager.NewWriter(websocket.TextMessage) + err = events.Global.ListenJSON(c.Request.Context(), writer) + if err != nil && !errors.Is(err, context.Canceled) { + c.Error(apitypes.InternalServerError(err, "failed to listen to events")) + return + } +} diff --git a/internal/api/v1/favicon.go b/internal/api/v1/favicon.go index ea5b9dff..6619eeb4 100644 --- a/internal/api/v1/favicon.go +++ b/internal/api/v1/favicon.go @@ -5,9 +5,9 @@ import ( "net/http" "github.com/gin-gonic/gin" + entrypoint "github.com/yusing/godoxy/internal/entrypoint/types" "github.com/yusing/godoxy/internal/homepage/icons" iconfetch "github.com/yusing/godoxy/internal/homepage/icons/fetch" - "github.com/yusing/godoxy/internal/route/routes" apitypes "github.com/yusing/goutils/apitypes" _ "unsafe" @@ -73,7 +73,11 @@ func FavIcon(c *gin.Context) { //go:linkname GetFavIconFromAlias v1.GetFavIconFromAlias func GetFavIconFromAlias(ctx context.Context, alias string, variant icons.Variant) (iconfetch.Result, error) { // try with route.Icon - r, ok := routes.HTTP.Get(alias) + ep := entrypoint.FromCtx(ctx) + if ep == nil { // impossible, but just in case + return iconfetch.FetchResultWithErrorf(http.StatusInternalServerError, "entrypoint not initialized") + } + r, ok := ep.HTTPRoutes().Get(alias) if !ok { return iconfetch.FetchResultWithErrorf(http.StatusNotFound, "route not found") } diff --git a/internal/api/v1/file/validate.go b/internal/api/v1/file/validate.go index e2fa07dc..62a78822 100644 --- a/internal/api/v1/file/validate.go +++ b/internal/api/v1/file/validate.go @@ -51,7 +51,7 @@ func Validate(c *gin.Context) { c.JSON(http.StatusOK, apitypes.Success("file validated")) } -func validateFile(fileType FileType, content []byte) gperr.Error { +func validateFile(fileType FileType, content []byte) error { switch fileType { case FileTypeConfig: return config.Validate(content) diff --git a/internal/api/v1/health.go b/internal/api/v1/health.go index 6fc19bfa..62c48b01 100644 --- a/internal/api/v1/health.go +++ b/internal/api/v1/health.go @@ -5,13 +5,15 @@ import ( "time" "github.com/gin-gonic/gin" - "github.com/yusing/godoxy/internal/route/routes" + entrypoint "github.com/yusing/godoxy/internal/entrypoint/types" + "github.com/yusing/godoxy/internal/types" + "github.com/yusing/goutils/apitypes" "github.com/yusing/goutils/http/httpheaders" "github.com/yusing/goutils/http/websocket" - - _ "github.com/yusing/goutils/apitypes" ) +type HealthMap = map[string]types.HealthInfoWithoutDetail // @name HealthMap + // @x-id "health" // @BasePath /api/v1 // @Summary Get routes health info @@ -19,16 +21,21 @@ import ( // @Tags v1,websocket // @Accept json // @Produce json -// @Success 200 {object} routes.HealthMap "Health info by route name" +// @Success 200 {object} HealthMap "Health info by route name" // @Failure 403 {object} apitypes.ErrorResponse // @Failure 500 {object} apitypes.ErrorResponse // @Router /health [get] func Health(c *gin.Context) { + ep := entrypoint.FromCtx(c.Request.Context()) + if ep == nil { // impossible, but just in case + c.JSON(http.StatusInternalServerError, apitypes.Error("entrypoint not initialized")) + return + } if httpheaders.IsWebsocket(c.Request.Header) { websocket.PeriodicWrite(c, 1*time.Second, func() (any, error) { - return routes.GetHealthInfoSimple(), nil + return ep.GetHealthInfoWithoutDetail(), nil }) } else { - c.JSON(http.StatusOK, routes.GetHealthInfoSimple()) + c.JSON(http.StatusOK, ep.GetHealthInfoWithoutDetail()) } } diff --git a/internal/api/v1/homepage/categories.go b/internal/api/v1/homepage/categories.go index 1180ce2b..b5e17eea 100644 --- a/internal/api/v1/homepage/categories.go +++ b/internal/api/v1/homepage/categories.go @@ -4,10 +4,10 @@ import ( "net/http" "github.com/gin-gonic/gin" + entrypoint "github.com/yusing/godoxy/internal/entrypoint/types" "github.com/yusing/godoxy/internal/homepage" - "github.com/yusing/godoxy/internal/route/routes" - _ "github.com/yusing/goutils/apitypes" + apitypes "github.com/yusing/goutils/apitypes" ) // @x-id "categories" @@ -19,17 +19,23 @@ import ( // @Produce json // @Success 200 {array} string // @Failure 403 {object} apitypes.ErrorResponse +// @Failure 500 {object} apitypes.ErrorResponse // @Router /homepage/categories [get] func Categories(c *gin.Context) { - c.JSON(http.StatusOK, HomepageCategories()) + ep := entrypoint.FromCtx(c.Request.Context()) + if ep == nil { // impossible, but just in case + c.JSON(http.StatusInternalServerError, apitypes.Error("entrypoint not initialized")) + return + } + c.JSON(http.StatusOK, HomepageCategories(ep)) } -func HomepageCategories() []string { +func HomepageCategories(ep entrypoint.Entrypoint) []string { check := make(map[string]struct{}) categories := make([]string, 0) categories = append(categories, homepage.CategoryAll) categories = append(categories, homepage.CategoryFavorites) - for _, r := range routes.HTTP.Iter { + for _, r := range ep.HTTPRoutes().Iter { item := r.HomepageItem() if item.Category == "" { continue diff --git a/internal/api/v1/homepage/items.go b/internal/api/v1/homepage/items.go index ed0b1b2c..a3c52a87 100644 --- a/internal/api/v1/homepage/items.go +++ b/internal/api/v1/homepage/items.go @@ -10,8 +10,8 @@ import ( "github.com/gin-gonic/gin" "github.com/lithammer/fuzzysearch/fuzzy" + entrypoint "github.com/yusing/godoxy/internal/entrypoint/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" @@ -36,6 +36,7 @@ type HomepageItemsRequest struct { // @Success 200 {object} homepage.Homepage // @Failure 400 {object} apitypes.ErrorResponse // @Failure 403 {object} apitypes.ErrorResponse +// @Failure 500 {object} apitypes.ErrorResponse // @Router /homepage/items [get] func Items(c *gin.Context) { var request HomepageItemsRequest @@ -53,29 +54,35 @@ func Items(c *gin.Context) { hostname = host } + ep := entrypoint.FromCtx(c.Request.Context()) + if ep == nil { + c.JSON(http.StatusInternalServerError, apitypes.Error("entrypoint not found in context", nil)) + return + } + if httpheaders.IsWebsocket(c.Request.Header) { websocket.PeriodicWrite(c, 2*time.Second, func() (any, error) { - return HomepageItems(proto, hostname, &request), nil + return HomepageItems(ep, proto, hostname, &request), nil }) } else { - c.JSON(http.StatusOK, HomepageItems(proto, hostname, &request)) + c.JSON(http.StatusOK, HomepageItems(ep, proto, hostname, &request)) } } -func HomepageItems(proto, hostname string, request *HomepageItemsRequest) homepage.Homepage { +func HomepageItems(ep entrypoint.Entrypoint, proto, hostname string, request *HomepageItemsRequest) homepage.Homepage { switch proto { case "http", "https": default: proto = "http" } - hp := homepage.NewHomepageMap(routes.HTTP.Size()) + hp := homepage.NewHomepageMap(ep.HTTPRoutes().Size()) if strings.Count(hostname, ".") > 1 { _, hostname, _ = strings.Cut(hostname, ".") // remove the subdomain } - for _, r := range routes.HTTP.Iter { + for _, r := range ep.HTTPRoutes().Iter { if request.Provider != "" && r.ProviderName() != request.Provider { continue } diff --git a/internal/api/v1/metrics/all_system_info.go b/internal/api/v1/metrics/all_system_info.go index ea5430aa..b541dbad 100644 --- a/internal/api/v1/metrics/all_system_info.go +++ b/internal/api/v1/metrics/all_system_info.go @@ -112,7 +112,7 @@ func AllSystemInfo(c *gin.Context) { data, err := systeminfo.Poller.GetRespData(req.Period, query) if err != nil { numErrs.Add(1) - return gperr.PrependSubject("Main server", err) + return gperr.PrependSubject(err, "Main server") } select { case <-manager.Done(): @@ -132,7 +132,7 @@ func AllSystemInfo(c *gin.Context) { data, err := getAgentSystemInfoWithRetry(manager.Context(), a, queryEncoded) if err != nil { numErrs.Add(1) - return gperr.PrependSubject("Agent "+a.Name, err) + return gperr.PrependSubject(err, "Agent "+a.Name) } select { case <-manager.Done(): @@ -169,7 +169,7 @@ func AllSystemInfo(c *gin.Context) { c.Error(apitypes.InternalServerError(err, "failed to get all system info")) return } - gperr.LogWarn("failed to get some system info", err) + log.Warn().Err(err).Msg("failed to get some system info") } } } diff --git a/internal/api/v1/proxmox/common.go b/internal/api/v1/proxmox/common.go index 18864910..d24764a5 100644 --- a/internal/api/v1/proxmox/common.go +++ b/internal/api/v1/proxmox/common.go @@ -2,5 +2,5 @@ package proxmoxapi type ActionRequest struct { Node string `uri:"node" binding:"required"` - VMID int `uri:"vmid" binding:"required"` + VMID uint64 `uri:"vmid" binding:"required"` } // @name ProxmoxVMActionRequest diff --git a/internal/api/v1/proxmox/stats.go b/internal/api/v1/proxmox/stats.go index 0220b628..1ab60b88 100644 --- a/internal/api/v1/proxmox/stats.go +++ b/internal/api/v1/proxmox/stats.go @@ -11,10 +11,7 @@ import ( "github.com/yusing/goutils/http/websocket" ) -type StatsRequest struct { - Node string `uri:"node" binding:"required"` - VMID int `uri:"vmid" binding:"required"` -} +type StatsRequest ActionRequest // @x-id "nodeStats" // @BasePath /api/v1 diff --git a/internal/api/v1/reload.go b/internal/api/v1/reload.go deleted file mode 100644 index ba24d46d..00000000 --- a/internal/api/v1/reload.go +++ /dev/null @@ -1,28 +0,0 @@ -package v1 - -import ( - "net/http" - - "github.com/gin-gonic/gin" - "github.com/yusing/godoxy/internal/config" - apitypes "github.com/yusing/goutils/apitypes" -) - -// @x-id "reload" -// @BasePath /api/v1 -// @Summary Reload config -// @Description Reload config -// @Tags v1 -// @Accept json -// @Produce json -// @Success 200 {object} apitypes.SuccessResponse -// @Failure 403 {object} apitypes.ErrorResponse -// @Failure 500 {object} apitypes.ErrorResponse -// @Router /reload [post] -func Reload(c *gin.Context) { - if err := config.Reload(); err != nil { - c.Error(apitypes.InternalServerError(err, "failed to reload config")) - return - } - c.JSON(http.StatusOK, apitypes.Success("config reloaded")) -} diff --git a/internal/api/v1/route/by_provider.go b/internal/api/v1/route/by_provider.go index 04fd8113..fb80aecf 100644 --- a/internal/api/v1/route/by_provider.go +++ b/internal/api/v1/route/by_provider.go @@ -4,10 +4,10 @@ import ( "net/http" "github.com/gin-gonic/gin" + entrypoint "github.com/yusing/godoxy/internal/entrypoint/types" "github.com/yusing/godoxy/internal/route" - "github.com/yusing/godoxy/internal/route/routes" - _ "github.com/yusing/goutils/apitypes" + apitypes "github.com/yusing/goutils/apitypes" ) type RoutesByProvider map[string][]route.Route @@ -24,5 +24,10 @@ type RoutesByProvider map[string][]route.Route // @Failure 500 {object} apitypes.ErrorResponse // @Router /route/by_provider [get] func ByProvider(c *gin.Context) { - c.JSON(http.StatusOK, routes.ByProvider()) + ep := entrypoint.FromCtx(c.Request.Context()) + if ep == nil { // impossible, but just in case + c.JSON(http.StatusInternalServerError, apitypes.Error("entrypoint not initialized")) + return + } + c.JSON(http.StatusOK, ep.RoutesByProvider()) } diff --git a/internal/api/v1/route/playground.go b/internal/api/v1/route/playground.go index b7321839..39d40549 100644 --- a/internal/api/v1/route/playground.go +++ b/internal/api/v1/route/playground.go @@ -1,6 +1,7 @@ package routeApi import ( + "fmt" "io" "net/http" "net/http/httptest" @@ -54,16 +55,16 @@ type PlaygroundResponse struct { MatchedRules []string `json:"matchedRules"` FinalRequest FinalRequest `json:"finalRequest"` FinalResponse FinalResponse `json:"finalResponse"` - ExecutionError gperr.Error `json:"executionError,omitempty"` + ExecutionError error `json:"executionError,omitempty"` // we need the structured error, not the plain string 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 string `json:"name"` + On string `json:"on"` + Do string `json:"do"` + ValidationError error `json:"validationError,omitempty"` // we need the structured error, not the plain string + IsResponseRule bool `json:"isResponseRule"` } // @name ParsedRule type FinalRequest struct { @@ -138,7 +139,7 @@ func Playground(c *gin.Context) { // Execute rules matchedRules := []string{} upstreamCalled := false - var executionError gperr.Error + var executionError error // Variables to capture modified request state var finalReqMethod, finalReqPath, finalReqHost string @@ -244,20 +245,22 @@ func Playground(c *gin.Context) { c.JSON(http.StatusOK, response) } -func handlerWithRecover(w http.ResponseWriter, r *http.Request, h http.HandlerFunc, outErr *gperr.Error) { +func handlerWithRecover(w http.ResponseWriter, r *http.Request, h http.HandlerFunc, outErr *error) { defer func() { if r := recover(); r != nil { if outErr != nil { - *outErr = gperr.Errorf("panic during rule execution: %v", r) + *outErr = fmt.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 +func parseRules(rawRules []RawRule) ([]ParsedRule, rules.Rules, error) { + parsedRules := make([]ParsedRule, 0, len(rawRules)) + rulesList := make(rules.Rules, 0, len(rawRules)) + + var valErrs gperr.Builder // Parse each rule individually to capture per-rule errors for _, rawRule := range rawRules { @@ -284,7 +287,11 @@ func parseRules(rawRules []RawRule) ([]ParsedRule, rules.Rules, gperr.Error) { // Determine if valid isValid := onErr == nil && doErr == nil - validationErr := gperr.Join(gperr.PrependSubject("on", onErr), gperr.PrependSubject("do", doErr)) + var validationErr error + if !isValid { + validationErr = gperr.Join(gperr.PrependSubject(onErr, "on"), gperr.PrependSubject(doErr, "do")) + valErrs.Add(validationErr) + } parsedRules = append(parsedRules, ParsedRule{ Name: name, @@ -300,7 +307,7 @@ func parseRules(rawRules []RawRule) ([]ParsedRule, rules.Rules, gperr.Error) { } } - return parsedRules, rulesList, nil + return parsedRules, rulesList, valErrs.Error() } func createMockRequest(mock MockRequest) *http.Request { diff --git a/internal/api/v1/route/playground_test.go b/internal/api/v1/route/playground_test.go index 7b357856..91c79026 100644 --- a/internal/api/v1/route/playground_test.go +++ b/internal/api/v1/route/playground_test.go @@ -79,7 +79,7 @@ func TestPlayground(t *testing.T) { if len(resp.MatchedRules) != 1 { t.Errorf("expected 1 matched rule, got %d", len(resp.MatchedRules)) } - if resp.FinalResponse.StatusCode != 403 { + if resp.FinalResponse.StatusCode != http.StatusForbidden { t.Errorf("expected status 403, got %d", resp.FinalResponse.StatusCode) } if resp.UpstreamCalled { @@ -168,7 +168,7 @@ func TestPlayground(t *testing.T) { if len(resp.MatchedRules) != 1 { t.Errorf("expected 1 matched rule, got %d", len(resp.MatchedRules)) } - if resp.FinalResponse.StatusCode != 405 { + if resp.FinalResponse.StatusCode != http.StatusMethodNotAllowed { t.Errorf("expected status 405, got %d", resp.FinalResponse.StatusCode) } }, @@ -179,7 +179,7 @@ func TestPlayground(t *testing.T) { 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 := httptest.NewRequest(http.MethodPost, "/api/v1/route/playground", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") // Create response recorder @@ -214,7 +214,7 @@ func TestPlayground(t *testing.T) { func TestPlaygroundInvalidRequest(t *testing.T) { gin.SetMode(gin.TestMode) - req := httptest.NewRequest("POST", "/api/v1/route/playground", bytes.NewReader([]byte(`{}`))) + req := httptest.NewRequest(http.MethodPost, "/api/v1/route/playground", bytes.NewReader([]byte(`{}`))) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() diff --git a/internal/api/v1/route/route.go b/internal/api/v1/route/route.go index bd6ab1be..fa118757 100644 --- a/internal/api/v1/route/route.go +++ b/internal/api/v1/route/route.go @@ -4,7 +4,7 @@ import ( "net/http" "github.com/gin-gonic/gin" - "github.com/yusing/godoxy/internal/route/routes" + entrypoint "github.com/yusing/godoxy/internal/entrypoint/types" apitypes "github.com/yusing/goutils/apitypes" ) @@ -32,7 +32,13 @@ func Route(c *gin.Context) { return } - route, ok := routes.GetIncludeExcluded(request.Which) + ep := entrypoint.FromCtx(c.Request.Context()) + if ep == nil { // impossible, but just in case + c.JSON(http.StatusInternalServerError, apitypes.Error("entrypoint not initialized")) + return + } + + route, ok := ep.GetRoute(request.Which) if ok { c.JSON(http.StatusOK, route) return diff --git a/internal/api/v1/route/routes.go b/internal/api/v1/route/routes.go index 8dcf2820..37b003df 100644 --- a/internal/api/v1/route/routes.go +++ b/internal/api/v1/route/routes.go @@ -6,8 +6,8 @@ import ( "time" "github.com/gin-gonic/gin" + entrypoint "github.com/yusing/godoxy/internal/entrypoint/types" "github.com/yusing/godoxy/internal/route" - "github.com/yusing/godoxy/internal/route/routes" "github.com/yusing/godoxy/internal/types" "github.com/yusing/goutils/http/httpheaders" "github.com/yusing/goutils/http/websocket" @@ -32,14 +32,16 @@ func Routes(c *gin.Context) { return } + ep := entrypoint.FromCtx(c.Request.Context()) + provider := c.Query("provider") if provider == "" { - c.JSON(http.StatusOK, slices.Collect(routes.IterAll)) + c.JSON(http.StatusOK, slices.Collect(ep.IterRoutes)) return } - rts := make([]types.Route, 0, routes.NumAllRoutes()) - for r := range routes.IterAll { + rts := make([]types.Route, 0, ep.NumRoutes()) + for r := range ep.IterRoutes { if r.ProviderName() == provider { rts = append(rts, r) } @@ -48,17 +50,19 @@ func Routes(c *gin.Context) { } func RoutesWS(c *gin.Context) { + ep := entrypoint.FromCtx(c.Request.Context()) + provider := c.Query("provider") if provider == "" { websocket.PeriodicWrite(c, 3*time.Second, func() (any, error) { - return slices.Collect(routes.IterAll), nil + return slices.Collect(ep.IterRoutes), nil }) return } websocket.PeriodicWrite(c, 3*time.Second, func() (any, error) { - rts := make([]types.Route, 0, routes.NumAllRoutes()) - for r := range routes.IterAll { + rts := make([]types.Route, 0, ep.NumRoutes()) + for r := range ep.IterRoutes { if r.ProviderName() == provider { rts = append(rts, r) } diff --git a/internal/auth/oauth_refresh.go b/internal/auth/oauth_refresh.go index 0fa529cd..1bd894e8 100644 --- a/internal/auth/oauth_refresh.go +++ b/internal/auth/oauth_refresh.go @@ -135,7 +135,7 @@ func (auth *OIDCProvider) setSessionTokenCookie(w http.ResponseWriter, r *http.R func (auth *OIDCProvider) parseSessionJWT(sessionJWT string) (claims *sessionClaims, valid bool, err error) { claims = &sessionClaims{} - sessionToken, err := jwt.ParseWithClaims(sessionJWT, claims, func(t *jwt.Token) (interface{}, error) { + sessionToken, err := jwt.ParseWithClaims(sessionJWT, claims, func(t *jwt.Token) (any, error) { if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"]) } diff --git a/internal/auth/oidc.go b/internal/auth/oidc.go index 27fc732e..f41ec606 100644 --- a/internal/auth/oidc.go +++ b/internal/auth/oidc.go @@ -17,7 +17,6 @@ import ( "github.com/rs/zerolog/log" "github.com/yusing/godoxy/internal/common" "github.com/yusing/godoxy/internal/utils" - gperr "github.com/yusing/goutils/errs" httputils "github.com/yusing/goutils/http" "golang.org/x/oauth2" "golang.org/x/time/rate" @@ -76,8 +75,8 @@ const ( var ( errMissingIDToken = errors.New("missing id_token field from oauth token") - ErrMissingOAuthToken = gperr.New("missing oauth token") - ErrInvalidOAuthToken = gperr.New("invalid oauth token") + ErrMissingOAuthToken = errors.New("missing oauth token") + ErrInvalidOAuthToken = errors.New("invalid oauth token") ) // generateState generates a random string for OIDC state. diff --git a/internal/auth/userpass.go b/internal/auth/userpass.go index 7bf981a9..a079d58e 100644 --- a/internal/auth/userpass.go +++ b/internal/auth/userpass.go @@ -2,22 +2,19 @@ package auth import ( "encoding/json" + "errors" "fmt" "net/http" "time" "github.com/golang-jwt/jwt/v5" "github.com/yusing/godoxy/internal/common" - gperr "github.com/yusing/goutils/errs" httputils "github.com/yusing/goutils/http" strutils "github.com/yusing/goutils/strings" "golang.org/x/crypto/bcrypt" ) -var ( - ErrInvalidUsername = gperr.New("invalid username") - ErrInvalidPassword = gperr.New("invalid password") -) +var ErrInvalidUsername = errors.New("invalid username") type ( UserPassAuth struct { @@ -27,8 +24,9 @@ type ( tokenTTL time.Duration } UserPassClaims struct { - Username string `json:"username"` jwt.RegisteredClaims + + Username string `json:"username"` } ) @@ -81,7 +79,7 @@ func (auth *UserPassAuth) CheckToken(r *http.Request) error { return ErrMissingSessionToken } var claims UserPassClaims - token, err := jwt.ParseWithClaims(jwtCookie.Value, &claims, func(t *jwt.Token) (interface{}, error) { + token, err := jwt.ParseWithClaims(jwtCookie.Value, &claims, func(t *jwt.Token) (any, error) { if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"]) } @@ -94,9 +92,9 @@ func (auth *UserPassAuth) CheckToken(r *http.Request) error { case !token.Valid: return ErrInvalidSessionToken case claims.Username != auth.username: - return ErrUserNotAllowed.Subject(claims.Username) + return fmt.Errorf("%w: %s", ErrUserNotAllowed, claims.Username) case claims.ExpiresAt.Before(time.Now()): - return gperr.Errorf("token expired on %s", strutils.FormatTime(claims.ExpiresAt.Time)) + return fmt.Errorf("token expired on %s", strutils.FormatTime(claims.ExpiresAt.Time)) } return nil @@ -139,11 +137,12 @@ func (auth *UserPassAuth) LogoutHandler(w http.ResponseWriter, r *http.Request) } func (auth *UserPassAuth) validatePassword(user, pass string) error { - if user != auth.username { - return ErrInvalidUsername.Subject(user) - } + // always perform bcrypt comparison to avoid timing attacks if err := bcrypt.CompareHashAndPassword(auth.pwdHash, []byte(pass)); err != nil { - return ErrInvalidPassword.With(err).Subject(pass) + return err + } + if user != auth.username { + return ErrInvalidUsername } return nil } diff --git a/internal/auth/userpass_test.go b/internal/auth/userpass_test.go index 75bdb7d0..ffbeb774 100644 --- a/internal/auth/userpass_test.go +++ b/internal/auth/userpass_test.go @@ -27,7 +27,7 @@ func TestUserPassValidateCredentials(t *testing.T) { err := auth.validatePassword("username", "password") expect.NoError(t, err) err = auth.validatePassword("username", "wrong-password") - expect.ErrorIs(t, ErrInvalidPassword, err) + expect.ErrorIs(t, bcrypt.ErrMismatchedHashAndPassword, err) err = auth.validatePassword("wrong-username", "password") expect.ErrorIs(t, ErrInvalidUsername, err) } diff --git a/internal/auth/utils.go b/internal/auth/utils.go index f674b286..ae90e6df 100644 --- a/internal/auth/utils.go +++ b/internal/auth/utils.go @@ -1,20 +1,20 @@ package auth import ( + "errors" "net" "net/http" "strings" "time" "github.com/yusing/godoxy/internal/common" - gperr "github.com/yusing/goutils/errs" strutils "github.com/yusing/goutils/strings" ) var ( - ErrMissingSessionToken = gperr.New("missing session token") - ErrInvalidSessionToken = gperr.New("invalid session token") - ErrUserNotAllowed = gperr.New("user not allowed") + ErrMissingSessionToken = errors.New("missing session token") + ErrInvalidSessionToken = errors.New("invalid session token") + ErrUserNotAllowed = errors.New("user not allowed") ) func IsFrontend(r *http.Request) bool { diff --git a/internal/autocert/config.go b/internal/autocert/config.go index 68aafae6..e0af3b64 100644 --- a/internal/autocert/config.go +++ b/internal/autocert/config.go @@ -66,13 +66,13 @@ const ( var domainOrWildcardRE = regexp.MustCompile(`^\*?([^.]+\.)+[^.]+$`) -// Validate implements the utils.CustomValidator interface. -func (cfg *Config) Validate() gperr.Error { +// Validate implements the serialization.CustomValidator interface. +func (cfg *Config) Validate() error { seenPaths := make(map[string]int) // path -> provider idx (0 for main, 1+ for extras) return cfg.validate(seenPaths) } -func (cfg *ConfigExtra) Validate() gperr.Error { +func (cfg *ConfigExtra) Validate() error { return nil // done by main config's validate } @@ -80,7 +80,7 @@ func (cfg *ConfigExtra) AsConfig() *Config { return (*Config)(cfg) } -func (cfg *Config) validate(seenPaths map[string]int) gperr.Error { +func (cfg *Config) validate(seenPaths map[string]int) error { if cfg.Provider == "" { cfg.Provider = ProviderLocal } @@ -157,7 +157,7 @@ func (cfg *Config) validate(seenPaths map[string]int) gperr.Error { cfg.Extra[i].AsConfig().idx = i + 1 err := cfg.Extra[i].AsConfig().validate(seenPaths) if err != nil { - b.Add(err.Subjectf("extra[%d]", i)) + b.AddSubjectf(err, "extra[%d]", i) } } } @@ -179,10 +179,10 @@ func (cfg *Config) GetLegoConfig() (*User, *lego.Config, error) { log.Info().Err(err).Msg("failed to load ACME private key, generating a now one") privKey, err = ecdsa.GenerateKey(elliptic.P256(), rand.Reader) if err != nil { - return nil, nil, gperr.New("generate ACME private key").With(err) + return nil, nil, fmt.Errorf("generate ACME private key: %w", err) } if err = cfg.SaveACMEKey(privKey); err != nil { - return nil, nil, gperr.New("save ACME private key").With(err) + return nil, nil, fmt.Errorf("save ACME private key: %w", err) } } } @@ -206,7 +206,7 @@ func (cfg *Config) GetLegoConfig() (*User, *lego.Config, error) { if len(cfg.CACerts) > 0 { certPool, err := lego.CreateCertPool(cfg.CACerts, true) if err != nil { - return nil, nil, gperr.New("failed to create cert pool").With(err) + return nil, nil, fmt.Errorf("failed to create cert pool: %w", err) } legoCfg.HTTPClient.Transport.(*http.Transport).TLSClientConfig.RootCAs = certPool } diff --git a/internal/autocert/provider.go b/internal/autocert/provider.go index 3c84f906..f8839fb1 100644 --- a/internal/autocert/provider.go +++ b/internal/autocert/provider.go @@ -22,6 +22,7 @@ import ( "github.com/go-acme/lego/v4/registration" "github.com/rs/zerolog" "github.com/rs/zerolog/log" + autocert "github.com/yusing/godoxy/internal/autocert/types" "github.com/yusing/godoxy/internal/common" "github.com/yusing/godoxy/internal/notif" gperr "github.com/yusing/goutils/errs" @@ -56,15 +57,6 @@ type ( CertExpiries map[string]time.Time - CertInfo struct { - Subject string `json:"subject"` - Issuer string `json:"issuer"` - NotBefore int64 `json:"not_before"` - NotAfter int64 `json:"not_after"` - DNSNames []string `json:"dns_names"` - EmailAddresses []string `json:"email_addresses"` - } // @name CertInfo - RenewMode uint8 ) @@ -82,9 +74,6 @@ const ( renewModeIfNeeded ) -// could be nil -var ActiveProvider atomic.Pointer[Provider] - func NewProvider(cfg *Config, user *User, legoCfg *lego.Config) (*Provider, error) { p := &Provider{ cfg: cfg, @@ -119,14 +108,14 @@ func (p *Provider) GetCert(hello *tls.ClientHelloInfo) (*tls.Certificate, error) return p.tlsCert, nil } -func (p *Provider) GetCertInfos() ([]CertInfo, error) { +func (p *Provider) GetCertInfos() ([]autocert.CertInfo, error) { allProviders := p.allProviders() - certInfos := make([]CertInfo, 0, len(allProviders)) + certInfos := make([]autocert.CertInfo, 0, len(allProviders)) for _, provider := range allProviders { if provider.tlsCert == nil { continue } - certInfos = append(certInfos, CertInfo{ + certInfos = append(certInfos, autocert.CertInfo{ Subject: provider.tlsCert.Leaf.Subject.CommonName, Issuer: provider.tlsCert.Leaf.Issuer.CommonName, NotBefore: provider.tlsCert.Leaf.NotBefore.Unix(), @@ -150,7 +139,7 @@ func (p *Provider) GetName() string { } func (p *Provider) fmtError(err error) error { - return gperr.PrependSubject(fmt.Sprintf("provider: %s", p.GetName()), err) + return gperr.PrependSubject(err, "provider: "+p.GetName()) } func (p *Provider) GetCertPath() string { @@ -216,7 +205,7 @@ func (p *Provider) ObtainCertIfNotExistsAll() error { for _, provider := range p.allProviders() { errs.Go(func() error { if err := provider.obtainCertIfNotExists(); err != nil { - return fmt.Errorf("failed to obtain cert for %s: %w", provider.GetName(), err) + return gperr.PrependSubject(err, provider.GetName()) } return nil }) @@ -257,7 +246,7 @@ func (p *Provider) ObtainCertAll() error { for _, provider := range p.allProviders() { errs.Go(func() error { if err := provider.obtainCertIfNotExists(); err != nil { - return fmt.Errorf("failed to obtain cert for %s: %w", provider.GetName(), err) + return gperr.PrependSubject(err, provider.GetName()) } return nil }) @@ -475,10 +464,10 @@ func (p *Provider) scheduleRenewal(parent task.Parent) { renewed, err := p.renew(renewMode) if err != nil { - gperr.LogWarn("autocert: cert renew failed", p.fmtError(err)) + log.Warn().Err(p.fmtError(err)).Msg("autocert: cert renew failed") notif.Notify(¬if.LogMessage{ Level: zerolog.ErrorLevel, - Title: fmt.Sprintf("SSL certificate renewal failed for %s", p.GetName()), + Title: "SSL certificate renewal failed for " + p.GetName(), Body: notif.MessageBody(err.Error()), }) return @@ -488,13 +477,13 @@ func (p *Provider) scheduleRenewal(parent task.Parent) { notif.Notify(¬if.LogMessage{ Level: zerolog.InfoLevel, - Title: fmt.Sprintf("SSL certificate renewed for %s", p.GetName()), + Title: "SSL certificate renewed for " + p.GetName(), Body: notif.ListBody(p.cfg.Domains), }) // Reset on success if err := p.ClearLastFailure(); err != nil { - gperr.LogWarn("autocert: failed to clear last failure", p.fmtError(err)) + log.Warn().Err(p.fmtError(err)).Msg("autocert: failed to clear last failure") } timer.Reset(time.Until(p.ShouldRenewOn())) } diff --git a/internal/autocert/providers.go b/internal/autocert/providers.go index 79895ba8..6d3cd38f 100644 --- a/internal/autocert/providers.go +++ b/internal/autocert/providers.go @@ -3,11 +3,10 @@ package autocert import ( "github.com/go-acme/lego/v4/challenge" "github.com/yusing/godoxy/internal/serialization" - gperr "github.com/yusing/goutils/errs" strutils "github.com/yusing/goutils/strings" ) -type Generator func(map[string]strutils.Redacted) (challenge.Provider, gperr.Error) +type Generator func(map[string]strutils.Redacted) (challenge.Provider, error) var Providers = make(map[string]Generator) @@ -15,7 +14,7 @@ func DNSProvider[CT any, PT challenge.Provider]( defaultCfg func() *CT, newProvider func(*CT) (PT, error), ) Generator { - return func(opt map[string]strutils.Redacted) (challenge.Provider, gperr.Error) { + return func(opt map[string]strutils.Redacted) (challenge.Provider, error) { cfg := defaultCfg() if len(opt) > 0 { err := serialization.MapUnmarshalValidate(serialization.ToSerializedObject(opt), &cfg) @@ -24,6 +23,6 @@ func DNSProvider[CT any, PT challenge.Provider]( } } p, pErr := newProvider(cfg) - return p, gperr.Wrap(pErr) + return p, pErr } } diff --git a/internal/autocert/setup.go b/internal/autocert/setup.go index 119a8759..4091a7f0 100644 --- a/internal/autocert/setup.go +++ b/internal/autocert/setup.go @@ -4,7 +4,7 @@ import ( gperr "github.com/yusing/goutils/errs" ) -func (p *Provider) setupExtraProviders() gperr.Error { +func (p *Provider) setupExtraProviders() error { p.sniMatcher = sniMatcher{} if len(p.cfg.Extra) == 0 { return nil diff --git a/internal/autocert/types/cert_info.go b/internal/autocert/types/cert_info.go new file mode 100644 index 00000000..cd6ccbdd --- /dev/null +++ b/internal/autocert/types/cert_info.go @@ -0,0 +1,10 @@ +package autocert + +type CertInfo struct { + Subject string `json:"subject"` + Issuer string `json:"issuer"` + NotBefore int64 `json:"not_before"` + NotAfter int64 `json:"not_after"` + DNSNames []string `json:"dns_names"` + EmailAddresses []string `json:"email_addresses"` +} // @name CertInfo diff --git a/internal/autocert/types/context.go b/internal/autocert/types/context.go new file mode 100644 index 00000000..37e17f5f --- /dev/null +++ b/internal/autocert/types/context.go @@ -0,0 +1,16 @@ +package autocert + +import "context" + +type ContextKey struct{} + +func SetCtx(ctx interface{ SetValue(key, value any) }, p Provider) { + ctx.SetValue(ContextKey{}, p) +} + +func FromCtx(ctx context.Context) Provider { + if provider, ok := ctx.Value(ContextKey{}).(Provider); ok { + return provider + } + return nil +} diff --git a/internal/autocert/types/provider.go b/internal/autocert/types/provider.go index 64b95224..2a104cab 100644 --- a/internal/autocert/types/provider.go +++ b/internal/autocert/types/provider.go @@ -1,14 +1,17 @@ package autocert import ( + "context" "crypto/tls" "github.com/yusing/goutils/task" ) type Provider interface { - Setup() error - GetCert(*tls.ClientHelloInfo) (*tls.Certificate, error) - ScheduleRenewalAll(task.Parent) + GetCert(hello *tls.ClientHelloInfo) (*tls.Certificate, error) + GetCertInfos() ([]CertInfo, error) + ScheduleRenewalAll(parent task.Parent) ObtainCertAll() error + ForceExpiryAll() bool + WaitRenewalDone(ctx context.Context) bool } diff --git a/internal/config/README.md b/internal/config/README.md index 5ad73378..3fe95604 100644 --- a/internal/config/README.md +++ b/internal/config/README.md @@ -54,7 +54,7 @@ type State interface { Task() *task.Task Context() context.Context Value() *Config - EntrypointHandler() http.Handler + Entrypoint() entrypoint.Entrypoint ShortLinkMatcher() config.ShortLinkMatcher AutoCertProvider() server.CertProvider LoadOrStoreProvider(key string, value types.RouteProvider) (actual types.RouteProvider, loaded bool) @@ -62,6 +62,12 @@ type State interface { IterProviders() iter.Seq2[string, types.RouteProvider] StartProviders() error NumProviders() int + + // Lifecycle management + StartAPIServers() + StartMetrics() + + FlushTmpLog() } ``` @@ -214,12 +220,15 @@ Configuration supports hot-reloading via editing `config/config.yml`. - `internal/acl` - Access control configuration - `internal/autocert` - SSL certificate management -- `internal/entrypoint` - HTTP entrypoint setup +- `internal/entrypoint` - HTTP entrypoint setup (now via interface) - `internal/route/provider` - Route providers (Docker, file, agent) - `internal/maxmind` - GeoIP configuration - `internal/notif` - Notification providers - `internal/proxmox` - LXC container management - `internal/homepage/types` - Dashboard configuration +- `internal/api` - REST API servers +- `internal/metrics/systeminfo` - System metrics polling +- `internal/metrics/uptime` - Uptime tracking - `github.com/yusing/goutils/task` - Object lifecycle management ### External dependencies @@ -312,5 +321,8 @@ for name, provider := range config.GetState().IterProviders() { ```go state := config.GetState() -http.Handle("/", state.EntrypointHandler()) +// Get entrypoint interface for route management +ep := state.Entrypoint() +// Add routes directly to entrypoint +ep.AddRoute(route) ``` diff --git a/internal/config/events.go b/internal/config/events.go index 1f6859f2..2c7b1524 100644 --- a/internal/config/events.go +++ b/internal/config/events.go @@ -7,14 +7,15 @@ import ( "time" "github.com/rs/zerolog" + "github.com/rs/zerolog/log" "github.com/yusing/godoxy/internal/common" config "github.com/yusing/godoxy/internal/config/types" "github.com/yusing/godoxy/internal/notif" - "github.com/yusing/godoxy/internal/route/routes" "github.com/yusing/godoxy/internal/watcher" - "github.com/yusing/godoxy/internal/watcher/events" + watcherEvents "github.com/yusing/godoxy/internal/watcher/events" gperr "github.com/yusing/goutils/errs" - "github.com/yusing/goutils/server" + "github.com/yusing/goutils/eventqueue" + "github.com/yusing/goutils/events" "github.com/yusing/goutils/strings/ansi" "github.com/yusing/goutils/task" ) @@ -26,29 +27,29 @@ var ( const configEventFlushInterval = 500 * time.Millisecond -const ( - cfgRenameWarn = `Config file renamed, not reloading. -Make sure you rename it back before next time you start.` - cfgDeleteWarn = `Config file deleted, not reloading. -You may run "ls-config" to show or dump the current config.` +var ( + errCfgRenameWarn = errors.New("config file renamed, not reloading; Make sure you rename it back before next time you start") + errCfgDeleteWarn = errors.New(`config file deleted, not reloading; You may run "ls-config" to show or dump the current config`) ) func logNotifyError(action string, err error) { - gperr.LogError("config "+action+" error", err) + log.Error().Err(err).Msg("config " + action + " error") notif.Notify(¬if.LogMessage{ Level: zerolog.ErrorLevel, Title: fmt.Sprintf("Config %s error", action), Body: notif.ErrorBody(err), }) + events.Global.Add(events.NewEvent(events.LevelError, "config", action, err)) } func logNotifyWarn(action string, err error) { - gperr.LogWarn("config "+action+" error", err) + log.Warn().Err(err).Msg("config " + action + " warning") notif.Notify(¬if.LogMessage{ Level: zerolog.WarnLevel, Title: fmt.Sprintf("Config %s warning", action), Body: notif.ErrorBody(err), }) + events.Global.Add(events.NewEvent(events.LevelWarn, "config", action, err)) } func Load() error { @@ -60,20 +61,29 @@ func Load() error { cfgWatcher = watcher.NewConfigFileWatcher(common.ConfigFileName) - // disable pool logging temporary since we already have pretty logging - routes.HTTP.DisableLog(true) - routes.Stream.DisableLog(true) + initErr := state.InitFromFile(common.ConfigPath) + if initErr != nil { + // if error is critical, notify and return it without starting providers + if criticalErr, ok := errors.AsType[CriticalError](initErr); ok { + logNotifyError("init", criticalErr.err) + return criticalErr + } + } + // disable pool logging temporary since we already have pretty logging + state.Entrypoint().DisablePoolsLog(true) defer func() { - routes.HTTP.DisableLog(false) - routes.Stream.DisableLog(false) + state.Entrypoint().DisablePoolsLog(false) }() - initErr := state.InitFromFile(common.ConfigPath) err := errors.Join(initErr, state.StartProviders()) if err != nil { logNotifyError("init", err) } + + state.StartAPIServers() + state.StartMetrics() + SetState(state) // flush temporary log @@ -81,7 +91,9 @@ func Load() error { return nil } -func Reload() gperr.Error { +func Reload() error { + events.Global.Add(events.NewEvent(events.LevelInfo, "config", "reload", nil)) + // avoid race between config change and API reload request reloadMu.Lock() defer reloadMu.Unlock() @@ -108,32 +120,35 @@ func Reload() gperr.Error { logNotifyError("start providers", err) return nil // continue } - StartProxyServers() + + newState.StartAPIServers() + newState.StartMetrics() return nil } func WatchChanges() { - t := task.RootTask("config_watcher", true) - eventQueue := events.NewEventQueue( - t, - configEventFlushInterval, - OnConfigChange, - func(err gperr.Error) { + opts := eventqueue.Options[watcherEvents.Event]{ + FlushInterval: configEventFlushInterval, + OnFlush: OnConfigChange, + OnError: func(err error) { logNotifyError("reload", err) }, - ) + Debug: common.IsDebug, + } + t := task.RootTask("config_watcher", true) + eventQueue := eventqueue.New(t, opts) eventQueue.Start(cfgWatcher.Events(t.Context())) } -func OnConfigChange(ev []events.Event) { +func OnConfigChange(ev []watcherEvents.Event) { // no matter how many events during the interval // just reload once and check the last event switch ev[len(ev)-1].Action { - case events.ActionFileRenamed: - logNotifyWarn("rename", errors.New(cfgRenameWarn)) + case watcherEvents.ActionFileRenamed: + logNotifyWarn("rename", errCfgRenameWarn) return - case events.ActionFileDeleted: - logNotifyWarn("delete", errors.New(cfgDeleteWarn)) + case watcherEvents.ActionFileDeleted: + logNotifyWarn("delete", errCfgDeleteWarn) return } @@ -142,16 +157,3 @@ func OnConfigChange(ev []events.Event) { panic(err) } } - -func StartProxyServers() { - cfg := GetState() - server.StartServer(cfg.Task(), server.Options{ - Name: "proxy", - CertProvider: cfg.AutoCertProvider(), - HTTPAddr: common.ProxyHTTPAddr, - HTTPSAddr: common.ProxyHTTPSAddr, - Handler: cfg.EntrypointHandler(), - ACL: cfg.Value().ACL, - SupportProxyProtocol: cfg.Value().Entrypoint.SupportProxyProtocol, - }) -} diff --git a/internal/config/state.go b/internal/config/state.go index 9d5fb259..22c7f2d7 100644 --- a/internal/config/state.go +++ b/internal/config/state.go @@ -9,7 +9,6 @@ import ( "fmt" "io/fs" "iter" - "net/http" "os" "strconv" "strings" @@ -18,14 +17,21 @@ import ( "github.com/goccy/go-yaml" "github.com/puzpuzpuz/xsync/v4" "github.com/rs/zerolog" - "github.com/yusing/godoxy/internal/acl" + "github.com/rs/zerolog/log" + acl "github.com/yusing/godoxy/internal/acl/types" "github.com/yusing/godoxy/internal/agentpool" + "github.com/yusing/godoxy/internal/api" "github.com/yusing/godoxy/internal/autocert" + autocertctx "github.com/yusing/godoxy/internal/autocert/types" + "github.com/yusing/godoxy/internal/common" config "github.com/yusing/godoxy/internal/config/types" "github.com/yusing/godoxy/internal/entrypoint" + entrypointctx "github.com/yusing/godoxy/internal/entrypoint/types" homepage "github.com/yusing/godoxy/internal/homepage/types" "github.com/yusing/godoxy/internal/logging" "github.com/yusing/godoxy/internal/maxmind" + "github.com/yusing/godoxy/internal/metrics/systeminfo" + "github.com/yusing/godoxy/internal/metrics/uptime" "github.com/yusing/godoxy/internal/notif" route "github.com/yusing/godoxy/internal/route/provider" "github.com/yusing/godoxy/internal/serialization" @@ -40,7 +46,7 @@ type state struct { providers *xsync.Map[string, types.RouteProvider] autocertProvider *autocert.Provider - entrypoint entrypoint.Entrypoint + entrypoint *entrypoint.Entrypoint task *task.Task @@ -50,14 +56,25 @@ type state struct { tmpLog zerolog.Logger } +type CriticalError struct { + err error +} + +func (e CriticalError) Error() string { + return e.err.Error() +} + +func (e CriticalError) Unwrap() error { + return e.err +} + func NewState() config.State { tmpLogBuf := bytes.NewBuffer(make([]byte, 0, 4096)) return &state{ - providers: xsync.NewMap[string, types.RouteProvider](), - entrypoint: entrypoint.NewEntrypoint(), - task: task.RootTask("config", false), - tmpLogBuf: tmpLogBuf, - tmpLog: logging.NewLoggerWithFixedLevel(zerolog.InfoLevel, tmpLogBuf), + providers: xsync.NewMap[string, types.RouteProvider](), + task: task.RootTask("config", false), + tmpLogBuf: tmpLogBuf, + tmpLog: logging.NewLoggerWithFixedLevel(zerolog.InfoLevel, tmpLogBuf), } } @@ -73,13 +90,7 @@ func SetState(state config.State) { cfg := state.Value() config.ActiveState.Store(state) - entrypoint.ActiveConfig.Store(&cfg.Entrypoint) homepage.ActiveConfig.Store(&cfg.Homepage) - if autocertProvider := state.AutoCertProvider(); autocertProvider != nil { - autocert.ActiveProvider.Store(autocertProvider.(*autocert.Provider)) - } else { - autocert.ActiveProvider.Store(nil) - } } func HasState() bool { @@ -96,7 +107,7 @@ func (state *state) InitFromFile(filename string) error { if errors.Is(err, fs.ErrNotExist) { state.Config = config.DefaultConfig() } else { - return err + return CriticalError{err} } } return state.Init(data) @@ -105,7 +116,7 @@ func (state *state) InitFromFile(filename string) error { func (state *state) Init(data []byte) error { err := serialization.UnmarshalValidate(data, &state.Config, yaml.Unmarshal) if err != nil { - return err + return CriticalError{err} } g := gperr.NewGroup("config load error") @@ -117,7 +128,9 @@ func (state *state) Init(data []byte) error { // these won't benefit from running on goroutines errs.Add(state.initNotification()) errs.Add(state.initACL()) - errs.Add(state.initEntrypoint()) + if err := state.initEntrypoint(); err != nil { + errs.Add(CriticalError{err}) + } errs.Add(state.loadRouteProviders()) return errs.Error() } @@ -134,8 +147,8 @@ func (state *state) Value() *config.Config { return &state.Config } -func (state *state) EntrypointHandler() http.Handler { - return &state.entrypoint +func (state *state) Entrypoint() entrypointctx.Entrypoint { + return state.entrypoint } func (state *state) ShortLinkMatcher() config.ShortLinkMatcher { @@ -186,10 +199,39 @@ func (state *state) NumProviders() int { } func (state *state) FlushTmpLog() { - state.tmpLogBuf.WriteTo(os.Stdout) + _, _ = state.tmpLogBuf.WriteTo(os.Stdout) state.tmpLogBuf.Reset() } +func (state *state) StartAPIServers() { + // API Handler needs to start after auth is initialized. + _, err := server.StartServer(state.task.Subtask("api_server", false), server.Options{ + Name: "api", + HTTPAddr: common.APIHTTPAddr, + Handler: api.NewHandler(true), + }) + if err != nil { + log.Err(err).Msg("failed to start API server") + } + + // Local API Handler is used for unauthenticated access. + if common.LocalAPIHTTPAddr != "" { + _, err := server.StartServer(state.task.Subtask("local_api_server", false), server.Options{ + Name: "local_api", + HTTPAddr: common.LocalAPIHTTPAddr, + Handler: api.NewHandler(false), + }) + if err != nil { + log.Err(err).Msg("failed to start local API server") + } + } +} + +func (state *state) StartMetrics() { + systeminfo.Poller.Start(state.task) + uptime.Poller.Start(state.task) +} + // initACL initializes the ACL. func (state *state) initACL() error { if !state.ACL.Valid() { @@ -199,7 +241,7 @@ func (state *state) initACL() error { if err != nil { return err } - state.task.SetValue(acl.ContextKey{}, state.ACL) + acl.SetCtx(state.task, state.ACL) return nil } @@ -207,6 +249,7 @@ func (state *state) initEntrypoint() error { epCfg := state.Config.Entrypoint matchDomains := state.MatchDomains + state.entrypoint = entrypoint.NewEntrypoint(state.task, &epCfg) state.entrypoint.SetFindRouteDomains(matchDomains) state.entrypoint.SetNotFoundRules(epCfg.Rules.NotFound) @@ -220,6 +263,8 @@ func (state *state) initEntrypoint() error { } } + entrypointctx.SetCtx(state.task, state.entrypoint) + errs := gperr.NewBuilder("entrypoint error") errs.Add(state.entrypoint.SetMiddlewares(epCfg.Middlewares)) errs.Add(state.entrypoint.SetAccessLogger(state.task, epCfg.AccessLog)) @@ -296,6 +341,7 @@ func (state *state) initAutoCert() error { p.PrintCertExpiriesAll() state.autocertProvider = p + autocertctx.SetCtx(state.task, p) return nil } @@ -309,7 +355,7 @@ func (state *state) initProxmox() error { for _, cfg := range proxmoxCfg { errs.Go(func() error { if err := cfg.Init(state.task.Context()); err != nil { - return err.Subject(cfg.URL) + return gperr.PrependSubject(err, cfg.URL) } return nil }) @@ -333,7 +379,7 @@ func (state *state) loadRouteProviders() error { for _, a := range providers.Agents { agentErrs.Go(func() error { if err := a.Init(state.task.Context()); err != nil { - return gperr.PrependSubject(a.String(), err) + return gperr.PrependSubject(err, a.String()) } agentpool.Add(a) return nil @@ -351,7 +397,7 @@ func (state *state) loadRouteProviders() error { for _, filename := range providers.Files { p, err := route.NewFileProvider(filename) if err != nil { - errs.Add(gperr.PrependSubject(filename, err)) + errs.Add(gperr.PrependSubject(err, filename)) return err } registerProvider(p) @@ -376,7 +422,7 @@ func (state *state) loadRouteProviders() error { for _, p := range state.providers.Range { loadErrs.Go(func() error { if err := p.LoadRoutes(); err != nil { - return err.Subject(p.String()) + return gperr.PrependSubject(err, p.String()) } resultsMu.Lock() results.Addf("%-"+strconv.Itoa(lenLongestName)+"s %d routes", p.String(), p.NumRoutes()) diff --git a/internal/config/types/config.go b/internal/config/types/config.go index f9ce7312..99dc9b52 100644 --- a/internal/config/types/config.go +++ b/internal/config/types/config.go @@ -8,14 +8,13 @@ import ( "github.com/yusing/godoxy/agent/pkg/agent" "github.com/yusing/godoxy/internal/acl" "github.com/yusing/godoxy/internal/autocert" - entrypoint "github.com/yusing/godoxy/internal/entrypoint/types" + "github.com/yusing/godoxy/internal/entrypoint" homepage "github.com/yusing/godoxy/internal/homepage/types" maxmind "github.com/yusing/godoxy/internal/maxmind/types" "github.com/yusing/godoxy/internal/notif" "github.com/yusing/godoxy/internal/proxmox" "github.com/yusing/godoxy/internal/serialization" "github.com/yusing/godoxy/internal/types" - gperr "github.com/yusing/goutils/errs" ) type ( @@ -42,7 +41,7 @@ type ( } ) -func Validate(data []byte) gperr.Error { +func Validate(data []byte) error { var model Config return serialization.UnmarshalValidate(data, &model, yaml.Unmarshal) } diff --git a/internal/config/types/state.go b/internal/config/types/state.go index 06b26111..2464eb57 100644 --- a/internal/config/types/state.go +++ b/internal/config/types/state.go @@ -6,6 +6,7 @@ import ( "iter" "net/http" + entrypoint "github.com/yusing/godoxy/internal/entrypoint/types" "github.com/yusing/godoxy/internal/types" "github.com/yusing/goutils/server" "github.com/yusing/goutils/synk" @@ -21,7 +22,7 @@ type State interface { Value() *Config - EntrypointHandler() http.Handler + Entrypoint() entrypoint.Entrypoint ShortLinkMatcher() ShortLinkMatcher AutoCertProvider() server.CertProvider @@ -32,6 +33,9 @@ type State interface { StartProviders() error FlushTmpLog() + + StartAPIServers() + StartMetrics() } type ShortLinkMatcher interface { diff --git a/internal/dnsproviders/go.mod b/internal/dnsproviders/go.mod index 3e563001..e9f0f0df 100644 --- a/internal/dnsproviders/go.mod +++ b/internal/dnsproviders/go.mod @@ -1,16 +1,16 @@ module github.com/yusing/godoxy/internal/dnsproviders -go 1.25.6 +go 1.26.0 replace github.com/yusing/godoxy => ../.. require ( github.com/go-acme/lego/v4 v4.31.0 - github.com/yusing/godoxy v0.25.2 + github.com/yusing/godoxy v0.26.0 ) require ( - cloud.google.com/go/auth v0.18.1 // indirect + cloud.google.com/go/auth v0.18.2 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/compute/metadata v0.9.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 // indirect @@ -28,7 +28,7 @@ require ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/fatih/structs v1.1.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect - github.com/gabriel-vasile/mimetype v1.4.12 // indirect + github.com/gabriel-vasile/mimetype v1.4.13 // 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 @@ -36,7 +36,7 @@ require ( github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.30.1 // indirect - github.com/go-resty/resty/v2 v2.17.1 // indirect + github.com/go-resty/resty/v2 v2.17.2 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/goccy/go-yaml v1.19.2 // indirect github.com/gofrs/flock v0.13.0 // indirect @@ -44,15 +44,15 @@ require ( github.com/google/go-querystring v1.2.0 // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/googleapis/enterprise-certificate-proxy v0.3.11 // indirect - github.com/googleapis/gax-go/v2 v2.16.0 // indirect - github.com/gotify/server/v2 v2.8.0 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.12 // indirect + github.com/googleapis/gax-go/v2 v2.17.0 // indirect + github.com/gotify/server/v2 v2.9.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-retryablehttp v0.7.8 // indirect github.com/kolo/xmlrpc v0.0.0-20220921171641-a4b6fa1dd06b // indirect github.com/kylelemons/godebug v1.1.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect - github.com/linode/linodego v1.64.0 // indirect + github.com/linode/linodego v1.65.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/maxatome/go-testdeep v1.14.0 // indirect @@ -60,8 +60,8 @@ require ( github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/nrdcg/goacmedns v0.2.0 // indirect github.com/nrdcg/goinwx v0.12.0 // indirect - github.com/nrdcg/oci-go-sdk/common/v1065 v1065.107.0 // indirect - github.com/nrdcg/oci-go-sdk/dns/v1065 v1065.107.0 // indirect + github.com/nrdcg/oci-go-sdk/common/v1065 v1065.108.1 // indirect + github.com/nrdcg/oci-go-sdk/dns/v1065 v1065.108.1 // 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 @@ -73,27 +73,27 @@ require ( github.com/sony/gobreaker v1.0.0 // indirect github.com/stretchr/objx v0.5.3 // indirect github.com/stretchr/testify v1.11.1 // indirect - github.com/vultr/govultr/v3 v3.26.1 // indirect + github.com/vultr/govultr/v3 v3.27.0 // indirect github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect - github.com/yusing/gointernals v0.1.16 // indirect + github.com/yusing/gointernals v0.2.0 // 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.64.0 // indirect - go.opentelemetry.io/otel v1.39.0 // indirect - go.opentelemetry.io/otel/metric v1.39.0 // indirect - go.opentelemetry.io/otel/trace v1.39.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 // indirect + go.opentelemetry.io/otel v1.40.0 // indirect + go.opentelemetry.io/otel/metric v1.40.0 // indirect + go.opentelemetry.io/otel/trace v1.40.0 // indirect go.uber.org/ratelimit v0.3.1 // indirect - golang.org/x/crypto v0.47.0 // indirect - golang.org/x/mod v0.32.0 // indirect - golang.org/x/net v0.49.0 // indirect - golang.org/x/oauth2 v0.34.0 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/mod v0.33.0 // indirect + golang.org/x/net v0.50.0 // indirect + golang.org/x/oauth2 v0.35.0 // indirect golang.org/x/sync v0.19.0 // indirect - golang.org/x/sys v0.40.0 // indirect - golang.org/x/text v0.33.0 // indirect - golang.org/x/tools v0.41.0 // indirect - google.golang.org/api v0.263.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect - google.golang.org/grpc v1.78.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.0 // indirect + golang.org/x/tools v0.42.0 // indirect + google.golang.org/api v0.266.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 // indirect + google.golang.org/grpc v1.79.1 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/ini.v1 v1.67.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect diff --git a/internal/dnsproviders/go.sum b/internal/dnsproviders/go.sum index 7957b0aa..97229946 100644 --- a/internal/dnsproviders/go.sum +++ b/internal/dnsproviders/go.sum @@ -1,5 +1,5 @@ -cloud.google.com/go/auth v0.18.1 h1:IwTEx92GFUo2pJ6Qea0EU3zYvKnTAeRCODxfA/G5UWs= -cloud.google.com/go/auth v0.18.1/go.mod h1:GfTYoS9G3CWpRA3Va9doKN9mjPGRS+v41jmZAhBzbrA= +cloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM= +cloud.google.com/go/auth v0.18.2/go.mod h1:xD+oY7gcahcu7G2SG2DsBerfFxgPAJz17zz2joOFF3M= cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= @@ -52,8 +52,8 @@ github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= 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.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= -github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= +github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/go-acme/lego/v4 v4.31.0 h1:gd4oUYdfs83PR1/SflkNdit9xY1iul2I4EystnU8NXM= github.com/go-acme/lego/v4 v4.31.0/go.mod h1:m6zcfX/zcbMYDa8s6AnCMnoORWNP8Epnei+6NBCTUGs= github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= @@ -73,8 +73,8 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= -github.com/go-resty/resty/v2 v2.17.1 h1:x3aMpHK1YM9e4va/TMDRlusDDoZiQ+ViDu/WpA6xTM4= -github.com/go-resty/resty/v2 v2.17.1/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2mLtQrOyQlVA= +github.com/go-resty/resty/v2 v2.17.2 h1:FQW5oHYcIlkCNrMD2lloGScxcHJ0gkjshV3qcQAyHQk= +github.com/go-resty/resty/v2 v2.17.2/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2mLtQrOyQlVA= github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= @@ -95,12 +95,12 @@ 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.11 h1:vAe81Msw+8tKUxi2Dqh/NZMz7475yUvmRIkXr4oN2ao= -github.com/googleapis/enterprise-certificate-proxy v0.3.11/go.mod h1:RFV7MUdlb7AgEq2v7FmMCfeSMCllAzWxFgRdusoGks8= -github.com/googleapis/gax-go/v2 v2.16.0 h1:iHbQmKLLZrexmb0OSsNGTeSTS0HO4YvFOG8g5E4Zd0Y= -github.com/googleapis/gax-go/v2 v2.16.0/go.mod h1:o1vfQjjNZn4+dPnRdl/4ZD7S9414Y4xA+a/6Icj6l14= -github.com/gotify/server/v2 v2.8.0 h1:E3UDDn/3rFZi1sjZfbuhXNnxJP3ACZhdcw/iySegPRA= -github.com/gotify/server/v2 v2.8.0/go.mod h1:6ci5adxcE2hf1v+2oowKiQmixOxXV8vU+CRLKP6sqZA= +github.com/googleapis/enterprise-certificate-proxy v0.3.12 h1:Fg+zsqzYEs1ZnvmcztTYxhgCBsx3eEhEwQ1W/lHq/sQ= +github.com/googleapis/enterprise-certificate-proxy v0.3.12/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= +github.com/googleapis/gax-go/v2 v2.17.0 h1:RksgfBpxqff0EZkDWYuz9q/uWsTVz+kf43LsZ1J6SMc= +github.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOIDp5YKHhb9PAgDY= +github.com/gotify/server/v2 v2.9.0 h1:2zRCl28wkq0oc6YNbyJS2n0dDOOVvOS3Oez5AG2ij54= +github.com/gotify/server/v2 v2.9.0/go.mod h1:249wwlUqHTr0QsiKARGtFVqds0pNLIMjYLinHyMACdQ= github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= @@ -121,8 +121,8 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0 github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= -github.com/linode/linodego v1.64.0 h1:If6pULIwHuQytgogtpQaBdVLX7z2TTHUF5u1tj2TPiY= -github.com/linode/linodego v1.64.0/go.mod h1:GoiwLVuLdBQcAebxAVKVL3mMYUgJZR/puOUSla04xBE= +github.com/linode/linodego v1.65.0 h1:SdsuGD8VSsPWeShXpE7ihl5vec+fD3MgwhnfYC/rj7k= +github.com/linode/linodego v1.65.0/go.mod h1:tOFiTErdjkbVnV+4S0+NmIE9dqqZUEM2HsJaGu8wMh8= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= @@ -140,10 +140,10 @@ 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/goinwx v0.12.0 h1:ujdUqDBnaRSFwzVnImvPHYw3w3m9XgmGImNUw1GyMb4= github.com/nrdcg/goinwx v0.12.0/go.mod h1:IrVKd3ZDbFiMjdPgML4CSxZAY9wOoqLvH44zv3NodJ0= -github.com/nrdcg/oci-go-sdk/common/v1065 v1065.107.0 h1:eMzyN+jGJbxG4ut278uwIsUo9XacXc711lFjhKnaUso= -github.com/nrdcg/oci-go-sdk/common/v1065 v1065.107.0/go.mod h1:Gcs8GCaZXL3FdiDWgdnMxlOLEdRprJJnPYB22TX1jw8= -github.com/nrdcg/oci-go-sdk/dns/v1065 v1065.107.0 h1:t34IpOa+8NfmjkU8bdWtYrLrmr346/FGhu8FlpJDQok= -github.com/nrdcg/oci-go-sdk/dns/v1065 v1065.107.0/go.mod h1:p95/OxVsdx71I2Qrck1GtIS87sRxcTRKXzUi5nWm9NY= +github.com/nrdcg/oci-go-sdk/common/v1065 v1065.108.1 h1:3oOIAQ9Fd2qTKTS/VlWmvKyBPKKhXBcCXjRZqOUypI4= +github.com/nrdcg/oci-go-sdk/common/v1065 v1065.108.1/go.mod h1:Gcs8GCaZXL3FdiDWgdnMxlOLEdRprJJnPYB22TX1jw8= +github.com/nrdcg/oci-go-sdk/dns/v1065 v1065.108.1 h1:2H75475moAv1hVVYlOk815KfqeiFCiQ7ovqn3OnN6FY= +github.com/nrdcg/oci-go-sdk/dns/v1065 v1065.108.1/go.mod h1:9HGOXiiQxcsG+4amgdr4xBIMq6IchdLW/nQDyZz07IE= 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= @@ -180,70 +180,70 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 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/vultr/govultr/v3 v3.26.1 h1:G/M0rMQKwVSmL+gb0UgETbW5mcQi0Vf/o/ZSGdBCxJw= -github.com/vultr/govultr/v3 v3.26.1/go.mod h1:9WwnWGCKnwDlNjHjtt+j+nP+0QWq6hQXzaHgddqrLWY= +github.com/vultr/govultr/v3 v3.27.0 h1:J8etMyu/Jh5+idMsu2YZpOWmDXXHeW4VZnkYXmJYHx8= +github.com/vultr/govultr/v3 v3.27.0/go.mod h1:9WwnWGCKnwDlNjHjtt+j+nP+0QWq6hQXzaHgddqrLWY= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= -github.com/yusing/gointernals v0.1.16 h1:GrhZZdxzA+jojLEqankctJrOuAYDb7kY1C93S1pVR34= -github.com/yusing/gointernals v0.1.16/go.mod h1:B/0FVXt4WPmgzVy3ynzkqKi+BSGaJVmwCJBRXYapo34= +github.com/yusing/gointernals v0.2.0 h1:jyWB3kdUPkuU6s0r8QY/sS5h2WNBF4Kfisly8dtSVvg= +github.com/yusing/gointernals v0.2.0/go.mod h1:xGzNbPGMm5Z8kG0t4JYISMscw+gMQlgghkLxlgRZv5Y= 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= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 h1:ssfIgGNANqpVFCndZvcuyKbl0g+UAVcbBcqGkG28H0Y= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0/go.mod h1:GQ/474YrbE4Jx8gZ4q5I4hrhUzM6UPzyrqJYV2AqPoQ= -go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= -go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= -go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= -go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= -go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= -go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= -go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= -go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= -go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= -go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 h1:7iP2uCb7sGddAr30RRS6xjKy7AZ2JtTOPA3oolgVSw8= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0/go.mod h1:c7hN3ddxs/z6q9xwvfLPk+UHlWRQyaeR1LdgfL/66l0= +go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= +go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= +go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= +go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= +go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= +go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE= +go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw= +go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg= +go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= +go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= 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/ratelimit v0.3.1 h1:K4qVE+byfv/B3tC+4nYWP7v/6SimcO7HzHekoMNBma0= go.uber.org/ratelimit v0.3.1/go.mod h1:6euWsTB6U/Nb3X++xEUXA8ciPJvr19Q/0h1+oDcJhRk= -golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= -golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= -golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= -golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= -golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= -golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= -golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= -golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= +golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= +golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= +golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= -golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= -golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= -golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= 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.263.0 h1:UFs7qn8gInIdtk1ZA6eXRXp5JDAnS4x9VRsRVCeKdbk= -google.golang.org/api v0.263.0/go.mod h1:fAU1xtNNisHgOF5JooAs8rRaTkl2rT3uaoNGo9NS3R8= -google.golang.org/genproto v0.0.0-20251202230838-ff82c1b0f217 h1:GvESR9BIyHUahIb0NcTum6itIWtdoglGX+rnGxm2934= -google.golang.org/genproto v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:yJ2HH4EHEDTd3JiLmhds6NkJ17ITVYOdV3m3VKOnws0= -google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls= -google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= -google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= -google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= +google.golang.org/api v0.266.0 h1:hco+oNCf9y7DmLeAtHJi/uBAY7n/7XC9mZPxu1ROiyk= +google.golang.org/api v0.266.0/go.mod h1:Jzc0+ZfLnyvXma3UtaTl023TdhZu6OMBP9tJ+0EmFD0= +google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 h1:VQZ/yAbAtjkHgH80teYd2em3xtIkkHd7ZhqfH2N9CsM= +google.golang.org/genproto v0.0.0-20260128011058-8636f8732409/go.mod h1:rxKD3IEILWEu3P44seeNOAwZN4SaoKaQ/2eTg4mM6EM= +google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M= +google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 h1:mWPCjDEyshlQYzBpMNHaEof6UX1PmHcaUODUywQ0uac= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= +google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/docker/label.go b/internal/docker/label.go index baf86d71..d5800ca5 100644 --- a/internal/docker/label.go +++ b/internal/docker/label.go @@ -1,6 +1,7 @@ package docker import ( + "errors" "fmt" "strconv" "strings" @@ -11,7 +12,7 @@ import ( strutils "github.com/yusing/goutils/strings" ) -var ErrInvalidLabel = gperr.New("invalid label") +var ErrInvalidLabel = errors.New("invalid label") const nsProxyDot = NSProxy + "." @@ -23,7 +24,7 @@ var refPrefixes = func() []string { return prefixes }() -func ParseLabels(labels map[string]string, aliases ...string) (types.LabelMap, gperr.Error) { +func ParseLabels(labels map[string]string, aliases ...string) (types.LabelMap, error) { nestedMap := make(types.LabelMap) errs := gperr.NewBuilder("labels error") @@ -35,7 +36,7 @@ func ParseLabels(labels map[string]string, aliases ...string) (types.LabelMap, g continue } if len(parts) == 1 { - errs.Add(ErrInvalidLabel.Subject(lbl)) + errs.AddSubject(ErrInvalidLabel, lbl) continue } parts = parts[1:] @@ -53,7 +54,7 @@ func ParseLabels(labels map[string]string, aliases ...string) (types.LabelMap, g // Move deeper into the nested map m, ok := currentMap[k].(types.LabelMap) if !ok && currentMap[k] != "" { - errs.Add(gperr.Errorf("expect mapping, got %T", currentMap[k]).Subject(lbl)) + errs.AddSubject(fmt.Errorf("expect mapping, got %T", currentMap[k]), lbl) continue } else if !ok { m = make(types.LabelMap) @@ -82,15 +83,7 @@ func ExpandWildcard(labels map[string]string, aliases ...string) { } // lbl is "proxy.X..." where X is alias or wildcard rest := lbl[len(nsProxyDot):] // "X..." or "X.suffix" - dotIdx := strings.IndexByte(rest, '.') - var alias, suffix string - if dotIdx == -1 { - alias = rest - } else { - alias = rest[:dotIdx] - suffix = rest[dotIdx+1:] - } - + alias, suffix, _ := strings.Cut(rest, ".") if alias == WildcardAlias { delete(labels, lbl) if suffix == "" || strings.Count(value, "\n") > 1 { @@ -120,15 +113,10 @@ func ExpandWildcard(labels map[string]string, aliases ...string) { continue } rest := lbl[len(nsProxyDot):] - dotIdx := strings.IndexByte(rest, '.') - if dotIdx == -1 { + alias, suffix, ok := strings.Cut(rest, ".") + if !ok || alias == "" || alias[0] == '#' { continue } - alias := rest[:dotIdx] - if alias[0] == '#' { - continue - } - suffix := rest[dotIdx+1:] idx, known := aliasSet[alias] if !known { diff --git a/internal/entrypoint/README.md b/internal/entrypoint/README.md index 2f9b256b..0380f976 100644 --- a/internal/entrypoint/README.md +++ b/internal/entrypoint/README.md @@ -1,10 +1,10 @@ # Entrypoint -The entrypoint package provides the main HTTP entry point for GoDoxy, handling domain-based routing, middleware application, short link matching, and access logging. +The entrypoint package provides the main HTTP entry point for GoDoxy, handling domain-based routing, middleware application, short link matching, access logging, and HTTP server lifecycle management. ## Overview -The entrypoint package implements the primary HTTP handler that receives all incoming requests, determines the target route based on hostname, applies middleware, and forwards requests to the appropriate route handler. +The entrypoint package implements the primary HTTP handler that receives all incoming requests, manages the lifecycle of HTTP servers, determines the target route based on hostname, applies middleware, and forwards requests to the appropriate route handler. ### Key Features @@ -14,103 +14,350 @@ The entrypoint package implements the primary HTTP handler that receives all inc - Access logging for all requests - Configurable not-found handling - Per-domain route resolution +- HTTP server management (HTTP/HTTPS) +- Route pool abstractions via [`PoolLike`](internal/entrypoint/types/entrypoint.go:27) and [`RWPoolLike`](internal/entrypoint/types/entrypoint.go:33) interfaces -## Architecture +### Primary Consumers -```mermaid -graph TD - A[HTTP Request] --> B[Entrypoint Handler] - B --> C{Access Logger?} - C -->|Yes| D[Wrap Response Recorder] - C -->|No| E[Skip Logging] +- **HTTP servers**: Per-listen-addr servers dispatch requests to routes +- **Route providers**: Register routes via [`StartAddRoute`](internal/entrypoint/routes.go:48) +- **Configuration layer**: Validates and applies middleware/access-logging config - D --> F[Find Route by Host] - E --> F +### Non-goals - F --> G{Route Found?} - G -->|Yes| H{Middleware?} - G -->|No| I{Short Link?} - I -->|Yes| J[Short Link Handler] - I -->|No| K{Not Found Handler?} - K -->|Yes| L[Not Found Handler] - K -->|No| M[Serve 404] +- Does not implement route discovery (delegates to providers) +- Does not handle TLS certificate management (delegates to autocert) +- Does not implement health checks (delegates to `internal/health/monitor`) +- Does not manage TCP/UDP listeners directly (only HTTP/HTTPS via `goutils/server`) - H -->|Yes| N[Apply Middleware] - H -->|No| O[Direct Route] - N --> O +### Stability - O --> P[Route ServeHTTP] - P --> Q[Response] - - L --> R[404 Response] - J --> Q - M --> R -``` - -## Core Components - -### Entrypoint Structure - -```go -type Entrypoint struct { - middleware *middleware.Middleware - notFoundHandler http.Handler - accessLogger accesslog.AccessLogger - findRouteFunc func(host string) types.HTTPRoute - shortLinkTree *ShortLinkMatcher -} -``` - -### Active Config - -```go -var ActiveConfig atomic.Pointer[entrypoint.Config] -``` +Internal package with stable core interfaces. The [`Entrypoint`](internal/entrypoint/types/entrypoint.go:7) interface is the public contract. ## Public API -### Creation +### Entrypoint Interface ```go -// NewEntrypoint creates a new entrypoint instance. -func NewEntrypoint() Entrypoint +type Entrypoint interface { + // Server capabilities + SupportProxyProtocol() bool + DisablePoolsLog(v bool) + + // Route registry access + GetRoute(alias string) (types.Route, bool) + StartAddRoute(r types.Route) error + IterRoutes(yield func(r types.Route) bool) + NumRoutes() int + RoutesByProvider() map[string][]types.Route + + // Route pool accessors + HTTPRoutes() PoolLike[types.HTTPRoute] + StreamRoutes() PoolLike[types.StreamRoute] + ExcludedRoutes() RWPoolLike[types.Route] + + // Health info queries + GetHealthInfo() map[string]types.HealthInfo + GetHealthInfoWithoutDetail() map[string]types.HealthInfoWithoutDetail + GetHealthInfoSimple() map[string]types.HealthStatus + + // Configuration + SetFindRouteDomains(domains []string) + SetMiddlewares(mws []map[string]any) error + SetNotFoundRules(rules rules.Rules) + SetAccessLogger(parent task.Parent, cfg *accesslog.RequestLoggerConfig) error + + // Context integration + ShortLinkMatcher() *ShortLinkMatcher +} +``` + +### Pool Interfaces + +```go +type PoolLike[Route types.Route] interface { + Get(alias string) (Route, bool) + Iter(yield func(alias string, r Route) bool) + Size() int +} + +type RWPoolLike[Route types.Route] interface { + PoolLike[Route] + Add(r Route) + Del(r Route) +} ``` ### Configuration ```go -// SetFindRouteDomains configures domain-based route lookup. -func (ep *Entrypoint) SetFindRouteDomains(domains []string) - -// SetMiddlewares loads and configures middleware chain. -func (ep *Entrypoint) SetMiddlewares(mws []map[string]any) error - -// SetNotFoundRules configures the not-found handler. -func (ep *Entrypoint) SetNotFoundRules(rules rules.Rules) - -// SetAccessLogger initializes access logging. -func (ep *Entrypoint) SetAccessLogger(parent task.Parent, cfg *accesslog.RequestLoggerConfig) error - -// ShortLinkMatcher returns the short link matcher. -func (ep *Entrypoint) ShortLinkMatcher() *ShortLinkMatcher +type Config struct { + SupportProxyProtocol bool `json:"support_proxy_protocol"` + Rules struct { + NotFound rules.Rules `json:"not_found"` + } `json:"rules"` + Middlewares []map[string]any `json:"middlewares"` + AccessLog *accesslog.RequestLoggerConfig `json:"access_log" validate:"omitempty"` +} ``` -### Request Handling +### Context Functions ```go -// ServeHTTP is the main HTTP handler. -func (ep *Entrypoint) ServeHTTP(w http.ResponseWriter, r *http.Request) - -// FindRoute looks up a route by hostname. -func (ep *Entrypoint) FindRoute(s string) types.HTTPRoute +func SetCtx(ctx interface{ SetValue(any, any) }, ep Entrypoint) +func FromCtx(ctx context.Context) Entrypoint ``` -## Usage +## Architecture + +### Core Components + +```mermaid +classDiagram + class Entrypoint { + +task *task.new_task + +cfg *Config + +middleware *middleware.Middleware + +notFoundHandler http.Handler + +accessLogger AccessLogger + +findRouteFunc findRouteFunc + +shortLinkMatcher *ShortLinkMatcher + +streamRoutes *pool.Pool[types.StreamRoute] + +excludedRoutes *pool.Pool[types.Route] + +servers *xsync.Map[string, *httpServer] + +SupportProxyProtocol() bool + +StartAddRoute(r) error + +IterRoutes(yield) + +HTTPRoutes() PoolLike + } + + class httpServer { + +routes *pool.Pool[types.HTTPRoute] + +ServeHTTP(w, r) + +AddRoute(route) + +DelRoute(route) + +FindRoute(s) types.HTTPRoute + } + + class PoolLike { + <> + +Get(alias) (Route, bool) + +Iter(yield) bool + +Size() int + } + + class RWPoolLike { + <> + +PoolLike + +Add(r Route) + +Del(r Route) + } + + class ShortLinkMatcher { + +fqdnRoutes *xsync.Map[string, string] + +subdomainRoutes *xsync.Map[string, struct{}] + +ServeHTTP(w, r) + +AddRoute(alias) + +DelRoute(alias) + +SetDefaultDomainSuffix(suffix) + } + + Entrypoint --> httpServer : manages + Entrypoint --> ShortLinkMatcher : owns + Entrypoint --> PoolLike : HTTPRoutes() + Entrypoint --> RWPoolLike : ExcludedRoutes() + httpServer --> PoolLike : routes pool +``` + +### Request Processing Pipeline + +```mermaid +flowchart TD + A[HTTP Request] --> B[Find Route by Host] + B --> C{Route Found?} + C -->|Yes| D{Middleware?} + C -->|No| E{Short Link?} + E -->|Yes| F[Short Link Handler] + E -->|No| G{Not Found Handler?} + G -->|Yes| H[Not Found Handler] + G -->|No| I[Serve 404] + + D -->|Yes| J[Apply Middleware Chain] + D -->|No| K[Direct Route Handler] + J --> K + + K --> L[Route ServeHTTP] + L --> M[Response] + + F --> M + H --> N[404 Response] + I --> N +``` + +### Server Lifecycle + +```mermaid +stateDiagram-v2 + [*] --> Empty: NewEntrypoint() + + Empty --> Listening: StartAddRoute() + Listening --> Listening: StartAddRoute() + Listening --> Listening: delHTTPRoute() + Listening --> [*]: Cancel() + + Listening --> AddingServer: addHTTPRoute() + AddingServer --> Listening: Server starts + + note right of Listening + servers map: addr -> httpServer + For HTTPS, routes are added to ProxyHTTPSAddr + Default routes added to both HTTP and HTTPS + end note +``` + +## Data Flow + +```mermaid +sequenceDiagram + participant Client + participant httpServer + participant Entrypoint + participant Middleware + participant Route + + Client->>httpServer: GET /path + httpServer->>Entrypoint: FindRoute(host) + + alt Route Found + Entrypoint-->>httpServer: HTTPRoute + httpServer->>Middleware: ServeHTTP(routeHandler) + alt Has Middleware + Middleware->>Middleware: Process Chain + end + Middleware->>Route: Forward Request + Route-->>Middleware: Response + Middleware-->>httpServer: Response + else Short Link (go.example.com/alias) + httpServer->>ShortLinkMatcher: Match short code + ShortLinkMatcher-->>httpServer: Redirect + else Not Found + httpServer->>NotFoundHandler: Serve 404 + NotFoundHandler-->>httpServer: 404 Page + end + + httpServer-->>Client: Response +``` + +## Route Registry + +Routes are managed per-entrypoint: + +```go +// Adding a route (main entry point for providers) +if err := ep.StartAddRoute(route); err != nil { + return err +} + +// Iterating all routes including excluded +ep.IterRoutes(func(r types.Route) bool { + log.Info().Str("alias", r.Name()).Msg("route") + return true // continue iteration +}) + +// Querying by alias +route, ok := ep.GetRoute("myapp") + +// Grouping by provider +byProvider := ep.RoutesByProvider() +``` + +## Configuration Surface + +### Config Source + +Environment variables and YAML config file: + +```yaml +entrypoint: + support_proxy_protocol: true + middlewares: + - rate_limit: + requests_per_second: 100 + rules: + not_found: + # not-found rules configuration + access_log: + path: /var/log/godoxy/access.log +``` + +### Environment Variables + +| Variable | Description | +| ------------------------------ | ----------------------------- | +| `PROXY_SUPPORT_PROXY_PROTOCOL` | Enable PROXY protocol support | + +## Dependency and Integration Map + +| Dependency | Purpose | +| ---------------------------------- | --------------------------- | +| `internal/route` | Route types and handlers | +| `internal/route/rules` | Not-found rules processing | +| `internal/logging/accesslog` | Request logging | +| `internal/net/gphttp/middleware` | Middleware chain | +| `internal/types` | Route and health types | +| `github.com/puzpuzpuz/xsync/v4` | Concurrent server map | +| `github.com/yusing/goutils/pool` | Route pool implementations | +| `github.com/yusing/goutils/task` | Lifecycle management | +| `github.com/yusing/goutils/server` | HTTP/HTTPS server lifecycle | + +## Observability + +### Logs + +| Level | Context | Description | +| ------- | --------------------- | ----------------------- | +| `DEBUG` | `route`, `listen_url` | Route addition/removal | +| `DEBUG` | `addr`, `proto` | Server lifecycle | +| `ERROR` | `route`, `listen_url` | Server startup failures | + +### Metrics + +Route metrics exposed via [`GetHealthInfo`](internal/entrypoint/query.go:10) methods: + +```go +// Health info for all routes +healthMap := ep.GetHealthInfo() +// { +// "myapp": {Status: "healthy", Uptime: 3600, Latency: 5ms}, +// "excluded-route": {Status: "unknown", Detail: "n/a"}, +// } +``` + +## Security Considerations + +- Route lookup is read-only from route pools +- Middleware chain is applied per-request +- Proxy protocol support must be explicitly enabled +- Access logger captures request metadata before processing +- Short link matching is limited to configured domains + +## Failure Modes and Recovery + +| Failure | Behavior | Recovery | +| --------------------- | ------------------------------- | ---------------------------- | +| Server bind fails | Error returned, route not added | Fix port/address conflict | +| Route start fails | Route excluded, error logged | Fix route configuration | +| Middleware load fails | SetMiddlewares returns error | Fix middleware configuration | +| Context cancelled | All servers stopped gracefully | Restart entrypoint | + +## Usage Examples ### Basic Setup ```go -ep := entrypoint.NewEntrypoint() +ep := entrypoint.NewEntrypoint(parent, &entrypoint.Config{ + SupportProxyProtocol: false, +}) // Configure domain matching ep.SetFindRouteDomains([]string{".example.com", "example.com"}) @@ -120,7 +367,7 @@ err := ep.SetMiddlewares([]map[string]any{ {"rate_limit": map[string]any{"requests_per_second": 100}}, }) if err != nil { - log.Fatal(err) + return err } // Configure access logging @@ -128,181 +375,58 @@ err = ep.SetAccessLogger(parent, &accesslog.RequestLoggerConfig{ Path: "/var/log/godoxy/access.log", }) if err != nil { - log.Fatal(err) + return err } - -// Start server -http.ListenAndServe(":80", &ep) ``` -### Route Lookup Logic - -The entrypoint uses multiple strategies to find routes: - -1. **Subdomain Matching**: For `sub.domain.com`, looks for `sub` -1. **Exact Match**: Looks for the full hostname -1. **Port Stripping**: Strips port from host if present +### Route Querying ```go -func findRouteAnyDomain(host string) types.HTTPRoute { - // Try subdomain (everything before first dot) - idx := strings.IndexByte(host, '.') - if idx != -1 { - target := host[:idx] - if r, ok := routes.HTTP.Get(target); ok { - return r - } - } +// Iterate all routes including excluded +ep.IterRoutes(func(r types.Route) bool { + log.Info(). + Str("alias", r.Name()). + Str("provider", r.ProviderName()). + Bool("excluded", r.ShouldExclude()). + Msg("route") + return true // continue iteration +}) - // Try exact match - if r, ok := routes.HTTP.Get(host); ok { - return r - } - - // Try stripping port - if before, _, ok := strings.Cut(host, ":"); ok { - if r, ok := routes.HTTP.Get(before); ok { - return r - } - } - - return nil +// Get health info for all routes +healthMap := ep.GetHealthInfoSimple() +for alias, status := range healthMap { + log.Info().Str("alias", alias).Str("status", string(status)).Msg("health") } ``` -### Short Links +### Route Addition -Short links use a special `.short` domain: +Routes are typically added by providers via `StartAddRoute`: ```go -// Request to: https://abc.short.example.com -// Looks for route with alias "abc" -if strings.EqualFold(host, common.ShortLinkPrefix) { - // Handle short link - ep.shortLinkTree.ServeHTTP(w, r) +// StartAddRoute handles route registration and server creation +if err := ep.StartAddRoute(route); err != nil { + return err } ``` -## Data Flow +### Context Integration -```mermaid -sequenceDiagram - participant Client - participant Entrypoint - participant Middleware - participant Route - participant Logger - - Client->>Entrypoint: GET /path - Entrypoint->>Entrypoint: FindRoute(host) - alt Route Found - Entrypoint->>Logger: Get ResponseRecorder - Logger-->>Entrypoint: Recorder - Entrypoint->>Middleware: ServeHTTP(routeHandler) - alt Has Middleware - Middleware->>Middleware: Process Chain - end - Middleware->>Route: Forward Request - Route-->>Middleware: Response - Middleware-->>Entrypoint: Response - else Short Link - Entrypoint->>ShortLinkTree: Match short code - ShortLinkTree-->>Entrypoint: Redirect - else Not Found - Entrypoint->>NotFoundHandler: Serve 404 - NotFoundHandler-->>Entrypoint: 404 Page - end - - Entrypoint->>Logger: Log Request - Logger-->>Entrypoint: Complete - Entrypoint-->>Client: Response -``` - -## Not-Found Handling - -When no route is found, the entrypoint: - -1. Attempts to serve a static error page file -1. Logs the 404 request -1. Falls back to the configured error page -1. Returns 404 status code +Routes can access the entrypoint from request context: ```go -func (ep *Entrypoint) serveNotFound(w http.ResponseWriter, r *http.Request) { - if served := middleware.ServeStaticErrorPageFile(w, r); !served { - log.Error(). - Str("method", r.Method). - Str("url", r.URL.String()). - Str("remote", r.RemoteAddr). - Msgf("not found: %s", r.Host) +// Set entrypoint in context (typically during initialization) +entrypoint.SetCtx(task, ep) - errorPage, ok := errorpage.GetErrorPageByStatus(http.StatusNotFound) - if ok { - w.WriteHeader(http.StatusNotFound) - w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.Write(errorPage) - } else { - http.NotFound(w, r) - } - } +// Get entrypoint from context +if ep := entrypoint.FromCtx(r.Context()); ep != nil { + route, ok := ep.GetRoute("alias") } ``` -## Configuration Structure +## Testing Notes -```go -type Config struct { - Middlewares []map[string]any `json:"middlewares"` - Rules rules.Rules `json:"rules"` - AccessLog *accesslog.RequestLoggerConfig `json:"access_log"` -} -``` - -## Middleware Integration - -The entrypoint supports middleware chains configured via YAML: - -```yaml -entrypoint: - middlewares: - - use: rate_limit - average: 100 - burst: 200 - bypass: - - remote 192.168.1.0/24 - - use: redirect_http -``` - -## Access Logging - -Access logging wraps the response recorder to capture: - -- Request method and URL -- Response status code -- Response size -- Request duration -- Client IP address - -```go -func (ep *Entrypoint) ServeHTTP(w http.ResponseWriter, r *http.Request) { - if ep.accessLogger != nil { - rec := accesslog.GetResponseRecorder(w) - w = rec - defer func() { - ep.accessLogger.Log(r, rec.Response()) - accesslog.PutResponseRecorder(rec) - }() - } - // ... handle request -} -``` - -## Integration Points - -The entrypoint integrates with: - -- **Route Registry**: HTTP route lookup -- **Middleware**: Request processing chain -- **AccessLog**: Request logging -- **ErrorPage**: 404 error pages -- **ShortLink**: Short link handling +- Benchmark tests in [`entrypoint_benchmark_test.go`](internal/entrypoint/entrypoint_benchmark_test.go) +- Integration tests in [`entrypoint_test.go`](internal/entrypoint/entrypoint_test.go) +- Mock route pools for unit testing +- Short link tests in [`shortlink_test.go`](internal/entrypoint/shortlink_test.go) diff --git a/internal/entrypoint/types/config.go b/internal/entrypoint/config.go similarity index 63% rename from internal/entrypoint/types/config.go rename to internal/entrypoint/config.go index eeca7518..82e3bf33 100644 --- a/internal/entrypoint/types/config.go +++ b/internal/entrypoint/config.go @@ -5,11 +5,13 @@ import ( "github.com/yusing/godoxy/internal/route/rules" ) +// Config defines the entrypoint configuration for proxy handling, +// including proxy protocol support, routing rules, middlewares, and access logging. type Config struct { SupportProxyProtocol bool `json:"support_proxy_protocol"` Rules struct { NotFound rules.Rules `json:"not_found"` } `json:"rules"` Middlewares []map[string]any `json:"middlewares"` - AccessLog *accesslog.RequestLoggerConfig `json:"access_log" validate:"omitempty"` + AccessLog *accesslog.RequestLoggerConfig `json:"access_log"` } diff --git a/internal/entrypoint/entrypoint.go b/internal/entrypoint/entrypoint.go index 5cd58468..d54c1442 100644 --- a/internal/entrypoint/entrypoint.go +++ b/internal/entrypoint/entrypoint.go @@ -4,44 +4,112 @@ import ( "net/http" "strings" "sync/atomic" + "testing" + "github.com/puzpuzpuz/xsync/v4" "github.com/rs/zerolog/log" - "github.com/yusing/godoxy/internal/common" entrypoint "github.com/yusing/godoxy/internal/entrypoint/types" "github.com/yusing/godoxy/internal/logging/accesslog" "github.com/yusing/godoxy/internal/net/gphttp/middleware" - "github.com/yusing/godoxy/internal/net/gphttp/middleware/errorpage" - "github.com/yusing/godoxy/internal/route/routes" "github.com/yusing/godoxy/internal/route/rules" "github.com/yusing/godoxy/internal/types" + "github.com/yusing/goutils/pool" "github.com/yusing/goutils/task" ) +type HTTPRoutes interface { + Get(alias string) (types.HTTPRoute, bool) +} + +type findRouteFunc func(HTTPRoutes, string) types.HTTPRoute + type Entrypoint struct { - middleware *middleware.Middleware - notFoundHandler http.Handler - accessLogger accesslog.AccessLogger - findRouteFunc func(host string) types.HTTPRoute - shortLinkTree *ShortLinkMatcher + task *task.Task + + cfg *Config + + middleware *middleware.Middleware + notFoundHandler http.Handler + accessLogger accesslog.AccessLogger + findRouteFunc findRouteFunc + shortLinkMatcher *ShortLinkMatcher + + streamRoutes *pool.Pool[types.StreamRoute] + excludedRoutes *pool.Pool[types.Route] + + // this only affects future http servers creation + httpPoolDisableLog atomic.Bool + + servers *xsync.Map[string, *httpServer] // listen addr -> server } -// nil-safe -var ActiveConfig atomic.Pointer[entrypoint.Config] +var _ entrypoint.Entrypoint = &Entrypoint{} -func init() { - // make sure it's not nil - ActiveConfig.Store(&entrypoint.Config{}) +var emptyCfg Config + +func NewTestEntrypoint(tb testing.TB, cfg *Config) *Entrypoint { + tb.Helper() + + testTask := task.GetTestTask(tb) + ep := NewEntrypoint(testTask, cfg) + entrypoint.SetCtx(testTask, ep) + return ep } -func NewEntrypoint() Entrypoint { - return Entrypoint{ - findRouteFunc: findRouteAnyDomain, - shortLinkTree: newShortLinkTree(), +func NewEntrypoint(parent task.Parent, cfg *Config) *Entrypoint { + if cfg == nil { + cfg = &emptyCfg } + + ep := &Entrypoint{ + task: parent.Subtask("entrypoint", false), + cfg: cfg, + findRouteFunc: findRouteAnyDomain, + shortLinkMatcher: newShortLinkMatcher(), + streamRoutes: pool.New[types.StreamRoute]("stream_routes", "stream_routes"), + excludedRoutes: pool.New[types.Route]("excluded_routes", "excluded_routes"), + servers: xsync.NewMap[string, *httpServer](), + } + return ep +} + +func (ep *Entrypoint) Task() *task.Task { + return ep.task +} + +func (ep *Entrypoint) SupportProxyProtocol() bool { + return ep.cfg.SupportProxyProtocol +} + +func (ep *Entrypoint) DisablePoolsLog(v bool) { + ep.httpPoolDisableLog.Store(v) + // apply to all running http servers + for _, srv := range ep.servers.Range { + srv.routes.DisableLog(v) + } + // apply to other pools + ep.streamRoutes.DisableLog(v) + ep.excludedRoutes.DisableLog(v) } func (ep *Entrypoint) ShortLinkMatcher() *ShortLinkMatcher { - return ep.shortLinkTree + return ep.shortLinkMatcher +} + +func (ep *Entrypoint) HTTPRoutes() entrypoint.PoolLike[types.HTTPRoute] { + return newHTTPPoolAdapter(ep) +} + +func (ep *Entrypoint) StreamRoutes() entrypoint.PoolLike[types.StreamRoute] { + return ep.streamRoutes +} + +func (ep *Entrypoint) ExcludedRoutes() entrypoint.RWPoolLike[types.Route] { + return ep.excludedRoutes +} + +func (ep *Entrypoint) GetServer(addr string) (HTTPServer, bool) { + return ep.servers.Load(addr) } func (ep *Entrypoint) SetFindRouteDomains(domains []string) { @@ -74,128 +142,59 @@ func (ep *Entrypoint) SetMiddlewares(mws []map[string]any) error { } func (ep *Entrypoint) SetNotFoundRules(rules rules.Rules) { - ep.notFoundHandler = rules.BuildHandler(http.HandlerFunc(ep.serveNotFound)) + ep.notFoundHandler = rules.BuildHandler(serveNotFound) } -func (ep *Entrypoint) SetAccessLogger(parent task.Parent, cfg *accesslog.RequestLoggerConfig) (err error) { +func (ep *Entrypoint) SetAccessLogger(parent task.Parent, cfg *accesslog.RequestLoggerConfig) error { if cfg == nil { ep.accessLogger = nil - return err + return nil } - ep.accessLogger, err = accesslog.NewAccessLogger(parent, cfg) + accessLogger, err := accesslog.NewAccessLogger(parent, cfg) if err != nil { return err } + + ep.accessLogger = accessLogger log.Debug().Msg("entrypoint access logger created") - return err + return nil } -func (ep *Entrypoint) FindRoute(s string) types.HTTPRoute { - return ep.findRouteFunc(s) -} - -func (ep *Entrypoint) ServeHTTP(w http.ResponseWriter, r *http.Request) { - if ep.accessLogger != nil { - rec := accesslog.GetResponseRecorder(w) - w = rec - defer func() { - ep.accessLogger.LogRequest(r, rec.Response()) - accesslog.PutResponseRecorder(rec) - }() - } - - route := ep.findRouteFunc(r.Host) - switch { - case route != nil: - r = routes.WithRouteContext(r, route) - if ep.middleware != nil { - ep.middleware.ServeHTTP(route.ServeHTTP, w, r) - } else { - route.ServeHTTP(w, r) - } - case ep.tryHandleShortLink(w, r): - return - case ep.notFoundHandler != nil: - ep.notFoundHandler.ServeHTTP(w, r) - default: - ep.serveNotFound(w, r) - } -} - -func (ep *Entrypoint) tryHandleShortLink(w http.ResponseWriter, r *http.Request) (handled bool) { - host := r.Host - if before, _, ok := strings.Cut(host, ":"); ok { - host = before - } - if strings.EqualFold(host, common.ShortLinkPrefix) { - if ep.middleware != nil { - ep.middleware.ServeHTTP(ep.shortLinkTree.ServeHTTP, w, r) - } else { - ep.shortLinkTree.ServeHTTP(w, r) - } - return true - } - return false -} - -func (ep *Entrypoint) serveNotFound(w http.ResponseWriter, r *http.Request) { - // Why use StatusNotFound instead of StatusBadRequest or StatusBadGateway? - // On nginx, when route for domain does not exist, it returns StatusBadGateway. - // Then scraper / scanners will know the subdomain is invalid. - // With StatusNotFound, they won't know whether it's the path, or the subdomain that is invalid. - if served := middleware.ServeStaticErrorPageFile(w, r); !served { - log.Error(). - Str("method", r.Method). - Str("url", r.URL.String()). - Str("remote", r.RemoteAddr). - Msgf("not found: %s", r.Host) - errorPage, ok := errorpage.GetErrorPageByStatus(http.StatusNotFound) - if ok { - w.WriteHeader(http.StatusNotFound) - w.Header().Set("Content-Type", "text/html; charset=utf-8") - if _, err := w.Write(errorPage); err != nil { - log.Err(err).Msg("failed to write error page") - } - } else { - http.NotFound(w, r) - } - } -} - -func findRouteAnyDomain(host string) types.HTTPRoute { +func findRouteAnyDomain(routes HTTPRoutes, host string) types.HTTPRoute { + //nolint:modernize idx := strings.IndexByte(host, '.') if idx != -1 { target := host[:idx] - if r, ok := routes.HTTP.Get(target); ok { + if r, ok := routes.Get(target); ok { return r } } - if r, ok := routes.HTTP.Get(host); ok { + if r, ok := routes.Get(host); ok { return r } // try striping the trailing :port from the host if before, _, ok := strings.Cut(host, ":"); ok { - if r, ok := routes.HTTP.Get(before); ok { + if r, ok := routes.Get(before); ok { return r } } return nil } -func findRouteByDomains(domains []string) func(host string) types.HTTPRoute { - return func(host string) types.HTTPRoute { +func findRouteByDomains(domains []string) func(routes HTTPRoutes, host string) types.HTTPRoute { + return func(routes HTTPRoutes, host string) types.HTTPRoute { host, _, _ = strings.Cut(host, ":") // strip the trailing :port for _, domain := range domains { if target, ok := strings.CutSuffix(host, domain); ok { - if r, ok := routes.HTTP.Get(target); ok { + if r, ok := routes.Get(target); ok { return r } } } // fallback to exact match - if r, ok := routes.HTTP.Get(host); ok { + if r, ok := routes.Get(host); ok { return r } return nil diff --git a/internal/entrypoint/entrypoint_benchmark_test.go b/internal/entrypoint/entrypoint_benchmark_test.go index 2b432199..1258eebd 100644 --- a/internal/entrypoint/entrypoint_benchmark_test.go +++ b/internal/entrypoint/entrypoint_benchmark_test.go @@ -10,12 +10,12 @@ import ( "strings" "testing" + "github.com/stretchr/testify/require" + "github.com/yusing/godoxy/internal/common" . "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" ) type noopResponseWriter struct { @@ -48,9 +48,9 @@ func (t noopTransport) RoundTrip(req *http.Request) (*http.Response, error) { } func BenchmarkEntrypointReal(b *testing.B) { - var ep Entrypoint + ep := NewTestEntrypoint(b, nil) req := http.Request{ - Method: "GET", + Method: http.MethodGet, URL: &url.URL{Path: "/", RawPath: "/"}, Host: "test.domain.tld", } @@ -77,48 +77,48 @@ func BenchmarkEntrypointReal(b *testing.B) { b.Fatal(err) } - r := &route.Route{ + r, err := route.NewStartedTestRoute(b, &route.Route{ Alias: "test", Scheme: routeTypes.SchemeHTTP, Host: host, - Port: route.Port{Proxy: portInt}, + Port: route.Port{Listening: 1000, Proxy: portInt}, HealthCheck: types.HealthCheckConfig{Disable: true}, - } + }) - err = r.Validate() - if err != nil { - b.Fatal(err) - } - - err = r.Start(task.RootTask("test", false)) - if err != nil { - b.Fatal(err) - } + require.NoError(b, err) + require.False(b, r.ShouldExclude()) var w noopResponseWriter + server, ok := ep.GetServer(":1000") + if !ok { + b.Fatal("server not found") + } + + server.ServeHTTP(&w, &req) + if w.statusCode != http.StatusOK { + b.Fatalf("status code is not 200: %d", w.statusCode) + } + if string(w.written) != "1" { + b.Fatalf("written is not 1: %s", string(w.written)) + } + b.ResetTimer() for b.Loop() { - ep.ServeHTTP(&w, &req) - // if w.statusCode != http.StatusOK { - // b.Fatalf("status code is not 200: %d", w.statusCode) - // } - // if string(w.written) != "1" { - // b.Fatalf("written is not 1: %s", string(w.written)) - // } + server.ServeHTTP(&w, &req) } } func BenchmarkEntrypoint(b *testing.B) { - var ep Entrypoint + ep := NewTestEntrypoint(b, nil) req := http.Request{ - Method: "GET", + Method: http.MethodGet, URL: &url.URL{Path: "/", RawPath: "/"}, Host: "test.domain.tld", } ep.SetFindRouteDomains([]string{}) - r := &route.Route{ + r, err := route.NewStartedTestRoute(b, &route.Route{ Alias: "test", Scheme: routeTypes.SchemeHTTP, Host: "localhost", @@ -128,29 +128,23 @@ func BenchmarkEntrypoint(b *testing.B) { HealthCheck: types.HealthCheckConfig{ Disable: true, }, - } + }) - err := r.Validate() - if err != nil { - b.Fatal(err) - } + require.NoError(b, err) + require.False(b, r.ShouldExclude()) - err = r.Start(task.RootTask("test", false)) - if err != nil { - b.Fatal(err) - } - - rev, ok := routes.HTTP.Get("test") - if !ok { - b.Fatal("route not found") - } - rev.(types.ReverseProxyRoute).ReverseProxy().Transport = noopTransport{} + r.(types.ReverseProxyRoute).ReverseProxy().Transport = noopTransport{} var w noopResponseWriter + server, ok := ep.GetServer(common.ProxyHTTPAddr) + if !ok { + b.Fatal("server not found") + } + b.ResetTimer() for b.Loop() { - ep.ServeHTTP(&w, &req) + server.ServeHTTP(&w, &req) if w.statusCode != http.StatusOK { b.Fatalf("status code is not 200: %d", w.statusCode) } diff --git a/internal/entrypoint/entrypoint_test.go b/internal/entrypoint/entrypoint_test.go index 526e878a..d3f03e22 100644 --- a/internal/entrypoint/entrypoint_test.go +++ b/internal/entrypoint/entrypoint_test.go @@ -3,48 +3,70 @@ package entrypoint_test import ( "testing" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" . "github.com/yusing/godoxy/internal/entrypoint" + entrypoint "github.com/yusing/godoxy/internal/entrypoint/types" "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" expect "github.com/yusing/goutils/testing" ) -var ep = NewEntrypoint() +func addRoute(t *testing.T, alias string) { + t.Helper() -func addRoute(alias string) { - routes.HTTP.Add(&route.ReveseProxyRoute{ - Route: &route.Route{ - Alias: alias, - Port: route.Port{ - Proxy: 80, - }, + ep := entrypoint.FromCtx(task.GetTestTask(t).Context()) + require.NotNil(t, ep) + + _, err := route.NewStartedTestRoute(t, &route.Route{ + Alias: alias, + Scheme: routeTypes.SchemeHTTP, + Port: route.Port{ + Listening: 1000, + Proxy: 8080, + }, + HealthCheck: types.HealthCheckConfig{ + Disable: true, }, }) + if err != nil { + t.Fatal(err) + } + + route, ok := ep.HTTPRoutes().Get(alias) + require.True(t, ok, "route not found") + require.NotNil(t, route) } -func run(t *testing.T, match []string, noMatch []string) { +func run(t *testing.T, ep *Entrypoint, match []string, noMatch []string) { t.Helper() - t.Cleanup(routes.Clear) - t.Cleanup(func() { ep.SetFindRouteDomains(nil) }) + + server, ok := ep.GetServer(":1000") + require.True(t, ok, "server not found") + require.NotNil(t, server) for _, test := range match { t.Run(test, func(t *testing.T) { - found := ep.FindRoute(test) - expect.NotNil(t, found) + route := server.FindRoute(test) + assert.NotNil(t, route) }) } for _, test := range noMatch { t.Run(test, func(t *testing.T) { - found := ep.FindRoute(test) - expect.Nil(t, found) + found, ok := ep.HTTPRoutes().Get(test) + assert.False(t, ok) + assert.Nil(t, found) }) } } func TestFindRouteAnyDomain(t *testing.T) { - addRoute("app1") + ep := NewTestEntrypoint(t, nil) + + addRoute(t, "app1") tests := []string{ "app1.com", @@ -58,10 +80,12 @@ func TestFindRouteAnyDomain(t *testing.T) { "app2.sub.domain.com", } - run(t, tests, testsNoMatch) + run(t, ep, tests, testsNoMatch) } func TestFindRouteExactHostMatch(t *testing.T) { + ep := NewTestEntrypoint(t, nil) + tests := []string{ "app2.com", "app2.domain.com", @@ -75,19 +99,20 @@ func TestFindRouteExactHostMatch(t *testing.T) { } for _, test := range tests { - addRoute(test) + addRoute(t, test) } - run(t, tests, testsNoMatch) + run(t, ep, tests, testsNoMatch) } func TestFindRouteByDomains(t *testing.T) { + ep := NewTestEntrypoint(t, nil) ep.SetFindRouteDomains([]string{ ".domain.com", ".sub.domain.com", }) - addRoute("app1") + addRoute(t, "app1") tests := []string{ "app1.domain.com", @@ -103,16 +128,17 @@ func TestFindRouteByDomains(t *testing.T) { "app2.sub.domain.com", } - run(t, tests, testsNoMatch) + run(t, ep, tests, testsNoMatch) } func TestFindRouteByDomainsExactMatch(t *testing.T) { + ep := NewTestEntrypoint(t, nil) ep.SetFindRouteDomains([]string{ ".domain.com", ".sub.domain.com", }) - addRoute("app1.foo.bar") + addRoute(t, "app1.foo.bar") tests := []string{ "app1.foo.bar", // exact match @@ -126,13 +152,14 @@ func TestFindRouteByDomainsExactMatch(t *testing.T) { "app1.sub.domain.com", } - run(t, tests, testsNoMatch) + run(t, ep, tests, testsNoMatch) } func TestFindRouteWithPort(t *testing.T) { t.Run("AnyDomain", func(t *testing.T) { - addRoute("app1") - addRoute("app2.com") + ep := NewTestEntrypoint(t, nil) + addRoute(t, "app1") + addRoute(t, "app2.com") tests := []string{ "app1:8080", @@ -144,16 +171,17 @@ func TestFindRouteWithPort(t *testing.T) { "app2.co", "app2.co:8080", } - run(t, tests, testsNoMatch) + run(t, ep, tests, testsNoMatch) }) t.Run("ByDomains", func(t *testing.T) { + ep := NewTestEntrypoint(t, nil) ep.SetFindRouteDomains([]string{ ".domain.com", }) - addRoute("app1") - addRoute("app2") - addRoute("app3.domain.com") + addRoute(t, "app1") + addRoute(t, "app2") + addRoute(t, "app3.domain.com") tests := []string{ "app1.domain.com:8080", @@ -169,6 +197,120 @@ func TestFindRouteWithPort(t *testing.T) { "app3.domain.co", "app3.domain.co:8080", } - run(t, tests, testsNoMatch) + run(t, ep, tests, testsNoMatch) }) } + +func TestHealthInfoQueries(t *testing.T) { + ep := NewTestEntrypoint(t, nil) + + // Add routes without health monitors (default case) + addRoute(t, "app1") + addRoute(t, "app2") + + // Test GetHealthInfo + t.Run("GetHealthInfo", func(t *testing.T) { + info := ep.GetHealthInfo() + expect.Equal(t, 2, len(info)) + for _, health := range info { + expect.Equal(t, types.StatusUnknown, health.Status) + expect.Equal(t, "n/a", health.Detail) + } + }) + + // Test GetHealthInfoWithoutDetail + t.Run("GetHealthInfoWithoutDetail", func(t *testing.T) { + info := ep.GetHealthInfoWithoutDetail() + expect.Equal(t, 2, len(info)) + for _, health := range info { + expect.Equal(t, types.StatusUnknown, health.Status) + } + }) + + // Test GetHealthInfoSimple + t.Run("GetHealthInfoSimple", func(t *testing.T) { + info := ep.GetHealthInfoSimple() + expect.Equal(t, 2, len(info)) + for _, status := range info { + expect.Equal(t, types.StatusUnknown, status) + } + }) +} + +func TestRoutesByProvider(t *testing.T) { + ep := NewTestEntrypoint(t, nil) + + // Add routes with provider info + addRoute(t, "app1") + addRoute(t, "app2") + + byProvider := ep.RoutesByProvider() + expect.Equal(t, 1, len(byProvider)) // All routes are from same implicit provider + + routes, ok := byProvider[""] + expect.True(t, ok) + expect.Equal(t, 2, len(routes)) +} + +func TestNumRoutes(t *testing.T) { + ep := NewTestEntrypoint(t, nil) + + expect.Equal(t, 0, ep.NumRoutes()) + + addRoute(t, "app1") + expect.Equal(t, 1, ep.NumRoutes()) + + addRoute(t, "app2") + expect.Equal(t, 2, ep.NumRoutes()) +} + +func TestIterRoutes(t *testing.T) { + ep := NewTestEntrypoint(t, nil) + + addRoute(t, "app1") + addRoute(t, "app2") + addRoute(t, "app3") + + count := 0 + for r := range ep.IterRoutes { + count++ + expect.NotNil(t, r) + } + expect.Equal(t, 3, count) +} + +func TestGetRoute(t *testing.T) { + ep := NewTestEntrypoint(t, nil) + + // Route not found case + _, ok := ep.GetRoute("nonexistent") + expect.False(t, ok) + + addRoute(t, "app1") + + route, ok := ep.GetRoute("app1") + expect.True(t, ok) + expect.NotNil(t, route) +} + +func TestHTTPRoutesPool(t *testing.T) { + ep := NewTestEntrypoint(t, nil) + + pool := ep.HTTPRoutes() + expect.Equal(t, 0, pool.Size()) + + addRoute(t, "app1") + expect.Equal(t, 1, pool.Size()) + + // Verify route is accessible + route, ok := pool.Get("app1") + expect.True(t, ok) + expect.NotNil(t, route) +} + +func TestExcludedRoutesPool(t *testing.T) { + ep := NewTestEntrypoint(t, nil) + + excludedPool := ep.ExcludedRoutes() + expect.Equal(t, 0, excludedPool.Size()) +} diff --git a/internal/entrypoint/http_pool_adapter.go b/internal/entrypoint/http_pool_adapter.go new file mode 100644 index 00000000..15af15de --- /dev/null +++ b/internal/entrypoint/http_pool_adapter.go @@ -0,0 +1,51 @@ +package entrypoint + +import ( + "github.com/yusing/godoxy/internal/common" + "github.com/yusing/godoxy/internal/types" +) + +// httpPoolAdapter implements the PoolLike interface for the HTTP routes. +type httpPoolAdapter struct { + ep *Entrypoint +} + +func newHTTPPoolAdapter(ep *Entrypoint) httpPoolAdapter { + return httpPoolAdapter{ep: ep} +} + +func (h httpPoolAdapter) Iter(yield func(alias string, route types.HTTPRoute) bool) { + for addr, srv := range h.ep.servers.Range { + // default routes are added to both HTTP and HTTPS servers, we don't need to iterate over them twice. + if addr == common.ProxyHTTPSAddr { + continue + } + for alias, route := range srv.routes.Iter { + if !yield(alias, route) { + return + } + } + } +} + +func (h httpPoolAdapter) Get(alias string) (types.HTTPRoute, bool) { + for addr, srv := range h.ep.servers.Range { + if addr == common.ProxyHTTPSAddr { + continue + } + if route, ok := srv.routes.Get(alias); ok { + return route, true + } + } + return nil, false +} + +func (h httpPoolAdapter) Size() (n int) { + for addr, srv := range h.ep.servers.Range { + if addr == common.ProxyHTTPSAddr { + continue + } + n += srv.routes.Size() + } + return +} diff --git a/internal/entrypoint/http_server.go b/internal/entrypoint/http_server.go new file mode 100644 index 00000000..a5880eb6 --- /dev/null +++ b/internal/entrypoint/http_server.go @@ -0,0 +1,175 @@ +package entrypoint + +import ( + "errors" + "fmt" + "net/http" + "strings" + + "github.com/rs/zerolog/log" + acl "github.com/yusing/godoxy/internal/acl/types" + autocert "github.com/yusing/godoxy/internal/autocert/types" + "github.com/yusing/godoxy/internal/common" + "github.com/yusing/godoxy/internal/logging/accesslog" + "github.com/yusing/godoxy/internal/net/gphttp/middleware" + "github.com/yusing/godoxy/internal/net/gphttp/middleware/errorpage" + "github.com/yusing/godoxy/internal/route/routes" + "github.com/yusing/godoxy/internal/types" + "github.com/yusing/goutils/pool" + "github.com/yusing/goutils/server" +) + +// HTTPServer is a server that listens on a given address and serves HTTP routes. +type HTTPServer interface { + Listen(addr string, proto HTTPProto) error + AddRoute(route types.HTTPRoute) + DelRoute(route types.HTTPRoute) + FindRoute(s string) types.HTTPRoute + ServeHTTP(w http.ResponseWriter, r *http.Request) +} + +type httpServer struct { + ep *Entrypoint + + stopFunc func(reason any) + + addr string + routes *pool.Pool[types.HTTPRoute] +} + +type HTTPProto string + +const ( + HTTPProtoHTTP HTTPProto = "http" + HTTPProtoHTTPS HTTPProto = "https" +) + +func NewHTTPServer(ep *Entrypoint) HTTPServer { + return newHTTPServer(ep) +} + +func newHTTPServer(ep *Entrypoint) *httpServer { + return &httpServer{ep: ep} +} + +// Listen starts the server and stop when entrypoint is stopped. +func (srv *httpServer) Listen(addr string, proto HTTPProto) error { + if srv.addr != "" { + return errors.New("server already started") + } + + opts := server.Options{ + Name: addr, + Handler: srv, + ACL: acl.FromCtx(srv.ep.task.Context()), + SupportProxyProtocol: srv.ep.cfg.SupportProxyProtocol, + } + + switch proto { + case HTTPProtoHTTP: + opts.HTTPAddr = addr + case HTTPProtoHTTPS: + opts.HTTPSAddr = addr + opts.CertProvider = autocert.FromCtx(srv.ep.task.Context()) + } + + task := srv.ep.task.Subtask("http_server", false) + _, err := server.StartServer(task, opts) + if err != nil { + return err + } + srv.stopFunc = task.FinishAndWait + srv.addr = addr + srv.routes = pool.New[types.HTTPRoute](fmt.Sprintf("[%s] %s", proto, addr), "http_routes") + srv.routes.DisableLog(srv.ep.httpPoolDisableLog.Load()) + return nil +} + +func (srv *httpServer) Close() { + if srv.stopFunc == nil { + return + } + srv.stopFunc(nil) +} + +func (srv *httpServer) AddRoute(route types.HTTPRoute) { + srv.routes.Add(route) +} + +func (srv *httpServer) DelRoute(route types.HTTPRoute) { + srv.routes.Del(route) +} + +func (srv *httpServer) FindRoute(s string) types.HTTPRoute { + return srv.ep.findRouteFunc(srv.routes, s) +} + +func (srv *httpServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if srv.ep.accessLogger != nil { + rec := accesslog.GetResponseRecorder(w) + w = rec + defer func() { + // there is no body to close + //nolint:bodyclose + srv.ep.accessLogger.LogRequest(r, rec.Response()) + accesslog.PutResponseRecorder(rec) + }() + } + + route := srv.ep.findRouteFunc(srv.routes, r.Host) + switch { + case route != nil: + r = routes.WithRouteContext(r, route) + if srv.ep.middleware != nil { + srv.ep.middleware.ServeHTTP(route.ServeHTTP, w, r) + } else { + route.ServeHTTP(w, r) + } + case srv.tryHandleShortLink(w, r): + return + case srv.ep.notFoundHandler != nil: + srv.ep.notFoundHandler.ServeHTTP(w, r) + default: + serveNotFound(w, r) + } +} + +func (srv *httpServer) tryHandleShortLink(w http.ResponseWriter, r *http.Request) (handled bool) { + host := r.Host + if before, _, ok := strings.Cut(host, ":"); ok { + host = before + } + if strings.EqualFold(host, common.ShortLinkPrefix) { + if srv.ep.middleware != nil { + srv.ep.middleware.ServeHTTP(srv.ep.shortLinkMatcher.ServeHTTP, w, r) + } else { + srv.ep.shortLinkMatcher.ServeHTTP(w, r) + } + return true + } + return false +} + +func serveNotFound(w http.ResponseWriter, r *http.Request) { + // Why use StatusNotFound instead of StatusBadRequest or StatusBadGateway? + // On nginx, when route for domain does not exist, it returns StatusBadGateway. + // Then scraper / scanners will know the subdomain is invalid. + // With StatusNotFound, they won't know whether it's the path, or the subdomain that is invalid. + if served := middleware.ServeStaticErrorPageFile(w, r); !served { + log.Warn(). + Str("method", r.Method). + Str("url", r.URL.String()). + Str("remote", r.RemoteAddr). + Msgf("not found: %s", r.Host) + errorPage, ok := errorpage.GetErrorPageByStatus(http.StatusNotFound) + if ok { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusNotFound) + if _, err := w.Write(errorPage); err != nil { + log.Err(err).Msg("failed to write error page") + } + } else { + http.NotFound(w, r) + } + } +} diff --git a/internal/entrypoint/query.go b/internal/entrypoint/query.go new file mode 100644 index 00000000..f8b11675 --- /dev/null +++ b/internal/entrypoint/query.go @@ -0,0 +1,92 @@ +package entrypoint + +import ( + "github.com/yusing/godoxy/internal/types" +) + +// GetHealthInfo returns a map of route name to health info. +// +// The health info is for all routes, including excluded routes. +func (ep *Entrypoint) GetHealthInfo() map[string]types.HealthInfo { + healthMap := make(map[string]types.HealthInfo, ep.NumRoutes()) + for r := range ep.IterRoutes { + healthMap[r.Name()] = getHealthInfo(r) + } + return healthMap +} + +// GetHealthInfoWithoutDetail returns a map of route name to health info without detail. +// +// The health info is for all routes, including excluded routes. +func (ep *Entrypoint) GetHealthInfoWithoutDetail() map[string]types.HealthInfoWithoutDetail { + healthMap := make(map[string]types.HealthInfoWithoutDetail, ep.NumRoutes()) + for r := range ep.IterRoutes { + healthMap[r.Name()] = getHealthInfoWithoutDetail(r) + } + return healthMap +} + +// GetHealthInfoSimple returns a map of route name to health status. +// +// The health status is for all routes, including excluded routes. +func (ep *Entrypoint) GetHealthInfoSimple() map[string]types.HealthStatus { + healthMap := make(map[string]types.HealthStatus, ep.NumRoutes()) + for r := range ep.IterRoutes { + healthMap[r.Name()] = getHealthInfoSimple(r) + } + return healthMap +} + +// RoutesByProvider returns a map of provider name to routes. +// +// The routes are all routes, including excluded routes. +func (ep *Entrypoint) RoutesByProvider() map[string][]types.Route { + rts := make(map[string][]types.Route) + for r := range ep.IterRoutes { + providerName := r.ProviderName() + rts[providerName] = append(rts[providerName], r) + } + return rts +} + +func getHealthInfo(r types.Route) types.HealthInfo { + mon := r.HealthMonitor() + if mon == nil { + return types.HealthInfo{ + HealthInfoWithoutDetail: types.HealthInfoWithoutDetail{ + Status: types.StatusUnknown, + }, + Detail: "n/a", + } + } + return types.HealthInfo{ + HealthInfoWithoutDetail: types.HealthInfoWithoutDetail{ + Status: mon.Status(), + Uptime: mon.Uptime(), + Latency: mon.Latency(), + }, + Detail: mon.Detail(), + } +} + +func getHealthInfoWithoutDetail(r types.Route) types.HealthInfoWithoutDetail { + mon := r.HealthMonitor() + if mon == nil { + return types.HealthInfoWithoutDetail{ + Status: types.StatusUnknown, + } + } + return types.HealthInfoWithoutDetail{ + Status: mon.Status(), + Uptime: mon.Uptime(), + Latency: mon.Latency(), + } +} + +func getHealthInfoSimple(r types.Route) types.HealthStatus { + mon := r.HealthMonitor() + if mon == nil { + return types.StatusUnknown + } + return mon.Status() +} diff --git a/internal/entrypoint/routes.go b/internal/entrypoint/routes.go new file mode 100644 index 00000000..53219e88 --- /dev/null +++ b/internal/entrypoint/routes.go @@ -0,0 +1,146 @@ +package entrypoint + +import ( + "errors" + "fmt" + "net" + "strconv" + + "github.com/yusing/godoxy/internal/common" + "github.com/yusing/godoxy/internal/types" +) + +func (ep *Entrypoint) IterRoutes(yield func(r types.Route) bool) { + for _, r := range ep.HTTPRoutes().Iter { + if !yield(r) { + return + } + } + for _, r := range ep.streamRoutes.Iter { + if !yield(r) { + return + } + } + for _, r := range ep.excludedRoutes.Iter { + if !yield(r) { + return + } + } +} + +func (ep *Entrypoint) NumRoutes() int { + return ep.HTTPRoutes().Size() + ep.streamRoutes.Size() + ep.excludedRoutes.Size() +} + +func (ep *Entrypoint) GetRoute(alias string) (types.Route, bool) { + if r, ok := ep.HTTPRoutes().Get(alias); ok { + return r, true + } + if r, ok := ep.streamRoutes.Get(alias); ok { + return r, true + } + if r, ok := ep.excludedRoutes.Get(alias); ok { + return r, true + } + return nil, false +} + +func (ep *Entrypoint) StartAddRoute(r types.Route) error { + if r.ShouldExclude() { + ep.excludedRoutes.Add(r) + r.Task().OnCancel("remove_route", func() { + ep.excludedRoutes.Del(r) + }) + return nil + } + switch r := r.(type) { + case types.HTTPRoute: + if err := ep.AddHTTPRoute(r); err != nil { + return err + } + ep.shortLinkMatcher.AddRoute(r.Key()) + r.Task().OnCancel("remove_route", func() { + ep.delHTTPRoute(r) + ep.shortLinkMatcher.DelRoute(r.Key()) + }) + case types.StreamRoute: + err := r.ListenAndServe(r.Task().Context(), nil, nil) + if err != nil { + return err + } + ep.streamRoutes.Add(r) + + r.Task().OnCancel("remove_route", func() { + r.Stream().Close() + ep.streamRoutes.Del(r) + }) + default: + return fmt.Errorf("unknown route type: %T", r) + } + return nil +} + +func getAddr(route types.HTTPRoute) (httpAddr, httpsAddr string) { + if port := route.ListenURL().Port(); port == "" || port == "0" { + host := route.ListenURL().Hostname() + if host == "" { + httpAddr = common.ProxyHTTPAddr + httpsAddr = common.ProxyHTTPSAddr + } else { + httpAddr = net.JoinHostPort(host, strconv.Itoa(common.ProxyHTTPPort)) + httpsAddr = net.JoinHostPort(host, strconv.Itoa(common.ProxyHTTPSPort)) + } + return httpAddr, httpsAddr + } + + httpsAddr = route.ListenURL().Host + return +} + +// AddHTTPRoute adds a HTTP route to the entrypoint's server. +// +// If the server does not exist, it will be created, started and return any error. +func (ep *Entrypoint) AddHTTPRoute(route types.HTTPRoute) error { + httpAddr, httpsAddr := getAddr(route) + var httpErr, httpsErr error + if httpAddr != "" { + httpErr = ep.addHTTPRoute(route, httpAddr, HTTPProtoHTTP) + } + if httpsAddr != "" { + httpsErr = ep.addHTTPRoute(route, httpsAddr, HTTPProtoHTTPS) + } + return errors.Join(httpErr, httpsErr) +} + +func (ep *Entrypoint) addHTTPRoute(route types.HTTPRoute, addr string, proto HTTPProto) error { + var err error + srv, _ := ep.servers.LoadOrCompute(addr, func() (newSrv *httpServer, cancel bool) { + newSrv = newHTTPServer(ep) + err = newSrv.Listen(addr, proto) + cancel = err != nil + return + }) + if err != nil { + return err + } + + srv.AddRoute(route) + return nil +} + +func (ep *Entrypoint) delHTTPRoute(route types.HTTPRoute) { + httpAddr, httpsAddr := getAddr(route) + if httpAddr != "" { + srv, _ := ep.servers.Load(httpAddr) + if srv != nil { + srv.DelRoute(route) + } + } + if httpsAddr != "" { + srv, _ := ep.servers.Load(httpsAddr) + if srv != nil { + srv.DelRoute(route) + } + } + // TODO: close server if no routes are left +} diff --git a/internal/entrypoint/shortlink.go b/internal/entrypoint/shortlink.go index e3bf20c1..e62dc9ca 100644 --- a/internal/entrypoint/shortlink.go +++ b/internal/entrypoint/shortlink.go @@ -14,7 +14,7 @@ type ShortLinkMatcher struct { subdomainRoutes *xsync.Map[string, struct{}] } -func newShortLinkTree() *ShortLinkMatcher { +func newShortLinkMatcher() *ShortLinkMatcher { return &ShortLinkMatcher{ fqdnRoutes: xsync.NewMap[string, string](), subdomainRoutes: xsync.NewMap[string, struct{}](), diff --git a/internal/entrypoint/shortlink_test.go b/internal/entrypoint/shortlink_test.go index 6e28a8b0..99322340 100644 --- a/internal/entrypoint/shortlink_test.go +++ b/internal/entrypoint/shortlink_test.go @@ -6,18 +6,20 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/yusing/godoxy/internal/common" . "github.com/yusing/godoxy/internal/entrypoint" + "github.com/yusing/goutils/task" ) func TestShortLinkMatcher_FQDNAlias(t *testing.T) { - ep := NewEntrypoint() + ep := NewEntrypoint(task.GetTestTask(t), nil) matcher := ep.ShortLinkMatcher() matcher.AddRoute("app.domain.com") t.Run("exact path", func(t *testing.T) { - req := httptest.NewRequest("GET", "/app", nil) + req := httptest.NewRequest(http.MethodGet, "/app", nil) w := httptest.NewRecorder() matcher.ServeHTTP(w, req) @@ -26,7 +28,7 @@ func TestShortLinkMatcher_FQDNAlias(t *testing.T) { }) t.Run("with path remainder", func(t *testing.T) { - req := httptest.NewRequest("GET", "/app/foo/bar", nil) + req := httptest.NewRequest(http.MethodGet, "/app/foo/bar", nil) w := httptest.NewRecorder() matcher.ServeHTTP(w, req) @@ -35,7 +37,7 @@ func TestShortLinkMatcher_FQDNAlias(t *testing.T) { }) t.Run("with query", func(t *testing.T) { - req := httptest.NewRequest("GET", "/app/foo?x=y&z=1", nil) + req := httptest.NewRequest(http.MethodGet, "/app/foo?x=y&z=1", nil) w := httptest.NewRecorder() matcher.ServeHTTP(w, req) @@ -45,13 +47,13 @@ func TestShortLinkMatcher_FQDNAlias(t *testing.T) { } func TestShortLinkMatcher_SubdomainAlias(t *testing.T) { - ep := NewEntrypoint() + ep := NewEntrypoint(task.GetTestTask(t), nil) matcher := ep.ShortLinkMatcher() matcher.SetDefaultDomainSuffix(".example.com") matcher.AddRoute("app") t.Run("exact path", func(t *testing.T) { - req := httptest.NewRequest("GET", "/app", nil) + req := httptest.NewRequest(http.MethodGet, "/app", nil) w := httptest.NewRecorder() matcher.ServeHTTP(w, req) @@ -60,7 +62,7 @@ func TestShortLinkMatcher_SubdomainAlias(t *testing.T) { }) t.Run("with path remainder", func(t *testing.T) { - req := httptest.NewRequest("GET", "/app/foo/bar", nil) + req := httptest.NewRequest(http.MethodGet, "/app/foo/bar", nil) w := httptest.NewRecorder() matcher.ServeHTTP(w, req) @@ -70,13 +72,13 @@ func TestShortLinkMatcher_SubdomainAlias(t *testing.T) { } func TestShortLinkMatcher_NotFound(t *testing.T) { - ep := NewEntrypoint() + ep := NewEntrypoint(task.GetTestTask(t), nil) matcher := ep.ShortLinkMatcher() matcher.SetDefaultDomainSuffix(".example.com") matcher.AddRoute("app") t.Run("missing key", func(t *testing.T) { - req := httptest.NewRequest("GET", "/", nil) + req := httptest.NewRequest(http.MethodGet, "/", nil) w := httptest.NewRecorder() matcher.ServeHTTP(w, req) @@ -84,7 +86,7 @@ func TestShortLinkMatcher_NotFound(t *testing.T) { }) t.Run("unknown key", func(t *testing.T) { - req := httptest.NewRequest("GET", "/unknown", nil) + req := httptest.NewRequest(http.MethodGet, "/unknown", nil) w := httptest.NewRecorder() matcher.ServeHTTP(w, req) @@ -93,7 +95,7 @@ func TestShortLinkMatcher_NotFound(t *testing.T) { } func TestShortLinkMatcher_AddDelRoute(t *testing.T) { - ep := NewEntrypoint() + ep := NewEntrypoint(task.GetTestTask(t), nil) matcher := ep.ShortLinkMatcher() matcher.SetDefaultDomainSuffix(".example.com") @@ -101,13 +103,13 @@ func TestShortLinkMatcher_AddDelRoute(t *testing.T) { matcher.AddRoute("app2.domain.com") t.Run("both routes work", func(t *testing.T) { - req := httptest.NewRequest("GET", "/app1", nil) + req := httptest.NewRequest(http.MethodGet, "/app1", nil) w := httptest.NewRecorder() matcher.ServeHTTP(w, req) assert.Equal(t, http.StatusTemporaryRedirect, w.Code) assert.Equal(t, "https://app1.example.com/", w.Header().Get("Location")) - req = httptest.NewRequest("GET", "/app2.domain.com", nil) + req = httptest.NewRequest(http.MethodGet, "/app2.domain.com", nil) w = httptest.NewRecorder() matcher.ServeHTTP(w, req) assert.Equal(t, http.StatusTemporaryRedirect, w.Code) @@ -117,12 +119,12 @@ func TestShortLinkMatcher_AddDelRoute(t *testing.T) { t.Run("delete route", func(t *testing.T) { matcher.DelRoute("app1") - req := httptest.NewRequest("GET", "/app1", nil) + req := httptest.NewRequest(http.MethodGet, "/app1", nil) w := httptest.NewRecorder() matcher.ServeHTTP(w, req) assert.Equal(t, http.StatusNotFound, w.Code) - req = httptest.NewRequest("GET", "/app2.domain.com", nil) + req = httptest.NewRequest(http.MethodGet, "/app2.domain.com", nil) w = httptest.NewRecorder() matcher.ServeHTTP(w, req) assert.Equal(t, http.StatusTemporaryRedirect, w.Code) @@ -131,14 +133,14 @@ func TestShortLinkMatcher_AddDelRoute(t *testing.T) { } func TestShortLinkMatcher_NoDefaultDomainSuffix(t *testing.T) { - ep := NewEntrypoint() + ep := NewEntrypoint(task.GetTestTask(t), nil) matcher := ep.ShortLinkMatcher() // no SetDefaultDomainSuffix called t.Run("subdomain alias ignored", func(t *testing.T) { matcher.AddRoute("app") - req := httptest.NewRequest("GET", "/app", nil) + req := httptest.NewRequest(http.MethodGet, "/app", nil) w := httptest.NewRecorder() matcher.ServeHTTP(w, req) @@ -148,7 +150,7 @@ func TestShortLinkMatcher_NoDefaultDomainSuffix(t *testing.T) { t.Run("FQDN alias still works", func(t *testing.T) { matcher.AddRoute("app.domain.com") - req := httptest.NewRequest("GET", "/app.domain.com", nil) + req := httptest.NewRequest(http.MethodGet, "/app.domain.com", nil) w := httptest.NewRecorder() matcher.ServeHTTP(w, req) @@ -158,35 +160,39 @@ func TestShortLinkMatcher_NoDefaultDomainSuffix(t *testing.T) { } func TestEntrypoint_ShortLinkDispatch(t *testing.T) { - ep := NewEntrypoint() + ep := NewEntrypoint(task.GetTestTask(t), nil) ep.ShortLinkMatcher().SetDefaultDomainSuffix(".example.com") ep.ShortLinkMatcher().AddRoute("app") + server := NewHTTPServer(ep) + err := server.Listen("localhost:0", HTTPProtoHTTP) + require.NoError(t, err) + t.Run("shortlink host", func(t *testing.T) { - req := httptest.NewRequest("GET", "/app", nil) + req := httptest.NewRequest(http.MethodGet, "/app", nil) req.Host = common.ShortLinkPrefix w := httptest.NewRecorder() - ep.ServeHTTP(w, req) + server.ServeHTTP(w, req) assert.Equal(t, http.StatusTemporaryRedirect, w.Code) assert.Equal(t, "https://app.example.com/", w.Header().Get("Location")) }) t.Run("shortlink host with port", func(t *testing.T) { - req := httptest.NewRequest("GET", "/app", nil) + req := httptest.NewRequest(http.MethodGet, "/app", nil) req.Host = common.ShortLinkPrefix + ":8080" w := httptest.NewRecorder() - ep.ServeHTTP(w, req) + server.ServeHTTP(w, req) assert.Equal(t, http.StatusTemporaryRedirect, w.Code) assert.Equal(t, "https://app.example.com/", w.Header().Get("Location")) }) t.Run("normal host", func(t *testing.T) { - req := httptest.NewRequest("GET", "/app", nil) + req := httptest.NewRequest(http.MethodGet, "/app", nil) req.Host = "app.example.com" w := httptest.NewRecorder() - ep.ServeHTTP(w, req) + server.ServeHTTP(w, req) // Should not redirect, should try normal route lookup (which will 404) assert.NotEqual(t, http.StatusTemporaryRedirect, w.Code) diff --git a/internal/entrypoint/types/context.go b/internal/entrypoint/types/context.go new file mode 100644 index 00000000..f2bde899 --- /dev/null +++ b/internal/entrypoint/types/context.go @@ -0,0 +1,18 @@ +package entrypoint + +import ( + "context" +) + +type ContextKey struct{} + +func SetCtx(ctx interface{ SetValue(any, any) }, ep Entrypoint) { + ctx.SetValue(ContextKey{}, ep) +} + +func FromCtx(ctx context.Context) Entrypoint { + if ep, ok := ctx.Value(ContextKey{}).(Entrypoint); ok { + return ep + } + return nil +} diff --git a/internal/entrypoint/types/entrypoint.go b/internal/entrypoint/types/entrypoint.go new file mode 100644 index 00000000..5d145d43 --- /dev/null +++ b/internal/entrypoint/types/entrypoint.go @@ -0,0 +1,58 @@ +package entrypoint + +import ( + "github.com/yusing/godoxy/internal/types" +) + +// Entrypoint is the main HTTP entry point for the proxy: it performs domain-based +// route lookup, applies middleware, manages HTTP/HTTPS server lifecycle, and +// exposes route pools and health info. Route providers register routes via +// StartAddRoute; request handling uses the route pools to resolve targets. +type Entrypoint interface { + // SupportProxyProtocol reports whether the entrypoint is configured to accept + // PROXY protocol (v1/v2) on incoming connections. When true, servers expect + // the PROXY header before reading HTTP. + SupportProxyProtocol() bool + + // DisablePoolsLog sets whether add/del logging for route pools is disabled. + // When v is true, logging for HTTP, stream, and excluded route pools is + // turned off; when false, it is turned on. Affects all existing and future + // pool operations until called again. + DisablePoolsLog(v bool) + + GetRoute(alias string) (types.Route, bool) + // StartAddRoute registers the route with the entrypoint. It is synchronous: + // it does not return until the route is registered or an error occurs. For + // HTTP routes, a server for the route's listen address is created and + // started if needed. For stream routes, ListenAndServe is invoked and the + // route is added to the pool only on success. Excluded routes are added to + // the excluded pool only. Returns an error on listen/bind failure, stream + // listen failure, or unsupported route type. + StartAddRoute(r types.Route) error + IterRoutes(yield func(r types.Route) bool) + NumRoutes() int + RoutesByProvider() map[string][]types.Route + + // HTTPRoutes returns a read-only view of all HTTP routes (across listen addrs). + HTTPRoutes() PoolLike[types.HTTPRoute] + // StreamRoutes returns a read-only view of all stream (e.g. TCP/UDP) routes. + StreamRoutes() PoolLike[types.StreamRoute] + // ExcludedRoutes returns the read-write pool of excluded routes (e.g. disabled). + ExcludedRoutes() RWPoolLike[types.Route] + + GetHealthInfo() map[string]types.HealthInfo + GetHealthInfoWithoutDetail() map[string]types.HealthInfoWithoutDetail + GetHealthInfoSimple() map[string]types.HealthStatus +} + +type PoolLike[Route types.Route] interface { + Get(alias string) (Route, bool) + Iter(yield func(alias string, r Route) bool) + Size() int +} + +type RWPoolLike[Route types.Route] interface { + PoolLike[Route] + Add(r Route) + Del(r Route) +} diff --git a/internal/health/check/docker.go b/internal/health/check/docker.go index e5509b60..eb3637bd 100644 --- a/internal/health/check/docker.go +++ b/internal/health/check/docker.go @@ -15,21 +15,23 @@ import ( type DockerHealthcheckState struct { client *docker.SharedClient - containerId string + containerID string numDockerFailures int } const dockerFailuresThreshold = 3 -var ErrDockerHealthCheckFailedTooManyTimes = errors.New("docker health check failed too many times") -var ErrDockerHealthCheckNotAvailable = errors.New("docker health check not available") +var ( + ErrDockerHealthCheckFailedTooManyTimes = errors.New("docker health check failed too many times") + ErrDockerHealthCheckNotAvailable = errors.New("docker health check not available") +) -func NewDockerHealthcheckState(client *docker.SharedClient, containerId string) *DockerHealthcheckState { +func NewDockerHealthcheckState(client *docker.SharedClient, containerID string) *DockerHealthcheckState { client.InterceptHTTPClient(interceptDockerInspectResponse) return &DockerHealthcheckState{ client: client, - containerId: containerId, + containerID: containerID, numDockerFailures: 0, } } @@ -43,7 +45,7 @@ func Docker(ctx context.Context, state *DockerHealthcheckState, timeout time.Dur defer cancel() // the actual inspect response is intercepted and returned as RequestInterceptedError - _, err := state.client.ContainerInspect(ctx, state.containerId) + _, err := state.client.ContainerInspect(ctx, state.containerID) var interceptedErr *httputils.RequestInterceptedError if !httputils.AsRequestInterceptedError(err, &interceptedErr) { diff --git a/internal/health/check/http.go b/internal/health/check/http.go index 5ee11688..d49c7fd2 100644 --- a/internal/health/check/http.go +++ b/internal/health/check/http.go @@ -31,7 +31,7 @@ var pinger = &fasthttp.Client{ TLSConfig: &tls.Config{ InsecureSkipVerify: true, }, - MaxConnsPerHost: 1, + MaxConnsPerHost: 1000, NoDefaultUserAgentHeader: true, } @@ -45,7 +45,7 @@ func HTTP(url *url.URL, method, path string, timeout time.Duration) (types.Healt req.SetRequestURI(url.JoinPath(path).String()) req.Header.SetMethod(method) setCommonHeaders(req.Header.Set) - req.SetConnectionClose() + // req.SetConnectionClose() // allow connection reuse start := time.Now() respErr := pinger.DoTimeout(req, resp, timeout) diff --git a/internal/health/monitor/README.md b/internal/health/monitor/README.md index 51841d89..e8e8d614 100644 --- a/internal/health/monitor/README.md +++ b/internal/health/monitor/README.md @@ -41,7 +41,7 @@ type HealthCheckFunc func(url *url.URL) (result types.HealthCheckResult, err err ```go type HealthMonitor interface { - Start(parent task.Parent) gperr.Error + Start(parent task.Parent) error Task() *task.Task Finish(reason any) UpdateURL(url *url.URL) diff --git a/internal/health/monitor/monitor.go b/internal/health/monitor/monitor.go index 2e019403..13213bb7 100644 --- a/internal/health/monitor/monitor.go +++ b/internal/health/monitor/monitor.go @@ -2,6 +2,7 @@ package monitor import ( "context" + "errors" "fmt" "math/rand" "net/url" @@ -13,13 +14,15 @@ import ( config "github.com/yusing/godoxy/internal/config/types" "github.com/yusing/godoxy/internal/notif" "github.com/yusing/godoxy/internal/types" - gperr "github.com/yusing/goutils/errs" + "github.com/yusing/goutils/events" strutils "github.com/yusing/goutils/strings" "github.com/yusing/goutils/synk" "github.com/yusing/goutils/task" ) type ( + DisplayNameKey struct{} + HealthCheckFunc func(url *url.URL) (result types.HealthCheckResult, err error) monitor struct { service string @@ -42,7 +45,7 @@ type ( } ) -var ErrNegativeInterval = gperr.New("negative interval") +var ErrNegativeInterval = errors.New("negative interval") func (mon *monitor) init(u *url.URL, cfg types.HealthCheckConfig, healthCheckFunc HealthCheckFunc) { if state := config.WorkingState.Load(); state != nil { @@ -79,14 +82,18 @@ func (mon *monitor) CheckHealth() (types.HealthCheckResult, error) { } // Start implements task.TaskStarter. -func (mon *monitor) Start(parent task.Parent) gperr.Error { +func (mon *monitor) Start(parent task.Parent) error { if mon.config.Interval <= 0 { return ErrNegativeInterval } - mon.service = parent.Name() mon.task = parent.Subtask("health_monitor", true) + mon.service = parent.Name() + if displayName, ok := parent.GetValue(DisplayNameKey{}).(string); ok { + mon.service = displayName + } + go func() { logger := log.With().Str("name", mon.service).Logger() @@ -269,6 +276,7 @@ func (mon *monitor) notifyServiceUp(logger *zerolog.Logger, result *types.Health Body: extras, Color: notif.ColorSuccess, }) + events.Global.Add(events.NewEvent(events.LevelInfo, "health", "service_up", mon)) } func (mon *monitor) notifyServiceDown(logger *zerolog.Logger, result *types.HealthCheckResult) { @@ -281,6 +289,7 @@ func (mon *monitor) notifyServiceDown(logger *zerolog.Logger, result *types.Heal Body: extras, Color: notif.ColorError, }) + events.Global.Add(events.NewEvent(events.LevelWarn, "health", "service_down", mon)) } func (mon *monitor) buildNotificationExtras(result *types.HealthCheckResult) notif.FieldsBody { diff --git a/internal/health/monitor/new.go b/internal/health/monitor/new.go index 285f081b..87121c77 100644 --- a/internal/health/monitor/new.go +++ b/internal/health/monitor/new.go @@ -2,9 +2,9 @@ package monitor import ( "errors" - "fmt" "net/http" "net/url" + "strconv" "time" "github.com/rs/zerolog/log" @@ -14,8 +14,10 @@ import ( "github.com/yusing/godoxy/internal/types" ) -type Result = types.HealthCheckResult -type Monitor = types.HealthMonCheck +type ( + Result = types.HealthCheckResult + Monitor = types.HealthMonCheck +) // NewMonitor creates a health monitor based on the route type and configuration. // @@ -78,22 +80,22 @@ func NewFileServerHealthMonitor(config types.HealthCheckConfig, path string) Mon return &mon } -func NewStreamHealthMonitor(config types.HealthCheckConfig, targetUrl *url.URL) Monitor { +func NewStreamHealthMonitor(config types.HealthCheckConfig, targetURL *url.URL) Monitor { var mon monitor - mon.init(targetUrl, config, func(u *url.URL) (result Result, err error) { + mon.init(targetURL, config, func(u *url.URL) (result Result, err error) { return healthcheck.Stream(mon.Context(), u, config.Timeout) }) return &mon } -func NewDockerHealthMonitor(config types.HealthCheckConfig, client *docker.SharedClient, containerId string, fallback Monitor) Monitor { - state := healthcheck.NewDockerHealthcheckState(client, containerId) +func NewDockerHealthMonitor(config types.HealthCheckConfig, client *docker.SharedClient, containerID string, fallback Monitor) Monitor { + state := healthcheck.NewDockerHealthcheckState(client, containerID) displayURL := &url.URL{ // only for display purposes, no actual request is made Scheme: "docker", Host: client.DaemonHost(), - Path: "/containers/" + containerId + "/json", + Path: "/containers/" + containerID + "/json", } - logger := log.With().Str("host", client.DaemonHost()).Str("container_id", containerId).Logger() + logger := log.With().Str("host", client.DaemonHost()).Str("container_id", containerID).Logger() isFirstFailure := true var mon monitor @@ -114,20 +116,20 @@ func NewDockerHealthMonitor(config types.HealthCheckConfig, client *docker.Share return &mon } -func NewAgentProxiedMonitor(config types.HealthCheckConfig, agent *agentpool.Agent, targetUrl *url.URL) Monitor { +func NewAgentProxiedMonitor(config types.HealthCheckConfig, agent *agentpool.Agent, targetURL *url.URL) Monitor { var mon monitor - mon.init(targetUrl, config, func(u *url.URL) (result Result, err error) { + mon.init(targetURL, config, func(u *url.URL) (result Result, err error) { return CheckHealthAgentProxied(agent, config.Timeout, u) }) return &mon } -func CheckHealthAgentProxied(agent *agentpool.Agent, timeout time.Duration, targetUrl *url.URL) (Result, error) { +func CheckHealthAgentProxied(agent *agentpool.Agent, timeout time.Duration, targetURL *url.URL) (Result, error) { query := url.Values{ - "scheme": {targetUrl.Scheme}, - "host": {targetUrl.Host}, - "path": {targetUrl.Path}, - "timeout": {fmt.Sprintf("%d", timeout.Milliseconds())}, + "scheme": {targetURL.Scheme}, + "host": {targetURL.Host}, + "path": {targetURL.Path}, + "timeout": {strconv.FormatInt(timeout.Milliseconds(), 10)}, } resp, err := agent.DoHealthCheck(timeout, query.Encode()) result := Result{ diff --git a/internal/homepage/icons/README.md b/internal/homepage/icons/README.md index 800affe1..012dd545 100644 --- a/internal/homepage/icons/README.md +++ b/internal/homepage/icons/README.md @@ -176,7 +176,7 @@ func (icon *Meta) Filenames(ref string) []string func NewURL(source Source, refOrName, format string) *URL // ErrInvalidIconURL is returned when icon URL parsing fails -var ErrInvalidIconURL = gperr.New("invalid icon url") +var ErrInvalidIconURL = errors.New("invalid icon url") ``` ### Provider Interface diff --git a/internal/homepage/icons/fetch/fetch.go b/internal/homepage/icons/fetch/fetch.go index 40061445..67b4b4ea 100644 --- a/internal/homepage/icons/fetch/fetch.go +++ b/internal/homepage/icons/fetch/fetch.go @@ -137,11 +137,11 @@ func fetchIcon(ctx context.Context, filename string) (Result, error) { for _, fileType := range []string{"svg", "webp", "png"} { result, err := fetchKnownIcon(ctx, icons.NewURL(icons.SourceSelfhSt, filename, fileType)) if err == nil { - return result, err + return result, nil } result, err = fetchKnownIcon(ctx, icons.NewURL(icons.SourceWalkXCode, filename, fileType)) if err == nil { - return result, err + return result, nil } } return FetchResultWithErrorf(http.StatusNotFound, "no icon found") @@ -152,6 +152,8 @@ type contextValue struct { uri string } +type contextKey struct{} + func FindIcon(ctx context.Context, r route, uri string, variant icons.Variant) (Result, error) { for _, ref := range r.References() { ref = sanitizeName(ref) @@ -160,7 +162,7 @@ func FindIcon(ctx context.Context, r route, uri string, variant icons.Variant) ( } result, err := fetchIcon(ctx, ref) if err == nil { - return result, err + return result, nil } } if r, ok := r.(httpRoute); ok { @@ -168,13 +170,13 @@ func FindIcon(ctx context.Context, r route, uri string, variant icons.Variant) ( return FetchResultWithErrorf(http.StatusServiceUnavailable, "service unavailable") } // fallback to parse html - return findIconSlowCached(context.WithValue(ctx, "route", contextValue{r: r, uri: uri}), r.Key()) + return findIconSlowCached(context.WithValue(ctx, contextKey{}, contextValue{r: r, uri: uri}), r.Key()) } return FetchResultWithErrorf(http.StatusNotFound, "no icon found") } var findIconSlowCached = cache.NewKeyFunc(func(ctx context.Context, key string) (Result, error) { - v := ctx.Value("route").(contextValue) + v := ctx.Value(contextKey{}).(contextValue) return findIconSlow(ctx, v.r, v.uri, nil) }).WithMaxEntries(200).WithRetriesConstantBackoff(math.MaxInt, 15*time.Second).Build() // infinite retries, 15 seconds interval diff --git a/internal/homepage/icons/provider.go b/internal/homepage/icons/provider.go index dfd6241e..06acfff0 100644 --- a/internal/homepage/icons/provider.go +++ b/internal/homepage/icons/provider.go @@ -1,6 +1,10 @@ package icons -import "sync/atomic" +import ( + "sync/atomic" + + "github.com/yusing/godoxy/internal/common" +) type Provider interface { HasIcon(u *URL) bool @@ -13,6 +17,9 @@ func SetProvider(p Provider) { } func hasIcon(u *URL) bool { + if common.IsTest { + return true + } v := provider.Load() if v == nil { return false diff --git a/internal/homepage/icons/url.go b/internal/homepage/icons/url.go index 271b6b7d..7e99bfd8 100644 --- a/internal/homepage/icons/url.go +++ b/internal/homepage/icons/url.go @@ -1,6 +1,7 @@ package icons import ( + "errors" "fmt" "strings" @@ -40,7 +41,7 @@ const ( VariantDark Variant = "dark" ) -var ErrInvalidIconURL = gperr.New("invalid icon url") +var ErrInvalidIconURL = errors.New("invalid icon url") func NewURL(source Source, refOrName, format string) *URL { switch source { @@ -119,7 +120,7 @@ func (u *URL) parse(v string, checkExists bool) error { case "@target", "": // @target/favicon.ico, /favicon.ico url := v[slashIndex:] if url == "/" { - return ErrInvalidIconURL.Withf("%s", "empty path") + return fmt.Errorf("%w: empty path", ErrInvalidIconURL) } u.FullURL = &url u.Source = SourceRelative @@ -131,7 +132,7 @@ func (u *URL) parse(v string, checkExists bool) error { } parts := strings.Split(v[slashIndex+1:], ".") if len(parts) != 2 { - return ErrInvalidIconURL.Withf("expect @%s/., e.g. @%s/adguard-home.webp", beforeSlash, beforeSlash) + return fmt.Errorf("%w: expect %s/., e.g. %s/adguard-home.webp", ErrInvalidIconURL, beforeSlash, beforeSlash) } reference, format := parts[0], strings.ToLower(parts[1]) if reference == "" || format == "" { @@ -140,7 +141,7 @@ func (u *URL) parse(v string, checkExists bool) error { switch format { case "svg", "png", "webp": default: - return ErrInvalidIconURL.Withf("%s", "invalid image format, expect svg/png/webp") + return fmt.Errorf("%w: invalid image format, expect svg/png/webp", ErrInvalidIconURL) } isLight, isDark := false, false if strings.HasSuffix(reference, "-light") { @@ -158,10 +159,10 @@ func (u *URL) parse(v string, checkExists bool) error { IsDark: isDark, } if checkExists && !u.HasIcon() { - return ErrInvalidIconURL.Withf("no such icon %s.%s from %s", reference, format, u.Source) + return fmt.Errorf("%w: no such icon %s.%s from %s", ErrInvalidIconURL, reference, format, u.Source) } default: - return ErrInvalidIconURL.Subject(v) + return gperr.PrependSubject(ErrInvalidIconURL, v) } return nil diff --git a/internal/homepage/integrations/qbittorrent/client.go b/internal/homepage/integrations/qbittorrent/client.go index 3b225ee0..0cff3075 100644 --- a/internal/homepage/integrations/qbittorrent/client.go +++ b/internal/homepage/integrations/qbittorrent/client.go @@ -3,12 +3,12 @@ package qbittorrent import ( "context" "encoding/json" + "fmt" "io" "net/http" "net/url" "github.com/yusing/godoxy/internal/homepage/widgets" - gperr "github.com/yusing/goutils/errs" ) type Client struct { @@ -46,7 +46,7 @@ func (c *Client) doRequest(ctx context.Context, method, endpoint string, query u } if resp.StatusCode != http.StatusOK { - return nil, gperr.Errorf("%w: %d %s", widgets.ErrHTTPStatus, resp.StatusCode, resp.Status) + return nil, fmt.Errorf("%w: %d %s", widgets.ErrHTTPStatus, resp.StatusCode, resp.Status) } return resp, nil diff --git a/internal/homepage/widgets/README.md b/internal/homepage/widgets/README.md index c52d62d6..e2a2b719 100644 --- a/internal/homepage/widgets/README.md +++ b/internal/homepage/widgets/README.md @@ -50,8 +50,7 @@ const ( ### Errors ```go -var ErrInvalidProvider = gperr.New("invalid provider") -var ErrHTTPStatus = gperr.New("http status") +var ErrInvalidProvider = errors.New("invalid provider") ``` ## API Reference diff --git a/internal/homepage/widgets/http.go b/internal/homepage/widgets/http.go index f4b71982..59e168ca 100644 --- a/internal/homepage/widgets/http.go +++ b/internal/homepage/widgets/http.go @@ -1,14 +1,13 @@ package widgets import ( + "errors" "net/http" "time" - - gperr "github.com/yusing/goutils/errs" ) var HTTPClient = &http.Client{ Timeout: 10 * time.Second, } -var ErrHTTPStatus = gperr.New("http status") +var ErrHTTPStatus = errors.New("http status") diff --git a/internal/homepage/widgets/widgets.go b/internal/homepage/widgets/widgets.go index 5ee440ea..0580cfcb 100644 --- a/internal/homepage/widgets/widgets.go +++ b/internal/homepage/widgets/widgets.go @@ -2,6 +2,8 @@ package widgets import ( "context" + "errors" + "fmt" "github.com/yusing/godoxy/internal/serialization" gperr "github.com/yusing/goutils/errs" @@ -30,21 +32,21 @@ var widgetProviders = map[string]struct{}{ WidgetProviderQbittorrent: {}, } -var ErrInvalidProvider = gperr.New("invalid provider") +var ErrInvalidProvider = errors.New("invalid provider") func (cfg *Config) UnmarshalMap(m map[string]any) error { var ok bool cfg.Provider, ok = m["provider"].(string) if !ok { - return ErrInvalidProvider.Withf("non string") + return fmt.Errorf("%w: non string", ErrInvalidProvider) } if _, ok := widgetProviders[cfg.Provider]; !ok { - return ErrInvalidProvider.Subject(cfg.Provider) + return gperr.PrependSubject(ErrInvalidProvider, cfg.Provider) } delete(m, "provider") m, ok = m["config"].(map[string]any) if !ok { - return gperr.New("invalid config") + return errors.New("invalid config") } return serialization.MapUnmarshalValidate(m, &cfg.Config) } diff --git a/internal/idlewatcher/README.md b/internal/idlewatcher/README.md index 56af1d86..f73eb0d1 100644 --- a/internal/idlewatcher/README.md +++ b/internal/idlewatcher/README.md @@ -70,7 +70,7 @@ func NewWatcher(parent task.Parent, r types.Route, cfg *types.IdlewatcherConfig) func (w *Watcher) Wake(ctx context.Context) error // Start begins the idle watcher loop -func (w *Watcher) Start(parent task.Parent) gperr.Error +func (w *Watcher) Start(parent task.Parent) error // ServeHTTP serves the loading page and SSE events func (w *Watcher) ServeHTTP(rw http.ResponseWriter, r *http.Request) @@ -103,7 +103,7 @@ var ( classDiagram class Watcher { +Wake(ctx) error - +Start(parent) gperr.Error + +Start(parent) error +ServeHTTP(ResponseWriter, *Request) +ListenAndServe(ctx, preDial, onRead) +Key() string diff --git a/internal/idlewatcher/errors.go b/internal/idlewatcher/errors.go index 3254f6f6..d855050a 100644 --- a/internal/idlewatcher/errors.go +++ b/internal/idlewatcher/errors.go @@ -20,7 +20,7 @@ func (e *watcherError) Error() string { } func (w *Watcher) newWatcherError(err error) error { - if errors.Is(err, causeReload) { + if errors.Is(err, errCauseReload) { return nil } if wErr, ok := err.(*watcherError); ok { //nolint:errorlint @@ -44,7 +44,7 @@ func (e *depError) Error() string { } func (w *Watcher) newDepError(action string, dep *dependency, err error) error { - if errors.Is(err, causeReload) { + if errors.Is(err, errCauseReload) { return nil } if dErr, ok := err.(*depError); ok { //nolint:errorlint diff --git a/internal/idlewatcher/events.go b/internal/idlewatcher/events.go index a3b9cfd8..64c63f9b 100644 --- a/internal/idlewatcher/events.go +++ b/internal/idlewatcher/events.go @@ -4,14 +4,11 @@ import ( "encoding/json" "fmt" "io" - "time" ) type WakeEvent struct { - Type string `json:"type"` - Message string `json:"message"` - Timestamp time.Time `json:"timestamp"` - Error string `json:"error,omitempty"` + Message string `json:"message"` + Error string `json:"error,omitempty"` } type WakeEventType string @@ -26,11 +23,18 @@ const ( WakeEventError WakeEventType = "error" ) -func (w *Watcher) newWakeEvent(eventType WakeEventType, message string, err error) *WakeEvent { +func writeSSE(w io.Writer, v any) error { + data, err := json.Marshal(v) + if err != nil { + return err + } + _, err = fmt.Fprintf(w, "data: %s\n\n", data) + return err +} + +func (w *Watcher) newWakeEvent(message string, err error) *WakeEvent { event := &WakeEvent{ - Type: string(eventType), - Message: message, - Timestamp: time.Now(), + Message: message, } if err != nil { event.Error = err.Error() @@ -39,37 +43,28 @@ func (w *Watcher) newWakeEvent(eventType WakeEventType, message string, err erro } func (e *WakeEvent) WriteSSE(w io.Writer) error { - data, err := json.Marshal(e) - if err != nil { - return err - } - _, err = fmt.Fprintf(w, "data: %s\n\n", data) - return err + return writeSSE(w, e) } func (w *Watcher) clearEventHistory() { - w.eventHistoryMu.Lock() - w.eventHistory = w.eventHistory[:0] - w.eventHistoryMu.Unlock() + w.events.Clear() } func (w *Watcher) sendEvent(eventType WakeEventType, message string, err error) { // NOTE: events will be cleared on stop/pause - event := w.newWakeEvent(eventType, message, err) + wakeEvent := w.newWakeEvent(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 - } + level := gevents.LevelInfo + if eventType == WakeEventError { + level = gevents.LevelError } + + w.events.Add(gevents.NewEvent( + level, + w.cfg.ContainerName(), + string(eventType), + wakeEvent, + )) } diff --git a/internal/idlewatcher/handle_http.go b/internal/idlewatcher/handle_http.go index 2778c0e8..40d2c15e 100644 --- a/internal/idlewatcher/handle_http.go +++ b/internal/idlewatcher/handle_http.go @@ -7,10 +7,10 @@ import ( "net/http" "strconv" + "github.com/rs/zerolog/log" "github.com/yusing/godoxy/internal/homepage/icons" iconfetch "github.com/yusing/godoxy/internal/homepage/icons/fetch" idlewatcher "github.com/yusing/godoxy/internal/idlewatcher/types" - gperr "github.com/yusing/goutils/errs" httputils "github.com/yusing/goutils/http" _ "unsafe" @@ -47,15 +47,6 @@ func (w *Watcher) ServeHTTP(rw http.ResponseWriter, r *http.Request) { } 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) - }() - // Set SSE headers rw.Header().Set("Content-Type", "text/event-stream") rw.Header().Set("Cache-Control", "no-cache") @@ -66,20 +57,18 @@ func (w *Watcher) handleWakeEventsSSE(rw http.ResponseWriter, r *http.Request) { 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() + current, ch, cancel := w.events.SnapshotAndListen() + defer cancel() - for _, event := range historicalEvents { + // Send historical events first + for _, evt := range current { select { case <-ctx.Done(): return default: - err := errors.Join(event.WriteSSE(rw), controller.Flush()) + err := errors.Join(writeSSE(rw, evt), controller.Flush()) if err != nil { - gperr.LogError("Failed to write SSE event", err, &w.l) + log.Err(err).Msg("Failed to write SSE event") return } } @@ -88,10 +77,10 @@ func (w *Watcher) handleWakeEventsSSE(rw http.ResponseWriter, r *http.Request) { // Listen for new events and send them to client for { select { - case event := <-eventCh: - err := errors.Join(event.WriteSSE(rw), controller.Flush()) + case evt := <-ch: + err := errors.Join(writeSSE(rw, evt), controller.Flush()) if err != nil { - gperr.LogError("Failed to write SSE event", err, &w.l) + log.Err(err).Msg("Failed to write SSE event") return } case <-ctx.Done(): @@ -169,7 +158,7 @@ func (w *Watcher) wakeFromHTTP(rw http.ResponseWriter, r *http.Request) (shouldN err := w.Wake(r.Context()) if err != nil { - gperr.LogError("Failed to wake container", err, &w.l) + log.Err(err).Msg("Failed to wake container") if !acceptHTML { http.Error(rw, "Failed to wake container", http.StatusInternalServerError) return false diff --git a/internal/idlewatcher/handle_http_debug.go b/internal/idlewatcher/handle_http_debug.go index b0b144fc..98108d65 100644 --- a/internal/idlewatcher/handle_http_debug.go +++ b/internal/idlewatcher/handle_http_debug.go @@ -7,14 +7,14 @@ import ( "net/http" "time" - "github.com/puzpuzpuz/xsync/v4" idlewatcher "github.com/yusing/godoxy/internal/idlewatcher/types" "github.com/yusing/godoxy/internal/types" + gevents "github.com/yusing/goutils/events" ) func DebugHandler(rw http.ResponseWriter, r *http.Request) { w := &Watcher{ - eventChs: xsync.NewMap[chan *WakeEvent, struct{}](), + events: gevents.NewHistory(), cfg: &types.IdlewatcherConfig{ IdlewatcherProviderConfig: types.IdlewatcherProviderConfig{ Docker: &types.DockerConfig{ @@ -33,7 +33,7 @@ func DebugHandler(rw http.ResponseWriter, r *http.Request) { go w.handleWakeEventsSSE(rw, r) ticker := time.NewTicker(1 * time.Second) defer ticker.Stop() - events := []WakeEventType{ + eventTypes := []WakeEventType{ WakeEventStarting, WakeEventWakingDep, WakeEventDepReady, @@ -57,14 +57,8 @@ func DebugHandler(rw http.ResponseWriter, r *http.Request) { case <-r.Context().Done(): return case <-ticker.C: - idx := rand.IntN(len(events)) - for ch := range w.eventChs.Range { - ch <- &WakeEvent{ - Type: string(events[idx]), - Message: messages[idx], - Timestamp: time.Now(), - } - } + idx := rand.IntN(len(eventTypes)) + w.sendEvent(eventTypes[idx], messages[idx], nil) } } default: diff --git a/internal/idlewatcher/handle_stream.go b/internal/idlewatcher/handle_stream.go index f24648ba..3dfde66c 100644 --- a/internal/idlewatcher/handle_stream.go +++ b/internal/idlewatcher/handle_stream.go @@ -10,8 +10,8 @@ import ( var _ nettypes.Stream = (*Watcher)(nil) // ListenAndServe implements nettypes.Stream. -func (w *Watcher) ListenAndServe(ctx context.Context, predial, onRead nettypes.HookFunc) { - w.stream.ListenAndServe(ctx, func(ctx context.Context) error { //nolint:contextcheck +func (w *Watcher) ListenAndServe(ctx context.Context, predial, onRead nettypes.HookFunc) error { + return w.stream.ListenAndServe(ctx, func(ctx context.Context) error { return w.preDial(ctx, predial) }, func(ctx context.Context) error { return w.onRead(ctx, onRead) diff --git a/internal/idlewatcher/health.go b/internal/idlewatcher/health.go index 64fa070d..dc7fc5ae 100644 --- a/internal/idlewatcher/health.go +++ b/internal/idlewatcher/health.go @@ -1,16 +1,16 @@ package idlewatcher import ( + "fmt" "time" idlewatcher "github.com/yusing/godoxy/internal/idlewatcher/types" "github.com/yusing/godoxy/internal/types" - gperr "github.com/yusing/goutils/errs" "github.com/yusing/goutils/task" ) // Start implements health.HealthMonitor. -func (w *Watcher) Start(parent task.Parent) gperr.Error { +func (w *Watcher) Start(parent task.Parent) error { w.task.OnCancel("route_cleanup", func() { parent.Finish(w.task.FinishCause()) }) @@ -113,7 +113,7 @@ func (w *Watcher) checkUpdateState() (ready bool, err error) { if !state.startedAt.IsZero() { elapsed := time.Since(state.startedAt) if elapsed > w.cfg.WakeTimeout { - err := gperr.Errorf("container failed to become ready within %v (started at %v, %d health check attempts)", + err := fmt.Errorf("container failed to become ready within %v (started at %v, %d health check attempts)", w.cfg.WakeTimeout, state.startedAt, state.healthTries) w.l.Error(). Dur("elapsed", elapsed). diff --git a/internal/idlewatcher/html/loading.js b/internal/idlewatcher/html/loading.js index 9860d259..42ec7a15 100644 --- a/internal/idlewatcher/html/loading.js +++ b/internal/idlewatcher/html/loading.js @@ -1,6 +1,26 @@ +/** + * @typedef {"debug"|"info"|"warn"|"error"} EventLevel + * @see goutils/events/level.go + */ + +/** + * @typedef {{ message?: string, error?: string }} WakeEvent + * @see internal/idlewatcher/events.go WakeEvent + */ + +/** + * @typedef {"starting"|"waking_dep"|"dep_ready"|"container_woke"|"waiting_ready"|"ready"|"error"} WakeEventType + * @see internal/idlewatcher/events.go WakeEventType + */ + +/** + * @typedef {{ timestamp: string, level: EventLevel, category: string, action: WakeEventType, data: WakeEvent }} WakeSSEEvent + * @see goutils/events/event.go Event + */ + let ready = false; -window.onload = async function () { +window.onload = async () => { const consoleEl = document.getElementById("console"); const loadingDotsEl = document.getElementById("loading-dots"); @@ -9,6 +29,10 @@ window.onload = async function () { return; } + /** + * @param {string} timestamp - ISO timestamp string + * @returns {string} + */ function formatTimestamp(timestamp) { const date = new Date(timestamp); return date.toLocaleTimeString("en-US", { @@ -20,6 +44,11 @@ window.onload = async function () { }); } + /** + * @param {string} type - Console line type (e.g. ready, error, or WakeEventType) + * @param {string} message + * @param {string} timestamp - ISO timestamp string + */ function addConsoleLine(type, message, timestamp) { const line = document.createElement("div"); line.className = `console-line ${type}`; @@ -43,7 +72,7 @@ window.onload = async function () { addConsoleLine( "error", "Configuration error: wakeEventsPath not defined", - new Date().toISOString() + new Date().toISOString(), ); loadingDotsEl.style.display = "none"; return; @@ -53,7 +82,7 @@ window.onload = async function () { addConsoleLine( "error", "Browser does not support Server-Sent Events", - new Date().toISOString() + new Date().toISOString(), ); loadingDotsEl.style.display = "none"; return; @@ -62,44 +91,46 @@ window.onload = async function () { // Connect to SSE endpoint const eventSource = new EventSource(wakeEventsPath); - eventSource.onmessage = function (event) { - let data; + eventSource.onmessage = (event) => { + /** @type {WakeSSEEvent} */ + let evt; try { - data = JSON.parse(event.data); - } catch (error) { + evt = JSON.parse(event.data); + } catch { addConsoleLine( "error", - "Invalid event data: " + event.data, - new Date().toISOString() + `Invalid event data: ${event.data}`, + new Date().toISOString(), ); return; } - if (data.type === "ready") { + const payload = evt.data || {}; + const type = evt.action; + const timestamp = evt.timestamp; + + if (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 - ); + addConsoleLine(type, "Container is ready, refreshing...", timestamp); setTimeout(() => { window.location.reload(); }, 200); - } else if (data.type === "error") { + } else if (type === "error" || evt.level === "error") { // Show error message and hide loading dots - const errorMessage = data.error || data.message; - addConsoleLine(data.type, errorMessage, data.timestamp); + const errorMessage = payload.error || payload.message || "Unknown error"; + addConsoleLine(type, errorMessage, timestamp); loadingDotsEl.style.display = "none"; eventSource.close(); } else { // Show other message types - addConsoleLine(data.type, data.message, data.timestamp); + const message = payload.message; + addConsoleLine(type, message, timestamp); } }; - eventSource.onerror = function (event) { + eventSource.onerror = () => { if (ready) { // event will be closed by the server return; @@ -107,7 +138,7 @@ window.onload = async function () { addConsoleLine( "error", "Connection lost. Please refresh the page.", - new Date().toISOString() + new Date().toISOString(), ); loadingDotsEl.style.display = "none"; eventSource.close(); diff --git a/internal/idlewatcher/loading_page.go b/internal/idlewatcher/loading_page.go index 8fdd71a7..c06976d8 100644 --- a/internal/idlewatcher/loading_page.go +++ b/internal/idlewatcher/loading_page.go @@ -25,7 +25,7 @@ var loadingPageTmpl = template.Must(template.New("loading_page").Parse(string(lo //go:embed html/style.css var cssBytes []byte -//go:embed html/loading.js +//go:embed html/loading-min.js var jsBytes []byte func (w *Watcher) writeLoadingPage(rw http.ResponseWriter) error { diff --git a/internal/idlewatcher/provider/README.md b/internal/idlewatcher/provider/README.md index 5224098e..fb42ba67 100644 --- a/internal/idlewatcher/provider/README.md +++ b/internal/idlewatcher/provider/README.md @@ -36,7 +36,7 @@ type Provider interface { // Status and monitoring ContainerStatus(ctx context.Context) (ContainerStatus, error) - Watch(ctx context.Context) (eventCh <-chan events.Event, errCh <-chan gperr.Error) + Watch(ctx context.Context) (eventCh <-chan events.Event, errCh <-chan error) // Cleanup Close() diff --git a/internal/idlewatcher/provider/docker.go b/internal/idlewatcher/provider/docker.go index 0a0510af..740b5c83 100644 --- a/internal/idlewatcher/provider/docker.go +++ b/internal/idlewatcher/provider/docker.go @@ -2,13 +2,13 @@ package provider import ( "context" + "fmt" "github.com/docker/docker/api/types/container" "github.com/yusing/godoxy/internal/docker" idlewatcher "github.com/yusing/godoxy/internal/idlewatcher/types" "github.com/yusing/godoxy/internal/types" "github.com/yusing/godoxy/internal/watcher" - gperr "github.com/yusing/goutils/errs" ) type DockerProvider struct { @@ -67,10 +67,10 @@ func (p *DockerProvider) ContainerStatus(ctx context.Context) (idlewatcher.Conta case container.StatePaused: return idlewatcher.ContainerStatusPaused, nil } - return idlewatcher.ContainerStatusError, idlewatcher.ErrUnexpectedContainerStatus.Subject(status.State.Status) + return idlewatcher.ContainerStatusError, fmt.Errorf("%w: %s", idlewatcher.ErrUnexpectedContainerStatus, status.State.Status) } -func (p *DockerProvider) Watch(ctx context.Context) (eventCh <-chan watcher.Event, errCh <-chan gperr.Error) { +func (p *DockerProvider) Watch(ctx context.Context) (eventCh <-chan watcher.Event, errCh <-chan error) { return p.watcher.EventsWithOptions(ctx, watcher.DockerListOptions{ Filters: watcher.NewDockerFilter( watcher.DockerFilterContainer, diff --git a/internal/idlewatcher/provider/proxmox.go b/internal/idlewatcher/provider/proxmox.go index 0b7e5b19..0ef6131f 100644 --- a/internal/idlewatcher/provider/proxmox.go +++ b/internal/idlewatcher/provider/proxmox.go @@ -2,6 +2,8 @@ package provider import ( "context" + "errors" + "fmt" "strconv" "time" @@ -9,14 +11,14 @@ import ( "github.com/yusing/godoxy/internal/proxmox" "github.com/yusing/godoxy/internal/types" "github.com/yusing/godoxy/internal/watcher" - "github.com/yusing/godoxy/internal/watcher/events" + watcherEvents "github.com/yusing/godoxy/internal/watcher/events" gperr "github.com/yusing/goutils/errs" ) type ProxmoxProvider struct { *proxmox.Node - vmid int + vmid uint64 lxcName string running bool } @@ -25,9 +27,9 @@ const proxmoxStateCheckInterval = 1 * time.Second var ErrNodeNotFound = gperr.New("node not found in pool") -func NewProxmoxProvider(ctx context.Context, nodeName string, vmid int) (idlewatcher.Provider, error) { +func NewProxmoxProvider(ctx context.Context, nodeName string, vmid uint64) (idlewatcher.Provider, error) { if nodeName == "" || vmid == 0 { - return nil, gperr.New("node name and vmid are required") + return nil, errors.New("node name and vmid are required") } node, ok := proxmox.Nodes.Get(nodeName) @@ -77,12 +79,12 @@ func (p *ProxmoxProvider) ContainerStatus(ctx context.Context) (idlewatcher.Cont case proxmox.LXCStatusStopped: return idlewatcher.ContainerStatusStopped, nil } - return idlewatcher.ContainerStatusError, idlewatcher.ErrUnexpectedContainerStatus.Subject(string(status)) + return idlewatcher.ContainerStatusError, fmt.Errorf("%w: %s", idlewatcher.ErrUnexpectedContainerStatus, string(status)) } -func (p *ProxmoxProvider) Watch(ctx context.Context) (<-chan watcher.Event, <-chan gperr.Error) { +func (p *ProxmoxProvider) Watch(ctx context.Context) (<-chan watcher.Event, <-chan error) { eventCh := make(chan watcher.Event) - errCh := make(chan gperr.Error) + errCh := make(chan error) go func() { defer close(eventCh) @@ -91,7 +93,7 @@ func (p *ProxmoxProvider) Watch(ctx context.Context) (<-chan watcher.Event, <-ch var err error p.running, err = p.LXCIsRunning(ctx, p.vmid) if err != nil { - errCh <- gperr.Wrap(err) + errCh <- err return } @@ -99,8 +101,8 @@ func (p *ProxmoxProvider) Watch(ctx context.Context) (<-chan watcher.Event, <-ch defer ticker.Stop() event := watcher.Event{ - Type: events.EventTypeDocker, - ActorID: strconv.Itoa(p.vmid), + Type: watcherEvents.EventTypeDocker, + ActorID: strconv.FormatUint(p.vmid, 10), ActorName: p.lxcName, } for { @@ -110,16 +112,16 @@ func (p *ProxmoxProvider) Watch(ctx context.Context) (<-chan watcher.Event, <-ch case <-ticker.C: status, err := p.ContainerStatus(ctx) if err != nil { - errCh <- gperr.Wrap(err) + errCh <- err return } running := status == idlewatcher.ContainerStatusRunning if p.running != running { p.running = running if running { - event.Action = events.ActionContainerStart + event.Action = watcherEvents.ActionContainerStart } else { - event.Action = events.ActionContainerStop + event.Action = watcherEvents.ActionContainerStop } eventCh <- event } diff --git a/internal/idlewatcher/types/container_status.go b/internal/idlewatcher/types/container_status.go index c2f13b76..6e2ac644 100644 --- a/internal/idlewatcher/types/container_status.go +++ b/internal/idlewatcher/types/container_status.go @@ -1,6 +1,6 @@ package idlewatcher -import gperr "github.com/yusing/goutils/errs" +import "errors" type ContainerStatus string @@ -11,4 +11,4 @@ const ( ContainerStatusStopped ContainerStatus = "stopped" ) -var ErrUnexpectedContainerStatus = gperr.New("unexpected container status") +var ErrUnexpectedContainerStatus = errors.New("unexpected container status") diff --git a/internal/idlewatcher/types/provider.go b/internal/idlewatcher/types/provider.go index 66af96a4..135e5700 100644 --- a/internal/idlewatcher/types/provider.go +++ b/internal/idlewatcher/types/provider.go @@ -4,8 +4,7 @@ import ( "context" "github.com/yusing/godoxy/internal/types" - "github.com/yusing/godoxy/internal/watcher/events" - gperr "github.com/yusing/goutils/errs" + watcherEvents "github.com/yusing/godoxy/internal/watcher/events" ) type Provider interface { @@ -15,6 +14,6 @@ type Provider interface { ContainerStop(ctx context.Context, signal types.ContainerSignal, timeout int) error ContainerKill(ctx context.Context, signal types.ContainerSignal) error ContainerStatus(ctx context.Context) (ContainerStatus, error) - Watch(ctx context.Context) (eventCh <-chan events.Event, errCh <-chan gperr.Error) + Watch(ctx context.Context) (eventCh <-chan watcherEvents.Event, errCh <-chan error) Close() } diff --git a/internal/idlewatcher/watcher.go b/internal/idlewatcher/watcher.go index 5f43f9e5..5d5e7fd5 100644 --- a/internal/idlewatcher/watcher.go +++ b/internal/idlewatcher/watcher.go @@ -3,25 +3,26 @@ package idlewatcher import ( "context" "errors" + "fmt" "math" "strings" "sync" "time" - "github.com/puzpuzpuz/xsync/v4" "github.com/rs/zerolog" "github.com/rs/zerolog/log" "github.com/yusing/ds/ordered" config "github.com/yusing/godoxy/internal/config/types" "github.com/yusing/godoxy/internal/docker" + entrypoint "github.com/yusing/godoxy/internal/entrypoint/types" "github.com/yusing/godoxy/internal/health/monitor" "github.com/yusing/godoxy/internal/idlewatcher/provider" idlewatcher "github.com/yusing/godoxy/internal/idlewatcher/types" nettypes "github.com/yusing/godoxy/internal/net/types" - "github.com/yusing/godoxy/internal/route/routes" "github.com/yusing/godoxy/internal/types" - "github.com/yusing/godoxy/internal/watcher/events" + watcherEvents "github.com/yusing/godoxy/internal/watcher/events" gperr "github.com/yusing/goutils/errs" + gevents "github.com/yusing/goutils/events" "github.com/yusing/goutils/http/reverseproxy" strutils "github.com/yusing/goutils/strings" "github.com/yusing/goutils/synk" @@ -63,12 +64,9 @@ 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 + // Per-watcher event history (for SSE and debug) + events *gevents.History - // FIXME: missing dependencies dependsOn []*dependency } @@ -97,8 +95,8 @@ const ( ) var ( - causeReload = gperr.New("reloaded") //nolint:errname - causeContainerDestroy = gperr.New("container destroyed") //nolint:errname + errCauseReload = errors.New("reloaded") + errCauseContainerDestroy = errors.New("container destroyed") ) const reqTimeout = 3 * time.Second @@ -132,7 +130,7 @@ func NewWatcher(parent task.Parent, r types.Route, cfg *types.IdlewatcherConfig) idleTicker: time.NewTicker(cfg.IdleTimeout), healthTicker: time.NewTicker(idleWakerCheckInterval), readyNotifyCh: make(chan struct{}, 1), // buffered to avoid blocking - eventChs: xsync.NewMap[chan *WakeEvent, struct{}](), + events: gevents.NewHistory(), cfg: cfg, routeHelper: routeHelper{ hc: monitor.NewMonitor(r), @@ -173,7 +171,7 @@ func NewWatcher(parent task.Parent, r types.Route, cfg *types.IdlewatcherConfig) } if !ok { - depRoute, ok = routes.GetIncludeExcluded(dep) + depRoute, ok = entrypoint.FromCtx(parent.Context()).GetRoute(dep) if !ok { depErrors.Addf("dependency %q not found", dep) continue @@ -286,7 +284,7 @@ func NewWatcher(parent task.Parent, r types.Route, cfg *types.IdlewatcherConfig) w.stream = r.Stream() default: p.Close() - return nil, w.newWatcherError(gperr.Errorf("unexpected route type: %T", r)) + return nil, w.newWatcherError(fmt.Errorf("unexpected route type: %T", r)) } w.route = r @@ -320,12 +318,13 @@ func NewWatcher(parent task.Parent, r types.Route, cfg *types.IdlewatcherConfig) delete(watcherMap, key) watcherMapMu.Unlock() - if errors.Is(cause, causeReload) { + switch { + case errors.Is(cause, errCauseReload): // no log - } else if errors.Is(cause, causeContainerDestroy) || errors.Is(cause, task.ErrProgramExiting) || errors.Is(cause, config.ErrConfigChanged) { + case errors.Is(cause, errCauseContainerDestroy), errors.Is(cause, task.ErrProgramExiting), errors.Is(cause, config.ErrConfigChanged): w.l.Info().Msg("idlewatcher stopped") - } else { - gperr.LogError("idlewatcher stopped unexpectedly", cause, &w.l) + default: + w.l.Err(cause).Msg("idlewatcher stopped unexpectedly") } w.idleTicker.Stop() @@ -467,7 +466,7 @@ func (w *Watcher) wakeIfStopped(ctx context.Context) error { defer cancel() p := w.provider.Load() if p == nil { - return gperr.Errorf("provider not set") + return errors.New("provider not set") } switch state.status { case idlewatcher.ContainerStatusStopped: @@ -477,7 +476,7 @@ func (w *Watcher) wakeIfStopped(ctx context.Context) error { w.sendEvent(WakeEventStarting, w.cfg.ContainerName()+" is unpausing...", nil) return p.ContainerUnpause(ctx) default: - return gperr.Errorf("unexpected container status: %s", state.status) + return fmt.Errorf("unexpected container status: %s", state.status) } } @@ -512,7 +511,7 @@ func (w *Watcher) stopByMethod() error { var err error p := w.provider.Load() if p == nil { - return gperr.New("provider not set") + return errors.New("provider not set") } switch cfg.StopMethod { case types.ContainerStopMethodPause: @@ -522,7 +521,7 @@ func (w *Watcher) stopByMethod() error { case types.ContainerStopMethodKill: err = p.ContainerKill(ctx, cfg.StopSignal) default: - err = w.newWatcherError(gperr.Errorf("unexpected stop method: %q", cfg.StopMethod)) + err = w.newWatcherError(fmt.Errorf("unexpected stop method: %q", cfg.StopMethod)) } if err != nil { @@ -564,7 +563,7 @@ func (w *Watcher) expires() time.Time { func (w *Watcher) watchUntilDestroy() (returnCause error) { p := w.provider.Load() if p == nil { - return gperr.Errorf("provider not set") + return errors.New("provider not set") } defer p.Close() eventCh, errCh := p.Watch(w.Task().Context()) @@ -572,15 +571,15 @@ func (w *Watcher) watchUntilDestroy() (returnCause error) { for { select { case <-w.task.Context().Done(): - return gperr.Wrap(w.task.FinishCause()) + return w.task.FinishCause() case err := <-errCh: - gperr.LogError("watcher error", err, &w.l) + w.l.Err(err).Msg("watcher error") case e := <-eventCh: w.l.Debug().Stringer("action", e.Action).Msg("state changed") switch e.Action { - case events.ActionContainerDestroy: - return causeContainerDestroy - case events.ActionForceReload: + case watcherEvents.ActionContainerDestroy: + return errCauseContainerDestroy + case watcherEvents.ActionForceReload: continue } w.resetIdleTimer() diff --git a/internal/logging/accesslog/config.go b/internal/logging/accesslog/config.go index 1200ba2f..944fd41b 100644 --- a/internal/logging/accesslog/config.go +++ b/internal/logging/accesslog/config.go @@ -1,11 +1,11 @@ package accesslog import ( + "errors" "net/http" "time" "github.com/yusing/godoxy/internal/serialization" - gperr "github.com/yusing/goutils/errs" ) type ( @@ -58,9 +58,9 @@ var ( ReqLoggerFormats = []Format{FormatCommon, FormatCombined, FormatJSON} ) -func (cfg *ConfigBase) Validate() gperr.Error { +func (cfg *ConfigBase) Validate() error { if cfg.Path == "" && !cfg.Stdout { - return gperr.New("path or stdout is required") + return errors.New("path or stdout is required") } return nil } diff --git a/internal/logging/accesslog/console_logger.go b/internal/logging/accesslog/console_logger.go index 70d61bc9..7651dceb 100644 --- a/internal/logging/accesslog/console_logger.go +++ b/internal/logging/accesslog/console_logger.go @@ -59,8 +59,8 @@ func (l *ConsoleLogger) LogError(req *http.Request, err error) { l.formatter.LogRequestZeroLog(&log, req, internalErrorResponse) } -func (l *ConsoleLogger) LogACL(info *maxmind.IPInfo, blocked bool) { - ConsoleACLFormatter{}.LogACLZeroLog(stdoutLogger, info, blocked) +func (l *ConsoleLogger) LogACL(info *maxmind.IPInfo, blocked bool, reason string) { + ConsoleACLFormatter{}.LogACLZeroLog(stdoutLogger, info, blocked, reason) } func (l *ConsoleLogger) Flush() { diff --git a/internal/logging/accesslog/file_access_logger.go b/internal/logging/accesslog/file_access_logger.go index 74bcf0f7..17b45c9a 100644 --- a/internal/logging/accesslog/file_access_logger.go +++ b/internal/logging/accesslog/file_access_logger.go @@ -1,6 +1,7 @@ package accesslog import ( + "fmt" "io" "net/http" "sync" @@ -11,7 +12,6 @@ import ( "github.com/rs/zerolog" "github.com/rs/zerolog/log" maxmind "github.com/yusing/godoxy/internal/maxmind/types" - gperr "github.com/yusing/goutils/errs" ioutils "github.com/yusing/goutils/io" strutils "github.com/yusing/goutils/strings" "github.com/yusing/goutils/synk" @@ -131,7 +131,7 @@ func (l *fileAccessLogger) LogError(req *http.Request, err error) { l.LogRequest(req, internalErrorResponse) } -func (l *fileAccessLogger) LogACL(info *maxmind.IPInfo, blocked bool) { +func (l *fileAccessLogger) LogACL(info *maxmind.IPInfo, blocked bool, reason string) { line := bytesPool.GetBuffer() defer bytesPool.PutBuffer(line) l.AppendACLLog(line, info, blocked) @@ -161,9 +161,9 @@ func (l *fileAccessLogger) Rotate(result *RotateResult) (rotated bool, err error func (l *fileAccessLogger) handleErr(err error) { if l.errRateLimiter.Allow() { - gperr.LogError("failed to write access log", err, &l.logger) + l.logger.Err(err).Msg("failed to write access log") } else { - gperr.LogError("too many errors, stopping access log", err, &l.logger) + l.logger.Err(err).Msg("too many errors, stopping access log") l.task.Finish(err) } } @@ -234,7 +234,7 @@ func (l *fileAccessLogger) write(data []byte) { 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)) + l.handleErr(fmt.Errorf("%w, writing %d bytes, only %d written", io.ErrShortWrite, len(data), n)) } atomic.AddInt64(&l.writeCount, int64(n)) } diff --git a/internal/logging/accesslog/filter.go b/internal/logging/accesslog/filter.go index 5352174e..d77d3b84 100644 --- a/internal/logging/accesslog/filter.go +++ b/internal/logging/accesslog/filter.go @@ -1,12 +1,13 @@ package accesslog import ( + "errors" + "fmt" "net" "net/http" "strings" nettypes "github.com/yusing/godoxy/internal/net/types" - gperr "github.com/yusing/goutils/errs" strutils "github.com/yusing/goutils/strings" ) @@ -29,7 +30,7 @@ type ( } // @name CIDR ) -var ErrInvalidHTTPHeaderFilter = gperr.New("invalid http header filter") +var ErrInvalidHTTPHeaderFilter = errors.New("invalid http header filter") func (f *LogFilter[T]) CheckKeep(req *http.Request, res *http.Response) bool { if len(f.Values) == 0 { @@ -59,7 +60,7 @@ func (k *HTTPHeader) Parse(v string) error { split = append(split, "") case 2: default: - return ErrInvalidHTTPHeaderFilter.Subject(v) + return fmt.Errorf("%w: %s", ErrInvalidHTTPHeaderFilter, v) } k.Key = split[0] k.Value = split[1] diff --git a/internal/logging/accesslog/formatter.go b/internal/logging/accesslog/formatter.go index 8be1fdcc..68fbf42a 100644 --- a/internal/logging/accesslog/formatter.go +++ b/internal/logging/accesslog/formatter.go @@ -171,7 +171,7 @@ func (f ACLLogFormatter) LogACLZeroLog(logger *zerolog.Logger, info *maxmind.IPI event.Send() } -func (f ConsoleACLFormatter) LogACLZeroLog(logger *zerolog.Logger, info *maxmind.IPInfo, blocked bool) { +func (f ConsoleACLFormatter) LogACLZeroLog(logger *zerolog.Logger, info *maxmind.IPInfo, blocked bool, reason string) { event := logger.Info() if info.City != nil { if isoCode := info.City.Country.IsoCode; isoCode != "" { @@ -186,6 +186,10 @@ func (f ConsoleACLFormatter) LogACLZeroLog(logger *zerolog.Logger, info *maxmind action = "denied" } + if reason != "" { + event.Str("reason", reason) + } + // NOTE: zerolog will append a newline to the buffer event.Msgf("request %s from %s", action, info.Str) } diff --git a/internal/logging/accesslog/multi_access_logger.go b/internal/logging/accesslog/multi_access_logger.go index bb694178..d34c887d 100644 --- a/internal/logging/accesslog/multi_access_logger.go +++ b/internal/logging/accesslog/multi_access_logger.go @@ -50,9 +50,9 @@ func (m *MultiAccessLogger) LogError(req *http.Request, err error) { } } -func (m *MultiAccessLogger) LogACL(info *maxmind.IPInfo, blocked bool) { +func (m *MultiAccessLogger) LogACL(info *maxmind.IPInfo, blocked bool, reason string) { for _, accessLogger := range m.accessLoggers { - accessLogger.LogACL(info, blocked) + accessLogger.LogACL(info, blocked, reason) } } diff --git a/internal/logging/accesslog/multi_access_logger_test.go b/internal/logging/accesslog/multi_access_logger_test.go index ce54c93c..2fbcf6df 100644 --- a/internal/logging/accesslog/multi_access_logger_test.go +++ b/internal/logging/accesslog/multi_access_logger_test.go @@ -116,7 +116,7 @@ func TestMultiAccessLoggerLogACL(t *testing.T) { Str: "192.168.1.1", } - logger.LogACL(info, false) + logger.LogACL(info, false, "test reason") logger.Flush() expect.Equal(t, writer1.NumLines(), 1) @@ -252,7 +252,7 @@ func TestMultiAccessLoggerMixedOperations(t *testing.T) { cfg2 := DefaultACLLoggerConfig() cfg2.LogAllowed = true aclLogger := NewMultiAccessLogger(testTask, cfg2, writers) - aclLogger.LogACL(info, false) + aclLogger.LogACL(info, false, "test reason") logger.Flush() diff --git a/internal/logging/accesslog/retention.go b/internal/logging/accesslog/retention.go index a67cf953..7033d61f 100644 --- a/internal/logging/accesslog/retention.go +++ b/internal/logging/accesslog/retention.go @@ -1,10 +1,10 @@ package accesslog import ( + "errors" "fmt" "strconv" - gperr "github.com/yusing/goutils/errs" strutils "github.com/yusing/goutils/strings" ) @@ -15,8 +15,8 @@ type Retention struct { } // @name LogRetention var ( - ErrInvalidSyntax = gperr.New("invalid syntax") - ErrZeroValue = gperr.New("zero value") + ErrInvalidSyntax = errors.New("invalid syntax") + ErrZeroValue = errors.New("zero value") ) // see back_scanner_test.go#L210 for benchmarks @@ -34,7 +34,7 @@ var defaultChunkSize = 32 * kilobyte func (r *Retention) Parse(v string) (err error) { split := strutils.SplitSpace(v) if len(split) != 2 { - return ErrInvalidSyntax.Subject(v) + return fmt.Errorf("%w: %s", ErrInvalidSyntax, v) } switch split[0] { case "last": @@ -64,7 +64,7 @@ func (r *Retention) Parse(v string) (err error) { case "GB": r.KeepSize = n * gigabyte default: - return ErrInvalidSyntax.Subject("unit " + split[1]) + return fmt.Errorf("%w: unit %s", ErrInvalidSyntax, split[1]) } } if !r.IsValid() { diff --git a/internal/logging/accesslog/status_code_range.go b/internal/logging/accesslog/status_code_range.go index 9c26d987..45341df5 100644 --- a/internal/logging/accesslog/status_code_range.go +++ b/internal/logging/accesslog/status_code_range.go @@ -1,6 +1,8 @@ package accesslog import ( + "errors" + "fmt" "strconv" gperr "github.com/yusing/goutils/errs" @@ -12,7 +14,7 @@ type StatusCodeRange struct { End int } // @name StatusCodeRange -var ErrInvalidStatusCodeRange = gperr.New("invalid status code range") +var ErrInvalidStatusCodeRange = errors.New("invalid status code range") func (r *StatusCodeRange) Includes(code int) bool { return r.Start <= code && code <= r.End @@ -25,7 +27,7 @@ func (r *StatusCodeRange) Parse(v string) error { case 1: start, err := strconv.Atoi(split[0]) if err != nil { - return gperr.Wrap(err) + return err } r.Start = start r.End = start @@ -40,7 +42,7 @@ func (r *StatusCodeRange) Parse(v string) error { r.End = end return nil default: - return ErrInvalidStatusCodeRange.Subject(v) + return fmt.Errorf("%w: %s", ErrInvalidStatusCodeRange, v) } } diff --git a/internal/logging/accesslog/types.go b/internal/logging/accesslog/types.go index 8017ef67..7d293c17 100644 --- a/internal/logging/accesslog/types.go +++ b/internal/logging/accesslog/types.go @@ -13,7 +13,7 @@ type ( AccessLogger interface { LogRequest(req *http.Request, res *http.Response) LogError(req *http.Request, err error) - LogACL(info *maxmind.IPInfo, blocked bool) + LogACL(info *maxmind.IPInfo, blocked bool, reason string) Config() *Config @@ -35,9 +35,9 @@ type ( } ACLFormatter interface { // AppendACLLog appends a log line to line with or without a trailing newline - AppendACLLog(line *bytes.Buffer, info *maxmind.IPInfo, blocked bool) + AppendACLLog(line *bytes.Buffer, info *maxmind.IPInfo, blocked bool, reason string) // LogACLZeroLog logs an ACL log to the logger - LogACLZeroLog(logger *zerolog.Logger, info *maxmind.IPInfo, blocked bool) + LogACLZeroLog(logger *zerolog.Logger, info *maxmind.IPInfo, blocked bool, reason string) } ) diff --git a/internal/logging/logging.go b/internal/logging/logging.go index ab9deb90..29225d48 100644 --- a/internal/logging/logging.go +++ b/internal/logging/logging.go @@ -87,8 +87,28 @@ func multiWriter(out ...io.Writer) io.Writer { func NewLogger(out ...io.Writer) zerolog.Logger { writer := zerolog.NewConsoleWriter(func(w *zerolog.ConsoleWriter) { - w.Out = diodeMultiWriter(out...) + if !common.IsTest { + w.Out = diodeMultiWriter(out...) + } else { + w.Out = multiWriter(out...) + } w.TimeFormat = timeFmt + w.FormatPrepare = func(evt map[string]any) error { + // move error field to join message if it's multiline + if err, ok := evt[zerolog.ErrorFieldName].(string); ok { + if strings.Count(err, "\n") == 0 { + return nil + } + msg, ok := evt[zerolog.MessageFieldName].(string) + if ok && msg != "" { + evt[zerolog.MessageFieldName] = msg + "\n" + err + } else { + evt[zerolog.MessageFieldName] = err + } + delete(evt, zerolog.ErrorFieldName) + } + return nil + } w.FormatMessage = func(msgI any) string { // pad spaces for each line if msgI == nil { return "" diff --git a/internal/maxmind/README.md b/internal/maxmind/README.md index 2791e799..f98b3369 100644 --- a/internal/maxmind/README.md +++ b/internal/maxmind/README.md @@ -99,7 +99,7 @@ type Location struct { ```go // LoadMaxMindDB loads or downloads the MaxMind database. -func (cfg *MaxMind) LoadMaxMindDB(parent task.Parent) gperr.Error +func (cfg *MaxMind) LoadMaxMindDB(parent task.Parent) error ``` ### Lookup @@ -324,8 +324,8 @@ The maxmind package integrates with: ```go var ( - ErrResponseNotOK = gperr.New("response not OK") - ErrDownloadFailure = gperr.New("download failure") + ErrResponseNotOK = errors.New("response not OK") + ErrDownloadFailure = errors.New("download failure") ) ``` diff --git a/internal/maxmind/instance.go b/internal/maxmind/instance.go index 056417c4..fd575622 100644 --- a/internal/maxmind/instance.go +++ b/internal/maxmind/instance.go @@ -6,7 +6,6 @@ import ( "github.com/rs/zerolog" "github.com/rs/zerolog/log" "github.com/yusing/godoxy/internal/notif" - gperr "github.com/yusing/goutils/errs" "github.com/yusing/goutils/task" ) @@ -24,7 +23,7 @@ func warnNotConfigured() { }) } -func SetInstance(parent task.Parent, cfg *Config) gperr.Error { +func SetInstance(parent task.Parent, cfg *Config) error { newInstance := &MaxMind{Config: cfg} if err := newInstance.LoadMaxMindDB(parent); err != nil { return err diff --git a/internal/maxmind/maxmind.go b/internal/maxmind/maxmind.go index a487cd83..0ab8e77e 100644 --- a/internal/maxmind/maxmind.go +++ b/internal/maxmind/maxmind.go @@ -16,7 +16,6 @@ import ( "github.com/oschwald/maxminddb-golang" "github.com/yusing/godoxy/internal/common" maxmind "github.com/yusing/godoxy/internal/maxmind/types" - gperr "github.com/yusing/goutils/errs" "github.com/yusing/goutils/task" ) @@ -52,8 +51,8 @@ var httpClient = &http.Client{ } var ( - ErrResponseNotOK = gperr.New("response not OK") - ErrDownloadFailure = gperr.New("download failure") + ErrResponseNotOK = errors.New("response not OK") + ErrDownloadFailure = errors.New("download failure") ) func (cfg *MaxMind) dbPath() string { @@ -74,7 +73,7 @@ func (cfg *MaxMind) dbFilename() string { return "GeoIP2-Country.mmdb" } -func (cfg *MaxMind) LoadMaxMindDB(parent task.Parent) gperr.Error { +func (cfg *MaxMind) LoadMaxMindDB(parent task.Parent) error { if cfg.Database == "" { return nil } @@ -92,7 +91,7 @@ func (cfg *MaxMind) LoadMaxMindDB(parent task.Parent) gperr.Error { // ignore invalid error, just download it again var invalidErr maxminddb.InvalidDatabaseError if !errors.As(err, &invalidErr) { - return gperr.Wrap(err) + return err } } valid = false @@ -101,7 +100,7 @@ func (cfg *MaxMind) LoadMaxMindDB(parent task.Parent) gperr.Error { if !valid { cfg.Logger().Info().Msg("MaxMind DB not found/invalid, downloading...") if err = cfg.download(); err != nil { - return ErrDownloadFailure.With(err) + return fmt.Errorf("%w: %w", ErrDownloadFailure, err) } } else { cfg.Logger().Info().Msg("MaxMind DB loaded") @@ -236,7 +235,7 @@ func (cfg *MaxMind) download() error { // extract .tar.gz and to database err = extractFileFromTarGz(databaseGZ, cfg.dbFilename(), tmpDBPath) if err != nil { - return gperr.New("failed to extract database from archive").With(err) + return err } // test if the downloaded database is valid diff --git a/internal/maxmind/types/config.go b/internal/maxmind/types/config.go index 16003029..977d070b 100644 --- a/internal/maxmind/types/config.go +++ b/internal/maxmind/types/config.go @@ -3,7 +3,6 @@ package maxmind import ( "github.com/rs/zerolog" "github.com/rs/zerolog/log" - gperr "github.com/yusing/goutils/errs" strutils "github.com/yusing/goutils/strings" ) @@ -21,7 +20,7 @@ const ( MaxMindGeoIP2 DatabaseType = "geoip2" ) -func (cfg *Config) Validate() gperr.Error { +func (cfg *Config) Validate() error { if cfg.Database == "" { cfg.Database = MaxMindGeoLite } diff --git a/internal/metrics/period/poller.go b/internal/metrics/period/poller.go index 8f2ca4ed..87671497 100644 --- a/internal/metrics/period/poller.go +++ b/internal/metrics/period/poller.go @@ -3,7 +3,6 @@ package period import ( "context" "encoding/json" - "fmt" "net/url" "os" "path/filepath" @@ -152,8 +151,8 @@ func (p *Poller[T, AggregateT]) pollWithTimeout(ctx context.Context) { p.lastResult.Store(data) } -func (p *Poller[T, AggregateT]) Start() { - t := task.RootTask("poller."+p.name, true) +func (p *Poller[T, AggregateT]) Start(parent task.Parent) { + t := parent.Subtask("poller."+p.name, true) l := log.With().Str("name", p.name).Logger() err := p.load() if err != nil { @@ -195,7 +194,7 @@ func (p *Poller[T, AggregateT]) Start() { if tickCount%gatherErrsTicks == 0 { errs, ok := p.gatherErrs() if ok { - gperr.LogError(fmt.Sprintf("poller %s has encountered %d errors in the last %s:", p.name, len(p.errs), gatherErrsInterval), errs) + log.Err(errs).Msgf("poller %s has encountered %d errors in the last %s:", p.name, len(p.errs), gatherErrsInterval) } p.clearErrs() } diff --git a/internal/metrics/uptime/README.md b/internal/metrics/uptime/README.md index 8c748c14..65e7bbd0 100644 --- a/internal/metrics/uptime/README.md +++ b/internal/metrics/uptime/README.md @@ -303,7 +303,7 @@ curl "http://localhost:8080/api/uptime?period=1d&limit=20&offset=0&keyword=docke ```javascript const ws = new WebSocket( - "ws://localhost:8080/api/uptime?period=1m&interval=5s" + "ws://localhost:8080/api/uptime?period=1m&interval=5s", ); ws.onmessage = (event) => { diff --git a/internal/metrics/uptime/uptime.go b/internal/metrics/uptime/uptime.go index 5801ea01..d8380050 100644 --- a/internal/metrics/uptime/uptime.go +++ b/internal/metrics/uptime/uptime.go @@ -3,21 +3,23 @@ package uptime import ( "context" "encoding/json" + "errors" "net/url" "slices" "time" "github.com/lithammer/fuzzysearch/fuzzy" + config "github.com/yusing/godoxy/internal/config/types" + entrypoint "github.com/yusing/godoxy/internal/entrypoint/types" "github.com/yusing/godoxy/internal/metrics/period" metricsutils "github.com/yusing/godoxy/internal/metrics/utils" - "github.com/yusing/godoxy/internal/route/routes" "github.com/yusing/godoxy/internal/types" ) type ( StatusByAlias struct { - Map map[string]routes.HealthInfoWithoutDetail `json:"statuses"` - Timestamp int64 `json:"timestamp"` + Map map[string]types.HealthInfoWithoutDetail `json:"statuses"` + Timestamp int64 `json:"timestamp"` } // @name RouteStatusesByAlias Status struct { Status types.HealthStatus `json:"status" swaggertype:"string" enums:"healthy,unhealthy,unknown,napping,starting"` @@ -40,8 +42,12 @@ type ( var Poller = period.NewPoller("uptime", getStatuses, aggregateStatuses) func getStatuses(ctx context.Context, _ StatusByAlias) (StatusByAlias, error) { + ep := entrypoint.FromCtx(ctx) + if ep == nil { + return StatusByAlias{}, errors.New("entrypoint not found in context") + } return StatusByAlias{ - Map: routes.GetHealthInfoWithoutDetail(), + Map: ep.GetHealthInfoWithoutDetail(), Timestamp: time.Now().Unix(), }, nil } @@ -127,11 +133,14 @@ func (rs RouteStatuses) aggregate(limit int, offset int) Aggregated { up, down, idle, latency := rs.calculateInfo(statuses) status := types.StatusUnknown - r, ok := routes.GetIncludeExcluded(alias) - if ok { - mon := r.HealthMonitor() - if mon != nil { - status = mon.Status() + if state := config.ActiveState.Load(); state != nil { + // FIXME: pass ctx to getRoute + r, ok := entrypoint.FromCtx(state.Context()).GetRoute(alias) + if ok { + mon := r.HealthMonitor() + if mon != nil { + status = mon.Status() + } } } diff --git a/internal/net/gphttp/loadbalancer/README.md b/internal/net/gphttp/loadbalancer/README.md index 97432657..58425276 100644 --- a/internal/net/gphttp/loadbalancer/README.md +++ b/internal/net/gphttp/loadbalancer/README.md @@ -102,7 +102,7 @@ type LoadBalancer struct { func New(cfg *types.LoadBalancerConfig) *LoadBalancer // Start the load balancer as a background task -func (lb *LoadBalancer) Start(parent task.Parent) gperr.Error +func (lb *LoadBalancer) Start(parent task.Parent) error // Update configuration dynamically func (lb *LoadBalancer) UpdateConfigIfNeeded(cfg *types.LoadBalancerConfig) diff --git a/internal/net/gphttp/loadbalancer/ip_hash.go b/internal/net/gphttp/loadbalancer/ip_hash.go index 710757c6..907646ac 100644 --- a/internal/net/gphttp/loadbalancer/ip_hash.go +++ b/internal/net/gphttp/loadbalancer/ip_hash.go @@ -9,7 +9,6 @@ import ( "github.com/bytedance/gopkg/util/xxhash3" "github.com/yusing/godoxy/internal/net/gphttp/middleware" "github.com/yusing/godoxy/internal/types" - gperr "github.com/yusing/goutils/errs" ) type ipHash struct { @@ -28,10 +27,10 @@ func (lb *LoadBalancer) newIPHash() impl { if len(lb.Options) == 0 { return impl } - var err gperr.Error + var err error impl.realIP, err = middleware.RealIP.New(lb.Options) if err != nil { - gperr.LogError("invalid real_ip options, ignoring", err, &impl.l) + impl.l.Err(err).Msg("invalid real_ip options, ignoring") } return impl } diff --git a/internal/net/gphttp/loadbalancer/loadbalancer.go b/internal/net/gphttp/loadbalancer/loadbalancer.go index ac2b14cc..6a8477ec 100644 --- a/internal/net/gphttp/loadbalancer/loadbalancer.go +++ b/internal/net/gphttp/loadbalancer/loadbalancer.go @@ -48,7 +48,7 @@ const maxWeight int = 100 func New(cfg *types.LoadBalancerConfig) *LoadBalancer { lb := &LoadBalancer{ LoadBalancerConfig: cfg, - pool: pool.New[types.LoadBalancerServer]("loadbalancer." + cfg.Link), + pool: pool.New[types.LoadBalancerServer]("loadbalancer."+cfg.Link, "loadbalancers"), l: log.With().Str("name", cfg.Link).Logger(), } lb.UpdateConfigIfNeeded(cfg) @@ -56,7 +56,7 @@ func New(cfg *types.LoadBalancerConfig) *LoadBalancer { } // Start implements task.TaskStarter. -func (lb *LoadBalancer) Start(parent task.Parent) gperr.Error { +func (lb *LoadBalancer) Start(parent task.Parent) error { lb.startTime = time.Now() lb.task = parent.Subtask("loadbalancer."+lb.Link, true) lb.task.OnCancel("cleanup", func() { @@ -234,7 +234,7 @@ func (lb *LoadBalancer) ServeHTTP(rw http.ResponseWriter, r *http.Request) { }) } if err := errs.Wait().Error(); err != nil { - gperr.LogWarn("failed to wake some servers", err, &lb.l) + lb.l.Warn().Err(err).Msg("failed to wake some servers") } } diff --git a/internal/net/gphttp/middleware/bypass_test.go b/internal/net/gphttp/middleware/bypass_test.go index b4a72f45..631c55ff 100644 --- a/internal/net/gphttp/middleware/bypass_test.go +++ b/internal/net/gphttp/middleware/bypass_test.go @@ -15,7 +15,6 @@ import ( "github.com/yusing/godoxy/internal/route" routeTypes "github.com/yusing/godoxy/internal/route/types" "github.com/yusing/goutils/http/reverseproxy" - "github.com/yusing/goutils/task" expect "github.com/yusing/goutils/testing" ) @@ -40,7 +39,7 @@ func TestBypassCIDR(t *testing.T) { } for _, test := range tests { t.Run(test.name, func(t *testing.T) { - req := httptest.NewRequest("GET", "http://example.com", nil) + req := httptest.NewRequest(http.MethodGet, "http://example.com", nil) req.RemoteAddr = test.remoteAddr recorder := httptest.NewRecorder() mr.ModifyRequest(noOpHandler, recorder, req) @@ -76,7 +75,7 @@ func TestBypassPath(t *testing.T) { } for _, test := range tests { t.Run(test.name, func(t *testing.T) { - req := httptest.NewRequest("GET", "http://example.com"+test.path, nil) + req := httptest.NewRequest(http.MethodGet, "http://example.com"+test.path, nil) recorder := httptest.NewRecorder() mr.ModifyRequest(noOpHandler, recorder, req) expect.NoError(t, err) @@ -126,7 +125,7 @@ func TestReverseProxyBypass(t *testing.T) { } for _, test := range tests { t.Run(test.name, func(t *testing.T) { - req := httptest.NewRequest("GET", "http://example.com"+test.path, nil) + req := httptest.NewRequest(http.MethodGet, "http://example.com"+test.path, nil) recorder := httptest.NewRecorder() rp.ServeHTTP(recorder, req) if test.expectBypass { @@ -160,7 +159,7 @@ func TestBypassResponse(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { - req := httptest.NewRequest("GET", "http://example.com"+test.path, nil) + req := httptest.NewRequest(http.MethodGet, "http://example.com"+test.path, nil) resp := &http.Response{ StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader("test")), @@ -201,7 +200,7 @@ func TestBypassResponse(t *testing.T) { StatusCode: test.statusCode, Body: io.NopCloser(strings.NewReader("test")), Header: make(http.Header), - Request: httptest.NewRequest("GET", "http://example.com", nil), + Request: httptest.NewRequest(http.MethodGet, "http://example.com", nil), } mErr := mr.ModifyResponse(resp) expect.NoError(t, mErr) @@ -230,15 +229,17 @@ func TestEntrypointBypassRoute(t *testing.T) { portInt, err := strconv.Atoi(port) expect.NoError(t, err) - expect.NoError(t, err) - entry := entrypoint.NewEntrypoint() - r := &route.Route{ - Alias: "test-route", - Host: host, + entry := entrypoint.NewTestEntrypoint(t, nil) + _, err = route.NewStartedTestRoute(t, &route.Route{ + Alias: "test-route", + Scheme: routeTypes.SchemeHTTP, + Host: host, Port: routeTypes.Port{ - Proxy: portInt, + Listening: 1000, + Proxy: portInt, }, - } + }) + expect.NoError(t, err) err = entry.SetMiddlewares([]map[string]any{ { @@ -254,13 +255,13 @@ func TestEntrypointBypassRoute(t *testing.T) { }) expect.NoError(t, err) - err = r.Validate() - expect.NoError(t, err) - r.Start(task.RootTask("test", false)) - recorder := httptest.NewRecorder() - req := httptest.NewRequest("GET", "http://test-route.example.com", nil) - entry.ServeHTTP(recorder, req) + req := httptest.NewRequest(http.MethodGet, "http://test-route.example.com", nil) + server, ok := entry.GetServer(":1000") + if !ok { + t.Fatal("server not found") + } + server.ServeHTTP(recorder, req) expect.Equal(t, recorder.Code, http.StatusOK, "should bypass http redirect") expect.Equal(t, recorder.Body.String(), "test") expect.Equal(t, recorder.Header().Get("Test-Header"), "test-value") diff --git a/internal/net/gphttp/middleware/captcha/README.md b/internal/net/gphttp/middleware/captcha/README.md index eeb0d57a..7e5d7650 100644 --- a/internal/net/gphttp/middleware/captcha/README.md +++ b/internal/net/gphttp/middleware/captcha/README.md @@ -249,7 +249,7 @@ The package includes an embedded HTML template (`captcha.html`) that renders the ## Error Handling ```go -var ErrCaptchaVerificationFailed = gperr.New("captcha verification failed") +var ErrCaptchaVerificationFailed = errors.New("captcha verification failed") // Verification errors are logged with request details log.Warn().Err(err).Str("url", r.URL.String()).Str("remote_addr", r.RemoteAddr).Msg("failed to verify captcha") diff --git a/internal/net/gphttp/middleware/captcha/provider.go b/internal/net/gphttp/middleware/captcha/provider.go index e8c2d470..560a1960 100644 --- a/internal/net/gphttp/middleware/captcha/provider.go +++ b/internal/net/gphttp/middleware/captcha/provider.go @@ -1,10 +1,9 @@ package captcha import ( + "errors" "net/http" "time" - - gperr "github.com/yusing/goutils/errs" ) type Provider interface { @@ -16,4 +15,4 @@ type Provider interface { FormHTML() string } -var ErrCaptchaVerificationFailed = gperr.New("captcha verification failed") +var ErrCaptchaVerificationFailed = errors.New("captcha verification failed") diff --git a/internal/net/gphttp/middleware/cidr_whitelist.go b/internal/net/gphttp/middleware/cidr_whitelist.go index fff9d611..269f9266 100644 --- a/internal/net/gphttp/middleware/cidr_whitelist.go +++ b/internal/net/gphttp/middleware/cidr_whitelist.go @@ -8,6 +8,7 @@ import ( "github.com/puzpuzpuz/xsync/v4" nettypes "github.com/yusing/godoxy/internal/net/types" "github.com/yusing/godoxy/internal/serialization" + httpevents "github.com/yusing/goutils/events/http" httputils "github.com/yusing/goutils/http" ) @@ -71,6 +72,7 @@ func (wl *cidrWhitelist) checkIP(w http.ResponseWriter, r *http.Request) bool { } } if !allow { + defer httpevents.Blocked(r, "CIDRWhitelist", "IP not allowed") http.Error(w, wl.Message, wl.StatusCode) return false } diff --git a/internal/net/gphttp/middleware/cloudflare_real_ip.go b/internal/net/gphttp/middleware/cloudflare_real_ip.go index 39bacd68..8733dc64 100644 --- a/internal/net/gphttp/middleware/cloudflare_real_ip.go +++ b/internal/net/gphttp/middleware/cloudflare_real_ip.go @@ -105,7 +105,7 @@ func fetchUpdateCFIPRange(endpoint string, cfCIDRs *[]*nettypes.CIDR) error { return err } - resp, err := http.DefaultClient.Do(req) //nolint:gosec + resp, err := http.DefaultClient.Do(req) if err != nil { return err } diff --git a/internal/net/gphttp/middleware/crowdsec.go b/internal/net/gphttp/middleware/crowdsec.go index ec3af22b..9540deb6 100644 --- a/internal/net/gphttp/middleware/crowdsec.go +++ b/internal/net/gphttp/middleware/crowdsec.go @@ -3,6 +3,7 @@ package middleware import ( "bytes" "context" + "errors" "fmt" "io" "net" @@ -11,7 +12,7 @@ import ( "strings" "time" - "github.com/yusing/godoxy/internal/route/routes" + entrypoint "github.com/yusing/godoxy/internal/entrypoint/types" httputils "github.com/yusing/goutils/http" ioutils "github.com/yusing/goutils/io" ) @@ -48,7 +49,7 @@ func (m *crowdsecMiddleware) setup() { func (m *crowdsecMiddleware) finalize() error { if !strings.HasPrefix(m.Endpoint, "/") { - return fmt.Errorf("endpoint must start with /") + return errors.New("endpoint must start with /") } if m.Timeout == 0 { m.Timeout = 5 * time.Second @@ -66,7 +67,7 @@ func (m *crowdsecMiddleware) finalize() error { // before implements RequestModifier. func (m *crowdsecMiddleware) before(w http.ResponseWriter, r *http.Request) (proceed bool) { // Build CrowdSec URL - crowdsecURL, err := m.buildCrowdSecURL() + crowdsecURL, err := m.buildCrowdSecURL(r.Context()) if err != nil { Crowdsec.LogError(r).Err(err).Msg("failed to build CrowdSec URL") w.WriteHeader(http.StatusInternalServerError) @@ -167,10 +168,10 @@ func (m *crowdsecMiddleware) before(w http.ResponseWriter, r *http.Request) (pro } // buildCrowdSecURL constructs the CrowdSec server URL based on route or IP configuration -func (m *crowdsecMiddleware) buildCrowdSecURL() (string, error) { +func (m *crowdsecMiddleware) buildCrowdSecURL(ctx context.Context) (string, error) { // Try to get route first if m.Route != "" { - if route, ok := routes.HTTP.Get(m.Route); ok { + if route, ok := entrypoint.FromCtx(ctx).GetRoute(m.Route); ok { // Using route name targetURL := *route.TargetURL() targetURL.Path = m.Endpoint @@ -179,12 +180,12 @@ func (m *crowdsecMiddleware) buildCrowdSecURL() (string, error) { // If not found in routes, assume it's an IP address if m.Port == 0 { - return "", fmt.Errorf("port must be specified when using IP address") + return "", errors.New("port must be specified when using IP address") } return fmt.Sprintf("http://%s%s", net.JoinHostPort(m.Route, strconv.Itoa(m.Port)), m.Endpoint), nil } - return "", fmt.Errorf("route or IP address must be specified") + return "", errors.New("route or IP address must be specified") } func (m *crowdsecMiddleware) getHTTPVersion(r *http.Request) string { diff --git a/internal/net/gphttp/middleware/errorpage/error_page.go b/internal/net/gphttp/middleware/errorpage/error_page.go index c9590729..dc4f2431 100644 --- a/internal/net/gphttp/middleware/errorpage/error_page.go +++ b/internal/net/gphttp/middleware/errorpage/error_page.go @@ -10,8 +10,7 @@ import ( "github.com/rs/zerolog/log" "github.com/yusing/godoxy/internal/common" "github.com/yusing/godoxy/internal/watcher" - "github.com/yusing/godoxy/internal/watcher/events" - gperr "github.com/yusing/goutils/errs" + watcherEvents "github.com/yusing/godoxy/internal/watcher/events" "github.com/yusing/goutils/fs" "github.com/yusing/goutils/task" ) @@ -81,19 +80,19 @@ func watchDir() { } filename := event.ActorName switch event.Action { - case events.ActionFileWritten: + case watcherEvents.ActionFileWritten: fileContentMap.Delete(filename) loadContent() - case events.ActionFileDeleted: + case watcherEvents.ActionFileDeleted: fileContentMap.Delete(filename) log.Warn().Msgf("error page resource %s deleted", filename) - case events.ActionFileRenamed: + case watcherEvents.ActionFileRenamed: log.Warn().Msgf("error page resource %s deleted", filename) fileContentMap.Delete(filename) loadContent() } case err := <-errCh: - gperr.LogError("error watching error page directory", err) + log.Err(err).Msg("error watching error page directory") } } } diff --git a/internal/net/gphttp/middleware/forwardauth.go b/internal/net/gphttp/middleware/forwardauth.go index 90b16f0f..cd2c3a30 100644 --- a/internal/net/gphttp/middleware/forwardauth.go +++ b/internal/net/gphttp/middleware/forwardauth.go @@ -3,12 +3,14 @@ package middleware import ( "context" "errors" + "fmt" "net" "net/http" "strings" "time" - "github.com/yusing/godoxy/internal/route/routes" + entrypoint "github.com/yusing/godoxy/internal/entrypoint/types" + httpevents "github.com/yusing/goutils/events/http" httputils "github.com/yusing/goutils/http" "github.com/yusing/goutils/http/httpheaders" ) @@ -46,7 +48,7 @@ func (m *forwardAuthMiddleware) setup() { // before implements RequestModifier. func (m *forwardAuthMiddleware) before(w http.ResponseWriter, r *http.Request) (proceed bool) { - route, ok := routes.HTTP.Get(m.Route) + route, ok := entrypoint.FromCtx(r.Context()).HTTPRoutes().Get(m.Route) if !ok { ForwardAuth.LogWarn(r).Str("route", m.Route).Msg("forwardauth route not found") w.WriteHeader(http.StatusInternalServerError) @@ -92,6 +94,8 @@ func (m *forwardAuthMiddleware) before(w http.ResponseWriter, r *http.Request) ( defer resp.Body.Close() if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + defer httpevents.Blocked(r, "ForwardAuth", fmt.Sprintf("HTTP %d", resp.StatusCode)) + body, release, err := httputils.ReadAllBody(resp) defer release(body) @@ -100,10 +104,23 @@ func (m *forwardAuthMiddleware) before(w http.ResponseWriter, r *http.Request) ( w.WriteHeader(http.StatusInternalServerError) return false } - httpheaders.CopyHeader(w.Header(), resp.Header) httpheaders.RemoveHopByHopHeaders(w.Header()) + isGet := r.Method == http.MethodGet + isWS := httpheaders.IsWebsocket(r.Header) + if !isGet || isWS { + reqType := r.Method + if isWS { + reqType = "WebSocket" + } + ForwardAuth.LogWarn(r).Msgf( + "[ForwardAuth] %s request rejected by auth upstream (HTTP %d).\nConsider adding bypass rule for this path if needed", + reqType, + resp.StatusCode, + ) + } + loc, err := resp.Location() if err != nil { if !errors.Is(err, http.ErrNoLocation) { diff --git a/internal/net/gphttp/middleware/middleware.go b/internal/net/gphttp/middleware/middleware.go index ca519070..556e0c32 100644 --- a/internal/net/gphttp/middleware/middleware.go +++ b/internal/net/gphttp/middleware/middleware.go @@ -2,6 +2,7 @@ package middleware import ( "encoding/json" + "fmt" "maps" "net/http" "reflect" @@ -10,15 +11,12 @@ import ( "github.com/rs/zerolog" "github.com/rs/zerolog/log" "github.com/yusing/godoxy/internal/serialization" - gperr "github.com/yusing/goutils/errs" httputils "github.com/yusing/goutils/http" "github.com/yusing/goutils/http/httpheaders" "github.com/yusing/goutils/http/reverseproxy" ) type ( - Error = gperr.Error - ReverseProxy = reverseproxy.ReverseProxy ProxyRequest = reverseproxy.ProxyRequest @@ -87,7 +85,7 @@ func (m *Middleware) setup() { } } -func (m *Middleware) apply(optsRaw OptionsRaw) gperr.Error { +func (m *Middleware) apply(optsRaw OptionsRaw) error { if len(optsRaw) == 0 { return nil } @@ -120,10 +118,10 @@ func (m *Middleware) finalize() error { return nil } -func (m *Middleware) New(optsRaw OptionsRaw) (*Middleware, gperr.Error) { +func (m *Middleware) New(optsRaw OptionsRaw) (*Middleware, error) { if m.construct == nil { // likely a middleware from compose if len(optsRaw) != 0 { - return nil, gperr.New("additional options not allowed for middleware").Subject(m.name) + return nil, fmt.Errorf("additional options not allowed for middleware %s", m.name) } return m, nil } @@ -133,7 +131,7 @@ func (m *Middleware) New(optsRaw OptionsRaw) (*Middleware, gperr.Error) { return nil, err } if err := mid.finalize(); err != nil { - return nil, gperr.Wrap(err) + return nil, err } mid.impl = mid.withCheckBypass() return mid, nil @@ -252,14 +250,13 @@ func (m *Middleware) LogError(req *http.Request) *zerolog.Event { Str("path", req.URL.Path) } -func PatchReverseProxy(rp *ReverseProxy, middlewaresMap map[string]OptionsRaw) (err gperr.Error) { - var middlewares []*Middleware - middlewares, err = compileMiddlewares(middlewaresMap) +func PatchReverseProxy(rp *ReverseProxy, middlewaresMap map[string]OptionsRaw) error { + middlewares, err := compileMiddlewares(middlewaresMap) if err != nil { return err } patchReverseProxy(rp, middlewares) - return err + return nil } func patchReverseProxy(rp *ReverseProxy, middlewares []*Middleware) { diff --git a/internal/net/gphttp/middleware/middleware_builder.go b/internal/net/gphttp/middleware/middleware_builder.go index 738e439c..8ca63472 100644 --- a/internal/net/gphttp/middleware/middleware_builder.go +++ b/internal/net/gphttp/middleware/middleware_builder.go @@ -1,7 +1,9 @@ package middleware import ( + "errors" "fmt" + "maps" "os" "path" "sort" @@ -10,7 +12,7 @@ import ( gperr "github.com/yusing/goutils/errs" ) -var ErrMissingMiddlewareUse = gperr.New("missing middleware 'use' field") +var ErrMissingMiddlewareUse = errors.New("missing middleware 'use' field") func BuildMiddlewaresFromComposeFile(filePath string, eb *gperr.Builder) map[string]*Middleware { fileContent, err := os.ReadFile(filePath) @@ -32,7 +34,7 @@ func BuildMiddlewaresFromYAML(source string, data []byte, eb *gperr.Builder) map for name, defs := range rawMap { chain, err := BuildMiddlewareFromChainRaw(name, defs) if err != nil { - eb.Add(err.Subject(source)) + eb.AddSubject(err, source) } else { middlewares[name+"@file"] = chain } @@ -40,7 +42,7 @@ func BuildMiddlewaresFromYAML(source string, data []byte, eb *gperr.Builder) map return middlewares } -func compileMiddlewares(middlewaresMap map[string]OptionsRaw) ([]*Middleware, gperr.Error) { +func compileMiddlewares(middlewaresMap map[string]OptionsRaw) ([]*Middleware, error) { middlewares := make([]*Middleware, 0, len(middlewaresMap)) var errs gperr.Builder @@ -68,7 +70,7 @@ func compileMiddlewares(middlewaresMap map[string]OptionsRaw) ([]*Middleware, gp return middlewares, errs.Error() } -func BuildMiddlewareFromMap(name string, middlewaresMap map[string]OptionsRaw) (*Middleware, gperr.Error) { +func BuildMiddlewareFromMap(name string, middlewaresMap map[string]OptionsRaw) (*Middleware, error) { compiled, err := compileMiddlewares(middlewaresMap) if err != nil { return nil, err @@ -77,7 +79,7 @@ func BuildMiddlewareFromMap(name string, middlewaresMap map[string]OptionsRaw) ( } // TODO: check conflict or duplicates. -func BuildMiddlewareFromChainRaw(name string, defs []map[string]any) (*Middleware, gperr.Error) { +func BuildMiddlewareFromChainRaw(name string, defs []map[string]any) (*Middleware, error) { var chainErr gperr.Builder chain := make([]*Middleware, 0, len(defs)) for i, def := range defs { @@ -91,6 +93,7 @@ func BuildMiddlewareFromChainRaw(name string, defs []map[string]any) (*Middlewar chainErr.AddSubjectf(err, "%s[%d]", name, i) continue } + def = maps.Clone(def) delete(def, "use") m, err := base.New(def) if err != nil { diff --git a/internal/net/gphttp/middleware/middleware_chain.go b/internal/net/gphttp/middleware/middleware_chain.go index ad589a17..d9e2be97 100644 --- a/internal/net/gphttp/middleware/middleware_chain.go +++ b/internal/net/gphttp/middleware/middleware_chain.go @@ -2,6 +2,7 @@ package middleware import ( "net/http" + "strconv" gperr "github.com/yusing/goutils/errs" ) @@ -47,7 +48,7 @@ func (m *middlewareChain) modifyResponse(resp *http.Response) error { } for i, mr := range m.modResps { if err := mr.modifyResponse(resp); err != nil { - return gperr.Wrap(err).Subjectf("%d", i) + return gperr.PrependSubject(err, strconv.Itoa(i)) } } return nil diff --git a/internal/net/gphttp/middleware/middlewares.go b/internal/net/gphttp/middleware/middlewares.go index 76b28682..d4411521 100644 --- a/internal/net/gphttp/middleware/middlewares.go +++ b/internal/net/gphttp/middleware/middlewares.go @@ -44,15 +44,14 @@ var allMiddlewares = map[string]*Middleware{ } var ( - ErrUnknownMiddleware = gperr.New("unknown middleware") - ErrMiddlewareAlreadyExists = gperr.New("middleware with the same name already exists") + ErrUnknownMiddleware = errors.New("unknown middleware") + ErrMiddlewareAlreadyExists = errors.New("middleware with the same name already exists") ) -func Get(name string) (*Middleware, Error) { +func Get(name string) (*Middleware, error) { middleware, ok := allMiddlewares[strutils.ToLowerNoSnake(name)] if !ok { - return nil, ErrUnknownMiddleware. - Subject(name). + return nil, gperr.PrependSubject(ErrUnknownMiddleware, name). With(gperr.DoYouMeanField(name, allMiddlewares)) } return middleware, nil @@ -63,7 +62,7 @@ func All() map[string]*Middleware { } func LoadComposeFiles() { - errs := gperr.NewBuilder("middleware compile errors") + var errs gperr.Builder middlewareDefs, err := fsutils.ListFiles(common.MiddlewareComposeBasePath, 0) if err != nil { if errors.Is(err, fs.ErrNotExist) { @@ -81,7 +80,7 @@ func LoadComposeFiles() { for name, m := range mws { name = strutils.ToLowerNoSnake(name) if _, ok := allMiddlewares[name]; ok { - errs.Add(ErrMiddlewareAlreadyExists.Subject(name)) + errs.AddSubject(ErrMiddlewareAlreadyExists, name) continue } allMiddlewares[name] = m @@ -111,6 +110,6 @@ func LoadComposeFiles() { } } if errs.HasError() { - gperr.LogError(errs.About(), errs.Error()) + log.Err(errs.Error()).Msg("middleware compile errors") } } diff --git a/internal/net/gphttp/middleware/oidc.go b/internal/net/gphttp/middleware/oidc.go index 6406de4a..bd8c74e9 100644 --- a/internal/net/gphttp/middleware/oidc.go +++ b/internal/net/gphttp/middleware/oidc.go @@ -7,8 +7,10 @@ import ( "sync" "sync/atomic" + "github.com/rs/zerolog/log" "github.com/yusing/godoxy/internal/auth" - gperr "github.com/yusing/goutils/errs" + httpevents "github.com/yusing/goutils/events/http" + "github.com/yusing/goutils/http/httpheaders" ) type oidcMiddleware struct { @@ -28,7 +30,7 @@ var OIDC = NewMiddleware[oidcMiddleware]() func (amw *oidcMiddleware) finalize() error { if !auth.IsOIDCEnabled() { - return gperr.New("OIDC not enabled but OIDC middleware is used") + log.Error().Msg("OIDC not enabled but OIDC middleware is used") } return nil } @@ -97,6 +99,10 @@ func (amw *oidcMiddleware) initSlow() error { } func (amw *oidcMiddleware) before(w http.ResponseWriter, r *http.Request) (proceed bool) { + if !auth.IsOIDCEnabled() { + return true + } + if err := amw.init(); err != nil { // no need to log here, main OIDC should've already failed and logged http.Error(w, err.Error(), http.StatusInternalServerError) @@ -105,7 +111,7 @@ func (amw *oidcMiddleware) before(w http.ResponseWriter, r *http.Request) (proce if r.URL.Path == auth.OIDCLogoutPath { amw.auth.LogoutHandler(w, r) - return true + return false } err := amw.auth.CheckToken(r) @@ -113,11 +119,31 @@ func (amw *oidcMiddleware) before(w http.ResponseWriter, r *http.Request) (proce return true } + emitBlockedEvent := func() { + if r.Method != http.MethodHead { + httpevents.Blocked(r, "OIDC", err.Error()) + } + } + + isGet := r.Method == http.MethodGet + isWS := httpheaders.IsWebsocket(r.Header) switch { + case r.Method == http.MethodHead: + w.WriteHeader(http.StatusOK) + case !isGet, isWS: + http.Error(w, err.Error(), http.StatusForbidden) + reqType := r.Method + if isWS { + reqType = "WebSocket" + } + OIDC.LogWarn(r).Msgf("[OIDC] %s request blocked.\nConsider adding bypass rule for this path if needed", reqType) + emitBlockedEvent() + return false case errors.Is(err, auth.ErrMissingOAuthToken): amw.auth.HandleAuth(w, r) default: auth.WriteBlockPage(w, http.StatusForbidden, err.Error(), "Logout", auth.OIDCLogoutPath) + emitBlockedEvent() } return false } diff --git a/internal/net/gphttp/middleware/test_utils.go b/internal/net/gphttp/middleware/test_utils.go index e3226c6a..ad56d6bc 100644 --- a/internal/net/gphttp/middleware/test_utils.go +++ b/internal/net/gphttp/middleware/test_utils.go @@ -11,7 +11,6 @@ import ( "github.com/yusing/godoxy/internal/common" nettypes "github.com/yusing/godoxy/internal/net/types" - gperr "github.com/yusing/goutils/errs" "github.com/yusing/goutils/http/reverseproxy" ) @@ -121,7 +120,7 @@ func (args *testArgs) bodyReader() io.Reader { return nil } -func newMiddlewareTest(middleware *Middleware, args *testArgs) (*TestResult, gperr.Error) { +func newMiddlewareTest(middleware *Middleware, args *testArgs) (*TestResult, error) { if args == nil { args = new(testArgs) } @@ -135,7 +134,7 @@ func newMiddlewareTest(middleware *Middleware, args *testArgs) (*TestResult, gpe return newMiddlewaresTest([]*Middleware{mid}, args) } -func newMiddlewaresTest(middlewares []*Middleware, args *testArgs) (*TestResult, gperr.Error) { +func newMiddlewaresTest(middlewares []*Middleware, args *testArgs) (*TestResult, error) { if args == nil { args = new(testArgs) } @@ -160,7 +159,7 @@ func newMiddlewaresTest(middlewares []*Middleware, args *testArgs) (*TestResult, data, err := io.ReadAll(resp.Body) if err != nil { - return nil, gperr.Wrap(err) + return nil, err } return &TestResult{ diff --git a/internal/net/gphttp/middleware/themed.go b/internal/net/gphttp/middleware/themed.go index 67dbd13f..8e0ca4bf 100644 --- a/internal/net/gphttp/middleware/themed.go +++ b/internal/net/gphttp/middleware/themed.go @@ -2,6 +2,7 @@ package middleware import ( "bytes" + "errors" "fmt" "net/http" "os" @@ -66,7 +67,7 @@ func (m *themed) finalize() error { m.m.HTML += buf.String() } if m.CSS != "" && m.Theme != "" { - return gperr.New("css and theme are mutually exclusive") + return errors.New("css and theme are mutually exclusive") } // credit: https://hackcss.egoist.dev if m.Theme != "" { @@ -78,7 +79,7 @@ func (m *themed) finalize() error { case SolarizedDarkTheme: m.m.HTML += wrapStyleTag(solarizedDarkModeCSS) default: - return gperr.New("invalid theme").Subject(string(m.Theme)) + return gperr.PrependSubject(errors.New("invalid theme"), m.Theme) } } if m.CSS != "" { diff --git a/internal/net/types/stream.go b/internal/net/types/stream.go index 25ba81ef..f555b0b5 100644 --- a/internal/net/types/stream.go +++ b/internal/net/types/stream.go @@ -6,7 +6,7 @@ import ( ) type Stream interface { - ListenAndServe(ctx context.Context, preDial, onRead HookFunc) + ListenAndServe(ctx context.Context, preDial, onRead HookFunc) error LocalAddr() net.Addr Close() error } diff --git a/internal/notif/README.md b/internal/notif/README.md index 3571f520..c10bf03c 100644 --- a/internal/notif/README.md +++ b/internal/notif/README.md @@ -304,9 +304,9 @@ The notif package integrates with: ```go var ( - ErrMissingNotifProvider = gperr.New("missing notification provider") - ErrInvalidNotifProviderType = gperr.New("invalid notification provider type") - ErrUnknownNotifProvider = gperr.New("unknown notification provider") + ErrMissingNotifProvider = errors.New("missing notification provider") + ErrInvalidNotifProviderType = errors.New("invalid notification provider type") + ErrUnknownNotifProvider = errors.New("unknown notification provider") ) ``` diff --git a/internal/notif/base.go b/internal/notif/base.go index 87a55366..0fe9d8c0 100644 --- a/internal/notif/base.go +++ b/internal/notif/base.go @@ -1,6 +1,8 @@ package notif import ( + "errors" + "fmt" "io" "net/http" "net/url" @@ -23,13 +25,13 @@ func (e rawError) Error() string { } var ( - ErrMissingToken = gperr.New("token is required") - ErrURLMissingScheme = gperr.New("url missing scheme, expect 'http://' or 'https://'") - ErrUnknownError = gperr.New("unknown error") + ErrMissingToken = errors.New("token is required") + ErrURLMissingScheme = errors.New("url missing scheme, expect 'http://' or 'https://'") + ErrUnknownError = errors.New("unknown error") ) // Validate implements the utils.CustomValidator interface. -func (base *ProviderBase) Validate() gperr.Error { +func (base *ProviderBase) Validate() error { switch base.Format { case "": base.Format = LogFormatMarkdown @@ -48,7 +50,7 @@ func (base *ProviderBase) Validate() gperr.Error { } u, err := url.Parse(base.URL) if err != nil { - return gperr.Wrap(err) + return fmt.Errorf("invalid url: %w", err) } base.URL = u.String() return nil diff --git a/internal/notif/config.go b/internal/notif/config.go index c4838067..eb7a6843 100644 --- a/internal/notif/config.go +++ b/internal/notif/config.go @@ -1,6 +1,9 @@ package notif import ( + "errors" + "strings" + "github.com/yusing/godoxy/internal/serialization" gperr "github.com/yusing/goutils/errs" ) @@ -11,13 +14,13 @@ type NotificationConfig struct { } var ( - ErrMissingNotifProvider = gperr.New("missing notification provider") - ErrInvalidNotifProviderType = gperr.New("invalid notification provider type") - ErrUnknownNotifProvider = gperr.New("unknown notification provider") + ErrMissingNotifProvider = errors.New("missing notification provider") + ErrInvalidNotifProviderType = errors.New("invalid notification provider type") + ErrUnknownNotifProvider = errors.New("unknown notification provider") ) // UnmarshalMap implements MapUnmarshaler. -func (cfg *NotificationConfig) UnmarshalMap(m map[string]any) (err gperr.Error) { +func (cfg *NotificationConfig) UnmarshalMap(m map[string]any) (err error) { // extract provider name providerName := m["provider"] switch providerName := providerName.(type) { @@ -41,9 +44,8 @@ func (cfg *NotificationConfig) UnmarshalMap(m map[string]any) (err gperr.Error) case ProviderNtfy: cfg.Provider = &Ntfy{} default: - return ErrUnknownNotifProvider. - Subject(cfg.ProviderName). - Withf("expect %s or %s", ProviderWebhook, ProviderGotify) + return gperr.PrependSubject(ErrUnknownNotifProvider, cfg.ProviderName). + Withf("expect %s", strings.Join(AvailableProviders, ", ")) } return serialization.MapUnmarshalValidate(m, cfg.Provider) diff --git a/internal/notif/gotify.go b/internal/notif/gotify.go index 1a70b6f7..7dcaa554 100644 --- a/internal/notif/gotify.go +++ b/internal/notif/gotify.go @@ -19,14 +19,15 @@ type ( const gotifyMsgEndpoint = "/message" -func (client *GotifyClient) Validate() gperr.Error { +func (client *GotifyClient) Validate() error { + var errs gperr.Builder if err := client.ProviderBase.Validate(); err != nil { - return err + errs.Add(err) } if client.Token == "" { - return gperr.New("token is required") + errs.Adds("token is required") } - return nil + return errs.Error() } func (client *GotifyClient) GetURL() string { @@ -58,7 +59,7 @@ func (client *GotifyClient) MarshalMessage(logMsg *LogMessage) ([]byte, error) { } if client.Format == LogFormatMarkdown { - msg.Extras = map[string]interface{}{ + msg.Extras = map[string]any{ "client::display": map[string]string{ "contentType": "text/markdown", }, diff --git a/internal/notif/ntfy.go b/internal/notif/ntfy.go index 5f2c3b7e..e45a51f4 100644 --- a/internal/notif/ntfy.go +++ b/internal/notif/ntfy.go @@ -14,20 +14,21 @@ type Ntfy struct { } // Validate implements the utils.CustomValidator interface. -func (n *Ntfy) Validate() gperr.Error { +func (n *Ntfy) Validate() error { + var errs gperr.Builder if err := n.ProviderBase.Validate(); err != nil { - return err + errs.Add(err) } if n.URL == "" { - return gperr.New("url is required") + errs.Adds("url is required") } if n.Topic == "" { - return gperr.New("topic is required") + errs.Adds("topic is required") } - if n.Topic[0] == '/' { - return gperr.New("topic should not start with a slash") + if n.Topic != "" && n.Topic[0] == '/' { + errs.Adds("topic should not start with a slash") } - return nil + return errs.Error() } // GetURL implements Provider. diff --git a/internal/notif/providers.go b/internal/notif/providers.go index ace7235f..68648742 100644 --- a/internal/notif/providers.go +++ b/internal/notif/providers.go @@ -9,7 +9,6 @@ import ( "time" "github.com/yusing/godoxy/internal/serialization" - gperr "github.com/yusing/goutils/errs" ) type ( @@ -27,7 +26,7 @@ type ( fmtError(respBody io.Reader) error } - ProviderCreateFunc func(map[string]any) (Provider, gperr.Error) + ProviderCreateFunc func(map[string]any) (Provider, error) ProviderConfig map[string]any ) @@ -37,6 +36,8 @@ const ( ProviderWebhook = "webhook" ) +var AvailableProviders = []string{ProviderGotify, ProviderNtfy, ProviderWebhook} + func (msg *LogMessage) notify(ctx context.Context, provider Provider) error { body, err := provider.MarshalMessage(msg) if err != nil { diff --git a/internal/notif/webhook.go b/internal/notif/webhook.go index 4f073d99..9f6ee1e1 100644 --- a/internal/notif/webhook.go +++ b/internal/notif/webhook.go @@ -3,6 +3,7 @@ package notif import ( _ "embed" "encoding/json" + "errors" "io" "net/http" "strings" @@ -26,9 +27,11 @@ var webhookTemplates = map[string]string{ "discord": discordPayload, } -func (webhook *Webhook) Validate() gperr.Error { - if err := webhook.ProviderBase.Validate(); err != nil && !err.Is(ErrMissingToken) { - return err +func (webhook *Webhook) Validate() error { + var errs gperr.Builder + + if err := webhook.ProviderBase.Validate(); err != nil && !errors.Is(err, ErrMissingToken) { + errs.Add(err) } switch webhook.MIMEType { @@ -36,18 +39,17 @@ func (webhook *Webhook) Validate() gperr.Error { webhook.MIMEType = MimeTypeJSON case MimeTypeJSON, MimeTypeForm, MimeTypeText: default: - return gperr.Errorf("invalid mime_type, expect %s", strings.Join([]string{"empty", MimeTypeJSON, MimeTypeForm, MimeTypeText}, ", ")) + errs.Addf("invalid mime_type, expect %s", strings.Join([]string{"empty", MimeTypeJSON, MimeTypeForm, MimeTypeText}, ", ")) } switch webhook.Template { case "": - if webhook.MIMEType == MimeTypeJSON { - if !validateJSONPayload(webhook.Payload) { - return gperr.New("invalid payload, expect valid JSON") - } - } if webhook.Payload == "" { - return gperr.New("invalid payload, expect non-empty") + errs.Adds("invalid payload, expect non-empty") + } else if webhook.MIMEType == MimeTypeJSON { + if !validateJSONPayload(webhook.Payload) { + errs.Adds("invalid payload, expect valid JSON") + } } case "discord": webhook.ColorMode = "dec" @@ -57,7 +59,7 @@ func (webhook *Webhook) Validate() gperr.Error { webhook.Payload = discordPayload } default: - return gperr.New("invalid template, expect empty or 'discord'") + errs.Adds("invalid template, expect empty or 'discord'") } switch webhook.Method { @@ -65,7 +67,7 @@ func (webhook *Webhook) Validate() gperr.Error { webhook.Method = http.MethodPost case http.MethodGet, http.MethodPost, http.MethodPut: default: - return gperr.New("invalid method, expect empty, 'GET', 'POST' or 'PUT'") + errs.Adds("invalid method, expect empty, 'GET', 'POST' or 'PUT'") } switch webhook.ColorMode { @@ -73,10 +75,10 @@ func (webhook *Webhook) Validate() gperr.Error { webhook.ColorMode = "hex" case "hex", "dec": default: - return gperr.New("invalid color_mode, expect empty, 'hex' or 'dec'") + errs.Adds("invalid color_mode, expect empty, 'hex' or 'dec'") } - return nil + return errs.Error() } // GetMethod implements Provider. diff --git a/internal/proxmox/README.md b/internal/proxmox/README.md index 0373ff4d..58fb648e 100644 --- a/internal/proxmox/README.md +++ b/internal/proxmox/README.md @@ -115,7 +115,7 @@ type NodeConfig struct { ```go // Init initializes the Proxmox client. -func (c *Config) Init(ctx context.Context) gperr.Error +func (c *Config) Init(ctx context.Context) error // Client returns the Proxmox client. func (c *Config) Client() *Client @@ -460,7 +460,7 @@ if r.Idlewatcher != nil && r.Idlewatcher.Proxmox != nil { node, ok := proxmox.Nodes.Get(node) if !ok { - return gperr.Errorf("proxmox node %s not found", node) + return fmt.Errorf("proxmox node %s not found", node) } // Get container IPs diff --git a/internal/proxmox/client.go b/internal/proxmox/client.go index ac07501a..84789863 100644 --- a/internal/proxmox/client.go +++ b/internal/proxmox/client.go @@ -20,6 +20,7 @@ import ( type Client struct { *proxmox.Client *proxmox.Cluster + Version *proxmox.Version BaseURL *url.URL // id -> resource; id: lxc/ or qemu/ @@ -29,6 +30,7 @@ type Client struct { type VMResource struct { *proxmox.ClusterResource + IPs []net.IP } @@ -37,9 +39,9 @@ var ( ErrNoResources = errors.New("no resources") ) -func NewClient(baseUrl string, opts ...proxmox.Option) *Client { +func NewClient(baseURL string, opts ...proxmox.Option) *Client { return &Client{ - Client: proxmox.NewClient(baseUrl, opts...), + Client: proxmox.NewClient(baseURL, opts...), resources: make(map[string]*VMResource), } } @@ -125,10 +127,10 @@ func (c *Client) UpdateResources(ctx context.Context) error { // GetResource gets a resource by kind and id. // kind: lxc or qemu // id: -func (c *Client) GetResource(kind string, id int) (*VMResource, error) { +func (c *Client) GetResource(kind string, id uint64) (*VMResource, error) { c.resourcesMu.RLock() defer c.resourcesMu.RUnlock() - resource, ok := c.resources[kind+"/"+strconv.Itoa(id)] + resource, ok := c.resources[kind+"/"+strconv.FormatUint(id, 10)] if !ok { return nil, ErrResourceNotFound } diff --git a/internal/proxmox/config.go b/internal/proxmox/config.go index 54269adf..24203319 100644 --- a/internal/proxmox/config.go +++ b/internal/proxmox/config.go @@ -4,6 +4,7 @@ import ( "context" "crypto/tls" "errors" + "fmt" "math" "net/http" "strings" @@ -12,7 +13,6 @@ import ( "github.com/luthermonson/go-proxmox" "github.com/rs/zerolog/log" "github.com/yusing/godoxy/internal/net/gphttp" - gperr "github.com/yusing/goutils/errs" strutils "github.com/yusing/goutils/strings" ) @@ -31,8 +31,10 @@ type Config struct { client *Client } -const ResourcePollInterval = 3 * time.Second -const SessionRefreshInterval = 1 * time.Minute +const ( + ResourcePollInterval = 3 * time.Second + SessionRefreshInterval = 1 * time.Minute +) // NodeStatsPollInterval controls how often node stats are streamed when streaming is enabled. const NodeStatsPollInterval = time.Second @@ -44,7 +46,7 @@ func (c *Config) Client() *Client { return c.client } -func (c *Config) Init(ctx context.Context) gperr.Error { +func (c *Config) Init(ctx context.Context) error { var tr *http.Transport if c.NoTLSVerify { // user specified @@ -87,15 +89,15 @@ func (c *Config) Init(ctx context.Context) gperr.Error { if useCredentials { err := c.client.CreateSession(initCtx) if err != nil { - return gperr.New("failed to create session").With(err) + return fmt.Errorf("failed to create session: %w", err) } } if err := c.client.UpdateClusterInfo(initCtx); err != nil { if errors.Is(err, context.DeadlineExceeded) { - return gperr.New("timeout fetching proxmox cluster info") + return fmt.Errorf("timeout fetching proxmox cluster info: %w", err) } - return gperr.New("failed to fetch proxmox cluster info").With(err) + return fmt.Errorf("failed to fetch proxmox cluster info: %w", err) } { @@ -158,6 +160,7 @@ func (c *Config) refreshSessionLoop(ctx context.Context) { backoff := time.Duration(min(math.Pow(2, float64(numRetries)), 10)) * time.Second ticker.Reset(backoff) } else { + numRetries = 0 ticker.Reset(SessionRefreshInterval) } } diff --git a/internal/proxmox/lxc.go b/internal/proxmox/lxc.go index 2670963d..66a0c4ee 100644 --- a/internal/proxmox/lxc.go +++ b/internal/proxmox/lxc.go @@ -41,7 +41,7 @@ const ( proxmoxTaskCheckInterval = 300 * time.Millisecond ) -func (n *Node) LXCAction(ctx context.Context, vmid int, action LXCAction) error { +func (n *Node) LXCAction(ctx context.Context, vmid uint64, action LXCAction) error { var upid proxmox.UPID if err := n.client.Post(ctx, fmt.Sprintf("/nodes/%s/lxc/%d/status/%s", n.name, vmid, action), nil, &upid); err != nil { return err @@ -82,7 +82,7 @@ func (n *Node) LXCAction(ctx context.Context, vmid int, action LXCAction) error } } -func (n *Node) LXCName(ctx context.Context, vmid int) (string, error) { +func (n *Node) LXCName(ctx context.Context, vmid uint64) (string, error) { var name nameOnly if err := n.client.Get(ctx, fmt.Sprintf("/nodes/%s/lxc/%d/status/current", n.name, vmid), &name); err != nil { return "", err @@ -90,7 +90,7 @@ func (n *Node) LXCName(ctx context.Context, vmid int) (string, error) { return name.Name, nil } -func (n *Node) LXCStatus(ctx context.Context, vmid int) (LXCStatus, error) { +func (n *Node) LXCStatus(ctx context.Context, vmid uint64) (LXCStatus, error) { var status statusOnly if err := n.client.Get(ctx, fmt.Sprintf("/nodes/%s/lxc/%d/status/current", n.name, vmid), &status); err != nil { return "", err @@ -98,18 +98,18 @@ func (n *Node) LXCStatus(ctx context.Context, vmid int) (LXCStatus, error) { return status.Status, nil } -func (n *Node) LXCIsRunning(ctx context.Context, vmid int) (bool, error) { +func (n *Node) LXCIsRunning(ctx context.Context, vmid uint64) (bool, error) { status, err := n.LXCStatus(ctx, vmid) return status == LXCStatusRunning, err } -func (n *Node) LXCIsStopped(ctx context.Context, vmid int) (bool, error) { +func (n *Node) LXCIsStopped(ctx context.Context, vmid uint64) (bool, error) { status, err := n.LXCStatus(ctx, vmid) return status == LXCStatusStopped, err } -func (n *Node) LXCSetShutdownTimeout(ctx context.Context, vmid int, timeout time.Duration) error { - return n.client.Put(ctx, fmt.Sprintf("/nodes/%s/lxc/%d/config", n.name, vmid), map[string]interface{}{ +func (n *Node) LXCSetShutdownTimeout(ctx context.Context, vmid uint64, timeout time.Duration) error { + return n.client.Put(ctx, fmt.Sprintf("/nodes/%s/lxc/%d/config", n.name, vmid), map[string]any{ "startup": fmt.Sprintf("down=%.0f", timeout.Seconds()), }, nil) } diff --git a/internal/proxmox/lxc_stats.go b/internal/proxmox/lxc_stats.go index 541e57ab..0e579fef 100644 --- a/internal/proxmox/lxc_stats.go +++ b/internal/proxmox/lxc_stats.go @@ -42,7 +42,7 @@ import ( // // - format: "STATUS|CPU%%|MEM USAGE/LIMIT|MEM%%|NET I/O|BLOCK I/O" // - example: running|31.1%|9.6GiB/20GiB|48.87%|4.7GiB/3.3GiB|25GiB/36GiB -func (n *Node) LXCStats(ctx context.Context, vmid int, stream bool) (io.ReadCloser, error) { +func (n *Node) LXCStats(ctx context.Context, vmid uint64, stream bool) (io.ReadCloser, error) { if !stream { resource, err := n.client.GetResource("lxc", vmid) if err != nil { @@ -140,9 +140,6 @@ func formatIECBytes(b uint64) string { // One decimal, trimming trailing ".0" to keep output compact (e.g. "10GiB"). s := fmt.Sprintf("%.1f", val) s = strings.TrimSuffix(s, ".0") - if exp == 0 { - return s + "B" - } return s + prefixes[exp] + "B" } diff --git a/internal/proxmox/node.go b/internal/proxmox/node.go index 11cacc75..0565ebc9 100644 --- a/internal/proxmox/node.go +++ b/internal/proxmox/node.go @@ -12,7 +12,7 @@ import ( type NodeConfig struct { Node string `json:"node"` - VMID *int `json:"vmid"` // unset: auto discover; explicit 0: node-level route; >0: lxc/qemu resource route + VMID *uint64 `json:"vmid"` // unset: auto discover; explicit 0: node-level route; >0: lxc/qemu resource route VMName string `json:"vmname,omitempty"` Services []string `json:"services,omitempty" aliases:"service"` Files []string `json:"files,omitempty" aliases:"file"` @@ -27,7 +27,7 @@ type Node struct { } // Validate implements the serialization.CustomValidator interface. -func (n *NodeConfig) Validate() gperr.Error { +func (n *NodeConfig) Validate() error { var errs gperr.Builder for i, service := range n.Services { if err := checkValidInput(service); err != nil { @@ -42,7 +42,7 @@ func (n *NodeConfig) Validate() gperr.Error { return errs.Error() } -var Nodes = pool.New[*Node]("proxmox_nodes") +var Nodes = pool.New[*Node]("proxmox_nodes", "proxmox_node") func NewNode(client *Client, name, id string) *Node { return &Node{ diff --git a/internal/route/README.md b/internal/route/README.md index acf7b83a..b1c5c68a 100644 --- a/internal/route/README.md +++ b/internal/route/README.md @@ -30,9 +30,11 @@ Internal package with stable core types. Route configuration schema is versioned type Route struct { Alias string // Unique route identifier Scheme Scheme // http, https, h2c, tcp, udp, fileserver - Host string // Virtual host + Host string // Target host Port Port // Listen and target ports + Bind string // Bind address for listening (IP address, optional) + // File serving Root string // Document root SPA bool // Single-page app mode @@ -91,8 +93,8 @@ const ( ```go // Validation and lifecycle -func (r *Route) Validate() gperr.Error -func (r *Route) Start(parent task.Parent) gperr.Error +func (r *Route) Validate() error +func (r *Route) Start(parent task.Parent) error func (r *Route) Finish(reason any) func (r *Route) Started() <-chan struct{} @@ -117,8 +119,8 @@ func (r *Route) UseHealthCheck() bool ```mermaid classDiagram class Route { - +Validate() gperr.Error - +Start(parent) gperr.Error + +Validate() error + +Start(parent) error +Finish(reason) +Started() <-chan struct#123;#125; } @@ -196,6 +198,7 @@ type Route struct { Alias string `json:"alias"` Scheme Scheme `json:"scheme"` Host string `json:"host,omitempty"` + Bind string `json:"bind,omitempty"` // Listen bind address Port Port `json:"port"` Root string `json:"root,omitempty"` SPA bool `json:"spa,omitempty"` @@ -218,23 +221,26 @@ labels: routes: myapp: scheme: http - root: /var/www/myapp - spa: true + host: myapp.local + bind: 192.168.1.100 # Optional: bind to specific address + port: + proxy: 80 + target: 3000 ``` ## Dependency and Integration Map -| Dependency | Purpose | -| -------------------------------- | -------------------------------- | -| `internal/route/routes` | Route registry and lookup | -| `internal/route/rules` | Request/response rule processing | -| `internal/route/stream` | TCP/UDP stream proxying | -| `internal/route/provider` | Route discovery and loading | -| `internal/health/monitor` | Health checking | -| `internal/idlewatcher` | Idle container management | -| `internal/logging/accesslog` | Request logging | -| `internal/homepage` | Dashboard integration | -| `github.com/yusing/goutils/errs` | Error handling | +| Dependency | Purpose | +| ---------------------------------- | --------------------------------- | +| `internal/route/routes/context.go` | Route context helpers (only file) | +| `internal/route/rules` | Request/response rule processing | +| `internal/route/stream` | TCP/UDP stream proxying | +| `internal/route/provider` | Route discovery and loading | +| `internal/health/monitor` | Health checking | +| `internal/idlewatcher` | Idle container management | +| `internal/logging/accesslog` | Request logging | +| `internal/homepage` | Dashboard integration | +| `github.com/yusing/goutils/errs` | Error handling | ## Observability @@ -305,6 +311,18 @@ route := &route.Route{ } ``` +### Route with Custom Bind Address + +```go +route := &route.Route{ + Alias: "myapp", + Scheme: route.SchemeHTTP, + Host: "myapp.local", + Bind: "192.168.1.100", // Bind to specific interface + Port: route.Port{Listening: 8443, Proxy: 80}, +} +``` + ### File Server Route ```go diff --git a/internal/route/common.go b/internal/route/common.go index a1f1a785..4c6a4376 100644 --- a/internal/route/common.go +++ b/internal/route/common.go @@ -1,27 +1,36 @@ package route import ( - "github.com/yusing/godoxy/internal/route/routes" + "context" + "fmt" + + entrypoint "github.com/yusing/godoxy/internal/entrypoint/types" "github.com/yusing/godoxy/internal/types" - gperr "github.com/yusing/goutils/errs" ) -func checkExists(r types.Route) gperr.Error { +// checkExists checks if the route already exists in the entrypoint. +// +// Context must be passed from the parent task that carries the entrypoint value. +func checkExists(ctx context.Context, r types.Route) error { if r.UseLoadBalance() { // skip checking for load balanced routes return nil } + ep := entrypoint.FromCtx(ctx) + if ep == nil { + return fmt.Errorf("entrypoint not found in context") + } var ( existing types.Route ok bool ) switch r := r.(type) { case types.HTTPRoute: - existing, ok = routes.HTTP.Get(r.Key()) + existing, ok = entrypoint.FromCtx(ctx).HTTPRoutes().Get(r.Key()) case types.StreamRoute: - existing, ok = routes.Stream.Get(r.Key()) + existing, ok = entrypoint.FromCtx(ctx).StreamRoutes().Get(r.Key()) } if ok { - return gperr.Errorf("route already exists: from provider %s and %s", existing.ProviderName(), r.ProviderName()) + return fmt.Errorf("route already exists: from provider %s and %s", existing.ProviderName(), r.ProviderName()) } return nil } diff --git a/internal/route/fileserver.go b/internal/route/fileserver.go index fbdfcc8f..8567e43c 100644 --- a/internal/route/fileserver.go +++ b/internal/route/fileserver.go @@ -1,17 +1,18 @@ package route import ( + "errors" "net/http" "os" "path" "path/filepath" - config "github.com/yusing/godoxy/internal/config/types" + "github.com/rs/zerolog/log" + entrypoint "github.com/yusing/godoxy/internal/entrypoint/types" "github.com/yusing/godoxy/internal/health/monitor" "github.com/yusing/godoxy/internal/logging/accesslog" gphttp "github.com/yusing/godoxy/internal/net/gphttp" "github.com/yusing/godoxy/internal/net/gphttp/middleware" - "github.com/yusing/godoxy/internal/route/routes" "github.com/yusing/godoxy/internal/types" gperr "github.com/yusing/goutils/errs" "github.com/yusing/goutils/task" @@ -50,12 +51,12 @@ func handler(root string, spa bool, index string) http.Handler { }) } -func NewFileServer(base *Route) (*FileServer, gperr.Error) { +func NewFileServer(base *Route) (*FileServer, error) { s := &FileServer{Route: base} s.Root = filepath.Clean(s.Root) - if !path.IsAbs(s.Root) { - return nil, gperr.New("`root` must be an absolute path") + if !filepath.IsAbs(s.Root) { + return nil, errors.New("`root` must be an absolute path") } if s.Index == "" { @@ -77,8 +78,9 @@ func NewFileServer(base *Route) (*FileServer, gperr.Error) { } // Start implements task.TaskStarter. -func (s *FileServer) Start(parent task.Parent) gperr.Error { +func (s *FileServer) Start(parent task.Parent) error { s.task = parent.Subtask("fileserver."+s.Name(), false) + s.task.SetValue(monitor.DisplayNameKey{}, s.DisplayName()) pathPatterns := s.PathPatterns switch { @@ -109,7 +111,7 @@ func (s *FileServer) Start(parent task.Parent) gperr.Error { s.accessLogger, err = accesslog.NewAccessLogger(s.task, s.AccessLog) if err != nil { s.task.Finish(err) - return gperr.Wrap(err) + return err } } @@ -120,20 +122,22 @@ func (s *FileServer) Start(parent task.Parent) gperr.Error { if s.UseHealthCheck() { s.HealthMon = monitor.NewMonitor(s) if err := s.HealthMon.Start(s.task); err != nil { - return err + log.Warn().EmbedObject(s).Err(err).Msg("health monitor error") + s.HealthMon = nil } } - routes.HTTP.Add(s) - if state := config.WorkingState.Load(); state != nil { - state.ShortLinkMatcher().AddRoute(s.Alias) + ep := entrypoint.FromCtx(parent.Context()) + if ep == nil { + err := errors.New("entrypoint not initialized") + s.task.Finish(err) + return err + } + + if err := ep.StartAddRoute(s); err != nil { + s.task.Finish(err) + return err } - s.task.OnFinished("remove_route_from_http", func() { - routes.HTTP.Del(s) - if state := config.WorkingState.Load(); state != nil { - state.ShortLinkMatcher().DelRoute(s.Alias) - } - }) return nil } diff --git a/internal/route/provider/README.md b/internal/route/provider/README.md index b378b21d..be206abe 100644 --- a/internal/route/provider/README.md +++ b/internal/route/provider/README.md @@ -39,7 +39,7 @@ type ProviderImpl interface { fmt.Stringer ShortName() string IsExplicitOnly() bool - loadRoutesImpl() (route.Routes, gperr.Error) + loadRoutesImpl() (route.Routes, error) NewWatcher() W.Watcher Logger() *zerolog.Logger } @@ -62,8 +62,8 @@ func NewAgentProvider(cfg *agent.AgentConfig) *Provider ```go func (p *Provider) GetType() provider.Type -func (p *Provider) Start(parent task.Parent) gperr.Error -func (p *Provider) LoadRoutes() gperr.Error +func (p *Provider) Start(parent task.Parent) error +func (p *Provider) LoadRoutes() error func (p *Provider) IterRoutes(yield func(string, types.Route) bool) func (p *Provider) GetRoute(alias string) (types.Route, bool) func (p *Provider) FindService(project, service string) (types.Route, bool) @@ -80,8 +80,8 @@ classDiagram +t provider.Type +routes route.Routes +watcher W.Watcher - +Start(parent) gperr.Error - +LoadRoutes() gperr.Error + +Start(parent) error + +LoadRoutes() error +IterRoutes(yield) } @@ -90,7 +90,7 @@ classDiagram +String() string +ShortName() string +IsExplicitOnly() bool - +loadRoutesImpl() (route.Routes, gperr.Error) + +loadRoutesImpl() (route.Routes, error) +NewWatcher() W.Watcher +Logger() *zerolog.Logger } @@ -99,20 +99,20 @@ classDiagram +name string +dockerCfg types.DockerProviderConfig +ShortName() string - +loadRoutesImpl() (route.Routes, gperr.Error) + +loadRoutesImpl() (route.Routes, error) } class FileProviderImpl { +filename string +ShortName() string - +loadRoutesImpl() (route.Routes, gperr.Error) + +loadRoutesImpl() (route.Routes, error) } class AgentProviderImpl { +*agent.AgentConfig +docker DockerProviderImpl +ShortName() string - +loadRoutesImpl() (route.Routes, gperr.Error) + +loadRoutesImpl() (route.Routes, error) } Provider --> ProviderImpl : wraps diff --git a/internal/route/provider/agent.go b/internal/route/provider/agent.go index 0d35532b..44c7a48f 100644 --- a/internal/route/provider/agent.go +++ b/internal/route/provider/agent.go @@ -5,7 +5,6 @@ import ( "github.com/yusing/godoxy/agent/pkg/agent" "github.com/yusing/godoxy/internal/route" "github.com/yusing/godoxy/internal/watcher" - gperr "github.com/yusing/goutils/errs" ) type AgentProvider struct { @@ -25,7 +24,7 @@ func (p *AgentProvider) IsExplicitOnly() bool { return p.docker.IsExplicitOnly() } -func (p *AgentProvider) loadRoutesImpl() (route.Routes, gperr.Error) { +func (p *AgentProvider) loadRoutesImpl() (route.Routes, error) { return p.docker.loadRoutesImpl() } diff --git a/internal/route/provider/all_fields.yaml b/internal/route/provider/all_fields.yaml index 4931b7db..451a32ee 100644 --- a/internal/route/provider/all_fields.yaml +++ b/internal/route/provider/all_fields.yaml @@ -2,18 +2,44 @@ example: # matching `example.y.z` scheme: http host: 10.0.0.254 port: 80 + bind: 0.0.0.0 + root: /var/www/example + spa: true + index: index.html no_tls_verify: true + disable_compression: false + response_header_timeout: 30s + ssl_server_name: "" # empty uses target hostname, "off" disables SNI + ssl_trusted_certificate: /etc/ssl/certs/ca-certificates.crt + ssl_certificate: /etc/ssl/client.crt + ssl_certificate_key: /etc/ssl/client.key + ssl_protocols: + - tlsv1.2 + - tlsv1.3 path_patterns: # Check https://pkg.go.dev/net/http#hdr-Patterns-ServeMux for syntax - GET / # accept any GET request - POST /auth # for /auth and /auth/* accept only POST - GET /home/{$} # for exactly /home + rules: + - name: default + do: pass + - name: block-admin + on: path /admin + do: error 403 Forbidden + rule_file: embed://webui.yml healthcheck: disabled: false + use_get: true path: / interval: 5s + timeout: 5s + retries: -1 # -1: immediate fail, 0: use default, >0: retry count load_balance: - link: app - mode: ip_hash + link: app # link to another route alias + mode: roundrobin # roundrobin, leastconn, iphash + weight: 1 + sticky: false + sticky_max_age: 1h options: header: X-Forwarded-For middlewares: @@ -23,15 +49,19 @@ example: # matching `example.y.z` - 10.0.0.0/8 status_code: 403 message: IP not allowed - hideXForwarded: homepage: + show: true name: Example App icon: "@selfhst/adguard-home.png" description: An example app category: example access_log: - buffer_size: 100 path: /var/log/example.log + stdout: false + retention: + days: 30 + rotate_interval: 24h + format: combined # common, combined, json filters: status_codes: values: @@ -53,14 +83,29 @@ example: # matching `example.y.z` - 192.168.10.0/24 fields: headers: - default: keep - config: - foo: redact - query: default: drop config: - foo: keep - cookies: - default: redact + foo: redact + authorization: drop + query: + default: keep config: foo: keep + password: redact + cookies: + default: drop + config: + session: keep + idlewatcher: + idle_timeout: 30m + wake_timeout: 30s + stop_timeout: 1m + stop_method: stop # pause, stop, kill + stop_signal: SIGTERM + start_endpoint: /api/wake + depends_on: + - other-service + no_loading_page: false + docker: + container_id: abc123 + container_name: example-app diff --git a/internal/route/provider/docker.go b/internal/route/provider/docker.go index e17445fb..d6415e20 100755 --- a/internal/route/provider/docker.go +++ b/internal/route/provider/docker.go @@ -58,13 +58,13 @@ func (p *DockerProvider) NewWatcher() watcher.Watcher { return watcher.NewDockerWatcher(p.dockerCfg) } -func (p *DockerProvider) loadRoutesImpl() (route.Routes, gperr.Error) { +func (p *DockerProvider) loadRoutesImpl() (route.Routes, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() containers, err := docker.ListContainers(ctx, p.dockerCfg) if err != nil { - return nil, gperr.Wrap(err) + return nil, err } errs := gperr.NewBuilder("") @@ -74,21 +74,21 @@ func (p *DockerProvider) loadRoutesImpl() (route.Routes, gperr.Error) { container := docker.FromDocker(&c, p.dockerCfg) if container.Errors != nil { - errs.Add(gperr.PrependSubject(container.ContainerName, container.Errors)) + errs.AddSubject(container.Errors, container.ContainerName) continue } if container.IsHostNetworkMode { err := docker.UpdatePorts(ctx, container) if err != nil { - errs.Add(gperr.PrependSubject(container.ContainerName, err)) + errs.AddSubject(err, container.ContainerName) continue } } newEntries, err := p.routesFromContainerLabels(container) if err != nil { - errs.Add(err.Subject(container.ContainerName)) + errs.AddSubject(err, container.ContainerName) } for k, v := range newEntries { if conflict, ok := routes[k]; ok { @@ -97,7 +97,7 @@ func (p *DockerProvider) loadRoutesImpl() (route.Routes, gperr.Error) { Addf("container %s", container.ContainerName). Addf("conflicting container %s", conflict.Container.ContainerName) if conflict.ShouldExclude() || v.ShouldExclude() { - gperr.LogWarn("skipping conflicting route", err) + log.Warn().Err(err).Msg("skipping conflicting route") } else { errs.Add(err) } @@ -112,7 +112,7 @@ func (p *DockerProvider) loadRoutesImpl() (route.Routes, gperr.Error) { // Returns a list of proxy entries for a container. // Always non-nil. -func (p *DockerProvider) routesFromContainerLabels(container *types.Container) (route.Routes, gperr.Error) { +func (p *DockerProvider) routesFromContainerLabels(container *types.Container) (route.Routes, error) { if !container.IsExplicit && p.IsExplicitOnly() { return make(route.Routes, 0), nil } @@ -150,7 +150,7 @@ func (p *DockerProvider) routesFromContainerLabels(container *types.Container) ( panic(fmt.Errorf("invalid entry map type %T", entryMapAny)) } if err := yaml.Unmarshal([]byte(yamlStr), &entryMap); err != nil { - errs.Add(gperr.Wrap(err).Subject(alias)) + errs.AddSubject(err, alias) continue } } @@ -185,7 +185,7 @@ func (p *DockerProvider) routesFromContainerLabels(container *types.Container) ( // deserialize map into entry object err := serialization.MapUnmarshalValidate(entryMap, r) if err != nil { - errs.Add(err.Subject(alias)) + errs.AddSubject(err, alias) } else { routes[alias] = r } diff --git a/internal/route/provider/docker_labels.yaml b/internal/route/provider/docker_labels.yaml index 92cbe32d..1ccdad22 100644 --- a/internal/route/provider/docker_labels.yaml +++ b/internal/route/provider/docker_labels.yaml @@ -31,7 +31,6 @@ proxy.app: | description: An example app category: example access_log: - buffer_size: 100 path: /var/log/example.log filters: status_codes: @@ -92,7 +91,6 @@ proxy.app1.homepage.name: Example App proxy.app1.homepage.icon: "@selfhst/adguard-home.png" proxy.app1.homepage.description: An example app proxy.app1.homepage.category: example -proxy.app1.access_log.buffer_size: 100 proxy.app1.access_log.path: /var/log/example.log proxy.app1.access_log.filters: | status_codes: diff --git a/internal/route/provider/event_handler.go b/internal/route/provider/event_handler.go index 23f45ab3..ab665b4d 100644 --- a/internal/route/provider/event_handler.go +++ b/internal/route/provider/event_handler.go @@ -81,7 +81,7 @@ func (handler *EventHandler) match(event watcher.Event, route *route.Route) bool func (handler *EventHandler) Add(parent task.Parent, route *route.Route) { err := handler.provider.startRoute(parent, route) if err != nil { - handler.errs.Add(err.Subject("add")) + handler.errs.AddSubjectf(err, "add") } } @@ -93,12 +93,12 @@ func (handler *EventHandler) Update(parent task.Parent, oldRoute *route.Route, n oldRoute.FinishAndWait("route update") err := handler.provider.startRoute(parent, newRoute) if err != nil { - handler.errs.Add(err.Subject("update")) + handler.errs.AddSubjectf(err, "update") } } func (handler *EventHandler) Log() { if err := handler.errs.Error(); err != nil { - handler.provider.Logger().Info().Msg(err.Error()) + handler.provider.Logger().Error().Msg(err.Error()) } } diff --git a/internal/route/provider/file.go b/internal/route/provider/file.go index 82860581..3c78c952 100644 --- a/internal/route/provider/file.go +++ b/internal/route/provider/file.go @@ -12,7 +12,6 @@ import ( "github.com/yusing/godoxy/internal/route" "github.com/yusing/godoxy/internal/serialization" W "github.com/yusing/godoxy/internal/watcher" - gperr "github.com/yusing/goutils/errs" ) type FileProvider struct { @@ -34,7 +33,7 @@ func FileProviderImpl(filename string) (ProviderImpl, error) { return impl, nil } -func removeXPrefix(m map[string]any) gperr.Error { +func removeXPrefix(m map[string]any) error { for alias := range m { if strings.HasPrefix(alias, "x-") { delete(m, alias) @@ -43,12 +42,12 @@ func removeXPrefix(m map[string]any) gperr.Error { return nil } -func validate(data []byte) (routes route.Routes, err gperr.Error) { +func validate(data []byte) (routes route.Routes, err error) { err = serialization.UnmarshalValidate(data, &routes, yaml.Unmarshal, removeXPrefix) return routes, err } -func Validate(data []byte) (err gperr.Error) { +func Validate(data []byte) (err error) { _, err = validate(data) return err } @@ -69,16 +68,16 @@ func (p *FileProvider) Logger() *zerolog.Logger { return &p.l } -func (p *FileProvider) loadRoutesImpl() (route.Routes, gperr.Error) { +func (p *FileProvider) loadRoutesImpl() (route.Routes, error) { data, err := os.ReadFile(p.path) if err != nil { - return nil, gperr.Wrap(err) + return nil, err } routes, err := validate(data) if err != nil && len(routes) == 0 { - return nil, gperr.Wrap(err) + return nil, err } - return routes, gperr.Wrap(err) + return routes, err } func (p *FileProvider) NewWatcher() W.Watcher { diff --git a/internal/route/provider/provider.go b/internal/route/provider/provider.go index de32fe8a..8869cee0 100644 --- a/internal/route/provider/provider.go +++ b/internal/route/provider/provider.go @@ -15,8 +15,10 @@ import ( provider "github.com/yusing/godoxy/internal/route/provider/types" "github.com/yusing/godoxy/internal/types" W "github.com/yusing/godoxy/internal/watcher" - "github.com/yusing/godoxy/internal/watcher/events" + watcherEvents "github.com/yusing/godoxy/internal/watcher/events" gperr "github.com/yusing/goutils/errs" + "github.com/yusing/goutils/eventqueue" + "github.com/yusing/goutils/events" "github.com/yusing/goutils/task" ) @@ -34,7 +36,7 @@ type ( fmt.Stringer ShortName() string IsExplicitOnly() bool - loadRoutesImpl() (route.Routes, gperr.Error) + loadRoutesImpl() (route.Routes, error) NewWatcher() W.Watcher Logger() *zerolog.Logger } @@ -90,13 +92,13 @@ func (p *Provider) GetType() provider.Type { return p.t } -// to work with json marshaller. +// MarshalText implements encoding.TextMarshaler. func (p *Provider) MarshalText() ([]byte, error) { return []byte(p.String()), nil } // Start implements task.TaskStarter. -func (p *Provider) Start(parent task.Parent) gperr.Error { +func (p *Provider) Start(parent task.Parent) error { errs := gperr.NewGroup("routes error") t := parent.Subtask("provider."+p.String(), false) @@ -115,19 +117,29 @@ func (p *Provider) Start(parent task.Parent) gperr.Error { err := errs.Wait().Error() - eventQueue := events.NewEventQueue( - t.Subtask("event_queue", false), - providerEventFlushInterval, - func(events []events.Event) { + opts := eventqueue.Options[watcherEvents.Event]{ + FlushInterval: providerEventFlushInterval, + OnFlush: func(evs []watcherEvents.Event) { handler := p.newEventHandler() // routes' lifetime should follow the provider's lifetime - handler.Handle(t, events) + handler.Handle(t, evs) handler.Log() + + globalEvents := make([]events.Event, len(evs)) + for i, ev := range evs { + globalEvents[i] = events.NewEvent(events.LevelInfo, "provider_event", ev.Action.String(), map[string]any{ + "provider": p.String(), + "type": ev.Type, // file / docker + "actor": ev.ActorName, // file path / container name + }) + } + events.Global.AddAll(globalEvents) }, - func(err gperr.Error) { - gperr.LogError("event error", err, p.Logger()) + OnError: func(err error) { + p.Logger().Err(err).Msg("event error") }, - ) + } + eventQueue := eventqueue.New(t.Subtask("event_queue", false), opts) eventQueue.Start(p.watcher.Events(t.Context())) if err != nil { @@ -136,7 +148,7 @@ func (p *Provider) Start(parent task.Parent) gperr.Error { return nil } -func (p *Provider) LoadRoutes() (err gperr.Error) { +func (p *Provider) LoadRoutes() (err error) { p.routes, err = p.loadRoutes() return err } @@ -188,7 +200,7 @@ func (p *Provider) GetRoute(alias string) (types.Route, bool) { return r.Impl(), true } -func (p *Provider) loadRoutes() (routes route.Routes, err gperr.Error) { +func (p *Provider) loadRoutes() (routes route.Routes, err error) { routes, err = p.loadRoutesImpl() if err != nil && len(routes) == 0 { return route.Routes{}, err @@ -201,7 +213,7 @@ func (p *Provider) loadRoutes() (routes route.Routes, err gperr.Error) { r.Alias = alias r.SetProvider(p) if err := r.Validate(); err != nil { - errs.Add(err.Subject(alias)) + errs.AddSubject(err, alias) delete(routes, alias) continue } @@ -210,11 +222,11 @@ func (p *Provider) loadRoutes() (routes route.Routes, err gperr.Error) { return routes, errs.Error() } -func (p *Provider) startRoute(parent task.Parent, r *route.Route) gperr.Error { +func (p *Provider) startRoute(parent task.Parent, r *route.Route) error { err := r.Start(parent) if err != nil { p.lockDeleteRoute(r.Alias) - return err.Subject(r.Alias) + return gperr.PrependSubject(err, r.Alias) } p.lockAddRoute(r) diff --git a/internal/route/reverse_proxy.go b/internal/route/reverse_proxy.go index 08225aa5..95b4f457 100755 --- a/internal/route/reverse_proxy.go +++ b/internal/route/reverse_proxy.go @@ -1,12 +1,14 @@ package route import ( + "errors" "net/http" "sync" + "github.com/rs/zerolog/log" "github.com/yusing/godoxy/agent/pkg/agent" "github.com/yusing/godoxy/agent/pkg/agentproxy" - config "github.com/yusing/godoxy/internal/config/types" + entrypoint "github.com/yusing/godoxy/internal/entrypoint/types" "github.com/yusing/godoxy/internal/health/monitor" "github.com/yusing/godoxy/internal/idlewatcher" "github.com/yusing/godoxy/internal/logging/accesslog" @@ -14,16 +16,14 @@ import ( "github.com/yusing/godoxy/internal/net/gphttp/loadbalancer" "github.com/yusing/godoxy/internal/net/gphttp/middleware" nettypes "github.com/yusing/godoxy/internal/net/types" - "github.com/yusing/godoxy/internal/route/routes" route "github.com/yusing/godoxy/internal/route/types" "github.com/yusing/godoxy/internal/types" - gperr "github.com/yusing/goutils/errs" "github.com/yusing/goutils/http/reverseproxy" "github.com/yusing/goutils/task" "github.com/yusing/goutils/version" ) -type ReveseProxyRoute struct { +type ReverseProxyRoute struct { *Route loadBalancer *loadbalancer.LoadBalancer @@ -31,11 +31,11 @@ type ReveseProxyRoute struct { rp *reverseproxy.ReverseProxy } -var _ types.ReverseProxyRoute = (*ReveseProxyRoute)(nil) +var _ types.ReverseProxyRoute = (*ReverseProxyRoute)(nil) // var globalMux = http.NewServeMux() // TODO: support regex subdomain matching. -func NewReverseProxyRoute(base *Route) (*ReveseProxyRoute, gperr.Error) { +func NewReverseProxyRoute(base *Route) (*ReverseProxyRoute, error) { httpConfig := base.HTTPConfig proxyURL := base.ProxyURL @@ -111,7 +111,7 @@ func NewReverseProxyRoute(base *Route) (*ReveseProxyRoute, gperr.Error) { } } - r := &ReveseProxyRoute{ + r := &ReverseProxyRoute{ Route: base, rp: rp, } @@ -119,20 +119,21 @@ func NewReverseProxyRoute(base *Route) (*ReveseProxyRoute, gperr.Error) { } // ReverseProxy implements routes.ReverseProxyRoute. -func (r *ReveseProxyRoute) ReverseProxy() *reverseproxy.ReverseProxy { +func (r *ReverseProxyRoute) ReverseProxy() *reverseproxy.ReverseProxy { return r.rp } // Start implements task.TaskStarter. -func (r *ReveseProxyRoute) Start(parent task.Parent) gperr.Error { +func (r *ReverseProxyRoute) Start(parent task.Parent) error { r.task = parent.Subtask("http."+r.Name(), false) + r.task.SetValue(monitor.DisplayNameKey{}, r.DisplayName()) switch { case r.UseIdleWatcher(): waker, err := idlewatcher.NewWatcher(parent, r, r.IdlewatcherConfig()) if err != nil { r.task.Finish(err) - return gperr.Wrap(err) + return err } r.handler = waker r.HealthMon = waker @@ -149,7 +150,7 @@ func (r *ReveseProxyRoute) Start(parent task.Parent) gperr.Error { r.rp.AccessLogger, err = accesslog.NewAccessLogger(r.task, r.AccessLog) if err != nil { r.task.Finish(err) - return gperr.Wrap(err) + return err } } @@ -159,44 +160,50 @@ func (r *ReveseProxyRoute) Start(parent task.Parent) gperr.Error { if r.HealthMon != nil { if err := r.HealthMon.Start(r.task); err != nil { - return err + // TODO: add to event history + log.Warn().Err(err).Msg("health monitor error") + r.HealthMon = nil } } + ep := entrypoint.FromCtx(parent.Context()) + if ep == nil { + err := errors.New("entrypoint not initialized") + r.task.Finish(err) + return err + } + if r.UseLoadBalance() { - r.addToLoadBalancer(parent) - } else { - routes.HTTP.Add(r) - if state := config.WorkingState.Load(); state != nil { - state.ShortLinkMatcher().AddRoute(r.Alias) + if err := r.addToLoadBalancer(parent, ep); err != nil { + r.task.Finish(err) + return err + } + } else { + if err := ep.StartAddRoute(r); err != nil { + r.task.Finish(err) + return err } - r.task.OnCancel("remove_route", func() { - routes.HTTP.Del(r) - if state := config.WorkingState.Load(); state != nil { - state.ShortLinkMatcher().DelRoute(r.Alias) - } - }) } return nil } -func (r *ReveseProxyRoute) ServeHTTP(w http.ResponseWriter, req *http.Request) { +func (r *ReverseProxyRoute) ServeHTTP(w http.ResponseWriter, req *http.Request) { // req.Header.Set("Accept-Encoding", "identity") r.handler.ServeHTTP(w, req) } var lbLock sync.Mutex -func (r *ReveseProxyRoute) addToLoadBalancer(parent task.Parent) { +func (r *ReverseProxyRoute) addToLoadBalancer(parent task.Parent, ep entrypoint.Entrypoint) error { var lb *loadbalancer.LoadBalancer cfg := r.LoadBalance lbLock.Lock() + defer lbLock.Unlock() - l, ok := routes.HTTP.Get(cfg.Link) - var linked *ReveseProxyRoute + l, ok := ep.HTTPRoutes().Get(cfg.Link) + var linked *ReverseProxyRoute if ok { - lbLock.Unlock() - linked = l.(*ReveseProxyRoute) + linked = l.(*ReverseProxyRoute) // it must be a reverse proxy route lb = linked.loadBalancer lb.UpdateConfigIfNeeded(cfg) if linked.Homepage.Name == "" { @@ -205,26 +212,24 @@ func (r *ReveseProxyRoute) addToLoadBalancer(parent task.Parent) { } else { lb = loadbalancer.New(cfg) _ = lb.Start(parent) // always return nil - linked = &ReveseProxyRoute{ + linked = &ReverseProxyRoute{ Route: &Route{ Alias: cfg.Link, Homepage: r.Homepage, + Bind: r.Bind, + Metadata: Metadata{ + LisURL: r.ListenURL(), + task: lb.Task(), + }, }, loadBalancer: lb, handler: lb, } linked.SetHealthMonitor(lb) - routes.HTTP.AddKey(cfg.Link, linked) - if state := config.WorkingState.Load(); state != nil { - state.ShortLinkMatcher().AddRoute(cfg.Link) + if err := ep.StartAddRoute(linked); err != nil { + lb.Finish(err) + return err } - r.task.OnFinished("remove_loadbalancer_route", func() { - routes.HTTP.DelKey(cfg.Link) - if state := config.WorkingState.Load(); state != nil { - state.ShortLinkMatcher().DelRoute(cfg.Link) - } - }) - lbLock.Unlock() } r.loadBalancer = lb @@ -233,4 +238,5 @@ func (r *ReveseProxyRoute) addToLoadBalancer(parent task.Parent) { r.task.OnCancel("lb_remove_server", func() { lb.RemoveServer(server) }) + return nil } diff --git a/internal/route/reverse_proxy_test.go b/internal/route/reverse_proxy_test.go new file mode 100644 index 00000000..bd579c83 --- /dev/null +++ b/internal/route/reverse_proxy_test.go @@ -0,0 +1,39 @@ +package route + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + route "github.com/yusing/godoxy/internal/route/types" + "github.com/yusing/godoxy/internal/types" +) + +func TestReverseProxyRoute(t *testing.T) { + t.Run("LinkToLoadBalancer", func(t *testing.T) { + cfg := Route{ + Alias: "test", + Scheme: route.SchemeHTTP, + Host: "example.com", + Port: Port{Proxy: 80}, + LoadBalance: &types.LoadBalancerConfig{ + Link: "test", + }, + } + cfg1 := Route{ + Alias: "test1", + Scheme: route.SchemeHTTP, + Host: "example.com", + Port: Port{Proxy: 80}, + LoadBalance: &types.LoadBalancerConfig{ + Link: "test", + }, + } + r, err := NewStartedTestRoute(t, &cfg) + require.NoError(t, err) + assert.NotNil(t, r) + r2, err := NewStartedTestRoute(t, &cfg1) + require.NoError(t, err) + assert.NotNil(t, r2) + }) +} diff --git a/internal/route/route.go b/internal/route/route.go index 3a472eae..749d3939 100644 --- a/internal/route/route.go +++ b/internal/route/route.go @@ -14,10 +14,12 @@ import ( "sync" "time" + "github.com/rs/zerolog" "github.com/rs/zerolog/log" "github.com/yusing/godoxy/internal/agentpool" config "github.com/yusing/godoxy/internal/config/types" "github.com/yusing/godoxy/internal/docker" + entrypoint "github.com/yusing/godoxy/internal/entrypoint/types" "github.com/yusing/godoxy/internal/health/monitor" "github.com/yusing/godoxy/internal/homepage" iconlist "github.com/yusing/godoxy/internal/homepage/icons/list" @@ -33,7 +35,6 @@ import ( "github.com/yusing/godoxy/internal/common" "github.com/yusing/godoxy/internal/logging/accesslog" - "github.com/yusing/godoxy/internal/route/routes" "github.com/yusing/godoxy/internal/route/rules" rulepresets "github.com/yusing/godoxy/internal/route/rules/presets" route "github.com/yusing/godoxy/internal/route/types" @@ -46,7 +47,6 @@ type ( Host string `json:"host,omitempty"` Port route.Port `json:"port"` - // for TCP and UDP routes, bind address to listen on Bind string `json:"bind,omitempty" validate:"omitempty,ip_addr" extensions:"x-nullable"` Root string `json:"root,omitempty"` @@ -57,7 +57,7 @@ type ( PathPatterns []string `json:"path_patterns,omitempty" extensions:"x-nullable"` Rules rules.Rules `json:"rules,omitempty" extensions:"x-nullable"` RuleFile string `json:"rule_file,omitempty" extensions:"x-nullable"` - HealthCheck types.HealthCheckConfig `json:"healthcheck,omitempty" extensions:"x-nullable"` // null on load-balancer routes + HealthCheck types.HealthCheckConfig `json:"healthcheck,omitzero" extensions:"x-nullable"` // null on load-balancer routes LoadBalance *types.LoadBalancerConfig `json:"load_balance,omitempty" extensions:"x-nullable"` Middlewares map[string]types.LabelMap `json:"middlewares,omitempty" extensions:"x-nullable"` Homepage *homepage.ItemConfig `json:"homepage"` @@ -108,17 +108,17 @@ type ( ) type lockedError struct { - err gperr.Error + err error lock sync.Mutex } -func (le *lockedError) Get() gperr.Error { +func (le *lockedError) Get() error { le.lock.Lock() defer le.lock.Unlock() return le.err } -func (le *lockedError) Set(err gperr.Error) { +func (le *lockedError) Set(err error) { le.lock.Lock() defer le.lock.Unlock() le.err = err @@ -131,7 +131,7 @@ func (r Routes) Contains(alias string) bool { return ok } -func (r *Route) Validate() gperr.Error { +func (r *Route) Validate() error { // wait for alias to be set if r.Alias == "" { return nil @@ -150,13 +150,13 @@ func (r *Route) Validate() gperr.Error { return r.valErr.Get() } -func (r *Route) validate() gperr.Error { +func (r *Route) validate() error { // if strings.HasPrefix(r.Alias, "godoxy") { // log.Debug().Any("route", r).Msg("validating route") // } if r.Agent != "" { if r.Container != nil { - return gperr.Errorf("specifying agent is not allowed for docker container routes") + return errors.New("specifying agent is not allowed for docker container routes") } var ok bool // by agent address @@ -165,7 +165,7 @@ func (r *Route) validate() gperr.Error { // fallback to get agent by name r.agent, ok = agentpool.GetAgent(r.Agent) if !ok { - return gperr.Errorf("agent %s not found", r.Agent) + return fmt.Errorf("agent %s not found", r.Agent) } } } @@ -200,7 +200,11 @@ func (r *Route) validate() gperr.Error { if (r.Proxmox == nil || r.Proxmox.Node == "" || r.Proxmox.VMID == nil) && r.Container == nil { wasNotNil := r.Proxmox != nil - proxmoxProviders := config.WorkingState.Load().Value().Providers.Proxmox + workingState := config.WorkingState.Load() + var proxmoxProviders []*proxmox.Config + if workingState != nil { // nil in tests + proxmoxProviders = workingState.Value().Providers.Proxmox + } if len(proxmoxProviders) > 0 { // it's fine if ip is nil hostname := r.Host @@ -208,40 +212,34 @@ func (r *Route) validate() gperr.Error { for _, p := range proxmoxProviders { // First check if hostname, IP, or alias matches a node (node-level route) if nodeName := p.Client().ReverseLookupNode(hostname, ip, r.Alias); nodeName != "" { - zero := 0 + zero := uint64(0) if r.Proxmox == nil { r.Proxmox = &proxmox.NodeConfig{} } r.Proxmox.Node = nodeName r.Proxmox.VMID = &zero r.Proxmox.VMName = "" - log.Info(). - Str("node", nodeName). - Msgf("found proxmox node for route %q", r.Alias) + log.Info().EmbedObject(r).Msg("found proxmox node") break } // Then check if hostname, IP, or alias matches a VM resource resource, _ := p.Client().ReverseLookupResource(ip, hostname, r.Alias) if resource != nil { - vmid := int(resource.VMID) + vmid := resource.VMID if r.Proxmox == nil { r.Proxmox = &proxmox.NodeConfig{} } r.Proxmox.Node = resource.Node r.Proxmox.VMID = &vmid r.Proxmox.VMName = resource.Name - log.Info(). - Str("node", resource.Node). - Int("vmid", int(resource.VMID)). - Str("vmname", resource.Name). - Msgf("found proxmox resource for route %q", r.Alias) + log.Info().EmbedObject(r).Msg("found proxmox resource") break } } } if wasNotNil && (r.Proxmox.Node == "" || r.Proxmox.VMID == nil) { - log.Warn().Msgf("no proxmox node / resource found for route %q", r.Alias) + log.Warn().EmbedObject(r).Msg("no proxmox node / resource found") } } @@ -260,7 +258,7 @@ func (r *Route) validate() gperr.Error { switch r.Port.Proxy { case common.ProxyHTTPPort, common.ProxyHTTPSPort, common.APIHTTPPort: if r.Scheme.IsReverseProxy() || r.Scheme == route.SchemeTCP { - return gperr.Errorf("localhost:%d is reserved for godoxy", r.Port.Proxy) + return fmt.Errorf("localhost:%d is reserved for godoxy", r.Port.Proxy) } } } @@ -271,27 +269,19 @@ func (r *Route) validate() gperr.Error { errs.Add(err) } - var impl types.Route - var err gperr.Error - - switch r.Scheme { - case route.SchemeFileServer: - r.Host = "" - r.Port.Proxy = 0 - r.ProxyURL = gperr.Collect(&errs, nettypes.ParseURL, "file://"+r.Root) - case route.SchemeHTTP, route.SchemeHTTPS, route.SchemeH2C: - if r.Port.Listening != 0 { - errs.Addf("unexpected listening port for %s scheme", r.Scheme) - } + if r.ShouldExclude() { r.ProxyURL = gperr.Collect(&errs, nettypes.ParseURL, fmt.Sprintf("%s://%s", r.Scheme, net.JoinHostPort(r.Host, strconv.Itoa(r.Port.Proxy)))) - case route.SchemeTCP, route.SchemeUDP: - if r.ShouldExclude() { - // should exclude, we don't care the scheme here. + } else { + switch r.Scheme { + case route.SchemeFileServer: + r.Host = "" + r.Port.Proxy = 0 + r.LisURL = gperr.Collect(&errs, nettypes.ParseURL, "https://"+net.JoinHostPort(r.Bind, strconv.Itoa(r.Port.Listening))) + r.ProxyURL = gperr.Collect(&errs, nettypes.ParseURL, "file://"+r.Root) + case route.SchemeHTTP, route.SchemeHTTPS, route.SchemeH2C: + r.LisURL = gperr.Collect(&errs, nettypes.ParseURL, "https://"+net.JoinHostPort(r.Bind, strconv.Itoa(r.Port.Listening))) r.ProxyURL = gperr.Collect(&errs, nettypes.ParseURL, fmt.Sprintf("%s://%s", r.Scheme, net.JoinHostPort(r.Host, strconv.Itoa(r.Port.Proxy)))) - } else { - if r.Bind == "" { - r.Bind = "0.0.0.0" - } + case route.SchemeTCP, route.SchemeUDP: bindIP := net.ParseIP(r.Bind) remoteIP := net.ParseIP(r.Host) toNetwork := func(ip net.IP, scheme route.Scheme) string { @@ -325,6 +315,8 @@ func (r *Route) validate() gperr.Error { return errs.Error() } + var impl types.Route + var err error switch r.Scheme { case route.SchemeFileServer: impl, err = NewFileServer(r) @@ -360,8 +352,8 @@ func (r *Route) validateRules() error { return errors.New("rule preset `webui.yml` not found") } r.Rules = rules + return nil } - return nil } if r.RuleFile != "" && len(r.Rules) > 0 { @@ -397,7 +389,7 @@ func (r *Route) validateRules() error { } func (r *Route) validateProxmox() { - l := log.With().Str("route", r.Alias).Logger() + l := log.With().EmbedObject(r).Logger() nodeName := r.Proxmox.Node vmid := r.Proxmox.VMID @@ -426,7 +418,7 @@ func (r *Route) validateProxmox() { } else { res, err := node.Client().GetResource("lxc", *vmid) if err != nil { // ErrResourceNotFound - l.Err(err).Msgf("failed to get resource %d", *vmid) + l.Error().Err(err).Msgf("failed to get resource %d", *vmid) return } @@ -445,24 +437,22 @@ func (r *Route) validateProxmox() { return } - l = l.With().Str("container", containerName).Logger() - - l.Info().Msgf("checking if container is running") + l.Info().Str("container", containerName).Msg("checking if container is running") running, err := node.LXCIsRunning(ctx, *vmid) if err != nil { - l.Err(err).Msgf("failed to check container state") + l.Error().Err(err).Msgf("failed to check container state") return } if !running { - l.Info().Msgf("starting container") + l.Info().Msg("starting container") if err := node.LXCAction(ctx, *vmid, proxmox.LXCStart); err != nil { - l.Err(err).Msgf("failed to start container") + l.Error().Err(err).Msg("failed to start container") return } } - l.Info().Msgf("finding reachable ip addresses") + l.Info().Msg("finding reachable ip addresses") errs := gperr.NewBuilder("failed to find reachable ip addresses") for _, ip := range ips { if err := netutils.PingTCP(ctx, ip, r.Port.Proxy); err != nil { @@ -488,23 +478,23 @@ func (r *Route) Task() *task.Task { return r.task } -func (r *Route) Start(parent task.Parent) gperr.Error { +func (r *Route) Start(parent task.Parent) error { r.onceStart.Do(func() { r.startErr.Set(r.start(parent)) }) return r.startErr.Get() } -func (r *Route) start(parent task.Parent) gperr.Error { +func (r *Route) start(parent task.Parent) error { if r.impl == nil { // should not happen - return gperr.New("route not initialized") + return errors.New("route not initialized") } defer close(r.started) // skip checking for excluded routes excluded := r.ShouldExclude() if !excluded { - if err := checkExists(r); err != nil { + if err := checkExists(parent.Context(), r); err != nil { return err } } @@ -518,15 +508,23 @@ func (r *Route) start(parent task.Parent) gperr.Error { return err } } else { - r.task = parent.Subtask("excluded."+r.Name(), true) - routes.Excluded.Add(r.impl) + ep := entrypoint.FromCtx(parent.Context()) + if ep == nil { + return errors.New("entrypoint not initialized") + } + + r.task = parent.Subtask("excluded."+r.Name(), false) + r.task.SetValue(monitor.DisplayNameKey{}, r.DisplayName()) + ep.ExcludedRoutes().Add(r.impl) r.task.OnCancel("remove_route_from_excluded", func() { - routes.Excluded.Del(r.impl) + ep.ExcludedRoutes().Del(r.impl) }) if r.UseHealthCheck() { r.HealthMon = monitor.NewMonitor(r.impl) err := r.HealthMon.Start(r.task) - return err + if err != nil { + return err + } } } return nil @@ -564,6 +562,10 @@ func (r *Route) ProviderName() string { return r.Provider } +func (r *Route) ListenURL() *nettypes.URL { + return r.LisURL +} + func (r *Route) TargetURL() *nettypes.URL { return r.ProxyURL } @@ -587,10 +589,9 @@ func (r *Route) References() []string { return []string{r.Proxmox.VMName, aliasRef, r.Proxmox.Services[0]} } return []string{r.Proxmox.Services[0], aliasRef} - } else { - if r.Proxmox.VMName != aliasRef { - return []string{r.Proxmox.VMName, aliasRef} - } + } + if r.Proxmox.VMName != aliasRef { + return []string{r.Proxmox.VMName, aliasRef} } } return []string{aliasRef} @@ -678,6 +679,44 @@ func (r *Route) DisplayName() string { return r.Homepage.Name } +func (r *Route) MarshalZerologObject(e *zerolog.Event) { + e.Str("alias", r.Alias) + switch r := r.impl.(type) { + case *ReverseProxyRoute: + e.Str("type", "reverse_proxy"). + Str("scheme", r.Scheme.String()). + Str("bind", r.LisURL.Host). + Str("target", r.ProxyURL.URL.String()) + case *FileServer: + e.Str("type", "file_server"). + Str("root", r.Root) + case *StreamRoute: + e.Str("type", "stream"). + Str("scheme", r.LisURL.Scheme+"->"+r.ProxyURL.Scheme) + if r.stream != nil { + // listening port could be zero (random), + // use LocalAddr() to get the actual listening host+port. + e.Str("bind", r.stream.LocalAddr().String()) + } else { + // not yet started + e.Str("bind", r.LisURL.Host) + } + e.Str("target", r.ProxyURL.URL.String()) + } + if r.Proxmox != nil { + e.Str("proxmox", r.Proxmox.Node) + if r.Proxmox.VMID != nil { + e.Uint64("vmid", *r.Proxmox.VMID) + } + if r.Proxmox.VMName != "" { + e.Str("vmname", r.Proxmox.VMName) + } + } + if r.Container != nil { + e.Str("container", r.Container.ContainerName) + } +} + // PreferOver implements pool.Preferable to resolve duplicate route keys deterministically. // Preference policy: // - Prefer routes with rules over routes without rules. @@ -689,7 +728,7 @@ func (r *Route) PreferOver(other any) bool { switch v := other.(type) { case *Route: or = v - case *ReveseProxyRoute: + case *ReverseProxyRoute: or = v.Route case *FileServer: or = v.Route @@ -932,6 +971,13 @@ func (r *Route) Finalize() { } } + switch r.Scheme { + case route.SchemeTCP, route.SchemeUDP: + if r.Bind == "" { + r.Bind = "0.0.0.0" + } + } + r.Port.Listening, r.Port.Proxy = lp, pp workingState := config.WorkingState.Load() @@ -942,7 +988,8 @@ func (r *Route) Finalize() { panic("bug: working state is nil") } - r.HealthCheck.ApplyDefaults(config.WorkingState.Load().Value().Defaults.HealthCheck) + // TODO: default value from context + r.HealthCheck.ApplyDefaults(workingState.Value().Defaults.HealthCheck) } func (r *Route) FinalizeHomepageConfig() { diff --git a/internal/route/route_test.go b/internal/route/route_test.go index 588d66cc..8be85c1b 100644 --- a/internal/route/route_test.go +++ b/internal/route/route_test.go @@ -4,10 +4,10 @@ import ( "testing" "time" + "github.com/stretchr/testify/require" "github.com/yusing/godoxy/internal/common" route "github.com/yusing/godoxy/internal/route/types" "github.com/yusing/godoxy/internal/types" - expect "github.com/yusing/goutils/testing" ) func TestRouteValidate(t *testing.T) { @@ -19,20 +19,8 @@ func TestRouteValidate(t *testing.T) { Port: route.Port{Proxy: common.ProxyHTTPPort}, } err := r.Validate() - expect.HasError(t, err, "Validate should return error for localhost with reserved port") - expect.ErrorContains(t, err, "reserved for godoxy") - }) - - t.Run("ListeningPortWithHTTP", func(t *testing.T) { - r := &Route{ - Alias: "test", - Scheme: route.SchemeHTTP, - Host: "example.com", - Port: route.Port{Proxy: 80, Listening: 1234}, - } - err := r.Validate() - expect.HasError(t, err, "Validate should return error for HTTP scheme with listening port") - expect.ErrorContains(t, err, "unexpected listening port") + require.Error(t, err, "Validate should return error for localhost with reserved port") + require.ErrorContains(t, err, "reserved for godoxy") }) t.Run("DisabledHealthCheckWithLoadBalancer", func(t *testing.T) { @@ -49,8 +37,8 @@ func TestRouteValidate(t *testing.T) { }, // Minimal LoadBalance config with non-empty Link will be checked by UseLoadBalance } err := r.Validate() - expect.HasError(t, err, "Validate should return error for disabled healthcheck with loadbalancer") - expect.ErrorContains(t, err, "cannot disable healthcheck") + require.Error(t, err, "Validate should return error for disabled healthcheck with loadbalancer") + require.ErrorContains(t, err, "cannot disable healthcheck") }) t.Run("FileServerScheme", func(t *testing.T) { @@ -62,8 +50,8 @@ func TestRouteValidate(t *testing.T) { Root: "/tmp", // Root is required for file server } err := r.Validate() - expect.NoError(t, err, "Validate should not return error for valid file server route") - expect.NotNil(t, r.impl, "Impl should be initialized") + require.NoError(t, err, "Validate should not return error for valid file server route") + require.NotNil(t, r.impl, "Impl should be initialized") }) t.Run("HTTPScheme", func(t *testing.T) { @@ -74,8 +62,8 @@ func TestRouteValidate(t *testing.T) { Port: route.Port{Proxy: 80}, } err := r.Validate() - expect.NoError(t, err, "Validate should not return error for valid HTTP route") - expect.NotNil(t, r.impl, "Impl should be initialized") + require.NoError(t, err, "Validate should not return error for valid HTTP route") + require.NotNil(t, r.impl, "Impl should be initialized") }) t.Run("TCPScheme", func(t *testing.T) { @@ -86,8 +74,8 @@ func TestRouteValidate(t *testing.T) { Port: route.Port{Proxy: 80, Listening: 8080}, } err := r.Validate() - expect.NoError(t, err, "Validate should not return error for valid TCP route") - expect.NotNil(t, r.impl, "Impl should be initialized") + require.NoError(t, err, "Validate should not return error for valid TCP route") + require.NotNil(t, r.impl, "Impl should be initialized") }) t.Run("DockerContainer", func(t *testing.T) { @@ -106,8 +94,8 @@ func TestRouteValidate(t *testing.T) { }, } err := r.Validate() - expect.NoError(t, err, "Validate should not return error for valid docker container route") - expect.NotNil(t, r.ProxyURL, "ProxyURL should be set") + require.NoError(t, err, "Validate should not return error for valid docker container route") + require.NotNil(t, r.ProxyURL, "ProxyURL should be set") }) t.Run("InvalidScheme", func(t *testing.T) { @@ -117,7 +105,7 @@ func TestRouteValidate(t *testing.T) { Host: "example.com", Port: route.Port{Proxy: 80}, } - expect.Panics(t, func() { + require.Panics(t, func() { _ = r.Validate() }, "Validate should panic for invalid scheme") }) @@ -130,9 +118,9 @@ func TestRouteValidate(t *testing.T) { Port: route.Port{Proxy: 80}, } err := r.Validate() - expect.NoError(t, err) - expect.NotNil(t, r.ProxyURL) - expect.NotNil(t, r.HealthCheck) + require.NoError(t, err) + require.NotNil(t, r.ProxyURL) + require.NotNil(t, r.HealthCheck) }) } @@ -144,7 +132,7 @@ func TestPreferredPort(t *testing.T) { } port := preferredPort(ports) - expect.Equal(t, port, 3000) + require.Equal(t, 3000, port) } func TestDockerRouteDisallowAgent(t *testing.T) { @@ -164,8 +152,8 @@ func TestDockerRouteDisallowAgent(t *testing.T) { }, } err := r.Validate() - expect.HasError(t, err, "Validate should return error for docker route with agent") - expect.ErrorContains(t, err, "specifying agent is not allowed for docker container routes") + require.Error(t, err, "Validate should return error for docker route with agent") + require.ErrorContains(t, err, "specifying agent is not allowed for docker container routes") } func TestRouteAgent(t *testing.T) { @@ -177,8 +165,8 @@ func TestRouteAgent(t *testing.T) { Agent: "test-agent", } err := r.Validate() - expect.NoError(t, err, "Validate should not return error for valid route with agent") - expect.NotNil(t, r.GetAgent(), "GetAgent should return agent") + require.NoError(t, err, "Validate should not return error for valid route with agent") + require.NotNil(t, r.GetAgent(), "GetAgent should return agent") } func TestRouteApplyingHealthCheckDefaults(t *testing.T) { @@ -188,6 +176,106 @@ func TestRouteApplyingHealthCheckDefaults(t *testing.T) { Timeout: 10 * time.Second, }) - expect.Equal(t, hc.Interval, 15*time.Second) - expect.Equal(t, hc.Timeout, 10*time.Second) + require.Equal(t, 15*time.Second, hc.Interval) + require.Equal(t, 10*time.Second, hc.Timeout) +} + +func TestRouteBindField(t *testing.T) { + t.Run("TCPSchemeWithCustomBind", func(t *testing.T) { + r := &Route{ + Alias: "test-tcp", + Scheme: route.SchemeTCP, + Host: "192.168.1.100", + Port: route.Port{Proxy: 80, Listening: 8080}, + Bind: "192.168.1.1", + } + err := r.Validate() + require.NoError(t, err, "Validate should not return error for TCP route with custom bind") + require.NotNil(t, r.LisURL, "LisURL should be set") + require.Equal(t, "tcp4://192.168.1.1:8080", r.LisURL.String(), "LisURL should contain custom bind address") + }) + + t.Run("UDPSchemeWithCustomBind", func(t *testing.T) { + r := &Route{ + Alias: "test-udp", + Scheme: route.SchemeUDP, + Host: "10.0.0.1", + Port: route.Port{Proxy: 53, Listening: 53}, + Bind: "10.0.0.254", + } + err := r.Validate() + require.NoError(t, err, "Validate should not return error for UDP route with custom bind") + require.NotNil(t, r.LisURL, "LisURL should be set") + require.Equal(t, "udp4://10.0.0.254:53", r.LisURL.String(), "LisURL should contain custom bind address") + }) + + t.Run("HTTPSchemeWithoutBind", func(t *testing.T) { + r := &Route{ + Alias: "test-http", + Scheme: route.SchemeHTTP, + Host: "example.com", + Port: route.Port{Proxy: 80}, + } + err := r.Validate() + require.NoError(t, err, "Validate should not return error for HTTP route without bind") + require.NotNil(t, r.LisURL, "LisURL should be set") + require.Equal(t, "https://:0", r.LisURL.String(), "LisURL should contain bind address") + }) + + t.Run("HTTPSchemeWithBind", func(t *testing.T) { + r := &Route{ + Alias: "test-http", + Scheme: route.SchemeHTTP, + Host: "example.com", + Port: route.Port{Proxy: 80}, + Bind: "0.0.0.0", + } + err := r.Validate() + require.NoError(t, err, "Validate should not return error for HTTP route with bind") + require.NotNil(t, r.LisURL, "LisURL should be set") + require.Equal(t, "https://0.0.0.0:0", r.LisURL.String(), "LisURL should contain bind address") + }) + + t.Run("HTTPSchemeWithBindAndPort", func(t *testing.T) { + r := &Route{ + Alias: "test-http", + Scheme: route.SchemeHTTP, + Host: "example.com", + Port: route.Port{Listening: 8080, Proxy: 80}, + Bind: "0.0.0.0", + } + err := r.Validate() + require.NoError(t, err, "Validate should not return error for HTTP route with bind and port") + require.NotNil(t, r.LisURL, "LisURL should be set") + require.Equal(t, "https://0.0.0.0:8080", r.LisURL.String(), "LisURL should contain bind address and listening port") + }) + + t.Run("TCPSchemeDefaultsToZeroBind", func(t *testing.T) { + r := &Route{ + Alias: "test-default-bind", + Scheme: route.SchemeTCP, + Host: "example.com", + Port: route.Port{Proxy: 80, Listening: 8080}, + Bind: "", + } + err := r.Validate() + require.NoError(t, err, "Validate should not return error for TCP route with empty bind") + require.Equal(t, "0.0.0.0", r.Bind, "Bind should default to 0.0.0.0 for TCP scheme") + require.NotNil(t, r.LisURL, "LisURL should be set") + require.Equal(t, "tcp4://0.0.0.0:8080", r.LisURL.String(), "LisURL should use default bind address") + }) + + t.Run("FileServerSchemeWithBind", func(t *testing.T) { + r := &Route{ + Alias: "test-fileserver", + Scheme: route.SchemeFileServer, + Port: route.Port{Listening: 9000}, + Root: "/tmp", + Bind: "127.0.0.1", + } + err := r.Validate() + require.NoError(t, err, "Validate should not return error for fileserver route with bind") + require.NotNil(t, r.LisURL, "LisURL should be set") + require.Equal(t, "https://127.0.0.1:9000", r.LisURL.String(), "LisURL should contain bind address") + }) } diff --git a/internal/route/routes/README.md b/internal/route/routes/README.md deleted file mode 100644 index 16bf1a0a..00000000 --- a/internal/route/routes/README.md +++ /dev/null @@ -1,307 +0,0 @@ -# Route Registry - -Provides centralized route registry with O(1) lookups and route context management for HTTP handlers. - -## Overview - -The `internal/route/routes` package maintains the global route registry for GoDoxy. It provides thread-safe route lookups by alias, route iteration, and utilities for propagating route context through HTTP request handlers. - -### Primary Consumers - -- **HTTP handlers**: Lookup routes and extract request context -- **Route providers**: Register and unregister routes -- **Health system**: Query route health status -- **WebUI**: Display route information - -### Non-goals - -- Does not create or modify routes -- Does not handle route validation -- Does not implement routing logic (matching) - -### Stability - -Internal package with stable public API. - -## Public API - -### Route Pools - -```go -var ( - HTTP = pool.New[types.HTTPRoute]("http_routes") - Stream = pool.New[types.StreamRoute]("stream_routes") - Excluded = pool.New[types.Route]("excluded_routes") -) -``` - -Pool methods: - -- `Get(alias string) (T, bool)` - O(1) lookup -- `Add(r T)` - Register route -- `Del(r T)` - Unregister route -- `Size() int` - Route count -- `Clear()` - Remove all routes -- `Iter` - Channel-based iteration - -### Exported Functions - -```go -// Iterate over active routes (HTTP + Stream) -func IterActive(yield func(r types.Route) bool) - -// Iterate over all routes (HTTP + Stream + Excluded) -func IterAll(yield func(r types.Route) bool) - -// Get route count -func NumActiveRoutes() int -func NumAllRoutes() int - -// Clear all routes -func Clear() - -// Lookup functions -func Get(alias string) (types.Route, bool) -func GetHTTPRouteOrExact(alias, host string) (types.HTTPRoute, bool) -``` - -### Route Context - -```go -type RouteContext struct { - context.Context - Route types.HTTPRoute -} - -// Attach route to request context (uses unsafe pointer for performance) -func WithRouteContext(r *http.Request, route types.HTTPRoute) *http.Request - -// Extract route from request context -func TryGetRoute(r *http.Request) types.HTTPRoute -``` - -### Upstream Information - -```go -func TryGetUpstreamName(r *http.Request) string -func TryGetUpstreamScheme(r *http.Request) string -func TryGetUpstreamHost(r *http.Request) string -func TryGetUpstreamPort(r *http.Request) string -func TryGetUpstreamHostPort(r *http.Request) string -func TryGetUpstreamAddr(r *http.Request) string -func TryGetUpstreamURL(r *http.Request) string -``` - -### Health Information - -```go -type HealthInfo struct { - HealthInfoWithoutDetail - Detail string -} - -type HealthInfoWithoutDetail struct { - Status types.HealthStatus - Uptime time.Duration - Latency time.Duration -} - -func GetHealthInfo() map[string]HealthInfo -func GetHealthInfoWithoutDetail() map[string]HealthInfoWithoutDetail -func GetHealthInfoSimple() map[string]types.HealthStatus -``` - -### Provider Grouping - -```go -func ByProvider() map[string][]types.Route -``` - -## Proxmox Integration - -Routes can be automatically linked to Proxmox nodes or LXC containers through reverse lookup during validation. - -### Node-Level Routes - -Routes can be linked to a Proxmox node directly (VMID = 0) when the route's hostname, IP, or alias matches a node name or IP: - -```go -// Route linked to Proxmox node (no specific VM) -route.Proxmox = &proxmox.NodeConfig{ - Node: "pve-node-01", - VMID: 0, // node-level, no container - VMName: "", -} -``` - -### Container-Level Routes - -Routes are linked to LXC containers when they match a VM resource by hostname, IP, or alias: - -```go -// Route linked to LXC container -route.Proxmox = &proxmox.NodeConfig{ - Node: "pve-node-01", - VMID: 100, - VMName: "my-container", -} -``` - -### Lookup Priority - -1. **Node match** - If hostname, IP, or alias matches a Proxmox node -2. **VM match** - If hostname, IP, or alias matches a VM resource - -Node-level routes skip container control logic (start/check IPs) and can be used to proxy node services directly. - -## Architecture - -### Core Components - -```mermaid -classDiagram - class HTTP - class Stream - class Excluded - class RouteContext - - HTTP : +Get(alias) T - HTTP : +Add(r) - HTTP : +Del(r) - HTTP : +Size() int - HTTP : +Iter chan - - Stream : +Get(alias) T - Stream : +Add(r) - Stream : +Del(r) - - Excluded : +Get(alias) T - Excluded : +Add(r) - Excluded : +Del(r) -``` - -### Route Lookup Flow - -```mermaid -flowchart TD - A[Lookup Request] --> B{HTTP Pool} - B -->|Found| C[Return Route] - B -->|Not Found| D{Stream Pool} - D -->|Found| C - D -->|Not Found| E[Return nil] -``` - -### Context Propagation - -```mermaid -sequenceDiagram - participant H as HTTP Handler - participant R as Registry - participant C as RouteContext - - H->>R: WithRouteContext(req, route) - R->>C: Attach route via unsafe pointer - C-->>H: Modified request - - H->>R: TryGetRoute(req) - R->>C: Extract route from context - C-->>R: Route - R-->>H: Route -``` - -## Dependency and Integration Map - -| Dependency | Purpose | -| -------------------------------- | ---------------------------------- | -| `internal/types` | Route and health type definitions | -| `internal/proxmox` | Proxmox node/container integration | -| `github.com/yusing/goutils/pool` | Thread-safe pool implementation | - -## Observability - -### Logs - -Registry operations logged at DEBUG level: - -- Route add/remove -- Pool iteration -- Context operations - -### Performance - -- `WithRouteContext` uses `unsafe.Pointer` to avoid request cloning -- Route lookups are O(1) using internal maps -- Iteration uses channels for memory efficiency - -## Security Considerations - -- Route context propagation is internal to the process -- No sensitive data exposed in context keys -- Routes are validated before registration - -## Failure Modes and Recovery - -| Failure | Behavior | Recovery | -| ---------------------------------------- | ------------------------------ | -------------------- | -| Route not found | Returns (nil, false) | Verify route alias | -| Context extraction on non-route request | Returns nil | Check request origin | -| Concurrent modification during iteration | Handled by pool implementation | N/A | - -## Usage Examples - -### Basic Route Lookup - -```go -route, ok := routes.Get("myapp") -if !ok { - return fmt.Errorf("route not found") -} -``` - -### Iterating Over All Routes - -```go -for r := range routes.IterActive { - log.Printf("Route: %s", r.Name()) -} -``` - -### Getting Health Status - -```go -healthMap := routes.GetHealthInfo() -for name, health := range healthMap { - log.Printf("Route %s: %s (uptime: %v)", name, health.Status, health.Uptime) -} -``` - -### Using Route Context in Handler - -```go -func MyHandler(w http.ResponseWriter, r *http.Request) { - route := routes.TryGetRoute(r) - if route == nil { - http.Error(w, "Route not found", http.StatusNotFound) - return - } - - upstreamHost := routes.TryGetUpstreamHost(r) - log.Printf("Proxying to: %s", upstreamHost) -} -``` - -### Grouping Routes by Provider - -```go -byProvider := routes.ByProvider() -for providerName, routeList := range byProvider { - log.Printf("Provider %s: %d routes", providerName, len(routeList)) -} -``` - -## Testing Notes - -- Unit tests for pool thread safety -- Context propagation tests -- Health info aggregation tests -- Provider grouping tests diff --git a/internal/route/routes/query.go b/internal/route/routes/query.go deleted file mode 100644 index 50849c9a..00000000 --- a/internal/route/routes/query.go +++ /dev/null @@ -1,103 +0,0 @@ -package routes - -import ( - "time" - - "github.com/yusing/godoxy/internal/types" -) - -type HealthInfo struct { - HealthInfoWithoutDetail - Detail string `json:"detail"` -} // @name HealthInfo - -type HealthInfoWithoutDetail struct { - Status types.HealthStatus `json:"status" swaggertype:"string" enums:"healthy,unhealthy,napping,starting,error,unknown"` - Uptime time.Duration `json:"uptime" swaggertype:"number"` // uptime in milliseconds - Latency time.Duration `json:"latency" swaggertype:"number"` // latency in microseconds -} // @name HealthInfoWithoutDetail - -type HealthMap = map[string]types.HealthStatusString // @name HealthMap - -// GetHealthInfo returns a map of route name to health info. -// -// The health info is for all routes, including excluded routes. -func GetHealthInfo() map[string]HealthInfo { - healthMap := make(map[string]HealthInfo, NumAllRoutes()) - for r := range IterAll { - healthMap[r.Name()] = getHealthInfo(r) - } - return healthMap -} - -// GetHealthInfoWithoutDetail returns a map of route name to health info without detail. -// -// The health info is for all routes, including excluded routes. -func GetHealthInfoWithoutDetail() map[string]HealthInfoWithoutDetail { - healthMap := make(map[string]HealthInfoWithoutDetail, NumAllRoutes()) - for r := range IterAll { - healthMap[r.Name()] = getHealthInfoWithoutDetail(r) - } - return healthMap -} - -func GetHealthInfoSimple() map[string]types.HealthStatus { - healthMap := make(map[string]types.HealthStatus, NumAllRoutes()) - for r := range IterAll { - healthMap[r.Name()] = getHealthInfoSimple(r) - } - return healthMap -} - -func getHealthInfo(r types.Route) HealthInfo { - mon := r.HealthMonitor() - if mon == nil { - return HealthInfo{ - HealthInfoWithoutDetail: HealthInfoWithoutDetail{ - Status: types.StatusUnknown, - }, - Detail: "n/a", - } - } - return HealthInfo{ - HealthInfoWithoutDetail: HealthInfoWithoutDetail{ - Status: mon.Status(), - Uptime: mon.Uptime(), - Latency: mon.Latency(), - }, - Detail: mon.Detail(), - } -} - -func getHealthInfoWithoutDetail(r types.Route) HealthInfoWithoutDetail { - mon := r.HealthMonitor() - if mon == nil { - return HealthInfoWithoutDetail{ - Status: types.StatusUnknown, - } - } - return HealthInfoWithoutDetail{ - Status: mon.Status(), - Uptime: mon.Uptime(), - Latency: mon.Latency(), - } -} - -func getHealthInfoSimple(r types.Route) types.HealthStatus { - mon := r.HealthMonitor() - if mon == nil { - return types.StatusUnknown - } - return mon.Status() -} - -// ByProvider returns a map of provider name to routes. -// -// The routes are all routes, including excluded routes. -func ByProvider() map[string][]types.Route { - rts := make(map[string][]types.Route) - for r := range IterAll { - rts[r.ProviderName()] = append(rts[r.ProviderName()], r) - } - return rts -} diff --git a/internal/route/routes/routes.go b/internal/route/routes/routes.go deleted file mode 100644 index fc8d39d9..00000000 --- a/internal/route/routes/routes.go +++ /dev/null @@ -1,91 +0,0 @@ -package routes - -import ( - "github.com/yusing/godoxy/internal/types" - "github.com/yusing/goutils/pool" -) - -var ( - HTTP = pool.New[types.HTTPRoute]("http_routes") - Stream = pool.New[types.StreamRoute]("stream_routes") - - Excluded = pool.New[types.Route]("excluded_routes") -) - -func IterActive(yield func(r types.Route) bool) { - for _, r := range HTTP.Iter { - if !yield(r) { - break - } - } - for _, r := range Stream.Iter { - if !yield(r) { - break - } - } -} - -func IterAll(yield func(r types.Route) bool) { - for _, r := range HTTP.Iter { - if !yield(r) { - break - } - } - for _, r := range Stream.Iter { - if !yield(r) { - break - } - } - for _, r := range Excluded.Iter { - if !yield(r) { - break - } - } -} - -func NumActiveRoutes() int { - return HTTP.Size() + Stream.Size() -} - -func NumAllRoutes() int { - return HTTP.Size() + Stream.Size() + Excluded.Size() -} - -func Clear() { - HTTP.Clear() - Stream.Clear() - Excluded.Clear() -} - -func GetHTTPRouteOrExact(alias, host string) (types.HTTPRoute, bool) { - r, ok := HTTP.Get(alias) - if ok { - return r, true - } - // try find with exact match - return HTTP.Get(host) -} - -// Get returns the route with the given alias. -// -// It does not return excluded routes. -func Get(alias string) (types.Route, bool) { - if r, ok := HTTP.Get(alias); ok { - return r, true - } - if r, ok := Stream.Get(alias); ok { - return r, true - } - return nil, false -} - -// GetIncludeExcluded returns the route with the given alias, including excluded routes. -func GetIncludeExcluded(alias string) (types.Route, bool) { - if r, ok := HTTP.Get(alias); ok { - return r, true - } - if r, ok := Stream.Get(alias); ok { - return r, true - } - return Excluded.Get(alias) -} diff --git a/internal/route/rules/do.go b/internal/route/rules/do.go index f0343d0c..11e98412 100644 --- a/internal/route/rules/do.go +++ b/internal/route/rules/do.go @@ -10,6 +10,7 @@ import ( "strings" "github.com/rs/zerolog" + entrypoint "github.com/yusing/godoxy/internal/entrypoint/types" "github.com/yusing/godoxy/internal/logging" gphttp "github.com/yusing/godoxy/internal/net/gphttp" nettypes "github.com/yusing/godoxy/internal/net/types" @@ -71,10 +72,11 @@ var commands = map[string]struct { description: makeLines("Require HTTP authentication for incoming requests"), args: map[string]string{}, }, - validate: func(args []string) (any, gperr.Error) { + validate: func(args []string) (any, error) { if len(args) != 0 { return nil, ErrExpectNoArg } + //nolint:nilnil return nil, nil }, build: func(args any) CommandHandler { @@ -102,17 +104,17 @@ var commands = map[string]struct { "to": "the path to rewrite to, must start with /", }, }, - validate: func(args []string) (any, gperr.Error) { + validate: func(args []string) (any, error) { if len(args) != 2 { return nil, ErrExpectTwoArgs } path1, err1 := validateURLPath(args[:1]) path2, err2 := validateURLPath(args[1:]) if err1 != nil { - err1 = gperr.Errorf("from: %w", err1) + err1 = gperr.PrependSubject(err1, "from") } if err2 != nil { - err2 = gperr.Errorf("to: %w", err2) + err2 = gperr.PrependSubject(err2, "to") } if err1 != nil || err2 != nil { return nil, gperr.Join(err1, err2) @@ -188,7 +190,7 @@ var commands = map[string]struct { "route": "the route to route to", }, }, - validate: func(args []string) (any, gperr.Error) { + validate: func(args []string) (any, error) { if len(args) != 1 { return nil, ErrExpectOneArg } @@ -197,9 +199,10 @@ var commands = map[string]struct { build: func(args any) CommandHandler { route := args.(string) return TerminatingCommand(func(w http.ResponseWriter, req *http.Request) error { - r, ok := routes.HTTP.Get(route) + ep := entrypoint.FromCtx(req.Context()) + r, ok := ep.HTTPRoutes().Get(route) if !ok { - excluded, has := routes.Excluded.Get(route) + excluded, has := ep.ExcludedRoutes().Get(route) if has { r, ok = excluded.(types.HTTPRoute) } @@ -225,7 +228,7 @@ var commands = map[string]struct { "text": "the error message to return", }, }, - validate: func(args []string) (any, gperr.Error) { + validate: func(args []string) (any, error) { if len(args) != 2 { return nil, ErrExpectTwoArgs } @@ -265,7 +268,7 @@ var commands = map[string]struct { "realm": "the authentication realm", }, }, - validate: func(args []string) (any, gperr.Error) { + validate: func(args []string) (any, error) { if len(args) == 1 { return args[0], nil } @@ -327,12 +330,12 @@ var commands = map[string]struct { helpExample(CommandSet, "header", "User-Agent", "godoxy"), ), args: map[string]string{ - "target": fmt.Sprintf("the target to set, can be %s", strings.Join(AllFields, ", ")), + "target": "the target to set, can be " + strings.Join(AllFields, ", "), "field": "the field to set", "value": "the value to set", }, }, - validate: func(args []string) (any, gperr.Error) { + validate: func(args []string) (any, error) { return validateModField(ModFieldSet, args) }, build: func(args any) CommandHandler { @@ -347,12 +350,12 @@ var commands = map[string]struct { helpExample(CommandAdd, "header", "X-Foo", "bar"), ), args: map[string]string{ - "target": fmt.Sprintf("the target to add, can be %s", strings.Join(AllFields, ", ")), + "target": "the target to add, can be " + strings.Join(AllFields, ", "), "field": "the field to add", "value": "the value to add", }, }, - validate: func(args []string) (any, gperr.Error) { + validate: func(args []string) (any, error) { return validateModField(ModFieldAdd, args) }, build: func(args any) CommandHandler { @@ -367,11 +370,11 @@ var commands = map[string]struct { helpExample(CommandRemove, "header", "User-Agent"), ), args: map[string]string{ - "target": fmt.Sprintf("the target to remove, can be %s", strings.Join(AllFields, ", ")), + "target": "the target to remove, can be " + strings.Join(AllFields, ", "), "field": "the field to remove", }, }, - validate: func(args []string) (any, gperr.Error) { + validate: func(args []string) (any, error) { return validateModField(ModFieldRemove, args) }, build: func(args any) CommandHandler { @@ -396,7 +399,7 @@ var commands = map[string]struct { "template": "the template to log", }, }, - validate: func(args []string) (any, gperr.Error) { + validate: func(args []string) (any, error) { if len(args) != 3 { return nil, ErrExpectThreeArgs } @@ -453,7 +456,7 @@ var commands = map[string]struct { "body": "the body of the notification", }, }, - validate: func(args []string) (any, gperr.Error) { + validate: func(args []string) (any, error) { if len(args) != 4 { return nil, ErrExpectFourArgs } @@ -509,8 +512,10 @@ var commands = map[string]struct { }, } -type onLogArgs = Tuple3[zerolog.Level, io.WriteCloser, templateString] -type onNotifyArgs = Tuple4[zerolog.Level, string, templateString, templateString] +type ( + onLogArgs = Tuple3[zerolog.Level, io.WriteCloser, templateString] + onNotifyArgs = Tuple4[zerolog.Level, string, templateString, templateString] +) // Parse implements strutils.Parser. func (cmd *Command) Parse(v string) error { @@ -541,7 +546,7 @@ func (cmd *Command) Parse(v string) error { validArgs, err := builder.validate(args) if err != nil { // Only attach help for the directive that failed, avoid bringing in unrelated KV errors - return err.Subject(directive).With(builder.help.Error()) + return gperr.PrependSubject(err, directive).With(builder.help.Error()) } handler := builder.build(validArgs) diff --git a/internal/route/rules/do_log_test.go b/internal/route/rules/do_log_test.go index e7741ab1..79b39f87 100644 --- a/internal/route/rules/do_log_test.go +++ b/internal/route/rules/do_log_test.go @@ -12,7 +12,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/yusing/godoxy/internal/serialization" - gperr "github.com/yusing/goutils/errs" ) // mockUpstream creates a simple upstream handler for testing @@ -32,7 +31,7 @@ func mockUpstreamWithHeaders(status int, body string, headers http.Header) http. } } -func parseRules(data string, target *Rules) gperr.Error { +func parseRules(data string, target *Rules) error { _, err := serialization.ConvertString(data, reflect.ValueOf(target)) return err } @@ -54,7 +53,7 @@ func TestLogCommand_TemporaryFile(t *testing.T) { handler := rules.BuildHandler(upstream) - req := httptest.NewRequest("POST", "/api/users", nil) + req := httptest.NewRequest(http.MethodPost, "/api/users", nil) req.Header.Set("User-Agent", "test-agent") w := httptest.NewRecorder() @@ -71,7 +70,7 @@ func TestLogCommand_TemporaryFile(t *testing.T) { } func TestLogCommand_StdoutAndStderr(t *testing.T) { - upstream := mockUpstream(200, "success") + upstream := mockUpstream(http.StatusOK, "success") var rules Rules err := parseRules(` @@ -86,7 +85,7 @@ func TestLogCommand_StdoutAndStderr(t *testing.T) { handler := rules.BuildHandler(upstream) - req := httptest.NewRequest("GET", "/test", nil) + req := httptest.NewRequest(http.MethodGet, "/test", nil) w := httptest.NewRecorder() handler.ServeHTTP(w, req) @@ -97,7 +96,7 @@ func TestLogCommand_StdoutAndStderr(t *testing.T) { } func TestLogCommand_DifferentLogLevels(t *testing.T) { - upstream := mockUpstream(404, "not found") + upstream := mockUpstream(http.StatusNotFound, "not found") infoFile := TestRandomFileName() warnFile := TestRandomFileName() @@ -141,7 +140,7 @@ func TestLogCommand_TemplateVariables(t *testing.T) { upstream := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-Custom-Header", "custom-value") w.Header().Set("Content-Length", "42") - w.WriteHeader(201) + w.WriteHeader(http.StatusCreated) w.Write([]byte("created")) }) @@ -177,13 +176,13 @@ func TestLogCommand_ConditionalLogging(t *testing.T) { upstream := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/error": - w.WriteHeader(500) + w.WriteHeader(http.StatusInternalServerError) w.Write([]byte("internal server error")) case "/notfound": - w.WriteHeader(404) + w.WriteHeader(http.StatusNotFound) w.Write([]byte("not found")) default: - w.WriteHeader(200) + w.WriteHeader(http.StatusOK) w.Write([]byte("success")) } }) @@ -207,22 +206,22 @@ func TestLogCommand_ConditionalLogging(t *testing.T) { handler := rules.BuildHandler(upstream) // Test success request - req1 := httptest.NewRequest("GET", "/success", nil) + req1 := httptest.NewRequest(http.MethodGet, "/success", nil) w1 := httptest.NewRecorder() handler.ServeHTTP(w1, req1) - assert.Equal(t, 200, w1.Code) + assert.Equal(t, http.StatusOK, w1.Code) // Test not found request - req2 := httptest.NewRequest("GET", "/notfound", nil) + req2 := httptest.NewRequest(http.MethodGet, "/notfound", nil) w2 := httptest.NewRecorder() handler.ServeHTTP(w2, req2) - assert.Equal(t, 404, w2.Code) + assert.Equal(t, http.StatusNotFound, w2.Code) // Test server error request - req3 := httptest.NewRequest("POST", "/error", nil) + req3 := httptest.NewRequest(http.MethodPost, "/error", nil) w3 := httptest.NewRecorder() handler.ServeHTTP(w3, req3) - assert.Equal(t, 500, w3.Code) + assert.Equal(t, http.StatusInternalServerError, w3.Code) // Verify success log successContent := TestFileContent(successFile) @@ -239,7 +238,7 @@ func TestLogCommand_ConditionalLogging(t *testing.T) { } func TestLogCommand_MultipleLogEntries(t *testing.T) { - upstream := mockUpstream(200, "response") + upstream := mockUpstream(http.StatusOK, "response") tempFile := TestRandomFileName() @@ -267,7 +266,7 @@ func TestLogCommand_MultipleLogEntries(t *testing.T) { req := httptest.NewRequest(reqInfo.method, reqInfo.path, nil) w := httptest.NewRecorder() handler.ServeHTTP(w, req) - assert.Equal(t, 200, w.Code) + assert.Equal(t, http.StatusOK, w.Code) } // Verify all requests were logged diff --git a/internal/route/rules/do_set.go b/internal/route/rules/do_set.go index 97e7a8ef..025f9f1c 100644 --- a/internal/route/rules/do_set.go +++ b/internal/route/rules/do_set.go @@ -6,7 +6,6 @@ import ( "net/url" "strconv" - gperr "github.com/yusing/goutils/errs" httputils "github.com/yusing/goutils/http" ioutils "github.com/yusing/goutils/io" ) @@ -228,7 +227,7 @@ var modFields = map[string]struct { "template": "the body template", }, }, - validate: func(args []string) (any, gperr.Error) { + validate: func(args []string) (any, error) { if len(args) != 1 { return nil, ErrExpectOneArg } @@ -273,7 +272,7 @@ var modFields = map[string]struct { "template": "the response body template", }, }, - validate: func(args []string) (any, gperr.Error) { + validate: func(args []string) (any, error) { if len(args) != 1 { return nil, ErrExpectOneArg } @@ -301,7 +300,7 @@ var modFields = map[string]struct { "code": "the status code", }, }, - validate: func(args []string) (any, gperr.Error) { + validate: func(args []string) (any, error) { if len(args) != 1 { return nil, ErrExpectOneArg } diff --git a/internal/route/rules/do_set_test.go b/internal/route/rules/do_set_test.go index 4d77ccd1..21b20108 100644 --- a/internal/route/rules/do_set_test.go +++ b/internal/route/rules/do_set_test.go @@ -67,7 +67,7 @@ func TestFieldHandler_Header(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - req := httptest.NewRequest("GET", "/", nil) + req := httptest.NewRequest(http.MethodGet, "/", nil) tt.setup(req) w := httptest.NewRecorder() @@ -126,8 +126,8 @@ func TestFieldHandler_ResponseHeader(t *testing.T) { verify: func(w *httptest.ResponseRecorder) { values := w.Header()["X-Response-Test"] require.Len(t, values, 2) - assert.Equal(t, values[0], "existing-value") - assert.Equal(t, values[1], "additional-value") + assert.Equal(t, "existing-value", values[0]) + assert.Equal(t, "additional-value", values[1]) }, }, { @@ -143,7 +143,7 @@ func TestFieldHandler_ResponseHeader(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - req := httptest.NewRequest("GET", "/", nil) + req := httptest.NewRequest(http.MethodGet, "/", nil) w := httptest.NewRecorder() if tt.setup != nil { tt.setup(w) @@ -232,7 +232,7 @@ func TestFieldHandler_Query(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - req := httptest.NewRequest("GET", "/", nil) + req := httptest.NewRequest(http.MethodGet, "/", nil) tt.setup(req) w := httptest.NewRecorder() @@ -330,7 +330,7 @@ func TestFieldHandler_Cookie(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - req := httptest.NewRequest("GET", "/", nil) + req := httptest.NewRequest(http.MethodGet, "/", nil) tt.setup(req) w := httptest.NewRecorder() @@ -396,7 +396,7 @@ func TestFieldHandler_Body(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - req := httptest.NewRequest("GET", "/", nil) + req := httptest.NewRequest(http.MethodGet, "/", nil) tt.setup(req) w := httptest.NewRecorder() @@ -440,7 +440,7 @@ func TestFieldHandler_ResponseBody(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - req := httptest.NewRequest("GET", "/", nil) + req := httptest.NewRequest(http.MethodGet, "/", nil) tt.setup(req) w := httptest.NewRecorder() @@ -494,7 +494,7 @@ func TestFieldHandler_StatusCode(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - req := httptest.NewRequest("GET", "/", nil) + req := httptest.NewRequest(http.MethodGet, "/", nil) w := httptest.NewRecorder() rm := httputils.NewResponseModifier(w) var cmd Command diff --git a/internal/route/rules/error_format_test.go b/internal/route/rules/error_format_test.go index 9a2846b6..9d086880 100644 --- a/internal/route/rules/error_format_test.go +++ b/internal/route/rules/error_format_test.go @@ -3,7 +3,7 @@ package rules import ( "testing" - gperr "github.com/yusing/goutils/errs" + "github.com/rs/zerolog/log" ) func TestErrorFormat(t *testing.T) { @@ -19,5 +19,5 @@ func TestErrorFormat(t *testing.T) { do: set invalid_command - do: set resp_body "{{ .Request.Method {{ .Request.URL.Path }}" `, &rules) - gperr.LogError("error", err) + log.Err(err).Msg("error") } diff --git a/internal/route/rules/help.go b/internal/route/rules/help.go index de1bf00c..52b84786 100644 --- a/internal/route/rules/help.go +++ b/internal/route/rules/help.go @@ -131,7 +131,7 @@ Generate help string as error, e.g. from: the path to rewrite, must start with / to: the path to rewrite to, must start with / */ -func (h *Help) Error() gperr.Error { +func (h *Help) Error() error { var lines gperr.MultilineError lines.Adds(ansi.WithANSI(h.command, ansi.HighlightGreen)) diff --git a/internal/route/rules/http_flow_test.go b/internal/route/rules/http_flow_test.go index b8321e85..ae40edb4 100644 --- a/internal/route/rules/http_flow_test.go +++ b/internal/route/rules/http_flow_test.go @@ -17,16 +17,14 @@ import ( "github.com/yusing/godoxy/internal/route" "github.com/yusing/godoxy/internal/route/routes" "github.com/yusing/godoxy/internal/serialization" - gperr "github.com/yusing/goutils/errs" "golang.org/x/crypto/bcrypt" . "github.com/yusing/godoxy/internal/route/rules" ) // mockUpstream creates a simple upstream handler for testing -func mockUpstream(status int, body string) http.HandlerFunc { +func mockUpstream(body string) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(status) w.Write([]byte(body)) } } @@ -44,7 +42,7 @@ func mockRoute(alias string) *route.FileServer { return &route.FileServer{Route: &route.Route{Alias: alias}} } -func parseRules(data string, target *Rules) gperr.Error { +func parseRules(data string, target *Rules) error { _, err := serialization.ConvertString(strings.TrimSpace(data), reflect.ValueOf(target)) return err } @@ -52,7 +50,7 @@ func parseRules(data string, target *Rules) gperr.Error { func TestHTTPFlow_BasicPreRules(t *testing.T) { upstream := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-Custom-Header", r.Header.Get("X-Custom-Header")) - w.WriteHeader(200) + w.WriteHeader(http.StatusOK) w.Write([]byte("upstream response")) }) @@ -66,18 +64,18 @@ func TestHTTPFlow_BasicPreRules(t *testing.T) { handler := rules.BuildHandler(upstream) - req := httptest.NewRequest("GET", "/", nil) + req := httptest.NewRequest(http.MethodGet, "/", nil) w := httptest.NewRecorder() handler.ServeHTTP(w, req) - assert.Equal(t, 200, w.Code) + assert.Equal(t, http.StatusOK, w.Code) assert.Equal(t, "upstream response", w.Body.String()) assert.Equal(t, "test-value", w.Header().Get("X-Custom-Header")) } func TestHTTPFlow_BypassRule(t *testing.T) { - upstream := mockUpstream(200, "upstream response") + upstream := mockUpstream("upstream response") var rules Rules err := parseRules(` @@ -92,17 +90,17 @@ func TestHTTPFlow_BypassRule(t *testing.T) { handler := rules.BuildHandler(upstream) - req := httptest.NewRequest("GET", "/bypass", nil) + req := httptest.NewRequest(http.MethodGet, "/bypass", nil) w := httptest.NewRecorder() handler.ServeHTTP(w, req) - assert.Equal(t, 200, w.Code) + assert.Equal(t, http.StatusOK, w.Code) assert.Equal(t, "upstream response", w.Body.String()) } func TestHTTPFlow_TerminatingCommand(t *testing.T) { - upstream := mockUpstream(200, "should not be called") + upstream := mockUpstream("should not be called") var rules Rules err := parseRules(` @@ -117,18 +115,18 @@ func TestHTTPFlow_TerminatingCommand(t *testing.T) { handler := rules.BuildHandler(upstream) - req := httptest.NewRequest("GET", "/error", nil) + req := httptest.NewRequest(http.MethodGet, "/error", nil) w := httptest.NewRecorder() handler.ServeHTTP(w, req) - assert.Equal(t, 403, w.Code) + assert.Equal(t, http.StatusForbidden, w.Code) assert.Equal(t, "Forbidden\n", w.Body.String()) assert.Empty(t, w.Header().Get("X-Header")) } func TestHTTPFlow_RedirectFlow(t *testing.T) { - upstream := mockUpstream(200, "should not be called") + upstream := mockUpstream("should not be called") var rules Rules err := parseRules(` @@ -140,18 +138,18 @@ func TestHTTPFlow_RedirectFlow(t *testing.T) { handler := rules.BuildHandler(upstream) - req := httptest.NewRequest("GET", "/old-path", nil) + req := httptest.NewRequest(http.MethodGet, "/old-path", nil) w := httptest.NewRecorder() handler.ServeHTTP(w, req) - assert.Equal(t, 307, w.Code) // TemporaryRedirect + assert.Equal(t, http.StatusTemporaryRedirect, w.Code) // TemporaryRedirect assert.Equal(t, "/new-path", w.Header().Get("Location")) } func TestHTTPFlow_RewriteFlow(t *testing.T) { upstream := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(200) + w.WriteHeader(http.StatusOK) w.Write([]byte("path: " + r.URL.Path)) }) @@ -165,18 +163,18 @@ func TestHTTPFlow_RewriteFlow(t *testing.T) { handler := rules.BuildHandler(upstream) - req := httptest.NewRequest("GET", "/api/users", nil) + req := httptest.NewRequest(http.MethodGet, "/api/users", nil) w := httptest.NewRecorder() handler.ServeHTTP(w, req) - assert.Equal(t, 200, w.Code) + assert.Equal(t, http.StatusOK, w.Code) assert.Equal(t, "path: /v1/users", w.Body.String()) } func TestHTTPFlow_MultiplePreRules(t *testing.T) { upstream := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(200) + w.WriteHeader(http.StatusOK) w.Write([]byte("upstream: " + r.Header.Get("X-Request-Id"))) }) @@ -193,18 +191,18 @@ func TestHTTPFlow_MultiplePreRules(t *testing.T) { handler := rules.BuildHandler(upstream) - req := httptest.NewRequest("GET", "/", nil) + req := httptest.NewRequest(http.MethodGet, "/", nil) w := httptest.NewRecorder() handler.ServeHTTP(w, req) - assert.Equal(t, 200, w.Code) + assert.Equal(t, http.StatusOK, w.Code) assert.Equal(t, "upstream: req-123", w.Body.String()) assert.Equal(t, "token-456", req.Header.Get("X-Auth-Token")) } func TestHTTPFlow_PostResponseRule(t *testing.T) { - upstream := mockUpstreamWithHeaders(200, "success", http.Header{ + upstream := mockUpstreamWithHeaders(http.StatusOK, "success", http.Header{ "X-Upstream": []string{"upstream-value"}, }) @@ -220,12 +218,12 @@ func TestHTTPFlow_PostResponseRule(t *testing.T) { handler := rules.BuildHandler(upstream) - req := httptest.NewRequest("GET", "/test", nil) + req := httptest.NewRequest(http.MethodGet, "/test", nil) w := httptest.NewRecorder() handler.ServeHTTP(w, req) - assert.Equal(t, 200, w.Code) + assert.Equal(t, http.StatusOK, w.Code) assert.Equal(t, "success", w.Body.String()) assert.Equal(t, "upstream-value", w.Header().Get("X-Upstream")) @@ -238,10 +236,10 @@ func TestHTTPFlow_PostResponseRule(t *testing.T) { func TestHTTPFlow_ResponseRuleWithStatusCondition(t *testing.T) { upstream := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/success" { - w.WriteHeader(200) + w.WriteHeader(http.StatusOK) w.Write([]byte("success")) } else { - w.WriteHeader(404) + w.WriteHeader(http.StatusNotFound) w.Write([]byte("not found")) } }) @@ -261,18 +259,18 @@ func TestHTTPFlow_ResponseRuleWithStatusCondition(t *testing.T) { handler := rules.BuildHandler(upstream) // Test successful request (should not log) - req1 := httptest.NewRequest("GET", "/success", nil) + req1 := httptest.NewRequest(http.MethodGet, "/success", nil) w1 := httptest.NewRecorder() handler.ServeHTTP(w1, req1) - assert.Equal(t, 200, w1.Code) + assert.Equal(t, http.StatusOK, w1.Code) // Test error request (should log) - req2 := httptest.NewRequest("GET", "/notfound", nil) + req2 := httptest.NewRequest(http.MethodGet, "/notfound", nil) w2 := httptest.NewRecorder() handler.ServeHTTP(w2, req2) - assert.Equal(t, 404, w2.Code) + assert.Equal(t, http.StatusNotFound, w2.Code) // Check log file content := TestFileContent(tempFile) @@ -284,7 +282,7 @@ func TestHTTPFlow_ResponseRuleWithStatusCondition(t *testing.T) { func TestHTTPFlow_ConditionalRules(t *testing.T) { upstream := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(200) + w.WriteHeader(http.StatusOK) w.Write([]byte("hello " + r.Header.Get("X-Username"))) }) @@ -305,19 +303,19 @@ func TestHTTPFlow_ConditionalRules(t *testing.T) { handler := rules.BuildHandler(upstream) // Test with Authorization header - req1 := httptest.NewRequest("GET", "/", nil) + req1 := httptest.NewRequest(http.MethodGet, "/", nil) req1.Header.Set("Authorization", "Bearer token") w1 := httptest.NewRecorder() handler.ServeHTTP(w1, req1) - assert.Equal(t, 200, w1.Code) + assert.Equal(t, http.StatusOK, w1.Code) assert.Equal(t, "hello authenticated-user", w1.Body.String()) assert.Equal(t, "authenticated-user", w1.Header().Get("X-Username")) // Test without Authorization header - req2 := httptest.NewRequest("GET", "/", nil) + req2 := httptest.NewRequest(http.MethodGet, "/", nil) w2 := httptest.NewRecorder() handler.ServeHTTP(w2, req2) - assert.Equal(t, 200, w2.Code) + assert.Equal(t, http.StatusOK, w2.Code) assert.Equal(t, "hello anonymous", w2.Body.String()) assert.Equal(t, "anonymous", w2.Header().Get("X-Username")) } @@ -327,13 +325,13 @@ func TestHTTPFlow_ComplexFlowWithPreAndPostRules(t *testing.T) { // Simulate different responses based on path if r.URL.Path == "/protected" { if r.Header.Get("X-Auth") != "valid" { - w.WriteHeader(401) + w.WriteHeader(http.StatusUnauthorized) w.Write([]byte("unauthorized")) return } } w.Header().Set("X-Response-Time", "100ms") - w.WriteHeader(200) + w.WriteHeader(http.StatusOK) w.Write([]byte("success")) }) @@ -361,32 +359,32 @@ func TestHTTPFlow_ComplexFlowWithPreAndPostRules(t *testing.T) { handler := rules.BuildHandler(upstream) // Test successful request - req1 := httptest.NewRequest("GET", "/public", nil) + req1 := httptest.NewRequest(http.MethodGet, "/public", nil) w1 := httptest.NewRecorder() handler.ServeHTTP(w1, req1) - assert.Equal(t, 200, w1.Code) + assert.Equal(t, http.StatusOK, w1.Code) assert.Equal(t, "success", w1.Body.String()) assert.Equal(t, "random_uuid", w1.Header().Get("X-Correlation-Id")) assert.Equal(t, "100ms", w1.Header().Get("X-Response-Time")) // Test unauthorized protected request - req2 := httptest.NewRequest("GET", "/protected", nil) + req2 := httptest.NewRequest(http.MethodGet, "/protected", nil) w2 := httptest.NewRecorder() handler.ServeHTTP(w2, req2) - assert.Equal(t, 401, w2.Code) - assert.Equal(t, w2.Body.String(), "Unauthorized\n") + assert.Equal(t, http.StatusUnauthorized, w2.Code) + assert.Equal(t, "Unauthorized\n", w2.Body.String()) // Test authorized protected request - req3 := httptest.NewRequest("GET", "/protected", nil) + req3 := httptest.NewRequest(http.MethodGet, "/protected", nil) req3.SetBasicAuth("user", "pass") w3 := httptest.NewRecorder() handler.ServeHTTP(w3, req3) // This should fail because our simple upstream expects X-Auth: valid header // but the basic auth requirement should add the appropriate header - assert.Equal(t, 401, w3.Code) + assert.Equal(t, http.StatusUnauthorized, w3.Code) // Check log files logContent := TestFileContent(logFile) @@ -405,7 +403,7 @@ func TestHTTPFlow_ComplexFlowWithPreAndPostRules(t *testing.T) { } func TestHTTPFlow_DefaultRule(t *testing.T) { - upstream := mockUpstream(200, "upstream response") + upstream := mockUpstream("upstream response") var rules Rules err := parseRules(` @@ -420,20 +418,20 @@ func TestHTTPFlow_DefaultRule(t *testing.T) { handler := rules.BuildHandler(upstream) // Test default rule - req1 := httptest.NewRequest("GET", "/regular", nil) + req1 := httptest.NewRequest(http.MethodGet, "/regular", nil) w1 := httptest.NewRecorder() handler.ServeHTTP(w1, req1) - assert.Equal(t, 200, w1.Code) + assert.Equal(t, http.StatusOK, w1.Code) assert.Equal(t, "true", w1.Header().Get("X-Default-Applied")) assert.Empty(t, w1.Header().Get("X-Special-Handled")) // Test special rule + default rule - req2 := httptest.NewRequest("GET", "/special", nil) + req2 := httptest.NewRequest(http.MethodGet, "/special", nil) w2 := httptest.NewRecorder() handler.ServeHTTP(w2, req2) - assert.Equal(t, 200, w2.Code) + assert.Equal(t, http.StatusOK, w2.Code) assert.Equal(t, "true", w2.Header().Get("X-Default-Applied")) assert.Equal(t, "true", w2.Header().Get("X-Special-Handled")) } @@ -443,7 +441,7 @@ func TestHTTPFlow_HeaderManipulation(t *testing.T) { // Echo back a header headerValue := r.Header.Get("X-Test-Header") w.Header().Set("X-Echoed-Header", headerValue) - w.WriteHeader(200) + w.WriteHeader(http.StatusOK) w.Write([]byte("header echoed")) }) @@ -461,14 +459,14 @@ func TestHTTPFlow_HeaderManipulation(t *testing.T) { handler := rules.BuildHandler(upstream) - req := httptest.NewRequest("GET", "/", nil) + req := httptest.NewRequest(http.MethodGet, "/", nil) req.Header.Set("X-Secret", "secret-value") req.Header.Set("X-Test-Header", "original-value") w := httptest.NewRecorder() handler.ServeHTTP(w, req) - assert.Equal(t, 200, w.Code) + assert.Equal(t, http.StatusOK, w.Code) assert.Equal(t, "modified-value", w.Header().Get("X-Echoed-Header")) assert.Equal(t, "custom-value", w.Header().Get("X-Custom-Header")) // Ensure the secret header was removed and not passed to upstream @@ -478,7 +476,7 @@ func TestHTTPFlow_HeaderManipulation(t *testing.T) { func TestHTTPFlow_QueryParameterHandling(t *testing.T) { upstream := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { query := r.URL.Query() - w.WriteHeader(200) + w.WriteHeader(http.StatusOK) w.Write([]byte("query: " + query.Get("param"))) }) @@ -492,25 +490,23 @@ func TestHTTPFlow_QueryParameterHandling(t *testing.T) { handler := rules.BuildHandler(upstream) - req := httptest.NewRequest("GET", "/path?param=original", nil) + req := httptest.NewRequest(http.MethodGet, "/path?param=original", nil) w := httptest.NewRecorder() handler.ServeHTTP(w, req) - assert.Equal(t, 200, w.Code) + assert.Equal(t, http.StatusOK, w.Code) // The set command should have modified the query parameter assert.Equal(t, "query: added-value", w.Body.String()) } func TestHTTPFlow_ServeCommand(t *testing.T) { // Create a temporary directory with test files - tempDir, err := os.MkdirTemp("", "test-serve-*") - require.NoError(t, err) - defer os.RemoveAll(tempDir) + tempDir := t.TempDir() // Create test files directly in the temp directory testFile := filepath.Join(tempDir, "index.html") - err = os.WriteFile(testFile, []byte("

Test Page

"), 0644) + err := os.WriteFile(testFile, []byte("

Test Page

"), 0o644) require.NoError(t, err) var rules Rules @@ -521,7 +517,7 @@ func TestHTTPFlow_ServeCommand(t *testing.T) { `, tempDir), &rules) require.NoError(t, err) - handler := rules.BuildHandler(mockUpstream(200, "should not be called")) + handler := rules.BuildHandler(mockUpstream("should not be called")) // Test serving a file - serve command serves files relative to the root directory // The path /files/index.html gets mapped to tempDir + "/files/index.html" @@ -534,7 +530,7 @@ func TestHTTPFlow_ServeCommand(t *testing.T) { err = os.WriteFile(filesIndexFile, []byte("

Test Page

"), 0644) require.NoError(t, err) - req1 := httptest.NewRequest("GET", "/files/index.html", nil) + req1 := httptest.NewRequest(http.MethodGet, "/files/index.html", nil) w1 := httptest.NewRecorder() handler.ServeHTTP(w1, req1) @@ -543,18 +539,18 @@ func TestHTTPFlow_ServeCommand(t *testing.T) { assert.NotEqual(t, "should not be called", w1.Body.String()) // Test file not found - req2 := httptest.NewRequest("GET", "/files/nonexistent.html", nil) + req2 := httptest.NewRequest(http.MethodGet, "/files/nonexistent.html", nil) w2 := httptest.NewRecorder() handler.ServeHTTP(w2, req2) - assert.Equal(t, 404, w2.Code) + assert.Equal(t, http.StatusNotFound, w2.Code) } func TestHTTPFlow_ProxyCommand(t *testing.T) { // Create a mock upstream server upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-Upstream-Header", "upstream-value") - w.WriteHeader(200) + w.WriteHeader(http.StatusOK) w.Write([]byte("upstream response")) })) defer upstreamServer.Close() @@ -567,15 +563,15 @@ func TestHTTPFlow_ProxyCommand(t *testing.T) { `, upstreamServer.URL), &rules) require.NoError(t, err) - handler := rules.BuildHandler(mockUpstream(200, "should not be called")) + handler := rules.BuildHandler(mockUpstream("should not be called")) - req := httptest.NewRequest("GET", "/api/test", nil) + req := httptest.NewRequest(http.MethodGet, "/api/test", nil) w := httptest.NewRecorder() handler.ServeHTTP(w, req) // The proxy command should forward the request to the upstream server - assert.Equal(t, 200, w.Code) + assert.Equal(t, http.StatusOK, w.Code) assert.Equal(t, "upstream response", w.Body.String()) assert.Equal(t, "upstream-value", w.Header().Get("X-Upstream-Header")) } @@ -586,7 +582,7 @@ func TestHTTPFlow_NotifyCommand(t *testing.T) { func TestHTTPFlow_FormConditions(t *testing.T) { upstream := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(200) + w.WriteHeader(http.StatusOK) w.Write([]byte("form processed")) }) @@ -605,28 +601,28 @@ func TestHTTPFlow_FormConditions(t *testing.T) { // Test form condition formData := url.Values{"username": {"john_doe"}} - req1 := httptest.NewRequest("POST", "/", strings.NewReader(formData.Encode())) + req1 := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(formData.Encode())) req1.Header.Set("Content-Type", "application/x-www-form-urlencoded") w1 := httptest.NewRecorder() handler.ServeHTTP(w1, req1) - assert.Equal(t, 200, w1.Code) + assert.Equal(t, http.StatusOK, w1.Code) assert.Equal(t, "john_doe", w1.Header().Get("X-Username")) // Test postform condition postFormData := url.Values{"email": {"john@example.com"}} - req2 := httptest.NewRequest("POST", "/", strings.NewReader(postFormData.Encode())) + req2 := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(postFormData.Encode())) req2.Header.Set("Content-Type", "application/x-www-form-urlencoded") w2 := httptest.NewRecorder() handler.ServeHTTP(w2, req2) - assert.Equal(t, 200, w2.Code) + assert.Equal(t, http.StatusOK, w2.Code) assert.Equal(t, "john@example.com", w2.Header().Get("X-Email")) } func TestHTTPFlow_RemoteConditions(t *testing.T) { upstream := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(200) + w.WriteHeader(http.StatusOK) w.Write([]byte("remote processed")) }) @@ -644,27 +640,27 @@ func TestHTTPFlow_RemoteConditions(t *testing.T) { handler := rules.BuildHandler(upstream) // Test localhost condition - req1 := httptest.NewRequest("GET", "/", nil) + req1 := httptest.NewRequest(http.MethodGet, "/", nil) req1.RemoteAddr = "127.0.0.1:12345" w1 := httptest.NewRecorder() handler.ServeHTTP(w1, req1) - assert.Equal(t, 200, w1.Code) + assert.Equal(t, http.StatusOK, w1.Code) assert.Equal(t, "local", w1.Header().Get("X-Access")) // Test private network block - req2 := httptest.NewRequest("GET", "/", nil) + req2 := httptest.NewRequest(http.MethodGet, "/", nil) req2.RemoteAddr = "192.168.1.100:12345" w2 := httptest.NewRecorder() handler.ServeHTTP(w2, req2) - assert.Equal(t, 403, w2.Code) + assert.Equal(t, http.StatusForbidden, w2.Code) assert.Equal(t, "Private network blocked\n", w2.Body.String()) } func TestHTTPFlow_BasicAuthConditions(t *testing.T) { upstream := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(200) + w.WriteHeader(http.StatusOK) w.Write([]byte("auth processed")) }) @@ -688,27 +684,27 @@ func TestHTTPFlow_BasicAuthConditions(t *testing.T) { handler := rules.BuildHandler(upstream) // Test admin user - req1 := httptest.NewRequest("GET", "/", nil) + req1 := httptest.NewRequest(http.MethodGet, "/", nil) req1.SetBasicAuth("admin", "adminpass") w1 := httptest.NewRecorder() handler.ServeHTTP(w1, req1) - assert.Equal(t, 200, w1.Code) + assert.Equal(t, http.StatusOK, w1.Code) assert.Equal(t, "admin", w1.Header().Get("X-Auth-Status")) // Test guest user - req2 := httptest.NewRequest("GET", "/", nil) + req2 := httptest.NewRequest(http.MethodGet, "/", nil) req2.SetBasicAuth("guest", "guestpass") w2 := httptest.NewRecorder() handler.ServeHTTP(w2, req2) - assert.Equal(t, 200, w2.Code) + assert.Equal(t, http.StatusOK, w2.Code) assert.Equal(t, "guest", w2.Header().Get("X-Auth-Status")) } func TestHTTPFlow_RouteConditions(t *testing.T) { upstream := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(200) + w.WriteHeader(http.StatusOK) w.Write([]byte("route processed")) }) @@ -726,29 +722,29 @@ func TestHTTPFlow_RouteConditions(t *testing.T) { handler := rules.BuildHandler(upstream) // Test API route - req1 := httptest.NewRequest("GET", "/", nil) + req1 := httptest.NewRequest(http.MethodGet, "/", nil) req1 = routes.WithRouteContext(req1, mockRoute("backend")) w1 := httptest.NewRecorder() handler.ServeHTTP(w1, req1) - assert.Equal(t, 200, w1.Code) + assert.Equal(t, http.StatusOK, w1.Code) assert.Equal(t, "backend", w1.Header().Get("X-Route")) // Test admin route - req2 := httptest.NewRequest("GET", "/", nil) + req2 := httptest.NewRequest(http.MethodGet, "/", nil) req2 = routes.WithRouteContext(req2, mockRoute("frontend")) w2 := httptest.NewRecorder() handler.ServeHTTP(w2, req2) - assert.Equal(t, 200, w2.Code) + assert.Equal(t, http.StatusOK, w2.Code) assert.Equal(t, "frontend", w2.Header().Get("X-Route")) } func TestHTTPFlow_ResponseStatusConditions(t *testing.T) { upstream := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(405) + w.WriteHeader(http.StatusMethodNotAllowed) w.Write([]byte("method not allowed")) }) @@ -763,18 +759,18 @@ func TestHTTPFlow_ResponseStatusConditions(t *testing.T) { handler := rules.BuildHandler(upstream) - req := httptest.NewRequest("GET", "/", nil) + req := httptest.NewRequest(http.MethodGet, "/", nil) w := httptest.NewRecorder() handler.ServeHTTP(w, req) - assert.Equal(t, 405, w.Code) + assert.Equal(t, http.StatusMethodNotAllowed, w.Code) assert.Equal(t, "error\n", w.Body.String()) } func TestHTTPFlow_ResponseHeaderConditions(t *testing.T) { upstream := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-Response-Header", "response header") - w.WriteHeader(200) + w.WriteHeader(http.StatusOK) w.Write([]byte("processed")) }) @@ -789,11 +785,11 @@ func TestHTTPFlow_ResponseHeaderConditions(t *testing.T) { handler := rules.BuildHandler(upstream) - req := httptest.NewRequest("GET", "/", nil) + req := httptest.NewRequest(http.MethodGet, "/", nil) w := httptest.NewRecorder() handler.ServeHTTP(w, req) - assert.Equal(t, 405, w.Code) + assert.Equal(t, http.StatusMethodNotAllowed, w.Code) assert.Equal(t, "error\n", w.Body.String()) }) t.Run("with_value", func(t *testing.T) { @@ -807,11 +803,11 @@ func TestHTTPFlow_ResponseHeaderConditions(t *testing.T) { handler := rules.BuildHandler(upstream) - req := httptest.NewRequest("GET", "/", nil) + req := httptest.NewRequest(http.MethodGet, "/", nil) w := httptest.NewRecorder() handler.ServeHTTP(w, req) - assert.Equal(t, 405, w.Code) + assert.Equal(t, http.StatusMethodNotAllowed, w.Code) assert.Equal(t, "error\n", w.Body.String()) }) @@ -826,18 +822,18 @@ func TestHTTPFlow_ResponseHeaderConditions(t *testing.T) { handler := rules.BuildHandler(upstream) - req := httptest.NewRequest("GET", "/", nil) + req := httptest.NewRequest(http.MethodGet, "/", nil) w := httptest.NewRecorder() handler.ServeHTTP(w, req) - assert.Equal(t, 200, w.Code) + assert.Equal(t, http.StatusOK, w.Code) assert.Equal(t, "processed", w.Body.String()) }) } func TestHTTPFlow_ComplexRuleCombinations(t *testing.T) { upstream := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(200) + w.WriteHeader(http.StatusOK) w.Write([]byte("complex processed")) }) @@ -868,26 +864,26 @@ func TestHTTPFlow_ComplexRuleCombinations(t *testing.T) { handler := rules.BuildHandler(upstream) // Test admin API (should match first rule) - req1 := httptest.NewRequest("POST", "/api/admin/users", nil) + req1 := httptest.NewRequest(http.MethodPost, "/api/admin/users", nil) req1.Header.Set("Authorization", "Bearer token") w1 := httptest.NewRecorder() handler.ServeHTTP(w1, req1) - assert.Equal(t, 200, w1.Code) + assert.Equal(t, http.StatusOK, w1.Code) assert.Equal(t, "admin", w1.Header().Get("X-Access-Level")) assert.Equal(t, "v1", w1.Header()["X-API-Version"][0]) // Test user API (should match second rule) - req2 := httptest.NewRequest("GET", "/api/users/profile", nil) + req2 := httptest.NewRequest(http.MethodGet, "/api/users/profile", nil) w2 := httptest.NewRecorder() handler.ServeHTTP(w2, req2) - assert.Equal(t, 200, w2.Code) + assert.Equal(t, http.StatusOK, w2.Code) assert.Equal(t, "user", w2.Header().Get("X-Access-Level")) assert.Equal(t, "v1", w2.Header()["X-API-Version"][0]) // Test public API (should match third rule) - req3 := httptest.NewRequest("GET", "/api/public/info", nil) + req3 := httptest.NewRequest(http.MethodGet, "/api/public/info", nil) w3 := httptest.NewRecorder() handler.ServeHTTP(w3, req3) @@ -898,7 +894,7 @@ func TestHTTPFlow_ComplexRuleCombinations(t *testing.T) { func TestHTTPFlow_ResponseModifier(t *testing.T) { upstream := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(200) + w.WriteHeader(http.StatusOK) w.Write([]byte("original response")) }) @@ -913,12 +909,12 @@ func TestHTTPFlow_ResponseModifier(t *testing.T) { handler := rules.BuildHandler(upstream) - req := httptest.NewRequest("GET", "/test", nil) + req := httptest.NewRequest(http.MethodGet, "/test", nil) w := httptest.NewRecorder() handler.ServeHTTP(w, req) - assert.Equal(t, 200, w.Code) + assert.Equal(t, http.StatusOK, w.Code) assert.Equal(t, "true", w.Header().Get("X-Modified")) assert.Equal(t, "Modified: GET /test\n", w.Body.String()) } diff --git a/internal/route/rules/io.go b/internal/route/rules/io.go index 67df0ac3..6ce0e53c 100644 --- a/internal/route/rules/io.go +++ b/internal/route/rules/io.go @@ -10,7 +10,6 @@ import ( "github.com/yusing/godoxy/internal/common" "github.com/yusing/godoxy/internal/logging/accesslog" - gperr "github.com/yusing/goutils/errs" ) type noopWriteCloser struct { @@ -31,7 +30,7 @@ var ( testFilesLock sync.Mutex ) -func openFile(path string) (io.WriteCloser, gperr.Error) { +func openFile(path string) (io.WriteCloser, error) { switch path { case "/dev/stdout": return stdout, nil diff --git a/internal/route/rules/on.go b/internal/route/rules/on.go index 4471c656..79330268 100644 --- a/internal/route/rules/on.go +++ b/internal/route/rules/on.go @@ -41,6 +41,7 @@ const ( OnRoute = "route" // on response + OnResponseHeader = "resp_header" OnStatus = "status" ) @@ -59,10 +60,11 @@ var checkers = map[string]struct { ), args: map[string]string{}, }, - validate: func(args []string) (any, gperr.Error) { + validate: func(args []string) (any, error) { if len(args) != 0 { return nil, ErrExpectNoArg } + //nolint:nilnil return nil, nil }, builder: func(args any) CheckFunc { return func(w http.ResponseWriter, r *http.Request) bool { return false } }, // this should never be called @@ -251,7 +253,7 @@ var checkers = map[string]struct { "proto": "the http protocol (http, https, h3)", }, }, - validate: func(args []string) (any, gperr.Error) { + validate: func(args []string) (any, error) { if len(args) != 1 { return nil, ErrExpectOneArg } @@ -581,7 +583,7 @@ func (on *RuleOn) Parse(v string) error { } parsed, isResp, err := parseOn(rule) if err != nil { - errs.Add(err.Subjectf("line %d", i+1)) + errs.AddSubjectf(err, "line %d", i+1) continue } if isResp { @@ -603,7 +605,7 @@ func (on *RuleOn) MarshalText() ([]byte, error) { return []byte(on.String()), nil } -func parseOn(line string) (Checker, bool, gperr.Error) { +func parseOn(line string) (Checker, bool, error) { ors := splitPipe(line) if len(ors) > 1 { @@ -645,7 +647,7 @@ func parseOn(line string) (Checker, bool, gperr.Error) { validArgs, err := checker.validate(args) if err != nil { - return nil, false, err.With(checker.help.Error()) + return nil, false, gperr.Wrap(err).With(checker.help.Error()) } checkFunc := checker.builder(validArgs) diff --git a/internal/route/rules/parser.go b/internal/route/rules/parser.go index 149da13b..83265323 100644 --- a/internal/route/rules/parser.go +++ b/internal/route/rules/parser.go @@ -31,7 +31,7 @@ var quoteChars = [256]bool{ // error 403 "Forbidden 'foo' 'bar'" // error 403 Forbidden\ \"foo\"\ \"bar\". // error 403 "Message: ${CLOUDFLARE_API_KEY}" -func parse(v string) (subject string, args []string, err gperr.Error) { +func parse(v string) (subject string, args []string, err error) { buf := bytes.NewBuffer(make([]byte, 0, len(v))) escaped := false diff --git a/internal/route/rules/parser_test.go b/internal/route/rules/parser_test.go index 1b0e8b9c..c8451c86 100644 --- a/internal/route/rules/parser_test.go +++ b/internal/route/rules/parser_test.go @@ -1,11 +1,9 @@ package rules import ( - "os" "strconv" "testing" - gperr "github.com/yusing/goutils/errs" expect "github.com/yusing/goutils/testing" ) @@ -15,7 +13,6 @@ func TestParser(t *testing.T) { input string subject string args []string - wantErr gperr.Error }{ { name: "basic", @@ -93,10 +90,6 @@ func TestParser(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { subject, args, err := parse(tt.input) - if tt.wantErr != nil { - expect.ErrorIs(t, tt.wantErr, err) - return - } // t.Log(subject, args, err) expect.NoError(t, err) expect.Equal(t, subject, tt.subject) @@ -105,12 +98,8 @@ func TestParser(t *testing.T) { } t.Run("env substitution", func(t *testing.T) { // Set up test environment variables - os.Setenv("CLOUDFLARE_API_KEY", "test-api-key-123") - os.Setenv("DOMAIN", "example.com") - defer func() { - os.Unsetenv("CLOUDFLARE_API_KEY") - os.Unsetenv("DOMAIN") - }() + t.Setenv("CLOUDFLARE_API_KEY", "test-api-key-123") + t.Setenv("DOMAIN", "example.com") tests := []struct { name string diff --git a/internal/route/rules/presets/embed.go b/internal/route/rules/presets/embed.go index 77e4d7ba..4a0c9238 100644 --- a/internal/route/rules/presets/embed.go +++ b/internal/route/rules/presets/embed.go @@ -8,7 +8,6 @@ import ( "github.com/rs/zerolog/log" "github.com/yusing/godoxy/internal/route/rules" "github.com/yusing/godoxy/internal/serialization" - gperr "github.com/yusing/goutils/errs" ) //go:embed *.yml @@ -35,12 +34,12 @@ func initPresets() { var rules rules.Rules content, err := fs.ReadFile(file.Name()) if err != nil { - gperr.LogError("failed to read rule preset", err) + log.Err(err).Msg("failed to read rule preset") continue } _, err = serialization.ConvertString(string(content), reflect.ValueOf(&rules)) if err != nil { - gperr.LogError("failed to unmarshal rule preset", err) + log.Err(err).Msg("failed to unmarshal rule preset") continue } rulePresets[file.Name()] = rules diff --git a/internal/route/rules/presets/webui.yml b/internal/route/rules/presets/webui.yml index b8c65431..c31b4dc7 100644 --- a/internal/route/rules/presets/webui.yml +++ b/internal/route/rules/presets/webui.yml @@ -3,12 +3,19 @@ do: pass - name: protected on: | - !path regex("(_next/static|_next/image|favicon.ico).*") !path glob("/api/v1/auth/*") !path glob("/auth/*") - !path regex("[A-Za-z0-9_-]+\.(svg|png|jpg|jpeg|gif|ico|webp|woff2?|eot|ttf|otf|txt)(\?.+)?") + !path /icon0.svg + !path /favicon.ico + !path /apple-icon.png + !path glob("/web-app-manifest-*x*.png") + !path regex("\/assets\/(chunks\/)?[a-zA-Z0-9\-_]+\.(css|js|woff2)") + !path regex("\/assets\/workbox-window\.prod\.es5-[a-zA-Z0-9]+\.js") + !path regex("/workbox-[a-zA-Z0-9]+\.js") !path /api/v1/version !path /manifest.json + !path /sw.js + !path /registerSW.js do: require_auth - name: proxy to backend on: path glob("/api/v1/*") diff --git a/internal/route/rules/presets/webui_dev.yml b/internal/route/rules/presets/webui_dev.yml new file mode 100644 index 00000000..555c8b17 --- /dev/null +++ b/internal/route/rules/presets/webui_dev.yml @@ -0,0 +1,26 @@ +- name: login page + on: path /login + do: pass +- name: protected + on: | + !path glob("/@tanstack-start/*") + !path glob("/@vite-plugin-pwa/*") + !path glob("/__tsd/*") + !path /@react-refresh + !path /@vite/client + !path regex("/\?token=[a-zA-Z0-9-_]+") + !path glob("/@id/*") + !path glob("/api/v1/auth/*") + !path glob("/auth/*") + !path regex("([A-Za-z0-9_\-/]+)+\.(css|ts|js|mjs|svg|png|jpg|jpeg|gif|ico|webp|woff2?|eot|ttf|otf|txt)(\?.*)?") + !path /api/v1/version + !path /manifest.json + do: require_auth +- name: proxy to backend + on: path glob("/api/v1/*") + do: proxy http://${API_ADDR}/ +- name: proxy to auth api + on: path glob("/auth/*") + do: | + rewrite /auth /api/v1/auth + proxy http://${API_ADDR}/ diff --git a/internal/route/rules/rules.go b/internal/route/rules/rules.go index ebaf5fab..9e6d52cf 100644 --- a/internal/route/rules/rules.go +++ b/internal/route/rules/rules.go @@ -7,7 +7,6 @@ import ( "github.com/quic-go/quic-go/http3" "github.com/rs/zerolog/log" - gperr "github.com/yusing/goutils/errs" httputils "github.com/yusing/goutils/http" "golang.org/x/net/http2" @@ -58,7 +57,7 @@ func (rule *Rule) IsResponseRule() bool { return rule.On.IsResponseChecker() || rule.Do.IsResponseHandler() } -func (rules Rules) Validate() gperr.Error { +func (rules Rules) Validate() error { var defaultRulesFound []int for i, rule := range rules { if rule.Name == "default" || rule.On.raw == OnDefault { diff --git a/internal/route/rules/validate.go b/internal/route/rules/validate.go index 6b2e2062..2446f54b 100644 --- a/internal/route/rules/validate.go +++ b/internal/route/rules/validate.go @@ -16,7 +16,7 @@ import ( ) type ( - ValidateFunc func(args []string) (any, gperr.Error) + ValidateFunc func(args []string) (any, error) Tuple[T1, T2 any] struct { First T1 Second T2 @@ -62,7 +62,7 @@ func (t *Tuple4[T1, T2, T3, T4]) String() string { } // validateSingleMatcher returns Matcher with the matcher validated. -func validateSingleMatcher(args []string) (any, gperr.Error) { +func validateSingleMatcher(args []string) (any, error) { if len(args) != 1 { return nil, ErrExpectOneArg } @@ -70,7 +70,7 @@ func validateSingleMatcher(args []string) (any, gperr.Error) { } // toKVOptionalVMatcher returns *MapValueMatcher that value is optional. -func toKVOptionalVMatcher(args []string) (any, gperr.Error) { +func toKVOptionalVMatcher(args []string) (any, error) { switch len(args) { case 1: return &MapValueMatcher{args[0], nil}, nil @@ -85,7 +85,7 @@ func toKVOptionalVMatcher(args []string) (any, gperr.Error) { } } -func toKeyValueTemplate(args []string) (any, gperr.Error) { +func toKeyValueTemplate(args []string) (any, error) { if len(args) != 2 { return nil, ErrExpectTwoArgs } @@ -98,7 +98,7 @@ func toKeyValueTemplate(args []string) (any, gperr.Error) { } // validateURL returns types.URL with the URL validated. -func validateURL(args []string) (any, gperr.Error) { +func validateURL(args []string) (any, error) { if len(args) != 1 { return nil, ErrExpectOneArg } @@ -134,7 +134,7 @@ func validateAbsoluteURL(args []string) (any, gperr.Error) { } // validateCIDR returns types.CIDR with the CIDR validated. -func validateCIDR(args []string) (any, gperr.Error) { +func validateCIDR(args []string) (any, error) { if len(args) != 1 { return nil, ErrExpectOneArg } @@ -149,7 +149,7 @@ func validateCIDR(args []string) (any, gperr.Error) { } // validateURLPath returns string with the path validated. -func validateURLPath(args []string) (any, gperr.Error) { +func validateURLPath(args []string) (any, error) { if len(args) != 1 { return nil, ErrExpectOneArg } @@ -166,7 +166,7 @@ func validateURLPath(args []string) (any, gperr.Error) { return p, nil } -func validateURLPathMatcher(args []string) (any, gperr.Error) { +func validateURLPathMatcher(args []string) (any, error) { path, err := validateURLPath(args) if err != nil { return nil, err @@ -175,7 +175,7 @@ func validateURLPathMatcher(args []string) (any, gperr.Error) { } // validateFSPath returns string with the path validated. -func validateFSPath(args []string) (any, gperr.Error) { +func validateFSPath(args []string) (any, error) { if len(args) != 1 { return nil, ErrExpectOneArg } @@ -187,7 +187,7 @@ func validateFSPath(args []string) (any, gperr.Error) { } // validateMethod returns string with the method validated. -func validateMethod(args []string) (any, gperr.Error) { +func validateMethod(args []string) (any, error) { if len(args) != 1 { return nil, ErrExpectOneArg } @@ -218,7 +218,7 @@ func validateStatusCode(status string) (int, error) { // - 3xx // - 4xx // - 5xx -func validateStatusRange(args []string) (any, gperr.Error) { +func validateStatusRange(args []string) (any, error) { if len(args) != 1 { return nil, ErrExpectOneArg } @@ -250,7 +250,7 @@ func validateStatusRange(args []string) (any, gperr.Error) { } // validateUserBCryptPassword returns *HashedCrendential with the password validated. -func validateUserBCryptPassword(args []string) (any, gperr.Error) { +func validateUserBCryptPassword(args []string) (any, error) { if len(args) != 2 { return nil, ErrExpectTwoArgs } @@ -258,7 +258,7 @@ func validateUserBCryptPassword(args []string) (any, gperr.Error) { } // validateModField returns CommandHandler with the field validated. -func validateModField(mod FieldModifier, args []string) (CommandHandler, gperr.Error) { +func validateModField(mod FieldModifier, args []string) (CommandHandler, error) { if len(args) == 0 { return nil, ErrExpectTwoOrThreeArgs } @@ -275,7 +275,7 @@ func validateModField(mod FieldModifier, args []string) (CommandHandler, gperr.E } validArgs, err := setField.validate(args[1:]) if err != nil { - return nil, err.With(setField.help.Error()) + return nil, gperr.Wrap(err).With(setField.help.Error()) } modder := setField.builder(validArgs) switch mod { @@ -299,7 +299,7 @@ func validateModField(mod FieldModifier, args []string) (CommandHandler, gperr.E return set, nil } -func validateTemplate(tmplStr string, newline bool) (templateString, gperr.Error) { +func validateTemplate(tmplStr string, newline bool) (templateString, error) { if newline && !strings.HasSuffix(tmplStr, "\n") { tmplStr += "\n" } @@ -310,22 +310,15 @@ func validateTemplate(tmplStr string, newline bool) (templateString, gperr.Error err := ValidateVars(tmplStr) if err != nil { - return templateString{}, gperr.Wrap(err) + return templateString{}, err } return templateString{tmplStr, true}, nil } -func validateLevel(level string) (zerolog.Level, gperr.Error) { +func validateLevel(level string) (zerolog.Level, error) { l, err := zerolog.ParseLevel(level) if err != nil { return zerolog.NoLevel, ErrInvalidArguments.With(err) } return l, nil } - -// func validateNotifProvider(provider string) gperr.Error { -// if !notif.HasProvider(provider) { -// return ErrInvalidArguments.Subject(provider) -// } -// return nil -// } diff --git a/internal/route/rules/var_bench_test.go b/internal/route/rules/var_bench_test.go index 74cef1d3..328ad338 100644 --- a/internal/route/rules/var_bench_test.go +++ b/internal/route/rules/var_bench_test.go @@ -2,6 +2,7 @@ package rules import ( "io" + "net/http" "net/http/httptest" "net/url" "testing" @@ -11,9 +12,9 @@ import ( func BenchmarkExpandVars(b *testing.B) { testResponseModifier := httputils.NewResponseModifier(httptest.NewRecorder()) - testResponseModifier.WriteHeader(200) + testResponseModifier.WriteHeader(http.StatusOK) testResponseModifier.Write([]byte("Hello, world!")) - testRequest := httptest.NewRequest("GET", "/", nil) + testRequest := httptest.NewRequest(http.MethodGet, "/", nil) testRequest.Header.Set("User-Agent", "test-agent/1.0") testRequest.Header.Set("X-Custom", "value1,value2") testRequest.ContentLength = 12345 diff --git a/internal/route/rules/vars_test.go b/internal/route/rules/vars_test.go index c719a84a..ca487bad 100644 --- a/internal/route/rules/vars_test.go +++ b/internal/route/rules/vars_test.go @@ -203,7 +203,7 @@ func TestExpandVars(t *testing.T) { postFormData.Add("postmulti", "first") postFormData.Add("postmulti", "second") - testRequest := httptest.NewRequest("POST", "https://example.com:8080/api/users?param1=value1¶m2=value2#fragment", strings.NewReader(postFormData.Encode())) + testRequest := httptest.NewRequest(http.MethodPost, "https://example.com:8080/api/users?param1=value1¶m2=value2#fragment", strings.NewReader(postFormData.Encode())) testRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded") testRequest.Header.Set("User-Agent", "test-agent/1.0") testRequest.Header.Add("X-Custom", "value1") @@ -218,7 +218,7 @@ func TestExpandVars(t *testing.T) { testResponseModifier := httputils.NewResponseModifier(httptest.NewRecorder()) testResponseModifier.Header().Set("Content-Type", "text/html") testResponseModifier.Header().Set("X-Custom-Resp", "resp-value") - testResponseModifier.WriteHeader(200) + testResponseModifier.WriteHeader(http.StatusOK) // set content length to 9876 by writing 9876 'a' bytes testResponseModifier.Write(bytes.Repeat([]byte("a"), 9876)) @@ -498,12 +498,12 @@ func TestExpandVars(t *testing.T) { func TestExpandVars_Integration(t *testing.T) { t.Run("complex log format", func(t *testing.T) { - testRequest := httptest.NewRequest("GET", "https://api.example.com/users/123?sort=asc", nil) + testRequest := httptest.NewRequest(http.MethodGet, "https://api.example.com/users/123?sort=asc", nil) testRequest.Header.Set("User-Agent", "curl/7.68.0") testRequest.RemoteAddr = "10.0.0.1:54321" testResponseModifier := httputils.NewResponseModifier(httptest.NewRecorder()) - testResponseModifier.WriteHeader(200) + testResponseModifier.WriteHeader(http.StatusOK) var out strings.Builder err := ExpandVars(testResponseModifier, testRequest, @@ -515,7 +515,7 @@ func TestExpandVars_Integration(t *testing.T) { }) t.Run("with query parameters", func(t *testing.T) { - testRequest := httptest.NewRequest("GET", "http://example.com/search?q=test&page=1", nil) + testRequest := httptest.NewRequest(http.MethodGet, "http://example.com/search?q=test&page=1", nil) testResponseModifier := httputils.NewResponseModifier(httptest.NewRecorder()) @@ -529,12 +529,12 @@ func TestExpandVars_Integration(t *testing.T) { }) t.Run("response headers", func(t *testing.T) { - testRequest := httptest.NewRequest("GET", "/", nil) + testRequest := httptest.NewRequest(http.MethodGet, "/", nil) testResponseModifier := httputils.NewResponseModifier(httptest.NewRecorder()) testResponseModifier.Header().Set("Cache-Control", "no-cache") testResponseModifier.Header().Set("X-Rate-Limit", "100") - testResponseModifier.WriteHeader(200) + testResponseModifier.WriteHeader(http.StatusOK) var out strings.Builder err := ExpandVars(testResponseModifier, testRequest, @@ -554,7 +554,7 @@ func TestExpandVars_RequestSchemes(t *testing.T) { }{ { name: "http scheme", - request: httptest.NewRequest("GET", "http://example.com/", nil), + request: httptest.NewRequest(http.MethodGet, "http://example.com/", nil), expected: "http", }, { @@ -581,7 +581,7 @@ func TestExpandVars_RequestSchemes(t *testing.T) { func TestExpandVars_UpstreamVariables(t *testing.T) { // Upstream variables require context from routes package - testRequest := httptest.NewRequest("GET", "/", nil) + testRequest := httptest.NewRequest(http.MethodGet, "/", nil) testResponseModifier := httputils.NewResponseModifier(httptest.NewRecorder()) @@ -607,7 +607,7 @@ func TestExpandVars_UpstreamVariables(t *testing.T) { func TestExpandVars_NoHostPort(t *testing.T) { // Test request without port in Host header - testRequest := httptest.NewRequest("GET", "/", nil) + testRequest := httptest.NewRequest(http.MethodGet, "/", nil) testRequest.Host = "example.com" // No port testResponseModifier := httputils.NewResponseModifier(httptest.NewRecorder()) @@ -623,13 +623,13 @@ func TestExpandVars_NoHostPort(t *testing.T) { var out strings.Builder err := ExpandVars(testResponseModifier, testRequest, "$req_port", &out) require.NoError(t, err) - require.Equal(t, "", out.String()) + require.Empty(t, out.String()) }) } func TestExpandVars_NoRemotePort(t *testing.T) { // Test request without port in RemoteAddr - testRequest := httptest.NewRequest("GET", "/", nil) + testRequest := httptest.NewRequest(http.MethodGet, "/", nil) testRequest.RemoteAddr = "192.168.1.1" // No port testResponseModifier := httputils.NewResponseModifier(httptest.NewRecorder()) @@ -638,19 +638,19 @@ func TestExpandVars_NoRemotePort(t *testing.T) { var out strings.Builder err := ExpandVars(testResponseModifier, testRequest, "$remote_host", &out) require.NoError(t, err) - require.Equal(t, "", out.String()) + require.Empty(t, out.String()) }) t.Run("remote_port without port", func(t *testing.T) { var out strings.Builder err := ExpandVars(testResponseModifier, testRequest, "$remote_port", &out) require.NoError(t, err) - require.Equal(t, "", out.String()) + require.Empty(t, out.String()) }) } func TestExpandVars_WhitespaceHandling(t *testing.T) { - testRequest := httptest.NewRequest("GET", "/test", nil) + testRequest := httptest.NewRequest(http.MethodGet, "/test", nil) testResponseModifier := httputils.NewResponseModifier(httptest.NewRecorder()) var out strings.Builder diff --git a/internal/route/stream.go b/internal/route/stream.go index a9cd14ca..cdaa6158 100755 --- a/internal/route/stream.go +++ b/internal/route/stream.go @@ -2,19 +2,18 @@ package route import ( "context" + "errors" "fmt" "net" "strings" - "github.com/rs/zerolog" "github.com/rs/zerolog/log" + entrypoint "github.com/yusing/godoxy/internal/entrypoint/types" "github.com/yusing/godoxy/internal/health/monitor" "github.com/yusing/godoxy/internal/idlewatcher" nettypes "github.com/yusing/godoxy/internal/net/types" - "github.com/yusing/godoxy/internal/route/routes" "github.com/yusing/godoxy/internal/route/stream" "github.com/yusing/godoxy/internal/types" - gperr "github.com/yusing/goutils/errs" "github.com/yusing/goutils/task" ) @@ -22,11 +21,11 @@ import ( type StreamRoute struct { *Route stream nettypes.Stream - - l zerolog.Logger } -func NewStreamRoute(base *Route) (types.Route, gperr.Error) { +var _ types.StreamRoute = (*StreamRoute)(nil) + +func NewStreamRoute(base *Route) (types.Route, error) { // TODO: support non-coherent scheme return &StreamRoute{Route: base}, nil } @@ -36,25 +35,26 @@ func (r *StreamRoute) Stream() nettypes.Stream { } // Start implements task.TaskStarter. -func (r *StreamRoute) Start(parent task.Parent) gperr.Error { +func (r *StreamRoute) Start(parent task.Parent) error { if r.LisURL == nil { - return gperr.Errorf("listen URL is not set") + return errors.New("listen URL is not set") } stream, err := r.initStream() if err != nil { - return gperr.Wrap(err) + return err } r.stream = stream r.task = parent.Subtask("stream."+r.Name(), !r.ShouldExclude()) + r.task.SetValue(monitor.DisplayNameKey{}, r.DisplayName()) switch { case r.UseIdleWatcher(): waker, err := idlewatcher.NewWatcher(parent, r, r.IdlewatcherConfig()) if err != nil { r.task.Finish(err) - return gperr.Wrap(err, "idlewatcher error") + return fmt.Errorf("idlewatcher error: %w", err) } r.stream = waker r.HealthMon = waker @@ -64,32 +64,26 @@ func (r *StreamRoute) Start(parent task.Parent) gperr.Error { if r.HealthMon != nil { if err := r.HealthMon.Start(r.task); err != nil { - gperr.LogWarn("health monitor error", err, &r.l) + log.Warn().Err(err).Msg("health monitor error") + r.HealthMon = nil } } - r.ListenAndServe(r.task.Context(), nil, nil) - r.l = log.With(). - Str("type", r.LisURL.Scheme+"->"+r.ProxyURL.Scheme). - Str("name", r.Name()). - Stringer("rurl", r.ProxyURL). - Stringer("laddr", r.LocalAddr()).Logger() - r.l.Info().Msg("stream started") - - r.task.OnCancel("close_stream", func() { - r.stream.Close() - r.l.Info().Msg("stream closed") - }) - - routes.Stream.Add(r) - r.task.OnCancel("remove_route_from_stream", func() { - routes.Stream.Del(r) - }) + ep := entrypoint.FromCtx(parent.Context()) + if ep == nil { + err := errors.New("entrypoint not initialized") + r.task.Finish(err) + return err + } + if err := ep.StartAddRoute(r); err != nil { + r.task.Finish(err) + return err + } return nil } -func (r *StreamRoute) ListenAndServe(ctx context.Context, preDial, onRead nettypes.HookFunc) { - r.stream.ListenAndServe(ctx, preDial, onRead) +func (r *StreamRoute) ListenAndServe(ctx context.Context, preDial, onRead nettypes.HookFunc) error { + return r.stream.ListenAndServe(ctx, preDial, onRead) } func (r *StreamRoute) Close() error { diff --git a/internal/route/stream/README.md b/internal/route/stream/README.md index 2b1a3909..67e4544f 100644 --- a/internal/route/stream/README.md +++ b/internal/route/stream/README.md @@ -63,10 +63,9 @@ func NewUDPUDPStream(network, listenAddr, dstAddr string) (nettypes.Stream, erro ```go type Stream interface { - ListenAndServe(ctx context.Context, preDial, onRead HookFunc) + ListenAndServe(ctx context.Context, preDial, onRead HookFunc) error Close() error LocalAddr() net.Addr - zerolog.LogObjectMarshaler } type HookFunc func(ctx context.Context) error diff --git a/internal/route/stream/debug_debug.go b/internal/route/stream/debug_debug.go index 26e814dc..7499ee44 100644 --- a/internal/route/stream/debug_debug.go +++ b/internal/route/stream/debug_debug.go @@ -8,5 +8,5 @@ import ( ) func logDebugf(stream zerolog.LogObjectMarshaler, format string, v ...any) { - log.Debug().Object("stream", stream).Msgf(format, v...) + log.Debug().EmbedObject(stream).Msgf(format, v...) } diff --git a/internal/route/stream/errors.go b/internal/route/stream/errors.go index 79a5dbff..8b54652e 100644 --- a/internal/route/stream/errors.go +++ b/internal/route/stream/errors.go @@ -29,7 +29,7 @@ func logErr(stream zerolog.LogObjectMarshaler, err error, msg string) { if err == nil { return } - log.Err(err).Object("stream", stream).Msg(msg) + log.Err(err).EmbedObject(stream).Msg(msg) } func logErrf(stream zerolog.LogObjectMarshaler, err error, format string, v ...any) { @@ -37,5 +37,5 @@ func logErrf(stream zerolog.LogObjectMarshaler, err error, format string, v ...a if err == nil { return } - log.Err(err).Object("stream", stream).Msgf(format, v...) + log.Err(err).EmbedObject(stream).Msgf(format, v...) } diff --git a/internal/route/stream/tcp_tcp.go b/internal/route/stream/tcp_tcp.go index f9ecd743..16e328b0 100644 --- a/internal/route/stream/tcp_tcp.go +++ b/internal/route/stream/tcp_tcp.go @@ -7,9 +7,9 @@ import ( "github.com/pires/go-proxyproto" "github.com/rs/zerolog" "github.com/rs/zerolog/log" - "github.com/yusing/godoxy/internal/acl" + acl "github.com/yusing/godoxy/internal/acl/types" "github.com/yusing/godoxy/internal/agentpool" - "github.com/yusing/godoxy/internal/entrypoint" + entrypoint "github.com/yusing/godoxy/internal/entrypoint/types" nettypes "github.com/yusing/godoxy/internal/net/types" ioutils "github.com/yusing/goutils/io" "go.uber.org/atomic" @@ -43,26 +43,29 @@ func NewTCPTCPStream(network, dstNetwork, listenAddr, dstAddr string, agent *age return &TCPTCPStream{network: network, dstNetwork: dstNetwork, laddr: laddr, dst: dst, agent: agent}, nil } -func (s *TCPTCPStream) ListenAndServe(ctx context.Context, preDial, onRead nettypes.HookFunc) { +func (s *TCPTCPStream) ListenAndServe(ctx context.Context, preDial, onRead nettypes.HookFunc) error { var err error s.listener, err = net.ListenTCP(s.network, s.laddr) if err != nil { - logErr(s, err, "failed to listen") - return + return err } - if acl, ok := ctx.Value(acl.ContextKey{}).(*acl.Config); ok { - log.Debug().Str("listener", s.listener.Addr().String()).Msg("wrapping listener with ACL") - s.listener = acl.WrapTCP(s.listener) + if ep := entrypoint.FromCtx(ctx); ep != nil { + if proxyProto := ep.SupportProxyProtocol(); proxyProto { + log.Debug().EmbedObject(s).Msg("wrapping listener with proxy protocol") + s.listener = &proxyproto.Listener{Listener: s.listener} + } } - if proxyProto := entrypoint.ActiveConfig.Load().SupportProxyProtocol; proxyProto { - s.listener = &proxyproto.Listener{Listener: s.listener} + if aclCfg := acl.FromCtx(ctx); aclCfg != nil { + log.Debug().EmbedObject(s).Msg("wrapping listener with ACL") + s.listener = aclCfg.WrapTCP(s.listener) } s.preDial = preDial s.onRead = onRead go s.listen(ctx) + return nil } func (s *TCPTCPStream) Close() error { diff --git a/internal/route/stream/udp_udp.go b/internal/route/stream/udp_udp.go index 65972cc8..a33f5c2a 100644 --- a/internal/route/stream/udp_udp.go +++ b/internal/route/stream/udp_udp.go @@ -11,7 +11,7 @@ import ( "github.com/rs/zerolog" "github.com/rs/zerolog/log" - "github.com/yusing/godoxy/internal/acl" + acl "github.com/yusing/godoxy/internal/acl/types" "github.com/yusing/godoxy/internal/agentpool" nettypes "github.com/yusing/godoxy/internal/net/types" "github.com/yusing/goutils/synk" @@ -75,21 +75,21 @@ func NewUDPUDPStream(network, dstNetwork, listenAddr, dstAddr string, agent *age }, nil } -func (s *UDPUDPStream) ListenAndServe(ctx context.Context, preDial, onRead nettypes.HookFunc) { +func (s *UDPUDPStream) ListenAndServe(ctx context.Context, preDial, onRead nettypes.HookFunc) error { l, err := net.ListenUDP(s.network, s.laddr) if err != nil { - logErr(s, err, "failed to listen") - return + return err } s.listener = l - if acl, ok := ctx.Value(acl.ContextKey{}).(*acl.Config); ok { - log.Debug().Str("listener", s.listener.LocalAddr().String()).Msg("wrapping listener with ACL") - s.listener = acl.WrapUDP(s.listener) + if aclCfg := acl.FromCtx(ctx); aclCfg != nil { + log.Debug().EmbedObject(s).Msg("wrapping listener with ACL") + s.listener = aclCfg.WrapUDP(l) } s.preDial = preDial s.onRead = onRead go s.listen(ctx) go s.cleanUp(ctx) + return nil } func (s *UDPUDPStream) Close() error { diff --git a/internal/route/test_route.go b/internal/route/test_route.go new file mode 100644 index 00000000..3d034ba8 --- /dev/null +++ b/internal/route/test_route.go @@ -0,0 +1,32 @@ +package route + +import ( + "testing" + + "github.com/yusing/godoxy/internal/entrypoint" + epctx "github.com/yusing/godoxy/internal/entrypoint/types" + "github.com/yusing/godoxy/internal/types" + "github.com/yusing/goutils/task" +) + +func NewStartedTestRoute(t testing.TB, base *Route) (types.Route, error) { + t.Helper() + + task := task.GetTestTask(t) + if ep := epctx.FromCtx(task.Context()); ep == nil { + ep = entrypoint.NewEntrypoint(task, nil) + epctx.SetCtx(task, ep) + } + + err := base.Validate() + if err != nil { + return nil, err + } + + err = base.Start(task) + if err != nil { + return nil, err + } + + return base.impl, nil +} diff --git a/internal/route/types/http_config.go b/internal/route/types/http_config.go index 03a65dde..334897a4 100644 --- a/internal/route/types/http_config.go +++ b/internal/route/types/http_config.go @@ -3,6 +3,7 @@ package route import ( "crypto/tls" "crypto/x509" + "errors" "net/url" "os" "strings" @@ -25,7 +26,8 @@ type HTTPConfig struct { } // BuildTLSConfig creates a TLS configuration based on the HTTP config options. -func (cfg *HTTPConfig) BuildTLSConfig(targetURL *url.URL) (*tls.Config, gperr.Error) { +func (cfg *HTTPConfig) BuildTLSConfig(targetURL *url.URL) (*tls.Config, error) { + //nolint:gosec tlsConfig := &tls.Config{} // Handle InsecureSkipVerify (legacy NoTLSVerify option) @@ -54,15 +56,12 @@ func (cfg *HTTPConfig) BuildTLSConfig(targetURL *url.URL) (*tls.Config, gperr.Er if cfg.SSLTrustedCertificate != "" { caCertData, err := os.ReadFile(cfg.SSLTrustedCertificate) if err != nil { - return nil, gperr.New("failed to read trusted certificate file"). - Subject(cfg.SSLTrustedCertificate). - With(err) + return nil, gperr.PrependSubject(err, cfg.SSLTrustedCertificate) } caCertPool := x509.NewCertPool() if !caCertPool.AppendCertsFromPEM(caCertData) { - return nil, gperr.New("failed to parse trusted certificates"). - Subject(cfg.SSLTrustedCertificate) + return nil, gperr.PrependSubject(errors.New("failed to parse trusted certificates"), cfg.SSLTrustedCertificate) } tlsConfig.RootCAs = caCertPool } @@ -70,16 +69,16 @@ func (cfg *HTTPConfig) BuildTLSConfig(targetURL *url.URL) (*tls.Config, gperr.Er // Handle ssl_certificate and ssl_certificate_key (client certificates) if cfg.SSLCertificate != "" { if cfg.SSLCertificateKey == "" { - return nil, gperr.New("ssl_certificate_key is required when ssl_certificate is specified") + return nil, errors.New("ssl_certificate_key is required when ssl_certificate is specified") } clientCert, err := tls.LoadX509KeyPair(cfg.SSLCertificate, cfg.SSLCertificateKey) if err != nil { - return nil, gperr.New("failed to load client certificate"). - Subject(cfg.SSLCertificate). - With(err) + return nil, gperr.PrependSubject(err, cfg.SSLCertificate) } tlsConfig.Certificates = []tls.Certificate{clientCert} + } else if cfg.SSLCertificateKey != "" { + return nil, errors.New("ssl_certificate is required when ssl_certificate_key is specified") } // Handle ssl_protocols (TLS versions) diff --git a/internal/route/types/port.go b/internal/route/types/port.go index 769f9831..eb4382ed 100644 --- a/internal/route/types/port.go +++ b/internal/route/types/port.go @@ -1,6 +1,7 @@ package route import ( + "errors" "strconv" gperr "github.com/yusing/goutils/errs" @@ -13,8 +14,8 @@ type Port struct { } // @name Port var ( - ErrInvalidPortSyntax = gperr.New("invalid port syntax, expect [listening_port:]target_port") - ErrPortOutOfRange = gperr.New("port out of range") + ErrInvalidPortSyntax = errors.New("invalid port syntax, expect [listening_port:]target_port") + ErrPortOutOfRange = errors.New("port out of range") ) // Parse implements strutils.Parser. @@ -30,7 +31,7 @@ func (p *Port) Parse(v string) (err error) { p.Proxy, err2 = strconv.Atoi(parts[1]) err = gperr.Join(err, err2) default: - return ErrInvalidPortSyntax.Subject(v) + return gperr.PrependSubject(ErrInvalidPortSyntax, v) } if err != nil { @@ -38,11 +39,11 @@ func (p *Port) Parse(v string) (err error) { } if p.Listening < MinPort || p.Listening > MaxPort { - return ErrPortOutOfRange.Subjectf("%d", p.Listening) + return gperr.PrependSubject(ErrPortOutOfRange, strconv.Itoa(p.Listening)) } if p.Proxy < MinPort || p.Proxy > MaxPort { - return ErrPortOutOfRange.Subjectf("%d", p.Proxy) + return gperr.PrependSubject(ErrPortOutOfRange, strconv.Itoa(p.Proxy)) } return nil diff --git a/internal/route/types/scheme.go b/internal/route/types/scheme.go index 8c22e8e4..e376f0d9 100644 --- a/internal/route/types/scheme.go +++ b/internal/route/types/scheme.go @@ -2,6 +2,7 @@ package route import ( "encoding/json" + "errors" "strconv" gperr "github.com/yusing/goutils/errs" @@ -9,7 +10,7 @@ import ( type Scheme uint8 -var ErrInvalidScheme = gperr.New("invalid scheme") +var ErrInvalidScheme = errors.New("invalid scheme") const ( SchemeHTTP Scheme = 1 << iota @@ -79,7 +80,7 @@ func (s *Scheme) Parse(v string) error { case schemeStrFileServer: *s = SchemeFileServer default: - return ErrInvalidScheme.Subject(v) + return gperr.PrependSubject(ErrInvalidScheme, v) } return nil } diff --git a/internal/serialization/README.md b/internal/serialization/README.md index 89cedf34..45aa3c16 100644 --- a/internal/serialization/README.md +++ b/internal/serialization/README.md @@ -43,12 +43,12 @@ type SerializedObject = map[string]any ```go // For custom map unmarshaling logic type MapUnmarshaller interface { - UnmarshalMap(m map[string]any) gperr.Error + UnmarshalMap(m map[string]any) error } // For custom validation logic type CustomValidator interface { - Validate() gperr.Error + Validate() error } ``` @@ -56,16 +56,16 @@ type CustomValidator interface { ```go // Generic unmarshal with pluggable format handler -func UnmarshalValidate[T any](data []byte, target *T, unmarshaler unmarshalFunc, interceptFns ...interceptFunc) gperr.Error +func UnmarshalValidate[T any](data []byte, target *T, unmarshaler unmarshalFunc, interceptFns ...interceptFunc) error // Read from io.Reader with format decoder -func UnmarshalValidateReader[T any](reader io.Reader, target *T, newDecoder newDecoderFunc, interceptFns ...interceptFunc) gperr.Error +func UnmarshalValidateReader[T any](reader io.Reader, target *T, newDecoder newDecoderFunc, interceptFns ...interceptFunc) error // Direct map deserialization -func MapUnmarshalValidate(src SerializedObject, dst any) gperr.Error +func MapUnmarshalValidate(src SerializedObject, dst any) error // To xsync.Map with pluggable format handler -func UnmarshalValidateXSync[V any](data []byte, unmarshaler unmarshalFunc, interceptFns ...interceptFunc) (*xsync.Map[string, V], gperr.Error) +func UnmarshalValidateXSync[V any](data []byte, unmarshaler unmarshalFunc, interceptFns ...interceptFunc) (*xsync.Map[string, V], error) ``` ### File I/O Functions @@ -82,23 +82,23 @@ func LoadFileIfExist[T any](path string, dst *T, unmarshaler unmarshalFunc) erro ```go // Convert any value to target reflect.Value -func Convert(src reflect.Value, dst reflect.Value, checkValidateTag bool) gperr.Error +func Convert(src reflect.Value, dst reflect.Value, checkValidateTag bool) error // String to target type conversion -func ConvertString(src string, dst reflect.Value) (convertible bool, convErr gperr.Error) +func ConvertString(src string, dst reflect.Value) (convertible bool, convErr error) ``` ### Validation Functions ```go // Validate using struct tags -func ValidateWithFieldTags(s any) gperr.Error +func ValidateWithFieldTags(s any) error // Register custom validator func MustRegisterValidation(tag string, fn validator.Func) // Validate using CustomValidator interface -func ValidateWithCustomValidator(v reflect.Value) gperr.Error +func ValidateWithCustomValidator(v reflect.Value) error // Get underlying validator func Validator() *validator.Validate @@ -301,9 +301,9 @@ type Config struct { URL string `json:"url" validate:"required"` } -func (c *Config) Validate() gperr.Error { +func (c *Config) Validate() error { if !strings.HasPrefix(c.URL, "https://") { - return gperr.New("url must use https").Subject("url") + return errors.New("url must use https") } return nil } diff --git a/internal/serialization/gin_binding_test.go b/internal/serialization/gin_binding_test.go index d7f8830e..62193da2 100644 --- a/internal/serialization/gin_binding_test.go +++ b/internal/serialization/gin_binding_test.go @@ -2,11 +2,12 @@ package serialization_test import ( "bytes" + "errors" + "net/http" "net/http/httptest" "testing" "github.com/yusing/godoxy/internal/serialization" - gperr "github.com/yusing/goutils/errs" ) type TestStruct struct { @@ -14,18 +15,17 @@ type TestStruct struct { Value2 int `json:"value2"` } -func (t *TestStruct) Validate() gperr.Error { +func (t *TestStruct) Validate() error { if t.Value == "" { - return gperr.New("value is required") + return errors.New("value is required") } if t.Value2 != 0 && (t.Value2 < 5 || t.Value2 > 10) { - return gperr.New("value2 must be between 5 and 10") + return errors.New("value2 must be between 5 and 10") } return nil } func TestGinBinding(t *testing.T) { - tests := []struct { name string input string @@ -40,7 +40,7 @@ func TestGinBinding(t *testing.T) { t.Run(tt.name, func(t *testing.T) { var dst TestStruct body := bytes.NewBufferString(tt.input) - req := httptest.NewRequest("POST", "/", body) + req := httptest.NewRequest(http.MethodPost, "/", body) err := serialization.GinJSONBinding{}.Bind(req, &dst) if (err != nil) != tt.wantErr { t.Errorf("%s: Bind() error = %v, wantErr %v", tt.name, err, tt.wantErr) diff --git a/internal/serialization/reader.go b/internal/serialization/reader.go index b44ee3cd..7a4c4167 100644 --- a/internal/serialization/reader.go +++ b/internal/serialization/reader.go @@ -15,8 +15,10 @@ func NewSubstituteEnvReader(reader io.Reader) *SubstituteEnvReader { return &SubstituteEnvReader{reader: reader} } -const peekSize = 4096 -const maxVarNameLength = 256 +const ( + peekSize = 4096 + maxVarNameLength = 256 +) func (r *SubstituteEnvReader) Read(p []byte) (n int, err error) { // Return buffered data first @@ -66,6 +68,7 @@ func (r *SubstituteEnvReader) Read(p []byte) (n int, err error) { if nMore > 0 { incomplete = append(incomplete, more[:nMore]...) // Check if pattern is now complete + //nolint:modernize if idx := bytes.IndexByte(incomplete, '}'); idx >= 0 { // Pattern complete, append the rest back to chunk chunk = append(chunk, incomplete...) diff --git a/internal/serialization/reader_bench_test.go b/internal/serialization/reader_bench_test.go index 7a415b6d..b948c08c 100644 --- a/internal/serialization/reader_bench_test.go +++ b/internal/serialization/reader_bench_test.go @@ -2,8 +2,8 @@ package serialization import ( "bytes" + "errors" "io" - "os" "strings" "testing" ) @@ -11,17 +11,9 @@ import ( // setupEnv sets up environment variables for benchmarks func setupEnv(b *testing.B) { b.Helper() - os.Setenv("BENCH_VAR", "benchmark_value") - os.Setenv("BENCH_VAR_2", "second_value") - os.Setenv("BENCH_VAR_3", "third_value") -} - -// cleanupEnv cleans up environment variables after benchmarks -func cleanupEnv(b *testing.B) { - b.Helper() - os.Unsetenv("BENCH_VAR") - os.Unsetenv("BENCH_VAR_2") - os.Unsetenv("BENCH_VAR_3") + b.Setenv("BENCH_VAR", "benchmark_value") + b.Setenv("BENCH_VAR_2", "second_value") + b.Setenv("BENCH_VAR_3", "third_value") } // BenchmarkSubstituteEnvReader_NoSubstitution benchmarks reading without any env substitutions @@ -44,7 +36,6 @@ data: some content here // BenchmarkSubstituteEnvReader_SingleSubstitution benchmarks reading with a single env substitution func BenchmarkSubstituteEnvReader_SingleSubstitution(b *testing.B) { setupEnv(b) - defer cleanupEnv(b) r := strings.NewReader(`key: ${BENCH_VAR} `) @@ -62,7 +53,6 @@ func BenchmarkSubstituteEnvReader_SingleSubstitution(b *testing.B) { // BenchmarkSubstituteEnvReader_MultipleSubstitutions benchmarks reading with multiple env substitutions func BenchmarkSubstituteEnvReader_MultipleSubstitutions(b *testing.B) { setupEnv(b) - defer cleanupEnv(b) r := strings.NewReader(`key1: ${BENCH_VAR} key2: ${BENCH_VAR_2} @@ -96,7 +86,6 @@ func BenchmarkSubstituteEnvReader_LargeInput_NoSubstitution(b *testing.B) { // BenchmarkSubstituteEnvReader_LargeInput_WithSubstitutions benchmarks large input with scattered substitutions func BenchmarkSubstituteEnvReader_LargeInput_WithSubstitutions(b *testing.B) { setupEnv(b) - defer cleanupEnv(b) var builder bytes.Buffer for range 100 { @@ -118,7 +107,6 @@ func BenchmarkSubstituteEnvReader_LargeInput_WithSubstitutions(b *testing.B) { // BenchmarkSubstituteEnvReader_SmallBuffer benchmarks reading with a small buffer size func BenchmarkSubstituteEnvReader_SmallBuffer(b *testing.B) { setupEnv(b) - defer cleanupEnv(b) r := strings.NewReader(`key: ${BENCH_VAR} and some more content here`) buf := make([]byte, 16) @@ -127,7 +115,7 @@ func BenchmarkSubstituteEnvReader_SmallBuffer(b *testing.B) { reader := NewSubstituteEnvReader(r) for { _, err := reader.Read(buf) - if err == io.EOF { + if errors.Is(err, io.EOF) { break } if err != nil { @@ -141,7 +129,6 @@ func BenchmarkSubstituteEnvReader_SmallBuffer(b *testing.B) { // BenchmarkSubstituteEnvReader_YAMLConfig benchmarks a realistic YAML config scenario func BenchmarkSubstituteEnvReader_YAMLConfig(b *testing.B) { setupEnv(b) - defer cleanupEnv(b) r := strings.NewReader(`database: host: ${BENCH_VAR} @@ -170,7 +157,6 @@ server: // BenchmarkSubstituteEnvReader_BoundaryPattern benchmarks patterns at buffer boundaries (4096 bytes) func BenchmarkSubstituteEnvReader_BoundaryPattern(b *testing.B) { setupEnv(b) - defer cleanupEnv(b) // Pattern exactly at 4090 bytes, with ${VAR} crossing the 4096 boundary prefix := strings.Repeat("x", 4090) @@ -189,7 +175,6 @@ func BenchmarkSubstituteEnvReader_BoundaryPattern(b *testing.B) { // BenchmarkSubstituteEnvReader_MultipleBoundaries benchmarks multiple patterns crossing boundaries func BenchmarkSubstituteEnvReader_MultipleBoundaries(b *testing.B) { setupEnv(b) - defer cleanupEnv(b) var builder bytes.Buffer for range 10 { @@ -210,8 +195,7 @@ func BenchmarkSubstituteEnvReader_MultipleBoundaries(b *testing.B) { // BenchmarkSubstituteEnvReader_SpecialChars benchmarks substitution with special characters func BenchmarkSubstituteEnvReader_SpecialChars(b *testing.B) { - os.Setenv("SPECIAL_BENCH_VAR", `value with "quotes" and \backslash\`) - defer os.Unsetenv("SPECIAL_BENCH_VAR") + b.Setenv("SPECIAL_BENCH_VAR", `value with "quotes" and \backslash\`) r := strings.NewReader(`key: ${SPECIAL_BENCH_VAR} `) @@ -228,8 +212,7 @@ func BenchmarkSubstituteEnvReader_SpecialChars(b *testing.B) { // BenchmarkSubstituteEnvReader_EmptyValue benchmarks substitution with empty value func BenchmarkSubstituteEnvReader_EmptyValue(b *testing.B) { - os.Setenv("EMPTY_BENCH_VAR", "") - defer os.Unsetenv("EMPTY_BENCH_VAR") + b.Setenv("EMPTY_BENCH_VAR", "") r := strings.NewReader(`key: ${EMPTY_BENCH_VAR} `) @@ -246,8 +229,7 @@ func BenchmarkSubstituteEnvReader_EmptyValue(b *testing.B) { // BenchmarkSubstituteEnvReader_DollarWithoutBrace benchmarks $ without following { func BenchmarkSubstituteEnvReader_DollarWithoutBrace(b *testing.B) { - os.Setenv("BENCH_VAR", "benchmark_value") - defer os.Unsetenv("BENCH_VAR") + b.Setenv("BENCH_VAR", "benchmark_value") r := strings.NewReader(`price: $100 and $200 for ${BENCH_VAR}`) diff --git a/internal/serialization/reader_test.go b/internal/serialization/reader_test.go index 2d9f6961..cbc5d9b6 100644 --- a/internal/serialization/reader_test.go +++ b/internal/serialization/reader_test.go @@ -2,8 +2,8 @@ package serialization import ( "bytes" + "errors" "io" - "os" "strings" "testing" @@ -11,8 +11,7 @@ import ( ) func TestSubstituteEnvReader_Basic(t *testing.T) { - os.Setenv("TEST_VAR", "hello") - defer os.Unsetenv("TEST_VAR") + t.Setenv("TEST_VAR", "hello") input := []byte(`key: ${TEST_VAR}`) reader := NewSubstituteEnvReader(bytes.NewReader(input)) @@ -23,10 +22,8 @@ func TestSubstituteEnvReader_Basic(t *testing.T) { } func TestSubstituteEnvReader_Multiple(t *testing.T) { - os.Setenv("VAR1", "first") - os.Setenv("VAR2", "second") - defer os.Unsetenv("VAR1") - defer os.Unsetenv("VAR2") + t.Setenv("VAR1", "first") + t.Setenv("VAR2", "second") input := []byte(`a: ${VAR1}, b: ${VAR2}`) reader := NewSubstituteEnvReader(bytes.NewReader(input)) @@ -46,8 +43,6 @@ func TestSubstituteEnvReader_NoSubstitution(t *testing.T) { } func TestSubstituteEnvReader_UnsetEnvError(t *testing.T) { - os.Unsetenv("UNSET_VAR_FOR_TEST") - input := []byte(`key: ${UNSET_VAR_FOR_TEST}`) reader := NewSubstituteEnvReader(bytes.NewReader(input)) @@ -57,8 +52,7 @@ func TestSubstituteEnvReader_UnsetEnvError(t *testing.T) { } func TestSubstituteEnvReader_SmallBuffer(t *testing.T) { - os.Setenv("SMALL_BUF_VAR", "value") - defer os.Unsetenv("SMALL_BUF_VAR") + t.Setenv("SMALL_BUF_VAR", "value") input := []byte(`key: ${SMALL_BUF_VAR}`) reader := NewSubstituteEnvReader(bytes.NewReader(input)) @@ -70,7 +64,7 @@ func TestSubstituteEnvReader_SmallBuffer(t *testing.T) { if n > 0 { result = append(result, buf[:n]...) } - if err == io.EOF { + if errors.Is(err, io.EOF) { break } require.NoError(t, err) @@ -79,8 +73,7 @@ func TestSubstituteEnvReader_SmallBuffer(t *testing.T) { } func TestSubstituteEnvReader_SpecialChars(t *testing.T) { - os.Setenv("SPECIAL_VAR", `hello "world" \n`) - defer os.Unsetenv("SPECIAL_VAR") + t.Setenv("SPECIAL_VAR", `hello "world" \n`) input := []byte(`key: ${SPECIAL_VAR}`) reader := NewSubstituteEnvReader(bytes.NewReader(input)) @@ -91,8 +84,7 @@ func TestSubstituteEnvReader_SpecialChars(t *testing.T) { } func TestSubstituteEnvReader_EmptyValue(t *testing.T) { - os.Setenv("EMPTY_VAR", "") - defer os.Unsetenv("EMPTY_VAR") + t.Setenv("EMPTY_VAR", "") input := []byte(`key: ${EMPTY_VAR}`) reader := NewSubstituteEnvReader(bytes.NewReader(input)) @@ -103,8 +95,7 @@ func TestSubstituteEnvReader_EmptyValue(t *testing.T) { } func TestSubstituteEnvReader_LargeInput(t *testing.T) { - os.Setenv("LARGE_VAR", "replaced") - defer os.Unsetenv("LARGE_VAR") + t.Setenv("LARGE_VAR", "replaced") prefix := strings.Repeat("x", 5000) suffix := strings.Repeat("y", 5000) @@ -119,8 +110,7 @@ func TestSubstituteEnvReader_LargeInput(t *testing.T) { } func TestSubstituteEnvReader_PatternAtBoundary(t *testing.T) { - os.Setenv("BOUNDARY_VAR", "boundary_value") - defer os.Unsetenv("BOUNDARY_VAR") + t.Setenv("BOUNDARY_VAR", "boundary_value") prefix := strings.Repeat("a", 4090) input := []byte(prefix + "${BOUNDARY_VAR}") @@ -134,10 +124,8 @@ func TestSubstituteEnvReader_PatternAtBoundary(t *testing.T) { } func TestSubstituteEnvReader_MultiplePatternsBoundary(t *testing.T) { - os.Setenv("VAR_A", "aaa") - os.Setenv("VAR_B", "bbb") - defer os.Unsetenv("VAR_A") - defer os.Unsetenv("VAR_B") + t.Setenv("VAR_A", "aaa") + t.Setenv("VAR_B", "bbb") prefix := strings.Repeat("x", 4090) input := []byte(prefix + "${VAR_A} middle ${VAR_B}") @@ -151,12 +139,9 @@ func TestSubstituteEnvReader_MultiplePatternsBoundary(t *testing.T) { } func TestSubstituteEnvReader_YAMLConfig(t *testing.T) { - os.Setenv("DB_HOST", "localhost") - os.Setenv("DB_PORT", "5432") - os.Setenv("DB_PASSWORD", "secret123") - defer os.Unsetenv("DB_HOST") - defer os.Unsetenv("DB_PORT") - defer os.Unsetenv("DB_PASSWORD") + t.Setenv("DB_HOST", "localhost") + t.Setenv("DB_PORT", "5432") + t.Setenv("DB_PASSWORD", "secret123") input := []byte(`database: host: ${DB_HOST} diff --git a/internal/serialization/serialization.go b/internal/serialization/serialization.go index 920abbb9..ecbf385d 100644 --- a/internal/serialization/serialization.go +++ b/internal/serialization/serialization.go @@ -1,7 +1,9 @@ package serialization import ( + "encoding/json" "errors" + "fmt" "io" "os" "reflect" @@ -11,7 +13,6 @@ import ( "time" "unsafe" - "github.com/bytedance/sonic" "github.com/go-playground/validator/v10" "github.com/goccy/go-yaml" "github.com/puzpuzpuz/xsync/v4" @@ -33,22 +34,22 @@ func ToSerializedObject[VT any](m map[string]VT) SerializedObject { } func init() { - strutils.SetJSONMarshaler(sonic.Marshal) - strutils.SetJSONUnmarshaler(sonic.Unmarshal) + strutils.SetJSONMarshaler(json.Marshal) + strutils.SetJSONUnmarshaler(json.Unmarshal) strutils.SetYAMLMarshaler(yaml.Marshal) strutils.SetYAMLUnmarshaler(yaml.Unmarshal) } type MapUnmarshaller interface { - UnmarshalMap(m map[string]any) gperr.Error + UnmarshalMap(m map[string]any) error } var ( - ErrInvalidType = gperr.New("invalid type") - ErrNilValue = gperr.New("nil") - ErrUnsettable = gperr.New("unsettable") - ErrUnsupportedConversion = gperr.New("unsupported conversion") - ErrUnknownField = gperr.New("unknown field") + ErrInvalidType = errors.New("invalid type") + ErrNilValue = errors.New("nil") + ErrUnsettable = errors.New("unsettable") + ErrUnsupportedConversion = errors.New("unsupported conversion") + ErrUnknownField = errors.New("unknown field") ) var ( @@ -86,11 +87,11 @@ func initPtr(dst reflect.Value) { } } -// Validate performs struct validation using go-playground/validator tags. +// ValidateWithFieldTags performs struct validation using go-playground/validator tags. // // It collects all validation errors and returns them as a single error. // Field names in errors are prefixed with their namespace (e.g., "User.Email"). -func ValidateWithFieldTags(s any) gperr.Error { +func ValidateWithFieldTags(s any) error { var errs gperr.Builder err := validate.Struct(s) var valErrs validator.ValidationErrors @@ -103,15 +104,16 @@ func ValidateWithFieldTags(s any) gperr.Error { if detail != "required" { detail = "require " + strconv.Quote(detail) } - errs.Add(ErrValidationError. - Subject(e.Namespace()). - Withf(detail)) + errs.Add(gperr.PrependSubject(ErrValidationError, e.Namespace()). + Withf("%s", detail)) } } return errs.Error() } -func dive(dst reflect.Value) (v reflect.Value, t reflect.Type, err gperr.Error) { +// dive recursively dives into the nested pointers of the dst. +// dst value pointer must be valid (satisfies reflect.Value.IsValid()). +func dive(dst reflect.Value) (v reflect.Value, t reflect.Type) { dstT := dst.Type() for { switch dstT.Kind() { @@ -119,7 +121,7 @@ func dive(dst reflect.Value) (v reflect.Value, t reflect.Type, err gperr.Error) dst = dst.Elem() dstT = dstT.Elem() default: - return dst, dstT, nil + return dst, dstT } } } @@ -276,32 +278,26 @@ func initTypeKeyFieldIndexesMap(t reflect.Type) typeInfo { // If the target value is a map[string]any the SerializedObject will be deserialized into the map. // // The function returns an error if the target value is not a struct or a map[string]any, or if there is an error during deserialization. -func MapUnmarshalValidate(src SerializedObject, dst any) (err gperr.Error) { +func MapUnmarshalValidate(src SerializedObject, dst any) error { return mapUnmarshalValidate(src, reflect.ValueOf(dst), true) } -func mapUnmarshalValidate(src SerializedObject, dstV reflect.Value, checkValidateTag bool) (err gperr.Error) { +func mapUnmarshalValidate(src SerializedObject, dstV reflect.Value, checkValidateTag bool) (err error) { dstT := dstV.Type() if src != nil && dstT.Implements(mapUnmarshalerType) { - dstV, _, err = dive(dstV) - if err != nil { - return err - } + dstV, _ = dive(dstV) return dstV.Addr().Interface().(MapUnmarshaller).UnmarshalMap(src) } - dstV, dstT, err = dive(dstV) - if err != nil { - return err - } + dstV, dstT = dive(dstV) if src == nil { if dstV.CanSet() { dstV.SetZero() return nil } - return gperr.Errorf("deserialize: src is %w and dst is not settable", ErrNilValue) + return fmt.Errorf("deserialize: src is %w and dst is not settable", ErrNilValue) } // convert data fields to lower no-snake @@ -317,10 +313,10 @@ func mapUnmarshalValidate(src SerializedObject, dstV reflect.Value, checkValidat if field, ok := info.getField(dstV, k); ok { err := Convert(reflect.ValueOf(v), field, checkValidateTag) if err != nil { - errs.Add(err.Subject(k)) + errs.AddSubject(err, k) } } else { - errs.Add(ErrUnknownField.Subject(k).With(gperr.DoYouMeanField(k, info.fieldNames))) + errs.Add(gperr.PrependSubject(ErrUnknownField, k).With(gperr.DoYouMeanField(k, info.fieldNames))) } } if info.hasValidateTag && checkValidateTag { @@ -333,23 +329,23 @@ func mapUnmarshalValidate(src SerializedObject, dstV reflect.Value, checkValidat case reflect.Map: if dstV.IsNil() { if !dstV.CanSet() { - return gperr.Errorf("dive: dst is %w and is not settable", ErrNilValue) + return fmt.Errorf("dive: dst is %w and is not settable", ErrNilValue) } gi.ReflectInitMap(dstV, len(src)) } if dstT.Key().Kind() != reflect.String { - return gperr.Errorf("deserialize: %w for map of non string keys (map of %s)", ErrUnsupportedConversion, dstT.Elem().String()) + return fmt.Errorf("deserialize: %w for map of non string keys (map of %s)", ErrUnsupportedConversion, dstT.Elem().String()) } // ?: should we clear the map? for k, v := range src { elem := gi.ReflectStrMapAssign(dstV, k) err := Convert(reflect.ValueOf(v), elem, true) if err != nil { - errs.Add(err.Subject(k)) + errs.AddSubject(err, k) continue } if err := ValidateWithCustomValidator(elem); err != nil { - errs.Add(err.Subject(k)) + errs.AddSubject(err, k) } } if err := ValidateWithCustomValidator(dstV); err != nil { @@ -357,7 +353,7 @@ func mapUnmarshalValidate(src SerializedObject, dstV reflect.Value, checkValidat } return errs.Error() default: - return ErrUnsupportedConversion.Subject("mapping to " + dstT.String() + " ") + return fmt.Errorf("deserialize: %w for mapping to %s", ErrUnsupportedConversion, dstT) } } @@ -373,14 +369,14 @@ func mapUnmarshalValidate(src SerializedObject, dstV reflect.Value, checkValidat // // Returns: // - error: the error occurred during conversion, or nil if no error occurred. -func Convert(src reflect.Value, dst reflect.Value, checkValidateTag bool) gperr.Error { +func Convert(src reflect.Value, dst reflect.Value, checkValidateTag bool) error { if !dst.IsValid() { - return gperr.Errorf("convert: dst is %w", ErrNilValue) + return fmt.Errorf("convert: dst is %w", ErrNilValue) } if (src.Kind() == reflect.Pointer && src.IsNil()) || !src.IsValid() { if !dst.CanSet() { - return gperr.Errorf("convert: src is %w", ErrNilValue) + return fmt.Errorf("convert: src is %w", ErrNilValue) } dst.SetZero() return nil @@ -388,7 +384,7 @@ func Convert(src reflect.Value, dst reflect.Value, checkValidateTag bool) gperr. if src.IsZero() { if !dst.CanSet() { - return gperr.Errorf("convert: src is %w", ErrNilValue) + return fmt.Errorf("convert: src is %w", ErrNilValue) } switch dst.Kind() { case reflect.Pointer: @@ -410,7 +406,7 @@ func Convert(src reflect.Value, dst reflect.Value, checkValidateTag bool) gperr. if dst.Kind() == reflect.Pointer { if dst.IsNil() { if !dst.CanSet() { - return ErrUnsettable.Subject(dstT.String()) + return fmt.Errorf("convert: dst is %w", ErrUnsettable) } initPtr(dst) } @@ -423,13 +419,13 @@ func Convert(src reflect.Value, dst reflect.Value, checkValidateTag bool) gperr. switch { case srcT == dstT, srcT.AssignableTo(dstT): if !dst.CanSet() { - return ErrUnsettable.Subject(dstT.String()) + return fmt.Errorf("convert: dst is %w", ErrUnsettable) } dst.Set(src) return nil case srcKind == reflect.String: if !dst.CanSet() { - return ErrUnsettable.Subject(dstT.String()) + return fmt.Errorf("convert: dst is %w", ErrUnsettable) } if convertible, err := ConvertString(src.String(), dst); convertible { return err @@ -451,14 +447,14 @@ func Convert(src reflect.Value, dst reflect.Value, checkValidateTag bool) gperr. } obj, ok := src.Interface().(SerializedObject) if !ok { - return ErrUnsupportedConversion.Subject(dstT.String() + " to " + srcT.String()) + return fmt.Errorf("convert: %w from %s to %s", ErrUnsupportedConversion, srcT, dstT) } return mapUnmarshalValidate(obj, dst.Addr(), checkValidateTag) case srcKind == reflect.Slice: // slice to slice return ConvertSlice(src, dst, checkValidateTag) } - return ErrUnsupportedConversion.Subjectf("%s to %s", srcT, dstT) + return fmt.Errorf("convert: %w for %s to %s", ErrUnsupportedConversion, srcT, dstT) } // ConvertSlice converts a source slice to a destination slice. @@ -468,17 +464,17 @@ func Convert(src reflect.Value, dst reflect.Value, checkValidateTag bool) gperr. // - The destination slice is initialized with the source length. // - On error, the destination slice is truncated to the number of // successfully converted elements. -func ConvertSlice(src reflect.Value, dst reflect.Value, checkValidateTag bool) gperr.Error { +func ConvertSlice(src reflect.Value, dst reflect.Value, checkValidateTag bool) error { if dst.Kind() == reflect.Pointer { if dst.IsNil() && !dst.CanSet() { - return ErrNilValue + return fmt.Errorf("convert: dst is %w", ErrNilValue) } initPtr(dst) dst = dst.Elem() } if !dst.CanSet() { - return ErrUnsettable.Subject(dst.Type().String()) + return fmt.Errorf("convert: dst is %w", ErrUnsettable) } if src.Kind() != reflect.Slice { @@ -491,7 +487,7 @@ func ConvertSlice(src reflect.Value, dst reflect.Value, checkValidateTag bool) g return nil } if dst.Kind() != reflect.Slice { - return ErrUnsupportedConversion.Subjectf("%s to %s", dst.Type(), src.Type()) + return fmt.Errorf("convert: %w for %s to %s", ErrUnsupportedConversion, dst.Type(), src.Type()) } var sliceErrs gperr.Builder @@ -500,7 +496,7 @@ func ConvertSlice(src reflect.Value, dst reflect.Value, checkValidateTag bool) g for j := range srcLen { err := Convert(src.Index(j), dst.Index(numValid), checkValidateTag) if err != nil { - sliceErrs.Add(err.Subjectf("[%d]", j)) + sliceErrs.AddSubjectf(err, "[%d]", j) continue } numValid++ @@ -526,8 +522,7 @@ func ConvertSlice(src reflect.Value, dst reflect.Value, checkValidateTag bool) g // - If the destination implements the Parser interface, it is used for conversion. // - Returns true if conversion was handled (even with error), false if // conversion is unsupported. -func ConvertString(src string, dst reflect.Value) (convertible bool, convErr gperr.Error) { - convertible = true +func ConvertString(src string, dst reflect.Value) (convertible bool, convErr error) { dstT := dst.Type() if dst.Kind() == reflect.Pointer { if dst.IsNil() { @@ -555,14 +550,14 @@ func ConvertString(src string, dst reflect.Value) (convertible bool, convErr gpe // check if (*T).Convertor is implemented if addr := dst.Addr(); addr.Type().Implements(reflect.TypeFor[strutils.Parser]()) { parser := addr.Interface().(strutils.Parser) - return true, gperr.Wrap(parser.Parse(src)) + return true, parser.Parse(src) } switch dstT { case reflect.TypeFor[time.Duration](): d, err := time.ParseDuration(src) if err != nil { - return true, gperr.Wrap(err) + return true, err } gi.ReflectValueSet(dst, d) return true, nil @@ -572,7 +567,7 @@ func ConvertString(src string, dst reflect.Value) (convertible bool, convErr gpe if gi.ReflectIsNumeric(dst) || dst.Kind() == reflect.Bool { err := gi.ReflectStrToNumBool(dst, src) if err != nil { - return true, gperr.Wrap(err) + return true, err } return true, nil } @@ -602,14 +597,14 @@ func ConvertString(src string, dst reflect.Value) (convertible bool, convErr gpe sl := []any{} err := yaml.Unmarshal(unsafe.Slice(unsafe.StringData(src), len(src)), &sl) if err != nil { - return true, gperr.Wrap(err) + return true, err } return true, ConvertSlice(reflect.ValueOf(sl), dst, true) case reflect.Map, reflect.Struct: rawMap := SerializedObject{} err := yaml.Unmarshal(unsafe.Slice(unsafe.StringData(src), len(src)), &rawMap) if err != nil { - return true, gperr.Wrap(err) + return true, err } return true, mapUnmarshalValidate(rawMap, dst, true) default: @@ -619,7 +614,7 @@ func ConvertString(src string, dst reflect.Value) (convertible bool, convErr gpe var envRegex = regexp.MustCompile(`\$\{([^}]+)\}`) // e.g. ${CLOUDFLARE_API_KEY} -func substituteEnv(data []byte) ([]byte, gperr.Error) { +func substituteEnv(data []byte) ([]byte, error) { envError := gperr.NewBuilder("env substitution error") data = envRegex.ReplaceAllFunc(data, func(match []byte) []byte { varName := string(match[2 : len(match)-1]) @@ -643,7 +638,7 @@ type ( newDecoderFunc func(r io.Reader) interface { Decode(v any) error } - interceptFunc func(m map[string]any) gperr.Error + interceptFunc func(m map[string]any) error ) // UnmarshalValidate unmarshals data into a map, applies optional intercept @@ -651,7 +646,7 @@ type ( // - Environment variables in the data are substituted using ${VAR} syntax. // - The unmarshaler function converts data to a map[string]any. // - Intercept functions can modify or validate the map before unmarshaling. -func UnmarshalValidate[T any](data []byte, target *T, unmarshaler unmarshalFunc, interceptFns ...interceptFunc) gperr.Error { +func UnmarshalValidate[T any](data []byte, target *T, unmarshaler unmarshalFunc, interceptFns ...interceptFunc) error { data, err := substituteEnv(data) if err != nil { return err @@ -659,7 +654,7 @@ func UnmarshalValidate[T any](data []byte, target *T, unmarshaler unmarshalFunc, m := make(map[string]any) if err := unmarshaler(data, &m); err != nil { - return gperr.Wrap(err) + return err } for _, intercept := range interceptFns { if err := intercept(m); err != nil { @@ -674,10 +669,10 @@ func UnmarshalValidate[T any](data []byte, target *T, unmarshaler unmarshalFunc, // - Environment variables are substituted during reading using ${VAR} syntax. // - The newDecoder function creates a decoder for the reader (e.g., // json.NewDecoder). -func UnmarshalValidateReader[T any](reader io.Reader, target *T, newDecoder newDecoderFunc, interceptFns ...interceptFunc) gperr.Error { +func UnmarshalValidateReader[T any](reader io.Reader, target *T, newDecoder newDecoderFunc, interceptFns ...interceptFunc) error { m := make(map[string]any) if err := newDecoder(NewSubstituteEnvReader(reader)).Decode(&m); err != nil { - return gperr.Wrap(err) + return err } for _, intercept := range interceptFns { if err := intercept(m); err != nil { @@ -692,7 +687,7 @@ func UnmarshalValidateReader[T any](reader io.Reader, target *T, newDecoder newD // - The unmarshaler function converts data to a map[string]any. // - Intercept functions can modify or validate the map before unmarshaling. // - Returns a thread-safe concurrent map with the unmarshaled values. -func UnmarshalValidateXSync[V any](data []byte, unmarshaler unmarshalFunc, interceptFns ...interceptFunc) (*xsync.Map[string, V], gperr.Error) { +func UnmarshalValidateXSync[V any](data []byte, unmarshaler unmarshalFunc, interceptFns ...interceptFunc) (*xsync.Map[string, V], error) { data, err := substituteEnv(data) if err != nil { return nil, err @@ -700,7 +695,7 @@ func UnmarshalValidateXSync[V any](data []byte, unmarshaler unmarshalFunc, inter m := make(map[string]any) if err := unmarshaler(data, &m); err != nil { - return nil, gperr.Wrap(err) + return nil, err } for _, intercept := range interceptFns { if err := intercept(m); err != nil { diff --git a/internal/serialization/serialization_benchmark_test.go b/internal/serialization/serialization_benchmark_test.go index 4fb881df..45357797 100644 --- a/internal/serialization/serialization_benchmark_test.go +++ b/internal/serialization/serialization_benchmark_test.go @@ -42,7 +42,7 @@ func BenchmarkDeserialize(b *testing.B) { dst := complexStruct{} err := MapUnmarshalValidate(src, &dst) if err != nil { - b.Fatal(string(err.Plain())) + b.Fatal(err.Error()) } } } diff --git a/internal/serialization/validation.go b/internal/serialization/validation.go index bebd9370..7582bdaa 100644 --- a/internal/serialization/validation.go +++ b/internal/serialization/validation.go @@ -1,15 +1,15 @@ package serialization import ( + "errors" "reflect" "github.com/go-playground/validator/v10" - gperr "github.com/yusing/goutils/errs" ) var validate = validator.New() -var ErrValidationError = gperr.New("validation error") +var ErrValidationError = errors.New("validation error") func Validator() *validator.Validate { return validate @@ -23,12 +23,12 @@ func MustRegisterValidation(tag string, fn validator.Func) { } type CustomValidator interface { - Validate() gperr.Error + Validate() error } var validatorType = reflect.TypeFor[CustomValidator]() -func ValidateWithCustomValidator(v reflect.Value) gperr.Error { +func ValidateWithCustomValidator(v reflect.Value) error { vt := v.Type() if v.Kind() == reflect.Pointer { elemType := vt.Elem() diff --git a/internal/serialization/validation_mismatch_test.go b/internal/serialization/validation_mismatch_test.go index 15aad4ca..a46f65a8 100644 --- a/internal/serialization/validation_mismatch_test.go +++ b/internal/serialization/validation_mismatch_test.go @@ -1,24 +1,23 @@ package serialization import ( + "errors" "reflect" "testing" - - gperr "github.com/yusing/goutils/errs" ) // Test cases for when *T implements CustomValidator but T is passed in type CustomValidatingInt int -func (c *CustomValidatingInt) Validate() gperr.Error { +func (c *CustomValidatingInt) Validate() error { if c == nil { - return gperr.New("pointer int cannot be nil") + return errors.New("pointer int cannot be nil") } if *c <= 0 { - return gperr.New("int must be positive") + return errors.New("int must be positive") } if *c > 100 { - return gperr.New("int must be <= 100") + return errors.New("int must be <= 100") } return nil } @@ -26,12 +25,12 @@ func (c *CustomValidatingInt) Validate() gperr.Error { // Test cases for when T implements CustomValidator but *T is passed in type CustomValidatingFloat float64 -func (c CustomValidatingFloat) Validate() gperr.Error { +func (c CustomValidatingFloat) Validate() error { if c < 0 { - return gperr.New("float must be non-negative") + return errors.New("float must be non-negative") } if c > 1000 { - return gperr.New("float must be <= 1000") + return errors.New("float must be <= 1000") } return nil } diff --git a/internal/serialization/validation_string_ptr_test.go b/internal/serialization/validation_string_ptr_test.go index 1de98ecc..4d6a58f2 100644 --- a/internal/serialization/validation_string_ptr_test.go +++ b/internal/serialization/validation_string_ptr_test.go @@ -1,23 +1,22 @@ package serialization import ( + "errors" "reflect" "testing" - - gperr "github.com/yusing/goutils/errs" ) type CustomValidatingPointerString string -func (c *CustomValidatingPointerString) Validate() gperr.Error { +func (c *CustomValidatingPointerString) Validate() error { if c == nil { - return gperr.New("pointer string cannot be nil") + return errors.New("pointer string cannot be nil") } if *c == "" { - return gperr.New("string cannot be empty") + return errors.New("string cannot be empty") } if len(*c) < 2 { - return gperr.New("string must be at least 2 characters") + return errors.New("string must be at least 2 characters") } return nil } diff --git a/internal/serialization/validation_string_test.go b/internal/serialization/validation_string_test.go index b432492b..89e3773e 100644 --- a/internal/serialization/validation_string_test.go +++ b/internal/serialization/validation_string_test.go @@ -1,20 +1,19 @@ package serialization import ( + "errors" "reflect" "testing" - - gperr "github.com/yusing/goutils/errs" ) type CustomValidatingString string -func (c CustomValidatingString) Validate() gperr.Error { +func (c CustomValidatingString) Validate() error { if c == "" { - return gperr.New("string cannot be empty") + return errors.New("string cannot be empty") } if len(c) < 2 { - return gperr.New("string must be at least 2 characters") + return errors.New("string must be at least 2 characters") } return nil } diff --git a/internal/serialization/validation_struct_ptr_test.go b/internal/serialization/validation_struct_ptr_test.go index 6d09646b..1924a392 100644 --- a/internal/serialization/validation_struct_ptr_test.go +++ b/internal/serialization/validation_struct_ptr_test.go @@ -1,25 +1,24 @@ package serialization import ( + "errors" "reflect" "testing" - - gperr "github.com/yusing/goutils/errs" ) type CustomValidatingPointerStruct struct { Value string } -func (c *CustomValidatingPointerStruct) Validate() gperr.Error { +func (c *CustomValidatingPointerStruct) Validate() error { if c == nil { - return gperr.New("pointer struct cannot be nil") + return errors.New("pointer struct cannot be nil") } if c.Value == "" { - return gperr.New("value cannot be empty") + return errors.New("value cannot be empty") } if len(c.Value) < 3 { - return gperr.New("value must be at least 3 characters") + return errors.New("value must be at least 3 characters") } return nil } diff --git a/internal/serialization/validation_struct_test.go b/internal/serialization/validation_struct_test.go index 37fef26e..1c22aa1f 100644 --- a/internal/serialization/validation_struct_test.go +++ b/internal/serialization/validation_struct_test.go @@ -1,22 +1,21 @@ package serialization import ( + "errors" "reflect" "testing" - - gperr "github.com/yusing/goutils/errs" ) type CustomValidatingStruct struct { Value string } -func (c CustomValidatingStruct) Validate() gperr.Error { +func (c CustomValidatingStruct) Validate() error { if c.Value == "" { - return gperr.New("value cannot be empty") + return errors.New("value cannot be empty") } if len(c.Value) < 3 { - return gperr.New("value must be at least 3 characters") + return errors.New("value must be at least 3 characters") } return nil } diff --git a/internal/types/docker_provider_config.go b/internal/types/docker_provider_config.go index e5117809..99410c4b 100644 --- a/internal/types/docker_provider_config.go +++ b/internal/types/docker_provider_config.go @@ -59,7 +59,7 @@ func (cfg *DockerProviderConfig) Parse(value string) error { return nil } -func (cfg *DockerProviderConfig) UnmarshalMap(m map[string]any) gperr.Error { +func (cfg *DockerProviderConfig) UnmarshalMap(m map[string]any) error { var tmp DockerProviderConfigDetailed var err = serialization.MapUnmarshalValidate(m, &tmp) if err != nil { @@ -70,7 +70,7 @@ func (cfg *DockerProviderConfig) UnmarshalMap(m map[string]any) gperr.Error { cfg.TLS = tmp.TLS if cfg.TLS != nil { if err := checkFilesOk(cfg.TLS.CAFile, cfg.TLS.CertFile, cfg.TLS.KeyFile); err != nil { - return gperr.Wrap(err) + return err } } return nil diff --git a/internal/types/health.go b/internal/types/health.go index 1281a423..cc9b1a34 100644 --- a/internal/types/health.go +++ b/internal/types/health.go @@ -73,6 +73,17 @@ type ( Config *LoadBalancerConfig `json:"config"` Pool map[string]any `json:"pool"` } // @name HealthExtra + + HealthInfoWithoutDetail struct { + Status HealthStatus `json:"status" swaggertype:"string" enums:"healthy,unhealthy,napping,starting,error,unknown"` + Uptime time.Duration `json:"uptime" swaggertype:"number"` + Latency time.Duration `json:"latency" swaggertype:"number"` + } // @name HealthInfoWithoutDetail + + HealthInfo struct { + HealthInfoWithoutDetail + Detail string `json:"detail"` + } // @name HealthInfo ) const ( diff --git a/internal/types/idlewatcher.go b/internal/types/idlewatcher.go index 60518738..6b7ca36a 100644 --- a/internal/types/idlewatcher.go +++ b/internal/types/idlewatcher.go @@ -1,6 +1,7 @@ package types import ( + "errors" "net/url" "strconv" "strings" @@ -32,7 +33,7 @@ type ( DependsOn []string `json:"depends_on,omitempty"` NoLoadingPage bool `json:"no_loading_page,omitempty"` - valErr gperr.Error + valErr error } // @name IdlewatcherConfig ContainerStopMethod string // @name ContainerStopMethod ContainerSignal string // @name ContainerSignal @@ -44,7 +45,7 @@ type ( } // @name IdlewatcherDockerConfig ProxmoxConfig struct { Node string `json:"node" validate:"required"` - VMID int `json:"vmid" validate:"required"` + VMID uint64 `json:"vmid" validate:"required"` } // @name IdlewatcherProxmoxNodeConfig ) @@ -57,21 +58,28 @@ const ( ContainerStopMethodKill ContainerStopMethod = "kill" ) +var ( + ErrMissingProviderConfig = errors.New("missing idlewatcher provider config") + ErrInvalidStopMethod = errors.New("invalid stop method") + ErrInvalidStopSignal = errors.New("invalid stop signal") + ErrEmptyStartEndpoint = errors.New("start endpoint must not be empty if defined") +) + func (c *IdlewatcherConfig) Key() string { if c.Docker != nil { return c.Docker.ContainerID } - return c.Proxmox.Node + ":" + strconv.Itoa(c.Proxmox.VMID) + return c.Proxmox.Node + ":" + strconv.FormatUint(c.Proxmox.VMID, 10) } func (c *IdlewatcherConfig) ContainerName() string { if c.Docker != nil { return c.Docker.ContainerName } - return "lxc-" + strconv.Itoa(c.Proxmox.VMID) + return "lxc-" + strconv.FormatUint(c.Proxmox.VMID, 10) } -func (c *IdlewatcherConfig) Validate() gperr.Error { +func (c *IdlewatcherConfig) Validate() error { if c.IdleTimeout == 0 { // zero idle timeout means no idle watcher c.valErr = nil return nil @@ -89,13 +97,13 @@ func (c *IdlewatcherConfig) Validate() gperr.Error { return c.valErr } -func (c *IdlewatcherConfig) ValErr() gperr.Error { +func (c *IdlewatcherConfig) ValErr() error { return c.valErr } func (c *IdlewatcherConfig) validateProvider() error { if c.Docker == nil && c.Proxmox == nil { - return gperr.New("missing idlewatcher provider config") + return ErrMissingProviderConfig } return nil } @@ -118,16 +126,16 @@ func (c *IdlewatcherConfig) validateStopMethod() error { case ContainerStopMethodPause, ContainerStopMethodStop, ContainerStopMethodKill: return nil default: - return gperr.New("invalid stop method").Subject(string(c.StopMethod)) + return gperr.PrependSubject(ErrInvalidStopMethod, string(c.StopMethod)) } } func (c *IdlewatcherConfig) validateStopSignal() error { switch c.StopSignal { - case "", "SIGINT", "SIGTERM", "SIGQUIT", "SIGHUP", "INT", "TERM", "QUIT", "HUP": + case "", "SIGINT", "SIGTERM", "SIGKILL", "SIGQUIT", "SIGHUP", "INT", "TERM", "KILL", "QUIT", "HUP": return nil default: - return gperr.New("invalid stop signal").Subject(string(c.StopSignal)) + return gperr.PrependSubject(ErrInvalidStopSignal, string(c.StopSignal)) } } @@ -141,7 +149,7 @@ func (c *IdlewatcherConfig) validateStartEndpoint() error { c.StartEndpoint = c.StartEndpoint[:i] } if len(c.StartEndpoint) == 0 { - return gperr.New("start endpoint must not be empty if defined") + return ErrEmptyStartEndpoint } _, err := url.ParseRequestURI(c.StartEndpoint) return err diff --git a/internal/types/routes.go b/internal/types/routes.go index 2d63215a..c0238015 100644 --- a/internal/types/routes.go +++ b/internal/types/routes.go @@ -3,11 +3,11 @@ package types import ( "net/http" + "github.com/rs/zerolog" "github.com/yusing/godoxy/internal/agentpool" "github.com/yusing/godoxy/internal/homepage" nettypes "github.com/yusing/godoxy/internal/net/types" provider "github.com/yusing/godoxy/internal/route/provider/types" - gperr "github.com/yusing/goutils/errs" "github.com/yusing/goutils/http/reverseproxy" "github.com/yusing/goutils/pool" "github.com/yusing/goutils/task" @@ -18,8 +18,11 @@ type ( task.TaskStarter task.TaskFinisher pool.Object + zerolog.LogObjectMarshaler + ProviderName() string GetProvider() RouteProvider + ListenURL() *nettypes.URL TargetURL() *nettypes.URL HealthMonitor() HealthMonitor SetHealthMonitor(m HealthMonitor) @@ -62,8 +65,8 @@ type ( Stream() nettypes.Stream } RouteProvider interface { - Start(task.Parent) gperr.Error - LoadRoutes() gperr.Error + Start(parent task.Parent) error + LoadRoutes() error GetRoute(alias string) (r Route, ok bool) // should be used like `for _, r := range p.IterRoutes` (no braces), not calling it directly IterRoutes(yield func(alias string, r Route) bool) diff --git a/internal/watcher/README.md b/internal/watcher/README.md index 6eca197a..29efd8f2 100644 --- a/internal/watcher/README.md +++ b/internal/watcher/README.md @@ -40,14 +40,14 @@ Alias to `events.Event` for convenience. type Watcher interface { // Events returns channels for receiving events and errors. // The channels are closed when the context is cancelled. - Events(ctx context.Context) (<-chan Event, <-chan gperr.Error) + Events(ctx context.Context) (<-chan Event, <-chan error) } ``` Core interface that all watchers implement. Callers receive: - `<-chan Event` - Events as they occur -- `<-chan gperr.Error` - Errors during event watching +- `<-chan error` - Errors during event watching ### Docker Watcher @@ -62,8 +62,8 @@ Creates a Docker watcher for the given Docker configuration. #### Event Streaming ```go -func (w DockerWatcher) Events(ctx context.Context) (<-chan Event, <-chan gperr.Error) -func (w DockerWatcher) EventsWithOptions(ctx context.Context, options DockerListOptions) (<-chan Event, <-chan gperr.Error) +func (w DockerWatcher) Events(ctx context.Context) (<-chan Event, <-chan error) +func (w DockerWatcher) EventsWithOptions(ctx context.Context, options DockerListOptions) (<-chan Event, <-chan error) ``` Returns event and error channels. `Events` uses default filters; `EventsWithOptions` allows custom filters. @@ -107,7 +107,8 @@ graph TD A --> E[DirectoryWatcher] B --> F[Docker Client] - G[events.EventQueue] --> H[Event Consumers] + G[events.Event] --> H[Event Consumers] + H --> I[goutils/eventqueue] ``` | Component | Responsibility | @@ -186,23 +187,24 @@ Docker watcher is configured via `types.DockerProviderConfig`: ### Internal Dependencies -| Package | Purpose | -| -------------------------------- | -------------------------------- | -| `internal/docker` | Docker client management | -| `internal/watcher/events` | Event type definitions and queue | -| `internal/types` | Configuration types | -| `github.com/yusing/goutils/task` | Lifetime management | +| Package | Purpose | +| -------------------------------- | -------------------------------------- | +| `internal/docker` | Docker client management | +| `internal/watcher/events` | Event type definitions (Event, Action) | +| `internal/types` | Configuration types | +| `github.com/yusing/goutils/task` | Lifetime management | ### External Dependencies -| Dependency | Purpose | -| ----------------------- | --------------------------- | -| `github.com/moby/moby` | Docker API types and client | -| `github.com/rs/zerolog` | Structured logging | +| Dependency | Purpose | +| -------------------------------- | --------------------------- | +| `github.com/moby/moby` | Docker API types and client | +| `github.com/rs/zerolog` | Structured logging | +| `github.com/yusing/goutils/errs` | Error handling | ### Integration Points -- Events channel feeds into `EventQueue` for buffering +- Events channel feeds into `goutils/eventqueue.EventQueue` for buffering - Route provider subscribes to events for configuration reloads ## Observability @@ -288,37 +290,6 @@ dw := watcher.NewDockerWatcher(cfg) eventCh, errCh := dw.EventsWithOptions(ctx, options) ``` -### Integration with Event Queue - -```go -import ( - "github.com/yusing/godoxy/internal/watcher" - "github.com/yusing/godoxy/internal/watcher/events" - "github.com/yusing/goutils/task" -) - -func watchWithQueue(ctx context.Context) { - dw := watcher.NewDockerWatcher(cfg) - eventCh, errCh := dw.Events(ctx) - - queue := events.NewEventQueue( - task.Subtask("event-flush"), - 5*time.Second, - func(batch []events.Event) { - // Process batch of events - for _, e := range batch { - log.Info().Str("event", e.String()).Msg("event batch") - } - }, - func(err gperr.Error) { - log.Error().Err(err).Msg("event error") - }, - ) - - queue.Start(eventCh, errCh) -} -``` - ## Testing Notes - Mock Docker client via `internal/docker` package @@ -327,6 +298,7 @@ func watchWithQueue(ctx context.Context) { ## Related Packages -- `internal/watcher/events` - Event definitions and queuing +- `internal/watcher/events` - Event type definitions (Event, Action, EventType) +- `goutils/eventqueue` - Generic buffered event queue - `internal/docker` - Docker client management - `internal/route/routes` - Route management diff --git a/internal/watcher/config_file_watcher.go b/internal/watcher/config_file_watcher.go index 75a9522d..87dd5c16 100644 --- a/internal/watcher/config_file_watcher.go +++ b/internal/watcher/config_file_watcher.go @@ -17,7 +17,7 @@ func initConfigDirWatcher() { configDirWatcher = NewDirectoryWatcher(t, common.ConfigBasePath) } -// create a new file watcher for file under ConfigBasePath. +// NewConfigFileWatcher creates a new file watcher for a file under common.ConfigBasePath. func NewConfigFileWatcher(filename string) Watcher { configDirWatcherInitOnce.Do(initConfigDirWatcher) return configDirWatcher.Add(filename) diff --git a/internal/watcher/directory_watcher.go b/internal/watcher/directory_watcher.go index d491ac81..21aef302 100644 --- a/internal/watcher/directory_watcher.go +++ b/internal/watcher/directory_watcher.go @@ -9,8 +9,7 @@ import ( "github.com/fsnotify/fsnotify" "github.com/rs/zerolog" "github.com/rs/zerolog/log" - "github.com/yusing/godoxy/internal/watcher/events" - gperr "github.com/yusing/goutils/errs" + watcherEvents "github.com/yusing/godoxy/internal/watcher/events" "github.com/yusing/goutils/task" ) @@ -24,7 +23,7 @@ type DirWatcher struct { mu sync.Mutex eventCh chan Event - errCh chan gperr.Error + errCh chan error task *task.Task } @@ -55,14 +54,17 @@ func NewDirectoryWatcher(parent task.Parent, dirPath string) *DirWatcher { w: w, fwMap: make(map[string]*fileWatcher), eventCh: make(chan Event), - errCh: make(chan gperr.Error), + errCh: make(chan error), task: parent.Subtask("dir_watcher("+dirPath+")", true), } go helper.start() return helper } -func (h *DirWatcher) Events(_ context.Context) (<-chan Event, <-chan gperr.Error) { +var _ Watcher = (*DirWatcher)(nil) + +// Events implements the Watcher interface. +func (h *DirWatcher) Events(_ context.Context) (<-chan Event, <-chan error) { return h.eventCh, h.errCh } @@ -78,7 +80,7 @@ func (h *DirWatcher) Add(relPath string) Watcher { s = &fileWatcher{ relPath: relPath, eventCh: make(chan Event), - errCh: make(chan gperr.Error), + errCh: make(chan error), } h.fwMap[relPath] = s return s @@ -113,23 +115,23 @@ func (h *DirWatcher) start() { relPath := strings.TrimPrefix(fsEvent.Name, h.dir) relPath = strings.TrimPrefix(relPath, "/") - if len(relPath) > 0 && relPath[0] == '.' { // hideden file + if len(relPath) > 0 && relPath[0] == '.' { // hidden file continue } msg := Event{ - Type: events.EventTypeFile, + Type: watcherEvents.EventTypeFile, ActorName: relPath, } switch { case fsEvent.Has(fsnotify.Write): - msg.Action = events.ActionFileWritten + msg.Action = watcherEvents.ActionFileWritten case fsEvent.Has(fsnotify.Create): - msg.Action = events.ActionFileCreated + msg.Action = watcherEvents.ActionFileCreated case fsEvent.Has(fsnotify.Remove): - msg.Action = events.ActionFileDeleted + msg.Action = watcherEvents.ActionFileDeleted case fsEvent.Has(fsnotify.Rename): - msg.Action = events.ActionFileRenamed + msg.Action = watcherEvents.ActionFileRenamed default: // ignore other events continue } @@ -162,7 +164,7 @@ func (h *DirWatcher) start() { return } select { - case h.errCh <- gperr.Wrap(err): + case h.errCh <- err: default: } } diff --git a/internal/watcher/docker_watcher.go b/internal/watcher/docker_watcher.go index 47ebb3a8..c92cbd58 100644 --- a/internal/watcher/docker_watcher.go +++ b/internal/watcher/docker_watcher.go @@ -3,6 +3,7 @@ package watcher import ( "context" "errors" + "fmt" "time" dockerEvents "github.com/docker/docker/api/types/events" @@ -11,8 +12,7 @@ import ( "github.com/rs/zerolog/log" "github.com/yusing/godoxy/internal/docker" "github.com/yusing/godoxy/internal/types" - "github.com/yusing/godoxy/internal/watcher/events" - gperr "github.com/yusing/goutils/errs" + watcherEvents "github.com/yusing/godoxy/internal/watcher/events" ) type ( @@ -46,8 +46,8 @@ var ( dockerWatcherRetryInterval = 3 * time.Second reloadTrigger = Event{ - Type: events.EventTypeDocker, - Action: events.ActionForceReload, + Type: watcherEvents.EventTypeDocker, + Action: watcherEvents.ActionForceReload, ActorAttributes: map[string]string{}, ActorName: "", ActorID: "", @@ -64,18 +64,21 @@ func NewDockerWatcher(dockerCfg types.DockerProviderConfig) DockerWatcher { } } -func (w DockerWatcher) Events(ctx context.Context) (<-chan Event, <-chan gperr.Error) { +var _ Watcher = (*DockerWatcher)(nil) + +// Events implements the Watcher interface. +func (w DockerWatcher) Events(ctx context.Context) (<-chan Event, <-chan error) { return w.EventsWithOptions(ctx, optionsDefault) } -func (w DockerWatcher) EventsWithOptions(ctx context.Context, options DockerListOptions) (<-chan Event, <-chan gperr.Error) { +func (w DockerWatcher) EventsWithOptions(ctx context.Context, options DockerListOptions) (<-chan Event, <-chan error) { eventCh := make(chan Event) - errCh := make(chan gperr.Error) + errCh := make(chan error) go func() { client, err := docker.NewClient(w.cfg) if err != nil { - errCh <- gperr.Wrap(err, "docker watcher: failed to initialize client") + errCh <- fmt.Errorf("docker watcher: failed to initialize client: %w", err) return } @@ -105,20 +108,19 @@ func (w DockerWatcher) EventsWithOptions(ctx context.Context, options DockerList eventCh <- reloadTrigger retry := time.NewTicker(dockerWatcherRetryInterval) - defer retry.Stop() - ok := false outer: - for !ok { + for { select { case <-ctx.Done(): + retry.Stop() return case <-retry.C: if checkConnection(ctx, client) { - ok = true break outer } } } + retry.Stop() // connection successful, trigger reload (reload routes) eventCh <- reloadTrigger // reopen event channel @@ -130,23 +132,23 @@ func (w DockerWatcher) EventsWithOptions(ctx context.Context, options DockerList return eventCh, errCh } -func (w DockerWatcher) parseError(err error) gperr.Error { +func (w DockerWatcher) parseError(err error) error { if errors.Is(err, context.DeadlineExceeded) { - return gperr.New("docker client connection timeout") + return errors.New("docker client connection timeout") } if client.IsErrConnectionFailed(err) { - return gperr.New("docker client connection failure") + return errors.New("docker client connection failure") } - return gperr.Wrap(err) + return err } func (w DockerWatcher) handleEvent(event dockerEvents.Message, ch chan<- Event) { - action, ok := events.DockerEventMap[event.Action] + action, ok := watcherEvents.DockerEventMap[event.Action] if !ok { return } ch <- Event{ - Type: events.EventTypeDocker, + Type: watcherEvents.EventTypeDocker, ActorID: event.Actor.ID, ActorAttributes: event.Actor.Attributes, // labels ActorName: event.Actor.Attributes["name"], diff --git a/internal/watcher/events/README.md b/internal/watcher/events/README.md deleted file mode 100644 index 936cb033..00000000 --- a/internal/watcher/events/README.md +++ /dev/null @@ -1,444 +0,0 @@ -# Watcher Events - -Defines event types and utilities for the watcher system, providing a unified way to handle Docker and file system events. - -## Overview - -The `internal/watcher/events` package defines the event model used throughout GoDoxy's watcher system. It provides types for container actions, file operations, and buffered event processing through the EventQueue. - -### Primary Consumers - -- `internal/watcher` - Docker and file watchers emit events -- `internal/route` - Route provider consumes events for configuration updates -- `internal/idlewatcher` - Consumes container lifecycle events - -### Non-goals - -- Does not implement event storage or persistence -- Does not provide event filtering (handled by watchers) -- Does not transform events (only normalization) - -### Stability - -Internal package. Event types and action constants are stable once defined. - -## Public API - -### Exported Types - -#### Event - -```go -type Event struct { - Type EventType // Event source (docker, file) - ActorName string // container name or file path - ActorID string // container ID or empty - ActorAttributes map[string]string // container labels or empty - Action Action // Specific action performed -} -``` - -Represents an event from any watcher source. - -#### Action - -```go -type Action uint16 -``` - -Bitmask flags for event actions. Supports efficient group checking via bitwise operations. - -**Container Actions:** - -```go -const ( - ActionContainerCreate Action = (1 << iota) // Container created - ActionContainerStart // Container started - ActionContainerUnpause // Container unpaused - ActionContainerKill // Container killed - ActionContainerStop // Container stopped - ActionContainerPause // Container paused - ActionContainerDie // Container died - ActionContainerDestroy // Container destroyed -) -``` - -**File Actions:** - -```go -const ( - ActionFileWritten Action = (1 << iota) // File written/modified - ActionFileCreated // File created - ActionFileDeleted // File deleted - ActionFileRenamed // File renamed -) -``` - -**Special Actions:** - -```go -const ( - ActionForceReload Action = 1 << 10 // Force configuration reload -) -``` - -#### EventType - -```go -type EventType string - -const ( - EventTypeDocker EventType = "docker" - EventTypeFile EventType = "file" -) -``` - -### Event Methods - -#### String - -```go -func (e Event) String() string -``` - -Returns a human-readable representation: `"action actor_name"`. - -**Example:** - -```go -event := Event{Type: EventTypeDocker, ActorName: "nginx", Action: ActionContainerStart} -fmt.Println(event.String()) // "start nginx" -``` - -#### Action Classification - -```go -func (a Action) IsContainerStart() bool -func (a Action) IsContainerStop() bool -func (a Action) IsContainerPause() bool -``` - -Efficiently check action categories using bitmask operations. - -**Example:** - -```go -if event.Action.IsContainerStart() { - // Container is starting -} -``` - -### Event Queue - -#### EventQueue - -```go -type EventQueue struct { - task *task.Task - queue []Event - ticker *time.Ticker - flushInterval time.Duration - onFlush OnFlushFunc - onError OnErrorFunc -} -``` - -Buffers events and flushes them in batches at configurable intervals. - -#### Callbacks - -```go -type OnFlushFunc = func(events []Event) -type OnErrorFunc = func(err gperr.Error) -``` - -Callbacks invoked when events are flushed or errors occur. - -#### Constructor - -```go -func NewEventQueue(queueTask *task.Task, flushInterval time.Duration, onFlush OnFlushFunc, onError OnErrorFunc) *EventQueue -``` - -Creates a new event queue. - -**Lifecycle:** - -- Queue starts via `Start(eventCh, errCh)` goroutine -- Events are buffered until flush interval -- On flush: queue is cloned, cleared, and `onFlush` is called -- Errors from error channel trigger `onError` -- Panics in `onFlush` are recovered and sent to `onError` -- Task cancellation discards remaining events - -#### Start - -```go -func (e *EventQueue) Start(eventCh <-chan Event, errCh <-chan gperr.Error) -``` - -Begins processing events from the channels. Must be called after construction. - -### Event Mapping - -#### DockerEventMap - -```go -var DockerEventMap = map[dockerEvents.Action]Action{ - dockerEvents.ActionCreate: ActionContainerCreate, - dockerEvents.ActionStart: ActionContainerStart, - dockerEvents.ActionUnPause: ActionContainerUnpause, - dockerEvents.ActionKill: ActionContainerKill, - dockerEvents.ActionStop: ActionContainerStop, - dockerEvents.ActionPause: ActionContainerPause, - dockerEvents.ActionDie: ActionContainerDie, - dockerEvents.ActionDestroy: ActionContainerDestroy, -} -``` - -Maps Docker event actions to watcher event actions. - -## Architecture - -### Event Flow - -```mermaid -sequenceDiagram - participant Source as Event Source - participant Watcher as Watcher - participant EventQueue as Event Queue - participant Processor as Event Processor - - Source->>Watcher: Raw Event - Watcher->>Watcher: Parse & normalize - Watcher->>EventQueue: eventCh <- Event - EventQueue->>EventQueue: Buffer event - EventQueue->>EventQueue: Check flush timer - - alt Flush interval reached - EventQueue->>EventQueue: Clone queue - EventQueue->>EventQueue: Clear queue - EventQueue->>Processor: onFlush(events) - Processor-->>EventQueue: Complete - end - - alt Error occurred - Watcher->>EventQueue: errCh <- Error - EventQueue->>EventQueue: Handle error - EventQueue->>Processor: onError(err) - end -``` - -### Queue Behavior - -```mermaid -stateDiagram-v2 - [*] --> Empty: Start() - Empty --> Buffering: Event received - Buffering --> Flushing: Flush interval - Flushing --> Buffering: Reset timer - Buffering --> Empty: Task cancelled - Flushing --> Empty: Task cancelled - Flushing --> [*]: Finish() -``` - -### Core Components - -| Component | Responsibility | -| ------------ | ------------------------------------- | -| `Event` | Unified event representation | -| `Action` | Bitmask for efficient action checking | -| `EventQueue` | Buffered batch processing of events | - -### Queue Capacity - -```go -const eventQueueCapacity = 10 -``` - -Queue has fixed capacity. Excess events may block the sender. - -## Configuration Surface - -EventQueue is configured at construction time: - -| Parameter | Type | Default | Description | -| --------------- | --------------- | ------- | ---------------------------------- | -| `queueTask` | `*task.Task` | - | Lifetime management | -| `flushInterval` | `time.Duration` | - | How often to flush buffered events | -| `onFlush` | `OnFlushFunc` | - | Called with batch of events | -| `onError` | `OnErrorFunc` | - | Called on errors | - -## Dependency and Integration Map - -### Internal Dependencies - -| Package | Purpose | -| -------------------------------- | --------------------------------------- | -| `internal/common` | Debug mode detection for panic handling | -| `github.com/yusing/goutils/task` | Lifetime management | - -### External Dependencies - -| Dependency | Purpose | -| ---------------------- | ------------------ | -| `github.com/moby/moby` | Docker event types | - -### Integration Points - -- Watchers emit events via channel to `EventQueue.Start()` -- Processors implement `OnFlushFunc` and `OnErrorFunc` callbacks - -## Observability - -### Logs - -No direct logging in this package. Errors are propagated via callbacks. - -### Metrics - -None exposed. - -## Failure Modes and Recovery - -| Failure | Detection | Recovery | -| ---------------- | ------------------------- | --------------------------------- | -| Channel closed | `!ok` on receive | Queue stops | -| Panic in onFlush | `recover()` | Error sent to `onError`, continue | -| Task cancelled | `<-task.Context().Done()` | Queue stops, events discarded | -| Queue full | `append()` blocks | Sender blocks | - -### Panic Recovery - -```go -e.onFlush = func(events []Event) { - defer func() { - if errV := recover(); errV != nil { - if err, ok := errV.(error); ok { - e.onError(gperr.Wrap(err).Subject(e.task.Name())) - } else { - e.onError(gperr.New("recovered panic in onFlush").Withf("%v", errV).Subject(e.task.Name())) - } - if common.IsDebug { - panic(string(debug.Stack())) - } - } - }() - origOnFlush(events) -} -``` - -In debug mode, panics are re-panicked after logging. - -## Usage Examples - -### Basic Event Queue Setup - -```go -import ( - "context" - "fmt" - "time" - "github.com/yusing/godoxy/internal/watcher/events" - "github.com/yusing/goutils/task" -) - -func setupEventQueue(ctx context.Context) *events.EventQueue { - flushTask := task.Subtask("event-flush") - - return events.NewEventQueue( - flushTask, - 5*time.Second, - func(events []events.Event) { - fmt.Printf("Flushed %d events:\n", len(events)) - for _, e := range events { - fmt.Printf(" %s\n", e) - } - }, - func(err gperr.Error) { - fmt.Printf("Error: %v\n", err) - }, - ) -} -``` - -### Integration with Docker Watcher - -```go -import ( - "context" - "github.com/yusing/godoxy/internal/watcher" - "github.com/yusing/godoxy/internal/watcher/events" - "github.com/yusing/goutils/task" -) - -func watchContainers(ctx context.Context, dockerCfg types.DockerProviderConfig) error { - dw := watcher.NewDockerWatcher(dockerCfg) - eventCh, errCh := dw.Events(ctx) - - queue := events.NewEventQueue( - task.Subtask("container-events"), - 10*time.Second, - handleContainerEvents, - logError, - ) - - queue.Start(eventCh, errCh) - return nil -} - -func handleContainerEvents(batch []events.Event) { - for _, event := range batch { - if event.Action.IsContainerStart() { - log.Info().Str("container", event.ActorName).Msg("Container started") - } else if event.Action.IsContainerStop() { - log.Info().Str("container", event.ActorName).Msg("Container stopped") - } - } -} - -func logError(err gperr.Error) { - log.Error().Err(err).Msg("event queue error") -} -``` - -### Event Classification - -```go -func classifyEvent(event events.Event) string { - switch { - case event.Type == events.EventTypeDocker: - switch { - case event.Action.IsContainerStart(): - return "container_start" - case event.Action.IsContainerStop(): - return "container_stop" - case event.Action == events.ActionContainerPause: - return "container_pause" - case event.Action == events.ActionForceReload: - return "force_reload" - } - case event.Type == events.EventTypeFile: - switch { - case event.Action == events.ActionFileWritten: - return "file_modified" - case event.Action == events.ActionFileDeleted: - return "file_deleted" - } - } - return "unknown" -} -``` - -## Testing Notes - -- Test with synthetic events via channel -- Verify batch ordering is preserved -- Test panic recovery by injecting panics in callback -- Verify task cancellation discards events correctly - -## Related Packages - -- `internal/watcher` - Watcher implementations that emit events -- `internal/task` - Task management for queue lifetime -- `internal/idlewatcher/provider` - Provider implementations using events diff --git a/internal/watcher/events/event_queue.go b/internal/watcher/events/event_queue.go deleted file mode 100644 index 40002c47..00000000 --- a/internal/watcher/events/event_queue.go +++ /dev/null @@ -1,106 +0,0 @@ -package events - -import ( - "runtime/debug" - "time" - - "github.com/yusing/godoxy/internal/common" - gperr "github.com/yusing/goutils/errs" - "github.com/yusing/goutils/task" -) - -type ( - EventQueue struct { - task *task.Task - queue []Event - ticker *time.Ticker - flushInterval time.Duration - onFlush OnFlushFunc - onError OnErrorFunc - } - OnFlushFunc = func(events []Event) - OnErrorFunc = func(err gperr.Error) -) - -const eventQueueCapacity = 10 - -// NewEventQueue returns a new EventQueue with the given -// queueTask, flushInterval, onFlush and onError. -// -// The returned EventQueue will start a goroutine to flush events in the queue -// when the flushInterval is reached. -// -// The onFlush function is called when the flushInterval is reached and the queue is not empty, -// -// The onError function is called when an error received from the errCh, -// or panic occurs in the onFlush function. Panic will cause a E.ErrPanicRecv error. -// -// flushTask.Finish must be called after the flush is done, -// but the onFlush function can return earlier (e.g. run in another goroutine). -// -// If task is canceled before the flushInterval is reached, the events in queue will be discarded. -func NewEventQueue(queueTask *task.Task, flushInterval time.Duration, onFlush OnFlushFunc, onError OnErrorFunc) *EventQueue { - return &EventQueue{ - task: queueTask, - queue: make([]Event, 0, eventQueueCapacity), - ticker: time.NewTicker(flushInterval), - flushInterval: flushInterval, - onFlush: onFlush, - onError: onError, - } -} - -func (e *EventQueue) Start(eventCh <-chan Event, errCh <-chan gperr.Error) { - origOnFlush := e.onFlush - // recover panic in onFlush when in production mode - e.onFlush = func(events []Event) { - defer func() { - if errV := recover(); errV != nil { - if err, ok := errV.(error); ok { - e.onError(gperr.Wrap(err).Subject(e.task.Name())) - } else { - e.onError(gperr.New("recovered panic in onFlush").Withf("%v", errV).Subject(e.task.Name())) - } - if common.IsDebug { - panic(string(debug.Stack())) - } - } - }() - origOnFlush(events) - } - - go func() { - defer e.ticker.Stop() - defer e.task.Finish(nil) - - for { - select { - case <-e.task.Context().Done(): - return - case <-e.ticker.C: - if len(e.queue) > 0 { - // clone -> clear -> flush - queue := make([]Event, len(e.queue)) - copy(queue, e.queue) - - e.queue = e.queue[:0] - - e.onFlush(queue) - } - e.ticker.Reset(e.flushInterval) - case event, ok := <-eventCh: - if !ok { - return - } - e.queue = append(e.queue, event) - case err, ok := <-errCh: - if !ok { - return - } - if err != nil { - e.onError(err) - } - } - } - }() -} diff --git a/internal/watcher/events/events.go b/internal/watcher/events/events.go index 4fe137cd..9ac694d9 100644 --- a/internal/watcher/events/events.go +++ b/internal/watcher/events/events.go @@ -1,7 +1,8 @@ -package events +package watcherevents import ( "fmt" + "maps" dockerEvents "github.com/docker/docker/api/types/events" ) @@ -64,14 +65,22 @@ var fileActionNameMap = map[Action]string{ ActionFileRenamed: "renamed", } +var dockerActionNameMap = map[Action]string{ + ActionContainerCreate: "created", + ActionContainerStart: "started", + ActionContainerUnpause: "unpaused", + ActionContainerKill: "killed", + ActionContainerStop: "stopped", + ActionContainerPause: "paused", + ActionContainerDie: "died", + ActionContainerDestroy: "destroyed", +} + var actionNameMap = func() (m map[Action]string) { - m = make(map[Action]string, len(DockerEventMap)) - for k, v := range DockerEventMap { - m[v] = string(k) - } - for k, v := range fileActionNameMap { - m[k] = v - } + m = make(map[Action]string, len(fileActionNameMap)+len(dockerActionNameMap)+1) + maps.Copy(m, fileActionNameMap) + maps.Copy(m, dockerActionNameMap) + m[ActionForceReload] = "force-reloaded" return m }() diff --git a/internal/watcher/file_watcher.go b/internal/watcher/file_watcher.go index c0563981..c0983b93 100644 --- a/internal/watcher/file_watcher.go +++ b/internal/watcher/file_watcher.go @@ -2,16 +2,17 @@ package watcher import ( "context" - - gperr "github.com/yusing/goutils/errs" ) type fileWatcher struct { relPath string eventCh chan Event - errCh chan gperr.Error + errCh chan error } -func (fw *fileWatcher) Events(ctx context.Context) (<-chan Event, <-chan gperr.Error) { +var _ Watcher = (*fileWatcher)(nil) + +// Events implements the Watcher interface. +func (fw *fileWatcher) Events(ctx context.Context) (<-chan Event, <-chan error) { return fw.eventCh, fw.errCh } diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index 925d94ed..245ad58a 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -3,12 +3,11 @@ package watcher import ( "context" - "github.com/yusing/godoxy/internal/watcher/events" - gperr "github.com/yusing/goutils/errs" + watcherEvents "github.com/yusing/godoxy/internal/watcher/events" ) -type Event = events.Event +type Event = watcherEvents.Event type Watcher interface { - Events(ctx context.Context) (<-chan Event, <-chan gperr.Error) + Events(ctx context.Context) (<-chan Event, <-chan error) } diff --git a/scripts/benchmark.sh b/scripts/benchmark.sh old mode 100644 new mode 100755 diff --git a/scripts/update-wiki/main.ts b/scripts/update-wiki/main.ts index e195a80d..8e29452d 100644 --- a/scripts/update-wiki/main.ts +++ b/scripts/update-wiki/main.ts @@ -1,6 +1,6 @@ +import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises"; +import path from "node:path"; import { Glob } from "bun"; -import { mkdir, readdir, readFile, rm, writeFile } from "fs/promises"; -import path from "path"; type ImplDoc = { /** Directory path relative to this repo, e.g. "internal/health/check" */ @@ -18,7 +18,11 @@ type ImplDoc = { const START_MARKER = "// GENERATED-IMPL-SIDEBAR-START"; const END_MARKER = "// GENERATED-IMPL-SIDEBAR-END"; -const skipSubmodules = ["internal/go-oidc/", "internal/gopsutil/", "internal/go-proxmox/"]; +const skipSubmodules = [ + "internal/go-oidc/", + "internal/gopsutil/", + "internal/go-proxmox/", +]; function escapeRegex(s: string) { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); @@ -72,7 +76,7 @@ function isExternalOrAbsoluteUrl(url: string) { function isRepoSourceFilePath(filePath: string) { // Conservative allow-list: avoid rewriting .md (non-README) which may be VitePress docs. return /\.(go|ts|tsx|js|jsx|py|sh|yml|yaml|json|toml|env|css|html|txt)$/i.test( - filePath + filePath, ); } @@ -88,7 +92,7 @@ function parseFileLineSuffix(urlNoFragment: string): { function rewriteMarkdownLinksOutsideFences( md: string, - rewriteInline: (url: string) => string + rewriteInline: (url: string) => string, ) { const lines = md.split("\n"); let inFence = false; @@ -108,7 +112,7 @@ function rewriteMarkdownLinksOutsideFences( (_full, urlRaw: string, maybeTitle: string | undefined) => { const rewritten = rewriteInline(urlRaw); return `](${rewritten}${maybeTitle ?? ""})`; - } + }, ); } @@ -138,17 +142,30 @@ function rewriteImplMarkdown(params: { // 1) Directory links like "common" or "common/" that have a README const dirPathNormalized = urlNoFragment.replace(/\/+$/, ""); + let rewritten: string | undefined; + // First try exact match if (dirPathToDocRoute.has(dirPathNormalized)) { - const rewritten = `${dirPathToDocRoute.get( - dirPathNormalized - )!}${fragment}`; + rewritten = `${dirPathToDocRoute.get(dirPathNormalized)}${fragment}`; + } else { + // Fallback: check parent directories for a README + // This handles paths like "internal/watcher/events" where only the parent has a README + let parentPath = dirPathNormalized; + while (parentPath.includes("/")) { + parentPath = parentPath.slice(0, parentPath.lastIndexOf("/")); + if (dirPathToDocRoute.has(parentPath)) { + rewritten = `${dirPathToDocRoute.get(parentPath)}${fragment}`; + break; + } + } + } + if (rewritten) { return angleWrapped === urlRaw ? rewritten : `<${rewritten}>`; } // 2) Intra-repo README links -> VitePress impl routes if (/(^|\/)README\.md$/.test(urlNoFragment)) { const targetReadmeRel = path.posix.normalize( - path.posix.join(pkgPath, urlNoFragment) + path.posix.join(pkgPath, urlNoFragment), ); const route = readmeRelToDocRoute.get(targetReadmeRel); if (route) { @@ -163,10 +180,11 @@ function rewriteImplMarkdown(params: { const { filePath, line } = parseFileLineSuffix(urlNoFragment); if (isRepoSourceFilePath(filePath)) { const repoRel = path.posix.normalize( - path.posix.join(pkgPath, filePath) + path.posix.join(pkgPath, filePath), ); - const githubUrl = `${repoUrl}/blob/main/${repoRel}${line ? `#L${line}` : "" - }`; + const githubUrl = `${repoUrl}/blob/main/${repoRel}${ + line ? `#L${line}` : "" + }`; const rewritten = `${githubUrl}${fragment}`; return angleWrapped === urlRaw ? rewritten : `<${rewritten}>`; } @@ -238,7 +256,7 @@ async function writeImplDocCopy(params: { async function syncImplDocs( repoRootAbs: string, - wikiRootAbs: string + wikiRootAbs: string, ): Promise { const implDirAbs = path.join(wikiRootAbs, "src", "impl"); await mkdir(implDirAbs, { recursive: true }); @@ -249,7 +267,7 @@ async function syncImplDocs( expectedFileNames.add("introduction.md"); const repoUrl = normalizeRepoUrl( - Bun.env.REPO_URL ?? "https://github.com/yusing/godoxy" + Bun.env.REPO_URL ?? "https://github.com/yusing/godoxy", ); // Precompute mapping from repo-relative README path -> VitePress route. @@ -336,21 +354,21 @@ async function updateVitepressSidebar(wikiRootAbs: string, docs: ImplDoc[]) { // We keep indentation based on the marker line. const markerRe = new RegExp( `(^[\\t ]*)${escapeRegex(START_MARKER)}[\\s\\S]*?\\n\\1${escapeRegex( - END_MARKER + END_MARKER, )}`, - "m" + "m", ); const m = original.match(markerRe); if (!m) { throw new Error( - `sidebar markers not found in ${configPathAbs}. Expected lines: ${START_MARKER} ... ${END_MARKER}` + `sidebar markers not found in ${configPathAbs}. Expected lines: ${START_MARKER} ... ${END_MARKER}`, ); } const indent = m[1] ?? ""; const generated = `${indent}${START_MARKER}\n${renderSidebarItems( docs, - indent + indent, )}${indent}${END_MARKER}`; const updated = original.replace(markerRe, generated); diff --git a/socket-proxy.Dockerfile b/socket-proxy.Dockerfile index cbe18ba6..e11186e6 100644 --- a/socket-proxy.Dockerfile +++ b/socket-proxy.Dockerfile @@ -1,5 +1,5 @@ # Stage 1: deps -FROM golang:1.25.6-alpine AS deps +FROM golang:1.26.0-alpine AS deps HEALTHCHECK NONE # package version does not matter diff --git a/socket-proxy/go.mod b/socket-proxy/go.mod index 72b62008..9e4d88b3 100644 --- a/socket-proxy/go.mod +++ b/socket-proxy/go.mod @@ -1,13 +1,13 @@ module github.com/yusing/godoxy/socketproxy -go 1.25.6 +go 1.26.0 replace github.com/yusing/goutils => ../goutils require ( github.com/gorilla/mux v1.8.1 github.com/yusing/goutils v0.7.0 - golang.org/x/net v0.49.0 + golang.org/x/net v0.50.0 ) require ( @@ -17,6 +17,6 @@ require ( github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/puzpuzpuz/xsync/v4 v4.4.0 // indirect github.com/rs/zerolog v1.34.0 // indirect - golang.org/x/sys v0.40.0 // indirect - golang.org/x/text v0.33.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.0 // indirect ) diff --git a/socket-proxy/go.sum b/socket-proxy/go.sum index a8868111..653fc02c 100644 --- a/socket-proxy/go.sum +++ b/socket-proxy/go.sum @@ -21,14 +21,14 @@ github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= -golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= +golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= -golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= -golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=