diff --git a/.gitignore b/.gitignore index 8495d27..d1e3b10 100755 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,2 @@ -bin/ -compose.yml \ No newline at end of file +compose.yml +go-proxy.yml \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index e066e6c..a37ca8c 100755 --- a/Dockerfile +++ b/Dockerfile @@ -10,5 +10,6 @@ ENV DOCKER_HOST unix:///var/run/docker.sock EXPOSE 80 EXPOSE 443 +EXPOSE 8443 CMD ["go-proxy"] diff --git a/README.md b/README.md index dc0171c..d586a08 100755 --- a/README.md +++ b/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`) ![panel screenshot](screenshots/panel.png) ## 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..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 .yourdomain.com +7. start your docker app, and visit .y.z ## Configuration @@ -55,17 +73,20 @@ However, there are some labels you can manipulate with: - `proxy..host`: proxy host - defaults to `container_name` - `proxy..port`: proxy port - - defaults to first expose port (declared in `Dockerfile` or `docker-compose.yml`) -- `proxy..path`: path matching + - http/https: defaults to first expose port (declared in `Dockerfile` or `docker-compose.yml`) + - tcp/udp: is in format of `[:]` + - 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..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://.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` diff --git a/bin/go-proxy b/bin/go-proxy new file mode 100755 index 0000000..3d3c5db Binary files /dev/null and b/bin/go-proxy differ diff --git a/compose.example.yml b/compose.example.yml index f4ca5e6..7192f1b 100755 --- a/compose.example.yml +++ b/compose.example.yml @@ -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..scheme == tcp) + - 20000:20100/udp # tcp (optional, if you have proxy..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 \ No newline at end of file diff --git a/go-proxy.yml b/go-proxy.yml new file mode 120000 index 0000000..5d20dc6 --- /dev/null +++ b/go-proxy.yml @@ -0,0 +1 @@ +/home/yusing/docker/compose/go-proxy.yml \ No newline at end of file diff --git a/go.mod b/go.mod index 8fa68c4..06c7435 100755 --- a/go.mod +++ b/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 diff --git a/go.sum b/go.sum index 6e9c2df..04253d0 100755 --- a/go.sum +++ b/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= diff --git a/screenshots/panel.png b/screenshots/panel.png index a784b77..cc539a2 100755 Binary files a/screenshots/panel.png and b/screenshots/panel.png differ diff --git a/build.sh b/scripts/build.sh similarity index 100% rename from build.sh rename to scripts/build.sh diff --git a/scripts/udp-test-container.sh b/scripts/udp-test-container.sh new file mode 100644 index 0000000..2241cbb --- /dev/null +++ b/scripts/udp-test-container.sh @@ -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" diff --git a/src/go-proxy/docker.go b/src/go-proxy/docker.go index 217f41f..3cdf285 100644 --- a/src/go-proxy/docker.go +++ b/src/go-proxy/docker.go @@ -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) } diff --git a/src/go-proxy/healthcheck.go b/src/go-proxy/healthcheck.go new file mode 100644 index 0000000..e249069 --- /dev/null +++ b/src/go-proxy/healthcheck.go @@ -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 +} diff --git a/src/go-proxy/http_proxy.go b/src/go-proxy/http_proxy.go new file mode 100755 index 0000000..5165b38 --- /dev/null +++ b/src/go-proxy/http_proxy.go @@ -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) +} diff --git a/src/go-proxy/main.go b/src/go-proxy/main.go old mode 100755 new mode 100644 index 6c64d4e..c5bcf2f --- a/src/go-proxy/main.go +++ b/src/go-proxy/main.go @@ -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) -} diff --git a/src/go-proxy/panel.go b/src/go-proxy/panel.go index 8a90124..fcd5c66 100644 --- a/src/go-proxy/panel.go +++ b/src/go-proxy/panel.go @@ -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) + } } diff --git a/src/go-proxy/proxy.go b/src/go-proxy/proxy.go new file mode 100644 index 0000000..1a26ac1 --- /dev/null +++ b/src/go-proxy/proxy.go @@ -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 +} diff --git a/src/go-proxy/stream.go b/src/go-proxy/stream.go new file mode 100644 index 0000000..14c2dc1 --- /dev/null +++ b/src/go-proxy/stream.go @@ -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 :`, 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) + } +} \ No newline at end of file diff --git a/src/go-proxy/tcp.go b/src/go-proxy/tcp.go new file mode 100644 index 0000000..3419b26 --- /dev/null +++ b/src/go-proxy/tcp.go @@ -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() +} diff --git a/src/go-proxy/udp.go b/src/go-proxy/udp.go new file mode 100644 index 0000000..d4df6cd --- /dev/null +++ b/src/go-proxy/udp.go @@ -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 +} diff --git a/templates/panel.html b/templates/panel.html index 5f35c97..0bf366c 100644 --- a/templates/panel.html +++ b/templates/panel.html @@ -7,7 +7,7 @@