mirror of
https://github.com/yusing/godoxy.git
synced 2025-05-19 20:32:35 +02:00
added a simple panel
This commit is contained in:
parent
47733ec05f
commit
12e23c3517
11 changed files with 342 additions and 113 deletions
|
@ -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
|
||||
|
|
11
README.md
11
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`)
|
||||
|
||||

|
||||
|
||||
## 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=<platform> go build -o bin/go-proxy
|
||||
docker build -t <tag> .
|
||||
```
|
||||
4. start your container with `docker compose up -d`
|
||||
|
||||
## Getting SSL certs
|
||||
|
||||
|
|
2
build.sh
2
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 && \
|
||||
|
|
|
@ -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
|
||||
|
|
3
go.mod
3
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
|
||||
)
|
||||
|
|
2
go.sum
2
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=
|
||||
|
|
BIN
screenshots/panel.png
Executable file
BIN
screenshots/panel.png
Executable file
Binary file not shown.
After Width: | Height: | Size: 100 KiB |
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
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
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)
|
||||
}
|
116
templates/panel.html
Normal file
116
templates/panel.html
Normal file
|
@ -0,0 +1,116 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<style>
|
||||
body {
|
||||
background-color: #343a40;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
border-spacing: 0;
|
||||
}
|
||||
|
||||
tr {
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
table th:first-child {
|
||||
border-radius: 10px 0 0 10px;
|
||||
}
|
||||
|
||||
table th:last-child {
|
||||
border-radius: 0 10px 10px 0;
|
||||
}
|
||||
|
||||
table td:first-of-type {
|
||||
border-top-left-radius: 10px;
|
||||
border-bottom-left-radius: 10px;
|
||||
}
|
||||
|
||||
table td:last-of-type {
|
||||
border-top-right-radius: 10px;
|
||||
border-bottom-right-radius: 10px;
|
||||
}
|
||||
|
||||
.health-circle {
|
||||
height: 15px;
|
||||
width: 15px;
|
||||
background-color: #28a745;
|
||||
border-radius: 50%;
|
||||
margin: auto;
|
||||
}
|
||||
</style>
|
||||
<title>Route Panel</title>
|
||||
<script>
|
||||
function checkHealth(url, cell) {
|
||||
var xhttp = new XMLHttpRequest();
|
||||
xhttp.onreadystatechange = function () {
|
||||
if (this.readyState != 4) {
|
||||
return
|
||||
}
|
||||
if (this.status === 200) {
|
||||
cell.innerHTML = '<div class="health-circle"></div>'; // Green circle for healthy
|
||||
} else {
|
||||
cell.innerHTML = '<div class="health-circle" style="background-color: #dc3545;"></div>'; // Red circle for unhealthy
|
||||
}
|
||||
};
|
||||
url = window.location.origin + '/checkhealth?target=' + encodeURIComponent(url);
|
||||
xhttp.open("HEAD", url, true);
|
||||
xhttp.send();
|
||||
}
|
||||
|
||||
function updateHealthStatus() {
|
||||
let rows = document.querySelectorAll('tbody tr');
|
||||
rows.forEach(row => {
|
||||
let url = row.cells[2].textContent;
|
||||
let cell = row.cells[3]; // Health column cell
|
||||
checkHealth(url, cell);
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
updateHealthStatus();
|
||||
|
||||
// Update health status every 5 seconds
|
||||
setInterval(updateHealthStatus, 5000);
|
||||
})
|
||||
</script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1 style="color: #ffffff;">Route Panel</h1>
|
||||
<table class="table table-striped table-dark w-auto">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Subdomain</th>
|
||||
<th>Path</th>
|
||||
<th>URL</th>
|
||||
<th>Health</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range $subdomain, $routes := .}}
|
||||
{{range $route := $routes.Iter}}
|
||||
<tr>
|
||||
<td>{{$subdomain}}</td>
|
||||
<td>{{$route.Path}}</td>
|
||||
<td>{{$route.Url.String}}</td>
|
||||
<td class="align-middle">
|
||||
<div class="health-circle"></div>
|
||||
</td> <!-- Health column -->
|
||||
</tr>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
Loading…
Add table
Reference in a new issue