Compare commits

...

64 Commits

Author SHA1 Message Date
yusing
996b418ea9 v0.5-rc1: updated Dockerfile to conform latest format 2024-09-16 13:24:53 +08:00
yusing
4cddd4ff71 v0.5-rc1: schema fixes, provider file example update 2024-09-16 13:19:24 +08:00
yusing
7a0478164f v0.5: (BREAKING) replacing path with path_patterns, improved docker monitoring mechanism, bug fixes 2024-09-16 13:05:04 +08:00
yusing
2e7ba51521 v0.5: (BREAKING) new syntax for set_headers and hide_headers, updated label parser, error.Nil().String() will now return 'nil', better readme 2024-09-16 07:21:45 +08:00
yusing
5be8659a99 v0.5: (BREAKING) simplified config format, improved output formatting, fixed docker watcher 2024-09-16 03:48:39 +08:00
default
719693deb7 v0.5: (BREAKING) simplified config format, improved error output, updated proxy entry default value for 'port' 2024-08-14 02:41:11 +08:00
default
23e7d06081 v0.5: removed system service env, log output format fix 2024-08-13 06:00:22 +08:00
default
85fb637551 v0.5: fixed nil dereference for empty autocert config, fixed and simplified 'error' module, small readme and docs update 2024-08-13 04:59:34 +08:00
default
2fc82c3790 v0.5: remove binary build 2024-08-09 07:09:42 +08:00
default
a5a31a0d63 v0.5: readme and dockerfile update, removed old panel sources, added new frontend as submodule 2024-08-09 07:03:24 +08:00
default
73e481bc96 v0.5: dependencies update, EOF fix for PUT/POST /v1/file 2024-08-09 02:10:48 +08:00
default
93359110a2 preparing for v0.5 2024-08-01 10:06:42 +08:00
yusing
24778d1093 doc fix 2024-04-09 06:53:54 +00:00
yusing
830d0bdadd revert github workflow 2024-04-08 05:34:41 +00:00
yusing w
e12b356d0d Merge branch 'dev' into 'main'
Dev

See merge request yusing/go-proxy!3
2024-04-08 05:07:27 +00:00
yusing w
52549b6446 Dev 2024-04-08 05:07:27 +00:00
yusing
8694987ef9 version bump 2024-04-01 04:09:22 +00:00
yusing
b125b14bf6 added license 2024-04-01 04:08:48 +00:00
yusing
c782f365f9 readme update 2024-04-01 03:29:34 +00:00
yusing
72418a2056 added host network mode support, docs update, UDP fix 2024-04-01 03:23:30 +00:00
yusing
03bf425a38 fix selecting wrong port on remote docker provide 2024-03-31 21:33:09 +00:00
yusing
5fafa619ee version bump and binary doc fix 2024-03-31 16:28:48 +00:00
yusing
bebf99ed6c docker example update' 2024-03-31 11:45:23 +00:00
yusing
8483263d01 readme update 2024-03-31 11:32:16 +00:00
yusing
351bf84559 tcp/udp fix 2024-03-31 11:26:39 +00:00
yusing
cbe23d2ed1 tcp/udp fix 2024-03-31 07:04:08 +00:00
yusing
6e45f3683c docs fix 2024-03-30 00:39:05 +00:00
yusing
581894c05b binary setup script fix 2024-03-30 00:15:05 +00:00
yusing
2657b1f726 binary setup script fix 2024-03-29 23:07:17 +00:00
yusing
3505e8ff7e systemd service param update 2024-03-29 23:03:56 +00:00
yusing
2314e39291 workflow update 2024-03-29 22:53:20 +00:00
yusing
bd19f443d4 Merge branch 'main' of github.com:yusing/go-proxy 2024-03-29 22:43:13 +00:00
yusing
ce433f0c51 script update for auto version discovery, dockerfile fix for CI 2024-03-29 22:40:11 +00:00
Yuzerion
47877e5119 Merge pull request #12 from yusing/dev
0.4.4
2024-03-30 06:07:34 +08:00
yusing
486122f3d8 no timestamp, color and sorting in systemd mode 2024-03-29 21:45:29 +00:00
yusing
a0be1f11d3 script systemd auto restart on crash 2024-03-29 21:43:43 +00:00
yusing
662190e09e scripts fix 2024-03-29 21:29:06 +00:00
yusing
ce1e5da72e scripts fix 2024-03-29 21:22:51 +00:00
yusing
eb7e744a75 scripts fix 2024-03-29 21:20:18 +00:00
yusing
ac26baf97f scripts fix 2024-03-29 21:12:38 +00:00
yusing
5a8c11de16 docs update, added setup scripts 2024-03-29 21:02:21 +00:00
yusing
a8ecafcd09 workflow updte 2024-03-29 19:21:05 +00:00
Yuzerion
af37d1f29e Merge pull request #10 from yusing/test-go-workflow
Update go.yml
2024-03-30 03:06:16 +08:00
Yuzerion
8cfd24e6bd Update go.yml 2024-03-30 00:09:08 +08:00
Yuzerion
7bf5784016 Merge pull request #9 from yusing/test-go-workflow
Create go.yml
2024-03-30 00:01:49 +08:00
Yuzerion
25930a1a73 Create go.yml 2024-03-30 00:00:35 +08:00
Yuzerion
f20a1ff523 Merge pull request #7 from yusing/test-docker-image
Create docker-image.yml
2024-03-29 23:58:03 +08:00
Yuzerion
ba51796a64 Create docker-image.yml 2024-03-29 23:56:55 +08:00
yusing
c445d50221 smarter port selection 2024-03-29 13:55:28 +00:00
yusing
73dfc17a82 smarter port selection 2024-03-29 13:35:10 +00:00
yusing
fdab026a3b fix docker port discovery 2024-03-29 13:20:44 +00:00
yusing
c789c69c86 codemirror 5 fix for config edit 2024-03-29 13:13:26 +00:00
Yuzerion
2b298aa7fa Update README.md 2024-03-29 21:01:18 +08:00
yusing
d20e4d435a verify -> validate 2024-03-29 01:50:00 +00:00
yusing
15d9436d52 readme update 2024-03-29 01:47:13 +00:00
yusing
ca98b31458 fix default config value 2024-03-29 01:38:58 +00:00
yusing
77f957c7a8 makefile update 2024-03-29 01:31:51 +00:00
yusing
51493c9fdd makefile update 2024-03-29 01:28:44 +00:00
yusing
9b34dc994d added new file button in config editor, dockerfile fix 2024-03-29 01:24:47 +00:00
yusing
6bc4c1c49a fixed http redirect to https when no cert available 2024-03-28 05:59:25 +00:00
yusing
443dd99b5b readme update 2024-03-27 07:09:05 +00:00
yusing
db6f857aaf readme update 2024-03-27 07:05:11 +00:00
yusing
6a54fc85ac typos fix and url update for schema 2024-03-27 06:33:02 +00:00
yusing
90f4aac946 fixes, meaningful error messages and new features 2024-03-27 06:30:47 +00:00
126 changed files with 6129 additions and 3735 deletions

21
.github/workflows/docker-image.yml vendored Normal file
View File

@@ -0,0 +1,21 @@
name: Docker Image CI
on:
push:
tags:
- "*"
jobs:
build_and_push:
runs-on: ubuntu-latest
steps:
- name: Set up Docker Build and Push
id: docker_build_push
uses: GlueOps/github-actions-build-push-containers@v0.3.7
with:
tags: ${{ github.ref_name }}
- name: Tag as latest
if: startsWith(github.ref, 'refs/tags/v') && !contains(github.ref_name, '-')
run: |
docker tag ghcr.io/${{ github.repository }}:${{ github.ref_name }} ghcr.io/${{ github.repository }}:latest
docker push ghcr.io/${{ github.repository }}:latest

21
.gitignore vendored
View File

@@ -1,7 +1,18 @@
compose.yml
go-proxy.yml
config.yml
providers.yml
bin/go-proxy.bak
config/
certs/
bin/
templates/codemirror/
logs/
log/
log/
.vscode/settings.json
go.work.sum
!src/config/
todo.md

15
.gitlab-ci.yml Normal file
View File

@@ -0,0 +1,15 @@
build-image:
image: docker
rules:
- if: $CI_COMMIT_REF_NAME == $CI_DEFAULT_BRANCH
variables:
CI_REGISTRY_IMAGE: $CI_REGISTRY_IMAGE:latest
- if: $CI_COMMIT_REF_NAME != $CI_DEFAULT_BRANCH
variables:
CI_REGISTRY_IMAGE: $CI_REGISTRY_IMAGE:$CI_COMMIT_BRANCH
before_script:
- echo $CI_REGISTRY_PASSWORD | docker login -u $CI_REGISTRY_USER $CI_REGISTRY --password-stdin
script:
- echo building $CI_REGISTRY_IMAGE
- docker build --pull -t $CI_REGISTRY_IMAGE .
- docker push $CI_REGISTRY_IMAGE

3
.gitmodules vendored Normal file
View File

@@ -0,0 +1,3 @@
[submodule "frontend"]
path = frontend
url = https://github.com/yusing/go-proxy-frontend

13
.vscode/settings.example.json vendored Normal file
View File

@@ -0,0 +1,13 @@
{
"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",
"providers.yml"
]
}
}

View File

@@ -1,3 +0,0 @@
{
"go.inferGopath": false
}

View File

