panel apperance, added experimental tcp/udp proxy support, slight performance improvement for http proxy

This commit is contained in:
yusing 2024-03-02 17:02:11 +08:00
parent 12e23c3517
commit 9cb1b1d31a
21 changed files with 865 additions and 207 deletions

4
.gitignore vendored
View file

@ -1,2 +1,2 @@
bin/
compose.yml
compose.yml
go-proxy.yml

View file

@ -10,5 +10,6 @@ ENV DOCKER_HOST unix:///var/run/docker.sock
EXPOSE 80
EXPOSE 443
EXPOSE 8443
CMD ["go-proxy"]

140
README.md
View file

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

Binary file not shown.

View file

@ -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
View file

@ -0,0 +1 @@
/home/yusing/docker/compose/go-proxy.yml

5
go.mod
View file

@ -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
View file

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

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

View file

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

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

View file

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

View file

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