added load balance support and verbose level

This commit is contained in:
yusing 2024-03-06 12:34:06 +08:00
parent a5c53a4f4f
commit 2f439233ed
25 changed files with 530 additions and 240 deletions

4
.gitignore vendored
View file

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

10
Dockerfile Executable file → Normal file
View file

@ -2,14 +2,18 @@ FROM alpine:latest
LABEL maintainer="yusing@6uo.me"
COPY bin/go-proxy /usr/bin
RUN apk add --no-cache bash
RUN mkdir /app
COPY bin/go-proxy entrypoint.sh /app/
COPY templates/ /app/templates
RUN chmod +rx /usr/bin/go-proxy
RUN chmod +x /app/go-proxy /app/entrypoint.sh
ENV DOCKER_HOST unix:///var/run/docker.sock
ENV VERBOSITY=1
EXPOSE 80
EXPOSE 443
EXPOSE 8443
CMD ["go-proxy"]
WORKDIR /app
ENTRYPOINT /app/entrypoint.sh

18
Makefile Normal file → Executable file
View file

@ -1,6 +1,6 @@
.PHONY: build up restart logs get test-udp-container
.PHONY: all build up quick-restart restart logs get udp-server
all: build up logs
all: build quick-restart logs
build:
mkdir -p bin
@ -9,12 +9,18 @@ build:
up:
docker compose up -d --build go-proxy
quick-restart: # quick restart without restarting the container
docker cp bin/go-proxy go-proxy:/app/go-proxy
docker cp templates/* go-proxy:/app/templates
docker cp entrypoint.sh go-proxy:/app/entrypoint.sh
docker exec -d go-proxy bash -c "/app/entrypoint.sh restart"
restart:
docker compose down -t 0
docker compose up -d
docker kill go-proxy
docker compose up -d go-proxy
logs:
docker compose logs -f
docker logs -f go-proxy
get:
go get -d -u ./src/go-proxy
@ -26,4 +32,4 @@ udp-server:
--label proxy.test-udp.port=20003:9999 \
--network data_default \
--name test-udp \
$$(docker build -q -f udp-test-server.Dockerfile .)
$$(docker build -q -f udp-test-server.Dockerfile .)

View file

@ -13,6 +13,7 @@ In the examples domain `x.y.z` is used, replace them with your domain
- [Single Port Configuration](#single-port-configuration-example)
- [Multiple Ports Configuration](#multiple-ports-configuration-example)
- [TCP/UDP Configuration](#tcpudp-configuration-example)
- [Load balancing Configuration](#load-balancing-configuration-example)
- [Troubleshooting](#troubleshooting)
- [Benchmarks](#benchmarks)
- [Memory usage](#memory-usage)
@ -25,6 +26,7 @@ In the examples domain `x.y.z` is used, replace them with your domain
- path matching
- HTTP proxy
- TCP/UDP Proxy (experimental, unable to release port on hot-reload)
- HTTP round robin load balance support (same subdomain and path across containers replicas)
- Auto hot-reload when container start / die / stop.
- Simple panel to see all reverse proxies and health (visit port [panel port] of go-proxy `https://*.y.z:[panel port]`)
@ -62,6 +64,11 @@ In the examples domain `x.y.z` is used, replace them with your domain
8. check the logs with `docker compose logs` or `make logs` to see if there is any error, check panel at [panel port] for active proxies
## Known issues
- When a container has replicas, you have to specify `proxy.<alias>.host` to the container_name
- UDP proxy does not work properly
## Configuration
With container name, no label needs to be added.
@ -81,6 +88,19 @@ However, there are some labels you can manipulate with:
- `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
- `proxy.<alias>.path_mode`: mode for path handling
- defaults to empty
- allowed: \<empty>, forward, sub
- empty: remove path prefix from URL when proxying
1. apps.y.z/webdav -> webdav:80
2. apps.y.z./webdav/path/to/file -> webdav:80/path/to/file
- forward: path remain unchanged
1. apps.y.z/webdav -> webdav:80/webdav
2. apps.y.z./webdav/path/to/file -> webdav:80/webdav/path/to/file
- sub: remove path prefix from both URL and HTML attributes (`src`, `href` and `action`)
- `proxy.<alias>.load_balance`: enable load balance
- allowed: `1`, `true`
### Single port configuration example
@ -109,9 +129,9 @@ minio:
container_name: minio
...
labels:
proxy.aliases: minio,minio-console
proxy.minio.port: 9000
proxy.minio-console.port: 9001
- proxy.aliases=minio,minio-console
- proxy.minio.port=9000
- proxy.minio-console.port=9001
# visit https://minio.y.z to access minio
# visit https://minio-console.y.z/whoami to access minio console
@ -144,6 +164,18 @@ go-proxy:
# access app-db via <*>.y.z:20000
```
## Load balancing Configuration Example
```yaml
nginx:
...
deploy:
mode: replicated
replicas: 3
labels:
- proxy.nginx.load_balance=1 # allowed: [1, true]
```
## Troubleshooting
Q: How to fix when it shows "no matching route for subdomain \<subdomain>"?

Binary file not shown.

View file

@ -3,9 +3,13 @@ services:
app:
build: .
container_name: go-proxy
hostname: go-proxy # set hostname to prevent adding itself to proxy list
restart: always
networks: # also add here
networks: # ^also add here
- default
environment:
- VERBOSITY=1 # LOG LEVEL (optional, defaults to 1)
- DEBUG=1 # (optional enable only for debug)
ports:
- 80:80 # http
- 443:443 # https
@ -15,14 +19,15 @@ services:
volumes:
- /path/to/cert.pem:/certs/cert.crt:ro
- /path/to/privkey.pem:/certs/priv.key:ro
- ./go-proxy/logs:/app/log # path to logs
- /var/run/docker.sock:/var/run/docker.sock:ro
extra_hosts:
- host.docker.internal:host-gateway
- host.docker.internal:host-gateway # required if you have containers in `host` network_mode
logging:
driver: 'json-file'
options:
max-file: '1'
max-size: 128k
networks: # you may add other external networks
networks: # ^you may add other external networks
default:
driver: bridge

12
entrypoint.sh Normal file
View file

@ -0,0 +1,12 @@
#!/bin/bash
if [ "$1" == "restart" ]; then
killall go-proxy
fi
if [ "$DEBUG" == "1" ]; then
/app/go-proxy -v=$VERBOSITY -log_dir=log --stderrthreshold=0 &
if [ "$1" != "restart" ]; then
tail -f /dev/null
fi
else
/app/go-proxy -v=$VERBOSITY -log_dir=log --stderrthreshold=0 &
fi

15
go.mod
View file

@ -2,10 +2,9 @@ module github.com/yusing/go-proxy
go 1.21.7
require (
github.com/docker/docker v25.0.3+incompatible
golang.org/x/text v0.14.0
)
require github.com/docker/docker v25.0.3+incompatible
require github.com/golang/glog v1.2.0
require (
github.com/containerd/log v0.1.0 // indirect
@ -13,9 +12,9 @@ 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/mod v0.16.0 // indirect
golang.org/x/time v0.5.0 // indirect
golang.org/x/tools v0.18.0 // indirect
golang.org/x/tools v0.19.0 // indirect
gotest.tools/v3 v3.5.1 // indirect
)
@ -35,6 +34,6 @@ require (
go.opentelemetry.io/otel v1.24.0 // indirect
go.opentelemetry.io/otel/metric v1.24.0 // indirect
go.opentelemetry.io/otel/trace v1.24.0 // indirect
golang.org/x/net v0.21.0
golang.org/x/sys v0.17.0 // indirect
golang.org/x/net v0.22.0
golang.org/x/sys v0.18.0 // indirect
)

10
go.sum
View file

@ -25,6 +25,8 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang/glog v1.2.0 h1:uCdmnmatrKCgMBlM4rMuJZWOkPDqdbZPnrMXDY4gI68=
github.com/golang/glog v1.2.0/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w=
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
@ -74,12 +76,16 @@ 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/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic=
golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc=
golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/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=
@ -90,6 +96,8 @@ golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7w
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=
golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
@ -102,6 +110,8 @@ golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roY
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/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw=
golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc=
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=

57
src/go-proxy/constants.go Normal file
View file

@ -0,0 +1,57 @@
package main
import "time"
var (
ImageNamePortMap = map[string]string{
"postgres": "5432",
"mysql": "3306",
"mariadb": "3306",
"redis": "6379",
"mssql": "1433",
"memcached": "11211",
"rabbitmq": "5672",
"mongo": "27017",
}
ExtraNamePortMap = map[string]string{
"dns": "53",
"ssh": "22",
"ftp": "21",
"smtp": "25",
"pop3": "110",
"imap": "143",
}
NamePortMap = func() map[string]string {
m := make(map[string]string)
for k, v := range ImageNamePortMap {
m[k] = v
}
for k, v := range ExtraNamePortMap {
m[k] = v
}
return m
}()
)
var (
StreamSchemes = []string{TCPStreamType, UDPStreamType} // TODO: support "tcp:udp", "udp:tcp"
HTTPSchemes = []string{"http", "https"}
ValidSchemes = append(StreamSchemes, HTTPSchemes...)
)
const (
UDPStreamType = "udp"
TCPStreamType = "tcp"
)
const (
ProxyPathMode_Forward = "forward"
ProxyPathMode_Sub = "sub" // TODO: implement
ProxyPathMode_RemovedPath = ""
)
const StreamStopListenTimeout = 1 * time.Second
const templateFile = "/app/templates/panel.html"
const udpBufferSize = 1500

74
src/go-proxy/docker.go Normal file → Executable file
View file

@ -2,7 +2,6 @@ package main
import (
"fmt"
"log"
"os"
"reflect"
"sort"
@ -12,28 +11,10 @@ import (
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/client"
"github.com/golang/glog"
"golang.org/x/net/context"
"golang.org/x/text/cases"
"golang.org/x/text/language"
)
type ProxyConfig struct {
id string
Alias string
Scheme string
Host string
Port string
Path string // http proxy only
}
func NewProxyConfig() ProxyConfig {
return ProxyConfig{}
}
func (cfg *ProxyConfig) UpdateId() {
cfg.id = fmt.Sprintf("%s-%s-%s-%s-%s", cfg.Alias, cfg.Scheme, cfg.Host, cfg.Port, cfg.Path)
}
var dockerClient *client.Client
func buildContainerRoute(container types.Container) {
@ -54,8 +35,12 @@ func buildContainerRoute(container types.Container) {
for label, value := range container.Labels {
if strings.HasPrefix(label, prefix) {
field := strings.TrimPrefix(label, prefix)
field = cases.Title(language.Und, cases.NoLower).String(field)
field = utils.snakeToCamel(field)
prop := reflect.ValueOf(&config).Elem().FieldByName(field)
if prop.Kind() == 0 {
glog.Infof("[Build] %s: ignoring unknown field %s", alias, field)
continue
}
prop.Set(reflect.ValueOf(value))
}
}
@ -76,6 +61,7 @@ func buildContainerRoute(container types.Container) {
}
if config.Port == "" {
// no ports exposed or specified
glog.Infof("[Build] %s has no port exposed", alias)
return
}
if config.Scheme == "" {
@ -87,7 +73,7 @@ func buildContainerRoute(container types.Container) {
imageSplit := strings.Split(container.Image, "/")
imageSplit = strings.Split(imageSplit[len(imageSplit)-1], ":")
imageName := imageSplit[0]
_, isKnownImage := imageNamePortMap[imageName]
_, isKnownImage := ImageNamePortMap[imageName]
if isKnownImage {
config.Scheme = "tcp"
} else {
@ -96,22 +82,37 @@ func buildContainerRoute(container types.Container) {
}
}
if !isValidScheme(config.Scheme) {
log.Printf("%s: unsupported scheme: %s, using http", container_name, config.Scheme)
glog.Infof("%s: unsupported scheme: %s, using http", container_name, config.Scheme)
config.Scheme = "http"
}
if config.Host == "" {
if container.HostConfig.NetworkMode != "host" {
config.Host = container_name
} else {
switch {
case container.HostConfig.NetworkMode == "host":
config.Host = "host.docker.internal"
case config.LoadBalance == "true":
case config.LoadBalance == "1":
for _, network := range container.NetworkSettings.Networks {
config.Host = network.IPAddress
break
}
default:
for _, network := range container.NetworkSettings.Networks {
for _, alias := range network.Aliases {
config.Host = alias
break
}
}
}
}
if config.Host == "" {
config.Host = container_name
}
config.Alias = alias
config.UpdateId()
wg.Add(1)
go func() {
createRoute(&config)
CreateRoute(&config)
wg.Done()
}()
}
@ -119,10 +120,10 @@ func buildContainerRoute(container types.Container) {
}
func buildRoutes() {
initRoutes()
InitRoutes()
containerSlice, err := dockerClient.ContainerList(context.Background(), container.ListOptions{})
if err != nil {
log.Fatal(err)
glog.Fatal(err)
}
hostname, err := os.Hostname()
if err != nil {
@ -130,22 +131,9 @@ func buildRoutes() {
}
for _, container := range containerSlice {
if container.Names[0] == hostname { // skip self
glog.Infof("[Build] Skipping %s", container.Names[0])
continue
}
buildContainerRoute(container)
}
}
func findHTTPRoute(host string, path string) (*HTTPRoute, error) {
subdomain := strings.Split(host, ".")[0]
routeMap, ok := routes.HTTPRoutes.TryGet(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)
}

22
src/go-proxy/http_lbpool.go Executable file
View file

@ -0,0 +1,22 @@
package main
import "sync/atomic"
type httpLoadBalancePool struct {
pool []*HTTPRoute
curentIndex atomic.Int32
}
func NewHTTPLoadBalancePool() *httpLoadBalancePool {
return &httpLoadBalancePool{
pool: make([]*HTTPRoute, 0),
}
}
func (p *httpLoadBalancePool) Add(route *HTTPRoute) {
p.pool = append(p.pool, route)
}
func (p *httpLoadBalancePool) Iterator() []*HTTPRoute {
return p.pool
}

View file

@ -1,66 +0,0 @@
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)
}

160
src/go-proxy/http_route.go Executable file
View file

@ -0,0 +1,160 @@
package main
import (
"fmt"
"net"
"net/http"
"net/http/httputil"
"net/url"
"strings"
"time"
"github.com/golang/glog"
)
type HTTPRoute struct {
Url *url.URL
Path string
PathMode string
Proxy *httputil.ReverseProxy
}
func isValidProxyPathMode(mode string) bool {
switch mode {
case ProxyPathMode_Forward, ProxyPathMode_Sub, ProxyPathMode_RemovedPath:
return true
default:
return false
}
}
func NewHTTPRoute(config *ProxyConfig) (*HTTPRoute, error) {
url, err := url.Parse(fmt.Sprintf("%s://%s:%s", config.Scheme, config.Host, config.Port))
if err != nil {
glog.Infoln(err)
return nil, err
}
proxy := httputil.NewSingleHostReverseProxy(url)
proxy.Transport = transport
if !isValidProxyPathMode(config.PathMode) {
return nil, fmt.Errorf("invalid path mode: %s", config.PathMode)
}
route := &HTTPRoute{
Url: url,
Path: config.Path,
Proxy: proxy,
PathMode: config.PathMode,
}
proxy.Director = nil
initRewrite := func(pr *httputil.ProxyRequest) {
pr.SetURL(url)
pr.SetXForwarded()
}
rewrite := initRewrite
switch {
case config.Path == "", config.PathMode == ProxyPathMode_Forward:
break
case config.PathMode == ProxyPathMode_Sub:
rewrite = func(pr *httputil.ProxyRequest) {
initRewrite(pr)
// disable compression
pr.Out.Header.Set("Accept-Encoding", "identity")
pr.Out.URL.Path = strings.TrimPrefix(pr.Out.URL.Path, config.Path)
}
route.Proxy.ModifyResponse = func(r *http.Response) error {
contentType, ok := r.Header["Content-Type"]
if !ok || len(contentType) == 0 {
glog.Infof("unknown content type for %s", r.Request.URL.String())
return nil
}
if !strings.HasPrefix(contentType[0], "text/html") {
return nil
}
err := utils.respRemovePath(r, config.Path)
if err != nil {
err = fmt.Errorf("failed to remove path prefix %s: %v", config.Path, err)
r.Status = err.Error()
r.StatusCode = http.StatusInternalServerError
}
return err
}
default:
rewrite = func(pr *httputil.ProxyRequest) {
initRewrite(pr)
pr.Out.URL.Path = strings.TrimPrefix(pr.Out.URL.Path, config.Path)
}
}
if glog.V(3) {
route.Proxy.Rewrite = func(pr *httputil.ProxyRequest) {
rewrite(pr)
r := pr.In
glog.Infof("[Request] %s %s%s", r.Method, r.Host, r.URL.Path)
glog.V(4).InfoDepthf(1, "Headers: %v", r.Header)
}
} else {
route.Proxy.Rewrite = rewrite
}
return route, nil
}
func (p *httpLoadBalancePool) Pick() *HTTPRoute {
// round-robin
index := int(p.curentIndex.Load())
defer p.curentIndex.Add(1)
return p.pool[index%len(p.pool)]
}
func redirectToTLS(w http.ResponseWriter, r *http.Request) {
// Redirect to the same host but with HTTPS
var redirectCode int
if r.Method == http.MethodGet {
redirectCode = http.StatusMovedPermanently
} else {
redirectCode = http.StatusPermanentRedirect
}
http.Redirect(w, r, fmt.Sprintf("https://%s%s?%s", r.Host, r.URL.Path, r.URL.RawQuery), redirectCode)
}
func findHTTPRoute(host string, path string) (*HTTPRoute, error) {
subdomain := strings.Split(host, ".")[0]
routeMap, ok := routes.HTTPRoutes.UnsafeGet(subdomain)
if !ok {
return nil, fmt.Errorf("no matching route for subdomain %s", subdomain)
}
return routeMap.FindMatch(path)
}
func httpProxyHandler(w http.ResponseWriter, r *http.Request) {
route, err := findHTTPRoute(r.Host, r.URL.Path)
if err != nil {
err = fmt.Errorf("[Request] failed %s %s%s, error: %v",
r.Method,
r.Host,
r.URL.Path,
err,
)
glog.Error(err)
http.Error(w, err.Error(), http.StatusNotFound)
return
}
route.Proxy.ServeHTTP(w, r)
}
// 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,
}

32
src/go-proxy/main.go Normal file → Executable file
View file

@ -1,28 +1,30 @@
package main
import (
"log"
"flag"
"net/http"
"runtime"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/filters"
"github.com/docker/docker/client"
"github.com/golang/glog"
"golang.org/x/net/context"
)
func main() {
var err error
flag.Parse()
runtime.GOMAXPROCS(runtime.NumCPU())
dockerClient, err = client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
if err != nil {
log.Fatal(err)
glog.Fatal(err)
}
buildRoutes()
log.Printf("[Build] built %v reverse proxies", countRoutes())
beginListenStreams()
glog.Infof("[Build] built %v reverse proxies", CountRoutes())
BeginListenStreams()
go func() {
filter := filters.NewArgs(
@ -37,13 +39,13 @@ func main() {
select {
case msg := <-msgChan:
// TODO: handle actor only
log.Printf("[Event] %s %s caused rebuild", msg.Action, msg.Actor.Attributes["name"])
endListenStreams()
glog.Infof("[Event] %s %s caused rebuild", msg.Action, msg.Actor.Attributes["name"])
EndListenStreams()
buildRoutes()
log.Printf("[Build] rebuilt %v reverse proxies", countRoutes())
beginListenStreams()
glog.Infof("[Build] rebuilt %v reverse proxies", CountRoutes())
BeginListenStreams()
case err := <-errChan:
log.Printf("[Event] %s", err)
glog.Infof("[Event] %s", err)
msgChan, errChan = dockerClient.Events(context.Background(), types.EventsOptions{Filters: filter})
}
}
@ -53,22 +55,22 @@ func main() {
mux.HandleFunc("/", httpProxyHandler)
go func() {
log.Println("Starting HTTP server on port 80")
glog.Infoln("Starting HTTP server on port 80")
err := http.ListenAndServe(":80", http.HandlerFunc(redirectToTLS))
if err != nil {
log.Fatal("HTTP server error", err)
glog.Fatal("HTTP server error", err)
}
}()
go func() {
log.Println("Starting HTTPS panel on port 8443")
glog.Infoln("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)
glog.Fatal("HTTP server error", err)
}
}()
log.Println("Starting HTTPS server on port 443")
glog.Infoln("Starting HTTPS server on port 443")
err = http.ListenAndServeTLS(":443", "/certs/cert.crt", "/certs/priv.key", mux)
if err != nil {
log.Fatal("HTTPS Server error: ", err)
glog.Fatal("HTTPS Server error: ", err)
}
}

14
src/go-proxy/map.go Normal file → Executable file
View file

@ -16,19 +16,19 @@ type SafeMapInterface[KT comparable, VT interface{}] interface {
type SafeMap[KT comparable, VT interface{}] struct {
SafeMapInterface[KT, VT]
m map[KT]VT
mutex sync.Mutex
m map[KT]VT
mutex sync.Mutex
defaultFactory func() VT
}
func NewSafeMap[KT comparable, VT interface{}](df... func() VT) *SafeMap[KT, VT] {
func NewSafeMap[KT comparable, VT interface{}](df ...func() VT) *SafeMap[KT, VT] {
if len(df) == 0 {
return &SafeMap[KT, VT]{
m: make(map[KT]VT),
}
}
return &SafeMap[KT, VT]{
m: make(map[KT]VT),
m: make(map[KT]VT),
defaultFactory: df[0],
}
}
@ -54,10 +54,8 @@ func (m *SafeMap[KT, VT]) Get(key KT) VT {
return value
}
func (m *SafeMap[KT, VT]) TryGet(key KT) (VT, bool) {
m.mutex.Lock()
func (m *SafeMap[KT, VT]) UnsafeGet(key KT) (VT, bool) {
value, ok := m.m[key]
m.mutex.Unlock()
return value, ok
}
@ -91,4 +89,4 @@ func (m *SafeMap[KT, VT]) ForEach(fn func(key KT, value VT)) {
func (m *SafeMap[KT, VT]) Iterator() map[KT]VT {
return m.m
}
}

7
src/go-proxy/panel.go Normal file → Executable file
View file

@ -2,14 +2,13 @@ package main
import (
"html/template"
"log"
"net"
"net/http"
"net/url"
"time"
)
const templateFile = "/app/templates/panel.html"
"github.com/golang/glog"
)
var healthCheckHttpClient = &http.Client{
Timeout: 5 * time.Second,
@ -72,7 +71,7 @@ func panelCheckTargetHealth(w http.ResponseWriter, r *http.Request) {
url, err := url.Parse(targetUrl)
if err != nil {
log.Printf("[Panel] failed to parse %s, error: %v", targetUrl, err)
glog.Infof("[Panel] failed to parse %s, error: %v", targetUrl, err)
http.Error(w, err.Error(), http.StatusBadRequest)
return
}

View file

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

View file

@ -0,0 +1,22 @@
package main
import "fmt"
type ProxyConfig struct {
id string
Alias string
Scheme string
Host string
Port string
LoadBalance string
Path string // http proxy only
PathMode string // http proxy only
}
func NewProxyConfig() ProxyConfig {
return ProxyConfig{}
}
func (cfg *ProxyConfig) UpdateId() {
cfg.id = fmt.Sprintf("%s-%s-%s-%s-%s", cfg.Alias, cfg.Scheme, cfg.Host, cfg.Port, cfg.Path)
}

39
src/go-proxy/route.go Normal file → Executable file
View file

@ -1,27 +1,21 @@
package main
import (
"fmt"
"log"
"net/url"
"sync"
"github.com/golang/glog"
)
type Routes struct {
HTTPRoutes *SafeMap[string, []HTTPRoute] // id -> path
HTTPRoutes *SafeMap[string, pathPoolMap] // id -> (path -> routes)
StreamRoutes *SafeMap[string, StreamRoute] // id -> target
Mutex sync.Mutex
}
var routes = Routes{}
var streamSchemes = []string{"tcp", "udp"} // TODO: support "tcp:udp", "udp:tcp"
var httpSchemes = []string{"http", "https"}
var validSchemes = append(streamSchemes, httpSchemes...)
func isValidScheme(scheme string) bool {
for _, v := range validSchemes {
for _, v := range ValidSchemes {
if v == scheme {
return true
}
@ -30,7 +24,7 @@ func isValidScheme(scheme string) bool {
}
func isStreamScheme(scheme string) bool {
for _, v := range streamSchemes {
for _, v := range StreamSchemes {
if v == scheme {
return true
}
@ -38,40 +32,35 @@ func isStreamScheme(scheme string) bool {
return false
}
func initRoutes() {
func InitRoutes() {
utils.resetPortsInUse()
routes.HTTPRoutes = NewSafeMap[string, []HTTPRoute](
func() []HTTPRoute {
return make([]HTTPRoute, 0)
},
)
routes.HTTPRoutes = NewSafeMap[string](newPathPoolMap)
routes.StreamRoutes = NewSafeMap[string, StreamRoute]()
}
func countRoutes() int {
func CountRoutes() int {
return routes.HTTPRoutes.Size() + routes.StreamRoutes.Size()
}
func createRoute(config *ProxyConfig) {
func CreateRoute(config *ProxyConfig) {
if isStreamScheme(config.Scheme) {
if routes.StreamRoutes.Contains(config.id) {
log.Printf("[Build] Duplicated %s stream %s, ignoring", config.Scheme, config.id)
glog.Infof("[Build] Duplicated %s stream %s, ignoring", config.Scheme, config.id)
return
}
route, err := NewStreamRoute(config)
if err != nil {
log.Println(err)
glog.Infoln(err)
return
}
routes.StreamRoutes.Set(config.id, route)
} else {
routes.HTTPRoutes.Ensure(config.Alias)
url, err := url.Parse(fmt.Sprintf("%s://%s:%s", config.Scheme, config.Host, config.Port))
route, err := NewHTTPRoute(config)
if err != nil {
log.Println(err)
glog.Infoln(err)
return
}
route := NewHTTPRoute(url, config.Path)
routes.HTTPRoutes.Set(config.Alias, append(routes.HTTPRoutes.Get(config.Alias), route))
routes.HTTPRoutes.Get(config.Alias).Add(config.Path, route)
}
}

61
src/go-proxy/stream.go → src/go-proxy/stream_route.go Normal file → Executable file
View file

@ -3,11 +3,12 @@ package main
import (
"errors"
"fmt"
"log"
"strconv"
"strings"
"sync"
"time"
"github.com/golang/glog"
)
type StreamRoute interface {
@ -46,7 +47,7 @@ func newStreamRouteBase(config *ProxyConfig) (*StreamRouteBase, error) {
port_split := strings.Split(config.Port, ":")
if len(port_split) != 2 {
log.Printf(`[Build] %s: Invalid stream port %s, `+
glog.Infof(`[Build] %s: Invalid stream port %s, `+
`assuming it's targetPort`, config.Alias, config.Port)
srcPort = "0"
dstPort = config.Port
@ -55,7 +56,7 @@ func newStreamRouteBase(config *ProxyConfig) (*StreamRouteBase, error) {
dstPort = port_split[1]
}
port, hasName := namePortMap[dstPort]
port, hasName := NamePortMap[dstPort]
if hasName {
dstPort = port
}
@ -117,11 +118,16 @@ func (route *StreamRouteBase) PrintError(err error) {
if err == nil {
return
}
route.Logf("Error: %s", err.Error())
glog.Errorf("[%s -> %s] %s: %v",
route.ListeningScheme,
route.TargetScheme,
route.Alias,
err,
)
}
func (route *StreamRouteBase) Logf(format string, v ...interface{}) {
log.Printf("[%s -> %s] %s: "+format,
glog.Infof("[%s -> %s] %s: "+format,
append([]interface{}{
route.ListeningScheme,
route.TargetScheme,
@ -176,14 +182,14 @@ func stopListening(route StreamRoute) {
case <-done:
route.Logf("Stopped listening")
return
case <-time.After(streamStopListenTimeout):
case <-time.After(StreamStopListenTimeout):
route.Logf("timed out waiting for connections")
return
}
}
func allStreamsDo(msg string, fn ...func(StreamRoute)) {
log.Printf("[Stream] %s", msg)
glog.Infof("[Stream] %s", msg)
var wg sync.WaitGroup
@ -198,48 +204,13 @@ func allStreamsDo(msg string, fn ...func(StreamRoute)) {
}
wg.Wait()
log.Printf("[Stream] Finished %s", msg)
glog.Infof("[Stream] Finished %s", msg)
}
func beginListenStreams() {
func BeginListenStreams() {
allStreamsDo("Start", StreamRoute.SetupListen, StreamRoute.Listen)
}
func endListenStreams() {
func EndListenStreams() {
allStreamsDo("Stop", StreamRoute.StopListening)
}
var imageNamePortMap = map[string]string{
"postgres": "5432",
"mysql": "3306",
"mariadb": "3306",
"redis": "6379",
"mssql": "1433",
"memcached": "11211",
"rabbitmq": "5672",
"mongo": "27017",
}
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"
// const maxQueueSizePerStream = 100
const streamStopListenTimeout = 1 * time.Second

5
src/go-proxy/tcp.go → src/go-proxy/tcp_route.go Normal file → Executable file
View file

@ -4,10 +4,11 @@ import (
"context"
"fmt"
"io"
"log"
"net"
"sync"
"time"
"github.com/golang/glog"
)
const tcpDialTimeout = 5 * time.Second
@ -100,7 +101,7 @@ func (route *TCPRoute) grHandleConnection(clientConn net.Conn) {
dialer := &net.Dialer{}
serverConn, err := dialer.DialContext(ctx, route.TargetScheme, serverAddr)
if err != nil {
log.Printf("[Stream Dial] %v", err)
glog.Infof("[Stream Dial] %v", err)
return
}
route.tcpPipe(clientConn, serverConn)

5
src/go-proxy/udp.go → src/go-proxy/udp_route.go Normal file → Executable file
View file

@ -7,11 +7,6 @@ import (
"sync"
)
const udpBufferSize = 1500
// const udpListenTimeout = 100 * time.Second
// const udpConnectionTimeout = 30 * time.Second
type UDPRoute struct {
*StreamRouteBase

50
src/go-proxy/utils.go Normal file → Executable file
View file

@ -1,11 +1,16 @@
package main
import (
"bytes"
"fmt"
"io"
"net"
"net/http"
"strings"
"sync"
"time"
xhtml "golang.org/x/net/html"
)
type Utils struct {
@ -82,3 +87,48 @@ func (*Utils) healthCheckStream(scheme string, host string) error {
conn.Close()
return nil
}
func (*Utils) snakeToCamel(s string) string {
toHyphenCamel := http.CanonicalHeaderKey(strings.ReplaceAll(s, "_", "-"))
return strings.ReplaceAll(toHyphenCamel, "-", "")
}
func htmlNodesSubPath(node *xhtml.Node, path string) {
if node.Type == xhtml.ElementNode {
for _, attr := range node.Attr {
switch attr.Key {
case "src": // img, script, etc.
case "href": // link
case "action": // form
if strings.HasPrefix(attr.Val, path) {
attr.Val = strings.Replace(attr.Val, path, "", 1)
}
}
}
}
for c := node.FirstChild; c != nil; c = c.NextSibling {
htmlNodesSubPath(c, path)
}
}
func (*Utils) respRemovePath(r *http.Response, path string) error {
// remove all path prefix from relative path in script, img, a, ...
doc, err := xhtml.Parse(r.Body)
if err != nil {
return err
}
htmlNodesSubPath(doc, path)
var buf bytes.Buffer
err = xhtml.Render(&buf, doc)
if err != nil {
return err
}
r.Body = io.NopCloser(strings.NewReader(buf.String()))
return nil
}

8
templates/panel.html Normal file → Executable file
View file

@ -105,11 +105,12 @@
</tr>
</thead>
<tbody>
{{range $alias, $httpRoutes := .HTTPRoutes.Iterator}}
{{range $route := $httpRoutes}}
{{range $alias, $pathPoolMap := .HTTPRoutes.Iterator}}
{{range $path, $lbPool := $pathPoolMap.Iterator}}
{{range $_, $route := $lbPool.Iterator}}
<tr>
<td>{{$alias}}</td>
<td>{{$route.Path}}</td>
<td>{{$path}}</td>
<td>{{$route.Url.String}}</td>
<td class="align-middle">
<div class="health-circle"></div>
@ -117,6 +118,7 @@
</tr>
{{end}}
{{end}}
{{end}}
</tbody>
</table>
</div>