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

11
.gitignore vendored
View file

@ -1,7 +1,10 @@
compose.yml
go-proxy.yml
config.yml
providers.yml
config/**
bin/go-proxy.bak
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
COPY bin/go-proxy entrypoint.sh /app/
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
ENV DOCKER_HOST unix:///var/run/docker.sock

231
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
- [Key Points](#key-points)
- [How to use](#how-to-use)
- [Binary](#binary)
- [Docker](#docker)
- [Configuration](#configuration)
- [Labels](#labels)
- [Environment Variables](#environment-variables)
- [Config File](#config-file)
- [Provider File](#provider-file)
- [Supported Cert Providers](#supported-cert-providers)
- [Examples](#examples)
- [Single Port Configuration](#single-port-configuration-example)
- [Multiple Ports Configuration](#multiple-ports-configuration-example)
- [TCP/UDP Configuration](#tcpudp-configuration-example)
- [Load balancing Configuration](#load-balancing-configuration-example)
- [Troubleshooting](#troubleshooting)
- [Benchmarks](#benchmarks)
- [Memory usage](#memory-usage)
- [Build it yourself](#build-it-yourself)
- [go-proxy](#go-proxy)
- [Table of content](#table-of-content)
- [Key Points](#key-points)
- [How to use](#how-to-use)
- [Binary](#binary)
- [Docker](#docker)
- [Use JSON Schema in VSCode](#use-json-schema-in-vscode)
- [Configuration](#configuration)
- [Labels (docker)](#labels-docker)
- [Environment variables](#environment-variables)
- [Config File](#config-file)
- [Fields](#fields)
- [Provider Kinds](#provider-kinds)
- [Provider File](#provider-file)
- [Supported DNS Challenge Providers](#supported-dns-challenge-providers)
- [Examples](#examples)
- [Single port configuration example](#single-port-configuration-example)
- [Multiple ports configuration example](#multiple-ports-configuration-example)
- [TCP/UDP configuration example](#tcpudp-configuration-example)
- [Load balancing Configuration Example](#load-balancing-configuration-example)
- [Troubleshooting](#troubleshooting)
- [Benchmarks](#benchmarks)
- [Known issues](#known-issues)
- [Memory usage](#memory-usage)
- [Build it yourself](#build-it-yourself)
## Key Points
- fast, nearly no performance penalty for end users when comparing to direct IP connections (See [benchmarks](#benchmarks))
- auto detect reverse proxies from docker
- additional reverse proxies from provider yaml file
- allow multiple docker / file providers by custom `config.yml` file
- auto certificate obtaining and renewal (See [Config File](#config-file) and [Supported Cert Providers](#supported-cert-providers))
- subdomain matching **(domain name doesn't matter)**
- path matching
- HTTP proxy
- TCP/UDP Proxy
- 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)
- Fast (See [benchmarks](#benchmarks))
- Auto certificate obtaining and renewal (See [Config File](#config-file) and [Supported DNS Challenge Providers](#supported-dns-challenge-providers))
- Auto detect reverse proxies from docker
- Custom proxy entries with `config.yml` and additional provider files
- Subdomain matching + Path matching **(domain name doesn't matter)**
- HTTP(s) proxy + TCP/UDP Proxy
- HTTP(s) round robin load balance support (same subdomain and path across different hosts)
- Auto hot-reload on container `start` / `die` / `stop` or config file changes
- Simple panel to see all reverse proxies and health available on port [panel_port_http] (http) and port [panel_port_https] (https)
![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
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)
@ -83,17 +89,19 @@ In the examples domain `x.y.z` is used, replace them with your domain
- Use autocert feature
1. mount `./certs` to `/app/certs`
```yaml
go-proxy:
...
volumes:
- ./certs:/app/certs
```
```yaml
go-proxy:
...
volumes:
- ./certs:/app/certs
```
2. complete `autocert` in `config.yml`
- 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`
- 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
## 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
With container name, most of the time no label needs to be added.
### Labels
### Labels (docker)
- `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
- allowed: \<empty>, forward, sub
- empty: remove path prefix from URL when proxying
- default: `container_name`
- `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
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
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
e.g. apps.y.z/app1 -> webdav:80, `href="/path/to/file"` -> `href="/app1/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
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`
### Environment variables
@ -164,22 +194,45 @@ With container name, most of the time no label needs to be added.
### 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
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
```yaml
autocert:
...
options:
auth_token: "YOUR_ZONE_API_TOKEN"
```
- `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
@ -315,7 +368,7 @@ Local benchmark (client running wrk and `go-proxy` server are under same proxmox
- Direct connection
```
```shell
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
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
```
```shell
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
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`
```
```shell
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
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
```
## Known issues
None
## Memory usage
It takes ~13 MB for 50 proxy entries
## 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)
- /var/run/docker.sock:/var/run/docker.sock:ro
# to use custom config
# - path/to/config.yml:/app/config.yml
# mount file provider yaml files
# - path/to/provider1.yml:/app/provider1.yml
# - path/to/provider2.yml:/app/provider2.yml
# etc.
# to use custom config and providers
# - ./config:/app/config
dns:
- 127.0.0.1 # workaround for "lookup: no such host"
extra_hosts:

View file

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

5
go.mod
View file

@ -1,12 +1,13 @@
module github.com/yusing/go-proxy
go 1.21.7
go 1.22
require (
github.com/docker/cli v26.0.0+incompatible
github.com/docker/docker v26.0.0+incompatible
github.com/fsnotify/fsnotify v1.7.0
github.com/go-acme/lego/v4 v4.16.1
github.com/santhosh-tekuri/jsonschema v1.2.4
github.com/sirupsen/logrus v1.9.3
golang.org/x/net v0.22.0
gopkg.in/yaml.v3 v3.0.1
@ -14,7 +15,7 @@ require (
require (
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/containerd/log v0.1.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/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.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/go.mod h1:wYW/5UP02TUfBToa/yKbQHV+r6h1NnJ1Je7XjuGM4Jw=
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/rogpeppe/go-internal v1.8.1 h1:geMPLpDpQOgVyCg5z5GoRwLHepNdb71NXb67XFkP+Eg=
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.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
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.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
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.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
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`
# optional
scheme: http
example: # matching `app.y.z`
# optional, defaults to http
scheme:
# required, proxy target
host: 10.0.0.1
# optional
# optional, defaults to 80 for http, 443 for https
port: 80
# optional, defaults to empty
path:
# optional
# optional, defaults to sub
path_mode:
# optional
notlsverify: false
# app2:
# ...
# optional (https only)
# no_tls_verify: false
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/tls"
"crypto/x509"
"fmt"
"os"
"path"
"sync"
@ -26,10 +25,10 @@ type ProviderGenerator = func(ProviderOptions) (challenge.Provider, error)
type CertExpiries = map[string]time.Time
type AutoCertConfig struct {
Email string
Domains []string `yaml:",flow"`
Provider string
Options ProviderOptions `yaml:",flow"`
Email string `json:"email"`
Domains []string `yaml:",flow" json:"domains"`
Provider string `json:"provider"`
Options ProviderOptions `yaml:",flow" json:"options"`
}
type AutoCertUser struct {
@ -53,25 +52,35 @@ type AutoCertProvider interface {
GetName() string
GetExpiries() CertExpiries
LoadCert() bool
ObtainCert() error
ObtainCert() NestedErrorLike
RenewalOn() time.Time
ScheduleRenewal()
}
func (cfg AutoCertConfig) GetProvider() (AutoCertProvider, error) {
ne := NewNestedError("invalid autocert config")
if len(cfg.Domains) == 0 {
return nil, fmt.Errorf("no domains specified")
ne.Extra("no domains specified")
}
if cfg.Provider == "" {
return nil, fmt.Errorf("no provider specified")
ne.Extra("no provider specified")
}
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)
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{
Email: cfg.Email,
@ -81,7 +90,7 @@ func (cfg AutoCertConfig) GetProvider() (AutoCertProvider, error) {
legoCfg.Certificate.KeyType = certcrypto.RSA2048
legoClient, err := lego.NewClient(legoCfg)
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{
name: cfg.Provider,
@ -90,17 +99,13 @@ func (cfg AutoCertConfig) GetProvider() (AutoCertProvider, error) {
legoCfg: legoCfg,
client: legoClient,
}
gen, ok := providersGenMap[cfg.Provider]
if !ok {
return nil, fmt.Errorf("unknown provider: %s", cfg.Provider)
}
legoProvider, err := gen(cfg.Options)
if err != nil {
return nil, fmt.Errorf("unable to create provider: %v", err)
return nil, ne.With(err)
}
err = legoClient.Challenge.SetDNS01Provider(legoProvider)
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
}
@ -119,7 +124,7 @@ type autoCertProvider struct {
func (p *autoCertProvider) GetCert(_ *tls.ClientHelloInfo) (*tls.Certificate, error) {
if p.tlsCert == nil {
aclog.Fatal("no certificate available")
return nil, NewNestedError("no certificate available")
}
return p.tlsCert, nil
}
@ -132,12 +137,14 @@ func (p *autoCertProvider) GetExpiries() CertExpiries {
return p.certExpiries
}
func (p *autoCertProvider) ObtainCert() error {
func (p *autoCertProvider) ObtainCert() NestedErrorLike {
ne := NewNestedError("failed to obtain certificate")
client := p.client
if p.user.Registration == nil {
reg, err := client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true})
if err != nil {
return err
return ne.With(NewNestedError("failed to register account").With(err))
}
p.user.Registration = reg
}
@ -147,19 +154,19 @@ func (p *autoCertProvider) ObtainCert() error {
}
cert, err := client.Certificate.Obtain(req)
if err != nil {
return err
return ne.With(err)
}
err = p.saveCert(cert)
if err != nil {
return err
return ne.With(NewNestedError("failed to save certificate").With(err))
}
tlsCert, err := tls.X509KeyPair(cert.Certificate, cert.PrivateKey)
if err != nil {
return err
return ne.With(NewNestedError("failed to parse obtained certificate").With(err))
}
expiries, err := getCertExpiries(&tlsCert)
if err != nil {
return err
return ne.With(NewNestedError("failed to get certificate expiry").With(err))
}
p.tlsCert = &tlsCert
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)
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-------
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--
if err != nil {
return fmt.Errorf("unable to write cert file: %v", err)
return NewNestedError("unable to write cert file").With(err)
}
return nil
}
@ -225,18 +232,18 @@ func (p *autoCertProvider) needRenewal() bool {
return time.Now().After(p.RenewalOn())
}
func (p *autoCertProvider) renewIfNeeded() error {
func (p *autoCertProvider) renewIfNeeded() NestedErrorLike {
if !p.needRenewal() {
return nil
}
p.mutex.Lock()
defer p.mutex.Unlock()
if !p.needRenewal() {
return nil
}
trials := 0
for {
err := p.ObtainCert()
@ -245,14 +252,17 @@ func (p *autoCertProvider) renewIfNeeded() error {
}
trials++
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)
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) {
cfg := defaultCfg()
err := setOptions(cfg, opt)
@ -272,7 +282,7 @@ func getCertExpiries(cert *tls.Certificate) (CertExpiries, error) {
for _, cert := range cert.Certificate {
x509Cert, err := x509.ParseCertificate(cert)
if err != nil {
return nil, err
return nil, NewNestedError("unable to parse certificate").With(err)
}
if x509Cert.IsCA {
continue
@ -284,9 +294,9 @@ func getCertExpiries(cert *tls.Certificate) (CertExpiries, error) {
func setOptions[T interface{}](cfg *T, opt ProviderOptions) error {
for k, v := range opt {
err := SetFieldFromSnake(cfg, k, v)
err := setFieldFromSnake(cfg, k, v)
if err != nil {
return err
return NewNestedError("unable to set option").Subject(k).With(err)
}
}
return nil

View file

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

View file

@ -7,6 +7,7 @@ import (
"os"
"time"
"github.com/santhosh-tekuri/jsonschema"
"github.com/sirupsen/logrus"
)
@ -80,6 +81,19 @@ var (
clone.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
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.*."
@ -87,13 +101,43 @@ const wildcardLabelPrefix = "proxy.*."
const clientUrlFromEnv = "FROM_ENV"
const (
certFileDefault = "certs/cert.crt"
keyFileDefault = "certs/priv.key"
configPath = "config.yml"
templatePath = "templates/panel.html"
certBasePath = "certs/"
certFileDefault = certBasePath + "cert.crt"
keyFileDefault = certBasePath + "priv.key"
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
@ -105,4 +149,4 @@ var logLevel = func() logrus.Level {
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 {
if strings.HasPrefix(label, prefix) {
field := strings.TrimPrefix(label, prefix)
SetFieldFromSnake(c, field, value)
if err := setFieldFromSnake(c, field, value); err != nil {
return err
}
}
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
cfgs := make([]*ProxyConfig, 0)
cfgs := make(ProxyConfigSlice, 0)
container_name := strings.TrimPrefix(container.Names[0], "/")
aliases_label, ok := container.Labels["proxy.aliases"]
containerName := strings.TrimPrefix(container.Names[0], "/")
aliasesLabel, ok := container.Labels["proxy.aliases"]
if !ok {
aliases = []string{container_name}
aliases = []string{containerName}
} else {
aliases = strings.Split(aliases_label, ",")
aliases = strings.Split(aliasesLabel, ",")
}
isRemote := clientIP != ""
ne := NewNestedError("invalid label config").Subjectf("container %s", containerName)
defer func() {
if ne.HasExtras() {
p.l.Error(ne)
}
}()
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)
prefix := fmt.Sprintf("proxy.%s.", alias)
for label, value := range container.Labels {
err := p.setConfigField(&config, label, value, prefix)
if err != nil {
l.Error(err)
ne.ExtraError(NewNestedErrorFrom(err).Subjectf("alias %s", alias))
}
err = p.setConfigField(&config, label, value, wildcardLabelPrefix)
if err != nil {
l.Error(err)
ne.ExtraError(NewNestedErrorFrom(err).Subjectf("alias %s", alias))
}
}
if config.Port == "" {
config.Port = fmt.Sprintf("%d", selectPort(container))
}
if config.Port == "0" {
// no ports exposed or specified
l.Debugf("no ports exposed, ignored")
continue
}
@ -102,11 +110,11 @@ func (p *Provider) getContainerProxyConfigs(container types.Container, clientIP
}
}
if config.Host == "" {
config.Host = container_name
config.Host = containerName
}
config.Alias = alias
cfgs = append(cfgs, &config)
cfgs = append(cfgs, config)
}
return cfgs
}
@ -145,7 +153,7 @@ func (p *Provider) getDockerClient() (*client.Client, error) {
return client.NewClientWithOpts(dockerOpts...)
}
func (p *Provider) getDockerProxyConfigs() ([]*ProxyConfig, error) {
func (p *Provider) getDockerProxyConfigs() (ProxyConfigSlice, error) {
var clientIP string
if p.Value == clientUrlFromEnv {
@ -153,7 +161,7 @@ func (p *Provider) getDockerProxyConfigs() ([]*ProxyConfig, error) {
} else {
url, err := client.ParseHostURL(p.Value)
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]
}
@ -161,17 +169,17 @@ func (p *Provider) getDockerProxyConfigs() ([]*ProxyConfig, error) {
dockerClient, err := p.getDockerClient()
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)
containerSlice, err := dockerClient.ContainerList(ctx, container.ListOptions{All: true})
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 {
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
import (
"fmt"
"os"
"path"
"gopkg.in/yaml.v3"
)
func (p *Provider) getFileProxyConfigs() ([]*ProxyConfig, error) {
path := p.Value
if _, err := os.Stat(path); err == nil {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("unable to read config file %q: %v", path, err)
}
configMap := make(map[string]ProxyConfig, 0)
configs := make([]*ProxyConfig, 0)
err = yaml.Unmarshal(data, &configMap)
if err != nil {
return nil, fmt.Errorf("unable to parse config file %q: %v", path, err)
}
for alias, cfg := range configMap {
cfg.Alias = alias
err = cfg.SetDefaults()
if err != nil {
return nil, err
}
configs = append(configs, &cfg)
}
return configs, nil
} else if !os.IsNotExist(err) {
return nil, fmt.Errorf("file not found: %s", path)
} else {
return nil, err
}
func (p *Provider) GetFilePath() string {
return path.Join(configBasePath, p.Value)
}
func (p *Provider) ValidateFile() (ProxyConfigSlice, error) {
path := p.GetFilePath()
data, err := os.ReadFile(path)
if err != nil {
return nil, NewNestedError("unable to read providers file").Subject(path).With(err)
}
result, err := ValidateFileContent(data)
if err != nil {
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 {
cfg.Alias = alias
if err := cfg.SetDefaults(); err != nil {
ne.ExtraError(err)
} else {
configs[i] = cfg
}
i++
}
if err := validateYaml(providersSchema, data); err != nil {
ne.ExtraError(err)
}
if ne.HasExtras() {
return nil, ne
}
return configs, nil
}

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) {
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 {
return nil, err
return nil, NewNestedErrorf("invalid url").Subject(u).With(err)
}
var tr *http.Transport
@ -35,10 +36,6 @@ func NewHTTPRoute(config *ProxyConfig) (*HTTPRoute, error) {
proxy := NewSingleHostReverseProxy(url, tr)
if !isValidProxyPathMode(config.PathMode) {
return nil, fmt.Errorf("invalid path mode: %s", config.PathMode)
}
route := &HTTPRoute{
Alias: config.Alias,
Url: url,
@ -59,6 +56,11 @@ func NewHTTPRoute(config *ProxyConfig) (*HTTPRoute, error) {
switch {
case config.Path == "", config.PathMode == ProxyPathMode_Forward:
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:
rewrite = func(pr *ProxyRequest) {
rewriteBegin(pr)
@ -67,37 +69,9 @@ func NewHTTPRoute(config *ProxyConfig) (*HTTPRoute, error) {
// remove path prefix
pr.Out.URL.Path = strings.TrimPrefix(pr.Out.URL.Path, config.Path)
}
modifyResponse = func(r *http.Response) error {
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:
route.l.Debug("unknown content type(s): ", contentType)
}
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
}
modifyResponse = config.pathSubModResp
default:
rewrite = func(pr *ProxyRequest) {
rewriteBegin(pr)
pr.Out.URL.Path = strings.TrimPrefix(pr.Out.URL.Path, config.Path)
}
return nil, NewNestedError("invalid path mode").Subject(config.PathMode)
}
if logLevel == logrus.DebugLevel {
@ -121,20 +95,13 @@ func NewHTTPRoute(config *ProxyConfig) (*HTTPRoute, error) {
return route, nil
}
func (r *HTTPRoute) Start() {}
func (r *HTTPRoute) Start() {
// dummy
}
func (r *HTTPRoute) Stop() {
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) {
// Redirect to the same host but with HTTPS
var redirectCode int
@ -152,26 +119,44 @@ func findHTTPRoute(host string, path string) (*HTTPRoute, error) {
if ok {
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) {
route, err := findHTTPRoute(r.Host, r.URL.Path)
if err != nil {
err = fmt.Errorf("request failed %s %s%s, error: %v",
r.Method,
r.Host,
r.URL.Path,
err,
)
http.Error(w, "404 Not Found", http.StatusNotFound)
err = NewNestedError("request failed").
Subjectf("%s %s%s", r.Method, r.Host, r.URL.Path).
With(err)
logrus.Error(err)
http.Error(w, err.Error(), http.StatusNotFound)
return
}
route.Proxy.ServeHTTP(w, r)
}
// alias -> (path -> routes)
type HTTPRoutes = SafeMap[string, *pathPoolMap]
func (config *ProxyConfig) pathSubModResp(r *http.Response) error {
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)

View file

@ -474,7 +474,7 @@ func (p *ReverseProxy) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
rw.WriteHeader(res.StatusCode)
// NOTE: changing this line extremely improve throughput
// NOTE: changing this line extremely improve throughput
// err = p.copyResponse(rw, res.Body, p.flushInterval(res))
_, err = io.Copy(rw, res.Body)
if err != nil {

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

View file

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

View file

@ -1,87 +1,53 @@
package main
import (
"errors"
"html/template"
"net"
"net/http"
"net/url"
"time"
"os"
"path"
)
var 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,
},
var panelHandler = panelRouter()
func panelRouter() *http.ServeMux {
mux := http.NewServeMux()
mux.HandleFunc("GET /{$}", panelServeFile)
mux.HandleFunc("GET /{file}", panelServeFile)
mux.HandleFunc("GET /panel/", panelPage)
mux.HandleFunc("GET /panel/{file}", panelServeFile)
mux.HandleFunc("HEAD /checkhealth", panelCheckTargetHealth)
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) {
switch r.URL.Path {
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 {
func panelPage(w http.ResponseWriter, r *http.Request) {
resp := struct {
HTTPRoutes HTTPRoutes
StreamRoutes StreamRoutes
}
}{httpRoutes, streamRoutes}
err = tmpl.Execute(w, allRoutes{
HTTPRoutes: httpRoutes,
StreamRoutes: streamRoutes,
})
if err != nil {
palog.Error(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
}
panelRenderFile(w, r, panelTemplatePath, resp)
}
func panelCheckTargetHealth(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodHead {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
targetUrl := r.URL.Query().Get("target")
if targetUrl == "" {
http.Error(w, "target is required", http.StatusBadRequest)
panelHandleErr(w, r, errors.New("target is required"), http.StatusBadRequest)
return
}
url, err := url.Parse(targetUrl)
if err != nil {
palog.Infof("failed to parse url %q, error: %v", targetUrl, err)
http.Error(w, err.Error(), http.StatusBadRequest)
err = NewNestedError("failed to parse url").Subject(targetUrl).With(err)
panelHandleErr(w, r, err, http.StatusBadRequest)
return
}
scheme := url.Scheme
@ -98,3 +64,81 @@ func panelCheckTargetHealth(w http.ResponseWriter, r *http.Request) {
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
import (
"fmt"
"strings"
)
@ -9,10 +8,8 @@ type pathPoolMap struct {
SafeMap[string, *httpLoadBalancePool]
}
func newPathPoolMap() *pathPoolMap {
return &pathPoolMap{
NewSafeMap[string](NewHTTPLoadBalancePool),
}
func newPathPoolMap() pathPoolMap {
return pathPoolMap{NewSafeMapOf[pathPoolMap](NewHTTPLoadBalancePool)}
}
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)
}
func (m pathPoolMap) FindMatch(pathGot string) (*HTTPRoute, error) {
func (m pathPoolMap) FindMatch(pathGot string) (*HTTPRoute, NestedErrorLike) {
for pathWant, v := range m.Iterator() {
if strings.HasPrefix(pathGot, pathWant) {
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
import (
"fmt"
"sync"
"github.com/sirupsen/logrus"
)
type Provider struct {
Kind string // docker, file
Value string
Kind string `json:"kind"` // docker, file
Value string `json:"value"`
watcher Watcher
routes map[string]Route // id -> Route
@ -20,12 +19,12 @@ type Provider struct {
// Init is called after LoadProxyConfig
func (p *Provider) Init(name string) error {
p.l = prlog.WithFields(logrus.Fields{"kind": p.Kind, "name": name})
defer p.initWatcher()
if err := p.loadProxyConfig(); err != nil {
return err
}
p.initWatcher()
return nil
}
@ -37,7 +36,7 @@ func (p *Provider) StartAllRoutes() {
func (p *Provider) StopAllRoutes() {
p.watcher.Stop()
ParallelForEachValue(p.routes, Route.Stop)
p.routes = make(map[string]Route)
p.routes = nil
}
func (p *Provider) ReloadRoutes() {
@ -54,17 +53,17 @@ func (p *Provider) ReloadRoutes() {
}
func (p *Provider) loadProxyConfig() error {
var cfgs []*ProxyConfig
var cfgs ProxyConfigSlice
var err error
switch p.Kind {
case ProviderKind_Docker:
cfgs, err = p.getDockerProxyConfigs()
case ProviderKind_File:
cfgs, err = p.getFileProxyConfigs()
cfgs, err = p.ValidateFile()
default:
// this line should never be reached
return fmt.Errorf("unknown provider kind")
return NewNestedError("unknown provider kind")
}
if err != nil {
@ -73,29 +72,34 @@ func (p *Provider) loadProxyConfig() error {
p.l.Infof("loaded %d proxy configurations", len(cfgs))
p.routes = make(map[string]Route, len(cfgs))
pErrs := NewNestedError("failed to create these routes")
for _, cfg := range cfgs {
r, err := NewRoute(cfg)
r, err := NewRoute(&cfg)
if err != nil {
p.l.Errorf("error creating route %s: %v", cfg.Alias, err)
pErrs.ExtraError(NewNestedErrorFrom(err).Subject(cfg.Alias))
continue
}
p.routes[cfg.GetID()] = r
}
if pErrs.HasExtras() {
p.routes = nil
return pErrs
}
return nil
}
func (p *Provider) initWatcher() error {
switch p.Kind {
case ProviderKind_Docker:
var err error
dockerClient, err := p.getDockerClient()
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)
case ProviderKind_File:
p.watcher = NewFileWatcher(p.Value, p.ReloadRoutes, p.StopAllRoutes)
p.watcher = NewFileWatcher(p.GetFilePath(), p.ReloadRoutes, p.StopAllRoutes)
}
return nil
}

View file

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

View file

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

View file

@ -2,6 +2,7 @@ package main
import (
"crypto/tls"
"log"
"net/http"
"time"
@ -20,35 +21,66 @@ type Server struct {
httpsStarted bool
}
func NewServer(name string, provider AutoCertProvider, httpAddr string, httpHandler http.Handler, httpsAddr string, httpsHandler http.Handler) *Server {
if provider != nil {
type ServerOptions struct {
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{
Name: name,
CertProvider: provider,
Name: opt.Name,
CertProvider: opt.CertProvider,
http: &http.Server{
Addr: httpAddr,
Handler: httpHandler,
Addr: opt.HTTPAddr,
Handler: httpHandler,
ErrorLog: logger,
},
https: &http.Server{
Addr: httpsAddr,
Handler: httpsHandler,
Addr: opt.HTTPSAddr,
Handler: opt.Handler,
ErrorLog: logger,
TLSConfig: &tls.Config{
GetCertificate: provider.GetCert,
GetCertificate: opt.CertProvider.GetCert,
},
},
}
}
return &Server{
Name: name,
Name: opt.Name,
KeyFile: keyFileDefault,
CertFile: certFileDefault,
http: &http.Server{
Addr: httpAddr,
Handler: httpHandler,
Addr: opt.HTTPAddr,
Handler: httpHandler,
ErrorLog: logger,
},
https: &http.Server{
Addr: httpsAddr,
Handler: httpsHandler,
Addr: opt.HTTPSAddr,
Handler: opt.Handler,
ErrorLog: logger,
},
}
}

View file

@ -1,7 +1,6 @@
package main
import (
"errors"
"fmt"
"strconv"
"strings"
@ -11,16 +10,18 @@ import (
"github.com/sirupsen/logrus"
)
type StreamImpl interface {
Setup() error
Accept() (interface{}, error)
Handle(interface{}) error
CloseListeners()
}
type StreamRoute interface {
Route
ListeningUrl() string
TargetUrl() string
Logger() logrus.FieldLogger
closeListeners()
closeChannel()
unmarkPort()
wait()
}
type StreamRouteBase struct {
@ -32,10 +33,14 @@ type StreamRouteBase struct {
TargetHost string
TargetPort int
id string
wg sync.WaitGroup
stopChann chan struct{}
l logrus.FieldLogger
id string
wg sync.WaitGroup
stopCh chan struct{}
connCh chan interface{}
started bool
l logrus.FieldLogger
StreamImpl
}
func newStreamRouteBase(config *ProxyConfig) (*StreamRouteBase, error) {
@ -45,14 +50,14 @@ func newStreamRouteBase(config *ProxyConfig) (*StreamRouteBase, error) {
var srcScheme string
var dstScheme string
port_split := strings.Split(config.Port, ":")
if len(port_split) != 2 {
portSplit := strings.Split(config.Port, ":")
if len(portSplit) != 2 {
cfgl.Warnf("invalid port %s, assuming it is target port", config.Port)
srcPort = "0"
dstPort = config.Port
} else {
srcPort = port_split[0]
dstPort = port_split[1]
srcPort = portSplit[0]
dstPort = portSplit[1]
}
if port, hasName := NamePortMap[dstPort]; hasName {
@ -61,25 +66,20 @@ func newStreamRouteBase(config *ProxyConfig) (*StreamRouteBase, error) {
srcPortInt, err := strconv.Atoi(srcPort)
if err != nil {
return nil, fmt.Errorf(
"invalid stream source port %s, ignoring", srcPort,
)
return nil, NewNestedError("invalid stream source port").Subject(srcPort)
}
utils.markPortInUse(srcPortInt)
dstPortInt, err := strconv.Atoi(dstPort)
if err != nil {
return nil, fmt.Errorf(
"invalid stream target port %s, ignoring", dstPort,
)
return nil, NewNestedError("invalid stream target port").Subject(dstPort)
}
scheme_split := strings.Split(config.Scheme, ":")
if len(scheme_split) == 2 {
srcScheme = scheme_split[0]
dstScheme = scheme_split[1]
schemeSplit := strings.Split(config.Scheme, ":")
if len(schemeSplit) == 2 {
srcScheme = schemeSplit[0]
dstScheme = schemeSplit[1]
} else {
srcScheme = config.Scheme
dstScheme = config.Scheme
@ -94,9 +94,11 @@ func newStreamRouteBase(config *ProxyConfig) (*StreamRouteBase, error) {
TargetHost: config.Host,
TargetPort: dstPortInt,
id: config.GetID(),
wg: sync.WaitGroup{},
stopChann: make(chan struct{}, 1),
id: config.GetID(),
wg: sync.WaitGroup{},
stopCh: make(chan struct{}, 1),
connCh: make(chan interface{}),
started: false,
l: srlog.WithFields(logrus.Fields{
"alias": config.Alias,
"src": fmt.Sprintf("%s://:%d", srcScheme, srcPortInt),
@ -112,7 +114,7 @@ func NewStreamRoute(config *ProxyConfig) (StreamRoute, error) {
case StreamType_UDP:
return NewUDPRoute(config)
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
}
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 {
freePort, err := utils.findUseFreePort(20000)
if err != nil {
@ -142,40 +182,43 @@ func (route *StreamRouteBase) setupListen() {
route.l.Info("listening on ", route.ListeningUrl())
}
func (route *StreamRouteBase) wait() {
route.wg.Wait()
}
func (route *StreamRouteBase) grAcceptConnections() {
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()
}()
select {
case <-done:
l.Info("stopped listening")
case <-time.After(StreamStopListenTimeout):
l.Error("timed out waiting for connections")
for {
select {
case <-route.stopCh:
return
default:
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 (
"context"
"fmt"
"io"
"net"
"sync"
"time"
)
const tcpDialTimeout = 5 * time.Second
type Pipes []*BidirectionalPipe
type TCPRoute struct {
*StreamRouteBase
listener net.Listener
connChan chan net.Conn
}
func NewTCPRoute(config *ProxyConfig) (StreamRoute, error) {
base, err := newStreamRouteBase(config)
if err != nil {
return nil, err
return nil, NewNestedErrorFrom(err).Subject(config.Alias)
}
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,
listener: nil,
connChan: make(chan net.Conn),
}, nil
}
return base, nil
}
func (route *TCPRoute) Start() {
route.setupListen()
func (route *TCPRoute) Setup() error {
in, err := net.Listen("tcp", fmt.Sprintf(":%v", route.ListeningPort))
if err != nil {
route.l.Error(err)
return
return err
}
route.listener = in
route.wg.Add(2)
go route.grAcceptConnections()
go route.grHandleConnections()
return nil
}
func (route *TCPRoute) Stop() {
stopListening(route)
streamRoutes.Delete(route.id)
func (route *TCPRoute) Accept() (interface{}, error) {
return route.listener.Accept()
}
func (route *TCPRoute) closeListeners() {
if route.listener == nil {
return
}
route.listener.Close()
route.listener = nil
}
func (route *TCPRoute) HandleConnection(c interface{}) error {
clientConn := c.(net.Conn)
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 route.wg.Done()
@ -99,34 +55,28 @@ func (route *TCPRoute) grHandleConnection(clientConn net.Conn) {
serverAddr := fmt.Sprintf("%s:%v", route.TargetHost, route.TargetPort)
dialer := &net.Dialer{}
serverConn, err := dialer.DialContext(ctx, route.TargetScheme, serverAddr)
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
}
route.tcpPipe(clientConn, serverConn)
}
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()
route.listener.Close()
route.listener = nil
}

View file

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

View file

@ -2,6 +2,7 @@ package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net"
@ -13,10 +14,11 @@ import (
"regexp"
"strings"
"sync"
"time"
"github.com/santhosh-tekuri/jsonschema"
"github.com/sirupsen/logrus"
xhtml "golang.org/x/net/html"
"gopkg.in/yaml.v3"
)
type Utils struct {
@ -52,7 +54,7 @@ func (u *Utils) findUseFreePort(startingPort int) (int, error) {
l.Close()
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) {
@ -84,7 +86,7 @@ func (*Utils) healthCheckHttp(targetUrl 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 {
return err
}
@ -194,12 +196,37 @@ func (*Utils) fileOK(path string) bool {
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)
prop := reflect.ValueOf(obj).Elem().FieldByName(field)
if prop.Kind() == 0 {
return fmt.Errorf("unknown field %s", field)
return NewNestedError("unknown field").Subject(field)
}
prop.Set(reflect.ValueOf(value))
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
onChange func()
l logrus.FieldLogger
sync.Mutex
}
type fileWatcher struct {
@ -66,6 +67,8 @@ func NewDockerWatcher(c *client.Client, onChange func()) Watcher {
}
func (w *fileWatcher) Start() {
w.Lock()
defer w.Unlock()
if fsWatcher == nil {
return
}
@ -78,6 +81,8 @@ func (w *fileWatcher) Start() {
}
func (w *fileWatcher) Stop() {
w.Lock()
defer w.Unlock()
if fsWatcher == nil {
return
}
@ -93,12 +98,16 @@ func (w *fileWatcher) Dispose() {
}
func (w *dockerWatcher) Start() {
w.Lock()
defer w.Unlock()
dockerWatchMap.Set(w.name, w)
w.wg.Add(1)
go w.watch()
}
func (w *dockerWatcher) Stop() {
w.Lock()
defer w.Unlock()
if w.stopCh == nil {
return
}
@ -124,31 +133,22 @@ func InitFSWatcher() {
go watchFiles()
}
func InitDockerWatcher() {
// stop all docker client on watcher stop
go func() {
<-dockerWatcherStop
ParallelForEachValue(
dockerWatchMap.Iterator(),
(*dockerWatcher).Dispose,
)
dockerWatcherWg.Done()
}()
}
func StopFSWatcher() {
close(fsWatcherStop)
fsWatcherWg.Wait()
}
func StopDockerWatcher() {
close(dockerWatcherStop)
dockerWatcherWg.Wait()
ParallelForEachValue(
dockerWatchMap.Iterator(),
(*dockerWatcher).Dispose,
)
}
func watchFiles() {
defer fsWatcher.Close()
defer fsWatcherWg.Done()
for {
select {
case <-fsWatcherStop:
@ -197,23 +197,33 @@ func (w *dockerWatcher) watch() {
w.l.Infof("container %s %s", msg.Actor.Attributes["name"], msg.Action)
go w.onChange()
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)
msgChan, errChan = listen()
}
}
}
type (
FileWatcherMap = SafeMap[string, *fileWatcher]
DockerWatcherMap = SafeMap[string, *dockerWatcher]
)
var fsWatcher *fsnotify.Watcher
var (
fileWatchMap = NewSafeMap[string, *fileWatcher]()
dockerWatchMap = NewSafeMap[string, *dockerWatcher]()
fileWatchMap FileWatcherMap = NewSafeMapOf[FileWatcherMap]()
dockerWatchMap DockerWatcherMap = NewSafeMapOf[DockerWatcherMap]()
)
var (
fsWatcherStop = make(chan struct{}, 1)
dockerWatcherStop = make(chan struct{}, 1)
fsWatcherStop = make(chan struct{}, 1)
)
var (
fsWatcherWg sync.WaitGroup
dockerWatcherWg sync.WaitGroup
fsWatcherWg 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;
}