added a simple panel

This commit is contained in:
yusing 2024-03-01 16:50:26 +08:00
parent 47733ec05f
commit 12e23c3517
11 changed files with 342 additions and 113 deletions

View file

@ -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

View file

@ -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=<platform> go build -o bin/go-proxy
docker build -t <tag> .
```
4. start your container with `docker compose up -d`
## Getting SSL certs

View file

@ -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 && \

View file

@ -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
View file

@ -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
View file

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

116
src/go-proxy/docker.go Normal file
View 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
}

View file

@ -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
View 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
View 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>