diff --git a/.gitignore b/.gitignore index d1e3b106..7a124407 100755 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ compose.yml -go-proxy.yml \ No newline at end of file +go-proxy.yml +bin/go-proxy.bak +logs/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile old mode 100755 new mode 100644 index a37ca8cc..45041c0a --- a/Dockerfile +++ b/Dockerfile @@ -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 \ No newline at end of file diff --git a/Makefile b/Makefile old mode 100644 new mode 100755 index 498a49fa..93e9db51 --- a/Makefile +++ b/Makefile @@ -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 .) \ No newline at end of file + $$(docker build -q -f udp-test-server.Dockerfile .) diff --git a/README.md b/README.md index 0d44b6fa..750774c4 100755 --- a/README.md +++ b/README.md @@ -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..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..path`: path matching (for http proxy only) - defaults to empty +- `proxy..path_mode`: mode for path handling + - defaults to empty + - allowed: \, 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..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 \"? diff --git a/bin/go-proxy b/bin/go-proxy index 23d49a62..115cc521 100755 Binary files a/bin/go-proxy and b/bin/go-proxy differ diff --git a/compose.example.yml b/compose.example.yml index 7192f1bf..200dcf3e 100755 --- a/compose.example.yml +++ b/compose.example.yml @@ -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 \ No newline at end of file diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 00000000..ca77871c --- /dev/null +++ b/entrypoint.sh @@ -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 \ No newline at end of file diff --git a/go.mod b/go.mod index 06c74351..4baffcf2 100755 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index 04253d0c..215c67fa 100755 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/src/go-proxy/constants.go b/src/go-proxy/constants.go new file mode 100644 index 00000000..744ce285 --- /dev/null +++ b/src/go-proxy/constants.go @@ -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 diff --git a/src/go-proxy/docker.go b/src/go-proxy/docker.go old mode 100644 new mode 100755 index f81f673f..2581420b --- a/src/go-proxy/docker.go +++ b/src/go-proxy/docker.go @@ -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) -} diff --git a/src/go-proxy/http_lbpool.go b/src/go-proxy/http_lbpool.go new file mode 100755 index 00000000..332be8f0 --- /dev/null +++ b/src/go-proxy/http_lbpool.go @@ -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 +} diff --git a/src/go-proxy/http_proxy.go b/src/go-proxy/http_proxy.go deleted file mode 100755 index 5165b38a..00000000 --- a/src/go-proxy/http_proxy.go +++ /dev/null @@ -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) -} diff --git a/src/go-proxy/http_route.go b/src/go-proxy/http_route.go new file mode 100755 index 00000000..e2b9c431 --- /dev/null +++ b/src/go-proxy/http_route.go @@ -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, +} diff --git a/src/go-proxy/main.go b/src/go-proxy/main.go old mode 100644 new mode 100755 index b7a473d4..c5a44d2e --- a/src/go-proxy/main.go +++ b/src/go-proxy/main.go @@ -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) } } diff --git a/src/go-proxy/map.go b/src/go-proxy/map.go old mode 100644 new mode 100755 index 640c7cce..ecd70b52 --- a/src/go-proxy/map.go +++ b/src/go-proxy/map.go @@ -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 -} \ No newline at end of file +} diff --git a/src/go-proxy/panel.go b/src/go-proxy/panel.go old mode 100644 new mode 100755 index 720affcc..a3b7d608 --- a/src/go-proxy/panel.go +++ b/src/go-proxy/panel.go @@ -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 } diff --git a/src/go-proxy/path_pool_map.go b/src/go-proxy/path_pool_map.go new file mode 100644 index 00000000..9b1033e1 --- /dev/null +++ b/src/go-proxy/path_pool_map.go @@ -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) +} diff --git a/src/go-proxy/proxy_config.go b/src/go-proxy/proxy_config.go new file mode 100644 index 00000000..c263910b --- /dev/null +++ b/src/go-proxy/proxy_config.go @@ -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) +} diff --git a/src/go-proxy/route.go b/src/go-proxy/route.go old mode 100644 new mode 100755 index c7ef6e39..6825ef9a --- a/src/go-proxy/route.go +++ b/src/go-proxy/route.go @@ -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) } } diff --git a/src/go-proxy/stream.go b/src/go-proxy/stream_route.go old mode 100644 new mode 100755 similarity index 79% rename from src/go-proxy/stream.go rename to src/go-proxy/stream_route.go index c665bbef..3f5e163d --- a/src/go-proxy/stream.go +++ b/src/go-proxy/stream_route.go @@ -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 diff --git a/src/go-proxy/tcp.go b/src/go-proxy/tcp_route.go old mode 100644 new mode 100755 similarity index 97% rename from src/go-proxy/tcp.go rename to src/go-proxy/tcp_route.go index 7d3df92a..0abd6ceb --- a/src/go-proxy/tcp.go +++ b/src/go-proxy/tcp_route.go @@ -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) diff --git a/src/go-proxy/udp.go b/src/go-proxy/udp_route.go old mode 100644 new mode 100755 similarity index 97% rename from src/go-proxy/udp.go rename to src/go-proxy/udp_route.go index 44a8c66f..100243c4 --- a/src/go-proxy/udp.go +++ b/src/go-proxy/udp_route.go @@ -7,11 +7,6 @@ import ( "sync" ) -const udpBufferSize = 1500 - -// const udpListenTimeout = 100 * time.Second -// const udpConnectionTimeout = 30 * time.Second - type UDPRoute struct { *StreamRouteBase diff --git a/src/go-proxy/utils.go b/src/go-proxy/utils.go old mode 100644 new mode 100755 index 45845399..b2e3ba08 --- a/src/go-proxy/utils.go +++ b/src/go-proxy/utils.go @@ -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 +} diff --git a/templates/panel.html b/templates/panel.html old mode 100644 new mode 100755 index 461b82f7..a331dfde --- a/templates/panel.html +++ b/templates/panel.html @@ -105,11 +105,12 @@ - {{range $alias, $httpRoutes := .HTTPRoutes.Iterator}} - {{range $route := $httpRoutes}} + {{range $alias, $pathPoolMap := .HTTPRoutes.Iterator}} + {{range $path, $lbPool := $pathPoolMap.Iterator}} + {{range $_, $route := $lbPool.Iterator}} {{$alias}} - {{$route.Path}} + {{$path}} {{$route.Url.String}}
@@ -117,6 +118,7 @@ {{end}} {{end}} + {{end}}