diff --git a/Dockerfile b/Dockerfile index 31a2e14..e066e6c 100755 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,9 @@ FROM alpine:latest -MAINTAINER yusing@6uo.me +LABEL maintainer="yusing@6uo.me" COPY bin/go-proxy /usr/bin +COPY templates/ /app/templates RUN chmod +rx /usr/bin/go-proxy ENV DOCKER_HOST unix:///var/run/docker.sock diff --git a/README.md b/README.md index f8ca830..dc0171c 100755 --- a/README.md +++ b/README.md @@ -9,6 +9,9 @@ Written in **Go** with *~220 loc*. - subdomain matching **(domain name doesn't matter)** - path matching - Auto hot-reload when container start / die / stop. +- Simple panel to see all reverse proxies and health (visit `https://go-proxy.yourdomain.com`) + + ![panel screenshot](screenshots/panel.png) ## Why am I making this @@ -157,13 +160,9 @@ Transfer/sec: 1.98MB 2. Get dependencies with `go get` -3. build image with following commands +3. build binary with `sh build.sh` - ```shell - mkdir -p bin - CGO_ENABLED=0 GOOS= go build -o bin/go-proxy - docker build -t . - ``` +4. start your container with `docker compose up -d` ## Getting SSL certs diff --git a/build.sh b/build.sh index 3d637fc..f445a54 100755 --- a/build.sh +++ b/build.sh @@ -1,6 +1,6 @@ #!/bin/sh mkdir -p bin -CGO_ENABLED=0 GOOS=linux go build -o bin/go-proxy || exit 1 +CGO_ENABLED=0 GOOS=linux go build -o bin/go-proxy src/go-proxy/*.go || exit 1 if [ "$1" = "up" ]; then docker compose up -d --build app && \ diff --git a/compose.example.yml b/compose.example.yml index 73efb66..f4ca5e6 100755 --- a/compose.example.yml +++ b/compose.example.yml @@ -12,3 +12,8 @@ services: - /var/run/docker.sock:/var/run/docker.sock:ro extra_hosts: - host.docker.internal:host-gateway + logging: + driver: 'json-file' + options: + max-file: '1' + max-size: 128k diff --git a/go.mod b/go.mod index f454b9e..8fa68c4 100755 --- a/go.mod +++ b/go.mod @@ -1,8 +1,9 @@ -module go-proxy +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 ) diff --git a/go.sum b/go.sum index c65c784..6e9c2df 100755 --- a/go.sum +++ b/go.sum @@ -8,6 +8,8 @@ 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= diff --git a/screenshots/panel.png b/screenshots/panel.png new file mode 100755 index 0000000..a784b77 Binary files /dev/null and b/screenshots/panel.png differ diff --git a/src/go-proxy/docker.go b/src/go-proxy/docker.go new file mode 100644 index 0000000..217f41f --- /dev/null +++ b/src/go-proxy/docker.go @@ -0,0 +1,116 @@ +package go_proxy + +import ( + "fmt" + "log" + "net/url" + "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" + "golang.org/x/net/context" + "golang.org/x/text/cases" + "golang.org/x/text/language" +) + +type Config struct { + Scheme string + Host string + Port string + Path string +} + +type Route struct { + Url *url.URL + Path string +} + +var dockerClient *client.Client +var subdomainRouteMap map[string]mapset.Set[Route] // subdomain -> path + +func buildContainerCfg(container types.Container) { + var aliases []string + + container_name := strings.TrimPrefix(container.Names[0], "/") + aliases_label, ok := container.Labels["proxy.aliases"] + if !ok { + aliases = []string{container_name} + } else { + aliases = strings.Split(aliases_label, ",") + } + + for _, alias := range aliases { + config := NewConfig() + prefix := fmt.Sprintf("proxy.%s.", alias) + 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) + prop := reflect.ValueOf(&config).Elem().FieldByName(field) + prop.Set(reflect.ValueOf(value)) + } + } + if config.Port == "" { + // usually the smaller port is the http one + // so make it the last one to be set (if 80 or 8080 are not exposed) + sort.Slice(container.Ports, func(i, j int) bool { + return container.Ports[i].PrivatePort > container.Ports[j].PrivatePort + }) + for _, port := range container.Ports { + // set first, but keep trying + config.Port = fmt.Sprintf("%d", port.PrivatePort) + // until we find 80 or 8080 + if port.PrivatePort == 80 || port.PrivatePort == 8080 { + break + } + } + } + if config.Port == "" { + // no ports exposed or specified + return + } + if config.Scheme == "" { + if strings.HasSuffix(config.Port, "443") { + config.Scheme = "https" + } else { + config.Scheme = "http" + } + } + if config.Scheme != "http" && config.Scheme != "https" { + log.Printf("%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 { + 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}) + } +} + +func buildRoutes() { + subdomainRouteMap = make(map[string]mapset.Set[Route]) + containerSlice, err := dockerClient.ContainerList(context.Background(), container.ListOptions{}) + if err != nil { + log.Fatal(err) + } + for _, container := range containerSlice { + buildContainerCfg(container) + } + subdomainRouteMap["go-proxy"] = panelRoute +} diff --git a/main.go b/src/go-proxy/main.go similarity index 53% rename from main.go rename to src/go-proxy/main.go index e83f222..6c64d4e 100755 --- a/main.go +++ b/src/go-proxy/main.go @@ -1,4 +1,4 @@ -package main +package go_proxy import ( "fmt" @@ -7,35 +7,19 @@ import ( "net/http" "net/http/httputil" "net/url" - "reflect" "runtime" - "sort" "strings" "time" "github.com/docker/docker/api/types" - "github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/filters" "github.com/docker/docker/client" "golang.org/x/net/context" - "golang.org/x/text/cases" - "golang.org/x/text/language" + + mapset "github.com/deckarep/golang-set/v2" ) -type Config struct { - Scheme string - Host string - Port string - Path string -} - -type Route struct { - Url url.URL - Path string -} - -var dockerClient *client.Client -var subdomainRouteMap map[string][]Route // subdomain -> path +var panelRoute = mapset.NewSet(Route{Url: &url.URL{Scheme: "http", Host: "localhost:81", Path: "/"}, Path: "/"}) // TODO: default + per proxy var transport = &http.Transport{ @@ -95,6 +79,13 @@ func main() { log.Fatal("HTTP server error", err) } }() + go func() { + log.Println("Starting HTTP panel on port 81") + err := http.ListenAndServe(":81", http.HandlerFunc(panelHandler)) + if err != nil { + log.Fatal("HTTP server error", err) + } + }() log.Println("Starting HTTPS server on port 443") err = http.ListenAndServeTLS(":443", "/certs/cert.crt", "/certs/priv.key", mux) if err != nil { @@ -122,13 +113,13 @@ func handler(w http.ResponseWriter, r *http.Request) { http.Error(w, fmt.Sprintf("no matching route for subdomain %s", subdomain), http.StatusNotFound) return } - for _, route := range routeMap { + 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 := httputil.NewSingleHostReverseProxy(route.Url) proxyServer.Transport = transport proxyServer.ServeHTTP(w, r) return @@ -136,85 +127,3 @@ func handler(w http.ResponseWriter, r *http.Request) { } http.Error(w, fmt.Sprintf("no matching route for path %s for subdomain %s", r.URL.Path, subdomain), http.StatusNotFound) } - -func buildContainerCfg(container types.Container) { - var aliases []string - - container_name := strings.TrimPrefix(container.Names[0], "/") - aliases_label, ok := container.Labels["proxy.aliases"] - if !ok { - aliases = []string{container_name} - } else { - aliases = strings.Split(aliases_label, ",") - } - - for _, alias := range aliases { - config := NewConfig() - prefix := fmt.Sprintf("proxy.%s.", alias) - 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) - prop := reflect.ValueOf(&config).Elem().FieldByName(field) - prop.Set(reflect.ValueOf(value)) - } - } - if config.Port == "" { - // usually the smaller port is the http one - // so make it the last one to be set (if 80 or 8080 are not exposed) - sort.Slice(container.Ports, func(i, j int) bool { - return container.Ports[i].PrivatePort > container.Ports[j].PrivatePort - }) - for _, port := range container.Ports { - // set first, but keep trying - config.Port = fmt.Sprintf("%d", port.PrivatePort) - // until we find 80 or 8080 - if port.PrivatePort == 80 || port.PrivatePort == 8080 { - break - } - } - } - if config.Port == "" { - // no ports exposed or specified - return - } - if config.Scheme == "" { - if strings.HasSuffix(config.Port, "443") { - config.Scheme = "https" - } else { - config.Scheme = "http" - } - } - if config.Scheme != "http" && config.Scheme != "https" { - log.Printf("%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 { - config.Host = "host.docker.internal" - } - } - _, inMap := subdomainRouteMap[alias] - if !inMap { - subdomainRouteMap[alias] = make([]Route, 0) - } - url, err := url.Parse(fmt.Sprintf("%s://%s:%s", config.Scheme, config.Host, config.Port)) - if err != nil { - log.Fatal(err) - } - route := Route{Url: *url, Path: config.Path} - subdomainRouteMap[alias] = append(subdomainRouteMap[alias], route) - } -} -func buildRoutes() { - subdomainRouteMap = make(map[string][]Route) - containerSlice, err := dockerClient.ContainerList(context.Background(), container.ListOptions{}) - if err != nil { - log.Fatal(err) - } - for _, container := range containerSlice { - buildContainerCfg(container) - } -} diff --git a/src/go-proxy/panel.go b/src/go-proxy/panel.go new file mode 100644 index 0000000..8a90124 --- /dev/null +++ b/src/go-proxy/panel.go @@ -0,0 +1,80 @@ +package go_proxy + +import ( + "html/template" + "net/http" + "time" +) + +const templateFile = "/app/templates/panel.html" + +var healthCheckHttpClient = &http.Client{ + Timeout: 5 * time.Second, + Transport: &http.Transport{ + DisableKeepAlives: true, + }, +} + +func panelHandler(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/": + panelIndex(w, r) + return + case "/checkhealth": + panelCheckTargetHealth(w, r) + return + default: + http.NotFound(w, r) + return + } +} + +func panelIndex(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + tmpl, err := template.ParseFiles(templateFile) + + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + err = tmpl.Execute(w, subdomainRouteMap) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} + +func panelCheckTargetHealth(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodHead { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + targetUrl := r.URL.Query().Get("target") + + if targetUrl == "" { + http.Error(w, "target is required", http.StatusBadRequest) + 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 + } + } + if err != nil { + http.Error(w, err.Error(), http.StatusServiceUnavailable) + return + } + + w.WriteHeader(http.StatusOK) +} diff --git a/templates/panel.html b/templates/panel.html new file mode 100644 index 0000000..5f35c97 --- /dev/null +++ b/templates/panel.html @@ -0,0 +1,116 @@ + + + + + + + + + Route Panel + + + + +
+

Route Panel

+ + + + + + + + + + + {{range $subdomain, $routes := .}} + {{range $route := $routes.Iter}} + + + + + + + {{end}} + {{end}} + +
SubdomainPathURLHealth
{{$subdomain}}{{$route.Path}}{{$route.Url.String}} +
+
+
+ + + \ No newline at end of file