fixes, meaningful error messages and new features

This commit is contained in:
yusing 2024-03-27 06:30:47 +00:00
parent 539ef911de
commit 90f4aac946
50 changed files with 2079 additions and 885 deletions

9
.gitignore vendored
View file

@ -1,7 +1,10 @@
compose.yml compose.yml
go-proxy.yml
config.yml config/**
providers.yml
bin/go-proxy.bak bin/go-proxy.bak
logs/ logs/
log/ log/
config-editor/

3
.gitmodules vendored Normal file
View file

@ -0,0 +1,3 @@
[submodule "templates/codemirror"]
path = templates/codemirror
url = https://github.com/codemirror/codemirror5.git

12
.vscode/settings.json vendored
View file

@ -1,3 +1,13 @@
{ {
"go.inferGopath": false "go.inferGopath": false,
"yaml.schemas": {
"https://gitbuh.com/yusing/go-proxy/schema/config.schema.json": [
"config.example.yml",
"config.yml"
],
"https://gitbuh.com/yusing/go-proxy/schema/providers.schema.json": [
"providers.example.yml",
"*.providers.yml",
]
}
} }

View file

@ -6,7 +6,7 @@ RUN apk add --no-cache bash tzdata
RUN mkdir /app RUN mkdir /app
COPY bin/go-proxy entrypoint.sh /app/ COPY bin/go-proxy entrypoint.sh /app/
COPY templates/ /app/templates COPY templates/ /app/templates
COPY config.example.yml /app/config.yml COPY config.example.yml /app/config/config.yml
RUN chmod +x /app/go-proxy /app/entrypoint.sh RUN chmod +x /app/go-proxy /app/entrypoint.sh
ENV DOCKER_HOST unix:///var/run/docker.sock ENV DOCKER_HOST unix:///var/run/docker.sock

211
README.md
View file

@ -6,52 +6,58 @@ In the examples domain `x.y.z` is used, replace them with your domain
## Table of content ## Table of content
- [Key Points](#key-points) - [go-proxy](#go-proxy)
- [How to use](#how-to-use) - [Table of content](#table-of-content)
- [Key Points](#key-points)
- [How to use](#how-to-use)
- [Binary](#binary) - [Binary](#binary)
- [Docker](#docker) - [Docker](#docker)
- [Configuration](#configuration) - [Use JSON Schema in VSCode](#use-json-schema-in-vscode)
- [Labels](#labels) - [Configuration](#configuration)
- [Environment Variables](#environment-variables) - [Labels (docker)](#labels-docker)
- [Environment variables](#environment-variables)
- [Config File](#config-file) - [Config File](#config-file)
- [Fields](#fields)
- [Provider Kinds](#provider-kinds)
- [Provider File](#provider-file) - [Provider File](#provider-file)
- [Supported Cert Providers](#supported-cert-providers) - [Supported DNS Challenge Providers](#supported-dns-challenge-providers)
- [Examples](#examples) - [Examples](#examples)
- [Single Port Configuration](#single-port-configuration-example) - [Single port configuration example](#single-port-configuration-example)
- [Multiple Ports Configuration](#multiple-ports-configuration-example) - [Multiple ports configuration example](#multiple-ports-configuration-example)
- [TCP/UDP Configuration](#tcpudp-configuration-example) - [TCP/UDP configuration example](#tcpudp-configuration-example)
- [Load balancing Configuration](#load-balancing-configuration-example) - [Load balancing Configuration Example](#load-balancing-configuration-example)
- [Troubleshooting](#troubleshooting) - [Troubleshooting](#troubleshooting)
- [Benchmarks](#benchmarks) - [Benchmarks](#benchmarks)
- [Memory usage](#memory-usage) - [Known issues](#known-issues)
- [Build it yourself](#build-it-yourself) - [Memory usage](#memory-usage)
- [Build it yourself](#build-it-yourself)
## Key Points ## Key Points
- fast, nearly no performance penalty for end users when comparing to direct IP connections (See [benchmarks](#benchmarks)) - Fast (See [benchmarks](#benchmarks))
- auto detect reverse proxies from docker - Auto certificate obtaining and renewal (See [Config File](#config-file) and [Supported DNS Challenge Providers](#supported-dns-challenge-providers))
- additional reverse proxies from provider yaml file - Auto detect reverse proxies from docker
- allow multiple docker / file providers by custom `config.yml` file - Custom proxy entries with `config.yml` and additional provider files
- auto certificate obtaining and renewal (See [Config File](#config-file) and [Supported Cert Providers](#supported-cert-providers)) - Subdomain matching + Path matching **(domain name doesn't matter)**
- subdomain matching **(domain name doesn't matter)** - HTTP(s) proxy + TCP/UDP Proxy
- path matching - HTTP(s) round robin load balance support (same subdomain and path across different hosts)
- HTTP proxy - Auto hot-reload on container `start` / `die` / `stop` or config file changes
- TCP/UDP Proxy - Simple panel to see all reverse proxies and health available on port [panel_port_http] (http) and port [panel_port_https] (https)
- HTTP round robin load balance support (same subdomain and path across different hosts)
- Auto hot-reload on container start / die / stop or config changes.
- Simple panel to see all reverse proxies and health (visit port [panel port] of go-proxy `https://*.y.z:[panel port]`)
- you can customize it by modifying [templates/panel.html](templates/panel.html)
![panel screenshot](screenshots/panel.png) ![panel screenshot](screenshots/panel.png)
- Config editor to edit config and provider files with validation
**Validate and save file with Ctrl+S**
![config editor screenshot](screenshots/config_editor.png)
## How to use ## How to use
1. Download and extract the latest release (or clone the repository if you want to try out experimental features) 1. Download and extract the latest release (or clone the repository if you want to try out experimental features)
2. Copy `config.example.yml` to `config.yml` and modify the content to fit your needs 2. Copy `config.example.yml` to `config/config.yml` and modify the content to fit your needs
3. Do the same for `providers.example.yml` 3. (Optional) write your own `config/providers.yml` from `providers.example.yml`
4. See [Binary](#binary) or [docker](#docker) 4. See [Binary](#binary) or [docker](#docker)
@ -83,17 +89,19 @@ In the examples domain `x.y.z` is used, replace them with your domain
- Use autocert feature - Use autocert feature
1. mount `./certs` to `/app/certs` 1. mount `./certs` to `/app/certs`
```yaml ```yaml
go-proxy: go-proxy:
... ...
volumes: volumes:
- ./certs:/app/certs - ./certs:/app/certs
``` ```
2. complete `autocert` in `config.yml` 2. complete `autocert` in `config.yml`
- Use existing certificate - Use existing certificate
Mount your wildcard (`*.y.z`) SSL cert to enable https. See [Getting SSL Certs](#getting-ssl-certs) Mount your wildcard (`*.y.z`) SSL cert to enable https.
- cert / chain / fullchain -> `/app/certs/cert.crt` - cert / chain / fullchain -> `/app/certs/cert.crt`
- private key -> `/app/certs/priv.key` - private key -> `/app/certs/priv.key`
@ -115,46 +123,68 @@ In the examples domain `x.y.z` is used, replace them with your domain
7. 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 7. 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 ## Use JSON Schema in VSCode
None Modify `.vscode/settings.json` to fit your needs
```json
{
"yaml.schemas": {
"https://gitbuh.com/yusing/go-proxy/schema/config.schema.json": [
"config.example.yml",
"config.yml"
],
"https://gitbuh.com/yusing/go-proxy/schema/providers.schema.json": [
"providers.example.yml",
"*.providers.yml",
]
}
}
```
## Configuration ## Configuration
With container name, most of the time no label needs to be added. With container name, most of the time no label needs to be added.
### Labels ### Labels (docker)
- `proxy.aliases`: comma separated aliases for subdomain matching - `proxy.aliases`: comma separated aliases for subdomain matching
- defaults to `container_name`
- `proxy.*.<field>`: wildcard config for all aliases
- `proxy.<alias>.scheme`: container port protocol (`http` or `https`)
- defaults to `http`
- `proxy.<alias>.host`: proxy host
- defaults to `container_name`
- `proxy.<alias>.port`: proxy port
- http/https: defaults to first expose port (declared in `Dockerfile` or `docker-compose.yml`)
- tcp/udp: is in format of `[<listeningPort>:]<targetPort>`
- when `listeningPort` is omitted (not suggested), a free port will be used automatically.
- `targetPort` must be a number, or the predefined names (see [constants.go:14](src/go-proxy/constants.go#L14))
- `proxy.<alias>.no_tls_verify`: whether skip tls verify when scheme is https
- defaults to false
- `proxy.<alias>.path`: path matching (for http proxy only)
- defaults to empty
- `proxy.<alias>.path_mode`: mode for path handling
- defaults to empty - default: `container_name`
- allowed: \<empty>, forward, sub
- empty: remove path prefix from URL when proxying - `proxy.*.<field>`: wildcard label for all aliases
Below labels has a **`proxy.<alias>`** prefix (i.e. `proxy.nginx.scheme: http`)
- `scheme`: proxy protocol
- default: `http`
- allowed: `http`, `https`, `tcp`, `udp`
- `host`: proxy host
- default: `container_name`
- `port`: proxy port
- default: first expose port (declared in `Dockerfile` or `docker-compose.yml`)
- `http(s)`: number in range og `0 - 65535`
- `tcp/udp`: `[<listeningPort>:]<targetPort>`
- `listeningPort`: number, when it is omitted (not suggested), a free port starting from 20000 will be used.
- `targetPort`: number, or predefined names (see [constants.go:14](src/go-proxy/constants.go#L14))
- `no_tls_verify`: whether skip tls verify when scheme is https
- default: `false`
- `path`: proxy path _(http(s) proxy only)_
- default: empty
- `path_mode`: mode for path handling
- default: empty
- allowed: empty, `forward`, `sub`
- `empty`: remove path prefix from URL when proxying
1. apps.y.z/webdav -> webdav:80 1. apps.y.z/webdav -> webdav:80
2. apps.y.z./webdav/path/to/file -> webdav:80/path/to/file 2. apps.y.z./webdav/path/to/file -> webdav:80/path/to/file
- forward: path remain unchanged - `forward`: path remain unchanged
1. apps.y.z/webdav -> webdav:80/webdav 1. apps.y.z/webdav -> webdav:80/webdav
2. apps.y.z./webdav/path/to/file -> webdav:80/webdav/path/to/file 2. apps.y.z./webdav/path/to/file -> webdav:80/webdav/path/to/file
- sub: (experimental) remove path prefix from URL and also append path to HTML link attributes (`src`, `href` and `action`) and Javascript `fetch(url)` by response body substitution - `sub`: (experimental) remove path prefix from URL and also append path to HTML link attributes (`src`, `href` and `action`) and Javascript `fetch(url)` by response body substitution
e.g. apps.y.z/app1 -> webdav:80, `href="/path/to/file"` -> `href="/app1/path/to/file"` e.g. apps.y.z/app1 -> webdav:80, `href="/app1/path/to/file"` -> `href="/path/to/file"`
- `proxy.<alias>.load_balance`: enable load balance - `load_balance`: enable load balance (docker only)
- allowed: `1`, `true` - allowed: `1`, `true`
### Environment variables ### Environment variables
@ -164,22 +194,45 @@ With container name, most of the time no label needs to be added.
### Config File ### Config File
See [config.example.yml](config.example.yml) See [config.example.yml](config.example.yml) for more
#### Fields
- `autocert`: autocert configuration
- `email`: ACME Email
- `domains`: a list of domains for cert registration
- `provider`: DNS Challenge provider, see [Supported DNS Challenge Providers](#supported-dns-challenge-providers)
- `options`: provider specific options
- `providers`: reverse proxy providers configuration
- `kind`: provider kind (string), see [Provider Kinds](#provider-kinds)
- `value`: provider specific value
#### Provider Kinds
- `docker`: load reverse proxies from docker
values:
- `FROM_ENV`: value from environment
- full url to docker host (i.e. `tcp://host:2375`)
- `file`: load reverse proxies from provider file
value: relative path of file to `config/`
### Provider File ### Provider File
See [providers.example.yml](providers.example.yml) Fields are same as [docker labels](#labels-docker) starting from `scheme`
### Supported cert providers See [providers.example.yml](providers.example.yml) for examples
### Supported DNS Challenge Providers
- Cloudflare - Cloudflare
```yaml - `auth_token`: your zone API token
autocert:
...
options:
auth_token: "YOUR_ZONE_API_TOKEN"
```
Follow [this guide](https://cloudkul.com/blog/automcatic-renew-and-generate-ssl-on-your-website-using-lego-client/) to create a new token with `Zone.DNS` read and edit permissions Follow [this guide](https://cloudkul.com/blog/automcatic-renew-and-generate-ssl-on-your-website-using-lego-client/) to create a new token with `Zone.DNS` read and edit permissions
@ -315,7 +368,7 @@ Local benchmark (client running wrk and `go-proxy` server are under same proxmox
- Direct connection - Direct connection
``` ```shell
root@http-benchmark-client:~# wrk -t 10 -c 200 -d 10s --latency http://10.0.100.1/bench root@http-benchmark-client:~# wrk -t 10 -c 200 -d 10s --latency http://10.0.100.1/bench
Running 10s test @ http://10.0.100.1/bench Running 10s test @ http://10.0.100.1/bench
10 threads and 200 connections 10 threads and 200 connections
@ -334,7 +387,7 @@ Local benchmark (client running wrk and `go-proxy` server are under same proxmox
- With `go-proxy` reverse proxy - With `go-proxy` reverse proxy
``` ```shell
root@http-benchmark-client:~# wrk -t 10 -c 200 -d 10s -H "Host: bench.6uo.me" --latency http://10.0.1.7/bench root@http-benchmark-client:~# wrk -t 10 -c 200 -d 10s -H "Host: bench.6uo.me" --latency http://10.0.1.7/bench
Running 10s test @ http://10.0.1.7/bench Running 10s test @ http://10.0.1.7/bench
10 threads and 200 connections 10 threads and 200 connections
@ -352,7 +405,8 @@ Local benchmark (client running wrk and `go-proxy` server are under same proxmox
``` ```
- With `traefik-v3` - With `traefik-v3`
```
```shell
root@traefik-benchmark:~# wrk -t10 -c200 -d10s -H "Host: benchmark.whoami" --latency http://127.0.0.1:8000/bench root@traefik-benchmark:~# wrk -t10 -c200 -d10s -H "Host: benchmark.whoami" --latency http://127.0.0.1:8000/bench
Running 10s test @ http://127.0.0.1:8000/bench Running 10s test @ http://127.0.0.1:8000/bench
10 threads and 200 connections 10 threads and 200 connections
@ -369,18 +423,25 @@ Local benchmark (client running wrk and `go-proxy` server are under same proxmox
Transfer/sec: 10.94MB Transfer/sec: 10.94MB
``` ```
## Known issues
None
## Memory usage ## Memory usage
It takes ~13 MB for 50 proxy entries It takes ~13 MB for 50 proxy entries
## Build it yourself ## Build it yourself
1. Install [go](https://go.dev/doc/install) and `make` if not already 1. Install / Upgrade [go (>=1.22)](https://go.dev/doc/install) and `make` if not already
2. get dependencies with `make get` 2. Clear cache if you have built this before (go < 1.22) with `go clean -cache`
3. build binary with `make build` 3. get dependencies with `make get`
4. start your container with `make up` (docker) or `bin/go-proxy` (binary) 4. build binary with `make build`
[panel port]: 8443 5. start your container with `make up` (docker) or `bin/go-proxy` (binary)
[panel_port_http]: 8080
[panel_port_https]: 8443

Binary file not shown.

View file

@ -29,13 +29,8 @@ services:
# if local docker provider is used (by default) # if local docker provider is used (by default)
- /var/run/docker.sock:/var/run/docker.sock:ro - /var/run/docker.sock:/var/run/docker.sock:ro
# to use custom config # to use custom config and providers
# - path/to/config.yml:/app/config.yml # - ./config:/app/config
# mount file provider yaml files
# - path/to/provider1.yml:/app/provider1.yml
# - path/to/provider2.yml:/app/provider2.yml
# etc.
dns: dns:
- 127.0.0.1 # workaround for "lookup: no such host" - 127.0.0.1 # workaround for "lookup: no such host"
extra_hosts: extra_hosts:

View file

@ -1,11 +1,11 @@
# uncomment to use autocert # uncomment to use autocert
# autocert: autocert: # (optional, if you need autocert feature)
# email: "user@y.z" # email for acme certificate email: "user@domain.com" # (required) email for acme certificate
# domains: domains: # (required)
# - "*.y.z" # domain for acme certificate, use wild card to allow all subdomains - "*.y.z" # domain for acme certificate, use wild card to allow all subdomains
# provider: cloudflare provider: cloudflare # (required) dns challenge provider (string)
# options: options: # provider specific options
# auth_token: "YOUR_ZONE_API_TOKEN" auth_token: "YOUR_ZONE_API_TOKEN"
providers: providers:
local: local:
kind: docker kind: docker

5
go.mod
View file

@ -1,12 +1,13 @@
module github.com/yusing/go-proxy module github.com/yusing/go-proxy
go 1.21.7 go 1.22
require ( require (
github.com/docker/cli v26.0.0+incompatible github.com/docker/cli v26.0.0+incompatible
github.com/docker/docker v26.0.0+incompatible github.com/docker/docker v26.0.0+incompatible
github.com/fsnotify/fsnotify v1.7.0 github.com/fsnotify/fsnotify v1.7.0
github.com/go-acme/lego/v4 v4.16.1 github.com/go-acme/lego/v4 v4.16.1
github.com/santhosh-tekuri/jsonschema v1.2.4
github.com/sirupsen/logrus v1.9.3 github.com/sirupsen/logrus v1.9.3
golang.org/x/net v0.22.0 golang.org/x/net v0.22.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
@ -14,7 +15,7 @@ require (
require ( require (
github.com/Microsoft/go-winio v0.6.1 // indirect github.com/Microsoft/go-winio v0.6.1 // indirect
github.com/cenkalti/backoff/v4 v4.2.1 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cloudflare/cloudflare-go v0.91.0 // indirect github.com/cloudflare/cloudflare-go v0.91.0 // indirect
github.com/containerd/log v0.1.0 // indirect github.com/containerd/log v0.1.0 // indirect
github.com/distribution/reference v0.5.0 // indirect github.com/distribution/reference v0.5.0 // indirect

5
go.sum
View file

@ -6,6 +6,8 @@ github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migc
github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM=
github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM= github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM=
github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cloudflare/cloudflare-go v0.86.0 h1:jEKN5VHNYNYtfDL2lUFLTRo+nOVNPFxpXTstVx0rqHI= github.com/cloudflare/cloudflare-go v0.86.0 h1:jEKN5VHNYNYtfDL2lUFLTRo+nOVNPFxpXTstVx0rqHI=
github.com/cloudflare/cloudflare-go v0.86.0/go.mod h1:wYW/5UP02TUfBToa/yKbQHV+r6h1NnJ1Je7XjuGM4Jw= github.com/cloudflare/cloudflare-go v0.86.0/go.mod h1:wYW/5UP02TUfBToa/yKbQHV+r6h1NnJ1Je7XjuGM4Jw=
github.com/cloudflare/cloudflare-go v0.91.0 h1:L7IR+86qrZuEMSjGFg4cwRwtHqC8uCPmMUkP7BD4CPw= github.com/cloudflare/cloudflare-go v0.91.0 h1:L7IR+86qrZuEMSjGFg4cwRwtHqC8uCPmMUkP7BD4CPw=
@ -90,6 +92,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.8.1 h1:geMPLpDpQOgVyCg5z5GoRwLHepNdb71NXb67XFkP+Eg= github.com/rogpeppe/go-internal v1.8.1 h1:geMPLpDpQOgVyCg5z5GoRwLHepNdb71NXb67XFkP+Eg=
github.com/rogpeppe/go-internal v1.8.1/go.mod h1:JeRgkft04UBgHMgCIwADu4Pn6Mtm5d4nPKWu0nJ5d+o= github.com/rogpeppe/go-internal v1.8.1/go.mod h1:JeRgkft04UBgHMgCIwADu4Pn6Mtm5d4nPKWu0nJ5d+o=
github.com/santhosh-tekuri/jsonschema v1.2.4 h1:hNhW8e7t+H1vgY+1QeEQpveR6D4+OwKPXCfD2aieJis=
github.com/santhosh-tekuri/jsonschema v1.2.4/go.mod h1:TEAUOeZSmIxTTuHatJzrvARHiuO9LYd+cIxzgEHCQI4=
github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
@ -99,7 +103,6 @@ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXf
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk=

View file

@ -1,15 +1,25 @@
app: # matching `app.y.z` example: # matching `app.y.z`
# optional # optional, defaults to http
scheme: http scheme:
# required, proxy target # required, proxy target
host: 10.0.0.1 host: 10.0.0.1
# optional # optional, defaults to 80 for http, 443 for https
port: 80 port: 80
# optional, defaults to empty # optional, defaults to empty
path: path:
# optional # optional, defaults to sub
path_mode: path_mode:
# optional # optional (https only)
notlsverify: false # no_tls_verify: false
# app2: app1: # matching `app1.y.z` -> http://x.y.z
# ... host: x.y.z
app2: # `app2` has no effect for tcp / udp, but still has to be unique across files
scheme: tcp
host: 10.0.0.2
port: 20000:tcp
app3: # matching `app3.y.z` -> https://10.0.0.1/app3
scheme: https
host: 10.0.0.1
path: /app3
path_mode: forward
no_tls_verify: false

123
schema/config.schema.json Normal file
View file

@ -0,0 +1,123 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"title": "go-proxy config file",
"properties": {
"autocert": {
"title": "Autocert configuration",
"type": "object",
"properties": {
"email": {
"description": "ACME Email",
"type": "string",
"pattern": "^[\\w-\\.]+@([\\w-]+\\.)+[\\w-]{2,4}$",
"patternErrorMessage": "Invalid email"
},
"domains": {
"description": "Cert Domains",
"type": "array",
"items": {
"type": "string"
},
"minItems": 1
},
"provider": {
"description": "DNS Challenge Provider",
"type": "string",
"enum": ["cloudflare"]
},
"options": {
"description": "Provider specific options",
"type": "object",
"properties": {
"auth_token": {
"description": "Cloudflare API Token with Zone Scope",
"type": "string"
}
}
}
},
"required": ["email", "domains", "provider", "options"],
"anyOf": [
{
"properties": {
"provider": {
"const": "cloudflare"
},
"options": {
"required": ["auth_token"]
}
}
}
]
},
"providers": {
"title": "Proxy providers configuration",
"type": "object",
"patternProperties": {
"^[a-zA-Z0-9_-]+$": {
"description": "Proxy provider",
"type": "object",
"properties": {
"kind": {
"description": "Proxy provider kind",
"type": "string",
"enum": ["docker", "file"]
},
"value": {
"type": "string"
}
},
"required": ["kind", "value"],
"allOf": [
{
"if": {
"properties": {
"kind": {
"const": "docker"
}
}
},
"then": {
"if": {
"properties": {
"value": {
"const": "FROM_ENV"
}
}
},
"then": {
"properties": {
"value": {
"description": "use docker client from environment"
}
}
},
"else": {
"properties": {
"value": {
"description": "docker client URL",
"examples": [
"unix:///var/run/docker.sock",
"tcp://127.0.0.1:2375",
"ssh://user@host:port"
]
}
}
}
},
"else": {
"properties": {
"value": {
"description": "file path"
}
}
}
}
]
}
}
}
},
"additionalProperties": false
}

View file

@ -0,0 +1,168 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"title": "go-proxy providers file",
"patternProperties": {
"^[a-zA-Z0-9_-]+$": {
"title": "Proxy entry",
"type": "object",
"properties": {
"scheme": {
"title": "Proxy scheme (http, https, tcp, udp)",
"anyOf": [
{
"type": "string",
"enum": ["http", "https", "tcp", "udp"]
},
{
"type": "null",
"description": "HTTP proxy"
}
]
},
"host": {
"anyOf": [
{
"type": "string",
"format": "ipv4",
"description": "Proxy to ipv4 address"
},
{
"type": "string",
"format": "ipv6",
"description": "Proxy to ipv6 address"
},
{
"type": "string",
"format": "hostname",
"description": "Proxy to hostname"
}
],
"title": "Proxy host (ipv4 / ipv6 / hostname)"
},
"port": {
"title": "Proxy port"
},
"path": {},
"path_mode": {},
"no_tls_verify": {
"description": "Disable TLS verification for https proxy",
"type": "boolean"
}
},
"required": ["host"],
"additionalProperties": false,
"allOf": [
{
"if": {
"anyOf": [
{
"properties": {
"scheme": {
"enum": ["http", "https"]
}
}
},
{
"properties": {
"scheme": {
"not": true
}
}
},
{
"properties": {
"scheme": {
"type": "null"
}
}
}
]
},
"then": {
"properties": {
"port": {
"anyOf": [
{
"type": "string",
"pattern": "^[0-9]{1,5}$",
"minimum": 1,
"maximum": 65535,
"markdownDescription": "Proxy port from **1** to **65535**",
"patternErrorMessage": "'port' must be a number"
},
{
"type": "integer",
"minimum": 1,
"maximum": 65535
}
]
},
"path": {
"anyOf": [
{
"type": "string",
"description": "Proxy path"
},
{
"type": "null",
"description": "No proxy path"
}
]
},
"path_mode": {
"anyOf": [
{
"description": "Proxy path mode (forward, sub, empty)",
"type": "string",
"enum": ["", "forward", "sub"]
},
{
"description": "Default proxy path mode (sub)",
"type": "null"
}
]
}
}
},
"else": {
"properties": {
"port": {
"markdownDescription": "`listening port`:`target port | service type`",
"type": "string",
"pattern": "^[0-9]+\\:[0-9a-z]+$",
"patternErrorMessage": "'port' must be in the format of '<listening port>:<target port | service type>'"
},
"path": {
"not": true
},
"path_mode": {
"not": true
}
},
"required": ["port"]
}
},
{
"if": {
"not": {
"properties": {
"scheme": {
"const": "https"
}
}
}
},
"then": {
"properties": {
"no_tls_verify": {
"not": true
}
}
}
}
]
}
},
"additionalProperties": false
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

BIN
screenshots/panel.png Executable file → Normal file

Binary file not shown.

Before

Width:  |  Height:  |  Size: 149 KiB

After

Width:  |  Height:  |  Size: 304 KiB

View file

@ -7,7 +7,6 @@ import (
"crypto/rand" "crypto/rand"
"crypto/tls" "crypto/tls"
"crypto/x509" "crypto/x509"
"fmt"
"os" "os"
"path" "path"
"sync" "sync"
@ -26,10 +25,10 @@ type ProviderGenerator = func(ProviderOptions) (challenge.Provider, error)
type CertExpiries = map[string]time.Time type CertExpiries = map[string]time.Time
type AutoCertConfig struct { type AutoCertConfig struct {
Email string Email string `json:"email"`
Domains []string `yaml:",flow"` Domains []string `yaml:",flow" json:"domains"`
Provider string Provider string `json:"provider"`
Options ProviderOptions `yaml:",flow"` Options ProviderOptions `yaml:",flow" json:"options"`
} }
type AutoCertUser struct { type AutoCertUser struct {
@ -53,25 +52,35 @@ type AutoCertProvider interface {
GetName() string GetName() string
GetExpiries() CertExpiries GetExpiries() CertExpiries
LoadCert() bool LoadCert() bool
ObtainCert() error ObtainCert() NestedErrorLike
RenewalOn() time.Time RenewalOn() time.Time
ScheduleRenewal() ScheduleRenewal()
} }
func (cfg AutoCertConfig) GetProvider() (AutoCertProvider, error) { func (cfg AutoCertConfig) GetProvider() (AutoCertProvider, error) {
ne := NewNestedError("invalid autocert config")
if len(cfg.Domains) == 0 { if len(cfg.Domains) == 0 {
return nil, fmt.Errorf("no domains specified") ne.Extra("no domains specified")
} }
if cfg.Provider == "" { if cfg.Provider == "" {
return nil, fmt.Errorf("no provider specified") ne.Extra("no provider specified")
} }
if cfg.Email == "" { if cfg.Email == "" {
return nil, fmt.Errorf("no email specified") ne.Extra("no email specified")
}
gen, ok := providersGenMap[cfg.Provider]
if !ok {
ne.Extraf("unknown provider: %s", cfg.Provider)
}
if ne.HasExtras() {
return nil, ne
} }
ne = NewNestedError("unable to create provider")
privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil { if err != nil {
return nil, fmt.Errorf("unable to generate private key: %v", err) return nil, ne.With(NewNestedError("unable to generate private key").With(err))
} }
user := &AutoCertUser{ user := &AutoCertUser{
Email: cfg.Email, Email: cfg.Email,
@ -81,7 +90,7 @@ func (cfg AutoCertConfig) GetProvider() (AutoCertProvider, error) {
legoCfg.Certificate.KeyType = certcrypto.RSA2048 legoCfg.Certificate.KeyType = certcrypto.RSA2048
legoClient, err := lego.NewClient(legoCfg) legoClient, err := lego.NewClient(legoCfg)
if err != nil { if err != nil {
return nil, fmt.Errorf("unable to create lego client: %v", err) return nil, ne.With(NewNestedError("unable to create lego client").With(err))
} }
base := &autoCertProvider{ base := &autoCertProvider{
name: cfg.Provider, name: cfg.Provider,
@ -90,17 +99,13 @@ func (cfg AutoCertConfig) GetProvider() (AutoCertProvider, error) {
legoCfg: legoCfg, legoCfg: legoCfg,
client: legoClient, client: legoClient,
} }
gen, ok := providersGenMap[cfg.Provider]
if !ok {
return nil, fmt.Errorf("unknown provider: %s", cfg.Provider)
}
legoProvider, err := gen(cfg.Options) legoProvider, err := gen(cfg.Options)
if err != nil { if err != nil {
return nil, fmt.Errorf("unable to create provider: %v", err) return nil, ne.With(err)
} }
err = legoClient.Challenge.SetDNS01Provider(legoProvider) err = legoClient.Challenge.SetDNS01Provider(legoProvider)
if err != nil { if err != nil {
return nil, fmt.Errorf("unable to set challenge provider: %v", err) return nil, ne.With(NewNestedError("unable to set challenge provider").With(err))
} }
return base, nil return base, nil
} }
@ -119,7 +124,7 @@ type autoCertProvider struct {
func (p *autoCertProvider) GetCert(_ *tls.ClientHelloInfo) (*tls.Certificate, error) { func (p *autoCertProvider) GetCert(_ *tls.ClientHelloInfo) (*tls.Certificate, error) {
if p.tlsCert == nil { if p.tlsCert == nil {
aclog.Fatal("no certificate available") return nil, NewNestedError("no certificate available")
} }
return p.tlsCert, nil return p.tlsCert, nil
} }
@ -132,12 +137,14 @@ func (p *autoCertProvider) GetExpiries() CertExpiries {
return p.certExpiries return p.certExpiries
} }
func (p *autoCertProvider) ObtainCert() error { func (p *autoCertProvider) ObtainCert() NestedErrorLike {
ne := NewNestedError("failed to obtain certificate")
client := p.client client := p.client
if p.user.Registration == nil { if p.user.Registration == nil {
reg, err := client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true}) reg, err := client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true})
if err != nil { if err != nil {
return err return ne.With(NewNestedError("failed to register account").With(err))
} }
p.user.Registration = reg p.user.Registration = reg
} }
@ -147,19 +154,19 @@ func (p *autoCertProvider) ObtainCert() error {
} }
cert, err := client.Certificate.Obtain(req) cert, err := client.Certificate.Obtain(req)
if err != nil { if err != nil {
return err return ne.With(err)
} }
err = p.saveCert(cert) err = p.saveCert(cert)
if err != nil { if err != nil {
return err return ne.With(NewNestedError("failed to save certificate").With(err))
} }
tlsCert, err := tls.X509KeyPair(cert.Certificate, cert.PrivateKey) tlsCert, err := tls.X509KeyPair(cert.Certificate, cert.PrivateKey)
if err != nil { if err != nil {
return err return ne.With(NewNestedError("failed to parse obtained certificate").With(err))
} }
expiries, err := getCertExpiries(&tlsCert) expiries, err := getCertExpiries(&tlsCert)
if err != nil { if err != nil {
return err return ne.With(NewNestedError("failed to get certificate expiry").With(err))
} }
p.tlsCert = &tlsCert p.tlsCert = &tlsCert
p.certExpiries = expiries p.certExpiries = expiries
@ -205,18 +212,18 @@ func (p *autoCertProvider) ScheduleRenewal() {
} }
} }
func (p *autoCertProvider) saveCert(cert *certificate.Resource) error { func (p *autoCertProvider) saveCert(cert *certificate.Resource) NestedErrorLike {
err := os.MkdirAll(path.Dir(certFileDefault), 0644) err := os.MkdirAll(path.Dir(certFileDefault), 0644)
if err != nil { if err != nil {
return fmt.Errorf("unable to create cert directory: %v", err) return NewNestedError("unable to create cert directory").With(err)
} }
err = os.WriteFile(keyFileDefault, cert.PrivateKey, 0600) // -rw------- err = os.WriteFile(keyFileDefault, cert.PrivateKey, 0600) // -rw-------
if err != nil { if err != nil {
return fmt.Errorf("unable to write key file: %v", err) return NewNestedError("unable to write key file").With(err)
} }
err = os.WriteFile(certFileDefault, cert.Certificate, 0644) // -rw-r--r-- err = os.WriteFile(certFileDefault, cert.Certificate, 0644) // -rw-r--r--
if err != nil { if err != nil {
return fmt.Errorf("unable to write cert file: %v", err) return NewNestedError("unable to write cert file").With(err)
} }
return nil return nil
} }
@ -225,7 +232,7 @@ func (p *autoCertProvider) needRenewal() bool {
return time.Now().After(p.RenewalOn()) return time.Now().After(p.RenewalOn())
} }
func (p *autoCertProvider) renewIfNeeded() error { func (p *autoCertProvider) renewIfNeeded() NestedErrorLike {
if !p.needRenewal() { if !p.needRenewal() {
return nil return nil
} }
@ -245,14 +252,17 @@ func (p *autoCertProvider) renewIfNeeded() error {
} }
trials++ trials++
if trials > 3 { if trials > 3 {
return fmt.Errorf("unable to renew certificate: %v after 3 trials", err) return NewNestedError("failed to renew certificate after 3 trials").With(err)
} }
aclog.Errorf("failed to renew certificate: %v, trying again in 5 seconds", err) aclog.Errorf("failed to renew certificate: %v, trying again in 5 seconds", err)
time.Sleep(5 * time.Second) time.Sleep(5 * time.Second)
} }
} }
func providerGenerator[CT interface{}, PT challenge.Provider](defaultCfg func() *CT, newProvider func(*CT) (PT, error)) ProviderGenerator { func providerGenerator[CT any, PT challenge.Provider](
defaultCfg func() *CT,
newProvider func(*CT) (PT, error),
) ProviderGenerator {
return func(opt ProviderOptions) (challenge.Provider, error) { return func(opt ProviderOptions) (challenge.Provider, error) {
cfg := defaultCfg() cfg := defaultCfg()
err := setOptions(cfg, opt) err := setOptions(cfg, opt)
@ -272,7 +282,7 @@ func getCertExpiries(cert *tls.Certificate) (CertExpiries, error) {
for _, cert := range cert.Certificate { for _, cert := range cert.Certificate {
x509Cert, err := x509.ParseCertificate(cert) x509Cert, err := x509.ParseCertificate(cert)
if err != nil { if err != nil {
return nil, err return nil, NewNestedError("unable to parse certificate").With(err)
} }
if x509Cert.IsCA { if x509Cert.IsCA {
continue continue
@ -284,9 +294,9 @@ func getCertExpiries(cert *tls.Certificate) (CertExpiries, error) {
func setOptions[T interface{}](cfg *T, opt ProviderOptions) error { func setOptions[T interface{}](cfg *T, opt ProviderOptions) error {
for k, v := range opt { for k, v := range opt {
err := SetFieldFromSnake(cfg, k, v) err := setFieldFromSnake(cfg, k, v)
if err != nil { if err != nil {
return err return NewNestedError("unable to set option").Subject(k).With(err)
} }
} }
return nil return nil

View file

@ -1,7 +1,6 @@
package main package main
import ( import (
"fmt"
"os" "os"
"sync" "sync"
@ -14,48 +13,76 @@ type Config interface {
MustLoad() MustLoad()
GetAutoCertProvider() (AutoCertProvider, error) GetAutoCertProvider() (AutoCertProvider, error)
// MustReload() // MustReload()
// Reload() error Reload() error
StartProviders() StartProviders()
StopProviders() StopProviders()
WatchChanges() WatchChanges()
StopWatching() StopWatching()
} }
func NewConfig() Config { func NewConfig(path string) Config {
cfg := &config{} cfg := &config{reader: &FileReader{Path: path}}
cfg.watcher = NewFileWatcher( cfg.watcher = NewFileWatcher(
configPath, path,
cfg.MustReload, // OnChange cfg.MustReload, // OnChange
func() { os.Exit(1) }, // OnDelete func() { os.Exit(1) }, // OnDelete
) )
return cfg return cfg
} }
func (cfg *config) Load() error { func ValidateConfig(data []byte) error {
cfg := &config{reader: &ByteReader{data}}
return cfg.Load()
}
func (cfg *config) Load(reader ...Reader) error {
cfg.mutex.Lock() cfg.mutex.Lock()
defer cfg.mutex.Unlock() defer cfg.mutex.Unlock()
// unload if any if cfg.reader == nil {
cfg.StopProviders() panic("config reader not set")
}
data, err := os.ReadFile(configPath) data, err := cfg.reader.Read()
if err != nil { if err != nil {
return fmt.Errorf("unable to read config file: %v", err) return NewNestedError("unable to read config file").With(err)
} }
cfg.Providers = make(map[string]*Provider) model := &configModel{}
if err = yaml.Unmarshal(data, &cfg); err != nil { if err := yaml.Unmarshal(data, model); err != nil {
return fmt.Errorf("unable to parse config file: %v", err) return NewNestedError("unable to parse config file").With(err)
} }
for name, p := range cfg.Providers { ne := NewNestedError("invalid config")
err := p.Init(name)
err = validateYaml(configSchema, data)
if err != nil { if err != nil {
cfgl.Errorf("failed to initialize provider %q %v", name, err) ne.With(err)
cfg.Providers[name] = nil
}
} }
pErrs := NewNestedError("errors in these providers")
for name, p := range model.Providers {
if p.Kind != ProviderKind_File {
continue
}
_, err := p.ValidateFile()
if err != nil {
pErrs.ExtraError(
NewNestedError("provider file validation error").
Subject(name).
With(err),
)
}
}
if pErrs.HasExtras() {
ne.With(pErrs)
}
if ne.HasInner() {
return ne
}
cfg.m = model
return nil return nil
} }
@ -66,43 +93,92 @@ func (cfg *config) MustLoad() {
} }
func (cfg *config) GetAutoCertProvider() (AutoCertProvider, error) { func (cfg *config) GetAutoCertProvider() (AutoCertProvider, error) {
return cfg.AutoCert.GetProvider() return cfg.m.AutoCert.GetProvider()
} }
func (cfg *config) Reload() error { func (cfg *config) Reload() error {
return cfg.Load() cfg.StopProviders()
if err := cfg.Load(); err != nil {
return err
}
cfg.StartProviders()
return nil
} }
func (cfg *config) MustReload() { func (cfg *config) MustReload() {
cfg.MustLoad() if err := cfg.Reload(); err != nil {
cfgl.Fatal(err)
}
} }
func (cfg *config) StartProviders() { func (cfg *config) StartProviders() {
if cfg.Providers == nil { if cfg.providerInitialized {
cfgl.Fatal("providers not loaded") return
}
cfg.mutex.Lock()
defer cfg.mutex.Unlock()
if cfg.providerInitialized {
return
}
pErrs := NewNestedError("failed to start these providers")
ParallelForEachKeyValue(cfg.m.Providers, func(name string, p *Provider) {
err := p.Init(name)
if err != nil {
pErrs.ExtraError(NewNestedErrorFrom(err).Subjectf("%s providers %q", p.Kind, name))
delete(cfg.m.Providers, name)
}
p.StartAllRoutes()
})
cfg.providerInitialized = true
if pErrs.HasExtras() {
cfgl.Error(pErrs)
} }
// Providers have their own mutex, no lock needed
ParallelForEachValue(cfg.Providers, (*Provider).StartAllRoutes)
} }
func (cfg *config) StopProviders() { func (cfg *config) StopProviders() {
if cfg.Providers != nil { if !cfg.providerInitialized {
// Providers have their own mutex, no lock needed return
ParallelForEachValue(cfg.Providers, (*Provider).StopAllRoutes)
} }
cfg.mutex.Lock()
defer cfg.mutex.Unlock()
if !cfg.providerInitialized {
return
}
ParallelForEachValue(cfg.m.Providers, (*Provider).StopAllRoutes)
cfg.m.Providers = make(map[string]*Provider)
cfg.providerInitialized = false
} }
func (cfg *config) WatchChanges() { func (cfg *config) WatchChanges() {
if cfg.watcher == nil {
return
}
cfg.watcher.Start() cfg.watcher.Start()
} }
func (cfg *config) StopWatching() { func (cfg *config) StopWatching() {
if cfg.watcher == nil {
return
}
cfg.watcher.Stop() cfg.watcher.Stop()
} }
type configModel struct {
Providers map[string]*Provider `yaml:",flow" json:"providers"`
AutoCert AutoCertConfig `yaml:",flow" json:"autocert"`
}
type config struct { type config struct {
Providers map[string]*Provider `yaml:",flow"` m *configModel
AutoCert AutoCertConfig `yaml:",flow"`
reader Reader
watcher Watcher watcher Watcher
mutex sync.Mutex mutex sync.Mutex
providerInitialized bool
} }

View file

@ -7,6 +7,7 @@ import (
"os" "os"
"time" "time"
"github.com/santhosh-tekuri/jsonschema"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
) )
@ -80,6 +81,19 @@ var (
clone.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} clone.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
return clone return clone
}() }()
healthCheckHttpClient = &http.Client{
Timeout: 5 * time.Second,
Transport: &http.Transport{
Proxy: http.ProxyFromEnvironment,
DisableKeepAlives: true,
ForceAttemptHTTP2: true,
DialContext: (&net.Dialer{
Timeout: 5 * time.Second,
KeepAlive: 5 * time.Second,
}).DialContext,
},
}
) )
const wildcardLabelPrefix = "proxy.*." const wildcardLabelPrefix = "proxy.*."
@ -87,13 +101,43 @@ const wildcardLabelPrefix = "proxy.*."
const clientUrlFromEnv = "FROM_ENV" const clientUrlFromEnv = "FROM_ENV"
const ( const (
certFileDefault = "certs/cert.crt" certBasePath = "certs/"
keyFileDefault = "certs/priv.key" certFileDefault = certBasePath + "cert.crt"
configPath = "config.yml" keyFileDefault = certBasePath + "priv.key"
templatePath = "templates/panel.html"
configBasePath = "config/"
configPath = configBasePath + "config.yml"
templatesBasePath = "templates/"
panelTemplatePath = templatesBasePath + "panel/index.html"
configEditorTemplatePath = templatesBasePath + "config_editor/index.html"
schemaBasePath = "schema/"
configSchemaPath = schemaBasePath + "config.schema.json"
providersSchemaPath = schemaBasePath + "providers.schema.json"
) )
const StreamStopListenTimeout = 1 * time.Second var (
configSchema *jsonschema.Schema
providersSchema *jsonschema.Schema
_ = func() *jsonschema.Compiler {
c := jsonschema.NewCompiler()
c.Draft = jsonschema.Draft7
var err error
if configSchema, err = c.Compile(configSchemaPath); err != nil {
panic(err)
}
if providersSchema, err = c.Compile(providersSchemaPath); err != nil {
panic(err)
}
return c
}()
)
const (
streamStopListenTimeout = 1 * time.Second
streamDialTimeout = 3 * time.Second
)
const udpBufferSize = 1500 const udpBufferSize = 1500
@ -105,4 +149,4 @@ var logLevel = func() logrus.Level {
return logrus.GetLevel() return logrus.GetLevel()
}() }()
var redirectHTTP = os.Getenv("GOPROXY_REDIRECT_HTTP") != "0" && os.Getenv("GOPROXY_REDIRECT_HTTP") != "false" var redirectToHTTPS = os.Getenv("GOPROXY_REDIRECT_HTTP") != "0" && os.Getenv("GOPROXY_REDIRECT_HTTP") != "false"

View file

@ -16,46 +16,54 @@ import (
func (p *Provider) setConfigField(c *ProxyConfig, label string, value string, prefix string) error { func (p *Provider) setConfigField(c *ProxyConfig, label string, value string, prefix string) error {
if strings.HasPrefix(label, prefix) { if strings.HasPrefix(label, prefix) {
field := strings.TrimPrefix(label, prefix) field := strings.TrimPrefix(label, prefix)
SetFieldFromSnake(c, field, value) if err := setFieldFromSnake(c, field, value); err != nil {
return err
}
} }
return nil return nil
} }
func (p *Provider) getContainerProxyConfigs(container types.Container, clientIP string) []*ProxyConfig { func (p *Provider) getContainerProxyConfigs(container types.Container, clientIP string) ProxyConfigSlice {
var aliases []string var aliases []string
cfgs := make([]*ProxyConfig, 0) cfgs := make(ProxyConfigSlice, 0)
container_name := strings.TrimPrefix(container.Names[0], "/") containerName := strings.TrimPrefix(container.Names[0], "/")
aliases_label, ok := container.Labels["proxy.aliases"] aliasesLabel, ok := container.Labels["proxy.aliases"]
if !ok { if !ok {
aliases = []string{container_name} aliases = []string{containerName}
} else { } else {
aliases = strings.Split(aliases_label, ",") aliases = strings.Split(aliasesLabel, ",")
} }
isRemote := clientIP != "" isRemote := clientIP != ""
ne := NewNestedError("invalid label config").Subjectf("container %s", containerName)
defer func() {
if ne.HasExtras() {
p.l.Error(ne)
}
}()
for _, alias := range aliases { for _, alias := range aliases {
l := p.l.WithField("container", container_name).WithField("alias", alias) l := p.l.WithField("container", containerName).WithField("alias", alias)
config := NewProxyConfig(p) config := NewProxyConfig(p)
prefix := fmt.Sprintf("proxy.%s.", alias) prefix := fmt.Sprintf("proxy.%s.", alias)
for label, value := range container.Labels { for label, value := range container.Labels {
err := p.setConfigField(&config, label, value, prefix) err := p.setConfigField(&config, label, value, prefix)
if err != nil { if err != nil {
l.Error(err) ne.ExtraError(NewNestedErrorFrom(err).Subjectf("alias %s", alias))
} }
err = p.setConfigField(&config, label, value, wildcardLabelPrefix) err = p.setConfigField(&config, label, value, wildcardLabelPrefix)
if err != nil { if err != nil {
l.Error(err) ne.ExtraError(NewNestedErrorFrom(err).Subjectf("alias %s", alias))
} }
} }
if config.Port == "" { if config.Port == "" {
config.Port = fmt.Sprintf("%d", selectPort(container)) config.Port = fmt.Sprintf("%d", selectPort(container))
} }
if config.Port == "0" { if config.Port == "0" {
// no ports exposed or specified
l.Debugf("no ports exposed, ignored") l.Debugf("no ports exposed, ignored")
continue continue
} }
@ -102,11 +110,11 @@ func (p *Provider) getContainerProxyConfigs(container types.Container, clientIP
} }
} }
if config.Host == "" { if config.Host == "" {
config.Host = container_name config.Host = containerName
} }
config.Alias = alias config.Alias = alias
cfgs = append(cfgs, &config) cfgs = append(cfgs, config)
} }
return cfgs return cfgs
} }
@ -145,7 +153,7 @@ func (p *Provider) getDockerClient() (*client.Client, error) {
return client.NewClientWithOpts(dockerOpts...) return client.NewClientWithOpts(dockerOpts...)
} }
func (p *Provider) getDockerProxyConfigs() ([]*ProxyConfig, error) { func (p *Provider) getDockerProxyConfigs() (ProxyConfigSlice, error) {
var clientIP string var clientIP string
if p.Value == clientUrlFromEnv { if p.Value == clientUrlFromEnv {
@ -153,7 +161,7 @@ func (p *Provider) getDockerProxyConfigs() ([]*ProxyConfig, error) {
} else { } else {
url, err := client.ParseHostURL(p.Value) url, err := client.ParseHostURL(p.Value)
if err != nil { if err != nil {
return nil, fmt.Errorf("unable to parse docker host url: %v", err) return nil, NewNestedError("invalid host url").Subject(p.Value).With(err)
} }
clientIP = strings.Split(url.Host, ":")[0] clientIP = strings.Split(url.Host, ":")[0]
} }
@ -161,17 +169,17 @@ func (p *Provider) getDockerProxyConfigs() ([]*ProxyConfig, error) {
dockerClient, err := p.getDockerClient() dockerClient, err := p.getDockerClient()
if err != nil { if err != nil {
return nil, fmt.Errorf("unable to create docker client: %v", err) return nil, NewNestedError("unable to create docker client").With(err)
} }
ctx, _ := context.WithTimeout(context.Background(), 3*time.Second) ctx, _ := context.WithTimeout(context.Background(), 3*time.Second)
containerSlice, err := dockerClient.ContainerList(ctx, container.ListOptions{All: true}) containerSlice, err := dockerClient.ContainerList(ctx, container.ListOptions{All: true})
if err != nil { if err != nil {
return nil, fmt.Errorf("unable to list containers: %v", err) return nil, NewNestedError("unable to list containers").With(err)
} }
cfgs := make([]*ProxyConfig, 0) cfgs := make(ProxyConfigSlice, 0)
for _, container := range containerSlice { for _, container := range containerSlice {
cfgs = append(cfgs, p.getContainerProxyConfigs(container, clientIP)...) cfgs = append(cfgs, p.getContainerProxyConfigs(container, clientIP)...)

194
src/go-proxy/error.go Normal file
View file

@ -0,0 +1,194 @@
package main
import (
"fmt"
"strings"
"sync"
)
type NestedError struct {
subject string
message string
extras []string
inner *NestedError
level int
sync.Mutex
}
type NestedErrorLike interface {
Error() string
Inner() NestedErrorLike
Level() int
HasInner() bool
HasExtras() bool
Extra(string) NestedErrorLike
Extraf(string, ...any) NestedErrorLike
ExtraError(error) NestedErrorLike
Subject(string) NestedErrorLike
Subjectf(string, ...any) NestedErrorLike
With(error) NestedErrorLike
addLevel(int) NestedErrorLike
copy() *NestedError
}
func NewNestedError(message string) NestedErrorLike {
return &NestedError{message: message, extras: make([]string, 0)}
}
func NewNestedErrorf(format string, args ...any) NestedErrorLike {
return NewNestedError(fmt.Sprintf(format, args...))
}
func NewNestedErrorFrom(err error) NestedErrorLike {
if err == nil {
panic("cannot convert nil error to NestedError")
}
return NewNestedError(err.Error())
}
func (ne *NestedError) Extra(s string) NestedErrorLike {
s = strings.TrimSpace(s)
if s == "" {
return ne
}
ne.Lock()
defer ne.Unlock()
ne.extras = append(ne.extras, s)
return ne
}
func (ne *NestedError) Extraf(format string, args ...any) NestedErrorLike {
return ne.Extra(fmt.Sprintf(format, args...))
}
func (ne *NestedError) ExtraError(e error) NestedErrorLike {
switch t := e.(type) {
case NestedErrorLike:
extra := t.copy()
extra.addLevel(ne.Level() + 1)
e = extra
}
return ne.Extra(e.Error())
}
func (ne *NestedError) Subject(s string) NestedErrorLike {
ne.subject = s
return ne
}
func (ne *NestedError) Subjectf(format string, args ...any) NestedErrorLike {
ne.subject = fmt.Sprintf(format, args...)
return ne
}
func (ne *NestedError) Inner() NestedErrorLike {
return ne.inner
}
func (ne *NestedError) Level() int {
return ne.level
}
func (ef *NestedError) Error() string {
var buf strings.Builder
ef.writeToSB(&buf, "")
return buf.String()
}
func (ef *NestedError) HasInner() bool {
return ef.inner != nil
}
func (ef *NestedError) HasExtras() bool {
return len(ef.extras) > 0
}
func (ef *NestedError) With(inner error) NestedErrorLike {
ef.Lock()
defer ef.Unlock()
var in *NestedError
switch t := inner.(type) {
case NestedErrorLike:
in = t.copy()
default:
in = &NestedError{extras: []string{t.Error()}}
}
if ef.inner == nil {
ef.inner = in
} else {
ef.inner.ExtraError(in)
}
root := ef
for root.inner != nil {
root.inner.level = root.level + 1
root = root.inner
}
return ef
}
func (ef *NestedError) addLevel(level int) NestedErrorLike {
ef.level += level
if ef.inner != nil {
ef.inner.addLevel(level)
}
return ef
}
func (ef *NestedError) copy() *NestedError {
var inner *NestedError
if ef.inner != nil {
inner = ef.inner.copy()
}
return &NestedError{
subject: ef.subject,
message: ef.message,
extras: ef.extras,
inner: inner,
level: ef.level,
}
}
func (ef *NestedError) writeIndents(sb *strings.Builder, level int) {
for i := 0; i < level; i++ {
sb.WriteString(" ")
}
}
func (ef *NestedError) writeToSB(sb *strings.Builder, prefix string) {
ef.writeIndents(sb, ef.level)
sb.WriteString(prefix)
if ef.subject != "" {
sb.WriteRune('"')
sb.WriteString(ef.subject)
sb.WriteRune('"')
if ef.message != "" {
sb.WriteString(":\n")
} else {
sb.WriteRune('\n')
}
}
if ef.message != "" {
ef.writeIndents(sb, ef.level)
sb.WriteString(ef.message)
sb.WriteRune('\n')
}
for _, l := range ef.extras {
l = strings.TrimSpace(l)
if l == "" {
continue
}
ef.writeIndents(sb, ef.level)
sb.WriteString("- ")
sb.WriteString(l)
sb.WriteRune('\n')
}
if ef.inner != nil {
ef.inner.writeToSB(sb, "- ")
}
}

View file

@ -1,39 +1,54 @@
package main package main
import ( import (
"fmt"
"os" "os"
"path"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
func (p *Provider) getFileProxyConfigs() ([]*ProxyConfig, error) { func (p *Provider) GetFilePath() string {
path := p.Value return path.Join(configBasePath, p.Value)
}
if _, err := os.Stat(path); err == nil { func (p *Provider) ValidateFile() (ProxyConfigSlice, error) {
path := p.GetFilePath()
data, err := os.ReadFile(path) data, err := os.ReadFile(path)
if err != nil { if err != nil {
return nil, fmt.Errorf("unable to read config file %q: %v", path, err) return nil, NewNestedError("unable to read providers file").Subject(path).With(err)
} }
configMap := make(map[string]ProxyConfig, 0) result, err := ValidateFileContent(data)
configs := make([]*ProxyConfig, 0)
err = yaml.Unmarshal(data, &configMap)
if err != nil { if err != nil {
return nil, fmt.Errorf("unable to parse config file %q: %v", path, err) return nil, NewNestedError(err.Error()).Subject(path)
}
return result, nil
}
func ValidateFileContent(data []byte) (ProxyConfigSlice, error) {
configMap := make(ProxyConfigMap, 0)
if err := yaml.Unmarshal(data, &configMap); err != nil {
return nil, NewNestedError("invalid yaml").With(err)
} }
ne := NewNestedError("errors in providers")
configs := make(ProxyConfigSlice, len(configMap))
i := 0
for alias, cfg := range configMap { for alias, cfg := range configMap {
cfg.Alias = alias cfg.Alias = alias
err = cfg.SetDefaults() if err := cfg.SetDefaults(); err != nil {
if err != nil { ne.ExtraError(err)
return nil, err } else {
configs[i] = cfg
} }
configs = append(configs, &cfg) i++
}
if err := validateYaml(providersSchema, data); err != nil {
ne.ExtraError(err)
}
if ne.HasExtras() {
return nil, ne
} }
return configs, nil return configs, nil
} else if !os.IsNotExist(err) {
return nil, fmt.Errorf("file not found: %s", path)
} else {
return nil, err
}
} }

View file

@ -0,0 +1,23 @@
package main
import "os"
type Reader interface {
Read() ([]byte, error)
}
type FileReader struct {
Path string
}
func (r *FileReader) Read() ([]byte, error) {
return os.ReadFile(r.Path)
}
type ByteReader struct {
Data []byte
}
func (r *ByteReader) Read() ([]byte, error) {
return r.Data, nil
}

View file

@ -21,9 +21,10 @@ type HTTPRoute struct {
} }
func NewHTTPRoute(config *ProxyConfig) (*HTTPRoute, error) { func NewHTTPRoute(config *ProxyConfig) (*HTTPRoute, error) {
url, err := url.Parse(fmt.Sprintf("%s://%s:%s", config.Scheme, config.Host, config.Port)) u := fmt.Sprintf("%s://%s:%s", config.Scheme, config.Host, config.Port)
url, err := url.Parse(u)
if err != nil { if err != nil {
return nil, err return nil, NewNestedErrorf("invalid url").Subject(u).With(err)
} }
var tr *http.Transport var tr *http.Transport
@ -35,10 +36,6 @@ func NewHTTPRoute(config *ProxyConfig) (*HTTPRoute, error) {
proxy := NewSingleHostReverseProxy(url, tr) proxy := NewSingleHostReverseProxy(url, tr)
if !isValidProxyPathMode(config.PathMode) {
return nil, fmt.Errorf("invalid path mode: %s", config.PathMode)
}
route := &HTTPRoute{ route := &HTTPRoute{
Alias: config.Alias, Alias: config.Alias,
Url: url, Url: url,
@ -59,6 +56,11 @@ func NewHTTPRoute(config *ProxyConfig) (*HTTPRoute, error) {
switch { switch {
case config.Path == "", config.PathMode == ProxyPathMode_Forward: case config.Path == "", config.PathMode == ProxyPathMode_Forward:
rewrite = rewriteBegin rewrite = rewriteBegin
case config.PathMode == ProxyPathMode_RemovedPath:
rewrite = func(pr *ProxyRequest) {
rewriteBegin(pr)
pr.Out.URL.Path = strings.TrimPrefix(pr.Out.URL.Path, config.Path)
}
case config.PathMode == ProxyPathMode_Sub: case config.PathMode == ProxyPathMode_Sub:
rewrite = func(pr *ProxyRequest) { rewrite = func(pr *ProxyRequest) {
rewriteBegin(pr) rewriteBegin(pr)
@ -67,37 +69,9 @@ func NewHTTPRoute(config *ProxyConfig) (*HTTPRoute, error) {
// remove path prefix // remove path prefix
pr.Out.URL.Path = strings.TrimPrefix(pr.Out.URL.Path, config.Path) pr.Out.URL.Path = strings.TrimPrefix(pr.Out.URL.Path, config.Path)
} }
modifyResponse = func(r *http.Response) error { modifyResponse = config.pathSubModResp
contentType, ok := r.Header["Content-Type"]
if !ok || len(contentType) == 0 {
route.l.Debug("unknown content type for ", r.Request.URL.String())
return nil
}
// disable cache
r.Header.Set("Cache-Control", "no-store")
var err error = nil
switch {
case strings.HasPrefix(contentType[0], "text/html"):
err = utils.respHTMLSubPath(r, config.Path)
case strings.HasPrefix(contentType[0], "application/javascript"):
err = utils.respJSSubPath(r, config.Path)
default: default:
route.l.Debug("unknown content type(s): ", contentType) return nil, NewNestedError("invalid path mode").Subject(config.PathMode)
}
if err != nil {
err = fmt.Errorf("failed to remove path prefix %s: %v", config.Path, err)
route.l.WithField("action", "path_sub").Error(err)
r.Status = err.Error()
r.StatusCode = http.StatusInternalServerError
}
return err
}
default:
rewrite = func(pr *ProxyRequest) {
rewriteBegin(pr)
pr.Out.URL.Path = strings.TrimPrefix(pr.Out.URL.Path, config.Path)
}
} }
if logLevel == logrus.DebugLevel { if logLevel == logrus.DebugLevel {
@ -121,20 +95,13 @@ func NewHTTPRoute(config *ProxyConfig) (*HTTPRoute, error) {
return route, nil return route, nil
} }
func (r *HTTPRoute) Start() {} func (r *HTTPRoute) Start() {
// dummy
}
func (r *HTTPRoute) Stop() { func (r *HTTPRoute) Stop() {
httpRoutes.Delete(r.Alias) httpRoutes.Delete(r.Alias)
} }
func isValidProxyPathMode(mode string) bool {
switch mode {
case ProxyPathMode_Forward, ProxyPathMode_Sub, ProxyPathMode_RemovedPath:
return true
default:
return false
}
}
func redirectToTLSHandler(w http.ResponseWriter, r *http.Request) { func redirectToTLSHandler(w http.ResponseWriter, r *http.Request) {
// Redirect to the same host but with HTTPS // Redirect to the same host but with HTTPS
var redirectCode int var redirectCode int
@ -152,26 +119,44 @@ func findHTTPRoute(host string, path string) (*HTTPRoute, error) {
if ok { if ok {
return routeMap.FindMatch(path) return routeMap.FindMatch(path)
} }
return nil, fmt.Errorf("no matching route for subdomain %s", subdomain) return nil, NewNestedError("no matching route for subdomain").Subject(subdomain)
} }
func proxyHandler(w http.ResponseWriter, r *http.Request) { func proxyHandler(w http.ResponseWriter, r *http.Request) {
route, err := findHTTPRoute(r.Host, r.URL.Path) route, err := findHTTPRoute(r.Host, r.URL.Path)
if err != nil { if err != nil {
err = fmt.Errorf("request failed %s %s%s, error: %v", http.Error(w, "404 Not Found", http.StatusNotFound)
r.Method, err = NewNestedError("request failed").
r.Host, Subjectf("%s %s%s", r.Method, r.Host, r.URL.Path).
r.URL.Path, With(err)
err,
)
logrus.Error(err) logrus.Error(err)
http.Error(w, err.Error(), http.StatusNotFound)
return return
} }
route.Proxy.ServeHTTP(w, r) route.Proxy.ServeHTTP(w, r)
} }
// alias -> (path -> routes) func (config *ProxyConfig) pathSubModResp(r *http.Response) error {
type HTTPRoutes = SafeMap[string, *pathPoolMap] contentType, ok := r.Header["Content-Type"]
if !ok || len(contentType) == 0 {
return nil
}
// disable cache
r.Header.Set("Cache-Control", "no-store")
var httpRoutes HTTPRoutes = NewSafeMap[string](newPathPoolMap) var err error = nil
switch {
case strings.HasPrefix(contentType[0], "text/html"):
err = utils.respHTMLSubPath(r, config.Path)
case strings.HasPrefix(contentType[0], "application/javascript"):
err = utils.respJSSubPath(r, config.Path)
}
if err != nil {
err = NewNestedError("failed to remove path prefix").Subject(config.Path).With(err)
}
return err
}
// alias -> (path -> routes)
type HTTPRoutes = SafeMap[string, pathPoolMap]
var httpRoutes HTTPRoutes = NewSafeMapOf[HTTPRoutes](newPathPoolMap)

100
src/go-proxy/io.go Normal file
View file

@ -0,0 +1,100 @@
package main
import (
"context"
"io"
"sync"
)
type ReadCloser struct {
ctx context.Context
r io.ReadCloser
}
func (r *ReadCloser) Read(p []byte) (int, error) {
select {
case <-r.ctx.Done():
return 0, r.ctx.Err()
default:
return r.r.Read(p)
}
}
func (r *ReadCloser) Close() error {
return r.r.Close()
}
type Pipe struct {
r ReadCloser
w io.WriteCloser
wg sync.WaitGroup
ctx context.Context
cancel context.CancelFunc
}
func NewPipe(ctx context.Context, r io.ReadCloser, w io.WriteCloser) *Pipe {
ctx, cancel := context.WithCancel(ctx)
return &Pipe{
r: ReadCloser{ctx, r},
w: w,
ctx: ctx,
cancel: cancel,
}
}
func (p *Pipe) Start() {
p.wg.Add(1)
go func() {
Copy(p.ctx, p.w, &p.r)
p.wg.Done()
}()
}
func (p *Pipe) Stop() {
p.cancel()
p.wg.Wait()
}
func (p *Pipe) Close() (error, error) {
return p.r.Close(), p.w.Close()
}
func (p *Pipe) Wait() {
p.wg.Wait()
}
type BidirectionalPipe struct {
pSrcDst Pipe
pDstSrc Pipe
}
func NewBidirectionalPipe(ctx context.Context, rw1 io.ReadWriteCloser, rw2 io.ReadWriteCloser) *BidirectionalPipe {
return &BidirectionalPipe{
pSrcDst: *NewPipe(ctx, rw1, rw2),
pDstSrc: *NewPipe(ctx, rw2, rw1),
}
}
func (p *BidirectionalPipe) Start() {
p.pSrcDst.Start()
p.pDstSrc.Start()
}
func (p *BidirectionalPipe) Stop() {
p.pSrcDst.Stop()
p.pDstSrc.Stop()
}
func (p *BidirectionalPipe) Close() (error, error) {
return p.pSrcDst.Close()
}
func (p *BidirectionalPipe) Wait() {
p.pSrcDst.Wait()
p.pDstSrc.Wait()
}
func Copy(ctx context.Context, dst io.WriteCloser, src io.ReadCloser) error {
_, err := io.Copy(dst, &ReadCloser{ctx, src})
return err
}

View file

@ -1,6 +1,7 @@
package main package main
import ( import (
"flag"
"net/http" "net/http"
"os" "os"
"os/signal" "os/signal"
@ -10,45 +11,45 @@ import (
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
) )
var cfg Config
func main() { func main() {
runtime.GOMAXPROCS(runtime.NumCPU()) runtime.GOMAXPROCS(runtime.NumCPU())
var verifyOnly bool
flag.BoolVar(&verifyOnly, "verify", false, "verify config without starting server")
flag.Parse()
logrus.SetFormatter(&logrus.TextFormatter{ logrus.SetFormatter(&logrus.TextFormatter{
ForceColors: true, ForceColors: true,
DisableColors: false, DisableColors: false,
FullTimestamp: true, FullTimestamp: true,
TimestampFormat: "01-02 15:04:05",
}) })
cfg := NewConfig() cfg = NewConfig(configPath)
cfg.MustLoad() cfg.MustLoad()
if verifyOnly {
logrus.Printf("config OK")
return
}
autoCertProvider, err := cfg.GetAutoCertProvider() autoCertProvider, err := cfg.GetAutoCertProvider()
if err != nil { if err != nil {
aclog.Warn(err) aclog.Warn(err)
autoCertProvider = nil autoCertProvider = nil // TODO: remove, it is expected to be nil if error is not nil, but it is not for now
} }
var httpProxyHandler http.Handler
var httpPanelHandler http.Handler
var proxyServer *Server var proxyServer *Server
var panelServer *Server var panelServer *Server
if redirectHTTP {
httpProxyHandler = http.HandlerFunc(redirectToTLSHandler)
httpPanelHandler = http.HandlerFunc(redirectToTLSHandler)
} else {
httpProxyHandler = http.HandlerFunc(proxyHandler)
httpPanelHandler = http.HandlerFunc(panelHandler)
}
if autoCertProvider != nil { if autoCertProvider != nil {
ok := autoCertProvider.LoadCert() ok := autoCertProvider.LoadCert()
if !ok { if !ok {
err := autoCertProvider.ObtainCert() if ne := autoCertProvider.ObtainCert(); ne != nil {
if err != nil { aclog.Fatal(ne)
aclog.Fatal("error obtaining certificate ", err)
} }
} }
for name, expiry := range autoCertProvider.GetExpiries() { for name, expiry := range autoCertProvider.GetExpiries() {
@ -56,28 +57,27 @@ func main() {
} }
go autoCertProvider.ScheduleRenewal() go autoCertProvider.ScheduleRenewal()
} }
proxyServer = NewServer( proxyServer = NewServer(ServerOptions{
"proxy", Name: "proxy",
autoCertProvider, CertProvider: autoCertProvider,
":80", HTTPAddr: ":80",
httpProxyHandler, HTTPSAddr: ":443",
":443", Handler: http.HandlerFunc(proxyHandler),
http.HandlerFunc(proxyHandler), RedirectToHTTPS: redirectToHTTPS,
) })
panelServer = NewServer( panelServer = NewServer(ServerOptions{
"panel", Name: "panel",
autoCertProvider, CertProvider: autoCertProvider,
":8080", HTTPAddr: ":8080",
httpPanelHandler, HTTPSAddr: ":8443",
":8443", Handler: panelHandler,
http.HandlerFunc(panelHandler), RedirectToHTTPS: redirectToHTTPS,
) })
proxyServer.Start() proxyServer.Start()
panelServer.Start() panelServer.Start()
InitFSWatcher() InitFSWatcher()
InitDockerWatcher()
cfg.StartProviders() cfg.StartProviders()
cfg.WatchChanges() cfg.WatchChanges()
@ -89,7 +89,8 @@ func main() {
<-sig <-sig
// cfg.StopWatching() // cfg.StopWatching()
StopFSWatcher()
StopDockerWatcher()
cfg.StopProviders() cfg.StopProviders()
panelServer.Stop() panelServer.Stop()
proxyServer.Stop() proxyServer.Stop()

View file

@ -5,8 +5,8 @@ import "sync"
type safeMap[KT comparable, VT interface{}] struct { type safeMap[KT comparable, VT interface{}] struct {
SafeMap[KT, VT] SafeMap[KT, VT]
m map[KT]VT m map[KT]VT
mutex sync.Mutex
defaultFactory func() VT defaultFactory func() VT
sync.RWMutex
} }
type SafeMap[KT comparable, VT interface{}] interface { type SafeMap[KT comparable, VT interface{}] interface {
@ -22,7 +22,7 @@ type SafeMap[KT comparable, VT interface{}] interface {
Iterator() map[KT]VT Iterator() map[KT]VT
} }
func NewSafeMap[KT comparable, VT interface{}](df ...func() VT) SafeMap[KT, VT] { func NewSafeMapOf[T SafeMap[KT, VT], KT comparable, VT interface{}](df ...func() VT) SafeMap[KT, VT] {
if len(df) == 0 { if len(df) == 0 {
return &safeMap[KT, VT]{ return &safeMap[KT, VT]{
m: make(map[KT]VT), m: make(map[KT]VT),
@ -35,23 +35,23 @@ func NewSafeMap[KT comparable, VT interface{}](df ...func() VT) SafeMap[KT, VT]
} }
func (m *safeMap[KT, VT]) Set(key KT, value VT) { func (m *safeMap[KT, VT]) Set(key KT, value VT) {
m.mutex.Lock() m.Lock()
m.m[key] = value m.m[key] = value
m.mutex.Unlock() m.Unlock()
} }
func (m *safeMap[KT, VT]) Ensure(key KT) { func (m *safeMap[KT, VT]) Ensure(key KT) {
m.mutex.Lock() m.Lock()
if _, ok := m.m[key]; !ok { if _, ok := m.m[key]; !ok {
m.m[key] = m.defaultFactory() m.m[key] = m.defaultFactory()
} }
m.mutex.Unlock() m.Unlock()
} }
func (m *safeMap[KT, VT]) Get(key KT) VT { func (m *safeMap[KT, VT]) Get(key KT) VT {
m.mutex.Lock() m.RLock()
value := m.m[key] value := m.m[key]
m.mutex.Unlock() m.RUnlock()
return value return value
} }
@ -61,37 +61,36 @@ func (m *safeMap[KT, VT]) UnsafeGet(key KT) (VT, bool) {
} }
func (m *safeMap[KT, VT]) Delete(key KT) { func (m *safeMap[KT, VT]) Delete(key KT) {
m.mutex.Lock() m.Lock()
delete(m.m, key) delete(m.m, key)
m.mutex.Unlock() m.Unlock()
} }
func (m *safeMap[KT, VT]) Clear() { func (m *safeMap[KT, VT]) Clear() {
m.mutex.Lock() m.Lock()
m.m = make(map[KT]VT) m.m = make(map[KT]VT)
m.mutex.Unlock() m.Unlock()
} }
func (m *safeMap[KT, VT]) Size() int { func (m *safeMap[KT, VT]) Size() int {
m.mutex.Lock() m.RLock()
size := len(m.m) defer m.RUnlock()
m.mutex.Unlock() return len(m.m)
return size
} }
func (m *safeMap[KT, VT]) Contains(key KT) bool { func (m *safeMap[KT, VT]) Contains(key KT) bool {
m.mutex.Lock() m.RLock()
_, ok := m.m[key] _, ok := m.m[key]
m.mutex.Unlock() m.RUnlock()
return ok return ok
} }
func (m *safeMap[KT, VT]) ForEach(fn func(key KT, value VT)) { func (m *safeMap[KT, VT]) ForEach(fn func(key KT, value VT)) {
m.mutex.Lock() m.RLock()
for k, v := range m.m { for k, v := range m.m {
fn(k, v) fn(k, v)
} }
m.mutex.Unlock() m.RUnlock()
} }
func (m *safeMap[KT, VT]) Iterator() map[KT]VT { func (m *safeMap[KT, VT]) Iterator() map[KT]VT {

View file

@ -1,87 +1,53 @@
package main package main
import ( import (
"errors"
"html/template" "html/template"
"net"
"net/http" "net/http"
"net/url" "net/url"
"time" "os"
"path"
) )
var healthCheckHttpClient = &http.Client{ var panelHandler = panelRouter()
Timeout: 5 * time.Second,
Transport: &http.Transport{ func panelRouter() *http.ServeMux {
Proxy: http.ProxyFromEnvironment, mux := http.NewServeMux()
DisableKeepAlives: true, mux.HandleFunc("GET /{$}", panelServeFile)
ForceAttemptHTTP2: true, mux.HandleFunc("GET /{file}", panelServeFile)
DialContext: (&net.Dialer{ mux.HandleFunc("GET /panel/", panelPage)
Timeout: 5 * time.Second, mux.HandleFunc("GET /panel/{file}", panelServeFile)
KeepAlive: 5 * time.Second, mux.HandleFunc("HEAD /checkhealth", panelCheckTargetHealth)
}).DialContext, mux.HandleFunc("GET /config_editor/", panelConfigEditor)
}, mux.HandleFunc("GET /config_editor/{file}", panelServeFile)
mux.HandleFunc("GET /config/{file}", panelConfigGet)
mux.HandleFunc("PUT /config/{file}", panelConfigUpdate)
mux.HandleFunc("POST /reload", configReload)
mux.HandleFunc("GET /codemirror/", panelServeFile)
return mux
} }
func panelHandler(w http.ResponseWriter, r *http.Request) { func panelPage(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path { resp := struct {
case "/":
panelIndex(w, r)
return
case "/checkhealth":
panelCheckTargetHealth(w, r)
return
default:
palog.Errorf("%s not found", r.URL.Path)
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(templatePath)
if err != nil {
palog.Error(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
type allRoutes struct {
HTTPRoutes HTTPRoutes HTTPRoutes HTTPRoutes
StreamRoutes StreamRoutes StreamRoutes StreamRoutes
} }{httpRoutes, streamRoutes}
err = tmpl.Execute(w, allRoutes{ panelRenderFile(w, r, panelTemplatePath, resp)
HTTPRoutes: httpRoutes,
StreamRoutes: streamRoutes,
})
if err != nil {
palog.Error(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
}
} }
func panelCheckTargetHealth(w http.ResponseWriter, r *http.Request) { 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") targetUrl := r.URL.Query().Get("target")
if targetUrl == "" { if targetUrl == "" {
http.Error(w, "target is required", http.StatusBadRequest) panelHandleErr(w, r, errors.New("target is required"), http.StatusBadRequest)
return return
} }
url, err := url.Parse(targetUrl) url, err := url.Parse(targetUrl)
if err != nil { if err != nil {
palog.Infof("failed to parse url %q, error: %v", targetUrl, err) err = NewNestedError("failed to parse url").Subject(targetUrl).With(err)
http.Error(w, err.Error(), http.StatusBadRequest) panelHandleErr(w, r, err, http.StatusBadRequest)
return return
} }
scheme := url.Scheme scheme := url.Scheme
@ -98,3 +64,81 @@ func panelCheckTargetHealth(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
} }
} }
func panelConfigEditor(w http.ResponseWriter, r *http.Request) {
cfgFiles := make([]string, 0)
cfgFiles = append(cfgFiles, path.Base(configPath))
for _, p := range cfg.(*config).m.Providers {
if p.Kind != ProviderKind_File {
continue
}
cfgFiles = append(cfgFiles, p.Value)
}
panelRenderFile(w, r, configEditorTemplatePath, cfgFiles)
}
func panelConfigGet(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, path.Join(configBasePath, r.PathValue("file")))
}
func panelConfigUpdate(w http.ResponseWriter, r *http.Request) {
p := r.PathValue("file")
content := make([]byte, r.ContentLength)
_, err := r.Body.Read(content)
if err != nil {
panelHandleErr(w, r, NewNestedError("unable to read request body").Subject(p).With(err))
return
}
if p == path.Base(configPath) {
err = ValidateConfig(content)
} else {
_, err = ValidateFileContent(content)
}
if err != nil {
panelHandleErr(w, r, err)
return
}
err = os.WriteFile(path.Join(configBasePath, p), content, 0644)
if err != nil {
panelHandleErr(w, r, NewNestedError("unable to write config file").With(err))
return
}
w.WriteHeader(http.StatusOK)
}
func panelServeFile(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, path.Join(templatesBasePath, r.URL.Path))
}
func panelRenderFile(w http.ResponseWriter, r *http.Request, f string, data any) {
tmpl, err := template.ParseFiles(f)
if err != nil {
panelHandleErr(w, r, NewNestedError("unable to parse template").With(err))
return
}
err = tmpl.Execute(w, data)
if err != nil {
panelHandleErr(w, r, NewNestedError("unable to render template").With(err))
}
}
func configReload(w http.ResponseWriter, r *http.Request) {
err := cfg.Reload()
if err != nil {
panelHandleErr(w, r, err)
return
}
w.WriteHeader(http.StatusOK)
}
func panelHandleErr(w http.ResponseWriter, r *http.Request, err error, code ...int) {
err = NewNestedErrorFrom(err).Subjectf("%s %s", r.Method, r.URL)
palog.Error(err)
if len(code) > 0 {
http.Error(w, err.Error(), code[0])
return
}
http.Error(w, err.Error(), http.StatusInternalServerError)
}

View file

@ -1,7 +1,6 @@
package main package main
import ( import (
"fmt"
"strings" "strings"
) )
@ -9,10 +8,8 @@ type pathPoolMap struct {
SafeMap[string, *httpLoadBalancePool] SafeMap[string, *httpLoadBalancePool]
} }
func newPathPoolMap() *pathPoolMap { func newPathPoolMap() pathPoolMap {
return &pathPoolMap{ return pathPoolMap{NewSafeMapOf[pathPoolMap](NewHTTPLoadBalancePool)}
NewSafeMap[string](NewHTTPLoadBalancePool),
}
} }
func (m pathPoolMap) Add(path string, route *HTTPRoute) { func (m pathPoolMap) Add(path string, route *HTTPRoute) {
@ -20,11 +17,11 @@ func (m pathPoolMap) Add(path string, route *HTTPRoute) {
m.Get(path).Add(route) m.Get(path).Add(route)
} }
func (m pathPoolMap) FindMatch(pathGot string) (*HTTPRoute, error) { func (m pathPoolMap) FindMatch(pathGot string) (*HTTPRoute, NestedErrorLike) {
for pathWant, v := range m.Iterator() { for pathWant, v := range m.Iterator() {
if strings.HasPrefix(pathGot, pathWant) { if strings.HasPrefix(pathGot, pathWant) {
return v.Pick(), nil return v.Pick(), nil
} }
} }
return nil, fmt.Errorf("no matching route for path %s", pathGot) return nil, NewNestedError("no matching path").Subject(pathGot)
} }

View file

@ -1,15 +1,14 @@
package main package main
import ( import (
"fmt"
"sync" "sync"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
) )
type Provider struct { type Provider struct {
Kind string // docker, file Kind string `json:"kind"` // docker, file
Value string Value string `json:"value"`
watcher Watcher watcher Watcher
routes map[string]Route // id -> Route routes map[string]Route // id -> Route
@ -20,12 +19,12 @@ type Provider struct {
// Init is called after LoadProxyConfig // Init is called after LoadProxyConfig
func (p *Provider) Init(name string) error { func (p *Provider) Init(name string) error {
p.l = prlog.WithFields(logrus.Fields{"kind": p.Kind, "name": name}) p.l = prlog.WithFields(logrus.Fields{"kind": p.Kind, "name": name})
defer p.initWatcher()
if err := p.loadProxyConfig(); err != nil { if err := p.loadProxyConfig(); err != nil {
return err return err
} }
p.initWatcher()
return nil return nil
} }
@ -37,7 +36,7 @@ func (p *Provider) StartAllRoutes() {
func (p *Provider) StopAllRoutes() { func (p *Provider) StopAllRoutes() {
p.watcher.Stop() p.watcher.Stop()
ParallelForEachValue(p.routes, Route.Stop) ParallelForEachValue(p.routes, Route.Stop)
p.routes = make(map[string]Route) p.routes = nil
} }
func (p *Provider) ReloadRoutes() { func (p *Provider) ReloadRoutes() {
@ -54,17 +53,17 @@ func (p *Provider) ReloadRoutes() {
} }
func (p *Provider) loadProxyConfig() error { func (p *Provider) loadProxyConfig() error {
var cfgs []*ProxyConfig var cfgs ProxyConfigSlice
var err error var err error
switch p.Kind { switch p.Kind {
case ProviderKind_Docker: case ProviderKind_Docker:
cfgs, err = p.getDockerProxyConfigs() cfgs, err = p.getDockerProxyConfigs()
case ProviderKind_File: case ProviderKind_File:
cfgs, err = p.getFileProxyConfigs() cfgs, err = p.ValidateFile()
default: default:
// this line should never be reached // this line should never be reached
return fmt.Errorf("unknown provider kind") return NewNestedError("unknown provider kind")
} }
if err != nil { if err != nil {
@ -73,29 +72,34 @@ func (p *Provider) loadProxyConfig() error {
p.l.Infof("loaded %d proxy configurations", len(cfgs)) p.l.Infof("loaded %d proxy configurations", len(cfgs))
p.routes = make(map[string]Route, len(cfgs)) p.routes = make(map[string]Route, len(cfgs))
pErrs := NewNestedError("failed to create these routes")
for _, cfg := range cfgs { for _, cfg := range cfgs {
r, err := NewRoute(cfg) r, err := NewRoute(&cfg)
if err != nil { if err != nil {
p.l.Errorf("error creating route %s: %v", cfg.Alias, err) pErrs.ExtraError(NewNestedErrorFrom(err).Subject(cfg.Alias))
continue continue
} }
p.routes[cfg.GetID()] = r p.routes[cfg.GetID()] = r
} }
if pErrs.HasExtras() {
p.routes = nil
return pErrs
}
return nil return nil
} }
func (p *Provider) initWatcher() error { func (p *Provider) initWatcher() error {
switch p.Kind { switch p.Kind {
case ProviderKind_Docker: case ProviderKind_Docker:
var err error
dockerClient, err := p.getDockerClient() dockerClient, err := p.getDockerClient()
if err != nil { if err != nil {
return fmt.Errorf("unable to create docker client: %v", err) return NewNestedError("unable to create docker client").With(err)
} }
p.watcher = NewDockerWatcher(dockerClient, p.ReloadRoutes) p.watcher = NewDockerWatcher(dockerClient, p.ReloadRoutes)
case ProviderKind_File: case ProviderKind_File:
p.watcher = NewFileWatcher(p.Value, p.ReloadRoutes, p.StopAllRoutes) p.watcher = NewFileWatcher(p.GetFilePath(), p.ReloadRoutes, p.StopAllRoutes)
} }
return nil return nil
} }

View file

@ -3,18 +3,21 @@ package main
import "fmt" import "fmt"
type ProxyConfig struct { type ProxyConfig struct {
Alias string Alias string `yaml:"-" json:"-"`
Scheme string Scheme string `yaml:"scheme" json:"scheme"`
Host string Host string `yaml:"host" json:"host"`
Port string Port string `yaml:"port" json:"port"`
LoadBalance string // docker provider only LoadBalance string `yaml:"-" json:"-"` // docker provider only
NoTLSVerify bool // http proxy only NoTLSVerify bool `yaml:"no_tls_verify" json:"no_tls_verify"` // http proxy only
Path string // http proxy only Path string `yaml:"path" json:"path"` // http proxy only
PathMode string `yaml:"path_mode"` // http proxy only PathMode string `yaml:"path_mode" json:"path_mode"` // http proxy only
provider *Provider provider *Provider
} }
type ProxyConfigMap = map[string]ProxyConfig
type ProxyConfigSlice = []ProxyConfig
func NewProxyConfig(provider *Provider) ProxyConfig { func NewProxyConfig(provider *Provider) ProxyConfig {
return ProxyConfig{ return ProxyConfig{
provider: provider, provider: provider,
@ -23,17 +26,29 @@ func NewProxyConfig(provider *Provider) ProxyConfig {
// used by `GetFileProxyConfigs` // used by `GetFileProxyConfigs`
func (cfg *ProxyConfig) SetDefaults() error { func (cfg *ProxyConfig) SetDefaults() error {
err := NewNestedError("invalid proxy config").Subject(cfg.Alias)
if cfg.Alias == "" { if cfg.Alias == "" {
return fmt.Errorf("alias is required") err.Extra("alias is required")
} }
if cfg.Scheme == "" { if cfg.Scheme == "" {
cfg.Scheme = "http" cfg.Scheme = "http"
} }
if cfg.Host == "" { if cfg.Host == "" {
return fmt.Errorf("host is required for %q", cfg.Alias) err.Extra("host is required")
} }
if cfg.Port == "" { if cfg.Port == "" {
switch cfg.Scheme {
case "http":
cfg.Port = "80" cfg.Port = "80"
case "https":
cfg.Port = "443"
default:
err.Extraf("port is required for %s scheme", cfg.Scheme)
}
}
if err.HasExtras() {
return err
} }
return nil return nil
} }

View file

@ -1,9 +1,5 @@
package main package main
import (
"fmt"
)
type Route interface { type Route interface {
Start() Start()
Stop() Stop()
@ -13,11 +9,11 @@ func NewRoute(cfg *ProxyConfig) (Route, error) {
if isStreamScheme(cfg.Scheme) { if isStreamScheme(cfg.Scheme) {
id := cfg.GetID() id := cfg.GetID()
if streamRoutes.Contains(id) { if streamRoutes.Contains(id) {
return nil, fmt.Errorf("duplicated %s stream %s, ignoring", cfg.Scheme, id) return nil, NewNestedError("duplicated stream").Subject(cfg.Alias)
} }
route, err := NewStreamRoute(cfg) route, err := NewStreamRoute(cfg)
if err != nil { if err != nil {
return nil, err return nil, NewNestedErrorFrom(err).Subject(cfg.Alias)
} }
streamRoutes.Set(id, route) streamRoutes.Set(id, route)
return route, nil return route, nil
@ -25,7 +21,7 @@ func NewRoute(cfg *ProxyConfig) (Route, error) {
httpRoutes.Ensure(cfg.Alias) httpRoutes.Ensure(cfg.Alias)
route, err := NewHTTPRoute(cfg) route, err := NewHTTPRoute(cfg)
if err != nil { if err != nil {
return nil, err return nil, NewNestedErrorFrom(err).Subject(cfg.Alias)
} }
httpRoutes.Get(cfg.Alias).Add(cfg.Path, route) httpRoutes.Get(cfg.Alias).Add(cfg.Path, route)
return route, nil return route, nil
@ -53,4 +49,4 @@ func isStreamScheme(s string) bool {
// id -> target // id -> target
type StreamRoutes = SafeMap[string, StreamRoute] type StreamRoutes = SafeMap[string, StreamRoute]
var streamRoutes = NewSafeMap[string, StreamRoute]() var streamRoutes StreamRoutes = NewSafeMapOf[StreamRoutes]()

View file

@ -2,6 +2,7 @@ package main
import ( import (
"crypto/tls" "crypto/tls"
"log"
"net/http" "net/http"
"time" "time"
@ -20,35 +21,66 @@ type Server struct {
httpsStarted bool httpsStarted bool
} }
func NewServer(name string, provider AutoCertProvider, httpAddr string, httpHandler http.Handler, httpsAddr string, httpsHandler http.Handler) *Server { type ServerOptions struct {
if provider != nil { Name string
HTTPAddr string
HTTPSAddr string
CertProvider AutoCertProvider
RedirectToHTTPS bool
Handler http.Handler
}
type LogrusWrapper struct {
l *logrus.Entry
}
func (l LogrusWrapper) Write(b []byte) (int, error) {
return l.l.Logger.WriterLevel(logrus.ErrorLevel).Write(b)
}
func NewServer(opt ServerOptions) *Server {
var httpHandler http.Handler
if opt.RedirectToHTTPS {
httpHandler = http.HandlerFunc(redirectToTLSHandler)
} else {
httpHandler = opt.Handler
}
logger := log.Default()
logger.SetOutput(LogrusWrapper{
logrus.WithFields(logrus.Fields{"component": "server", "name": opt.Name}),
})
if opt.CertProvider != nil {
return &Server{ return &Server{
Name: name, Name: opt.Name,
CertProvider: provider, CertProvider: opt.CertProvider,
http: &http.Server{ http: &http.Server{
Addr: httpAddr, Addr: opt.HTTPAddr,
Handler: httpHandler, Handler: httpHandler,
ErrorLog: logger,
}, },
https: &http.Server{ https: &http.Server{
Addr: httpsAddr, Addr: opt.HTTPSAddr,
Handler: httpsHandler, Handler: opt.Handler,
ErrorLog: logger,
TLSConfig: &tls.Config{ TLSConfig: &tls.Config{
GetCertificate: provider.GetCert, GetCertificate: opt.CertProvider.GetCert,
}, },
}, },
} }
} }
return &Server{ return &Server{
Name: name, Name: opt.Name,
KeyFile: keyFileDefault, KeyFile: keyFileDefault,
CertFile: certFileDefault, CertFile: certFileDefault,
http: &http.Server{ http: &http.Server{
Addr: httpAddr, Addr: opt.HTTPAddr,
Handler: httpHandler, Handler: httpHandler,
ErrorLog: logger,
}, },
https: &http.Server{ https: &http.Server{
Addr: httpsAddr, Addr: opt.HTTPSAddr,
Handler: httpsHandler, Handler: opt.Handler,
ErrorLog: logger,
}, },
} }
} }

View file

@ -1,7 +1,6 @@
package main package main
import ( import (
"errors"
"fmt" "fmt"
"strconv" "strconv"
"strings" "strings"
@ -11,16 +10,18 @@ import (
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
) )
type StreamImpl interface {
Setup() error
Accept() (interface{}, error)
Handle(interface{}) error
CloseListeners()
}
type StreamRoute interface { type StreamRoute interface {
Route Route
ListeningUrl() string ListeningUrl() string
TargetUrl() string TargetUrl() string
Logger() logrus.FieldLogger Logger() logrus.FieldLogger
closeListeners()
closeChannel()
unmarkPort()
wait()
} }
type StreamRouteBase struct { type StreamRouteBase struct {
@ -34,8 +35,12 @@ type StreamRouteBase struct {
id string id string
wg sync.WaitGroup wg sync.WaitGroup
stopChann chan struct{} stopCh chan struct{}
connCh chan interface{}
started bool
l logrus.FieldLogger l logrus.FieldLogger
StreamImpl
} }
func newStreamRouteBase(config *ProxyConfig) (*StreamRouteBase, error) { func newStreamRouteBase(config *ProxyConfig) (*StreamRouteBase, error) {
@ -45,14 +50,14 @@ func newStreamRouteBase(config *ProxyConfig) (*StreamRouteBase, error) {
var srcScheme string var srcScheme string
var dstScheme string var dstScheme string
port_split := strings.Split(config.Port, ":") portSplit := strings.Split(config.Port, ":")
if len(port_split) != 2 { if len(portSplit) != 2 {
cfgl.Warnf("invalid port %s, assuming it is target port", config.Port) cfgl.Warnf("invalid port %s, assuming it is target port", config.Port)
srcPort = "0" srcPort = "0"
dstPort = config.Port dstPort = config.Port
} else { } else {
srcPort = port_split[0] srcPort = portSplit[0]
dstPort = port_split[1] dstPort = portSplit[1]
} }
if port, hasName := NamePortMap[dstPort]; hasName { if port, hasName := NamePortMap[dstPort]; hasName {
@ -61,25 +66,20 @@ func newStreamRouteBase(config *ProxyConfig) (*StreamRouteBase, error) {
srcPortInt, err := strconv.Atoi(srcPort) srcPortInt, err := strconv.Atoi(srcPort)
if err != nil { if err != nil {
return nil, fmt.Errorf( return nil, NewNestedError("invalid stream source port").Subject(srcPort)
"invalid stream source port %s, ignoring", srcPort,
)
} }
utils.markPortInUse(srcPortInt) utils.markPortInUse(srcPortInt)
dstPortInt, err := strconv.Atoi(dstPort) dstPortInt, err := strconv.Atoi(dstPort)
if err != nil { if err != nil {
return nil, fmt.Errorf( return nil, NewNestedError("invalid stream target port").Subject(dstPort)
"invalid stream target port %s, ignoring", dstPort,
)
} }
scheme_split := strings.Split(config.Scheme, ":") schemeSplit := strings.Split(config.Scheme, ":")
if len(schemeSplit) == 2 {
if len(scheme_split) == 2 { srcScheme = schemeSplit[0]
srcScheme = scheme_split[0] dstScheme = schemeSplit[1]
dstScheme = scheme_split[1]
} else { } else {
srcScheme = config.Scheme srcScheme = config.Scheme
dstScheme = config.Scheme dstScheme = config.Scheme
@ -96,7 +96,9 @@ func newStreamRouteBase(config *ProxyConfig) (*StreamRouteBase, error) {
id: config.GetID(), id: config.GetID(),
wg: sync.WaitGroup{}, wg: sync.WaitGroup{},
stopChann: make(chan struct{}, 1), stopCh: make(chan struct{}, 1),
connCh: make(chan interface{}),
started: false,
l: srlog.WithFields(logrus.Fields{ l: srlog.WithFields(logrus.Fields{
"alias": config.Alias, "alias": config.Alias,
"src": fmt.Sprintf("%s://:%d", srcScheme, srcPortInt), "src": fmt.Sprintf("%s://:%d", srcScheme, srcPortInt),
@ -112,7 +114,7 @@ func NewStreamRoute(config *ProxyConfig) (StreamRoute, error) {
case StreamType_UDP: case StreamType_UDP:
return NewUDPRoute(config) return NewUDPRoute(config)
default: default:
return nil, errors.New("unknown stream type") return nil, NewNestedError("invalid stream type").Subject(config.Scheme)
} }
} }
@ -128,7 +130,45 @@ func (route *StreamRouteBase) Logger() logrus.FieldLogger {
return route.l return route.l
} }
func (route *StreamRouteBase) setupListen() { func (route *StreamRouteBase) Start() {
route.ensurePort()
if err := route.Setup(); err != nil {
route.l.Errorf("failed to setup: %v", err)
return
}
route.started = true
route.wg.Add(2)
go route.grAcceptConnections()
go route.grHandleConnections()
}
func (route *StreamRouteBase) Stop() {
if !route.started {
return
}
l := route.Logger()
l.Debug("stopping listening")
close(route.stopCh)
route.CloseListeners()
done := make(chan struct{}, 1)
go func() {
route.wg.Wait()
close(done)
}()
select {
case <-done:
l.Info("stopped listening")
case <-time.After(streamStopListenTimeout):
l.Error("timed out waiting for connections")
}
utils.unmarkPortInUse(route.ListeningPort)
streamRoutes.Delete(route.id)
}
func (route *StreamRouteBase) ensurePort() {
if route.ListeningPort == 0 { if route.ListeningPort == 0 {
freePort, err := utils.findUseFreePort(20000) freePort, err := utils.findUseFreePort(20000)
if err != nil { if err != nil {
@ -142,40 +182,43 @@ func (route *StreamRouteBase) setupListen() {
route.l.Info("listening on ", route.ListeningUrl()) route.l.Info("listening on ", route.ListeningUrl())
} }
func (route *StreamRouteBase) wait() { func (route *StreamRouteBase) grAcceptConnections() {
route.wg.Wait() defer route.wg.Done()
}
func (route *StreamRouteBase) closeChannel() {
close(route.stopChann)
}
func (route *StreamRouteBase) unmarkPort() {
utils.unmarkPortInUse(route.ListeningPort)
}
func stopListening(route StreamRoute) {
l := route.Logger()
l.Debug("stopping listening")
// close channel -> wait -> close listeners
route.closeChannel()
done := make(chan struct{})
go func() {
route.wait()
close(done)
route.unmarkPort()
}()
for {
select { select {
case <-done: case <-route.stopCh:
l.Info("stopped listening") return
case <-time.After(StreamStopListenTimeout): default:
l.Error("timed out waiting for connections") conn, err := route.Accept()
if err != nil {
select {
case <-route.stopCh:
return
default:
route.l.Error(err)
continue
}
}
route.connCh <- conn
}
}
}
func (route *StreamRouteBase) grHandleConnections() {
defer route.wg.Done()
for {
select {
case <-route.stopCh:
return
case conn := <-route.connCh:
go func() {
err := route.Handle(conn)
if err != nil {
route.l.Error(err)
}
}()
}
} }
route.closeListeners()
} }

View file

@ -3,94 +3,50 @@ package main
import ( import (
"context" "context"
"fmt" "fmt"
"io"
"net" "net"
"sync"
"time" "time"
) )
const tcpDialTimeout = 5 * time.Second const tcpDialTimeout = 5 * time.Second
type Pipes []*BidirectionalPipe
type TCPRoute struct { type TCPRoute struct {
*StreamRouteBase *StreamRouteBase
listener net.Listener listener net.Listener
connChan chan net.Conn
} }
func NewTCPRoute(config *ProxyConfig) (StreamRoute, error) { func NewTCPRoute(config *ProxyConfig) (StreamRoute, error) {
base, err := newStreamRouteBase(config) base, err := newStreamRouteBase(config)
if err != nil { if err != nil {
return nil, err return nil, NewNestedErrorFrom(err).Subject(config.Alias)
} }
if base.TargetScheme != StreamType_TCP { if base.TargetScheme != StreamType_TCP {
return nil, fmt.Errorf("tcp to %s not yet supported", base.TargetScheme) return nil, NewNestedError("unsupported").Subjectf("tcp -> %s", base.TargetScheme)
} }
return &TCPRoute{ base.StreamImpl = &TCPRoute{
StreamRouteBase: base, StreamRouteBase: base,
listener: nil, listener: nil,
connChan: make(chan net.Conn), }
}, nil return base, nil
} }
func (route *TCPRoute) Start() { func (route *TCPRoute) Setup() error {
route.setupListen()
in, err := net.Listen("tcp", fmt.Sprintf(":%v", route.ListeningPort)) in, err := net.Listen("tcp", fmt.Sprintf(":%v", route.ListeningPort))
if err != nil { if err != nil {
route.l.Error(err) return err
return
} }
route.listener = in route.listener = in
route.wg.Add(2) return nil
go route.grAcceptConnections()
go route.grHandleConnections()
} }
func (route *TCPRoute) Stop() { func (route *TCPRoute) Accept() (interface{}, error) {
stopListening(route) return route.listener.Accept()
streamRoutes.Delete(route.id)
} }
func (route *TCPRoute) closeListeners() { func (route *TCPRoute) HandleConnection(c interface{}) error {
if route.listener == nil { clientConn := c.(net.Conn)
return
}
route.listener.Close()
route.listener = nil
}
func (route *TCPRoute) grAcceptConnections() {
defer route.wg.Done()
for {
select {
case <-route.stopChann:
return
default:
conn, err := route.listener.Accept()
if err != nil {
route.l.Error(err)
continue
}
route.connChan <- conn
}
}
}
func (route *TCPRoute) grHandleConnections() {
defer route.wg.Done()
for {
select {
case <-route.stopChann:
return
case conn := <-route.connChan:
route.wg.Add(1)
go route.grHandleConnection(conn)
}
}
}
func (route *TCPRoute) grHandleConnection(clientConn net.Conn) {
defer clientConn.Close() defer clientConn.Close()
defer route.wg.Done() defer route.wg.Done()
@ -99,34 +55,28 @@ func (route *TCPRoute) grHandleConnection(clientConn net.Conn) {
serverAddr := fmt.Sprintf("%s:%v", route.TargetHost, route.TargetPort) serverAddr := fmt.Sprintf("%s:%v", route.TargetHost, route.TargetPort)
dialer := &net.Dialer{} dialer := &net.Dialer{}
serverConn, err := dialer.DialContext(ctx, route.TargetScheme, serverAddr) serverConn, err := dialer.DialContext(ctx, route.TargetScheme, serverAddr)
if err != nil { if err != nil {
route.l.WithField("stage", "dial").Infof("%v", err) return err
}
pipeCtx, pipeCancel := context.WithCancel(context.Background())
go func() {
<-route.stopCh
pipeCancel()
}()
pipe := NewBidirectionalPipe(pipeCtx, clientConn, serverConn)
pipe.Start()
pipe.Wait()
pipe.Close()
return nil
}
func (route *TCPRoute) CloseListeners() {
if route.listener == nil {
return return
} }
route.tcpPipe(clientConn, serverConn) route.listener.Close()
} route.listener = nil
func (route *TCPRoute) tcpPipe(src net.Conn, dest net.Conn) {
close := func() {
src.Close()
dest.Close()
}
var wg sync.WaitGroup
wg.Add(2) // Number of goroutines
go func() {
_, err := io.Copy(src, dest)
route.l.Error(err)
close()
wg.Done()
}()
go func() {
_, err := io.Copy(dest, src)
route.l.Error(err)
close()
wg.Done()
}()
wg.Wait()
} }

View file

@ -17,8 +17,6 @@ type UDPRoute struct {
listeningConn *net.UDPConn listeningConn *net.UDPConn
targetConn *net.UDPConn targetConn *net.UDPConn
connChan chan *UDPConn
} }
type UDPConn struct { type UDPConn struct {
@ -35,99 +33,60 @@ func NewUDPRoute(config *ProxyConfig) (StreamRoute, error) {
} }
if base.TargetScheme != StreamType_UDP { if base.TargetScheme != StreamType_UDP {
return nil, fmt.Errorf("udp to %s not yet supported", base.TargetScheme) return nil, NewNestedError("unsupported").Subjectf("udp->%s", base.TargetScheme)
} }
return &UDPRoute{ base.StreamImpl = &UDPRoute{
StreamRouteBase: base, StreamRouteBase: base,
connMap: make(map[net.Addr]net.Conn), connMap: make(map[net.Addr]net.Conn),
connChan: make(chan *UDPConn), }
}, nil return base, nil
} }
func (route *UDPRoute) Start() { func (route *UDPRoute) Setup() error {
route.setupListen()
source, err := net.ListenPacket(route.ListeningScheme, fmt.Sprintf(":%v", route.ListeningPort)) source, err := net.ListenPacket(route.ListeningScheme, fmt.Sprintf(":%v", route.ListeningPort))
if err != nil { if err != nil {
route.l.Error(err) return err
return
} }
target, err := net.Dial(route.TargetScheme, fmt.Sprintf("%s:%v", route.TargetHost, route.TargetPort)) target, err := net.Dial(route.TargetScheme, fmt.Sprintf("%s:%v", route.TargetHost, route.TargetPort))
if err != nil { if err != nil {
route.l.Error(err)
source.Close() source.Close()
return return err
} }
route.listeningConn = source.(*net.UDPConn) route.listeningConn = source.(*net.UDPConn)
route.targetConn = target.(*net.UDPConn) route.targetConn = target.(*net.UDPConn)
return nil
route.wg.Add(2)
go route.grAcceptConnections()
go route.grHandleConnections()
} }
func (route *UDPRoute) Stop() { func (route *UDPRoute) Accept() (interface{}, error) {
stopListening(route) in := route.listeningConn
streamRoutes.Delete(route.id)
}
func (route *UDPRoute) closeListeners() { buffer := make([]byte, udpBufferSize)
if route.listeningConn != nil { nRead, srcAddr, err := in.ReadFromUDP(buffer)
route.listeningConn.Close()
route.listeningConn = nil
}
if route.targetConn != nil {
route.targetConn.Close()
route.targetConn = nil
}
for _, conn := range route.connMap {
conn.(*net.UDPConn).Close() // TODO: change on non udp target
}
route.connMap = make(map[net.Addr]net.Conn)
}
func (route *UDPRoute) grAcceptConnections() {
defer route.wg.Done()
for {
select {
case <-route.stopChann:
return
default:
conn, err := route.accept()
if err != nil { if err != nil {
route.l.Error(err) return nil, err
continue
} }
route.connChan <- conn
if nRead == 0 {
return nil, io.ErrShortBuffer
} }
conn := &UDPConn{
remoteAddr: srcAddr,
buffer: buffer,
bytesReceived: buffer[:nRead],
nReceived: nRead,
} }
return conn, nil
} }
func (route *UDPRoute) grHandleConnections() { func (route *UDPRoute) HandleConnection(c interface{}) error {
defer route.wg.Done()
for {
select {
case <-route.stopChann:
return
case conn := <-route.connChan:
go func() {
err := route.handleConnection(conn)
if err != nil {
route.l.Error(err)
}
}()
}
}
}
func (route *UDPRoute) handleConnection(conn *UDPConn) error {
var err error var err error
conn := c.(*UDPConn)
srcConn, ok := route.connMap[conn.remoteAddr] srcConn, ok := route.connMap[conn.remoteAddr]
if !ok { if !ok {
route.connMapMutex.Lock() route.connMapMutex.Lock()
@ -155,7 +114,7 @@ func (route *UDPRoute) handleConnection(conn *UDPConn) error {
for { for {
select { select {
case <-route.stopChann: case <-route.stopCh:
return nil return nil
default: default:
// receive from target // receive from target
@ -182,26 +141,19 @@ func (route *UDPRoute) handleConnection(conn *UDPConn) error {
} }
} }
func (route *UDPRoute) accept() (*UDPConn, error) { func (route *UDPRoute) CloseListeners() {
in := route.listeningConn if route.listeningConn != nil {
route.listeningConn.Close()
buffer := make([]byte, udpBufferSize) route.listeningConn = nil
nRead, srcAddr, err := in.ReadFromUDP(buffer)
if err != nil {
return nil, err
} }
if route.targetConn != nil {
if nRead == 0 { route.targetConn.Close()
return nil, io.ErrShortBuffer route.targetConn = nil
} }
for _, conn := range route.connMap {
return &UDPConn{ conn.(*net.UDPConn).Close() // TODO: change on non udp target
remoteAddr: srcAddr, }
buffer: buffer, route.connMap = make(map[net.Addr]net.Conn)
bytesReceived: buffer[:nRead],
nReceived: nRead},
nil
} }
func (route *UDPRoute) readFrom(src net.Conn, buffer []byte) (*UDPConn, error) { func (route *UDPRoute) readFrom(src net.Conn, buffer []byte) (*UDPConn, error) {

View file

@ -2,6 +2,7 @@ package main
import ( import (
"bytes" "bytes"
"encoding/json"
"fmt" "fmt"
"io" "io"
"net" "net"
@ -13,10 +14,11 @@ import (
"regexp" "regexp"
"strings" "strings"
"sync" "sync"
"time"
"github.com/santhosh-tekuri/jsonschema"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
xhtml "golang.org/x/net/html" xhtml "golang.org/x/net/html"
"gopkg.in/yaml.v3"
) )
type Utils struct { type Utils struct {
@ -52,7 +54,7 @@ func (u *Utils) findUseFreePort(startingPort int) (int, error) {
l.Close() l.Close()
return port, nil return port, nil
} }
return -1, fmt.Errorf("unable to find free port: %v", err) return -1, NewNestedError("unable to find free port").With(err)
} }
func (u *Utils) markPortInUse(port int) { func (u *Utils) markPortInUse(port int) {
@ -84,7 +86,7 @@ func (*Utils) healthCheckHttp(targetUrl string) error {
} }
func (*Utils) healthCheckStream(scheme, host string) error { func (*Utils) healthCheckStream(scheme, host string) error {
conn, err := net.DialTimeout(scheme, host, 5*time.Second) conn, err := net.DialTimeout(scheme, host, streamDialTimeout)
if err != nil { if err != nil {
return err return err
} }
@ -194,12 +196,37 @@ func (*Utils) fileOK(path string) bool {
return err == nil return err == nil
} }
func SetFieldFromSnake[T interface{}, VT interface{}](obj *T, field string, value VT) error { func setFieldFromSnake[T interface{}, VT interface{}](obj *T, field string, value VT) error {
field = utils.snakeToPascal(field) field = utils.snakeToPascal(field)
prop := reflect.ValueOf(obj).Elem().FieldByName(field) prop := reflect.ValueOf(obj).Elem().FieldByName(field)
if prop.Kind() == 0 { if prop.Kind() == 0 {
return fmt.Errorf("unknown field %s", field) return NewNestedError("unknown field").Subject(field)
} }
prop.Set(reflect.ValueOf(value)) prop.Set(reflect.ValueOf(value))
return nil return nil
} }
func validateYaml(schema *jsonschema.Schema, data []byte) error {
var i interface{}
err := yaml.Unmarshal(data, &i)
if err != nil {
return NewNestedError("unable to unmarshal yaml").With(err)
}
m, err := json.Marshal(i)
if err != nil {
return NewNestedError("unable to marshal json").With(err)
}
err = schema.Validate(bytes.NewReader(m))
if err != nil {
valErr := err.(*jsonschema.ValidationError)
ne := NewNestedError("validation error")
for _, e := range valErr.Causes {
ne.ExtraError(e)
}
return ne
}
return nil
}

View file

@ -26,6 +26,7 @@ type watcherBase struct {
kind string // for log / error output kind string // for log / error output
onChange func() onChange func()
l logrus.FieldLogger l logrus.FieldLogger
sync.Mutex
} }
type fileWatcher struct { type fileWatcher struct {
@ -66,6 +67,8 @@ func NewDockerWatcher(c *client.Client, onChange func()) Watcher {
} }
func (w *fileWatcher) Start() { func (w *fileWatcher) Start() {
w.Lock()
defer w.Unlock()
if fsWatcher == nil { if fsWatcher == nil {
return return
} }
@ -78,6 +81,8 @@ func (w *fileWatcher) Start() {
} }
func (w *fileWatcher) Stop() { func (w *fileWatcher) Stop() {
w.Lock()
defer w.Unlock()
if fsWatcher == nil { if fsWatcher == nil {
return return
} }
@ -93,12 +98,16 @@ func (w *fileWatcher) Dispose() {
} }
func (w *dockerWatcher) Start() { func (w *dockerWatcher) Start() {
w.Lock()
defer w.Unlock()
dockerWatchMap.Set(w.name, w) dockerWatchMap.Set(w.name, w)
w.wg.Add(1) w.wg.Add(1)
go w.watch() go w.watch()
} }
func (w *dockerWatcher) Stop() { func (w *dockerWatcher) Stop() {
w.Lock()
defer w.Unlock()
if w.stopCh == nil { if w.stopCh == nil {
return return
} }
@ -124,31 +133,22 @@ func InitFSWatcher() {
go watchFiles() go watchFiles()
} }
func InitDockerWatcher() {
// stop all docker client on watcher stop
go func() {
<-dockerWatcherStop
ParallelForEachValue(
dockerWatchMap.Iterator(),
(*dockerWatcher).Dispose,
)
dockerWatcherWg.Done()
}()
}
func StopFSWatcher() { func StopFSWatcher() {
close(fsWatcherStop) close(fsWatcherStop)
fsWatcherWg.Wait() fsWatcherWg.Wait()
} }
func StopDockerWatcher() { func StopDockerWatcher() {
close(dockerWatcherStop) ParallelForEachValue(
dockerWatcherWg.Wait() dockerWatchMap.Iterator(),
(*dockerWatcher).Dispose,
)
} }
func watchFiles() { func watchFiles() {
defer fsWatcher.Close() defer fsWatcher.Close()
defer fsWatcherWg.Done() defer fsWatcherWg.Done()
for { for {
select { select {
case <-fsWatcherStop: case <-fsWatcherStop:
@ -197,23 +197,33 @@ func (w *dockerWatcher) watch() {
w.l.Infof("container %s %s", msg.Actor.Attributes["name"], msg.Action) w.l.Infof("container %s %s", msg.Actor.Attributes["name"], msg.Action)
go w.onChange() go w.onChange()
case err := <-errChan: case err := <-errChan:
w.l.Errorf("%s, retrying in 1s", err) switch {
case client.IsErrConnectionFailed(err):
w.l.Error(NewNestedError("connection failed").Subject(w.name))
case client.IsErrNotFound(err):
w.l.Error(NewNestedError("endpoint not found").Subject(w.name))
default:
w.l.Error(NewNestedErrorFrom(err).Subject(w.name))
}
time.Sleep(1 * time.Second) time.Sleep(1 * time.Second)
msgChan, errChan = listen() msgChan, errChan = listen()
} }
} }
} }
type (
FileWatcherMap = SafeMap[string, *fileWatcher]
DockerWatcherMap = SafeMap[string, *dockerWatcher]
)
var fsWatcher *fsnotify.Watcher var fsWatcher *fsnotify.Watcher
var ( var (
fileWatchMap = NewSafeMap[string, *fileWatcher]() fileWatchMap FileWatcherMap = NewSafeMapOf[FileWatcherMap]()
dockerWatchMap = NewSafeMap[string, *dockerWatcher]() dockerWatchMap DockerWatcherMap = NewSafeMapOf[DockerWatcherMap]()
) )
var ( var (
fsWatcherStop = make(chan struct{}, 1) fsWatcherStop = make(chan struct{}, 1)
dockerWatcherStop = make(chan struct{}, 1)
) )
var ( var (
fsWatcherWg sync.WaitGroup fsWatcherWg sync.WaitGroup
dockerWatcherWg sync.WaitGroup
) )

1
templates/codemirror Submodule

@ -0,0 +1 @@
Subproject commit 0c8456c3bc92fb3085ac636f5ed117df24e22ca7

View file

@ -0,0 +1,32 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link href="/codemirror/lib/codemirror.css" rel="stylesheet" />
<link href="/codemirror/theme/dracula.css" rel="stylesheet" />
<link href="style.css" rel="stylesheet" />
<title>Config Editor</title>
</head>
<body>
<div class="container">
<div class="file-navigation">
<h3 class="navigation-header">Config Files</h3>
<ul id="file-list">
{{- range $_, $cfgFile := .}}
<li id="file-{{$cfgFile}}">
<a class="unselectable">{{$cfgFile}}</a>
</li>
{{- end}}
</ul>
</div>
<div id="config-editor"></div>
</div>
<script src="/codemirror/lib/codemirror.js"></script>
<script src="/codemirror/mode/yaml/yaml.js"></script>
<script src="/codemirror/keymap/sublime.js"></script>
<script src="/codemirror/addon/comment/comment.js"></script>
<script src="index.js" onload="onLoad()"></script>
</body>
</html>

View file

@ -0,0 +1,75 @@
let currentFile = "config.yml";
let editorElement = document.getElementById("config-editor");
let fileListElement = document.getElementById("file-list");
let editor = CodeMirror(editorElement, {
lineNumbers: true,
mode: "yaml",
theme: "dracula",
autofocus: true,
lineWiseCopyCut: true,
keyMap: "sublime",
tabSize: 2
});
function loadFile(fileName) {
if (fileName === undefined) {
return;
}
let req = new XMLHttpRequest();
req.open("GET", `/config/${fileName}`, true);
req.onreadystatechange = function () {
if (req.readyState == 4) {
if (req.status == 200) {
let old_nav_item = document.getElementById(`file-${currentFile}`);
old_nav_item.classList.remove("active");
editor.setValue(req.responseText);
currentFile = fileName;
let new_nav_item = document.getElementById(`file-${currentFile}`);
new_nav_item.classList.add("active");
document.title = `${currentFile} - Config Editor`;
console.log(`loaded ${currentFile}`);
} else {
let msg = `Failed to load ${fileName}: ` + req.responseText;
alert(msg);
console.log(msg);
}
}
};
req.send();
}
function saveFile(filename, content) {
let req = new XMLHttpRequest();
req.open("PUT", `/config/${filename}`, true);
req.setRequestHeader("Content-Type", "text/plain");
req.send(content);
req.onreadystatechange = function () {
if (req.readyState == 4) {
if (req.status == 200) {
alert("Saved " + filename);
} else {
alert("Error: " + req.responseText);
}
}
};
}
editor.setSize("100wh", "100vh");
editor.setOption("extraKeys", {
Tab: function (cm) {
const spaces = Array(cm.getOption("indentUnit") + 1).join(" ");
cm.replaceSelection(spaces);
},
"Ctrl-S": function (cm) {
saveFile(currentFile, cm.getValue());
},
});
fileListElement.addEventListener("click", function (e) {
if (e.target === null) {
return;
}
loadFile(e.target.text);
});
function onLoad() {
loadFile(currentFile);
}

View file

@ -0,0 +1,60 @@
html,
body {
height: 100%;
margin: 0;
padding: 0;
font: 14px !important;
font-family: monospace !important;
}
.container {
display: flex;
}
.navigation-header {
color: #f8f8f2 !important;
padding-left: 2em;
display: block;
}
.file-navigation {
width: 250px;
height: auto;
overflow-y: auto;
background: #282a36 !important;
}
.file-navigation ul {
list-style: none;
padding: 0;
margin: 0;
}
.file-navigation li {
padding-top: 8px;
padding-bottom: 8px;
}
.file-navigation a {
color: #f8f8f2 !important;
text-decoration: none;
padding-left: 4em;
padding-right: 4em;
display: block;
}
.active {
font-weight: bold;
background: rgba(255, 255, 255, 0.1);
}
.unselectable {
-webkit-touch-callout: none;
-webkit-user-select: none;
-khtml-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
.CodeMirror * {
font-size: 14px !important;
}
.CodeMirror pre {
padding-top: 3px;
padding-bottom: 3px;
}
#config-editor {
flex-grow: 1;
}

23
templates/index.html Normal file
View file

@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link href="style.css" rel="stylesheet" />
<title>go-proxy</title>
</head>
<body>
<script src="main.js"></script>
<div id="sidenav" class="sidenav">
<a href="javascript:void(0)" class="closebtn" onclick="closeNav()"
>&times;</a
>
<a href="#" onClick='setContent("/panel")'>Panel</a>
<a href="#" onClick='setContent("/config_editor")'>Config Editor</a>
</div>
<a class="openbtn" id="openbtn" onclick="openNav()">&equiv;</a>
<div id="main">
<iframe id="content" src="/config_editor" title="panel"></iframe>
</div>
</body>
</html>

27
templates/main.js Normal file
View file

@ -0,0 +1,27 @@
function contentIFrame() {
return document.getElementById("content");
}
function openNavBtn() {
return document.getElementById("openbtn");
}
function sideNav() {
return document.getElementById("sidenav");
}
function setContent(path) {
contentIFrame().attributes.src.value = path;
}
function openNav() {
sideNav().style.width = "250px";
contentIFrame().style.marginLeft = "250px";
openNavBtn().style.display = "none";
}
function closeNav() {
sideNav().style.width = "0";
contentIFrame().style.marginLeft = "0px";
openNavBtn().style.display = "inline-block";
}

View file

@ -1,156 +0,0 @@
<!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: #131516;
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;
}
table caption {
color: antiquewhite;
}
.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.querySelector('#url-cell').textContent;
let cell = row.querySelector('#health-cell'); // Health column cell
checkHealth(url, cell);
});
}
document.addEventListener("DOMContentLoaded", () => {
updateHealthStatus();
// Update health status every 5 seconds
setInterval(updateHealthStatus, 5000);
})
</script>
</head>
<body class="m-3">
<div class="container">
<h1 class="text-success">
Route Panel
</h1>
<div class="row">
<div class="table-responsive col-md-6">
<table class="table table-striped table-dark caption-top w-auto">
<caption>HTTP Proxies</caption>
<thead>
<tr>
<th>Alias</th>
<th>Path</th>
<th>Path Mode</th>
<th>URL</th>
<th>Health</th>
</tr>
</thead>
<tbody>
{{range $alias, $pathPoolMap := .HTTPRoutes.Iterator}}
{{range $path, $lbPool := $pathPoolMap.Iterator}}
{{range $_, $route := $lbPool.Iterator}}
<tr>
<td>{{$alias}}</td>
<td>{{$path}}</td>
<td>{{$route.PathMode}}</td>
<td id="url-cell">{{$route.Url.String}}</td>
<td class="align-middle" id="health-cell">
<div class="health-circle"></div>
</td> <!-- Health column -->
</tr>
{{end}}
{{end}}
{{end}}
</tbody>
</table>
</div>
<div class="table-responsive col-md-6">
<table class="table table-striped table-dark caption-top w-auto">
<caption>Streams</caption>
<thead>
<tr>
<th>Alias</th>
<th>Source</th>
<th>Target</th>
<th>Health</th>
</tr>
</thead>
<tbody>
{{range $_, $route := .StreamRoutes.Iterator}}
<tr>
<td>{{$route.Alias}}</td>
<td>{{$route.ListeningUrl}}</td>
<td id="url-cell">{{$route.TargetUrl}}</td>
<td class="align-middle" id="health-cell">
<div class="health-circle"></div>
</td> <!-- Health column -->
</tr>
{{end}}
</tbody>
</table>
</div>
</div>
</div>
</body>
</html>

6
templates/panel/bootstrap.min.css vendored Normal file

File diff suppressed because one or more lines are too long

79
templates/panel/index.html Executable file
View file

@ -0,0 +1,79 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link href="bootstrap.min.css" rel="stylesheet" />
<link href="style.css" rel="stylesheet" />
<title>Route Panel</title>
</head>
<body class="m-3">
<script src="index.js" defer></script>
<div class="container">
<h1 class="text-success">Route Panel</h1>
<div class="row">
<div class="table-responsive col-md-auto flex-shrink-1">
<table class="table table-striped table-dark caption-top">
<caption>
HTTP Proxies
</caption>
<thead>
<tr>
<th>Alias</th>
<th>Path</th>
<th>Path Mode</th>
<th>URL</th>
<th>Health</th>
</tr>
</thead>
<tbody>
{{range $alias, $pathPoolMap := .HTTPRoutes.Iterator}} {{range
$path, $lbPool := $pathPoolMap.Iterator}} {{range $_, $route :=
$lbPool.Iterator}}
<tr>
<td>{{$alias}}</td>
<td>{{$path}}</td>
<td>{{$route.PathMode}}</td>
<td id="url-cell">{{$route.Url.String}}</td>
<td class="align-middle" id="health-cell">
<div class="health-circle"></div>
</td>
<!-- Health column -->
</tr>
{{end}} {{end}} {{end}}
</tbody>
</table>
</div>
<div class="table-responsive col-md">
<table class="table table-striped table-dark caption-top w-auto">
<caption>
Streams
</caption>
<thead>
<tr>
<th>Alias</th>
<th>Source</th>
<th>Target</th>
<th>Health</th>
</tr>
</thead>
<tbody>
{{range $_, $route := .StreamRoutes.Iterator}}
<tr>
<td>{{$route.Alias}}</td>
<td>{{$route.ListeningUrl}}</td>
<td id="url-cell">{{$route.TargetUrl}}</td>
<td class="align-middle" id="health-cell">
<div class="health-circle"></div>
</td>
<!-- Health column -->
</tr>
{{end}}
</tbody>
</table>
</div>
</div>
</div>
</body>
</html>

34
templates/panel/index.js Normal file
View file

@ -0,0 +1,34 @@
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.querySelector("#url-cell").textContent;
let cell = row.querySelector("#health-cell"); // Health column cell
checkHealth(url, cell);
});
}
document.addEventListener("DOMContentLoaded", () => {
updateHealthStatus();
// Update health status every 5 seconds
setInterval(updateHealthStatus, 5000);
});

43
templates/panel/style.css Normal file
View file

@ -0,0 +1,43 @@
body {
background-color: #131516;
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;
}
table caption {
color: antiquewhite;
}
.health-circle {
height: 15px;
width: 15px;
background-color: #28a745;
border-radius: 50%;
margin: auto;
}

68
templates/style.css Normal file
View file

@ -0,0 +1,68 @@
html,
body {
font-family: monospace !important;
}
.sidenav {
height: 100%;
width: 0;
position: fixed;
z-index: 1;
top: 0;
left: 0;
background-color: #111;
overflow-x: hidden;
padding-top: 32px;
transition: 0.3s;
}
.sidenav a {
padding: 8px 8px 8px 24px;
text-decoration: none;
font-size: 24px;
font-weight: bold;
color: #818181;
display: block;
}
.sidenav a:hover {
color: #f1f1f1;
}
.sidenav .closebtn {
position: absolute;
top: 0;
right: 24px;
font-size: 24px;
margin-left: 42px;
}
.openbtn {
z-index: 1;
position: absolute;
top: 16;
left: 16;
font: 24px bold monospace;
color: #f8f8f2 !important;
}
#main {
transition: margin-left 0.3s;
padding: 20px;
}
#content {
transition: margin-left 0.3s;
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
width: 100%;
height: 100%;
border: none;
margin: 0;
margin-left: 0px;
padding: 0;
overflow: hidden;
z-index: 0;
}