mirror of
https://github.com/yusing/godoxy.git
synced 2025-05-19 20:32:35 +02:00
panel apperance, added experimental tcp/udp proxy support, slight performance improvement for http proxy
This commit is contained in:
parent
12e23c3517
commit
9cb1b1d31a
21 changed files with 865 additions and 207 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
@ -1,2 +1,2 @@
|
|||
bin/
|
||||
compose.yml
|
||||
compose.yml
|
||||
go-proxy.yml
|
|
@ -10,5 +10,6 @@ ENV DOCKER_HOST unix:///var/run/docker.sock
|
|||
|
||||
EXPOSE 80
|
||||
EXPOSE 443
|
||||
EXPOSE 8443
|
||||
|
||||
CMD ["go-proxy"]
|
||||
|
|
140
README.md
140
README.md
|
@ -1,27 +1,45 @@
|
|||
# go-proxy
|
||||
|
||||
A simple auto docker reverse proxy for home use.
|
||||
A simple auto docker reverse proxy for home use. *Written in **Go***
|
||||
|
||||
Written in **Go** with *~220 loc*.
|
||||
In the examples domain `x.y.z` is used, replace them with your domain
|
||||
|
||||
## Table of content
|
||||
|
||||
- [Features](#features)
|
||||
- [Why am I making this](#why-am-i-making-this)
|
||||
- [How to use](#how-to-use)
|
||||
- [Configuration](#configuration)
|
||||
- [Single Port Configuration](#single-port-configuration-example)
|
||||
- [Multiple Configuration](#multiple-configuration-example)
|
||||
- [TCP/UDP Configuration](#tcpudp-configuration-example)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
- [Benchmarks](#benchmarks)
|
||||
- [Memory usage](#memory-usage)
|
||||
- [Build it yourself](#build-it-yourself)
|
||||
- [Getting SSL certs](#getting-ssl-certs)
|
||||
|
||||
## Features
|
||||
|
||||
- subdomain matching **(domain name doesn't matter)**
|
||||
- path matching
|
||||
- HTTP proxy
|
||||
- TCP/UDP Proxy (experimental, unable to release port on hot-reload)
|
||||
- Auto hot-reload when container start / die / stop.
|
||||
- Simple panel to see all reverse proxies and health (visit `https://go-proxy.yourdomain.com`)
|
||||
- Simple panel to see all reverse proxies and health (visit port :81 of go-proxy `https://*.y.z:81`)
|
||||
|
||||

|
||||
|
||||
## Why am I making this
|
||||
|
||||
I have tried different reverse proxy services, i.e. [nginx proxy manager](https://nginxproxymanager.com/), [traefik](https://github.com/traefik/traefik), [nginx-proxy](https://github.com/nginx-proxy/nginx-proxy). I have found that `traefik` is not easy to use, and I don't want to click buttons every time I spin up a new container (`nginx proxy manager`). For `nginx-proxy` I found it buggy and quite unusable.
|
||||
1. It's fun.
|
||||
2. I have tried different reverse proxy services, i.e. [nginx proxy manager](https://nginxproxymanager.com/), [traefik](https://github.com/traefik/traefik), [nginx-proxy](https://github.com/nginx-proxy/nginx-proxy). I have found that `traefik` is not easy to use, and I don't want to click buttons every time I spin up a new container (`nginx proxy manager`). For `nginx-proxy` I found it buggy and quite unusable.
|
||||
|
||||
## How to use
|
||||
|
||||
1. Clone the repo `git clone https://github.com/yusing/go-proxy`
|
||||
1. Clone the repo git clone `https://github.com/yusing/go-proxy`
|
||||
|
||||
2. Copy [compose.example.yml](compose.example.yml) to `compose.yml`
|
||||
2. Copy content from [compose.example.yml](compose.example.yml) and create your own `compose.yml`
|
||||
|
||||
3. Add networks to make sure it is in the same network with other containers, or make sure `proxy.<alias>.host` is reachable
|
||||
|
||||
|
@ -40,7 +58,7 @@ I have tried different reverse proxy services, i.e. [nginx proxy manager](https:
|
|||
|
||||
`docker network inspect $(docker network ls | awk '$3 == "bridge" { print $1}') | jq -r '.[] | .Name + " " + .IPAM.Config[0].Subnet' -`
|
||||
|
||||
7. start your docker app, and visit <container_name>.yourdomain.com
|
||||
7. start your docker app, and visit <container_name>.y.z
|
||||
|
||||
## Configuration
|
||||
|
||||
|
@ -55,17 +73,20 @@ However, there are some labels you can manipulate with:
|
|||
- `proxy.<alias>.host`: proxy host
|
||||
- defaults to `container_name`
|
||||
- `proxy.<alias>.port`: proxy port
|
||||
- defaults to first expose port (declared in `Dockerfile` or `docker-compose.yml`)
|
||||
- `proxy.<alias>.path`: path matching
|
||||
- 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 [stream.go](src/go-proxy/stream.go#L28))
|
||||
- `proxy.<alias>.path`: path matching (for http proxy only)
|
||||
- defaults to empty
|
||||
|
||||
### Single port configuration example
|
||||
|
||||
```yaml
|
||||
version: '3'
|
||||
services:
|
||||
whoami:
|
||||
image: traefik/whoami # port 80 is exposed
|
||||
container_name: whoami
|
||||
# (default) https://whoami.yourdomain.com
|
||||
# (default) https://<container_name>.y.z
|
||||
whoami:
|
||||
image: traefik/whoami
|
||||
container_name: whoami # => whoami.y.z
|
||||
|
||||
# enable both subdomain and path matching:
|
||||
whoami:
|
||||
|
@ -74,36 +95,51 @@ whoami:
|
|||
labels:
|
||||
- proxy.aliases=whoami,apps
|
||||
- proxy.apps.path=/whoami
|
||||
# 1. visit https://whoami.yourdomain.com
|
||||
# 2. visit https://apps.yourdomain.com/whoami
|
||||
# 1. visit https://whoami.y.z
|
||||
# 2. visit https://apps.y.z/whoami
|
||||
```
|
||||
|
||||
For multiple port container (i.e. minio)
|
||||
### Multiple configuration example
|
||||
|
||||
```yaml
|
||||
version: '3'
|
||||
services:
|
||||
minio:
|
||||
image: quay.io/minio/minio
|
||||
container_name: minio
|
||||
command:
|
||||
- server
|
||||
- /data
|
||||
- --console-address
|
||||
- "9001"
|
||||
env_file: minio.env
|
||||
expose:
|
||||
- 9000
|
||||
- 9001
|
||||
volumes:
|
||||
- ./data/minio/data:/data
|
||||
labels:
|
||||
proxy.aliases: minio,minio-console
|
||||
proxy.minio.port: 9000
|
||||
proxy.minio-console.port: 9001
|
||||
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.yourdomain.com to access minio
|
||||
# visit https://minio-console.yourdomain.com/whoami to access minio console
|
||||
# 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
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
@ -142,25 +178,29 @@ With **go-proxy** reverse proxy
|
|||
Running 10s test @ https://whoami.6uo.me/bench
|
||||
20 threads and 100 connections
|
||||
Thread Stats Avg Stdev Max +/- Stdev
|
||||
Latency 4.94ms 1.88ms 43.49ms 85.82%
|
||||
Req/Sec 1.03k 123.57 1.22k 83.20%
|
||||
Latency 4.02ms 2.13ms 47.49ms 95.14%
|
||||
Req/Sec 1.28k 139.15 1.47k 91.67%
|
||||
Latency Distribution
|
||||
50% 4.60ms
|
||||
75% 5.59ms
|
||||
90% 6.77ms
|
||||
99% 10.81ms
|
||||
203565 requests in 10.02s, 19.80MB read
|
||||
Requests/sec: 20320.87
|
||||
Transfer/sec: 1.98MB
|
||||
50% 3.60ms
|
||||
75% 4.36ms
|
||||
90% 5.29ms
|
||||
99% 8.83ms
|
||||
253874 requests in 10.02s, 24.70MB read
|
||||
Requests/sec: 25342.46
|
||||
Transfer/sec: 2.47MB
|
||||
```
|
||||
|
||||
## Memory usage
|
||||
|
||||
It takes ~ 0.1-0.4MB for each HTTP Proxy, and <2MB for each TCP/UDP Proxy
|
||||
|
||||
## Build it yourself
|
||||
|
||||
1. [Install go](https://go.dev/doc/install) if not already
|
||||
|
||||
2. Get dependencies with `go get`
|
||||
|
||||
3. build binary with `sh build.sh`
|
||||
3. build binary with `sh scripts/build.sh`
|
||||
|
||||
4. start your container with `docker compose up -d`
|
||||
|
||||
|
|
BIN
bin/go-proxy
Executable file
BIN
bin/go-proxy
Executable file
Binary file not shown.
|
@ -3,9 +3,15 @@ services:
|
|||
app:
|
||||
build: .
|
||||
container_name: go-proxy
|
||||
restart: always
|
||||
networks: # also add here
|
||||
- default
|
||||
ports:
|
||||
- 80:80
|
||||
- 443:443
|
||||
- 80:80 # http
|
||||
- 443:443 # https
|
||||
- 8443:8443 # panel
|
||||
- 20000:20100/tcp # tcp (optional, if you have proxy.<app>.scheme == tcp)
|
||||
- 20000:20100/udp # tcp (optional, if you have proxy.<app>.scheme == udp)
|
||||
volumes:
|
||||
- /path/to/cert.pem:/certs/cert.crt:ro
|
||||
- /path/to/privkey.pem:/certs/priv.key:ro
|
||||
|
@ -17,3 +23,6 @@ services:
|
|||
options:
|
||||
max-file: '1'
|
||||
max-size: 128k
|
||||
networks: # you may add other external networks
|
||||
default:
|
||||
driver: bridge
|
1
go-proxy.yml
Symbolic link
1
go-proxy.yml
Symbolic link
|
@ -0,0 +1 @@
|
|||
/home/yusing/docker/compose/go-proxy.yml
|
5
go.mod
5
go.mod
|
@ -3,7 +3,6 @@ module github.com/yusing/go-proxy
|
|||
go 1.21.7
|
||||
|
||||
require (
|
||||
github.com/deckarep/golang-set/v2 v2.6.0
|
||||
github.com/docker/docker v25.0.3+incompatible
|
||||
golang.org/x/text v0.14.0
|
||||
)
|
||||
|
@ -14,12 +13,14 @@ require (
|
|||
github.com/morikuni/aec v1.0.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.24.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk v1.24.0 // indirect
|
||||
golang.org/x/mod v0.15.0 // indirect
|
||||
golang.org/x/time v0.5.0 // indirect
|
||||
golang.org/x/tools v0.18.0 // indirect
|
||||
gotest.tools/v3 v3.5.1 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/Microsoft/go-winio v0.4.14 // indirect
|
||||
github.com/Microsoft/go-winio v0.6.1 // 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
|
||||
|
|
19
go.sum
19
go.sum
|
@ -1,15 +1,13 @@
|
|||
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/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.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/deckarep/golang-set/v2 v2.6.0 h1:XfcQbWM1LlMB8BsJ8N9vW5ehnnPVIw0je80NsVHagjM=
|
||||
github.com/deckarep/golang-set/v2 v2.6.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4=
|
||||
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/docker v25.0.3+incompatible h1:D5fy/lYmY7bvZa0XTZ5/UJPljor41F+vdyJG5luQLfQ=
|
||||
|
@ -35,7 +33,6 @@ github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.0 h1:Wqo399gCIufwto+VfwCSvsnfGpF
|
|||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.0/go.mod h1:qmOFXW2epJhM0qSnUUYpldc7gVz2KMQwJ/QYCDIa7XU=
|
||||
github.com/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/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
|
||||
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
|
||||
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
||||
|
@ -44,16 +41,12 @@ 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/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q=
|
||||
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.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
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/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
|
@ -79,6 +72,8 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U
|
|||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
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.15.0 h1:SernR4v+D55NyBH2QiEQrlBAnj1ECL6AGrA5+dPaMY8=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
|
@ -88,10 +83,10 @@ golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
|||
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/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
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-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.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
|
@ -105,6 +100,8 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm
|
|||
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.18.0 h1:k8NLag8AGHnn+PHbl7g43CtqZAwG60vZkLqgyZgIHgQ=
|
||||
golang.org/x/tools v0.18.0/go.mod h1:GL7B4CwcLLeo59yx/9UWWuNOW1n3VZ4f5axWfML7Lcg=
|
||||
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=
|
||||
|
|
Binary file not shown.
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 149 KiB |
10
scripts/udp-test-container.sh
Normal file
10
scripts/udp-test-container.sh
Normal file
|
@ -0,0 +1,10 @@
|
|||
#!/bin/sh
|
||||
docker run -it --tty --rm \
|
||||
-p 9999:9999/udp \
|
||||
--label proxy.test-udp.scheme=udp \
|
||||
--label proxy.test-udp.port=20003:9999 \
|
||||
--network data_default \
|
||||
--name test-udp \
|
||||
debian:stable-slim \
|
||||
/bin/bash -c \
|
||||
"apt update && apt install -y netcat-openbsd && echo 'nc -u -l 9999' >> ~/.bashrc && bash"
|
|
@ -1,14 +1,13 @@
|
|||
package go_proxy
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/url"
|
||||
"os"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
mapset "github.com/deckarep/golang-set/v2"
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/client"
|
||||
|
@ -17,22 +16,21 @@ import (
|
|||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
type ProxyConfig struct {
|
||||
Alias string
|
||||
Scheme string
|
||||
Host string
|
||||
Port string
|
||||
Path string
|
||||
Path string // http proxy only
|
||||
}
|
||||
|
||||
type Route struct {
|
||||
Url *url.URL
|
||||
Path string
|
||||
func NewProxyConfig() ProxyConfig {
|
||||
return ProxyConfig{}
|
||||
}
|
||||
|
||||
var dockerClient *client.Client
|
||||
var subdomainRouteMap map[string]mapset.Set[Route] // subdomain -> path
|
||||
|
||||
func buildContainerCfg(container types.Container) {
|
||||
func buildContainerRoute(container types.Container) {
|
||||
var aliases []string
|
||||
|
||||
container_name := strings.TrimPrefix(container.Names[0], "/")
|
||||
|
@ -44,7 +42,7 @@ func buildContainerCfg(container types.Container) {
|
|||
}
|
||||
|
||||
for _, alias := range aliases {
|
||||
config := NewConfig()
|
||||
config := NewProxyConfig()
|
||||
prefix := fmt.Sprintf("proxy.%s.", alias)
|
||||
for label, value := range container.Labels {
|
||||
if strings.HasPrefix(label, prefix) {
|
||||
|
@ -76,11 +74,22 @@ func buildContainerCfg(container types.Container) {
|
|||
if config.Scheme == "" {
|
||||
if strings.HasSuffix(config.Port, "443") {
|
||||
config.Scheme = "https"
|
||||
} else {
|
||||
} else if strings.HasPrefix(container.Image, "sha256:") {
|
||||
config.Scheme = "http"
|
||||
} else {
|
||||
imageSplit := strings.Split(container.Image, "/")
|
||||
imageSplit = strings.Split(imageSplit[len(imageSplit)-1], ":")
|
||||
imageName := imageSplit[0]
|
||||
_, isKnownImage := imageNamePortMap[imageName]
|
||||
if isKnownImage {
|
||||
log.Printf("[Build] Known image '%s' detected for %s", imageName, container_name)
|
||||
config.Scheme = "tcp"
|
||||
} else {
|
||||
config.Scheme = "http"
|
||||
}
|
||||
}
|
||||
}
|
||||
if config.Scheme != "http" && config.Scheme != "https" {
|
||||
if !isValidScheme(config.Scheme) {
|
||||
log.Printf("%s: unsupported scheme: %s, using http", container_name, config.Scheme)
|
||||
config.Scheme = "http"
|
||||
}
|
||||
|
@ -91,26 +100,39 @@ func buildContainerCfg(container types.Container) {
|
|||
config.Host = "host.docker.internal"
|
||||
}
|
||||
}
|
||||
_, inMap := subdomainRouteMap[alias]
|
||||
if !inMap {
|
||||
subdomainRouteMap[alias] = mapset.NewSet[Route]()
|
||||
}
|
||||
url, err := url.Parse(fmt.Sprintf("%s://%s:%s", config.Scheme, config.Host, config.Port))
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
subdomainRouteMap[alias].Add(Route{Url: url, Path: config.Path})
|
||||
config.Alias = alias
|
||||
createProxy(config)
|
||||
}
|
||||
}
|
||||
|
||||
func buildRoutes() {
|
||||
subdomainRouteMap = make(map[string]mapset.Set[Route])
|
||||
initProxyMaps()
|
||||
containerSlice, err := dockerClient.ContainerList(context.Background(), container.ListOptions{})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
for _, container := range containerSlice {
|
||||
buildContainerCfg(container)
|
||||
hostname, err := os.Hostname()
|
||||
if err != nil {
|
||||
hostname = "go-proxy"
|
||||
}
|
||||
for _, container := range containerSlice {
|
||||
if container.Names[0] == hostname { // skip self
|
||||
continue
|
||||
}
|
||||
buildContainerRoute(container)
|
||||
}
|
||||
subdomainRouteMap["go-proxy"] = panelRoute
|
||||
}
|
||||
|
||||
func findHTTPRoute(host string, path string) (*HTTPRoute, error) {
|
||||
subdomain := strings.Split(host, ".")[0]
|
||||
routeMap, ok := routes.HTTPRoutes[subdomain]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("no matching route for subdomain %s", subdomain)
|
||||
}
|
||||
for _, route := range routeMap {
|
||||
if strings.HasPrefix(path, route.Path) {
|
||||
return &route, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("no matching route for path %s for subdomain %s", path, subdomain)
|
||||
}
|
||||
|
|
32
src/go-proxy/healthcheck.go
Normal file
32
src/go-proxy/healthcheck.go
Normal file
|
@ -0,0 +1,32 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
func healthCheckHttp(targetUrl string) error {
|
||||
// try HEAD first
|
||||
// if HEAD is not allowed, try GET
|
||||
resp, err := healthCheckHttpClient.Head(targetUrl)
|
||||
if resp != nil {
|
||||
defer resp.Body.Close()
|
||||
}
|
||||
if err != nil && resp != nil && resp.StatusCode == http.StatusMethodNotAllowed {
|
||||
_, err = healthCheckHttpClient.Get(targetUrl)
|
||||
}
|
||||
if resp != nil {
|
||||
defer resp.Body.Close()
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func healthCheckStream(scheme string, host string) error {
|
||||
conn, err := net.DialTimeout(scheme, host, 5*time.Second)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
return nil
|
||||
}
|
66
src/go-proxy/http_proxy.go
Executable file
66
src/go-proxy/http_proxy.go
Executable file
|
@ -0,0 +1,66 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
type HTTPRoute struct {
|
||||
Url *url.URL
|
||||
Path string
|
||||
Proxy *httputil.ReverseProxy
|
||||
}
|
||||
|
||||
// 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,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
ExpectContinueTimeout: 1 * time.Second,
|
||||
ResponseHeaderTimeout: 10 * time.Second,
|
||||
ForceAttemptHTTP2: true,
|
||||
}
|
||||
|
||||
func NewHTTPRoute(Url *url.URL, Path string) HTTPRoute {
|
||||
proxy := httputil.NewSingleHostReverseProxy(Url)
|
||||
proxy.Transport = transport
|
||||
return HTTPRoute{Url: Url, Path: Path, Proxy: proxy}
|
||||
}
|
||||
|
||||
func redirectToTLS(w http.ResponseWriter, r *http.Request) {
|
||||
// Redirect to the same host but with HTTPS
|
||||
log.Printf("[Redirect] redirecting to 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 httpProxyHandler(w http.ResponseWriter, r *http.Request) {
|
||||
route, err := findHTTPRoute(r.Host, r.URL.Path)
|
||||
if err != nil {
|
||||
log.Printf("[Request] failed %s %s%s, error: %v",
|
||||
r.Method,
|
||||
r.Host,
|
||||
r.URL.Path,
|
||||
err,
|
||||
)
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
route.Proxy.ServeHTTP(w, r)
|
||||
}
|
81
src/go-proxy/main.go
Executable file → Normal file
81
src/go-proxy/main.go
Executable file → Normal file
|
@ -1,46 +1,16 @@
|
|||
package go_proxy
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/api/types/filters"
|
||||
"github.com/docker/docker/client"
|
||||
"golang.org/x/net/context"
|
||||
|
||||
mapset "github.com/deckarep/golang-set/v2"
|
||||
)
|
||||
|
||||
var panelRoute = mapset.NewSet(Route{Url: &url.URL{Scheme: "http", Host: "localhost:81", Path: "/"}, Path: "/"})
|
||||
|
||||
// TODO: default + per proxy
|
||||
var transport = &http.Transport{
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: 60 * time.Second,
|
||||
KeepAlive: 60 * time.Second,
|
||||
DualStack: true,
|
||||
}).DialContext,
|
||||
MaxIdleConns: 1000,
|
||||
MaxIdleConnsPerHost: 1000,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
ExpectContinueTimeout: 1 * time.Second,
|
||||
ResponseHeaderTimeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
func NewConfig() Config {
|
||||
return Config{Scheme: "", Host: "", Port: "", Path: ""}
|
||||
}
|
||||
|
||||
func main() {
|
||||
var err error
|
||||
runtime.GOMAXPROCS(runtime.NumCPU())
|
||||
|
@ -49,6 +19,9 @@ func main() {
|
|||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
buildRoutes()
|
||||
log.Printf("[Build] built %v reverse proxies", countProxies())
|
||||
|
||||
go func() {
|
||||
filter := filters.NewArgs(
|
||||
filters.Arg("type", "container"),
|
||||
|
@ -62,15 +35,12 @@ func main() {
|
|||
// TODO: handle actor only
|
||||
log.Printf("[Event] %s %s caused rebuild", msg.Action, msg.Actor.Attributes["name"])
|
||||
buildRoutes()
|
||||
log.Printf("[Build] rebuilt %v reverse proxies", len(subdomainRouteMap))
|
||||
log.Printf("[Build] rebuilt %v reverse proxies", countProxies())
|
||||
}
|
||||
}()
|
||||
|
||||
buildRoutes()
|
||||
log.Printf("[Build] built %v reverse proxies", len(subdomainRouteMap))
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/", handler)
|
||||
mux.HandleFunc("/", httpProxyHandler)
|
||||
|
||||
go func() {
|
||||
log.Println("Starting HTTP server on port 80")
|
||||
|
@ -80,8 +50,8 @@ func main() {
|
|||
}
|
||||
}()
|
||||
go func() {
|
||||
log.Println("Starting HTTP panel on port 81")
|
||||
err := http.ListenAndServe(":81", http.HandlerFunc(panelHandler))
|
||||
log.Println("Starting HTTPS panel on port 8443")
|
||||
err := http.ListenAndServeTLS(":8443", "/certs/cert.crt", "/certs/priv.key", http.HandlerFunc(panelHandler))
|
||||
if err != nil {
|
||||
log.Fatal("HTTP server error", err)
|
||||
}
|
||||
|
@ -92,38 +62,3 @@ func main() {
|
|||
log.Fatal("HTTPS Server error: ", err)
|
||||
}
|
||||
}
|
||||
|
||||
func redirectToTLS(w http.ResponseWriter, r *http.Request) {
|
||||
// Redirect to the same host but with HTTPS
|
||||
log.Printf("[Redirect] redirecting to 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 handler(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("[Request] %s %s", r.Method, r.URL.String())
|
||||
subdomain := strings.Split(r.Host, ".")[0]
|
||||
routeMap, ok := subdomainRouteMap[subdomain]
|
||||
if !ok {
|
||||
http.Error(w, fmt.Sprintf("no matching route for subdomain %s", subdomain), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
for route := range routeMap.Iter() {
|
||||
if strings.HasPrefix(r.URL.Path, route.Path) {
|
||||
realPath := strings.TrimPrefix(r.URL.Path, route.Path)
|
||||
origHost := r.Host
|
||||
r.URL.Path = realPath
|
||||
log.Printf("[Route] %s -> %s%s ", origHost, route.Url.String(), route.Path)
|
||||
proxyServer := httputil.NewSingleHostReverseProxy(route.Url)
|
||||
proxyServer.Transport = transport
|
||||
proxyServer.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
http.Error(w, fmt.Sprintf("no matching route for path %s for subdomain %s", r.URL.Path, subdomain), http.StatusNotFound)
|
||||
}
|
||||
|
|
|
@ -1,8 +1,11 @@
|
|||
package go_proxy
|
||||
package main
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
|
@ -11,7 +14,13 @@ const templateFile = "/app/templates/panel.html"
|
|||
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,
|
||||
},
|
||||
}
|
||||
|
||||
|
@ -42,7 +51,7 @@ func panelIndex(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
err = tmpl.Execute(w, subdomainRouteMap)
|
||||
err = tmpl.Execute(w, routes)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
|
@ -61,20 +70,23 @@ func panelCheckTargetHealth(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
// try HEAD first
|
||||
// if HEAD is not allowed, try GET
|
||||
resp, err := healthCheckHttpClient.Head(targetUrl)
|
||||
if err != nil && resp != nil && resp.StatusCode == http.StatusMethodNotAllowed {
|
||||
_, err = healthCheckHttpClient.Get(targetUrl)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
}
|
||||
url, err := url.Parse(targetUrl)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusServiceUnavailable)
|
||||
log.Printf("[Panel] failed to parse %s, error: %v", targetUrl, err)
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
scheme := url.Scheme
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if isStreamScheme(scheme) {
|
||||
err = healthCheckStream(scheme, url.Host)
|
||||
} else {
|
||||
err = healthCheckHttp(targetUrl)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
} else {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
|
120
src/go-proxy/proxy.go
Normal file
120
src/go-proxy/proxy.go
Normal file
|
@ -0,0 +1,120 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/url"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type Routes struct {
|
||||
HTTPRoutes map[string][]HTTPRoute // subdomain/alias -> path
|
||||
StreamRoutes map[string]*StreamRoute // port -> target
|
||||
}
|
||||
|
||||
var routes = Routes{
|
||||
HTTPRoutes: make(map[string][]HTTPRoute),
|
||||
StreamRoutes: make(map[string]*StreamRoute),
|
||||
}
|
||||
var routesMutex = sync.Mutex{}
|
||||
|
||||
var streamSchemes = []string{"tcp", "udp"} // TODO: support "tcp:udp", "udp:tcp"
|
||||
var httpSchemes = []string{"http", "https"}
|
||||
|
||||
var validSchemes = append(streamSchemes, httpSchemes...)
|
||||
|
||||
var lastFreePort int
|
||||
|
||||
|
||||
func isValidScheme(scheme string) bool {
|
||||
for _, v := range validSchemes {
|
||||
if v == scheme {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isStreamScheme(scheme string) bool {
|
||||
for _, v := range streamSchemes {
|
||||
if v == scheme {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func initProxyMaps() {
|
||||
routesMutex.Lock()
|
||||
defer routesMutex.Unlock()
|
||||
|
||||
lastFreePort = 20000
|
||||
oldStreamRoutes := routes.StreamRoutes
|
||||
routes.StreamRoutes = make(map[string]*StreamRoute)
|
||||
routes.HTTPRoutes = make(map[string][]HTTPRoute)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(len(oldStreamRoutes))
|
||||
defer wg.Wait()
|
||||
|
||||
for _, route := range oldStreamRoutes {
|
||||
go func(r *StreamRoute) {
|
||||
r.Cancel()
|
||||
wg.Done()
|
||||
}(route)
|
||||
}
|
||||
}
|
||||
|
||||
func countProxies() int {
|
||||
return len(routes.HTTPRoutes) + len(routes.StreamRoutes)
|
||||
}
|
||||
|
||||
func createProxy(config ProxyConfig) {
|
||||
if isStreamScheme(config.Scheme) {
|
||||
_, inMap := routes.StreamRoutes[config.Port]
|
||||
if inMap {
|
||||
log.Printf("[Build] Duplicated stream :%s, ignoring", config.Port)
|
||||
return
|
||||
}
|
||||
route, err := NewStreamRoute(config)
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
return
|
||||
}
|
||||
routes.StreamRoutes[config.Port] = route
|
||||
go route.listenStream()
|
||||
} else {
|
||||
_, inMap := routes.HTTPRoutes[config.Alias]
|
||||
if !inMap {
|
||||
routes.HTTPRoutes[config.Alias] = make([]HTTPRoute, 0)
|
||||
}
|
||||
url, err := url.Parse(fmt.Sprintf("%s://%s:%s", config.Scheme, config.Host, config.Port))
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
routes.HTTPRoutes[config.Alias] = append(routes.HTTPRoutes[config.Alias], NewHTTPRoute(url, config.Path))
|
||||
}
|
||||
}
|
||||
|
||||
func findFreePort() (int, error) {
|
||||
var portStr string
|
||||
var l net.Listener
|
||||
var err error = nil
|
||||
|
||||
for lastFreePort <= 21000 {
|
||||
portStr = fmt.Sprintf(":%d", lastFreePort)
|
||||
l, err = net.Listen("tcp", portStr)
|
||||
lastFreePort++
|
||||
if err != nil {
|
||||
l.Close()
|
||||
return lastFreePort, nil
|
||||
}
|
||||
}
|
||||
l, err = net.Listen("tcp", ":0")
|
||||
if err != nil {
|
||||
return -1, fmt.Errorf("unable to find free port: %v", err)
|
||||
}
|
||||
// NOTE: may not be after 20000
|
||||
return l.Addr().(*net.TCPAddr).Port, nil
|
||||
}
|
177
src/go-proxy/stream.go
Normal file
177
src/go-proxy/stream.go
Normal file
|
@ -0,0 +1,177 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
type StreamRoute struct {
|
||||
Alias string // to show in panel
|
||||
Type string
|
||||
ListeningScheme string
|
||||
ListeningPort string
|
||||
TargetScheme string
|
||||
TargetHost string
|
||||
TargetPort string
|
||||
|
||||
Context context.Context
|
||||
Cancel context.CancelFunc
|
||||
}
|
||||
|
||||
var imageNamePortMap = map[string]string{
|
||||
"postgres": "5432",
|
||||
"mysql": "3306",
|
||||
"mariadb": "3306",
|
||||
"redis": "6379",
|
||||
"mssql": "1433",
|
||||
"memcached": "11211",
|
||||
"rabbitmq": "5672",
|
||||
}
|
||||
var extraNamePortMap = map[string]string{
|
||||
"dns": "53",
|
||||
"ssh": "22",
|
||||
"ftp": "21",
|
||||
"smtp": "25",
|
||||
"pop3": "110",
|
||||
"imap": "143",
|
||||
}
|
||||
var 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
|
||||
}()
|
||||
|
||||
const UDPStreamType = "udp"
|
||||
const TCPStreamType = "tcp"
|
||||
|
||||
func NewStreamRoute(config ProxyConfig) (*StreamRoute, error) {
|
||||
port_split := strings.Split(config.Port, ":")
|
||||
|
||||
var streamType string = TCPStreamType
|
||||
var srcPort string
|
||||
var dstPort string
|
||||
var srcScheme string
|
||||
var dstScheme string
|
||||
var srcUDPAddr *net.UDPAddr = nil
|
||||
var dstUDPAddr *net.UDPAddr = nil
|
||||
|
||||
if len(port_split) != 2 {
|
||||
warnMsg := fmt.Sprintf(`[Build] Invalid stream port %s, `+
|
||||
`should be <listeningPort>:<targetPort>`, config.Port)
|
||||
freePort, err := findFreePort()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s and %s", warnMsg, err)
|
||||
}
|
||||
srcPort = fmt.Sprintf("%d", freePort)
|
||||
dstPort = config.Port
|
||||
fmt.Printf(`%s, assuming %s is targetPort and `+
|
||||
`using free port %s as listeningPort`,
|
||||
warnMsg,
|
||||
srcPort,
|
||||
dstPort,
|
||||
)
|
||||
} else {
|
||||
srcPort = port_split[0]
|
||||
dstPort = port_split[1]
|
||||
}
|
||||
|
||||
port, hasName := namePortMap[dstPort]
|
||||
if hasName {
|
||||
dstPort = port
|
||||
}
|
||||
_, err := strconv.Atoi(dstPort)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"[Build] Unrecognized 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
|
||||
}
|
||||
|
||||
if srcScheme == "udp" {
|
||||
streamType = UDPStreamType
|
||||
srcUDPAddr, err = net.ResolveUDPAddr("udp", fmt.Sprintf("0.0.0.0:%s", srcPort))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if dstScheme == "udp" {
|
||||
streamType = UDPStreamType
|
||||
dstUDPAddr, err = net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%s", config.Host, dstPort))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
route := StreamRoute{
|
||||
Alias: config.Alias,
|
||||
Type: streamType,
|
||||
ListeningScheme: srcScheme,
|
||||
TargetScheme: dstScheme,
|
||||
TargetHost: config.Host,
|
||||
ListeningPort: srcPort,
|
||||
TargetPort: dstPort,
|
||||
|
||||
Context: ctx,
|
||||
Cancel: cancel,
|
||||
}
|
||||
|
||||
if streamType == UDPStreamType {
|
||||
return (*StreamRoute)(unsafe.Pointer(&UDPRoute{
|
||||
StreamRoute: route,
|
||||
ConnMap: make(map[net.Addr]*net.UDPConn),
|
||||
ConnMapMutex: sync.Mutex{},
|
||||
QueueSize: atomic.Int32{},
|
||||
SourceUDPAddr: srcUDPAddr,
|
||||
TargetUDPAddr: dstUDPAddr,
|
||||
})), nil
|
||||
}
|
||||
return &route, nil
|
||||
}
|
||||
|
||||
func (route *StreamRoute) PrintError(err error) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
log.Printf("[Stream] %s => %s error: %v", route.ListeningUrl(), route.TargetUrl(), err)
|
||||
}
|
||||
|
||||
func (route *StreamRoute) ListeningUrl() string {
|
||||
return fmt.Sprintf("%s://:%s", route.ListeningScheme, route.ListeningPort)
|
||||
}
|
||||
|
||||
func (route *StreamRoute) TargetUrl() string {
|
||||
return fmt.Sprintf("%s://%s:%s", route.TargetScheme, route.TargetHost, route.TargetPort)
|
||||
}
|
||||
|
||||
func (route *StreamRoute) listenStream() {
|
||||
if route.Type == UDPStreamType {
|
||||
listenUDP((*UDPRoute)(unsafe.Pointer(route)))
|
||||
} else {
|
||||
listenTCP(route)
|
||||
}
|
||||
}
|
74
src/go-proxy/tcp.go
Normal file
74
src/go-proxy/tcp.go
Normal file
|
@ -0,0 +1,74 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const tcpDialTimeout = 5 * time.Second
|
||||
|
||||
func listenTCP(route *StreamRoute) {
|
||||
in, err := net.Listen(
|
||||
route.ListeningScheme,
|
||||
fmt.Sprintf(":%s", route.ListeningPort),
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("[Stream Listen] %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
defer in.Close()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-route.Context.Done():
|
||||
return
|
||||
default:
|
||||
clientConn, err := in.Accept()
|
||||
if err != nil {
|
||||
log.Printf("[Stream Accept] %v", err)
|
||||
return
|
||||
}
|
||||
go connectTCPPipe(route, clientConn)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func connectTCPPipe(route *StreamRoute, clientConn net.Conn) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), tcpDialTimeout)
|
||||
defer cancel()
|
||||
|
||||
serverAddr := fmt.Sprintf("%s:%s", route.TargetHost, route.TargetPort)
|
||||
dialer := &net.Dialer{}
|
||||
serverConn, err := dialer.DialContext(ctx, route.TargetScheme, serverAddr)
|
||||
if err != nil {
|
||||
log.Printf("[Stream Dial] %v", err)
|
||||
return
|
||||
}
|
||||
tcpPipe(route, clientConn, serverConn)
|
||||
}
|
||||
|
||||
func tcpPipe(route *StreamRoute, src net.Conn, dest net.Conn) {
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2) // Number of goroutines
|
||||
defer src.Close()
|
||||
defer dest.Close()
|
||||
|
||||
go func() {
|
||||
_, err := io.Copy(src, dest)
|
||||
go route.PrintError(err)
|
||||
wg.Done()
|
||||
}()
|
||||
go func() {
|
||||
_, err := io.Copy(dest, src)
|
||||
go route.PrintError(err)
|
||||
wg.Done()
|
||||
}()
|
||||
|
||||
wg.Wait()
|
||||
}
|
125
src/go-proxy/udp.go
Normal file
125
src/go-proxy/udp.go
Normal file
|
@ -0,0 +1,125 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
const udpBufferSize = 1500
|
||||
const udpMaxQueueSizePerStream = 100
|
||||
const udpListenTimeout = 100 * time.Second
|
||||
const udpConnectionTimeout = 30 * time.Second
|
||||
|
||||
type UDPRoute struct {
|
||||
StreamRoute
|
||||
|
||||
ConnMap map[net.Addr]*net.UDPConn
|
||||
ConnMapMutex sync.Mutex
|
||||
QueueSize atomic.Int32
|
||||
SourceUDPAddr *net.UDPAddr
|
||||
TargetUDPAddr *net.UDPAddr
|
||||
}
|
||||
|
||||
func listenUDP(route *UDPRoute) {
|
||||
source, err := net.ListenUDP(route.ListeningScheme, route.SourceUDPAddr)
|
||||
if err != nil {
|
||||
route.PrintError(err)
|
||||
return
|
||||
}
|
||||
|
||||
target, err := net.DialUDP(route.TargetScheme, nil, route.TargetUDPAddr)
|
||||
if err != nil {
|
||||
route.PrintError(err)
|
||||
return
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
|
||||
defer wg.Wait()
|
||||
defer source.Close()
|
||||
defer target.Close()
|
||||
|
||||
var udpBuffers = [udpMaxQueueSizePerStream][udpBufferSize]byte{}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-route.Context.Done():
|
||||
return
|
||||
default:
|
||||
if route.QueueSize.Load() >= udpMaxQueueSizePerStream {
|
||||
wg.Wait()
|
||||
}
|
||||
go udpLoop(
|
||||
route,
|
||||
source,
|
||||
target,
|
||||
udpBuffers[route.QueueSize.Load()][:],
|
||||
&wg,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func udpLoop(route *UDPRoute, in *net.UDPConn, out *net.UDPConn, buffer []byte, wg *sync.WaitGroup) {
|
||||
wg.Add(1)
|
||||
route.QueueSize.Add(1)
|
||||
defer route.QueueSize.Add(-1)
|
||||
defer wg.Done()
|
||||
|
||||
in.SetReadDeadline(time.Now().Add(udpListenTimeout))
|
||||
|
||||
var nRead int
|
||||
var nWritten int
|
||||
nRead, srcAddr, err := in.ReadFromUDP(buffer)
|
||||
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[Stream] received %d bytes from %s, forwarding to %s", nRead, srcAddr.String(), out.RemoteAddr().String())
|
||||
out.SetWriteDeadline(time.Now().Add(udpConnectionTimeout))
|
||||
nWritten, err = out.Write(buffer[:nRead])
|
||||
if nWritten != nRead {
|
||||
err = io.ErrShortWrite
|
||||
}
|
||||
if err != nil {
|
||||
go route.PrintError(err)
|
||||
return
|
||||
}
|
||||
|
||||
err = udpPipe(route, out, srcAddr, buffer)
|
||||
if err != nil {
|
||||
go route.PrintError(err)
|
||||
}
|
||||
}
|
||||
|
||||
func udpPipe(route *UDPRoute, src *net.UDPConn, destAddr *net.UDPAddr, buffer []byte) error {
|
||||
src.SetReadDeadline(time.Now().Add(udpConnectionTimeout))
|
||||
nRead, err := src.Read(buffer)
|
||||
if err != nil || nRead == 0 {
|
||||
return err
|
||||
}
|
||||
log.Printf("[Stream] received %d bytes from %s, forwarding to %s", nRead, src.RemoteAddr().String(), destAddr.String())
|
||||
dest, ok := route.ConnMap[destAddr]
|
||||
if !ok {
|
||||
dest, err = net.DialUDP(src.LocalAddr().Network(), nil, destAddr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
route.ConnMapMutex.Lock()
|
||||
route.ConnMap[destAddr] = dest
|
||||
route.ConnMapMutex.Unlock()
|
||||
}
|
||||
dest.SetWriteDeadline(time.Now().Add(udpConnectionTimeout))
|
||||
nWritten, err := dest.Write(buffer[:nRead])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if nWritten != nRead {
|
||||
return io.ErrShortWrite
|
||||
}
|
||||
return nil
|
||||
}
|
|
@ -7,7 +7,7 @@
|
|||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<style>
|
||||
body {
|
||||
background-color: #343a40;
|
||||
background-color: #131516;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
|
@ -38,6 +38,10 @@
|
|||
border-bottom-right-radius: 10px;
|
||||
}
|
||||
|
||||
table caption {
|
||||
color: antiquewhite;
|
||||
}
|
||||
|
||||
.health-circle {
|
||||
height: 15px;
|
||||
width: 15px;
|
||||
|
@ -83,33 +87,65 @@
|
|||
</script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<body class="m-3">
|
||||
<div class="container">
|
||||
<h1 style="color: #ffffff;">Route Panel</h1>
|
||||
<table class="table table-striped table-dark w-auto">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Subdomain</th>
|
||||
<th>Path</th>
|
||||
<th>URL</th>
|
||||
<th>Health</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range $subdomain, $routes := .}}
|
||||
{{range $route := $routes.Iter}}
|
||||
<tr>
|
||||
<td>{{$subdomain}}</td>
|
||||
<td>{{$route.Path}}</td>
|
||||
<td>{{$route.Url.String}}</td>
|
||||
<td class="align-middle">
|
||||
<div class="health-circle"></div>
|
||||
</td> <!-- Health column -->
|
||||
</tr>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
<h1 class="text-success">
|
||||
Route Panel
|
||||
</h1>
|
||||
<div class="row">
|
||||
<div class="table-responsive col-md-6">
|
||||
<table class="table table-striped table-dark caption-top w-auto">
|
||||
<caption>HTTP Proxies</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Alias</th>
|
||||
<th>Path</th>
|
||||
<th>URL</th>
|
||||
<th>Health</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range $alias, $httpRoutes := .HTTPRoutes}}
|
||||
{{range $route := $httpRoutes}}
|
||||
<tr>
|
||||
<td>{{$alias}}</td>
|
||||
<td>{{$route.Path}}</td>
|
||||
<td>{{$route.Url.String}}</td>
|
||||
<td class="align-middle">
|
||||
<div class="health-circle"></div>
|
||||
</td> <!-- Health column -->
|
||||
</tr>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="table-responsive col-md-6">
|
||||
<table class="table table-striped table-dark caption-top w-auto">
|
||||
<caption>Streams</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Alias</th>
|
||||
<th>Source</th>
|
||||
<th>Target</th>
|
||||
<th>Health</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range $_, $route := .StreamRoutes}}
|
||||
<tr>
|
||||
<td>{{$route.Alias}}</td>
|
||||
<td>{{$route.ListeningUrl}}</td>
|
||||
<td>{{$route.TargetUrl}}</td>
|
||||
<td class="align-middle">
|
||||
<div class="health-circle"></div>
|
||||
</td> <!-- Health column -->
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue