From 88f3a95b610dceff7fd1dbf36dd94bd5cd244530 Mon Sep 17 00:00:00 2001 From: Yuzerion Date: Wed, 16 Apr 2025 12:56:17 +0800 Subject: [PATCH 1/2] chore: add migrations directory to Dockerfile --- Dockerfile | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 981901d..4e4ebb0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,6 +24,7 @@ COPY cmd ./cmd COPY internal ./internal COPY pkg ./pkg COPY agent ./agent +COPY migrations ./migrations ARG VERSION ENV VERSION=${VERSION} @@ -59,4 +60,4 @@ ENV DOCKER_HOST=unix:///var/run/docker.sock WORKDIR /app -CMD ["/app/run"] \ No newline at end of file +CMD ["/app/run"] From 57292f0fe8a1b63357feefc3903c5749e987ff0d Mon Sep 17 00:00:00 2001 From: Yuzerion Date: Wed, 16 Apr 2025 14:52:33 +0800 Subject: [PATCH 2/2] feat: proxmox idlewatcher (#88) * feat: idle sleep for proxmox LXCs * refactor: replace deprecated docker api types * chore(api): remove debug task list endpoint * refactor: move servemux to gphttp/servemux; favicon.go to v1/favicon * refactor: introduce Pool interface, move agent_pool to agent module * refactor: simplify api code * feat: introduce debug api * refactor: remove net.URL and net.CIDR types, improved unmarshal handling * chore: update Makefile for debug build tag, update README * chore: add gperr.Unwrap method * feat: relative time and duration formatting * chore: add ROOT_DIR environment variable, refactor * migration: move homepage override and icon cache to $BASE_DIR/data, add migration code * fix: nil dereference on marshalling service health * fix: wait for route deletion * chore: enhance tasks debuggability * feat: stdout access logger and MultiWriter * fix(agent): remove agent properly on verify error * fix(metrics): disk exclusion logic and added corresponding tests * chore: update schema and prettify, fix package.json and Makefile * fix: I/O buffer not being shrunk before putting back to pool * feat: enhanced error handling module * chore: deps upgrade * feat: better value formatting and handling --------- Co-authored-by: yusing --- .gitignore | 1 + Makefile | 50 +-- README.md | 33 +- README_CHT.md | 12 +- agent/pkg/agent/agents.go | 16 + agent/pkg/agent/config.go | 26 +- agent/pkg/certs/zip.go | 2 +- agent/pkg/handler/check_health.go | 9 +- agent/pkg/handler/docker_socket.go | 5 +- agent/pkg/handler/proxy_http.go | 5 +- cmd/main.go | 7 + cmd/pprof_production.go | 2 +- go.mod | 8 +- go.sum | 12 +- internal/api/handler.go | 55 +-- internal/api/v1/agents.go | 14 +- internal/api/v1/config_file.go | 6 +- internal/api/v1/debug/handler.go | 75 ++++ internal/api/v1/debug/handler_production.go | 11 + internal/api/v1/dockerapi/containers.go | 2 +- internal/api/v1/dockerapi/info.go | 2 +- internal/api/v1/dockerapi/logs.go | 2 +- internal/api/v1/dockerapi/utils.go | 9 +- internal/api/v1/{ => favicon}/favicon.go | 2 +- internal/api/v1/health.go | 12 +- internal/api/v1/list.go | 12 +- internal/api/v1/new_agent.go | 2 +- internal/api/v1/query/query.go | 4 - internal/api/v1/stats.go | 24 +- internal/api/v1/system_info.go | 6 +- internal/api/v1/version.go | 2 +- internal/autocert/constants.go | 12 +- internal/common/constants.go | 38 +- internal/common/env.go | 2 + internal/common/paths.go | 33 ++ internal/config/agent_pool.go | 66 ---- internal/config/config.go | 38 +- internal/config/config_test.go | 4 +- internal/config/query.go | 33 ++ internal/config/types/config.go | 16 +- internal/docker/client.go | 72 ++-- internal/docker/container.go | 69 ++-- internal/docker/inspect.go | 2 +- internal/entrypoint/entrypoint.go | 2 +- internal/gperr/base.go | 16 +- internal/gperr/builder.go | 24 +- internal/gperr/log.go | 4 +- internal/gperr/multiline.go | 45 +++ internal/gperr/multiline_test.go | 38 ++ internal/gperr/nested_error.go | 2 +- internal/gperr/subject.go | 7 +- internal/gperr/utils.go | 15 +- internal/homepage/route.go | 5 +- internal/idlewatcher/common.go | 13 + internal/idlewatcher/container.go | 60 --- internal/idlewatcher/debug.go | 40 ++ .../{waker_http.go => handle_http.go} | 46 +-- .../{waker_stream.go => handle_stream.go} | 43 +-- internal/idlewatcher/health.go | 122 ++++++ internal/idlewatcher/loading_page.go | 4 +- internal/idlewatcher/provider/docker.go | 90 +++++ internal/idlewatcher/provider/proxmox.go | 129 +++++++ internal/idlewatcher/state.go | 25 +- internal/idlewatcher/types/config.go | 144 ++++--- internal/idlewatcher/types/config_test.go | 5 +- .../idlewatcher/types/container_status.go | 14 + internal/idlewatcher/types/provider.go | 19 + internal/idlewatcher/waker.go | 172 --------- internal/idlewatcher/watcher.go | 359 ++++++++++-------- internal/metrics/period/poller.go | 7 +- internal/metrics/systeminfo/system_info.go | 66 ++-- .../metrics/systeminfo/system_info_test.go | 77 +++- .../net/gphttp/accesslog/access_logger.go | 40 +- .../gphttp/accesslog/access_logger_test.go | 2 +- internal/net/gphttp/accesslog/back_scanner.go | 4 +- internal/net/gphttp/accesslog/config.go | 16 +- internal/net/gphttp/accesslog/file_logger.go | 11 +- .../net/gphttp/accesslog/file_logger_test.go | 7 +- internal/net/gphttp/accesslog/filter.go | 11 +- internal/net/gphttp/accesslog/filter_test.go | 8 +- internal/net/gphttp/accesslog/multi_writer.go | 46 +++ internal/net/gphttp/accesslog/rotate.go | 23 +- internal/net/gphttp/accesslog/rotate_test.go | 2 +- .../net/gphttp/accesslog/stdout_logger.go | 18 + internal/net/gphttp/gpwebsocket/utils.go | 16 + .../net/gphttp/loadbalancer/loadbalancer.go | 9 +- .../net/gphttp/loadbalancer/types/server.go | 12 +- .../net/gphttp/middleware/cidr_whitelist.go | 7 +- .../gphttp/middleware/cloudflare_real_ip.go | 11 +- .../gphttp/middleware/errorpage/error_page.go | 6 +- internal/net/gphttp/middleware/middlewares.go | 2 +- .../gphttp/middleware/modify_request_test.go | 10 +- .../gphttp/middleware/modify_response_test.go | 6 +- internal/net/gphttp/middleware/real_ip.go | 5 +- .../net/gphttp/middleware/real_ip_test.go | 3 +- .../gphttp/middleware/redirect_http_test.go | 6 +- internal/net/gphttp/middleware/test_utils.go | 10 +- .../gphttp/reverseproxy/reverse_proxy_mod.go | 7 +- internal/net/gphttp/servemux/mux.go | 61 +++ internal/net/ping.go | 120 ++++++ internal/net/ping_test.go | 23 ++ internal/net/types/cidr.go | 39 -- internal/net/types/url.go | 56 --- internal/proxmox/client.go | 68 ++++ internal/proxmox/config.go | 69 ++++ internal/proxmox/lxc.go | 236 ++++++++++++ internal/proxmox/lxc_test.go | 40 ++ internal/proxmox/node.go | 50 +++ internal/route/fileserver.go | 2 +- internal/route/provider/docker.go | 6 +- internal/route/provider/docker_test.go | 77 ++-- internal/route/provider/file.go | 2 +- internal/route/provider/provider.go | 2 +- internal/route/reverse_proxy.go | 15 +- internal/route/route.go | 114 +++++- internal/route/route_test.go | 2 +- internal/route/rules/do.go | 6 +- internal/route/rules/on.go | 4 +- internal/route/rules/validate.go | 13 +- internal/route/stream.go | 7 +- internal/route/stream_impl.go | 10 +- internal/route/types/http_config_test.go | 1 + internal/route/types/route.go | 3 +- internal/route/udp_forwarder.go | 6 +- internal/task/debug.go | 48 ++- internal/task/task.go | 12 +- internal/task/utils.go | 8 +- internal/utils/functional/map.go | 27 -- internal/utils/io.go | 2 +- internal/utils/pool/pool.go | 74 ++++ internal/utils/serialization.go | 133 +++++-- internal/utils/serialization_test.go | 141 +++---- internal/utils/strutils/format.go | 182 ++++++--- internal/utils/strutils/format_test.go | 205 ++++++++++ internal/utils/strutils/parser.go | 9 + internal/utils/testing/testing.go | 41 +- internal/watcher/config_file_watcher.go | 2 +- internal/watcher/events/events.go | 16 +- internal/watcher/health/{monitor => }/json.go | 23 +- .../watcher/health/monitor/agent_proxied.go | 9 +- internal/watcher/health/monitor/docker.go | 4 +- internal/watcher/health/monitor/http.go | 6 +- internal/watcher/health/monitor/monitor.go | 20 +- internal/watcher/health/monitor/raw.go | 4 +- internal/watcher/health/types.go | 10 +- migrations/001_move_json_data.go | 22 ++ migrations/migrate.go | 30 ++ migrations/migration.go | 9 + migrations/utils.go | 20 + pkg/version.go | 115 +++++- schemas/Makefile | 33 ++ schemas/bun.lock | 14 +- schemas/config/access_log.d.ts | 80 ++-- schemas/config/autocert.d.ts | 114 +++--- schemas/config/config.d.ts | 85 +++-- schemas/config/entrypoint.d.ts | 58 +-- schemas/config/homepage.d.ts | 10 +- schemas/config/notification.d.ts | 91 +++-- schemas/config/providers.d.ts | 75 ++-- schemas/docker.d.ts | 2 +- schemas/docker_routes.schema.json | 2 +- schemas/index.d.ts | 21 +- schemas/middlewares/middlewares.d.ts | 257 ++++++++----- schemas/package.json | 19 +- schemas/providers/healthcheck.d.ts | 52 +-- schemas/providers/homepage.d.ts | 26 +- schemas/providers/idlewatcher.d.ts | 48 ++- schemas/providers/loadbalance.d.ts | 46 ++- schemas/providers/routes.d.ts | 238 ++++++------ schemas/providers/routes.ts | 14 +- schemas/routes.schema.json | 2 +- schemas/tsconfig.json | 12 +- schemas/types.d.ts | 20 +- 173 files changed, 4131 insertions(+), 2096 deletions(-) create mode 100644 agent/pkg/agent/agents.go create mode 100644 internal/api/v1/debug/handler.go create mode 100644 internal/api/v1/debug/handler_production.go rename internal/api/v1/{ => favicon}/favicon.go (99%) create mode 100644 internal/common/paths.go delete mode 100644 internal/config/agent_pool.go create mode 100644 internal/gperr/multiline.go create mode 100644 internal/gperr/multiline_test.go create mode 100644 internal/idlewatcher/common.go delete mode 100644 internal/idlewatcher/container.go create mode 100644 internal/idlewatcher/debug.go rename internal/idlewatcher/{waker_http.go => handle_http.go} (69%) rename internal/idlewatcher/{waker_stream.go => handle_stream.go} (50%) create mode 100644 internal/idlewatcher/health.go create mode 100644 internal/idlewatcher/provider/docker.go create mode 100644 internal/idlewatcher/provider/proxmox.go create mode 100644 internal/idlewatcher/types/container_status.go create mode 100644 internal/idlewatcher/types/provider.go delete mode 100644 internal/idlewatcher/waker.go create mode 100644 internal/net/gphttp/accesslog/multi_writer.go create mode 100644 internal/net/gphttp/accesslog/stdout_logger.go create mode 100644 internal/net/gphttp/servemux/mux.go create mode 100644 internal/net/ping.go create mode 100644 internal/net/ping_test.go delete mode 100644 internal/net/types/cidr.go delete mode 100644 internal/net/types/url.go create mode 100644 internal/proxmox/client.go create mode 100644 internal/proxmox/config.go create mode 100644 internal/proxmox/lxc.go create mode 100644 internal/proxmox/lxc_test.go create mode 100644 internal/proxmox/node.go create mode 100644 internal/utils/pool/pool.go create mode 100644 internal/utils/strutils/format_test.go rename internal/watcher/health/{monitor => }/json.go (72%) create mode 100644 migrations/001_move_json_data.go create mode 100644 migrations/migrate.go create mode 100644 migrations/migration.go create mode 100644 migrations/utils.go create mode 100644 schemas/Makefile diff --git a/.gitignore b/.gitignore index e78c180..ad25e74 100755 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ error_pages/ !examples/error_pages/ profiles/ data/ +debug/ logs/ log/ diff --git a/Makefile b/Makefile index 1744af2..86863e6 100755 --- a/Makefile +++ b/Makefile @@ -27,16 +27,16 @@ endif ifeq ($(debug), 1) CGO_ENABLED = 0 GODOXY_DEBUG = 1 - BUILD_FLAGS += -gcflags=all='-N -l' + BUILD_FLAGS += -gcflags=all='-N -l' -tags debug else ifeq ($(pprof), 1) CGO_ENABLED = 1 GORACE = log_path=logs/pprof strip_path_prefix=$(shell pwd)/ halt_on_error=1 - BUILD_FLAGS = -tags pprof + BUILD_FLAGS += -tags pprof VERSION := ${VERSION}-pprof else CGO_ENABLED = 0 LDFLAGS += -s -w - BUILD_FLAGS = -pgo=auto -tags production + BUILD_FLAGS += -pgo=auto -tags production endif BUILD_FLAGS += -ldflags='$(LDFLAGS)' @@ -50,6 +50,8 @@ export GODEBUG export GORACE export BUILD_FLAGS +.PHONY: debug + test: GODOXY_TEST=1 go test ./internal/... @@ -67,6 +69,10 @@ build: run: [ -f .env ] && godotenv -f .env go run ${BUILD_FLAGS} ${CMD_PATH} +debug: + make NAME="godoxy-test" debug=1 build + sh -c 'HTTP_ADDR=:81 HTTPS_ADDR=:8443 API_ADDR=:8899 DEBUG=1 bin/godoxy-test' + mtrace: bin/godoxy debug-ls-mtrace > mtrace.json @@ -88,43 +94,5 @@ cloc: link-binary: ln -s /app/${NAME} bin/run -# To generate schema -# comment out this part from typescript-json-schema.js#L884 -# -# if (indexType.flags !== ts.TypeFlags.Number && !isIndexedObject) { -# throw new Error("Not supported: IndexSignatureDeclaration with index symbol other than a number or a string"); -# } - -gen-schema-single: - bun --bun run typescript-json-schema --noExtraProps --required --skipLibCheck --tsNodeRegister=true -o schemas/${OUT} schemas/${IN} ${CLASS} - # minify - python3 -c "import json; f=open('schemas/${OUT}', 'r'); j=json.load(f); f.close(); f=open('schemas/${OUT}', 'w'); json.dump(j, f, separators=(',', ':'));" - -gen-schema: - cd schemas && bun --bun tsc - make IN=config/config.ts \ - CLASS=Config \ - OUT=config.schema.json \ - gen-schema-single - make IN=providers/routes.ts \ - CLASS=Routes \ - OUT=routes.schema.json \ - gen-schema-single - make IN=middlewares/middleware_compose.ts \ - CLASS=MiddlewareCompose \ - OUT=middleware_compose.schema.json \ - gen-schema-single - make IN=docker.ts \ - CLASS=DockerRoutes \ - OUT=docker_routes.schema.json \ - gen-schema-single - cd .. - -publish-schema: - cd schemas && bun publish && cd .. - -update-schema-generator: - pnpm up -g typescript-json-schema - push-github: git push origin $(shell git rev-parse --abbrev-ref HEAD) \ No newline at end of file diff --git a/README.md b/README.md index a61ef21..f87d1d0 100755 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=yusing_go-proxy&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=yusing_godoxy) ![GitHub last commit](https://img.shields.io/github/last-commit/yusing/godoxy) [![Lines of Code](https://sonarcloud.io/api/project_badges/measure?project=yusing_go-proxy&metric=ncloc)](https://sonarcloud.io/summary/new_code?id=yusing_godoxy) +![Demo](https://img.shields.io/website?url=https%3A%2F%2Fgodoxy.demo.6uo.me&label=Demo&link=https%3A%2F%2Fgodoxy.demo.6uo.me) [![Discord](https://dcbadge.limes.pink/api/server/umReR62nRd?style=flat)](https://discord.gg/umReR62nRd) A lightweight, simple, and [performant](https://github.com/yusing/godoxy/wiki/Benchmarks) reverse proxy with WebUI. @@ -47,19 +48,17 @@ For full documentation, check out **[Wiki](https://github.com/yusing/godoxy/wiki - Effortless configuration - Simple multi-node setup with GoDoxy agents or Docker Socket Proxies - Error messages is clear and detailed, easy troubleshooting -- Auto SSL with Let's Encrypt (See [Supported DNS-01 Challenge Providers](https://github.com/yusing/go-proxy/wiki/Supported-DNS%E2%80%9001-Providers)) -- Auto hot-reload on container state / config file changes -- Container aware: create routes dynamically from running docker containers +- **Auto SSL** with Let's Encrypt (See [Supported DNS-01 Challenge Providers](https://github.com/yusing/go-proxy/wiki/Supported-DNS%E2%80%9001-Providers)) +- **Auto hot-reload** on container state / config file changes +- **Container aware**: create routes dynamically from running docker containers - **idlesleeper**: stop and wake containers based on traffic _(optional, see [screenshots](#idlesleeper))_ - HTTP reserve proxy and TCP/UDP port forwarding -- OpenID Connect integration: SSO and secure your apps easily +- **OpenID Connect integration**: SSO and secure your apps easily - [HTTP middleware](https://github.com/yusing/go-proxy/wiki/Middlewares) and [Custom error pages support](https://github.com/yusing/go-proxy/wiki/Middlewares#custom-error-pages) - **Web UI with App dashboard, config editor, _uptime and system metrics_, _docker logs viewer_** -- Supports linux/amd64 and linux/arm64 +- Supports **linux/amd64** and **linux/arm64** - Written in **[Go](https://go.dev)** -[🔼Back to top](#table-of-content) - ## Prerequisites Setup Wildcard DNS Record(s) for machine running `GoDoxy`, e.g. @@ -74,13 +73,17 @@ Setup Wildcard DNS Record(s) for machine running `GoDoxy`, e.g. 3. Create a route if applicable (a route is like a "Virtual Host" in NPM) 4. Watch for container / config changes and update automatically -GoDoxy uses the label `proxy.aliases` as the subdomain(s), if unset it defaults to the `container_name` field in docker compose. - -For example, with the label `proxy.aliases: qbt` you can access your app via `qbt.domain.com`. +> [!NOTE] +> GoDoxy uses the label `proxy.aliases` as the subdomain(s), if unset it defaults to the `container_name` field in docker compose. +> +> For example, with the label `proxy.aliases: qbt` you can access your app via `qbt.domain.com`. ## Setup -**NOTE:** GoDoxy is designed to be (and only works when) running in `host` network mode, do not change it. To change listening ports, modify `.env`. +> [!NOTE] +> GoDoxy is designed to be running in `host` network mode, do not change it. +> +> To change listening ports, modify `.env`. 1. Prepare a new directory for docker compose and config files. @@ -90,11 +93,7 @@ For example, with the label `proxy.aliases: qbt` you can access your app via `qb /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/yusing/godoxy/main/scripts/setup.sh)" ``` -3. Start the container `docker compose up -d` and wait for it to be ready - -4. You may now do some extra configuration on WebUI `https://godoxy.yourdomain.com` - -[🔼Back to top](#table-of-content) +3. You may now do some extra configuration on WebUI `https://godoxy.yourdomain.com` ## Screenshots @@ -127,8 +126,6 @@ For example, with the label `proxy.aliases: qbt` you can access your app via `qb -[🔼Back to top](#table-of-content) - ## Manual Setup 1. Make `config` directory then grab `config.example.yml` into `config/config.yml` diff --git a/README_CHT.md b/README_CHT.md index 5e99ac2..68bf81f 100644 --- a/README_CHT.md +++ b/README_CHT.md @@ -5,7 +5,8 @@ [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=yusing_go-proxy&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy) ![GitHub last commit](https://img.shields.io/github/last-commit/yusing/godoxy) [![Lines of Code](https://sonarcloud.io/api/project_badges/measure?project=yusing_go-proxy&metric=ncloc)](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy) -[![](https://dcbadge.limes.pink/api/server/umReR62nRd?style=flat)](https://discord.gg/umReR62nRd) +![Demo](https://img.shields.io/website?url=https%3A%2F%2Fgodoxy.demo.6uo.me&label=Demo&link=https%3A%2F%2Fgodoxy.demo.6uo.me) +[![Discord](https://dcbadge.limes.pink/api/server/umReR62nRd?style=flat)](https://discord.gg/umReR62nRd) 輕量、易用、 [高效能](https://github.com/yusing/godoxy/wiki/Benchmarks),且帶有主頁和配置面板的反向代理 @@ -68,7 +69,10 @@ ## 安裝 -**注意:** GoDoxy 設計為(且僅在)`host` 網路模式下運作,請勿更改。如需更改監聽埠,請修改 `.env`。 +> [!NOTE] +> GoDoxy 僅在 `host` 網路模式下運作,請勿更改。 +> +> 如需更改監聽埠,請修改 `.env`。 1. 準備一個新目錄用於 docker compose 和配置文件。 @@ -78,9 +82,7 @@ /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/yusing/godoxy/main/scripts/setup.sh)" ``` -3. 啟動容器 `docker compose up -d` 並等待就緒 - -4. 現在可以在 WebUI `https://godoxy.yourdomain.com` 進行額外配置 +3. 現在可以在 WebUI `https://godoxy.yourdomain.com` 進行額外配置 [🔼回到頂部](#目錄) diff --git a/agent/pkg/agent/agents.go b/agent/pkg/agent/agents.go new file mode 100644 index 0000000..a5e9536 --- /dev/null +++ b/agent/pkg/agent/agents.go @@ -0,0 +1,16 @@ +package agent + +import ( + "github.com/yusing/go-proxy/internal/utils/pool" +) + +type agents struct{ pool.Pool[*AgentConfig] } + +var Agents = agents{pool.New[*AgentConfig]("agents")} + +func (agents agents) Get(agentAddrOrDockerHost string) (*AgentConfig, bool) { + if !IsDockerHostAgent(agentAddrOrDockerHost) { + return agents.Base().Load(agentAddrOrDockerHost) + } + return agents.Base().Load(GetAgentAddrFromDockerHost(agentAddrOrDockerHost)) +} diff --git a/agent/pkg/agent/config.go b/agent/pkg/agent/config.go index 371c684..673994a 100644 --- a/agent/pkg/agent/config.go +++ b/agent/pkg/agent/config.go @@ -4,19 +4,16 @@ import ( "context" "crypto/tls" "crypto/x509" - "encoding/json" "net" "net/http" + "net/url" "os" "strings" "time" - "github.com/rs/zerolog" "github.com/yusing/go-proxy/agent/pkg/certs" "github.com/yusing/go-proxy/internal/gperr" - "github.com/yusing/go-proxy/internal/logging" "github.com/yusing/go-proxy/internal/net/gphttp" - "github.com/yusing/go-proxy/internal/net/types" "github.com/yusing/go-proxy/pkg" ) @@ -26,7 +23,6 @@ type AgentConfig struct { httpClient *http.Client tlsConfig *tls.Config name string - l zerolog.Logger } const ( @@ -49,8 +45,8 @@ const ( ) var ( - AgentURL = types.MustParseURL(APIBaseURL) - HTTPProxyURL = types.MustParseURL(APIBaseURL + EndpointProxyHTTP) + AgentURL, _ = url.Parse(APIBaseURL) + HTTPProxyURL, _ = url.Parse(APIBaseURL + EndpointProxyHTTP) HTTPProxyURLPrefixLen = len(APIEndpointBase + EndpointProxyHTTP) ) @@ -71,6 +67,11 @@ func GetAgentAddrFromDockerHost(dockerHost string) string { return dockerHost[FakeDockerHostPrefixLen:] } +// Key implements pool.Object +func (cfg *AgentConfig) Key() string { + return cfg.Addr +} + func (cfg *AgentConfig) FakeDockerHost() string { return FakeDockerHostPrefix + cfg.Addr } @@ -121,7 +122,7 @@ func (cfg *AgentConfig) InitWithCerts(ctx context.Context, ca, crt, key []byte) versionStr := string(version) // skip version check for dev versions - if strings.HasPrefix(versionStr, "v") && !checkVersion(versionStr, pkg.GetVersion()) { + if strings.HasPrefix(versionStr, "v") && !checkVersion(versionStr, pkg.GetVersion().String()) { return gperr.Errorf("agent version mismatch: server: %s, agent: %s", pkg.GetVersion(), versionStr) } @@ -132,8 +133,6 @@ func (cfg *AgentConfig) InitWithCerts(ctx context.Context, ca, crt, key []byte) } cfg.name = string(name) - cfg.l = logging.With().Str("agent", cfg.name).Logger() - cfg.l.Info().Msg("agent initialized") return nil } @@ -193,9 +192,10 @@ func (cfg *AgentConfig) String() string { return cfg.name + "@" + cfg.Addr } -func (cfg *AgentConfig) MarshalJSON() ([]byte, error) { - return json.Marshal(map[string]string{ +// MarshalMap implements pool.Object +func (cfg *AgentConfig) MarshalMap() map[string]any { + return map[string]any{ "name": cfg.Name(), "addr": cfg.Addr, - }) + } } diff --git a/agent/pkg/certs/zip.go b/agent/pkg/certs/zip.go index 61db6f8..7f8861a 100644 --- a/agent/pkg/certs/zip.go +++ b/agent/pkg/certs/zip.go @@ -59,7 +59,7 @@ func AgentCertsFilepath(host string) (filepathOut string, ok bool) { if !isValidAgentHost(host) { return "", false } - return filepath.Join(common.AgentCertsBasePath, host+".zip"), true + return filepath.Join(common.CertsDir, host+".zip"), true } func ExtractCert(data []byte) (ca, crt, key []byte, err error) { diff --git a/agent/pkg/handler/check_health.go b/agent/pkg/handler/check_health.go index 5f9c954..ceddc99 100644 --- a/agent/pkg/handler/check_health.go +++ b/agent/pkg/handler/check_health.go @@ -8,7 +8,6 @@ import ( "strings" "github.com/yusing/go-proxy/internal/net/gphttp" - "github.com/yusing/go-proxy/internal/net/types" "github.com/yusing/go-proxy/internal/watcher/health" "github.com/yusing/go-proxy/internal/watcher/health/monitor" ) @@ -44,11 +43,11 @@ func CheckHealth(w http.ResponseWriter, r *http.Request) { http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) return } - result, err = monitor.NewHTTPHealthMonitor(types.NewURL(&url.URL{ + result, err = monitor.NewHTTPHealthMonitor(&url.URL{ Scheme: scheme, Host: host, Path: path, - }), defaultHealthConfig).CheckHealth() + }, defaultHealthConfig).CheckHealth() case "tcp", "udp": host := query.Get("host") if host == "" { @@ -63,10 +62,10 @@ func CheckHealth(w http.ResponseWriter, r *http.Request) { http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) return } - result, err = monitor.NewRawHealthMonitor(types.NewURL(&url.URL{ + result, err = monitor.NewRawHealthMonitor(&url.URL{ Scheme: scheme, Host: host, - }), defaultHealthConfig).CheckHealth() + }, defaultHealthConfig).CheckHealth() } if err != nil { diff --git a/agent/pkg/handler/docker_socket.go b/agent/pkg/handler/docker_socket.go index 27bedf2..3d30910 100644 --- a/agent/pkg/handler/docker_socket.go +++ b/agent/pkg/handler/docker_socket.go @@ -9,7 +9,6 @@ import ( "github.com/yusing/go-proxy/internal/docker" "github.com/yusing/go-proxy/internal/logging" "github.com/yusing/go-proxy/internal/net/gphttp/reverseproxy" - "github.com/yusing/go-proxy/internal/net/types" ) func serviceUnavailable(w http.ResponseWriter, r *http.Request) { @@ -22,10 +21,10 @@ func DockerSocketHandler() http.HandlerFunc { logging.Warn().Err(err).Msg("failed to connect to docker client") return serviceUnavailable } - rp := reverseproxy.NewReverseProxy("docker", types.NewURL(&url.URL{ + rp := reverseproxy.NewReverseProxy("docker", &url.URL{ Scheme: "http", Host: client.DummyHost, - }), dockerClient.HTTPClient().Transport) + }, dockerClient.HTTPClient().Transport) return rp.ServeHTTP } diff --git a/agent/pkg/handler/proxy_http.go b/agent/pkg/handler/proxy_http.go index 712f261..eeeb368 100644 --- a/agent/pkg/handler/proxy_http.go +++ b/agent/pkg/handler/proxy_http.go @@ -12,7 +12,6 @@ import ( "github.com/yusing/go-proxy/internal/logging" "github.com/yusing/go-proxy/internal/net/gphttp" "github.com/yusing/go-proxy/internal/net/gphttp/reverseproxy" - "github.com/yusing/go-proxy/internal/net/types" "github.com/yusing/go-proxy/internal/utils/strutils" ) @@ -55,9 +54,9 @@ func ProxyHTTP(w http.ResponseWriter, r *http.Request) { logging.Debug().Msgf("proxy http request: %s %s", r.Method, r.URL.String()) - rp := reverseproxy.NewReverseProxy("agent", types.NewURL(&url.URL{ + rp := reverseproxy.NewReverseProxy("agent", &url.URL{ Scheme: scheme, Host: host, - }), transport) + }, transport) rp.ServeHTTP(w, r) } diff --git a/cmd/main.go b/cmd/main.go index f1461db..51f9d4a 100755 --- a/cmd/main.go +++ b/cmd/main.go @@ -7,6 +7,7 @@ import ( "sync" "github.com/yusing/go-proxy/internal/api/v1/auth" + debugapi "github.com/yusing/go-proxy/internal/api/v1/debug" "github.com/yusing/go-proxy/internal/api/v1/query" "github.com/yusing/go-proxy/internal/common" "github.com/yusing/go-proxy/internal/config" @@ -19,6 +20,7 @@ import ( "github.com/yusing/go-proxy/internal/net/gphttp/middleware" "github.com/yusing/go-proxy/internal/route/routes/routequery" "github.com/yusing/go-proxy/internal/task" + "github.com/yusing/go-proxy/migrations" "github.com/yusing/go-proxy/pkg" ) @@ -38,6 +40,9 @@ func parallel(fns ...func()) { func main() { initProfiling() + if err := migrations.RunMigrations(); err != nil { + gperr.LogFatal("migration error", err) + } args := pkg.GetArgs(common.MainServerCommandValidator{}) switch args.Command { @@ -146,6 +151,8 @@ func main() { uptime.Poller.Start() config.WatchChanges() + debugapi.StartServer(cfg) + task.WaitExit(cfg.Value().TimeoutShutdown) } diff --git a/cmd/pprof_production.go b/cmd/pprof_production.go index 493fd06..8dbf53a 100644 --- a/cmd/pprof_production.go +++ b/cmd/pprof_production.go @@ -1,4 +1,4 @@ -//go:build production +//go:build !pprof package main diff --git a/go.mod b/go.mod index ca99f51..77bdc37 100644 --- a/go.mod +++ b/go.mod @@ -36,7 +36,7 @@ require ( // favicon extraction require ( - github.com/PuerkitoBio/goquery v1.10.2 // parsing HTML for extract fav icon + github.com/PuerkitoBio/goquery v1.10.3 // parsing HTML for extract fav icon github.com/vincent-petithory/dataurl v1.0.0 // data url for fav icon ) @@ -63,6 +63,8 @@ require ( github.com/stretchr/testify v1.10.0 // testing utilities ) +require github.com/luthermonson/go-proxmox v0.2.2 + require ( github.com/Microsoft/go-winio v0.6.2 // indirect github.com/andybalholm/cascadia v1.3.3 // indirect @@ -73,7 +75,7 @@ require ( github.com/cloudflare/cloudflare-go v0.115.0 // indirect github.com/containerd/log v0.1.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/diskfs/go-diskfs v1.5.2 // indirect + github.com/diskfs/go-diskfs v1.6.0 // indirect github.com/distribution/reference v0.6.0 // indirect github.com/djherbis/times v1.6.0 // indirect github.com/docker/go-units v0.5.0 // indirect @@ -111,7 +113,7 @@ require ( github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect - github.com/prometheus/client_model v0.6.1 // indirect + github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.63.0 // indirect github.com/prometheus/procfs v0.16.0 // indirect github.com/quic-go/qpack v0.5.1 // indirect diff --git a/go.sum b/go.sum index cee6246..3bee87a 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,8 @@ github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOEl github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= -github.com/PuerkitoBio/goquery v1.10.2 h1:7fh2BdHcG6VFZsK7toXBT/Bh1z5Wmy8Q9MV9HqT2AM8= -github.com/PuerkitoBio/goquery v1.10.2/go.mod h1:0guWGjcLu9AYC7C1GHnpysHy056u9aEkUHwhdnePMCU= +github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo= +github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y= github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= @@ -27,8 +27,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/diskfs/go-diskfs v1.5.2 h1:Aj+f4sYlu3seXJe5KwyOWlol0eRBG9EKGYYYm37DO9s= -github.com/diskfs/go-diskfs v1.5.2/go.mod h1:bRFumZeGFCO8C2KNswrQeuj2m1WCVr4Ms5IjWMczMDk= +github.com/diskfs/go-diskfs v1.6.0 h1:YmK5+vLSfkwC6kKKRTRPGaDGNF+Xh8FXeiNHwryDfu4= +github.com/diskfs/go-diskfs v1.6.0/go.mod h1:bRFumZeGFCO8C2KNswrQeuj2m1WCVr4Ms5IjWMczMDk= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c= @@ -174,8 +174,8 @@ github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4 github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U= github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q= github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= -github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= -github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= github.com/prometheus/common v0.63.0 h1:YR/EIY1o3mEFP/kZCD7iDMnLPlGyuU2Gb3HIcXnA98k= github.com/prometheus/common v0.63.0/go.mod h1:VVFF/fBIoToEnWRVkYoXEkq3R3paCoxG9PXP74SnV18= github.com/prometheus/procfs v0.16.0 h1:xh6oHhKwnOJKMYiYBDWmkHqQPyiY40sny36Cmx2bbsM= diff --git a/internal/api/handler.go b/internal/api/handler.go index e1d86db..cc43e13 100644 --- a/internal/api/handler.go +++ b/internal/api/handler.go @@ -1,7 +1,6 @@ package api import ( - "fmt" "net/http" "github.com/prometheus/client_golang/prometheus/promhttp" @@ -9,63 +8,17 @@ import ( "github.com/yusing/go-proxy/internal/api/v1/auth" "github.com/yusing/go-proxy/internal/api/v1/certapi" "github.com/yusing/go-proxy/internal/api/v1/dockerapi" + "github.com/yusing/go-proxy/internal/api/v1/favicon" "github.com/yusing/go-proxy/internal/common" config "github.com/yusing/go-proxy/internal/config/types" "github.com/yusing/go-proxy/internal/logging" "github.com/yusing/go-proxy/internal/logging/memlogger" "github.com/yusing/go-proxy/internal/metrics/uptime" - "github.com/yusing/go-proxy/internal/net/gphttp/httpheaders" - "github.com/yusing/go-proxy/internal/utils/strutils" + "github.com/yusing/go-proxy/internal/net/gphttp/servemux" ) -type ( - ServeMux struct { - *http.ServeMux - cfg config.ConfigInstance - } - WithCfgHandler = func(config.ConfigInstance, http.ResponseWriter, *http.Request) -) - -func (mux ServeMux) HandleFunc(methods, endpoint string, h any, requireAuth ...bool) { - var handler http.HandlerFunc - switch h := h.(type) { - case func(http.ResponseWriter, *http.Request): - handler = h - case http.Handler: - handler = h.ServeHTTP - case WithCfgHandler: - handler = func(w http.ResponseWriter, r *http.Request) { - h(mux.cfg, w, r) - } - default: - panic(fmt.Errorf("unsupported handler type: %T", h)) - } - - matchDomains := mux.cfg.Value().MatchDomains - if len(matchDomains) > 0 { - origHandler := handler - handler = func(w http.ResponseWriter, r *http.Request) { - if httpheaders.IsWebsocket(r.Header) { - httpheaders.SetWebsocketAllowedDomains(r.Header, matchDomains) - } - origHandler(w, r) - } - } - - if len(requireAuth) > 0 && requireAuth[0] { - handler = auth.RequireAuth(handler) - } - if methods == "" { - mux.ServeMux.HandleFunc(endpoint, handler) - } else { - for _, m := range strutils.CommaSeperatedList(methods) { - mux.ServeMux.HandleFunc(m+" "+endpoint, handler) - } - } -} - func NewHandler(cfg config.ConfigInstance) http.Handler { - mux := ServeMux{http.NewServeMux(), cfg} + mux := servemux.NewServeMux(cfg) mux.HandleFunc("GET", "/v1", v1.Index) mux.HandleFunc("GET", "/v1/version", v1.GetVersion) @@ -79,7 +32,7 @@ func NewHandler(cfg config.ConfigInstance) http.Handler { mux.HandleFunc("POST", "/v1/file/validate/{type}", v1.ValidateFile, true) mux.HandleFunc("GET", "/v1/health", v1.Health, true) mux.HandleFunc("GET", "/v1/logs", memlogger.Handler(), true) - mux.HandleFunc("GET", "/v1/favicon", v1.GetFavIcon, true) + mux.HandleFunc("GET", "/v1/favicon", favicon.GetFavIcon, true) mux.HandleFunc("POST", "/v1/homepage/set", v1.SetHomePageOverrides, true) mux.HandleFunc("GET", "/v1/agents", v1.ListAgents, true) mux.HandleFunc("GET", "/v1/agents/new", v1.NewAgent, true) diff --git a/internal/api/v1/agents.go b/internal/api/v1/agents.go index d9d2a87..81636d5 100644 --- a/internal/api/v1/agents.go +++ b/internal/api/v1/agents.go @@ -4,21 +4,11 @@ import ( "net/http" "time" - "github.com/coder/websocket" - "github.com/coder/websocket/wsjson" + "github.com/yusing/go-proxy/agent/pkg/agent" config "github.com/yusing/go-proxy/internal/config/types" - "github.com/yusing/go-proxy/internal/net/gphttp" "github.com/yusing/go-proxy/internal/net/gphttp/gpwebsocket" - "github.com/yusing/go-proxy/internal/net/gphttp/httpheaders" ) func ListAgents(cfg config.ConfigInstance, w http.ResponseWriter, r *http.Request) { - if httpheaders.IsWebsocket(r.Header) { - gpwebsocket.Periodic(w, r, 10*time.Second, func(conn *websocket.Conn) error { - wsjson.Write(r.Context(), conn, cfg.ListAgents()) - return nil - }) - } else { - gphttp.RespondJSON(w, r, cfg.ListAgents()) - } + gpwebsocket.DynamicJSONHandler(w, r, agent.Agents.Slice, 10*time.Second) } diff --git a/internal/api/v1/config_file.go b/internal/api/v1/config_file.go index d05c1db..631e65f 100644 --- a/internal/api/v1/config_file.go +++ b/internal/api/v1/config_file.go @@ -27,7 +27,7 @@ func fileType(file string) FileType { switch { case strings.HasPrefix(path.Base(file), "config."): return FileTypeConfig - case strings.HasPrefix(file, common.MiddlewareComposeBasePath): + case strings.HasPrefix(file, common.MiddlewareComposeDir): return FileTypeMiddleware } return FileTypeProvider @@ -43,9 +43,9 @@ func (t FileType) IsValid() bool { func (t FileType) GetPath(filename string) string { if t == FileTypeMiddleware { - return path.Join(common.MiddlewareComposeBasePath, filename) + return path.Join(common.MiddlewareComposeDir, filename) } - return path.Join(common.ConfigBasePath, filename) + return path.Join(common.ConfigDir, filename) } func getArgs(r *http.Request) (fileType FileType, filename string, err error) { diff --git a/internal/api/v1/debug/handler.go b/internal/api/v1/debug/handler.go new file mode 100644 index 0000000..271ab09 --- /dev/null +++ b/internal/api/v1/debug/handler.go @@ -0,0 +1,75 @@ +//go:build debug + +package debugapi + +import ( + "iter" + "net/http" + "sort" + "time" + + "github.com/yusing/go-proxy/agent/pkg/agent" + config "github.com/yusing/go-proxy/internal/config/types" + "github.com/yusing/go-proxy/internal/docker" + "github.com/yusing/go-proxy/internal/idlewatcher" + "github.com/yusing/go-proxy/internal/net/gphttp/gpwebsocket" + "github.com/yusing/go-proxy/internal/net/gphttp/servemux" + "github.com/yusing/go-proxy/internal/net/gphttp/server" + "github.com/yusing/go-proxy/internal/proxmox" + "github.com/yusing/go-proxy/internal/task" +) + +func StartServer(cfg config.ConfigInstance) { + srv := server.NewServer(server.Options{ + Name: "debug", + HTTPAddr: "127.0.0.1:7777", + Handler: newHandler(cfg), + }) + srv.Start(task.RootTask("debug_server", false)) +} + +type debuggable interface { + MarshalMap() map[string]any + Key() string +} + +func toSortedSlice[T debuggable](data iter.Seq2[string, T]) []map[string]any { + s := make([]map[string]any, 0) + for _, v := range data { + m := v.MarshalMap() + m["key"] = v.Key() + s = append(s, m) + } + sort.Slice(s, func(i, j int) bool { + return s[i]["key"].(string) < s[j]["key"].(string) + }) + return s +} + +func jsonHandler[T debuggable](getData iter.Seq2[string, T]) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + gpwebsocket.DynamicJSONHandler(w, r, func() []map[string]any { + return toSortedSlice(getData) + }, 1*time.Second) + } +} + +func iterMap[K comparable, V debuggable](m func() map[K]V) iter.Seq2[K, V] { + return func(yield func(K, V) bool) { + for k, v := range m() { + if !yield(k, v) { + break + } + } + } +} + +func newHandler(cfg config.ConfigInstance) http.Handler { + mux := servemux.NewServeMux(cfg) + mux.HandleFunc("GET", "/tasks", jsonHandler(task.AllTasks())) + mux.HandleFunc("GET", "/idlewatcher", jsonHandler(idlewatcher.Watchers())) + mux.HandleFunc("GET", "/agents", jsonHandler(agent.Agents.Iter)) + mux.HandleFunc("GET", "/proxmox", jsonHandler(proxmox.Clients.Iter)) + mux.HandleFunc("GET", "/docker", jsonHandler(iterMap(docker.Clients))) + return mux +} diff --git a/internal/api/v1/debug/handler_production.go b/internal/api/v1/debug/handler_production.go new file mode 100644 index 0000000..b54c86e --- /dev/null +++ b/internal/api/v1/debug/handler_production.go @@ -0,0 +1,11 @@ +//go:build !debug + +package debugapi + +import ( + config "github.com/yusing/go-proxy/internal/config/types" +) + +func StartServer(cfg config.ConfigInstance) { + // do nothing +} diff --git a/internal/api/v1/dockerapi/containers.go b/internal/api/v1/dockerapi/containers.go index eabac6d..9d98076 100644 --- a/internal/api/v1/dockerapi/containers.go +++ b/internal/api/v1/dockerapi/containers.go @@ -18,7 +18,7 @@ type Container struct { } func Containers(w http.ResponseWriter, r *http.Request) { - serveHTTP[Container, []Container](w, r, GetContainers) + serveHTTP[Container](w, r, GetContainers) } func GetContainers(ctx context.Context, dockerClients DockerClients) ([]Container, gperr.Error) { diff --git a/internal/api/v1/dockerapi/info.go b/internal/api/v1/dockerapi/info.go index 373895d..a8bd08c 100644 --- a/internal/api/v1/dockerapi/info.go +++ b/internal/api/v1/dockerapi/info.go @@ -25,7 +25,7 @@ func (d *dockerInfo) MarshalJSON() ([]byte, error) { }, "images": d.Images, "n_cpu": d.NCPU, - "memory": strutils.FormatByteSizeWithUnit(d.MemTotal), + "memory": strutils.FormatByteSize(d.MemTotal), }) } diff --git a/internal/api/v1/dockerapi/logs.go b/internal/api/v1/dockerapi/logs.go index 0bec4c6..29c700b 100644 --- a/internal/api/v1/dockerapi/logs.go +++ b/internal/api/v1/dockerapi/logs.go @@ -22,7 +22,7 @@ func Logs(w http.ResponseWriter, r *http.Request) { until := query.Get("to") levels := query.Get("levels") // TODO: implement levels - dockerClient, found, err := getDockerClient(w, server) + dockerClient, found, err := getDockerClient(server) if err != nil { gphttp.BadRequest(w, err.Error()) return diff --git a/internal/api/v1/dockerapi/utils.go b/internal/api/v1/dockerapi/utils.go index 2345b00..b25b59a 100644 --- a/internal/api/v1/dockerapi/utils.go +++ b/internal/api/v1/dockerapi/utils.go @@ -8,6 +8,7 @@ import ( "github.com/coder/websocket" "github.com/coder/websocket/wsjson" + "github.com/yusing/go-proxy/agent/pkg/agent" config "github.com/yusing/go-proxy/internal/config/types" "github.com/yusing/go-proxy/internal/docker" "github.com/yusing/go-proxy/internal/gperr" @@ -44,7 +45,7 @@ func getDockerClients() (DockerClients, gperr.Error) { dockerClients[name] = dockerClient } - for _, agent := range cfg.ListAgents() { + for _, agent := range agent.Agents.Iter { dockerClient, err := docker.NewClient(agent.FakeDockerHost()) if err != nil { connErrs.Add(err) @@ -56,7 +57,7 @@ func getDockerClients() (DockerClients, gperr.Error) { return dockerClients, connErrs.Error() } -func getDockerClient(w http.ResponseWriter, server string) (*docker.SharedClient, bool, error) { +func getDockerClient(server string) (*docker.SharedClient, bool, error) { cfg := config.GetInstance() var host string for name, h := range cfg.Value().Providers.Docker { @@ -65,7 +66,7 @@ func getDockerClient(w http.ResponseWriter, server string) (*docker.SharedClient break } } - for _, agent := range cfg.ListAgents() { + for _, agent := range agent.Agents.Iter { if agent.Name() == server { host = agent.FakeDockerHost() break @@ -119,6 +120,6 @@ func serveHTTP[V any, T ResultType[V]](w http.ResponseWriter, r *http.Request, g }) } else { result, err := getResult(r.Context(), dockerClients) - handleResult[V, T](w, err, result) + handleResult[V](w, err, result) } } diff --git a/internal/api/v1/favicon.go b/internal/api/v1/favicon/favicon.go similarity index 99% rename from internal/api/v1/favicon.go rename to internal/api/v1/favicon/favicon.go index 8d314f6..6a56ee5 100644 --- a/internal/api/v1/favicon.go +++ b/internal/api/v1/favicon/favicon.go @@ -1,4 +1,4 @@ -package v1 +package favicon import ( "errors" diff --git a/internal/api/v1/health.go b/internal/api/v1/health.go index 3379022..fafaf93 100644 --- a/internal/api/v1/health.go +++ b/internal/api/v1/health.go @@ -4,20 +4,10 @@ import ( "net/http" "time" - "github.com/coder/websocket" - "github.com/coder/websocket/wsjson" - "github.com/yusing/go-proxy/internal/net/gphttp" "github.com/yusing/go-proxy/internal/net/gphttp/gpwebsocket" - "github.com/yusing/go-proxy/internal/net/gphttp/httpheaders" "github.com/yusing/go-proxy/internal/route/routes/routequery" ) func Health(w http.ResponseWriter, r *http.Request) { - if httpheaders.IsWebsocket(r.Header) { - gpwebsocket.Periodic(w, r, 1*time.Second, func(conn *websocket.Conn) error { - return wsjson.Write(r.Context(), conn, routequery.HealthMap()) - }) - } else { - gphttp.RespondJSON(w, r, routequery.HealthMap()) - } + gpwebsocket.DynamicJSONHandler(w, r, routequery.HealthMap, 1*time.Second) } diff --git a/internal/api/v1/list.go b/internal/api/v1/list.go index ef6f95f..123c320 100644 --- a/internal/api/v1/list.go +++ b/internal/api/v1/list.go @@ -13,7 +13,6 @@ import ( "github.com/yusing/go-proxy/internal/net/gphttp/middleware" "github.com/yusing/go-proxy/internal/route/routes/routequery" route "github.com/yusing/go-proxy/internal/route/types" - "github.com/yusing/go-proxy/internal/task" "github.com/yusing/go-proxy/internal/utils" ) @@ -28,7 +27,6 @@ const ( ListRouteProviders = "route_providers" ListHomepageCategories = "homepage_categories" ListIcons = "icons" - ListTasks = "tasks" ) func List(cfg config.ConfigInstance, w http.ResponseWriter, r *http.Request) { @@ -76,8 +74,6 @@ func List(cfg config.ConfigInstance, w http.ResponseWriter, r *http.Request) { icons = []string{} } gphttp.RespondJSON(w, r, icons) - case ListTasks: - gphttp.RespondJSON(w, r, task.DebugTaskList()) default: gphttp.BadRequest(w, fmt.Sprintf("invalid what: %s", what)) } @@ -98,7 +94,7 @@ func listRoute(which string) any { } func listFiles(w http.ResponseWriter, r *http.Request) { - files, err := utils.ListFiles(common.ConfigBasePath, 0, true) + files, err := utils.ListFiles(common.ConfigDir, 0, true) if err != nil { gphttp.ServerError(w, r, err) return @@ -111,17 +107,17 @@ func listFiles(w http.ResponseWriter, r *http.Request) { for _, file := range files { t := fileType(file) - file = strings.TrimPrefix(file, common.ConfigBasePath+"/") + file = strings.TrimPrefix(file, common.ConfigDir+"/") resp[t] = append(resp[t], file) } - mids, err := utils.ListFiles(common.MiddlewareComposeBasePath, 0, true) + mids, err := utils.ListFiles(common.MiddlewareComposeDir, 0, true) if err != nil { gphttp.ServerError(w, r, err) return } for _, mid := range mids { - mid = strings.TrimPrefix(mid, common.MiddlewareComposeBasePath+"/") + mid = strings.TrimPrefix(mid, common.MiddlewareComposeDir+"/") resp[FileTypeMiddleware] = append(resp[FileTypeMiddleware], mid) } gphttp.RespondJSON(w, r, resp) diff --git a/internal/api/v1/new_agent.go b/internal/api/v1/new_agent.go index e4d67e8..9abc627 100644 --- a/internal/api/v1/new_agent.go +++ b/internal/api/v1/new_agent.go @@ -40,7 +40,7 @@ func NewAgent(w http.ResponseWriter, r *http.Request) { return } hostport := fmt.Sprintf("%s:%d", host, port) - if _, ok := config.GetInstance().GetAgent(hostport); ok { + if _, ok := agent.Agents.Get(hostport); ok { gphttp.ClientError(w, gphttp.ErrAlreadyExists("agent", hostport), http.StatusConflict) return } diff --git a/internal/api/v1/query/query.go b/internal/api/v1/query/query.go index 0a1d576..4463834 100644 --- a/internal/api/v1/query/query.go +++ b/internal/api/v1/query/query.go @@ -58,7 +58,3 @@ func ListRoutes() (map[string]map[string]any, gperr.Error) { func ListMiddlewareTraces() (middleware.Traces, gperr.Error) { return List[middleware.Traces](v1.ListMiddlewareTraces) } - -func DebugListTasks() (map[string]any, gperr.Error) { - return List[map[string]any](v1.ListTasks) -} diff --git a/internal/api/v1/stats.go b/internal/api/v1/stats.go index 1fbac51..80c66dd 100644 --- a/internal/api/v1/stats.go +++ b/internal/api/v1/stats.go @@ -4,30 +4,18 @@ import ( "net/http" "time" - "github.com/coder/websocket" - "github.com/coder/websocket/wsjson" config "github.com/yusing/go-proxy/internal/config/types" - "github.com/yusing/go-proxy/internal/net/gphttp" "github.com/yusing/go-proxy/internal/net/gphttp/gpwebsocket" - "github.com/yusing/go-proxy/internal/net/gphttp/httpheaders" "github.com/yusing/go-proxy/internal/utils/strutils" ) func Stats(cfg config.ConfigInstance, w http.ResponseWriter, r *http.Request) { - if httpheaders.IsWebsocket(r.Header) { - gpwebsocket.Periodic(w, r, 1*time.Second, func(conn *websocket.Conn) error { - return wsjson.Write(r.Context(), conn, getStats(cfg)) - }) - } else { - gphttp.RespondJSON(w, r, getStats(cfg)) - } + gpwebsocket.DynamicJSONHandler(w, r, func() map[string]any { + return map[string]any{ + "proxies": cfg.Statistics(), + "uptime": strutils.FormatDuration(time.Since(startTime)), + } + }, 1*time.Second) } var startTime = time.Now() - -func getStats(cfg config.ConfigInstance) map[string]any { - return map[string]any{ - "proxies": cfg.Statistics(), - "uptime": strutils.FormatDuration(time.Since(startTime)), - } -} diff --git a/internal/api/v1/system_info.go b/internal/api/v1/system_info.go index 31f6829..cf1c7ac 100644 --- a/internal/api/v1/system_info.go +++ b/internal/api/v1/system_info.go @@ -3,8 +3,8 @@ package v1 import ( "net/http" + "github.com/yusing/go-proxy/agent/pkg/agent" agentPkg "github.com/yusing/go-proxy/agent/pkg/agent" - config "github.com/yusing/go-proxy/internal/config/types" "github.com/yusing/go-proxy/internal/gperr" "github.com/yusing/go-proxy/internal/metrics/systeminfo" "github.com/yusing/go-proxy/internal/net/gphttp" @@ -12,7 +12,7 @@ import ( "github.com/yusing/go-proxy/internal/net/gphttp/reverseproxy" ) -func SystemInfo(cfg config.ConfigInstance, w http.ResponseWriter, r *http.Request) { +func SystemInfo(w http.ResponseWriter, r *http.Request) { query := r.URL.Query() agentAddr := query.Get("agent_addr") query.Del("agent_addr") @@ -21,7 +21,7 @@ func SystemInfo(cfg config.ConfigInstance, w http.ResponseWriter, r *http.Reques return } - agent, ok := cfg.GetAgent(agentAddr) + agent, ok := agent.Agents.Get(agentAddr) if !ok { gphttp.NotFound(w, "agent_addr") return diff --git a/internal/api/v1/version.go b/internal/api/v1/version.go index f43a9d6..5cf574f 100644 --- a/internal/api/v1/version.go +++ b/internal/api/v1/version.go @@ -8,5 +8,5 @@ import ( ) func GetVersion(w http.ResponseWriter, r *http.Request) { - gphttp.WriteBody(w, []byte(pkg.GetVersion())) + gphttp.WriteBody(w, []byte(pkg.GetVersion().String())) } diff --git a/internal/autocert/constants.go b/internal/autocert/constants.go index 0ff5fb6..59e3aaf 100644 --- a/internal/autocert/constants.go +++ b/internal/autocert/constants.go @@ -1,18 +1,20 @@ package autocert import ( + "path/filepath" + "github.com/go-acme/lego/v4/providers/dns/clouddns" "github.com/go-acme/lego/v4/providers/dns/cloudflare" "github.com/go-acme/lego/v4/providers/dns/duckdns" "github.com/go-acme/lego/v4/providers/dns/ovh" "github.com/go-acme/lego/v4/providers/dns/porkbun" + "github.com/yusing/go-proxy/internal/common" ) -const ( - certBasePath = "certs/" - CertFileDefault = certBasePath + "cert.crt" - KeyFileDefault = certBasePath + "priv.key" - ACMEKeyFileDefault = certBasePath + "acme.key" +var ( + CertFileDefault = filepath.Join(common.CertsDir, "cert.crt") + KeyFileDefault = filepath.Join(common.CertsDir, "priv.key") + ACMEKeyFileDefault = filepath.Join(common.CertsDir, "acme.key") ) const ( diff --git a/internal/common/constants.go b/internal/common/constants.go index 17e6190..0ebc77c 100644 --- a/internal/common/constants.go +++ b/internal/common/constants.go @@ -1,46 +1,10 @@ package common -import ( - "time" -) - -// file, folder structure - -const ( - DotEnvPath = ".env" - DotEnvExamplePath = ".env.example" - - ConfigBasePath = "config" - ConfigFileName = "config.yml" - ConfigExampleFileName = "config.example.yml" - ConfigPath = ConfigBasePath + "/" + ConfigFileName - HomepageJSONConfigPath = ConfigBasePath + "/.homepage.json" - IconListCachePath = ConfigBasePath + "/.icon_list_cache.json" - IconCachePath = ConfigBasePath + "/.icon_cache.json" - - MiddlewareComposeBasePath = ConfigBasePath + "/middlewares" - - ComposeFileName = "compose.yml" - ComposeExampleFileName = "compose.example.yml" - - ErrorPagesBasePath = "error_pages" - - AgentCertsBasePath = "certs" -) - -var RequiredDirectories = []string{ - ConfigBasePath, - ErrorPagesBasePath, - MiddlewareComposeBasePath, -} +import "time" const DockerHostFromEnv = "$DOCKER_HOST" const ( HealthCheckIntervalDefault = 5 * time.Second HealthCheckTimeoutDefault = 5 * time.Second - - WakeTimeoutDefault = "30s" - StopTimeoutDefault = "30s" - StopMethodDefault = "stop" ) diff --git a/internal/common/env.go b/internal/common/env.go index 767d411..079231e 100644 --- a/internal/common/env.go +++ b/internal/common/env.go @@ -19,6 +19,8 @@ var ( IsDebug = GetEnvBool("DEBUG", IsTest) IsTrace = GetEnvBool("TRACE", false) && IsDebug + RootDir = GetEnvString("ROOT_DIR", "./") + HTTP3Enabled = GetEnvBool("HTTP3_ENABLED", true) ProxyHTTPAddr, diff --git a/internal/common/paths.go b/internal/common/paths.go new file mode 100644 index 0000000..09e0f90 --- /dev/null +++ b/internal/common/paths.go @@ -0,0 +1,33 @@ +package common + +import ( + "path/filepath" +) + +// file, folder structure + +var ( + ConfigDir = filepath.Join(RootDir, "config") + ConfigFileName = "config.yml" + ConfigExampleFileName = "config.example.yml" + ConfigPath = filepath.Join(ConfigDir, ConfigFileName) + + MiddlewareComposeDir = filepath.Join(ConfigDir, "middlewares") + ErrorPagesDir = filepath.Join(RootDir, "error_pages") + CertsDir = filepath.Join(RootDir, "certs") + + DataDir = filepath.Join(RootDir, "data") + MetricsDataDir = filepath.Join(DataDir, "metrics") + + HomepageJSONConfigPath = filepath.Join(DataDir, "homepage.json") + IconListCachePath = filepath.Join(DataDir, "icon_list_cache.json") + IconCachePath = filepath.Join(DataDir, "icon_cache.json") +) + +var RequiredDirectories = []string{ + ConfigDir, + ErrorPagesDir, + MiddlewareComposeDir, + DataDir, + MetricsDataDir, +} diff --git a/internal/config/agent_pool.go b/internal/config/agent_pool.go deleted file mode 100644 index d3b7610..0000000 --- a/internal/config/agent_pool.go +++ /dev/null @@ -1,66 +0,0 @@ -package config - -import ( - "slices" - - "github.com/yusing/go-proxy/agent/pkg/agent" - "github.com/yusing/go-proxy/internal/gperr" - "github.com/yusing/go-proxy/internal/route/provider" - "github.com/yusing/go-proxy/internal/utils/functional" -) - -var agentPool = functional.NewMapOf[string, *agent.AgentConfig]() - -func addAgent(agent *agent.AgentConfig) { - agentPool.Store(agent.Addr, agent) -} - -func removeAllAgents() { - agentPool.Clear() -} - -func GetAgent(addr string) (agent *agent.AgentConfig, ok bool) { - agent, ok = agentPool.Load(addr) - return -} - -func (cfg *Config) GetAgent(agentAddrOrDockerHost string) (*agent.AgentConfig, bool) { - if !agent.IsDockerHostAgent(agentAddrOrDockerHost) { - return GetAgent(agentAddrOrDockerHost) - } - return GetAgent(agent.GetAgentAddrFromDockerHost(agentAddrOrDockerHost)) -} - -func (cfg *Config) VerifyNewAgent(host string, ca agent.PEMPair, client agent.PEMPair) (int, gperr.Error) { - if slices.ContainsFunc(cfg.value.Providers.Agents, func(a *agent.AgentConfig) bool { - return a.Addr == host - }) { - return 0, gperr.New("agent already exists") - } - - var agentCfg agent.AgentConfig - agentCfg.Addr = host - err := agentCfg.InitWithCerts(cfg.task.Context(), ca.Cert, client.Cert, client.Key) - if err != nil { - return 0, gperr.Wrap(err, "failed to start agent") - } - addAgent(&agentCfg) - - provider := provider.NewAgentProvider(&agentCfg) - if err := cfg.errIfExists(provider); err != nil { - return 0, err - } - err = provider.LoadRoutes() - if err != nil { - return 0, gperr.Wrap(err, "failed to load routes") - } - return provider.NumRoutes(), nil -} - -func (cfg *Config) ListAgents() []*agent.AgentConfig { - agents := make([]*agent.AgentConfig, 0, agentPool.Size()) - agentPool.RangeAll(func(key string, value *agent.AgentConfig) { - agents = append(agents, value) - }) - return agents -} diff --git a/internal/config/config.go b/internal/config/config.go index 2dacc66..d21d135 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -19,6 +19,7 @@ import ( "github.com/yusing/go-proxy/internal/logging" "github.com/yusing/go-proxy/internal/net/gphttp/server" "github.com/yusing/go-proxy/internal/notif" + "github.com/yusing/go-proxy/internal/proxmox" proxy "github.com/yusing/go-proxy/internal/route/provider" "github.com/yusing/go-proxy/internal/task" "github.com/yusing/go-proxy/internal/utils" @@ -215,23 +216,22 @@ func (cfg *Config) StartServers(opts ...*StartServersOptions) { } func (cfg *Config) load() gperr.Error { - const errMsg = "config load error" - data, err := os.ReadFile(common.ConfigPath) if err != nil { - gperr.LogFatal(errMsg, err) + gperr.LogFatal("error reading config", err) } model := config.DefaultConfig() if err := utils.UnmarshalValidateYAML(data, model); err != nil { - gperr.LogFatal(errMsg, err) + gperr.LogFatal("error unmarshalling config", err) } // errors are non fatal below - errs := gperr.NewBuilder(errMsg) + errs := gperr.NewBuilder() errs.Add(cfg.entrypoint.SetMiddlewares(model.Entrypoint.Middlewares)) errs.Add(cfg.entrypoint.SetAccessLogger(cfg.task, model.Entrypoint.AccessLog)) cfg.initNotification(model.Providers.Notification) + errs.Add(cfg.initProxmox(model.Providers.Proxmox)) errs.Add(cfg.initAutoCert(model.AutoCert)) errs.Add(cfg.loadRouteProviders(&model.Providers)) @@ -256,6 +256,18 @@ func (cfg *Config) initNotification(notifCfg []notif.NotificationConfig) { } } +func (cfg *Config) initProxmox(proxmoxCfgs []proxmox.Config) (err gperr.Error) { + errs := gperr.NewBuilder("proxmox config errors") + for _, proxmoxCfg := range proxmoxCfgs { + if err := proxmoxCfg.Init(); err != nil { + errs.Add(err.Subject(proxmoxCfg.URL)) + } else { + proxmox.Clients.Add(proxmoxCfg.Client()) + } + } + return errs.Error() +} + func (cfg *Config) initAutoCert(autocertCfg *autocert.AutocertConfig) (err gperr.Error) { if cfg.autocertProvider != nil { return @@ -277,8 +289,8 @@ func (cfg *Config) errIfExists(p *proxy.Provider) gperr.Error { func (cfg *Config) initAgents(agentCfgs []*agent.AgentConfig) gperr.Error { var wg sync.WaitGroup - var errs gperr.Builder + errs := gperr.NewBuilderWithConcurrency() wg.Add(len(agentCfgs)) for _, agentCfg := range agentCfgs { go func(agentCfg *agent.AgentConfig) { @@ -286,7 +298,7 @@ func (cfg *Config) initAgents(agentCfgs []*agent.AgentConfig) gperr.Error { if err := agentCfg.Init(cfg.task.Context()); err != nil { errs.Add(err.Subject(agentCfg.String())) } else { - addAgent(agentCfg) + agent.Agents.Add(agentCfg) } }(agentCfg) } @@ -298,7 +310,7 @@ func (cfg *Config) loadRouteProviders(providers *config.Providers) gperr.Error { errs := gperr.NewBuilder("route provider errors") results := gperr.NewBuilder("loaded route providers") - removeAllAgents() + agent.Agents.Clear() n := len(providers.Agents) + len(providers.Docker) + len(providers.Files) if n == 0 { @@ -309,12 +321,12 @@ func (cfg *Config) loadRouteProviders(providers *config.Providers) gperr.Error { errs.Add(cfg.initAgents(providers.Agents)) - for _, agent := range providers.Agents { - if !agent.IsInitialized() { // failed to initialize + for _, a := range providers.Agents { + if !a.IsInitialized() { // failed to initialize continue } - addAgent(agent) - routeProviders = append(routeProviders, proxy.NewAgentProvider(agent)) + agent.Agents.Add(a) + routeProviders = append(routeProviders, proxy.NewAgentProvider(a)) } for _, filename := range providers.Files { routeProviders = append(routeProviders, proxy.NewFileProvider(filename)) @@ -338,6 +350,8 @@ func (cfg *Config) loadRouteProviders(providers *config.Providers) gperr.Error { lenLongestName = len(k) } }) + errs.EnableConcurrency() + results.EnableConcurrency() cfg.providers.RangeAllParallel(func(_ string, p *proxy.Provider) { if err := p.LoadRoutes(); err != nil { errs.Add(err.Subject(p.String())) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index ed6c702..7070ec5 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -56,7 +56,7 @@ func TestFileProviderValidate(t *testing.T) { cfg := config.DefaultConfig() if tt.init != nil { for _, filename := range tt.filenames { - filepath := path.Join(common.ConfigBasePath, filename) + filepath := path.Join(common.ConfigDir, filename) assert.NoError(t, tt.init(filepath)) } } @@ -67,7 +67,7 @@ func TestFileProviderValidate(t *testing.T) { })), cfg) if tt.cleanup != nil { for _, filename := range tt.filenames { - filepath := path.Join(common.ConfigBasePath, filename) + filepath := path.Join(common.ConfigDir, filename) assert.NoError(t, tt.cleanup(filepath)) } } diff --git a/internal/config/query.go b/internal/config/query.go index 70b101b..ec4cccf 100644 --- a/internal/config/query.go +++ b/internal/config/query.go @@ -1,6 +1,10 @@ package config import ( + "slices" + + "github.com/yusing/go-proxy/agent/pkg/agent" + "github.com/yusing/go-proxy/internal/gperr" "github.com/yusing/go-proxy/internal/route" "github.com/yusing/go-proxy/internal/route/provider" ) @@ -51,3 +55,32 @@ func (cfg *Config) Statistics() map[string]any { "providers": providerStats, } } + +func (cfg *Config) VerifyNewAgent(host string, ca agent.PEMPair, client agent.PEMPair) (int, gperr.Error) { + if slices.ContainsFunc(cfg.value.Providers.Agents, func(a *agent.AgentConfig) bool { + return a.Addr == host + }) { + return 0, gperr.New("agent already exists") + } + + agentCfg := new(agent.AgentConfig) + agentCfg.Addr = host + err := agentCfg.InitWithCerts(cfg.task.Context(), ca.Cert, client.Cert, client.Key) + if err != nil { + return 0, gperr.Wrap(err, "failed to start agent") + } + // must add it first to let LoadRoutes() reference from it + agent.Agents.Add(agentCfg) + + provider := provider.NewAgentProvider(agentCfg) + if err := cfg.errIfExists(provider); err != nil { + agent.Agents.Del(agentCfg) + return 0, err + } + err = provider.LoadRoutes() + if err != nil { + agent.Agents.Del(agentCfg) + return 0, gperr.Wrap(err, "failed to load routes") + } + return provider.NumRoutes(), nil +} diff --git a/internal/config/types/config.go b/internal/config/types/config.go index 7c1b9d3..a2d7326 100644 --- a/internal/config/types/config.go +++ b/internal/config/types/config.go @@ -14,7 +14,7 @@ import ( "github.com/yusing/go-proxy/internal/gperr" "github.com/yusing/go-proxy/internal/net/gphttp/accesslog" "github.com/yusing/go-proxy/internal/notif" - proxmox "github.com/yusing/go-proxy/internal/proxmox/types" + "github.com/yusing/go-proxy/internal/proxmox" "github.com/yusing/go-proxy/internal/utils" ) @@ -28,11 +28,11 @@ type ( TimeoutShutdown int `json:"timeout_shutdown" validate:"gte=0"` } Providers struct { - Files []string `json:"include" yaml:"include,omitempty" validate:"unique,dive,config_file_exists"` - Docker map[string]string `json:"docker" yaml:"docker,omitempty" validate:"unique,dive,unix_addr|url"` - Proxmox map[string]proxmox.Config `json:"proxmox" yaml:"proxmox,omitempty"` - Agents []*agent.AgentConfig `json:"agents" yaml:"agents,omitempty" validate:"unique=Addr"` - Notification []notif.NotificationConfig `json:"notification" yaml:"notification,omitempty" validate:"unique=ProviderName"` + Files []string `json:"include" validate:"unique,dive,config_file_exists"` + Docker map[string]string `json:"docker" validate:"unique,dive,unix_addr|url"` + Proxmox []proxmox.Config `json:"proxmox"` + Agents []*agent.AgentConfig `json:"agents" validate:"unique=Addr"` + Notification []notif.NotificationConfig `json:"notification" validate:"unique=ProviderName"` } Entrypoint struct { Middlewares []map[string]any `json:"middlewares"` @@ -45,9 +45,7 @@ type ( Statistics() map[string]any RouteProviderList() []string Context() context.Context - GetAgent(agentAddrOrDockerHost string) (*agent.AgentConfig, bool) VerifyNewAgent(host string, ca agent.PEMPair, client agent.PEMPair) (int, gperr.Error) - ListAgents() []*agent.AgentConfig AutoCertProvider() *autocert.Provider } ) @@ -104,7 +102,7 @@ func init() { }) utils.MustRegisterValidation("config_file_exists", func(fl validator.FieldLevel) bool { filename := fl.Field().Interface().(string) - info, err := os.Stat(path.Join(common.ConfigBasePath, filename)) + info, err := os.Stat(path.Join(common.ConfigDir, filename)) return err == nil && !info.IsDir() }) } diff --git a/internal/docker/client.go b/internal/docker/client.go index f502ec5..99abccc 100644 --- a/internal/docker/client.go +++ b/internal/docker/client.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "maps" "net" "net/http" "sync" @@ -14,16 +15,15 @@ import ( "github.com/docker/docker/client" "github.com/yusing/go-proxy/agent/pkg/agent" "github.com/yusing/go-proxy/internal/common" - config "github.com/yusing/go-proxy/internal/config/types" "github.com/yusing/go-proxy/internal/logging" "github.com/yusing/go-proxy/internal/task" + "github.com/yusing/go-proxy/internal/utils/strutils" ) type ( SharedClient struct { *client.Client - key string refCount uint32 closedOn int64 @@ -66,7 +66,7 @@ func initClientCleaner() { defer clientMapMu.Unlock() for _, c := range clientMap { - delete(clientMap, c.key) + delete(clientMap, c.Key()) c.Client.Close() } }) @@ -80,30 +80,20 @@ func closeTimedOutClients() { for _, c := range clientMap { if atomic.LoadUint32(&c.refCount) == 0 && now-atomic.LoadInt64(&c.closedOn) > clientTTLSecs { - delete(clientMap, c.key) + delete(clientMap, c.Key()) c.Client.Close() - logging.Debug().Str("host", c.key).Msg("docker client closed") + logging.Debug().Str("host", c.DaemonHost()).Msg("docker client closed") } } } -func (c *SharedClient) Address() string { - return c.addr -} +func Clients() map[string]*SharedClient { + clientMapMu.RLock() + defer clientMapMu.RUnlock() -func (c *SharedClient) CheckConnection(ctx context.Context) error { - conn, err := c.dial(ctx) - if err != nil { - return err - } - conn.Close() - return nil -} - -// if the client is still referenced, this is no-op. -func (c *SharedClient) Close() { - atomic.StoreInt64(&c.closedOn, time.Now().Unix()) - atomic.AddUint32(&c.refCount, ^uint32(0)) + clients := make(map[string]*SharedClient, len(clientMap)) + maps.Copy(clients, clientMap) + return clients } // NewClient creates a new Docker client connection to the specified host. @@ -134,7 +124,7 @@ func NewClient(host string) (*SharedClient, error) { var dial func(ctx context.Context) (net.Conn, error) if agent.IsDockerHostAgent(host) { - cfg, ok := config.GetInstance().GetAgent(host) + cfg, ok := agent.Agents.Get(host) if !ok { panic(fmt.Errorf("agent %q not found", host)) } @@ -187,7 +177,6 @@ func NewClient(host string) (*SharedClient, error) { c := &SharedClient{ Client: client, - key: host, refCount: 1, addr: addr, dial: dial, @@ -197,9 +186,44 @@ func NewClient(host string) (*SharedClient, error) { if c.dial == nil { c.dial = client.Dialer() } + if c.addr == "" { + c.addr = c.Client.DaemonHost() + } defer logging.Debug().Str("host", host).Msg("docker client initialized") - clientMap[c.key] = c + clientMap[c.Key()] = c return c, nil } + +func (c *SharedClient) Key() string { + return c.DaemonHost() +} + +func (c *SharedClient) Address() string { + return c.addr +} + +func (c *SharedClient) CheckConnection(ctx context.Context) error { + conn, err := c.dial(ctx) + if err != nil { + return err + } + conn.Close() + return nil +} + +// if the client is still referenced, this is no-op. +func (c *SharedClient) Close() { + atomic.StoreInt64(&c.closedOn, time.Now().Unix()) + atomic.AddUint32(&c.refCount, ^uint32(0)) +} + +func (c *SharedClient) MarshalMap() map[string]any { + return map[string]any{ + "host": c.DaemonHost(), + "addr": c.addr, + "ref_count": c.refCount, + "closed_on": strutils.FormatUnixTime(c.closedOn), + } +} diff --git a/internal/docker/container.go b/internal/docker/container.go index 43e0288..3edd2af 100644 --- a/internal/docker/container.go +++ b/internal/docker/container.go @@ -8,16 +8,17 @@ import ( "github.com/docker/docker/api/types/container" "github.com/docker/go-connections/nat" "github.com/yusing/go-proxy/agent/pkg/agent" - config "github.com/yusing/go-proxy/internal/config/types" + "github.com/yusing/go-proxy/internal/gperr" + idlewatcher "github.com/yusing/go-proxy/internal/idlewatcher/types" "github.com/yusing/go-proxy/internal/logging" - U "github.com/yusing/go-proxy/internal/utils" + "github.com/yusing/go-proxy/internal/utils" "github.com/yusing/go-proxy/internal/utils/strutils" ) type ( PortMapping = map[int]*container.Port Container struct { - _ U.NoCopy + _ utils.NoCopy DockerHost string `json:"docker_host"` Image *ContainerImage `json:"image"` @@ -26,7 +27,8 @@ type ( Agent *agent.AgentConfig `json:"agent"` - Labels map[string]string `json:"-"` + RouteConfig map[string]string `json:"route_config"` + IdlewatcherConfig *idlewatcher.Config `json:"idlewatcher_config"` Mounts []string `json:"mounts"` @@ -35,16 +37,10 @@ type ( PublicHostname string `json:"public_hostname"` PrivateHostname string `json:"private_hostname"` - Aliases []string `json:"aliases"` - IsExcluded bool `json:"is_excluded"` - IsExplicit bool `json:"is_explicit"` - IdleTimeout string `json:"idle_timeout,omitempty"` - WakeTimeout string `json:"wake_timeout,omitempty"` - StopMethod string `json:"stop_method,omitempty"` - StopTimeout string `json:"stop_timeout,omitempty"` // stop_method = "stop" only - StopSignal string `json:"stop_signal,omitempty"` // stop_method = "stop" | "kill" only - StartEndpoint string `json:"start_endpoint,omitempty"` - Running bool `json:"running"` + Aliases []string `json:"aliases"` + IsExcluded bool `json:"is_excluded"` + IsExplicit bool `json:"is_explicit"` + Running bool `json:"running"` } ContainerImage struct { Author string `json:"author,omitempty"` @@ -69,21 +65,15 @@ func FromDocker(c *container.Summary, dockerHost string) (res *Container) { PublicPortMapping: helper.getPublicPortMapping(), PrivatePortMapping: helper.getPrivatePortMapping(), - Aliases: helper.getAliases(), - IsExcluded: strutils.ParseBool(helper.getDeleteLabel(LabelExclude)), - IsExplicit: isExplicit, - IdleTimeout: helper.getDeleteLabel(LabelIdleTimeout), - WakeTimeout: helper.getDeleteLabel(LabelWakeTimeout), - StopMethod: helper.getDeleteLabel(LabelStopMethod), - StopTimeout: helper.getDeleteLabel(LabelStopTimeout), - StopSignal: helper.getDeleteLabel(LabelStopSignal), - StartEndpoint: helper.getDeleteLabel(LabelStartEndpoint), - Running: c.Status == "running" || c.State == "running", + Aliases: helper.getAliases(), + IsExcluded: strutils.ParseBool(helper.getDeleteLabel(LabelExclude)), + IsExplicit: isExplicit, + Running: c.Status == "running" || c.State == "running", } if agent.IsDockerHostAgent(dockerHost) { var ok bool - res.Agent, ok = config.GetInstance().GetAgent(dockerHost) + res.Agent, ok = agent.Agents.Get(dockerHost) if !ok { logging.Error().Msgf("agent %q not found", dockerHost) } @@ -91,6 +81,7 @@ func FromDocker(c *container.Summary, dockerHost string) (res *Container) { res.setPrivateHostname(helper) res.setPublicHostname() + res.loadDeleteIdlewatcherLabels(helper) for lbl := range c.Labels { if strings.HasPrefix(lbl, NSProxy+".") { @@ -200,3 +191,31 @@ func (c *Container) setPrivateHostname(helper containerHelper) { return } } + +func (c *Container) loadDeleteIdlewatcherLabels(helper containerHelper) { + cfg := map[string]any{ + "idle_timeout": helper.getDeleteLabel(LabelIdleTimeout), + "wake_timeout": helper.getDeleteLabel(LabelWakeTimeout), + "stop_method": helper.getDeleteLabel(LabelStopMethod), + "stop_timeout": helper.getDeleteLabel(LabelStopTimeout), + "stop_signal": helper.getDeleteLabel(LabelStopSignal), + "start_endpoint": helper.getDeleteLabel(LabelStartEndpoint), + } + // set only if idlewatcher is enabled + idleTimeout := cfg["idle_timeout"] + if idleTimeout != "" { + idwCfg := &idlewatcher.Config{ + Docker: &idlewatcher.DockerConfig{ + DockerHost: c.DockerHost, + ContainerID: c.ContainerID, + ContainerName: c.ContainerName, + }, + } + err := utils.MapUnmarshalValidate(cfg, idwCfg) + if err != nil { + gperr.LogWarn("invalid idlewatcher config", gperr.PrependSubject(c.ContainerName, err)) + } else { + c.IdlewatcherConfig = idwCfg + } + } +} diff --git a/internal/docker/inspect.go b/internal/docker/inspect.go index 8eb1413..6d708c5 100644 --- a/internal/docker/inspect.go +++ b/internal/docker/inspect.go @@ -24,5 +24,5 @@ func (c *SharedClient) Inspect(containerID string) (*Container, error) { if err != nil { return nil, err } - return FromInspectResponse(json, c.key), nil + return FromInspectResponse(json, c.DaemonHost()), nil } diff --git a/internal/entrypoint/entrypoint.go b/internal/entrypoint/entrypoint.go index 1a3a166..65e8d07 100644 --- a/internal/entrypoint/entrypoint.go +++ b/internal/entrypoint/entrypoint.go @@ -61,7 +61,7 @@ func (ep *Entrypoint) SetAccessLogger(parent task.Parent, cfg *accesslog.Config) return } - ep.accessLogger, err = accesslog.NewFileAccessLogger(parent, cfg) + ep.accessLogger, err = accesslog.NewAccessLogger(parent, cfg) if err != nil { return } diff --git a/internal/gperr/base.go b/internal/gperr/base.go index 96a36b3..bc30107 100644 --- a/internal/gperr/base.go +++ b/internal/gperr/base.go @@ -1,9 +1,10 @@ package gperr import ( - "encoding/json" "errors" "fmt" + + "encoding/json" ) // baseError is an immutable wrapper around an error. @@ -48,17 +49,6 @@ func (err *baseError) Error() string { return err.Err.Error() } -// MarshalJSON implements the json.Marshaler interface. func (err *baseError) MarshalJSON() ([]byte, error) { - //nolint:errorlint - switch err := err.Err.(type) { - case Error, *withSubject: - return json.Marshal(err) - case json.Marshaler: - return err.MarshalJSON() - case interface{ MarshalText() ([]byte, error) }: - return err.MarshalText() - default: - return json.Marshal(err.Error()) - } + return json.Marshal(err.Err) } diff --git a/internal/gperr/builder.go b/internal/gperr/builder.go index ac85b56..6910ec6 100644 --- a/internal/gperr/builder.go +++ b/internal/gperr/builder.go @@ -24,6 +24,10 @@ type Builder struct { rwLock } +type multiline struct { + *Builder +} + // NewBuilder creates a new Builder. // // If about is not provided, the Builder will not have a subject @@ -78,12 +82,15 @@ func (b *Builder) Add(err error) *Builder { return b } - wrapped := wrap(err) - b.Lock() defer b.Unlock() - switch err := wrapped.(type) { + b.add(err) + return b +} + +func (b *Builder) add(err error) { + switch err := err.(type) { case *baseError: b.errs = append(b.errs, err.Err) case *nestedError: @@ -92,11 +99,11 @@ func (b *Builder) Add(err error) *Builder { } else { b.errs = append(b.errs, err) } + case *MultilineError: + b.add(&err.nestedError) default: - panic("bug: should not reach here") + b.errs = append(b.errs, err) } - - return b } func (b *Builder) Adds(err string) *Builder { @@ -144,8 +151,9 @@ func (b *Builder) AddRange(errs ...error) *Builder { b.Lock() defer b.Unlock() - b.errs = append(b.errs, nonNilErrs...) - + for _, err := range nonNilErrs { + b.add(err) + } return b } diff --git a/internal/gperr/log.go b/internal/gperr/log.go index 94c2d15..dfa43b5 100644 --- a/internal/gperr/log.go +++ b/internal/gperr/log.go @@ -6,14 +6,14 @@ import ( "github.com/yusing/go-proxy/internal/logging" ) -func log(msg string, err error, level zerolog.Level, logger ...*zerolog.Logger) { +func log(_ string, err error, level zerolog.Level, logger ...*zerolog.Logger) { var l *zerolog.Logger if len(logger) > 0 { l = logger[0] } else { l = logging.GetLogger() } - l.WithLevel(level).Msg(msg + ": " + err.Error()) + l.WithLevel(level).Msg(err.Error()) } func LogFatal(msg string, err error, logger ...*zerolog.Logger) { diff --git a/internal/gperr/multiline.go b/internal/gperr/multiline.go new file mode 100644 index 0000000..6fff371 --- /dev/null +++ b/internal/gperr/multiline.go @@ -0,0 +1,45 @@ +package gperr + +import ( + "fmt" + "reflect" +) + +type MultilineError struct { + nestedError +} + +func Multiline() *MultilineError { + return &MultilineError{} +} + +func (m *MultilineError) add(err error) { + m.Extras = append(m.Extras, err) +} + +func (m *MultilineError) Addf(format string, args ...any) *MultilineError { + m.add(fmt.Errorf(format, args...)) + return m +} + +func (m *MultilineError) Adds(s string) *MultilineError { + m.add(newError(s)) + return m +} + +func (m *MultilineError) AddLines(lines any) *MultilineError { + v := reflect.ValueOf(lines) + if v.Kind() == reflect.Slice { + for i := range v.Len() { + switch v := v.Index(i).Interface().(type) { + case string: + m.add(newError(v)) + case error: + m.add(v) + default: + m.add(fmt.Errorf("%v", v)) + } + } + } + return m +} diff --git a/internal/gperr/multiline_test.go b/internal/gperr/multiline_test.go new file mode 100644 index 0000000..43e030c --- /dev/null +++ b/internal/gperr/multiline_test.go @@ -0,0 +1,38 @@ +package gperr + +import ( + "net" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestMultiline(t *testing.T) { + multiline := Multiline() + multiline.Addf("line 1 %s", "test") + multiline.Adds("line 2") + multiline.AddLines([]any{1, "2", 3.0, net.IPv4(127, 0, 0, 1)}) + t.Error(New("result").With(multiline)) + t.Error(multiline.Subject("subject").Withf("inner")) +} + +func TestWrapMultiline(t *testing.T) { + multiline := Multiline() + var wrapper error = wrap(multiline) + _, ok := wrapper.(*MultilineError) + if !ok { + t.Errorf("wrapper is not a MultilineError") + } +} + +func TestPrependSubjectMultiline(t *testing.T) { + multiline := Multiline() + multiline.Addf("line 1 %s", "test") + multiline.Adds("line 2") + multiline.AddLines([]any{1, "2", 3.0, net.IPv4(127, 0, 0, 1)}) + multiline.Subject("subject") + + builder := NewBuilder() + builder.Add(multiline) + require.Equal(t, len(builder.errs), len(multiline.Extras), builder.errs) +} diff --git a/internal/gperr/nested_error.go b/internal/gperr/nested_error.go index 08a5e77..12dbc19 100644 --- a/internal/gperr/nested_error.go +++ b/internal/gperr/nested_error.go @@ -15,7 +15,7 @@ type nestedError struct { func (err nestedError) Subject(subject string) Error { if err.Err == nil { - err.Err = newError(subject) + err.Err = PrependSubject(subject, errStr("")) } else { err.Err = PrependSubject(subject, err.Err) } diff --git a/internal/gperr/subject.go b/internal/gperr/subject.go index 6bbee63..4439951 100644 --- a/internal/gperr/subject.go +++ b/internal/gperr/subject.go @@ -1,10 +1,12 @@ package gperr import ( - "encoding/json" + "errors" "slices" "strings" + "encoding/json" + "github.com/yusing/go-proxy/internal/utils/strutils/ansi" ) @@ -64,7 +66,7 @@ func (err *withSubject) Prepend(subject string) *withSubject { } func (err *withSubject) Is(other error) bool { - return err.Err == other + return errors.Is(other, err.Err) } func (err *withSubject) Unwrap() error { @@ -92,7 +94,6 @@ func (err *withSubject) Error() string { return sb.String() } -// MarshalJSON implements the json.Marshaler interface. func (err *withSubject) MarshalJSON() ([]byte, error) { subjects := slices.Clone(err.Subjects) slices.Reverse(subjects) diff --git a/internal/gperr/utils.go b/internal/gperr/utils.go index 4ac1d64..b26b683 100644 --- a/internal/gperr/utils.go +++ b/internal/gperr/utils.go @@ -1,9 +1,10 @@ package gperr import ( - "encoding/json" "errors" "fmt" + + "encoding/json" ) func newError(message string) error { @@ -41,6 +42,18 @@ func Wrap(err error, message ...string) Error { return &baseError{fmt.Errorf("%s: %w", message[0], err)} } +func Unwrap(err error) Error { + //nolint:errorlint + switch err := err.(type) { + case interface{ Unwrap() []error }: + return &nestedError{Extras: err.Unwrap()} + case interface{ Unwrap() error }: + return &baseError{err.Unwrap()} + default: + return &baseError{err} + } +} + func wrap(err error) Error { if err == nil { return nil diff --git a/internal/homepage/route.go b/internal/homepage/route.go index d48cf71..a91c5ae 100644 --- a/internal/homepage/route.go +++ b/internal/homepage/route.go @@ -2,15 +2,14 @@ package homepage import ( "net/http" - - net "github.com/yusing/go-proxy/internal/net/types" + "net/url" ) type route interface { TargetName() string ProviderName() string Reference() string - TargetURL() *net.URL + TargetURL() *url.URL } type httpRoute interface { diff --git a/internal/idlewatcher/common.go b/internal/idlewatcher/common.go new file mode 100644 index 0000000..6753237 --- /dev/null +++ b/internal/idlewatcher/common.go @@ -0,0 +1,13 @@ +package idlewatcher + +import "context" + +func (w *Watcher) cancelled(reqCtx context.Context) bool { + select { + case <-reqCtx.Done(): + w.l.Debug().AnErr("cause", context.Cause(reqCtx)).Msg("wake canceled") + return true + default: + return false + } +} diff --git a/internal/idlewatcher/container.go b/internal/idlewatcher/container.go deleted file mode 100644 index 1d8fbd0..0000000 --- a/internal/idlewatcher/container.go +++ /dev/null @@ -1,60 +0,0 @@ -package idlewatcher - -import ( - "context" - "errors" - - "github.com/docker/docker/api/types/container" -) - -type ( - containerMeta struct { - ContainerID, ContainerName string - } - containerState struct { - running bool - ready bool - err error - } -) - -func (w *Watcher) ContainerID() string { - return w.route.ContainerInfo().ContainerID -} - -func (w *Watcher) ContainerName() string { - return w.route.ContainerInfo().ContainerName -} - -func (w *Watcher) containerStop(ctx context.Context) error { - return w.client.ContainerStop(ctx, w.ContainerID(), container.StopOptions{ - Signal: string(w.Config().StopSignal), - Timeout: &w.Config().StopTimeout, - }) -} - -func (w *Watcher) containerPause(ctx context.Context) error { - return w.client.ContainerPause(ctx, w.ContainerID()) -} - -func (w *Watcher) containerKill(ctx context.Context) error { - return w.client.ContainerKill(ctx, w.ContainerID(), string(w.Config().StopSignal)) -} - -func (w *Watcher) containerUnpause(ctx context.Context) error { - return w.client.ContainerUnpause(ctx, w.ContainerID()) -} - -func (w *Watcher) containerStart(ctx context.Context) error { - return w.client.ContainerStart(ctx, w.ContainerID(), container.StartOptions{}) -} - -func (w *Watcher) containerStatus() (string, error) { - ctx, cancel := context.WithTimeoutCause(w.task.Context(), dockerReqTimeout, errors.New("docker request timeout")) - defer cancel() - json, err := w.client.ContainerInspect(ctx, w.ContainerID()) - if err != nil { - return "", err - } - return json.State.Status, nil -} diff --git a/internal/idlewatcher/debug.go b/internal/idlewatcher/debug.go new file mode 100644 index 0000000..a8aa7d3 --- /dev/null +++ b/internal/idlewatcher/debug.go @@ -0,0 +1,40 @@ +package idlewatcher + +import ( + "iter" + "strconv" + + "github.com/yusing/go-proxy/internal/utils/strutils" +) + +type watcherDebug struct { + *Watcher +} + +func (w watcherDebug) MarshalMap() map[string]any { + state := w.state.Load() + return map[string]any{ + "name": w.Name(), + "state": map[string]string{ + "status": string(state.status), + "ready": strconv.FormatBool(state.ready), + "err": fmtErr(state.err), + }, + "expires": strutils.FormatTime(w.expires()), + "last_reset": strutils.FormatTime(w.lastReset.Load()), + "config": w.cfg, + } +} + +func Watchers() iter.Seq2[string, watcherDebug] { + return func(yield func(string, watcherDebug) bool) { + watcherMapMu.RLock() + defer watcherMapMu.RUnlock() + + for k, w := range watcherMap { + if !yield(k, watcherDebug{w}) { + return + } + } + } +} diff --git a/internal/idlewatcher/waker_http.go b/internal/idlewatcher/handle_http.go similarity index 69% rename from internal/idlewatcher/waker_http.go rename to internal/idlewatcher/handle_http.go index 3965713..6d87f49 100644 --- a/internal/idlewatcher/waker_http.go +++ b/internal/idlewatcher/handle_http.go @@ -42,20 +42,6 @@ func (w *Watcher) ServeHTTP(rw http.ResponseWriter, r *http.Request) { } } -func (w *Watcher) cancelled(reqCtx context.Context, rw http.ResponseWriter) bool { - select { - case <-reqCtx.Done(): - w.WakeDebug().Str("cause", context.Cause(reqCtx).Error()).Msg("canceled") - return true - case <-w.task.Context().Done(): - w.WakeDebug().Str("cause", w.task.FinishCause().Error()).Msg("canceled") - http.Error(rw, "Service unavailable", http.StatusServiceUnavailable) - return true - default: - return false - } -} - func isFaviconPath(path string) bool { return path == "/favicon.ico" } @@ -70,13 +56,13 @@ func (w *Watcher) wakeFromHTTP(rw http.ResponseWriter, r *http.Request) (shouldN // handle favicon request if isFaviconPath(r.URL.Path) { - r.URL.RawQuery = "alias=" + w.route.TargetName() + r.URL.RawQuery = "alias=" + w.rp.TargetName favicon.GetFavIcon(rw, r) return false } // Check if start endpoint is configured and request path matches - if w.Config().StartEndpoint != "" && r.URL.Path != w.Config().StartEndpoint { + if w.cfg.StartEndpoint != "" && r.URL.Path != w.cfg.StartEndpoint { http.Error(rw, "Forbidden: Container can only be started via configured start endpoint", http.StatusForbidden) return false } @@ -95,44 +81,48 @@ func (w *Watcher) wakeFromHTTP(rw http.ResponseWriter, r *http.Request) (shouldN rw.Header().Add("Cache-Control", "must-revalidate") rw.Header().Add("Connection", "close") if _, err := rw.Write(body); err != nil { - w.Err(err).Msg("error writing http response") + return false } return false } - ctx, cancel := context.WithTimeoutCause(r.Context(), w.Config().WakeTimeout, errors.New("wake timeout")) + ctx, cancel := context.WithTimeoutCause(r.Context(), w.cfg.WakeTimeout, errors.New("wake timeout")) defer cancel() - if w.cancelled(ctx, rw) { + if w.cancelled(ctx) { + gphttp.ServerError(rw, r, context.Cause(ctx), http.StatusServiceUnavailable) return false } - w.WakeTrace().Msg("signal received") + w.l.Trace().Msg("signal received") err := w.wakeIfStopped() if err != nil { - w.WakeError(err) - http.Error(rw, "Error waking container", http.StatusInternalServerError) + gphttp.ServerError(rw, r, err) return false } + var ready bool + for { - if w.cancelled(ctx, rw) { + w.resetIdleTimer() + + if w.cancelled(ctx) { + gphttp.ServerError(rw, r, context.Cause(ctx), http.StatusServiceUnavailable) return false } - ready, err := w.checkUpdateState() + w, ready, err = checkUpdateState(w.Key()) if err != nil { - http.Error(rw, "Error waking container", http.StatusInternalServerError) + gphttp.ServerError(rw, r, err) return false } if ready { - w.resetIdleTimer() if isCheckRedirect { - w.Debug().Msgf("redirecting to %s ...", w.hc.URL()) + w.l.Debug().Stringer("url", w.hc.URL()).Msg("container is ready, redirecting") rw.WriteHeader(http.StatusOK) return false } - w.Debug().Msgf("passing through to %s ...", w.hc.URL()) + w.l.Debug().Stringer("url", w.hc.URL()).Msg("container is ready, passing through") return true } diff --git a/internal/idlewatcher/waker_stream.go b/internal/idlewatcher/handle_stream.go similarity index 50% rename from internal/idlewatcher/waker_stream.go rename to internal/idlewatcher/handle_stream.go index 22f55d3..50cf6e2 100644 --- a/internal/idlewatcher/waker_stream.go +++ b/internal/idlewatcher/handle_stream.go @@ -3,11 +3,10 @@ package idlewatcher import ( "context" "errors" - "fmt" "net" "time" - "github.com/yusing/go-proxy/internal/net/types" + gpnet "github.com/yusing/go-proxy/internal/net/types" ) // Setup implements types.Stream. @@ -21,19 +20,19 @@ func (w *Watcher) Setup() error { } // Accept implements types.Stream. -func (w *Watcher) Accept() (conn types.StreamConn, err error) { +func (w *Watcher) Accept() (conn gpnet.StreamConn, err error) { conn, err = w.stream.Accept() if err != nil { return } if wakeErr := w.wakeFromStream(); wakeErr != nil { - w.WakeError(wakeErr) + w.l.Err(wakeErr).Msg("error waking container") } return } // Handle implements types.Stream. -func (w *Watcher) Handle(conn types.StreamConn) error { +func (w *Watcher) Handle(conn gpnet.StreamConn) error { if err := w.wakeFromStream(); err != nil { return err } @@ -53,35 +52,29 @@ func (w *Watcher) wakeFromStream() error { return nil } - w.WakeDebug().Msg("wake signal received") - wakeErr := w.wakeIfStopped() - if wakeErr != nil { - wakeErr = fmt.Errorf("%s failed: %w", w.String(), wakeErr) - w.WakeError(wakeErr) - return wakeErr + w.l.Debug().Msg("wake signal received") + err := w.wakeIfStopped() + if err != nil { + return err } - ctx, cancel := context.WithTimeoutCause(w.task.Context(), w.Config().WakeTimeout, errors.New("wake timeout")) + ctx, cancel := context.WithTimeoutCause(w.task.Context(), w.cfg.WakeTimeout, errors.New("wake timeout")) defer cancel() + var ready bool + for { - select { - case <-w.task.Context().Done(): - cause := w.task.FinishCause() - w.WakeDebug().Str("cause", cause.Error()).Msg("canceled") - return cause - case <-ctx.Done(): - cause := context.Cause(ctx) - w.WakeDebug().Str("cause", cause.Error()).Msg("timeout") - return cause - default: + if w.cancelled(ctx) { + return context.Cause(ctx) } - if ready, err := w.checkUpdateState(); err != nil { + w, ready, err = checkUpdateState(w.Key()) + if err != nil { return err - } else if ready { + } + if ready { w.resetIdleTimer() - w.Debug().Msg("container is ready, passing through to " + w.hc.URL().String()) + w.l.Debug().Stringer("url", w.hc.URL()).Msg("container is ready, passing through") return nil } diff --git a/internal/idlewatcher/health.go b/internal/idlewatcher/health.go new file mode 100644 index 0000000..ec305f7 --- /dev/null +++ b/internal/idlewatcher/health.go @@ -0,0 +1,122 @@ +package idlewatcher + +import ( + "errors" + "time" + + "github.com/yusing/go-proxy/internal/gperr" + idlewatcher "github.com/yusing/go-proxy/internal/idlewatcher/types" + "github.com/yusing/go-proxy/internal/task" + "github.com/yusing/go-proxy/internal/watcher/health" +) + +// Start implements health.HealthMonitor. +func (w *Watcher) Start(parent task.Parent) gperr.Error { + w.task.OnCancel("route_cleanup", func() { + parent.Finish(w.task.FinishCause()) + }) + return nil +} + +// Task implements health.HealthMonitor. +func (w *Watcher) Task() *task.Task { + return w.task +} + +// Finish implements health.HealthMonitor. +func (w *Watcher) Finish(reason any) { + if w.stream != nil { + w.stream.Close() + } +} + +// Name implements health.HealthMonitor. +func (w *Watcher) Name() string { + return w.cfg.ContainerName() +} + +// String implements health.HealthMonitor. +func (w *Watcher) String() string { + return w.Name() +} + +// Uptime implements health.HealthMonitor. +func (w *Watcher) Uptime() time.Duration { + return 0 +} + +// Latency implements health.HealthMonitor. +func (w *Watcher) Latency() time.Duration { + return 0 +} + +// Status implements health.HealthMonitor. +func (w *Watcher) Status() health.Status { + state := w.state.Load() + if state.err != nil { + return health.StatusError + } + if state.ready { + return health.StatusHealthy + } + if state.status == idlewatcher.ContainerStatusRunning { + return health.StatusStarting + } + return health.StatusNapping +} + +func checkUpdateState(key string) (w *Watcher, ready bool, err error) { + watcherMapMu.RLock() + w, ok := watcherMap[key] + if !ok { + watcherMapMu.RUnlock() + return nil, false, errors.New("watcher not found") + } + watcherMapMu.RUnlock() + + // already ready + if w.ready() { + return w, true, nil + } + + if !w.running() { + return w, false, nil + } + + // the new container info not yet updated + if w.hc.URL().Host == "" { + return w, false, nil + } + + res, err := w.hc.CheckHealth() + if err != nil { + w.setError(err) + return w, false, err + } + + if res.Healthy { + w.setReady() + return w, true, nil + } + w.setStarting() + return w, false, nil +} + +// MarshalMap implements health.HealthMonitor. +func (w *Watcher) MarshalMap() map[string]any { + url := w.hc.URL() + if url.Port() == "0" { + url = nil + } + var detail string + if err := w.error(); err != nil { + detail = err.Error() + } + return (&health.JSONRepresentation{ + Name: w.Name(), + Status: w.Status(), + Config: dummyHealthCheckConfig, + URL: url, + Detail: detail, + }).MarshalMap() +} diff --git a/internal/idlewatcher/loading_page.go b/internal/idlewatcher/loading_page.go index 3ece21e..7ddf6c0 100644 --- a/internal/idlewatcher/loading_page.go +++ b/internal/idlewatcher/loading_page.go @@ -19,11 +19,11 @@ var loadingPage []byte var loadingPageTmpl = template.Must(template.New("loading_page").Parse(string(loadingPage))) func (w *Watcher) makeLoadingPageBody() []byte { - msg := w.ContainerName() + " is starting..." + msg := w.cfg.ContainerName() + " is starting..." data := new(templateData) data.CheckRedirectHeader = httpheaders.HeaderGoDoxyCheckRedirect - data.Title = w.route.HomepageItem().Name + data.Title = w.cfg.ContainerName() data.Message = msg buf := bytes.NewBuffer(make([]byte, len(loadingPage)+len(data.Title)+len(data.Message)+len(httpheaders.HeaderGoDoxyCheckRedirect))) diff --git a/internal/idlewatcher/provider/docker.go b/internal/idlewatcher/provider/docker.go new file mode 100644 index 0000000..cd0b39a --- /dev/null +++ b/internal/idlewatcher/provider/docker.go @@ -0,0 +1,90 @@ +package provider + +import ( + "context" + + "github.com/docker/docker/api/types/container" + "github.com/yusing/go-proxy/internal/docker" + "github.com/yusing/go-proxy/internal/gperr" + idlewatcher "github.com/yusing/go-proxy/internal/idlewatcher/types" + "github.com/yusing/go-proxy/internal/watcher" +) + +type DockerProvider struct { + client *docker.SharedClient + watcher *watcher.DockerWatcher + containerID string +} + +var startOptions = container.StartOptions{} + +func NewDockerProvider(dockerHost, containerID string) (idlewatcher.Provider, error) { + client, err := docker.NewClient(dockerHost) + if err != nil { + return nil, err + } + return &DockerProvider{ + client: client, + watcher: watcher.NewDockerWatcher(dockerHost), + containerID: containerID, + }, nil +} + +func (p *DockerProvider) ContainerPause(ctx context.Context) error { + return p.client.ContainerPause(ctx, p.containerID) +} + +func (p *DockerProvider) ContainerUnpause(ctx context.Context) error { + return p.client.ContainerUnpause(ctx, p.containerID) +} + +func (p *DockerProvider) ContainerStart(ctx context.Context) error { + return p.client.ContainerStart(ctx, p.containerID, startOptions) +} + +func (p *DockerProvider) ContainerStop(ctx context.Context, signal idlewatcher.Signal, timeout int) error { + return p.client.ContainerStop(ctx, p.containerID, container.StopOptions{ + Signal: string(signal), + Timeout: &timeout, + }) +} + +func (p *DockerProvider) ContainerKill(ctx context.Context, signal idlewatcher.Signal) error { + return p.client.ContainerKill(ctx, p.containerID, string(signal)) +} + +func (p *DockerProvider) ContainerStatus(ctx context.Context) (idlewatcher.ContainerStatus, error) { + status, err := p.client.ContainerInspect(ctx, p.containerID) + if err != nil { + return idlewatcher.ContainerStatusError, err + } + switch status.State.Status { + case "running": + return idlewatcher.ContainerStatusRunning, nil + case "exited", "dead", "restarting": + return idlewatcher.ContainerStatusStopped, nil + case "paused": + return idlewatcher.ContainerStatusPaused, nil + } + return idlewatcher.ContainerStatusError, idlewatcher.ErrUnexpectedContainerStatus.Subject(status.State.Status) +} + +func (p *DockerProvider) Watch(ctx context.Context) (eventCh <-chan watcher.Event, errCh <-chan gperr.Error) { + return p.watcher.EventsWithOptions(ctx, watcher.DockerListOptions{ + Filters: watcher.NewDockerFilter( + watcher.DockerFilterContainer, + watcher.DockerFilterContainerNameID(p.containerID), + watcher.DockerFilterStart, + watcher.DockerFilterStop, + watcher.DockerFilterDie, + watcher.DockerFilterKill, + watcher.DockerFilterDestroy, + watcher.DockerFilterPause, + watcher.DockerFilterUnpause, + ), + }) +} + +func (p *DockerProvider) Close() { + p.client.Close() +} diff --git a/internal/idlewatcher/provider/proxmox.go b/internal/idlewatcher/provider/proxmox.go new file mode 100644 index 0000000..7cbb02d --- /dev/null +++ b/internal/idlewatcher/provider/proxmox.go @@ -0,0 +1,129 @@ +package provider + +import ( + "context" + "strconv" + "time" + + "github.com/yusing/go-proxy/internal/gperr" + idlewatcher "github.com/yusing/go-proxy/internal/idlewatcher/types" + "github.com/yusing/go-proxy/internal/proxmox" + "github.com/yusing/go-proxy/internal/watcher" + "github.com/yusing/go-proxy/internal/watcher/events" +) + +type ProxmoxProvider struct { + *proxmox.Node + vmid int + lxcName string + running bool +} + +const proxmoxStateCheckInterval = 1 * time.Second + +var ErrNodeNotFound = gperr.New("node not found in pool") + +func NewProxmoxProvider(nodeName string, vmid int) (idlewatcher.Provider, error) { + node, ok := proxmox.Nodes.Get(nodeName) + if !ok { + return nil, ErrNodeNotFound.Subject(nodeName). + Withf("available nodes: %s", proxmox.AvailableNodeNames()) + } + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + lxcName, err := node.LXCName(ctx, vmid) + if err != nil { + return nil, err + } + return &ProxmoxProvider{Node: node, vmid: vmid, lxcName: lxcName}, nil +} + +func (p *ProxmoxProvider) ContainerPause(ctx context.Context) error { + return p.LXCAction(ctx, p.vmid, proxmox.LXCSuspend) +} + +func (p *ProxmoxProvider) ContainerUnpause(ctx context.Context) error { + return p.LXCAction(ctx, p.vmid, proxmox.LXCResume) +} + +func (p *ProxmoxProvider) ContainerStart(ctx context.Context) error { + return p.LXCAction(ctx, p.vmid, proxmox.LXCStart) +} + +func (p *ProxmoxProvider) ContainerStop(ctx context.Context, _ idlewatcher.Signal, _ int) error { + return p.LXCAction(ctx, p.vmid, proxmox.LXCShutdown) +} + +func (p *ProxmoxProvider) ContainerKill(ctx context.Context, _ idlewatcher.Signal) error { + return p.LXCAction(ctx, p.vmid, proxmox.LXCShutdown) +} + +func (p *ProxmoxProvider) ContainerStatus(ctx context.Context) (idlewatcher.ContainerStatus, error) { + status, err := p.LXCStatus(ctx, p.vmid) + if err != nil { + return idlewatcher.ContainerStatusError, err + } + switch status { + case proxmox.LXCStatusRunning: + return idlewatcher.ContainerStatusRunning, nil + case proxmox.LXCStatusStopped: + return idlewatcher.ContainerStatusStopped, nil + } + return idlewatcher.ContainerStatusError, idlewatcher.ErrUnexpectedContainerStatus.Subject(string(status)) +} + +func (p *ProxmoxProvider) Watch(ctx context.Context) (<-chan watcher.Event, <-chan gperr.Error) { + eventCh := make(chan watcher.Event) + errCh := make(chan gperr.Error) + + go func() { + defer close(eventCh) + defer close(errCh) + + var err error + p.running, err = p.LXCIsRunning(ctx, p.vmid) + if err != nil { + errCh <- gperr.Wrap(err) + return + } + + ticker := time.NewTicker(proxmoxStateCheckInterval) + defer ticker.Stop() + + event := watcher.Event{ + Type: events.EventTypeDocker, + ActorID: strconv.Itoa(p.vmid), + ActorName: p.lxcName, + } + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + status, err := p.ContainerStatus(ctx) + if err != nil { + errCh <- gperr.Wrap(err) + return + } + running := status == idlewatcher.ContainerStatusRunning + if p.running != running { + p.running = running + if running { + event.Action = events.ActionContainerStart + } else { + event.Action = events.ActionContainerStop + } + eventCh <- event + } + } + } + }() + + return eventCh, errCh +} + +func (p *ProxmoxProvider) Close() { + // noop +} diff --git a/internal/idlewatcher/state.go b/internal/idlewatcher/state.go index 939ec4e..7db2d79 100644 --- a/internal/idlewatcher/state.go +++ b/internal/idlewatcher/state.go @@ -1,7 +1,9 @@ package idlewatcher +import idlewatcher "github.com/yusing/go-proxy/internal/idlewatcher/types" + func (w *Watcher) running() bool { - return w.state.Load().running + return w.state.Load().status == idlewatcher.ContainerStatusRunning } func (w *Watcher) ready() bool { @@ -14,26 +16,29 @@ func (w *Watcher) error() error { func (w *Watcher) setReady() { w.state.Store(&containerState{ - running: true, - ready: true, + status: idlewatcher.ContainerStatusRunning, + ready: true, }) } func (w *Watcher) setStarting() { w.state.Store(&containerState{ - running: true, - ready: false, + status: idlewatcher.ContainerStatusRunning, + ready: false, }) } -func (w *Watcher) setNapping() { - w.setError(nil) +func (w *Watcher) setNapping(status idlewatcher.ContainerStatus) { + w.state.Store(&containerState{ + status: status, + ready: false, + }) } func (w *Watcher) setError(err error) { w.state.Store(&containerState{ - running: false, - ready: false, - err: err, + status: idlewatcher.ContainerStatusError, + ready: false, + err: err, }) } diff --git a/internal/idlewatcher/types/config.go b/internal/idlewatcher/types/config.go index d53c3a1..f4ad536 100644 --- a/internal/idlewatcher/types/config.go +++ b/internal/idlewatcher/types/config.go @@ -1,110 +1,128 @@ package idlewatcher import ( - "errors" "net/url" + "strconv" "strings" "time" - "github.com/yusing/go-proxy/internal/docker" "github.com/yusing/go-proxy/internal/gperr" ) type ( Config struct { - IdleTimeout time.Duration `json:"idle_timeout,omitempty"` - WakeTimeout time.Duration `json:"wake_timeout,omitempty"` - StopTimeout int `json:"stop_timeout,omitempty"` // docker api takes integer seconds for timeout argument - StopMethod StopMethod `json:"stop_method,omitempty"` + Proxmox *ProxmoxConfig `json:"proxmox,omitempty"` + Docker *DockerConfig `json:"docker,omitempty"` + + IdleTimeout time.Duration `json:"idle_timeout"` + WakeTimeout time.Duration `json:"wake_timeout"` + StopTimeout time.Duration `json:"stop_timeout"` + StopMethod StopMethod `json:"stop_method"` StopSignal Signal `json:"stop_signal,omitempty"` StartEndpoint string `json:"start_endpoint,omitempty"` // Optional path that must be hit to start container } StopMethod string Signal string + + DockerConfig struct { + DockerHost string `json:"docker_host" validate:"required"` + ContainerID string `json:"container_id" validate:"required"` + ContainerName string `json:"container_name" validate:"required"` + } + ProxmoxConfig struct { + Node string `json:"node" validate:"required"` + VMID int `json:"vmid" validate:"required"` + } ) const ( + WakeTimeoutDefault = 30 * time.Second + StopTimeoutDefault = 1 * time.Minute + StopMethodPause StopMethod = "pause" StopMethodStop StopMethod = "stop" StopMethodKill StopMethod = "kill" ) -var validSignals = map[string]struct{}{ - "": {}, - "SIGINT": {}, "SIGTERM": {}, "SIGHUP": {}, "SIGQUIT": {}, - "INT": {}, "TERM": {}, "HUP": {}, "QUIT": {}, +func (c *Config) Key() string { + if c.Docker != nil { + return c.Docker.ContainerID + } + return c.Proxmox.Node + ":" + strconv.Itoa(c.Proxmox.VMID) } -func ValidateConfig(cont *docker.Container) (*Config, gperr.Error) { - if cont == nil || cont.IdleTimeout == "" { - return nil, nil +func (c *Config) ContainerName() string { + if c.Docker != nil { + return c.Docker.ContainerName } - - errs := gperr.NewBuilder("invalid idlewatcher config") - - idleTimeout := gperr.Collect(errs, validateDurationPostitive, cont.IdleTimeout) - wakeTimeout := gperr.Collect(errs, validateDurationPostitive, cont.WakeTimeout) - stopTimeout := gperr.Collect(errs, validateDurationPostitive, cont.StopTimeout) - stopMethod := gperr.Collect(errs, validateStopMethod, cont.StopMethod) - signal := gperr.Collect(errs, validateSignal, cont.StopSignal) - startEndpoint := gperr.Collect(errs, validateStartEndpoint, cont.StartEndpoint) - - if errs.HasError() { - return nil, errs.Error() - } - - return &Config{ - IdleTimeout: idleTimeout, - WakeTimeout: wakeTimeout, - StopTimeout: int(stopTimeout.Seconds()), - StopMethod: stopMethod, - StopSignal: signal, - StartEndpoint: startEndpoint, - }, nil + return "lxc " + strconv.Itoa(c.Proxmox.VMID) } -func validateDurationPostitive(value string) (time.Duration, error) { - d, err := time.ParseDuration(value) - if err != nil { - return 0, err +func (c *Config) Validate() gperr.Error { + if c.IdleTimeout == 0 { // no idle timeout means no idle watcher + return nil } - if d < 0 { - return 0, errors.New("duration must be positive") - } - return d, nil + errs := gperr.NewBuilder("idlewatcher config validation error") + errs.AddRange( + c.validateProvider(), + c.validateTimeouts(), + c.validateStopMethod(), + c.validateStopSignal(), + c.validateStartEndpoint(), + ) + return errs.Error() } -func validateSignal(s string) (Signal, error) { - if _, ok := validSignals[s]; ok { - return Signal(s), nil +func (c *Config) validateProvider() error { + if c.Docker == nil && c.Proxmox == nil { + return gperr.New("missing idlewatcher provider config") } - return "", errors.New("invalid signal " + s) + return nil } -func validateStopMethod(s string) (StopMethod, error) { - sm := StopMethod(s) - switch sm { +func (c *Config) validateTimeouts() error { + if c.WakeTimeout == 0 { + c.WakeTimeout = WakeTimeoutDefault + } + if c.StopTimeout == 0 { + c.StopTimeout = StopTimeoutDefault + } + return nil +} + +func (c *Config) validateStopMethod() error { + switch c.StopMethod { + case "": + c.StopMethod = StopMethodStop + return nil case StopMethodPause, StopMethodStop, StopMethodKill: - return sm, nil + return nil default: - return "", errors.New("invalid stop method " + s) + return gperr.New("invalid stop method").Subject(string(c.StopMethod)) } } -func validateStartEndpoint(s string) (string, error) { - if s == "" { - return "", nil +func (c *Config) validateStopSignal() error { + switch c.StopSignal { + case "", "SIGINT", "SIGTERM", "SIGQUIT", "SIGHUP", "INT", "TERM", "QUIT", "HUP": + return nil + default: + return gperr.New("invalid stop signal").Subject(string(c.StopSignal)) + } +} + +func (c *Config) validateStartEndpoint() error { + if c.StartEndpoint == "" { + return nil } // checks needed as of Go 1.6 because of change https://github.com/golang/go/commit/617c93ce740c3c3cc28cdd1a0d712be183d0b328#diff-6c2d018290e298803c0c9419d8739885L195 // emulate browser and strip the '#' suffix prior to validation. see issue-#237 - if i := strings.Index(s, "#"); i > -1 { - s = s[:i] + if i := strings.Index(c.StartEndpoint, "#"); i > -1 { + c.StartEndpoint = c.StartEndpoint[:i] } - if len(s) == 0 { - return "", errors.New("start endpoint must not be empty if defined") + if len(c.StartEndpoint) == 0 { + return gperr.New("start endpoint must not be empty if defined") } - if _, err := url.ParseRequestURI(s); err != nil { - return "", err - } - return s, nil + _, err := url.ParseRequestURI(c.StartEndpoint) + return err } diff --git a/internal/idlewatcher/types/config_test.go b/internal/idlewatcher/types/config_test.go index 8ec4315..dacd412 100644 --- a/internal/idlewatcher/types/config_test.go +++ b/internal/idlewatcher/types/config_test.go @@ -35,9 +35,10 @@ func TestValidateStartEndpoint(t *testing.T) { } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - s, err := validateStartEndpoint(tc.input) + cfg := Config{StartEndpoint: tc.input} + err := cfg.validateStartEndpoint() if err == nil { - ExpectEqual(t, s, tc.input) + ExpectEqual(t, cfg.StartEndpoint, tc.input) } if (err != nil) != tc.wantErr { t.Errorf("validateStartEndpoint() error = %v, wantErr %t", err, tc.wantErr) diff --git a/internal/idlewatcher/types/container_status.go b/internal/idlewatcher/types/container_status.go new file mode 100644 index 0000000..0418255 --- /dev/null +++ b/internal/idlewatcher/types/container_status.go @@ -0,0 +1,14 @@ +package idlewatcher + +import "github.com/yusing/go-proxy/internal/gperr" + +type ContainerStatus string + +const ( + ContainerStatusError ContainerStatus = "error" + ContainerStatusRunning ContainerStatus = "running" + ContainerStatusPaused ContainerStatus = "paused" + ContainerStatusStopped ContainerStatus = "stopped" +) + +var ErrUnexpectedContainerStatus = gperr.New("unexpected container status") diff --git a/internal/idlewatcher/types/provider.go b/internal/idlewatcher/types/provider.go new file mode 100644 index 0000000..0df599f --- /dev/null +++ b/internal/idlewatcher/types/provider.go @@ -0,0 +1,19 @@ +package idlewatcher + +import ( + "context" + + "github.com/yusing/go-proxy/internal/gperr" + "github.com/yusing/go-proxy/internal/watcher/events" +) + +type Provider interface { + ContainerPause(ctx context.Context) error + ContainerUnpause(ctx context.Context) error + ContainerStart(ctx context.Context) error + ContainerStop(ctx context.Context, signal Signal, timeout int) error + ContainerKill(ctx context.Context, signal Signal) error + ContainerStatus(ctx context.Context) (ContainerStatus, error) + Watch(ctx context.Context) (eventCh <-chan events.Event, errCh <-chan gperr.Error) + Close() +} diff --git a/internal/idlewatcher/waker.go b/internal/idlewatcher/waker.go deleted file mode 100644 index 1787cd9..0000000 --- a/internal/idlewatcher/waker.go +++ /dev/null @@ -1,172 +0,0 @@ -package idlewatcher - -import ( - "time" - - "github.com/yusing/go-proxy/internal/gperr" - idlewatcher "github.com/yusing/go-proxy/internal/idlewatcher/types" - "github.com/yusing/go-proxy/internal/net/gphttp/reverseproxy" - net "github.com/yusing/go-proxy/internal/net/types" - route "github.com/yusing/go-proxy/internal/route/types" - "github.com/yusing/go-proxy/internal/task" - U "github.com/yusing/go-proxy/internal/utils" - "github.com/yusing/go-proxy/internal/watcher/health" - "github.com/yusing/go-proxy/internal/watcher/health/monitor" -) - -type ( - Waker = idlewatcher.Waker - waker struct { - _ U.NoCopy - - rp *reverseproxy.ReverseProxy - stream net.Stream - hc health.HealthChecker - } -) - -const ( - idleWakerCheckInterval = 100 * time.Millisecond - idleWakerCheckTimeout = time.Second -) - -// TODO: support stream - -func newWaker(parent task.Parent, route route.Route, rp *reverseproxy.ReverseProxy, stream net.Stream) (Waker, gperr.Error) { - hcCfg := route.HealthCheckConfig() - hcCfg.Timeout = idleWakerCheckTimeout - - waker := &waker{ - rp: rp, - stream: stream, - } - watcher, err := registerWatcher(parent, route, waker) - if err != nil { - return nil, gperr.Errorf("register watcher: %w", err) - } - - switch { - case route.IsAgent(): - waker.hc = monitor.NewAgentProxiedMonitor(route.Agent(), hcCfg, monitor.AgentTargetFromURL(route.TargetURL())) - case rp != nil: - waker.hc = monitor.NewHTTPHealthChecker(route.TargetURL(), hcCfg) - case stream != nil: - waker.hc = monitor.NewRawHealthChecker(route.TargetURL(), hcCfg) - default: - panic("both nil") - } - - return watcher, nil -} - -// lifetime should follow route provider. -func NewHTTPWaker(parent task.Parent, route route.Route, rp *reverseproxy.ReverseProxy) (Waker, gperr.Error) { - return newWaker(parent, route, rp, nil) -} - -func NewStreamWaker(parent task.Parent, route route.Route, stream net.Stream) (Waker, gperr.Error) { - return newWaker(parent, route, nil, stream) -} - -// Start implements health.HealthMonitor. -func (w *Watcher) Start(parent task.Parent) gperr.Error { - w.task.OnCancel("route_cleanup", func() { - parent.Finish(w.task.FinishCause()) - }) - return nil -} - -// Task implements health.HealthMonitor. -func (w *Watcher) Task() *task.Task { - return w.task -} - -// Finish implements health.HealthMonitor. -func (w *Watcher) Finish(reason any) { - if w.stream != nil { - w.stream.Close() - } -} - -// Name implements health.HealthMonitor. -func (w *Watcher) Name() string { - return w.String() -} - -// String implements health.HealthMonitor. -func (w *Watcher) String() string { - return w.ContainerName() -} - -// Uptime implements health.HealthMonitor. -func (w *Watcher) Uptime() time.Duration { - return 0 -} - -// Latency implements health.HealthMonitor. -func (w *Watcher) Latency() time.Duration { - return 0 -} - -// Status implements health.HealthMonitor. -func (w *Watcher) Status() health.Status { - state := w.state.Load() - if state.err != nil { - return health.StatusError - } - if state.ready { - return health.StatusHealthy - } - if state.running { - return health.StatusStarting - } - return health.StatusNapping -} - -func (w *Watcher) checkUpdateState() (ready bool, err error) { - // already ready - if w.ready() { - return true, nil - } - - if !w.running() { - return false, nil - } - - // the new container info not yet updated - if w.hc.URL().Host == "" { - return false, nil - } - - res, err := w.hc.CheckHealth() - if err != nil { - w.setError(err) - return false, err - } - - if res.Healthy { - w.setReady() - return true, nil - } - w.setStarting() - return false, nil -} - -// MarshalJSON implements health.HealthMonitor. -func (w *Watcher) MarshalJSON() ([]byte, error) { - var url *net.URL - if w.hc.URL().Port() != "0" { - url = w.hc.URL() - } - var detail string - if err := w.error(); err != nil { - detail = err.Error() - } - return (&monitor.JSONRepresentation{ - Name: w.Name(), - Status: w.Status(), - Config: w.hc.Config(), - URL: url, - Detail: detail, - }).MarshalJSON() -} diff --git a/internal/idlewatcher/watcher.go b/internal/idlewatcher/watcher.go index f46919e..fc1c052 100644 --- a/internal/idlewatcher/watcher.go +++ b/internal/idlewatcher/watcher.go @@ -7,195 +7,236 @@ import ( "time" "github.com/rs/zerolog" - "github.com/yusing/go-proxy/internal/docker" "github.com/yusing/go-proxy/internal/gperr" + "github.com/yusing/go-proxy/internal/idlewatcher/provider" idlewatcher "github.com/yusing/go-proxy/internal/idlewatcher/types" "github.com/yusing/go-proxy/internal/logging" + "github.com/yusing/go-proxy/internal/net/gphttp/reverseproxy" + net "github.com/yusing/go-proxy/internal/net/types" route "github.com/yusing/go-proxy/internal/route/types" "github.com/yusing/go-proxy/internal/task" U "github.com/yusing/go-proxy/internal/utils" "github.com/yusing/go-proxy/internal/utils/atomic" - "github.com/yusing/go-proxy/internal/watcher" "github.com/yusing/go-proxy/internal/watcher/events" + "github.com/yusing/go-proxy/internal/watcher/health" + "github.com/yusing/go-proxy/internal/watcher/health/monitor" ) type ( + routeHelper struct { + rp *reverseproxy.ReverseProxy + stream net.Stream + hc health.HealthChecker + } + + containerState struct { + status idlewatcher.ContainerStatus + ready bool + err error + } + Watcher struct { _ U.NoCopy + routeHelper - zerolog.Logger + l zerolog.Logger - *waker + cfg *idlewatcher.Config - route route.Route + provider idlewatcher.Provider - client *docker.SharedClient - state atomic.Value[*containerState] + state atomic.Value[*containerState] + lastReset atomic.Value[time.Time] - stopByMethod StopCallback // send a docker command w.r.t. `stop_method` - ticker *time.Ticker - lastReset time.Time - task *task.Task + ticker *time.Ticker + task *task.Task } StopCallback func() error ) +const ContextKey = "idlewatcher.watcher" + var ( watcherMap = make(map[string]*Watcher) watcherMapMu sync.RWMutex - - errShouldNotReachHere = errors.New("should not reach here") ) -const dockerReqTimeout = 3 * time.Second +const ( + idleWakerCheckInterval = 100 * time.Millisecond + idleWakerCheckTimeout = time.Second +) -func registerWatcher(parent task.Parent, route route.Route, waker *waker) (*Watcher, error) { - cfg := route.IdlewatcherConfig() - cont := route.ContainerInfo() - key := cont.ContainerID +var dummyHealthCheckConfig = &health.HealthCheckConfig{ + Interval: idleWakerCheckInterval, + Timeout: idleWakerCheckTimeout, +} + +var ( + causeReload = gperr.New("reloaded") + causeContainerDestroy = gperr.New("container destroyed") +) + +const reqTimeout = 3 * time.Second + +// TODO: fix stream type +func NewWatcher(parent task.Parent, r route.Route) (*Watcher, error) { + cfg := r.IdlewatcherConfig() + key := cfg.Key() + + watcherMapMu.RLock() + // if the watcher already exists, finish it + w, exists := watcherMap[key] + if exists { + if w.cfg == cfg { + // same address, likely two routes from the same container + return w, nil + } + w.task.Finish(causeReload) + } + watcherMapMu.RUnlock() + + w = &Watcher{ + ticker: time.NewTicker(cfg.IdleTimeout), + cfg: cfg, + routeHelper: routeHelper{ + hc: monitor.NewMonitor(r), + }, + } + + var p idlewatcher.Provider + var providerType string + var err error + switch { + case cfg.Docker != nil: + p, err = provider.NewDockerProvider(cfg.Docker.DockerHost, cfg.Docker.ContainerID) + providerType = "docker" + default: + p, err = provider.NewProxmoxProvider(cfg.Proxmox.Node, cfg.Proxmox.VMID) + providerType = "proxmox" + } + + if err != nil { + return nil, err + } + w.provider = p + w.l = logging.With(). + Str("provider", providerType). + Str("container", cfg.ContainerName()). + Logger() + + switch r := r.(type) { + case route.ReverseProxyRoute: + w.rp = r.ReverseProxy() + case route.StreamRoute: + w.stream = r + default: + return nil, gperr.New("unexpected route type") + } + + ctx, cancel := context.WithTimeout(parent.Context(), reqTimeout) + defer cancel() + status, err := w.provider.ContainerStatus(ctx) + if err != nil { + w.provider.Close() + return nil, gperr.Wrap(err, "failed to get container status") + } + + switch p := w.provider.(type) { + case *provider.ProxmoxProvider: + shutdownTimeout := max(time.Second, cfg.StopTimeout-idleWakerCheckTimeout) + err = p.LXCSetShutdownTimeout(ctx, cfg.Proxmox.VMID, shutdownTimeout) + if err != nil { + w.l.Warn().Err(err).Msg("failed to set shutdown timeout") + } + } + + w.state.Store(&containerState{status: status}) + + w.task = parent.Subtask("idlewatcher."+r.TargetName(), true) watcherMapMu.Lock() defer watcherMapMu.Unlock() - w, ok := watcherMap[key] - if !ok { - client, err := docker.NewClient(cont.DockerHost) - if err != nil { - return nil, err - } - - w = &Watcher{ - Logger: logging.With().Str("name", cont.ContainerName).Logger(), - client: client, - task: parent.Subtask("idlewatcher." + cont.ContainerName), - ticker: time.NewTicker(cfg.IdleTimeout), - } - } - - // FIXME: possible race condition here - w.waker = waker - w.route = route - w.ticker.Reset(cfg.IdleTimeout) - - if cont.Running { - w.setStarting() - } else { - w.setNapping() - } - - if !ok { - w.stopByMethod = w.getStopCallback() - watcherMap[key] = w - - go func() { - cause := w.watchUntilDestroy() - + watcherMap[key] = w + go func() { + cause := w.watchUntilDestroy() + if cause.Is(causeContainerDestroy) { watcherMapMu.Lock() defer watcherMapMu.Unlock() delete(watcherMap, key) + w.l.Info().Msg("idlewatcher stopped") + } else if !cause.Is(causeReload) { + gperr.LogError("idlewatcher stopped unexpectedly", cause, &w.l) + } - w.ticker.Stop() - w.client.Close() - w.task.Finish(cause) - }() - } - + w.ticker.Stop() + w.provider.Close() + w.task.Finish(cause) + }() + w.l.Info().Msg("idlewatcher started") return w, nil } -func (w *Watcher) Config() *idlewatcher.Config { - return w.route.IdlewatcherConfig() +func (w *Watcher) Key() string { + return w.cfg.Key() } func (w *Watcher) Wake() error { return w.wakeIfStopped() } -// WakeDebug logs a debug message related to waking the container. -func (w *Watcher) WakeDebug() *zerolog.Event { - //nolint:zerologlint - return w.Debug().Str("action", "wake") -} - -func (w *Watcher) WakeTrace() *zerolog.Event { - //nolint:zerologlint - return w.Trace().Str("action", "wake") -} - -func (w *Watcher) WakeError(err error) { - w.Err(err).Str("action", "wake").Msg("error") -} - func (w *Watcher) wakeIfStopped() error { - if w.running() { + state := w.state.Load() + if state.status == idlewatcher.ContainerStatusRunning { + w.l.Debug().Msg("container is already running") return nil } - status, err := w.containerStatus() - if err != nil { - return err + ctx, cancel := context.WithTimeout(w.task.Context(), w.cfg.WakeTimeout) + defer cancel() + switch state.status { + case idlewatcher.ContainerStatusStopped: + w.l.Info().Msg("starting container") + return w.provider.ContainerStart(ctx) + case idlewatcher.ContainerStatusPaused: + w.l.Info().Msg("unpausing container") + return w.provider.ContainerUnpause(ctx) + default: + return gperr.Errorf("unexpected container status: %s", state.status) + } +} + +func (w *Watcher) stopByMethod() error { + if !w.running() { + return nil } - ctx, cancel := context.WithTimeout(w.task.Context(), w.Config().WakeTimeout) + cfg := w.cfg + ctx, cancel := context.WithTimeout(w.task.Context(), cfg.StopTimeout) defer cancel() - // !Hard coded here since theres no constants from Docker API - switch status { - case "exited", "dead": - return w.containerStart(ctx) - case "paused": - return w.containerUnpause(ctx) - case "running": - return nil - default: - return gperr.Errorf("unexpected container status: %s", status) - } -} - -func (w *Watcher) getStopCallback() StopCallback { - var cb func(context.Context) error - switch w.Config().StopMethod { + switch cfg.StopMethod { case idlewatcher.StopMethodPause: - cb = w.containerPause + return w.provider.ContainerPause(ctx) case idlewatcher.StopMethodStop: - cb = w.containerStop + return w.provider.ContainerStop(ctx, cfg.StopSignal, int(cfg.StopTimeout.Seconds())) case idlewatcher.StopMethodKill: - cb = w.containerKill + return w.provider.ContainerKill(ctx, cfg.StopSignal) default: - panic(errShouldNotReachHere) - } - return func() error { - ctx, cancel := context.WithTimeout(w.task.Context(), time.Duration(w.Config().StopTimeout)*time.Second) - defer cancel() - return cb(ctx) + return gperr.Errorf("unexpected stop method: %q", cfg.StopMethod) } } func (w *Watcher) resetIdleTimer() { - w.Trace().Msg("reset idle timer") - w.ticker.Reset(w.Config().IdleTimeout) - w.lastReset = time.Now() + w.ticker.Reset(w.cfg.IdleTimeout) + w.lastReset.Store(time.Now()) } func (w *Watcher) expires() time.Time { - return w.lastReset.Add(w.Config().IdleTimeout) -} - -func (w *Watcher) getEventCh(ctx context.Context, dockerWatcher *watcher.DockerWatcher) (eventCh <-chan events.Event, errCh <-chan gperr.Error) { - eventCh, errCh = dockerWatcher.EventsWithOptions(ctx, watcher.DockerListOptions{ - Filters: watcher.NewDockerFilter( - watcher.DockerFilterContainer, - watcher.DockerFilterContainerNameID(w.route.ContainerInfo().ContainerID), - watcher.DockerFilterStart, - watcher.DockerFilterStop, - watcher.DockerFilterDie, - watcher.DockerFilterKill, - watcher.DockerFilterDestroy, - watcher.DockerFilterPause, - watcher.DockerFilterUnpause, - ), - }) - return + if !w.running() { + return time.Time{} + } + return w.lastReset.Load().Add(w.cfg.IdleTimeout) } // watchUntilDestroy waits for the container to be created, started, or unpaused, @@ -209,55 +250,34 @@ func (w *Watcher) getEventCh(ctx context.Context, dockerWatcher *watcher.DockerW // // it exits only if the context is canceled, the container is destroyed, // errors occurred on docker client, or route provider died (mainly caused by config reload). -func (w *Watcher) watchUntilDestroy() (returnCause error) { - eventCtx, eventCancel := context.WithCancel(w.task.Context()) - defer eventCancel() - - dockerWatcher := watcher.NewDockerWatcher(w.client.DaemonHost()) - dockerEventCh, dockerEventErrCh := w.getEventCh(eventCtx, dockerWatcher) +func (w *Watcher) watchUntilDestroy() (returnCause gperr.Error) { + eventCh, errCh := w.provider.Watch(w.Task().Context()) for { select { case <-w.task.Context().Done(): - return w.task.FinishCause() - case err := <-dockerEventErrCh: - if !err.Is(context.Canceled) { - gperr.LogError("idlewatcher error", err, &w.Logger) - } + return gperr.Wrap(w.task.FinishCause()) + case err := <-errCh: return err - case e := <-dockerEventCh: + case e := <-eventCh: + w.l.Debug().Stringer("action", e.Action).Msg("state changed") + if e.Action == events.ActionContainerDestroy { + return causeContainerDestroy + } + w.resetIdleTimer() switch { - case e.Action == events.ActionContainerDestroy: - w.setError(errors.New("container destroyed")) - w.Info().Str("reason", "container destroyed").Msg("watcher stopped") - return errors.New("container destroyed") - // create / start / unpause - case e.Action.IsContainerWake(): + case e.Action.IsContainerStart(): // create / start / unpause w.setStarting() - w.resetIdleTimer() - w.Info().Msg("awaken") - case e.Action.IsContainerSleep(): // stop / pause / kil - w.setNapping() - w.resetIdleTimer() + w.l.Info().Msg("awaken") + case e.Action.IsContainerStop(): // stop / kill / die + w.setNapping(idlewatcher.ContainerStatusStopped) + w.ticker.Stop() + case e.Action.IsContainerPause(): // pause + w.setNapping(idlewatcher.ContainerStatusPaused) w.ticker.Stop() default: - w.Error().Msg("unexpected docker event: " + e.String()) + w.l.Error().Stringer("action", e.Action).Msg("unexpected container action") } - // container name changed should also change the container id - // if w.ContainerName != e.ActorName { - // w.Debug().Msgf("renamed %s -> %s", w.ContainerName, e.ActorName) - // w.ContainerName = e.ActorName - // } - // if w.ContainerID != e.ActorID { - // w.Debug().Msgf("id changed %s -> %s", w.ContainerID, e.ActorID) - // w.ContainerID = e.ActorID - // // recreate event stream - // eventCancel() - - // eventCtx, eventCancel = context.WithCancel(w.task.Context()) - // defer eventCancel() - // dockerEventCh, dockerEventErrCh = w.getEventCh(eventCtx, dockerWatcher) - // } case <-w.ticker.C: w.ticker.Stop() if w.running() { @@ -269,11 +289,18 @@ func (w *Watcher) watchUntilDestroy() (returnCause error) { if errors.Is(err, context.DeadlineExceeded) { err = errors.New("timeout waiting for container to stop, please set a higher value for `stop_timeout`") } - w.Err(err).Msgf("container stop with method %q failed", w.Config().StopMethod) + w.l.Err(err).Msgf("container stop with method %q failed", w.cfg.StopMethod) default: - w.Info().Str("reason", "idle timeout").Msg("container stopped") + w.l.Info().Str("reason", "idle timeout").Msg("container stopped") } } } } } + +func fmtErr(err error) string { + if err == nil { + return "" + } + return err.Error() +} diff --git a/internal/metrics/period/poller.go b/internal/metrics/period/poller.go index 9422f7a..f60014d 100644 --- a/internal/metrics/period/poller.go +++ b/internal/metrics/period/poller.go @@ -10,6 +10,7 @@ import ( "sync" "time" + "github.com/yusing/go-proxy/internal/common" "github.com/yusing/go-proxy/internal/gperr" "github.com/yusing/go-proxy/internal/logging" "github.com/yusing/go-proxy/internal/task" @@ -39,14 +40,12 @@ const ( pollInterval = 1 * time.Second gatherErrsInterval = 30 * time.Second saveInterval = 5 * time.Minute - - saveBaseDir = "data/metrics" ) var initDataDirOnce sync.Once func initDataDir() { - if err := os.MkdirAll(saveBaseDir, 0o755); err != nil { + if err := os.MkdirAll(common.MetricsDataDir, 0o755); err != nil { logging.Error().Err(err).Msg("failed to create metrics data directory") } } @@ -65,7 +64,7 @@ func NewPoller[T any, AggregateT json.Marshaler]( } func (p *Poller[T, AggregateT]) savePath() string { - return filepath.Join(saveBaseDir, fmt.Sprintf("%s.json", p.name)) + return filepath.Join(common.MetricsDataDir, fmt.Sprintf("%s.json", p.name)) } func (p *Poller[T, AggregateT]) load() error { diff --git a/internal/metrics/systeminfo/system_info.go b/internal/metrics/systeminfo/system_info.go index 569967d..abe37b9 100644 --- a/internal/metrics/systeminfo/system_info.go +++ b/internal/metrics/systeminfo/system_info.go @@ -172,6 +172,45 @@ func (s *SystemInfo) collectMemoryInfo(ctx context.Context) error { return nil } +func shouldExcludeDisk(name string) bool { + // include only sd* and nvme* disk devices + // but not partitions like nvme0p1 + + if len(name) < 3 { + return true + } + switch { + case strings.HasPrefix(name, "nvme"), + strings.HasPrefix(name, "mmcblk"): // NVMe/SD/MMC + s := name[len(name)-2] + // skip namespaces/partitions + switch s { + case 'p', 'n': + return true + default: + return false + } + } + switch name[0] { + case 's', 'h', 'v': // SCSI/SATA/virtio disks + if name[1] != 'd' { + return true + } + case 'x': // Xen virtual disks + if name[1:3] != "vd" { + return true + } + default: + return true + } + last := name[len(name)-1] + if last >= '0' && last <= '9' { + // skip partitions + return true + } + return false +} + func (s *SystemInfo) collectDisksInfo(ctx context.Context, lastResult *SystemInfo) error { ioCounters, err := disk.IOCountersWithContext(ctx) if err != nil { @@ -179,34 +218,9 @@ func (s *SystemInfo) collectDisksInfo(ctx context.Context, lastResult *SystemInf } s.DisksIO = make(map[string]*DiskIO, len(ioCounters)) for name, io := range ioCounters { - // include only /dev/sd* and /dev/nvme* disk devices - if len(name) < 3 { + if shouldExcludeDisk(name) { continue } - switch { - case strings.HasPrefix(name, "nvme"), - strings.HasPrefix(name, "mmcblk"): // NVMe/SD/MMC - if name[len(name)-2] == 'p' { - continue // skip partitions - } - default: - switch name[0] { - case 's', 'h', 'v': // SCSI/SATA/virtio disks - if name[1] != 'd' { - continue - } - case 'x': // Xen virtual disks - if name[1:3] != "vd" { - continue - } - default: - continue - } - last := name[len(name)-1] - if last >= '0' && last <= '9' { - continue // skip partitions - } - } s.DisksIO[name] = &DiskIO{ ReadBytes: io.ReadBytes, WriteBytes: io.WriteBytes, diff --git a/internal/metrics/systeminfo/system_info_test.go b/internal/metrics/systeminfo/system_info_test.go index dc061fd..6579069 100644 --- a/internal/metrics/systeminfo/system_info_test.go +++ b/internal/metrics/systeminfo/system_info_test.go @@ -10,6 +10,73 @@ import ( . "github.com/yusing/go-proxy/internal/utils/testing" ) +func TestExcludeDisks(t *testing.T) { + tests := []struct { + name string + shouldExclude bool + }{ + { + name: "nvme0", + shouldExclude: false, + }, + { + name: "nvme0n1", + shouldExclude: true, + }, + { + name: "nvme0n1p1", + shouldExclude: true, + }, + { + name: "sda", + shouldExclude: false, + }, + { + name: "sda1", + shouldExclude: true, + }, + { + name: "hda", + shouldExclude: false, + }, + { + name: "vda", + shouldExclude: false, + }, + { + name: "xvda", + shouldExclude: false, + }, + { + name: "xva", + shouldExclude: true, + }, + { + name: "loop0", + shouldExclude: true, + }, + { + name: "mmcblk0", + shouldExclude: false, + }, + { + name: "mmcblk0p1", + shouldExclude: true, + }, + { + name: "ab", + shouldExclude: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := shouldExcludeDisk(tt.name) + ExpectEqual(t, result, tt.shouldExclude) + }) + } +} + // Create test data var cpuAvg = 45.67 var testInfo = &SystemInfo{ @@ -118,7 +185,7 @@ func TestSystemInfo(t *testing.T) { func TestSerialize(t *testing.T) { entries := make([]*SystemInfo, 5) - for i := 0; i < 5; i++ { + for i := range 5 { entries[i] = testInfo } for _, query := range allQueries { @@ -140,9 +207,9 @@ func TestSerialize(t *testing.T) { } } -func BenchmarkSerialize(b *testing.B) { +func BenchmarkJSONMarshal(b *testing.B) { entries := make([]*SystemInfo, b.N) - for i := 0; i < b.N; i++ { + for i := range b.N { entries[i] = testInfo } queries := map[string]Aggregated{} @@ -153,14 +220,14 @@ func BenchmarkSerialize(b *testing.B) { b.ReportAllocs() b.ResetTimer() b.Run("optimized", func(b *testing.B) { - for i := 0; i < b.N; i++ { + for b.Loop() { for _, query := range allQueries { _, _ = queries[query].MarshalJSON() } } }) b.Run("json", func(b *testing.B) { - for i := 0; i < b.N; i++ { + for b.Loop() { for _, query := range allQueries { _, _ = json.Marshal([]map[string]any(queries[query])) } diff --git a/internal/net/gphttp/accesslog/access_logger.go b/internal/net/gphttp/accesslog/access_logger.go index 9a6fc8a..1637377 100644 --- a/internal/net/gphttp/accesslog/access_logger.go +++ b/internal/net/gphttp/accesslog/access_logger.go @@ -25,11 +25,15 @@ type ( } AccessLogIO interface { + io.Writer + sync.Locker + Name() string // file name or path + } + + supportRotate interface { io.ReadWriteCloser io.ReadWriteSeeker io.ReaderAt - sync.Locker - Name() string // file name or path Truncate(size int64) error } @@ -40,7 +44,33 @@ type ( } ) -func NewAccessLogger(parent task.Parent, io AccessLogIO, cfg *Config) *AccessLogger { +func NewAccessLogger(parent task.Parent, cfg *Config) (*AccessLogger, error) { + var ios []AccessLogIO + + if cfg.Stdout { + ios = append(ios, stdoutIO) + } + + if cfg.Path != "" { + io, err := newFileIO(cfg.Path) + if err != nil { + return nil, err + } + ios = append(ios, io) + } + + if len(ios) == 0 { + return nil, nil + } + + return NewAccessLoggerWithIO(parent, NewMultiWriter(ios...), cfg), nil +} + +func NewMockAccessLogger(parent task.Parent, cfg *Config) *AccessLogger { + return NewAccessLoggerWithIO(parent, &MockFile{}, cfg) +} + +func NewAccessLoggerWithIO(parent task.Parent, io AccessLogIO, cfg *Config) *AccessLogger { if cfg.BufferSize == 0 { cfg.BufferSize = DefaultBufferSize } @@ -152,7 +182,9 @@ func (l *AccessLogger) Flush() error { func (l *AccessLogger) close() { l.io.Lock() defer l.io.Unlock() - l.io.Close() + if r, ok := l.io.(io.Closer); ok { + r.Close() + } } func (l *AccessLogger) write(data []byte) { diff --git a/internal/net/gphttp/accesslog/access_logger_test.go b/internal/net/gphttp/accesslog/access_logger_test.go index 012d8eb..7e7af45 100644 --- a/internal/net/gphttp/accesslog/access_logger_test.go +++ b/internal/net/gphttp/accesslog/access_logger_test.go @@ -56,7 +56,7 @@ func fmtLog(cfg *Config) (ts string, line string) { var buf bytes.Buffer t := time.Now() - logger := NewAccessLogger(testTask, nil, cfg) + logger := NewMockAccessLogger(testTask, cfg) logger.Formatter.SetGetTimeNow(func() time.Time { return t }) diff --git a/internal/net/gphttp/accesslog/back_scanner.go b/internal/net/gphttp/accesslog/back_scanner.go index 2e55005..c05692b 100644 --- a/internal/net/gphttp/accesslog/back_scanner.go +++ b/internal/net/gphttp/accesslog/back_scanner.go @@ -7,7 +7,7 @@ import ( // BackScanner provides an interface to read a file backward line by line. type BackScanner struct { - file AccessLogIO + file supportRotate chunkSize int offset int64 buffer []byte @@ -18,7 +18,7 @@ type BackScanner struct { // NewBackScanner creates a new Scanner to read the file backward. // chunkSize determines the size of each read chunk from the end of the file. -func NewBackScanner(file AccessLogIO, chunkSize int) *BackScanner { +func NewBackScanner(file supportRotate, chunkSize int) *BackScanner { size, err := file.Seek(0, io.SeekEnd) if err != nil { return &BackScanner{err: err} diff --git a/internal/net/gphttp/accesslog/config.go b/internal/net/gphttp/accesslog/config.go index a1dbe2f..9a7eb46 100644 --- a/internal/net/gphttp/accesslog/config.go +++ b/internal/net/gphttp/accesslog/config.go @@ -1,6 +1,10 @@ package accesslog -import "github.com/yusing/go-proxy/internal/utils" +import ( + "errors" + + "github.com/yusing/go-proxy/internal/utils" +) type ( Format string @@ -19,7 +23,8 @@ type ( Config struct { BufferSize int `json:"buffer_size"` Format Format `json:"format" validate:"oneof=common combined json"` - Path string `json:"path" validate:"required"` + Path string `json:"path"` + Stdout bool `json:"stdout"` Filters Filters `json:"filters"` Fields Fields `json:"fields"` Retention *Retention `json:"retention"` @@ -34,6 +39,13 @@ var ( const DefaultBufferSize = 64 * 1024 // 64KB +func (cfg *Config) Validate() error { + if cfg.Path == "" && !cfg.Stdout { + return errors.New("path or stdout is required") + } + return nil +} + func DefaultConfig() *Config { return &Config{ BufferSize: DefaultBufferSize, diff --git a/internal/net/gphttp/accesslog/file_logger.go b/internal/net/gphttp/accesslog/file_logger.go index 1b3ace1..a3679ac 100644 --- a/internal/net/gphttp/accesslog/file_logger.go +++ b/internal/net/gphttp/accesslog/file_logger.go @@ -3,11 +3,10 @@ package accesslog import ( "fmt" "os" - "path" + pathPkg "path" "sync" "github.com/yusing/go-proxy/internal/logging" - "github.com/yusing/go-proxy/internal/task" "github.com/yusing/go-proxy/internal/utils" ) @@ -27,16 +26,16 @@ var ( openedFilesMu sync.Mutex ) -func NewFileAccessLogger(parent task.Parent, cfg *Config) (*AccessLogger, error) { +func newFileIO(path string) (AccessLogIO, error) { openedFilesMu.Lock() var file *File - path := path.Clean(cfg.Path) + path = pathPkg.Clean(path) if opened, ok := openedFiles[path]; ok { opened.refCount.Add() file = opened } else { - f, err := os.OpenFile(cfg.Path, os.O_APPEND|os.O_CREATE|os.O_RDWR, 0o644) + f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_RDWR, 0o644) if err != nil { openedFilesMu.Unlock() return nil, fmt.Errorf("access log open error: %w", err) @@ -47,7 +46,7 @@ func NewFileAccessLogger(parent task.Parent, cfg *Config) (*AccessLogger, error) } openedFilesMu.Unlock() - return NewAccessLogger(parent, file, cfg), nil + return file, nil } func (f *File) Close() error { diff --git a/internal/net/gphttp/accesslog/file_logger_test.go b/internal/net/gphttp/accesslog/file_logger_test.go index 0321a85..5159d01 100644 --- a/internal/net/gphttp/accesslog/file_logger_test.go +++ b/internal/net/gphttp/accesslog/file_logger_test.go @@ -16,7 +16,6 @@ func TestConcurrentFileLoggersShareSameAccessLogIO(t *testing.T) { cfg := DefaultConfig() cfg.Path = "test.log" - parent := task.RootTask("test", false) loggerCount := 10 accessLogIOs := make([]AccessLogIO, loggerCount) @@ -33,9 +32,9 @@ func TestConcurrentFileLoggersShareSameAccessLogIO(t *testing.T) { wg.Add(1) go func(index int) { defer wg.Done() - logger, err := NewFileAccessLogger(parent, cfg) + file, err := newFileIO(cfg.Path) ExpectNoError(t, err) - accessLogIOs[index] = logger.io + accessLogIOs[index] = file }(i) } @@ -59,7 +58,7 @@ func TestConcurrentAccessLoggerLogAndFlush(t *testing.T) { loggers := make([]*AccessLogger, loggerCount) for i := range loggerCount { - loggers[i] = NewAccessLogger(parent, &file, cfg) + loggers[i] = NewAccessLoggerWithIO(parent, &file, cfg) } var wg sync.WaitGroup diff --git a/internal/net/gphttp/accesslog/filter.go b/internal/net/gphttp/accesslog/filter.go index c0c3e29..dad1401 100644 --- a/internal/net/gphttp/accesslog/filter.go +++ b/internal/net/gphttp/accesslog/filter.go @@ -6,7 +6,6 @@ import ( "strings" "github.com/yusing/go-proxy/internal/gperr" - "github.com/yusing/go-proxy/internal/net/types" "github.com/yusing/go-proxy/internal/utils/strutils" ) @@ -24,7 +23,7 @@ type ( Key, Value string } Host string - CIDR struct{ types.CIDR } + CIDR net.IPNet ) var ErrInvalidHTTPHeaderFilter = gperr.New("invalid http header filter") @@ -86,7 +85,7 @@ func (h Host) Fulfill(req *http.Request, res *http.Response) bool { return req.Host == string(h) } -func (cidr CIDR) Fulfill(req *http.Request, res *http.Response) bool { +func (cidr *CIDR) Fulfill(req *http.Request, res *http.Response) bool { ip, _, err := net.SplitHostPort(req.RemoteAddr) if err != nil { ip = req.RemoteAddr @@ -95,5 +94,9 @@ func (cidr CIDR) Fulfill(req *http.Request, res *http.Response) bool { if netIP == nil { return false } - return cidr.Contains(netIP) + return (*net.IPNet)(cidr).Contains(netIP) +} + +func (cidr *CIDR) String() string { + return (*net.IPNet)(cidr).String() } diff --git a/internal/net/gphttp/accesslog/filter_test.go b/internal/net/gphttp/accesslog/filter_test.go index a934a7b..5d8e8c8 100644 --- a/internal/net/gphttp/accesslog/filter_test.go +++ b/internal/net/gphttp/accesslog/filter_test.go @@ -1,6 +1,7 @@ package accesslog_test import ( + "net" "net/http" "testing" @@ -155,9 +156,10 @@ func TestHeaderFilter(t *testing.T) { } func TestCIDRFilter(t *testing.T) { - cidr := []*CIDR{ - strutils.MustParse[*CIDR]("192.168.10.0/24"), - } + cidr := []*CIDR{{ + IP: net.ParseIP("192.168.10.0"), + Mask: net.CIDRMask(24, 32), + }} ExpectEqual(t, cidr[0].String(), "192.168.10.0/24") inCIDR := &http.Request{ RemoteAddr: "192.168.10.1", diff --git a/internal/net/gphttp/accesslog/multi_writer.go b/internal/net/gphttp/accesslog/multi_writer.go new file mode 100644 index 0000000..3577bc4 --- /dev/null +++ b/internal/net/gphttp/accesslog/multi_writer.go @@ -0,0 +1,46 @@ +package accesslog + +import "strings" + +type MultiWriter struct { + writers []AccessLogIO +} + +func NewMultiWriter(writers ...AccessLogIO) AccessLogIO { + if len(writers) == 0 { + return nil + } + if len(writers) == 1 { + return writers[0] + } + return &MultiWriter{ + writers: writers, + } +} + +func (w *MultiWriter) Write(p []byte) (n int, err error) { + for _, writer := range w.writers { + writer.Write(p) + } + return len(p), nil +} + +func (w *MultiWriter) Lock() { + for _, writer := range w.writers { + writer.Lock() + } +} + +func (w *MultiWriter) Unlock() { + for _, writer := range w.writers { + writer.Unlock() + } +} + +func (w *MultiWriter) Name() string { + names := make([]string, len(w.writers)) + for i, writer := range w.writers { + names[i] = writer.Name() + } + return strings.Join(names, ", ") +} diff --git a/internal/net/gphttp/accesslog/rotate.go b/internal/net/gphttp/accesslog/rotate.go index e93c22d..2f3e92e 100644 --- a/internal/net/gphttp/accesslog/rotate.go +++ b/internal/net/gphttp/accesslog/rotate.go @@ -2,11 +2,15 @@ package accesslog import ( "bytes" - "io" + ioPkg "io" "time" ) func (l *AccessLogger) rotate() (err error) { + io, ok := l.io.(supportRotate) + if !ok { + return nil + } // Get retention configuration config := l.Config().Retention var shouldKeep func(t time.Time, lineCount int) bool @@ -24,7 +28,7 @@ func (l *AccessLogger) rotate() (err error) { return nil // No retention policy set } - s := NewBackScanner(l.io, defaultChunkSize) + s := NewBackScanner(io, defaultChunkSize) nRead := 0 nLines := 0 for s.Scan() { @@ -40,11 +44,11 @@ func (l *AccessLogger) rotate() (err error) { } beg := int64(nRead) - if _, err := l.io.Seek(-beg, io.SeekEnd); err != nil { + if _, err := io.Seek(-beg, ioPkg.SeekEnd); err != nil { return err } buf := make([]byte, nRead) - if _, err := l.io.Read(buf); err != nil { + if _, err := io.Read(buf); err != nil { return err } @@ -55,8 +59,13 @@ func (l *AccessLogger) rotate() (err error) { } func (l *AccessLogger) writeTruncate(buf []byte) (err error) { + io, ok := l.io.(supportRotate) + if !ok { + return nil + } + // Seek to beginning and truncate - if _, err := l.io.Seek(0, 0); err != nil { + if _, err := io.Seek(0, 0); err != nil { return err } @@ -70,13 +79,13 @@ func (l *AccessLogger) writeTruncate(buf []byte) (err error) { } // Truncate file - if err = l.io.Truncate(int64(nWritten)); err != nil { + if err = io.Truncate(int64(nWritten)); err != nil { return err } // check bytes written == buffer size if nWritten != len(buf) { - return io.ErrShortWrite + return ioPkg.ErrShortWrite } return } diff --git a/internal/net/gphttp/accesslog/rotate_test.go b/internal/net/gphttp/accesslog/rotate_test.go index 8b81792..727a3cb 100644 --- a/internal/net/gphttp/accesslog/rotate_test.go +++ b/internal/net/gphttp/accesslog/rotate_test.go @@ -33,7 +33,7 @@ func TestParseLogTime(t *testing.T) { func TestRetentionCommonFormat(t *testing.T) { var file MockFile - logger := NewAccessLogger(task.RootTask("test", false), &file, &Config{ + logger := NewAccessLoggerWithIO(task.RootTask("test", false), &file, &Config{ Format: FormatCommon, BufferSize: 1024, }) diff --git a/internal/net/gphttp/accesslog/stdout_logger.go b/internal/net/gphttp/accesslog/stdout_logger.go new file mode 100644 index 0000000..2e1f245 --- /dev/null +++ b/internal/net/gphttp/accesslog/stdout_logger.go @@ -0,0 +1,18 @@ +package accesslog + +import ( + "io" + "os" +) + +type StdoutLogger struct { + io.Writer +} + +var stdoutIO = &StdoutLogger{os.Stdout} + +func (l *StdoutLogger) Lock() {} +func (l *StdoutLogger) Unlock() {} +func (l *StdoutLogger) Name() string { + return "stdout" +} diff --git a/internal/net/gphttp/gpwebsocket/utils.go b/internal/net/gphttp/gpwebsocket/utils.go index cb67dd6..be4fa74 100644 --- a/internal/net/gphttp/gpwebsocket/utils.go +++ b/internal/net/gphttp/gpwebsocket/utils.go @@ -6,6 +6,7 @@ import ( "time" "github.com/coder/websocket" + "github.com/coder/websocket/wsjson" "github.com/yusing/go-proxy/internal/common" "github.com/yusing/go-proxy/internal/gperr" "github.com/yusing/go-proxy/internal/logging" @@ -84,3 +85,18 @@ func WriteText(r *http.Request, conn *websocket.Conn, msg string) bool { } return true } + +// DynamicJSONHandler serves a JSON response depending on the request type. +// +// If the request is a websocket, it serves the data for the given interval. +// +// Otherwise, it serves the data once. +func DynamicJSONHandler[ResultType any](w http.ResponseWriter, r *http.Request, getter func() ResultType, interval time.Duration) { + if httpheaders.IsWebsocket(r.Header) { + Periodic(w, r, interval, func(conn *websocket.Conn) error { + return wsjson.Write(r.Context(), conn, getter()) + }) + } else { + gphttp.RespondJSON(w, r, getter()) + } +} diff --git a/internal/net/gphttp/loadbalancer/loadbalancer.go b/internal/net/gphttp/loadbalancer/loadbalancer.go index 3d474cf..85d3a95 100644 --- a/internal/net/gphttp/loadbalancer/loadbalancer.go +++ b/internal/net/gphttp/loadbalancer/loadbalancer.go @@ -13,7 +13,6 @@ import ( "github.com/yusing/go-proxy/internal/route/routes" "github.com/yusing/go-proxy/internal/task" "github.com/yusing/go-proxy/internal/watcher/health" - "github.com/yusing/go-proxy/internal/watcher/health/monitor" ) // TODO: stats of each server. @@ -240,14 +239,14 @@ func (lb *LoadBalancer) ServeHTTP(rw http.ResponseWriter, r *http.Request) { lb.impl.ServeHTTP(srvs, rw, r) } -// MarshalJSON implements health.HealthMonitor. -func (lb *LoadBalancer) MarshalJSON() ([]byte, error) { +// MarshalMap implements health.HealthMonitor. +func (lb *LoadBalancer) MarshalMap() map[string]any { extra := make(map[string]any) lb.pool.RangeAll(func(k string, v Server) { extra[v.Key()] = v }) - return (&monitor.JSONRepresentation{ + return (&health.JSONRepresentation{ Name: lb.Name(), Status: lb.Status(), Started: lb.startTime, @@ -256,7 +255,7 @@ func (lb *LoadBalancer) MarshalJSON() ([]byte, error) { "config": lb.Config, "pool": extra, }, - }).MarshalJSON() + }).MarshalMap() } // Name implements health.HealthMonitor. diff --git a/internal/net/gphttp/loadbalancer/types/server.go b/internal/net/gphttp/loadbalancer/types/server.go index 59dee05..37eda42 100644 --- a/internal/net/gphttp/loadbalancer/types/server.go +++ b/internal/net/gphttp/loadbalancer/types/server.go @@ -2,9 +2,9 @@ package types import ( "net/http" + "net/url" idlewatcher "github.com/yusing/go-proxy/internal/idlewatcher/types" - net "github.com/yusing/go-proxy/internal/net/types" U "github.com/yusing/go-proxy/internal/utils" F "github.com/yusing/go-proxy/internal/utils/functional" "github.com/yusing/go-proxy/internal/watcher/health" @@ -15,7 +15,7 @@ type ( _ U.NoCopy name string - url *net.URL + url *url.URL weight Weight http.Handler `json:"-"` @@ -27,7 +27,7 @@ type ( health.HealthMonitor Name() string Key() string - URL() *net.URL + URL() *url.URL Weight() Weight SetWeight(weight Weight) TryWake() error @@ -38,7 +38,7 @@ type ( var NewServerPool = F.NewMap[Pool] -func NewServer(name string, url *net.URL, weight Weight, handler http.Handler, healthMon health.HealthMonitor) Server { +func NewServer(name string, url *url.URL, weight Weight, handler http.Handler, healthMon health.HealthMonitor) Server { srv := &server{ name: name, url: url, @@ -52,7 +52,7 @@ func NewServer(name string, url *net.URL, weight Weight, handler http.Handler, h func TestNewServer[T ~int | ~float32 | ~float64](weight T) Server { srv := &server{ weight: Weight(weight), - url: net.MustParseURL("http://localhost"), + url: &url.URL{Scheme: "http", Host: "localhost"}, } return srv } @@ -61,7 +61,7 @@ func (srv *server) Name() string { return srv.name } -func (srv *server) URL() *net.URL { +func (srv *server) URL() *url.URL { return srv.url } diff --git a/internal/net/gphttp/middleware/cidr_whitelist.go b/internal/net/gphttp/middleware/cidr_whitelist.go index 6b9271f..a9d1dc6 100644 --- a/internal/net/gphttp/middleware/cidr_whitelist.go +++ b/internal/net/gphttp/middleware/cidr_whitelist.go @@ -6,7 +6,6 @@ import ( "github.com/go-playground/validator/v10" gphttp "github.com/yusing/go-proxy/internal/net/gphttp" - "github.com/yusing/go-proxy/internal/net/types" "github.com/yusing/go-proxy/internal/utils" F "github.com/yusing/go-proxy/internal/utils/functional" ) @@ -18,8 +17,8 @@ type ( cachedAddr F.Map[string, bool] // cache for trusted IPs } CIDRWhitelistOpts struct { - Allow []*types.CIDR `validate:"min=1"` - StatusCode int `json:"status_code" aliases:"status" validate:"omitempty,status_code"` + Allow []*net.IPNet `validate:"min=1"` + StatusCode int `json:"status_code" aliases:"status" validate:"omitempty,status_code"` Message string } ) @@ -27,7 +26,7 @@ type ( var ( CIDRWhiteList = NewMiddleware[cidrWhitelist]() cidrWhitelistDefaults = CIDRWhitelistOpts{ - Allow: []*types.CIDR{}, + Allow: []*net.IPNet{}, StatusCode: http.StatusForbidden, Message: "IP not allowed", } diff --git a/internal/net/gphttp/middleware/cloudflare_real_ip.go b/internal/net/gphttp/middleware/cloudflare_real_ip.go index 4fdcf2e..fc299d3 100644 --- a/internal/net/gphttp/middleware/cloudflare_real_ip.go +++ b/internal/net/gphttp/middleware/cloudflare_real_ip.go @@ -11,7 +11,6 @@ import ( "github.com/yusing/go-proxy/internal/common" "github.com/yusing/go-proxy/internal/logging" - "github.com/yusing/go-proxy/internal/net/types" "github.com/yusing/go-proxy/internal/utils/atomic" "github.com/yusing/go-proxy/internal/utils/strutils" ) @@ -33,7 +32,7 @@ var ( cfCIDRsMu sync.Mutex // RFC 1918. - localCIDRs = []*types.CIDR{ + localCIDRs = []*net.IPNet{ {IP: net.IPv4(127, 0, 0, 1), Mask: net.IPv4Mask(255, 255, 255, 255)}, // 127.0.0.1/32 {IP: net.IPv4(10, 0, 0, 0), Mask: net.IPv4Mask(255, 0, 0, 0)}, // 10.0.0.0/8 {IP: net.IPv4(172, 16, 0, 0), Mask: net.IPv4Mask(255, 240, 0, 0)}, // 172.16.0.0/12 @@ -68,7 +67,7 @@ func (cri *cloudflareRealIP) getTracer() *Tracer { return cri.realIP.getTracer() } -func tryFetchCFCIDR() (cfCIDRs []*types.CIDR) { +func tryFetchCFCIDR() (cfCIDRs []*net.IPNet) { if time.Since(cfCIDRsLastUpdate.Load()) < cfCIDRsUpdateInterval { return } @@ -83,7 +82,7 @@ func tryFetchCFCIDR() (cfCIDRs []*types.CIDR) { if common.IsTest { cfCIDRs = localCIDRs } else { - cfCIDRs = make([]*types.CIDR, 0, 30) + cfCIDRs = make([]*net.IPNet, 0, 30) err := errors.Join( fetchUpdateCFIPRange(cfIPv4CIDRsEndpoint, &cfCIDRs), fetchUpdateCFIPRange(cfIPv6CIDRsEndpoint, &cfCIDRs), @@ -103,7 +102,7 @@ func tryFetchCFCIDR() (cfCIDRs []*types.CIDR) { return } -func fetchUpdateCFIPRange(endpoint string, cfCIDRs *[]*types.CIDR) error { +func fetchUpdateCFIPRange(endpoint string, cfCIDRs *[]*net.IPNet) error { resp, err := http.Get(endpoint) if err != nil { return err @@ -124,7 +123,7 @@ func fetchUpdateCFIPRange(endpoint string, cfCIDRs *[]*types.CIDR) error { return fmt.Errorf("cloudflare responeded an invalid CIDR: %s", line) } - *cfCIDRs = append(*cfCIDRs, (*types.CIDR)(cidr)) + *cfCIDRs = append(*cfCIDRs, (*net.IPNet)(cidr)) } *cfCIDRs = append(*cfCIDRs, localCIDRs...) return nil diff --git a/internal/net/gphttp/middleware/errorpage/error_page.go b/internal/net/gphttp/middleware/errorpage/error_page.go index a1acec6..2c9027e 100644 --- a/internal/net/gphttp/middleware/errorpage/error_page.go +++ b/internal/net/gphttp/middleware/errorpage/error_page.go @@ -16,8 +16,6 @@ import ( "github.com/yusing/go-proxy/internal/watcher/events" ) -const errPagesBasePath = common.ErrorPagesBasePath - var ( setupOnce sync.Once dirWatcher W.Watcher @@ -26,7 +24,7 @@ var ( func setup() { t := task.RootTask("error_page", false) - dirWatcher = W.NewDirectoryWatcher(t, errPagesBasePath) + dirWatcher = W.NewDirectoryWatcher(t, common.ErrorPagesDir) loadContent() go watchDir() } @@ -46,7 +44,7 @@ func GetErrorPageByStatus(statusCode int) (content []byte, ok bool) { } func loadContent() { - files, err := U.ListFiles(errPagesBasePath, 0) + files, err := U.ListFiles(common.ErrorPagesDir, 0) if err != nil { logging.Err(err).Msg("failed to list error page resources") return diff --git a/internal/net/gphttp/middleware/middlewares.go b/internal/net/gphttp/middleware/middlewares.go index 184de94..16e287c 100644 --- a/internal/net/gphttp/middleware/middlewares.go +++ b/internal/net/gphttp/middleware/middlewares.go @@ -55,7 +55,7 @@ func All() map[string]*Middleware { func LoadComposeFiles() { errs := gperr.NewBuilder("middleware compile errors") - middlewareDefs, err := utils.ListFiles(common.MiddlewareComposeBasePath, 0) + middlewareDefs, err := utils.ListFiles(common.MiddlewareComposeDir, 0) if err != nil { logging.Err(err).Msg("failed to list middleware definitions") return diff --git a/internal/net/gphttp/middleware/modify_request_test.go b/internal/net/gphttp/middleware/modify_request_test.go index 8e69422..aa67d6d 100644 --- a/internal/net/gphttp/middleware/modify_request_test.go +++ b/internal/net/gphttp/middleware/modify_request_test.go @@ -4,10 +4,10 @@ import ( "bytes" "net" "net/http" + "net/url" "slices" "testing" - "github.com/yusing/go-proxy/internal/net/types" . "github.com/yusing/go-proxy/internal/utils/testing" ) @@ -51,8 +51,8 @@ func TestModifyRequest(t *testing.T) { }) t.Run("request_headers", func(t *testing.T) { - reqURL := types.MustParseURL("https://my.app/?arg_1=b") - upstreamURL := types.MustParseURL("http://test.example.com") + reqURL := Must(url.Parse("https://my.app/?arg_1=b")) + upstreamURL := Must(url.Parse("http://test.example.com")) result, err := newMiddlewareTest(ModifyRequest, &testArgs{ middlewareOpt: opts, reqURL: reqURL, @@ -128,8 +128,8 @@ func TestModifyRequest(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - reqURL := types.MustParseURL("https://my.app" + tt.path) - upstreamURL := types.MustParseURL(tt.upstreamURL) + reqURL := Must(url.Parse("https://my.app" + tt.path)) + upstreamURL := Must(url.Parse(tt.upstreamURL)) opts["add_prefix"] = tt.addPrefix result, err := newMiddlewareTest(ModifyRequest, &testArgs{ diff --git a/internal/net/gphttp/middleware/modify_response_test.go b/internal/net/gphttp/middleware/modify_response_test.go index 60922a1..9f426b2 100644 --- a/internal/net/gphttp/middleware/modify_response_test.go +++ b/internal/net/gphttp/middleware/modify_response_test.go @@ -4,10 +4,10 @@ import ( "bytes" "net" "net/http" + "net/url" "slices" "testing" - "github.com/yusing/go-proxy/internal/net/types" . "github.com/yusing/go-proxy/internal/utils/testing" ) @@ -54,8 +54,8 @@ func TestModifyResponse(t *testing.T) { }) t.Run("response_headers", func(t *testing.T) { - reqURL := types.MustParseURL("https://my.app/?arg_1=b") - upstreamURL := types.MustParseURL("http://test.example.com") + reqURL := Must(url.Parse("https://my.app/?arg_1=b")) + upstreamURL := Must(url.Parse("http://test.example.com")) result, err := newMiddlewareTest(ModifyResponse, &testArgs{ middlewareOpt: opts, reqURL: reqURL, diff --git a/internal/net/gphttp/middleware/real_ip.go b/internal/net/gphttp/middleware/real_ip.go index ed11d12..558ad5a 100644 --- a/internal/net/gphttp/middleware/real_ip.go +++ b/internal/net/gphttp/middleware/real_ip.go @@ -5,7 +5,6 @@ import ( "net/http" "github.com/yusing/go-proxy/internal/net/gphttp/httpheaders" - "github.com/yusing/go-proxy/internal/net/types" ) // https://nginx.org/en/docs/http/ngx_http_realip_module.html @@ -19,7 +18,7 @@ type ( // Header is the name of the header to use for the real client IP Header string `validate:"required"` // From is a list of Address / CIDRs to trust - From []*types.CIDR `validate:"required,min=1"` + From []*net.IPNet `validate:"required,min=1"` /* If recursive search is disabled, the original client address that matches one of the trusted addresses is replaced by @@ -36,7 +35,7 @@ var ( RealIP = NewMiddleware[realIP]() realIPOptsDefault = RealIPOpts{ Header: "X-Real-IP", - From: []*types.CIDR{}, + From: []*net.IPNet{}, } ) diff --git a/internal/net/gphttp/middleware/real_ip_test.go b/internal/net/gphttp/middleware/real_ip_test.go index 372862d..a70a182 100644 --- a/internal/net/gphttp/middleware/real_ip_test.go +++ b/internal/net/gphttp/middleware/real_ip_test.go @@ -7,7 +7,6 @@ import ( "testing" "github.com/yusing/go-proxy/internal/net/gphttp/httpheaders" - "github.com/yusing/go-proxy/internal/net/types" . "github.com/yusing/go-proxy/internal/utils/testing" ) @@ -23,7 +22,7 @@ func TestSetRealIPOpts(t *testing.T) { } optExpected := &RealIPOpts{ Header: httpheaders.HeaderXRealIP, - From: []*types.CIDR{ + From: []*net.IPNet{ { IP: net.ParseIP("127.0.0.0"), Mask: net.IPv4Mask(255, 0, 0, 0), diff --git a/internal/net/gphttp/middleware/redirect_http_test.go b/internal/net/gphttp/middleware/redirect_http_test.go index eccd33c..82dfb7c 100644 --- a/internal/net/gphttp/middleware/redirect_http_test.go +++ b/internal/net/gphttp/middleware/redirect_http_test.go @@ -2,15 +2,15 @@ package middleware import ( "net/http" + "net/url" "testing" - "github.com/yusing/go-proxy/internal/net/types" . "github.com/yusing/go-proxy/internal/utils/testing" ) func TestRedirectToHTTPs(t *testing.T) { result, err := newMiddlewareTest(RedirectHTTP, &testArgs{ - reqURL: types.MustParseURL("http://example.com"), + reqURL: Must(url.Parse("http://example.com")), }) ExpectNoError(t, err) ExpectEqual(t, result.ResponseStatus, http.StatusPermanentRedirect) @@ -19,7 +19,7 @@ func TestRedirectToHTTPs(t *testing.T) { func TestNoRedirect(t *testing.T) { result, err := newMiddlewareTest(RedirectHTTP, &testArgs{ - reqURL: types.MustParseURL("https://example.com"), + reqURL: Must(url.Parse("https://example.com")), }) ExpectNoError(t, err) ExpectEqual(t, result.ResponseStatus, http.StatusOK) diff --git a/internal/net/gphttp/middleware/test_utils.go b/internal/net/gphttp/middleware/test_utils.go index 2bd208b..edb5e24 100644 --- a/internal/net/gphttp/middleware/test_utils.go +++ b/internal/net/gphttp/middleware/test_utils.go @@ -7,11 +7,11 @@ import ( "io" "net/http" "net/http/httptest" + "net/url" "github.com/yusing/go-proxy/internal/common" "github.com/yusing/go-proxy/internal/gperr" "github.com/yusing/go-proxy/internal/net/gphttp/reverseproxy" - "github.com/yusing/go-proxy/internal/net/types" . "github.com/yusing/go-proxy/internal/utils/testing" ) @@ -80,11 +80,11 @@ type TestResult struct { type testArgs struct { middlewareOpt OptionsRaw - upstreamURL *types.URL + upstreamURL *url.URL realRoundTrip bool - reqURL *types.URL + reqURL *url.URL reqMethod string headers http.Header body []byte @@ -96,13 +96,13 @@ type testArgs struct { func (args *testArgs) setDefaults() { if args.reqURL == nil { - args.reqURL = Must(types.ParseURL("https://example.com")) + args.reqURL = Must(url.Parse("https://example.com")) } if args.reqMethod == "" { args.reqMethod = http.MethodGet } if args.upstreamURL == nil { - args.upstreamURL = Must(types.ParseURL("https://10.0.0.1:8443")) // dummy url, no actual effect + args.upstreamURL = Must(url.Parse("https://10.0.0.1:8443")) // dummy url, no actual effect } if args.respHeaders == nil { args.respHeaders = http.Header{} diff --git a/internal/net/gphttp/reverseproxy/reverse_proxy_mod.go b/internal/net/gphttp/reverseproxy/reverse_proxy_mod.go index 49988f0..0530a97 100644 --- a/internal/net/gphttp/reverseproxy/reverse_proxy_mod.go +++ b/internal/net/gphttp/reverseproxy/reverse_proxy_mod.go @@ -28,7 +28,6 @@ import ( "github.com/yusing/go-proxy/internal/logging" "github.com/yusing/go-proxy/internal/net/gphttp/accesslog" "github.com/yusing/go-proxy/internal/net/gphttp/httpheaders" - "github.com/yusing/go-proxy/internal/net/types" U "github.com/yusing/go-proxy/internal/utils" "golang.org/x/net/http/httpguts" ) @@ -93,7 +92,7 @@ type ReverseProxy struct { HandlerFunc http.HandlerFunc TargetName string - TargetURL *types.URL + TargetURL *url.URL } func singleJoiningSlash(a, b string) string { @@ -133,7 +132,7 @@ func joinURLPath(a, b *url.URL) (path, rawpath string) { // URLs to the scheme, host, and base path provided in target. If the // target's path is "/base" and the incoming request was for "/dir", // the target request will be for /base/dir. -func NewReverseProxy(name string, target *types.URL, transport http.RoundTripper) *ReverseProxy { +func NewReverseProxy(name string, target *url.URL, transport http.RoundTripper) *ReverseProxy { if transport == nil { panic("nil transport") } @@ -151,7 +150,7 @@ func (p *ReverseProxy) rewriteRequestURL(req *http.Request) { targetQuery := p.TargetURL.RawQuery req.URL.Scheme = p.TargetURL.Scheme req.URL.Host = p.TargetURL.Host - req.URL.Path, req.URL.RawPath = joinURLPath(&p.TargetURL.URL, req.URL) + req.URL.Path, req.URL.RawPath = joinURLPath(p.TargetURL, req.URL) if targetQuery == "" || req.URL.RawQuery == "" { req.URL.RawQuery = targetQuery + req.URL.RawQuery } else { diff --git a/internal/net/gphttp/servemux/mux.go b/internal/net/gphttp/servemux/mux.go new file mode 100644 index 0000000..9746444 --- /dev/null +++ b/internal/net/gphttp/servemux/mux.go @@ -0,0 +1,61 @@ +package servemux + +import ( + "fmt" + "net/http" + + "github.com/yusing/go-proxy/internal/api/v1/auth" + config "github.com/yusing/go-proxy/internal/config/types" + "github.com/yusing/go-proxy/internal/net/gphttp/httpheaders" + "github.com/yusing/go-proxy/internal/utils/strutils" +) + +type ( + ServeMux struct { + *http.ServeMux + cfg config.ConfigInstance + } + WithCfgHandler = func(config.ConfigInstance, http.ResponseWriter, *http.Request) +) + +func NewServeMux(cfg config.ConfigInstance) ServeMux { + return ServeMux{http.NewServeMux(), cfg} +} + +func (mux ServeMux) HandleFunc(methods, endpoint string, h any, requireAuth ...bool) { + var handler http.HandlerFunc + switch h := h.(type) { + case func(http.ResponseWriter, *http.Request): + handler = h + case http.Handler: + handler = h.ServeHTTP + case WithCfgHandler: + handler = func(w http.ResponseWriter, r *http.Request) { + h(mux.cfg, w, r) + } + default: + panic(fmt.Errorf("unsupported handler type: %T", h)) + } + + matchDomains := mux.cfg.Value().MatchDomains + if len(matchDomains) > 0 { + origHandler := handler + handler = func(w http.ResponseWriter, r *http.Request) { + if httpheaders.IsWebsocket(r.Header) { + httpheaders.SetWebsocketAllowedDomains(r.Header, matchDomains) + } + origHandler(w, r) + } + } + + if len(requireAuth) > 0 && requireAuth[0] { + handler = auth.RequireAuth(handler) + } + if methods == "" { + mux.ServeMux.HandleFunc(endpoint, handler) + } else { + for _, m := range strutils.CommaSeperatedList(methods) { + mux.ServeMux.HandleFunc(m+" "+endpoint, handler) + } + } +} diff --git a/internal/net/ping.go b/internal/net/ping.go new file mode 100644 index 0000000..0414df8 --- /dev/null +++ b/internal/net/ping.go @@ -0,0 +1,120 @@ +package netutils + +import ( + "context" + "errors" + "fmt" + "net" + "os" + "time" + + "golang.org/x/net/icmp" + "golang.org/x/net/ipv4" + "golang.org/x/net/ipv6" +) + +var ( + ipv4EchoBytes []byte + ipv6EchoBytes []byte +) + +func init() { + echoBody := &icmp.Echo{ + ID: os.Getpid() & 0xffff, + Seq: 1, + Data: []byte("Hello"), + } + ipv4Echo := &icmp.Message{ + Type: ipv4.ICMPTypeEcho, + Body: echoBody, + } + ipv6Echo := &icmp.Message{ + Type: ipv6.ICMPTypeEchoRequest, + Body: echoBody, + } + var err error + ipv4EchoBytes, err = ipv4Echo.Marshal(nil) + if err != nil { + panic(err) + } + ipv6EchoBytes, err = ipv6Echo.Marshal(nil) + if err != nil { + panic(err) + } +} + +// Ping pings the IP address using ICMP. +func Ping(ctx context.Context, ip net.IP) (bool, error) { + var msgBytes []byte + if ip.To4() != nil { + msgBytes = ipv4EchoBytes + } else { + msgBytes = ipv6EchoBytes + } + + conn, err := icmp.ListenPacket("ip:icmp", ip.String()) + if err != nil { + return false, err + } + defer conn.Close() + + err = conn.SetWriteDeadline(time.Now().Add(5 * time.Second)) + if err != nil { + return false, err + } + + _, err = conn.WriteTo(msgBytes, &net.IPAddr{IP: ip}) + if err != nil { + return false, err + } + + err = conn.SetReadDeadline(time.Now().Add(5 * time.Second)) + if err != nil { + return false, err + } + + buf := make([]byte, 1500) + for { + select { + case <-ctx.Done(): + return false, ctx.Err() + default: + } + n, _, err := conn.ReadFrom(buf) + if err != nil { + return false, err + } + m, err := icmp.ParseMessage(ipv4.ICMPTypeEchoReply.Protocol(), buf[:n]) + if err != nil { + continue + } + if m.Type == ipv4.ICMPTypeEchoReply { + return true, nil + } + } +} + +var pingDialer = &net.Dialer{ + Timeout: 2 * time.Second, +} + +// PingWithTCPFallback pings the IP address using ICMP and TCP fallback. +// +// If the ICMP ping fails due to permission error, it will try to connect to the specified port. +func PingWithTCPFallback(ctx context.Context, ip net.IP, port int) (bool, error) { + ok, err := Ping(ctx, ip) + if err != nil { + if !errors.Is(err, os.ErrPermission) { + return false, err + } + } else { + return ok, nil + } + + conn, err := pingDialer.DialContext(ctx, "tcp", fmt.Sprintf("%s:%d", ip, port)) + if err != nil { + return false, err + } + defer conn.Close() + return true, nil +} diff --git a/internal/net/ping_test.go b/internal/net/ping_test.go new file mode 100644 index 0000000..ca6f7f2 --- /dev/null +++ b/internal/net/ping_test.go @@ -0,0 +1,23 @@ +package netutils + +import ( + "context" + "errors" + "net" + "os" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestPing(t *testing.T) { + t.Run("localhost", func(t *testing.T) { + ok, err := Ping(context.Background(), net.ParseIP("127.0.0.1")) + // ping (ICMP) is not allowed for non-root users + if errors.Is(err, os.ErrPermission) { + t.Skip("permission denied") + } + require.NoError(t, err) + require.True(t, ok) + }) +} diff --git a/internal/net/types/cidr.go b/internal/net/types/cidr.go deleted file mode 100644 index 67ca297..0000000 --- a/internal/net/types/cidr.go +++ /dev/null @@ -1,39 +0,0 @@ -package types - -import ( - "net" - "strings" -) - -//nolint:recvcheck -type CIDR net.IPNet - -func ParseCIDR(v string) (cidr CIDR, err error) { - err = cidr.Parse(v) - return -} - -func (cidr *CIDR) Parse(v string) error { - if !strings.Contains(v, "/") { - v += "/32" // single IP - } - _, ipnet, err := net.ParseCIDR(v) - if err != nil { - return err - } - cidr.IP = ipnet.IP - cidr.Mask = ipnet.Mask - return nil -} - -func (cidr CIDR) Contains(ip net.IP) bool { - return (*net.IPNet)(&cidr).Contains(ip) -} - -func (cidr CIDR) String() string { - return (*net.IPNet)(&cidr).String() -} - -func (cidr CIDR) MarshalText() ([]byte, error) { - return []byte(cidr.String()), nil -} diff --git a/internal/net/types/url.go b/internal/net/types/url.go deleted file mode 100644 index a704813..0000000 --- a/internal/net/types/url.go +++ /dev/null @@ -1,56 +0,0 @@ -package types - -import ( - urlPkg "net/url" - - "github.com/yusing/go-proxy/internal/utils" -) - -type URL struct { - _ utils.NoCopy - urlPkg.URL -} - -func MustParseURL(url string) *URL { - u, err := ParseURL(url) - if err != nil { - panic(err) - } - return u -} - -func ParseURL(url string) (*URL, error) { - u := &URL{} - return u, u.Parse(url) -} - -func NewURL(url *urlPkg.URL) *URL { - return &URL{URL: *url} -} - -func (u *URL) Parse(url string) error { - uu, err := urlPkg.Parse(url) - if err != nil { - return err - } - u.URL = *uu - return nil -} - -func (u *URL) String() string { - if u == nil { - return "nil" - } - return u.URL.String() -} - -func (u *URL) MarshalJSON() (text []byte, err error) { - if u == nil { - return []byte("null"), nil - } - return []byte("\"" + u.URL.String() + "\""), nil -} - -func (u *URL) Equals(other *URL) bool { - return u.String() == other.String() -} diff --git a/internal/proxmox/client.go b/internal/proxmox/client.go new file mode 100644 index 0000000..d218e01 --- /dev/null +++ b/internal/proxmox/client.go @@ -0,0 +1,68 @@ +package proxmox + +import ( + "context" + "fmt" + + "github.com/luthermonson/go-proxmox" + "github.com/yusing/go-proxy/internal/utils/pool" +) + +type Client struct { + *proxmox.Client + proxmox.Cluster + Version *proxmox.Version +} + +var Clients = pool.New[*Client]("proxmox_clients") + +func NewClient(baseUrl string, opts ...proxmox.Option) *Client { + return &Client{Client: proxmox.NewClient(baseUrl, opts...)} +} + +func (c *Client) UpdateClusterInfo(ctx context.Context) (err error) { + c.Version, err = c.Client.Version(ctx) + if err != nil { + return err + } + // requires (/, Sys.Audit) + if err := c.Get(ctx, "/cluster/status", &c.Cluster); err != nil { + return err + } + for _, node := range c.Cluster.Nodes { + Nodes.Add(&Node{name: node.Name, id: node.ID, client: c.Client}) + } + return nil +} + +// Key implements pool.Object +func (c *Client) Key() string { + return c.Cluster.ID +} + +// Name implements pool.Object +func (c *Client) Name() string { + return c.Cluster.Name +} + +// MarshalMap implements pool.Object +func (c *Client) MarshalMap() map[string]any { + return map[string]any{ + "version": c.Version, + "cluster": map[string]any{ + "name": c.Cluster.Name, + "id": c.Cluster.ID, + "version": c.Cluster.Version, + "nodes": c.Cluster.Nodes, + "quorate": c.Cluster.Quorate, + }, + } +} + +func (c *Client) NumNodes() int { + return len(c.Cluster.Nodes) +} + +func (c *Client) String() string { + return fmt.Sprintf("%s (%s)", c.Cluster.Name, c.Cluster.ID) +} diff --git a/internal/proxmox/config.go b/internal/proxmox/config.go new file mode 100644 index 0000000..59e3b1a --- /dev/null +++ b/internal/proxmox/config.go @@ -0,0 +1,69 @@ +package proxmox + +import ( + "context" + "crypto/tls" + "errors" + "net/http" + "strings" + "time" + + "github.com/luthermonson/go-proxmox" + "github.com/yusing/go-proxy/internal/gperr" + "github.com/yusing/go-proxy/internal/net/gphttp" +) + +type Config struct { + URL string `json:"url" yaml:"url" validate:"required,url"` + + TokenID string `json:"token_id" yaml:"token_id" validate:"required"` + Secret string `json:"secret" yaml:"token_secret" validate:"required"` + + NoTLSVerify bool `json:"no_tls_verify" yaml:"no_tls_verify,omitempty"` + + client *Client +} + +func (c *Config) Client() *Client { + if c.client == nil { + panic("proxmox client accessed before init") + } + return c.client +} + +func (c *Config) Init() gperr.Error { + var tr *http.Transport + if c.NoTLSVerify { + tr = gphttp.NewTransportWithTLSConfig(&tls.Config{ + InsecureSkipVerify: true, + }) + } else { + tr = gphttp.NewTransport() + } + + if strings.HasSuffix(c.URL, "/") { + c.URL = c.URL[:len(c.URL)-1] + } + if !strings.HasSuffix(c.URL, "/api2/json") { + c.URL += "/api2/json" + } + + opts := []proxmox.Option{ + proxmox.WithAPIToken(c.TokenID, c.Secret), + proxmox.WithHTTPClient(&http.Client{ + Transport: tr, + }), + } + c.client = NewClient(c.URL, opts...) + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + if err := c.client.UpdateClusterInfo(ctx); err != nil { + if errors.Is(err, context.DeadlineExceeded) { + return gperr.New("timeout fetching proxmox cluster info") + } + return gperr.New("failed to fetch proxmox cluster info").With(err) + } + return nil +} diff --git a/internal/proxmox/lxc.go b/internal/proxmox/lxc.go new file mode 100644 index 0000000..fde5b57 --- /dev/null +++ b/internal/proxmox/lxc.go @@ -0,0 +1,236 @@ +package proxmox + +import ( + "context" + "fmt" + "net" + "strings" + "time" + + "github.com/luthermonson/go-proxmox" +) + +type ( + LXCAction string + LXCStatus string + + statusOnly struct { + Status LXCStatus `json:"status"` + } + nameOnly struct { + Name string `json:"name"` + } +) + +const ( + LXCStart LXCAction = "start" + LXCShutdown LXCAction = "shutdown" + LXCSuspend LXCAction = "suspend" + LXCResume LXCAction = "resume" + LXCReboot LXCAction = "reboot" +) + +const ( + LXCStatusRunning LXCStatus = "running" + LXCStatusStopped LXCStatus = "stopped" + LXCStatusSuspended LXCStatus = "suspended" // placeholder, suspending lxc is experimental and the enum is undocumented +) + +const ( + proxmoxReqTimeout = 3 * time.Second + proxmoxTaskCheckInterval = 300 * time.Millisecond +) + +func (n *Node) LXCAction(ctx context.Context, vmid int, 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 + } + + task := proxmox.NewTask(upid, n.client) + checkTicker := time.NewTicker(proxmoxTaskCheckInterval) + defer checkTicker.Stop() + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-checkTicker.C: + if err := task.Ping(ctx); err != nil { + return err + } + if task.Status != proxmox.TaskRunning { + status, err := n.LXCStatus(ctx, vmid) + if err != nil { + return err + } + switch status { + case LXCStatusRunning: + if action == LXCStart { + return nil + } + case LXCStatusStopped: + if action == LXCShutdown { + return nil + } + case LXCStatusSuspended: + if action == LXCSuspend { + return nil + } + } + } + } + } +} + +func (n *Node) LXCName(ctx context.Context, vmid int) (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 + } + return name.Name, nil +} + +func (n *Node) LXCStatus(ctx context.Context, vmid int) (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 + } + return status.Status, nil +} + +func (n *Node) LXCIsRunning(ctx context.Context, vmid int) (bool, error) { + status, err := n.LXCStatus(ctx, vmid) + return status == LXCStatusRunning, err +} + +func (n *Node) LXCIsStopped(ctx context.Context, vmid int) (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{}{ + "startup": fmt.Sprintf("down=%.0f", timeout.Seconds()), + }, nil) +} + +func parseCIDR(s string) net.IP { + if s == "" { + return nil + } + ip, _, err := net.ParseCIDR(s) + if err != nil { + return nil + } + return checkIPPrivate(ip) +} + +func checkIPPrivate(ip net.IP) net.IP { + if ip == nil { + return nil + } + if ip.IsPrivate() { + return ip + } + return nil +} + +func getIPFromNet(s string) (res []net.IP) { // name:...,bridge:...,gw=..,ip=...,ip6=... + if s == "" { + return nil + } + var i4, i6 net.IP + cidrIndex := strings.Index(s, "ip=") + if cidrIndex != -1 { + cidrIndex += 3 + slash := strings.Index(s[cidrIndex:], "/") + if slash != -1 { + i4 = checkIPPrivate(net.ParseIP(s[cidrIndex : cidrIndex+slash])) + } else { + i4 = checkIPPrivate(net.ParseIP(s[cidrIndex:])) + } + } + cidr6Index := strings.Index(s, "ip6=") + if cidr6Index != -1 { + cidr6Index += 4 + slash := strings.Index(s[cidr6Index:], "/") + if slash != -1 { + i6 = checkIPPrivate(net.ParseIP(s[cidr6Index : cidr6Index+slash])) + } else { + i6 = checkIPPrivate(net.ParseIP(s[cidr6Index:])) + } + } + if i4 != nil { + res = append(res, i4) + } + if i6 != nil { + res = append(res, i6) + } + return res +} + +// LXCGetIPs returns the ip addresses of the container +// it first tries to get the ip addresses from the config +// if that fails, it gets the ip addresses from the interfaces +func (n *Node) LXCGetIPs(ctx context.Context, vmid int) (res []net.IP, err error) { + ips, err := n.LXCGetIPsFromConfig(ctx, vmid) + if err != nil { + return nil, err + } + if len(ips) > 0 { + return ips, nil + } + ips, err = n.LXCGetIPsFromInterfaces(ctx, vmid) + if err != nil { + return nil, err + } + return ips, nil +} + +// LXCGetIPsFromConfig returns the ip addresses of the container from the config +func (n *Node) LXCGetIPsFromConfig(ctx context.Context, vmid int) (res []net.IP, err error) { + type Config struct { + Net0 string `json:"net0"` + Net1 string `json:"net1"` + Net2 string `json:"net2"` + } + var cfg Config + if err := n.client.Get(ctx, fmt.Sprintf("/nodes/%s/lxc/%d/config", n.name, vmid), &cfg); err != nil { + return nil, err + } + + res = append(res, getIPFromNet(cfg.Net0)...) + res = append(res, getIPFromNet(cfg.Net1)...) + res = append(res, getIPFromNet(cfg.Net2)...) + return res, nil +} + +// LXCGetIPsFromInterfaces returns the ip addresses of the container from the interfaces +// it will return nothing if the container is stopped +func (n *Node) LXCGetIPsFromInterfaces(ctx context.Context, vmid int) ([]net.IP, error) { + type Interface struct { + IPv4 string `json:"inet"` + IPv6 string `json:"inet6"` + Name string `json:"name"` + } + var res []Interface + if err := n.client.Get(ctx, fmt.Sprintf("/nodes/%s/lxc/%d/interfaces", n.name, vmid), &res); err != nil { + return nil, err + } + ips := make([]net.IP, 0) + for _, ip := range res { + if ip.Name == "lo" || + strings.HasPrefix(ip.Name, "br-") || + strings.HasPrefix(ip.Name, "veth") || + strings.HasPrefix(ip.Name, "docker") { + continue + } + if ip := parseCIDR(ip.IPv4); ip != nil { + ips = append(ips, ip) + } + if ip := parseCIDR(ip.IPv6); ip != nil { + ips = append(ips, ip) + } + } + return ips, nil +} diff --git a/internal/proxmox/lxc_test.go b/internal/proxmox/lxc_test.go new file mode 100644 index 0000000..a9ff13d --- /dev/null +++ b/internal/proxmox/lxc_test.go @@ -0,0 +1,40 @@ +package proxmox + +import ( + "net" + "reflect" + "testing" +) + +func TestGetIPFromNet(t *testing.T) { + testCases := []struct { + name string + input string + want []net.IP + }{ + { + name: "ipv4 only", + input: "name=eth0,bridge=vmbr0,gw=10.0.0.1,hwaddr=BC:24:11:10:88:97,ip=10.0.6.68/16,type=veth", + want: []net.IP{net.ParseIP("10.0.6.68")}, + }, + { + name: "ipv6 only, at the end", + input: "name=eth0,bridge=vmbr0,hwaddr=BC:24:11:10:88:97,gw=::ffff:a00:1,type=veth,ip6=::ffff:a00:644/48", + want: []net.IP{net.ParseIP("::ffff:a00:644")}, + }, + { + name: "both", + input: "name=eth0,bridge=vmbr0,hwaddr=BC:24:11:10:88:97,gw=::ffff:a00:1,type=veth,ip6=::ffff:a00:644/48,ip=10.0.6.68/16", + want: []net.IP{net.ParseIP("10.0.6.68"), net.ParseIP("::ffff:a00:644")}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + got := getIPFromNet(tc.input) + if !reflect.DeepEqual(got, tc.want) { + t.Errorf("getIPFromNet(%q) = %s, want %s", tc.name, got, tc.want) + } + }) + } +} diff --git a/internal/proxmox/node.go b/internal/proxmox/node.go new file mode 100644 index 0000000..5ecd906 --- /dev/null +++ b/internal/proxmox/node.go @@ -0,0 +1,50 @@ +package proxmox + +import ( + "context" + "fmt" + "strings" + + "github.com/luthermonson/go-proxmox" + "github.com/yusing/go-proxy/internal/utils/pool" +) + +type Node struct { + name string + id string // likely node/ + client *proxmox.Client +} + +var Nodes = pool.New[*Node]("proxmox_nodes") + +func AvailableNodeNames() string { + var sb strings.Builder + for _, node := range Nodes.Iter { + sb.WriteString(node.name) + sb.WriteString(", ") + } + return sb.String()[:sb.Len()-2] +} + +func (n *Node) Key() string { + return n.name +} + +func (n *Node) Name() string { + return n.name +} + +func (n *Node) String() string { + return fmt.Sprintf("%s (%s)", n.name, n.id) +} + +func (n *Node) MarshalMap() map[string]any { + return map[string]any{ + "name": n.name, + "id": n.id, + } +} + +func (n *Node) Get(ctx context.Context, path string, v any) error { + return n.client.Get(ctx, path, v) +} diff --git a/internal/route/fileserver.go b/internal/route/fileserver.go index ed75e9b..e8e8154 100644 --- a/internal/route/fileserver.go +++ b/internal/route/fileserver.go @@ -84,7 +84,7 @@ func (s *FileServer) Start(parent task.Parent) gperr.Error { if s.UseAccessLog() { var err error - s.accessLogger, err = accesslog.NewFileAccessLogger(s.task, s.AccessLog) + s.accessLogger, err = accesslog.NewAccessLogger(s.task, s.AccessLog) if err != nil { s.task.Finish(err) return gperr.Wrap(err) diff --git a/internal/route/provider/docker.go b/internal/route/provider/docker.go index 1d1799a..592b3e5 100755 --- a/internal/route/provider/docker.go +++ b/internal/route/provider/docker.go @@ -66,8 +66,8 @@ func (p *DockerProvider) loadRoutesImpl() (route.Routes, gperr.Error) { return nil, gperr.Wrap(err) } - errs := gperr.NewBuilder("") - routes := make(route.Routes) + errs := gperr.NewBuilder() + routes := make(route.Routes, len(containers)) for _, c := range containers { container := docker.FromDocker(&c, p.dockerHost) @@ -111,7 +111,7 @@ func (p *DockerProvider) routesFromContainerLabels(container *docker.Container) errs := gperr.NewBuilder("label errors") - m, err := docker.ParseLabels(container.Labels) + m, err := docker.ParseLabels(container.RouteConfig) errs.Add(err) var wildcardProps docker.LabelMap diff --git a/internal/route/provider/docker_test.go b/internal/route/provider/docker_test.go index 47a9090..8c79950 100644 --- a/internal/route/provider/docker_test.go +++ b/internal/route/provider/docker_test.go @@ -4,10 +4,9 @@ import ( "testing" "time" - "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/network" "github.com/docker/docker/client" - "github.com/yusing/go-proxy/internal/common" D "github.com/yusing/go-proxy/internal/docker" "github.com/yusing/go-proxy/internal/route" T "github.com/yusing/go-proxy/internal/route/types" @@ -21,7 +20,7 @@ const ( testDockerIP = "172.17.0.123" ) -func makeRoutes(cont *types.Container, dockerHostIP ...string) route.Routes { +func makeRoutes(cont *container.Summary, dockerHostIP ...string) route.Routes { var p DockerProvider var host string if len(dockerHostIP) > 0 { @@ -64,15 +63,15 @@ func TestApplyLabel(t *testing.T) { }, }, } - entries := makeRoutes(&types.Container{ + entries := makeRoutes(&container.Summary{ Names: dummyNames, Labels: map[string]string{ D.LabelAliases: "a,b", D.LabelIdleTimeout: "", - D.LabelStopMethod: common.StopMethodDefault, + D.LabelStopMethod: "stop", D.LabelStopSignal: "SIGTERM", - D.LabelStopTimeout: common.StopTimeoutDefault, - D.LabelWakeTimeout: common.WakeTimeoutDefault, + D.LabelStopTimeout: "1h", + D.LabelWakeTimeout: "10s", "proxy.*.no_tls_verify": "true", "proxy.*.scheme": "https", "proxy.*.host": "app", @@ -110,20 +109,16 @@ func TestApplyLabel(t *testing.T) { ExpectEqual(t, a.Middlewares, middlewaresExpect) ExpectEqual(t, len(b.Middlewares), 0) - ExpectEqual(t, a.Container.IdleTimeout, "") - ExpectEqual(t, b.Container.IdleTimeout, "") - - ExpectEqual(t, a.Container.StopTimeout, common.StopTimeoutDefault) - ExpectEqual(t, b.Container.StopTimeout, common.StopTimeoutDefault) - - ExpectEqual(t, a.Container.StopMethod, common.StopMethodDefault) - ExpectEqual(t, b.Container.StopMethod, common.StopMethodDefault) - - ExpectEqual(t, a.Container.WakeTimeout, common.WakeTimeoutDefault) - ExpectEqual(t, b.Container.WakeTimeout, common.WakeTimeoutDefault) - - ExpectEqual(t, a.Container.StopSignal, "SIGTERM") - ExpectEqual(t, b.Container.StopSignal, "SIGTERM") + ExpectEqual(t, a.Container.IdlewatcherConfig.IdleTimeout, 0) + ExpectEqual(t, b.Container.IdlewatcherConfig.IdleTimeout, 0) + ExpectEqual(t, a.Container.IdlewatcherConfig.StopTimeout, time.Hour) + ExpectEqual(t, b.Container.IdlewatcherConfig.StopTimeout, time.Hour) + ExpectEqual(t, a.Container.IdlewatcherConfig.StopMethod, "stop") + ExpectEqual(t, b.Container.IdlewatcherConfig.StopMethod, "stop") + ExpectEqual(t, a.Container.IdlewatcherConfig.WakeTimeout, 10*time.Second) + ExpectEqual(t, b.Container.IdlewatcherConfig.WakeTimeout, 10*time.Second) + ExpectEqual(t, a.Container.IdlewatcherConfig.StopSignal, "SIGTERM") + ExpectEqual(t, b.Container.IdlewatcherConfig.StopSignal, "SIGTERM") ExpectEqual(t, a.Homepage.Show, true) ExpectEqual(t, a.Homepage.Icon.Value, "png/adguard-home.png") @@ -135,7 +130,7 @@ func TestApplyLabel(t *testing.T) { } func TestApplyLabelWithAlias(t *testing.T) { - entries := makeRoutes(&types.Container{ + entries := makeRoutes(&container.Summary{ Names: dummyNames, State: "running", Labels: map[string]string{ @@ -162,7 +157,7 @@ func TestApplyLabelWithAlias(t *testing.T) { } func TestApplyLabelWithRef(t *testing.T) { - entries := makeRoutes(&types.Container{ + entries := makeRoutes(&container.Summary{ Names: dummyNames, State: "running", Labels: map[string]string{ @@ -190,7 +185,7 @@ func TestApplyLabelWithRef(t *testing.T) { } func TestApplyLabelWithRefIndexError(t *testing.T) { - c := D.FromDocker(&types.Container{ + c := D.FromDocker(&container.Summary{ Names: dummyNames, State: "running", Labels: map[string]string{ @@ -204,7 +199,7 @@ func TestApplyLabelWithRefIndexError(t *testing.T) { _, err := p.routesFromContainerLabels(c) ExpectError(t, ErrAliasRefIndexOutOfRange, err) - c = D.FromDocker(&types.Container{ + c = D.FromDocker(&container.Summary{ Names: dummyNames, State: "running", Labels: map[string]string{ @@ -217,7 +212,7 @@ func TestApplyLabelWithRefIndexError(t *testing.T) { } func TestDynamicAliases(t *testing.T) { - c := &types.Container{ + c := &container.Summary{ Names: []string{"app1"}, State: "running", Labels: map[string]string{ @@ -240,7 +235,7 @@ func TestDynamicAliases(t *testing.T) { } func TestDisableHealthCheck(t *testing.T) { - c := &types.Container{ + c := &container.Summary{ Names: dummyNames, State: "running", Labels: map[string]string{ @@ -254,7 +249,7 @@ func TestDisableHealthCheck(t *testing.T) { } func TestPublicIPLocalhost(t *testing.T) { - c := &types.Container{Names: dummyNames, State: "running"} + c := &container.Summary{Names: dummyNames, State: "running"} r, ok := makeRoutes(c)["a"] ExpectTrue(t, ok) ExpectEqual(t, r.Container.PublicHostname, "127.0.0.1") @@ -262,7 +257,7 @@ func TestPublicIPLocalhost(t *testing.T) { } func TestPublicIPRemote(t *testing.T) { - c := &types.Container{Names: dummyNames, State: "running"} + c := &container.Summary{Names: dummyNames, State: "running"} raw, ok := makeRoutes(c, testIP)["a"] ExpectTrue(t, ok) ExpectEqual(t, raw.Container.PublicHostname, testIP) @@ -270,9 +265,9 @@ func TestPublicIPRemote(t *testing.T) { } func TestPrivateIPLocalhost(t *testing.T) { - c := &types.Container{ + c := &container.Summary{ Names: dummyNames, - NetworkSettings: &types.SummaryNetworkSettings{ + NetworkSettings: &container.NetworkSettingsSummary{ Networks: map[string]*network.EndpointSettings{ "network": { IPAddress: testDockerIP, @@ -287,10 +282,10 @@ func TestPrivateIPLocalhost(t *testing.T) { } func TestPrivateIPRemote(t *testing.T) { - c := &types.Container{ + c := &container.Summary{ Names: dummyNames, State: "running", - NetworkSettings: &types.SummaryNetworkSettings{ + NetworkSettings: &container.NetworkSettingsSummary{ Networks: map[string]*network.EndpointSettings{ "network": { IPAddress: testDockerIP, @@ -309,17 +304,17 @@ func TestStreamDefaultValues(t *testing.T) { privPort := uint16(1234) pubPort := uint16(4567) privIP := "172.17.0.123" - cont := &types.Container{ + cont := &container.Summary{ Names: []string{"a"}, State: "running", - NetworkSettings: &types.SummaryNetworkSettings{ + NetworkSettings: &container.NetworkSettingsSummary{ Networks: map[string]*network.EndpointSettings{ "network": { IPAddress: privIP, }, }, }, - Ports: []types.Port{ + Ports: []container.Port{ {Type: "udp", PrivatePort: privPort, PublicPort: pubPort}, }, } @@ -346,7 +341,7 @@ func TestStreamDefaultValues(t *testing.T) { } func TestExplicitExclude(t *testing.T) { - r, ok := makeRoutes(&types.Container{ + r, ok := makeRoutes(&container.Summary{ Names: dummyNames, Labels: map[string]string{ D.LabelAliases: "a", @@ -360,9 +355,9 @@ func TestExplicitExclude(t *testing.T) { func TestImplicitExcludeDatabase(t *testing.T) { t.Run("mount path detection", func(t *testing.T) { - r, ok := makeRoutes(&types.Container{ + r, ok := makeRoutes(&container.Summary{ Names: dummyNames, - Mounts: []types.MountPoint{ + Mounts: []container.MountPoint{ {Source: "/data", Destination: "/var/lib/postgresql/data"}, }, })["a"] @@ -370,9 +365,9 @@ func TestImplicitExcludeDatabase(t *testing.T) { ExpectTrue(t, r.ShouldExclude()) }) t.Run("exposed port detection", func(t *testing.T) { - r, ok := makeRoutes(&types.Container{ + r, ok := makeRoutes(&container.Summary{ Names: dummyNames, - Ports: []types.Port{ + Ports: []container.Port{ {Type: "tcp", PrivatePort: 5432, PublicPort: 5432}, }, })["a"] diff --git a/internal/route/provider/file.go b/internal/route/provider/file.go index 84bf84f..c68158d 100644 --- a/internal/route/provider/file.go +++ b/internal/route/provider/file.go @@ -23,7 +23,7 @@ type FileProvider struct { func FileProviderImpl(filename string) ProviderImpl { return &FileProvider{ fileName: filename, - path: path.Join(common.ConfigBasePath, filename), + path: path.Join(common.ConfigDir, filename), l: logging.With().Str("type", "file").Str("name", filename).Logger(), } } diff --git a/internal/route/provider/provider.go b/internal/route/provider/provider.go index 1313bc7..14e222c 100644 --- a/internal/route/provider/provider.go +++ b/internal/route/provider/provider.go @@ -128,7 +128,7 @@ func (p *Provider) loadRoutes() (routes route.Routes, err gperr.Error) { if err != nil && len(routes) == 0 { return route.Routes{}, err } - errs := gperr.NewBuilder("routes error") + errs := gperr.NewBuilder() errs.Add(err) // check for exclusion // set alias and provider, then validate diff --git a/internal/route/reverse_proxy.go b/internal/route/reverse_proxy.go index bd03fc1..f1cce4c 100755 --- a/internal/route/reverse_proxy.go +++ b/internal/route/reverse_proxy.go @@ -6,10 +6,9 @@ import ( "github.com/yusing/go-proxy/agent/pkg/agent" "github.com/yusing/go-proxy/agent/pkg/agentproxy" - "github.com/yusing/go-proxy/internal/api/v1/favicon" "github.com/yusing/go-proxy/internal/common" - "github.com/yusing/go-proxy/internal/docker" "github.com/yusing/go-proxy/internal/gperr" + "github.com/yusing/go-proxy/internal/homepage" "github.com/yusing/go-proxy/internal/idlewatcher" "github.com/yusing/go-proxy/internal/logging" gphttp "github.com/yusing/go-proxy/internal/net/gphttp" @@ -104,10 +103,10 @@ func (r *ReveseProxyRoute) Start(parent task.Parent) gperr.Error { switch { case r.UseIdleWatcher(): - waker, err := idlewatcher.NewHTTPWaker(parent, r, r.rp) + waker, err := idlewatcher.NewWatcher(parent, r) if err != nil { r.task.Finish(err) - return err + return gperr.Wrap(err, "idlewatcher error") } r.handler = waker r.HealthMon = waker @@ -117,7 +116,7 @@ func (r *ReveseProxyRoute) Start(parent task.Parent) gperr.Error { if r.UseAccessLog() { var err error - r.rp.AccessLogger, err = accesslog.NewFileAccessLogger(r.task, r.AccessLog) + r.rp.AccessLogger, err = accesslog.NewAccessLogger(r.task, r.AccessLog) if err != nil { r.task.Finish(err) return gperr.Wrap(err) @@ -168,7 +167,7 @@ func (r *ReveseProxyRoute) Start(parent task.Parent) gperr.Error { r.addToLoadBalancer(parent) } else { routes.SetHTTPRoute(r.TargetName(), r) - r.task.OnCancel("entrypoint_remove_route", func() { + r.task.OnFinished("entrypoint_remove_route", func() { routes.DeleteHTTPRoute(r.TargetName()) }) } @@ -191,6 +190,10 @@ func (r *ReveseProxyRoute) ServeHTTP(w http.ResponseWriter, req *http.Request) { r.handler.ServeHTTP(w, req) } +func (r *ReveseProxyRoute) ReverseProxy() *reverseproxy.ReverseProxy { + return r.rp +} + func (r *ReveseProxyRoute) HealthMonitor() health.HealthMonitor { return r.HealthMon } diff --git a/internal/route/route.go b/internal/route/route.go index d79033e..5479ed6 100644 --- a/internal/route/route.go +++ b/internal/route/route.go @@ -1,8 +1,11 @@ package route import ( + "context" "fmt" + "net/url" "strings" + "time" "github.com/yusing/go-proxy/agent/pkg/agent" @@ -10,7 +13,9 @@ import ( "github.com/yusing/go-proxy/internal/gperr" "github.com/yusing/go-proxy/internal/homepage" idlewatcher "github.com/yusing/go-proxy/internal/idlewatcher/types" - net "github.com/yusing/go-proxy/internal/net/types" + "github.com/yusing/go-proxy/internal/logging" + gpnet "github.com/yusing/go-proxy/internal/net" + "github.com/yusing/go-proxy/internal/proxmox" "github.com/yusing/go-proxy/internal/task" "github.com/yusing/go-proxy/internal/utils/strutils" "github.com/yusing/go-proxy/internal/watcher/health" @@ -43,6 +48,8 @@ type ( Homepage *homepage.ItemConfig `json:"homepage,omitempty"` AccessLog *accesslog.Config `json:"access_log,omitempty"` + Idlewatcher *idlewatcher.Config `json:"idlewatcher,omitempty"` + Metadata `deserialize:"-"` } @@ -52,16 +59,16 @@ type ( Provider string `json:"provider,omitempty"` // private fields - LisURL *net.URL `json:"lurl,omitempty"` - ProxyURL *net.URL `json:"purl,omitempty"` - - Idlewatcher *idlewatcher.Config `json:"idlewatcher,omitempty"` + LisURL *url.URL `json:"lurl,omitempty"` + ProxyURL *url.URL `json:"purl,omitempty"` impl route.Route } Routes map[string]*Route ) +const DefaultHost = "localhost" + func (r Routes) Contains(alias string) bool { _, ok := r[alias] return ok @@ -81,6 +88,70 @@ func (r *Route) Validate() (err gperr.Error) { } } + if r.Idlewatcher != nil && r.Idlewatcher.Proxmox != nil { + node := r.Idlewatcher.Proxmox.Node + vmid := r.Idlewatcher.Proxmox.VMID + if node == "" { + return gperr.Errorf("node (proxmox node name) is required") + } + if vmid <= 0 { + return gperr.Errorf("vmid (lxc id) is required") + } + if r.Host == DefaultHost { + containerName := r.Idlewatcher.ContainerName() + // get ip addresses of the vmid + node, ok := proxmox.Nodes.Get(node) + if !ok { + return gperr.Errorf("proxmox node %s not found in pool", node) + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + ips, err := node.LXCGetIPs(ctx, vmid) + if err != nil { + return gperr.Errorf("failed to get ip addresses of vmid %d: %w", vmid, err) + } + + if len(ips) == 0 { + return gperr.Multiline(). + Addf("no ip addresses found for %s", containerName). + Adds("make sure you have set static ip address for container instead of dhcp"). + Subject(containerName) + } + + l := logging.With().Str("container", containerName).Logger() + + l.Info().Msg("checking if container is running") + running, err := node.LXCIsRunning(ctx, vmid) + if err != nil { + return gperr.New("failed to check container state").With(err) + } + + if !running { + l.Info().Msg("starting container") + if err := node.LXCAction(ctx, vmid, proxmox.LXCStart); err != nil { + return gperr.New("failed to start container").With(err) + } + } + + l.Info().Msgf("finding reachable ip addresses") + for _, ip := range ips { + if ok, _ := gpnet.PingWithTCPFallback(ctx, ip, r.Port.Proxy); ok { + r.Host = ip.String() + l.Info().Msgf("using ip %s", r.Host) + break + } + } + if r.Host == DefaultHost { + return gperr.Multiline(). + Addf("no reachable ip addresses found, tried %d IPs", len(ips)). + AddLines(ips). + Subject(containerName) + } + } + } + errs := gperr.NewBuilder("entry validation failed") if r.Scheme == route.SchemeFileServer { @@ -88,19 +159,19 @@ func (r *Route) Validate() (err gperr.Error) { if err != nil { errs.Add(err) } - r.ProxyURL = gperr.Collect(errs, net.ParseURL, "file://"+r.Root) + r.ProxyURL = gperr.Collect(errs, url.Parse, "file://"+r.Root) r.Host = "" r.Port.Proxy = 0 } else { switch r.Scheme { - case route.SchemeHTTP, route.SchemeHTTPS: - if r.Port.Listening != 0 { - errs.Addf("unexpected listening port for %s scheme", r.Scheme) + case route.SchemeHTTP, route.SchemeHTTPS: + if r.Port.Listening != 0 { + errs.Addf("unexpected listening port for %s scheme", r.Scheme) + } + case route.SchemeTCP, route.SchemeUDP: + r.LisURL = gperr.Collect(errs, url.Parse, fmt.Sprintf("%s://:%d", r.Scheme, r.Port.Listening)) } - case route.SchemeTCP, route.SchemeUDP: - r.LisURL = gperr.Collect(errs, net.ParseURL, fmt.Sprintf("%s://:%d", r.Scheme, r.Port.Listening)) - } - r.ProxyURL = gperr.Collect(errs, net.ParseURL, fmt.Sprintf("%s://%s:%d", r.Scheme, r.Host, r.Port.Proxy)) + r.ProxyURL = gperr.Collect(errs, url.Parse, fmt.Sprintf("%s://%s:%d", r.Scheme, r.Host, r.Port.Proxy)) } if !r.UseHealthCheck() && (r.UseLoadBalance() || r.UseIdleWatcher()) { @@ -146,8 +217,8 @@ func (r *Route) Started() bool { } func (r *Route) Reference() string { - if r.Docker != nil { - return r.Docker.Image.Name + if r.Container != nil { + return r.Container.Image.Name } return r.Alias } @@ -160,7 +231,7 @@ func (r *Route) TargetName() string { return r.Alias } -func (r *Route) TargetURL() *net.URL { +func (r *Route) TargetURL() *url.URL { return r.ProxyURL } @@ -190,6 +261,10 @@ func (r *Route) HealthMonitor() health.HealthMonitor { } func (r *Route) IdlewatcherConfig() *idlewatcher.Config { + cont := r.Container + if cont != nil && cont.IdlewatcherConfig != nil { + return cont.IdlewatcherConfig + } return r.Idlewatcher } @@ -255,7 +330,8 @@ func (r *Route) UseLoadBalance() bool { } func (r *Route) UseIdleWatcher() bool { - return r.Idlewatcher != nil && r.Idlewatcher.IdleTimeout > 0 + cfg := r.IdlewatcherConfig() + return cfg != nil && cfg.IdleTimeout > 0 } func (r *Route) UseHealthCheck() bool { @@ -276,7 +352,7 @@ func (r *Route) Finalize() { if r.Host == "" { switch { case !isDocker: - r.Host = "localhost" + r.Host = DefaultHost case cont.PrivateHostname != "": r.Host = cont.PrivateHostname case cont.PublicHostname != "": @@ -379,7 +455,7 @@ func (r *Route) FinalizeHomepageConfig() { panic("alias is empty") } - isDocker := r.Container != nil + isDocker := r.IsDocker() if r.Homepage == nil { r.Homepage = &homepage.ItemConfig{Show: true} diff --git a/internal/route/route_test.go b/internal/route/route_test.go index 29753f4..ee68711 100644 --- a/internal/route/route_test.go +++ b/internal/route/route_test.go @@ -98,7 +98,7 @@ func TestRouteValidate(t *testing.T) { Host: "example.com", Port: route.Port{Proxy: 80}, Metadata: Metadata{ - DockerContainer: &docker.Container{ + Container: &docker.Container{ ContainerID: "test-id", Image: &docker.ContainerImage{ Name: "test-image", diff --git a/internal/route/rules/do.go b/internal/route/rules/do.go index f00c20d..f113a4c 100644 --- a/internal/route/rules/do.go +++ b/internal/route/rules/do.go @@ -2,6 +2,7 @@ package rules import ( "net/http" + "net/url" "path" "strconv" "strings" @@ -9,7 +10,6 @@ import ( "github.com/yusing/go-proxy/internal/gperr" gphttp "github.com/yusing/go-proxy/internal/net/gphttp" "github.com/yusing/go-proxy/internal/net/gphttp/reverseproxy" - "github.com/yusing/go-proxy/internal/net/types" "github.com/yusing/go-proxy/internal/utils/strutils" ) @@ -95,7 +95,7 @@ var commands = map[string]struct { }, validate: validateURL, build: func(args any) CommandHandler { - target := args.(*types.URL).String() + target := args.(*url.URL).String() return ReturningCommand(func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, target, http.StatusTemporaryRedirect) }) @@ -160,7 +160,7 @@ var commands = map[string]struct { }, validate: validateAbsoluteURL, build: func(args any) CommandHandler { - target := args.(*types.URL) + target := args.(*url.URL) if target.Scheme == "" { target.Scheme = "http" } diff --git a/internal/route/rules/on.go b/internal/route/rules/on.go index 8c9e29f..f69e7a8 100644 --- a/internal/route/rules/on.go +++ b/internal/route/rules/on.go @@ -1,10 +1,10 @@ package rules import ( + "net" "net/http" "github.com/yusing/go-proxy/internal/gperr" - "github.com/yusing/go-proxy/internal/net/types" "github.com/yusing/go-proxy/internal/utils/strutils" ) @@ -205,7 +205,7 @@ var checkers = map[string]struct { }, validate: validateCIDR, builder: func(args any) CheckFunc { - cidr := args.(types.CIDR) + cidr := args.(*net.IPNet) return func(cached Cache, r *http.Request) bool { ip := cached.GetRemoteIP(r) if ip == nil { diff --git a/internal/route/rules/validate.go b/internal/route/rules/validate.go index 58a7dd8..09d3a66 100644 --- a/internal/route/rules/validate.go +++ b/internal/route/rules/validate.go @@ -2,13 +2,14 @@ package rules import ( "fmt" + "net" + "net/url" "os" "path" "strings" "github.com/yusing/go-proxy/internal/gperr" gphttp "github.com/yusing/go-proxy/internal/net/gphttp" - "github.com/yusing/go-proxy/internal/net/types" ) type ( @@ -48,24 +49,24 @@ func toKVOptionalV(args []string) (any, gperr.Error) { } } -// validateURL returns types.URL with the URL validated. +// validateURL returns url.URL with the URL validated. func validateURL(args []string) (any, gperr.Error) { if len(args) != 1 { return nil, ErrExpectOneArg } - u, err := types.ParseURL(args[0]) + u, err := url.Parse(args[0]) if err != nil { return nil, ErrInvalidArguments.With(err) } return u, nil } -// validateAbsoluteURL returns types.URL with the URL validated. +// validateAbsoluteURL returns url.URL with the URL validated. func validateAbsoluteURL(args []string) (any, gperr.Error) { if len(args) != 1 { return nil, ErrExpectOneArg } - u, err := types.ParseURL(args[0]) + u, err := url.Parse(args[0]) if err != nil { return nil, ErrInvalidArguments.With(err) } @@ -86,7 +87,7 @@ func validateCIDR(args []string) (any, gperr.Error) { if !strings.Contains(args[0], "/") { args[0] += "/32" } - cidr, err := types.ParseCIDR(args[0]) + _, cidr, err := net.ParseCIDR(args[0]) if err != nil { return nil, ErrInvalidArguments.With(err) } diff --git a/internal/route/stream.go b/internal/route/stream.go index dd09df7..a52c9a8 100755 --- a/internal/route/stream.go +++ b/internal/route/stream.go @@ -5,7 +5,6 @@ import ( "errors" "github.com/rs/zerolog" - "github.com/yusing/go-proxy/internal/docker" "github.com/yusing/go-proxy/internal/gperr" "github.com/yusing/go-proxy/internal/idlewatcher" "github.com/yusing/go-proxy/internal/logging" @@ -58,10 +57,10 @@ func (r *StreamRoute) Start(parent task.Parent) gperr.Error { switch { case r.UseIdleWatcher(): - waker, err := idlewatcher.NewStreamWaker(parent, r, r.Stream) + waker, err := idlewatcher.NewWatcher(parent, r) if err != nil { r.task.Finish(err) - return err + return gperr.Wrap(err, "idlewatcher error") } r.Stream = waker r.HealthMon = waker @@ -85,7 +84,7 @@ func (r *StreamRoute) Start(parent task.Parent) gperr.Error { go r.acceptConnections() routes.SetStreamRoute(r.TargetName(), r) - r.task.OnCancel("entrypoint_remove_route", func() { + r.task.OnFinished("entrypoint_remove_route", func() { routes.DeleteStreamRoute(r.TargetName()) }) return nil diff --git a/internal/route/stream_impl.go b/internal/route/stream_impl.go index 316f6db..c6b95dd 100644 --- a/internal/route/stream_impl.go +++ b/internal/route/stream_impl.go @@ -7,7 +7,7 @@ import ( "net" "time" - "github.com/yusing/go-proxy/internal/net/types" + gpnet "github.com/yusing/go-proxy/internal/net/types" U "github.com/yusing/go-proxy/internal/utils" ) @@ -15,7 +15,7 @@ type ( Stream struct { *StreamRoute - listener types.StreamListener + listener gpnet.StreamListener targetAddr net.Addr } ) @@ -56,7 +56,7 @@ func (stream *Stream) Setup() error { } // in case ListeningPort was zero, get the actual port stream.Port.Listening = tcpListener.Addr().(*net.TCPAddr).Port - stream.listener = types.NetListener(tcpListener) + stream.listener = gpnet.NetListener(tcpListener) case "udp": stream.targetAddr, err = net.ResolveUDPAddr("udp", stream.ProxyURL.Host) if err != nil { @@ -80,7 +80,7 @@ func (stream *Stream) Setup() error { return nil } -func (stream *Stream) Accept() (conn types.StreamConn, err error) { +func (stream *Stream) Accept() (conn gpnet.StreamConn, err error) { if stream.listener == nil { return nil, errors.New("listener is nil") } @@ -100,7 +100,7 @@ func (stream *Stream) Accept() (conn types.StreamConn, err error) { } } -func (stream *Stream) Handle(conn types.StreamConn) error { +func (stream *Stream) Handle(conn gpnet.StreamConn) error { switch conn := conn.(type) { case *UDPConn: switch stream := stream.listener.(type) { diff --git a/internal/route/types/http_config_test.go b/internal/route/types/http_config_test.go index 2846212..3040818 100644 --- a/internal/route/types/http_config_test.go +++ b/internal/route/types/http_config_test.go @@ -39,6 +39,7 @@ func TestHTTPConfigDeserialize(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { cfg := Route{} + tt.input["host"] = "internal" err := utils.MapUnmarshalValidate(tt.input, &cfg) if err != nil { ExpectNoError(t, err) diff --git a/internal/route/types/route.go b/internal/route/types/route.go index d9be0ff..9be415a 100644 --- a/internal/route/types/route.go +++ b/internal/route/types/route.go @@ -2,6 +2,7 @@ package route import ( "net/http" + "net/url" "github.com/yusing/go-proxy/agent/pkg/agent" "github.com/yusing/go-proxy/internal/docker" @@ -22,7 +23,7 @@ type ( task.TaskFinisher ProviderName() string TargetName() string - TargetURL() *net.URL + TargetURL() *url.URL HealthMonitor() health.HealthMonitor Reference() string diff --git a/internal/route/udp_forwarder.go b/internal/route/udp_forwarder.go index 62149af..23a4de8 100644 --- a/internal/route/udp_forwarder.go +++ b/internal/route/udp_forwarder.go @@ -8,7 +8,7 @@ import ( "github.com/yusing/go-proxy/internal/gperr" "github.com/yusing/go-proxy/internal/logging" - "github.com/yusing/go-proxy/internal/net/types" + gpnet "github.com/yusing/go-proxy/internal/net/types" F "github.com/yusing/go-proxy/internal/utils/functional" ) @@ -57,7 +57,7 @@ func (w *UDPForwarder) Addr() net.Addr { return w.forwarder.LocalAddr() } -func (w *UDPForwarder) Accept() (types.StreamConn, error) { +func (w *UDPForwarder) Accept() (gpnet.StreamConn, error) { buf := newUDPBuf() addr, err := w.readFromListener(buf) if err != nil { @@ -161,7 +161,7 @@ func (w *UDPForwarder) getInitConn(conn *UDPConn, key string) (*UDPConn, error) return dst, nil } -func (w *UDPForwarder) Handle(streamConn types.StreamConn) error { +func (w *UDPForwarder) Handle(streamConn gpnet.StreamConn) error { conn, ok := streamConn.(*UDPConn) if !ok { panic("unexpected conn type") diff --git a/internal/task/debug.go b/internal/task/debug.go index bf8a2db..97b3086 100644 --- a/internal/task/debug.go +++ b/internal/task/debug.go @@ -1,7 +1,8 @@ package task import ( - "slices" + "iter" + "strconv" "strings" ) @@ -28,16 +29,37 @@ func (t *Task) listCallbacks() []string { return callbacks } -// DebugTaskList returns list of all tasks. -// -// The returned string is suitable for printing to the console. -func DebugTaskList() []string { - l := make([]string, 0, allTasks.Size()) - - allTasks.RangeAll(func(t *Task) { - l = append(l, t.name) - }) - - slices.Sort(l) - return l +func AllTasks() iter.Seq2[string, *Task] { + return func(yield func(k string, v *Task) bool) { + for t := range allTasks.Range { + if !yield(t.name, t) { + return + } + } + } +} + +func (t *Task) Key() string { + return t.name +} + +func (t *Task) callbackList() []map[string]any { + list := make([]map[string]any, 0, len(t.callbacks)) + for cb := range t.callbacks { + list = append(list, map[string]any{ + "about": cb.about, + "wait_children": strconv.FormatBool(cb.waitChildren), + }) + } + return list +} + +func (t *Task) MarshalMap() map[string]any { + return map[string]any{ + "name": t.name, + "need_finish": strconv.FormatBool(t.needFinish), + "childrens": t.children, + "callbacks": t.callbackList(), + "finish_called": t.finishedCalled, + } } diff --git a/internal/task/task.go b/internal/task/task.go index 2421a13..b93eae2 100644 --- a/internal/task/task.go +++ b/internal/task/task.go @@ -45,7 +45,8 @@ type ( callbacks map[*Callback]struct{} callbacksDone chan struct{} - finished chan struct{} + needFinish bool + finished chan struct{} // finishedCalled == 1 Finish has been called // but does not mean that the task is finished yet // this is used to avoid calling Finish twice @@ -145,10 +146,11 @@ func (t *Task) Subtask(name string, needFinish ...bool) *Task { ctx, cancel := context.WithCancelCause(t.ctx) child := &Task{ - parent: t, - finished: make(chan struct{}), - ctx: ctx, - cancel: cancel, + parent: t, + needFinish: nf, + finished: make(chan struct{}), + ctx: ctx, + cancel: cancel, } if t != root { child.name = t.name + "." + name diff --git a/internal/task/utils.go b/internal/task/utils.go index da0bc36..5e8a803 100644 --- a/internal/task/utils.go +++ b/internal/task/utils.go @@ -2,7 +2,6 @@ package task import ( "context" - "encoding/json" "errors" "os" "os/signal" @@ -66,12 +65,7 @@ func GracefulShutdown(timeout time.Duration) (err error) { case <-root.finished: return case <-after: - b, err := json.Marshal(DebugTaskList()) - if err != nil { - logging.Warn().Err(err).Msg("failed to marshal tasks") - return context.DeadlineExceeded - } - logging.Warn().RawJSON("tasks", b).Msgf("Timeout waiting for these %d tasks to finish", allTasks.Size()) + logging.Warn().Msgf("Timeout waiting for %d tasks to finish", allTasks.Size()) return context.DeadlineExceeded } } diff --git a/internal/utils/functional/map.go b/internal/utils/functional/map.go index 5dcfde1..1efc9ab 100644 --- a/internal/utils/functional/map.go +++ b/internal/utils/functional/map.go @@ -85,33 +85,6 @@ func (m Map[KT, VT]) CollectErrors(do func(k KT, v VT) error) []error { return errs } -// CollectErrors calls the given function for each key-value pair in the map, -// then returns a slice of errors collected. -func (m Map[KT, VT]) CollectErrorsParallel(do func(k KT, v VT) error) []error { - if m.Size() < minParallelSize { - return m.CollectErrors(do) - } - - var errs []error - var mu sync.Mutex - var wg sync.WaitGroup - - m.Range(func(k KT, v VT) bool { - wg.Add(1) - go func() { - if err := do(k, v); err != nil { - mu.Lock() - errs = append(errs, err) - mu.Unlock() - } - wg.Done() - }() - return true - }) - wg.Wait() - return errs -} - func (m Map[KT, VT]) Has(k KT) bool { _, ok := m.Load(k) return ok diff --git a/internal/utils/io.go b/internal/utils/io.go index 2ee52d2..0a25938 100644 --- a/internal/utils/io.go +++ b/internal/utils/io.go @@ -145,7 +145,7 @@ func CopyClose(dst *ContextWriter, src *ContextReader) (err error) { buf = make([]byte, 0, size) } else { buf = copyBufPool.Get().([]byte) - defer copyBufPool.Put(buf) + defer copyBufPool.Put(buf[:0]) } // close both as soon as one of them is done wCloser, wCanClose := dst.Writer.(io.Closer) diff --git a/internal/utils/pool/pool.go b/internal/utils/pool/pool.go new file mode 100644 index 0000000..493299c --- /dev/null +++ b/internal/utils/pool/pool.go @@ -0,0 +1,74 @@ +package pool + +import ( + "sort" + + "github.com/yusing/go-proxy/internal/logging" + "github.com/yusing/go-proxy/internal/utils" + "github.com/yusing/go-proxy/internal/utils/functional" +) + +type ( + Pool[T Object] struct { + m functional.Map[string, T] + name string + } + Object interface { + Key() string + Name() string + utils.MapMarshaler + } +) + +func New[T Object](name string) Pool[T] { + return Pool[T]{functional.NewMapOf[string, T](), name} +} + +func (p Pool[T]) Name() string { + return p.name +} + +func (p Pool[T]) Add(obj T) { + p.m.Store(obj.Key(), obj) + logging.Info().Msgf("%s: added %s", p.name, obj.Name()) +} + +func (p Pool[T]) Del(obj T) { + p.m.Delete(obj.Key()) + logging.Info().Msgf("%s: removed %s", p.name, obj.Name()) +} + +func (p Pool[T]) Get(key string) (T, bool) { + return p.m.Load(key) +} + +func (p Pool[T]) Size() int { + return p.m.Size() +} + +func (p Pool[T]) Clear() { + p.m.Clear() +} + +func (p Pool[T]) Base() functional.Map[string, T] { + return p.m +} + +func (p Pool[T]) Slice() []T { + slice := make([]T, 0, p.m.Size()) + for _, v := range p.m.Range { + slice = append(slice, v) + } + sort.Slice(slice, func(i, j int) bool { + return slice[i].Name() < slice[j].Name() + }) + return slice +} + +func (p Pool[T]) Iter(fn func(k string, v T) bool) { + p.m.Range(fn) +} + +func (p Pool[T]) IterAll(fn func(k string, v T)) { + p.m.RangeAll(fn) +} diff --git a/internal/utils/serialization.go b/internal/utils/serialization.go index 9430905..5120e29 100644 --- a/internal/utils/serialization.go +++ b/internal/utils/serialization.go @@ -3,6 +3,8 @@ package utils import ( "encoding/json" "errors" + "net" + "net/url" "os" "reflect" "strconv" @@ -18,9 +20,14 @@ import ( type SerializedObject = map[string]any -type MapUnmarshaller interface { - UnmarshalMap(m map[string]any) gperr.Error -} +type ( + MapMarshaler interface { + MarshalMap() map[string]any + } + MapUnmarshaller interface { + UnmarshalMap(m map[string]any) gperr.Error + } +) var ( ErrInvalidType = gperr.New("invalid type") @@ -37,7 +44,19 @@ var ( tagAliases = "aliases" // declare aliases for fields ) -var mapUnmarshalerType = reflect.TypeFor[MapUnmarshaller]() +var ( + typeDuration = reflect.TypeFor[time.Duration]() + typeTime = reflect.TypeFor[time.Time]() + typeURL = reflect.TypeFor[url.URL]() + typeCIDR = reflect.TypeFor[net.IPNet]() + + typeMapMarshaller = reflect.TypeFor[MapMarshaler]() + typeMapUnmarshaler = reflect.TypeFor[MapUnmarshaller]() + typeJSONMarshaller = reflect.TypeFor[json.Marshaler]() + typeStrParser = reflect.TypeFor[strutils.Parser]() + + typeAny = reflect.TypeOf((*any)(nil)).Elem() +) var defaultValues = functional.NewMapOf[reflect.Type, func() any]() @@ -191,7 +210,7 @@ func MapUnmarshalValidate(src SerializedObject, dst any) (err gperr.Error) { return gperr.Errorf("unmarshal: src is %w and dst is not settable", ErrNilValue) } - if dstT.Implements(mapUnmarshalerType) { + if dstT.Implements(typeMapUnmarshaler) { dstV, _, err = dive(dstV) if err != nil { return err @@ -289,6 +308,20 @@ func isIntFloat(t reflect.Kind) bool { return t >= reflect.Bool && t <= reflect.Float64 } +func itoa(v reflect.Value) string { + switch v.Kind() { + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return strconv.FormatInt(v.Int(), 10) + case reflect.Bool: + return strconv.FormatBool(v.Bool()) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr: + return strconv.FormatUint(v.Uint(), 10) + case reflect.Float32, reflect.Float64: + return strconv.FormatFloat(v.Float(), 'f', -1, 64) + } + panic("invalid call on itoa") +} + // Convert attempts to convert the src to dst. // // If src is a map, it is deserialized into dst. @@ -345,27 +378,25 @@ func Convert(src reflect.Value, dst reflect.Value) gperr.Error { return err } case isIntFloat(srcKind): - var strV string - switch { - case src.CanInt(): - strV = strconv.FormatInt(src.Int(), 10) - case srcKind == reflect.Bool: - strV = strconv.FormatBool(src.Bool()) - case src.CanUint(): - strV = strconv.FormatUint(src.Uint(), 10) - case src.CanFloat(): - strV = strconv.FormatFloat(src.Float(), 'f', -1, 64) + if dst.Kind() == reflect.String { + dst.Set(reflect.ValueOf(itoa(src))) + return nil } - if convertible, err := ConvertString(strV, dst); convertible { - return err + if dst.Addr().Type().Implements(typeStrParser) { + return Convert(reflect.ValueOf(itoa(src)), dst) } + if !isIntFloat(dstT.Kind()) || !src.CanConvert(dstT) { + return ErrUnsupportedConversion.Subjectf("%s to %s", srcT, dstT) + } + dst.Set(src.Convert(dstT)) + return nil case srcKind == reflect.Map: if src.Len() == 0 { return nil } obj, ok := src.Interface().(SerializedObject) if !ok { - return ErrUnsupportedConversion.Subject(dstT.String() + " to " + srcT.String()) + return ErrUnsupportedConversion.Subjectf("%s to %s", srcT, dstT) } return MapUnmarshalValidate(obj, dst.Addr().Interface()) case srcKind == reflect.Slice: @@ -373,7 +404,7 @@ func Convert(src reflect.Value, dst reflect.Value) gperr.Error { return nil } if dstT.Kind() != reflect.Slice { - return ErrUnsupportedConversion.Subject(dstT.String() + " to " + srcT.String()) + return ErrUnsupportedConversion.Subjectf("%s to %s", srcT, dstT) } sliceErrs := gperr.NewBuilder("slice conversion errors") newSlice := reflect.MakeSlice(dstT, src.Len(), src.Len()) @@ -397,6 +428,19 @@ func Convert(src reflect.Value, dst reflect.Value) gperr.Error { return ErrUnsupportedConversion.Subjectf("%s to %s", srcT, dstT) } +func isSameOrEmbededType(src, dst reflect.Type) bool { + return src == dst || src.ConvertibleTo(dst) +} + +func setSameOrEmbedddType(src, dst reflect.Value) { + dstT := dst.Type() + if src.Type().AssignableTo(dstT) { + dst.Set(src) + } else { + dst.Set(src.Convert(dstT)) + } +} + func ConvertString(src string, dst reflect.Value) (convertible bool, convErr gperr.Error) { convertible = true dstT := dst.Type() @@ -407,16 +451,17 @@ func ConvertString(src string, dst reflect.Value) (convertible bool, convErr gpe dst = dst.Elem() dstT = dst.Type() } - if dst.Kind() == reflect.String { + dstKind := dst.Kind() + if dstKind == reflect.String { dst.SetString(src) return } - switch dstT { - case reflect.TypeFor[time.Duration](): - if src == "" { - dst.Set(reflect.Zero(dstT)) - return - } + if src == "" { + dst.Set(reflect.Zero(dstT)) + return + } + switch { + case dstT == typeDuration: d, err := time.ParseDuration(src) if err != nil { return true, gperr.Wrap(err) @@ -426,9 +471,25 @@ func ConvertString(src string, dst reflect.Value) (convertible bool, convErr gpe } dst.Set(reflect.ValueOf(d)) return - default: + case isSameOrEmbededType(dstT, typeURL): + u, err := url.Parse(src) + if err != nil { + return true, gperr.Wrap(err) + } + setSameOrEmbedddType(reflect.ValueOf(u).Elem(), dst) + return + case isSameOrEmbededType(dstT, typeCIDR): + if !strings.ContainsRune(src, '/') { + src += "/32" // single IP + } + _, ipnet, err := net.ParseCIDR(src) + if err != nil { + return true, gperr.Wrap(err) + } + setSameOrEmbedddType(reflect.ValueOf(ipnet).Elem(), dst) + return } - if dstKind := dst.Kind(); isIntFloat(dstKind) { + if isIntFloat(dstKind) { var i any var err error switch { @@ -458,7 +519,14 @@ func ConvertString(src string, dst reflect.Value) (convertible bool, convErr gpe } // yaml like var tmp any - switch dst.Kind() { + switch dstKind { + case reflect.Map, reflect.Struct: + rawMap := make(SerializedObject) + err := yaml.Unmarshal([]byte(src), &rawMap) + if err != nil { + return true, gperr.Wrap(err) + } + tmp = rawMap case reflect.Slice: src = strings.TrimSpace(src) isMultiline := strings.ContainsRune(src, '\n') @@ -484,13 +552,6 @@ func ConvertString(src string, dst reflect.Value) (convertible bool, convErr gpe return true, gperr.Wrap(err) } tmp = sl - case reflect.Map, reflect.Struct: - rawMap := make(SerializedObject) - err := yaml.Unmarshal([]byte(src), &rawMap) - if err != nil { - return true, gperr.Wrap(err) - } - tmp = rawMap default: return false, nil } diff --git a/internal/utils/serialization_test.go b/internal/utils/serialization_test.go index ce5669a..4c67c75 100644 --- a/internal/utils/serialization_test.go +++ b/internal/utils/serialization_test.go @@ -1,6 +1,9 @@ package utils import ( + "fmt" + "net" + "net/url" "reflect" "strconv" "testing" @@ -9,7 +12,7 @@ import ( "gopkg.in/yaml.v3" ) -func TestDeserialize(t *testing.T) { +func TestUnmarshal(t *testing.T) { type S struct { I int S string @@ -38,15 +41,15 @@ func TestDeserialize(t *testing.T) { } ) - t.Run("deserialize", func(t *testing.T) { + t.Run("unmarshal", func(t *testing.T) { var s2 S err := MapUnmarshalValidate(testStructSerialized, &s2) ExpectNoError(t, err) - ExpectEqual(t, s2, testStruct) + ExpectEqualValues(t, s2, testStruct) }) } -func TestDeserializeAnonymousField(t *testing.T) { +func TestUnmarshalAnonymousField(t *testing.T) { type Anon struct { A, B int } @@ -62,71 +65,43 @@ func TestDeserializeAnonymousField(t *testing.T) { // t.Fatalf("anon %v, all %v", anon, all) err := MapUnmarshalValidate(map[string]any{"a": 1, "b": 2, "c": 3}, &s) ExpectNoError(t, err) - ExpectEqual(t, s.A, 1) - ExpectEqual(t, s.B, 2) - ExpectEqual(t, s.C, 3) + ExpectEqualValues(t, s.A, 1) + ExpectEqualValues(t, s.B, 2) + ExpectEqualValues(t, s.C, 3) err = MapUnmarshalValidate(map[string]any{"a": 1, "b": 2, "c": 3}, &s2) ExpectNoError(t, err) - ExpectEqual(t, s2.A, 1) - ExpectEqual(t, s2.B, 2) - ExpectEqual(t, s2.C, 3) + ExpectEqualValues(t, s2.A, 1) + ExpectEqualValues(t, s2.B, 2) + ExpectEqualValues(t, s2.C, 3) } func TestStringIntConvert(t *testing.T) { - s := "127" - test := struct { - i8 int8 - i16 int16 - i32 int32 - i64 int64 - u8 uint8 - u16 uint16 - u32 uint32 - u64 uint64 + I8 int8 + I16 int16 + I32 int32 + I64 int64 + U8 uint8 + U16 uint16 + U32 uint32 + U64 uint64 }{} - ok, err := ConvertString(s, reflect.ValueOf(&test.i8)) + refl := reflect.ValueOf(&test) + for i := range refl.Elem().NumField() { + field := refl.Elem().Field(i) + t.Run(fmt.Sprintf("field_%s", field.Type().Name()), func(t *testing.T) { + ok, err := ConvertString("127", field) + ExpectTrue(t, ok) + ExpectNoError(t, err) + ExpectEqualValues(t, field.Interface(), 127) - ExpectTrue(t, ok) - ExpectNoError(t, err) - ExpectEqual(t, test.i8, int8(127)) - - ok, err = ConvertString(s, reflect.ValueOf(&test.i16)) - ExpectTrue(t, ok) - ExpectNoError(t, err) - ExpectEqual(t, test.i16, int16(127)) - - ok, err = ConvertString(s, reflect.ValueOf(&test.i32)) - ExpectTrue(t, ok) - ExpectNoError(t, err) - ExpectEqual(t, test.i32, int32(127)) - - ok, err = ConvertString(s, reflect.ValueOf(&test.i64)) - ExpectTrue(t, ok) - ExpectNoError(t, err) - ExpectEqual(t, test.i64, int64(127)) - - ok, err = ConvertString(s, reflect.ValueOf(&test.u8)) - ExpectTrue(t, ok) - ExpectNoError(t, err) - ExpectEqual(t, test.u8, uint8(127)) - - ok, err = ConvertString(s, reflect.ValueOf(&test.u16)) - ExpectTrue(t, ok) - ExpectNoError(t, err) - ExpectEqual(t, test.u16, uint16(127)) - - ok, err = ConvertString(s, reflect.ValueOf(&test.u32)) - ExpectTrue(t, ok) - ExpectNoError(t, err) - ExpectEqual(t, test.u32, uint32(127)) - - ok, err = ConvertString(s, reflect.ValueOf(&test.u64)) - ExpectTrue(t, ok) - ExpectNoError(t, err) - ExpectEqual(t, test.u64, uint64(127)) + err = Convert(reflect.ValueOf(uint8(64)), field) + ExpectNoError(t, err) + ExpectEqualValues(t, field.Interface(), 64) + }) + } } type testModel struct { @@ -150,19 +125,19 @@ func TestConvertor(t *testing.T) { m := new(testModel) ExpectNoError(t, MapUnmarshalValidate(map[string]any{"Test": "123"}, m)) - ExpectEqual(t, m.Test.foo, 123) - ExpectEqual(t, m.Test.bar, "123") + ExpectEqualValues(t, m.Test.foo, 123) + ExpectEqualValues(t, m.Test.bar, "123") }) t.Run("int_to_string", func(t *testing.T) { m := new(testModel) ExpectNoError(t, MapUnmarshalValidate(map[string]any{"Test": "123"}, m)) - ExpectEqual(t, m.Test.foo, 123) - ExpectEqual(t, m.Test.bar, "123") + ExpectEqualValues(t, m.Test.foo, 123) + ExpectEqualValues(t, m.Test.bar, "123") - ExpectNoError(t, MapUnmarshalValidate(map[string]any{"Baz": 123}, m)) - ExpectEqual(t, m.Baz, "123") + ExpectNoError(t, MapUnmarshalValidate(map[string]any{"Baz": 456}, m)) + ExpectEqualValues(t, m.Baz, "456") }) t.Run("invalid", func(t *testing.T) { @@ -177,21 +152,21 @@ func TestStringToSlice(t *testing.T) { convertible, err := ConvertString("a,b,c", reflect.ValueOf(&dst)) ExpectTrue(t, convertible) ExpectNoError(t, err) - ExpectEqual(t, dst, []string{"a", "b", "c"}) + ExpectEqualValues(t, dst, []string{"a", "b", "c"}) }) t.Run("yaml-like", func(t *testing.T) { dst := make([]string, 0) convertible, err := ConvertString("- a\n- b\n- c", reflect.ValueOf(&dst)) ExpectTrue(t, convertible) ExpectNoError(t, err) - ExpectEqual(t, dst, []string{"a", "b", "c"}) + ExpectEqualValues(t, dst, []string{"a", "b", "c"}) }) t.Run("single-line-yaml-like", func(t *testing.T) { dst := make([]string, 0) convertible, err := ConvertString("- a", reflect.ValueOf(&dst)) ExpectTrue(t, convertible) ExpectNoError(t, err) - ExpectEqual(t, dst, []string{"a"}) + ExpectEqualValues(t, dst, []string{"a"}) }) } @@ -215,7 +190,7 @@ func TestStringToMap(t *testing.T) { convertible, err := ConvertString(" a: b\n c: d", reflect.ValueOf(&dst)) ExpectTrue(t, convertible) ExpectNoError(t, err) - ExpectEqual(t, dst, map[string]string{"a": "b", "c": "d"}) + ExpectEqualValues(t, dst, map[string]string{"a": "b", "c": "d"}) }) } @@ -234,18 +209,28 @@ func BenchmarkStringToMapYAML(b *testing.B) { } func TestStringToStruct(t *testing.T) { - t.Run("yaml-like", func(t *testing.T) { - dst := struct { - A string - B int - }{} + type T struct { + A string + B int + } + t.Run("yaml-like simple", func(t *testing.T) { + var dst T convertible, err := ConvertString(" A: a\n B: 123", reflect.ValueOf(&dst)) ExpectTrue(t, convertible) ExpectNoError(t, err) - ExpectEqual(t, dst, struct { - A string - B int - }{"a", 123}) + ExpectEqualValues(t, dst.A, "a") + ExpectEqualValues(t, dst.B, 123) + }) + + type T2 struct { + URL *url.URL + CIDR *net.IPNet + } + t.Run("yaml-like complex", func(t *testing.T) { + var dst T2 + convertible, err := ConvertString(" URL: http://example.com\n CIDR: 1.2.3.0/24", reflect.ValueOf(&dst)) + ExpectTrue(t, convertible) + ExpectNoError(t, err) }) } diff --git a/internal/utils/strutils/format.go b/internal/utils/strutils/format.go index 626b701..8ed1f65 100644 --- a/internal/utils/strutils/format.go +++ b/internal/utils/strutils/format.go @@ -4,13 +4,32 @@ import ( "fmt" "math" "strconv" - "strings" "time" "github.com/yusing/go-proxy/internal/utils/strutils/ansi" ) -func FormatDuration(d time.Duration) string { +func AppendDuration(d time.Duration, buf []byte) []byte { + if d < 0 { + buf = append(buf, '-') + d = -d + } + + if d == 0 { + return append(buf, []byte("0 Seconds")...) + } + + switch { + case d < time.Millisecond: + buf = strconv.AppendInt(buf, int64(d.Nanoseconds()), 10) + buf = append(buf, []byte(" ns")...) + return buf + case d < time.Second: + buf = strconv.AppendInt(buf, int64(d.Milliseconds()), 10) + buf = append(buf, []byte(" ms")...) + return buf + } + // Get total seconds from duration totalSeconds := int64(d.Seconds()) @@ -20,30 +39,27 @@ func FormatDuration(d time.Duration) string { minutes := (totalSeconds % 3600) / 60 seconds := totalSeconds % 60 - // Create a slice to hold parts of the duration - var parts []string - if days > 0 { - parts = append(parts, fmt.Sprintf("%d day%s", days, pluralize(days))) + buf = strconv.AppendInt(buf, days, 10) + buf = fmt.Appendf(buf, "day%s, ", Pluralize(days)) } if hours > 0 { - parts = append(parts, fmt.Sprintf("%d hour%s", hours, pluralize(hours))) + buf = strconv.AppendInt(buf, hours, 10) + buf = fmt.Appendf(buf, "hour%s, ", Pluralize(hours)) } if minutes > 0 { - parts = append(parts, fmt.Sprintf("%d minute%s", minutes, pluralize(minutes))) + buf = strconv.AppendInt(buf, minutes, 10) + buf = fmt.Appendf(buf, "minute%s, ", Pluralize(minutes)) } if seconds > 0 && totalSeconds < 3600 { - parts = append(parts, fmt.Sprintf("%d second%s", seconds, pluralize(seconds))) + buf = strconv.AppendInt(buf, seconds, 10) + buf = fmt.Appendf(buf, "second%s, ", Pluralize(seconds)) } + return buf[:len(buf)-2] +} - // Join the parts with appropriate connectors - if len(parts) == 0 { - return "0 Seconds" - } - if len(parts) == 1 { - return parts[0] - } - return strings.Join(parts[:len(parts)-1], ", ") + " and " + parts[len(parts)-1] +func FormatDuration(d time.Duration) string { + return string(AppendDuration(d, nil)) } func FormatLastSeen(t time.Time) string { @@ -53,28 +69,93 @@ func FormatLastSeen(t time.Time) string { return FormatTime(t) } -func FormatTime(t time.Time) string { - return t.Format("2006-01-02 15:04:05") +func appendRound(f float64, buf []byte) []byte { + return strconv.AppendInt(buf, int64(math.Round(f)), 10) } -func ParseBool(s string) bool { - switch strings.ToLower(s) { - case "1", "true", "yes", "on": - return true - default: - return false - } -} - -func formatFloat(f float64) string { +func appendFloat(f float64, buf []byte) []byte { f = math.Round(f*100) / 100 if f == 0 { - return "0" + return buf } - return strconv.FormatFloat(f, 'f', -1, 64) + return strconv.AppendFloat(buf, f, 'f', -1, 64) } -func FormatByteSize[T ~int64 | ~uint64 | ~float64](size T) (value, unit string) { +func AppendTime(t time.Time, buf []byte) []byte { + if t.IsZero() { + return append(buf, []byte("never")...) + } + return AppendTimeWithReference(t, time.Now(), buf) +} + +func FormatTime(t time.Time) string { + return string(AppendTime(t, nil)) +} + +func FormatUnixTime(t int64) string { + return FormatTime(time.Unix(t, 0)) +} + +func FormatTimeWithReference(t, ref time.Time) string { + return string(AppendTimeWithReference(t, ref, nil)) +} + +func AppendTimeWithReference(t, ref time.Time, buf []byte) []byte { + if t.IsZero() { + return append(buf, []byte("never")...) + } + diff := t.Sub(ref) + absDiff := diff.Abs() + switch { + case absDiff < time.Second: + return append(buf, []byte("now")...) + case absDiff < 3*time.Second: + if diff < 0 { + return append(buf, []byte("just now")...) + } + fallthrough + case absDiff < 60*time.Second: + if diff < 0 { + buf = appendRound(absDiff.Seconds(), buf) + buf = append(buf, []byte(" seconds ago")...) + } else { + buf = append(buf, []byte("in ")...) + buf = appendRound(absDiff.Seconds(), buf) + buf = append(buf, []byte(" seconds")...) + } + return buf + case absDiff < 60*time.Minute: + if diff < 0 { + buf = appendRound(absDiff.Minutes(), buf) + buf = append(buf, []byte(" minutes ago")...) + } else { + buf = append(buf, []byte("in ")...) + buf = appendRound(absDiff.Minutes(), buf) + buf = append(buf, []byte(" minutes")...) + } + return buf + case absDiff < 24*time.Hour: + if diff < 0 { + buf = appendRound(absDiff.Hours(), buf) + buf = append(buf, []byte(" hours ago")...) + } else { + buf = append(buf, []byte("in ")...) + buf = appendRound(absDiff.Hours(), buf) + buf = append(buf, []byte(" hours")...) + } + return buf + case t.Year() == ref.Year(): + return t.AppendFormat(buf, "01-02 15:04:05") + default: + return t.AppendFormat(buf, "2006-01-02 15:04:05") + } +} + +func FormatByteSize(size int64) string { + return string(AppendByteSize(size, nil)) +} + +func AppendByteSize[T ~int64 | ~uint64 | ~float64](size T, buf []byte) []byte { const ( _ = (1 << (10 * iota)) kb @@ -85,27 +166,32 @@ func FormatByteSize[T ~int64 | ~uint64 | ~float64](size T) (value, unit string) ) switch { case size < kb: - return fmt.Sprintf("%v", size), "B" + switch any(size).(type) { + case int64: + buf = strconv.AppendInt(buf, int64(size), 10) + case uint64: + buf = strconv.AppendUint(buf, uint64(size), 10) + case float64: + buf = appendFloat(float64(size), buf) + } + buf = append(buf, []byte(" B")...) case size < mb: - return formatFloat(float64(size) / kb), "KiB" + buf = appendFloat(float64(size)/kb, buf) + buf = append(buf, []byte(" KiB")...) case size < gb: - return formatFloat(float64(size) / mb), "MiB" + buf = appendFloat(float64(size)/mb, buf) + buf = append(buf, []byte(" MiB")...) case size < tb: - return formatFloat(float64(size) / gb), "GiB" + buf = appendFloat(float64(size)/gb, buf) + buf = append(buf, []byte(" GiB")...) case size < pb: - return formatFloat(float64(size/gb) / kb), "TiB" // prevent overflow + buf = appendFloat(float64(size/gb)/kb, buf) + buf = append(buf, []byte(" TiB")...) default: - return formatFloat(float64(size/tb) / kb), "PiB" // prevent overflow + buf = appendFloat(float64(size/tb)/kb, buf) + buf = append(buf, []byte(" PiB")...) } -} - -func FormatByteSizeWithUnit[T ~int64 | ~uint64 | ~float64](size T) string { - value, unit := FormatByteSize(size) - return value + " " + unit -} - -func PortString(port uint16) string { - return strconv.FormatUint(uint64(port), 10) + return buf } func DoYouMean(s string) string { @@ -115,7 +201,7 @@ func DoYouMean(s string) string { return "Did you mean " + ansi.HighlightGreen + s + ansi.Reset + "?" } -func pluralize(n int64) string { +func Pluralize(n int64) string { if n > 1 { return "s" } diff --git a/internal/utils/strutils/format_test.go b/internal/utils/strutils/format_test.go new file mode 100644 index 0000000..f782e05 --- /dev/null +++ b/internal/utils/strutils/format_test.go @@ -0,0 +1,205 @@ +package strutils_test + +import ( + "testing" + "time" + + . "github.com/yusing/go-proxy/internal/utils/strutils" + . "github.com/yusing/go-proxy/internal/utils/testing" +) + +func TestFormatTime(t *testing.T) { + now := Must(time.Parse(time.RFC3339, "2021-06-15T12:30:30Z")) + + tests := []struct { + name string + time time.Time + expected string + expectedLength int + }{ + { + name: "now", + time: now.Add(100 * time.Millisecond), + expected: "now", + }, + { + name: "just now (past within 3 seconds)", + time: now.Add(-1 * time.Second), + expected: "just now", + }, + { + name: "seconds ago", + time: now.Add(-10 * time.Second), + expected: "10 seconds ago", + }, + { + name: "in seconds", + time: now.Add(10 * time.Second), + expected: "in 10 seconds", + }, + { + name: "minutes ago", + time: now.Add(-10 * time.Minute), + expected: "10 minutes ago", + }, + { + name: "in minutes", + time: now.Add(10 * time.Minute), + expected: "in 10 minutes", + }, + { + name: "hours ago", + time: now.Add(-10 * time.Hour), + expected: "10 hours ago", + }, + { + name: "in hours", + time: now.Add(10 * time.Hour), + expected: "in 10 hours", + }, + { + name: "different day", + time: now.Add(-25 * time.Hour), + expectedLength: len("01-01 15:04:05"), + }, + { + name: "same year but different month", + time: now.Add(-30 * 24 * time.Hour), + expectedLength: len("01-01 15:04:05"), + }, + { + name: "different year", + time: time.Date(now.Year()-1, 1, 1, 10, 20, 30, 0, now.Location()), + expected: time.Date(now.Year()-1, 1, 1, 10, 20, 30, 0, now.Location()).Format("2006-01-02 15:04:05"), + }, + { + name: "zero time", + time: time.Time{}, + expected: "never", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := FormatTimeWithReference(tt.time, now) + + if tt.expectedLength > 0 { + ExpectEqual(t, len(result), tt.expectedLength, result) + } else { + ExpectEqual(t, result, tt.expected) + } + }) + } +} + +func TestFormatDuration(t *testing.T) { + tests := []struct { + name string + duration time.Duration + expected string + }{ + { + name: "zero duration", + duration: 0, + expected: "0 Seconds", + }, + { + name: "seconds only", + duration: 45 * time.Second, + expected: "45 seconds", + }, + { + name: "one second", + duration: 1 * time.Second, + expected: "1 second", + }, + { + name: "minutes only", + duration: 5 * time.Minute, + expected: "5 minutes", + }, + { + name: "one minute", + duration: 1 * time.Minute, + expected: "1 minute", + }, + { + name: "hours only", + duration: 3 * time.Hour, + expected: "3 hours", + }, + { + name: "one hour", + duration: 1 * time.Hour, + expected: "1 hour", + }, + { + name: "days only", + duration: 2 * 24 * time.Hour, + expected: "2 days", + }, + { + name: "one day", + duration: 24 * time.Hour, + expected: "1 day", + }, + { + name: "complex duration", + duration: 2*24*time.Hour + 3*time.Hour + 45*time.Minute + 15*time.Second, + expected: "2 days, 3 hours and 45 minutes", + }, + { + name: "hours and minutes", + duration: 2*time.Hour + 30*time.Minute, + expected: "2 hours and 30 minutes", + }, + { + name: "days and hours", + duration: 1*24*time.Hour + 12*time.Hour, + expected: "1 day and 12 hours", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := FormatDuration(tt.duration) + ExpectEqual(t, result, tt.expected) + }) + } +} + +func TestFormatLastSeen(t *testing.T) { + now := time.Now() + + tests := []struct { + name string + time time.Time + expected string + }{ + { + name: "zero time", + time: time.Time{}, + expected: "never", + }, + { + name: "non-zero time", + time: now.Add(-10 * time.Minute), + // The actual result will be handled by FormatTime, which is tested separately + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := FormatLastSeen(tt.time) + + if tt.name == "zero time" { + ExpectEqual(t, result, tt.expected) + } else { + // Just make sure it's not "never", the actual formatting is tested in TestFormatTime + if result == "never" { + t.Errorf("Expected non-zero time to not return 'never', got %s", result) + } + } + }) + } +} diff --git a/internal/utils/strutils/parser.go b/internal/utils/strutils/parser.go index fbf72ce..e66e12b 100644 --- a/internal/utils/strutils/parser.go +++ b/internal/utils/strutils/parser.go @@ -2,6 +2,7 @@ package strutils import ( "reflect" + "strconv" ) type Parser interface { @@ -24,3 +25,11 @@ func MustParse[T Parser](from string) T { } return t } + +func ParseBool(from string) bool { + b, err := strconv.ParseBool(from) + if err != nil { + return false + } + return b +} diff --git a/internal/utils/testing/testing.go b/internal/utils/testing/testing.go index 11c6d25..7187e15 100644 --- a/internal/utils/testing/testing.go +++ b/internal/utils/testing/testing.go @@ -21,50 +21,55 @@ func Must[Result any](r Result, err error) Result { return r } -func ExpectNoError(t *testing.T, err error) { +func ExpectNoError(t *testing.T, err error, msgAndArgs ...any) { t.Helper() - require.NoError(t, err) + require.NoError(t, err, msgAndArgs...) } -func ExpectHasError(t *testing.T, err error) { +func ExpectHasError(t *testing.T, err error, msgAndArgs ...any) { t.Helper() - require.Error(t, err) + require.Error(t, err, msgAndArgs...) } -func ExpectError(t *testing.T, expected error, err error) { +func ExpectError(t *testing.T, expected error, err error, msgAndArgs ...any) { t.Helper() - require.ErrorIs(t, err, expected) + require.ErrorIs(t, err, expected, msgAndArgs...) } -func ExpectErrorT[T error](t *testing.T, err error) { +func ExpectErrorT[T error](t *testing.T, err error, msgAndArgs ...any) { t.Helper() var errAs T - require.ErrorAs(t, err, &errAs) + require.ErrorAs(t, err, &errAs, msgAndArgs...) } -func ExpectEqual[T any](t *testing.T, got T, want T) { +func ExpectEqual[T any](t *testing.T, got T, want T, msgAndArgs ...any) { t.Helper() - require.EqualValues(t, got, want) + require.Equal(t, want, got, msgAndArgs...) } -func ExpectContains[T any](t *testing.T, got T, wants []T) { +func ExpectEqualValues(t *testing.T, got any, want any, msgAndArgs ...any) { t.Helper() - require.Contains(t, wants, got) + require.EqualValues(t, want, got, msgAndArgs...) } -func ExpectTrue(t *testing.T, got bool) { +func ExpectContains[T any](t *testing.T, got T, wants []T, msgAndArgs ...any) { t.Helper() - require.True(t, got) + require.Contains(t, wants, got, msgAndArgs...) } -func ExpectFalse(t *testing.T, got bool) { +func ExpectTrue(t *testing.T, got bool, msgAndArgs ...any) { t.Helper() - require.False(t, got) + require.True(t, got, msgAndArgs...) } -func ExpectType[T any](t *testing.T, got any) (_ T) { +func ExpectFalse(t *testing.T, got bool, msgAndArgs ...any) { + t.Helper() + require.False(t, got, msgAndArgs...) +} + +func ExpectType[T any](t *testing.T, got any, msgAndArgs ...any) (_ T) { t.Helper() _, ok := got.(T) - require.True(t, ok) + require.True(t, ok, msgAndArgs...) return got.(T) } diff --git a/internal/watcher/config_file_watcher.go b/internal/watcher/config_file_watcher.go index fc0ccfb..2147cc8 100644 --- a/internal/watcher/config_file_watcher.go +++ b/internal/watcher/config_file_watcher.go @@ -19,7 +19,7 @@ func NewConfigFileWatcher(filename string) Watcher { if configDirWatcher == nil { t := task.RootTask("config_dir_watcher", false) - configDirWatcher = NewDirectoryWatcher(t, common.ConfigBasePath) + configDirWatcher = NewDirectoryWatcher(t, common.ConfigDir) } return configDirWatcher.Add(filename) } diff --git a/internal/watcher/events/events.go b/internal/watcher/events/events.go index 6b851d0..f20457a 100644 --- a/internal/watcher/events/events.go +++ b/internal/watcher/events/events.go @@ -36,8 +36,8 @@ const ( ActionForceReload - actionContainerWakeMask = ActionContainerCreate | ActionContainerStart | ActionContainerUnpause - actionContainerSleepMask = ActionContainerKill | ActionContainerStop | ActionContainerPause | ActionContainerDie + actionContainerStartMask = ActionContainerCreate | ActionContainerStart | ActionContainerUnpause + actionContainerStopMask = ActionContainerKill | ActionContainerStop | ActionContainerDie ) const ( @@ -83,10 +83,14 @@ func (a Action) String() string { return actionNameMap[a] } -func (a Action) IsContainerWake() bool { - return a&actionContainerWakeMask != 0 +func (a Action) IsContainerStart() bool { + return a&actionContainerStartMask != 0 } -func (a Action) IsContainerSleep() bool { - return a&actionContainerSleepMask != 0 +func (a Action) IsContainerStop() bool { + return a&actionContainerStopMask != 0 +} + +func (a Action) IsContainerPause() bool { + return a == ActionContainerPause } diff --git a/internal/watcher/health/monitor/json.go b/internal/watcher/health/json.go similarity index 72% rename from internal/watcher/health/monitor/json.go rename to internal/watcher/health/json.go index 1ebdca5..9059f5c 100644 --- a/internal/watcher/health/monitor/json.go +++ b/internal/watcher/health/json.go @@ -1,34 +1,35 @@ -package monitor +package health import ( - "encoding/json" + "net/url" "strconv" "time" - net "github.com/yusing/go-proxy/internal/net/types" "github.com/yusing/go-proxy/internal/utils/strutils" - "github.com/yusing/go-proxy/internal/watcher/health" ) type JSONRepresentation struct { Name string - Config *health.HealthCheckConfig - Status health.Status + Config *HealthCheckConfig + Status Status Started time.Time Uptime time.Duration Latency time.Duration LastSeen time.Time Detail string - URL *net.URL + URL *url.URL Extra map[string]any } -func (jsonRepr *JSONRepresentation) MarshalJSON() ([]byte, error) { - url := jsonRepr.URL.String() +func (jsonRepr *JSONRepresentation) MarshalMap() map[string]any { + var url string + if jsonRepr.URL != nil { + url = jsonRepr.URL.String() + } if url == "http://:0" { url = "" } - return json.Marshal(map[string]any{ + return map[string]any{ "name": jsonRepr.Name, "config": jsonRepr.Config, "started": jsonRepr.Started.Unix(), @@ -43,5 +44,5 @@ func (jsonRepr *JSONRepresentation) MarshalJSON() ([]byte, error) { "detail": jsonRepr.Detail, "url": url, "extra": jsonRepr.Extra, - }) + } } diff --git a/internal/watcher/health/monitor/agent_proxied.go b/internal/watcher/health/monitor/agent_proxied.go index 82ca0aa..67db85c 100644 --- a/internal/watcher/health/monitor/agent_proxied.go +++ b/internal/watcher/health/monitor/agent_proxied.go @@ -7,7 +7,6 @@ import ( "net/url" agentPkg "github.com/yusing/go-proxy/agent/pkg/agent" - "github.com/yusing/go-proxy/internal/net/types" "github.com/yusing/go-proxy/internal/watcher/health" ) @@ -24,7 +23,7 @@ type ( } ) -func AgentTargetFromURL(url *types.URL) *AgentCheckHealthTarget { +func AgentTargetFromURL(url *url.URL) *AgentCheckHealthTarget { return &AgentCheckHealthTarget{ Scheme: url.Scheme, Host: url.Host, @@ -40,12 +39,12 @@ func (target *AgentCheckHealthTarget) buildQuery() string { return query.Encode() } -func (target *AgentCheckHealthTarget) displayURL() *types.URL { - return types.NewURL(&url.URL{ +func (target *AgentCheckHealthTarget) displayURL() *url.URL { + return &url.URL{ Scheme: target.Scheme, Host: target.Host, Path: target.Path, - }) + } } func NewAgentProxiedMonitor(agent *agentPkg.AgentConfig, config *health.HealthCheckConfig, target *AgentCheckHealthTarget) *AgentProxiedMonitor { diff --git a/internal/watcher/health/monitor/docker.go b/internal/watcher/health/monitor/docker.go index af40866..5bf71e3 100644 --- a/internal/watcher/health/monitor/docker.go +++ b/internal/watcher/health/monitor/docker.go @@ -1,9 +1,9 @@ package monitor import ( + "github.com/docker/docker/api/types/container" "github.com/yusing/go-proxy/internal/docker" - dockerTypes "github.com/docker/docker/api/types" "github.com/yusing/go-proxy/internal/watcher/health" ) @@ -48,7 +48,7 @@ func (mon *DockerHealthMonitor) CheckHealth() (result *health.HealthCheckResult, return mon.fallback.CheckHealth() } result = new(health.HealthCheckResult) - result.Healthy = cont.State.Health.Status == dockerTypes.Healthy + result.Healthy = cont.State.Health.Status == container.Healthy if len(cont.State.Health.Log) > 0 { lastLog := cont.State.Health.Log[len(cont.State.Health.Log)-1] result.Detail = lastLog.Output diff --git a/internal/watcher/health/monitor/http.go b/internal/watcher/health/monitor/http.go index 9dec9f6..1aa060f 100644 --- a/internal/watcher/health/monitor/http.go +++ b/internal/watcher/health/monitor/http.go @@ -4,9 +4,9 @@ import ( "crypto/tls" "errors" "net/http" + "net/url" "time" - "github.com/yusing/go-proxy/internal/net/types" "github.com/yusing/go-proxy/internal/watcher/health" "github.com/yusing/go-proxy/pkg" ) @@ -26,7 +26,7 @@ var pinger = &http.Client{ }, } -func NewHTTPHealthMonitor(url *types.URL, config *health.HealthCheckConfig) *HTTPHealthMonitor { +func NewHTTPHealthMonitor(url *url.URL, config *health.HealthCheckConfig) *HTTPHealthMonitor { mon := new(HTTPHealthMonitor) mon.monitor = newMonitor(url, config, mon.CheckHealth) if config.UseGet { @@ -53,7 +53,7 @@ func (mon *HTTPHealthMonitor) CheckHealth() (result *health.HealthCheckResult, e } req.Close = true req.Header.Set("Connection", "close") - req.Header.Set("User-Agent", "GoDoxy/"+pkg.GetVersion()) + req.Header.Set("User-Agent", "GoDoxy/"+pkg.GetVersion().String()) start := time.Now() resp, respErr := pinger.Do(req) diff --git a/internal/watcher/health/monitor/monitor.go b/internal/watcher/health/monitor/monitor.go index 05197f5..1dea243 100644 --- a/internal/watcher/health/monitor/monitor.go +++ b/internal/watcher/health/monitor/monitor.go @@ -4,12 +4,12 @@ import ( "context" "errors" "fmt" + "net/url" "time" "github.com/yusing/go-proxy/internal/docker" "github.com/yusing/go-proxy/internal/gperr" "github.com/yusing/go-proxy/internal/logging" - "github.com/yusing/go-proxy/internal/net/types" "github.com/yusing/go-proxy/internal/notif" route "github.com/yusing/go-proxy/internal/route/types" "github.com/yusing/go-proxy/internal/task" @@ -23,7 +23,7 @@ type ( monitor struct { service string config *health.HealthCheckConfig - url atomic.Value[*types.URL] + url atomic.Value[*url.URL] status atomic.Value[health.Status] lastResult atomic.Value[*health.HealthCheckResult] @@ -52,7 +52,7 @@ func NewMonitor(r route.Route) health.HealthMonCheck { } } if r.IsDocker() { - cont := r.DockerContainer() + cont := r.ContainerInfo() client, err := docker.NewClient(cont.DockerHost) if err != nil { return mon @@ -63,7 +63,7 @@ func NewMonitor(r route.Route) health.HealthMonCheck { return mon } -func newMonitor(url *types.URL, config *health.HealthCheckConfig, healthCheckFunc HealthCheckFunc) *monitor { +func newMonitor(url *url.URL, config *health.HealthCheckConfig, healthCheckFunc HealthCheckFunc) *monitor { mon := &monitor{ config: config, checkHealth: healthCheckFunc, @@ -135,12 +135,12 @@ func (mon *monitor) Finish(reason any) { } // UpdateURL implements HealthChecker. -func (mon *monitor) UpdateURL(url *types.URL) { +func (mon *monitor) UpdateURL(url *url.URL) { mon.url.Store(url) } // URL implements HealthChecker. -func (mon *monitor) URL() *types.URL { +func (mon *monitor) URL() *url.URL { return mon.url.Load() } @@ -179,8 +179,8 @@ func (mon *monitor) String() string { return mon.Name() } -// MarshalJSON implements json.Marshaler of HealthMonitor. -func (mon *monitor) MarshalJSON() ([]byte, error) { +// MarshalMap implements health.HealthMonitor. +func (mon *monitor) MarshalMap() map[string]any { res := mon.lastResult.Load() if res == nil { res = &health.HealthCheckResult{ @@ -188,7 +188,7 @@ func (mon *monitor) MarshalJSON() ([]byte, error) { } } - return (&JSONRepresentation{ + return (&health.JSONRepresentation{ Name: mon.service, Config: mon.config, Status: mon.status.Load(), @@ -198,7 +198,7 @@ func (mon *monitor) MarshalJSON() ([]byte, error) { LastSeen: GetLastSeen(mon.service), Detail: res.Detail, URL: mon.url.Load(), - }).MarshalJSON() + }).MarshalMap() } func (mon *monitor) checkUpdateHealth() error { diff --git a/internal/watcher/health/monitor/raw.go b/internal/watcher/health/monitor/raw.go index e6358fd..eef6004 100644 --- a/internal/watcher/health/monitor/raw.go +++ b/internal/watcher/health/monitor/raw.go @@ -2,9 +2,9 @@ package monitor import ( "net" + "net/url" "time" - "github.com/yusing/go-proxy/internal/net/types" "github.com/yusing/go-proxy/internal/watcher/health" ) @@ -15,7 +15,7 @@ type ( } ) -func NewRawHealthMonitor(url *types.URL, config *health.HealthCheckConfig) *RawHealthMonitor { +func NewRawHealthMonitor(url *url.URL, config *health.HealthCheckConfig) *RawHealthMonitor { mon := new(RawHealthMonitor) mon.monitor = newMonitor(url, config, mon.CheckHealth) mon.dialer = &net.Dialer{ diff --git a/internal/watcher/health/types.go b/internal/watcher/health/types.go index de8a07f..8f83143 100644 --- a/internal/watcher/health/types.go +++ b/internal/watcher/health/types.go @@ -1,12 +1,12 @@ package health import ( - "encoding/json" "fmt" + "net/url" "time" - "github.com/yusing/go-proxy/internal/net/types" "github.com/yusing/go-proxy/internal/task" + "github.com/yusing/go-proxy/internal/utils" ) type ( @@ -24,15 +24,15 @@ type ( task.TaskStarter task.TaskFinisher fmt.Stringer - json.Marshaler + utils.MapMarshaler WithHealthInfo Name() string } HealthChecker interface { CheckHealth() (result *HealthCheckResult, err error) - URL() *types.URL + URL() *url.URL Config() *HealthCheckConfig - UpdateURL(url *types.URL) + UpdateURL(url *url.URL) } HealthMonCheck interface { HealthMonitor diff --git a/migrations/001_move_json_data.go b/migrations/001_move_json_data.go new file mode 100644 index 0000000..1233eec --- /dev/null +++ b/migrations/001_move_json_data.go @@ -0,0 +1,22 @@ +package migrations + +import ( + "errors" + "path/filepath" + + "github.com/yusing/go-proxy/internal/common" +) + +var ( + homepageJSONConfigPathOld = filepath.Join(common.ConfigDir, ".homepage.json") + iconListCachePathOld = filepath.Join(common.ConfigDir, ".icon_list_cache.json") + iconCachePathOld = filepath.Join(common.ConfigDir, ".icon_cache.json") +) + +func m001_move_json_data() error { + return errors.Join( + mv(homepageJSONConfigPathOld, common.HomepageJSONConfigPath), + mv(iconListCachePathOld, common.IconListCachePath), + mv(iconCachePathOld, common.IconCachePath), + ) +} diff --git a/migrations/migrate.go b/migrations/migrate.go new file mode 100644 index 0000000..5227d61 --- /dev/null +++ b/migrations/migrate.go @@ -0,0 +1,30 @@ +package migrations + +import ( + "github.com/yusing/go-proxy/internal/gperr" + "github.com/yusing/go-proxy/internal/logging" + "github.com/yusing/go-proxy/pkg" +) + +func RunMigrations() error { + if currentVersion.IsEqual(lastVersion) { + return nil + } + logging.Info().Msg("running migrations...") + errs := gperr.NewBuilder("migration error") + for _, m := range migrations { + if !currentVersion.IsOlderThan(m.Since) { + continue + } + if err := m.Run(); err != nil { + errs.Add(gperr.PrependSubject(m.Name, err)) + } + } + return errs.Error() +} + +var currentVersion = pkg.GetVersion() +var lastVersion = pkg.GetLastVersion() +var migrations = []migration{ + {"move json data", m001_move_json_data, pkg.Ver(0, 11, 0)}, +} diff --git a/migrations/migration.go b/migrations/migration.go new file mode 100644 index 0000000..f475b1d --- /dev/null +++ b/migrations/migration.go @@ -0,0 +1,9 @@ +package migrations + +import "github.com/yusing/go-proxy/pkg" + +type migration struct { + Name string + Run func() error + Since pkg.Version +} diff --git a/migrations/utils.go b/migrations/utils.go new file mode 100644 index 0000000..2b0ae20 --- /dev/null +++ b/migrations/utils.go @@ -0,0 +1,20 @@ +package migrations + +import ( + "os" + "path/filepath" +) + +func mv(old, new string) error { + _, err := os.Stat(old) + if err != nil && os.IsNotExist(err) { + return nil + } + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(new), 0o755); err != nil { + return err + } + return os.Rename(old, new) +} diff --git a/pkg/version.go b/pkg/version.go index e4ef63f..a80be0b 100644 --- a/pkg/version.go +++ b/pkg/version.go @@ -1,7 +1,116 @@ package pkg -var version = "unset" +import ( + "fmt" + "os" + "path/filepath" + "strconv" + "strings" -func GetVersion() string { - return version + "github.com/yusing/go-proxy/internal/common" + "github.com/yusing/go-proxy/internal/logging" +) + +func GetVersion() Version { + return currentVersion +} + +func GetLastVersion() Version { + return lastVersion +} + +func init() { + currentVersion = parseVersion(version) + + // ignore errors + versionFile := filepath.Join(common.DataDir, "version") + var lastVersionStr string + f, err := os.OpenFile(versionFile, os.O_RDWR|os.O_CREATE, 0o644) + if err == nil { + _, err = fmt.Fscanf(f, "%s", &lastVersionStr) + lastVersion = parseVersion(lastVersionStr) + } + if err != nil && !os.IsNotExist(err) { + logging.Warn().Err(err).Msg("failed to read version file") + return + } + if err := f.Truncate(0); err != nil { + logging.Warn().Err(err).Msg("failed to truncate version file") + return + } + _, err = f.WriteString(version) + if err != nil { + logging.Warn().Err(err).Msg("failed to save version file") + return + } +} + +type Version struct{ Major, Minor, Patch int } + +func Ver(major, minor, patch int) Version { + return Version{major, minor, patch} +} + +func (v Version) String() string { + return fmt.Sprintf("%d.%d.%d", v.Major, v.Minor, v.Patch) +} + +func (v Version) MarshalText() ([]byte, error) { + return []byte(v.String()), nil +} + +func (v Version) IsNewerThan(other Version) bool { + if v.Major != other.Major { + return v.Major > other.Major + } + if v.Minor != other.Minor { + return v.Minor > other.Minor + } + return v.Patch > other.Patch +} + +func (v Version) IsOlderThan(other Version) bool { + if v.Major != other.Major { + return v.Major < other.Major + } + if v.Minor != other.Minor { + return v.Minor < other.Minor + } + return v.Patch < other.Patch +} + +func (v Version) IsEqual(other Version) bool { + return v.Major == other.Major && v.Minor == other.Minor && v.Patch == other.Patch +} + +var ( + version = "unset" + currentVersion Version + lastVersion Version +) + +func parseVersion(v string) (ver Version) { + if v == "" { + return + } + + v = strings.Split(v, "-")[0] + v = strings.TrimPrefix(v, "v") + parts := strings.Split(v, ".") + if len(parts) != 3 { + return + } + major, err := strconv.Atoi(parts[0]) + if err != nil { + return + } + minor, err := strconv.Atoi(parts[1]) + if err != nil { + return + } + patch, err := strconv.Atoi(parts[2]) + if err != nil { + return + } + return Ver(major, minor, patch) } diff --git a/schemas/Makefile b/schemas/Makefile new file mode 100644 index 0000000..9ed26b3 --- /dev/null +++ b/schemas/Makefile @@ -0,0 +1,33 @@ +# To generate schema +# comment out this part from typescript-json-schema.js#L884 +# +# if (indexType.flags !== ts.TypeFlags.Number && !isIndexedObject) { +# throw new Error("Not supported: IndexSignatureDeclaration with index symbol other than a number or a string"); +# } + +gen-schema-single: + bun -bun typescript-json-schema --noExtraProps --required --skipLibCheck --tsNodeRegister=true -o "${OUT}" "${IN}" ${CLASS} + # minify + python3 -c "import json; f=open('${OUT}', 'r'); j=json.load(f); f.close(); f=open('${OUT}', 'w'); json.dump(j, f, separators=(',', ':'));" + +gen-schema: + bun -bun tsc + sed -i 's#"type": "module"#"type": "commonjs"#' package.json + make IN=config/config.ts \ + CLASS=Config \ + OUT=config.schema.json \ + gen-schema-single + make IN=providers/routes.ts \ + CLASS=Routes \ + OUT=routes.schema.json \ + gen-schema-single + make IN=middlewares/middleware_compose.ts \ + CLASS=MiddlewareCompose \ + OUT=middleware_compose.schema.json \ + gen-schema-single + make IN=docker.ts \ + CLASS=DockerRoutes \ + OUT=docker_routes.schema.json \ + gen-schema-single + sed -i 's#"type": "commonjs"#"type": "module"#' package.json + bun format:write \ No newline at end of file diff --git a/schemas/bun.lock b/schemas/bun.lock index e12e73c..d4c0376 100644 --- a/schemas/bun.lock +++ b/schemas/bun.lock @@ -2,10 +2,10 @@ "lockfileVersion": 1, "workspaces": { "": { - "name": "godoxy-types", + "name": "godoxy-schemas", "devDependencies": { - "prettier": "^3.4.2", - "typescript": "^5.7.3", + "prettier": "^3.5.3", + "typescript": "^5.8.3", "typescript-json-schema": "^0.65.1", }, }, @@ -29,9 +29,9 @@ "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], - "@types/node": ["@types/node@18.19.74", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-HMwEkkifei3L605gFdV+/UwtpxP6JSzM+xFk2Ia6DNFSwSVBRh9qp5Tgf4lNFOMfPVuU0WnkcWpXZpgn5ufO4A=="], + "@types/node": ["@types/node@18.19.86", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-fifKayi175wLyKyc5qUfyENhQ1dCNI1UNjp653d8kuYcPQN5JhX3dGuP/XmvPTg/xRBn1VTLpbmi+H/Mr7tLfQ=="], - "acorn": ["acorn@8.14.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA=="], + "acorn": ["acorn@8.14.1", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg=="], "acorn-walk": ["acorn-walk@8.3.4", "", { "dependencies": { "acorn": "^8.11.0" } }, "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g=="], @@ -83,7 +83,7 @@ "path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="], - "prettier": ["prettier@3.4.2", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-e9MewbtFo+Fevyuxn/4rrcDAaq0IYxPGLvObpQjiZBMAzB9IGmzlnG9RZy3FFas+eBMu2vA0CszMeduow5dIuQ=="], + "prettier": ["prettier@3.5.3", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw=="], "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], @@ -95,7 +95,7 @@ "ts-node": ["ts-node@10.9.2", "", { "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", "@tsconfig/node12": "^1.0.7", "@tsconfig/node14": "^1.0.0", "@tsconfig/node16": "^1.0.2", "acorn": "^8.4.1", "acorn-walk": "^8.1.1", "arg": "^4.1.0", "create-require": "^1.1.0", "diff": "^4.0.1", "make-error": "^1.1.1", "v8-compile-cache-lib": "^3.0.1", "yn": "3.1.1" }, "peerDependencies": { "@swc/core": ">=1.2.50", "@swc/wasm": ">=1.2.50", "@types/node": "*", "typescript": ">=2.7" }, "optionalPeers": ["@swc/core", "@swc/wasm"], "bin": { "ts-node": "dist/bin.js", "ts-script": "dist/bin-script-deprecated.js", "ts-node-cwd": "dist/bin-cwd.js", "ts-node-esm": "dist/bin-esm.js", "ts-node-script": "dist/bin-script.js", "ts-node-transpile-only": "dist/bin-transpile.js" } }, "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ=="], - "typescript": ["typescript@5.7.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw=="], + "typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], "typescript-json-schema": ["typescript-json-schema@0.65.1", "", { "dependencies": { "@types/json-schema": "^7.0.9", "@types/node": "^18.11.9", "glob": "^7.1.7", "path-equal": "^1.2.5", "safe-stable-stringify": "^2.2.0", "ts-node": "^10.9.1", "typescript": "~5.5.0", "yargs": "^17.1.1" }, "bin": { "typescript-json-schema": "bin/typescript-json-schema" } }, "sha512-tuGH7ff2jPaUYi6as3lHyHcKpSmXIqN7/mu50x3HlYn0EHzLpmt3nplZ7EuhUkO0eqDRc9GqWNkfjgBPIS9kxg=="], diff --git a/schemas/config/access_log.d.ts b/schemas/config/access_log.d.ts index 7d3c304..7f57c49 100644 --- a/schemas/config/access_log.d.ts +++ b/schemas/config/access_log.d.ts @@ -1,49 +1,57 @@ import { CIDR, HTTPHeader, HTTPMethod, StatusCodeRange, URI } from "../types"; -export declare const ACCESS_LOG_FORMATS: readonly ["combined", "common", "json"]; +export declare const ACCESS_LOG_FORMATS: readonly [ + "combined", + "common", + "json", +]; export type AccessLogFormat = (typeof ACCESS_LOG_FORMATS)[number]; export type AccessLogConfig = { - /** - * The size of the buffer. - * - * @minimum 0 - * @default 65536 - * @TJS-type integer - */ - buffer_size?: number; - /** The format of the access log. - * - * @default "combined" - */ - format?: AccessLogFormat; - path: URI; - filters?: AccessLogFilters; - fields?: AccessLogFields; + /** + * The size of the buffer. + * + * @minimum 0 + * @default 65536 + * @TJS-type integer + */ + buffer_size?: number; + /** The format of the access log. + * + * @default "combined" + */ + format?: AccessLogFormat; + path: URI; + filters?: AccessLogFilters; + fields?: AccessLogFields; }; export type AccessLogFilter = { - /** Whether the filter is negative. - * - * @default false - */ - negative?: boolean; - values: T[]; + /** Whether the filter is negative. + * + * @default false + */ + negative?: boolean; + values: T[]; }; export type AccessLogFilters = { - status_code?: AccessLogFilter; - method?: AccessLogFilter; - host?: AccessLogFilter; - headers?: AccessLogFilter; - cidr?: AccessLogFilter; + status_code?: AccessLogFilter; + method?: AccessLogFilter; + host?: AccessLogFilter; + headers?: AccessLogFilter; + cidr?: AccessLogFilter; }; -export declare const ACCESS_LOG_FIELD_MODES: readonly ["keep", "drop", "redact"]; +export declare const ACCESS_LOG_FIELD_MODES: readonly [ + "keep", + "drop", + "redact", +]; export type AccessLogFieldMode = (typeof ACCESS_LOG_FIELD_MODES)[number]; export type AccessLogField = { - default?: AccessLogFieldMode; - config: { - [key: string]: AccessLogFieldMode; - }; + default?: AccessLogFieldMode; + config: { + [key: string]: AccessLogFieldMode; + }; }; export type AccessLogFields = { - header?: AccessLogField; - query?: AccessLogField; - cookie?: AccessLogField; + header?: AccessLogField; + query?: AccessLogField; + cookie?: AccessLogField; }; diff --git a/schemas/config/autocert.d.ts b/schemas/config/autocert.d.ts index ac7630e..702f91e 100644 --- a/schemas/config/autocert.d.ts +++ b/schemas/config/autocert.d.ts @@ -1,66 +1,88 @@ import { DomainOrWildcard, Email } from "../types"; -export declare const AUTOCERT_PROVIDERS: readonly ["local", "cloudflare", "clouddns", "duckdns", "ovh", "porkbun"]; +export declare const AUTOCERT_PROVIDERS: readonly [ + "local", + "cloudflare", + "clouddns", + "duckdns", + "ovh", + "porkbun", +]; export type AutocertProvider = (typeof AUTOCERT_PROVIDERS)[number]; -export type AutocertConfig = LocalOptions | CloudflareOptions | CloudDNSOptions | DuckDNSOptions | OVHOptionsWithAppKey | OVHOptionsWithOAuth2Config | PorkbunOptions; +export type AutocertConfig = + | LocalOptions + | CloudflareOptions + | CloudDNSOptions + | DuckDNSOptions + | OVHOptionsWithAppKey + | OVHOptionsWithOAuth2Config + | PorkbunOptions; export interface AutocertConfigBase { - email: Email; - domains: DomainOrWildcard[]; - cert_path?: string; - key_path?: string; + email: Email; + domains: DomainOrWildcard[]; + cert_path?: string; + key_path?: string; } export interface LocalOptions { - provider: "local"; - cert_path?: string; - key_path?: string; - options?: {} | null; + provider: "local"; + cert_path?: string; + key_path?: string; + options?: {} | null; } export interface CloudflareOptions extends AutocertConfigBase { - provider: "cloudflare"; - options: { - auth_token: string; - }; + provider: "cloudflare"; + options: { + auth_token: string; + }; } export interface CloudDNSOptions extends AutocertConfigBase { - provider: "clouddns"; - options: { - client_id: string; - email: Email; - password: string; - }; + provider: "clouddns"; + options: { + client_id: string; + email: Email; + password: string; + }; } export interface DuckDNSOptions extends AutocertConfigBase { - provider: "duckdns"; - options: { - token: string; - }; + provider: "duckdns"; + options: { + token: string; + }; } export interface PorkbunOptions extends AutocertConfigBase { - provider: "porkbun"; - options: { - api_key: string; - secret_api_key: string; - }; + provider: "porkbun"; + options: { + api_key: string; + secret_api_key: string; + }; } -export declare const OVH_ENDPOINTS: readonly ["ovh-eu", "ovh-ca", "ovh-us", "kimsufi-eu", "kimsufi-ca", "soyoustart-eu", "soyoustart-ca"]; +export declare const OVH_ENDPOINTS: readonly [ + "ovh-eu", + "ovh-ca", + "ovh-us", + "kimsufi-eu", + "kimsufi-ca", + "soyoustart-eu", + "soyoustart-ca", +]; export type OVHEndpoint = (typeof OVH_ENDPOINTS)[number]; export interface OVHOptionsWithAppKey extends AutocertConfigBase { - provider: "ovh"; - options: { - application_secret: string; - consumer_key: string; - api_endpoint?: OVHEndpoint; - application_key: string; - }; + provider: "ovh"; + options: { + application_secret: string; + consumer_key: string; + api_endpoint?: OVHEndpoint; + application_key: string; + }; } export interface OVHOptionsWithOAuth2Config extends AutocertConfigBase { - provider: "ovh"; - options: { - application_secret: string; - consumer_key: string; - api_endpoint?: OVHEndpoint; - oauth2_config: { - client_id: string; - client_secret: string; - }; + provider: "ovh"; + options: { + application_secret: string; + consumer_key: string; + api_endpoint?: OVHEndpoint; + oauth2_config: { + client_id: string; + client_secret: string; }; + }; } diff --git a/schemas/config/config.d.ts b/schemas/config/config.d.ts index b753f41..e88116f 100644 --- a/schemas/config/config.d.ts +++ b/schemas/config/config.d.ts @@ -4,51 +4,58 @@ import { EntrypointConfig } from "./entrypoint"; import { HomepageConfig } from "./homepage"; import { Providers } from "./providers"; export type Config = { - /** Optional autocert configuration - * - * @examples require(".").autocertExamples - */ - autocert?: AutocertConfig; - entrypoint?: EntrypointConfig; - providers: Providers; - /** Optional list of domains to match - * - * @minItems 1 - * @examples require(".").matchDomainsExamples - */ - match_domains?: DomainName[]; - homepage?: HomepageConfig; - /** - * Optional timeout before shutdown - * @default 3 - * @minimum 1 - */ - timeout_shutdown?: number; + /** Optional autocert configuration + * + * @examples require(".").autocertExamples + */ + autocert?: AutocertConfig; + entrypoint?: EntrypointConfig; + providers: Providers; + /** Optional list of domains to match + * + * @minItems 1 + * @examples require(".").matchDomainsExamples + */ + match_domains?: DomainName[]; + homepage?: HomepageConfig; + /** + * Optional timeout before shutdown + * @default 3 + * @minimum 1 + */ + timeout_shutdown?: number; }; -export declare const autocertExamples: ({ - provider: string; - email?: undefined; - domains?: undefined; - options?: undefined; -} | { - provider: string; - email: string; - domains: string[]; - options: { +export declare const autocertExamples: ( + | { + provider: string; + email?: undefined; + domains?: undefined; + options?: undefined; + } + | { + provider: string; + email: string; + domains: string[]; + options: { auth_token: string; client_id?: undefined; email?: undefined; password?: undefined; - }; -} | { - provider: string; - email: string; - domains: string[]; - options: { + }; + } + | { + provider: string; + email: string; + domains: string[]; + options: { client_id: string; email: string; password: string; auth_token?: undefined; - }; -})[]; -export declare const matchDomainsExamples: readonly ["example.com", "*.example.com"]; + }; + } +)[]; +export declare const matchDomainsExamples: readonly [ + "example.com", + "*.example.com", +]; diff --git a/schemas/config/entrypoint.d.ts b/schemas/config/entrypoint.d.ts index d436665..7584dd6 100644 --- a/schemas/config/entrypoint.d.ts +++ b/schemas/config/entrypoint.d.ts @@ -1,39 +1,49 @@ import { MiddlewareCompose } from "../middlewares/middleware_compose"; import { AccessLogConfig } from "./access_log"; export type EntrypointConfig = { - /** Entrypoint middleware configuration - * - * @examples require(".").middlewaresExamples - */ - middlewares?: MiddlewareCompose; - /** Entrypoint access log configuration - * - * @examples require(".").accessLogExamples - */ - access_log?: AccessLogConfig; + /** Entrypoint middleware configuration + * + * @examples require(".").middlewaresExamples + */ + middlewares?: MiddlewareCompose; + /** Entrypoint access log configuration + * + * @examples require(".").accessLogExamples + */ + access_log?: AccessLogConfig; }; -export declare const accessLogExamples: readonly [{ +export declare const accessLogExamples: readonly [ + { readonly path: "/var/log/access.log"; readonly format: "combined"; readonly filters: { - readonly status_codes: { - readonly values: readonly ["200-299"]; - }; + readonly status_codes: { + readonly values: readonly ["200-299"]; + }; }; readonly fields: { - readonly headers: { - readonly default: "keep"; - readonly config: { - readonly foo: "redact"; - }; + readonly headers: { + readonly default: "keep"; + readonly config: { + readonly foo: "redact"; }; + }; }; -}]; -export declare const middlewaresExamples: readonly [{ + }, +]; +export declare const middlewaresExamples: readonly [ + { readonly use: "RedirectHTTP"; -}, { + }, + { readonly use: "CIDRWhitelist"; - readonly allow: readonly ["127.0.0.1", "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"]; + readonly allow: readonly [ + "127.0.0.1", + "10.0.0.0/8", + "172.16.0.0/12", + "192.168.0.0/16", + ]; readonly status: 403; readonly message: "Forbidden"; -}]; + }, +]; diff --git a/schemas/config/homepage.d.ts b/schemas/config/homepage.d.ts index 0ac498d..31f783b 100644 --- a/schemas/config/homepage.d.ts +++ b/schemas/config/homepage.d.ts @@ -1,7 +1,7 @@ export type HomepageConfig = { - /** - * Use default app categories (uses docker image name) - * @default true - */ - use_default_categories: boolean; + /** + * Use default app categories (uses docker image name) + * @default true + */ + use_default_categories: boolean; }; diff --git a/schemas/config/notification.d.ts b/schemas/config/notification.d.ts index 0cf8610..8bf6552 100644 --- a/schemas/config/notification.d.ts +++ b/schemas/config/notification.d.ts @@ -1,60 +1,69 @@ import { URL } from "../types"; -export declare const NOTIFICATION_PROVIDERS: readonly ["webhook", "gotify", "ntfy"]; +export declare const NOTIFICATION_PROVIDERS: readonly [ + "webhook", + "gotify", + "ntfy", +]; export type NotificationProvider = (typeof NOTIFICATION_PROVIDERS)[number]; export type NotificationConfig = { - name: string; - url: URL; + name: string; + url: URL; }; export interface GotifyConfig extends NotificationConfig { - provider: "gotify"; - token: string; + provider: "gotify"; + token: string; } export declare const NTFY_MSG_STYLES: string[]; export type NtfyStyle = (typeof NTFY_MSG_STYLES)[number]; export interface NtfyConfig extends NotificationConfig { - provider: "ntfy"; - topic: string; - token?: string; - style?: NtfyStyle; + provider: "ntfy"; + topic: string; + token?: string; + style?: NtfyStyle; } export declare const WEBHOOK_TEMPLATES: readonly ["", "discord"]; export declare const WEBHOOK_METHODS: readonly ["POST", "GET", "PUT"]; -export declare const WEBHOOK_MIME_TYPES: readonly ["application/json", "application/x-www-form-urlencoded", "text/plain", "text/markdown"]; +export declare const WEBHOOK_MIME_TYPES: readonly [ + "application/json", + "application/x-www-form-urlencoded", + "text/plain", + "text/markdown", +]; export declare const WEBHOOK_COLOR_MODES: readonly ["hex", "dec"]; export type WebhookTemplate = (typeof WEBHOOK_TEMPLATES)[number]; export type WebhookMethod = (typeof WEBHOOK_METHODS)[number]; export type WebhookMimeType = (typeof WEBHOOK_MIME_TYPES)[number]; export type WebhookColorMode = (typeof WEBHOOK_COLOR_MODES)[number]; export interface WebhookConfig extends NotificationConfig { - provider: "webhook"; - /** - * Webhook template - * - * @default "discord" - */ - template?: WebhookTemplate; - token?: string; - /** - * Webhook message (usally JSON), - * required when template is not defined - */ - payload?: string; - /** - * Webhook method - * - * @default "POST" - */ - method?: WebhookMethod; - /** - * Webhook mime type - * - * @default "application/json" - */ - mime_type?: WebhookMimeType; - /** - * Webhook color mode - * - * @default "hex" - */ - color_mode?: WebhookColorMode; + provider: "webhook"; + /** + * Webhook template + * + * @default "discord" + */ + template?: WebhookTemplate; + token?: string; + /** + * Webhook message (usally JSON), + * required when template is not defined + */ + payload?: string; + /** + * Webhook method + * + * @default "POST" + */ + method?: WebhookMethod; + /** + * Webhook mime type + * + * @default "application/json" + */ + mime_type?: WebhookMimeType; + /** + * Webhook color mode + * + * @default "hex" + */ + color_mode?: WebhookColorMode; } diff --git a/schemas/config/providers.d.ts b/schemas/config/providers.d.ts index ad2816f..c91d083 100644 --- a/schemas/config/providers.d.ts +++ b/schemas/config/providers.d.ts @@ -1,51 +1,58 @@ import { URI, URL } from "../types"; import { GotifyConfig, NtfyConfig, WebhookConfig } from "./notification"; export type Providers = { - /** List of route definition files to include - * - * @minItems 1 - * @examples require(".").includeExamples - * @items.pattern ^[\w\d\-_]+\.(yaml|yml)$ - */ - include?: URI[]; - /** Name-value mapping of docker hosts to retrieve routes from - * - * @minProperties 1 - * @examples require(".").dockerExamples - */ - docker?: { - [name: string]: URL | "$DOCKER_HOST"; - }; - /** List of GoDoxy agents - * - * @minItems 1 - * @examples require(".").agentExamples - */ - agents?: `${string}:${number}`[]; - /** List of notification providers - * - * @minItems 1 - * @examples require(".").notificationExamples - */ - notification?: (WebhookConfig | GotifyConfig | NtfyConfig)[]; + /** List of route definition files to include + * + * @minItems 1 + * @examples require(".").includeExamples + * @items.pattern ^[\w\d\-_]+\.(yaml|yml)$ + */ + include?: URI[]; + /** Name-value mapping of docker hosts to retrieve routes from + * + * @minProperties 1 + * @examples require(".").dockerExamples + */ + docker?: { + [name: string]: URL | "$DOCKER_HOST"; + }; + /** List of GoDoxy agents + * + * @minItems 1 + * @examples require(".").agentExamples + */ + agents?: `${string}:${number}`[]; + /** List of notification providers + * + * @minItems 1 + * @examples require(".").notificationExamples + */ + notification?: (WebhookConfig | GotifyConfig | NtfyConfig)[]; }; export declare const includeExamples: readonly ["file1.yml", "file2.yml"]; -export declare const dockerExamples: readonly [{ +export declare const dockerExamples: readonly [ + { readonly local: "$DOCKER_HOST"; -}, { + }, + { readonly remote: "tcp://10.0.2.1:2375"; -}, { + }, + { readonly remote2: "ssh://root:1234@10.0.2.2"; -}]; -export declare const notificationExamples: readonly [{ + }, +]; +export declare const notificationExamples: readonly [ + { readonly name: "gotify"; readonly provider: "gotify"; readonly url: "https://gotify.domain.tld"; readonly token: "abcd"; -}, { + }, + { readonly name: "discord"; readonly provider: "webhook"; readonly template: "discord"; readonly url: "https://discord.com/api/webhooks/1234/abcd"; -}]; + }, +]; export declare const agentExamples: readonly ["10.0.2.3:8890", "10.0.2.4:8890"]; diff --git a/schemas/docker.d.ts b/schemas/docker.d.ts index 3b3c06d..02884ad 100644 --- a/schemas/docker.d.ts +++ b/schemas/docker.d.ts @@ -1,5 +1,5 @@ import { IdleWatcherConfig } from "./providers/idlewatcher"; import { Route } from "./providers/routes"; export type DockerRoutes = { - [key: string]: Route & IdleWatcherConfig; + [key: string]: Route & IdleWatcherConfig; }; diff --git a/schemas/docker_routes.schema.json b/schemas/docker_routes.schema.json index 26fe7fb..32025ef 100644 --- a/schemas/docker_routes.schema.json +++ b/schemas/docker_routes.schema.json @@ -1 +1 @@ -{"$schema":"http://json-schema.org/draft-07/schema#","additionalProperties":{"anyOf":[{"additionalProperties":false,"properties":{"access_log":{"additionalProperties":false,"description":"Access log config","examples":[{"fields":{"headers":{"config":{"foo":"redact"},"default":"keep"}},"filters":{"status_codes":{"values":["200-299"]}},"format":"combined","path":"/var/log/access.log"}],"properties":{"buffer_size":{"default":65536,"description":"The size of the buffer.","minimum":0,"type":"integer"},"fields":{"additionalProperties":false,"properties":{"cookie":{"additionalProperties":false,"properties":{"config":{"additionalProperties":{"enum":["drop","keep","redact"],"type":"string"},"type":"object"},"default":{"$ref":"#/definitions/AccessLogFieldMode"}},"required":["config"],"type":"object"},"header":{"additionalProperties":false,"properties":{"config":{"additionalProperties":{"enum":["drop","keep","redact"],"type":"string"},"type":"object"},"default":{"$ref":"#/definitions/AccessLogFieldMode"}},"required":["config"],"type":"object"},"query":{"additionalProperties":false,"properties":{"config":{"additionalProperties":{"enum":["drop","keep","redact"],"type":"string"},"type":"object"},"default":{"$ref":"#/definitions/AccessLogFieldMode"}},"required":["config"],"type":"object"}},"type":"object"},"filters":{"additionalProperties":false,"properties":{"cidr":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"$ref":"#/definitions/CIDR"},"type":"array"}},"required":["values"],"type":"object"},"headers":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"$ref":"#/definitions/HTTPHeader"},"type":"array"}},"required":["values"],"type":"object"},"host":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"type":"string"},"type":"array"}},"required":["values"],"type":"object"},"method":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"enum":["CONNECT","DELETE","GET","HEAD","OPTIONS","PATCH","POST","PUT","TRACE"],"type":"string"},"type":"array"}},"required":["values"],"type":"object"},"status_code":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"$ref":"#/definitions/StatusCodeRange"},"type":"array"}},"required":["values"],"type":"object"}},"type":"object"},"format":{"$ref":"#/definitions/AccessLogFormat","default":"combined","description":"The format of the access log."},"path":{"$ref":"#/definitions/URI"}},"required":["path"],"type":"object"},"alias":{"description":"Alias (subdomain or FDN)","minLength":1,"type":"string"},"healthcheck":{"additionalProperties":false,"description":"Healthcheck config","properties":{"disable":{"default":false,"description":"Disable healthcheck","type":"boolean"},"interval":{"$ref":"#/definitions/Duration","default":"5s","description":"Healthcheck interval"},"path":{"$ref":"#/definitions/URI","default":"/","description":"Healthcheck path"},"timeout":{"$ref":"#/definitions/Duration","default":"5s","description":"Healthcheck timeout"},"use_get":{"default":false,"description":"Use GET instead of HEAD","type":"boolean"}},"type":"object"},"homepage":{"additionalProperties":false,"description":"Homepage config","examples":[{"category":"Arr suite","icon":"png/sonarr.png","name":"Sonarr"},{"icon":"@target/favicon.ico","name":"App"}],"properties":{"category":{"type":"string"},"description":{"type":"string"},"icon":{"anyOf":[{"format":"uri","type":"string"},{"description":"Walkxcode icon","pattern":"^(png|svg|webp)\\/[\\w\\d\\-_]+\\.\\1","type":"string"},{"pattern":"^@selfhst/.*\\..*$","type":"string"},{"pattern":"^@walkxcode/.*\\..*$","type":"string"},{"pattern":"^@target/.*$","type":"string"},{"pattern":"^/.*$","type":"string"}]},"name":{"type":"string"},"show":{"default":true,"description":"Whether show in dashboard","type":"boolean"},"url":{"$ref":"#/definitions/URL"},"widget_config":{"additionalProperties":{},"type":"object"}},"type":"object"},"host":{"anyOf":[{"format":"hostname","type":"string"},{"format":"ipv4","type":"string"},{"format":"ipv6","type":"string"}],"default":"localhost","description":"Proxy host"},"idle_timeout":{"$ref":"#/definitions/Duration"},"load_balance":{"$ref":"#/definitions/LoadBalanceConfig","description":"Load balance config"},"middlewares":{"$ref":"#/definitions/MiddlewaresMap","description":"Middlewares"},"no_tls_verify":{"default":false,"description":"Skip TLS verification","type":"boolean"},"path_patterns":{"description":"Path patterns (only patterns that match will be proxied).\n\nSee https://pkg.go.dev/net/http#hdr-Patterns-ServeMux","items":{"$ref":"#/definitions/PathPattern"},"type":"array"},"port":{"$ref":"#/definitions/Port","default":80,"description":"Proxy port"},"response_header_timeout":{"$ref":"#/definitions/Duration","default":"60s","description":"Response header timeout"},"scheme":{"$ref":"#/definitions/ProxyScheme","default":"http","description":"Proxy scheme"},"start_endpoint":{"$ref":"#/definitions/URI"},"stop_method":{"$ref":"#/definitions/StopMethod","default":"stop","description":"Stop method"},"stop_signal":{"$ref":"#/definitions/Signal"},"stop_timeout":{"$ref":"#/definitions/Duration","default":"30s","description":"Stop timeout"},"wake_timeout":{"$ref":"#/definitions/Duration","default":"30s","description":"Wake timeout"}},"type":"object"},{"additionalProperties":false,"properties":{"access_log":{"additionalProperties":false,"description":"Access log config","examples":[{"fields":{"headers":{"config":{"foo":"redact"},"default":"keep"}},"filters":{"status_codes":{"values":["200-299"]}},"format":"combined","path":"/var/log/access.log"}],"properties":{"buffer_size":{"default":65536,"description":"The size of the buffer.","minimum":0,"type":"integer"},"fields":{"additionalProperties":false,"properties":{"cookie":{"additionalProperties":false,"properties":{"config":{"additionalProperties":{"enum":["drop","keep","redact"],"type":"string"},"type":"object"},"default":{"$ref":"#/definitions/AccessLogFieldMode"}},"required":["config"],"type":"object"},"header":{"additionalProperties":false,"properties":{"config":{"additionalProperties":{"enum":["drop","keep","redact"],"type":"string"},"type":"object"},"default":{"$ref":"#/definitions/AccessLogFieldMode"}},"required":["config"],"type":"object"},"query":{"additionalProperties":false,"properties":{"config":{"additionalProperties":{"enum":["drop","keep","redact"],"type":"string"},"type":"object"},"default":{"$ref":"#/definitions/AccessLogFieldMode"}},"required":["config"],"type":"object"}},"type":"object"},"filters":{"additionalProperties":false,"properties":{"cidr":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"$ref":"#/definitions/CIDR"},"type":"array"}},"required":["values"],"type":"object"},"headers":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"$ref":"#/definitions/HTTPHeader"},"type":"array"}},"required":["values"],"type":"object"},"host":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"type":"string"},"type":"array"}},"required":["values"],"type":"object"},"method":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"enum":["CONNECT","DELETE","GET","HEAD","OPTIONS","PATCH","POST","PUT","TRACE"],"type":"string"},"type":"array"}},"required":["values"],"type":"object"},"status_code":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"$ref":"#/definitions/StatusCodeRange"},"type":"array"}},"required":["values"],"type":"object"}},"type":"object"},"format":{"$ref":"#/definitions/AccessLogFormat","default":"combined","description":"The format of the access log."},"path":{"$ref":"#/definitions/URI"}},"required":["path"],"type":"object"},"alias":{"description":"Alias (subdomain or FDN)","minLength":1,"type":"string"},"homepage":{"additionalProperties":false,"description":"Homepage config","examples":[{"category":"Arr suite","icon":"png/sonarr.png","name":"Sonarr"},{"icon":"@target/favicon.ico","name":"App"}],"properties":{"category":{"type":"string"},"description":{"type":"string"},"icon":{"anyOf":[{"format":"uri","type":"string"},{"description":"Walkxcode icon","pattern":"^(png|svg|webp)\\/[\\w\\d\\-_]+\\.\\1","type":"string"},{"pattern":"^@selfhst/.*\\..*$","type":"string"},{"pattern":"^@walkxcode/.*\\..*$","type":"string"},{"pattern":"^@target/.*$","type":"string"},{"pattern":"^/.*$","type":"string"}]},"name":{"type":"string"},"show":{"default":true,"description":"Whether show in dashboard","type":"boolean"},"url":{"$ref":"#/definitions/URL"},"widget_config":{"additionalProperties":{},"type":"object"}},"type":"object"},"idle_timeout":{"$ref":"#/definitions/Duration"},"middlewares":{"$ref":"#/definitions/MiddlewaresMap","description":"Middlewares"},"path_patterns":{"description":"Path patterns (only patterns that match will be proxied).\n\nSee https://pkg.go.dev/net/http#hdr-Patterns-ServeMux","items":{"$ref":"#/definitions/PathPattern"},"type":"array"},"root":{"type":"string"},"scheme":{"const":"fileserver","type":"string"},"start_endpoint":{"$ref":"#/definitions/URI"},"stop_method":{"$ref":"#/definitions/StopMethod","default":"stop","description":"Stop method"},"stop_signal":{"$ref":"#/definitions/Signal"},"stop_timeout":{"$ref":"#/definitions/Duration","default":"30s","description":"Stop timeout"},"wake_timeout":{"$ref":"#/definitions/Duration","default":"30s","description":"Wake timeout"}},"required":["root","scheme"],"type":"object"},{"additionalProperties":false,"properties":{"alias":{"description":"Alias (subdomain or FDN)","minLength":1,"type":"string"},"healthcheck":{"additionalProperties":false,"description":"Healthcheck config","properties":{"disable":{"default":false,"description":"Disable healthcheck","type":"boolean"},"interval":{"$ref":"#/definitions/Duration","default":"5s","description":"Healthcheck interval"},"path":{"$ref":"#/definitions/URI","default":"/","description":"Healthcheck path"},"timeout":{"$ref":"#/definitions/Duration","default":"5s","description":"Healthcheck timeout"},"use_get":{"default":false,"description":"Use GET instead of HEAD","type":"boolean"}},"type":"object"},"host":{"anyOf":[{"format":"hostname","type":"string"},{"format":"ipv4","type":"string"},{"format":"ipv6","type":"string"}],"default":"localhost","description":"Stream host"},"idle_timeout":{"$ref":"#/definitions/Duration"},"port":{"$ref":"#/definitions/StreamPort"},"scheme":{"$ref":"#/definitions/StreamScheme","default":"tcp","description":"Stream scheme"},"start_endpoint":{"$ref":"#/definitions/URI"},"stop_method":{"$ref":"#/definitions/StopMethod","default":"stop","description":"Stop method"},"stop_signal":{"$ref":"#/definitions/Signal"},"stop_timeout":{"$ref":"#/definitions/Duration","default":"30s","description":"Stop timeout"},"wake_timeout":{"$ref":"#/definitions/Duration","default":"30s","description":"Wake timeout"}},"required":["port"],"type":"object"}]},"definitions":{"AccessLogFieldMode":{"enum":["drop","keep","redact"],"type":"string"},"AccessLogFormat":{"enum":["combined","common","json"],"type":"string"},"CIDR":{"anyOf":[{"pattern":"^[0-9]*\\.[0-9]*\\.[0-9]*\\.[0-9]*$","type":"string"},{"pattern":"^.*:.*:.*:.*:.*:.*:.*:.*$","type":"string"},{"pattern":"^[0-9]*\\.[0-9]*\\.[0-9]*\\.[0-9]*/[0-9]*$","type":"string"},{"pattern":"^::[0-9]*$","type":"string"},{"pattern":"^.*::/[0-9]*$","type":"string"},{"pattern":"^.*:.*::/[0-9]*$","type":"string"}]},"Duration":{"pattern":"^([0-9]+(ms|s|m|h))+$","type":"string"},"HTTPHeader":{"description":"HTTP Header","pattern":"^[a-zA-Z0-9\\-]+$","type":"string"},"LoadBalanceConfig":{"anyOf":[{"additionalProperties":false,"properties":{"link":{"description":"Alias (subdomain or FDN) of load-balancer","minLength":1,"type":"string"},"weight":{"description":"Load-balance weight (reserved for future use)","maximum":100,"minimum":0,"type":"number"}},"required":["link"],"type":"object"},{"additionalProperties":false,"properties":{"link":{"description":"Alias (subdomain or FDN) of load-balancer","minLength":1,"type":"string"},"mode":{"const":"round_robin","type":"string"},"weight":{"description":"Load-balance weight (reserved for future use)","maximum":100,"minimum":0,"type":"number"}},"required":["link","mode"],"type":"object"},{"additionalProperties":false,"properties":{"link":{"description":"Alias (subdomain or FDN) of load-balancer","minLength":1,"type":"string"},"mode":{"const":"least_conn","type":"string"},"weight":{"description":"Load-balance weight (reserved for future use)","maximum":100,"minimum":0,"type":"number"}},"required":["link","mode"],"type":"object"},{"additionalProperties":false,"properties":{"config":{"additionalProperties":false,"description":"Real IP config, header to get client IP from","properties":{"from":{"items":{"$ref":"#/definitions/CIDR"},"type":"array"},"header":{"$ref":"#/definitions/HTTPHeader","default":"X-Real-IP","description":"Header to get the client IP from"},"recursive":{"default":false,"description":"Recursive resolve the IP","type":"boolean"},"use":{"enum":["RealIP","realIP","real_ip"],"type":"string"}},"required":["from","use"],"type":"object"},"link":{"description":"Alias (subdomain or FDN) of load-balancer","minLength":1,"type":"string"},"mode":{"const":"ip_hash","type":"string"},"weight":{"description":"Load-balance weight (reserved for future use)","maximum":100,"minimum":0,"type":"number"}},"required":["config","link","mode"],"type":"object"}]},"MiddlewaresMap":{"anyOf":[{"additionalProperties":false,"properties":{"CIDRWhitelist":{"$ref":"#/definitions/Omit"},"CloudflareRealIP":{"$ref":"#/definitions/Omit"},"CustomErrorPage":{"$ref":"#/definitions/Omit"},"ErrorPage":{"$ref":"#/definitions/Omit"},"HideXForwarded":{"$ref":"#/definitions/Omit"},"ModifyRequest":{"$ref":"#/definitions/Omit"},"ModifyResponse":{"$ref":"#/definitions/Omit"},"OIDC":{"$ref":"#/definitions/Omit"},"RateLimit":{"$ref":"#/definitions/Omit"},"RealIP":{"$ref":"#/definitions/Omit"},"RedirectHTTP":{"$ref":"#/definitions/Omit"},"Request":{"$ref":"#/definitions/Omit"},"Response":{"$ref":"#/definitions/Omit"},"SetXForwarded":{"$ref":"#/definitions/Omit"},"cidrWhitelist":{"$ref":"#/definitions/Omit"},"cidr_whitelist":{"$ref":"#/definitions/Omit"},"cloudflareRealIp":{"$ref":"#/definitions/Omit"},"cloudflare_real_ip":{"$ref":"#/definitions/Omit"},"customErrorPage":{"$ref":"#/definitions/Omit"},"custom_error_page":{"$ref":"#/definitions/Omit"},"errorPage":{"$ref":"#/definitions/Omit"},"error_page":{"$ref":"#/definitions/Omit"},"hideXForwarded":{"$ref":"#/definitions/Omit"},"hide_x_forwarded":{"$ref":"#/definitions/Omit"},"modifyRequest":{"$ref":"#/definitions/Omit"},"modifyResponse":{"$ref":"#/definitions/Omit"},"modify_request":{"$ref":"#/definitions/Omit"},"modify_response":{"$ref":"#/definitions/Omit"},"oidc":{"$ref":"#/definitions/Omit"},"rateLimit":{"$ref":"#/definitions/Omit"},"rate_limit":{"$ref":"#/definitions/Omit"},"realIP":{"$ref":"#/definitions/Omit"},"real_ip":{"$ref":"#/definitions/Omit"},"redirectHTTP":{"$ref":"#/definitions/Omit"},"redirect_http":{"$ref":"#/definitions/Omit"},"request":{"$ref":"#/definitions/Omit"},"response":{"$ref":"#/definitions/Omit"},"setXForwarded":{"$ref":"#/definitions/Omit"},"set_x_forwarded":{"$ref":"#/definitions/Omit"}},"type":"object"},{"type":"object"}]},"Omit":{"additionalProperties":false,"properties":{"allow":{"items":{"$ref":"#/definitions/CIDR"},"type":"array"},"message":{"default":"IP not allowed","description":"Error message when blocked","type":"string"},"status":{"$ref":"#/definitions/StatusCode","default":403,"description":"HTTP status code when blocked (alias of status_code)"},"status_code":{"$ref":"#/definitions/StatusCode","default":403,"description":"HTTP status code when blocked"}},"required":["allow"],"type":"object"},"Omit":{"additionalProperties":false,"properties":{"recursive":{"default":false,"description":"Recursively resolve the IP","type":"boolean"}},"type":"object"},"Omit":{"additionalProperties":false,"type":"object"},"Omit":{"additionalProperties":false,"type":"object"},"Omit":{"additionalProperties":false,"properties":{"add_headers":{"additionalProperties":false,"description":"Add HTTP headers","items":{"type":"string"},"type":"array"},"add_prefix":{"description":"Add prefix to request URL","type":"string"},"hide_headers":{"description":"Hide HTTP headers","items":{"$ref":"#/definitions/HTTPHeader"},"type":"array"},"set_headers":{"additionalProperties":false,"description":"Set HTTP headers","items":{"type":"string"},"type":"array"}},"type":"object"},"Omit":{"additionalProperties":false,"properties":{"add_headers":{"additionalProperties":false,"description":"Add HTTP headers","items":{"type":"string"},"type":"array"},"hide_headers":{"description":"Hide HTTP headers","items":{"$ref":"#/definitions/HTTPHeader"},"type":"array"},"set_headers":{"additionalProperties":false,"description":"Set HTTP headers","items":{"type":"string"},"type":"array"}},"type":"object"},"Omit":{"additionalProperties":false,"properties":{"allowed_groups":{"description":"Allowed groups","items":{"type":"string"},"minItems":1,"type":"array"},"allowed_users":{"description":"Allowed users","items":{"type":"string"},"minItems":1,"type":"array"}},"type":"object"},"Omit":{"additionalProperties":false,"properties":{"average":{"description":"Average number of requests allowed in a period","type":"number"},"burst":{"description":"Maximum number of requests allowed in a period","type":"number"},"period":{"$ref":"#/definitions/Duration","default":"1s","description":"Duration of the rate limit"}},"required":["average","burst"],"type":"object"},"Omit":{"additionalProperties":false,"properties":{"from":{"items":{"$ref":"#/definitions/CIDR"},"type":"array"},"header":{"$ref":"#/definitions/HTTPHeader","default":"X-Real-IP","description":"Header to get the client IP from"},"recursive":{"default":false,"description":"Recursive resolve the IP","type":"boolean"}},"required":["from"],"type":"object"},"Omit":{"additionalProperties":false,"properties":{"bypass":{"additionalProperties":false,"description":"Bypass redirect","properties":{"user_agents":{"description":"Bypass redirect for user agents","items":{"type":"string"},"type":"array"}},"type":"object"}},"type":"object"},"Omit":{"additionalProperties":false,"type":"object"},"PathPattern":{"pattern":"^(?:([A-Z]+) )?(?:([a-zA-Z0-9.-]+)\\\\/)?(\\\\/[^\\\\s]*)$","type":"string"},"Port":{"maximum":65535,"minimum":0,"type":"integer"},"ProxyScheme":{"enum":["http","https"],"type":"string"},"Signal":{"enum":["","HUP","INT","QUIT","SIGHUP","SIGINT","SIGQUIT","SIGTERM","TERM"],"type":"string"},"StatusCode":{"anyOf":[{"pattern":"^[0-9]*$","type":"string"},{"type":"number"}]},"StatusCodeRange":{"anyOf":[{"pattern":"^[0-9]*$","type":"string"},{"pattern":"^[0-9]*-[0-9]*$","type":"string"},{"type":"number"}]},"StopMethod":{"enum":["kill","pause","stop"],"type":"string"},"StreamPort":{"pattern":"^\\d+:\\d+$","type":"string"},"StreamScheme":{"enum":["tcp","udp"],"type":"string"},"URI":{"format":"uri-reference","type":"string"},"URL":{"format":"uri","type":"string"}},"type":"object"} \ No newline at end of file +{"$schema":"http://json-schema.org/draft-07/schema#","additionalProperties":{"anyOf":[{"additionalProperties":false,"properties":{"access_log":{"additionalProperties":false,"description":"Access log config","examples":[{"fields":{"headers":{"config":{"foo":"redact"},"default":"keep"}},"filters":{"status_codes":{"values":["200-299"]}},"format":"combined","path":"/var/log/access.log"}],"properties":{"buffer_size":{"default":65536,"description":"The size of the buffer.","minimum":0,"type":"integer"},"fields":{"additionalProperties":false,"properties":{"cookie":{"additionalProperties":false,"properties":{"config":{"additionalProperties":{"enum":["drop","keep","redact"],"type":"string"},"type":"object"},"default":{"$ref":"#/definitions/AccessLogFieldMode"}},"required":["config"],"type":"object"},"header":{"additionalProperties":false,"properties":{"config":{"additionalProperties":{"enum":["drop","keep","redact"],"type":"string"},"type":"object"},"default":{"$ref":"#/definitions/AccessLogFieldMode"}},"required":["config"],"type":"object"},"query":{"additionalProperties":false,"properties":{"config":{"additionalProperties":{"enum":["drop","keep","redact"],"type":"string"},"type":"object"},"default":{"$ref":"#/definitions/AccessLogFieldMode"}},"required":["config"],"type":"object"}},"type":"object"},"filters":{"additionalProperties":false,"properties":{"cidr":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"$ref":"#/definitions/CIDR"},"type":"array"}},"required":["values"],"type":"object"},"headers":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"$ref":"#/definitions/HTTPHeader"},"type":"array"}},"required":["values"],"type":"object"},"host":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"type":"string"},"type":"array"}},"required":["values"],"type":"object"},"method":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"enum":["CONNECT","DELETE","GET","HEAD","OPTIONS","PATCH","POST","PUT","TRACE"],"type":"string"},"type":"array"}},"required":["values"],"type":"object"},"status_code":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"$ref":"#/definitions/StatusCodeRange"},"type":"array"}},"required":["values"],"type":"object"}},"type":"object"},"format":{"$ref":"#/definitions/AccessLogFormat","default":"combined","description":"The format of the access log."},"path":{"$ref":"#/definitions/URI"}},"required":["path"],"type":"object"},"alias":{"description":"Alias (subdomain or FDN)","minLength":1,"type":"string"},"healthcheck":{"additionalProperties":false,"description":"Healthcheck config","properties":{"disable":{"default":false,"description":"Disable healthcheck","type":"boolean"},"interval":{"$ref":"#/definitions/Duration","default":"5s","description":"Healthcheck interval"},"path":{"$ref":"#/definitions/URI","default":"/","description":"Healthcheck path"},"timeout":{"$ref":"#/definitions/Duration","default":"5s","description":"Healthcheck timeout"},"use_get":{"default":false,"description":"Use GET instead of HEAD","type":"boolean"}},"type":"object"},"homepage":{"additionalProperties":false,"description":"Homepage config","examples":[{"category":"Arr suite","icon":"png/sonarr.png","name":"Sonarr"},{"icon":"@target/favicon.ico","name":"App"}],"properties":{"category":{"type":"string"},"description":{"type":"string"},"icon":{"anyOf":[{"format":"uri","type":"string"},{"description":"Walkxcode icon","pattern":"^(png|svg|webp)\\/[\\w\\d\\-_]+\\.\\1","type":"string"},{"pattern":"^@selfhst/.*\\..*$","type":"string"},{"pattern":"^@walkxcode/.*\\..*$","type":"string"},{"pattern":"^@target/.*$","type":"string"},{"pattern":"^/.*$","type":"string"}]},"name":{"type":"string"},"show":{"default":true,"description":"Whether show in dashboard","type":"boolean"},"url":{"$ref":"#/definitions/URL"},"widget_config":{"additionalProperties":{},"type":"object"}},"type":"object"},"host":{"anyOf":[{"format":"hostname","type":"string"},{"format":"ipv4","type":"string"},{"format":"ipv6","type":"string"}],"default":"localhost","description":"Proxy host"},"idle_timeout":{"$ref":"#/definitions/Duration"},"load_balance":{"$ref":"#/definitions/LoadBalanceConfig","description":"Load balance config"},"middlewares":{"$ref":"#/definitions/MiddlewaresMap","description":"Middlewares"},"no_tls_verify":{"default":false,"description":"Skip TLS verification","type":"boolean"},"path_patterns":{"description":"Path patterns (only patterns that match will be proxied).\n\nSee https://pkg.go.dev/net/http#hdr-Patterns-ServeMux","items":{"$ref":"#/definitions/PathPattern"},"type":"array"},"port":{"$ref":"#/definitions/Port","default":80,"description":"Proxy port"},"response_header_timeout":{"$ref":"#/definitions/Duration","default":"60s","description":"Response header timeout"},"scheme":{"$ref":"#/definitions/ProxyScheme","default":"http","description":"Proxy scheme"},"start_endpoint":{"$ref":"#/definitions/URI"},"stop_method":{"$ref":"#/definitions/StopMethod","default":"stop","description":"Stop method"},"stop_signal":{"$ref":"#/definitions/Signal"},"stop_timeout":{"$ref":"#/definitions/Duration","default":"30s","description":"Stop timeout"},"wake_timeout":{"$ref":"#/definitions/Duration","default":"30s","description":"Wake timeout"}},"type":"object"},{"additionalProperties":false,"properties":{"access_log":{"additionalProperties":false,"description":"Access log config","examples":[{"fields":{"headers":{"config":{"foo":"redact"},"default":"keep"}},"filters":{"status_codes":{"values":["200-299"]}},"format":"combined","path":"/var/log/access.log"}],"properties":{"buffer_size":{"default":65536,"description":"The size of the buffer.","minimum":0,"type":"integer"},"fields":{"additionalProperties":false,"properties":{"cookie":{"additionalProperties":false,"properties":{"config":{"additionalProperties":{"enum":["drop","keep","redact"],"type":"string"},"type":"object"},"default":{"$ref":"#/definitions/AccessLogFieldMode"}},"required":["config"],"type":"object"},"header":{"additionalProperties":false,"properties":{"config":{"additionalProperties":{"enum":["drop","keep","redact"],"type":"string"},"type":"object"},"default":{"$ref":"#/definitions/AccessLogFieldMode"}},"required":["config"],"type":"object"},"query":{"additionalProperties":false,"properties":{"config":{"additionalProperties":{"enum":["drop","keep","redact"],"type":"string"},"type":"object"},"default":{"$ref":"#/definitions/AccessLogFieldMode"}},"required":["config"],"type":"object"}},"type":"object"},"filters":{"additionalProperties":false,"properties":{"cidr":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"$ref":"#/definitions/CIDR"},"type":"array"}},"required":["values"],"type":"object"},"headers":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"$ref":"#/definitions/HTTPHeader"},"type":"array"}},"required":["values"],"type":"object"},"host":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"type":"string"},"type":"array"}},"required":["values"],"type":"object"},"method":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"enum":["CONNECT","DELETE","GET","HEAD","OPTIONS","PATCH","POST","PUT","TRACE"],"type":"string"},"type":"array"}},"required":["values"],"type":"object"},"status_code":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"$ref":"#/definitions/StatusCodeRange"},"type":"array"}},"required":["values"],"type":"object"}},"type":"object"},"format":{"$ref":"#/definitions/AccessLogFormat","default":"combined","description":"The format of the access log."},"path":{"$ref":"#/definitions/URI"}},"required":["path"],"type":"object"},"alias":{"description":"Alias (subdomain or FDN)","minLength":1,"type":"string"},"healthcheck":{"additionalProperties":false,"description":"Healthcheck config","properties":{"disable":{"default":false,"description":"Disable healthcheck","type":"boolean"},"interval":{"$ref":"#/definitions/Duration","default":"5s","description":"Healthcheck interval"},"path":{"$ref":"#/definitions/URI","default":"/","description":"Healthcheck path"},"timeout":{"$ref":"#/definitions/Duration","default":"5s","description":"Healthcheck timeout"},"use_get":{"default":false,"description":"Use GET instead of HEAD","type":"boolean"}},"type":"object"},"homepage":{"additionalProperties":false,"description":"Homepage config","examples":[{"category":"Arr suite","icon":"png/sonarr.png","name":"Sonarr"},{"icon":"@target/favicon.ico","name":"App"}],"properties":{"category":{"type":"string"},"description":{"type":"string"},"icon":{"anyOf":[{"format":"uri","type":"string"},{"description":"Walkxcode icon","pattern":"^(png|svg|webp)\\/[\\w\\d\\-_]+\\.\\1","type":"string"},{"pattern":"^@selfhst/.*\\..*$","type":"string"},{"pattern":"^@walkxcode/.*\\..*$","type":"string"},{"pattern":"^@target/.*$","type":"string"},{"pattern":"^/.*$","type":"string"}]},"name":{"type":"string"},"show":{"default":true,"description":"Whether show in dashboard","type":"boolean"},"url":{"$ref":"#/definitions/URL"},"widget_config":{"additionalProperties":{},"type":"object"}},"type":"object"},"idle_timeout":{"$ref":"#/definitions/Duration"},"middlewares":{"$ref":"#/definitions/MiddlewaresMap","description":"Middlewares"},"path_patterns":{"description":"Path patterns (only patterns that match will be proxied).\n\nSee https://pkg.go.dev/net/http#hdr-Patterns-ServeMux","items":{"$ref":"#/definitions/PathPattern"},"type":"array"},"root":{"type":"string"},"scheme":{"const":"fileserver","type":"string"},"start_endpoint":{"$ref":"#/definitions/URI"},"stop_method":{"$ref":"#/definitions/StopMethod","default":"stop","description":"Stop method"},"stop_signal":{"$ref":"#/definitions/Signal"},"stop_timeout":{"$ref":"#/definitions/Duration","default":"30s","description":"Stop timeout"},"wake_timeout":{"$ref":"#/definitions/Duration","default":"30s","description":"Wake timeout"}},"required":["root","scheme"],"type":"object"},{"additionalProperties":false,"properties":{"alias":{"description":"Alias (subdomain or FDN)","minLength":1,"type":"string"},"healthcheck":{"additionalProperties":false,"description":"Healthcheck config","properties":{"disable":{"default":false,"description":"Disable healthcheck","type":"boolean"},"interval":{"$ref":"#/definitions/Duration","default":"5s","description":"Healthcheck interval"},"path":{"$ref":"#/definitions/URI","default":"/","description":"Healthcheck path"},"timeout":{"$ref":"#/definitions/Duration","default":"5s","description":"Healthcheck timeout"},"use_get":{"default":false,"description":"Use GET instead of HEAD","type":"boolean"}},"type":"object"},"host":{"anyOf":[{"format":"hostname","type":"string"},{"format":"ipv4","type":"string"},{"format":"ipv6","type":"string"}],"default":"localhost","description":"Stream host"},"idle_timeout":{"$ref":"#/definitions/Duration"},"port":{"$ref":"#/definitions/StreamPort"},"scheme":{"$ref":"#/definitions/StreamScheme","default":"tcp","description":"Stream scheme"},"start_endpoint":{"$ref":"#/definitions/URI"},"stop_method":{"$ref":"#/definitions/StopMethod","default":"stop","description":"Stop method"},"stop_signal":{"$ref":"#/definitions/Signal"},"stop_timeout":{"$ref":"#/definitions/Duration","default":"30s","description":"Stop timeout"},"wake_timeout":{"$ref":"#/definitions/Duration","default":"30s","description":"Wake timeout"}},"required":["port"],"type":"object"}]},"definitions":{"AccessLogFieldMode":{"enum":["drop","keep","redact"],"type":"string"},"AccessLogFormat":{"enum":["combined","common","json"],"type":"string"},"CIDR":{"anyOf":[{"pattern":"^[0-9]*\\.[0-9]*\\.[0-9]*\\.[0-9]*$","type":"string"},{"pattern":"^.*:.*:.*:.*:.*:.*:.*:.*$","type":"string"},{"pattern":"^[0-9]*\\.[0-9]*\\.[0-9]*\\.[0-9]*/[0-9]*$","type":"string"},{"pattern":"^::[0-9]*$","type":"string"},{"pattern":"^.*::/[0-9]*$","type":"string"},{"pattern":"^.*:.*::/[0-9]*$","type":"string"}]},"Duration":{"pattern":"^([0-9]+(ms|s|m|h))+$","type":"string"},"HTTPHeader":{"description":"HTTP Header","pattern":"^[a-zA-Z0-9\\-]+$","type":"string"},"LoadBalanceConfig":{"anyOf":[{"additionalProperties":false,"properties":{"link":{"description":"Alias (subdomain or FDN) of load-balancer","minLength":1,"type":"string"},"weight":{"description":"Load-balance weight (reserved for future use)","maximum":100,"minimum":0,"type":"number"}},"required":["link"],"type":"object"},{"additionalProperties":false,"properties":{"link":{"description":"Alias (subdomain or FDN) of load-balancer","minLength":1,"type":"string"},"mode":{"const":"round_robin","type":"string"},"weight":{"description":"Load-balance weight (reserved for future use)","maximum":100,"minimum":0,"type":"number"}},"required":["link","mode"],"type":"object"},{"additionalProperties":false,"properties":{"link":{"description":"Alias (subdomain or FDN) of load-balancer","minLength":1,"type":"string"},"mode":{"const":"least_conn","type":"string"},"weight":{"description":"Load-balance weight (reserved for future use)","maximum":100,"minimum":0,"type":"number"}},"required":["link","mode"],"type":"object"},{"additionalProperties":false,"properties":{"config":{"additionalProperties":false,"description":"Real IP config, header to get client IP from","properties":{"from":{"items":{"$ref":"#/definitions/CIDR"},"type":"array"},"header":{"$ref":"#/definitions/HTTPHeader","default":"X-Real-IP","description":"Header to get the client IP from"},"recursive":{"default":false,"description":"Recursive resolve the IP","type":"boolean"},"use":{"enum":["RealIP","realIP","real_ip"],"type":"string"}},"required":["from","use"],"type":"object"},"link":{"description":"Alias (subdomain or FDN) of load-balancer","minLength":1,"type":"string"},"mode":{"const":"ip_hash","type":"string"},"weight":{"description":"Load-balance weight (reserved for future use)","maximum":100,"minimum":0,"type":"number"}},"required":["config","link","mode"],"type":"object"}]},"MiddlewaresMap":{"anyOf":[{"additionalProperties":false,"properties":{"CIDRWhitelist":{"$ref":"#/definitions/Omit"},"CloudflareRealIP":{"$ref":"#/definitions/Omit"},"CustomErrorPage":{"$ref":"#/definitions/Omit"},"ErrorPage":{"$ref":"#/definitions/Omit"},"HideXForwarded":{"$ref":"#/definitions/Omit"},"ModifyRequest":{"$ref":"#/definitions/Omit"},"ModifyResponse":{"$ref":"#/definitions/Omit"},"OIDC":{"$ref":"#/definitions/Omit"},"RateLimit":{"$ref":"#/definitions/Omit"},"RealIP":{"$ref":"#/definitions/Omit"},"RedirectHTTP":{"$ref":"#/definitions/Omit"},"Request":{"$ref":"#/definitions/Omit"},"Response":{"$ref":"#/definitions/Omit"},"SetXForwarded":{"$ref":"#/definitions/Omit"},"cidrWhitelist":{"$ref":"#/definitions/Omit"},"cidr_whitelist":{"$ref":"#/definitions/Omit"},"cloudflareRealIp":{"$ref":"#/definitions/Omit"},"cloudflare_real_ip":{"$ref":"#/definitions/Omit"},"customErrorPage":{"$ref":"#/definitions/Omit"},"custom_error_page":{"$ref":"#/definitions/Omit"},"errorPage":{"$ref":"#/definitions/Omit"},"error_page":{"$ref":"#/definitions/Omit"},"hideXForwarded":{"$ref":"#/definitions/Omit"},"hide_x_forwarded":{"$ref":"#/definitions/Omit"},"modifyRequest":{"$ref":"#/definitions/Omit"},"modifyResponse":{"$ref":"#/definitions/Omit"},"modify_request":{"$ref":"#/definitions/Omit"},"modify_response":{"$ref":"#/definitions/Omit"},"oidc":{"$ref":"#/definitions/Omit"},"rateLimit":{"$ref":"#/definitions/Omit"},"rate_limit":{"$ref":"#/definitions/Omit"},"realIP":{"$ref":"#/definitions/Omit"},"real_ip":{"$ref":"#/definitions/Omit"},"redirectHTTP":{"$ref":"#/definitions/Omit"},"redirect_http":{"$ref":"#/definitions/Omit"},"request":{"$ref":"#/definitions/Omit"},"response":{"$ref":"#/definitions/Omit"},"setXForwarded":{"$ref":"#/definitions/Omit"},"set_x_forwarded":{"$ref":"#/definitions/Omit"}},"type":"object"},{"type":"object"}]},"Omit":{"additionalProperties":false,"properties":{"allow":{"items":{"$ref":"#/definitions/CIDR"},"type":"array"},"message":{"default":"IP not allowed","description":"Error message when blocked","type":"string"},"status":{"$ref":"#/definitions/StatusCode","default":403,"description":"HTTP status code when blocked (alias of status_code)"},"status_code":{"$ref":"#/definitions/StatusCode","default":403,"description":"HTTP status code when blocked"}},"required":["allow"],"type":"object"},"Omit":{"additionalProperties":false,"properties":{"recursive":{"default":false,"description":"Recursively resolve the IP","type":"boolean"}},"type":"object"},"Omit":{"additionalProperties":false,"type":"object"},"Omit":{"additionalProperties":false,"type":"object"},"Omit":{"additionalProperties":false,"properties":{"add_headers":{"additionalProperties":false,"description":"Add HTTP headers","items":{"type":"string"},"type":"array"},"add_prefix":{"description":"Add prefix to request URL","type":"string"},"hide_headers":{"description":"Hide HTTP headers","items":{"$ref":"#/definitions/HTTPHeader"},"type":"array"},"set_headers":{"additionalProperties":false,"description":"Set HTTP headers","items":{"type":"string"},"type":"array"}},"type":"object"},"Omit":{"additionalProperties":false,"properties":{"add_headers":{"additionalProperties":false,"description":"Add HTTP headers","items":{"type":"string"},"type":"array"},"hide_headers":{"description":"Hide HTTP headers","items":{"$ref":"#/definitions/HTTPHeader"},"type":"array"},"set_headers":{"additionalProperties":false,"description":"Set HTTP headers","items":{"type":"string"},"type":"array"}},"type":"object"},"Omit":{"additionalProperties":false,"properties":{"allowed_groups":{"description":"Allowed groups","items":{"type":"string"},"minItems":1,"type":"array"},"allowed_users":{"description":"Allowed users","items":{"type":"string"},"minItems":1,"type":"array"}},"type":"object"},"Omit":{"additionalProperties":false,"properties":{"average":{"description":"Average number of requests allowed in a period","type":"number"},"burst":{"description":"Maximum number of requests allowed in a period","type":"number"},"period":{"$ref":"#/definitions/Duration","default":"1s","description":"Duration of the rate limit"}},"required":["average","burst"],"type":"object"},"Omit":{"additionalProperties":false,"properties":{"from":{"items":{"$ref":"#/definitions/CIDR"},"type":"array"},"header":{"$ref":"#/definitions/HTTPHeader","default":"X-Real-IP","description":"Header to get the client IP from"},"recursive":{"default":false,"description":"Recursive resolve the IP","type":"boolean"}},"required":["from"],"type":"object"},"Omit":{"additionalProperties":false,"properties":{"bypass":{"additionalProperties":false,"description":"Bypass redirect","properties":{"user_agents":{"description":"Bypass redirect for user agents","items":{"type":"string"},"type":"array"}},"type":"object"}},"type":"object"},"Omit":{"additionalProperties":false,"type":"object"},"PathPattern":{"pattern":"^(?:([A-Z]+) )?(?:([a-zA-Z0-9.-]+)\\\\/)?(\\\\/[^\\\\s]*)$","type":"string"},"Port":{"maximum":65535,"minimum":0,"type":"integer"},"ProxyScheme":{"enum":["http","https"],"type":"string"},"Signal":{"enum":["","HUP","INT","QUIT","SIGHUP","SIGINT","SIGQUIT","SIGTERM","TERM"],"type":"string"},"StatusCode":{"anyOf":[{"pattern":"^[0-9]*$","type":"string"},{"type":"number"}]},"StatusCodeRange":{"anyOf":[{"pattern":"^[0-9]*$","type":"string"},{"pattern":"^[0-9]*-[0-9]*$","type":"string"},{"type":"number"}]},"StopMethod":{"enum":["kill","pause","stop"],"type":"string"},"StreamPort":{"pattern":"^\\d+:\\d+$","type":"string"},"StreamScheme":{"enum":["tcp","udp"],"type":"string"},"URI":{"format":"uri-reference","type":"string"},"URL":{"format":"uri","type":"string"}},"type":"object"} \ No newline at end of file diff --git a/schemas/index.d.ts b/schemas/index.d.ts index 0582c30..f078530 100644 --- a/schemas/index.d.ts +++ b/schemas/index.d.ts @@ -16,4 +16,23 @@ import ConfigSchema from "./config.schema.json"; import DockerRoutesSchema from "./docker_routes.schema.json"; import MiddlewareComposeSchema from "./middleware_compose.schema.json"; import RoutesSchema from "./routes.schema.json"; -export { AccessLog, Autocert, Config, ConfigSchema, DockerRoutesSchema, Entrypoint, GoDoxy, Healthcheck, Homepage, IdleWatcher, LoadBalance, MiddlewareCompose, MiddlewareComposeSchema, Middlewares, Notification, Providers, Routes, RoutesSchema, }; +export { + AccessLog, + Autocert, + Config, + ConfigSchema, + DockerRoutesSchema, + Entrypoint, + GoDoxy, + Healthcheck, + Homepage, + IdleWatcher, + LoadBalance, + MiddlewareCompose, + MiddlewareComposeSchema, + Middlewares, + Notification, + Providers, + Routes, + RoutesSchema, +}; diff --git a/schemas/middlewares/middlewares.d.ts b/schemas/middlewares/middlewares.d.ts index c7ad0c5..4a0df0d 100644 --- a/schemas/middlewares/middlewares.d.ts +++ b/schemas/middlewares/middlewares.d.ts @@ -1,133 +1,186 @@ import * as types from "../types"; -export type KeyOptMapping = { - [key in T["use"]]?: Omit; + }, +> = { + [key in T["use"]]?: Omit; }; -export declare const ALL_MIDDLEWARES: readonly ["ErrorPage", "RedirectHTTP", "SetXForwarded", "HideXForwarded", "CIDRWhitelist", "CloudflareRealIP", "ModifyRequest", "ModifyResponse", "OIDC", "RateLimit", "RealIP"]; +export declare const ALL_MIDDLEWARES: readonly [ + "ErrorPage", + "RedirectHTTP", + "SetXForwarded", + "HideXForwarded", + "CIDRWhitelist", + "CloudflareRealIP", + "ModifyRequest", + "ModifyResponse", + "OIDC", + "RateLimit", + "RealIP", +]; /** * @type object * @patternProperties {"^.*@file$": {"type": "null"}} */ export type MiddlewareFileRef = { - [key: `${string}@file`]: null; + [key: `${string}@file`]: null; }; -export type MiddlewaresMap = (KeyOptMapping & KeyOptMapping & KeyOptMapping & KeyOptMapping & KeyOptMapping & KeyOptMapping & KeyOptMapping & KeyOptMapping & KeyOptMapping & KeyOptMapping & KeyOptMapping) | MiddlewareFileRef; -export type MiddlewareComposeMap = CustomErrorPage | RedirectHTTP | SetXForwarded | HideXForwarded | CIDRWhitelist | CloudflareRealIP | ModifyRequest | ModifyResponse | OIDC | RateLimit | RealIP; +export type MiddlewaresMap = + | (KeyOptMapping & + KeyOptMapping & + KeyOptMapping & + KeyOptMapping & + KeyOptMapping & + KeyOptMapping & + KeyOptMapping & + KeyOptMapping & + KeyOptMapping & + KeyOptMapping & + KeyOptMapping) + | MiddlewareFileRef; +export type MiddlewareComposeMap = + | CustomErrorPage + | RedirectHTTP + | SetXForwarded + | HideXForwarded + | CIDRWhitelist + | CloudflareRealIP + | ModifyRequest + | ModifyResponse + | OIDC + | RateLimit + | RealIP; export type CustomErrorPage = { - use: "error_page" | "errorPage" | "ErrorPage" | "custom_error_page" | "customErrorPage" | "CustomErrorPage"; + use: + | "error_page" + | "errorPage" + | "ErrorPage" + | "custom_error_page" + | "customErrorPage" + | "CustomErrorPage"; }; export type RedirectHTTP = { - use: "redirect_http" | "redirectHTTP" | "RedirectHTTP"; - /** Bypass redirect */ - bypass?: { - /** Bypass redirect for user agents */ - user_agents?: string[]; - }; + use: "redirect_http" | "redirectHTTP" | "RedirectHTTP"; + /** Bypass redirect */ + bypass?: { + /** Bypass redirect for user agents */ + user_agents?: string[]; + }; }; export type SetXForwarded = { - use: "set_x_forwarded" | "setXForwarded" | "SetXForwarded"; + use: "set_x_forwarded" | "setXForwarded" | "SetXForwarded"; }; export type HideXForwarded = { - use: "hide_x_forwarded" | "hideXForwarded" | "HideXForwarded"; + use: "hide_x_forwarded" | "hideXForwarded" | "HideXForwarded"; }; export type CIDRWhitelist = { - use: "cidr_whitelist" | "cidrWhitelist" | "CIDRWhitelist"; - allow: types.CIDR[]; - /** HTTP status code when blocked - * - * @default 403 - */ - status_code?: types.StatusCode; - /** HTTP status code when blocked (alias of status_code) - * - * @default 403 - */ - status?: types.StatusCode; - /** Error message when blocked - * - * @default "IP not allowed" - */ - message?: string; + use: "cidr_whitelist" | "cidrWhitelist" | "CIDRWhitelist"; + allow: types.CIDR[]; + /** HTTP status code when blocked + * + * @default 403 + */ + status_code?: types.StatusCode; + /** HTTP status code when blocked (alias of status_code) + * + * @default 403 + */ + status?: types.StatusCode; + /** Error message when blocked + * + * @default "IP not allowed" + */ + message?: string; }; export type CloudflareRealIP = { - use: "cloudflare_real_ip" | "cloudflareRealIp" | "CloudflareRealIP"; - /** Recursively resolve the IP - * - * @default false - */ - recursive?: boolean; + use: "cloudflare_real_ip" | "cloudflareRealIp" | "CloudflareRealIP"; + /** Recursively resolve the IP + * + * @default false + */ + recursive?: boolean; }; export type ModifyRequest = { - use: "request" | "Request" | "modify_request" | "modifyRequest" | "ModifyRequest"; - /** Set HTTP headers */ - set_headers?: { - [key: types.HTTPHeader]: string; - }; - /** Add HTTP headers */ - add_headers?: { - [key: types.HTTPHeader]: string; - }; - /** Hide HTTP headers */ - hide_headers?: types.HTTPHeader[]; - /** Add prefix to request URL */ - add_prefix?: string; + use: + | "request" + | "Request" + | "modify_request" + | "modifyRequest" + | "ModifyRequest"; + /** Set HTTP headers */ + set_headers?: { + [key: types.HTTPHeader]: string; + }; + /** Add HTTP headers */ + add_headers?: { + [key: types.HTTPHeader]: string; + }; + /** Hide HTTP headers */ + hide_headers?: types.HTTPHeader[]; + /** Add prefix to request URL */ + add_prefix?: string; }; export type ModifyResponse = { - use: "response" | "Response" | "modify_response" | "modifyResponse" | "ModifyResponse"; - /** Set HTTP headers */ - set_headers?: { - [key: types.HTTPHeader]: string; - }; - /** Add HTTP headers */ - add_headers?: { - [key: types.HTTPHeader]: string; - }; - /** Hide HTTP headers */ - hide_headers?: types.HTTPHeader[]; + use: + | "response" + | "Response" + | "modify_response" + | "modifyResponse" + | "ModifyResponse"; + /** Set HTTP headers */ + set_headers?: { + [key: types.HTTPHeader]: string; + }; + /** Add HTTP headers */ + add_headers?: { + [key: types.HTTPHeader]: string; + }; + /** Hide HTTP headers */ + hide_headers?: types.HTTPHeader[]; }; export type OIDC = { - use: "oidc" | "OIDC"; - /** Allowed users - * - * @minItems 1 - */ - allowed_users?: string[]; - /** Allowed groups - * - * @minItems 1 - */ - allowed_groups?: string[]; + use: "oidc" | "OIDC"; + /** Allowed users + * + * @minItems 1 + */ + allowed_users?: string[]; + /** Allowed groups + * + * @minItems 1 + */ + allowed_groups?: string[]; }; export type RateLimit = { - use: "rate_limit" | "rateLimit" | "RateLimit"; - /** Average number of requests allowed in a period - * - * @min 1 - */ - average: number; - /** Maximum number of requests allowed in a period - * - * @min 1 - */ - burst: number; - /** Duration of the rate limit - * - * @default 1s - */ - period?: types.Duration; + use: "rate_limit" | "rateLimit" | "RateLimit"; + /** Average number of requests allowed in a period + * + * @min 1 + */ + average: number; + /** Maximum number of requests allowed in a period + * + * @min 1 + */ + burst: number; + /** Duration of the rate limit + * + * @default 1s + */ + period?: types.Duration; }; export type RealIP = { - use: "real_ip" | "realIP" | "RealIP"; - /** Header to get the client IP from - * - * @default "X-Real-IP" - */ - header?: types.HTTPHeader; - from: types.CIDR[]; - /** Recursive resolve the IP - * - * @default false - */ - recursive?: boolean; + use: "real_ip" | "realIP" | "RealIP"; + /** Header to get the client IP from + * + * @default "X-Real-IP" + */ + header?: types.HTTPHeader; + from: types.CIDR[]; + /** Recursive resolve the IP + * + * @default false + */ + recursive?: boolean; }; diff --git a/schemas/package.json b/schemas/package.json index 5a34c4f..1ca9a19 100644 --- a/schemas/package.json +++ b/schemas/package.json @@ -1,6 +1,6 @@ { "name": "godoxy-schemas", - "version": "0.10.0-3", + "version": "0.10.1-6", "description": "JSON Schema and typescript types for GoDoxy configuration", "license": "MIT", "repository": { @@ -8,9 +8,11 @@ "url": "https://github.com/yusing/godoxy" }, "files": [ - "schemas/", - "README.md", - "LICENSE" + "**/*.ts", + "**/*.js", + "*.schema.json", + "../README.md", + "../LICENSE" ], "type": "module", "main": "./index.ts", @@ -22,15 +24,14 @@ } }, "devDependencies": { - "prettier": "^3.4.2", - "typescript": "^5.7.3", + "prettier": "^3.5.3", + "typescript": "^5.8.3", "typescript-json-schema": "^0.65.1" }, "displayName": "GoDoxy Types", - "packageManager": "bun@1.2.0", + "packageManager": "bun@1.2.9", "publisher": "yusing", "scripts": { - "gen-schema": "make gen-schema", - "format:write": "prettier --write \"schemas/**/*.ts\" --cache" + "format:write": "prettier --write \"**/*.ts\" --cache" } } \ No newline at end of file diff --git a/schemas/providers/healthcheck.d.ts b/schemas/providers/healthcheck.d.ts index c819e54..05b6df5 100644 --- a/schemas/providers/healthcheck.d.ts +++ b/schemas/providers/healthcheck.d.ts @@ -3,30 +3,30 @@ import { Duration, URI } from "../types"; * @additionalProperties false */ export type HealthcheckConfig = { - /** Disable healthcheck - * - * @default false - */ - disable?: boolean; - /** Healthcheck path - * - * @default / - */ - path?: URI; - /** - * Use GET instead of HEAD - * - * @default false - */ - use_get?: boolean; - /** Healthcheck interval - * - * @default 5s - */ - interval?: Duration; - /** Healthcheck timeout - * - * @default 5s - */ - timeout?: Duration; + /** Disable healthcheck + * + * @default false + */ + disable?: boolean; + /** Healthcheck path + * + * @default / + */ + path?: URI; + /** + * Use GET instead of HEAD + * + * @default false + */ + use_get?: boolean; + /** Healthcheck interval + * + * @default 5s + */ + interval?: Duration; + /** Healthcheck timeout + * + * @default 5s + */ + timeout?: Duration; }; diff --git a/schemas/providers/homepage.d.ts b/schemas/providers/homepage.d.ts index db64be9..a51a129 100644 --- a/schemas/providers/homepage.d.ts +++ b/schemas/providers/homepage.d.ts @@ -3,19 +3,19 @@ import { URL } from "../types"; * @additionalProperties false */ export type HomepageConfig = { - /** Whether show in dashboard - * - * @default true - */ - show?: boolean; - name?: string; - icon?: URL | WalkxcodeIcon | ExternalIcon | TargetRelativeIconPath; - description?: string; - url?: URL; - category?: string; - widget_config?: { - [key: string]: any; - }; + /** Whether show in dashboard + * + * @default true + */ + show?: boolean; + name?: string; + icon?: URL | WalkxcodeIcon | ExternalIcon | TargetRelativeIconPath; + description?: string; + url?: URL; + category?: string; + widget_config?: { + [key: string]: any; + }; }; /** Walkxcode icon * diff --git a/schemas/providers/idlewatcher.d.ts b/schemas/providers/idlewatcher.d.ts index d9aed44..ed5daf5 100644 --- a/schemas/providers/idlewatcher.d.ts +++ b/schemas/providers/idlewatcher.d.ts @@ -1,25 +1,35 @@ import { Duration, URI } from "../types"; export declare const STOP_METHODS: readonly ["pause", "stop", "kill"]; export type StopMethod = (typeof STOP_METHODS)[number]; -export declare const STOP_SIGNALS: readonly ["", "SIGINT", "SIGTERM", "SIGHUP", "SIGQUIT", "INT", "TERM", "HUP", "QUIT"]; +export declare const STOP_SIGNALS: readonly [ + "", + "SIGINT", + "SIGTERM", + "SIGHUP", + "SIGQUIT", + "INT", + "TERM", + "HUP", + "QUIT", +]; export type Signal = (typeof STOP_SIGNALS)[number]; export type IdleWatcherConfig = { - idle_timeout?: Duration; - /** Wake timeout - * - * @default 30s - */ - wake_timeout?: Duration; - /** Stop timeout - * - * @default 30s - */ - stop_timeout?: Duration; - /** Stop method - * - * @default stop - */ - stop_method?: StopMethod; - stop_signal?: Signal; - start_endpoint?: URI; + idle_timeout?: Duration; + /** Wake timeout + * + * @default 30s + */ + wake_timeout?: Duration; + /** Stop timeout + * + * @default 30s + */ + stop_timeout?: Duration; + /** Stop method + * + * @default stop + */ + stop_method?: StopMethod; + stop_signal?: Signal; + start_endpoint?: URI; }; diff --git a/schemas/providers/loadbalance.d.ts b/schemas/providers/loadbalance.d.ts index 8ea4243..c2ccdcd 100644 --- a/schemas/providers/loadbalance.d.ts +++ b/schemas/providers/loadbalance.d.ts @@ -1,28 +1,38 @@ import { RealIP } from "../middlewares/middlewares"; -export declare const LOAD_BALANCE_MODES: readonly ["round_robin", "least_conn", "ip_hash"]; +export declare const LOAD_BALANCE_MODES: readonly [ + "round_robin", + "least_conn", + "ip_hash", +]; export type LoadBalanceMode = (typeof LOAD_BALANCE_MODES)[number]; export type LoadBalanceConfigBase = { - /** Alias (subdomain or FDN) of load-balancer - * - * @minLength 1 - */ - link: string; - /** Load-balance weight (reserved for future use) - * - * @minimum 0 - * @maximum 100 - */ - weight?: number; + /** Alias (subdomain or FDN) of load-balancer + * + * @minLength 1 + */ + link: string; + /** Load-balance weight (reserved for future use) + * + * @minimum 0 + * @maximum 100 + */ + weight?: number; }; -export type LoadBalanceConfig = LoadBalanceConfigBase & ({} | RoundRobinLoadBalanceConfig | LeastConnLoadBalanceConfig | IPHashLoadBalanceConfig); +export type LoadBalanceConfig = LoadBalanceConfigBase & + ( + | {} + | RoundRobinLoadBalanceConfig + | LeastConnLoadBalanceConfig + | IPHashLoadBalanceConfig + ); export type IPHashLoadBalanceConfig = { - mode: "ip_hash"; - /** Real IP config, header to get client IP from */ - config: RealIP; + mode: "ip_hash"; + /** Real IP config, header to get client IP from */ + config: RealIP; }; export type LeastConnLoadBalanceConfig = { - mode: "least_conn"; + mode: "least_conn"; }; export type RoundRobinLoadBalanceConfig = { - mode: "round_robin"; + mode: "round_robin"; }; diff --git a/schemas/providers/routes.d.ts b/schemas/providers/routes.d.ts index 1a5564b..4aaed50 100644 --- a/schemas/providers/routes.d.ts +++ b/schemas/providers/routes.d.ts @@ -1,7 +1,15 @@ import { AccessLogConfig } from "../config/access_log"; import { accessLogExamples } from "../config/entrypoint"; import { MiddlewaresMap } from "../middlewares/middlewares"; -import { Duration, Hostname, IPv4, IPv6, PathPattern, Port, StreamPort } from "../types"; +import { + Duration, + Hostname, + IPv4, + IPv6, + PathPattern, + Port, + StreamPort, +} from "../types"; import { HealthcheckConfig } from "./healthcheck"; import { HomepageConfig } from "./homepage"; import { LoadBalanceConfig } from "./loadbalance"; @@ -11,122 +19,130 @@ export type ProxyScheme = (typeof PROXY_SCHEMES)[number]; export type StreamScheme = (typeof STREAM_SCHEMES)[number]; export type Route = ReverseProxyRoute | FileServerRoute | StreamRoute; export type Routes = { - [key: string]: Route; + [key: string]: Route; }; export type ReverseProxyRoute = { - /** Alias (subdomain or FDN) - * @minLength 1 - */ - alias?: string; - /** Proxy scheme - * - * @default http - */ - scheme?: ProxyScheme; - /** Proxy host - * - * @default localhost - */ - host?: Hostname | IPv4 | IPv6; - /** Proxy port - * - * @default 80 - */ - port?: Port; - /** Skip TLS verification - * - * @default false - */ - no_tls_verify?: boolean; - /** Response header timeout - * - * @default 60s - */ - response_header_timeout?: Duration; - /** Path patterns (only patterns that match will be proxied). - * - * See https://pkg.go.dev/net/http#hdr-Patterns-ServeMux - */ - path_patterns?: PathPattern[]; - /** Healthcheck config */ - healthcheck?: HealthcheckConfig; - /** Load balance config */ - load_balance?: LoadBalanceConfig; - /** Middlewares */ - middlewares?: MiddlewaresMap; - /** Homepage config - * - * @examples require(".").homepageExamples - */ - homepage?: HomepageConfig; - /** Access log config - * - * @examples require(".").accessLogExamples - */ - access_log?: AccessLogConfig; + /** Alias (subdomain or FDN) + * @minLength 1 + */ + alias?: string; + /** Proxy scheme + * + * @default http + */ + scheme?: ProxyScheme; + /** Proxy host + * + * @default localhost + */ + host?: Hostname | IPv4 | IPv6; + /** Proxy port + * + * @default 80 + */ + port?: Port; + /** Skip TLS verification + * + * @default false + */ + no_tls_verify?: boolean; + /** Response header timeout + * + * @default 60s + */ + response_header_timeout?: Duration; + /** Path patterns (only patterns that match will be proxied). + * + * See https://pkg.go.dev/net/http#hdr-Patterns-ServeMux + */ + path_patterns?: PathPattern[]; + /** Healthcheck config */ + healthcheck?: HealthcheckConfig; + /** Load balance config */ + load_balance?: LoadBalanceConfig; + /** Middlewares */ + middlewares?: MiddlewaresMap; + /** Homepage config + * + * @examples require(".").homepageExamples + */ + homepage?: HomepageConfig; + /** Access log config + * + * @examples require(".").accessLogExamples + */ + access_log?: AccessLogConfig; }; export type FileServerRoute = { - /** Alias (subdomain or FDN) - * @minLength 1 - */ - alias?: string; - scheme: "fileserver"; - root: string; - /** Path patterns (only patterns that match will be proxied). - * - * See https://pkg.go.dev/net/http#hdr-Patterns-ServeMux - */ - path_patterns?: PathPattern[]; - /** Middlewares */ - middlewares?: MiddlewaresMap; - /** Homepage config - * - * @examples require(".").homepageExamples - */ - homepage?: HomepageConfig; - /** Access log config - * - * @examples require(".").accessLogExamples - */ - access_log?: AccessLogConfig; + /** Alias (subdomain or FDN) + * @minLength 1 + */ + alias?: string; + scheme: "fileserver"; + root: string; + /** Path patterns (only patterns that match will be proxied). + * + * See https://pkg.go.dev/net/http#hdr-Patterns-ServeMux + */ + path_patterns?: PathPattern[]; + /** Middlewares */ + middlewares?: MiddlewaresMap; + /** Homepage config + * + * @examples require(".").homepageExamples + */ + homepage?: HomepageConfig; + /** Access log config + * + * @examples require(".").accessLogExamples + */ + access_log?: AccessLogConfig; + /** Healthcheck config */ + healthcheck?: HealthcheckConfig; }; export type StreamRoute = { - /** Alias (subdomain or FDN) - * @minLength 1 - */ - alias?: string; - /** Stream scheme - * - * @default tcp - */ - scheme?: StreamScheme; - /** Stream host - * - * @default localhost - */ - host?: Hostname | IPv4 | IPv6; - port: StreamPort; - /** Healthcheck config */ - healthcheck?: HealthcheckConfig; + /** Alias (subdomain or FDN) + * @minLength 1 + */ + alias?: string; + /** Stream scheme + * + * @default tcp + */ + scheme?: StreamScheme; + /** Stream host + * + * @default localhost + */ + host?: Hostname | IPv4 | IPv6; + port: StreamPort; + /** Healthcheck config */ + healthcheck?: HealthcheckConfig; }; -export declare const homepageExamples: ({ - name: string; - icon: string; - category: string; -} | { - name: string; - icon: string; - category?: undefined; -})[]; -export declare const loadBalanceExamples: ({ - link: string; - mode: string; - config?: undefined; -} | { - link: string; - mode: string; - config: { +export declare const homepageExamples: ( + | { + name: string; + icon: string; + category: string; + } + | { + name: string; + icon: string; + category?: undefined; + } +)[]; +export declare const loadBalanceExamples: ( + | { + link: string; + mode: string; + config?: undefined; + } + | { + link: string; + mode: string; + config: { header: string; - }; -})[]; + }; + } +)[]; export { accessLogExamples }; diff --git a/schemas/providers/routes.ts b/schemas/providers/routes.ts index 86d4375..29ab6d8 100644 --- a/schemas/providers/routes.ts +++ b/schemas/providers/routes.ts @@ -1,7 +1,15 @@ import { AccessLogConfig } from "../config/access_log"; import { accessLogExamples } from "../config/entrypoint"; import { MiddlewaresMap } from "../middlewares/middlewares"; -import { Duration, Hostname, IPv4, IPv6, PathPattern, Port, StreamPort } from "../types"; +import { + Duration, + Hostname, + IPv4, + IPv6, + PathPattern, + Port, + StreamPort, +} from "../types"; import { HealthcheckConfig } from "./healthcheck"; import { HomepageConfig } from "./homepage"; import { LoadBalanceConfig } from "./loadbalance"; @@ -94,7 +102,9 @@ export type FileServerRoute = { * @examples require(".").accessLogExamples */ access_log?: AccessLogConfig; -} + /** Healthcheck config */ + healthcheck?: HealthcheckConfig; +}; export type StreamRoute = { /** Alias (subdomain or FDN) diff --git a/schemas/routes.schema.json b/schemas/routes.schema.json index 25a59bb..5c2be97 100644 --- a/schemas/routes.schema.json +++ b/schemas/routes.schema.json @@ -1 +1 @@ -{"$schema":"http://json-schema.org/draft-07/schema#","additionalProperties":{"$ref":"#/definitions/Route"},"definitions":{"AccessLogFieldMode":{"enum":["drop","keep","redact"],"type":"string"},"AccessLogFormat":{"enum":["combined","common","json"],"type":"string"},"CIDR":{"anyOf":[{"pattern":"^[0-9]*\\.[0-9]*\\.[0-9]*\\.[0-9]*$","type":"string"},{"pattern":"^.*:.*:.*:.*:.*:.*:.*:.*$","type":"string"},{"pattern":"^[0-9]*\\.[0-9]*\\.[0-9]*\\.[0-9]*/[0-9]*$","type":"string"},{"pattern":"^::[0-9]*$","type":"string"},{"pattern":"^.*::/[0-9]*$","type":"string"},{"pattern":"^.*:.*::/[0-9]*$","type":"string"}]},"Duration":{"pattern":"^([0-9]+(ms|s|m|h))+$","type":"string"},"HTTPHeader":{"description":"HTTP Header","pattern":"^[a-zA-Z0-9\\-]+$","type":"string"},"LoadBalanceConfig":{"anyOf":[{"additionalProperties":false,"properties":{"link":{"description":"Alias (subdomain or FDN) of load-balancer","minLength":1,"type":"string"},"weight":{"description":"Load-balance weight (reserved for future use)","maximum":100,"minimum":0,"type":"number"}},"required":["link"],"type":"object"},{"additionalProperties":false,"properties":{"link":{"description":"Alias (subdomain or FDN) of load-balancer","minLength":1,"type":"string"},"mode":{"const":"round_robin","type":"string"},"weight":{"description":"Load-balance weight (reserved for future use)","maximum":100,"minimum":0,"type":"number"}},"required":["link","mode"],"type":"object"},{"additionalProperties":false,"properties":{"link":{"description":"Alias (subdomain or FDN) of load-balancer","minLength":1,"type":"string"},"mode":{"const":"least_conn","type":"string"},"weight":{"description":"Load-balance weight (reserved for future use)","maximum":100,"minimum":0,"type":"number"}},"required":["link","mode"],"type":"object"},{"additionalProperties":false,"properties":{"config":{"additionalProperties":false,"description":"Real IP config, header to get client IP from","properties":{"from":{"items":{"$ref":"#/definitions/CIDR"},"type":"array"},"header":{"$ref":"#/definitions/HTTPHeader","default":"X-Real-IP","description":"Header to get the client IP from"},"recursive":{"default":false,"description":"Recursive resolve the IP","type":"boolean"},"use":{"enum":["RealIP","realIP","real_ip"],"type":"string"}},"required":["from","use"],"type":"object"},"link":{"description":"Alias (subdomain or FDN) of load-balancer","minLength":1,"type":"string"},"mode":{"const":"ip_hash","type":"string"},"weight":{"description":"Load-balance weight (reserved for future use)","maximum":100,"minimum":0,"type":"number"}},"required":["config","link","mode"],"type":"object"}]},"MiddlewaresMap":{"anyOf":[{"additionalProperties":false,"properties":{"CIDRWhitelist":{"$ref":"#/definitions/Omit"},"CloudflareRealIP":{"$ref":"#/definitions/Omit"},"CustomErrorPage":{"$ref":"#/definitions/Omit"},"ErrorPage":{"$ref":"#/definitions/Omit"},"HideXForwarded":{"$ref":"#/definitions/Omit"},"ModifyRequest":{"$ref":"#/definitions/Omit"},"ModifyResponse":{"$ref":"#/definitions/Omit"},"OIDC":{"$ref":"#/definitions/Omit"},"RateLimit":{"$ref":"#/definitions/Omit"},"RealIP":{"$ref":"#/definitions/Omit"},"RedirectHTTP":{"$ref":"#/definitions/Omit"},"Request":{"$ref":"#/definitions/Omit"},"Response":{"$ref":"#/definitions/Omit"},"SetXForwarded":{"$ref":"#/definitions/Omit"},"cidrWhitelist":{"$ref":"#/definitions/Omit"},"cidr_whitelist":{"$ref":"#/definitions/Omit"},"cloudflareRealIp":{"$ref":"#/definitions/Omit"},"cloudflare_real_ip":{"$ref":"#/definitions/Omit"},"customErrorPage":{"$ref":"#/definitions/Omit"},"custom_error_page":{"$ref":"#/definitions/Omit"},"errorPage":{"$ref":"#/definitions/Omit"},"error_page":{"$ref":"#/definitions/Omit"},"hideXForwarded":{"$ref":"#/definitions/Omit"},"hide_x_forwarded":{"$ref":"#/definitions/Omit"},"modifyRequest":{"$ref":"#/definitions/Omit"},"modifyResponse":{"$ref":"#/definitions/Omit"},"modify_request":{"$ref":"#/definitions/Omit"},"modify_response":{"$ref":"#/definitions/Omit"},"oidc":{"$ref":"#/definitions/Omit"},"rateLimit":{"$ref":"#/definitions/Omit"},"rate_limit":{"$ref":"#/definitions/Omit"},"realIP":{"$ref":"#/definitions/Omit"},"real_ip":{"$ref":"#/definitions/Omit"},"redirectHTTP":{"$ref":"#/definitions/Omit"},"redirect_http":{"$ref":"#/definitions/Omit"},"request":{"$ref":"#/definitions/Omit"},"response":{"$ref":"#/definitions/Omit"},"setXForwarded":{"$ref":"#/definitions/Omit"},"set_x_forwarded":{"$ref":"#/definitions/Omit"}},"type":"object"},{"type":"object"}]},"Omit":{"additionalProperties":false,"properties":{"allow":{"items":{"$ref":"#/definitions/CIDR"},"type":"array"},"message":{"default":"IP not allowed","description":"Error message when blocked","type":"string"},"status":{"$ref":"#/definitions/StatusCode","default":403,"description":"HTTP status code when blocked (alias of status_code)"},"status_code":{"$ref":"#/definitions/StatusCode","default":403,"description":"HTTP status code when blocked"}},"required":["allow"],"type":"object"},"Omit":{"additionalProperties":false,"properties":{"recursive":{"default":false,"description":"Recursively resolve the IP","type":"boolean"}},"type":"object"},"Omit":{"additionalProperties":false,"type":"object"},"Omit":{"additionalProperties":false,"type":"object"},"Omit":{"additionalProperties":false,"properties":{"add_headers":{"additionalProperties":false,"description":"Add HTTP headers","items":{"type":"string"},"type":"array"},"add_prefix":{"description":"Add prefix to request URL","type":"string"},"hide_headers":{"description":"Hide HTTP headers","items":{"$ref":"#/definitions/HTTPHeader"},"type":"array"},"set_headers":{"additionalProperties":false,"description":"Set HTTP headers","items":{"type":"string"},"type":"array"}},"type":"object"},"Omit":{"additionalProperties":false,"properties":{"add_headers":{"additionalProperties":false,"description":"Add HTTP headers","items":{"type":"string"},"type":"array"},"hide_headers":{"description":"Hide HTTP headers","items":{"$ref":"#/definitions/HTTPHeader"},"type":"array"},"set_headers":{"additionalProperties":false,"description":"Set HTTP headers","items":{"type":"string"},"type":"array"}},"type":"object"},"Omit":{"additionalProperties":false,"properties":{"allowed_groups":{"description":"Allowed groups","items":{"type":"string"},"minItems":1,"type":"array"},"allowed_users":{"description":"Allowed users","items":{"type":"string"},"minItems":1,"type":"array"}},"type":"object"},"Omit":{"additionalProperties":false,"properties":{"average":{"description":"Average number of requests allowed in a period","type":"number"},"burst":{"description":"Maximum number of requests allowed in a period","type":"number"},"period":{"$ref":"#/definitions/Duration","default":"1s","description":"Duration of the rate limit"}},"required":["average","burst"],"type":"object"},"Omit":{"additionalProperties":false,"properties":{"from":{"items":{"$ref":"#/definitions/CIDR"},"type":"array"},"header":{"$ref":"#/definitions/HTTPHeader","default":"X-Real-IP","description":"Header to get the client IP from"},"recursive":{"default":false,"description":"Recursive resolve the IP","type":"boolean"}},"required":["from"],"type":"object"},"Omit":{"additionalProperties":false,"properties":{"bypass":{"additionalProperties":false,"description":"Bypass redirect","properties":{"user_agents":{"description":"Bypass redirect for user agents","items":{"type":"string"},"type":"array"}},"type":"object"}},"type":"object"},"Omit":{"additionalProperties":false,"type":"object"},"PathPattern":{"pattern":"^(?:([A-Z]+) )?(?:([a-zA-Z0-9.-]+)\\\\/)?(\\\\/[^\\\\s]*)$","type":"string"},"Port":{"maximum":65535,"minimum":0,"type":"integer"},"ProxyScheme":{"enum":["http","https"],"type":"string"},"Route":{"anyOf":[{"additionalProperties":false,"properties":{"access_log":{"additionalProperties":false,"description":"Access log config","examples":[{"fields":{"headers":{"config":{"foo":"redact"},"default":"keep"}},"filters":{"status_codes":{"values":["200-299"]}},"format":"combined","path":"/var/log/access.log"}],"properties":{"buffer_size":{"default":65536,"description":"The size of the buffer.","minimum":0,"type":"integer"},"fields":{"additionalProperties":false,"properties":{"cookie":{"additionalProperties":false,"properties":{"config":{"additionalProperties":{"enum":["drop","keep","redact"],"type":"string"},"type":"object"},"default":{"$ref":"#/definitions/AccessLogFieldMode"}},"required":["config"],"type":"object"},"header":{"additionalProperties":false,"properties":{"config":{"additionalProperties":{"enum":["drop","keep","redact"],"type":"string"},"type":"object"},"default":{"$ref":"#/definitions/AccessLogFieldMode"}},"required":["config"],"type":"object"},"query":{"additionalProperties":false,"properties":{"config":{"additionalProperties":{"enum":["drop","keep","redact"],"type":"string"},"type":"object"},"default":{"$ref":"#/definitions/AccessLogFieldMode"}},"required":["config"],"type":"object"}},"type":"object"},"filters":{"additionalProperties":false,"properties":{"cidr":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"$ref":"#/definitions/CIDR"},"type":"array"}},"required":["values"],"type":"object"},"headers":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"$ref":"#/definitions/HTTPHeader"},"type":"array"}},"required":["values"],"type":"object"},"host":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"type":"string"},"type":"array"}},"required":["values"],"type":"object"},"method":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"enum":["CONNECT","DELETE","GET","HEAD","OPTIONS","PATCH","POST","PUT","TRACE"],"type":"string"},"type":"array"}},"required":["values"],"type":"object"},"status_code":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"$ref":"#/definitions/StatusCodeRange"},"type":"array"}},"required":["values"],"type":"object"}},"type":"object"},"format":{"$ref":"#/definitions/AccessLogFormat","default":"combined","description":"The format of the access log."},"path":{"$ref":"#/definitions/URI"}},"required":["path"],"type":"object"},"alias":{"description":"Alias (subdomain or FDN)","minLength":1,"type":"string"},"healthcheck":{"additionalProperties":false,"description":"Healthcheck config","properties":{"disable":{"default":false,"description":"Disable healthcheck","type":"boolean"},"interval":{"$ref":"#/definitions/Duration","default":"5s","description":"Healthcheck interval"},"path":{"$ref":"#/definitions/URI","default":"/","description":"Healthcheck path"},"timeout":{"$ref":"#/definitions/Duration","default":"5s","description":"Healthcheck timeout"},"use_get":{"default":false,"description":"Use GET instead of HEAD","type":"boolean"}},"type":"object"},"homepage":{"additionalProperties":false,"description":"Homepage config","examples":[{"category":"Arr suite","icon":"png/sonarr.png","name":"Sonarr"},{"icon":"@target/favicon.ico","name":"App"}],"properties":{"category":{"type":"string"},"description":{"type":"string"},"icon":{"anyOf":[{"format":"uri","type":"string"},{"description":"Walkxcode icon","pattern":"^(png|svg|webp)\\/[\\w\\d\\-_]+\\.\\1","type":"string"},{"pattern":"^@selfhst/.*\\..*$","type":"string"},{"pattern":"^@walkxcode/.*\\..*$","type":"string"},{"pattern":"^@target/.*$","type":"string"},{"pattern":"^/.*$","type":"string"}]},"name":{"type":"string"},"show":{"default":true,"description":"Whether show in dashboard","type":"boolean"},"url":{"$ref":"#/definitions/URL"},"widget_config":{"additionalProperties":{},"type":"object"}},"type":"object"},"host":{"anyOf":[{"format":"hostname","type":"string"},{"format":"ipv4","type":"string"},{"format":"ipv6","type":"string"}],"default":"localhost","description":"Proxy host"},"load_balance":{"$ref":"#/definitions/LoadBalanceConfig","description":"Load balance config"},"middlewares":{"$ref":"#/definitions/MiddlewaresMap","description":"Middlewares"},"no_tls_verify":{"default":false,"description":"Skip TLS verification","type":"boolean"},"path_patterns":{"description":"Path patterns (only patterns that match will be proxied).\n\nSee https://pkg.go.dev/net/http#hdr-Patterns-ServeMux","items":{"$ref":"#/definitions/PathPattern"},"type":"array"},"port":{"$ref":"#/definitions/Port","default":80,"description":"Proxy port"},"response_header_timeout":{"$ref":"#/definitions/Duration","default":"60s","description":"Response header timeout"},"scheme":{"$ref":"#/definitions/ProxyScheme","default":"http","description":"Proxy scheme"}},"type":"object"},{"additionalProperties":false,"properties":{"access_log":{"additionalProperties":false,"description":"Access log config","examples":[{"fields":{"headers":{"config":{"foo":"redact"},"default":"keep"}},"filters":{"status_codes":{"values":["200-299"]}},"format":"combined","path":"/var/log/access.log"}],"properties":{"buffer_size":{"default":65536,"description":"The size of the buffer.","minimum":0,"type":"integer"},"fields":{"additionalProperties":false,"properties":{"cookie":{"additionalProperties":false,"properties":{"config":{"additionalProperties":{"enum":["drop","keep","redact"],"type":"string"},"type":"object"},"default":{"$ref":"#/definitions/AccessLogFieldMode"}},"required":["config"],"type":"object"},"header":{"additionalProperties":false,"properties":{"config":{"additionalProperties":{"enum":["drop","keep","redact"],"type":"string"},"type":"object"},"default":{"$ref":"#/definitions/AccessLogFieldMode"}},"required":["config"],"type":"object"},"query":{"additionalProperties":false,"properties":{"config":{"additionalProperties":{"enum":["drop","keep","redact"],"type":"string"},"type":"object"},"default":{"$ref":"#/definitions/AccessLogFieldMode"}},"required":["config"],"type":"object"}},"type":"object"},"filters":{"additionalProperties":false,"properties":{"cidr":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"$ref":"#/definitions/CIDR"},"type":"array"}},"required":["values"],"type":"object"},"headers":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"$ref":"#/definitions/HTTPHeader"},"type":"array"}},"required":["values"],"type":"object"},"host":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"type":"string"},"type":"array"}},"required":["values"],"type":"object"},"method":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"enum":["CONNECT","DELETE","GET","HEAD","OPTIONS","PATCH","POST","PUT","TRACE"],"type":"string"},"type":"array"}},"required":["values"],"type":"object"},"status_code":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"$ref":"#/definitions/StatusCodeRange"},"type":"array"}},"required":["values"],"type":"object"}},"type":"object"},"format":{"$ref":"#/definitions/AccessLogFormat","default":"combined","description":"The format of the access log."},"path":{"$ref":"#/definitions/URI"}},"required":["path"],"type":"object"},"alias":{"description":"Alias (subdomain or FDN)","minLength":1,"type":"string"},"homepage":{"additionalProperties":false,"description":"Homepage config","examples":[{"category":"Arr suite","icon":"png/sonarr.png","name":"Sonarr"},{"icon":"@target/favicon.ico","name":"App"}],"properties":{"category":{"type":"string"},"description":{"type":"string"},"icon":{"anyOf":[{"format":"uri","type":"string"},{"description":"Walkxcode icon","pattern":"^(png|svg|webp)\\/[\\w\\d\\-_]+\\.\\1","type":"string"},{"pattern":"^@selfhst/.*\\..*$","type":"string"},{"pattern":"^@walkxcode/.*\\..*$","type":"string"},{"pattern":"^@target/.*$","type":"string"},{"pattern":"^/.*$","type":"string"}]},"name":{"type":"string"},"show":{"default":true,"description":"Whether show in dashboard","type":"boolean"},"url":{"$ref":"#/definitions/URL"},"widget_config":{"additionalProperties":{},"type":"object"}},"type":"object"},"middlewares":{"$ref":"#/definitions/MiddlewaresMap","description":"Middlewares"},"path_patterns":{"description":"Path patterns (only patterns that match will be proxied).\n\nSee https://pkg.go.dev/net/http#hdr-Patterns-ServeMux","items":{"$ref":"#/definitions/PathPattern"},"type":"array"},"root":{"type":"string"},"scheme":{"const":"fileserver","type":"string"}},"required":["root","scheme"],"type":"object"},{"additionalProperties":false,"properties":{"alias":{"description":"Alias (subdomain or FDN)","minLength":1,"type":"string"},"healthcheck":{"additionalProperties":false,"description":"Healthcheck config","properties":{"disable":{"default":false,"description":"Disable healthcheck","type":"boolean"},"interval":{"$ref":"#/definitions/Duration","default":"5s","description":"Healthcheck interval"},"path":{"$ref":"#/definitions/URI","default":"/","description":"Healthcheck path"},"timeout":{"$ref":"#/definitions/Duration","default":"5s","description":"Healthcheck timeout"},"use_get":{"default":false,"description":"Use GET instead of HEAD","type":"boolean"}},"type":"object"},"host":{"anyOf":[{"format":"hostname","type":"string"},{"format":"ipv4","type":"string"},{"format":"ipv6","type":"string"}],"default":"localhost","description":"Stream host"},"port":{"$ref":"#/definitions/StreamPort"},"scheme":{"$ref":"#/definitions/StreamScheme","default":"tcp","description":"Stream scheme"}},"required":["port"],"type":"object"}]},"StatusCode":{"anyOf":[{"pattern":"^[0-9]*$","type":"string"},{"type":"number"}]},"StatusCodeRange":{"anyOf":[{"pattern":"^[0-9]*$","type":"string"},{"pattern":"^[0-9]*-[0-9]*$","type":"string"},{"type":"number"}]},"StreamPort":{"pattern":"^\\d+:\\d+$","type":"string"},"StreamScheme":{"enum":["tcp","udp"],"type":"string"},"URI":{"format":"uri-reference","type":"string"},"URL":{"format":"uri","type":"string"}},"type":"object"} \ No newline at end of file +{"$schema":"http://json-schema.org/draft-07/schema#","additionalProperties":{"$ref":"#/definitions/Route"},"definitions":{"AccessLogFieldMode":{"enum":["drop","keep","redact"],"type":"string"},"AccessLogFormat":{"enum":["combined","common","json"],"type":"string"},"CIDR":{"anyOf":[{"pattern":"^[0-9]*\\.[0-9]*\\.[0-9]*\\.[0-9]*$","type":"string"},{"pattern":"^.*:.*:.*:.*:.*:.*:.*:.*$","type":"string"},{"pattern":"^[0-9]*\\.[0-9]*\\.[0-9]*\\.[0-9]*/[0-9]*$","type":"string"},{"pattern":"^::[0-9]*$","type":"string"},{"pattern":"^.*::/[0-9]*$","type":"string"},{"pattern":"^.*:.*::/[0-9]*$","type":"string"}]},"Duration":{"pattern":"^([0-9]+(ms|s|m|h))+$","type":"string"},"HTTPHeader":{"description":"HTTP Header","pattern":"^[a-zA-Z0-9\\-]+$","type":"string"},"LoadBalanceConfig":{"anyOf":[{"additionalProperties":false,"properties":{"link":{"description":"Alias (subdomain or FDN) of load-balancer","minLength":1,"type":"string"},"weight":{"description":"Load-balance weight (reserved for future use)","maximum":100,"minimum":0,"type":"number"}},"required":["link"],"type":"object"},{"additionalProperties":false,"properties":{"link":{"description":"Alias (subdomain or FDN) of load-balancer","minLength":1,"type":"string"},"mode":{"const":"round_robin","type":"string"},"weight":{"description":"Load-balance weight (reserved for future use)","maximum":100,"minimum":0,"type":"number"}},"required":["link","mode"],"type":"object"},{"additionalProperties":false,"properties":{"link":{"description":"Alias (subdomain or FDN) of load-balancer","minLength":1,"type":"string"},"mode":{"const":"least_conn","type":"string"},"weight":{"description":"Load-balance weight (reserved for future use)","maximum":100,"minimum":0,"type":"number"}},"required":["link","mode"],"type":"object"},{"additionalProperties":false,"properties":{"config":{"additionalProperties":false,"description":"Real IP config, header to get client IP from","properties":{"from":{"items":{"$ref":"#/definitions/CIDR"},"type":"array"},"header":{"$ref":"#/definitions/HTTPHeader","default":"X-Real-IP","description":"Header to get the client IP from"},"recursive":{"default":false,"description":"Recursive resolve the IP","type":"boolean"},"use":{"enum":["RealIP","realIP","real_ip"],"type":"string"}},"required":["from","use"],"type":"object"},"link":{"description":"Alias (subdomain or FDN) of load-balancer","minLength":1,"type":"string"},"mode":{"const":"ip_hash","type":"string"},"weight":{"description":"Load-balance weight (reserved for future use)","maximum":100,"minimum":0,"type":"number"}},"required":["config","link","mode"],"type":"object"}]},"MiddlewaresMap":{"anyOf":[{"additionalProperties":false,"properties":{"CIDRWhitelist":{"$ref":"#/definitions/Omit"},"CloudflareRealIP":{"$ref":"#/definitions/Omit"},"CustomErrorPage":{"$ref":"#/definitions/Omit"},"ErrorPage":{"$ref":"#/definitions/Omit"},"HideXForwarded":{"$ref":"#/definitions/Omit"},"ModifyRequest":{"$ref":"#/definitions/Omit"},"ModifyResponse":{"$ref":"#/definitions/Omit"},"OIDC":{"$ref":"#/definitions/Omit"},"RateLimit":{"$ref":"#/definitions/Omit"},"RealIP":{"$ref":"#/definitions/Omit"},"RedirectHTTP":{"$ref":"#/definitions/Omit"},"Request":{"$ref":"#/definitions/Omit"},"Response":{"$ref":"#/definitions/Omit"},"SetXForwarded":{"$ref":"#/definitions/Omit"},"cidrWhitelist":{"$ref":"#/definitions/Omit"},"cidr_whitelist":{"$ref":"#/definitions/Omit"},"cloudflareRealIp":{"$ref":"#/definitions/Omit"},"cloudflare_real_ip":{"$ref":"#/definitions/Omit"},"customErrorPage":{"$ref":"#/definitions/Omit"},"custom_error_page":{"$ref":"#/definitions/Omit"},"errorPage":{"$ref":"#/definitions/Omit"},"error_page":{"$ref":"#/definitions/Omit"},"hideXForwarded":{"$ref":"#/definitions/Omit"},"hide_x_forwarded":{"$ref":"#/definitions/Omit"},"modifyRequest":{"$ref":"#/definitions/Omit"},"modifyResponse":{"$ref":"#/definitions/Omit"},"modify_request":{"$ref":"#/definitions/Omit"},"modify_response":{"$ref":"#/definitions/Omit"},"oidc":{"$ref":"#/definitions/Omit"},"rateLimit":{"$ref":"#/definitions/Omit"},"rate_limit":{"$ref":"#/definitions/Omit"},"realIP":{"$ref":"#/definitions/Omit"},"real_ip":{"$ref":"#/definitions/Omit"},"redirectHTTP":{"$ref":"#/definitions/Omit"},"redirect_http":{"$ref":"#/definitions/Omit"},"request":{"$ref":"#/definitions/Omit"},"response":{"$ref":"#/definitions/Omit"},"setXForwarded":{"$ref":"#/definitions/Omit"},"set_x_forwarded":{"$ref":"#/definitions/Omit"}},"type":"object"},{"type":"object"}]},"Omit":{"additionalProperties":false,"properties":{"allow":{"items":{"$ref":"#/definitions/CIDR"},"type":"array"},"message":{"default":"IP not allowed","description":"Error message when blocked","type":"string"},"status":{"$ref":"#/definitions/StatusCode","default":403,"description":"HTTP status code when blocked (alias of status_code)"},"status_code":{"$ref":"#/definitions/StatusCode","default":403,"description":"HTTP status code when blocked"}},"required":["allow"],"type":"object"},"Omit":{"additionalProperties":false,"properties":{"recursive":{"default":false,"description":"Recursively resolve the IP","type":"boolean"}},"type":"object"},"Omit":{"additionalProperties":false,"type":"object"},"Omit":{"additionalProperties":false,"type":"object"},"Omit":{"additionalProperties":false,"properties":{"add_headers":{"additionalProperties":false,"description":"Add HTTP headers","items":{"type":"string"},"type":"array"},"add_prefix":{"description":"Add prefix to request URL","type":"string"},"hide_headers":{"description":"Hide HTTP headers","items":{"$ref":"#/definitions/HTTPHeader"},"type":"array"},"set_headers":{"additionalProperties":false,"description":"Set HTTP headers","items":{"type":"string"},"type":"array"}},"type":"object"},"Omit":{"additionalProperties":false,"properties":{"add_headers":{"additionalProperties":false,"description":"Add HTTP headers","items":{"type":"string"},"type":"array"},"hide_headers":{"description":"Hide HTTP headers","items":{"$ref":"#/definitions/HTTPHeader"},"type":"array"},"set_headers":{"additionalProperties":false,"description":"Set HTTP headers","items":{"type":"string"},"type":"array"}},"type":"object"},"Omit":{"additionalProperties":false,"properties":{"allowed_groups":{"description":"Allowed groups","items":{"type":"string"},"minItems":1,"type":"array"},"allowed_users":{"description":"Allowed users","items":{"type":"string"},"minItems":1,"type":"array"}},"type":"object"},"Omit":{"additionalProperties":false,"properties":{"average":{"description":"Average number of requests allowed in a period","type":"number"},"burst":{"description":"Maximum number of requests allowed in a period","type":"number"},"period":{"$ref":"#/definitions/Duration","default":"1s","description":"Duration of the rate limit"}},"required":["average","burst"],"type":"object"},"Omit":{"additionalProperties":false,"properties":{"from":{"items":{"$ref":"#/definitions/CIDR"},"type":"array"},"header":{"$ref":"#/definitions/HTTPHeader","default":"X-Real-IP","description":"Header to get the client IP from"},"recursive":{"default":false,"description":"Recursive resolve the IP","type":"boolean"}},"required":["from"],"type":"object"},"Omit":{"additionalProperties":false,"properties":{"bypass":{"additionalProperties":false,"description":"Bypass redirect","properties":{"user_agents":{"description":"Bypass redirect for user agents","items":{"type":"string"},"type":"array"}},"type":"object"}},"type":"object"},"Omit":{"additionalProperties":false,"type":"object"},"PathPattern":{"pattern":"^(?:([A-Z]+) )?(?:([a-zA-Z0-9.-]+)\\\\/)?(\\\\/[^\\\\s]*)$","type":"string"},"Port":{"maximum":65535,"minimum":0,"type":"integer"},"ProxyScheme":{"enum":["http","https"],"type":"string"},"Route":{"anyOf":[{"additionalProperties":false,"properties":{"access_log":{"additionalProperties":false,"description":"Access log config","examples":[{"fields":{"headers":{"config":{"foo":"redact"},"default":"keep"}},"filters":{"status_codes":{"values":["200-299"]}},"format":"combined","path":"/var/log/access.log"}],"properties":{"buffer_size":{"default":65536,"description":"The size of the buffer.","minimum":0,"type":"integer"},"fields":{"additionalProperties":false,"properties":{"cookie":{"additionalProperties":false,"properties":{"config":{"additionalProperties":{"enum":["drop","keep","redact"],"type":"string"},"type":"object"},"default":{"$ref":"#/definitions/AccessLogFieldMode"}},"required":["config"],"type":"object"},"header":{"additionalProperties":false,"properties":{"config":{"additionalProperties":{"enum":["drop","keep","redact"],"type":"string"},"type":"object"},"default":{"$ref":"#/definitions/AccessLogFieldMode"}},"required":["config"],"type":"object"},"query":{"additionalProperties":false,"properties":{"config":{"additionalProperties":{"enum":["drop","keep","redact"],"type":"string"},"type":"object"},"default":{"$ref":"#/definitions/AccessLogFieldMode"}},"required":["config"],"type":"object"}},"type":"object"},"filters":{"additionalProperties":false,"properties":{"cidr":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"$ref":"#/definitions/CIDR"},"type":"array"}},"required":["values"],"type":"object"},"headers":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"$ref":"#/definitions/HTTPHeader"},"type":"array"}},"required":["values"],"type":"object"},"host":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"type":"string"},"type":"array"}},"required":["values"],"type":"object"},"method":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"enum":["CONNECT","DELETE","GET","HEAD","OPTIONS","PATCH","POST","PUT","TRACE"],"type":"string"},"type":"array"}},"required":["values"],"type":"object"},"status_code":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"$ref":"#/definitions/StatusCodeRange"},"type":"array"}},"required":["values"],"type":"object"}},"type":"object"},"format":{"$ref":"#/definitions/AccessLogFormat","default":"combined","description":"The format of the access log."},"path":{"$ref":"#/definitions/URI"}},"required":["path"],"type":"object"},"alias":{"description":"Alias (subdomain or FDN)","minLength":1,"type":"string"},"healthcheck":{"additionalProperties":false,"description":"Healthcheck config","properties":{"disable":{"default":false,"description":"Disable healthcheck","type":"boolean"},"interval":{"$ref":"#/definitions/Duration","default":"5s","description":"Healthcheck interval"},"path":{"$ref":"#/definitions/URI","default":"/","description":"Healthcheck path"},"timeout":{"$ref":"#/definitions/Duration","default":"5s","description":"Healthcheck timeout"},"use_get":{"default":false,"description":"Use GET instead of HEAD","type":"boolean"}},"type":"object"},"homepage":{"additionalProperties":false,"description":"Homepage config","examples":[{"category":"Arr suite","icon":"png/sonarr.png","name":"Sonarr"},{"icon":"@target/favicon.ico","name":"App"}],"properties":{"category":{"type":"string"},"description":{"type":"string"},"icon":{"anyOf":[{"format":"uri","type":"string"},{"description":"Walkxcode icon","pattern":"^(png|svg|webp)\\/[\\w\\d\\-_]+\\.\\1","type":"string"},{"pattern":"^@selfhst/.*\\..*$","type":"string"},{"pattern":"^@walkxcode/.*\\..*$","type":"string"},{"pattern":"^@target/.*$","type":"string"},{"pattern":"^/.*$","type":"string"}]},"name":{"type":"string"},"show":{"default":true,"description":"Whether show in dashboard","type":"boolean"},"url":{"$ref":"#/definitions/URL"},"widget_config":{"additionalProperties":{},"type":"object"}},"type":"object"},"host":{"anyOf":[{"format":"hostname","type":"string"},{"format":"ipv4","type":"string"},{"format":"ipv6","type":"string"}],"default":"localhost","description":"Proxy host"},"load_balance":{"$ref":"#/definitions/LoadBalanceConfig","description":"Load balance config"},"middlewares":{"$ref":"#/definitions/MiddlewaresMap","description":"Middlewares"},"no_tls_verify":{"default":false,"description":"Skip TLS verification","type":"boolean"},"path_patterns":{"description":"Path patterns (only patterns that match will be proxied).\n\nSee https://pkg.go.dev/net/http#hdr-Patterns-ServeMux","items":{"$ref":"#/definitions/PathPattern"},"type":"array"},"port":{"$ref":"#/definitions/Port","default":80,"description":"Proxy port"},"response_header_timeout":{"$ref":"#/definitions/Duration","default":"60s","description":"Response header timeout"},"scheme":{"$ref":"#/definitions/ProxyScheme","default":"http","description":"Proxy scheme"}},"type":"object"},{"additionalProperties":false,"properties":{"access_log":{"additionalProperties":false,"description":"Access log config","examples":[{"fields":{"headers":{"config":{"foo":"redact"},"default":"keep"}},"filters":{"status_codes":{"values":["200-299"]}},"format":"combined","path":"/var/log/access.log"}],"properties":{"buffer_size":{"default":65536,"description":"The size of the buffer.","minimum":0,"type":"integer"},"fields":{"additionalProperties":false,"properties":{"cookie":{"additionalProperties":false,"properties":{"config":{"additionalProperties":{"enum":["drop","keep","redact"],"type":"string"},"type":"object"},"default":{"$ref":"#/definitions/AccessLogFieldMode"}},"required":["config"],"type":"object"},"header":{"additionalProperties":false,"properties":{"config":{"additionalProperties":{"enum":["drop","keep","redact"],"type":"string"},"type":"object"},"default":{"$ref":"#/definitions/AccessLogFieldMode"}},"required":["config"],"type":"object"},"query":{"additionalProperties":false,"properties":{"config":{"additionalProperties":{"enum":["drop","keep","redact"],"type":"string"},"type":"object"},"default":{"$ref":"#/definitions/AccessLogFieldMode"}},"required":["config"],"type":"object"}},"type":"object"},"filters":{"additionalProperties":false,"properties":{"cidr":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"$ref":"#/definitions/CIDR"},"type":"array"}},"required":["values"],"type":"object"},"headers":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"$ref":"#/definitions/HTTPHeader"},"type":"array"}},"required":["values"],"type":"object"},"host":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"type":"string"},"type":"array"}},"required":["values"],"type":"object"},"method":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"enum":["CONNECT","DELETE","GET","HEAD","OPTIONS","PATCH","POST","PUT","TRACE"],"type":"string"},"type":"array"}},"required":["values"],"type":"object"},"status_code":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"$ref":"#/definitions/StatusCodeRange"},"type":"array"}},"required":["values"],"type":"object"}},"type":"object"},"format":{"$ref":"#/definitions/AccessLogFormat","default":"combined","description":"The format of the access log."},"path":{"$ref":"#/definitions/URI"}},"required":["path"],"type":"object"},"alias":{"description":"Alias (subdomain or FDN)","minLength":1,"type":"string"},"healthcheck":{"additionalProperties":false,"description":"Healthcheck config","properties":{"disable":{"default":false,"description":"Disable healthcheck","type":"boolean"},"interval":{"$ref":"#/definitions/Duration","default":"5s","description":"Healthcheck interval"},"path":{"$ref":"#/definitions/URI","default":"/","description":"Healthcheck path"},"timeout":{"$ref":"#/definitions/Duration","default":"5s","description":"Healthcheck timeout"},"use_get":{"default":false,"description":"Use GET instead of HEAD","type":"boolean"}},"type":"object"},"homepage":{"additionalProperties":false,"description":"Homepage config","examples":[{"category":"Arr suite","icon":"png/sonarr.png","name":"Sonarr"},{"icon":"@target/favicon.ico","name":"App"}],"properties":{"category":{"type":"string"},"description":{"type":"string"},"icon":{"anyOf":[{"format":"uri","type":"string"},{"description":"Walkxcode icon","pattern":"^(png|svg|webp)\\/[\\w\\d\\-_]+\\.\\1","type":"string"},{"pattern":"^@selfhst/.*\\..*$","type":"string"},{"pattern":"^@walkxcode/.*\\..*$","type":"string"},{"pattern":"^@target/.*$","type":"string"},{"pattern":"^/.*$","type":"string"}]},"name":{"type":"string"},"show":{"default":true,"description":"Whether show in dashboard","type":"boolean"},"url":{"$ref":"#/definitions/URL"},"widget_config":{"additionalProperties":{},"type":"object"}},"type":"object"},"middlewares":{"$ref":"#/definitions/MiddlewaresMap","description":"Middlewares"},"path_patterns":{"description":"Path patterns (only patterns that match will be proxied).\n\nSee https://pkg.go.dev/net/http#hdr-Patterns-ServeMux","items":{"$ref":"#/definitions/PathPattern"},"type":"array"},"root":{"type":"string"},"scheme":{"const":"fileserver","type":"string"}},"required":["root","scheme"],"type":"object"},{"additionalProperties":false,"properties":{"alias":{"description":"Alias (subdomain or FDN)","minLength":1,"type":"string"},"healthcheck":{"additionalProperties":false,"description":"Healthcheck config","properties":{"disable":{"default":false,"description":"Disable healthcheck","type":"boolean"},"interval":{"$ref":"#/definitions/Duration","default":"5s","description":"Healthcheck interval"},"path":{"$ref":"#/definitions/URI","default":"/","description":"Healthcheck path"},"timeout":{"$ref":"#/definitions/Duration","default":"5s","description":"Healthcheck timeout"},"use_get":{"default":false,"description":"Use GET instead of HEAD","type":"boolean"}},"type":"object"},"host":{"anyOf":[{"format":"hostname","type":"string"},{"format":"ipv4","type":"string"},{"format":"ipv6","type":"string"}],"default":"localhost","description":"Stream host"},"port":{"$ref":"#/definitions/StreamPort"},"scheme":{"$ref":"#/definitions/StreamScheme","default":"tcp","description":"Stream scheme"}},"required":["port"],"type":"object"}]},"StatusCode":{"anyOf":[{"pattern":"^[0-9]*$","type":"string"},{"type":"number"}]},"StatusCodeRange":{"anyOf":[{"pattern":"^[0-9]*$","type":"string"},{"pattern":"^[0-9]*-[0-9]*$","type":"string"},{"type":"number"}]},"StreamPort":{"pattern":"^\\d+:\\d+$","type":"string"},"StreamScheme":{"enum":["tcp","udp"],"type":"string"},"URI":{"format":"uri-reference","type":"string"},"URL":{"format":"uri","type":"string"}},"type":"object"} \ No newline at end of file diff --git a/schemas/tsconfig.json b/schemas/tsconfig.json index 3a87871..5ac8e5d 100644 --- a/schemas/tsconfig.json +++ b/schemas/tsconfig.json @@ -5,12 +5,14 @@ "target": "ESNext", "module": "ESNext", "moduleResolution": "Node", - "strict": true, - "esModuleInterop": true, + "strict": false, + "esModuleInterop": false, "forceConsistentCasingInFileNames": true, - "allowJs": true, + "allowJs": false, "resolveJsonModule": true, - "declaration": true + "declaration": true, + "allowSyntheticDefaultImports": true }, - "include": ["."] + "include": ["**/*.ts"], + "exclude": ["node_modules"] } diff --git a/schemas/types.d.ts b/schemas/types.d.ts index 1031c77..f5390ae 100644 --- a/schemas/types.d.ts +++ b/schemas/types.d.ts @@ -4,7 +4,17 @@ export type Null = null; export type Nullable = T | Null; export type NullOrEmptyMap = {} | Null; -export declare const HTTP_METHODS: readonly ["GET", "POST", "PUT", "PATCH", "DELETE", "CONNECT", "HEAD", "OPTIONS", "TRACE"]; +export declare const HTTP_METHODS: readonly [ + "GET", + "POST", + "PUT", + "PATCH", + "DELETE", + "CONNECT", + "HEAD", + "OPTIONS", + "TRACE", +]; export type HTTPMethod = (typeof HTTP_METHODS)[number]; /** * HTTP Header @@ -49,7 +59,13 @@ export type IPv4 = string & {}; * @type string */ export type IPv6 = string & {}; -export type CIDR = `${number}.${number}.${number}.${number}` | `${string}:${string}:${string}:${string}:${string}:${string}:${string}:${string}` | `${number}.${number}.${number}.${number}/${number}` | `::${number}` | `${string}::/${number}` | `${string}:${string}::/${number}`; +export type CIDR = + | `${number}.${number}.${number}.${number}` + | `${string}:${string}:${string}:${string}:${string}:${string}:${string}:${string}` + | `${number}.${number}.${number}.${number}/${number}` + | `::${number}` + | `${string}::/${number}` + | `${string}:${string}::/${number}`; /** * @type integer * @minimum 0