@@ -1,22 +1,29 @@
FROM golang:1.23.1-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
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 github.com/yusing/go-proxy
FROM alpine:latest
LABEL maintainer="yusing@6uo.me"
RUN apk add --no-cache bash tzdata
RUN mkdir /app
COPY bin/go-proxy entrypoint.sh /app/
COPY templates/ /app/templates
COPY config.example.yml /app/config.yml
RUN apk add --no-cache tzdata
# copy binary
COPY --from=builder /src/go-proxy /app/
COPY schema/ /app/schema
RUN chmod +x /app/go-proxy /app/entrypoint.sh
ENV DOCKER_HOST unix:///var/run/docker.sock
ENV GOPROXY_DEBUG 0
ENV GOPROXY_REDIRECT_HTTP 1
RUN chmod +x /app/go-proxy
ENV DOCKER_HOST=unix:///var/run/docker.sock
ENV GOPROXY_DEBUG=0
EXPOSE 80
EXPOSE 8080
EXPOSE 8888
EXPOSE 443
EXPOSE 8443
WORKDIR /app
ENTRYPOINT /app/entrypoint.sh
CMD ["/app/go-proxy"]

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 [fullname]
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -2,34 +2,38 @@
all: build quick-restart logs
setup:
mkdir -p config certs
[ -f config/config.yml ] || cp config.example.yml config/config.yml
[ -f config/providers.yml ] || touch config/providers.yml
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:
cd src && go test ./... && cd ..
up:
docker compose up -d --build go-proxy
quick-restart: # quick restart without restarting the container
docker cp bin/go-proxy go-proxy:/app/go-proxy
docker cp templates/* go-proxy:/app/templates
docker cp entrypoint.sh go-proxy:/app/entrypoint.sh
docker exec -d go-proxy bash /app/entrypoint.sh restart
docker compose up -d
restart:
docker kill go-proxy
docker compose up -d go-proxy
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 ..
udp-server:
docker run -it --rm \
-p 9999:9999/udp \
--label proxy.test-udp.scheme=udp \
--label proxy.test-udp.port=20003:9999 \
--network data_default \
--name test-udp \
$$(docker build -q -f udp-test-server.Dockerfile .)
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

431
README.md
View File

@@ -1,386 +1,123 @@
# 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
<!-- TOC -->
- [Key Points](#key-points)
- [How to use](#how-to-use)
- [Binary](#binary)
- [Docker](#docker)
- [Configuration](#configuration)
- [Labels](#labels)
- [Environment Variables](#environment-variables)
- [Config File](#config-file)
- [Provider File](#provider-file)
- [Supported Cert Providers](#supported-cert-providers)
- [Examples](#examples)
- [Single Port Configuration](#single-port-configuration-example)
- [Multiple Ports Configuration](#multiple-ports-configuration-example)
- [TCP/UDP Configuration](#tcpudp-configuration-example)
- [Load balancing Configuration](#load-balancing-configuration-example)
- [Troubleshooting](#troubleshooting)
- [Benchmarks](#benchmarks)
- [Memory usage](#memory-usage)
- [Build it yourself](#build-it-yourself)
- [go-proxy](#go-proxy)
- [Key Points](#key-points)
- [Getting Started](#getting-started)
- [Commands line arguments](#commands-line-arguments)
- [Environment variables](#environment-variables)
- [Use JSON Schema in VSCode](#use-json-schema-in-vscode)
- [Config File](#config-file)
- [Provider File](#provider-file)
- [Known issues](#known-issues)
- [Build it yourself](#build-it-yourself)
## Key Points
- fast, nearly no performance penalty for end users when comparing to direct IP connections (See [benchmarks](#benchmarks))
- auto detect reverse proxies from docker
- additional reverse proxies from provider yaml file
- allow multiple docker / file providers by custom `config.yml` file
- auto certificate obtaining and renewal (See [Config File](#config-file) and [Supported Cert Providers](#supported-cert-providers))
- subdomain matching **(domain name doesn't matter)**
- path matching
- HTTP proxy
- TCP/UDP Proxy
- HTTP round robin load balance support (same subdomain and path across different hosts)
- Auto hot-reload on container start / die / stop or config changes.
- Simple panel to see all reverse proxies and health (visit port [panel port] of go-proxy `https://*.y.z:[panel port]`)
- 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)**
- you can customize it by modifying [templates/panel.html](templates/panel.html)
[🔼Back to top](#table-of-content)
![panel screenshot](screenshots/panel.png)
## Getting Started
## How to use
1. Setup DNS Records
1. Download and extract the latest release (or clone the repository if you want to try out experimental features)
- A Record: `*.y.z` -> `10.0.10.1`
- AAAA Record: `*.y.z` -> `::ffff:a00:a01`
2. Copy `config.example.yml` to `config.yml` and modify the content to fit your needs
2. Setup `go-proxy` [See here](docs/docker.md)
3. Do the same for `providers.example.yml`
3. Configure `go-proxy`
- with text editor (i.e. Visual Studio Code)
- or with web config editor via `http://gp.y.z`
4. See [Binary](#binary) or [docker](#docker)
[🔼Back to top](#table-of-content)
### Binary
### Commands line arguments
1. (Optional) enabled HTTPS
| Argument | Description |
| ---------- | -------------------------------- |
| empty | start proxy server |
| `validate` | validate config and exit |
| `reload` | trigger a force reload of config |
- Use autocert feature by completing `autocert` in `config.yml`
- Use existing certificate
Prepare your wildcard (`*.y.z`) SSL cert in `certs/`
- cert / chain / fullchain: `./certs/cert.crt`
- private key: `./certs/priv.key`
2. run the binary `bin/go-proxy`
3. enjoy
### Docker
1. Copy content from [compose.example.yml](compose.example.yml) and create your own `compose.yml`
2. Add networks to make sure it is in the same network with other containers, or make sure `proxy.<alias>.host` is reachable
3. (Optional) enable HTTPS
- Use autocert feature
1. mount `./certs` to `/app/certs`
```yaml
go-proxy:
...
volumes:
- ./certs:/app/certs
```
2. complete `autocert` in `config.yml`
- Use existing certificate
Mount your wildcard (`*.y.z`) SSL cert to enable https. See [Getting SSL Certs](#getting-ssl-certs)
- cert / chain / fullchain -> `/app/certs/cert.crt`
- private key -> `/app/certs/priv.key`
4. Start `go-proxy` with `docker compose up -d` or `make up`.
5. (Optional) If you are using ufw with vpn that drop all inbound traffic except vpn, run below to allow docker containers to connect to `go-proxy`
In case the network of your container is in subnet `172.16.0.0/16` (bridge),
and vpn network is under `100.64.0.0/10` (i.e. tailscale)
`sudo ufw allow from 172.16.0.0/16 to 100.64.0.0/10`
You can also list CIDRs of all docker bridge networks by:
`docker network inspect $(docker network ls | awk '$3 == "bridge" { print $1}') | jq -r '.[] | .Name + " " + .IPAM.Config[0].Subnet' -`
6. start your docker app, and visit <container_name>.y.z
7. check the logs with `docker compose logs` or `make logs` to see if there is any error, check panel at [panel port] for active proxies
## Known issues
None
## Configuration
With container name, most of the time no label needs to be added.
### Labels
- `proxy.aliases`: comma separated aliases for subdomain matching
- defaults to `container_name`
- `proxy.*.<field>`: wildcard config for all aliases
- `proxy.<alias>.scheme`: container port protocol (`http` or `https`)
- defaults to `http`
- `proxy.<alias>.host`: proxy host
- defaults to `container_name`
- `proxy.<alias>.port`: proxy port
- http/https: defaults to first expose port (declared in `Dockerfile` or `docker-compose.yml`)
- tcp/udp: is in format of `[<listeningPort>:]<targetPort>`
- when `listeningPort` is omitted (not suggested), a free port will be used automatically.
- `targetPort` must be a number, or the predefined names (see [constants.go:14](src/go-proxy/constants.go#L14))
- `proxy.<alias>.no_tls_verify`: whether skip tls verify when scheme is https
- defaults to false
- `proxy.<alias>.path`: path matching (for http proxy only)
- defaults to empty
- `proxy.<alias>.path_mode`: mode for path handling
- defaults to empty
- allowed: \<empty>, forward, sub
- empty: remove path prefix from URL when proxying
1. apps.y.z/webdav -> webdav:80
2. apps.y.z./webdav/path/to/file -> webdav:80/path/to/file
- 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="/path/to/file"` -> `href="/app1/path/to/file"`
- `proxy.<alias>.load_balance`: enable load balance
- allowed: `1`, `true`
**run with `docker exec <container_name> /app/go-proxy <command>`**
### Environment variables
- `GOPROXY_DEBUG`: set to `1` or `true` to enable debug behaviors (i.e. output, etc.)
- `GOPROXY_REDIRECT_HTTP`: set to `0` or `false` to disable http to https redirect (only when certs are located)
| Environment Variable | Description | Default | Values |
| ------------------------------ | ------------------------- | ------- | ------- |
| `GOPROXY_NO_SCHEMA_VALIDATION` | disable schema validation | `false` | boolean |
| `GOPROXY_DEBUG` | enable debug behaviors | `false` | boolean |
### 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)
### Config File
See [config.example.yml](config.example.yml)
See [config.example.yml](config.example.yml) for more
```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:
include:
- providers.yml
- other_file_1.yml
- ...
docker:
local: $DOCKER_HOST
remote-1: tcp://10.0.2.1:2375
remote-2: ssh://root:1234@10.0.2.2
```
[🔼Back to top](#table-of-content)
### Provider File
See [providers.example.yml](providers.example.yml)
Fields are same as [docker labels](docs/docker.md#labels) starting from `scheme`
### Supported cert providers
See [providers.example.yml](providers.example.yml) for examples
- Cloudflare
[🔼Back to top](#table-of-content)
```yaml
autocert:
...
options:
auth_token: "YOUR_ZONE_API_TOKEN"
```
## Known issues
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
- Cert "renewal" is actually obtaining a new cert instead of renewing the existing one
## Examples
### Single port configuration example
```yaml
# (default) https://<container_name>.y.z
whoami:
image: traefik/whoami
container_name: whoami # => whoami.y.z
# enable both subdomain and path matching:
whoami:
image: traefik/whoami
container_name: whoami
labels:
- proxy.aliases=whoami,apps
- proxy.apps.path=/whoami
# 1. visit https://whoami.y.z
# 2. visit https://apps.y.z/whoami
```
### Multiple ports configuration example
```yaml
minio:
image: quay.io/minio/minio
container_name: minio
...
labels:
- proxy.aliases=minio,minio-console
- proxy.minio.port=9000
- proxy.minio-console.port=9001
# visit https://minio.y.z to access minio
# visit https://minio-console.y.z/whoami to access minio console
```
### TCP/UDP configuration example
```yaml
# In the app
app-db:
image: postgres:15
container_name: app-db
...
labels:
# Optional (postgres is in the known image map)
- proxy.app-db.scheme=tcp
# Optional (first free port will be used for listening port)
- proxy.app-db.port=20000:postgres
# In go-proxy
go-proxy:
...
ports:
- 80:80
...
- 20000:20000/tcp
# or 20000-20010:20000-20010/tcp to declare large range at once
# access app-db via <*>.y.z:20000
```
## Load balancing Configuration Example
```yaml
nginx:
...
deploy:
mode: replicated
replicas: 3
labels:
- proxy.nginx.load_balance=1 # allowed: [1, true]
```
## Troubleshooting
Q: How to fix when it shows "no matching route for subdomain \<subdomain>"?
A: Make sure the container is running, and \<subdomain> matches any container name / alias
## 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
```
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
```
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`
```
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
```
## Memory usage
It takes ~13 MB for 50 proxy entries
[🔼Back to top](#table-of-content)
## Build it yourself
1. Install [go](https://go.dev/doc/install) and `make` if not already
1. Install / Upgrade [go (>=1.22)](https://go.dev/doc/install) and `make` if not already
2. get dependencies with `make get`
2. Clear cache if you have built this before (go < 1.22) with `go clean -cache`
3. build binary with `make build`
3. get dependencies with `make get`
4. start your container with `make up` (docker) or `bin/go-proxy` (binary)
4. build binary with `make build`
[panel port]: 8443
5. start your container with `make up` (docker) or `bin/go-proxy` (binary)
[🔼Back to top](#table-of-content)

Binary file not shown.

View File

@@ -1,51 +1,29 @@
version: '3'
services:
frontend:
image: ghcr.io/yusing/go-proxy-frontend:latest
container_name: go-proxy-frontend
restart: unless-stopped
network_mode: host
labels:
- proxy.*.aliases=gp
depends_on:
- app
app:
build: .
image: ghcr.io/yusing/go-proxy:latest
container_name: go-proxy
restart: always
networks: # ^also add here
- default
# environment:
# - GOPROXY_DEBUG=1 # (optional, enable only for debug)
# - GOPROXY_REDIRECT_HTTP=0 # (optional, uncomment to disable http redirect (http -> https))
ports:
- 80:80 # http
# - 443:443 # optional, https
- 8080:8080 # http panel
# - 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:
# use existing certificate
# - /path/to/cert.pem:/app/certs/cert.crt:ro
# - /path/to/privkey.pem:/app/certs/priv.key:ro
# use autocert feature
# - ./certs:/app/certs
# if local docker provider is used (by default)
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./config:/app/config
# to use custom config
# - path/to/config.yml:/app/config.yml
# (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`
# mount file provider yaml files
# - path/to/provider1.yml:/app/provider1.yml
# - path/to/provider2.yml:/app/provider2.yml
# etc.
dns:
- 127.0.0.1 # workaround for "lookup: no such host"
extra_hosts:
# required if you use local docker provider and have containers in `host` network_mode
- 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
# - /path/to/certs:/app/certs
# 2. use autocert, certs will be stored in ./certs (or other path you specify)
# - ./certs:/app/certs

View File

@@ -1,25 +1,37 @@
# uncomment to use autocert
# 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:
# email: "user@y.z" # email for acme certificate
# domains:
# - "*.y.z" # domain for acme certificate, use wild card to allow all subdomains
# provider: cloudflare
# email: # ACME Email
# domains: # a list of domains for cert registration
# - x.y.z
# options:
# auth_token: "YOUR_ZONE_API_TOKEN"
# - auth_token: c1234565789-abcdefghijklmnopqrst # your zone API token
# 3. other providers, check readme for more
providers:
local:
kind: docker
include:
- providers.yml # config/providers.yml
# add some more below if you want
# - file1.yml # config/file_1.yml
# - file2.yml
docker:
# for value format, see https://docs.docker.com/reference/cli/dockerd/
value: FROM_ENV
# remote1:
# kind: docker
# value: ssh://user@10.0.1.1
# remote2:
# kind: docker
# value: tcp://10.0.1.1:2375
# provider1:
# kind: file
# value: provider1.yml
# provider2:
# kind: file
# value: provider2.yml
# $DOCKER_HOST implies unix:///var/run/docker.sock by default
local: $DOCKER_HOST
# add more docker providers if needed
# remote-1: tcp://10.0.2.1:2375
# remote-2: ssh://root:1234@10.0.2.2
# Fixed options (optional, non hot-reloadable)
# timeout_shutdown: 5
# redirect_to_https: false

41
docs/add_dns_provider.md Normal file
View File

@@ -0,0 +1,41 @@
# Adding provider support
## **CloudDNS** as an example
1. Fork this repo, modify [autocert.go](../src/go-proxy/autocert.go#L305)
```go
var providersGenMap = map[string]ProviderGenerator{
"cloudflare": providerGenerator(cloudflare.NewDefaultConfig, cloudflare.NewDNSProviderConfig),
// add here, i.e.
"clouddns": providerGenerator(clouddns.NewDefaultConfig, clouddns.NewDNSProviderConfig),
}
```
2. Go to [https://go-acme.github.io/lego/dns/clouddns](https://go-acme.github.io/lego/dns/clouddns/) and check for required config
3. Build `go-proxy` with `make build`
4. Set required config in `config.yml` `autocert` -> `options` section
```shell
# From https://go-acme.github.io/lego/dns/clouddns/
CLOUDDNS_CLIENT_ID=bLsdFAks23429841238feb177a572aX \
CLOUDDNS_EMAIL=you@example.com \
CLOUDDNS_PASSWORD=b9841238feb177a84330f \
lego --email you@example.com --dns clouddns --domains my.example.org run
```
Should turn into:
```yaml
autocert:
...
options:
client_id: bLsdFAks23429841238feb177a572aX
email: you@example.com
password: b9841238feb177a84330f
```
5. Run with `GOPROXY_NO_SCHEMA_VALIDATION=1` and test if it works
6. Commit and create pull request

104
docs/benchmark_result.md Normal file
View File

@@ -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
```

32
docs/dns_providers.md Normal file
View File

@@ -0,0 +1,32 @@
# Supported DNS Providers
<!-- TOC -->
- [Cloudflare](#cloudflare)
- [CloudDNS](#clouddns)
- [DuckDNS](#duckdns)
- [Implement other DNS providers](#implement-other-dns-providers)
<!-- /TOC -->
## 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)

271
docs/docker.md Normal file
View File

@@ -0,0 +1,271 @@
# Docker compose guide
## Table of content
<!-- TOC -->
- [Docker compose guide](#docker-compose-guide)
- [Table of content](#table-of-content)
- [Setup](#setup)
- [Labels](#labels)
- [Syntax](#syntax)
- [Fields](#fields)
- [Key-value mapping example](#key-value-mapping-example)
- [List example](#list-example)
- [Troubleshooting](#troubleshooting)
- [Docker compose examples](#docker-compose-examples)
- [Services URLs for above examples](#services-urls-for-above-examples)
## Setup
1. Install `wget` if not already
- Ubuntu based: `sudo apt install -y wget`
- Fedora based: `sudo yum install -y wget`
- Arch based: `sudo pacman -Sy wget`
2. Run setup script
`bash <(wget -qO- https://github.com/yusing/go-proxy/raw/main/setup-docker.sh)`
It will setup folder structure and required config files
3. Verify folder structure and then `cd go-proxy`
```plain
go-proxy
├── certs
├── compose.yml
└── config
├── config.yml
└── providers.yml
```
4. Enable HTTPs _(optional)_
Mount a folder (to store obtained certs) or (containing existing cert)
```yaml
services:
go-proxy:
...
volumes:
- ./certs:/app/certs
```
To use **autocert**, complete that section in `config.yml`, e.g.
```yaml
autocert:
email: john.doe@x.y.z # ACME Email
domains: # a list of domains for cert registration
- x.y.z
provider: cloudflare
options:
- auth_token: c1234565789-abcdefghijklmnopqrst # your zone API token
```
To use **existing certificate**, set path for cert and key in `config.yml`, e.g.
```yaml
autocert:
cert_path: /app/certs/cert.crt
key_path: /app/certs/priv.key
```
5. Modify `compose.yml` to fit your needs
6. Run `docker compose up -d` to start the container
7. Navigate to Web panel `http://gp.yourdomain.com` or use **Visual Studio Code (provides schema check)** to edit proxy config
[🔼Back to top](#table-of-content)
## Labels
### Syntax
| Label | Description |
| ----------------------- | -------------------------------------------------------- |
| `proxy.aliases` | comma separated aliases for subdomain and label matching |
| `proxy.<alias>.<field>` | set field for specific alias |
| `proxy.*.<field>` | set field for all aliases |
### Fields
| Field | Description | Default | Allowed Values / Syntax |
| --------------------- | ---------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `scheme` | proxy protocol | <ul><li>`http` for numeric port</li><li>`tcp` for `x:y` port</li></ul> | `http`, `https`, `tcp`, `udp` |
| `host` | proxy host | <ul><li>Docker: `container_name`</li><li>File: `localhost`</li></ul> | IP address, hostname |
| `port` | proxy port **(http/s)** | first port in `ports:` | number in range of `1 - 65535` |
| `port` **(required)** | proxy port **(tcp/udp)** | N/A | `x:y` <br><ul><li>x: port for `go-proxy` to listen on</li><li>y: port or [_service name_](../src/common/constants.go#L55) of target container</li></ul> |
| `no_tls_verify` | whether skip tls verify **(https only)** | `false` | boolean |
| `path_patterns` | proxy path patterns **(http/s only)**<br> only requests that matched a pattern will be proxied | empty **(proxy all requests)** | yaml style list[<sup>1</sup>](#list-example) of path patterns ([syntax](https://pkg.go.dev/net/http#hdr-Patterns-ServeMux)) |
| `set_headers` | header to set **(http/s only)** | empty | yaml style key-value mapping[<sup>2</sup>](#key-value-mapping-example) of header-value pairs |
| `hide_headers` | header to hide **(http/s only)** | empty | yaml style list[<sup>1</sup>](#list-example) of headers |
#### Key-value mapping example
Docker Compose
```yaml
services:
nginx:
...
labels:
# values from duplicated header keys will be combined
proxy.nginx.set_headers: | # remember to add the '|'
X-Custom-Header1: value1, value2
X-Custom-Header2: value3
X-Custom-Header2: value4
# X-Custom-Header2 will be "value3, value4"
```
File Provider
```yaml
service_a:
host: service_a.internal
set_headers:
# do not duplicate header keys, as it is not allowed in YAML
X-Custom-Header1: value1, value2
X-Custom-Header2: value3
```
#### List example
Docker Compose
```yaml
services:
nginx:
...
labels:
proxy.nginx.path_patterns: | # remember to add the '|'
- GET /
- POST /auth
proxy.nginx.hide_headers: | # remember to add the '|'
- X-Custom-Header1
- X-Custom-Header2
```
File Provider
```yaml
service_a:
host: service_a.internal
path_patterns:
- GET /
- POST /auth
hide_headers:
- X-Custom-Header1
- X-Custom-Header2
```
[🔼Back to top](#table-of-content)
## Troubleshooting
- Firewall issues
If you are using `ufw` with vpn that drop all inbound traffic except vpn, run below:
`sudo ufw allow from 172.16.0.0/16 to 100.64.0.0/10`
Explaination:
Docker network is usually `172.16.0.0/16`
Tailscale is used as an example, `100.64.0.0/10` will be the CIDR
You can also list CIDRs of all docker bridge networks by:
`docker network inspect $(docker network ls | awk '$3 == "bridge" { print $1}') | jq -r '.[] | .Name + " " + .IPAM.Config[0].Subnet' -`
[🔼Back to top](#table-of-content)
## Docker compose examples
```yaml
volumes:
adg-work:
adg-conf:
mc-data:
palworld:
nginx:
services:
adg:
image: adguard/adguardhome
restart: unless-stopped
labels:
- proxy.aliases=adg,adg-dns,adg-setup
- proxy.adg.port=80
- proxy.adg-setup.port=3000
- proxy.adg-dns.scheme=udp
- proxy.adg-dns.port=20000:dns
volumes:
- adg-work:/opt/adguardhome/work
- adg-conf:/opt/adguardhome/conf
mc:
image: itzg/minecraft-server
tty: true
stdin_open: true
container_name: mc
restart: unless-stopped
labels:
- proxy.mc.scheme=tcp
- proxy.mc.port=20001:25565
environment:
- EULA=TRUE
volumes:
- mc-data:/data
palworld:
image: thijsvanloef/palworld-server-docker:latest
restart: unless-stopped
container_name: pal
stop_grace_period: 30s
labels:
- proxy.aliases=pal1,pal2
- proxy.*.scheme=udp
- proxy.pal1.port=20002:8211
- proxy.pal2.port=20003:27015
environment: ...
volumes:
- palworld:/palworld
nginx:
image: nginx
container_name: nginx
volumes:
- nginx:/usr/share/nginx/html
go-proxy:
image: ghcr.io/yusing/go-proxy:latest
container_name: go-proxy
restart: always
network_mode: host
volumes:
- ./config:/app/config
- /var/run/docker.sock:/var/run/docker.sock:ro
go-proxy-frontend:
image: ghcr.io/yusing/go-proxy-frontend:latest
container_name: go-proxy-frontend
restart: unless-stopped
network_mode: host
labels:
- proxy.*.aliases=gp
depends_on:
- go-proxy
```
[🔼Back to top](#table-of-content)
### Services URLs for above examples
- `gp.yourdomain.com`: go-proxy web panel
- `adg-setup.yourdomain.com`: adguard setup (first time setup)
- `adg.yourdomain.com`: adguard dashboard
- `nginx.yourdomain.com`: nginx
- `yourdomain.com:53`: adguard dns
- `yourdomain.com:25565`: minecraft server
- `yourdomain.com:8211`: palworld server
[🔼Back to top](#table-of-content)

View File

@@ -1,11 +0,0 @@
#!/bin/bash
if [ "$1" == "restart" ]; then
echo "restarting"
killall go-proxy
fi
if [ "$GOPROXY_DEBUG" == "1" ]; then
/app/go-proxy 2> log/go-proxy.log &
tail -f /dev/null
else
/app/go-proxy
fi

1
frontend Submodule

Submodule frontend added at 8cdf9eaa10

52
go.mod
View File

@@ -1,52 +0,0 @@
module github.com/yusing/go-proxy
go 1.21.7
require (
github.com/docker/cli v26.0.0+incompatible
github.com/docker/docker v26.0.0+incompatible
github.com/fsnotify/fsnotify v1.7.0
github.com/go-acme/lego/v4 v4.16.1
github.com/sirupsen/logrus v1.9.3
golang.org/x/net v0.22.0
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/Microsoft/go-winio v0.6.1 // indirect
github.com/cenkalti/backoff/v4 v4.2.1 // indirect
github.com/cloudflare/cloudflare-go v0.91.0 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/distribution/reference v0.5.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-logr/stdr v1.2.2 // indirect
github.com/goccy/go-json v0.10.2 // 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/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.49.0 // indirect
go.opentelemetry.io/otel v1.24.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.24.0 // indirect
go.opentelemetry.io/otel/metric v1.24.0 // indirect
go.opentelemetry.io/otel/sdk v1.24.0 // indirect
go.opentelemetry.io/otel/trace v1.24.0 // indirect
golang.org/x/crypto v0.21.0 // indirect
golang.org/x/mod v0.16.0 // indirect
golang.org/x/sys v0.18.0 // indirect
golang.org/x/text v0.14.0 // indirect
golang.org/x/time v0.5.0 // indirect
golang.org/x/tools v0.19.0 // indirect
gotest.tools/v3 v3.5.1 // indirect
)

5
go.work Normal file
View File

@@ -0,0 +1,5 @@
go 1.22
toolchain go1.23.1
use ./src

View File

@@ -1,15 +1,22 @@
app: # matching `app.y.z`
# optional
scheme: http
# required, proxy target
example: # matching `app.y.z`
scheme: https
host: 10.0.0.1
# optional
port: 80
# optional, defaults to empty
path:
# optional
path_mode:
# optional
notlsverify: false
# app2:
# ...
path_patterns: # Check https://pkg.go.dev/net/http#hdr-Patterns-ServeMux for syntax
- GET / # accept any GET request
- POST /auth # for /auth and /auth/* accept only POST
- GET /home/{$}
- /b/{bucket}/o/{any}
no_tls_verify: false
set_headers:
HEADER_A: VALUE_A, VALUE_B
HEADER_B: VALUE_C
hide_headers:
- HEADER_C
- HEADER_D
app1:
host: some_host
app2:
scheme: tcp
host: 10.0.0.2
port: 20000:tcp

207
schema/config.schema.json Normal file
View File

@@ -0,0 +1,207 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"title": "go-proxy config file",
"properties": {
"autocert": {
"title": "Autocert configuration",
"type": "object",
"properties": {
"email": {
"title": "ACME Email",
"type": "string",
"pattern": "^[\\w-\\.]+@([\\w-]+\\.)+[\\w-]{2,4}$",
"patternErrorMessage": "Invalid email"
},
"domains": {
"title": "Cert Domains",
"type": "array",
"items": {
"type": "string"
},
"minItems": 1
},
"cert_path": {
"title": "path of cert file to load/store",
"default": "certs/cert.crt",
"markdownDescription": "default: `certs/cert.crt`",
"type": "string"
},
"key_path": {
"title": "path of key file to load/store",
"default": "certs/priv.key",
"markdownDescription": "default: `certs/priv.key`",
"type": "string"
},
"provider": {
"title": "DNS Challenge Provider",
"default": "local",
"type": "string",
"enum": ["local", "cloudflare", "clouddns", "duckdns"]
},
"options": {
"title": "Provider specific options",
"type": "object"
}
},
"allOf": [
{
"if": {
"not": {
"properties": {
"provider": {
"const": "local"
}
}
}
},
"then": {
"required": ["email", "domains", "provider", "options"]
}
},
{
"if": {
"properties": {
"provider": {
"const": "cloudflare"
}
}
},
"then": {
"properties": {
"options": {
"required": ["auth_token"],
"additionalProperties": false,
"properties": {
"auth_token": {
"description": "Cloudflare API Token with Zone Scope",
"type": "string"
}
}
}
}
}
},
{
"if": {
"properties": {
"provider": {
"const": "clouddns"
}
}
},
"then": {
"properties": {
"options": {
"required": ["client_id", "email", "password"],
"additionalProperties": false,
"properties": {
"client_id": {
"description": "CloudDNS Client ID",
"type": "string"
},
"email": {
"description": "CloudDNS Email",
"type": "string"
},
"password": {
"description": "CloudDNS Password",
"type": "string"
}
}
}
}
}
},
{
"if": {
"properties": {
"provider": {
"const": "duckdns"
}
}
},
"then": {
"properties": {
"options": {
"required": ["token"],
"additionalProperties": false,
"properties": {
"token": {
"description": "DuckDNS Token",
"type": "string"
}
}
}
}
}
}
]
},
"providers": {
"title": "Proxy providers configuration",
"type": "object",
"additionalProperties": false,
"properties": {
"include": {
"title": "Proxy providers configuration files",
"description": "relative path to 'config'",
"type": "array",
"items": {
"type": "string",
"pattern": "^[a-zA-Z0-9_-]+\\.(yml|yaml)$",
"patternErrorMessage": "Invalid file name"
}
},
"docker": {
"title": "Docker provider configuration",
"description": "docker clients (name-address pairs)",
"type": "object",
"patternProperties": {
"^[a-zA-Z0-9-_]+$": {
"type": "string",
"examples": [
"unix:///var/run/docker.sock",
"tcp://127.0.0.1:2375",
"ssh://user@host:port"
],
"oneOf": [
{
"const": "$DOCKER_HOST",
"description": "Use DOCKER_HOST environment variable"
},
{
"pattern": "^unix://.+$",
"description": "A Unix socket for local Docker communication."
},
{
"pattern": "^ssh://.+$",
"description": "An SSH connection to a remote Docker host."
},
{
"pattern": "^fd://.+$",
"description": "A file descriptor for Docker communication."
},
{
"pattern": "^tcp://.+$",
"description": "A TCP connection to a remote Docker host."
}
]
}
}
}
}
},
"timeout_shutdown": {
"title": "Shutdown timeout (in seconds)",
"type": "integer",
"minimum": 0
},
"redirect_to_https": {
"title": "Redirect to HTTPS on HTTP requests",
"type": "boolean"
}
},
"additionalProperties": false,
"required": ["providers"]
}

View File

@@ -0,0 +1,195 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "go-proxy providers file",
"oneOf": [
{
"type": "object"
},
{
"type": "null"
}
],
"patternProperties": {
"^[a-zA-Z0-9_-]+$": {
"title": "Proxy entry",
"type": "object",
"properties": {
"scheme": {
"title": "Proxy scheme (http, https, tcp, udp)",
"oneOf": [
{
"type": "string",
"enum": [
"http",
"https",
"tcp",
"udp",
"tcp:tcp",
"udp:udp",
"tcp:udp",
"udp:tcp"
]
},
{
"type": "null",
"description": "Auto detect base on port format"
}
]
},
"host": {
"default": "localhost",
"oneOf": [
{
"type": "null",
"description": "localhost (default)"
},
{
"type": "string",
"format": "ipv4",
"description": "Proxy to ipv4 address"
},
{
"type": "string",
"format": "ipv6",
"description": "Proxy to ipv6 address"
},
{
"type": "string",
"format": "hostname",
"description": "Proxy to hostname"
}
],
"title": "Proxy host (ipv4 / ipv6 / hostname)"
},
"port": {},
"no_tls_verify": {},
"path_patterns": {},
"set_headers": {},
"hide_headers": {}
},
"additionalProperties": false,
"allOf": [
{
"if": {
"properties": {
"scheme": {
"anyOf": [
{
"enum": ["http", "https"]
},
{
"type": "null"
}
]
}
}
},
"then": {
"properties": {
"port": {
"markdownDescription": "Proxy port from **1** to **65535**",
"oneOf": [
{
"type": "string",
"pattern": "^\\d{1,5}$",
"patternErrorMessage": "`port` must be a number"
},
{
"type": "integer",
"minimum": 1,
"maximum": 65535
}
]
},
"path_patterns": {
"oneOf": [
{
"type": "array",
"markdownDescription": "A list of [path patterns](https://pkg.go.dev/net/http#hdr-Patterns-ServeMux)",
"items": {
"type": "string",
"pattern": "^((GET|POST|DELETE|PUT|PATCH|HEAD|OPTIONS|CONNECT)\\s)?(/(\\w*|{\\w*}|{\\$}))+/?$",
"patternErrorMessage": "invalid path pattern"
}
},
{
"type": "null",
"description": "No proxy path"
}
]
},
"set_headers": {
"type": "object",
"description": "Proxy headers to set",
"additionalProperties": {
"items": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
},
"hide_headers": {
"type": "array",
"description": "Proxy headers to hide",
"items": {
"type": "string"
}
}
}
},
"else": {
"properties": {
"port": {
"markdownDescription": "`listening port:proxy port` or `listening port:service name`",
"type": "string",
"pattern": "^[0-9]+\\:[0-9a-z]+$",
"patternErrorMessage": "invalid syntax"
},
"no_tls_verify": {
"not": true
},
"path_patterns": {
"not": true
},
"set_headers": {
"not": true
},
"hide_headers": {
"not": true
}
},
"required": ["port"]
}
},
{
"if": {
"properties": {
"scheme": {
"const": "https"
}
}
},
"then": {
"properties": {
"no_tls_verify": {
"description": "Disable TLS verification for https proxy",
"type": "boolean",
"default": false
}
}
},
"else": {
"properties": {
"no_tls_verify": {
"not": true
}
}
}
}
]
}
},
"additionalProperties": false
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 149 KiB

14
setup-docker.sh Normal file
View File

@@ -0,0 +1,14 @@
#!/bin/sh
set -e
if [ -z "$BRANCH" ]; then
BRANCH="main"
fi
BASE_URL="https://github.com/yusing/go-proxy/raw/${BRANCH}"
mkdir -p go-proxy
cd go-proxy
mkdir -p config
mkdir -p certs
[ -f compose.yml ] || wget -cO - ${BASE_URL}/compose.example.yml > compose.yml
[ -f config/config.yml ] || wget -cO - ${BASE_URL}/config.example.yml > config/config.yml
[ -f config/providers.yml ] || touch config/providers.yml

30
src/api/handler.go Normal file
View File

@@ -0,0 +1,30 @@
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("POST /v1/file/{filename}", v1.SetFileContent)
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)
}
}

39
src/api/v1/checkhealth.go Normal file
View File

@@ -0,0 +1,39 @@
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:
ok = U.IsSiteHealthy(route.TargetURL.String())
case *R.StreamRoute:
ok = U.IsStreamHealthy(
string(route.Scheme.ProxyScheme),
fmt.Sprintf("%s:%v", route.Host, route.Port.ProxyPort),
)
}
if ok {
w.WriteHeader(http.StatusOK)
} else {
w.WriteHeader(http.StatusRequestTimeout)
}
}

58
src/api/v1/file.go Normal file
View File

@@ -0,0 +1,58 @@
package v1
import (
"io"
"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, err := E.Check(io.ReadAll(r.Body))
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, http.StatusBadRequest)
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)
}

7
src/api/v1/index.go Normal file
View File

@@ -0,0 +1,7 @@
package v1
import "net/http"
func Index(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("API ready"))
}

62
src/api/v1/list.go Normal file
View File

@@ -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)
}

16
src/api/v1/reload.go Normal file
View File

@@ -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)
}

20
src/api/v1/stats.go Normal file
View File

@@ -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)
}
}

32
src/api/v1/utils/error.go Normal file
View File

@@ -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("module", "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)
}

62
src/api/v1/utils/net.go Normal file
View File

@@ -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},
},
}

17
src/api/v1/utils/utils.go Normal file
View File

@@ -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
}

78
src/autocert/config.go Normal file
View File

@@ -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()
}

31
src/autocert/constants.go Normal file
View File

@@ -0,0 +1,31 @@
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{
"": providerGenerator(NewDummyDefaultConfig, NewDummyDNSProviderConfig),
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("module", "autocert")

20
src/autocert/dummy.go Normal file
View File

@@ -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
}

258
src/autocert/provider.go Normal file
View File

@@ -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("module", "autocert")

22
src/autocert/user.go Normal file
View File

@@ -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
}

39
src/common/args.go Normal file
View File

@@ -0,0 +1,39 @@
package common
import (
"flag"
"github.com/sirupsen/logrus"
E "github.com/yusing/go-proxy/error"
)
type Args struct {
Command string
}
const (
CommandStart = ""
CommandValidate = "validate"
CommandReload = "reload"
)
var ValidCommands = []string{CommandStart, CommandValidate, CommandReload}
func GetArgs() Args {
var args Args
flag.Parse()
args.Command = flag.Arg(0)
if err := validateArgs(args.Command, ValidCommands); err.IsNotNil() {
logrus.Fatal(err)
}
return args
}
func validateArgs[T comparable](arg T, validArgs []T) E.NestedError {
for _, v := range validArgs {
if arg == v {
return E.Nil()
}
}
return E.Invalid("argument", arg)
}

103
src/common/constants.go Normal file
View File

@@ -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 = "$DOCKER_HOST"
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,
}

23
src/common/env.go Normal file
View File

@@ -0,0 +1,23 @@
package common
import (
"os"
"strings"
"github.com/sirupsen/logrus"
)
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"
}

257
src/config/config.go Normal file
View File

@@ -0,0 +1,257 @@
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("module", "config"),
reader: U.NewFileReader(common.ConfigPath),
watcher: W.NewFileWatcher(common.ConfigFileName),
reloadReq: make(chan struct{}, 1),
}
if err := cfg.load(); err.IsNotNil() {
return nil, err
}
cfg.startProviders()
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["type"] = p.GetType()
stats["num_streams"] = nStreams
stats["num_reverse_proxies"] = nRPs
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
}
}
warnings := E.NewBuilder("errors loading config")
cfg.l.Debug("starting autocert")
ap, err := autocert.NewConfig(&model.AutoCert).GetProvider()
if err.IsNotNil() {
warnings.Add(E.Failure("autocert provider").With(err))
} else {
cfg.l.Debug("started autocert")
}
cfg.autocertProvider = ap
cfg.l.Debug("loading providers")
cfg.proxyProviders = F.NewMap[string, *PR.Provider]()
for _, filename := range model.Providers.Files {
p := PR.NewFileProvider(filename)
cfg.proxyProviders.Set(p.GetName(), p)
}
for name, dockerHost := range model.Providers.Docker {
p := PR.NewDockerProvider(name, dockerHost)
cfg.proxyProviders.Set(p.GetName(), p)
}
cfg.l.Debug("loaded providers")
cfg.value = model
if err := warnings.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).Subject(p))
}
})
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 routes", (*PR.Provider).StopAllRoutes)
}

94
src/docker/client.go Normal file
View File

@@ -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("module", "docker")

48
src/docker/client_info.go Normal file
View File

@@ -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)
}

View File

@@ -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"

78
src/docker/label.go Normal file
View File

@@ -0,0 +1,78 @@
package docker
import (
"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)

View File

@@ -0,0 +1,70 @@
package docker
import (
"strings"
E "github.com/yusing/go-proxy/error"
"gopkg.in/yaml.v3"
)
func yamlListParser(value string) (any, E.NestedError) {
value = strings.TrimSpace(value)
if value == "" {
return []string{}, E.Nil()
}
var data []string
err := E.From(yaml.Unmarshal([]byte(value), &data))
return data, err
}
func yamlStringMappingParser(value string) (any, E.NestedError) {
value = strings.TrimSpace(value)
lines := strings.Split(value, "\n")
h := make(map[string]string)
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])
if existing, ok := h[key]; ok {
h[key] = existing + ", " + val
} else {
h[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,
"path_patterns": yamlListParser,
"set_headers": yamlStringMappingParser,
"hide_headers": yamlListParser,
"no_tls_verify": boolParser,
})
return 0
}()

View File

@@ -0,0 +1,169 @@
package docker
import (
"fmt"
"reflect"
"strings"
"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 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.Error())
}
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) {
v := "bar"
pl, err := ParseLabel(makeLabel(NSProxy, "foo", "ip"), v)
if err.IsNotNil() {
t.Errorf("expected err=nil, got %s", err.Error())
}
if pl.Value != v {
t.Errorf("expected value=%q, got %s", v, pl.Value)
}
}
func TestBoolProxyLabelValid(t *testing.T) {
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, "foo", "no_tls_verify"), k)
if err.IsNotNil() {
t.Errorf("expected err=nil, got %s", err.Error())
}
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 !err.Is(E.ErrInvalid) {
t.Errorf("expected err InvalidProxyLabel, got %s", err.Error())
}
}
func TestSetHeaderProxyLabelValid(t *testing.T) {
v := `
X-Custom-Header1: foo, bar
X-Custom-Header1: baz
X-Custom-Header2: boo`
v = strings.TrimPrefix(v, "\n")
h := map[string]string{
"X-Custom-Header1": "foo, bar, baz",
"X-Custom-Header2": "boo",
}
pl, err := ParseLabel(makeLabel(NSProxy, "foo", "set_headers"), v)
if err.IsNotNil() {
t.Errorf("expected err=nil, got %s", err.Error())
}
hGot, ok := pl.Value.(map[string]string)
if !ok {
t.Errorf("value is not a map[string]string, but %T", pl.Value)
return
}
if !reflect.DeepEqual(h, hGot) {
t.Errorf("expected %v, got %v", h, hGot)
}
}
func TestSetHeaderProxyLabelInvalid(t *testing.T) {
tests := []string{
"X-Custom-Header1 = bar",
"X-Custom-Header1",
"- X-Custom-Header1",
}
for _, v := range tests {
_, err := ParseLabel(makeLabel(NSProxy, "foo", "set_headers"), v)
if !err.Is(E.ErrInvalid) {
t.Errorf("expected invalid err for %q, got %s", v, err.Error())
}
}
}
func TestHideHeadersProxyLabel(t *testing.T) {
v := `
- X-Custom-Header1
- X-Custom-Header2
- X-Custom-Header3
`
v = strings.TrimPrefix(v, "\n")
pl, err := ParseLabel(makeLabel(NSProxy, "foo", "hide_headers"), v)
if err.IsNotNil() {
t.Errorf("expected err=nil, got %s", err.Error())
}
sGot, ok := pl.Value.([]string)
sWant := []string{"X-Custom-Header1", "X-Custom-Header2", "X-Custom-Header3"}
if !ok {
t.Errorf("value is not []string, but %T", pl.Value)
}
if !reflect.DeepEqual(sGot, sWant) {
t.Errorf("expected %q, got %q", sWant, sGot)
}
}
func TestCommaSepProxyLabelSingle(t *testing.T) {
v := "a"
pl, err := ParseLabel("proxy.aliases", v)
if err.IsNotNil() {
t.Errorf("expected err=nil, got %s", err.Error())
}
sGot, ok := pl.Value.([]string)
sWant := []string{"a"}
if !ok {
t.Errorf("value is not []string, but %T", pl.Value)
}
if !reflect.DeepEqual(sGot, sWant) {
t.Errorf("expected %q, got %q", sWant, sGot)
}
}
func TestCommaSepProxyLabelMulti(t *testing.T) {
v := "X-Custom-Header1, X-Custom-Header2,X-Custom-Header3"
pl, err := ParseLabel("proxy.aliases", v)
if err.IsNotNil() {
t.Errorf("expected err=nil, got %s", err.Error())
}
sGot, ok := pl.Value.([]string)
sWant := []string{"X-Custom-Header1", "X-Custom-Header2", "X-Custom-Header3"}
if !ok {
t.Errorf("value is not []string, but %T", pl.Value)
}
if !reflect.DeepEqual(sGot, sWant) {
t.Errorf("expected %q, got %q", sWant, sGot)
}
}

43
src/error/builder.go Normal file
View File

@@ -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...)
}

28
src/error/builder_test.go Normal file
View File

@@ -0,0 +1,28 @@
package error
import "testing"
func TestBuilder(t *testing.T) {
eb := NewBuilder("error occurred")
eb.Add(Failure("Action 1").With(Invalid("Inner", "1")).With(Invalid("Inner", "2")))
eb.Add(Failure("Action 2").With(Invalid("Inner", "3")))
got := eb.Build().Error()
expected1 :=
(`error occurred:
- Action 1 failed:
- invalid Inner - 1
- invalid Inner - 2
- Action 2 failed:
- invalid Inner - 3`)
expected2 :=
(`error occurred:
- Action 1 failed:
- invalid Inner - 2
- invalid Inner - 1
- Action 2 failed:
- invalid Inner - 3`)
if got != expected1 && got != expected2 {
t.Errorf("expected \n%s, got \n%s", expected1, got)
}
}

168
src/error/error.go Normal file
View File

@@ -0,0 +1,168 @@
package error
import (
"errors"
"fmt"
"strings"
)
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 {
subject string
err error // can be nil
extras []NestedError
}
)
func Nil() NestedError { return NestedError{} }
func From(err error) NestedError {
switch err := err.(type) {
case nil:
return Nil()
case NestedError:
return err
default:
return NestedError{err: 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([]NestedError, 0, len(err))
nErr := 0
for _, e := range err {
if err == nil {
continue
}
extras = append(extras, From(e))
nErr += 1
}
if nErr == 0 {
return Nil()
}
return NestedError{
err: errors.New(message),
extras: extras,
}
}
func (ne NestedError) Error() string {
var buf strings.Builder
ne.writeToSB(&buf, 0, "")
return buf.String()
}
func (ne NestedError) Is(err error) bool {
return errors.Is(ne.err, err)
}
func (ne NestedError) With(s any) NestedError {
var msg string
switch ss := s.(type) {
case nil:
return ne
case error:
return ne.withError(ss)
case string:
msg = ss
case fmt.Stringer:
msg = ss.String()
default:
msg = fmt.Sprint(s)
}
return ne.withError(errors.New(msg))
}
func (ne NestedError) Extraf(format string, args ...any) NestedError {
return ne.With(fmt.Errorf(format, args...))
}
func (ne NestedError) Subject(s any) NestedError {
switch ss := s.(type) {
case string:
ne.subject = ss
case fmt.Stringer:
ne.subject = ss.String()
default:
ne.subject = fmt.Sprint(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")
}
ne.subject = fmt.Sprintf(format, args...)
return ne
}
func (ne NestedError) IsNil() bool {
return ne.err == nil
}
func (ne NestedError) IsNotNil() bool {
return ne.err != nil
}
func errorf(format string, args ...any) NestedError {
return From(fmt.Errorf(format, args...))
}
func (ne NestedError) withError(err error) NestedError {
ne.extras = append(ne.extras, From(err))
return ne
}
func (ne *NestedError) writeToSB(sb *strings.Builder, level int, prefix string) {
ne.writeIndents(sb, level)
sb.WriteString(prefix)
if ne.IsNil() {
sb.WriteString("nil")
return
}
sb.WriteString(ne.err.Error())
if ne.subject != "" {
if ne.err != nil {
sb.WriteString(fmt.Sprintf(" for %q", ne.subject))
} else {
sb.WriteString(fmt.Sprint(ne.subject))
}
}
if len(ne.extras) > 0 {
sb.WriteRune(':')
for _, extra := range ne.extras {
sb.WriteRune('\n')
extra.writeToSB(sb, level+1, "- ")
}
}
}
func (ne *NestedError) writeIndents(sb *strings.Builder, level int) {
for i := 0; i < level; i++ {
sb.WriteString(" ")
}
}

80
src/error/error_test.go Normal file
View File

@@ -0,0 +1,80 @@
package error
import (
"testing"
)
func AssertEq[T comparable](t *testing.T, got, want T) {
t.Helper()
if got != want {
t.Errorf("expected:\n%v, got\n%v", want, got)
}
}
func TestErrorIs(t *testing.T) {
AssertEq(t, Failure("foo").Is(ErrFailure), true)
AssertEq(t, Failure("foo").With("bar").Is(ErrFailure), true)
AssertEq(t, Failure("foo").With("bar").Is(ErrInvalid), false)
AssertEq(t, Failure("foo").With("bar").With("baz").Is(ErrInvalid), false)
AssertEq(t, Invalid("foo", "bar").Is(ErrInvalid), true)
AssertEq(t, Invalid("foo", "bar").Is(ErrFailure), false)
AssertEq(t, Nil().Is(nil), true)
AssertEq(t, Nil().Is(ErrInvalid), false)
AssertEq(t, Invalid("foo", "bar").Is(nil), false)
}
func TestNil(t *testing.T) {
AssertEq(t, Nil().IsNil(), true)
AssertEq(t, Nil().IsNotNil(), false)
AssertEq(t, Nil().Error(), "nil")
}
func TestErrorSimple(t *testing.T) {
ne := Failure("foo bar")
AssertEq(t, ne.Error(), "foo bar failed")
ne = ne.Subject("baz")
AssertEq(t, ne.Error(), "foo bar failed for \"baz\"")
}
func TestErrorWith(t *testing.T) {
ne := Failure("foo").With("bar").With("baz")
AssertEq(t, ne.Error(), "foo failed:\n - bar\n - baz")
}
func TestErrorNested(t *testing.T) {
inner := Failure("inner").
With("1").
With("1")
inner2 := Failure("inner2").
Subject("action 2").
With("2").
With("2")
inner3 := Failure("inner3").
Subject("action 3").
With("3").
With("3")
ne := Failure("foo").
With("bar").
With("baz").
With(inner).
With(inner.With(inner2.With(inner3)))
want :=
`foo failed:
- bar
- baz
- inner failed:
- 1
- 1
- inner failed:
- 1
- 1
- inner2 failed for "action 2":
- 2
- 2
- inner3 failed for "action 3":
- 3
- 3`
AssertEq(t, ne.Error(), want)
}

33
src/error/errors.go Normal file
View File

@@ -0,0 +1,33 @@
package error
import (
stderrors "errors"
)
var (
ErrFailure = stderrors.New("failed")
ErrInvalid = stderrors.New("invalid")
ErrUnsupported = stderrors.New("unsupported")
ErrNotExists = stderrors.New("does not exist")
ErrDuplicated = stderrors.New("duplicated")
)
func Failure(what string) NestedError {
return errorf("%s %w", what, ErrFailure)
}
func Invalid(subject, what any) NestedError {
return errorf("%w %v - %v", ErrInvalid, subject, what)
}
func Unsupported(subject, what any) NestedError {
return errorf("%w %v - %v", ErrUnsupported, subject, what)
}
func NotExists(subject, what any) NestedError {
return errorf("%s %v - %v", subject, ErrNotExists, what)
}
func Duplicated(subject, what any) NestedError {
return errorf("%w %v: %v", ErrDuplicated, subject, what)
}

View File

@@ -1,297 +0,0 @@
package main
import (
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"fmt"
"os"
"path"
"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/cloudflare"
"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
Domains []string `yaml:",flow"`
Provider string
Options ProviderOptions `yaml:",flow"`
}
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() error
RenewalOn() time.Time
ScheduleRenewal()
}
func (cfg AutoCertConfig) GetProvider() (AutoCertProvider, error) {
if len(cfg.Domains) == 0 {
return nil, fmt.Errorf("no domains specified")
}
if cfg.Provider == "" {
return nil, fmt.Errorf("no provider specified")
}
if cfg.Email == "" {
return nil, fmt.Errorf("no email specified")
}
privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, fmt.Errorf("unable to generate private key: %v", 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, fmt.Errorf("unable to create lego client: %v", err)
}
base := &autoCertProvider{
name: cfg.Provider,
cfg: cfg,
user: user,
legoCfg: legoCfg,
client: legoClient,
}
gen, ok := providersGenMap[cfg.Provider]
if !ok {
return nil, fmt.Errorf("unknown provider: %s", cfg.Provider)
}
legoProvider, err := gen(cfg.Options)
if err != nil {
return nil, fmt.Errorf("unable to create provider: %v", err)
}
err = legoClient.Challenge.SetDNS01Provider(legoProvider)
if err != nil {
return nil, fmt.Errorf("unable to set challenge provider: %v", 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 {
aclog.Fatal("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() error {
client := p.client
if p.user.Registration == nil {
reg, err := client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true})
if err != nil {
return err
}
p.user.Registration = reg
}
req := certificate.ObtainRequest{
Domains: p.cfg.Domains,
Bundle: true,
}
cert, err := client.Certificate.Obtain(req)
if err != nil {
return err
}
err = p.saveCert(cert)
if err != nil {
return err
}
tlsCert, err := tls.X509KeyPair(cert.Certificate, cert.PrivateKey)
if err != nil {
return err
}
expiries, err := getCertExpiries(&tlsCert)
if err != nil {
return 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) RenewalOn() time.Time {
t := time.Now().AddDate(0, 0, 3)
for _, expiry := range p.certExpiries {
if expiry.Before(t) {
return time.Now()
}
return t
}
// this line should never be reached
panic("no certificate available")
}
func (p *autoCertProvider) ScheduleRenewal() {
for {
t := time.Until(p.RenewalOn())
aclog.Infof("next renewal in %v", t)
time.Sleep(t)
err := p.renewIfNeeded()
if err != nil {
aclog.Fatal(err)
}
}
}
func (p *autoCertProvider) saveCert(cert *certificate.Resource) error {
err := os.MkdirAll(path.Dir(certFileDefault), 0644)
if err != nil {
return fmt.Errorf("unable to create cert directory: %v", err)
}
err = os.WriteFile(keyFileDefault, cert.PrivateKey, 0600) // -rw-------
if err != nil {
return fmt.Errorf("unable to write key file: %v", err)
}
err = os.WriteFile(certFileDefault, cert.Certificate, 0644) // -rw-r--r--
if err != nil {
return fmt.Errorf("unable to write cert file: %v", err)
}
return nil
}
func (p *autoCertProvider) needRenewal() bool {
return time.Now().After(p.RenewalOn())
}
func (p *autoCertProvider) renewIfNeeded() error {
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 {
return nil
}
trials++
if trials > 3 {
return fmt.Errorf("unable to renew certificate: %v after 3 trials", err)
}
aclog.Errorf("failed to renew certificate: %v, trying again in 5 seconds", err)
time.Sleep(5 * time.Second)
}
}
func providerGenerator[CT interface{}, 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, 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 err
}
}
return nil
}
var providersGenMap = map[string]ProviderGenerator{
"cloudflare": providerGenerator(cloudflare.NewDefaultConfig, cloudflare.NewDNSProviderConfig),
}

View File

@@ -1,108 +0,0 @@
package main
import (
"fmt"
"os"
"sync"
"gopkg.in/yaml.v3"
)
// commented out if unused
type Config interface {
// Load() error
MustLoad()
GetAutoCertProvider() (AutoCertProvider, error)
// MustReload()
// Reload() error
StartProviders()
StopProviders()
WatchChanges()
StopWatching()
}
func NewConfig() Config {
cfg := &config{}
cfg.watcher = NewFileWatcher(
configPath,
cfg.MustReload, // OnChange
func() { os.Exit(1) }, // OnDelete
)
return cfg
}
func (cfg *config) Load() error {
cfg.mutex.Lock()
defer cfg.mutex.Unlock()
// unload if any
cfg.StopProviders()
data, err := os.ReadFile(configPath)
if err != nil {
return fmt.Errorf("unable to read config file: %v", err)
}
cfg.Providers = make(map[string]*Provider)
if err = yaml.Unmarshal(data, &cfg); err != nil {
return fmt.Errorf("unable to parse config file: %v", err)
}
for name, p := range cfg.Providers {
err := p.Init(name)
if err != nil {
cfgl.Errorf("failed to initialize provider %q %v", name, err)
cfg.Providers[name] = nil
}
}
return nil
}
func (cfg *config) MustLoad() {
if err := cfg.Load(); err != nil {
cfgl.Fatal(err)
}
}
func (cfg *config) GetAutoCertProvider() (AutoCertProvider, error) {
return cfg.AutoCert.GetProvider()
}
func (cfg *config) Reload() error {
return cfg.Load()
}
func (cfg *config) MustReload() {
cfg.MustLoad()
}
func (cfg *config) StartProviders() {
if cfg.Providers == nil {
cfgl.Fatal("providers not loaded")
}
// Providers have their own mutex, no lock needed
ParallelForEachValue(cfg.Providers, (*Provider).StartAllRoutes)
}
func (cfg *config) StopProviders() {
if cfg.Providers != nil {
// Providers have their own mutex, no lock needed
ParallelForEachValue(cfg.Providers, (*Provider).StopAllRoutes)
}
}
func (cfg *config) WatchChanges() {
cfg.watcher.Start()
}
func (cfg *config) StopWatching() {
cfg.watcher.Stop()
}
type config struct {
Providers map[string]*Provider `yaml:",flow"`
AutoCert AutoCertConfig `yaml:",flow"`
watcher Watcher
mutex sync.Mutex
}

View File

@@ -1,108 +0,0 @@
package main
import (
"crypto/tls"
"net"
"net/http"
"os"
"time"
"github.com/sirupsen/logrus"
)
var (
ImageNamePortMap = map[string]string{
"postgres": "5432",
"mysql": "3306",
"mariadb": "3306",
"redis": "6379",
"mssql": "1433",
"memcached": "11211",
"rabbitmq": "5672",
"mongo": "27017",
}
ExtraNamePortMap = map[string]string{
"dns": "53",
"ssh": "22",
"ftp": "21",
"smtp": "25",
"pop3": "110",
"imap": "143",
}
NamePortMap = func() map[string]string {
m := make(map[string]string)
for k, v := range ImageNamePortMap {
m[k] = v
}
for k, v := range ExtraNamePortMap {
m[k] = v
}
return m
}()
)
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
}()
)
const wildcardLabelPrefix = "proxy.*."
const clientUrlFromEnv = "FROM_ENV"
const (
certFileDefault = "certs/cert.crt"
keyFileDefault = "certs/priv.key"
configPath = "config.yml"
templatePath = "templates/panel.html"
)
const StreamStopListenTimeout = 1 * time.Second
const udpBufferSize = 1500
var logLevel = func() logrus.Level {
switch os.Getenv("GOPROXY_DEBUG") {
case "1", "true":
logrus.SetLevel(logrus.DebugLevel)
}
return logrus.GetLevel()
}()
var redirectHTTP = os.Getenv("GOPROXY_REDIRECT_HTTP") != "0" && os.Getenv("GOPROXY_REDIRECT_HTTP") != "false"

View File

@@ -1,202 +0,0 @@
package main
import (
"fmt"
"net/http"
"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 (p *Provider) setConfigField(c *ProxyConfig, label string, value string, prefix string) error {
if strings.HasPrefix(label, prefix) {
field := strings.TrimPrefix(label, prefix)
SetFieldFromSnake(c, field, value)
}
return nil
}
func (p *Provider) getContainerProxyConfigs(container types.Container, clientIP string) []*ProxyConfig {
var aliases []string
cfgs := make([]*ProxyConfig, 0)
container_name := strings.TrimPrefix(container.Names[0], "/")
aliases_label, ok := container.Labels["proxy.aliases"]
if !ok {
aliases = []string{container_name}
} else {
aliases = strings.Split(aliases_label, ",")
}
isRemote := clientIP != ""
for _, alias := range aliases {
l := p.l.WithField("container", container_name).WithField("alias", alias)
config := NewProxyConfig(p)
prefix := fmt.Sprintf("proxy.%s.", alias)
for label, value := range container.Labels {
err := p.setConfigField(&config, label, value, prefix)
if err != nil {
l.Error(err)
}
err = p.setConfigField(&config, label, value, wildcardLabelPrefix)
if err != nil {
l.Error(err)
}
}
if config.Port == "" {
config.Port = fmt.Sprintf("%d", selectPort(container))
}
if config.Port == "0" {
// no ports exposed or specified
l.Debugf("no ports exposed, ignored")
continue
}
if config.Scheme == "" {
switch {
case strings.HasSuffix(config.Port, "443"):
config.Scheme = "https"
case strings.HasPrefix(container.Image, "sha256:"):
config.Scheme = "http"
default:
imageSplit := strings.Split(container.Image, "/")
imageSplit = strings.Split(imageSplit[len(imageSplit)-1], ":")
imageName := imageSplit[0]
_, isKnownImage := ImageNamePortMap[imageName]
if isKnownImage {
config.Scheme = "tcp"
} else {
config.Scheme = "http"
}
}
}
if !isValidScheme(config.Scheme) {
l.Warnf("unsupported scheme: %s, using http", config.Scheme)
config.Scheme = "http"
}
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 = container_name
}
config.Alias = alias
cfgs = append(cfgs, &config)
}
return cfgs
}
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() ([]*ProxyConfig, error) {
var clientIP string
if p.Value == clientUrlFromEnv {
clientIP = ""
} else {
url, err := client.ParseHostURL(p.Value)
if err != nil {
return nil, fmt.Errorf("unable to parse docker host url: %v", err)
}
clientIP = strings.Split(url.Host, ":")[0]
}
dockerClient, err := p.getDockerClient()
if err != nil {
return nil, fmt.Errorf("unable to create docker client: %v", err)
}
ctx, _ := context.WithTimeout(context.Background(), 3*time.Second)
containerSlice, err := dockerClient.ContainerList(ctx, container.ListOptions{All: true})
if err != nil {
return nil, fmt.Errorf("unable to list containers: %v", err)
}
cfgs := make([]*ProxyConfig, 0)
for _, container := range containerSlice {
cfgs = append(cfgs, p.getContainerProxyConfigs(container, clientIP)...)
}
return cfgs, nil
}
// var dockerUrlRegex = regexp.MustCompile(`^(?P<scheme>\w+)://(?P<host>[^:]+)(?P<port>:\d+)?(?P<path>/.*)?$`)
func getPublicPort(p types.Port) uint16 { return p.PublicPort }
func getPrivatePort(p types.Port) uint16 { return p.PrivatePort }
func selectPort(c types.Container) uint16 {
if c.HostConfig.NetworkMode == "host" {
return selectPortInternal(c, getPrivatePort)
}
return selectPortInternal(c, getPublicPort)
}
func selectPortInternal(c types.Container, getPort func(types.Port) uint16) uint16 {
for _, p := range c.Ports {
if port := getPort(p); port != 0 {
return port
}
}
return 0
}

View File

@@ -1,39 +0,0 @@
package main
import (
"fmt"
"os"
"gopkg.in/yaml.v3"
)
func (p *Provider) getFileProxyConfigs() ([]*ProxyConfig, error) {
path := p.Value
if _, err := os.Stat(path); err == nil {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("unable to read config file %q: %v", path, err)
}
configMap := make(map[string]ProxyConfig, 0)
configs := make([]*ProxyConfig, 0)
err = yaml.Unmarshal(data, &configMap)
if err != nil {
return nil, fmt.Errorf("unable to parse config file %q: %v", path, err)
}
for alias, cfg := range configMap {
cfg.Alias = alias
err = cfg.SetDefaults()
if err != nil {
return nil, err
}
configs = append(configs, &cfg)
}
return configs, nil
} else if !os.IsNotExist(err) {
return nil, fmt.Errorf("file not found: %s", path)
} else {
return nil, err
}
}

View File

@@ -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()
}

View File

@@ -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)]
}

View File

@@ -1,177 +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) {
url, err := url.Parse(fmt.Sprintf("%s://%s:%s", config.Scheme, config.Host, config.Port))
if err != nil {
return nil, err
}
var tr *http.Transport
if config.NoTLSVerify {
tr = transportNoTLS
} else {
tr = transport
}
proxy := NewSingleHostReverseProxy(url, tr)
if !isValidProxyPathMode(config.PathMode) {
return nil, fmt.Errorf("invalid path mode: %s", config.PathMode)
}
route := &HTTPRoute{
Alias: config.Alias,
Url: url,
Path: config.Path,
Proxy: proxy,
PathMode: config.PathMode,
l: hrlog.WithFields(logrus.Fields{
"alias": config.Alias,
"path": config.Path,
"path_mode": config.PathMode,
}),
}
var rewriteBegin = proxy.Rewrite
var rewrite func(*ProxyRequest)
var modifyResponse func(*http.Response) error
switch {
case config.Path == "", config.PathMode == ProxyPathMode_Forward:
rewrite = rewriteBegin
case config.PathMode == 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 = func(r *http.Response) error {
contentType, ok := r.Header["Content-Type"]
if !ok || len(contentType) == 0 {
route.l.Debug("unknown content type for ", r.Request.URL.String())
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)
default:
route.l.Debug("unknown content type(s): ", contentType)
}
if err != nil {
err = fmt.Errorf("failed to remove path prefix %s: %v", config.Path, err)
route.l.WithField("action", "path_sub").Error(err)
r.Status = err.Error()
r.StatusCode = http.StatusInternalServerError
}
return err
}
default:
rewrite = func(pr *ProxyRequest) {
rewriteBegin(pr)
pr.Out.URL.Path = strings.TrimPrefix(pr.Out.URL.Path, config.Path)
}
}
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() {}
func (r *HTTPRoute) Stop() {
httpRoutes.Delete(r.Alias)
}
func isValidProxyPathMode(mode string) bool {
switch mode {
case ProxyPathMode_Forward, ProxyPathMode_Sub, ProxyPathMode_RemovedPath:
return true
default:
return false
}
}
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, fmt.Errorf("no matching route for subdomain %s", subdomain)
}
func proxyHandler(w http.ResponseWriter, r *http.Request) {
route, err := findHTTPRoute(r.Host, r.URL.Path)
if err != nil {
err = fmt.Errorf("request failed %s %s%s, error: %v",
r.Method,
r.Host,
r.URL.Path,
err,
)
logrus.Error(err)
http.Error(w, err.Error(), http.StatusNotFound)
return
}
route.Proxy.ServeHTTP(w, r)
}
// alias -> (path -> routes)
type HTTPRoutes = SafeMap[string, *pathPoolMap]
var httpRoutes HTTPRoutes = NewSafeMap[string](newPathPoolMap)

View File

@@ -1,11 +0,0 @@
package main
import "github.com/sirupsen/logrus"
var palog = logrus.WithField("component", "panel")
var prlog = logrus.WithField("component", "provider")
var cfgl = logrus.WithField("component", "config")
var hrlog = logrus.WithField("component", "http_proxy")
var srlog = logrus.WithField("component", "stream")
var wlog = logrus.WithField("component", "watcher")
var aclog = logrus.WithField("component", "autocert")

View File

@@ -1,96 +0,0 @@
package main
import (
"net/http"
"os"
"os/signal"
"runtime"
"syscall"
"github.com/sirupsen/logrus"
)
func main() {
runtime.GOMAXPROCS(runtime.NumCPU())
logrus.SetFormatter(&logrus.TextFormatter{
ForceColors: true,
DisableColors: false,
FullTimestamp: true,
})
cfg := NewConfig()
cfg.MustLoad()
autoCertProvider, err := cfg.GetAutoCertProvider()
if err != nil {
aclog.Warn(err)
autoCertProvider = nil
}
var httpProxyHandler http.Handler
var httpPanelHandler http.Handler
var proxyServer *Server
var panelServer *Server
if redirectHTTP {
httpProxyHandler = http.HandlerFunc(redirectToTLSHandler)
httpPanelHandler = http.HandlerFunc(redirectToTLSHandler)
} else {
httpProxyHandler = http.HandlerFunc(proxyHandler)
httpPanelHandler = http.HandlerFunc(panelHandler)
}
if autoCertProvider != nil {
ok := autoCertProvider.LoadCert()
if !ok {
err := autoCertProvider.ObtainCert()
if err != nil {
aclog.Fatal("error obtaining certificate ", err)
}
}
for name, expiry := range autoCertProvider.GetExpiries() {
aclog.Infof("certificate %q: expire on %v", name, expiry)
}
go autoCertProvider.ScheduleRenewal()
}
proxyServer = NewServer(
"proxy",
autoCertProvider,
":80",
httpProxyHandler,
":443",
http.HandlerFunc(proxyHandler),
)
panelServer = NewServer(
"panel",
autoCertProvider,
":8080",
httpPanelHandler,
":8443",
http.HandlerFunc(panelHandler),
)
proxyServer.Start()
panelServer.Start()
InitFSWatcher()
InitDockerWatcher()
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
// cfg.StopWatching()
cfg.StopProviders()
panelServer.Stop()
proxyServer.Stop()
}

View File

@@ -1,99 +0,0 @@
package main
import "sync"
type safeMap[KT comparable, VT interface{}] struct {
SafeMap[KT, VT]
m map[KT]VT
mutex sync.Mutex
defaultFactory func() VT
}
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 NewSafeMap[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.mutex.Lock()
m.m[key] = value
m.mutex.Unlock()
}
func (m *safeMap[KT, VT]) Ensure(key KT) {
m.mutex.Lock()
if _, ok := m.m[key]; !ok {
m.m[key] = m.defaultFactory()
}
m.mutex.Unlock()
}
func (m *safeMap[KT, VT]) Get(key KT) VT {
m.mutex.Lock()
value := m.m[key]
m.mutex.Unlock()
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.mutex.Lock()
delete(m.m, key)
m.mutex.Unlock()
}
func (m *safeMap[KT, VT]) Clear() {
m.mutex.Lock()
m.m = make(map[KT]VT)
m.mutex.Unlock()
}
func (m *safeMap[KT, VT]) Size() int {
m.mutex.Lock()
size := len(m.m)
m.mutex.Unlock()
return size
}
func (m *safeMap[KT, VT]) Contains(key KT) bool {
m.mutex.Lock()
_, ok := m.m[key]
m.mutex.Unlock()
return ok
}
func (m *safeMap[KT, VT]) ForEach(fn func(key KT, value VT)) {
m.mutex.Lock()
for k, v := range m.m {
fn(k, v)
}
m.mutex.Unlock()
}
func (m *safeMap[KT, VT]) Iterator() map[KT]VT {
return m.m
}

View File

@@ -1,100 +0,0 @@
package main
import (
"html/template"
"net"
"net/http"
"net/url"
"time"
)
var 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,
},
}
func panelHandler(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/":
panelIndex(w, r)
return
case "/checkhealth":
panelCheckTargetHealth(w, r)
return
default:
palog.Errorf("%s not found", r.URL.Path)
http.NotFound(w, r)
return
}
}
func panelIndex(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
tmpl, err := template.ParseFiles(templatePath)
if err != nil {
palog.Error(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
type allRoutes struct {
HTTPRoutes HTTPRoutes
StreamRoutes StreamRoutes
}
err = tmpl.Execute(w, allRoutes{
HTTPRoutes: httpRoutes,
StreamRoutes: streamRoutes,
})
if err != nil {
palog.Error(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func panelCheckTargetHealth(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodHead {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
targetUrl := r.URL.Query().Get("target")
if targetUrl == "" {
http.Error(w, "target is required", http.StatusBadRequest)
return
}
url, err := url.Parse(targetUrl)
if err != nil {
palog.Infof("failed to parse url %q, error: %v", targetUrl, err)
http.Error(w, err.Error(), 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)
}
}

View File

@@ -1,30 +0,0 @@
package main
import (
"fmt"
"strings"
)
type pathPoolMap struct {
SafeMap[string, *httpLoadBalancePool]
}
func newPathPoolMap() *pathPoolMap {
return &pathPoolMap{
NewSafeMap[string](NewHTTPLoadBalancePool),
}
}
func (m pathPoolMap) Add(path string, route *HTTPRoute) {
m.Ensure(path)
m.Get(path).Add(route)
}
func (m pathPoolMap) FindMatch(pathGot string) (*HTTPRoute, error) {
for pathWant, v := range m.Iterator() {
if strings.HasPrefix(pathGot, pathWant) {
return v.Pick(), nil
}
}
return nil, fmt.Errorf("no matching route for path %s", pathGot)
}

View File

@@ -1,101 +0,0 @@
package main
import (
"fmt"
"sync"
"github.com/sirupsen/logrus"
)
type Provider struct {
Kind string // docker, file
Value string
watcher Watcher
routes map[string]Route // id -> Route
mutex sync.Mutex
l logrus.FieldLogger
}
// Init is called after LoadProxyConfig
func (p *Provider) Init(name string) error {
p.l = prlog.WithFields(logrus.Fields{"kind": p.Kind, "name": name})
if err := p.loadProxyConfig(); err != nil {
return err
}
p.initWatcher()
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 = make(map[string]Route)
}
func (p *Provider) ReloadRoutes() {
p.mutex.Lock()
defer p.mutex.Unlock()
p.StopAllRoutes()
err := p.loadProxyConfig()
if err != nil {
p.l.Error("failed to reload routes: ", err)
return
}
p.StartAllRoutes()
}
func (p *Provider) loadProxyConfig() error {
var cfgs []*ProxyConfig
var err error
switch p.Kind {
case ProviderKind_Docker:
cfgs, err = p.getDockerProxyConfigs()
case ProviderKind_File:
cfgs, err = p.getFileProxyConfigs()
default:
// this line should never be reached
return fmt.Errorf("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))
for _, cfg := range cfgs {
r, err := NewRoute(cfg)
if err != nil {
p.l.Errorf("error creating route %s: %v", cfg.Alias, err)
continue
}
p.routes[cfg.GetID()] = r
}
return nil
}
func (p *Provider) initWatcher() error {
switch p.Kind {
case ProviderKind_Docker:
var err error
dockerClient, err := p.getDockerClient()
if err != nil {
return fmt.Errorf("unable to create docker client: %v", err)
}
p.watcher = NewDockerWatcher(dockerClient, p.ReloadRoutes)
case ProviderKind_File:
p.watcher = NewFileWatcher(p.Value, p.ReloadRoutes, p.StopAllRoutes)
}
return nil
}

View File

@@ -1,43 +0,0 @@
package main
import "fmt"
type ProxyConfig struct {
Alias string
Scheme string
Host string
Port string
LoadBalance string // docker provider only
NoTLSVerify bool // http proxy only
Path string // http proxy only
PathMode string `yaml:"path_mode"` // http proxy only
provider *Provider
}
func NewProxyConfig(provider *Provider) ProxyConfig {
return ProxyConfig{
provider: provider,
}
}
// used by `GetFileProxyConfigs`
func (cfg *ProxyConfig) SetDefaults() error {
if cfg.Alias == "" {
return fmt.Errorf("alias is required")
}
if cfg.Scheme == "" {
cfg.Scheme = "http"
}
if cfg.Host == "" {
return fmt.Errorf("host is required for %q", cfg.Alias)
}
if cfg.Port == "" {
cfg.Port = "80"
}
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)
}

View File

@@ -1,56 +0,0 @@
package main
import (
"fmt"
)
type Route interface {
Start()
Stop()
}
func NewRoute(cfg *ProxyConfig) (Route, error) {
if isStreamScheme(cfg.Scheme) {
id := cfg.GetID()
if streamRoutes.Contains(id) {
return nil, fmt.Errorf("duplicated %s stream %s, ignoring", cfg.Scheme, id)
}
route, err := NewStreamRoute(cfg)
if err != nil {
return nil, err
}
streamRoutes.Set(id, route)
return route, nil
} else {
httpRoutes.Ensure(cfg.Alias)
route, err := NewHTTPRoute(cfg)
if err != nil {
return nil, err
}
httpRoutes.Get(cfg.Alias).Add(cfg.Path, route)
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
}
// id -> target
type StreamRoutes = SafeMap[string, StreamRoute]
var streamRoutes = NewSafeMap[string, StreamRoute]()

View File

@@ -1,99 +0,0 @@
package main
import (
"crypto/tls"
"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
}
func NewServer(name string, provider AutoCertProvider, httpAddr string, httpHandler http.Handler, httpsAddr string, httpsHandler http.Handler) *Server {
if provider != nil {
return &Server{
Name: name,
CertProvider: provider,
http: &http.Server{
Addr: httpAddr,
Handler: httpHandler,
},
https: &http.Server{
Addr: httpsAddr,
Handler: httpsHandler,
TLSConfig: &tls.Config{
GetCertificate: provider.GetCert,
},
},
}
}
return &Server{
Name: name,
KeyFile: keyFileDefault,
CertFile: certFileDefault,
http: &http.Server{
Addr: httpAddr,
Handler: httpHandler,
},
https: &http.Server{
Addr: httpsAddr,
Handler: httpsHandler,
},
}
}
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 || utils.fileOK(s.CertFile) && utils.fileOK(s.KeyFile)) {
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)
}
}

View File

@@ -1,181 +0,0 @@
package main
import (
"errors"
"fmt"
"strconv"
"strings"
"sync"
"time"
"github.com/sirupsen/logrus"
)
type StreamRoute interface {
Route
ListeningUrl() string
TargetUrl() string
Logger() logrus.FieldLogger
closeListeners()
closeChannel()
unmarkPort()
wait()
}
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
stopChann chan struct{}
l logrus.FieldLogger
}
func newStreamRouteBase(config *ProxyConfig) (*StreamRouteBase, error) {
var streamType string = StreamType_TCP
var srcPort string
var dstPort string
var srcScheme string
var dstScheme string
port_split := strings.Split(config.Port, ":")
if len(port_split) != 2 {
cfgl.Warnf("invalid port %s, assuming it is target port", config.Port)
srcPort = "0"
dstPort = config.Port
} else {
srcPort = port_split[0]
dstPort = port_split[1]
}
if port, hasName := NamePortMap[dstPort]; hasName {
dstPort = port
}
srcPortInt, err := strconv.Atoi(srcPort)
if err != nil {
return nil, fmt.Errorf(
"invalid stream source port %s, ignoring", srcPort,
)
}
utils.markPortInUse(srcPortInt)
dstPortInt, err := strconv.Atoi(dstPort)
if err != nil {
return nil, fmt.Errorf(
"invalid stream target port %s, ignoring", dstPort,
)
}
scheme_split := strings.Split(config.Scheme, ":")
if len(scheme_split) == 2 {
srcScheme = scheme_split[0]
dstScheme = scheme_split[1]
} else {
srcScheme = config.Scheme
dstScheme = config.Scheme
}
return &StreamRouteBase{
Alias: config.Alias,
Type: streamType,
ListeningScheme: srcScheme,
ListeningPort: srcPortInt,
TargetScheme: dstScheme,
TargetHost: config.Host,
TargetPort: dstPortInt,
id: config.GetID(),
wg: sync.WaitGroup{},
stopChann: make(chan struct{}, 1),
l: srlog.WithFields(logrus.Fields{
"alias": config.Alias,
"src": fmt.Sprintf("%s://:%d", srcScheme, srcPortInt),
"dst": fmt.Sprintf("%s://%s:%d", dstScheme, config.Host, dstPortInt),
}),
}, nil
}
func NewStreamRoute(config *ProxyConfig) (StreamRoute, error) {
switch config.Scheme {
case StreamType_TCP:
return NewTCPRoute(config)
case StreamType_UDP:
return NewUDPRoute(config)
default:
return nil, errors.New("unknown stream type")
}
}
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) setupListen() {
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) wait() {
route.wg.Wait()
}
func (route *StreamRouteBase) closeChannel() {
close(route.stopChann)
}
func (route *StreamRouteBase) unmarkPort() {
utils.unmarkPortInUse(route.ListeningPort)
}
func stopListening(route StreamRoute) {
l := route.Logger()
l.Debug("stopping listening")
// close channel -> wait -> close listeners
route.closeChannel()
done := make(chan struct{})
go func() {
route.wait()
close(done)
route.unmarkPort()
}()
select {
case <-done:
l.Info("stopped listening")
case <-time.After(StreamStopListenTimeout):
l.Error("timed out waiting for connections")
}
route.closeListeners()
}

View File

@@ -1,132 +0,0 @@
package main
import (
"context"
"fmt"
"io"
"net"
"sync"
"time"
)
const tcpDialTimeout = 5 * time.Second
type TCPRoute struct {
*StreamRouteBase
listener net.Listener
connChan chan net.Conn
}
func NewTCPRoute(config *ProxyConfig) (StreamRoute, error) {
base, err := newStreamRouteBase(config)
if err != nil {
return nil, err
}
if base.TargetScheme != StreamType_TCP {
return nil, fmt.Errorf("tcp to %s not yet supported", base.TargetScheme)
}
return &TCPRoute{
StreamRouteBase: base,
listener: nil,
connChan: make(chan net.Conn),
}, nil
}
func (route *TCPRoute) Start() {
route.setupListen()
in, err := net.Listen("tcp", fmt.Sprintf(":%v", route.ListeningPort))
if err != nil {
route.l.Error(err)
return
}
route.listener = in
route.wg.Add(2)
go route.grAcceptConnections()
go route.grHandleConnections()
}
func (route *TCPRoute) Stop() {
stopListening(route)
streamRoutes.Delete(route.id)
}
func (route *TCPRoute) closeListeners() {
if route.listener == nil {
return
}
route.listener.Close()
route.listener = nil
}
func (route *TCPRoute) grAcceptConnections() {
defer route.wg.Done()
for {
select {
case <-route.stopChann:
return
default:
conn, err := route.listener.Accept()
if err != nil {
route.l.Error(err)
continue
}
route.connChan <- conn
}
}
}
func (route *TCPRoute) grHandleConnections() {
defer route.wg.Done()
for {
select {
case <-route.stopChann:
return
case conn := <-route.connChan:
route.wg.Add(1)
go route.grHandleConnection(conn)
}
}
}
func (route *TCPRoute) grHandleConnection(clientConn net.Conn) {
defer clientConn.Close()
defer route.wg.Done()
ctx, cancel := context.WithTimeout(context.Background(), tcpDialTimeout)
defer cancel()
serverAddr := fmt.Sprintf("%s:%v", route.TargetHost, route.TargetPort)
dialer := &net.Dialer{}
serverConn, err := dialer.DialContext(ctx, route.TargetScheme, serverAddr)
if err != nil {
route.l.WithField("stage", "dial").Infof("%v", err)
return
}
route.tcpPipe(clientConn, serverConn)
}
func (route *TCPRoute) tcpPipe(src net.Conn, dest net.Conn) {
close := func() {
src.Close()
dest.Close()
}
var wg sync.WaitGroup
wg.Add(2) // Number of goroutines
go func() {
_, err := io.Copy(src, dest)
route.l.Error(err)
close()
wg.Done()
}()
go func() {
_, err := io.Copy(dest, src)
route.l.Error(err)
close()
wg.Done()
}()
wg.Wait()
}

View File

@@ -1,243 +0,0 @@
package main
import (
"fmt"
"io"
"net"
"sync"
"github.com/sirupsen/logrus"
)
type UDPRoute struct {
*StreamRouteBase
connMap map[net.Addr]net.Conn
connMapMutex sync.Mutex
listeningConn *net.UDPConn
targetConn *net.UDPConn
connChan chan *UDPConn
}
type UDPConn struct {
remoteAddr net.Addr
buffer []byte
bytesReceived []byte
nReceived int
}
func NewUDPRoute(config *ProxyConfig) (StreamRoute, error) {
base, err := newStreamRouteBase(config)
if err != nil {
return nil, err
}
if base.TargetScheme != StreamType_UDP {
return nil, fmt.Errorf("udp to %s not yet supported", base.TargetScheme)
}
return &UDPRoute{
StreamRouteBase: base,
connMap: make(map[net.Addr]net.Conn),
connChan: make(chan *UDPConn),
}, nil
}
func (route *UDPRoute) Start() {
route.setupListen()
source, err := net.ListenPacket(route.ListeningScheme, fmt.Sprintf(":%v", route.ListeningPort))
if err != nil {
route.l.Error(err)
return
}
target, err := net.Dial(route.TargetScheme, fmt.Sprintf("%s:%v", route.TargetHost, route.TargetPort))
if err != nil {
route.l.Error(err)
source.Close()
return
}
route.listeningConn = source.(*net.UDPConn)
route.targetConn = target.(*net.UDPConn)
route.wg.Add(2)
go route.grAcceptConnections()
go route.grHandleConnections()
}
func (route *UDPRoute) Stop() {
stopListening(route)
streamRoutes.Delete(route.id)
}
func (route *UDPRoute) closeListeners() {
if route.listeningConn != nil {
route.listeningConn.Close()
route.listeningConn = nil
}
if route.targetConn != nil {
route.targetConn.Close()
route.targetConn = nil
}
for _, conn := range route.connMap {
conn.(*net.UDPConn).Close() // TODO: change on non udp target
}
route.connMap = make(map[net.Addr]net.Conn)
}
func (route *UDPRoute) grAcceptConnections() {
defer route.wg.Done()
for {
select {
case <-route.stopChann:
return
default:
conn, err := route.accept()
if err != nil {
route.l.Error(err)
continue
}
route.connChan <- conn
}
}
}
func (route *UDPRoute) grHandleConnections() {
defer route.wg.Done()
for {
select {
case <-route.stopChann:
return
case conn := <-route.connChan:
go func() {
err := route.handleConnection(conn)
if err != nil {
route.l.Error(err)
}
}()
}
}
}
func (route *UDPRoute) handleConnection(conn *UDPConn) error {
var err error
srcConn, ok := route.connMap[conn.remoteAddr]
if !ok {
route.connMapMutex.Lock()
srcConn, err = net.DialUDP("udp", nil, conn.remoteAddr.(*net.UDPAddr))
if err != nil {
return err
}
route.connMap[conn.remoteAddr] = srcConn
route.connMapMutex.Unlock()
}
var forwarder func(*UDPConn, net.Conn) error
if logLevel == logrus.DebugLevel {
forwarder = route.forwardReceivedDebug
} else {
forwarder = route.forwardReceivedReal
}
// initiate connection to target
err = forwarder(conn, route.targetConn)
if err != nil {
return err
}
for {
select {
case <-route.stopChann:
return nil
default:
// receive from target
conn, err = route.readFrom(route.targetConn, conn.buffer)
if err != nil {
return err
}
// forward to source
err = forwarder(conn, srcConn)
if err != nil {
return err
}
// read from source
conn, err = route.readFrom(srcConn, conn.buffer)
if err != nil {
continue
}
// forward to target
err = forwarder(conn, route.targetConn)
if err != nil {
return err
}
}
}
}
func (route *UDPRoute) accept() (*UDPConn, error) {
in := route.listeningConn
buffer := make([]byte, udpBufferSize)
nRead, srcAddr, err := in.ReadFromUDP(buffer)
if err != nil {
return nil, err
}
if nRead == 0 {
return nil, io.ErrShortBuffer
}
return &UDPConn{
remoteAddr: srcAddr,
buffer: buffer,
bytesReceived: buffer[:nRead],
nReceived: nRead},
nil
}
func (route *UDPRoute) readFrom(src net.Conn, buffer []byte) (*UDPConn, error) {
nRead, err := src.Read(buffer)
if err != nil {
return nil, err
}
if nRead == 0 {
return nil, io.ErrShortBuffer
}
return &UDPConn{
remoteAddr: src.RemoteAddr(),
buffer: buffer,
bytesReceived: buffer[:nRead],
nReceived: nRead,
}, nil
}
func (route *UDPRoute) forwardReceivedReal(receivedConn *UDPConn, dest net.Conn) error {
nWritten, err := dest.Write(receivedConn.bytesReceived)
if nWritten != receivedConn.nReceived {
err = io.ErrShortWrite
}
return err
}
func (route *UDPRoute) forwardReceivedDebug(receivedConn *UDPConn, dest net.Conn) error {
route.l.WithField("size", receivedConn.nReceived).Debugf(
"forwarding from %s to %s",
receivedConn.remoteAddr.String(),
dest.RemoteAddr().String(),
)
return route.forwardReceivedReal(receivedConn, dest)
}

View File

@@ -1,205 +0,0 @@
package main
import (
"bytes"
"fmt"
"io"
"net"
"net/http"
"os"
"path"
"path/filepath"
"reflect"
"regexp"
"strings"
"sync"
"time"
"github.com/sirupsen/logrus"
xhtml "golang.org/x/net/html"
)
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, fmt.Errorf("unable to find free port: %v", 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, 5*time.Second)
if err != nil {
return err
}
conn.Close()
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 fmt.Errorf("unknown field %s", field)
}
prop.Set(reflect.ValueOf(value))
return nil
}

View File

@@ -1,219 +0,0 @@
package main
import (
"path"
"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 {
name string // for log / error output
kind string // for log / error output
onChange func()
l logrus.FieldLogger
}
type fileWatcher struct {
*watcherBase
path string
onDelete func()
}
type dockerWatcher struct {
*watcherBase
client *client.Client
stopCh chan struct{}
wg sync.WaitGroup
}
func newWatcher(kind string, name string, onChange func()) *watcherBase {
return &watcherBase{
kind: kind,
name: name,
onChange: onChange,
l: wlog.WithFields(logrus.Fields{"kind": kind, "name": name}),
}
}
func NewFileWatcher(p string, onChange func(), onDelete func()) Watcher {
return &fileWatcher{
watcherBase: newWatcher("File", path.Base(p), onChange),
path: p,
onDelete: onDelete,
}
}
func NewDockerWatcher(c *client.Client, onChange func()) Watcher {
return &dockerWatcher{
watcherBase: newWatcher("Docker", c.DaemonHost(), onChange),
client: c,
stopCh: make(chan struct{}, 1),
}
}
func (w *fileWatcher) Start() {
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() {
if fsWatcher == nil {
return
}
fileWatchMap.Delete(w.path)
err := fsWatcher.Remove(w.path)
if err != nil {
w.l.WithField("action", "stop").Error(err)
}
}
func (w *fileWatcher) Dispose() {
w.Stop()
}
func (w *dockerWatcher) Start() {
dockerWatchMap.Set(w.name, w)
w.wg.Add(1)
go w.watch()
}
func (w *dockerWatcher) Stop() {
if w.stopCh == nil {
return
}
close(w.stopCh)
w.wg.Wait()
w.stopCh = nil
dockerWatchMap.Delete(w.name)
}
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 InitDockerWatcher() {
// stop all docker client on watcher stop
go func() {
<-dockerWatcherStop
ParallelForEachValue(
dockerWatchMap.Iterator(),
(*dockerWatcher).Dispose,
)
dockerWatcherWg.Done()
}()
}
func StopFSWatcher() {
close(fsWatcherStop)
fsWatcherWg.Wait()
}
func StopDockerWatcher() {
close(dockerWatcherStop)
dockerWatcherWg.Wait()
}
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")
go w.onChange()
case event.Has(fsnotify.Remove), event.Has(fsnotify.Rename):
w.l.Info("file renamed / deleted")
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:
w.l.Infof("container %s %s", msg.Actor.Attributes["name"], msg.Action)
go w.onChange()
case err := <-errChan:
w.l.Errorf("%s, retrying in 1s", err)
time.Sleep(1 * time.Second)
msgChan, errChan = listen()
}
}
}
var fsWatcher *fsnotify.Watcher
var (
fileWatchMap = NewSafeMap[string, *fileWatcher]()
dockerWatchMap = NewSafeMap[string, *dockerWatcher]()
)
var (
fsWatcherStop = make(chan struct{}, 1)
dockerWatcherStop = make(chan struct{}, 1)
)
var (
fsWatcherWg sync.WaitGroup
dockerWatcherWg sync.WaitGroup
)

54
src/go.mod Normal file
View File

@@ -0,0 +1,54 @@
module github.com/yusing/go-proxy
go 1.22
toolchain go1.23.1
require (
github.com/docker/cli v27.2.1+incompatible
github.com/docker/docker v27.2.1+incompatible
github.com/fsnotify/fsnotify v1.7.0
github.com/go-acme/lego/v4 v4.18.0
github.com/santhosh-tekuri/jsonschema v1.2.4
github.com/sirupsen/logrus v1.9.3
golang.org/x/net v0.29.0
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cloudflare/cloudflare-go v0.104.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.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.3 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/miekg/dns v1.1.62 // 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.55.0 // indirect
go.opentelemetry.io/otel v1.30.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.24.0 // indirect
go.opentelemetry.io/otel/metric v1.30.0 // indirect
go.opentelemetry.io/otel/sdk v1.24.0 // indirect
go.opentelemetry.io/otel/trace v1.30.0 // indirect
golang.org/x/crypto v0.27.0 // indirect
golang.org/x/mod v0.21.0 // indirect
golang.org/x/sync v0.8.0 // indirect
golang.org/x/sys v0.25.0 // indirect
golang.org/x/text v0.18.0 // indirect
golang.org/x/time v0.6.0 // indirect
golang.org/x/tools v0.25.0 // indirect
gotest.tools/v3 v3.5.1 // indirect
)

145
go.sum → src/go.sum Executable file → Normal file
View File

@@ -1,51 +1,43 @@
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.4.14 h1:+hMXMk01us9KgxGb7ftKQt2Xpf5hH/yky+TDA+qxleU=
github.com/Microsoft/go-winio v0.4.14/go.mod h1:qXqCSQ3Xa7+6tgxaGTIe4Kpcdsi+P8jBhyzoq1bpyYA=
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/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM=
github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cloudflare/cloudflare-go v0.86.0 h1:jEKN5VHNYNYtfDL2lUFLTRo+nOVNPFxpXTstVx0rqHI=
github.com/cloudflare/cloudflare-go v0.86.0/go.mod h1:wYW/5UP02TUfBToa/yKbQHV+r6h1NnJ1Je7XjuGM4Jw=
github.com/cloudflare/cloudflare-go v0.91.0 h1:L7IR+86qrZuEMSjGFg4cwRwtHqC8uCPmMUkP7BD4CPw=
github.com/cloudflare/cloudflare-go v0.91.0/go.mod h1:nUqvBUUDRxNzsDSQjbqUNWHEIYAoUlgRmcAzMKlFdKs=
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.104.0 h1:R/lB0dZupaZbOgibAH/BRrkFbZ6Acn/WsKg2iX2xXuY=
github.com/cloudflare/cloudflare-go v0.104.0/go.mod h1:pfUQ4PIG4ISI0/Mmc21Bp86UnFU0ktmPf3iTgbSL+cM=
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=
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.5.0 h1:/FUIFXtfc/x2gpa5/VGfiGLuOIdYa1t65IKK2OFGvA0=
github.com/distribution/reference v0.5.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/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 v27.2.1+incompatible h1:U5BPtiD0viUzjGAjV1p0MGB8eVA3L3cbIrnyWmSJI70=
github.com/docker/cli v27.2.1+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
github.com/docker/docker v27.2.1+incompatible h1:fQdiLfW7VLscyoeYEBz7/J8soYFDZV1u6VW6gJEjNMI=
github.com/docker/docker v27.2.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/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.18.0 h1:2hH8KcdRBSb+p5o9VZIm61GAOXYALgILUCSs1Q+OYsk=
github.com/go-acme/lego/v4 v4.18.0/go.mod h1:Blkg3izvXpl3zxk7WKngIuwR2I/hvYVP3vRnvgBp7m8=
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=
@@ -53,26 +45,14 @@ github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.0 h1:Wqo399gCIufwto+VfwCSvsnfGpF/w5E9CNxSwbpD6No=
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/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/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
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-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.62 h1:cN8OuEF1/x5Rq6Np+h1epln8OiyPWV+lROx9LxcGgIQ=
github.com/miekg/dns v1.1.62/go.mod h1:mvDlcItzm+br7MToIKqkglaGhlFMHJ9DTNNWONWXbNQ=
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=
@@ -83,98 +63,89 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.8.1 h1:geMPLpDpQOgVyCg5z5GoRwLHepNdb71NXb67XFkP+Eg=
github.com/rogpeppe/go-internal v1.8.1/go.mod h1:JeRgkft04UBgHMgCIwADu4Pn6Mtm5d4nPKWu0nJ5d+o=
github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q=
github.com/santhosh-tekuri/jsonschema v1.2.4 h1:hNhW8e7t+H1vgY+1QeEQpveR6D4+OwKPXCfD2aieJis=
github.com/santhosh-tekuri/jsonschema v1.2.4/go.mod h1:TEAUOeZSmIxTTuHatJzrvARHiuO9LYd+cIxzgEHCQI4=
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/objx v0.1.1/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.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
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.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw=
go.opentelemetry.io/otel v1.24.0 h1:0LAOdjNmQeSTzGBzduGe/rU4tZhMwL5rWgtp9Ku5Jfo=
go.opentelemetry.io/otel v1.24.0/go.mod h1:W7b9Ozg4nkF5tWI5zsXkaKKDjdVjpD4oAt9Qi/MArHo=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.55.0 h1:ZIg3ZT/aQ7AfKqdwp7ECpOK6vHqquXXuyTjIO8ZdmPs=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.55.0/go.mod h1:DQAwmETtZV00skUwgD6+0U89g80NKsJE3DCKeLLPQMI=
go.opentelemetry.io/otel v1.30.0 h1:F2t8sK4qf1fAmY9ua4ohFS/K+FUuOPemHUIXHtktrts=
go.opentelemetry.io/otel v1.30.0/go.mod h1:tFw4Br9b7fOS+uEao81PJjVMjW/5fvNCbpsDIXqP0pc=
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.24.0 h1:6EhoGWWK28x1fbpA4tYTOWBkPefTDQnb8WSGXlc88kI=
go.opentelemetry.io/otel/metric v1.24.0/go.mod h1:VYhLe1rFfxuTXLgj4CBiyz+9WYBA8pNGJgDcSFRKBco=
go.opentelemetry.io/otel/metric v1.30.0 h1:4xNulvn9gjzo4hjg+wzIKG7iNFEaBMX00Qd4QIZs7+w=
go.opentelemetry.io/otel/metric v1.30.0/go.mod h1:aXTfST94tswhWEb+5QjlSqG+cZlmyXy/u8jFpor3WqQ=
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.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y1YELI=
go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU=
go.opentelemetry.io/otel/trace v1.30.0 h1:7UBkkYzeg3C7kQX8VAidWh2biiQbtAKjyIML8dQ9wmc=
go.opentelemetry.io/otel/trace v1.30.0/go.mod h1:5EyKqTzzmyqB9bwtCCq6pDLktPK6fmGf/Dph+8VI02o=
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.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA=
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A=
golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=
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.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0=
golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic=
golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0=
golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
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.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc=
golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo=
golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0=
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=
golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/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.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
golang.org/x/sys v0.25.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/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/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224=
golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U=
golang.org/x/time v0.6.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.17.0 h1:FvmRgNOcs3kOa+T20R1uhfP9F6HgG2mfxDv1vrx1Htc=
golang.org/x/tools v0.17.0/go.mod h1:xsh6VxdV005rRVaS6SSAf9oiAqljS7UZUacMZ8Bnsps=
golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw=
golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc=
golang.org/x/tools v0.25.0 h1:oFU9pkj/iJgs+0DT+VMHrx+oBKs/LJMV+Uvg78sl+fE=
golang.org/x/tools v0.25.0/go.mod h1:/vtpO8WL1N9cQC3FN5zPqb//fRXskFHbLKk4OW1Q7rg=
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=

149
src/main.go Executable file
View File

@@ -0,0 +1,149 @@
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"
E "github.com/yusing/go-proxy/error"
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("module", "main")
if common.IsDebug {
logrus.SetLevel(logrus.DebugLevel)
}
logrus.SetFormatter(&logrus.TextFormatter{
DisableSorting: true,
FullTimestamp: true,
ForceColors: 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()]()
// exit if only validate config
if args.Command == common.CommandValidate {
var err E.NestedError
data, err := E.Check(os.ReadFile(common.ConfigPath))
if err.IsNotNil() {
l.WithError(err).Fatalf("config error")
}
if err = config.Validate(data); err.IsNotNil() {
l.WithError(err).Fatalf("config error")
}
l.Printf("config OK")
return
}
cfg, err := config.New()
if err.IsNotNil() {
l.Fatalf("config error: %s", err)
}
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()
if autocert != nil {
err = autocert.LoadCert()
if err.IsNotNil() {
l.Error(err)
l.Info("Now attempting to obtain a new certificate...")
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")
}
}

View File

@@ -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
)

16
src/models/config.go Normal file
View File

@@ -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,
}
}

48
src/models/proxy_entry.go Normal file
View File

@@ -0,0 +1,48 @@
package model
import (
"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"` // https proxy only
PathPatterns []string `yaml:"path_patterns" json:"path_patterns"` // http(s) proxy only
SetHeaders map[string]string `yaml:"set_headers" json:"set_headers"` // http(s) proxy only
HideHeaders []string `yaml:"hide_headers" json:"hide_headers"` // http(s) 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.Host == "" {
e.Host = "localhost"
}
switch e.Scheme {
case "http":
e.Port = "80"
case "https":
e.Port = "443"
}
}

View File

@@ -0,0 +1,6 @@
package model
type ProxyProviders struct {
Files []string `yaml:"include" json:"include"` // docker, file
Docker map[string]string `yaml:"docker" json:"docker"`
}

22
src/proxy/constants.go Normal file
View File

@@ -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...)
)

98
src/proxy/entry.go Normal file
View File

@@ -0,0 +1,98 @@
package proxy
import (
"fmt"
"net/http"
"net/url"
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
PathPatterns T.PathPatterns
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
}
pathPatterns, err := T.NewPathPatterns(m.PathPatterns)
if err.IsNotNil() {
return nil, err
}
setHeaders, err := T.NewHTTPHeaders(m.SetHeaders)
if err.IsNotNil() {
return nil, err
}
url, err := E.Check(url.Parse(fmt.Sprintf("%s://%s:%d", s, host, 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,
PathPatterns: pathPatterns,
SetHeaders: 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()
}

23
src/proxy/fields/alias.go Normal file
View File

@@ -0,0 +1,23 @@
package fields
import (
"strings"
F "github.com/yusing/go-proxy/utils/functional"
)
type Alias string
type Aliases struct{ *F.Slice[Alias] }
func NewAlias(s string) Alias {
return Alias(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
}

View File

@@ -0,0 +1,19 @@
package fields
import (
"net/http"
"strings"
E "github.com/yusing/go-proxy/error"
)
func NewHTTPHeaders(headers map[string]string) (http.Header, E.NestedError) {
h := make(http.Header)
for k, v := range headers {
vSplit := strings.Split(v, ",")
for _, header := range vSplit {
h.Add(k, strings.TrimSpace(header))
}
}
return h, E.Nil()
}

12
src/proxy/fields/host.go Normal file
View File

@@ -0,0 +1,12 @@
package fields
import (
E "github.com/yusing/go-proxy/error"
)
type Host string
type Subdomain = Alias
func NewHost(s string) (Host, E.NestedError) {
return Host(s), E.Nil()
}

View File

@@ -0,0 +1,24 @@
package fields
import (
E "github.com/yusing/go-proxy/error"
)
type PathMode string
func NewPathMode(pm string) (PathMode, E.NestedError) {
switch pm {
case "", "forward":
return PathMode(pm), E.Nil()
default:
return "", E.Invalid("path mode", pm)
}
}
func (p PathMode) IsRemove() bool {
return p == ""
}
func (p PathMode) IsForward() bool {
return p == "forward"
}

View File

@@ -0,0 +1,37 @@
package fields
import (
"regexp"
E "github.com/yusing/go-proxy/error"
)
type PathPattern string
type PathPatterns = []PathPattern
func NewPathPattern(s string) (PathPattern, E.NestedError) {
if len(s) == 0 {
return "", E.Invalid("path", "must not be empty")
}
if !pathPattern.MatchString(string(s)) {
return "", E.Invalid("path pattern", s)
}
return PathPattern(s), E.Nil()
}
func NewPathPatterns(s []string) (PathPatterns, E.NestedError) {
if len(s) == 0 {
return []PathPattern{"/"}, E.Nil()
}
pp := make(PathPatterns, len(s))
for i, v := range s {
if pattern, err := NewPathPattern(v); err.IsNotNil() {
return nil, err
} else {
pp[i] = pattern
}
}
return pp, E.Nil()
}
var pathPattern = regexp.MustCompile("^((GET|POST|DELETE|PUT|PATCH|HEAD|OPTIONS|CONNECT)\\s)?(/\\w*)+/?$")

40
src/proxy/fields/port.go Normal file
View File

@@ -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.Invalid("port number", v).With(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)
)

View File

@@ -0,0 +1,36 @@
package fields
import (
"strings"
E "github.com/yusing/go-proxy/error"
)
type Scheme string
func NewScheme(s string) (Scheme, E.NestedError) {
switch s {
case "http", "https", "tcp", "udp":
return Scheme(s), E.Nil()
}
return "", 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(s), E.Nil()
}
func (s Scheme) IsHTTP() bool { return s == "http" }
func (s Scheme) IsHTTPS() bool { return s == "https" }
func (s Scheme) IsTCP() bool { return s == "tcp" }
func (s Scheme) IsUDP() bool { return s == "udp" }
func (s Scheme) IsStream() bool { return s.IsTCP() || s.IsUDP() }

View File

@@ -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).With("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()
}

View File

@@ -0,0 +1,43 @@
package fields
import (
"fmt"
"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 fmt.Sprintf("%s -> %s", s.ListeningScheme, s.ProxyScheme)
}
// 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
}

View File

@@ -0,0 +1,3 @@
package provider
const wildcardAlias = "*"

View File

@@ -0,0 +1,150 @@
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(dockerHost string) ProviderImpl {
return &DockerProvider{dockerHost: dockerHost}
}
// 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: (non-nil) 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) {
entries := M.NewProxyEntries()
info, err := D.GetClientInfo(p.dockerHost)
if err.IsNotNil() {
return entries, E.From(err)
}
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(string(a), &M.ProxyEntry{
Alias: string(a),
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
}

View File

@@ -0,0 +1,54 @@
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(filename string) ProviderImpl {
return &FileProvider{
fileName: filename,
path: path.Join(common.ConfigBasePath, filename),
}
}
func Validate(data []byte) E.NestedError {
return U.ValidateYaml(U.GetSchema(common.ProvidersSchemaPath), data)
}
func (p *FileProvider) String() string {
return p.fileName
}
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).With(err)
}
ne := E.Failure("validation").Subject(p)
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)
}

View File

@@ -0,0 +1,185 @@
package provider
import (
"context"
"fmt"
"path"
"github.com/sirupsen/logrus"
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
t ProviderType
routes *R.Routes
reloadReqCh chan struct{}
watcher W.Watcher
watcherCtx context.Context
watcherCancel context.CancelFunc
l *logrus.Entry
}
type ProviderType string
const (
ProviderTypeDocker ProviderType = "docker"
ProviderTypeFile ProviderType = "file"
)
func newProvider(name string, t ProviderType) *Provider {
p := &Provider{
name: name,
t: t,
routes: R.NewRoutes(),
reloadReqCh: make(chan struct{}, 1),
}
p.l = logrus.WithField("provider", p)
return p
}
func NewFileProvider(filename string) *Provider {
name := path.Base(filename)
p := newProvider(name, ProviderTypeFile)
p.ProviderImpl = FileProviderImpl(filename)
p.watcher = p.NewWatcher()
return p
}
func NewDockerProvider(name string, dockerHost string) *Provider {
p := newProvider(name, ProviderTypeDocker)
p.ProviderImpl = DockerProviderImpl(dockerHost)
p.watcher = p.NewWatcher()
return p
}
func (p *Provider) GetName() string {
return p.name
}
func (p *Provider) GetType() ProviderType {
return p.t
}
func (p *Provider) String() string {
return fmt.Sprintf("%s: %s", p.t, 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()
errors := E.NewBuilder("errors in routes")
nStarted := 0
nFailed := 0
if err.IsNotNil() {
errors.Add(err)
}
p.routes.EachKVParallel(func(alias string, r R.Route) {
if err := r.Start(); err.IsNotNil() {
errors.Add(err.Subject(r))
nFailed++
} else {
nStarted++
}
})
p.l.Infof("%d routes started, %d failed", nStarted, nFailed)
return errors.Build()
}
func (p *Provider) StopAllRoutes() E.NestedError {
if p.watcherCancel != nil {
p.watcherCancel()
p.watcherCancel = nil
}
errors := E.NewBuilder("errors stopping routes for provider %q", p.name)
nStopped := 0
nFailed := 0
p.routes.EachKVParallel(func(alias string, r R.Route) {
if err := r.Stop(); err.IsNotNil() {
errors.Add(err.Subject(r))
nFailed++
} else {
nStopped++
}
})
p.l.Infof("%d routes stopped, %d failed", nStopped, nFailed)
return errors.Build()
}
func (p *Provider) ReloadRoutes() {
defer p.l.Info("routes reloaded")
p.StopAllRoutes()
p.loadRoutes()
p.StartAllRoutes()
}
func (p *Provider) GetCurrentRoutes() *R.Routes {
return p.routes
}
func (p *Provider) watchEvents() {
events, errs := p.watcher.Events(p.watcherCtx)
l := p.l.WithField("module", "watcher")
for {
select {
case <-p.reloadReqCh: // block until last reload is done
p.ReloadRoutes()
continue // ignore events once after reload
case event, ok := <-events:
if !ok {
return
}
l.Info(event)
p.reloadReqCh <- struct{}{}
case err, ok := <-errs:
if !ok {
return
}
if err.Is(context.Canceled) {
continue
}
l.Errorf("watcher error: %s", err)
}
}
}
func (p *Provider) loadRoutes() E.NestedError {
entries, err := p.GetProxyEntries()
if err.IsNotNil() {
p.l.Warn(err.Subject(p))
}
p.routes = R.NewRoutes()
errors := E.NewBuilder("errors loading routes from %s", p)
entries.EachKV(func(a string, e *M.ProxyEntry) {
e.Alias = a
r, err := R.NewRoute(e)
if err.IsNotNil() {
errors.Add(err.Subject(a))
} else {
p.routes.Set(a, r)
}
})
return errors.Build()
}

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