mirror of
https://github.com/yusing/godoxy.git
synced 2026-03-18 15:34:38 +01:00
added a simple panel
This commit is contained in:
116
src/go-proxy/docker.go
Normal file
116
src/go-proxy/docker.go
Normal file
@@ -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
|
||||
}
|
||||
129
src/go-proxy/main.go
Executable file
129
src/go-proxy/main.go
Executable file
@@ -0,0 +1,129 @@
|
||||
package go_proxy
|
||||
|
||||
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())
|
||||
|
||||
dockerClient, err = client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
go func() {
|
||||
filter := filters.NewArgs(
|
||||
filters.Arg("type", "container"),
|
||||
filters.Arg("event", "start"),
|
||||
filters.Arg("event", "die"), // stop seems like triggering die
|
||||
// filters.Arg("event", "stop"),
|
||||
)
|
||||
msgs, _ := dockerClient.Events(context.Background(), types.EventsOptions{Filters: filter})
|
||||
|
||||
for msg := range msgs {
|
||||
// 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))
|
||||
}
|
||||
}()
|
||||
|
||||
buildRoutes()
|
||||
log.Printf("[Build] built %v reverse proxies", len(subdomainRouteMap))
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/", handler)
|
||||
|
||||
go func() {
|
||||
log.Println("Starting HTTP server on port 80")
|
||||
err := http.ListenAndServe(":80", http.HandlerFunc(redirectToTLS))
|
||||
if err != nil {
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
80
src/go-proxy/panel.go
Normal file
80
src/go-proxy/panel.go
Normal file
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user