diff --git a/.gitignore b/.gitignore index 26ec954..8b9f0b7 100755 --- a/.gitignore +++ b/.gitignore @@ -3,8 +3,14 @@ compose.yml config/ certs/ bin/ + templates/codemirror/ logs/ log/ -.vscode/settings.json \ No newline at end of file + +.vscode/settings.json + +go.work.sum + +!src/config/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index c0bc13e..8dfeee6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,17 +3,15 @@ RUN apk add --no-cache unzip wget make COPY Makefile . RUN make setup-codemirror -FROM golang:1.22.2-alpine as builder -COPY src/ /src -COPY go.mod go.sum /src/go-proxy -WORKDIR /src/go-proxy +FROM golang:1.22.4-alpine as builder +COPY src /src +ENV GOCACHE=/root/.cache/go-build +WORKDIR /src RUN --mount=type=cache,target="/go/pkg/mod" \ go mod download - -ENV GOCACHE=/root/.cache/go-build RUN --mount=type=cache,target="/go/pkg/mod" \ --mount=type=cache,target="/root/.cache/go-build" \ - CGO_ENABLED=0 GOOS=linux go build -pgo=auto -o go-proxy + CGO_ENABLED=0 GOOS=linux go build -pgo=auto -o go-proxy github.com/yusing/go-proxy FROM alpine:latest @@ -33,7 +31,6 @@ ENV GOPROXY_DEBUG 0 EXPOSE 80 EXPOSE 8080 EXPOSE 443 -EXPOSE 8443 WORKDIR /app CMD ["/app/go-proxy"] \ No newline at end of file diff --git a/Makefile b/Makefile index 896d74d..0ba7768 100755 --- a/Makefile +++ b/Makefile @@ -16,10 +16,10 @@ setup-codemirror: build: mkdir -p bin - CGO_ENABLED=0 GOOS=linux go build -pgo=auto -o bin/go-proxy src/go-proxy/*.go + CGO_ENABLED=0 GOOS=linux go build -pgo=auto -o bin/go-proxy github.com/yusing/go-proxy test: - go test src/go-proxy/*.go + cd src && go test && cd .. up: docker compose up -d @@ -28,22 +28,19 @@ restart: docker compose restart -t 0 logs: - tail -f log/go-proxy.log + docker compose logs -f get: - go get -d -u ./src/go-proxy + cd src && go get -u && go mod tidy && cd .. + +debug: + make build && GOPROXY_DEBUG=1 bin/go-proxy + +archive: + git archive HEAD -o ../go-proxy-$$(date +"%Y%m%d%H%M").zip repush: git reset --soft HEAD^ git add -A git commit -m "repush" - git push gitlab dev --force - -udp-server: - docker run -it --rm \ - -p 9999:9999/udp \ - --label proxy.test-udp.scheme=udp \ - --label proxy.test-udp.port=20003:9999 \ - --network host \ - --name test-udp \ - $$(docker build -q -f udp-test-server.Dockerfile .) + git push gitlab dev --force \ No newline at end of file diff --git a/README.md b/README.md index 90b785a..ca3d05e 100755 --- a/README.md +++ b/README.md @@ -1,176 +1,94 @@ # go-proxy -A simple auto docker reverse proxy for home use. **Written in _Go_** +A [lightweight](docs/benchmark_result.md), easy-to-use, and efficient reverse proxy and load balancer with a web UI. -In the examples domain `x.y.z` is used, replace them with your domain - -## Table of content +**Table of content** -- [Table of content](#table-of-content) - [Key Points](#key-points) -- [How to use](#how-to-use) -- [Tested Services](#tested-services) - - [HTTP/HTTPs Reverse Proxy](#httphttps-reverse-proxy) - - [TCP Proxy](#tcp-proxy) - - [UDP Proxy](#udp-proxy) -- [Command-line args](#command-line-args) +- [Getting Started](#getting-started) - [Commands](#commands) -- [Use JSON Schema in VSCode](#use-json-schema-in-vscode) -- [Environment variables](#environment-variables) -- [Config File](#config-file) - - [Fields](#fields) - - [Provider Kinds](#provider-kinds) + - [Environment variables](#environment-variables) + - [Use JSON Schema in VSCode](#use-json-schema-in-vscode) + - [Config File](#config-file) - [Provider File](#provider-file) - - [Supported DNS Challenge Providers](#supported-dns-challenge-providers) -- [Troubleshooting](#troubleshooting) -- [Benchmarks](#benchmarks) - [Known issues](#known-issues) -- [Memory usage](#memory-usage) - [Build it yourself](#build-it-yourself) ## Key Points -- Fast (See [benchmarks](#benchmarks)) -- Auto certificate obtaining and renewal (See [Config File](#config-file) and [Supported DNS Challenge Providers](#supported-dns-challenge-providers)) -- Auto detect reverse proxies from docker -- Auto hot-reload on container `start` / `die` / `stop` or config file changes -- Custom proxy entries with `config.yml` and additional provider files -- Subdomain matching + Path matching **(domain name doesn't matter)** -- HTTP(s) reverse proxy + TCP/UDP Proxy -- HTTP(s) round robin load balance support (same subdomain and path across different hosts) -- Web UI on port 8080 (http) and port 8443 (https) - - - a simple panel to see all reverse proxies and health - - ![panel screenshot](screenshots/panel.png) - - - a config editor to edit config and provider files with validation - - **Validate and save file with Ctrl+S** - - ![config editor screenshot](screenshots/config_editor.png) +- Easy to use +- Auto certificate obtaining and renewal (See [Supported DNS Challenge Providers](docs/dns_providers.md)) +- Auto configuration for docker contaienrs +- Auto hot-reload on container state / config file changes +- Support HTTP(s), TCP and UDP +- Support HTTP(s) round robin load balancing +- Web UI for configuration and monitoring (See [screenshots](screeenshots)) +- Written in **[Go](https://go.dev)** [🔼Back to top](#table-of-content) -## How to use +## Getting Started -1. Setup DNS Records to your machine's IP address +1. Setup DNS Records - A Record: `*.y.z` -> `10.0.10.1` - AAAA Record: `*.y.z` -> `::ffff:a00:a01` -2. Start `go-proxy` by +2. Start `go-proxy` - - [Running from binary or as a system service](docs/binary.md) - - [Running as a docker container](docs/docker.md) + - [Binary / systemd service](docs/binary.md) + - [Docker](docs/docker.md) -3. Start editing config files +3. Configure `go-proxy` - with text editor (i.e. Visual Studio Code) - - or with web config editor by navigate to `http://ip:8080` + - or with web config editor via `http://ip:8080` [🔼Back to top](#table-of-content) -## Tested Services - -### HTTP/HTTPs Reverse Proxy - -- Nginx -- Minio -- AdguardHome Dashboard -- etc. - -### TCP Proxy - -- Minecraft server -- PostgreSQL -- MariaDB - -### UDP Proxy - -- Adguardhome DNS -- Palworld Dedicated Server - -[🔼Back to top](#table-of-content) - -## Command-line args - -`go-proxy [command]` - ### Commands -- empty: start proxy server -- validate: validate config and exit -- reload: trigger a force reload of config +- `go-proxy` start proxy server +- `go-proxy validate` validate config and exit +- `go-proxy reload` trigger a force reload of config -Examples: +**For docker containers, run `docker exec -it go-proxy /app/go-proxy `** -- Binary: `go-proxy reload` -- Docker: `docker exec -it go-proxy /app/go-proxy reload` +### Environment variables + +Booleans: + +- `GOPROXY_DEBUG` enable debug behaviors +- `GOPROXY_NO_SCHEMA_VALIDATION`: disable schema validation **(useful for testing new DNS Challenge providers)** + +### Use JSON Schema in VSCode + +Copy [`.vscode/settings.example.json`](.vscode/settings.example.json) to `.vscode/settings.json` and modify it to fit your needs [🔼Back to top](#table-of-content) -## Use JSON Schema in VSCode - -Copy [`.vscode/settings.example.json`](.vscode/settings.example.json) to `.vscode/settings.json` and modify to fit your needs - -```json -{ - "yaml.schemas": { - "https://github.com/yusing/go-proxy/raw/main/schema/config.schema.json": [ - "config.example.yml", - "config.yml" - ], - "https://github.com/yusing/go-proxy/raw/main/schema/providers.schema.json": [ - "providers.example.yml", - "*.providers.yml" - ] - } -} -``` - -[🔼Back to top](#table-of-content) - -## Environment variables - -- `GOPROXY_DEBUG`: set to `1` or `true` to enable debug behaviors (i.e. output, etc.) -- `GOPROXY_HOST_NETWORK`: _(Docker only)_ set to `1` when `network_mode: host` -- `GOPROXY_NO_SCHEMA_VALIDATION`: disable schema validation on config load / reload **(for testing new DNS Challenge providers)** - -[🔼Back to top](#table-of-content) - -## Config File +### Config File See [config.example.yml](config.example.yml) for more -### Fields - -- `autocert`: autocert configuration - - - `email`: ACME Email - - `domains`: a list of domains for cert registration - - `provider`: DNS Challenge provider, see [Supported DNS Challenge Providers](#supported-dns-challenge-providers) - - `options`: [provider specific options](#supported-dns-challenge-providers) - -- `providers`: reverse proxy providers configuration - - `kind`: provider kind (string), see [Provider Kinds](#provider-kinds) - - `value`: provider specific value - -[🔼Back to top](#table-of-content) - -### Provider Kinds - -- `docker`: load reverse proxies from docker - - values: - - - `FROM_ENV`: value from environment (`DOCKER_HOST`) - - full url to docker host (i.e. `tcp://host:2375`) - -- `file`: load reverse proxies from provider file - - value: relative path of file to `config/` +```yaml +# autocert configuration +autocert: + email: # ACME Email + domains: # a list of domains for cert registration + provider: # DNS Challenge provider + options: # provider specific options + - ... +# reverse proxy providers configuration +providers: + entry_1: + kind: docker + value: # `FROM_ENV` or full url to docker host + entry_2: + kind: file + value: # relative path of file to `config/` +``` [🔼Back to top](#table-of-content) @@ -182,155 +100,12 @@ See [providers.example.yml](providers.example.yml) for examples [🔼Back to top](#table-of-content) -### Supported DNS Challenge Providers - -- Cloudflare - - - `auth_token`: your zone API token - - Follow [this guide](https://cloudkul.com/blog/automcatic-renew-and-generate-ssl-on-your-website-using-lego-client/) to create a new token with `Zone.DNS` read and edit permissions - -- CloudDNS - - - `client_id` - - `email` - - `password` - -- DuckDNS (thanks [earvingad](https://github.com/earvingad)) - - - `token`: DuckDNS Token - -To add more provider support, see [this](docs/add_dns_provider.md) - -[🔼Back to top](#table-of-content) - -## Troubleshooting - -Q: How to fix when it shows "no matching route for subdomain \"? - -A: Make sure the container is running, and \ matches any container name / alias - -[🔼Back to top](#table-of-content) - -## Benchmarks - -Benchmarked with `wrk` connecting `traefik/whoami`'s `/bench` endpoint - -Remote benchmark (client running wrk and `go-proxy` server are different devices) - -- Direct connection - - ```shell - root@yusing-pc:~# wrk -t 10 -c 200 -d 10s -H "Host: bench.6uo.me" --latency http://10.0.100.3:8003/bench - Running 10s test @ http://10.0.100.3:8003/bench - 10 threads and 200 connections - Thread Stats Avg Stdev Max +/- Stdev - Latency 94.75ms 199.92ms 1.68s 91.27% - Req/Sec 4.24k 1.79k 18.79k 72.13% - Latency Distribution - 50% 1.14ms - 75% 120.23ms - 90% 245.63ms - 99% 1.03s - 423444 requests in 10.10s, 50.88MB read - Socket errors: connect 0, read 0, write 0, timeout 29 - Requests/sec: 41926.32 - Transfer/sec: 5.04MB - ``` - -- With reverse proxy - - ```shell - root@yusing-pc:~# wrk -t 10 -c 200 -d 10s -H "Host: bench.6uo.me" --latency http://10.0.1.7/bench - Running 10s test @ http://10.0.1.7/bench - 10 threads and 200 connections - Thread Stats Avg Stdev Max +/- Stdev - Latency 79.35ms 169.79ms 1.69s 92.55% - Req/Sec 4.27k 1.90k 19.61k 75.81% - Latency Distribution - 50% 1.12ms - 75% 105.66ms - 90% 200.22ms - 99% 814.59ms - 409836 requests in 10.10s, 49.25MB read - Socket errors: connect 0, read 0, write 0, timeout 18 - Requests/sec: 40581.61 - Transfer/sec: 4.88MB - ``` - -Local benchmark (client running wrk and `go-proxy` server are under same proxmox host but different LXCs) - -- Direct connection - - ```shell - root@http-benchmark-client:~# wrk -t 10 -c 200 -d 10s --latency http://10.0.100.1/bench - Running 10s test @ http://10.0.100.1/bench - 10 threads and 200 connections - Thread Stats Avg Stdev Max +/- Stdev - Latency 434.08us 539.35us 8.76ms 85.28% - Req/Sec 67.71k 6.31k 87.21k 71.20% - Latency Distribution - 50% 153.00us - 75% 646.00us - 90% 1.18ms - 99% 2.38ms - 6739591 requests in 10.01s, 809.85MB read - Requests/sec: 673608.15 - Transfer/sec: 80.94MB - ``` - -- With `go-proxy` reverse proxy - - ```shell - root@http-benchmark-client:~# wrk -t 10 -c 200 -d 10s -H "Host: bench.6uo.me" --latency http://10.0.1.7/bench - Running 10s test @ http://10.0.1.7/bench - 10 threads and 200 connections - Thread Stats Avg Stdev Max +/- Stdev - Latency 1.23ms 0.96ms 11.43ms 72.09% - Req/Sec 17.48k 1.76k 21.48k 70.20% - Latency Distribution - 50% 0.98ms - 75% 1.76ms - 90% 2.54ms - 99% 4.24ms - 1739079 requests in 10.01s, 208.97MB read - Requests/sec: 173779.44 - Transfer/sec: 20.88MB - ``` - -- With `traefik-v3` - - ```shell - root@traefik-benchmark:~# wrk -t10 -c200 -d10s -H "Host: benchmark.whoami" --latency http://127.0.0.1:8000/bench - Running 10s test @ http://127.0.0.1:8000/bench - 10 threads and 200 connections - Thread Stats Avg Stdev Max +/- Stdev - Latency 2.81ms 10.36ms 180.26ms 98.57% - Req/Sec 11.35k 1.74k 13.76k 85.54% - Latency Distribution - 50% 1.59ms - 75% 2.27ms - 90% 3.17ms - 99% 37.91ms - 1125723 requests in 10.01s, 109.50MB read - Requests/sec: 112499.59 - Transfer/sec: 10.94MB - ``` - -[🔼Back to top](#table-of-content) - ## Known issues - Cert "renewal" is actually obtaining a new cert instead of renewing the existing one [🔼Back to top](#table-of-content) -## Memory usage - -It takes ~15 MB for 50 proxy entries - -[🔼Back to top](#table-of-content) - ## Build it yourself 1. Install / Upgrade [go (>=1.22)](https://go.dev/doc/install) and `make` if not already diff --git a/compose.example.yml b/compose.example.yml index 8be7f43..db756a9 100755 --- a/compose.example.yml +++ b/compose.example.yml @@ -1,45 +1,20 @@ -version: '3' services: app: image: ghcr.io/yusing/go-proxy:latest container_name: go-proxy restart: always - networks: # ^also add here - - default - ports: - - 80:80 # http proxy - - 8080:8080 # http panel - # - 443:443 # optional, https proxy - # - 8443:8443 # optional, https panel - - # optional, if you declared any tcp/udp proxy, set a range you want to use - # - 20000:20100/tcp - # - 20000:20100/udp + network_mode: host volumes: - - ./config:/app/config - - # if local docker provider is used - /var/run/docker.sock:/var/run/docker.sock:ro + - ./config:/app/config - # use existing certificate - # - /path/to/cert.pem:/app/certs/cert.crt:ro - # - /path/to/privkey.pem:/app/certs/priv.key:ro + # (Optional) choose one of below to enable https + # 1. use existing certificate + # if your cert is not named `cert.crt` change `cert_path` in `config/config.yml` + # if your cert key is not named `priv.key` change `key_path` in `config/config.yml` - # store autocert obtained cert - # - ./certs:/app/certs - - # workaround for "lookup: no such host" - # dns: - # - 127.0.0.1 + # - /path/to/certs:/app/certs - # if you have container running in "host" network mode - # extra_hosts: - # - host.docker.internal:host-gateway - logging: - driver: 'json-file' - options: - max-file: '1' - max-size: 128k -networks: # ^you may add other external networks - default: - driver: bridge \ No newline at end of file + # 2. use autocert, certs will be stored in ./certs (or other path you specify) + + # - ./certs:/app/certs \ No newline at end of file diff --git a/config.example.yml b/config.example.yml index 55d3bd8..5152dbd 100644 --- a/config.example.yml +++ b/config.example.yml @@ -1,21 +1,33 @@ -# Autocert (uncomment to enable) -# autocert: # (optional, if you need autocert feature) -# email: "user@domain.com" # (required) email for acme certificate -# domains: # (required) -# - "*.y.z" # domain for acme certificate, use wild card to allow all subdomains -# provider: cloudflare # (required) dns challenge provider (string) -# options: # provider specific options -# auth_token: "YOUR_ZONE_API_TOKEN" +# Autocert (choose one below and uncomment to enable) + +# 1. use existing cert +# autocert: +# provider: local +# cert_path: certs/cert.crt # optional, uncomment only if you need to change it +# key_path: certs/priv.key # optional, uncomment only if you need to change it + +# 2. cloudflare +# autocert: +# provider: cloudflare +# email: # ACME Email +# domains: # a list of domains for cert registration +# - +# options: +# - auth_token: # your zone API token + +# 3. other providers, check readme for more + providers: local: kind: docker # for value format, see https://docs.docker.com/reference/cli/dockerd/ - # i.e. FROM_ENV, ssh://user@10.0.1.1:22, tcp://10.0.2.1:2375 + # i.e. ssh://user@10.0.1.1:22, tcp://10.0.2.1:2375 + # use FROM_ENV if you have binded docker socket to /var/run/docker.sock value: FROM_ENV providers: kind: file value: providers.yml - # Fixed options (optional, non hot-reloadable) + # timeout_shutdown: 5 -# redirect_to_https: false \ No newline at end of file +# redirect_to_https: false diff --git a/docs/benchmark_result.md b/docs/benchmark_result.md new file mode 100644 index 0000000..b34df2a --- /dev/null +++ b/docs/benchmark_result.md @@ -0,0 +1,104 @@ +# Benchmarks + +Benchmarked with `wrk` and `traefik/whoami`'s `/bench` endpoint + +## Remote benchmark + +- Direct connection + + ```shell + root@yusing-pc:~# wrk -t 10 -c 200 -d 10s -H "Host: bench.6uo.me" --latency http://10.0.100.3:8003/bench + Running 10s test @ http://10.0.100.3:8003/bench + 10 threads and 200 connections + Thread Stats Avg Stdev Max +/- Stdev + Latency 94.75ms 199.92ms 1.68s 91.27% + Req/Sec 4.24k 1.79k 18.79k 72.13% + Latency Distribution + 50% 1.14ms + 75% 120.23ms + 90% 245.63ms + 99% 1.03s + 423444 requests in 10.10s, 50.88MB read + Socket errors: connect 0, read 0, write 0, timeout 29 + Requests/sec: 41926.32 + Transfer/sec: 5.04MB + ``` + +- With reverse proxy + + ```shell + root@yusing-pc:~# wrk -t 10 -c 200 -d 10s -H "Host: bench.6uo.me" --latency http://10.0.1.7/bench + Running 10s test @ http://10.0.1.7/bench + 10 threads and 200 connections + Thread Stats Avg Stdev Max +/- Stdev + Latency 79.35ms 169.79ms 1.69s 92.55% + Req/Sec 4.27k 1.90k 19.61k 75.81% + Latency Distribution + 50% 1.12ms + 75% 105.66ms + 90% 200.22ms + 99% 814.59ms + 409836 requests in 10.10s, 49.25MB read + Socket errors: connect 0, read 0, write 0, timeout 18 + Requests/sec: 40581.61 + Transfer/sec: 4.88MB + ``` + +## Local benchmark (client running wrk and `go-proxy` server are under same proxmox host but different LXCs) + +- Direct connection + + ```shell + root@http-benchmark-client:~# wrk -t 10 -c 200 -d 10s --latency http://10.0.100.1/bench + Running 10s test @ http://10.0.100.1/bench + 10 threads and 200 connections + Thread Stats Avg Stdev Max +/- Stdev + Latency 434.08us 539.35us 8.76ms 85.28% + Req/Sec 67.71k 6.31k 87.21k 71.20% + Latency Distribution + 50% 153.00us + 75% 646.00us + 90% 1.18ms + 99% 2.38ms + 6739591 requests in 10.01s, 809.85MB read + Requests/sec: 673608.15 + Transfer/sec: 80.94MB + ``` + +- With `go-proxy` reverse proxy + + ```shell + root@http-benchmark-client:~# wrk -t 10 -c 200 -d 10s -H "Host: bench.6uo.me" --latency http://10.0.1.7/bench + Running 10s test @ http://10.0.1.7/bench + 10 threads and 200 connections + Thread Stats Avg Stdev Max +/- Stdev + Latency 1.23ms 0.96ms 11.43ms 72.09% + Req/Sec 17.48k 1.76k 21.48k 70.20% + Latency Distribution + 50% 0.98ms + 75% 1.76ms + 90% 2.54ms + 99% 4.24ms + 1739079 requests in 10.01s, 208.97MB read + Requests/sec: 173779.44 + Transfer/sec: 20.88MB + ``` + +- With `traefik-v3` + + ```shell + root@traefik-benchmark:~# wrk -t10 -c200 -d10s -H "Host: benchmark.whoami" --latency http://127.0.0.1:8000/bench + Running 10s test @ http://127.0.0.1:8000/bench + 10 threads and 200 connections + Thread Stats Avg Stdev Max +/- Stdev + Latency 2.81ms 10.36ms 180.26ms 98.57% + Req/Sec 11.35k 1.74k 13.76k 85.54% + Latency Distribution + 50% 1.59ms + 75% 2.27ms + 90% 3.17ms + 99% 37.91ms + 1125723 requests in 10.01s, 109.50MB read + Requests/sec: 112499.59 + Transfer/sec: 10.94MB + ``` \ No newline at end of file diff --git a/docs/binary.md b/docs/binary.md index 23ed969..8180f97 100644 --- a/docs/binary.md +++ b/docs/binary.md @@ -22,7 +22,7 @@ Setup ```shell - wget -qO- https://6uo.me/go-proxy-setup-binary | sudo bash + wget -qO- https://github.com/yusing/go-proxy/raw/main/setup-binary.sh | sudo bash ``` What it does: diff --git a/docs/dns_providers.md b/docs/dns_providers.md new file mode 100644 index 0000000..69343fb --- /dev/null +++ b/docs/dns_providers.md @@ -0,0 +1,32 @@ +# Supported DNS Providers + + +- [Cloudflare](#cloudflare) +- [CloudDNS](#clouddns) +- [DuckDNS](#duckdns) +- [Implement other DNS providers](#implement-other-dns-providers) + + +## Cloudflare + +`auth_token` your zone API token + +Follow [this guide](https://cloudkul.com/blog/automcatic-renew-and-generate-ssl-on-your-website-using-lego-client/) to create a new token with `Zone.DNS` read and edit permissions + +## CloudDNS + +- `client_id` + +- `email` + +- `password` + +## DuckDNS + +`token`: DuckDNS Token + +Tested by [earvingad](https://github.com/earvingad) + +## Implement other DNS providers + +See [add_dns_provider.md](docs/add_dns_provider.md) diff --git a/docs/docker.md b/docs/docker.md index 9b7e0d2..9d512d7 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -25,7 +25,7 @@ 2. Run setup script - `bash <(wget -qO- https://6uo.me/go-proxy-setup-docker)` + `bash <(wget -qO- https://github.com/yusing/go-proxy/raw/main/setup-docker.sh)` What it does: @@ -75,19 +75,25 @@ - `proxy.*.`: wildcard label for all aliases -Below labels has a **`proxy..`** prefix (i.e. `proxy.nginx.scheme: http`) +_Labels below should have a **`proxy..`** prefix._ + +_i.e. `proxy.nginx.scheme: http`_ - `scheme`: proxy protocol - - default: `http` + - default: + - if `port` is like `x:y`: `tcp` + - if `port` is a number: `http` - allowed: `http`, `https`, `tcp`, `udp` - `host`: proxy host - default: `container_name` + - allowed: IP address, hostname - `port`: proxy port - - default: first expose port (declared in `Dockerfile` or `docker-compose.yml`) + - default: first port in `ports:` - `http(s)`: number in range og `0 - 65535` - - `tcp/udp`: `[:]` - - `listeningPort`: number, when it is omitted (not suggested), a free port starting from 20000 will be used. - - `targetPort`: number, or predefined names (see [constants.go:14](src/go-proxy/constants.go#L14)) + - `tcp`, `udp`: `x:y` + - `x`: port for `go-proxy` to listen on + - `y`: port, or _service name_ of target container + see [constants.go:14 for _service names_](../src/common/constants.go#L74) - `no_tls_verify`: whether skip tls verify when scheme is https - default: `false` - `path`: proxy path _(http(s) proxy only)_ @@ -95,7 +101,7 @@ Below labels has a **`proxy..`** prefix (i.e. `proxy.nginx.scheme: http`) - `path_mode`: mode for path handling - default: empty - - allowed: empty, `forward`, `sub` + - allowed: empty, `forward` - `empty`: remove path prefix from URL when proxying 1. apps.y.z/webdav -> webdav:80 @@ -103,28 +109,24 @@ Below labels has a **`proxy..`** prefix (i.e. `proxy.nginx.scheme: http`) - `forward`: path remain unchanged 1. apps.y.z/webdav -> webdav:80/webdav 2. apps.y.z./webdav/path/to/file -> webdav:80/webdav/path/to/file - - `sub`: **(experimental)** remove path prefix from URL and also append path to HTML link attributes (`src`, `href` and `action`) and Javascript `fetch(url)` by response body substitution - e.g. apps.y.z/app1 -> webdav:80, `href="/app1/path/to/file"` -> `href="/path/to/file"` - - `set_headers`: a list of header to set, (key:value, one by line) +- `set_headers`: a list of header to set, (key:value, one by line) - Duplicated keys will be treated as multiple-value headers + Duplicated keys will be treated as multiple-value headers - ```yaml - labels: - proxy.app.set_headers: | - X-Custom-Header1: value1 - X-Custom-Header1: value2 - X-Custom-Header2: value2 - ``` + ```yaml + labels: + proxy.app.set_headers: | + X-Custom-Header1: value1 + X-Custom-Header1: value2 + X-Custom-Header2: value2 + ``` - - `hide_headers`: comma seperated list of headers to hide +- `hide_headers`: comma seperated list of headers to hide [🔼Back to top](#table-of-content) -## Labels (docker specific) - -Below labels has a **`proxy..`** prefix (i.e. `proxy.app.load_balance=1`) +**docker only:** - `load_balance`: enable load balance - allowed: `1`, `true` diff --git a/go.work b/go.work new file mode 100644 index 0000000..493fee9 --- /dev/null +++ b/go.work @@ -0,0 +1,3 @@ +go 1.22 + +use ./src diff --git a/providers.example.yml b/providers.example.yml index b3103ee..8ef1f10 100644 --- a/providers.example.yml +++ b/providers.example.yml @@ -7,9 +7,7 @@ example: # matching `app.y.z` port: "80" # optional, defaults to empty path: - # optional, defaults to empty - path_mode: - # optional (https only) + # optional (scheme=https only) # no_tls_verify: false # optional headers to set / override (http(s) only) set_headers: @@ -21,18 +19,9 @@ example: # matching `app.y.z` hide_headers: - HEADER_C - HEADER_D -app1: # matching `app1.y.z` -> http://x.y.z - host: x.y.z -app2: # `app2` has no effect for tcp / udp, but still has to be unique across files +app1: # matching `app1.y.z` -> http://some_host + host: some_host +app2: scheme: tcp host: 10.0.0.2 port: 20000:tcp -app3: # matching `app3.y.z` -> https://10.0.0.1/app3 - scheme: https - host: 10.0.0.1 - path: /app3 - path_mode: forward - no_tls_verify: false - set_headers: - X-Forwarded-Proto: [https] - X-Forwarded-Host: [app3.y.z] diff --git a/schema/config.schema.json b/schema/config.schema.json index 0bd9f5e..1b871de 100644 --- a/schema/config.schema.json +++ b/schema/config.schema.json @@ -8,31 +8,63 @@ "type": "object", "properties": { "email": { - "description": "ACME Email", + "title": "ACME Email", "type": "string", "pattern": "^[\\w-\\.]+@([\\w-]+\\.)+[\\w-]{2,4}$", "patternErrorMessage": "Invalid email" }, "domains": { - "description": "Cert Domains", + "title": "Cert Domains", "type": "array", "items": { "type": "string" }, "minItems": 1 }, + "cert_path": { + "title": "path of cert file to load/store", + "description": "default: certs/cert.crt", + "type": "string" + }, + "key_path": { + "title": "path of key file to load/store", + "description": "default: certs/priv.key", + "type": "string" + }, "provider": { - "description": "DNS Challenge Provider", + "title": "DNS Challenge Provider", "type": "string", - "enum": ["cloudflare", "clouddns", "duckdns"] + "enum": [ + "local", + "cloudflare", + "clouddns", + "duckdns" + ] }, "options": { - "description": "Provider specific options", + "title": "Provider specific options", "type": "object" } }, - "required": ["email", "domains", "provider", "options"], "allOf": [ + { + "if": { + "properties": { + "provider": { + "not": true, + "const": "local" + } + } + }, + "then": { + "required": [ + "email", + "domains", + "provider", + "options" + ] + } + }, { "if": { "properties": { @@ -44,7 +76,9 @@ "then": { "properties": { "options": { - "required": ["auth_token"], + "required": [ + "auth_token" + ], "additionalProperties": false, "properties": { "auth_token": { @@ -67,7 +101,11 @@ "then": { "properties": { "options": { - "required": ["client_id", "email", "password"], + "required": [ + "client_id", + "email", + "password" + ], "additionalProperties": false, "properties": { "client_id": { @@ -98,7 +136,9 @@ "then": { "properties": { "options": { - "required": ["token"], + "required": [ + "token" + ], "additionalProperties": false, "properties": { "token": { @@ -123,13 +163,19 @@ "kind": { "description": "Proxy provider kind", "type": "string", - "enum": ["docker", "file"] + "enum": [ + "docker", + "file" + ] }, "value": { "type": "string" } }, - "required": ["kind", "value"], + "required": [ + "kind", + "value" + ], "allOf": [ { "if": { @@ -190,5 +236,7 @@ } }, "additionalProperties": false, - "required": ["providers"] -} + "required": [ + "providers" + ] +} \ No newline at end of file diff --git a/schema/providers.schema.json b/schema/providers.schema.json index c689910..d809e24 100644 --- a/schema/providers.schema.json +++ b/schema/providers.schema.json @@ -3,10 +3,10 @@ "title": "go-proxy providers file", "anyOf": [ { - "type":"object" + "type": "object" }, { - "type":"null" + "type": "null" } ], "patternProperties": { @@ -19,11 +19,20 @@ "anyOf": [ { "type": "string", - "enum": ["http", "https", "tcp", "udp"] + "enum": [ + "http", + "https", + "tcp", + "udp", + "tcp:tcp", + "udp:udp", + "tcp:udp", + "udp:tcp" + ] }, { "type": "null", - "description": "HTTP proxy" + "description": "Auto detect base on port number" } ] }, @@ -50,8 +59,9 @@ "port": { "title": "Proxy port" }, - "path": {}, - "path_mode": {}, + "path": { + "title": "Proxy path pattern (See https://pkg.go.dev/net/http#ServeMux)" + }, "no_tls_verify": { "description": "Disable TLS verification for https proxy", "type": "boolean" @@ -59,7 +69,9 @@ "set_headers": {}, "hide_headers": {} }, - "required": ["host"], + "required": [ + "host" + ], "additionalProperties": false, "allOf": [ { @@ -68,7 +80,10 @@ { "properties": { "scheme": { - "enum": ["http", "https"] + "enum": [ + "http", + "https" + ] } } }, @@ -119,19 +134,6 @@ } ] }, - "path_mode": { - "anyOf": [ - { - "description": "Proxy path mode (forward, sub, empty)", - "type": "string", - "enum": ["", "forward", "sub"] - }, - { - "description": "Default proxy path mode (sub)", - "type": "null" - } - ] - }, "set_headers": { "type": "object", "description": "Proxy headers to set", @@ -143,7 +145,7 @@ } }, "hide_headers": { - "type":"array", + "type": "array", "description": "Proxy headers to hide", "items": { "type": "string" @@ -154,17 +156,14 @@ "else": { "properties": { "port": { - "markdownDescription": "`listening port`:`target port | service type`", + "markdownDescription": "`listening port`:`proxy port | service name`", "type": "string", "pattern": "^[0-9]+\\:[0-9a-z]+$", - "patternErrorMessage": "'port' must be in the format of ':'" + "patternErrorMessage": "'port' must be in the format of ':'" }, "path": { "not": true }, - "path_mode": { - "not": true - }, "set_headers": { "not": true }, @@ -172,7 +171,9 @@ "not": true } }, - "required": ["port"] + "required": [ + "port" + ] } }, { @@ -197,4 +198,4 @@ } }, "additionalProperties": false -} +} \ No newline at end of file diff --git a/src/api/handler.go b/src/api/handler.go new file mode 100644 index 0000000..a6eeeb3 --- /dev/null +++ b/src/api/handler.go @@ -0,0 +1,29 @@ +package api + +import ( + "net/http" + + v1 "github.com/yusing/go-proxy/api/v1" + "github.com/yusing/go-proxy/config" +) + +func NewHandler(cfg *config.Config) http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("GET /v1", v1.Index) + mux.HandleFunc("GET /v1/checkhealth", wrap(cfg, v1.CheckHealth)) + mux.HandleFunc("HEAD /v1/checkhealth", wrap(cfg, v1.CheckHealth)) + mux.HandleFunc("POST /v1/reload", wrap(cfg, v1.Reload)) + mux.HandleFunc("GET /v1/list", wrap(cfg, v1.List)) + mux.HandleFunc("GET /v1/list/{what}", wrap(cfg, v1.List)) + mux.HandleFunc("GET /v1/file", v1.GetFileContent) + mux.HandleFunc("GET /v1/file/{filename}", v1.GetFileContent) + mux.HandleFunc("PUT /v1/file/{filename}", v1.SetFileContent) + mux.HandleFunc("GET /v1/stats", wrap(cfg, v1.Stats)) + return mux +} + +func wrap(cfg *config.Config, f func(cfg *config.Config, w http.ResponseWriter, r *http.Request)) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + f(cfg, w, r) + } +} diff --git a/src/api/v1/checkhealth.go b/src/api/v1/checkhealth.go new file mode 100644 index 0000000..ae11d8b --- /dev/null +++ b/src/api/v1/checkhealth.go @@ -0,0 +1,49 @@ +package v1 + +import ( + "fmt" + "net/http" + + U "github.com/yusing/go-proxy/api/v1/utils" + "github.com/yusing/go-proxy/config" + R "github.com/yusing/go-proxy/route" +) + +func CheckHealth(cfg *config.Config, w http.ResponseWriter, r *http.Request) { + target := r.FormValue("target") + if target == "" { + U.HandleErr(w, r, U.ErrMissingKey("target"), http.StatusBadRequest) + return + } + + var ok bool + + switch route := cfg.FindRoute(target).(type) { + case nil: + U.HandleErr(w, r, U.ErrNotFound("target", target), http.StatusNotFound) + return + case *R.HTTPRoute: + path := r.FormValue("path") + if path == "" { + U.HandleErr(w, r, U.ErrMissingKey("path"), http.StatusBadRequest) + return + } + sr, hasSr := route.GetSubroute(path) + if !hasSr { + U.HandleErr(w, r, U.ErrNotFound("path", path), http.StatusNotFound) + return + } + ok = U.IsSiteHealthy(sr.TargetURL.String()) + case *R.StreamRoute: + ok = U.IsStreamHealthy( + route.Scheme.ProxyScheme.String(), + fmt.Sprintf("%s:%v", route.Host, route.Port.ProxyPort), + ) + } + + if ok { + w.WriteHeader(http.StatusOK) + } else { + w.WriteHeader(http.StatusRequestTimeout) + } +} diff --git a/src/api/v1/file.go b/src/api/v1/file.go new file mode 100644 index 0000000..159f483 --- /dev/null +++ b/src/api/v1/file.go @@ -0,0 +1,58 @@ +package v1 + +import ( + "net/http" + "os" + "path" + + U "github.com/yusing/go-proxy/api/v1/utils" + "github.com/yusing/go-proxy/common" + "github.com/yusing/go-proxy/config" + E "github.com/yusing/go-proxy/error" + "github.com/yusing/go-proxy/proxy/provider" +) + +func GetFileContent(w http.ResponseWriter, r *http.Request) { + filename := r.PathValue("filename") + if filename == "" { + filename = common.ConfigFileName + } + content, err := os.ReadFile(path.Join(common.ConfigBasePath, filename)) + if err != nil { + U.HandleErr(w, r, err) + return + } + w.Write(content) +} + +func SetFileContent(w http.ResponseWriter, r *http.Request) { + filename := r.PathValue("filename") + if filename == "" { + U.HandleErr(w, r, U.ErrMissingKey("filename"), http.StatusBadRequest) + return + } + content := make([]byte, r.ContentLength) + _, err := E.Check(r.Body.Read(content)) + if err.IsNotNil() { + U.HandleErr(w, r, err) + return + } + + if filename == common.ConfigFileName { + err = config.Validate(content) + } else { + err = provider.Validate(content) + } + + if err.IsNotNil() { + U.HandleErr(w, r, err) + return + } + + err = E.From(os.WriteFile(path.Join(common.ConfigBasePath, filename), content, 0644)) + if err.IsNotNil() { + U.HandleErr(w, r, err) + return + } + w.WriteHeader(http.StatusOK) +} diff --git a/src/api/v1/index.go b/src/api/v1/index.go new file mode 100644 index 0000000..6d887fa --- /dev/null +++ b/src/api/v1/index.go @@ -0,0 +1,7 @@ +package v1 + +import "net/http" + +func Index(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("API ready")) +} diff --git a/src/api/v1/list.go b/src/api/v1/list.go new file mode 100644 index 0000000..c557ed7 --- /dev/null +++ b/src/api/v1/list.go @@ -0,0 +1,62 @@ +package v1 + +import ( + "encoding/json" + "net/http" + "os" + + "github.com/yusing/go-proxy/common" + "github.com/yusing/go-proxy/config" + + U "github.com/yusing/go-proxy/api/v1/utils" +) + +func List(cfg *config.Config, w http.ResponseWriter, r *http.Request) { + what := r.PathValue("what") + if what == "" { + what = "routes" + } + + switch what { + case "routes": + listRoutes(cfg, w, r) + case "config_files": + listConfigFiles(w, r) + default: + U.HandleErr(w, r, U.ErrInvalidKey("what"), http.StatusBadRequest) + } +} + +func listRoutes(cfg *config.Config, w http.ResponseWriter, r *http.Request) { + routes := cfg.RoutesByAlias() + type_filter := r.FormValue("type") + if type_filter != "" { + for k, v := range routes { + if v["type"] != type_filter { + delete(routes, k) + } + } + } + + if err := U.RespondJson(routes, w); err != nil { + U.HandleErr(w, r, err) + } +} + +func listConfigFiles(w http.ResponseWriter, r *http.Request) { + files, err := os.ReadDir(common.ConfigBasePath) + if err != nil { + U.HandleErr(w, r, err) + return + } + filenames := make([]string, len(files)) + for i, f := range files { + filenames[i] = f.Name() + } + resp, err := json.Marshal(filenames) + if err != nil { + U.HandleErr(w, r, err) + return + } + w.Write(resp) +} diff --git a/src/api/v1/reload.go b/src/api/v1/reload.go new file mode 100644 index 0000000..430e61a --- /dev/null +++ b/src/api/v1/reload.go @@ -0,0 +1,16 @@ +package v1 + +import ( + "net/http" + + U "github.com/yusing/go-proxy/api/v1/utils" + "github.com/yusing/go-proxy/config" +) + +func Reload(cfg *config.Config, w http.ResponseWriter, r *http.Request) { + if err := cfg.Reload(); err.IsNotNil() { + U.HandleErr(w, r, err) + return + } + w.WriteHeader(http.StatusOK) +} diff --git a/src/api/v1/stats.go b/src/api/v1/stats.go new file mode 100644 index 0000000..794f1ae --- /dev/null +++ b/src/api/v1/stats.go @@ -0,0 +1,20 @@ +package v1 + +import ( + "net/http" + + U "github.com/yusing/go-proxy/api/v1/utils" + "github.com/yusing/go-proxy/config" + "github.com/yusing/go-proxy/server" + "github.com/yusing/go-proxy/utils" +) + +func Stats(cfg *config.Config, w http.ResponseWriter, r *http.Request) { + stats := map[string]interface{}{ + "proxies": cfg.Statistics(), + "uptime": utils.FormatDuration(server.GetProxyServer().Uptime()), + } + if err := U.RespondJson(stats, w); err != nil { + U.HandleErr(w, r, err) + } +} diff --git a/src/api/v1/utils/error.go b/src/api/v1/utils/error.go new file mode 100644 index 0000000..4f644fa --- /dev/null +++ b/src/api/v1/utils/error.go @@ -0,0 +1,32 @@ +package utils + +import ( + "errors" + "fmt" + "net/http" + + "github.com/sirupsen/logrus" + E "github.com/yusing/go-proxy/error" +) + +func HandleErr(w http.ResponseWriter, r *http.Request, err error, code ...int) { + err = E.From(err).Subjectf("%s %s", r.Method, r.URL) + logrus.WithField("?", "api").Error(err) + if len(code) > 0 { + http.Error(w, err.Error(), code[0]) + return + } + http.Error(w, err.Error(), http.StatusInternalServerError) +} + +func ErrMissingKey(k string) error { + return errors.New("missing key '" + k + "' in query or request body") +} + +func ErrInvalidKey(k string) error { + return errors.New("invalid key '" + k + "' in query or request body") +} + +func ErrNotFound(k, v string) error { + return fmt.Errorf("key %q with value %q not found", k, v) +} diff --git a/src/api/v1/utils/net.go b/src/api/v1/utils/net.go new file mode 100644 index 0000000..c7858f6 --- /dev/null +++ b/src/api/v1/utils/net.go @@ -0,0 +1,62 @@ +package utils + +import ( + "crypto/tls" + "fmt" + "net" + "net/http" + + "github.com/yusing/go-proxy/common" + E "github.com/yusing/go-proxy/error" +) + +func IsSiteHealthy(url string) bool { + // try HEAD first + // if HEAD is not allowed, try GET + resp, err := HttpClient.Head(url) + if resp != nil { + resp.Body.Close() + } + if err != nil && resp != nil && resp.StatusCode == http.StatusMethodNotAllowed { + _, err = HttpClient.Get(url) + } + if resp != nil { + resp.Body.Close() + } + return err == nil +} + +func IsStreamHealthy(scheme, address string) bool { + conn, err := net.DialTimeout(scheme, address, common.DialTimeout) + if err != nil { + return false + } + conn.Close() + return true +} + +func ReloadServer() E.NestedError { + resp, err := HttpClient.Post(fmt.Sprintf("http://localhost%v/reload", common.APIHTTPPort), "", nil) + if err != nil { + return E.From(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return E.Failure("server reload").Subjectf("status code: %v", resp.StatusCode) + } + return E.Nil() +} + +var HttpClient = &http.Client{ + Timeout: common.ConnectionTimeout, + Transport: &http.Transport{ + Proxy: http.ProxyFromEnvironment, + DisableKeepAlives: true, + ForceAttemptHTTP2: true, + DialContext: (&net.Dialer{ + Timeout: common.DialTimeout, + KeepAlive: common.KeepAlive, // this is different from DisableKeepAlives + }).DialContext, + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + }, +} diff --git a/src/api/v1/utils/utils.go b/src/api/v1/utils/utils.go new file mode 100644 index 0000000..a7501b1 --- /dev/null +++ b/src/api/v1/utils/utils.go @@ -0,0 +1,17 @@ +package utils + +import ( + "encoding/json" + "net/http" +) + +func RespondJson(data any, w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + j, err := json.Marshal(data) + if err != nil { + return err + } else { + w.Write(j) + } + return nil +} diff --git a/src/autocert/config.go b/src/autocert/config.go new file mode 100644 index 0000000..11bd63c --- /dev/null +++ b/src/autocert/config.go @@ -0,0 +1,78 @@ +package autocert + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + + "github.com/go-acme/lego/v4/certcrypto" + "github.com/go-acme/lego/v4/lego" + E "github.com/yusing/go-proxy/error" + M "github.com/yusing/go-proxy/models" +) + +type Config M.AutoCertConfig + +func NewConfig(cfg *M.AutoCertConfig) *Config { + if cfg.CertPath == "" { + cfg.CertPath = CertFileDefault + } + if cfg.KeyPath == "" { + cfg.KeyPath = KeyFileDefault + } + return (*Config)(cfg) +} + +func (cfg *Config) GetProvider() (*Provider, E.NestedError) { + errors := E.NewBuilder("cannot create autocert provider") + + if cfg.Provider != ProviderLocal { + if len(cfg.Domains) == 0 { + errors.Addf("no domains specified") + } + if cfg.Provider == "" { + errors.Addf("no provider specified") + } + if cfg.Email == "" { + errors.Addf("no email specified") + } + } + + gen, ok := providersGenMap[cfg.Provider] + if !ok { + errors.Addf("unknown provider: %q", cfg.Provider) + } + if err := errors.Build(); err.IsNotNil() { + return nil, err + } + + privKey, err := E.Check(ecdsa.GenerateKey(elliptic.P256(), rand.Reader)) + if err.IsNotNil() { + return nil, E.Failure("generate private key").With(err) + } + user := &User{ + Email: cfg.Email, + key: privKey, + } + legoCfg := lego.NewConfig(user) + legoCfg.Certificate.KeyType = certcrypto.RSA2048 + legoClient, err := E.Check(lego.NewClient(legoCfg)) + if err.IsNotNil() { + return nil, E.Failure("create lego client").With(err) + } + base := &Provider{ + cfg: cfg, + user: user, + legoCfg: legoCfg, + client: legoClient, + } + legoProvider, err := E.Check(gen(cfg.Options)) + if err.IsNotNil() { + return nil, E.Failure("create lego provider").With(err) + } + err = E.From(legoClient.Challenge.SetDNS01Provider(legoProvider)) + if err.IsNotNil() { + return nil, E.Failure("set challenge provider").With(err) + } + return base, E.Nil() +} diff --git a/src/autocert/constants.go b/src/autocert/constants.go new file mode 100644 index 0000000..cebde51 --- /dev/null +++ b/src/autocert/constants.go @@ -0,0 +1,30 @@ +package autocert + +import ( + "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/sirupsen/logrus" +) + +const ( + certBasePath = "certs/" + CertFileDefault = certBasePath + "cert.crt" + KeyFileDefault = certBasePath + "priv.key" +) + +const ( + ProviderLocal = "local" + ProviderCloudflare = "cloudflare" + ProviderClouddns = "clouddns" + ProviderDuckdns = "duckdns" +) + +var providersGenMap = map[string]ProviderGenerator{ + ProviderLocal: providerGenerator(NewDummyDefaultConfig, NewDummyDNSProviderConfig), + ProviderCloudflare: providerGenerator(cloudflare.NewDefaultConfig, cloudflare.NewDNSProviderConfig), + ProviderClouddns: providerGenerator(clouddns.NewDefaultConfig, clouddns.NewDNSProviderConfig), + ProviderDuckdns: providerGenerator(duckdns.NewDefaultConfig, duckdns.NewDNSProviderConfig), +} + +var Logger = logrus.WithField("?", "autocert") diff --git a/src/autocert/dummy.go b/src/autocert/dummy.go new file mode 100644 index 0000000..7fd050c --- /dev/null +++ b/src/autocert/dummy.go @@ -0,0 +1,20 @@ +package autocert + +type DummyConfig struct{} +type DummyProvider struct{} + +func NewDummyDefaultConfig() *DummyConfig { + return &DummyConfig{} +} + +func NewDummyDNSProviderConfig(*DummyConfig) (*DummyProvider, error) { + return &DummyProvider{}, nil +} + +func (DummyProvider) Present(domain, token, keyAuth string) error { + return nil +} + +func (DummyProvider) CleanUp(domain, token, keyAuth string) error { + return nil +} diff --git a/src/autocert/provider.go b/src/autocert/provider.go new file mode 100644 index 0000000..120a24b --- /dev/null +++ b/src/autocert/provider.go @@ -0,0 +1,258 @@ +package autocert + +import ( + "context" + "crypto/tls" + "crypto/x509" + "os" + "slices" + "sync" + "time" + + "github.com/go-acme/lego/v4/certificate" + "github.com/go-acme/lego/v4/challenge" + "github.com/go-acme/lego/v4/lego" + "github.com/go-acme/lego/v4/registration" + "github.com/sirupsen/logrus" + E "github.com/yusing/go-proxy/error" + M "github.com/yusing/go-proxy/models" + "github.com/yusing/go-proxy/utils" +) + +type Provider struct { + cfg *Config + user *User + legoCfg *lego.Config + client *lego.Client + + tlsCert *tls.Certificate + certExpiries CertExpiries + mutex sync.Mutex +} + +type ProviderGenerator func(M.AutocertProviderOpt) (challenge.Provider, error) +type CertExpiries map[string]time.Time + +func (p *Provider) GetCert(_ *tls.ClientHelloInfo) (*tls.Certificate, error) { + if p.tlsCert == nil { + return nil, E.Failure("get certificate") + } + return p.tlsCert, nil +} + +func (p *Provider) GetName() string { + return p.cfg.Provider +} + +func (p *Provider) GetCertPath() string { + return p.cfg.CertPath +} + +func (p *Provider) GetKeyPath() string { + return p.cfg.KeyPath +} + +func (p *Provider) GetExpiries() CertExpiries { + return p.certExpiries +} + +func (p *Provider) ObtainCert() E.NestedError { + ne := E.Failure("obtain certificate") + + client := p.client + if p.user.Registration == nil { + reg, err := E.Check(client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true})) + if err.IsNotNil() { + return ne.With(E.Failure("register account").With(err)) + } + p.user.Registration = reg + } + req := certificate.ObtainRequest{ + Domains: p.cfg.Domains, + Bundle: true, + } + cert, err := E.Check(client.Certificate.Obtain(req)) + if err.IsNotNil() { + return ne.With(err) + } + err = p.saveCert(cert) + if err.IsNotNil() { + return ne.With(E.Failure("save certificate").With(err)) + } + tlsCert, err := E.Check(tls.X509KeyPair(cert.Certificate, cert.PrivateKey)) + if err.IsNotNil() { + return ne.With(E.Failure("parse obtained certificate").With(err)) + } + expiries, err := getCertExpiries(&tlsCert) + if err.IsNotNil() { + return ne.With(E.Failure("get certificate expiry").With(err)) + } + p.tlsCert = &tlsCert + p.certExpiries = expiries + return E.Nil() +} + +func (p *Provider) LoadCert() E.NestedError { + cert, err := E.Check(tls.LoadX509KeyPair(p.cfg.CertPath, p.cfg.KeyPath)) + if err.IsNotNil() { + return err + } + expiries, err := getCertExpiries(&cert) + if err.IsNotNil() { + return err + } + p.tlsCert = &cert + p.certExpiries = expiries + p.renewIfNeeded() + return E.Nil() +} + +func (p *Provider) ShouldRenewOn() time.Time { + for _, expiry := range p.certExpiries { + return expiry.AddDate(0, -1, 0) + } + // this line should never be reached + panic("no certificate available") +} + +func (p *Provider) ScheduleRenewal(ctx context.Context) { + if p.GetName() == ProviderLocal { + return + } + + logger.Debug("starting renewal scheduler") + defer logger.Debug("renewal scheduler stopped") + + stop := make(chan struct{}) + + for { + select { + case <-ctx.Done(): + return + default: + t := time.Until(p.ShouldRenewOn()) + Logger.Infof("next renewal in %v", t.Round(time.Second)) + go func() { + <-time.After(t) + close(stop) + }() + select { + case <-ctx.Done(): + return + case <-stop: + if err := p.renewIfNeeded(); err.IsNotNil() { + Logger.Fatal(err) + } + } + } + } +} + +func (p *Provider) saveCert(cert *certificate.Resource) E.NestedError { + err := os.WriteFile(p.cfg.KeyPath, cert.PrivateKey, 0600) // -rw------- + if err != nil { + return E.Failure("write key file").With(err) + } + err = os.WriteFile(p.cfg.CertPath, cert.Certificate, 0644) // -rw-r--r-- + if err != nil { + return E.Failure("write cert file").With(err) + } + return E.Nil() +} + +func (p *Provider) needRenewal() bool { + expired := time.Now().After(p.ShouldRenewOn()) + if expired { + return true + } + if len(p.cfg.Domains) != len(p.certExpiries) { + return true + } + wantedDomains := make([]string, len(p.cfg.Domains)) + certDomains := make([]string, len(p.certExpiries)) + copy(wantedDomains, p.cfg.Domains) + i := 0 + for domain := range p.certExpiries { + certDomains[i] = domain + i++ + } + slices.Sort(wantedDomains) + slices.Sort(certDomains) + for i, domain := range certDomains { + if domain != wantedDomains[i] { + return true + } + } + return false +} + +func (p *Provider) renewIfNeeded() E.NestedError { + if !p.needRenewal() { + return E.Nil() + } + + p.mutex.Lock() + defer p.mutex.Unlock() + + if !p.needRenewal() { + return E.Nil() + } + + trials := 0 + for { + err := p.ObtainCert() + if err.IsNotNil() { + return E.Nil() + } + trials++ + if trials > 3 { + return E.Failure("renew certificate").With(err) + } + time.Sleep(5 * time.Second) + } +} + +func getCertExpiries(cert *tls.Certificate) (CertExpiries, E.NestedError) { + r := make(CertExpiries, len(cert.Certificate)) + for _, cert := range cert.Certificate { + x509Cert, err := E.Check(x509.ParseCertificate(cert)) + if err.IsNotNil() { + return nil, E.Failure("parse certificate").With(err) + } + if x509Cert.IsCA { + continue + } + r[x509Cert.Subject.CommonName] = x509Cert.NotAfter + } + return r, E.Nil() +} + +func setOptions[T interface{}](cfg *T, opt M.AutocertProviderOpt) E.NestedError { + for k, v := range opt { + err := utils.SetFieldFromSnake(cfg, k, v) + if err.IsNotNil() { + return E.Failure("set autocert option").Subject(k).With(err) + } + } + return E.Nil() +} + +func providerGenerator[CT any, PT challenge.Provider]( + defaultCfg func() *CT, + newProvider func(*CT) (PT, error), +) ProviderGenerator { + return func(opt M.AutocertProviderOpt) (challenge.Provider, error) { + cfg := defaultCfg() + err := setOptions(cfg, opt) + if err.IsNotNil() { + return nil, err + } + p, err := E.Check(newProvider(cfg)) + if err.IsNotNil() { + return nil, err + } + return p, nil + } +} + +var logger = logrus.WithField("?", "autocert") diff --git a/src/autocert/user.go b/src/autocert/user.go new file mode 100644 index 0000000..3117f2a --- /dev/null +++ b/src/autocert/user.go @@ -0,0 +1,22 @@ +package autocert + +import ( + "github.com/go-acme/lego/v4/registration" + "crypto" +) + +type User struct { + Email string + Registration *registration.Resource + key crypto.PrivateKey +} + +func (u *User) GetEmail() string { + return u.Email +} +func (u *User) GetRegistration() *registration.Resource { + return u.Registration +} +func (u *User) GetPrivateKey() crypto.PrivateKey { + return u.key +} \ No newline at end of file diff --git a/src/go-proxy/args.go b/src/common/args.go similarity index 60% rename from src/go-proxy/args.go rename to src/common/args.go index 408883b..1d96f69 100644 --- a/src/go-proxy/args.go +++ b/src/common/args.go @@ -1,9 +1,10 @@ -package main +package common import ( "flag" "github.com/sirupsen/logrus" + E "github.com/yusing/go-proxy/error" ) type Args struct { @@ -18,21 +19,21 @@ const ( var ValidCommands = []string{CommandStart, CommandValidate, CommandReload} -func getArgs() Args { +func GetArgs() Args { var args Args flag.Parse() args.Command = flag.Arg(0) - if err := validateArgs(args.Command, ValidCommands); err != nil { + if err := validateArgs(args.Command, ValidCommands); err.IsNotNil() { logrus.Fatal(err) } return args } -func validateArgs[T comparable](arg T, validArgs []T) error { +func validateArgs[T comparable](arg T, validArgs []T) E.NestedError { for _, v := range validArgs { if arg == v { - return nil + return E.Nil() } } - return NewNestedError("invalid argument").Subjectf("%v", arg) + return E.Invalid("argument", arg) } diff --git a/src/common/constants.go b/src/common/constants.go new file mode 100644 index 0000000..afa1258 --- /dev/null +++ b/src/common/constants.go @@ -0,0 +1,103 @@ +package common + +import ( + "time" +) + +const ( + ConnectionTimeout = 5 * time.Second + DialTimeout = 3 * time.Second + KeepAlive = 5 * time.Second +) + +const ( + ProviderKind_Docker = "docker" + ProviderKind_File = "file" +) + +// file, folder structure + +const ( + ConfigBasePath = "config/" + ConfigFileName = "config.yml" + ConfigPath = ConfigBasePath + ConfigFileName +) + +const ( + TemplatesBasePath = "templates/" + PanelTemplatePath = TemplatesBasePath + "panel/index.html" + ConfigEditorTemplatePath = TemplatesBasePath + "config_editor/index.html" +) + +const ( + SchemaBasePath = "schema/" + ConfigSchemaPath = SchemaBasePath + "config.schema.json" + ProvidersSchemaPath = SchemaBasePath + "providers.schema.json" +) + +const DockerHostFromEnv = "FROM_ENV" + +const ( + ProxyHTTPPort = ":80" + ProxyHTTPSPort = ":443" + APIHTTPPort = ":8888" + PanelHTTPPort = ":8080" +) + +var WellKnownHTTPPorts = map[uint16]bool{ + 80: true, + 8000: true, + 8008: true, + 8080: true, + 3000: true, +} + +var ( + ImageNamePortMapTCP = map[string]int{ + "postgres": 5432, + "mysql": 3306, + "mariadb": 3306, + "redis": 6379, + "mssql": 1433, + "memcached": 11211, + "rabbitmq": 5672, + "mongo": 27017, + } + ExtraNamePortMapTCP = map[string]int{ + "dns": 53, + "ssh": 22, + "ftp": 21, + "smtp": 25, + "pop3": 110, + "imap": 143, + } + NamePortMapTCP = func() map[string]int { + m := make(map[string]int) + for k, v := range ImageNamePortMapTCP { + m[k] = v + } + for k, v := range ExtraNamePortMapTCP { + m[k] = v + } + return m + }() +) + +// docker library uses uint16, so followed here +var ImageNamePortMapHTTP = map[string]uint16{ + "nginx": 80, + "httpd": 80, + "adguardhome": 3000, + "gogs": 3000, + "gitea": 3000, + "portainer": 9000, + "portainer-ce": 9000, + "home-assistant": 8123, + "homebridge": 8581, + "uptime-kuma": 3001, + "changedetection.io": 3000, + "prometheus": 9090, + "grafana": 3000, + "dockge": 5001, + "nginx-proxy-manager": 81, +} diff --git a/src/common/env.go b/src/common/env.go new file mode 100644 index 0000000..d6b7718 --- /dev/null +++ b/src/common/env.go @@ -0,0 +1,24 @@ +package common + +import ( + "os" + "strings" + + "github.com/sirupsen/logrus" +) + +var IsRunningAsService = getEnvBool("GOPROXY_IS_SYSTEMD") +var NoSchemaValidation = getEnvBool("GOPROXY_NO_SCHEMA_VALIDATION") +var IsDebug = getEnvBool("GOPROXY_DEBUG") + +var LogLevel = func() logrus.Level { + if IsDebug { + logrus.SetLevel(logrus.DebugLevel) + } + return logrus.GetLevel() +}() + +func getEnvBool(key string) bool { + v := os.Getenv(key) + return v == "1" || strings.ToLower(v) == "true" || strings.ToLower(v) == "yes" || strings.ToLower(v) == "on" +} diff --git a/src/config/config.go b/src/config/config.go new file mode 100644 index 0000000..eb9aa7f --- /dev/null +++ b/src/config/config.go @@ -0,0 +1,262 @@ +package config + +import ( + "context" + + "github.com/sirupsen/logrus" + "github.com/yusing/go-proxy/autocert" + "github.com/yusing/go-proxy/common" + E "github.com/yusing/go-proxy/error" + M "github.com/yusing/go-proxy/models" + PR "github.com/yusing/go-proxy/proxy/provider" + R "github.com/yusing/go-proxy/route" + U "github.com/yusing/go-proxy/utils" + F "github.com/yusing/go-proxy/utils/functional" + W "github.com/yusing/go-proxy/watcher" + "gopkg.in/yaml.v3" +) + +type Config struct { + value *M.Config + + l logrus.FieldLogger + reader U.Reader + proxyProviders *F.Map[string, *PR.Provider] + autocertProvider *autocert.Provider + + watcher W.Watcher + watcherCtx context.Context + watcherCancel context.CancelFunc + reloadReq chan struct{} +} + +func New() (*Config, E.NestedError) { + cfg := &Config{ + l: logrus.WithField("?", "config"), + reader: U.NewFileReader(common.ConfigPath), + watcher: W.NewFileWatcher(common.ConfigFileName), + reloadReq: make(chan struct{}), + } + if err := cfg.load(); err.IsNotNil() { + return nil, err + } + cfg.watchChanges() + return cfg, E.Nil() +} + +func Validate(data []byte) E.NestedError { + return U.ValidateYaml(U.GetSchema(common.ConfigSchemaPath), data) +} + +func (cfg *Config) Value() M.Config { + return *cfg.value +} + +func (cfg *Config) GetAutoCertProvider() *autocert.Provider { + return cfg.autocertProvider +} + +func (cfg *Config) Dispose() { + cfg.watcherCancel() + cfg.l.Debug("stopped watcher") + cfg.stopProviders() + cfg.l.Debug("stopped providers") +} + +func (cfg *Config) Reload() E.NestedError { + cfg.stopProviders() + if err := cfg.load(); err.IsNotNil() { + return err + } + cfg.startProviders() + return E.Nil() +} + +func (cfg *Config) FindRoute(alias string) R.Route { + r := cfg.proxyProviders.Find( + func(p *PR.Provider) (any, bool) { + rs := p.GetCurrentRoutes() + if rs.Contains(alias) { + return rs.Get(alias), true + } + return nil, false + }, + ) + if r == nil { + return nil + } + return r.(R.Route) +} + +func (cfg *Config) RoutesByAlias() map[string]U.SerializedObject { + routes := make(map[string]U.SerializedObject) + cfg.proxyProviders.Each(func(p *PR.Provider) { + prName := p.GetName() + p.GetCurrentRoutes().EachKV(func(a string, r R.Route) { + obj, err := U.Serialize(r) + if err != nil { + cfg.l.Error(err) + return + } + obj["provider"] = prName + switch r.(type) { + case *R.StreamRoute: + obj["type"] = "stream" + case *R.HTTPRoute: + obj["type"] = "reverse_proxy" + default: + panic("bug: should not reach here") + } + routes[a] = obj + }) + }) + return routes +} + +func (cfg *Config) Statistics() map[string]interface{} { + nTotalStreams := 0 + nTotalRPs := 0 + providerStats := make(map[string]interface{}) + + cfg.proxyProviders.Each(func(p *PR.Provider) { + stats := make(map[string]interface{}) + nStreams := 0 + nRPs := 0 + p.GetCurrentRoutes().EachKV(func(a string, r R.Route) { + switch r.(type) { + case *R.StreamRoute: + nStreams++ + nTotalStreams++ + case *R.HTTPRoute: + nRPs++ + nTotalRPs++ + default: + panic("bug: should not reach here") + } + }) + stats["num_streams"] = nStreams + stats["num_reverse_proxies"] = nRPs + switch p.ProviderImpl.(type) { + case *PR.DockerProvider: + stats["type"] = "docker" + case *PR.FileProvider: + stats["type"] = "file" + default: + panic("bug: should not reach here") + } + providerStats[p.GetName()] = stats + }) + + return map[string]interface{}{ + "num_total_streams": nTotalStreams, + "num_total_reverse_proxies": nTotalRPs, + "providers": providerStats, + } +} + +func (cfg *Config) watchChanges() { + cfg.watcherCtx, cfg.watcherCancel = context.WithCancel(context.Background()) + go func() { + for { + select { + case <-cfg.watcherCtx.Done(): + return + case <-cfg.reloadReq: + if err := cfg.Reload(); err.IsNotNil() { + cfg.l.Error(err) + } + } + } + }() + go func() { + eventCh, errCh := cfg.watcher.Events(cfg.watcherCtx) + for { + select { + case <-cfg.watcherCtx.Done(): + return + case event := <-eventCh: + if event.Action.IsDelete() { + cfg.stopProviders() + } else { + cfg.reloadReq <- struct{}{} + } + case err := <-errCh: + cfg.l.Error(err) + continue + } + } + }() +} + +func (cfg *Config) load() E.NestedError { + cfg.l.Debug("loading config") + + data, err := cfg.reader.Read() + if err.IsNotNil() { + return E.Failure("read config").With(err) + } + + model := M.DefaultConfig() + if err := E.From(yaml.Unmarshal(data, model)); err.IsNotNil() { + return E.Failure("parse config").With(err) + } + + if !common.NoSchemaValidation { + if err := Validate(data); err.IsNotNil() { + return err + } + } + + errors := E.NewBuilder("errors validating config") + + cfg.l.Debug("starting autocert") + ap, err := autocert.NewConfig(&model.AutoCert).GetProvider() + if err.IsNotNil() { + errors.Add(E.Failure("autocert provider").With(err)) + } else { + cfg.l.Debug("started autocert") + } + cfg.autocertProvider = ap + + cfg.l.Debug("starting providers") + cfg.proxyProviders = F.NewMap[string, *PR.Provider]() + for name, pm := range model.Providers { + p := PR.NewProvider(name, pm) + cfg.proxyProviders.Set(name, p) + if err := p.StartAllRoutes(); err.IsNotNil() { + errors.Add(E.Failure("start routes").Subjectf("provider %s", name).With(err)) + } + } + cfg.l.Debug("started providers") + + cfg.value = model + + if err := errors.Build(); err.IsNotNil() { + cfg.l.Warn(err) + } + + cfg.l.Debug("loaded config") + return E.Nil() +} + +func (cfg *Config) controlProviders(action string, do func(*PR.Provider) E.NestedError) { + errors := E.NewBuilder("cannot %s these providers", action) + + cfg.proxyProviders.EachKVParallel(func(name string, p *PR.Provider) { + if err := do(p); err.IsNotNil() { + errors.Add(E.From(err).Subjectf("provider %s", name)) + } + }) + + if err := errors.Build(); err.IsNotNil() { + cfg.l.Error(err) + } +} + +func (cfg *Config) startProviders() { + cfg.controlProviders("start", (*PR.Provider).StartAllRoutes) +} + +func (cfg *Config) stopProviders() { + cfg.controlProviders("stop", (*PR.Provider).StopAllRoutes) +} diff --git a/src/docker/client.go b/src/docker/client.go new file mode 100644 index 0000000..a3e1ba3 --- /dev/null +++ b/src/docker/client.go @@ -0,0 +1,94 @@ +package docker + +import ( + "net/http" + "sync" + + "github.com/docker/cli/cli/connhelper" + "github.com/docker/docker/client" + "github.com/sirupsen/logrus" + "github.com/yusing/go-proxy/common" + E "github.com/yusing/go-proxy/error" +) + +type Client = *client.Client + +// ConnectClient creates a new Docker client connection to the specified host. +// +// Returns existing client if available. +// +// Parameters: +// - host: the host to connect to (either a URL or "FROM_ENV"). +// +// Returns: +// - Client: the Docker client connection. +// - error: an error if the connection failed. +func ConnectClient(host string) (Client, E.NestedError) { + clientMapMu.Lock() + defer clientMapMu.Unlock() + + // check if client exists + if client, ok := clientMap[host]; ok { + return client, E.Nil() + } + + // create client + var opt []client.Opt + + switch host { + case common.DockerHostFromEnv: + opt = clientOptEnvHost + default: + helper, err := E.Check(connhelper.GetConnectionHelper(host)) + if err.IsNotNil() { + logger.Fatalf("unexpected error: %s", err) + } + if helper != nil { + httpClient := &http.Client{ + Transport: &http.Transport{ + DialContext: helper.Dialer, + }, + } + opt = []client.Opt{ + client.WithHTTPClient(httpClient), + client.WithHost(helper.Host), + client.WithAPIVersionNegotiation(), + client.WithDialContext(helper.Dialer), + } + } else { + opt = []client.Opt{ + client.WithHost(host), + client.WithAPIVersionNegotiation(), + } + } + } + + client, err := E.Check(client.NewClientWithOpts(opt...)) + + if err.IsNotNil() { + return nil, err + } + + clientMap[host] = client + return client, E.Nil() +} + +func CloseAllClients() { + clientMapMu.Lock() + defer clientMapMu.Unlock() + for _, client := range clientMap { + client.Close() + } + clientMap = make(map[string]Client) + logger.Debug("closed all clients") +} + +var clientMap map[string]Client = make(map[string]Client) +var clientMapMu sync.Mutex + +var clientOptEnvHost = []client.Opt{ + client.WithHostFromEnv(), + client.WithAPIVersionNegotiation(), +} + +var logger = logrus.WithField("?", "docker") diff --git a/src/docker/client_info.go b/src/docker/client_info.go new file mode 100644 index 0000000..b12ad7e --- /dev/null +++ b/src/docker/client_info.go @@ -0,0 +1,48 @@ +package docker + +import ( + "context" + "time" + + "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/client" + + E "github.com/yusing/go-proxy/error" +) + +type ClientInfo struct { + Host string + Containers []types.Container +} + +func GetClientInfo(clientHost string) (*ClientInfo, E.NestedError) { + dockerClient, err := ConnectClient(clientHost) + if err.IsNotNil() { + return nil, E.Failure("create docker client").With(err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + containers, err := E.Check(dockerClient.ContainerList(ctx, container.ListOptions{All: true})) + if err.IsNotNil() { + return nil, E.Failure("list containers").With(err) + } + + // extract host from docker client url + // since the services being proxied to + // should have the same IP as the docker client + url, err := E.Check(client.ParseHostURL(dockerClient.DaemonHost())) + if err.IsNotNil() { + return nil, E.Invalid("host url", dockerClient.DaemonHost()).With(err) + } + if url.Scheme == "unix" { + return &ClientInfo{Host: "localhost", Containers: containers}, E.Nil() + } + return &ClientInfo{Host: url.Hostname(), Containers: containers}, E.Nil() +} + +func IsErrConnectionFailed(err error) bool { + return client.IsErrConnectionFailed(err) +} diff --git a/src/docker/homepage_label.go b/src/docker/homepage_label.go new file mode 100644 index 0000000..3b1909e --- /dev/null +++ b/src/docker/homepage_label.go @@ -0,0 +1,32 @@ +package docker + +type ( + HomePageConfig struct{ m map[string]HomePageCategory } + HomePageCategory []HomePageItem + + HomePageItem struct { + Name string + Icon string + Category string + Description string + WidgetConfig map[string]interface{} + } +) + +func NewHomePageConfig() *HomePageConfig { + return &HomePageConfig{m: make(map[string]HomePageCategory)} +} + +func NewHomePageItem() *HomePageItem { + return &HomePageItem{} +} + +func (c *HomePageConfig) Clear() { + c.m = make(map[string]HomePageCategory) +} + +func (c *HomePageConfig) Add(item HomePageItem) { + c.m[item.Category] = HomePageCategory{item} +} + +const NSHomePage = "homepage" diff --git a/src/docker/label.go b/src/docker/label.go new file mode 100644 index 0000000..937bd06 --- /dev/null +++ b/src/docker/label.go @@ -0,0 +1,81 @@ +package docker + +import ( + "errors" + "strings" + + E "github.com/yusing/go-proxy/error" + U "github.com/yusing/go-proxy/utils" +) + +type Label struct { + Namespace string + Target string + Attribute string + Value any +} + +// Apply applies the value of a Label to the corresponding field in the given object. +// +// Parameters: +// - obj: a pointer to the object to which the Label value will be applied. +// - l: a pointer to the Label containing the attribute and value to be applied. +// +// Returns: +// - error: an error if the field does not exist. +func ApplyLabel[T any](obj *T, l *Label) E.NestedError { + return U.SetFieldFromSnake(obj, l.Attribute, l.Value) +} + +type ValueParser func(string) (any, E.NestedError) +type ValueParserMap map[string]ValueParser + +func ParseLabel(label string, value string) (*Label, E.NestedError) { + parts := strings.Split(label, ".") + + if len(parts) < 2 { + return &Label{ + Namespace: label, + Value: value, + }, E.Nil() + } + + l := &Label{ + Namespace: parts[0], + Target: parts[1], + Value: value, + } + + if len(parts) == 3 { + l.Attribute = parts[2] + } else { + l.Attribute = l.Target + } + + // find if namespace has value parser + pm, ok := labelValueParserMap[l.Namespace] + if !ok { + return l, E.Nil() + } + // find if attribute has value parser + p, ok := pm[l.Attribute] + if !ok { + return l, E.Nil() + } + // try to parse value + v, err := p(value) + if err.IsNotNil() { + return nil, err + } + l.Value = v + return l, E.Nil() +} + +func RegisterNamespace(namespace string, pm ValueParserMap) { + labelValueParserMap[namespace] = pm +} + +// namespace:target.attribute -> func(string) (any, error) +var labelValueParserMap = make(map[string]ValueParserMap) + +var ErrInvalidLabel = errors.New("invalid label") diff --git a/src/docker/proxy_label.go b/src/docker/proxy_label.go new file mode 100644 index 0000000..083953d --- /dev/null +++ b/src/docker/proxy_label.go @@ -0,0 +1,55 @@ +package docker + +import ( + "net/http" + "strings" + + E "github.com/yusing/go-proxy/error" +) + +func setHeadersParser(value string) (any, E.NestedError) { + value = strings.TrimSpace(value) + lines := strings.Split(value, "\n") + h := make(http.Header) + for _, line := range lines { + parts := strings.SplitN(line, ":", 2) + if len(parts) != 2 { + return nil, E.Invalid("set header statement", line) + } + key := strings.TrimSpace(parts[0]) + val := strings.TrimSpace(parts[1]) + h.Add(key, val) + } + return h, E.Nil() +} + +func commaSepParser(value string) (any, E.NestedError) { + v := strings.Split(value, ",") + for i := range v { + v[i] = strings.TrimSpace(v[i]) + } + return v, E.Nil() +} + +func boolParser(value string) (any, E.NestedError) { + switch strings.ToLower(value) { + case "true", "yes", "1": + return true, E.Nil() + case "false", "no", "0": + return false, E.Nil() + default: + return nil, E.Invalid("boolean value", value) + } +} + +const NSProxy = "proxy" + +var _ = func() int { + RegisterNamespace(NSProxy, ValueParserMap{ + "aliases": commaSepParser, + "set_headers": setHeadersParser, + "hide_headers": commaSepParser, + "no_tls_verify": boolParser, + }) + return 0 +}() diff --git a/src/docker/proxy_label_test.go b/src/docker/proxy_label_test.go new file mode 100644 index 0000000..1f86ffe --- /dev/null +++ b/src/docker/proxy_label_test.go @@ -0,0 +1,207 @@ +package docker + +import ( + "errors" + "fmt" + "net/http" + "reflect" + "testing" + + E "github.com/yusing/go-proxy/error" +) + +func makeLabel(namespace string, alias string, field string) string { + return fmt.Sprintf("%s.%s.%s", namespace, alias, field) +} + +func TestInvalidLabel(t *testing.T) { + pl, err := ParseLabel("foo.bar", "1234") + if !errors.Is(err, ErrInvalidLabel) { + t.Errorf("expected errInvalidLabel, got %s", err) + } + if pl != nil { + t.Errorf("expected nil, got %v", pl) + } + _, err = ParseLabel("proxy.foo", "bar") + if !errors.Is(err, ErrInvalidLabel) { + t.Errorf("expected errInvalidLabel, got %s", err) + } +} + +func TestHomePageLabel(t *testing.T) { + alias := "foo" + field := "ip" + v := "bar" + pl, err := ParseLabel(makeLabel(NSHomePage, alias, field), v) + if err.IsNotNil() { + t.Errorf("expected err=nil, got %s", err) + } + if pl.Target != alias { + t.Errorf("expected alias=%s, got %s", alias, pl.Target) + } + if pl.Attribute != field { + t.Errorf("expected field=%s, got %s", field, pl.Target) + } + if pl.Value != v { + t.Errorf("expected value=%q, got %s", v, pl.Value) + } +} + +func TestStringProxyLabel(t *testing.T) { + alias := "foo" + field := "ip" + v := "bar" + pl, err := ParseLabel(makeLabel(NSProxy, alias, field), v) + if err.IsNotNil() { + t.Errorf("expected err=nil, got %s", err) + } + if pl.Target != alias { + t.Errorf("expected alias=%s, got %s", alias, pl.Target) + } + if pl.Attribute != field { + t.Errorf("expected field=%s, got %s", field, pl.Target) + } + if pl.Value != v { + t.Errorf("expected value=%q, got %s", v, pl.Value) + } +} + +func TestBoolProxyLabelValid(t *testing.T) { + alias := "foo" + field := "no_tls_verify" + tests := map[string]bool{ + "true": true, + "TRUE": true, + "yes": true, + "1": true, + "false": false, + "FALSE": false, + "no": false, + "0": false, + } + + for k, v := range tests { + pl, err := ParseLabel(makeLabel(NSProxy, alias, field), k) + if err.IsNotNil() { + t.Errorf("expected err=nil, got %s", err) + } + if pl.Target != alias { + t.Errorf("expected alias=%s, got %s", alias, pl.Target) + } + if pl.Attribute != field { + t.Errorf("expected field=%s, got %s", field, pl.Attribute) + } + if pl.Value != v { + t.Errorf("expected value=%v, got %v", v, pl.Value) + } + } +} + +func TestBoolProxyLabelInvalid(t *testing.T) { + alias := "foo" + field := "no_tls_verify" + _, err := ParseLabel(makeLabel(NSProxy, alias, field), "invalid") + if !errors.Is(err, E.ErrInvalid) { + t.Errorf("expected err InvalidProxyLabel, got %s", err) + } +} + +func TestHeaderProxyLabelValid(t *testing.T) { + alias := "foo" + field := "set_headers" + v := ` + X-Custom-Header1: foo + X-Custom-Header1: bar + X-Custom-Header2: baz + ` + h := make(http.Header, 0) + h.Set("X-Custom-Header1", "foo") + h.Add("X-Custom-Header1", "bar") + h.Set("X-Custom-Header2", "baz") + + pl, err := ParseLabel(makeLabel(NSProxy, alias, field), v) + if err.IsNotNil() { + t.Errorf("expected err=nil, got %s", err) + } + if pl.Target != alias { + t.Errorf("expected alias=%s, got %s", alias, pl.Target) + } + if pl.Attribute != field { + t.Errorf("expected field=%s, got %s", field, pl.Attribute) + } + hGot, ok := pl.Value.(http.Header) + if !ok { + t.Error("value is not http.Header") + return + } + for k, vWant := range h { + vGot := hGot[k] + if !reflect.DeepEqual(vGot, vWant) { + t.Errorf("expected %s=%q, got %q", k, vWant, vGot) + } + } +} + +func TestHeaderProxyLabelInvalid(t *testing.T) { + alias := "foo" + field := "set_headers" + tests := []string{ + "X-Custom-Header1 = bar", + "X-Custom-Header1", + } + + for _, v := range tests { + _, err := ParseLabel(makeLabel(NSProxy, alias, field), v) + if !errors.Is(err, E.ErrInvalid) { + t.Errorf("expected err InvalidProxyLabel for %q, got %v", v, err) + } + } +} + +func TestCommaSepProxyLabelSingle(t *testing.T) { + alias := "foo" + field := "hide_headers" + v := "X-Custom-Header1" + pl, err := ParseLabel(makeLabel(NSProxy, alias, field), v) + if err.IsNotNil() { + t.Errorf("expected err=nil, got %s", err) + } + if pl.Target != alias { + t.Errorf("expected alias=%s, got %s", alias, pl.Target) + } + if pl.Attribute != field { + t.Errorf("expected field=%s, got %s", field, pl.Attribute) + } + sGot, ok := pl.Value.([]string) + sWant := []string{"X-Custom-Header1"} + if !ok { + t.Error("value is not []string") + } + if !reflect.DeepEqual(sGot, sWant) { + t.Errorf("expected %q, got %q", sWant, sGot) + } +} + +func TestCommaSepProxyLabelMulti(t *testing.T) { + alias := "foo" + field := "hide_headers" + v := "X-Custom-Header1, X-Custom-Header2,X-Custom-Header3" + pl, err := ParseLabel(makeLabel(NSProxy, alias, field), v) + if err.IsNotNil() { + t.Errorf("expected err=nil, got %s", err) + } + if pl.Target != alias { + t.Errorf("expected alias=%s, got %s", alias, pl.Target) + } + if pl.Attribute != field { + t.Errorf("expected field=%s, got %s", field, pl.Attribute) + } + sGot, ok := pl.Value.([]string) + sWant := []string{"X-Custom-Header1", "X-Custom-Header2", "X-Custom-Header3"} + if !ok { + t.Error("value is not []string") + } + if !reflect.DeepEqual(sGot, sWant) { + t.Errorf("expected %q, got %q", sWant, sGot) + } +} diff --git a/src/error/builder.go b/src/error/builder.go new file mode 100644 index 0000000..80d79b6 --- /dev/null +++ b/src/error/builder.go @@ -0,0 +1,43 @@ +package error + +import ( + "fmt" + "sync" +) + +type Builder struct { + message string + errors []error + sync.Mutex +} + +func NewBuilder(format string, args ...any) *Builder { + return &Builder{message: fmt.Sprintf(format, args...)} +} + +func (b *Builder) Add(err error) *Builder { + if err != nil { + b.Lock() + b.errors = append(b.errors, err) + b.Unlock() + } + return b +} + +func (b *Builder) Addf(format string, args ...any) *Builder { + return b.Add(fmt.Errorf(format, args...)) +} + +// Build builds a NestedError based on the errors collected in the Builder. +// +// If there are no errors in the Builder, it returns a Nil() NestedError. +// Otherwise, it returns a NestedError with the message and the errors collected. +// +// Returns: +// - NestedError: the built NestedError. +func (b *Builder) Build() NestedError { + if len(b.errors) == 0 { + return Nil() + } + return Join(b.message, b.errors...) +} diff --git a/src/error/error.go b/src/error/error.go new file mode 100644 index 0000000..e8a8426 --- /dev/null +++ b/src/error/error.go @@ -0,0 +1,217 @@ +package error + +import ( + "errors" + "fmt" + "strings" + "sync" +) + +type ( + // NestedError is an error with an inner error + // and a list of extra nested errors. + // + // It is designed to be non nil. + // + // You can use it to join multiple errors, + // or to set a inner reason for a nested error. + // + // When a method returns both valid values and errors, + // You should return (Slice/Map, NestedError). + // Caller then should handle the nested error, + // and continue with the valid values. + NestedError struct{ *nestedError } + nestedError struct { + neBase + sync.Mutex + } + neBase struct { + subject any + err error // can be nil + extras []neBase + inner *neBase // can be nil + level int + } +) + +func Nil() NestedError { return NestedError{} } + +func From(err error) NestedError { + if err == nil { + return Nil() + } + return NestedError{&nestedError{neBase: *copyFrom(err)}} +} + +// Check is a helper function that +// convert (T, error) to (T, NestedError). +func Check[T any](obj T, err error) (T, NestedError) { + return obj, From(err) +} + +func Join(message string, err ...error) NestedError { + extras := make([]neBase, 0, len(err)) + nErr := 0 + for _, e := range err { + if err == nil { + continue + } + extras = append(extras, *copyFrom(e)) + nErr += 1 + } + if nErr == 0 { + return Nil() + } + return NestedError{&nestedError{ + neBase: neBase{ + err: errors.New(message), + extras: extras, + }, + }} +} + +func copyFrom(err error) *neBase { + if err == nil { + return nil + } + switch base := err.(type) { + case *neBase: + copy := *base + return © + } + return &neBase{err: err} +} + +func new(message ...string) NestedError { + if len(message) == 0 { + return From(nil) + } + return From(errors.New(strings.Join(message, " "))) +} + +func errorf(format string, args ...any) NestedError { + return From(fmt.Errorf(format, args...)) +} + +func (ne *neBase) Error() string { + var buf strings.Builder + ne.writeToSB(&buf, ne.level, "") + return buf.String() +} + +func (ne NestedError) ExtraError(err error) NestedError { + if err != nil { + ne.Lock() + ne.extras = append(ne.extras, From(err).addLevel(ne.Level()+1)) + ne.Unlock() + } + return ne +} + +func (ne NestedError) Extra(s string) NestedError { + return ne.ExtraError(errors.New(s)) +} + +func (ne NestedError) ExtraAny(s any) NestedError { + var msg string + switch ss := s.(type) { + case error: + return ne.ExtraError(ss) + case string: + msg = ss + case fmt.Stringer: + msg = ss.String() + default: + msg = fmt.Sprint(s) + } + return ne.ExtraError(errors.New(msg)) +} + +func (ne NestedError) Extraf(format string, args ...any) NestedError { + return ne.ExtraError(fmt.Errorf(format, args...)) +} + +func (ne NestedError) Subject(s any) NestedError { + ne.subject = s + return ne +} + +func (ne NestedError) Subjectf(format string, args ...any) NestedError { + if strings.Contains(format, "%q") { + panic("Subjectf format should not contain %q") + } + if strings.Contains(format, "%w") { + panic("Subjectf format should not contain %w") + } + return ne.Subject(fmt.Sprintf(format, args...)) +} + +func (ne NestedError) Level() int { + return ne.level +} + +func (ne *nestedError) IsNil() bool { + return ne == nil +} + +func (ne *nestedError) IsNotNil() bool { + return ne != nil +} + +func (ne NestedError) With(inner error) NestedError { + ne.Lock() + defer ne.Unlock() + + if ne.inner == nil { + ne.inner = copyFrom(inner) + } else { + ne.ExtraError(inner) + } + + root := &ne.neBase + for root.inner != nil { + root.inner.level = root.level + 1 + root = root.inner + } + return ne +} + +func (ne *neBase) addLevel(level int) neBase { + ret := *ne + ret.level += level + if ret.inner != nil { + inner := ret.inner.addLevel(level) + ret.inner = &inner + } + return ret +} + +func (ne *neBase) writeToSB(sb *strings.Builder, level int, prefix string) { + ne.writeIndents(sb, level) + sb.WriteString(prefix) + + if ne.err != nil { + sb.WriteString(ne.err.Error()) + sb.WriteRune(' ') + } + if ne.subject != nil { + sb.WriteString(fmt.Sprintf("for %q", ne.subject)) + } + if ne.inner != nil || len(ne.extras) > 0 { + sb.WriteString(":\n") + } + level += 1 + for _, extra := range ne.extras { + extra.writeToSB(sb, level, "- ") + sb.WriteRune('\n') + } + if ne.inner != nil { + ne.inner.writeToSB(sb, level, "- ") + } +} + +func (ne *neBase) writeIndents(sb *strings.Builder, level int) { + for i := 0; i < level; i++ { + sb.WriteString(" ") + } +} diff --git a/src/go-proxy/error_test.go b/src/error/error_test.go similarity index 76% rename from src/go-proxy/error_test.go rename to src/error/error_test.go index 7ac5ddf..d393634 100644 --- a/src/go-proxy/error_test.go +++ b/src/error/error_test.go @@ -1,4 +1,4 @@ -package main +package error import ( "testing" @@ -12,35 +12,35 @@ func AssertEq(t *testing.T, got, want string) { } func TestErrorSimple(t *testing.T) { - ne := NewNestedError("foo bar") + ne := new("foo bar") AssertEq(t, ne.Error(), "foo bar") ne.Subject("baz") AssertEq(t, ne.Error(), "baz: foo bar") } func TestErrorSubjectOnly(t *testing.T) { - ne := NewNestedError("").Subject("bar") + ne := new().Subject("bar") AssertEq(t, ne.Error(), "bar") } func TestErrorExtra(t *testing.T) { - ne := NewNestedError("foo").Extra("bar").Extra("baz") + ne := new("foo").Extra("bar").Extra("baz") AssertEq(t, ne.Error(), "foo:\n - bar\n - baz\n") } func TestErrorNested(t *testing.T) { - inner := NewNestedError("inner"). + inner := new("inner"). Extra("123"). Extra("456") - inner2 := NewNestedError("inner"). + inner2 := new("inner"). Subject("2"). Extra("456"). Extra("789") - inner3 := NewNestedError("inner"). + inner3 := new("inner"). Subject("3"). Extra("456"). Extra("789") - ne := NewNestedError("foo"). + ne := new("foo"). Extra("bar"). Extra("baz"). ExtraError(inner). diff --git a/src/error/errors.go b/src/error/errors.go new file mode 100644 index 0000000..77bb47a --- /dev/null +++ b/src/error/errors.go @@ -0,0 +1,30 @@ +package error + +var ( + ErrAlreadyStarted = new("already started") + ErrNotStarted = new("not started") + ErrInvalid = new("invalid") + ErrUnsupported = new("unsupported") + ErrNotExists = new("does not exist") + ErrDuplicated = new("duplicated") +) + +func Failure(what string) NestedError { + return errorf("%s failed", what) +} + +func Invalid(subject, what any) NestedError { + return errorf("%w %s: %q", ErrInvalid, subject, what) +} + +func Unsupported(subject, what any) NestedError { + return errorf("%w %s: %q", ErrUnsupported, subject, what) +} + +func NotExists(subject, what any) NestedError { + return errorf("%s %w: %q", subject, ErrNotExists, what) +} + +func Duplicated(subject, what any) NestedError { + return errorf("%w %s: %q", ErrDuplicated, subject, what) +} diff --git a/src/go-proxy/autocert.go b/src/go-proxy/autocert.go deleted file mode 100644 index fa57be5..0000000 --- a/src/go-proxy/autocert.go +++ /dev/null @@ -1,331 +0,0 @@ -package main - -import ( - "crypto" - "crypto/ecdsa" - "crypto/elliptic" - "crypto/rand" - "crypto/tls" - "crypto/x509" - "os" - "path" - "slices" - "sync" - "time" - - "github.com/go-acme/lego/v4/certcrypto" - "github.com/go-acme/lego/v4/certificate" - "github.com/go-acme/lego/v4/challenge" - "github.com/go-acme/lego/v4/lego" - "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/registration" -) - -type ProviderOptions map[string]string -type ProviderGenerator func(ProviderOptions) (challenge.Provider, error) -type CertExpiries map[string]time.Time - -type AutoCertConfig struct { - Email string `json:"email"` - Domains []string `yaml:",flow" json:"domains"` - Provider string `json:"provider"` - Options ProviderOptions `yaml:",flow" json:"options"` -} - -type AutoCertUser struct { - Email string - Registration *registration.Resource - key crypto.PrivateKey -} - -func (u *AutoCertUser) GetEmail() string { - return u.Email -} -func (u *AutoCertUser) GetRegistration() *registration.Resource { - return u.Registration -} -func (u *AutoCertUser) GetPrivateKey() crypto.PrivateKey { - return u.key -} - -type AutoCertProvider interface { - GetCert(*tls.ClientHelloInfo) (*tls.Certificate, error) - GetName() string - GetExpiries() CertExpiries - LoadCert() bool - ObtainCert() NestedErrorLike - ShouldRenewOn() time.Time - ScheduleRenewal() -} - -func (cfg AutoCertConfig) GetProvider() (AutoCertProvider, error) { - ne := NewNestedError("invalid autocert config") - - if len(cfg.Domains) == 0 { - ne.Extra("no domains specified") - } - if cfg.Provider == "" { - ne.Extra("no provider specified") - } - if cfg.Email == "" { - ne.Extra("no email specified") - } - gen, ok := providersGenMap[cfg.Provider] - if !ok { - ne.Extraf("unknown provider: %q", cfg.Provider) - } - if ne.HasExtras() { - return nil, ne - } - - ne = NewNestedError("unable to create provider") - privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - if err != nil { - return nil, ne.With(NewNestedError("unable to generate private key").With(err)) - } - user := &AutoCertUser{ - Email: cfg.Email, - key: privKey, - } - legoCfg := lego.NewConfig(user) - legoCfg.Certificate.KeyType = certcrypto.RSA2048 - legoClient, err := lego.NewClient(legoCfg) - if err != nil { - return nil, ne.With(NewNestedError("unable to create lego client").With(err)) - } - base := &autoCertProvider{ - name: cfg.Provider, - cfg: cfg, - user: user, - legoCfg: legoCfg, - client: legoClient, - } - legoProvider, err := gen(cfg.Options) - if err != nil { - return nil, ne.With(err) - } - err = legoClient.Challenge.SetDNS01Provider(legoProvider) - if err != nil { - return nil, ne.With(NewNestedError("unable to set challenge provider").With(err)) - } - return base, nil -} - -type autoCertProvider struct { - name string - cfg AutoCertConfig - user *AutoCertUser - legoCfg *lego.Config - client *lego.Client - - tlsCert *tls.Certificate - certExpiries CertExpiries - mutex sync.Mutex -} - -func (p *autoCertProvider) GetCert(_ *tls.ClientHelloInfo) (*tls.Certificate, error) { - if p.tlsCert == nil { - return nil, NewNestedError("no certificate available") - } - return p.tlsCert, nil -} - -func (p *autoCertProvider) GetName() string { - return p.name -} - -func (p *autoCertProvider) GetExpiries() CertExpiries { - return p.certExpiries -} - -func (p *autoCertProvider) ObtainCert() NestedErrorLike { - ne := NewNestedError("failed to obtain certificate") - - client := p.client - if p.user.Registration == nil { - reg, err := client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true}) - if err != nil { - return ne.With(NewNestedError("failed to register account").With(err)) - } - p.user.Registration = reg - } - req := certificate.ObtainRequest{ - Domains: p.cfg.Domains, - Bundle: true, - } - cert, err := client.Certificate.Obtain(req) - if err != nil { - return ne.With(err) - } - err = p.saveCert(cert) - if err != nil { - return ne.With(NewNestedError("failed to save certificate").With(err)) - } - tlsCert, err := tls.X509KeyPair(cert.Certificate, cert.PrivateKey) - if err != nil { - return ne.With(NewNestedError("failed to parse obtained certificate").With(err)) - } - expiries, err := getCertExpiries(&tlsCert) - if err != nil { - return ne.With(NewNestedError("failed to get certificate expiry").With(err)) - } - p.tlsCert = &tlsCert - p.certExpiries = expiries - return nil -} - -func (p *autoCertProvider) LoadCert() bool { - cert, err := tls.LoadX509KeyPair(certFileDefault, keyFileDefault) - if err != nil { - return false - } - expiries, err := getCertExpiries(&cert) - if err != nil { - return false - } - p.tlsCert = &cert - p.certExpiries = expiries - p.renewIfNeeded() - return true -} - -func (p *autoCertProvider) ShouldRenewOn() time.Time { - for _, expiry := range p.certExpiries { - return expiry.AddDate(0, -1, 0) - } - // this line should never be reached - panic("no certificate available") -} - -func (p *autoCertProvider) ScheduleRenewal() { - for { - t := time.Until(p.ShouldRenewOn()) - aclog.Infof("next renewal in %v", t.Round(time.Second)) - time.Sleep(t) - err := p.renewIfNeeded() - if err != nil { - aclog.Fatal(err) - } - } -} - -func (p *autoCertProvider) saveCert(cert *certificate.Resource) NestedErrorLike { - err := os.MkdirAll(path.Dir(certFileDefault), 0644) - if err != nil { - return NewNestedError("unable to create cert directory").With(err) - } - err = os.WriteFile(keyFileDefault, cert.PrivateKey, 0600) // -rw------- - if err != nil { - return NewNestedError("unable to write key file").With(err) - } - err = os.WriteFile(certFileDefault, cert.Certificate, 0644) // -rw-r--r-- - if err != nil { - return NewNestedError("unable to write cert file").With(err) - } - return nil -} - -func (p *autoCertProvider) needRenewal() bool { - expired := time.Now().After(p.ShouldRenewOn()) - if expired { - return true - } - if len(p.cfg.Domains) != len(p.certExpiries) { - return true - } - wantedDomains := make([]string, len(p.cfg.Domains)) - certDomains := make([]string, len(p.certExpiries)) - copy(wantedDomains, p.cfg.Domains) - i := 0 - for domain := range p.certExpiries { - certDomains[i] = domain - i++ - } - slices.Sort(wantedDomains) - slices.Sort(certDomains) - for i, domain := range certDomains { - if domain != wantedDomains[i] { - return true - } - } - return false -} - -func (p *autoCertProvider) renewIfNeeded() NestedErrorLike { - if !p.needRenewal() { - return nil - } - - p.mutex.Lock() - defer p.mutex.Unlock() - - if !p.needRenewal() { - return nil - } - - trials := 0 - for { - err := p.ObtainCert() - if err == nil { - aclog.Info("renewed certificate") - return nil - } - trials++ - if trials > 3 { - return NewNestedError("failed to renew certificate after 3 trials").With(err) - } - aclog.Errorf("failed to renew certificate: %v, trying again in 5 seconds", err) - time.Sleep(5 * time.Second) - } -} - -func providerGenerator[CT any, PT challenge.Provider]( - defaultCfg func() *CT, - newProvider func(*CT) (PT, error), -) ProviderGenerator { - return func(opt ProviderOptions) (challenge.Provider, error) { - cfg := defaultCfg() - err := setOptions(cfg, opt) - if err != nil { - return nil, err - } - p, err := newProvider(cfg) - if err != nil { - return nil, err - } - return p, nil - } -} - -func getCertExpiries(cert *tls.Certificate) (CertExpiries, error) { - r := make(CertExpiries, len(cert.Certificate)) - for _, cert := range cert.Certificate { - x509Cert, err := x509.ParseCertificate(cert) - if err != nil { - return nil, NewNestedError("unable to parse certificate").With(err) - } - if x509Cert.IsCA { - continue - } - r[x509Cert.Subject.CommonName] = x509Cert.NotAfter - } - return r, nil -} - -func setOptions[T interface{}](cfg *T, opt ProviderOptions) error { - for k, v := range opt { - err := setFieldFromSnake(cfg, k, v) - if err != nil { - return NewNestedError("unable to set option").Subject(k).With(err) - } - } - return nil -} - -var providersGenMap = map[string]ProviderGenerator{ - "cloudflare": providerGenerator(cloudflare.NewDefaultConfig, cloudflare.NewDNSProviderConfig), - "clouddns": providerGenerator(clouddns.NewDefaultConfig, clouddns.NewDNSProviderConfig), - "duckdns": providerGenerator(duckdns.NewDefaultConfig, duckdns.NewDNSProviderConfig), -} diff --git a/src/go-proxy/config.go b/src/go-proxy/config.go deleted file mode 100644 index 21af8a6..0000000 --- a/src/go-proxy/config.go +++ /dev/null @@ -1,200 +0,0 @@ -package main - -import ( - "sync" - "time" - - "github.com/sirupsen/logrus" - "gopkg.in/yaml.v3" -) - -// commented out if unused -type Config interface { - Value() configModel - // Load() error - MustLoad() - GetAutoCertProvider() (AutoCertProvider, error) - // MustReload() - Reload() error - StartProviders() - StopProviders() - WatchChanges() - StopWatching() -} - -func NewConfig(path string) Config { - cfg := &config{ - reader: &FileReader{Path: path}, - l: cfgl, - } - // must init fields above before creating watcher - cfg.watcher = cfg.NewFileWatcher() - return cfg -} - -func ValidateConfig(data []byte) error { - cfg := &config{reader: &ByteReader{data}} - return cfg.Load() -} - -func (cfg *config) Value() configModel { - return *cfg.m -} - -func (cfg *config) Load() error { - if cfg.reader == nil { - panic("config reader not set") - } - - data, err := cfg.reader.Read() - if err != nil { - return NewNestedError("unable to read config file").With(err) - } - - model := defaultConfig() - if err := yaml.Unmarshal(data, model); err != nil { - return NewNestedError("unable to parse config file").With(err) - } - - ne := NewNestedError("invalid config") - - err = validateYaml(configSchema, data) - if err != nil { - ne.With(err) - } - - pErrs := NewNestedError("these providers have errors") - - for name, p := range model.Providers { - if p.Kind != ProviderKind_File { - continue - } - _, err := p.ValidateFile() - if err != nil { - pErrs.ExtraError( - NewNestedError("provider file validation error"). - Subject(name). - With(err), - ) - } - } - if pErrs.HasExtras() { - ne.With(pErrs) - } - if ne.HasInner() { - return ne - } - - cfg.mutex.Lock() - defer cfg.mutex.Unlock() - - cfg.m = model - return nil -} - -func (cfg *config) MustLoad() { - if err := cfg.Load(); err != nil { - cfg.l.Fatal(err) - } -} - -func (cfg *config) GetAutoCertProvider() (AutoCertProvider, error) { - return cfg.m.AutoCert.GetProvider() -} - -func (cfg *config) Reload() error { - cfg.StopProviders() - if err := cfg.Load(); err != nil { - return err - } - cfg.StartProviders() - return nil -} - -func (cfg *config) MustReload() { - if err := cfg.Reload(); err != nil { - cfg.l.Fatal(err) - } -} - -func (cfg *config) StartProviders() { - if cfg.providerInitialized { - return - } - - cfg.mutex.Lock() - defer cfg.mutex.Unlock() - if cfg.providerInitialized { - return - } - - pErrs := NewNestedError("failed to start these providers") - - ParallelForEachKeyValue(cfg.m.Providers, func(name string, p *Provider) { - err := p.Init(name) - if err != nil { - pErrs.ExtraError(NewNestedErrorFrom(err).Subjectf("%s providers %q", p.Kind, name)) - delete(cfg.m.Providers, name) - } - p.StartAllRoutes() - }) - - cfg.providerInitialized = true - - if pErrs.HasExtras() { - cfg.l.Error(pErrs) - } -} - -func (cfg *config) StopProviders() { - if !cfg.providerInitialized { - return - } - - cfg.mutex.Lock() - defer cfg.mutex.Unlock() - if !cfg.providerInitialized { - return - } - ParallelForEachValue(cfg.m.Providers, (*Provider).StopAllRoutes) - cfg.m.Providers = make(map[string]*Provider) - cfg.providerInitialized = false -} - -func (cfg *config) WatchChanges() { - if cfg.watcher == nil { - return - } - cfg.watcher.Start() -} - -func (cfg *config) StopWatching() { - if cfg.watcher == nil { - return - } - cfg.watcher.Stop() -} - -type configModel struct { - Providers map[string]*Provider `yaml:",flow" json:"providers"` - AutoCert AutoCertConfig `yaml:",flow" json:"autocert"` - TimeoutShutdown time.Duration `yaml:"timeout_shutdown" json:"timeout_shutdown"` - RedirectToHTTPS bool `yaml:"redirect_to_https" json:"redirect_to_https"` -} - -func defaultConfig() *configModel { - return &configModel{ - TimeoutShutdown: 3 * time.Second, - RedirectToHTTPS: false, - } -} - -type config struct { - m *configModel - - l logrus.FieldLogger - reader Reader - watcher Watcher - mutex sync.Mutex - providerInitialized bool -} diff --git a/src/go-proxy/constants.go b/src/go-proxy/constants.go deleted file mode 100644 index 5e42a7f..0000000 --- a/src/go-proxy/constants.go +++ /dev/null @@ -1,191 +0,0 @@ -package main - -import ( - "crypto/tls" - "net" - "net/http" - "os" - "time" - - "github.com/santhosh-tekuri/jsonschema" - "github.com/sirupsen/logrus" -) - -var ( - ImageNamePortMapTCP = map[string]string{ - "postgres": "5432", - "mysql": "3306", - "mariadb": "3306", - "redis": "6379", - "mssql": "1433", - "memcached": "11211", - "rabbitmq": "5672", - "mongo": "27017", - } - ExtraNamePortMapTCP = map[string]string{ - "dns": "53", - "ssh": "22", - "ftp": "21", - "smtp": "25", - "pop3": "110", - "imap": "143", - } - NamePortMapTCP = func() map[string]string { - m := make(map[string]string) - for k, v := range ImageNamePortMapTCP { - m[k] = v - } - for k, v := range ExtraNamePortMapTCP { - m[k] = v - } - return m - }() -) - -var ImageNamePortMapHTTP = map[string]uint16{ - "nginx": 80, - "httpd": 80, - "adguardhome": 3000, - "gogs": 3000, - "gitea": 3000, - "portainer": 9000, - "portainer-ce": 9000, - "home-assistant": 8123, - "homebridge": 8581, - "uptime-kuma": 3001, - "changedetection.io": 3000, - "prometheus": 9090, - "grafana": 3000, - "dockge": 5001, - "nginx-proxy-manager": 81, -} - -var wellKnownHTTPPorts = map[uint16]bool{ - 80: true, - 8000: true, - 8008: true, - 8080: true, - 3000: true, -} - -var ( - StreamSchemes = []string{StreamType_TCP, StreamType_UDP} // TODO: support "tcp:udp", "udp:tcp" - HTTPSchemes = []string{"http", "https"} - ValidSchemes = append(StreamSchemes, HTTPSchemes...) -) - -const ( - StreamType_UDP = "udp" - StreamType_TCP = "tcp" -) - -const ( - ProxyPathMode_Forward = "forward" - ProxyPathMode_Sub = "sub" - ProxyPathMode_RemovedPath = "" -) - -const ( - ProviderKind_Docker = "docker" - ProviderKind_File = "file" -) - -// TODO: default + per proxy -var ( - transport = &http.Transport{ - Proxy: http.ProxyFromEnvironment, - DialContext: (&net.Dialer{ - Timeout: 60 * time.Second, - KeepAlive: 60 * time.Second, - }).DialContext, - MaxIdleConns: 1000, - MaxIdleConnsPerHost: 1000, - } - - transportNoTLS = func() *http.Transport { - var clone = transport.Clone() - clone.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} - return clone - }() - - healthCheckHttpClient = &http.Client{ - Timeout: 5 * time.Second, - Transport: &http.Transport{ - Proxy: http.ProxyFromEnvironment, - DisableKeepAlives: true, - ForceAttemptHTTP2: true, - DialContext: (&net.Dialer{ - Timeout: 5 * time.Second, - KeepAlive: 5 * time.Second, - }).DialContext, - TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, - }, - } -) - -const wildcardAlias = "*" - -const clientUrlFromEnv = "FROM_ENV" - -const ( - certBasePath = "certs/" - certFileDefault = certBasePath + "cert.crt" - keyFileDefault = certBasePath + "priv.key" - - configBasePath = "config/" - configPath = configBasePath + "config.yml" - - templatesBasePath = "templates/" - panelTemplatePath = templatesBasePath + "panel/index.html" - configEditorTemplatePath = templatesBasePath + "config_editor/index.html" - - schemaBasePath = "schema/" - configSchemaPath = schemaBasePath + "config.schema.json" - providersSchemaPath = schemaBasePath + "providers.schema.json" -) - -var ( - configSchema *jsonschema.Schema - providersSchema *jsonschema.Schema -) - -const ( - streamStopListenTimeout = 1 * time.Second - streamDialTimeout = 3 * time.Second -) - -const udpBufferSize = 1500 - -var isHostNetworkMode = getEnvBool("GOPROXY_HOST_NETWORK") - -var logLevel = func() logrus.Level { - if getEnvBool("GOPROXY_DEBUG") { - logrus.SetLevel(logrus.DebugLevel) - } - return logrus.GetLevel() -}() - -var isRunningAsService = getEnvBool("IS_SYSTEMD") || getEnvBool("GOPROXY_IS_SYSTEMD") // IS_SYSTEMD is deprecated - -var noSchemaValidation = getEnvBool("GOPROXY_NO_SCHEMA_VALIDATION") - -func getEnvBool(key string) bool { - v := os.Getenv(key) - return v == "1" || v == "true" -} - -func initSchema() { - if noSchemaValidation { - return - } - - c := jsonschema.NewCompiler() - c.Draft = jsonschema.Draft7 - var err error - if configSchema, err = c.Compile(configSchemaPath); err != nil { - panic(err) - } - if providersSchema, err = c.Compile(providersSchemaPath); err != nil { - panic(err) - } -} \ No newline at end of file diff --git a/src/go-proxy/docker_provider.go b/src/go-proxy/docker_provider.go deleted file mode 100755 index 3ac66c7..0000000 --- a/src/go-proxy/docker_provider.go +++ /dev/null @@ -1,295 +0,0 @@ -package main - -import ( - "errors" - "fmt" - "net/http" - "strconv" - "strings" - "time" - - "github.com/docker/cli/cli/connhelper" - "github.com/docker/docker/api/types" - "github.com/docker/docker/api/types/container" - "github.com/docker/docker/client" - "golang.org/x/net/context" -) - -func setConfigField(pl *ProxyLabel, c *ProxyConfig) error { - return setFieldFromSnake(c, pl.Field, pl.Value) -} - -func (p *Provider) getContainerProxyConfigs(container *types.Container, clientIP string) (ProxyConfigSlice, error) { - var aliases []string - - cfgs := make(ProxyConfigSlice, 0) - cfgMap := make(map[string]*ProxyConfig) - - containerName := strings.TrimPrefix(container.Names[0], "/") - aliasesLabel, ok := container.Labels["proxy.aliases"] - - if !ok { - aliases = []string{containerName} - } else { - v, _ := commaSepParser(aliasesLabel) - aliases = v.([]string) - } - - if clientIP == "" && isHostNetworkMode { - clientIP = "127.0.0.1" - } - isRemote := clientIP != "" - - for _, alias := range aliases { - cfgMap[alias] = &ProxyConfig{} - } - - ne := NewNestedError("these labels have errors").Subject(containerName) - - for label, value := range container.Labels { - pl, err := parseProxyLabel(label, value) - if err != nil { - if !errors.Is(err, errNotProxyLabel) { - ne.ExtraError(NewNestedErrorFrom(err).Subject(label)) - } - continue - } - if pl.Alias == wildcardAlias { - for alias := range cfgMap { - pl.Alias = alias - err = setConfigField(pl, cfgMap[alias]) - if err != nil { - ne.ExtraError(NewNestedErrorFrom(err).Subject(pl.Alias)) - } - } - continue - } - config, ok := cfgMap[pl.Alias] - if !ok { - ne.ExtraError(NewNestedError("unknown alias").Subject(pl.Alias)) - continue - } - err = setConfigField(pl, config) - if err != nil { - ne.ExtraError(NewNestedErrorFrom(err).Subject(pl.Alias)) - } - } - - for alias, config := range cfgMap { - l := p.l.WithField("alias", alias) - if config.Port == "" { - config.Port = fmt.Sprintf("%d", selectPort(container, isRemote)) - } - if config.Port == "0" { - l.Infof("no ports exposed, ignored") - continue - } - if config.Scheme == "" { - switch { - case strings.HasSuffix(config.Port, "443"): - config.Scheme = "https" - default: - imageName := getImageName(container) - _, isKnownImage := ImageNamePortMapTCP[imageName] - if isKnownImage { - config.Scheme = "tcp" - } else { - config.Scheme = "http" - } - } - } - if !isValidScheme(config.Scheme) { - ne.Extra("unsupported scheme").Subject(config.Scheme) - } - - if isRemote && strings.HasPrefix(config.Port, "*") { - var err error - // find matching port - srcPort := config.Port[1:] - config.Port, err = findMatchingContainerPort(container, srcPort) - if err != nil { - ne.ExtraError(NewNestedErrorFrom(err).Subjectf("alias %s", alias)) - } - if isStreamScheme(config.Scheme) { - config.Port = fmt.Sprintf("%s:%s", srcPort, config.Port) - } - } - - if config.Host == "" { - switch { - case isRemote: - config.Host = clientIP - case container.HostConfig.NetworkMode == "host": - config.Host = "host.docker.internal" - case config.LoadBalance == "true", config.LoadBalance == "1": - for _, network := range container.NetworkSettings.Networks { - config.Host = network.IPAddress - break - } - default: - for _, network := range container.NetworkSettings.Networks { - for _, alias := range network.Aliases { - config.Host = alias - break - } - } - } - } - if config.Host == "" { - config.Host = containerName - } - config.Alias = alias - - if ne.HasExtras() { - continue - } - cfgs = append(cfgs, *config) - } - - if ne.HasExtras() { - return nil, ne - } - return cfgs, nil -} - -func (p *Provider) getDockerClient() (*client.Client, error) { - var dockerOpts []client.Opt - if p.Value == clientUrlFromEnv { - dockerOpts = []client.Opt{ - client.WithHostFromEnv(), - client.WithAPIVersionNegotiation(), - } - } else { - helper, err := connhelper.GetConnectionHelper(p.Value) - if err != nil { - p.l.Fatal("unexpected error: ", err) - } - if helper != nil { - httpClient := &http.Client{ - Transport: &http.Transport{ - DialContext: helper.Dialer, - }, - } - dockerOpts = []client.Opt{ - client.WithHTTPClient(httpClient), - client.WithHost(helper.Host), - client.WithAPIVersionNegotiation(), - client.WithDialContext(helper.Dialer), - } - } else { - dockerOpts = []client.Opt{ - client.WithHost(p.Value), - client.WithAPIVersionNegotiation(), - } - } - } - return client.NewClientWithOpts(dockerOpts...) -} - -func (p *Provider) getDockerProxyConfigs() (ProxyConfigSlice, error) { - var clientIP string - - if p.Value == clientUrlFromEnv { - clientIP = "" - } else { - url, err := client.ParseHostURL(p.Value) - if err != nil { - return nil, NewNestedError("invalid host url").Subject(p.Value).With(err) - } - clientIP = strings.Split(url.Host, ":")[0] - } - - dockerClient, err := p.getDockerClient() - - if err != nil { - return nil, NewNestedError("unable to create docker client").With(err) - } - - ctx, _ := context.WithTimeout(context.Background(), 3*time.Second) - containerSlice, err := dockerClient.ContainerList(ctx, container.ListOptions{All: true}) - - if err != nil { - return nil, NewNestedError("unable to list containers").With(err) - } - - cfgs := make(ProxyConfigSlice, 0) - - ne := NewNestedError("these containers have errors") - for _, container := range containerSlice { - ccfgs, err := p.getContainerProxyConfigs(&container, clientIP) - if err != nil { - ne.ExtraError(err) - continue - } - cfgs = append(cfgs, ccfgs...) - } - - if ne.HasExtras() { - // print but ignore - p.l.Error(ne) - } - - return cfgs, nil -} - -// var dockerUrlRegex = regexp.MustCompile(`^(?P\w+)://(?P[^:]+)(?P:\d+)?(?P/.*)?$`) -func getImageName(c *types.Container) string { - imageSplit := strings.Split(c.Image, "/") - imageSplit = strings.Split(imageSplit[len(imageSplit)-1], ":") - return imageSplit[0] -} - -func getPublicPort(p types.Port) uint16 { return p.PublicPort } -func getPrivatePort(p types.Port) uint16 { return p.PrivatePort } - -func selectPort(c *types.Container, isRemote bool) uint16 { - if isRemote || c.HostConfig.NetworkMode == "host" { - return selectPortInternal(c, getPublicPort) - } - return selectPortInternal(c, getPrivatePort) -} - -// used when isRemote is true -func findMatchingContainerPort(c *types.Container, ps string) (string, error) { - p, err := strconv.Atoi(ps) - if err != nil { - return "", err - } - pWant := uint16(p) - for _, pGot := range c.Ports { - if pGot.PrivatePort == pWant { - return fmt.Sprintf("%d", pGot.PublicPort), nil - } - } - return "", fmt.Errorf("port %d not found", p) -} - -func selectPortInternal(c *types.Container, getPort func(types.Port) uint16) uint16 { - imageName := getImageName(c) - // if is known image -> use known port - if port, isKnown := ImageNamePortMapHTTP[imageName]; isKnown { - for _, p := range c.Ports { - if p.PrivatePort == port { - return getPort(p) - } - } - } - // if it has known http port -> use it - for _, p := range c.Ports { - if isWellKnownHTTPPort(p.PrivatePort) { - return getPort(p) - } - } - // if it has any port -> use it - for _, p := range c.Ports { - if port := getPort(p); port != 0 { - return port - } - } - return 0 -} - -func isWellKnownHTTPPort(port uint16) bool { - _, ok := wellKnownHTTPPorts[port] - return ok -} diff --git a/src/go-proxy/error.go b/src/go-proxy/error.go deleted file mode 100644 index 7b5b448..0000000 --- a/src/go-proxy/error.go +++ /dev/null @@ -1,195 +0,0 @@ -package main - -import ( - "errors" - "fmt" - "strings" - "sync" -) - -type NestedError struct { - subject string - message string - extras []string - inner *NestedError - level int - - sync.Mutex -} - -type NestedErrorLike interface { - Error() string - Inner() NestedErrorLike - Level() int - HasInner() bool - HasExtras() bool - - Extra(string) NestedErrorLike - Extraf(string, ...any) NestedErrorLike - ExtraError(error) NestedErrorLike - Subject(string) NestedErrorLike - Subjectf(string, ...any) NestedErrorLike - With(error) NestedErrorLike - - addLevel(int) NestedErrorLike - copy() *NestedError -} - -func NewNestedError(message string) NestedErrorLike { - return &NestedError{message: message, extras: make([]string, 0)} -} - -func NewNestedErrorf(format string, args ...any) NestedErrorLike { - return NewNestedError(fmt.Sprintf(format, args...)) -} - -func NewNestedErrorFrom(err error) NestedErrorLike { - if err == nil { - panic("cannot convert nil error to NestedError") - } - errUnwrap := errors.Unwrap(err) - if errUnwrap != nil { - return NewNestedErrorFrom(errUnwrap) - } - return NewNestedError(err.Error()) -} - -func (ne *NestedError) Extra(s string) NestedErrorLike { - s = strings.TrimSpace(s) - if s == "" { - return ne - } - ne.Lock() - defer ne.Unlock() - ne.extras = append(ne.extras, s) - return ne -} - -func (ne *NestedError) Extraf(format string, args ...any) NestedErrorLike { - return ne.Extra(fmt.Sprintf(format, args...)) -} - -func (ne *NestedError) ExtraError(e error) NestedErrorLike { - switch t := e.(type) { - case NestedErrorLike: - extra := t.copy() - extra.addLevel(ne.Level() + 1) - e = extra - } - return ne.Extra(e.Error()) -} - -func (ne *NestedError) Subject(s string) NestedErrorLike { - ne.subject = s - return ne -} - -func (ne *NestedError) Subjectf(format string, args ...any) NestedErrorLike { - ne.subject = fmt.Sprintf(format, args...) - return ne -} - -func (ne *NestedError) Inner() NestedErrorLike { - return ne.inner -} - -func (ne *NestedError) Level() int { - return ne.level -} - -func (ne *NestedError) Error() string { - var buf strings.Builder - ne.writeToSB(&buf, ne.level, "") - return buf.String() -} - -func (ne *NestedError) HasInner() bool { - return ne.inner != nil -} - -func (ne *NestedError) HasExtras() bool { - return len(ne.extras) > 0 -} - -func (ne *NestedError) With(inner error) NestedErrorLike { - ne.Lock() - defer ne.Unlock() - - var in *NestedError - - switch t := inner.(type) { - case NestedErrorLike: - in = t.copy() - default: - in = &NestedError{message: t.Error()} - } - if ne.inner == nil { - ne.inner = in - } else { - ne.inner.ExtraError(in) - } - root := ne - for root.inner != nil { - root.inner.level = root.level + 1 - root = root.inner - } - return ne -} - -func (ne *NestedError) addLevel(level int) NestedErrorLike { - ne.level += level - if ne.inner != nil { - ne.inner.addLevel(level) - } - return ne -} - -func (ne *NestedError) copy() *NestedError { - var inner *NestedError - if ne.inner != nil { - inner = ne.inner.copy() - } - return &NestedError{ - subject: ne.subject, - message: ne.message, - extras: ne.extras, - inner: inner, - } -} - -func (ne *NestedError) writeIndents(sb *strings.Builder, level int) { - for i := 0; i < level; i++ { - sb.WriteString(" ") - } -} - -func (ne *NestedError) writeToSB(sb *strings.Builder, level int, prefix string) { - ne.writeIndents(sb, level) - sb.WriteString(prefix) - - if ne.subject != "" { - sb.WriteString(ne.subject) - if ne.message != "" { - sb.WriteString(": ") - } - } - if ne.message != "" { - sb.WriteString(ne.message) - } - if ne.HasExtras() || ne.HasInner() { - sb.WriteString(":\n") - } - level += 1 - for _, l := range ne.extras { - if l == "" { - continue - } - ne.writeIndents(sb, level) - sb.WriteString("- ") - sb.WriteString(l) - sb.WriteRune('\n') - } - if ne.inner != nil { - ne.inner.writeToSB(sb, level, "- ") - } -} diff --git a/src/go-proxy/file_provider.go b/src/go-proxy/file_provider.go deleted file mode 100644 index 97213fa..0000000 --- a/src/go-proxy/file_provider.go +++ /dev/null @@ -1,54 +0,0 @@ -package main - -import ( - "os" - "path" - - "gopkg.in/yaml.v3" -) - -func (p *Provider) GetFilePath() string { - return path.Join(configBasePath, p.Value) -} - -func (p *Provider) ValidateFile() (ProxyConfigSlice, error) { - path := p.GetFilePath() - data, err := os.ReadFile(path) - if err != nil { - return nil, NewNestedError("unable to read providers file").Subject(path).With(err) - } - result, err := ValidateFileContent(data) - if err != nil { - return nil, NewNestedError(err.Error()).Subject(path) - } - return result, nil -} - -func ValidateFileContent(data []byte) (ProxyConfigSlice, error) { - configMap := make(ProxyConfigMap, 0) - if err := yaml.Unmarshal(data, &configMap); err != nil { - return nil, NewNestedError("invalid yaml").With(err) - } - - ne := NewNestedError("errors in providers") - - configs := make(ProxyConfigSlice, len(configMap)) - i := 0 - for alias, cfg := range configMap { - cfg.Alias = alias - if err := cfg.SetDefaults(); err != nil { - ne.ExtraError(err) - } else { - configs[i] = cfg - } - i++ - } - - if err := validateYaml(providersSchema, data); err != nil { - ne.ExtraError(err) - } - if ne.HasExtras() { - return nil, ne - } - return configs, nil -} diff --git a/src/go-proxy/functional.go b/src/go-proxy/functional.go deleted file mode 100644 index 771d56f..0000000 --- a/src/go-proxy/functional.go +++ /dev/null @@ -1,39 +0,0 @@ -package main - -import "sync" - -func ParallelForEach[T interface{}](obj []T, do func(T)) { - var wg sync.WaitGroup - wg.Add(len(obj)) - for _, v := range obj { - go func(v T) { - do(v) - wg.Done() - }(v) - } - wg.Wait() -} - -func ParallelForEachValue[K comparable, V interface{}](obj map[K]V, do func(V)) { - var wg sync.WaitGroup - wg.Add(len(obj)) - for _, v := range obj { - go func(v V) { - do(v) - wg.Done() - }(v) - } - wg.Wait() -} - -func ParallelForEachKeyValue[K comparable, V interface{}](obj map[K]V, do func(K, V)) { - var wg sync.WaitGroup - wg.Add(len(obj)) - for k, v := range obj { - go func(k K, v V) { - do(k, v) - wg.Done() - }(k, v) - } - wg.Wait() -} \ No newline at end of file diff --git a/src/go-proxy/http_lbpool.go b/src/go-proxy/http_lbpool.go deleted file mode 100755 index 352a20a..0000000 --- a/src/go-proxy/http_lbpool.go +++ /dev/null @@ -1,29 +0,0 @@ -package main - -import "sync/atomic" - -type httpLoadBalancePool struct { - pool []*HTTPRoute - curentIndex atomic.Int32 -} - -func NewHTTPLoadBalancePool() *httpLoadBalancePool { - return &httpLoadBalancePool{ - pool: make([]*HTTPRoute, 0), - } -} - -func (p *httpLoadBalancePool) Add(route *HTTPRoute) { - p.pool = append(p.pool, route) -} - -func (p *httpLoadBalancePool) Iterator() []*HTTPRoute { - return p.pool -} - -func (p *httpLoadBalancePool) Pick() *HTTPRoute { - // round-robin - index := int(p.curentIndex.Load()) - defer p.curentIndex.Add(1) - return p.pool[index%len(p.pool)] -} \ No newline at end of file diff --git a/src/go-proxy/http_route.go b/src/go-proxy/http_route.go deleted file mode 100755 index afce379..0000000 --- a/src/go-proxy/http_route.go +++ /dev/null @@ -1,162 +0,0 @@ -package main - -import ( - "fmt" - - "net/http" - "net/url" - "strings" - - "github.com/sirupsen/logrus" -) - -type HTTPRoute struct { - Alias string - Url *url.URL - Path string - PathMode string - Proxy *ReverseProxy - - l logrus.FieldLogger -} - -func NewHTTPRoute(config *ProxyConfig) (*HTTPRoute, error) { - u := fmt.Sprintf("%s://%s:%s", config.Scheme, config.Host, config.Port) - url, err := url.Parse(u) - if err != nil { - return nil, NewNestedErrorf("invalid url").Subject(u).With(err) - } - - var tr *http.Transport - if config.NoTLSVerify { - tr = transportNoTLS - } else { - tr = transport - } - - proxy := NewReverseProxy(url, tr, config) - - route := &HTTPRoute{ - Alias: config.Alias, - Url: url, - Path: config.Path, - Proxy: proxy, - PathMode: config.PathMode, - l: logrus.WithField("alias", config.Alias), - } - - var rewriteBegin = proxy.Rewrite - var rewrite func(*ProxyRequest) - var modifyResponse func(*http.Response) error - - // no path or forward path - if config.Path == "" || config.PathMode == ProxyPathMode_Forward { - rewrite = rewriteBegin - } else { - switch config.PathMode { - case ProxyPathMode_RemovedPath: - rewrite = func(pr *ProxyRequest) { - rewriteBegin(pr) - pr.Out.URL.Path = strings.TrimPrefix(pr.Out.URL.Path, config.Path) - } - case ProxyPathMode_Sub: - rewrite = func(pr *ProxyRequest) { - rewriteBegin(pr) - // disable compression - pr.Out.Header.Set("Accept-Encoding", "identity") - // remove path prefix - pr.Out.URL.Path = strings.TrimPrefix(pr.Out.URL.Path, config.Path) - } - modifyResponse = config.pathSubModResp - default: - return nil, NewNestedError("invalid path mode").Subject(config.PathMode) - } - } - - if logLevel == logrus.DebugLevel { - route.Proxy.Rewrite = func(pr *ProxyRequest) { - rewrite(pr) - route.l.Debug("request URL: ", pr.In.Host, pr.In.URL.Path) - route.l.Debug("request headers: ", pr.In.Header) - } - route.Proxy.ModifyResponse = func(r *http.Response) error { - route.l.Debug("response URL: ", r.Request.URL.String()) - route.l.Debug("response headers: ", r.Header) - if modifyResponse != nil { - return modifyResponse(r) - } - return nil - } - } else { - route.Proxy.Rewrite = rewrite - } - - return route, nil -} - -func (r *HTTPRoute) Start() { - httpRoutes.Get(r.Alias).Add(r.Path, r) -} - -func (r *HTTPRoute) Stop() { - httpRoutes.Delete(r.Alias) -} - -func redirectToTLSHandler(w http.ResponseWriter, r *http.Request) { - // Redirect to the same host but with HTTPS - var redirectCode int - if r.Method == http.MethodGet { - redirectCode = http.StatusMovedPermanently - } else { - redirectCode = http.StatusPermanentRedirect - } - http.Redirect(w, r, fmt.Sprintf("https://%s%s?%s", r.Host, r.URL.Path, r.URL.RawQuery), redirectCode) -} - -func findHTTPRoute(host string, path string) (*HTTPRoute, error) { - subdomain := strings.Split(host, ".")[0] - routeMap, ok := httpRoutes.UnsafeGet(subdomain) - if ok { - return routeMap.FindMatch(path) - } - return nil, NewNestedError("no matching route for subdomain").Subject(subdomain) -} - -func proxyHandler(w http.ResponseWriter, r *http.Request) { - route, err := findHTTPRoute(r.Host, r.URL.Path) - if err != nil { - http.Error(w, "404 Not Found", http.StatusNotFound) - err = NewNestedError("request failed"). - Subjectf("%s %s%s", r.Method, r.Host, r.URL.Path). - With(err) - logrus.Error(err) - return - } - route.Proxy.ServeHTTP(w, r) -} - -func (config *ProxyConfig) pathSubModResp(r *http.Response) error { - contentType, ok := r.Header["Content-Type"] - if !ok || len(contentType) == 0 { - return nil - } - // disable cache - r.Header.Set("Cache-Control", "no-store") - - var err error = nil - switch { - case strings.HasPrefix(contentType[0], "text/html"): - err = utils.respHTMLSubPath(r, config.Path) - case strings.HasPrefix(contentType[0], "application/javascript"): - err = utils.respJSSubPath(r, config.Path) - } - if err != nil { - err = NewNestedError("failed to remove path prefix").Subject(config.Path).With(err) - } - return err -} - -// alias -> (path -> routes) -type HTTPRoutes SafeMap[string, pathPoolMap] - -var httpRoutes HTTPRoutes = NewSafeMapOf[HTTPRoutes](newPathPoolMap) diff --git a/src/go-proxy/io.go b/src/go-proxy/io.go deleted file mode 100644 index 854e1e0..0000000 --- a/src/go-proxy/io.go +++ /dev/null @@ -1,127 +0,0 @@ -package main - -import ( - "context" - "errors" - "fmt" - "io" - "os" - "sync/atomic" -) - -type Reader interface { - Read() ([]byte, error) -} - -type FileReader struct { - Path string -} - -func (r *FileReader) Read() ([]byte, error) { - return os.ReadFile(r.Path) -} - -type ByteReader struct { - Data []byte -} - -func (r *ByteReader) Read() ([]byte, error) { - return r.Data, nil -} - -type ReadCloser struct { - ctx context.Context - r io.ReadCloser - closed atomic.Bool -} - -func (r *ReadCloser) Read(p []byte) (int, error) { - select { - case <-r.ctx.Done(): - return 0, r.ctx.Err() - default: - return r.r.Read(p) - } -} - -func (r *ReadCloser) Close() error { - if r.closed.Load() { - return nil - } - r.closed.Store(true) - return r.r.Close() -} - -type Pipe struct { - r ReadCloser - w io.WriteCloser - ctx context.Context - cancel context.CancelFunc -} - -func NewPipe(ctx context.Context, r io.ReadCloser, w io.WriteCloser) *Pipe { - ctx, cancel := context.WithCancel(ctx) - return &Pipe{ - r: ReadCloser{ctx: ctx, r: r}, - w: w, - ctx: ctx, - cancel: cancel, - } -} - -func (p *Pipe) Start() error { - return Copy(p.ctx, p.w, &p.r) -} - -func (p *Pipe) Stop() error { - p.cancel() - return errors.Join(fmt.Errorf("read: %w", p.r.Close()), fmt.Errorf("write: %w", p.w.Close())) -} - -func (p *Pipe) Write(b []byte) (int, error) { - return p.w.Write(b) -} - -type BidirectionalPipe struct { - pSrcDst Pipe - pDstSrc Pipe -} - -func NewBidirectionalPipe(ctx context.Context, rw1 io.ReadWriteCloser, rw2 io.ReadWriteCloser) *BidirectionalPipe { - return &BidirectionalPipe{ - pSrcDst: *NewPipe(ctx, rw1, rw2), - pDstSrc: *NewPipe(ctx, rw2, rw1), - } -} - -func NewBidirectionalPipeIntermediate(ctx context.Context, listener io.ReadCloser, client io.ReadWriteCloser, target io.ReadWriteCloser) *BidirectionalPipe { - return &BidirectionalPipe{ - pSrcDst: *NewPipe(ctx, listener, client), - pDstSrc: *NewPipe(ctx, client, target), - } -} - -func (p *BidirectionalPipe) Start() error { - errCh := make(chan error, 2) - go func() { - errCh <- p.pSrcDst.Start() - }() - go func() { - errCh <- p.pDstSrc.Start() - }() - for err := range errCh { - if err != nil { - return err - } - } - return nil -} - -func (p *BidirectionalPipe) Stop() error { - return errors.Join(p.pSrcDst.Stop(), p.pDstSrc.Stop()) -} - -func Copy(ctx context.Context, dst io.WriteCloser, src io.ReadCloser) error { - _, err := io.Copy(dst, &ReadCloser{ctx: ctx, r: src}) - return err -} diff --git a/src/go-proxy/loggers.go b/src/go-proxy/loggers.go deleted file mode 100644 index 2edd6ad..0000000 --- a/src/go-proxy/loggers.go +++ /dev/null @@ -1,10 +0,0 @@ -package main - -import "github.com/sirupsen/logrus" - -var palog = logrus.WithField("?", "panel") -var cfgl = logrus.WithField("?", "config") -var hrlog = logrus.WithField("?", "http") -var srlog = logrus.WithField("?", "stream") -var wlog = logrus.WithField("?", "watcher") -var aclog = logrus.WithField("?", "autocert") \ No newline at end of file diff --git a/src/go-proxy/main.go b/src/go-proxy/main.go deleted file mode 100755 index f8eb494..0000000 --- a/src/go-proxy/main.go +++ /dev/null @@ -1,137 +0,0 @@ -package main - -import ( - "net/http" - "os" - "os/signal" - "runtime" - "sync" - "syscall" - "time" - - "github.com/sirupsen/logrus" -) - -var cfg Config - -func main() { - runtime.GOMAXPROCS(runtime.NumCPU()) - - args := getArgs() - - if isRunningAsService { - logrus.SetFormatter(&logrus.TextFormatter{ - DisableColors: true, - DisableTimestamp: true, - DisableSorting: true, - }) - } else { - logrus.SetFormatter(&logrus.TextFormatter{ - ForceColors: true, - DisableColors: false, - DisableSorting: true, - FullTimestamp: true, - TimestampFormat: "01-02 15:04:05", - }) - } - - if args.Command == CommandReload { - err := utils.reloadServer() - if err != nil { - logrus.Fatal(err) - } - return - } - - initSchema() - - cfg = NewConfig(configPath) - cfg.MustLoad() - - if args.Command == CommandValidate { - logrus.Printf("config OK") - return - } - - autoCertProvider, err := cfg.GetAutoCertProvider() - - if err != nil { - aclog.Warn(err) - autoCertProvider = nil // TODO: remove, it is expected to be nil if error is not nil, but it is not for now - } - - var proxyServer *Server - var panelServer *Server - - if autoCertProvider != nil { - ok := autoCertProvider.LoadCert() - if !ok { - if ne := autoCertProvider.ObtainCert(); ne != nil { - aclog.Fatal(ne) - } - } - for name, expiry := range autoCertProvider.GetExpiries() { - aclog.Infof("certificate %q: expire on %v", name, expiry) - } - go autoCertProvider.ScheduleRenewal() - } - proxyServer = NewServer(ServerOptions{ - Name: "proxy", - CertProvider: autoCertProvider, - HTTPAddr: ":80", - HTTPSAddr: ":443", - Handler: http.HandlerFunc(proxyHandler), - RedirectToHTTPS: cfg.Value().RedirectToHTTPS, - }) - panelServer = NewServer(ServerOptions{ - Name: "panel", - CertProvider: autoCertProvider, - HTTPAddr: ":8080", - HTTPSAddr: ":8443", - Handler: panelHandler, - RedirectToHTTPS: cfg.Value().RedirectToHTTPS, - }) - - proxyServer.Start() - panelServer.Start() - - InitFSWatcher() - - cfg.StartProviders() - cfg.WatchChanges() - - sig := make(chan os.Signal, 1) - signal.Notify(sig, syscall.SIGINT) - signal.Notify(sig, syscall.SIGTERM) - signal.Notify(sig, syscall.SIGHUP) - - <-sig - logrus.Info("shutting down") - done := make(chan struct{}, 1) - - var wg sync.WaitGroup - wg.Add(3) - - go func() { - StopFSWatcher() - StopDockerWatcher() - cfg.StopProviders() - wg.Done() - }() - go func() { - panelServer.Stop() - proxyServer.Stop() - wg.Done() - }() - go func() { - wg.Wait() - close(done) - }() - - select { - case <-done: - logrus.Info("shutdown complete") - case <-time.After(cfg.Value().TimeoutShutdown * time.Second): - logrus.Info("timeout waiting for shutdown") - } -} diff --git a/src/go-proxy/map.go b/src/go-proxy/map.go deleted file mode 100755 index 5fb797a..0000000 --- a/src/go-proxy/map.go +++ /dev/null @@ -1,98 +0,0 @@ -package main - -import "sync" - -type safeMap[KT comparable, VT interface{}] struct { - SafeMap[KT, VT] - m map[KT]VT - defaultFactory func() VT - sync.RWMutex -} - -type SafeMap[KT comparable, VT interface{}] interface { - Set(key KT, value VT) - Ensure(key KT) - Get(key KT) VT - UnsafeGet(key KT) (VT, bool) - Delete(key KT) - Clear() - Size() int - Contains(key KT) bool - ForEach(fn func(key KT, value VT)) - Iterator() map[KT]VT -} - -func NewSafeMapOf[T SafeMap[KT, VT], KT comparable, VT interface{}](df ...func() VT) SafeMap[KT, VT] { - if len(df) == 0 { - return &safeMap[KT, VT]{ - m: make(map[KT]VT), - } - } - return &safeMap[KT, VT]{ - m: make(map[KT]VT), - defaultFactory: df[0], - } -} - -func (m *safeMap[KT, VT]) Set(key KT, value VT) { - m.Lock() - m.m[key] = value - m.Unlock() -} - -func (m *safeMap[KT, VT]) Ensure(key KT) { - m.Lock() - if _, ok := m.m[key]; !ok { - m.m[key] = m.defaultFactory() - } - m.Unlock() -} - -func (m *safeMap[KT, VT]) Get(key KT) VT { - m.RLock() - value := m.m[key] - m.RUnlock() - return value -} - -func (m *safeMap[KT, VT]) UnsafeGet(key KT) (VT, bool) { - value, ok := m.m[key] - return value, ok -} - -func (m *safeMap[KT, VT]) Delete(key KT) { - m.Lock() - delete(m.m, key) - m.Unlock() -} - -func (m *safeMap[KT, VT]) Clear() { - m.Lock() - m.m = make(map[KT]VT) - m.Unlock() -} - -func (m *safeMap[KT, VT]) Size() int { - m.RLock() - defer m.RUnlock() - return len(m.m) -} - -func (m *safeMap[KT, VT]) Contains(key KT) bool { - m.RLock() - _, ok := m.m[key] - m.RUnlock() - return ok -} - -func (m *safeMap[KT, VT]) ForEach(fn func(key KT, value VT)) { - m.RLock() - for k, v := range m.m { - fn(k, v) - } - m.RUnlock() -} - -func (m *safeMap[KT, VT]) Iterator() map[KT]VT { - return m.m -} diff --git a/src/go-proxy/panel.go b/src/go-proxy/panel.go deleted file mode 100755 index 55c2ed4..0000000 --- a/src/go-proxy/panel.go +++ /dev/null @@ -1,153 +0,0 @@ -package main - -import ( - "errors" - "fmt" - "html/template" - "net/http" - "net/url" - "os" - "path" -) - -var panelHandler = panelRouter() - -func panelRouter() *http.ServeMux { - mux := http.NewServeMux() - mux.HandleFunc("GET /{$}", panelServeFile) - mux.HandleFunc("GET /{file}", panelServeFile) - mux.HandleFunc("GET /panel/", panelPage) - mux.HandleFunc("GET /panel/{file}", panelServeFile) - mux.HandleFunc("HEAD /checkhealth", panelCheckTargetHealth) - mux.HandleFunc("GET /config_editor/", panelConfigEditor) - mux.HandleFunc("GET /config_editor/{file}", panelServeFile) - mux.HandleFunc("GET /config/{file}", panelConfigGet) - mux.HandleFunc("PUT /config/{file}", panelConfigUpdate) - mux.HandleFunc("POST /reload", configReload) - mux.HandleFunc("GET /codemirror/", panelServeFile) - return mux -} - -func panelPage(w http.ResponseWriter, r *http.Request) { - resp := struct { - HTTPRoutes HTTPRoutes - StreamRoutes StreamRoutes - }{httpRoutes, streamRoutes} - - panelRenderFile(w, r, panelTemplatePath, resp) -} - -func panelCheckTargetHealth(w http.ResponseWriter, r *http.Request) { - targetUrl := r.URL.Query().Get("target") - - if targetUrl == "" { - panelHandleErr(w, r, errors.New("target is required"), http.StatusBadRequest) - return - } - - url, err := url.Parse(targetUrl) - if err != nil { - err = NewNestedError("failed to parse url").Subject(targetUrl).With(err) - panelHandleErr(w, r, err, http.StatusBadRequest) - return - } - scheme := url.Scheme - - if isStreamScheme(scheme) { - err = utils.healthCheckStream(scheme, url.Host) - } else { - err = utils.healthCheckHttp(targetUrl) - } - - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - } else { - w.WriteHeader(http.StatusOK) - } -} - -func panelConfigEditor(w http.ResponseWriter, r *http.Request) { - cfgFiles := make([]string, 0) - cfgFiles = append(cfgFiles, path.Base(configPath)) - for _, p := range cfg.Value().Providers { - if p.Kind != ProviderKind_File { - continue - } - cfgFiles = append(cfgFiles, p.Value) - } - - panelRenderFile(w, r, configEditorTemplatePath, cfgFiles) -} - -func panelConfigGet(w http.ResponseWriter, r *http.Request) { - http.ServeFile(w, r, path.Join(configBasePath, r.PathValue("file"))) -} - -func panelConfigUpdate(w http.ResponseWriter, r *http.Request) { - p := r.PathValue("file") - content := make([]byte, r.ContentLength) - _, err := r.Body.Read(content) - if err != nil { - panelHandleErr(w, r, NewNestedError("unable to read request body").Subject(p).With(err)) - return - } - if p == path.Base(configPath) { - err = ValidateConfig(content) - } else { - _, err = ValidateFileContent(content) - } - if err != nil { - panelHandleErr(w, r, err) - return - } - p = path.Join(configBasePath, p) - _, err = os.Stat(p) - exists := !errors.Is(err, os.ErrNotExist) - err = os.WriteFile(p, content, 0644) - if err != nil { - panelHandleErr(w, r, NewNestedError("unable to write config file").With(err)) - return - } - w.WriteHeader(http.StatusOK) - if !exists { - w.Write([]byte(fmt.Sprintf("Config file %s created, remember to add it to config.yml!", p))) - return - } - w.Write([]byte(fmt.Sprintf("Config file %s updated", p))) -} - -func panelServeFile(w http.ResponseWriter, r *http.Request) { - http.ServeFile(w, r, path.Join(templatesBasePath, r.URL.Path)) -} - -func panelRenderFile(w http.ResponseWriter, r *http.Request, f string, data any) { - tmpl, err := template.ParseFiles(f) - if err != nil { - panelHandleErr(w, r, NewNestedError("unable to parse template").With(err)) - return - } - - err = tmpl.Execute(w, data) - if err != nil { - panelHandleErr(w, r, NewNestedError("unable to render template").With(err)) - } -} - -func configReload(w http.ResponseWriter, r *http.Request) { - err := cfg.Reload() - if err != nil { - panelHandleErr(w, r, err) - return - } - w.WriteHeader(http.StatusOK) -} - -func panelHandleErr(w http.ResponseWriter, r *http.Request, err error, code ...int) { - err = NewNestedErrorFrom(err).Subjectf("%s %s", r.Method, r.URL) - palog.Error(err) - if len(code) > 0 { - http.Error(w, err.Error(), code[0]) - return - } - http.Error(w, err.Error(), http.StatusInternalServerError) -} diff --git a/src/go-proxy/path_pool_map.go b/src/go-proxy/path_pool_map.go deleted file mode 100644 index c747375..0000000 --- a/src/go-proxy/path_pool_map.go +++ /dev/null @@ -1,27 +0,0 @@ -package main - -import ( - "strings" -) - -type pathPoolMap struct { - SafeMap[string, *httpLoadBalancePool] -} - -func newPathPoolMap() pathPoolMap { - return pathPoolMap{NewSafeMapOf[pathPoolMap](NewHTTPLoadBalancePool)} -} - -func (m pathPoolMap) Add(path string, route *HTTPRoute) { - m.Ensure(path) - m.Get(path).Add(route) -} - -func (m pathPoolMap) FindMatch(pathGot string) (*HTTPRoute, NestedErrorLike) { - for pathWant, v := range m.Iterator() { - if strings.HasPrefix(pathGot, pathWant) { - return v.Pick(), nil - } - } - return nil, NewNestedError("no matching path").Subject(pathGot) -} diff --git a/src/go-proxy/provider.go b/src/go-proxy/provider.go deleted file mode 100644 index c7a2aad..0000000 --- a/src/go-proxy/provider.go +++ /dev/null @@ -1,112 +0,0 @@ -package main - -import ( - "github.com/sirupsen/logrus" -) - -type Provider struct { - Kind string `json:"kind"` // docker, file - Value string `json:"value"` - - watcher Watcher - routes map[string]Route // id -> Route - l logrus.FieldLogger - reloadReqCh chan struct{} -} - -// Init is called after LoadProxyConfig -func (p *Provider) Init(name string) error { - p.l = logrus.WithField("provider", name) - p.reloadReqCh = make(chan struct{}, 1) - - defer p.initWatcher() - - if err := p.loadProxyConfig(); err != nil { - return err - } - - return nil -} - -func (p *Provider) StartAllRoutes() { - ParallelForEachValue(p.routes, Route.Start) - p.watcher.Start() -} - -func (p *Provider) StopAllRoutes() { - p.watcher.Stop() - ParallelForEachValue(p.routes, Route.Stop) - p.routes = nil -} - -func (p *Provider) ReloadRoutes() { - select { - case p.reloadReqCh <- struct{}{}: - defer func() { - <-p.reloadReqCh - }() - - p.StopAllRoutes() - err := p.loadProxyConfig() - if err != nil { - p.l.Error("failed to reload routes: ", err) - return - } - p.StartAllRoutes() - default: - p.l.Info("reload request already in progress") - return - } -} - -func (p *Provider) loadProxyConfig() error { - var cfgs ProxyConfigSlice - var err error - - switch p.Kind { - case ProviderKind_Docker: - cfgs, err = p.getDockerProxyConfigs() - case ProviderKind_File: - cfgs, err = p.ValidateFile() - default: - // this line should never be reached - return NewNestedError("unknown provider kind") - } - - if err != nil { - return err - } - p.l.Infof("loaded %d proxy configurations", len(cfgs)) - - p.routes = make(map[string]Route, len(cfgs)) - pErrs := NewNestedError("failed to create these routes") - - for _, cfg := range cfgs { - r, err := NewRoute(&cfg) - if err != nil { - pErrs.ExtraError(NewNestedErrorFrom(err).Subject(cfg.Alias)) - continue - } - p.routes[cfg.GetID()] = r - } - - if pErrs.HasExtras() { - p.routes = nil - return pErrs - } - return nil -} - -func (p *Provider) initWatcher() error { - switch p.Kind { - case ProviderKind_Docker: - dockerClient, err := p.getDockerClient() - if err != nil { - return NewNestedError("unable to create docker client").With(err) - } - p.watcher = p.NewDockerWatcher(dockerClient) - case ProviderKind_File: - p.watcher = p.NewFileWatcher() - } - return nil -} diff --git a/src/go-proxy/proxy_config.go b/src/go-proxy/proxy_config.go deleted file mode 100644 index 4d033e0..0000000 --- a/src/go-proxy/proxy_config.go +++ /dev/null @@ -1,55 +0,0 @@ -package main - -import ( - "fmt" - "net/http" -) - -type ProxyConfig struct { - Alias string `yaml:"-" json:"-"` - Scheme string `yaml:"scheme" json:"scheme"` - Host string `yaml:"host" json:"host"` - Port string `yaml:"port" json:"port"` - LoadBalance string `yaml:"-" json:"-"` // docker provider only - NoTLSVerify bool `yaml:"no_tls_verify" json:"no_tls_verify"` // http proxy only - Path string `yaml:"path" json:"path"` // http proxy only - PathMode string `yaml:"path_mode" json:"path_mode"` // http proxy only - SetHeaders http.Header `yaml:"set_headers" json:"set_headers"` // http proxy only - HideHeaders []string `yaml:"hide_headers" json:"hide_headers"` // http proxy only -} - -type ProxyConfigMap map[string]ProxyConfig -type ProxyConfigSlice []ProxyConfig - -// used by `GetFileProxyConfigs` -func (cfg *ProxyConfig) SetDefaults() error { - err := NewNestedError("invalid proxy config").Subject(cfg.Alias) - - if cfg.Alias == "" { - err.Extra("alias is required") - } - if cfg.Scheme == "" { - cfg.Scheme = "http" - } - if cfg.Host == "" { - err.Extra("host is required") - } - if cfg.Port == "" { - switch cfg.Scheme { - case "http": - cfg.Port = "80" - case "https": - cfg.Port = "443" - default: - err.Extraf("port is required for %s scheme", cfg.Scheme) - } - } - if err.HasExtras() { - return err - } - return nil -} - -func (cfg *ProxyConfig) GetID() string { - return fmt.Sprintf("%s-%s-%s-%s-%s", cfg.Alias, cfg.Scheme, cfg.Host, cfg.Port, cfg.Path) -} \ No newline at end of file diff --git a/src/go-proxy/proxy_label.go b/src/go-proxy/proxy_label.go deleted file mode 100644 index 20997c7..0000000 --- a/src/go-proxy/proxy_label.go +++ /dev/null @@ -1,92 +0,0 @@ -package main - -import ( - "errors" - "fmt" - "net/http" - "strings" -) - -type ProxyLabel struct { - Alias string - Field string - Value any -} - -var errNotProxyLabel = errors.New("not a proxy label") -var errInvalidSetHeaderLine = errors.New("invalid set header line") -var errInvalidBoolean = errors.New("invalid boolean") - -const proxyLabelNamespace = "proxy" - -func parseProxyLabel(label string, value string) (*ProxyLabel, error) { - ns := strings.Split(label, ".") - var v any = value - - if len(ns) != 3 { - return nil, errNotProxyLabel - } - - if ns[0] != proxyLabelNamespace { - return nil, errNotProxyLabel - } - - field := ns[2] - - var err error - parser, ok := valueParser[field] - - if ok { - v, err = parser(v.(string)) - if err != nil { - return nil, err - } - } - - return &ProxyLabel{ - Alias: ns[1], - Field: field, - Value: v, - }, nil -} - -func setHeadersParser(value string) (any, error) { - value = strings.TrimSpace(value) - lines := strings.Split(value, "\n") - h := make(http.Header) - for _, line := range lines { - parts := strings.SplitN(line, ":", 2) - if len(parts) != 2 { - return nil, fmt.Errorf("%w: %q", errInvalidSetHeaderLine, line) - } - key := strings.TrimSpace(parts[0]) - val := strings.TrimSpace(parts[1]) - h.Add(key, val) - } - return h, nil -} - -func commaSepParser(value string) (any, error) { - v := strings.Split(value, ",") - for i := range v { - v[i] = strings.TrimSpace(v[i]) - } - return v, nil -} - -func boolParser(value string) (any, error) { - switch strings.ToLower(value) { - case "true", "yes", "1": - return true, nil - case "false", "no", "0": - return false, nil - default: - return nil, fmt.Errorf("%w: %q", errInvalidBoolean, value) - } -} - -var valueParser = map[string]func(string) (any, error){ - "set_headers": setHeadersParser, - "hide_headers": commaSepParser, - "no_tls_verify": boolParser, -} diff --git a/src/go-proxy/proxy_label_test.go b/src/go-proxy/proxy_label_test.go deleted file mode 100644 index ab712c1..0000000 --- a/src/go-proxy/proxy_label_test.go +++ /dev/null @@ -1,186 +0,0 @@ -package main - -import ( - "errors" - "fmt" - "net/http" - "reflect" - "testing" -) - -func makeLabel(alias string, field string) string { - return fmt.Sprintf("proxy.%s.%s", alias, field) -} - -func TestNotProxyLabel(t *testing.T) { - pl, err := parseProxyLabel("foo.bar", "1234") - if !errors.Is(err, errNotProxyLabel) { - t.Errorf("expected err NotProxyLabel, got %v", err) - } - if pl != nil { - t.Errorf("expected nil, got %v", pl) - } - _, err = parseProxyLabel("proxy.foo", "bar") - if !errors.Is(err, errNotProxyLabel) { - t.Errorf("expected err InvalidProxyLabel, got %v", err) - } -} - -func TestStringProxyLabel(t *testing.T) { - alias := "foo" - field := "ip" - v := "bar" - pl, err := parseProxyLabel(makeLabel(alias, field), v) - if err != nil { - t.Errorf("expected err=nil, got %v", err) - } - if pl.Alias != alias { - t.Errorf("expected alias=%s, got %s", alias, pl.Alias) - } - if pl.Field != field { - t.Errorf("expected field=%s, got %s", field, pl.Field) - } - if pl.Value != v { - t.Errorf("expected value=%q, got %s", v, pl.Value) - } -} - -func TestBoolProxyLabelValid(t *testing.T) { - alias := "foo" - field := "no_tls_verify" - tests := map[string]bool{ - "true": true, - "TRUE": true, - "yes": true, - "1": true, - "false": false, - "FALSE": false, - "no": false, - "0": false, - } - - for k, v := range tests { - pl, err := parseProxyLabel(makeLabel(alias, field), k) - if err != nil { - t.Errorf("expected err=nil, got %v", err) - } - if pl.Alias != alias { - t.Errorf("expected alias=%s, got %s", alias, pl.Alias) - } - if pl.Field != field { - t.Errorf("expected field=%s, got %s", field, pl.Field) - } - if pl.Value != v { - t.Errorf("expected value=%v, got %v", v, pl.Value) - } - } -} - -func TestBoolProxyLabelInvalid(t *testing.T) { - alias := "foo" - field := "no_tls_verify" - _, err := parseProxyLabel(makeLabel(alias, field), "invalid") - if !errors.Is(err, errInvalidBoolean) { - t.Errorf("expected err InvalidProxyLabel, got %v", err) - } -} - -func TestHeaderProxyLabelValid(t *testing.T) { - alias := "foo" - field := "set_headers" - v := ` - X-Custom-Header1: foo - X-Custom-Header1: bar - X-Custom-Header2: baz - ` - h := make(http.Header, 0) - h.Set("X-Custom-Header1", "foo") - h.Add("X-Custom-Header1", "bar") - h.Set("X-Custom-Header2", "baz") - - pl, err := parseProxyLabel(makeLabel(alias, field), v) - if err != nil { - t.Errorf("expected err=nil, got %v", err) - } - if pl.Alias != alias { - t.Errorf("expected alias=%s, got %s", alias, pl.Alias) - } - if pl.Field != field { - t.Errorf("expected field=%s, got %s", field, pl.Field) - } - hGot, ok := pl.Value.(http.Header) - if !ok { - t.Error("value is not http.Header") - return - } - for k, vWant := range h { - vGot := hGot[k] - if !reflect.DeepEqual(vGot, vWant) { - t.Errorf("expected %s=%q, got %q", k, vWant, vGot) - } - } -} - -func TestHeaderProxyLabelInvalid(t *testing.T) { - alias := "foo" - field := "set_headers" - tests := []string{ - "X-Custom-Header1 = bar", - "X-Custom-Header1", - } - - for _, v := range tests { - _, err := parseProxyLabel(makeLabel(alias, field), v) - if !errors.Is(err, errInvalidSetHeaderLine) { - t.Errorf("expected err InvalidProxyLabel for %q, got %v", v, err) - } - } -} - -func TestCommaSepProxyLabelSingle(t *testing.T) { - alias := "foo" - field := "hide_headers" - v := "X-Custom-Header1" - pl, err := parseProxyLabel(makeLabel(alias, field), v) - if err != nil { - t.Errorf("expected err=nil, got %v", err) - } - if pl.Alias != alias { - t.Errorf("expected alias=%s, got %s", alias, pl.Alias) - } - if pl.Field != field { - t.Errorf("expected field=%s, got %s", field, pl.Field) - } - sGot, ok := pl.Value.([]string) - sWant := []string{"X-Custom-Header1"} - if !ok { - t.Error("value is not []string") - } - if !reflect.DeepEqual(sGot, sWant) { - t.Errorf("expected %q, got %q", sWant, sGot) - } -} - -func TestCommaSepProxyLabelMulti(t *testing.T) { - alias := "foo" - field := "hide_headers" - v := "X-Custom-Header1, X-Custom-Header2,X-Custom-Header3" - pl, err := parseProxyLabel(makeLabel(alias, field), v) - if err != nil { - t.Errorf("expected err=nil, got %v", err) - } - if pl.Alias != alias { - t.Errorf("expected alias=%s, got %s", alias, pl.Alias) - } - if pl.Field != field { - t.Errorf("expected field=%s, got %s", field, pl.Field) - } - sGot, ok := pl.Value.([]string) - sWant := []string{"X-Custom-Header1", "X-Custom-Header2", "X-Custom-Header3"} - if !ok { - t.Error("value is not []string") - } - if !reflect.DeepEqual(sGot, sWant) { - t.Errorf("expected %q, got %q", sWant, sGot) - } -} diff --git a/src/go-proxy/reverse_proxy_mod_test.go b/src/go-proxy/reverse_proxy_mod_test.go deleted file mode 100644 index 182c044..0000000 --- a/src/go-proxy/reverse_proxy_mod_test.go +++ /dev/null @@ -1,102 +0,0 @@ -package main - -import ( - "net/http" - "net/url" - "os" - "reflect" - "testing" - "time" -) - -var proxyCfg ProxyConfig -var proxyUrl, _ = url.Parse("http://127.0.0.1:8181") -var proxyServer = NewServer(ServerOptions{ - Name: "proxy", - HTTPAddr: ":8080", - Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - NewReverseProxy(proxyUrl, &http.Transport{}, &proxyCfg).ServeHTTP(w, r) - }), -}) - -var testServer = NewServer(ServerOptions{ - Name: "test", - HTTPAddr: ":8181", - Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - h := r.Header - for k, vv := range h { - for _, v := range vv { - w.Header().Add(k, v) - } - } - w.WriteHeader(http.StatusOK) - }), -}) - -var httpClient = http.DefaultClient - -func TestMain(m *testing.M) { - proxyServer.Start() - testServer.Start() - time.Sleep(100 * time.Millisecond) - code := m.Run() - proxyServer.Stop() - testServer.Stop() - os.Exit(code) -} - -func TestSetHeader(t *testing.T) { - hWant := http.Header{"X-Test": []string{"foo", "bar"}, "X-Test2": []string{"baz"}} - proxyCfg = ProxyConfig{ - Alias: "test", - Scheme: "http", - Host: "127.0.0.1", - Port: "8181", - SetHeaders: hWant, - } - req, err := http.NewRequest("HEAD", "http://127.0.0.1:8080", nil) - if err != nil { - t.Fatal(err) - } - resp, err := httpClient.Do(req) - if err != nil { - t.Fatal(err) - } - hGot := resp.Header - t.Log("headers: ", hGot) - for k, v := range hWant { - if !reflect.DeepEqual(hGot[k], v) { - t.Errorf("header %s: expected %v, got %v", k, v, hGot[k]) - } - } -} - -func TestHideHeader(t *testing.T) { - hHide := []string{"X-Test", "X-Test2"} - proxyCfg = ProxyConfig{ - Alias: "test", - Scheme: "http", - Host: "127.0.0.1", - Port: "8181", - HideHeaders: hHide, - } - req, err := http.NewRequest("HEAD", "http://127.0.0.1:8080", nil) - for _, k := range hHide { - req.Header.Set(k, "foo") - } - if err != nil { - t.Fatal(err) - } - resp, err := httpClient.Do(req) - if err != nil { - t.Fatal(err) - } - hGot := resp.Header - t.Log("headers: ", hGot) - for _, v := range hHide { - _, ok := hGot[v] - if ok { - t.Errorf("header %s: expected hidden, got %v", v, hGot[v]) - } - } -} diff --git a/src/go-proxy/route.go b/src/go-proxy/route.go deleted file mode 100755 index 732a3d7..0000000 --- a/src/go-proxy/route.go +++ /dev/null @@ -1,45 +0,0 @@ -package main - -type Route interface { - Start() - Stop() -} - -func NewRoute(cfg *ProxyConfig) (Route, error) { - if isStreamScheme(cfg.Scheme) { - id := cfg.GetID() - if streamRoutes.Contains(id) { - return nil, NewNestedError("duplicated stream").Subject(cfg.Alias) - } - route, err := NewStreamRoute(cfg) - if err != nil { - return nil, NewNestedErrorFrom(err).Subject(cfg.Alias) - } - return route, nil - } else { - httpRoutes.Ensure(cfg.Alias) - route, err := NewHTTPRoute(cfg) - if err != nil { - return nil, NewNestedErrorFrom(err).Subject(cfg.Alias) - } - return route, nil - } -} - -func isValidScheme(s string) bool { - for _, v := range ValidSchemes { - if v == s { - return true - } - } - return false -} - -func isStreamScheme(s string) bool { - for _, v := range StreamSchemes { - if v == s { - return true - } - } - return false -} diff --git a/src/go-proxy/server.go b/src/go-proxy/server.go deleted file mode 100644 index 1a74504..0000000 --- a/src/go-proxy/server.go +++ /dev/null @@ -1,140 +0,0 @@ -package main - -import ( - "crypto/tls" - "log" - "net/http" - "time" - - "github.com/sirupsen/logrus" - "golang.org/x/net/context" -) - -type Server struct { - Name string - KeyFile string - CertFile string - CertProvider AutoCertProvider - http *http.Server - https *http.Server - httpStarted bool - httpsStarted bool -} - -type ServerOptions struct { - Name string - HTTPAddr string - HTTPSAddr string - CertProvider AutoCertProvider - RedirectToHTTPS bool - Handler http.Handler -} - -type LogrusWrapper struct { - *logrus.Entry -} - -func (l LogrusWrapper) Write(b []byte) (int, error) { - return l.Logger.WriterLevel(logrus.ErrorLevel).Write(b) -} - -func NewServer(opt ServerOptions) *Server { - var httpHandler http.Handler - var s *Server - if opt.RedirectToHTTPS { - httpHandler = http.HandlerFunc(redirectToTLSHandler) - } else { - httpHandler = opt.Handler - } - logger := log.Default() - logger.SetOutput(LogrusWrapper{ - logrus.WithFields(logrus.Fields{"component": "server", "name": opt.Name}), - }) - if opt.CertProvider != nil { - s = &Server{ - Name: opt.Name, - CertProvider: opt.CertProvider, - http: &http.Server{ - Addr: opt.HTTPAddr, - Handler: httpHandler, - ErrorLog: logger, - }, - https: &http.Server{ - Addr: opt.HTTPSAddr, - Handler: opt.Handler, - ErrorLog: logger, - TLSConfig: &tls.Config{ - GetCertificate: opt.CertProvider.GetCert, - }, - }, - } - } - s = &Server{ - Name: opt.Name, - KeyFile: keyFileDefault, - CertFile: certFileDefault, - http: &http.Server{ - Addr: opt.HTTPAddr, - Handler: httpHandler, - ErrorLog: logger, - }, - https: &http.Server{ - Addr: opt.HTTPSAddr, - Handler: opt.Handler, - ErrorLog: logger, - }, - } - if !s.certsOK() { - s.http.Handler = opt.Handler - } - return s -} - -func (s *Server) Start() { - if s.http != nil { - s.httpStarted = true - logrus.Printf("starting http %s server on %s", s.Name, s.http.Addr) - go func() { - err := s.http.ListenAndServe() - s.handleErr("http", err) - }() - } - - if s.https != nil && (s.CertProvider != nil || s.certsOK()) { - s.httpsStarted = true - logrus.Printf("starting https %s server on %s", s.Name, s.https.Addr) - go func() { - err := s.https.ListenAndServeTLS(s.CertFile, s.KeyFile) - s.handleErr("https", err) - }() - } -} - -func (s *Server) Stop() { - ctx, _ := context.WithTimeout(context.Background(), 3*time.Second) - - if s.httpStarted { - errHTTP := s.http.Shutdown(ctx) - s.handleErr("http", errHTTP) - s.httpStarted = false - } - - if s.httpsStarted { - errHTTPS := s.https.Shutdown(ctx) - s.handleErr("https", errHTTPS) - s.httpsStarted = false - } -} - -func (s *Server) handleErr(scheme string, err error) { - switch err { - case nil, http.ErrServerClosed: - return - default: - logrus.Fatalf("failed to start %s %s server: %v", scheme, s.Name, err) - } -} - -func (s *Server) certsOK() bool { - return utils.fileOK(s.CertFile) && utils.fileOK(s.KeyFile) -} \ No newline at end of file diff --git a/src/go-proxy/stream_route.go b/src/go-proxy/stream_route.go deleted file mode 100755 index 6100ff8..0000000 --- a/src/go-proxy/stream_route.go +++ /dev/null @@ -1,242 +0,0 @@ -package main - -import ( - "fmt" - "strconv" - "strings" - "sync" - "time" - - "github.com/sirupsen/logrus" -) - -type StreamImpl interface { - Setup() error - Accept() (interface{}, error) - Handle(interface{}) error - CloseListeners() -} - -type StreamRoute interface { - Route - ListeningUrl() string - TargetUrl() string - Logger() logrus.FieldLogger -} - -type StreamRouteBase struct { - Alias string // to show in panel - Type string - ListeningScheme string - ListeningPort int - TargetScheme string - TargetHost string - TargetPort int - - id string - wg sync.WaitGroup - stopCh chan struct{} - connCh chan interface{} - started bool - l logrus.FieldLogger - - StreamImpl -} - -func newStreamRouteBase(config *ProxyConfig) (*StreamRouteBase, error) { - var streamType string = StreamType_TCP - var srcPort, dstPort string - var srcScheme, dstScheme string - - l := srlog.WithFields(logrus.Fields{ - "alias": config.Alias, - }) - portSplit := strings.Split(config.Port, ":") - if len(portSplit) != 2 { - l.Warnf( - `%s: invalid port %s, - assuming it is target port`, - config.Alias, - config.Port, - ) - srcPort = "0" // will assign later - dstPort = config.Port - } else { - srcPort = portSplit[0] - dstPort = portSplit[1] - } - - if port, hasName := NamePortMapTCP[dstPort]; hasName { - dstPort = port - } - - srcPortInt, err := strconv.Atoi(srcPort) - if err != nil { - return nil, NewNestedError("invalid stream source port").Subject(srcPort) - } - - utils.markPortInUse(srcPortInt) - - dstPortInt, err := strconv.Atoi(dstPort) - if err != nil { - return nil, NewNestedError("invalid stream target port").Subject(dstPort) - } - - schemeSplit := strings.Split(config.Scheme, ":") - if len(schemeSplit) == 2 { - srcScheme = schemeSplit[0] - dstScheme = schemeSplit[1] - } else { - srcScheme = config.Scheme - dstScheme = config.Scheme - } - - if srcScheme != dstScheme { - return nil, NewNestedError("unsupported").Subjectf("%v -> %v", srcScheme, dstScheme) - } - - return &StreamRouteBase{ - Alias: config.Alias, - Type: streamType, - ListeningScheme: srcScheme, - ListeningPort: srcPortInt, - TargetScheme: dstScheme, - TargetHost: config.Host, - TargetPort: dstPortInt, - - id: config.GetID(), - wg: sync.WaitGroup{}, - stopCh: make(chan struct{}, 1), - connCh: make(chan interface{}), - started: false, - l: l, - }, nil -} - -func NewStreamRoute(config *ProxyConfig) (StreamRoute, error) { - base, err := newStreamRouteBase(config) - if err != nil { - return nil, err - } - switch config.Scheme { - case StreamType_TCP: - base.StreamImpl = NewTCPRoute(base) - case StreamType_UDP: - base.StreamImpl = NewUDPRoute(base) - default: - return nil, NewNestedError("invalid stream type").Subject(config.Scheme) - } - return base, nil -} - -func (route *StreamRouteBase) ListeningUrl() string { - return fmt.Sprintf("%s:%v", route.ListeningScheme, route.ListeningPort) -} - -func (route *StreamRouteBase) TargetUrl() string { - return fmt.Sprintf("%s://%s:%v", route.TargetScheme, route.TargetHost, route.TargetPort) -} - -func (route *StreamRouteBase) Logger() logrus.FieldLogger { - return route.l -} - -func (route *StreamRouteBase) Start() { - route.wg.Wait() - route.ensurePort() - if err := route.Setup(); err != nil { - route.l.Errorf("failed to setup: %v", err) - return - } - route.started = true - streamRoutes.Set(route.id, route) - route.wg.Add(2) - go route.grAcceptConnections() - go route.grHandleConnections() -} - -func (route *StreamRouteBase) Stop() { - if !route.started { - return - } - l := route.Logger() - l.Debug("stopping listening") - close(route.stopCh) - route.CloseListeners() - - done := make(chan struct{}, 1) - go func() { - route.wg.Wait() - close(done) - }() - - select { - case <-done: - l.Info("stopped listening") - case <-time.After(streamStopListenTimeout): - l.Error("timed out waiting for connections") - } - - utils.unmarkPortInUse(route.ListeningPort) - streamRoutes.Delete(route.id) -} - -func (route *StreamRouteBase) ensurePort() { - if route.ListeningPort == 0 { - freePort, err := utils.findUseFreePort(20000) - if err != nil { - route.l.Error(err) - return - } - route.ListeningPort = freePort - route.l.Info("listening on free port ", route.ListeningPort) - return - } - route.l.Info("listening on ", route.ListeningUrl()) -} - -func (route *StreamRouteBase) grAcceptConnections() { - defer route.wg.Done() - - for { - select { - case <-route.stopCh: - return - default: - conn, err := route.Accept() - if err != nil { - select { - case <-route.stopCh: - return - default: - route.l.Error(err) - continue - } - } - route.connCh <- conn - } - } -} - -func (route *StreamRouteBase) grHandleConnections() { - defer route.wg.Done() - - for { - select { - case <-route.stopCh: - return - case conn := <-route.connCh: - go func() { - err := route.Handle(conn) - if err != nil { - route.l.Error(err) - } - }() - } - } -} - -// id -> target -type StreamRoutes SafeMap[string, StreamRoute] - -var streamRoutes StreamRoutes = NewSafeMapOf[StreamRoutes]() diff --git a/src/go-proxy/utils.go b/src/go-proxy/utils.go deleted file mode 100755 index ca730eb..0000000 --- a/src/go-proxy/utils.go +++ /dev/null @@ -1,249 +0,0 @@ -package main - -import ( - "bytes" - "encoding/json" - "errors" - "fmt" - "io" - "net" - "net/http" - "os" - "path" - "path/filepath" - "reflect" - "regexp" - "strings" - "sync" - - "github.com/santhosh-tekuri/jsonschema" - "github.com/sirupsen/logrus" - xhtml "golang.org/x/net/html" - "gopkg.in/yaml.v3" -) - -type Utils struct { - portsInUse map[int]bool - portsInUseMutex sync.Mutex -} - -var utils = &Utils{ - portsInUse: make(map[int]bool), - portsInUseMutex: sync.Mutex{}, -} - -func (u *Utils) findUseFreePort(startingPort int) (int, error) { - u.portsInUseMutex.Lock() - defer u.portsInUseMutex.Unlock() - for port := startingPort; port <= startingPort+100 && port <= 65535; port++ { - if u.portsInUse[port] { - continue - } - addr := fmt.Sprintf(":%d", port) - l, err := net.Listen("tcp", addr) - if err == nil { - u.portsInUse[port] = true - l.Close() - return port, nil - } - } - l, err := net.Listen("tcp", ":0") - if err == nil { - // NOTE: may not be after 20000 - port := l.Addr().(*net.TCPAddr).Port - u.portsInUse[port] = true - l.Close() - return port, nil - } - return -1, NewNestedError("unable to find free port").With(err) -} - -func (u *Utils) markPortInUse(port int) { - u.portsInUseMutex.Lock() - u.portsInUse[port] = true - u.portsInUseMutex.Unlock() -} - -func (u *Utils) unmarkPortInUse(port int) { - u.portsInUseMutex.Lock() - delete(u.portsInUse, port) - u.portsInUseMutex.Unlock() -} - -func (*Utils) healthCheckHttp(targetUrl string) error { - // try HEAD first - // if HEAD is not allowed, try GET - resp, err := healthCheckHttpClient.Head(targetUrl) - if resp != nil { - resp.Body.Close() - } - if err != nil && resp != nil && resp.StatusCode == http.StatusMethodNotAllowed { - _, err = healthCheckHttpClient.Get(targetUrl) - } - if resp != nil { - resp.Body.Close() - } - return err -} - -func (*Utils) healthCheckStream(scheme, host string) error { - conn, err := net.DialTimeout(scheme, host, streamDialTimeout) - if err != nil { - return err - } - conn.Close() - return nil -} - -func (*Utils) reloadServer() error { - resp, err := healthCheckHttpClient.Post("http://localhost:8080/reload", "", nil) - if err != nil { - return err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return NewNestedError("server reload failed").Subjectf("%d", resp.StatusCode) - } - return nil -} - -func (*Utils) snakeToPascal(s string) string { - toHyphenCamel := http.CanonicalHeaderKey(strings.ReplaceAll(s, "_", "-")) - return strings.ReplaceAll(toHyphenCamel, "-", "") -} - -func tryAppendPathPrefixImpl(pOrig, pAppend string) string { - switch { - case strings.Contains(pOrig, "://"): - return pOrig - case pOrig == "", pOrig == "#", pOrig == "/": - return pAppend - case filepath.IsLocal(pOrig) && !strings.HasPrefix(pOrig, pAppend): - return path.Join(pAppend, pOrig) - default: - return pOrig - } -} - -var tryAppendPathPrefix func(string, string) string -var _ = func() int { - if logLevel == logrus.DebugLevel { - tryAppendPathPrefix = func(s1, s2 string) string { - replaced := tryAppendPathPrefixImpl(s1, s2) - return replaced - } - } else { - tryAppendPathPrefix = tryAppendPathPrefixImpl - } - return 1 -}() - -func htmlNodesSubPath(n *xhtml.Node, p string) { - if n.Type == xhtml.ElementNode { - for i, attr := range n.Attr { - switch attr.Key { - case "src", "href", "action": // img, script, link, form etc. - n.Attr[i].Val = tryAppendPathPrefix(attr.Val, p) - } - } - } - - for c := n.FirstChild; c != nil; c = c.NextSibling { - htmlNodesSubPath(c, p) - } -} - -func (*Utils) respHTMLSubPath(r *http.Response, p string) error { - // remove all path prefix from relative path in script, img, a, ... - doc, err := xhtml.Parse(r.Body) - - if err != nil { - return err - } - - if p[0] == '/' { - p = p[1:] - } - htmlNodesSubPath(doc, p) - - var buf bytes.Buffer - err = xhtml.Render(&buf, doc) - - if err != nil { - return err - } - - r.Body = io.NopCloser(strings.NewReader(buf.String())) - - return nil -} - -func (*Utils) respJSSubPath(r *http.Response, p string) error { - var buf bytes.Buffer - - _, err := buf.ReadFrom(r.Body) - if err != nil { - return err - } - - if p[0] == '/' { - p = p[1:] - } - - js := buf.String() - - re := regexp.MustCompile(`fetch\(["'].+["']\)`) - replace := func(match string) string { - match = match[7 : len(match)-2] - replaced := tryAppendPathPrefix(match, p) - return fmt.Sprintf(`fetch(%q)`, replaced) - } - js = re.ReplaceAllStringFunc(js, replace) - - r.Body = io.NopCloser(strings.NewReader(js)) - return nil -} - -func (*Utils) fileOK(path string) bool { - _, err := os.Stat(path) - return err == nil -} - -func setFieldFromSnake[T interface{}, VT interface{}](obj *T, field string, value VT) error { - field = utils.snakeToPascal(field) - prop := reflect.ValueOf(obj).Elem().FieldByName(field) - if prop.Kind() == 0 { - return errors.New("unknown field") - } - prop.Set(reflect.ValueOf(value)) - return nil -} - -func validateYaml(schema *jsonschema.Schema, data []byte) error { - if noSchemaValidation { - return nil - } - - var i interface{} - - err := yaml.Unmarshal(data, &i) - if err != nil { - return NewNestedError("unable to unmarshal yaml").With(err) - } - - m, err := json.Marshal(i) - if err != nil { - return NewNestedError("unable to marshal json").With(err) - } - - err = schema.Validate(bytes.NewReader(m)) - if err != nil { - valErr := err.(*jsonschema.ValidationError) - ne := NewNestedError("validation error") - for _, e := range valErr.Causes { - ne.ExtraError(e) - } - return ne - } - return nil -} diff --git a/src/go-proxy/watcher.go b/src/go-proxy/watcher.go deleted file mode 100644 index 84d336f..0000000 --- a/src/go-proxy/watcher.go +++ /dev/null @@ -1,245 +0,0 @@ -package main - -import ( - "strings" - "sync" - "time" - - "github.com/docker/docker/api/types" - "github.com/docker/docker/api/types/events" - "github.com/docker/docker/api/types/filters" - "github.com/docker/docker/client" - "github.com/fsnotify/fsnotify" - "github.com/sirupsen/logrus" - - "golang.org/x/net/context" -) - -type Watcher interface { - Start() - Stop() - Dispose() -} - -type watcherBase struct { - onChange func() - l logrus.FieldLogger - sync.Mutex -} - -type fileWatcher struct { - *watcherBase - path string - onDelete func() -} - -type dockerWatcher struct { - *watcherBase - client *client.Client - stopCh chan struct{} - wg sync.WaitGroup -} - -func (p *Provider) newWatcher() *watcherBase { - return &watcherBase{ - onChange: p.ReloadRoutes, - l: p.l, - } -} - -func (p *Provider) NewFileWatcher() Watcher { - return &fileWatcher{ - watcherBase: p.newWatcher(), - path: p.GetFilePath(), - onDelete: p.StopAllRoutes, - } -} - -func (p *Provider) NewDockerWatcher(c *client.Client) Watcher { - return &dockerWatcher{ - watcherBase: p.newWatcher(), - client: c, - stopCh: make(chan struct{}, 1), - } -} - -func (c *config) newWatcher() *watcherBase { - return &watcherBase{ - onChange: c.MustReload, - l: c.l, - } -} - -func (c *config) NewFileWatcher() Watcher { - return &fileWatcher{ - watcherBase: c.newWatcher(), - path: c.reader.(*FileReader).Path, - onDelete: func() { c.l.Fatal("config file deleted") }, - } -} - -func (w *fileWatcher) Start() { - w.Lock() - defer w.Unlock() - if fsWatcher == nil { - return - } - err := fsWatcher.Add(w.path) - if err != nil { - w.l.Error("failed to start: ", err) - return - } - fileWatchMap.Set(w.path, w) -} - -func (w *fileWatcher) Stop() { - w.Lock() - defer w.Unlock() - if fsWatcher == nil { - return - } - fileWatchMap.Delete(w.path) - err := fsWatcher.Remove(w.path) - if err != nil { - w.l.Error(err) - } -} - -func (w *fileWatcher) Dispose() { - w.Stop() -} - -func (w *dockerWatcher) Start() { - w.Lock() - defer w.Unlock() - dockerWatchMap.Set(w.client.DaemonHost(), w) - w.wg.Add(1) - go w.watch() -} - -func (w *dockerWatcher) Stop() { - w.Lock() - defer w.Unlock() - if w.stopCh == nil { - return - } - close(w.stopCh) - w.wg.Wait() - w.stopCh = nil - dockerWatchMap.Delete(w.client.DaemonHost()) -} - -func (w *dockerWatcher) Dispose() { - w.Stop() - w.client.Close() -} - -func InitFSWatcher() { - w, err := fsnotify.NewWatcher() - if err != nil { - wlog.Errorf("unable to create file watcher: %v", err) - return - } - fsWatcher = w - fsWatcherWg.Add(1) - go watchFiles() -} - -func StopFSWatcher() { - close(fsWatcherStop) - fsWatcherWg.Wait() -} - -func StopDockerWatcher() { - ParallelForEachValue( - dockerWatchMap.Iterator(), - (*dockerWatcher).Dispose, - ) -} - -func watchFiles() { - defer fsWatcher.Close() - defer fsWatcherWg.Done() - - for { - select { - case <-fsWatcherStop: - return - case event, ok := <-fsWatcher.Events: - if !ok { - wlog.Error("file watcher channel closed") - return - } - w, ok := fileWatchMap.UnsafeGet(event.Name) - if !ok { - wlog.Errorf("watcher for %s not found", event.Name) - } - switch { - case event.Has(fsnotify.Write): - w.l.Info("file changed: ", event.Name) - go w.onChange() - case event.Has(fsnotify.Remove), event.Has(fsnotify.Rename): - w.l.Info("file renamed / deleted: ", event.Name) - go w.onDelete() - } - case err := <-fsWatcher.Errors: - wlog.Error(err) - } - } -} - -func (w *dockerWatcher) watch() { - defer w.wg.Done() - - filter := filters.NewArgs( - filters.Arg("type", "container"), - filters.Arg("event", "start"), - filters.Arg("event", "die"), // 'stop' already triggering 'die' - ) - listen := func() (<-chan events.Message, <-chan error) { - return w.client.Events(context.Background(), types.EventsOptions{Filters: filter}) - } - msgChan, errChan := listen() - - for { - select { - case <-w.stopCh: - return - case msg := <-msgChan: - containerName := msg.Actor.Attributes["name"] - if strings.HasPrefix(containerName, "buildx_buildkit_builder-") { - continue - } - w.l.Infof("container %s %s", containerName, msg.Action) - go w.onChange() - case err := <-errChan: - switch { - case client.IsErrConnectionFailed(err): - w.l.Error("watcher: connection failed") - case client.IsErrNotFound(err): - w.l.Error("watcher: endpoint not found") - default: - w.l.Errorf("watcher: %v", err) - } - time.Sleep(1 * time.Second) - msgChan, errChan = listen() - } - } -} - -type ( - FileWatcherMap = SafeMap[string, *fileWatcher] - DockerWatcherMap = SafeMap[string, *dockerWatcher] -) - -var fsWatcher *fsnotify.Watcher -var ( - fileWatchMap FileWatcherMap = NewSafeMapOf[FileWatcherMap]() - dockerWatchMap DockerWatcherMap = NewSafeMapOf[DockerWatcherMap]() -) -var ( - fsWatcherStop = make(chan struct{}, 1) -) -var ( - fsWatcherWg sync.WaitGroup -) diff --git a/go.mod b/src/go.mod old mode 100755 new mode 100644 similarity index 60% rename from go.mod rename to src/go.mod index ddda21c..8213224 --- a/go.mod +++ b/src/go.mod @@ -3,52 +3,52 @@ module github.com/yusing/go-proxy go 1.22 require ( - github.com/docker/cli v26.0.0+incompatible - github.com/docker/docker v26.0.0+incompatible + github.com/docker/cli v27.1.1+incompatible + github.com/docker/docker v27.1.1+incompatible github.com/fsnotify/fsnotify v1.7.0 - github.com/go-acme/lego/v4 v4.16.1 + github.com/go-acme/lego/v4 v4.17.4 github.com/santhosh-tekuri/jsonschema v1.2.4 github.com/sirupsen/logrus v1.9.3 - golang.org/x/net v0.24.0 + golang.org/x/net v0.27.0 gopkg.in/yaml.v3 v3.0.1 ) require ( - github.com/Microsoft/go-winio v0.6.1 // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect - github.com/cloudflare/cloudflare-go v0.92.0 // indirect + github.com/cloudflare/cloudflare-go v0.100.0 // indirect github.com/containerd/log v0.1.0 // indirect github.com/distribution/reference v0.6.0 // indirect github.com/docker/go-connections v0.5.0 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect - github.com/go-jose/go-jose/v4 v4.0.1 // indirect - github.com/go-logr/logr v1.4.1 // indirect + github.com/go-jose/go-jose/v4 v4.0.4 // indirect + github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect - github.com/goccy/go-json v0.10.2 // indirect + github.com/goccy/go-json v0.10.3 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/google/go-querystring v1.1.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect - github.com/hashicorp/go-retryablehttp v0.7.5 // indirect - github.com/miekg/dns v1.1.58 // indirect + github.com/hashicorp/go-retryablehttp v0.7.7 // indirect + github.com/miekg/dns v1.1.61 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/term v0.5.0 // indirect github.com/morikuni/aec v1.0.0 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.0 // indirect github.com/pkg/errors v0.9.1 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.50.0 // indirect - go.opentelemetry.io/otel v1.25.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0 // indirect + go.opentelemetry.io/otel v1.28.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.24.0 // indirect - go.opentelemetry.io/otel/metric v1.25.0 // indirect + go.opentelemetry.io/otel/metric v1.28.0 // indirect go.opentelemetry.io/otel/sdk v1.24.0 // indirect - go.opentelemetry.io/otel/trace v1.25.0 // indirect - golang.org/x/crypto v0.22.0 // indirect - golang.org/x/mod v0.17.0 // indirect + go.opentelemetry.io/otel/trace v1.28.0 // indirect + golang.org/x/crypto v0.25.0 // indirect + golang.org/x/mod v0.19.0 // indirect golang.org/x/sync v0.7.0 // indirect - golang.org/x/sys v0.19.0 // indirect - golang.org/x/text v0.14.0 // indirect + golang.org/x/sys v0.22.0 // indirect + golang.org/x/text v0.16.0 // indirect golang.org/x/time v0.5.0 // indirect - golang.org/x/tools v0.20.0 // indirect + golang.org/x/tools v0.23.0 // indirect gotest.tools/v3 v3.5.1 // indirect ) diff --git a/go.sum b/src/go.sum old mode 100755 new mode 100644 similarity index 68% rename from go.sum rename to src/go.sum index 6ab3afe..aeb083b --- a/go.sum +++ b/src/go.sum @@ -1,11 +1,11 @@ github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8= github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= -github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow= -github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= -github.com/cloudflare/cloudflare-go v0.92.0 h1:ltJvGvqZ4G6Fm2hHOYZ5RWpJQcrM0oDrsjjZydZhFJQ= -github.com/cloudflare/cloudflare-go v0.92.0/go.mod h1:nUqvBUUDRxNzsDSQjbqUNWHEIYAoUlgRmcAzMKlFdKs= +github.com/cloudflare/cloudflare-go v0.100.0 h1:4iCUI2ZoIhRMyd7Z1TDsHhH1OhkgHC83eYbPlSgTRjo= +github.com/cloudflare/cloudflare-go v0.100.0/go.mod h1:VQ1t9Mvgdu4VFLx6uwQgFC10XxcCRIUuvkYGc9daMRU= github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -13,35 +13,33 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= -github.com/docker/cli v26.0.0+incompatible h1:90BKrx1a1HKYpSnnBFR6AgDq/FqkHxwlUyzJVPxD30I= -github.com/docker/cli v26.0.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= -github.com/docker/docker v26.0.0+incompatible h1:Ng2qi+gdKADUa/VM+6b6YaY2nlZhk/lVJiKR/2bMudU= -github.com/docker/docker v26.0.0+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/cli v27.1.1+incompatible h1:goaZxOqs4QKxznZjjBWKONQci/MywhtRv2oNn0GkeZE= +github.com/docker/cli v27.1.1+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= +github.com/docker/docker v27.1.1+incompatible h1:hO/M4MtV36kzKldqnA37IWhebRA+LnqqcqDja6kVaKY= +github.com/docker/docker v27.1.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= -github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w= -github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= +github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= +github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= -github.com/go-acme/lego/v4 v4.16.1 h1:JxZ93s4KG0jL27rZ30UsIgxap6VGzKuREsSkkyzeoCQ= -github.com/go-acme/lego/v4 v4.16.1/go.mod h1:AVvwdPned/IWpD/ihHhMsKnveF7HHYAz/CmtXi7OZoE= -github.com/go-jose/go-jose/v4 v4.0.1 h1:QVEPDE3OluqXBQZDcnNvQrInro2h0e4eqNbnZSWqS6U= -github.com/go-jose/go-jose/v4 v4.0.1/go.mod h1:WVf9LFMHh/QVrmqrOfqun0C45tMe3RoiKJMPvgWwLfY= +github.com/go-acme/lego/v4 v4.17.4 h1:h0nePd3ObP6o7kAkndtpTzCw8shOZuWckNYeUQwo36Q= +github.com/go-acme/lego/v4 v4.17.4/go.mod h1:dU94SvPNqimEeb7EVilGGSnS0nU1O5Exir0pQ4QFL4U= +github.com/go-jose/go-jose/v4 v4.0.4 h1:VsjPI33J0SB9vQM6PLmNjoHqMQNGPiZ0rHL7Ni7Q6/E= +github.com/go-jose/go-jose/v4 v4.0.4/go.mod h1:NKb5HO1EZccyMpiZNbdUw/14tiXNyUJh188dfnMCAfc= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= -github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= -github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= +github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= -github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= -github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= @@ -51,23 +49,22 @@ github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.0 h1:Wqo399gCIufwto+VfwCSvsnfGpF github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.0/go.mod h1:qmOFXW2epJhM0qSnUUYpldc7gVz2KMQwJ/QYCDIa7XU= github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= -github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ= -github.com/hashicorp/go-hclog v1.2.0 h1:La19f8d7WIlm4ogzNHB0JGqs5AUDAZ2UfCY4sJXcJdM= -github.com/hashicorp/go-hclog v1.2.0/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ= -github.com/hashicorp/go-retryablehttp v0.7.5 h1:bJj+Pj19UZMIweq/iie+1u5YCdGrnxCT9yvm0e+Nd5M= -github.com/hashicorp/go-retryablehttp v0.7.5/go.mod h1:Jy/gPYAdjqffZ/yFGCFV2doI5wjtH1ewM9u8iYVjtX8= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU= +github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40= -github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/miekg/dns v1.1.58 h1:ca2Hdkz+cDg/7eNF6V56jjzuZ4aCAE+DbVkILdQWG/4= -github.com/miekg/dns v1.1.58/go.mod h1:Ypv+3b/KadlvW9vJfXOTf300O4UqaHFzFCuHz+rPkBY= +github.com/miekg/dns v1.1.61 h1:nLxbwF3XxhwVSm8g9Dghm9MHPaUZuqhPiGL+675ZmEs= +github.com/miekg/dns v1.1.61/go.mod h1:mnAarhS3nWaW+NVP2wTkYVIZyHNJ098SJZUki3eykwQ= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= @@ -89,43 +86,42 @@ github.com/santhosh-tekuri/jsonschema v1.2.4/go.mod h1:TEAUOeZSmIxTTuHatJzrvARHi github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.50.0 h1:cEPbyTSEHlQR89XVlyo78gqluF8Y3oMeBkXGWzQsfXY= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.50.0/go.mod h1:DKdbWcT4GH1D0Y3Sqt/PFXt2naRKDWtU+eE6oLdFNA8= -go.opentelemetry.io/otel v1.25.0 h1:gldB5FfhRl7OJQbUHt/8s0a7cE8fbsPAtdpRaApKy4k= -go.opentelemetry.io/otel v1.25.0/go.mod h1:Wa2ds5NOXEMkCmUou1WA7ZBfLTHWIsp034OVD7AO+Vg= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0 h1:4K4tsIXefpVJtvA/8srF4V4y0akAoPHkIslgAkjixJA= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0/go.mod h1:jjdQuTGVsXV4vSs+CJ2qYDeDPf9yIJV23qlIzBm73Vg= +go.opentelemetry.io/otel v1.28.0 h1:/SqNcYk+idO0CxKEUOtKQClMK/MimZihKYMruSMViUo= +go.opentelemetry.io/otel v1.28.0/go.mod h1:q68ijF8Fc8CnMHKyzqL6akLO46ePnjkgfIMIjUIX9z4= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.24.0 h1:t6wl9SPayj+c7lEIFgm4ooDBZVb01IhLB4InpomhRw8= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.24.0/go.mod h1:iSDOcsnSA5INXzZtwaBPrKp/lWu/V14Dd+llD0oI2EA= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.24.0 h1:Xw8U6u2f8DK2XAkGRFV7BBLENgnTGX9i4rQRxJf+/vs= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.24.0/go.mod h1:6KW1Fm6R/s6Z3PGXwSJN2K4eT6wQB3vXX6CVnYX9NmM= -go.opentelemetry.io/otel/metric v1.25.0 h1:LUKbS7ArpFL/I2jJHdJcqMGxkRdxpPHE0VU/D4NuEwA= -go.opentelemetry.io/otel/metric v1.25.0/go.mod h1:rkDLUSd2lC5lq2dFNrX9LGAbINP5B7WBkC78RXCpH5s= +go.opentelemetry.io/otel/metric v1.28.0 h1:f0HGvSl1KRAU1DLgLGFjrwVyismPlnuU6JD6bOeuA5Q= +go.opentelemetry.io/otel/metric v1.28.0/go.mod h1:Fb1eVBFZmLVTMb6PPohq3TO9IIhUisDsbJoL/+uQW4s= go.opentelemetry.io/otel/sdk v1.24.0 h1:YMPPDNymmQN3ZgczicBY3B6sf9n62Dlj9pWD3ucgoDw= go.opentelemetry.io/otel/sdk v1.24.0/go.mod h1:KVrIYw6tEubO9E96HQpcmpTKDVn9gdv35HoYiQWGDFg= -go.opentelemetry.io/otel/trace v1.25.0 h1:tqukZGLwQYRIFtSQM2u2+yfMVTgGVeqRLPUYx1Dq6RM= -go.opentelemetry.io/otel/trace v1.25.0/go.mod h1:hCCs70XM/ljO+BeQkyFnbK28SBIJ/Emuha+ccrCRT7I= +go.opentelemetry.io/otel/trace v1.28.0 h1:GhQ9cUuQGmNDd5BTCP2dAvv75RdMxEfTmYejp+lkx9g= +go.opentelemetry.io/otel/trace v1.28.0/go.mod h1:jPyXzNPg6da9+38HEwElrQiHlVMTnVfM3/yv2OlIHaI= go.opentelemetry.io/proto/otlp v1.1.0 h1:2Di21piLrCqJ3U3eXGCTPHE9R8Nh+0uglSnOyxikMeI= go.opentelemetry.io/proto/otlp v1.1.0/go.mod h1:GpBHCBWiqvVLDqmHZsoMM3C5ySeKTC7ej/RNTae6MdY= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30= -golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= +golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30= +golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= -golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.19.0 h1:fEdghXQSo20giMthA7cd28ZC+jts4amQ3YMXiP5oMQ8= +golang.org/x/mod v0.19.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w= -golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= +golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys= +golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -135,33 +131,33 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= -golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= +golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= +golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.20.0 h1:hz/CVckiOxybQvFw6h7b/q80NTr9IUQb4s1IIzW7KNY= -golang.org/x/tools v0.20.0/go.mod h1:WvitBU7JJf6A4jOdg4S1tviW9bhUxkgeCui/0JHctQg= +golang.org/x/tools v0.23.0 h1:SGsXPZ+2l4JsgaCKkx+FQ9YZ5XEtA1GZYuoDjenLjvg= +golang.org/x/tools v0.23.0/go.mod h1:pnu6ufv6vQkll6szChhK3C3L/ruaIv5eBeztNG8wtsI= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/genproto v0.0.0-20230530153820-e85fd2cbaebc h1:8DyZCyvI8mE1IdLy/60bS+52xfymkE72wv1asokgtao= -google.golang.org/genproto/googleapis/api v0.0.0-20240102182953-50ed04b92917 h1:rcS6EyEaoCO52hQDupoSfrxI3R6C2Tq741is7X8OvnM= -google.golang.org/genproto/googleapis/api v0.0.0-20240102182953-50ed04b92917/go.mod h1:CmlNWB9lSezaYELKS5Ym1r44VrrbPUa7JTvw+6MbpJ0= -google.golang.org/genproto/googleapis/rpc v0.0.0-20240102182953-50ed04b92917 h1:6G8oQ016D88m1xAKljMlBOOGWDZkes4kMhgGFlf8WcQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20240102182953-50ed04b92917/go.mod h1:xtjpI3tXFPP051KaWnhvxkiubL/6dJ18vLVf7q2pTOU= -google.golang.org/grpc v1.61.1 h1:kLAiWrZs7YeDM6MumDe7m3y4aM6wacLzM1Y/wiLP9XY= -google.golang.org/grpc v1.61.1/go.mod h1:VUbo7IFqmF1QtCAstipjG0GIoq49KvMe9+h1jFLBNJs= -google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I= -google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +google.golang.org/genproto v0.0.0-20240227224415-6ceb2ff114de h1:F6qOa9AZTYJXOUEr4jDysRDLrm4PHePlge4v4TGAlxY= +google.golang.org/genproto/googleapis/api v0.0.0-20240311132316-a219d84964c2 h1:rIo7ocm2roD9DcFIX67Ym8icoGCKSARAiPljFhh5suQ= +google.golang.org/genproto/googleapis/api v0.0.0-20240311132316-a219d84964c2/go.mod h1:O1cOfN1Cy6QEYr7VxtjOyP5AdAuR0aJ/MYZaaof623Y= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240318140521-94a12d6c2237 h1:NnYq6UN9ReLM9/Y01KWNOWyI5xQ9kbIms5GGJVwS/Yc= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240318140521-94a12d6c2237/go.mod h1:WtryC6hu0hhx87FDGxWCDptyssuo68sk10vYjF+T9fY= +google.golang.org/grpc v1.63.1 h1:pNClQmvdlyNUiwFETOux/PYqfhmA7BrswEdGRnib1fA= +google.golang.org/grpc v1.63.1/go.mod h1:WAX/8DgncnokcFUldAxq7GeB5DXHDbMF+lLvDomNkRA= +google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= +google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/src/main.go b/src/main.go new file mode 100755 index 0000000..023adf2 --- /dev/null +++ b/src/main.go @@ -0,0 +1,142 @@ +package main + +import ( + "context" + "net/http" + "os" + "os/signal" + "runtime" + "sync" + "syscall" + "time" + + "github.com/sirupsen/logrus" + "github.com/yusing/go-proxy/api" + apiUtils "github.com/yusing/go-proxy/api/v1/utils" + "github.com/yusing/go-proxy/common" + "github.com/yusing/go-proxy/config" + "github.com/yusing/go-proxy/docker" + R "github.com/yusing/go-proxy/route" + "github.com/yusing/go-proxy/server" + F "github.com/yusing/go-proxy/utils/functional" +) + +func main() { + runtime.GOMAXPROCS(runtime.NumCPU()) + + args := common.GetArgs() + l := logrus.WithField("?", "init") + + if common.IsDebug { + logrus.SetLevel(logrus.DebugLevel) + } + + if common.IsRunningAsService { + logrus.SetFormatter(&logrus.TextFormatter{ + DisableColors: true, + DisableTimestamp: true, + DisableSorting: true, + }) + } else { + logrus.SetFormatter(&logrus.TextFormatter{ + DisableSorting: true, + FullTimestamp: true, + TimestampFormat: "01-02 15:04:05", + }) + } + + if args.Command == common.CommandReload { + if err := apiUtils.ReloadServer(); err.IsNotNil() { + l.Fatal(err) + } + return + } + + onShutdown := F.NewSlice[func()]() + cfg, err := config.New() + if err.IsNotNil() { + l.Fatalf("config error: %s", err) + } + + // exit if only validate config + // TODO: validate without load + if args.Command == common.CommandValidate { + l.Printf("config OK") + return + } + + onShutdown.Add(func() { + docker.CloseAllClients() + cfg.Dispose() + }) + + sig := make(chan os.Signal, 1) + signal.Notify(sig, syscall.SIGINT) + signal.Notify(sig, syscall.SIGTERM) + signal.Notify(sig, syscall.SIGHUP) + + autocert := cfg.GetAutoCertProvider() + err = autocert.LoadCert() + + if err.IsNotNil() { + l.Infof("error loading certificate: %s\nNow attempting to obtain a new certificate", err) + if err = autocert.ObtainCert(); err.IsNotNil() { + ctx, certRenewalCancel := context.WithCancel(context.Background()) + go autocert.ScheduleRenewal(ctx) + onShutdown.Add(certRenewalCancel) + } else { + l.Warn(err) + } + } else { + for name, expiry := range autocert.GetExpiries() { + l.Infof("certificate %q: expire on %s", name, expiry) + } + } + proxyServer := server.InitProxyServer(server.Options{ + Name: "proxy", + CertProvider: autocert, + HTTPPort: common.ProxyHTTPPort, + HTTPSPort: common.ProxyHTTPSPort, + Handler: http.HandlerFunc(R.ProxyHandler), + RedirectToHTTPS: cfg.Value().RedirectToHTTPS, + }) + apiServer := server.InitAPIServer(server.Options{ + Name: "api", + CertProvider: autocert, + HTTPPort: common.APIHTTPPort, + Handler: api.NewHandler(cfg), + RedirectToHTTPS: cfg.Value().RedirectToHTTPS, + }) + + proxyServer.Start() + apiServer.Start() + onShutdown.Add(proxyServer.Stop) + onShutdown.Add(apiServer.Stop) + + // wait for signal + <-sig + + // grafully shutdown + logrus.Info("shutting down") + done := make(chan struct{}, 1) + + var wg sync.WaitGroup + wg.Add(onShutdown.Size()) + onShutdown.ForEach(func(f func()) { + go func() { + f() + wg.Done() + }() + }) + go func() { + wg.Wait() + close(done) + }() + + select { + case <-done: + logrus.Info("shutdown complete") + case <-time.After(time.Duration(cfg.Value().TimeoutShutdown) * time.Second): + logrus.Info("timeout waiting for shutdown") + } +} diff --git a/src/models/autocert_config.go b/src/models/autocert_config.go new file mode 100644 index 0000000..928f484 --- /dev/null +++ b/src/models/autocert_config.go @@ -0,0 +1,13 @@ +package model + +type ( + AutoCertConfig struct { + Email string `json:"email"` + Domains []string `yaml:",flow" json:"domains"` + CertPath string `yaml:"cert_path" json:"cert_path"` + KeyPath string `yaml:"key_path" json:"key_path"` + Provider string `json:"provider"` + Options AutocertProviderOpt `yaml:",flow" json:"options"` + } + AutocertProviderOpt map[string]string +) diff --git a/src/models/config.go b/src/models/config.go new file mode 100644 index 0000000..5fb99a2 --- /dev/null +++ b/src/models/config.go @@ -0,0 +1,16 @@ +package model + +type Config struct { + Providers ProxyProviders `yaml:",flow" json:"providers"` + AutoCert AutoCertConfig `yaml:",flow" json:"autocert"` + TimeoutShutdown int `yaml:"timeout_shutdown" json:"timeout_shutdown"` + RedirectToHTTPS bool `yaml:"redirect_to_https" json:"redirect_to_https"` +} + +func DefaultConfig() *Config { + return &Config{ + Providers: ProxyProviders{}, + TimeoutShutdown: 3, + RedirectToHTTPS: true, + } +} diff --git a/src/models/proxy_entry.go b/src/models/proxy_entry.go new file mode 100644 index 0000000..5eebe2a --- /dev/null +++ b/src/models/proxy_entry.go @@ -0,0 +1,43 @@ +package model + +import ( + "net/http" + "strings" + + F "github.com/yusing/go-proxy/utils/functional" +) + +type ( + ProxyEntry struct { + Alias string `yaml:"-" json:"-"` + Scheme string `yaml:"scheme" json:"scheme"` + Host string `yaml:"host" json:"host"` + Port string `yaml:"port" json:"port"` + NoTLSVerify bool `yaml:"no_tls_verify" json:"no_tls_verify"` // http proxy only + Path string `yaml:"path" json:"path"` // http proxy only + SetHeaders http.Header `yaml:"set_headers" json:"set_headers"` // http proxy only + HideHeaders []string `yaml:"hide_headers" json:"hide_headers"` // http proxy only + } + + ProxyEntries = *F.Map[string, *ProxyEntry] +) + +var NewProxyEntries = F.NewMap[string, *ProxyEntry] + +func (e *ProxyEntry) SetDefaults() { + if e.Scheme == "" { + if strings.ContainsRune(e.Port, ':') { + e.Scheme = "tcp" + } else { + switch e.Port { + case "443", "8443": + e.Scheme = "https" + default: + e.Scheme = "http" + } + } + } + if e.Path == "" { + e.Path = "/" + } +} diff --git a/src/models/proxy_provider.go b/src/models/proxy_provider.go new file mode 100644 index 0000000..4149356 --- /dev/null +++ b/src/models/proxy_provider.go @@ -0,0 +1,9 @@ +package model + +type ( + ProxyProvider struct { + Kind string `json:"kind"` // docker, file + Value string `json:"value"` + } + ProxyProviders = map[string]ProxyProvider +) diff --git a/src/proxy/constants.go b/src/proxy/constants.go new file mode 100644 index 0000000..8acaaa8 --- /dev/null +++ b/src/proxy/constants.go @@ -0,0 +1,22 @@ +package proxy + +var ( + PathMode_Forward = "forward" + PathMode_RemovedPath = "" +) + +const ( + StreamType_UDP string = "udp" + StreamType_TCP string = "tcp" + // StreamType_UDP_TCP Scheme = "udp-tcp" + // StreamType_TCP_UDP Scheme = "tcp-udp" + // StreamType_TLS Scheme = "tls" +) + +var ( + // TODO: support "tcp-udp", "udp-tcp", etc. + StreamSchemes = []string{StreamType_TCP, StreamType_UDP} + HTTPSchemes = []string{"http", "https"} + ValidSchemes = append(StreamSchemes, HTTPSchemes...) +) + diff --git a/src/proxy/entry.go b/src/proxy/entry.go new file mode 100644 index 0000000..df3f484 --- /dev/null +++ b/src/proxy/entry.go @@ -0,0 +1,94 @@ +package proxy + +import ( + "net/http" + "net/url" + "strconv" + + E "github.com/yusing/go-proxy/error" + M "github.com/yusing/go-proxy/models" + T "github.com/yusing/go-proxy/proxy/fields" +) + +type ( + Entry struct { // real model after validation + Alias T.Alias + Scheme T.Scheme + Host T.Host + Port T.Port + URL *url.URL + NoTLSVerify bool + Path T.Path + SetHeaders http.Header + HideHeaders []string + } + StreamEntry struct { + Alias T.Alias `json:"alias"` + Scheme T.StreamScheme `json:"scheme"` + Host T.Host `json:"host"` + Port T.StreamPort `json:"port"` + } +) + +func NewEntry(m *M.ProxyEntry) (any, E.NestedError) { + m.SetDefaults() + scheme, err := T.NewScheme(m.Scheme) + if err.IsNotNil() { + return nil, err + } + if scheme.IsStream() { + return validateStreamEntry(m) + } + return validateEntry(m, *scheme) +} + +func validateEntry(m *M.ProxyEntry, s T.Scheme) (*Entry, E.NestedError) { + host, err := T.NewHost(m.Host) + if err.IsNotNil() { + return nil, err + } + port, err := T.NewPort(m.Port) + if err.IsNotNil() { + return nil, err + } + path, err := T.NewPath(m.Path) + if err.IsNotNil() { + return nil, err + } + url, err := E.Check(url.Parse(s.String() + "://" + host.String() + ":" + strconv.Itoa(int(port)))) + if err.IsNotNil() { + return nil, err + } + return &Entry{ + Alias: T.NewAlias(m.Alias), + Scheme: s, + Host: host, + Port: port, + URL: url, + NoTLSVerify: m.NoTLSVerify, + Path: path, + SetHeaders: m.SetHeaders, + HideHeaders: m.HideHeaders, + }, E.Nil() +} + +func validateStreamEntry(m *M.ProxyEntry) (*StreamEntry, E.NestedError) { + host, err := T.NewHost(m.Host) + if err.IsNotNil() { + return nil, err + } + port, err := T.NewStreamPort(m.Port) + if err.IsNotNil() { + return nil, err + } + scheme, err := T.NewStreamScheme(m.Scheme) + if err.IsNotNil() { + return nil, err + } + return &StreamEntry{ + Alias: T.NewAlias(m.Alias), + Scheme: *scheme, + Host: host, + Port: port, + }, E.Nil() +} diff --git a/src/proxy/fields/alias.go b/src/proxy/fields/alias.go new file mode 100644 index 0000000..d91beff --- /dev/null +++ b/src/proxy/fields/alias.go @@ -0,0 +1,23 @@ +package fields + +import ( + "strings" + + F "github.com/yusing/go-proxy/utils/functional" +) + +type Alias struct{ F.Stringable } +type Aliases struct{ *F.Slice[Alias] } + +func NewAlias(s string) Alias { + return Alias{F.NewStringable(s)} +} + +func NewAliases(s string) Aliases { + split := strings.Split(s, ",") + a := Aliases{F.NewSliceN[Alias](len(split))} + for i, v := range split { + a.Set(i, NewAlias(v)) + } + return a +} diff --git a/src/proxy/fields/host.go b/src/proxy/fields/host.go new file mode 100644 index 0000000..38bb325 --- /dev/null +++ b/src/proxy/fields/host.go @@ -0,0 +1,20 @@ +package fields + +import ( + E "github.com/yusing/go-proxy/error" + F "github.com/yusing/go-proxy/utils/functional" +) + +type Host struct{ F.Stringable } +type Subdomain = Alias + +func NewHost(s string) (Host, E.NestedError) { + return Host{F.NewStringable(s)}, E.Nil() +} + +func (h Host) Subdomain() (*Subdomain, E.NestedError) { + if i := h.IndexRune(':'); i != -1 { + return &Subdomain{h.SubStr(0, i)}, E.Nil() + } + return nil, E.Invalid("host", h) +} diff --git a/src/proxy/fields/path.go b/src/proxy/fields/path.go new file mode 100644 index 0000000..1397898 --- /dev/null +++ b/src/proxy/fields/path.go @@ -0,0 +1,15 @@ +package fields + +import ( + E "github.com/yusing/go-proxy/error" + F "github.com/yusing/go-proxy/utils/functional" +) + +type Path struct{ F.Stringable } + +func NewPath(s string) (Path, E.NestedError) { + if s == "" || s[0] == '/' { + return Path{F.NewStringable(s)}, E.Nil() + } + return Path{}, E.Invalid("path", s).Extra("must be empty or start with '/'") +} diff --git a/src/proxy/fields/path_mode.go b/src/proxy/fields/path_mode.go new file mode 100644 index 0000000..7a72754 --- /dev/null +++ b/src/proxy/fields/path_mode.go @@ -0,0 +1,25 @@ +package fields + +import ( + F "github.com/yusing/go-proxy/utils/functional" + E "github.com/yusing/go-proxy/error" +) + +type PathMode struct{ F.Stringable } + +func NewPathMode(pm string) (PathMode, E.NestedError) { + switch pm { + case "", "forward": + return PathMode{F.NewStringable(pm)}, E.Nil() + default: + return PathMode{}, E.Invalid("path mode", pm) + } +} + +func (p PathMode) IsRemove() bool { + return p.String() == "" +} + +func (p PathMode) IsForward() bool { + return p.String() == "forward" +} diff --git a/src/proxy/fields/port.go b/src/proxy/fields/port.go new file mode 100644 index 0000000..af1808e --- /dev/null +++ b/src/proxy/fields/port.go @@ -0,0 +1,40 @@ +package fields + +import ( + "strconv" + + E "github.com/yusing/go-proxy/error" +) + +type Port int + +func NewPort(v string) (Port, E.NestedError) { + p, err := strconv.Atoi(v) + if err != nil { + return ErrPort, E.From(err) + } + return NewPortInt(p) +} + +func NewPortInt(v int) (Port, E.NestedError) { + pp := Port(v) + if err := pp.boundCheck(); err.IsNotNil() { + return ErrPort, err + } + return pp, E.Nil() +} + +func (p Port) boundCheck() E.NestedError { + if p < MinPort || p > MaxPort { + return E.Invalid("port", p) + } + return E.Nil() +} + +const ( + MinPort = 0 + MaxPort = 65535 + ErrPort = Port(-1) + NoPort = Port(-1) + ZeroPort = Port(0) +) diff --git a/src/proxy/fields/scheme.go b/src/proxy/fields/scheme.go new file mode 100644 index 0000000..d2e667c --- /dev/null +++ b/src/proxy/fields/scheme.go @@ -0,0 +1,37 @@ +package fields + +import ( + "strings" + + E "github.com/yusing/go-proxy/error" + F "github.com/yusing/go-proxy/utils/functional" +) + +type Scheme struct{ F.Stringable } + +func NewScheme(s string) (*Scheme, E.NestedError) { + switch s { + case "http", "https", "tcp", "udp": + return &Scheme{F.NewStringable(s)}, E.Nil() + } + return nil, E.Invalid("scheme", s) +} + +func NewSchemeFromPort(p string) (*Scheme, E.NestedError) { + var s string + switch { + case strings.ContainsRune(p, ':'): + s = "tcp" + case strings.HasSuffix(p, "443"): + s = "https" + default: + s = "http" + } + return &Scheme{F.NewStringable(s)}, E.Nil() +} + +func (s Scheme) IsHTTP() bool { return s.String() == "http" } +func (s Scheme) IsHTTPS() bool { return s.String() == "https" } +func (s Scheme) IsTCP() bool { return s.String() == "tcp" } +func (s Scheme) IsUDP() bool { return s.String() == "udp" } +func (s Scheme) IsStream() bool { return s.IsTCP() || s.IsUDP() } diff --git a/src/proxy/fields/stream_port.go b/src/proxy/fields/stream_port.go new file mode 100644 index 0000000..82f785d --- /dev/null +++ b/src/proxy/fields/stream_port.go @@ -0,0 +1,49 @@ +package fields + +import ( + "strings" + + "github.com/yusing/go-proxy/common" + E "github.com/yusing/go-proxy/error" +) + +type StreamPort struct { + ListeningPort Port `json:"listening"` + ProxyPort Port `json:"proxy"` +} + +func NewStreamPort(p string) (StreamPort, E.NestedError) { + split := strings.Split(p, ":") + if len(split) != 2 { + return StreamPort{}, E.Invalid("stream port", p).Extra("should be in 'x:y' format") + } + + listeningPort, err := NewPort(split[0]) + if err.IsNotNil() { + return StreamPort{}, err + } + if err = listeningPort.boundCheck(); err.IsNotNil() { + return StreamPort{}, err + } + + proxyPort, err := NewPort(split[1]) + if err.IsNotNil() { + proxyPort, err = parseNameToPort(split[1]) + if err.IsNotNil() { + return StreamPort{}, err + } + } + if err = proxyPort.boundCheck(); err.IsNotNil() { + return StreamPort{}, err + } + + return StreamPort{ListeningPort: listeningPort, ProxyPort: proxyPort}, E.Nil() +} + +func parseNameToPort(name string) (Port, E.NestedError) { + port, ok := common.NamePortMapTCP[name] + if !ok { + return -1, E.Unsupported("service", name) + } + return Port(port), E.Nil() +} diff --git a/src/proxy/fields/stream_scheme.go b/src/proxy/fields/stream_scheme.go new file mode 100644 index 0000000..0ecfe2a --- /dev/null +++ b/src/proxy/fields/stream_scheme.go @@ -0,0 +1,42 @@ +package fields + +import ( + "strings" + + E "github.com/yusing/go-proxy/error" +) + +type StreamScheme struct { + ListeningScheme *Scheme `json:"listening"` + ProxyScheme *Scheme `json:"proxy"` +} + +func NewStreamScheme(s string) (ss *StreamScheme, err E.NestedError) { + ss = &StreamScheme{} + parts := strings.Split(s, ":") + if len(parts) == 1 { + parts = []string{s, s} + } else if len(parts) != 2 { + return nil, E.Invalid("stream scheme", s) + } + ss.ListeningScheme, err = NewScheme(parts[0]) + if err.IsNotNil() { + return nil, err + } + ss.ProxyScheme, err = NewScheme(parts[1]) + if err.IsNotNil() { + return nil, err + } + return ss, E.Nil() +} + +func (s StreamScheme) String() string { + return s.ListeningScheme.String() + " -> " + s.ProxyScheme.String() +} + +// IsCoherent checks if the ListeningScheme and ProxyScheme of the StreamScheme are equal. +// +// It returns a boolean value indicating whether the ListeningScheme and ProxyScheme are equal. +func (s StreamScheme) IsCoherent() bool { + return *s.ListeningScheme == *s.ProxyScheme +} diff --git a/src/proxy/provider/constants.go b/src/proxy/provider/constants.go new file mode 100644 index 0000000..2aade0d --- /dev/null +++ b/src/proxy/provider/constants.go @@ -0,0 +1,3 @@ +package provider + +const wildcardAlias = "*" diff --git a/src/proxy/provider/docker_provider.go b/src/proxy/provider/docker_provider.go new file mode 100755 index 0000000..861fbea --- /dev/null +++ b/src/proxy/provider/docker_provider.go @@ -0,0 +1,149 @@ +package provider + +import ( + "fmt" + "strings" + + "github.com/docker/docker/api/types" + D "github.com/yusing/go-proxy/docker" + E "github.com/yusing/go-proxy/error" + M "github.com/yusing/go-proxy/models" + PT "github.com/yusing/go-proxy/proxy/fields" + W "github.com/yusing/go-proxy/watcher" +) + +type DockerProvider struct { + dockerHost string +} + +func DockerProviderImpl(model *M.ProxyProvider) ProviderImpl { + return &DockerProvider{dockerHost: model.Value} +} + +// GetProxyEntries returns proxy entries from a docker client. +// +// It retrieves the docker client information using the dockerhelper.GetClientInfo method. +// Then, it iterates over the containers in the docker client information and calls +// the getEntriesFromLabels method to get the proxy entries for each container. +// Any errors encountered during the process are added to the ne error object. +// Finally, it returns the collected proxy entries and the ne error object. +// +// Parameters: +// - p: A pointer to the DockerProvider struct. +// +// Returns: +// - P.EntryModelSlice: A slice of EntryModel structs representing the proxy entries. +// - error: An error object if there was an error retrieving the docker client information or parsing the labels. +func (p DockerProvider) GetProxyEntries() (M.ProxyEntries, E.NestedError) { + info, err := D.GetClientInfo(p.dockerHost) + if err.IsNotNil() { + return nil, E.From(err) + } + + entries := M.NewProxyEntries() + errors := E.NewBuilder("errors when parse docker labels for %q", p.dockerHost) + + for _, container := range info.Containers { + en, err := p.getEntriesFromLabels(&container, info.Host) + if err.IsNotNil() { + errors.Add(err) + } + // although err is not nil + // there may be some valid entries in `en` + dups := entries.MergeWith(en) + // add the duplicate proxy entries to the error + dups.EachKV(func(k string, v *M.ProxyEntry) { + errors.Addf("duplicate alias %s", k) + }) + } + + return entries, errors.Build() +} + +func (p *DockerProvider) NewWatcher() W.Watcher { + return W.NewDockerWatcher(p.dockerHost) +} + +// Returns a list of proxy entries for a container. +// Always non-nil +func (p *DockerProvider) getEntriesFromLabels(container *types.Container, clientHost string) (M.ProxyEntries, E.NestedError) { + var mainAlias string + var aliases PT.Aliases + + // set mainAlias to docker compose service name if available + if serviceName, ok := container.Labels["com.docker.compose.service"]; ok { + mainAlias = serviceName + } + + // if mainAlias is not set, + // or container name is different from service name + // use container name + if containerName := strings.TrimPrefix(container.Names[0], "/"); containerName != mainAlias { + mainAlias = containerName + } + + if l, ok := container.Labels["proxy.aliases"]; ok { + aliases = PT.NewAliases(l) + delete(container.Labels, "proxy.aliases") + } else { + aliases = PT.NewAliases(mainAlias) + } + + entries := M.NewProxyEntries() + + // find first port, return if no port exposed + defaultPort := findFirstPort(container) + if defaultPort == PT.NoPort { + return entries, E.Nil() + } + + // init entries map for all aliases + aliases.ForEach(func(a PT.Alias) { + entries.Set(a.String(), &M.ProxyEntry{ + Alias: a.String(), + Host: clientHost, + Port: fmt.Sprint(defaultPort), + }) + }) + + errors := E.NewBuilder("failed to apply label for %q", mainAlias) + for key, val := range container.Labels { + lbl, err := D.ParseLabel(key, val) + if err.IsNotNil() { + errors.Add(E.From(err).Subject(key)) + continue + } + if lbl.Namespace != D.NSProxy { + continue + } + if lbl.Target == wildcardAlias { + // apply label for all aliases + entries.EachKV(func(a string, e *M.ProxyEntry) { + if err = D.ApplyLabel(e, lbl); err.IsNotNil() { + errors.Add(E.From(err).Subject(lbl.Target)) + } + }) + } else { + config, ok := entries.UnsafeGet(lbl.Target) + if !ok { + errors.Add(E.NotExists("alias", lbl.Target)) + continue + } + if err = D.ApplyLabel(config, lbl); err.IsNotNil() { + errors.Add(err.Subject(lbl.Target)) + } + } + } + + return entries, errors.Build() +} + +func findFirstPort(c *types.Container) (pp PT.Port) { + for _, p := range c.Ports { + if p.PublicPort != 0 || c.HostConfig.NetworkMode == "host" { + pp, _ = PT.NewPortInt(int(p.PublicPort)) + return + } + } + return PT.NoPort +} diff --git a/src/proxy/provider/file_provider.go b/src/proxy/provider/file_provider.go new file mode 100644 index 0000000..f0bfc94 --- /dev/null +++ b/src/proxy/provider/file_provider.go @@ -0,0 +1,50 @@ +package provider + +import ( + "os" + "path" + + "github.com/yusing/go-proxy/common" + E "github.com/yusing/go-proxy/error" + M "github.com/yusing/go-proxy/models" + U "github.com/yusing/go-proxy/utils" + W "github.com/yusing/go-proxy/watcher" +) + +type FileProvider struct { + fileName string + path string +} + +func FileProviderImpl(m *M.ProxyProvider) ProviderImpl { + return &FileProvider{ + fileName: m.Value, + path: path.Join(common.ConfigBasePath, m.Value), + } +} + +func Validate(data []byte) E.NestedError { + return U.ValidateYaml(U.GetSchema(common.ProvidersSchemaPath), data) +} + +func (p *FileProvider) GetProxyEntries() (M.ProxyEntries, E.NestedError) { + entries := M.NewProxyEntries() + data, err := E.Check(os.ReadFile(p.path)) + if err.IsNotNil() { + return entries, E.Failure("read file").Subject(p.fileName).With(err) + } + ne := E.Failure("validation").Subject(p.fileName) + if !common.NoSchemaValidation { + if err = Validate(data); err.IsNotNil() { + return entries, ne.With(err) + } + } + if err = entries.UnmarshalFromYAML(data); err.IsNotNil() { + return entries, ne.With(err) + } + return entries, E.Nil() +} + +func (p *FileProvider) NewWatcher() W.Watcher { + return W.NewFileWatcher(p.fileName) +} diff --git a/src/proxy/provider/provider.go b/src/proxy/provider/provider.go new file mode 100644 index 0000000..d87efd8 --- /dev/null +++ b/src/proxy/provider/provider.go @@ -0,0 +1,165 @@ +package provider + +import ( + "context" + + "github.com/sirupsen/logrus" + "github.com/yusing/go-proxy/common" + E "github.com/yusing/go-proxy/error" + M "github.com/yusing/go-proxy/models" + R "github.com/yusing/go-proxy/route" + W "github.com/yusing/go-proxy/watcher" +) + +type ProviderImpl interface { + GetProxyEntries() (M.ProxyEntries, E.NestedError) + NewWatcher() W.Watcher +} + +type Provider struct { + ProviderImpl + + name string + routes *R.Routes + reloadReqCh chan struct{} + + watcher W.Watcher + watcherCtx context.Context + watcherCancel context.CancelFunc + + l *logrus.Entry +} + +func NewProvider(name string, model M.ProxyProvider) (p *Provider) { + p = &Provider{ + name: name, + routes: R.NewRoutes(), + reloadReqCh: make(chan struct{}, 1), + l: logrus.WithField("provider", name), + } + switch model.Kind { + case common.ProviderKind_Docker: + p.ProviderImpl = DockerProviderImpl(&model) + case common.ProviderKind_File: + p.ProviderImpl = FileProviderImpl(&model) + } + p.watcher = p.NewWatcher() + return +} + +func (p *Provider) GetName() string { + return p.name +} + +func (p *Provider) StartAllRoutes() E.NestedError { + err := p.loadRoutes() + + // start watcher no matter load success or not + p.watcherCtx, p.watcherCancel = context.WithCancel(context.Background()) + go p.watchEvents() + + if err.IsNotNil() { + return err + } + errors := E.NewBuilder("errors starting routes for provider %q", p.name) + nStarted := 0 + p.routes.EachKVParallel(func(alias string, r R.Route) { + if err := r.Start(); err.IsNotNil() { + errors.Add(err.Subject(alias)) + } else { + nStarted++ + } + }) + if err := errors.Build(); err.IsNotNil() { + return err + } + p.l.Infof("%d routes started", nStarted) + return E.Nil() +} + +func (p *Provider) StopAllRoutes() E.NestedError { + defer p.routes.Clear() + + if p.watcherCancel != nil { + p.watcherCancel() + } + errors := E.NewBuilder("errors stopping routes for provider %q", p.name) + nStopped := 0 + p.routes.EachKVParallel(func(alias string, r R.Route) { + if err := r.Stop(); err.IsNotNil() { + errors.Add(err.Subject(alias)) + } else { + nStopped++ + } + }) + if err := errors.Build(); err.IsNotNil() { + return err + } + p.l.Infof("%d routes stopped", nStopped) + return E.Nil() +} + +func (p *Provider) ReloadRoutes() { + defer p.l.Info("routes reloaded") + + select { + case p.reloadReqCh <- struct{}{}: + defer func() { + <-p.reloadReqCh + }() + p.StopAllRoutes() + p.loadRoutes() + p.StartAllRoutes() + default: + return + } +} + +func (p *Provider) GetCurrentRoutes() *R.Routes { + return p.routes +} + +func (p *Provider) watchEvents() { + events, errs := p.watcher.Events(p.watcherCtx) + l := logrus.WithField("?", "watcher") + + for { + select { + case <-p.reloadReqCh: + p.ReloadRoutes() + case event, ok := <-events: + if !ok { + return + } + l.Infof("watcher event: %v", event) + p.reloadReqCh <- struct{}{} + case err, ok := <-errs: + if !ok { + return + } + l.Errorf("watcher error: %s", err) + } + } +} + +func (p *Provider) loadRoutes() E.NestedError { + entries, err := p.GetProxyEntries() + + if err.IsNotNil() { + p.l.Warn(err.Subjectf("provider %s", p.name)) + } + p.routes = R.NewRoutes() + + errors := E.NewBuilder("errors loading routes from provider %q", p.name) + entries.EachKV(func(a string, e *M.ProxyEntry) { + r, err := R.NewRoute(e) + if err.IsNotNil() { + errors.Addf("%s: %w", a, err) + p.l.Debugf("failed to load route: %s, %s", a, err) + } else { + p.routes.Set(a, r) + } + }) + p.l.Debugf("loaded %d routes from %d entries", p.routes.Size(), entries.Size()) + return errors.Build() +} diff --git a/src/go-proxy/reverse_proxy_mod.go b/src/proxy/reverse_proxy_mod.go similarity index 62% rename from src/go-proxy/reverse_proxy_mod.go rename to src/proxy/reverse_proxy_mod.go index fab8a20..09d72c2 100644 --- a/src/go-proxy/reverse_proxy_mod.go +++ b/src/proxy/reverse_proxy_mod.go @@ -1,4 +1,4 @@ -package main +package proxy // A small mod on net/http/httputil/reverseproxy.go // that doubled the performance @@ -15,6 +15,7 @@ import ( "net/url" "strings" + "github.com/sirupsen/logrus" "golang.org/x/net/http/httpguts" ) @@ -31,23 +32,6 @@ type ProxyRequest struct { Out *http.Request } -// SetURL routes the outbound request 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". -// -// SetURL rewrites the outbound Host header to match the target's host. -// To preserve the inbound request's Host header (the default behavior -// of [NewReverseProxy]): -// -// rewriteFunc := func(r *httputil.ProxyRequest) { -// r.SetURL(url) -// r.Out.Host = r.In.Host -// } -// func (r *ProxyRequest) SetURL(target *url.URL) { -// rewriteRequestURL(r.Out, target) -// r.Out.Host = "" -// } - // SetXForwarded sets the X-Forwarded-For, X-Forwarded-Host, and // X-Forwarded-Proto headers of the outbound request. // @@ -221,13 +205,15 @@ func joinURLPath(a, b *url.URL) (path, rawpath string) { // r.Out.Host = r.In.Host // if desired // }, // } -func NewReverseProxy(target *url.URL, transport *http.Transport, config *ProxyConfig) *ReverseProxy { +// +// TODO: headers in ModifyResponse +func NewReverseProxy(target *url.URL, transport *http.Transport, entry *Entry) *ReverseProxy { // check on init rather than on request var setHeaders = func(r *http.Request) {} var hideHeaders = func(r *http.Request) {} - if len(config.SetHeaders) > 0 { + if len(entry.SetHeaders) > 0 { setHeaders = func(r *http.Request) { - h := config.SetHeaders.Clone() + h := entry.SetHeaders.Clone() for k, vv := range h { if k == "Host" { r.Host = vv[0] @@ -237,9 +223,9 @@ func NewReverseProxy(target *url.URL, transport *http.Transport, config *ProxyCo } } } - if len(config.HideHeaders) > 0 { + if len(entry.HideHeaders) > 0 { hideHeaders = func(r *http.Request) { - for _, k := range config.HideHeaders { + for _, k := range entry.HideHeaders { r.Header.Del(k) } } @@ -272,27 +258,8 @@ func copyHeader(dst, src http.Header) { } } -// Hop-by-hop headers. These are removed when sent to the backend. -// As of RFC 7230, hop-by-hop headers are required to appear in the -// Connection header field. These are the headers defined by the -// obsoleted RFC 2616 (section 13.5.1) and are used for backward -// compatibility. -// var hopHeaders = []string{ -// "Connection", -// "Proxy-Connection", // non-standard but still sent by libcurl and rejected by e.g. google -// "Keep-Alive", -// "Proxy-Authenticate", -// "Proxy-Authorization", -// "Te", // canonicalized version of "TE" -// "Trailer", // not Trailers per URL above; https://www.rfc-editor.org/errata_search.php?eid=4522 -// "Transfer-Encoding", -// "Upgrade", -// } - -// NOTE: getErrorHandler and DefaultErrorHandler removed - func (p *ReverseProxy) errorHandler(rw http.ResponseWriter, _ *http.Request, err error) { - p.logf("http: proxy error: %v", err) + logger.Errorf("http: proxy error: %s", err) rw.WriteHeader(http.StatusBadGateway) } @@ -312,10 +279,6 @@ func (p *ReverseProxy) modifyResponse(rw http.ResponseWriter, res *http.Response func (p *ReverseProxy) ServeHTTP(rw http.ResponseWriter, req *http.Request) { transport := p.Transport - // Note: removed - // if transport == nil { - // transport = http.DefaultTransport - // } ctx := req.Context() if ctx.Done() != nil { @@ -360,18 +323,6 @@ func (p *ReverseProxy) ServeHTTP(rw http.ResponseWriter, req *http.Request) { outreq.Header = make(http.Header) // Issue 33142: historical behavior was to always allocate } - // NOTE: removed - // if (p.Director != nil) == (p.Rewrite != nil) { - // p.errorHandler(rw, req, errors.New("ReverseProxy must have exactly one of Director or Rewrite set")) - // return - // } - - // if p.Director != nil { - // p.Director(outreq) - // if outreq.Form != nil { - // outreq.URL.RawQuery = cleanQueryParams(outreq.URL.RawQuery) - // } - // } outreq.Close = false reqUpType := upgradeType(outreq.Header) @@ -379,8 +330,6 @@ func (p *ReverseProxy) ServeHTTP(rw http.ResponseWriter, req *http.Request) { p.errorHandler(rw, req, fmt.Errorf("client tried to switch to invalid protocol %q", reqUpType)) return } - // NOTE: removed - // removeHopByHopHeaders(outreq.Header) // Issue 21096: tell backend applications that care about trailer support // that we support trailers. (We do, but we don't go out of our way to @@ -398,41 +347,17 @@ func (p *ReverseProxy) ServeHTTP(rw http.ResponseWriter, req *http.Request) { outreq.Header.Set("Upgrade", reqUpType) } - // NOTE: removed - // if p.Rewrite != nil { - // Strip client-provided forwarding headers. - // The Rewrite func may use SetXForwarded to set new values - // for these or copy the previous values from the inbound request. outreq.Header.Del("Forwarded") // outreq.Header.Del("X-Forwarded-For") // outreq.Header.Del("X-Forwarded-Host") // outreq.Header.Del("X-Forwarded-Proto") - // NOTE: removed - // Remove unparsable query parameters from the outbound request. - // outreq.URL.RawQuery = cleanQueryParams(outreq.URL.RawQuery) pr := &ProxyRequest{ In: req, Out: outreq, } p.Rewrite(pr) outreq = pr.Out - // NOTE: removed - // } else { - // if clientIP, _, err := net.SplitHostPort(req.RemoteAddr); err == nil { - // // If we aren't the first proxy retain prior - // // X-Forwarded-For information as a comma+space - // // separated list and fold multiple headers into one. - // prior, ok := outreq.Header["X-Forwarded-For"] - // omit := ok && prior == nil // Issue 38079: nil now means don't populate the header - // if len(prior) > 0 { - // clientIP = strings.Join(prior, ", ") + ", " + clientIP - // } - // if !omit { - // outreq.Header.Set("X-Forwarded-For", clientIP) - // } - // } - // } if _, ok := outreq.Header["User-Agent"]; !ok { // If the outbound request doesn't have a User-Agent header set, @@ -473,9 +398,6 @@ func (p *ReverseProxy) ServeHTTP(rw http.ResponseWriter, req *http.Request) { return } - // NOTE: removed - // removeHopByHopHeaders(res.Header) - if !p.modifyResponse(rw, res, outreq) { return } @@ -495,8 +417,6 @@ func (p *ReverseProxy) ServeHTTP(rw http.ResponseWriter, req *http.Request) { rw.WriteHeader(res.StatusCode) - // NOTE: changing this line extremely improve throughput - // err = p.copyResponse(rw, res.Body, p.flushInterval(res)) _, err = io.Copy(rw, res.Body) if err != nil { defer res.Body.Close() @@ -505,7 +425,7 @@ func (p *ReverseProxy) ServeHTTP(rw http.ResponseWriter, req *http.Request) { // is abort the request. Issue 23643: ReverseProxy should use ErrAbortHandler // on read error while copying body. // if !shouldPanicOnCopyError(req) { - // p.logf("suppressing panic for copyResponse error in test; copy error: %v", err) + // p.logf("suppressing panic for copyResponse error in test; copy error: %s", err) // return // } panic(http.ErrAbortHandler) @@ -532,190 +452,6 @@ func (p *ReverseProxy) ServeHTTP(rw http.ResponseWriter, req *http.Request) { } } -// var inOurTests bool // whether we're in our own tests - -// NOTE: removed -// shouldPanicOnCopyError reports whether the reverse proxy should -// panic with http.ErrAbortHandler. This is the right thing to do by -// default, but Go 1.10 and earlier did not, so existing unit tests -// weren't expecting panics. Only panic in our own tests, or when -// running under the HTTP server. -// func shouldPanicOnCopyError(req *http.Request) bool { -// if inOurTests { -// // Our tests know to handle this panic. -// return true -// } -// if req.Context().Value(http.ServerContextKey) != nil { -// // We seem to be running under an HTTP server, so -// // it'll recover the panic. -// return true -// } -// // Otherwise act like Go 1.10 and earlier to not break -// // existing tests. -// return false -// } - -// removeHopByHopHeaders removes hop-by-hop headers. -// -// func removeHopByHopHeaders(h http.Header) { -// // RFC 7230, section 6.1: Remove headers listed in the "Connection" header. -// for _, f := range h["Connection"] { -// for _, sf := range strings.Split(f, ",") { -// if sf = textproto.TrimString(sf); sf != "" { -// h.Del(sf) -// } -// } -// } -// // RFC 2616, section 13.5.1: Remove a set of known hop-by-hop headers. -// // This behavior is superseded by the RFC 7230 Connection header, but -// // preserve it for backwards compatibility. -// for _, f := range hopHeaders { -// h.Del(f) -// } -// } - -// NOTE: removed -// flushInterval returns the p.FlushInterval value, conditionally -// overriding its value for a specific request/response. -// func (p *ReverseProxy) flushInterval(res *http.Response) time.Duration { -// resCT := res.Header.Get("Content-Type") - -// // For Server-Sent Events responses, flush immediately. -// // The MIME type is defined in https://www.w3.org/TR/eventsource/#text-event-stream -// if baseCT, _, _ := mime.ParseMediaType(resCT); baseCT == "text/event-stream" { -// return -1 // negative means immediately -// } - -// // We might have the case of streaming for which Content-Length might be unset. -// if res.ContentLength == -1 { -// return -1 -// } - -// return p.FlushInterval -// } - -// NOTE: removed -// func (p *ReverseProxy) copyResponse(dst http.ResponseWriter, src io.Reader, flushInterval time.Duration) error { -// var w io.Writer = dst - -// if flushInterval != 0 { -// mlw := &maxLatencyWriter{ -// dst: dst, -// flush: http.NewResponseController(dst).Flush, -// latency: flushInterval, -// } -// defer mlw.stop() - -// // set up initial timer so headers get flushed even if body writes are delayed -// mlw.flushPending = true -// mlw.t = time.AfterFunc(flushInterval, mlw.delayedFlush) - -// w = mlw -// } - -// var buf []byte -// if p.BufferPool != nil { -// buf = p.BufferPool.Get() -// defer p.BufferPool.Put(buf) -// } -// _, err := p.copyBuffer(w, src, buf) -// return err -// } - -// copyBuffer returns any write errors or non-EOF read errors, and the amount -// of bytes written. - -// NOTE: removed -// func (p *ReverseProxy) copyBuffer(dst io.Writer, src io.Reader, buf []byte) (int64, error) { -// if len(buf) == 0 { -// buf = make([]byte, 32*1024) -// } -// var written int64 -// for { -// nr, rerr := src.Read(buf) -// if rerr != nil && rerr != io.EOF && rerr != context.Canceled { -// p.logf("httputil: ReverseProxy read error during body copy: %v", rerr) -// } -// if nr > 0 { -// nw, werr := dst.Write(buf[:nr]) -// if nw > 0 { -// written += int64(nw) -// } -// if werr != nil { -// return written, werr -// } -// if nr != nw { -// return written, io.ErrShortWrite -// } -// } -// if rerr != nil { -// if rerr == io.EOF { -// rerr = nil -// } -// return written, rerr -// } -// } -// } - -func (p *ReverseProxy) logf(format string, args ...any) { - // if p.ErrorLog != nil { - // p.ErrorLog.Printf(format, args...) - // } else { - hrlog.Errorf(format, args...) - // } -} - -// NOTE: removed -// type maxLatencyWriter struct { -// dst io.Writer -// flush func() error -// latency time.Duration // non-zero; negative means to flush immediately - -// mu sync.Mutex // protects t, flushPending, and dst.Flush -// t *time.Timer -// flushPending bool -// } - -// NOTE: removed -// func (m *maxLatencyWriter) Write(p []byte) (n int, err error) { -// m.mu.Lock() -// defer m.mu.Unlock() -// n, err = m.dst.Write(p) -// if m.latency < 0 { -// m.flush() -// return -// } -// if m.flushPending { -// return -// } -// if m.t == nil { -// m.t = time.AfterFunc(m.latency, m.delayedFlush) -// } else { -// m.t.Reset(m.latency) -// } -// m.flushPending = true -// return -// } - -// func (m *maxLatencyWriter) delayedFlush() { -// m.mu.Lock() -// defer m.mu.Unlock() -// if !m.flushPending { // if stop was called but AfterFunc already started this goroutine -// return -// } -// m.flush() -// m.flushPending = false -// } - -// func (m *maxLatencyWriter) stop() { -// m.mu.Lock() -// defer m.mu.Unlock() -// m.flushPending = false -// if m.t != nil { -// m.t.Stop() -// } -// } - func upgradeType(h http.Header) string { if !httpguts.HeaderValuesContainsToken(h["Connection"], "Upgrade") { return "" @@ -760,7 +496,7 @@ func (p *ReverseProxy) handleUpgradeResponse(rw http.ResponseWriter, req *http.R defer close(backConnCloseCh) if hijackErr != nil { - p.errorHandler(rw, req, fmt.Errorf("hijack failed on protocol switch: %v", hijackErr)) + p.errorHandler(rw, req, fmt.Errorf("hijack failed on protocol switch: %w", hijackErr)) return } defer conn.Close() @@ -770,18 +506,15 @@ func (p *ReverseProxy) handleUpgradeResponse(rw http.ResponseWriter, req *http.R res.Header = rw.Header() res.Body = nil // so res.Write only writes the headers; we have res.Body in backConn above if err := res.Write(brw); err != nil { - p.errorHandler(rw, req, fmt.Errorf("response write: %v", err)) + p.errorHandler(rw, req, fmt.Errorf("response write: %s", err)) return } if err := brw.Flush(); err != nil { - p.errorHandler(rw, req, fmt.Errorf("response flush: %v", err)) + p.errorHandler(rw, req, fmt.Errorf("response flush: %s", err)) return } errc := make(chan error, 1) - // NOTE: removed - // spc := switchProtocolCopier{user: conn, backend: backConn} - // go spc.copyToBackend(errc) - // go spc.copyFromBackend(errc) + go func() { _, err := io.Copy(conn, backConn) errc <- err @@ -793,57 +526,6 @@ func (p *ReverseProxy) handleUpgradeResponse(rw http.ResponseWriter, req *http.R <-errc } -// NOTE: removed -// switchProtocolCopier exists so goroutines proxying data back and -// forth have nice names in stacks. -// type switchProtocolCopier struct { -// user, backend io.ReadWriter -// } - -// func (c switchProtocolCopier) copyFromBackend(errc chan<- error) { -// _, err := io.Copy(c.user, c.backend) -// errc <- err -// } - -// func (c switchProtocolCopier) copyToBackend(errc chan<- error) { -// _, err := io.Copy(c.backend, c.user) -// errc <- err -// } - -// NOTE: removed -// func cleanQueryParams(s string) string { -// reencode := func(s string) string { -// v, _ := url.ParseQuery(s) -// return v.Encode() -// } -// for i := 0; i < len(s); { -// switch s[i] { -// case ';': -// return reencode(s) -// case '%': -// if i+2 >= len(s) || !ishex(s[i+1]) || !ishex(s[i+2]) { -// return reencode(s) -// } -// i += 3 -// default: -// i++ -// } -// } -// return s -// } - -// func ishex(c byte) bool { -// switch { -// case '0' <= c && c <= '9': -// return true -// case 'a' <= c && c <= 'f': -// return true -// case 'A' <= c && c <= 'F': -// return true -// } -// return false -// } - func IsPrint(s string) bool { for i := 0; i < len(s); i++ { if s[i] < ' ' || s[i] > '~' { @@ -852,3 +534,5 @@ func IsPrint(s string) bool { } return true } + +var logger = logrus.WithField("?", "http") diff --git a/src/proxy/reverse_proxy_mod_test.go b/src/proxy/reverse_proxy_mod_test.go new file mode 100644 index 0000000..9ac0a0f --- /dev/null +++ b/src/proxy/reverse_proxy_mod_test.go @@ -0,0 +1,102 @@ +package proxy + +// import ( +// "net/http" +// "net/url" +// "os" +// "reflect" +// "testing" +// "time" +// ) + +// var proxy Entry +// var proxyUrl, _ = url.Parse("http://127.0.0.1:8181") +// var proxyServer = NewServer(ServerOptions{ +// Name: "proxy", +// HTTPAddr: ":8080", +// Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { +// NewReverseProxy(proxyUrl, &http.Transport{}, &proxy).ServeHTTP(w, r) +// }), +// }) + +// var testServer = NewServer(ServerOptions{ +// Name: "test", +// HTTPAddr: ":8181", +// Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { +// h := r.Header +// for k, vv := range h { +// for _, v := range vv { +// w.Header().Add(k, v) +// } +// } +// w.WriteHeader(http.StatusOK) +// }), +// }) + +// var httpClient = http.DefaultClient + +// func TestMain(m *testing.M) { +// proxyServer.Start() +// testServer.Start() +// time.Sleep(100 * time.Millisecond) +// code := m.Run() +// proxyServer.Stop() +// testServer.Stop() +// os.Exit(code) +// } + +// func TestSetHeader(t *testing.T) { +// hWant := http.Header{"X-Test": []string{"foo", "bar"}, "X-Test2": []string{"baz"}} +// proxy = Entry{ +// Alias: "test", +// Scheme: "http", +// Host: "127.0.0.1", +// Port: "8181", +// SetHeaders: hWant, +// } +// req, err := http.NewRequest("HEAD", "http://127.0.0.1:8080", nil) +// if err != nil { +// t.Fatal(err) +// } +// resp, err := httpClient.Do(req) +// if err != nil { +// t.Fatal(err) +// } +// hGot := resp.Header +// t.Log("headers: ", hGot) +// for k, v := range hWant { +// if !reflect.DeepEqual(hGot[k], v) { +// t.Errorf("header %s: expected %v, got %v", k, v, hGot[k]) +// } +// } +// } + +// func TestHideHeader(t *testing.T) { +// hHide := []string{"X-Test", "X-Test2"} +// proxy = Entry{ +// Alias: "test", +// Scheme: "http", +// Host: "127.0.0.1", +// Port: "8181", +// HideHeaders: hHide, +// } +// req, err := http.NewRequest("HEAD", "http://127.0.0.1:8080", nil) +// for _, k := range hHide { +// req.Header.Set(k, "foo") +// } +// if err != nil { +// t.Fatal(err) +// } +// resp, err := httpClient.Do(req) +// if err != nil { +// t.Fatal(err) +// } +// hGot := resp.Header +// t.Log("headers: ", hGot) +// for _, v := range hHide { +// _, ok := hGot[v] +// if ok { +// t.Errorf("header %s: expected hidden, got %v", v, hGot[v]) +// } +// } +// } diff --git a/src/route/constants.go b/src/route/constants.go new file mode 100644 index 0000000..5be1ebb --- /dev/null +++ b/src/route/constants.go @@ -0,0 +1,8 @@ +package route + +import ( + "time" +) + +const udpBufferSize = 1500 +const streamStopListenTimeout = 1 * time.Second diff --git a/src/route/http_route.go b/src/route/http_route.go new file mode 100755 index 0000000..1e85195 --- /dev/null +++ b/src/route/http_route.go @@ -0,0 +1,166 @@ +package route + +import ( + "crypto/tls" + "fmt" + "net" + "time" + + "net/http" + "net/url" + "strings" + + "github.com/sirupsen/logrus" + E "github.com/yusing/go-proxy/error" + P "github.com/yusing/go-proxy/proxy" + PT "github.com/yusing/go-proxy/proxy/fields" + F "github.com/yusing/go-proxy/utils/functional" +) + +type ( + HTTPRoute struct { + Alias PT.Alias `json:"alias"` + Subroutes HTTPSubroutes `json:"subroutes"` + + mux *http.ServeMux + } + + HTTPSubroute struct { + TargetURL URL `json:"targetURL"` + Path PathKey `json:"path"` + + proxy *P.ReverseProxy + } + + URL struct { + *url.URL + } + PathKey = string + SubdomainKey = string + HTTPSubroutes = map[PathKey]HTTPSubroute +) + +var httpRoutes = F.NewMap[SubdomainKey, *HTTPRoute]() + +func NewHTTPRoute(entry *P.Entry) (*HTTPRoute, E.NestedError) { + var tr *http.Transport + if entry.NoTLSVerify { + tr = transportNoTLS + } else { + tr = transport + } + + rp := P.NewReverseProxy(entry.URL, tr, entry) + + httpRoutes.Lock() + var r *HTTPRoute + r, ok := httpRoutes.UnsafeGet(entry.Alias.String()) + if !ok { + r = &HTTPRoute{ + Alias: entry.Alias, + Subroutes: make(HTTPSubroutes), + mux: http.NewServeMux(), + } + httpRoutes.UnsafeSet(entry.Alias.String(), r) + } + + path := entry.Path.String() + if _, exists := r.Subroutes[path]; exists { + httpRoutes.Unlock() + return nil, E.Duplicated("path", path).Subject(entry.Alias) + } + r.mux.HandleFunc(path, rp.ServeHTTP) + if err := recover(); err != nil { + httpRoutes.Unlock() + switch t := err.(type) { + case error: + // NOTE: likely path pattern error + return nil, E.From(t).Subject(entry.Alias) + default: + return nil, E.From(fmt.Errorf("%v", t)).Subject(entry.Alias) + } + } + + sr := HTTPSubroute{ + TargetURL: URL{entry.URL}, + proxy: rp, + Path: path, + } + + rewrite := rp.Rewrite + + if logrus.GetLevel() == logrus.DebugLevel { + l := logrus.WithField("alias", entry.Alias) + + sr.proxy.Rewrite = func(pr *P.ProxyRequest) { + l.Debug("request URL: ", pr.In.Host, pr.In.URL.Path) + l.Debug("request headers: ", pr.In.Header) + rewrite(pr) + } + } else { + sr.proxy.Rewrite = rewrite + } + + r.Subroutes[path] = sr + httpRoutes.Unlock() + return r, E.Nil() +} + +func (r *HTTPRoute) Start() E.NestedError { + httpRoutes.Set(r.Alias.String(), r) + return E.Nil() +} + +func (r *HTTPRoute) Stop() E.NestedError { + httpRoutes.Delete(r.Alias.String()) + return E.Nil() +} + +func (r *HTTPRoute) GetSubroute(path PathKey) (HTTPSubroute, bool) { + sr, ok := r.Subroutes[path] + return sr, ok +} + +func (u URL) MarshalText() (text []byte, err error) { + return []byte(u.String()), nil +} + +func ProxyHandler(w http.ResponseWriter, r *http.Request) { + mux, err := findMux(r.Host, PathKey(r.URL.Path)) + if err != nil { + err = E.Failure("request"). + Subjectf("%s %s%s", r.Method, r.Host, r.URL.Path). + With(err) + http.Error(w, err.Error(), http.StatusNotFound) + logrus.Error(err) + return + } + mux.ServeHTTP(w, r) +} + +func findMux(host string, path PathKey) (*http.ServeMux, error) { + sd := strings.Split(host, ".")[0] + if r, ok := httpRoutes.UnsafeGet(sd); ok { + return r.mux, nil + } + return nil, E.NotExists("route", fmt.Sprintf("subdomain: %s, path: %s", sd, path)) +} + +// TODO: default + per proxy +var ( + transport = &http.Transport{ + Proxy: http.ProxyFromEnvironment, + DialContext: (&net.Dialer{ + Timeout: 60 * time.Second, + KeepAlive: 60 * time.Second, + }).DialContext, + MaxIdleConns: 1000, + MaxIdleConnsPerHost: 1000, + } + + transportNoTLS = func() *http.Transport { + var clone = transport.Clone() + clone.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} + return clone + }() +) diff --git a/src/route/route.go b/src/route/route.go new file mode 100755 index 0000000..5efa927 --- /dev/null +++ b/src/route/route.go @@ -0,0 +1,34 @@ +package route + +import ( + E "github.com/yusing/go-proxy/error" + M "github.com/yusing/go-proxy/models" + P "github.com/yusing/go-proxy/proxy" + F "github.com/yusing/go-proxy/utils/functional" +) + +type ( + Route interface { + Start() E.NestedError + Stop() E.NestedError + } + Routes = F.Map[string, Route] +) + +// function alias +var NewRoutes = F.NewMap[string, Route] + +func NewRoute(en *M.ProxyEntry) (Route, E.NestedError) { + entry, err := P.NewEntry(en) + if err.IsNotNil() { + return nil, err + } + switch e := entry.(type) { + case *P.StreamEntry: + return NewStreamRoute(e) + case *P.Entry: + return NewHTTPRoute(e) + default: + panic("bug: should not reach here") + } +} diff --git a/src/route/stream_route.go b/src/route/stream_route.go new file mode 100755 index 0000000..9de3594 --- /dev/null +++ b/src/route/stream_route.go @@ -0,0 +1,131 @@ +package route + +import ( + "fmt" + "sync" + "sync/atomic" + "time" + + "github.com/sirupsen/logrus" + E "github.com/yusing/go-proxy/error" + P "github.com/yusing/go-proxy/proxy" +) + +type StreamRoute struct { + *P.StreamEntry + StreamImpl `json:"-"` + + wg sync.WaitGroup + stopCh chan struct{} + connCh chan any + started atomic.Bool + l logrus.FieldLogger +} + +type StreamImpl interface { + Setup() error + Accept() (any, error) + Handle(any) error + CloseListeners() +} + +func NewStreamRoute(entry *P.StreamEntry) (*StreamRoute, E.NestedError) { + // TODO: support non-coherent scheme + if !entry.Scheme.IsCoherent() { + return nil, E.Unsupported("scheme", fmt.Sprintf("%v -> %v", entry.Scheme.ListeningScheme, entry.Scheme.ProxyScheme)) + } + base := &StreamRoute{ + StreamEntry: entry, + wg: sync.WaitGroup{}, + stopCh: make(chan struct{}, 1), + connCh: make(chan any), + l: logger.WithField("alias", entry.Alias), + } + if entry.Scheme.ListeningScheme.IsTCP() { + base.StreamImpl = NewTCPRoute(base) + } else { + base.StreamImpl = NewUDPRoute(base) + } + return base, E.Nil() +} + +func (r *StreamRoute) Start() E.NestedError { + if r.started.Load() { + return E.ErrAlreadyStarted + } + r.wg.Wait() + if err := r.Setup(); err != nil { + return E.Failure("setup").With(err) + } + r.started.Store(true) + r.wg.Add(2) + go r.grAcceptConnections() + go r.grHandleConnections() + return E.Nil() +} + +func (r *StreamRoute) Stop() E.NestedError { + if !r.started.Load() { + return E.ErrNotStarted + } + l := r.l + close(r.stopCh) + r.CloseListeners() + + done := make(chan struct{}, 1) + go func() { + r.wg.Wait() + close(done) + }() + + select { + case <-done: + l.Info("stopped listening") + case <-time.After(streamStopListenTimeout): + l.Error("timed out waiting for connections") + } + return E.Nil() +} + +func (r *StreamRoute) grAcceptConnections() { + defer r.wg.Done() + + for { + select { + case <-r.stopCh: + return + default: + conn, err := r.Accept() + if err != nil { + select { + case <-r.stopCh: + return + default: + r.l.Error(err) + continue + } + } + r.connCh <- conn + } + } +} + +func (r *StreamRoute) grHandleConnections() { + defer r.wg.Done() + + for { + select { + case <-r.stopCh: + return + case conn := <-r.connCh: + go func() { + err := r.Handle(conn) + if err != nil { + r.l.Error(err) + } + }() + } + } +} + +var logger = logrus.WithField("?", "stream") diff --git a/src/go-proxy/tcp_route.go b/src/route/tcp_route.go similarity index 65% rename from src/go-proxy/tcp_route.go rename to src/route/tcp_route.go index e70d67a..4ee3c77 100755 --- a/src/go-proxy/tcp_route.go +++ b/src/route/tcp_route.go @@ -1,4 +1,4 @@ -package main +package route import ( "context" @@ -6,29 +6,31 @@ import ( "net" "sync" "time" + + U "github.com/yusing/go-proxy/utils" ) const tcpDialTimeout = 5 * time.Second -type Pipes []*BidirectionalPipe +type Pipes []*U.BidirectionalPipe type TCPRoute struct { - *StreamRouteBase + *StreamRoute listener net.Listener pipe Pipes mu sync.Mutex } -func NewTCPRoute(base *StreamRouteBase) StreamImpl { +func NewTCPRoute(base *StreamRoute) StreamImpl { return &TCPRoute{ - StreamRouteBase: base, - listener: nil, - pipe: make(Pipes, 0), + StreamRoute: base, + listener: nil, + pipe: make(Pipes, 0), } } func (route *TCPRoute) Setup() error { - in, err := net.Listen("tcp", fmt.Sprintf(":%v", route.ListeningPort)) + in, err := net.Listen("tcp", fmt.Sprintf(":%v", route.Port.ListeningPort)) if err != nil { return err } @@ -48,10 +50,10 @@ func (route *TCPRoute) Handle(c interface{}) error { ctx, cancel := context.WithTimeout(context.Background(), tcpDialTimeout) defer cancel() - serverAddr := fmt.Sprintf("%s:%v", route.TargetHost, route.TargetPort) + serverAddr := fmt.Sprintf("%s:%v", route.Host, route.Port.ProxyPort) dialer := &net.Dialer{} - serverConn, err := dialer.DialContext(ctx, route.TargetScheme, serverAddr) + serverConn, err := dialer.DialContext(ctx, route.Scheme.ProxyScheme.String(), serverAddr) if err != nil { return err } @@ -63,7 +65,7 @@ func (route *TCPRoute) Handle(c interface{}) error { }() route.mu.Lock() - pipe := NewBidirectionalPipe(pipeCtx, clientConn, serverConn) + pipe := U.NewBidirectionalPipe(pipeCtx, clientConn, serverConn) route.pipe = append(route.pipe, pipe) route.mu.Unlock() return pipe.Start() @@ -76,7 +78,7 @@ func (route *TCPRoute) CloseListeners() { route.listener.Close() route.listener = nil for _, pipe := range route.pipe { - if err := pipe.Stop(); err != nil { + if err := pipe.Stop(); err.IsNotNil() { route.l.Error(err) } } diff --git a/src/go-proxy/udp_route.go b/src/route/udp_route.go similarity index 74% rename from src/go-proxy/udp_route.go rename to src/route/udp_route.go index cefd637..2259897 100755 --- a/src/go-proxy/udp_route.go +++ b/src/route/udp_route.go @@ -1,4 +1,4 @@ -package main +package route import ( "context" @@ -6,10 +6,12 @@ import ( "io" "net" "sync" + + "github.com/yusing/go-proxy/utils" ) type UDPRoute struct { - *StreamRouteBase + *StreamRoute connMap UDPConnMap connMapMutex sync.Mutex @@ -21,28 +23,28 @@ type UDPRoute struct { type UDPConn struct { src *net.UDPConn dst *net.UDPConn - *BidirectionalPipe + *utils.BidirectionalPipe } type UDPConnMap map[string]*UDPConn -func NewUDPRoute(base *StreamRouteBase) StreamImpl { +func NewUDPRoute(base *StreamRoute) StreamImpl { return &UDPRoute{ - StreamRouteBase: base, - connMap: make(UDPConnMap), + StreamRoute: base, + connMap: make(UDPConnMap), } } func (route *UDPRoute) Setup() error { - laddr, err := net.ResolveUDPAddr(route.ListeningScheme, fmt.Sprintf(":%v", route.ListeningPort)) + laddr, err := net.ResolveUDPAddr(route.Scheme.ListeningScheme.String(), fmt.Sprintf(":%v", route.Port.ProxyPort)) if err != nil { return err } - source, err := net.ListenUDP(route.ListeningScheme, laddr) + source, err := net.ListenUDP(route.Scheme.ListeningScheme.String(), laddr) if err != nil { return err } - raddr, err := net.ResolveUDPAddr(route.TargetScheme, fmt.Sprintf("%s:%v", route.TargetHost, route.TargetPort)) + raddr, err := net.ResolveUDPAddr(route.Scheme.ProxyScheme.String(), fmt.Sprintf("%s:%v", route.Host, route.Port.ProxyPort)) if err != nil { source.Close() return err @@ -90,7 +92,7 @@ func (route *UDPRoute) Accept() (interface{}, error) { conn = &UDPConn{ srcConn, dstConn, - NewBidirectionalPipe(pipeCtx, sourceRWCloser{in, dstConn}, sourceRWCloser{in, srcConn}), + utils.NewBidirectionalPipe(pipeCtx, sourceRWCloser{in, dstConn}, sourceRWCloser{in, srcConn}), } route.connMap[key] = conn } @@ -112,10 +114,10 @@ func (route *UDPRoute) CloseListeners() { } for _, conn := range route.connMap { if err := conn.src.Close(); err != nil { - route.l.Errorf("error closing src conn: %w", err) + route.l.Errorf("error closing src conn: %s", err) } if err := conn.dst.Close(); err != nil { - route.l.Error("error closing dst conn: %w", err) + route.l.Error("error closing dst conn: %s", err) } } route.connMap = make(UDPConnMap) diff --git a/src/server/instance.go b/src/server/instance.go new file mode 100644 index 0000000..ba319f8 --- /dev/null +++ b/src/server/instance.go @@ -0,0 +1,25 @@ +package server + +var proxyServer, apiServer *server + +func InitProxyServer(opt Options) *server { + if proxyServer == nil { + proxyServer = NewServer(opt) + } + return proxyServer +} + +func InitAPIServer(opt Options) *server { + if apiServer == nil { + apiServer = NewServer(opt) + } + return apiServer +} + +func GetProxyServer() *server { + return proxyServer +} + +func GetAPIServer() *server { + return apiServer +} diff --git a/src/server/server.go b/src/server/server.go new file mode 100644 index 0000000..ca51bef --- /dev/null +++ b/src/server/server.go @@ -0,0 +1,157 @@ +package server + +import ( + "crypto/tls" + "log" + "net/http" + "time" + + "github.com/sirupsen/logrus" + "github.com/yusing/go-proxy/autocert" + "golang.org/x/net/context" +) + +type server struct { + Name string + CertProvider *autocert.Provider + http *http.Server + https *http.Server + httpStarted bool + httpsStarted bool + startTime time.Time +} + +type Options struct { + Name string + // port (with leading colon) + HTTPPort string + // port (with leading colon) + HTTPSPort string + CertProvider *autocert.Provider + RedirectToHTTPS bool + Handler http.Handler +} + +type LogrusWrapper struct { + *logrus.Entry +} + +func (l LogrusWrapper) Write(b []byte) (int, error) { + return l.Logger.WriterLevel(logrus.ErrorLevel).Write(b) +} + +func NewServer(opt Options) (s *server) { + var httpSer, httpsSer *http.Server + var httpHandler http.Handler + + logger := log.Default() + logger.SetOutput(LogrusWrapper{ + logrus.WithFields(logrus.Fields{"?": "server", "name": opt.Name}), + }) + + _, err := opt.CertProvider.GetCert(nil) + certAvailable := err == nil + if certAvailable && opt.RedirectToHTTPS && opt.HTTPSPort != "" { + httpHandler = redirectToTLSHandler(opt.HTTPSPort) + } else { + httpHandler = opt.Handler + } + + if opt.HTTPPort != "" { + httpSer = &http.Server{ + Addr: opt.HTTPPort, + Handler: httpHandler, + ErrorLog: logger, + } + } + if certAvailable && opt.HTTPSPort != "" { + httpsSer = &http.Server{ + Addr: opt.HTTPSPort, + Handler: opt.Handler, + ErrorLog: logger, + TLSConfig: &tls.Config{ + GetCertificate: opt.CertProvider.GetCert, + }, + } + } + return &server{ + Name: opt.Name, + CertProvider: opt.CertProvider, + http: httpSer, + https: httpsSer, + } +} + +func (s *server) Start() { + if s.http == nil && s.https == nil { + return + } + + s.startTime = time.Now() + if s.http != nil { + s.httpStarted = true + logrus.Printf("starting http %s server on %s", s.Name, s.http.Addr) + go func() { + s.handleErr("http", s.http.ListenAndServe()) + }() + } + + if s.https != nil { + s.httpsStarted = true + logrus.Printf("starting https %s server on %s", s.Name, s.https.Addr) + go func() { + s.handleErr("https", s.https.ListenAndServeTLS(s.CertProvider.GetCertPath(), s.CertProvider.GetKeyPath())) + }() + } +} + +func (s *server) Stop() { + if s.http == nil && s.https == nil { + return + } + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + if s.http != nil && s.httpStarted { + s.handleErr("http", s.http.Shutdown(ctx)) + s.httpStarted = false + logger.Debugf("HTTP server %q stopped", s.Name) + } + + if s.https != nil && s.httpsStarted { + s.handleErr("https", s.https.Shutdown(ctx)) + s.httpsStarted = false + logger.Debugf("HTTPS server %q stopped", s.Name) + } +} + +func (s *server) Uptime() time.Duration { + return time.Since(s.startTime) +} + +func (s *server) handleErr(scheme string, err error) { + switch err { + case nil, http.ErrServerClosed: + return + default: + logrus.Fatalf("failed to start %s %s server: %s", scheme, s.Name, err) + } +} + +func redirectToTLSHandler(port string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + r.URL.Scheme = "https" + r.URL.Host = r.URL.Hostname() + port + + var redirectCode int + if r.Method == http.MethodGet { + redirectCode = http.StatusMovedPermanently + } else { + redirectCode = http.StatusPermanentRedirect + } + http.Redirect(w, r, r.URL.String(), redirectCode) + } +} + +var logger = logrus.WithField("?", "server") diff --git a/src/utils/format.go b/src/utils/format.go new file mode 100644 index 0000000..e83115f --- /dev/null +++ b/src/utils/format.go @@ -0,0 +1,50 @@ +package utils + +import ( + "fmt" + "strings" + "time" +) + +func FormatDuration(d time.Duration) string { + // Get total seconds from duration + totalSeconds := int64(d.Seconds()) + + // Calculate days, hours, minutes, and seconds + days := totalSeconds / (24 * 3600) + hours := (totalSeconds % (24 * 3600)) / 3600 + 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))) + } + if hours > 0 { + parts = append(parts, fmt.Sprintf("%d Hour%s", hours, pluralize(hours))) + } + if minutes > 0 { + parts = append(parts, fmt.Sprintf("%d Minute%s", minutes, pluralize(minutes))) + } + if seconds > 0 { + parts = append(parts, fmt.Sprintf("%d Second%s", seconds, pluralize(seconds))) + } + + // 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 pluralize(n int64) string { + if n > 1 { + return "s" + } + return "" +} diff --git a/src/utils/fs.go b/src/utils/fs.go new file mode 100644 index 0000000..f7c01dd --- /dev/null +++ b/src/utils/fs.go @@ -0,0 +1,15 @@ +package utils + +import ( + "os" + "path" +) + +func FileOK(p string) bool { + _, err := os.Stat(p) + return err == nil +} + +func FileName(p string) string { + return path.Base(p) +} \ No newline at end of file diff --git a/src/utils/functional/functional.go b/src/utils/functional/functional.go new file mode 100644 index 0000000..254d8fb --- /dev/null +++ b/src/utils/functional/functional.go @@ -0,0 +1,69 @@ +package functional + +import "sync" + +func ForEachKey[K comparable, V interface{}](obj map[K]V, do func(K)) { + for k := range obj { + do(k) + } +} + +func ForEachValue[K comparable, V interface{}](obj map[K]V, do func(V)) { + for _, v := range obj { + do(v) + } +} + +func ForEachKV[K comparable, V interface{}](obj map[K]V, do func(K, V)) { + for k, v := range obj { + do(k, v) + } +} + +func ParallelForEach[T interface{}](obj []T, do func(T)) { + var wg sync.WaitGroup + wg.Add(len(obj)) + for _, v := range obj { + go func(v T) { + do(v) + wg.Done() + }(v) + } + wg.Wait() +} + +func ParallelForEachKey[K comparable, V interface{}](obj map[K]V, do func(K)) { + var wg sync.WaitGroup + wg.Add(len(obj)) + for k := range obj { + go func(k K) { + do(k) + wg.Done() + }(k) + } + wg.Wait() +} + +func ParallelForEachValue[K comparable, V interface{}](obj map[K]V, do func(V)) { + var wg sync.WaitGroup + wg.Add(len(obj)) + for _, v := range obj { + go func(v V) { + do(v) + wg.Done() + }(v) + } + wg.Wait() +} + +func ParallelForEachKV[K comparable, V interface{}](obj map[K]V, do func(K, V)) { + var wg sync.WaitGroup + wg.Add(len(obj)) + for k, v := range obj { + go func(k K, v V) { + do(k, v) + wg.Done() + }(k, v) + } + wg.Wait() +} diff --git a/src/utils/functional/map.go b/src/utils/functional/map.go new file mode 100644 index 0000000..e1ede2e --- /dev/null +++ b/src/utils/functional/map.go @@ -0,0 +1,225 @@ +package functional + +import ( + "context" + "sync" + + "gopkg.in/yaml.v3" + + E "github.com/yusing/go-proxy/error" +) + +type Map[KT comparable, VT interface{}] struct { + m map[KT]VT + defVals map[KT]VT + sync.RWMutex +} + +// NewMap creates a new Map with the given map as its initial values. +// +// Parameters: +// - dv: optional default values for the Map +// +// Return: +// - *Map[KT, VT]: a pointer to the newly created Map. +func NewMap[KT comparable, VT interface{}](dv ...map[KT]VT) *Map[KT, VT] { + return NewMapFrom(make(map[KT]VT), dv...) +} + +// NewMapOf creates a new Map with the given map as its initial values. +// +// Type parameters: +// - M: type for the new map. +// +// Parameters: +// - dv: optional default values for the Map +// +// Return: +// - *Map[KT, VT]: a pointer to the newly created Map. +func NewMapOf[M Map[KT, VT], KT comparable, VT interface{}](dv ...map[KT]VT) *Map[KT, VT] { + return NewMapFrom(make(map[KT]VT), dv...) +} + +// NewMapFrom creates a new Map with the given map as its initial values. +// +// Parameters: +// - from: a map of type KT to VT, which will be the initial values of the Map. +// - dv: optional default values for the Map +// +// Return: +// - *Map[KT, VT]: a pointer to the newly created Map. +func NewMapFrom[KT comparable, VT interface{}](from map[KT]VT, dv ...map[KT]VT) *Map[KT, VT] { + if len(dv) > 0 { + return &Map[KT, VT]{m: from, defVals: dv[0]} + } + return &Map[KT, VT]{m: from} +} + +func (m *Map[KT, VT]) Set(key KT, value VT) { + m.Lock() + m.m[key] = value + m.Unlock() +} + +func (m *Map[KT, VT]) Get(key KT) VT { + m.RLock() + defer m.RUnlock() + value, ok := m.m[key] + if !ok && m.defVals != nil { + return m.defVals[key] + } + return value +} + +// Find searches for the first element in the map that satisfies the given criteria. +// +// Parameters: +// - criteria: a function that takes a value of type VT and returns a tuple of any type and a boolean. +// +// Return: +// - any: the first value that satisfies the criteria, or nil if no match is found. +func (m *Map[KT, VT]) Find(criteria func(VT) (any, bool)) any { + m.RLock() + defer m.RUnlock() + + result := make(chan any) + wg := sync.WaitGroup{} + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + for _, v := range m.m { + wg.Add(1) + go func(val VT) { + defer wg.Done() + if value, ok := criteria(val); ok { + select { + case result <- value: + cancel() // Cancel other goroutines if a result is found + case <-ctx.Done(): // If already cancelled + return + } + } + }(v) + } + + go func() { + wg.Wait() + close(result) + }() + + // The first valid match, if any + select { + case res, ok := <-result: + if ok { + return res + } + case <-ctx.Done(): + } + + return nil // Return nil if no matches found +} + +func (m *Map[KT, VT]) UnsafeGet(key KT) (VT, bool) { + value, ok := m.m[key] + return value, ok +} + +func (m *Map[KT, VT]) UnsafeSet(key KT, value VT) { + m.m[key] = value +} + +func (m *Map[KT, VT]) Delete(key KT) { + m.Lock() + delete(m.m, key) + m.Unlock() +} + +// MergeWith merges the contents of another Map[KT, VT] +// into the current Map[KT, VT] and +// returns a map that were duplicated. +// +// Parameters: +// - other: a pointer to another Map[KT, VT] to be merged into the current Map[KT, VT]. +// +// Return: +// - Map[KT, VT]: a map of key-value pairs that were duplicated during the merge. +func (m *Map[KT, VT]) MergeWith(other *Map[KT, VT]) Map[KT, VT] { + dups := make(map[KT]VT) + + m.Lock() + for k, v := range other.m { + if _, isDup := m.m[k]; !isDup { + m.m[k] = v + } else { + dups[k] = v + } + } + m.Unlock() + return Map[KT, VT]{m: dups} +} + +func (m *Map[KT, VT]) Clear() { + m.Lock() + m.m = make(map[KT]VT) + m.Unlock() +} + +func (m *Map[KT, VT]) Size() int { + m.RLock() + defer m.RUnlock() + return len(m.m) +} + +func (m *Map[KT, VT]) Contains(key KT) bool { + m.RLock() + _, ok := m.m[key] + m.RUnlock() + return ok +} + +func (m *Map[KT, VT]) Clone() *Map[KT, VT] { + m.RLock() + defer m.RUnlock() + clone := make(map[KT]VT, len(m.m)) + for k, v := range m.m { + clone[k] = v + } + return &Map[KT, VT]{m: clone, defVals: m.defVals} +} + +func (m *Map[KT, VT]) EachKV(fn func(k KT, v VT)) { + m.Lock() + for k, v := range m.m { + fn(k, v) + } + m.Unlock() +} + +func (m *Map[KT, VT]) Each(fn func(v VT)) { + m.Lock() + for _, v := range m.m { + fn(v) + } + m.Unlock() +} + +func (m *Map[KT, VT]) EachParallel(fn func(v VT)) { + m.Lock() + ParallelForEachValue(m.m, fn) + m.Unlock() +} + +func (m *Map[KT, VT]) EachKVParallel(fn func(k KT, v VT)) { + m.Lock() + ParallelForEachKV(m.m, fn) + m.Unlock() +} + +func (m *Map[KT, VT]) UnmarshalFromYAML(data []byte) E.NestedError { + return E.From(yaml.Unmarshal(data, m.m)) +} + +func (m *Map[KT, VT]) Iterator() map[KT]VT { + return m.m +} diff --git a/src/utils/functional/slice.go b/src/utils/functional/slice.go new file mode 100644 index 0000000..457b4b3 --- /dev/null +++ b/src/utils/functional/slice.go @@ -0,0 +1,69 @@ +package functional + +type Slice[T any] struct { + s []T +} + +func NewSlice[T any]() *Slice[T] { + return &Slice[T]{make([]T, 0)} +} + +func NewSliceN[T any](n int) *Slice[T] { + return &Slice[T]{make([]T, n)} +} + +func NewSliceFrom[T any](s []T) *Slice[T] { + return &Slice[T]{s} +} + +func (s *Slice[T]) Size() int { + return len(s.s) +} + +func (s *Slice[T]) Empty() bool { + return len(s.s) == 0 +} + +func (s *Slice[T]) NotEmpty() bool { + return len(s.s) > 0 +} + +func (s *Slice[T]) Iterator() []T { + return s.s +} + +func (s *Slice[T]) Set(i int, v T) { + s.s[i] = v +} + +func (s *Slice[T]) Add(e T) *Slice[T] { + return &Slice[T]{append(s.s, e)} +} + +func (s *Slice[T]) AddRange(other *Slice[T]) *Slice[T] { + return &Slice[T]{append(s.s, other.s...)} +} + +func (s *Slice[T]) ForEach(do func(T)) { + for _, v := range s.s { + do(v) + } +} + +func (s *Slice[T]) Map(m func(T) T) *Slice[T] { + n := make([]T, len(s.s)) + for i, v := range s.s { + n[i] = m(v) + } + return &Slice[T]{n} +} + +func (s *Slice[T]) Filter(f func(T) bool) *Slice[T] { + n := make([]T, 0) + for _, v := range s.s { + if f(v) { + n = append(n, v) + } + } + return &Slice[T]{n} +} diff --git a/src/utils/functional/stringable.go b/src/utils/functional/stringable.go new file mode 100644 index 0000000..a223301 --- /dev/null +++ b/src/utils/functional/stringable.go @@ -0,0 +1,68 @@ +package functional + +import ( + "fmt" + "strconv" + "strings" +) + +type Stringable struct{ string } + +func NewStringable(v any) Stringable { + switch vv := v.(type) { + case string: + return Stringable{vv} + case fmt.Stringer: + return Stringable{vv.String()} + default: + return Stringable{fmt.Sprint(vv)} + } +} + +func (s Stringable) String() string { + return s.string +} + +func (s Stringable) Len() int { + return len(s.string) +} + +func (s Stringable) MarshalText() (text []byte, err error) { + return []byte(s.string), nil +} + +func (s Stringable) SubStr(start int, end int) Stringable { + return Stringable{s.string[start:end]} +} + +func (s Stringable) HasPrefix(p Stringable) bool { + return len(s.string) >= len(p.string) && s.string[0:len(p.string)] == p.string +} + +func (s Stringable) HasSuffix(p Stringable) bool { + return len(s.string) >= len(p.string) && s.string[len(s.string)-len(p.string):] == p.string +} + +func (s Stringable) IsEmpty() bool { + return len(s.string) == 0 +} + +func (s Stringable) IndexRune(r rune) int { + return strings.IndexRune(s.string, r) +} + +func (s Stringable) ToInt() (int, error) { + return strconv.Atoi(s.string) +} + +func (s Stringable) Split(sep string) []Stringable { + return Stringables(strings.Split(s.string, sep)) +} + +func Stringables(ss []string) []Stringable { + ret := make([]Stringable, len(ss)) + for i, s := range ss { + ret[i] = Stringable{s} + } + return ret +} diff --git a/src/utils/io.go b/src/utils/io.go new file mode 100644 index 0000000..3df7184 --- /dev/null +++ b/src/utils/io.go @@ -0,0 +1,152 @@ +package utils + +import ( + "context" + "io" + "os" + "sync/atomic" + + E "github.com/yusing/go-proxy/error" +) + +type ( + Reader interface { + Read() ([]byte, E.NestedError) + } + + StdReader struct { + r Reader + } + + FileReader struct { + Path string + } + + ReadCloser struct { + ctx context.Context + r io.ReadCloser + closed atomic.Bool + } + + StdReadCloser struct { + r *ReadCloser + } + + ByteReader []byte + NewByteReader = ByteReader + + Pipe struct { + r ReadCloser + w io.WriteCloser + ctx context.Context + cancel context.CancelFunc + } + + BidirectionalPipe struct { + pSrcDst Pipe + pDstSrc Pipe + } +) + +func NewFileReader(path string) *FileReader { + return &FileReader{Path: path} +} + +func (r StdReader) Read() ([]byte, error) { + return r.r.Read() +} + +func (r *FileReader) Read() ([]byte, E.NestedError) { + return E.Check(os.ReadFile(r.Path)) +} + +func (r ByteReader) Read() ([]byte, E.NestedError) { + return r, E.Nil() +} + +func (r *ReadCloser) Read(p []byte) (int, E.NestedError) { + select { + case <-r.ctx.Done(): + return 0, E.From(r.ctx.Err()) + default: + return E.Check(r.r.Read(p)) + } +} + +func (r *ReadCloser) Close() E.NestedError { + if r.closed.Load() { + return E.Nil() + } + r.closed.Store(true) + return E.From(r.r.Close()) +} + +func (r StdReadCloser) Read(p []byte) (int, error) { + return r.r.Read(p) +} + +func (r StdReadCloser) Close() error { + return r.r.Close() +} + +func NewPipe(ctx context.Context, r io.ReadCloser, w io.WriteCloser) *Pipe { + ctx, cancel := context.WithCancel(ctx) + return &Pipe{ + r: ReadCloser{ctx: ctx, r: r}, + w: w, + ctx: ctx, + cancel: cancel, + } +} + +func (p *Pipe) Start() E.NestedError { + return Copy(p.ctx, p.w, &StdReadCloser{&p.r}) +} + +func (p *Pipe) Stop() E.NestedError { + p.cancel() + return E.Join("error stopping pipe", p.r.Close(), p.w.Close()) +} + +func (p *Pipe) Write(b []byte) (int, E.NestedError) { + return E.Check(p.w.Write(b)) +} + +func NewBidirectionalPipe(ctx context.Context, rw1 io.ReadWriteCloser, rw2 io.ReadWriteCloser) *BidirectionalPipe { + return &BidirectionalPipe{ + pSrcDst: *NewPipe(ctx, rw1, rw2), + pDstSrc: *NewPipe(ctx, rw2, rw1), + } +} + +func NewBidirectionalPipeIntermediate(ctx context.Context, listener io.ReadCloser, client io.ReadWriteCloser, target io.ReadWriteCloser) *BidirectionalPipe { + return &BidirectionalPipe{ + pSrcDst: *NewPipe(ctx, listener, client), + pDstSrc: *NewPipe(ctx, client, target), + } +} + +func (p *BidirectionalPipe) Start() E.NestedError { + errCh := make(chan E.NestedError, 2) + go func() { + errCh <- p.pSrcDst.Start() + }() + go func() { + errCh <- p.pDstSrc.Start() + }() + for err := range errCh { + if err.IsNotNil() { + return err + } + } + return E.Nil() +} + +func (p *BidirectionalPipe) Stop() E.NestedError { + return E.Join("error stopping pipe", p.pSrcDst.Stop(), p.pDstSrc.Stop()) +} + +func Copy(ctx context.Context, dst io.WriteCloser, src io.ReadCloser) E.NestedError { + _, err := io.Copy(dst, StdReadCloser{&ReadCloser{ctx: ctx, r: src}}) + return E.From(err) +} \ No newline at end of file diff --git a/src/utils/reflection.go b/src/utils/reflection.go new file mode 100644 index 0000000..e0a1f80 --- /dev/null +++ b/src/utils/reflection.go @@ -0,0 +1,24 @@ +package utils + +import ( + "net/http" + "reflect" + "strings" + + E "github.com/yusing/go-proxy/error" +) + +func snakeToPascal(s string) string { + toHyphenCamel := http.CanonicalHeaderKey(strings.ReplaceAll(s, "_", "-")) + return strings.ReplaceAll(toHyphenCamel, "-", "") +} + +func SetFieldFromSnake[T, VT any](obj *T, field string, value VT) E.NestedError { + field = snakeToPascal(field) + prop := reflect.ValueOf(obj).Elem().FieldByName(field) + if prop.Kind() == 0 { + return E.Invalid("field", field) + } + prop.Set(reflect.ValueOf(value)) + return E.Nil() +} diff --git a/src/utils/schema.go b/src/utils/schema.go new file mode 100644 index 0000000..b241590 --- /dev/null +++ b/src/utils/schema.go @@ -0,0 +1,26 @@ +package utils + +import ( + "github.com/santhosh-tekuri/jsonschema" + "github.com/yusing/go-proxy/common" +) + +var schemaCompiler = func() *jsonschema.Compiler { + c := jsonschema.NewCompiler() + c.Draft = jsonschema.Draft7 + return c +}() + +var schemaStorage = make(map[string] *jsonschema.Schema) + +func GetSchema(path string) *jsonschema.Schema { + if common.NoSchemaValidation { + panic("bug: GetSchema called when schema validation disabled") + } + if schema, ok := schemaStorage[path]; ok { + return schema + } + schema := schemaCompiler.MustCompile(path) + schemaStorage[path] = schema + return schema +} \ No newline at end of file diff --git a/src/utils/serialization.go b/src/utils/serialization.go new file mode 100644 index 0000000..fd382db --- /dev/null +++ b/src/utils/serialization.go @@ -0,0 +1,127 @@ +package utils + +import ( + "bytes" + "encoding/json" + "fmt" + "reflect" + + "github.com/santhosh-tekuri/jsonschema" + E "github.com/yusing/go-proxy/error" + "gopkg.in/yaml.v3" +) + +func ValidateYaml(schema *jsonschema.Schema, data []byte) E.NestedError { + var i interface{} + + err := yaml.Unmarshal(data, &i) + if err != nil { + return E.Failure("unmarshal yaml").With(err) + } + + m, err := json.Marshal(i) + if err != nil { + return E.Failure("marshal json").With(err) + } + + err = schema.Validate(bytes.NewReader(m)) + if err == nil { + return E.Nil() + } + + errors := E.NewBuilder("yaml validation error") + for _, e := range err.(*jsonschema.ValidationError).Causes { + errors.Add(e) + } + return errors.Build() +} + +// TryJsonStringify converts the given object to a JSON string. +// +// It takes an object of any type and attempts to marshal it into a JSON string. +// If the marshaling is successful, the JSON string is returned. +// If the marshaling fails, the object is converted to a string using fmt.Sprint and returned. +// +// Parameters: +// - o: The object to be converted to a JSON string. +// +// Return type: +// - string: The JSON string representation of the object. +func TryJsonStringify(o any) string { + b, err := json.Marshal(o) + if err != nil { + return fmt.Sprint(o) + } + return string(b) +} + +// Serialize converts the given data into a map[string]interface{} representation. +// +// It uses reflection to inspect the data type and handle different kinds of data. +// For a struct, it extracts the fields using the json tag if present, or the field name if not. +// For an embedded struct, it recursively converts its fields into the result map. +// For any other type, it returns an error. +// +// Parameters: +// - data: The data to be converted into a map. +// +// Returns: +// - result: The resulting map[string]interface{} representation of the data. +// - error: An error if the data type is unsupported or if there is an error during conversion. +func Serialize(data interface{}) (SerializedObject, error) { + result := make(map[string]any) + + // Use reflection to inspect the data type + value := reflect.ValueOf(data) + + // Check if the value is valid + if !value.IsValid() { + return nil, fmt.Errorf("invalid data") + } + + // Dereference pointers if necessary + if value.Kind() == reflect.Ptr { + value = value.Elem() + } + + // Handle different kinds of data + switch value.Kind() { + case reflect.Map: + for _, key := range value.MapKeys() { + result[key.String()] = value.MapIndex(key).Interface() + } + case reflect.Struct: + for i := 0; i < value.NumField(); i++ { + field := value.Type().Field(i) + if !field.IsExported() { + continue + } + jsonTag := field.Tag.Get("json") // Get the json tag + if jsonTag == "-" { + continue // Ignore this field if the tag is "-" + } + + // If the json tag is not empty, use it as the key + if jsonTag != "" { + result[jsonTag] = value.Field(i).Interface() + } else if field.Anonymous { + // If the field is an embedded struct, add its fields to the result + fieldMap, err := Serialize(value.Field(i).Interface()) + if err != nil { + return nil, err + } + for k, v := range fieldMap { + result[k] = v + } + } else { + result[field.Name] = value.Field(i).Interface() + } + } + default: + return nil, fmt.Errorf("unsupported type: %s", value.Kind()) + } + + return result, nil +} + +type SerializedObject map[string]any diff --git a/src/watcher/docker_watcher.go b/src/watcher/docker_watcher.go new file mode 100644 index 0000000..e0fc2fb --- /dev/null +++ b/src/watcher/docker_watcher.go @@ -0,0 +1,86 @@ +package watcher + +import ( + "context" + "time" + + "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/filters" + D "github.com/yusing/go-proxy/docker" + E "github.com/yusing/go-proxy/error" +) + +type DockerWatcher struct { + host string +} + +func NewDockerWatcher(host string) *DockerWatcher { + return &DockerWatcher{host: host} +} + +func (w *DockerWatcher) Events(ctx context.Context) (<-chan Event, <-chan E.NestedError) { + eventCh := make(chan Event) + errCh := make(chan E.NestedError) + started := make(chan struct{}) + + go func() { + defer close(errCh) + + var cl D.Client + var err E.NestedError + for range 3 { + cl, err = D.ConnectClient(w.host) + if err.IsNotNil() { + break + } + errCh <- E.From(err) + time.Sleep(1 * time.Second) + } + if err.IsNotNil() { + errCh <- E.Failure("connect to docker") + return + } + + cEventCh, cErrCh := cl.Events(ctx, dwOptions) + started <- struct{}{} + + for { + select { + case <-ctx.Done(): + errCh <- E.From(<-cErrCh) + return + case msg := <-cEventCh: + containerName, ok := msg.Actor.Attributes["name"] + if !ok { + // NOTE: should not happen + // but if it happens, just ignore it + continue + } + eventCh <- Event{ + ActorName: containerName, + Action: ActionModified, + } + case err := <-cErrCh: + errCh <- E.From(err) + select { + case <-ctx.Done(): + return + default: + if D.IsErrConnectionFailed(err) { + time.Sleep(100 * time.Millisecond) + cEventCh, cErrCh = cl.Events(ctx, dwOptions) + } + } + } + } + }() + <-started + + return eventCh, errCh +} + +var dwOptions = types.EventsOptions{Filters: filters.NewArgs( + filters.Arg("type", "container"), + filters.Arg("event", "start"), + filters.Arg("event", "die"), // 'stop' already triggering 'die' +)} diff --git a/src/watcher/event.go b/src/watcher/event.go new file mode 100644 index 0000000..d7b4a34 --- /dev/null +++ b/src/watcher/event.go @@ -0,0 +1,33 @@ +package watcher + +import "fmt" + +type ( + Event struct { + ActorName string + Action Action + } + Action string +) + +const ( + ActionModified Action = "MODIFIED" + ActionDeleted Action = "DELETED" + ActionCreated Action = "CREATED" +) + +func (e *Event) String() string { + return fmt.Sprintf("%s %s", e.ActorName, e.Action) +} + +func (a Action) IsDelete() bool { + return a == ActionDeleted +} + +func (a Action) IsModify() bool { + return a == ActionModified +} + +func (a Action) IsCreate() bool { + return a == ActionCreated +} diff --git a/src/watcher/file_watcher.go b/src/watcher/file_watcher.go new file mode 100644 index 0000000..a871a11 --- /dev/null +++ b/src/watcher/file_watcher.go @@ -0,0 +1,25 @@ +package watcher + +import ( + "context" + "path" + + E "github.com/yusing/go-proxy/error" +) + +type fileWatcher struct { + filename string +} + +func NewFileWatcher(filename string) Watcher { + if path.Base(filename) != filename { + panic("filename must be a relative path") + } + return &fileWatcher{filename: filename} +} + +func (f *fileWatcher) Events(ctx context.Context) (<-chan Event, <-chan E.NestedError) { + return fwHelper.Add(ctx, f) +} + +var fwHelper = newFileWatcherHelper() diff --git a/src/watcher/file_watcher_helper.go b/src/watcher/file_watcher_helper.go new file mode 100644 index 0000000..cb00cd8 --- /dev/null +++ b/src/watcher/file_watcher_helper.go @@ -0,0 +1,132 @@ +package watcher + +import ( + "context" + "errors" + "path" + "sync" + + "github.com/fsnotify/fsnotify" + "github.com/sirupsen/logrus" + "github.com/yusing/go-proxy/common" + E "github.com/yusing/go-proxy/error" +) + +type fileWatcherHelper struct { + w *fsnotify.Watcher + m map[string]*fileWatcherStream + wg sync.WaitGroup + mu sync.Mutex +} + +type fileWatcherStream struct { + *fileWatcher + stopped chan struct{} + eventCh chan Event + errCh chan E.NestedError +} + +func newFileWatcherHelper() *fileWatcherHelper { + w, err := fsnotify.NewWatcher() + if err != nil { + logrus.Panicf("unable to create fs watcher: %s", err) + } + // watch config path for all changes + err = w.Add(common.ConfigBasePath) + if err != nil { + logrus.Panicf("unable to create fs watcher: %s", err) + } + helper := &fileWatcherHelper{ + w: w, + m: make(map[string]*fileWatcherStream), + } + go helper.start() + return helper +} + +func (h *fileWatcherHelper) Add(ctx context.Context, w *fileWatcher) (<-chan Event, <-chan E.NestedError) { + h.mu.Lock() + defer h.mu.Unlock() + + // check if the watcher already exists + s, ok := h.m[w.filename] + if ok { + return s.eventCh, s.errCh + } + s = &fileWatcherStream{ + fileWatcher: w, + stopped: make(chan struct{}), + eventCh: make(chan Event), + errCh: make(chan E.NestedError), + } + go func() { + select { + case <-ctx.Done(): + h.Remove(w) + return + case <-s.stopped: + return + } + }() + h.m[w.filename] = s + return s.eventCh, s.errCh +} + +func (h *fileWatcherHelper) Remove(w *fileWatcher) { + h.mu.Lock() + defer h.mu.Unlock() + + h.m[w.filename].stopped <- struct{}{} + delete(h.m, w.filename) +} + +// deinit closes the fs watcher +// and waits for the start() loop to finish +func (h *fileWatcherHelper) close() { + _ = h.w.Close() + h.wg.Wait() // wait for `start()` loop to finish +} + +func (h *fileWatcherHelper) start() { + defer h.wg.Done() + + for { + select { + case event, ok := <-h.w.Events: + if !ok { + // closed manually? + fsLogger.Error("channel closed") + return + } + // retrieve the watcher + w, ok := h.m[path.Base(event.Name)] + if !ok { + // watcher for this file does not exist + continue + } + + msg := Event{ActorName: w.filename} + switch { + case event.Has(fsnotify.Create): + msg.Action = ActionCreated + case event.Has(fsnotify.Write): + msg.Action = ActionModified + case event.Has(fsnotify.Remove), event.Has(fsnotify.Rename): + msg.Action = ActionDeleted + default: // ignore other events + continue + } + + // send event + w.eventCh <- msg + case err := <-h.w.Errors: + if errors.Is(err, fsnotify.ErrClosed) { + // closed manually? + return + } + fsLogger.Error(err) + } + } +} + +var fsLogger = logrus.WithField("?", "fsnotify") diff --git a/src/watcher/watcher.go b/src/watcher/watcher.go new file mode 100644 index 0000000..59c46cd --- /dev/null +++ b/src/watcher/watcher.go @@ -0,0 +1,11 @@ +package watcher + +import ( + "context" + + E "github.com/yusing/go-proxy/error" +) + +type Watcher interface { + Events(ctx context.Context) (<-chan Event, <-chan E.NestedError) +} diff --git a/version.txt b/version.txt index c650d5a..f73fb1c 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -0.4.8 \ No newline at end of file +0.5.0-beta \ No newline at end of file