Compare commits

..

34 Commits

Author SHA1 Message Date
yusing
d20e4d435a verify -> validate 2024-03-29 01:50:00 +00:00
yusing
15d9436d52 readme update 2024-03-29 01:47:13 +00:00
yusing
ca98b31458 fix default config value 2024-03-29 01:38:58 +00:00
yusing
77f957c7a8 makefile update 2024-03-29 01:31:51 +00:00
yusing
51493c9fdd makefile update 2024-03-29 01:28:44 +00:00
yusing
9b34dc994d added new file button in config editor, dockerfile fix 2024-03-29 01:24:47 +00:00
yusing
6bc4c1c49a fixed http redirect to https when no cert available 2024-03-28 05:59:25 +00:00
yusing
443dd99b5b readme update 2024-03-27 07:09:05 +00:00
yusing
db6f857aaf readme update 2024-03-27 07:05:11 +00:00
yusing
6a54fc85ac typos fix and url update for schema 2024-03-27 06:33:02 +00:00
yusing
90f4aac946 fixes, meaningful error messages and new features 2024-03-27 06:30:47 +00:00
yusing
539ef911de fix negative waitgroup, fix cert expiry date, better auto renewal strategy 2024-03-23 20:06:34 +00:00
yusing
fff790b527 readme update 2024-03-23 03:46:18 +00:00
yusing
094f75ef46 readme and dockerfile fix for v0.3 update 2024-03-23 03:29:35 +00:00
yusing
43ecd80687 added mkdir before saving cert 2024-03-23 03:16:27 +00:00
yusing
e7f6abf027 initial autocert support, readme update 2024-03-23 03:05:41 +00:00
yusing
22f911c30f entrypoint fix for debugging and readme update 2024-03-22 15:39:23 +00:00
yusing
5272829582 bug fixes 2024-03-21 04:23:07 +00:00
yusing
48a9e312f5 bug fixes 2024-03-21 04:21:28 +00:00
Yuzerion
f09b152cf9 Merge pull request #3 from yusing/dependabot/go_modules/github.com/docker/docker-25.0.5incompatible
Bump github.com/docker/docker from 25.0.4+incompatible to 25.0.5+incompatible
2024-03-21 09:34:41 +08:00
dependabot[bot]
8184eb5aff Bump github.com/docker/docker
Bumps [github.com/docker/docker](https://github.com/docker/docker) from 25.0.4+incompatible to 25.0.5+incompatible.
- [Release notes](https://github.com/docker/docker/releases)
- [Commits](https://github.com/docker/docker/compare/v25.0.4...v25.0.5)

---
updated-dependencies:
- dependency-name: github.com/docker/docker
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-03-20 18:01:46 +00:00
yusing
b37e201ea8 benchmark update 2024-03-18 21:57:29 +00:00
yusing
ad9fc3cfe5 benchmark update 2024-03-18 21:56:09 +00:00
yusing
264ac4886d benchmark update 2024-03-18 21:56:01 +00:00
yusing
50eb5e9eb1 readme fixes 2024-03-18 21:01:37 +00:00
yusing
8a640ec484 fixed stack overflow error due to recursive call of rewrite 2024-03-18 20:52:34 +00:00
yusing
076c19c4ea fixed output formatting 2024-03-18 16:00:45 +00:00
yusing
3895718d6d oops 2024-03-18 05:20:08 +00:00
yusing
1bda823252 readme update 2024-03-18 05:17:43 +00:00
yusing
bf2c9b0d66 dockerfile env update 2024-03-18 05:15:53 +00:00
yusing
eca67b24d5 example update 2024-03-18 05:05:28 +00:00
yusing
3a81064fba misindentation 2024-03-18 04:58:21 +00:00
yusing
45bd377b22 typo 2024-03-18 04:57:31 +00:00
yusing
a518fa8ac6 typo 2024-03-18 04:56:41 +00:00
56 changed files with 3114 additions and 1107 deletions

7
.gitignore vendored
View File

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

3
.gitmodules vendored Normal file
View File

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

15
.vscode/settings.json vendored
View File

@@ -1,3 +1,16 @@
{
"go.inferGopath": false
"go.inferGopath": false,
"yaml.schemas": {
// "https://github.com/yusing/go-proxy/raw/main/schema/config.schema.json": [
// "config.example.yml",
// "config.yml"
// ],
"https://github.com/yusing/go-proxy/raw/main/schema/providers.schema.json": [
"providers.example.yml",
"*.providers.yml"
],
"file:///config/workspace/go-proxy/schema/config.schema.json": [
"file:///config/workspace/go-proxy/config.example.yml"
]
}
}

View File

@@ -2,19 +2,21 @@ FROM alpine:latest
LABEL maintainer="yusing@6uo.me"
RUN apk add --no-cache bash tzdata
RUN apk add --no-cache tzdata
RUN mkdir /app
COPY bin/go-proxy entrypoint.sh /app/
COPY bin/go-proxy /app/
COPY templates/ /app/templates
COPY config.default.yml /app/config.yml
COPY schema/ /app/schema
RUN chmod +x /app/go-proxy /app/entrypoint.sh
RUN chmod +x /app/go-proxy
ENV DOCKER_HOST unix:///var/run/docker.sock
ENV VERBOSITY=1
ENV GOPROXY_DEBUG 0
ENV GOPROXY_REDIRECT_HTTP 1
EXPOSE 80
EXPOSE 8080
EXPOSE 443
EXPOSE 8443
WORKDIR /app
ENTRYPOINT /app/entrypoint.sh
CMD ["/app/go-proxy"]

View File

@@ -2,22 +2,22 @@
all: build quick-restart logs
setup:
mkdir -p config certs
[ -f config/config.yml ] || cp config.example.yml config/config.yml
[ -f config/providers.yml ] || touch config/providers.yml
[ -f compose.yml ] || cp compose.example.yml compose.yml
build:
mkdir -p bin
CGO_ENABLED=0 GOOS=linux go build -pgo=auto -o bin/go-proxy src/go-proxy/*.go
up:
docker compose up -d --build go-proxy
quick-restart: # quick restart without restarting the container
docker cp bin/go-proxy go-proxy:/app/go-proxy
docker cp templates/* go-proxy:/app/templates
docker cp entrypoint.sh go-proxy:/app/entrypoint.sh
docker exec -d go-proxy bash /app/entrypoint.sh restart
docker compose up -d --build app
restart:
docker kill go-proxy
docker compose up -d go-proxy
docker compose up -d app
logs:
tail -f log/go-proxy.log

435
README.md
View File

@@ -1,123 +1,292 @@
# go-proxy
A simple auto docker reverse proxy for home use. \*Written in **Go\***
A simple auto docker reverse proxy for home use. **Written in _Go_**
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)
- [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)
- [Getting SSL certs](#getting-ssl-certs)
- [go-proxy](#go-proxy)
- [Table of content](#table-of-content)
- [Key Points](#key-points)
- [How to use](#how-to-use)
- [Binary](#binary)
- [Docker](#docker)
- [Command-line args](#command-line-args)
- [Commands](#commands)
- [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
- 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]`)
- 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
- Auto hot-reload on container `start` / `die` / `stop` or config file changes
- 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 (UDP is _experimental_)
- HTTP(s) round robin load balance support (same subdomain and path across different hosts)
- Simple panel to see all reverse proxies and health available on port 8080 (http) and port 8443 (https)
![panel screenshot](screenshots/panel.png)
## How to use (docker)
- Config editor to edit config and provider files with validation
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
3. Do the same for `providers.example.yml`
4. See [Binary](#binary) or [docker](#docker)
**Validate and save file with Ctrl+S**
![config editor screenshot](screenshots/config_editor.png)
## How to use
1. Clone the repository `git clone https://github.com/yusing/go-proxy && cd go-proxy`
2. Call `make setup` to init config file, provider file, and docker compose file
3. Point your domain (i.e `y.z`) to your machine's IP address
- A Record: `*.y.z` -> `10.0.10.1`
- AAAA Record: `*.y.z` -> `::ffff:a00:a01`
4. Start `go-proxy` (see [Binary](#binary) or [docker](#docker))
5. Start editing config files
- with text editor (i.e. Visual Studio Code)
- with web config editor by navigate to `ip:8080`
### Binary
1. (Optional) Prepare your certificates in `certs/` to enable https. See [Getting SSL Certs](#getting-ssl-certs)
- cert / chain / fullchain: ./certs/cert.crt
- private key: ./certs/priv.key
2. run the binary `bin/go-proxy`
3. enjoy
1. (Optional) enabled HTTPS
- Use autocert feature by completing `autocert` in `config.yml`
- Use existing certificate
Prepare your wildcard (`*.y.z`) SSL cert in `certs/`
- cert / chain / fullchain: `certs/cert.crt`
- private key: `certs/priv.key`
2. run the binary `bin/go-proxy`
3. enjoy
### Docker
1. Copy content from [compose.example.yml](compose.example.yml) and create your own `compose.yml`
2. Add networks to make sure it is in the same network with other containers, or make sure `proxy.<alias>.host` is reachable
1. Copy content from [compose.example.yml](compose.example.yml) and create your own `compose.yml`
3. (Optional) Mount your SSL certs to enable https. See [Getting SSL Certs](#getting-ssl-certs)
- cert / chain / fullchain -> /app/certs/cert.crt
- private key -> /app/certs/priv.key
2. Add networks to make sure it is in the same network with other containers, or make sure `proxy.<alias>.host` is reachable
4. Start `go-proxy` with `docker compose up -d` or `make up`.
3. (Optional) enable HTTPS
5. (Optional) If you are using ufw with vpn that drop all inbound traffic except vpn, run below to allow docker containers to connect to `go-proxy`
- Use autocert feature by completing `autocert` section in `config/config.yml` and mount `certs/` to `/app/certs` in order to store obtained certs
In case the network of your container is in subnet `172.16.0.0/16` (bridge),
and vpn network is under `100.64.0.0/10` (i.e. tailscale)
- Use existing certificate by mount your wildcard (`*.y.z`) SSL cert
`sudo ufw allow from 172.16.0.0/16 to 100.64.0.0/10`
- cert / chain / fullchain -> `/app/certs/cert.crt`
- private key -> `/app/certs/priv.key`
You can also list CIDRs of all docker bridge networks by:
4. Start `go-proxy` with `docker compose up -d` or `make up`.
`docker network inspect $(docker network ls | awk '$3 == "bridge" { print $1}') | jq -r '.[] | .Name + " " + .IPAM.Config[0].Subnet' -`
5. (Optional) If you are using ufw with vpn that drop all inbound traffic except vpn, run below to allow docker containers to connect to `go-proxy`
6. start your docker app, and visit <container_name>.y.z
In case the network of your container is in subnet `172.16.0.0/16` (bridge),
and vpn network is under `100.64.0.0/10` (i.e. tailscale)
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
`sudo ufw allow from 172.16.0.0/16 to 100.64.0.0/10`
## Known issues
You can also list CIDRs of all docker bridge networks by:
None
`docker network inspect $(docker network ls | awk '$3 == "bridge" { print $1}') | jq -r '.[] | .Name + " " + .IPAM.Config[0].Subnet' -`
6. start your docker app, and visit <container_name>.y.z
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
## Command-line args
`go-proxy [command]`
### Commands
- empty: start proxy server
- validate: validate config and exit
- reload: force reload config and exit
## Use JSON Schema in VSCode
Modify `.vscode/settings.json` to fit your needs
```json
{
"yaml.schemas": {
"https://github.com/yusing/go-proxy/raw/main/schema/config.schema.json": [
"config.example.yml",
"config.yml"
],
"https://github.com/yusing/go-proxy/raw/main/schema/providers.schema.json": [
"providers.example.yml",
"*.providers.yml"
]
}
}
```
## Configuration
With container name, no label needs to be added.
With container name, no label needs to be added _(most of the time)_.
However, there are some labels you can manipulate with:
### Labels (docker)
See [compose.example.yml](compose.example.yml) for more
- `proxy.aliases`: comma separated aliases for subdomain matching
- defaults to `container_name`
- `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 [stream.go](src/go-proxy/stream.go#L28))
- `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
- `GOPROXY_DEBUG`: set to `1` or `true` to enable debug behaviors (i.e. output, etc.)
- `GOPROXY_REDIRECT_HTTP`: set to `0` or `false` to disable http to https redirect (only when certs are located)
### Config File
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
Fields are same as [docker labels](#labels-docker) starting from `scheme`
See [providers.example.yml](providers.example.yml) for examples
### Supported DNS Challenge Providers
- Cloudflare
- `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
To add more provider support (**CloudDNS** as an example):
1. Fork this repo, modify [autocert.go](src/go-proxy/autocert.go#L305)
```go
var providersGenMap = map[string]ProviderGenerator{
"cloudflare": providerGenerator(cloudflare.NewDefaultConfig, cloudflare.NewDNSProviderConfig),
// add here, i.e.
"clouddns": providerGenerator(clouddns.NewDefaultConfig, clouddns.NewDNSProviderConfig),
}
```
2. Go to [https://go-acme.github.io/lego/dns/clouddns](https://go-acme.github.io/lego/dns/clouddns/) and check for required config
3. Build `go-proxy` with `make build`
4. Set required config in `config.yml` `autocert` -> `options` section
```shell
# From https://go-acme.github.io/lego/dns/clouddns/
CLOUDDNS_CLIENT_ID=bLsdFAks23429841238feb177a572aX \
CLOUDDNS_EMAIL=you@example.com \
CLOUDDNS_PASSWORD=b9841238feb177a84330f \
lego --email you@example.com --dns clouddns --domains my.example.org run
```
Should turn into:
```yaml
autocert:
...
options:
client_id: bLsdFAks23429841238feb177a572aX
email: you@example.com
password: b9841238feb177a84330f
```
5. Run and test if it works
6. Commit and create pull request
## Examples
### Single port configuration example
```yaml
@@ -207,46 +376,48 @@ Remote benchmark (client running wrk and `go-proxy` server are different devices
- Direct connection
```shell
root@yusing-pc:~# wrk -t 10 -c 200 -d 30s --latency http://10.0.100.1/bench
Running 30s test @ http://10.0.100.1/bench
root@yusing-pc:~# wrk -t 10 -c 200 -d 10s -H "Host: bench.6uo.me" --latency http://10.0.100.3:8003/bench
Running 10s test @ http://10.0.100.3:8003/bench
10 threads and 200 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 4.34ms 1.16ms 22.76ms 85.77%
Req/Sec 4.63k 435.14 5.47k 90.07%
Latency 94.75ms 199.92ms 1.68s 91.27%
Req/Sec 4.24k 1.79k 18.79k 72.13%
Latency Distribution
50% 3.95ms
75% 4.71ms
90% 5.68ms
99% 8.61ms
1383812 requests in 30.02s, 166.28MB read
Requests/sec: 46100.87
Transfer/sec: 5.54MB
50% 1.14ms
75% 120.23ms
90% 245.63ms
99% 1.03s
423444 requests in 10.10s, 50.88MB read
Socket errors: connect 0, read 0, write 0, timeout 29
Requests/sec: 41926.32
Transfer/sec: 5.04MB
```
- With reverse proxy
```shell
root@yusing-pc:~# wrk -t 10 -c 200 -d 30s --latency http://bench.6uo.me/bench
Running 30s test @ http://bench.6uo.me/bench
root@yusing-pc:~# 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
Thread Stats Avg Stdev Max +/- Stdev
Latency 4.50ms 1.44ms 27.53ms 86.48%
Req/Sec 4.48k 375.00 5.12k 84.73%
Latency 79.35ms 169.79ms 1.69s 92.55%
Req/Sec 4.27k 1.90k 19.61k 75.81%
Latency Distribution
50% 4.09ms
75% 5.06ms
90% 6.03ms
99% 9.41ms
1338996 requests in 30.01s, 160.90MB read
Requests/sec: 44616.36
Transfer/sec: 5.36MB
50% 1.12ms
75% 105.66ms
90% 200.22ms
99% 814.59ms
409836 requests in 10.10s, 49.25MB read
Socket errors: connect 0, read 0, write 0, timeout 18
Requests/sec: 40581.61
Transfer/sec: 4.88MB
```
Local benchmark (client running wrk and `go-proxy` server are under same proxmox host but different LXCs)
- 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
@@ -263,40 +434,60 @@ Local benchmark (client running wrk and `go-proxy` server are under same proxmox
Transfer/sec: 80.94MB
```
- With reverse proxy
```
root@http-benchmark-client:~# wrk -t 10 -c 200 -d 10s --latency http://bench.6uo.me/bench
Running 10s test @ http://bench.6uo.me/bench
- 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
Thread Stats Avg Stdev Max +/- Stdev
Latency 1.78ms 5.49ms 117.53ms 99.00%
Req/Sec 16.31k 2.30k 21.01k 86.69%
Latency 1.23ms 0.96ms 11.43ms 72.09%
Req/Sec 17.48k 1.76k 21.48k 70.20%
Latency Distribution
50% 1.12ms
75% 1.88ms
90% 2.80ms
99% 7.27ms
1634774 requests in 10.10s, 196.44MB read
Requests/sec: 161858.70
Transfer/sec: 19.45MB
50% 0.98ms
75% 1.76ms
90% 2.54ms
99% 4.24ms
1739079 requests in 10.01s, 208.97MB read
Requests/sec: 173779.44
Transfer/sec: 20.88MB
```
- 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
Thread Stats Avg Stdev Max +/- Stdev
Latency 2.81ms 10.36ms 180.26ms 98.57%
Req/Sec 11.35k 1.74k 13.76k 85.54%
Latency Distribution
50% 1.59ms
75% 2.27ms
90% 3.17ms
99% 37.91ms
1125723 requests in 10.01s, 109.50MB read
Requests/sec: 112499.59
Transfer/sec: 10.94MB
```
## Known issues
None
## Memory usage
It takes ~ 0.1-0.4MB for each HTTP Proxy, and <2MB for each TCP/UDP Proxy
It takes ~15 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 `docker compose up -d`
4. build binary with `make build`
## Getting SSL certs
I personally use `nginx-proxy-manager` to get SSL certs with auto renewal by Cloudflare DNS challenge. You may symlink the certs from `nginx-proxy-manager` to somewhere else, and mount them to `go-proxy`'s `/certs`
[panel port]: 8443
5. start your container with `make up` (docker) or `bin/go-proxy` (binary)

Binary file not shown.

View File

@@ -19,29 +19,25 @@ services:
# - 20000:20100/tcp
# - 20000:20100/udp
volumes:
# if you want https
- ./config:/app/config
# if local docker provider is used
- /var/run/docker.sock:/var/run/docker.sock:ro
# use existing certificate
# - /path/to/cert.pem:/app/certs/cert.crt:ro
# - /path/to/privkey.pem:/app/certs/priv.key:ro
# path to logs
- ./log:/app/log
# store autocert obtained cert
# - ./certs:/app/certs
# workaround for "lookup: no such host"
# dns:
# - 127.0.0.1
# if you use default config, or declared local docker provider
# otherwise comment this line
- /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.
dns:
- 127.0.0.1 # workaround for "lookup: no such host"
extra_hosts:
# required if you use local docker provider and have containers in `host` network_mode
- host.docker.internal:host-gateway
# if you have container running in "host" network mode
# extra_hosts:
# - host.docker.internal:host-gateway
logging:
driver: 'json-file'
options:

View File

@@ -1,10 +0,0 @@
providers:
local:
kind: docker
value: FROM_ENV
# provider1:
# kind: file
# value: provider1.yml
# provider2:
# kind: file
# value: provider2.yml

21
config.example.yml Normal file
View File

@@ -0,0 +1,21 @@
# Autocert (uncomment to enable)
# 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
# for value format, see https://docs.docker.com/reference/cli/dockerd/
# i.e. FROM_ENV, ssh://user@10.0.1.1:22, tcp://10.0.2.1:2375
value: FROM_ENV
providers:
kind: file
value: providers.yml
# Fixed options (optional, non hot-reloadable)
# timeout_shutdown: 5
# redirect_to_https: false

View File

@@ -1,11 +0,0 @@
#!/bin/bash
if [ "$1" == "restart" ]; then
echo "restarting"
killall go-proxy
fi
if [ "$DEBUG" == "1" ]; then
/app/go-proxy 2> log/go-proxy.log &
tail -f /dev/null
else
/app/go-proxy
fi

20
go.mod
View File

@@ -1,11 +1,13 @@
module github.com/yusing/go-proxy
go 1.21.7
go 1.22
require (
github.com/docker/cli v25.0.4+incompatible
github.com/docker/docker v25.0.4+incompatible
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
@@ -13,14 +15,23 @@ require (
require (
github.com/Microsoft/go-winio v0.6.1 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cloudflare/cloudflare-go v0.92.0 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/distribution/reference v0.5.0 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/go-connections v0.5.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-jose/go-jose/v4 v4.0.1 // indirect
github.com/go-logr/logr v1.4.1 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-retryablehttp v0.7.5 // indirect
github.com/miekg/dns v1.1.58 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/term v0.5.0 // indirect
github.com/morikuni/aec v1.0.0 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
@@ -32,6 +43,7 @@ require (
go.opentelemetry.io/otel/metric v1.24.0 // indirect
go.opentelemetry.io/otel/sdk v1.24.0 // indirect
go.opentelemetry.io/otel/trace v1.24.0 // indirect
golang.org/x/crypto v0.21.0 // indirect
golang.org/x/mod v0.16.0 // indirect
golang.org/x/sys v0.18.0 // indirect
golang.org/x/text v0.14.0 // indirect

63
go.sum
View File

@@ -2,42 +2,74 @@ github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOEl
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow=
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.92.0 h1:ltJvGvqZ4G6Fm2hHOYZ5RWpJQcrM0oDrsjjZydZhFJQ=
github.com/cloudflare/cloudflare-go v0.92.0/go.mod h1:nUqvBUUDRxNzsDSQjbqUNWHEIYAoUlgRmcAzMKlFdKs=
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/distribution/reference v0.5.0 h1:/FUIFXtfc/x2gpa5/VGfiGLuOIdYa1t65IKK2OFGvA0=
github.com/distribution/reference v0.5.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/cli v25.0.4+incompatible h1:DatRkJ+nrFoYL2HZUzjM5Z5sAmcA5XGp+AW0oEw2+cA=
github.com/docker/cli v25.0.4+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
github.com/docker/docker v25.0.4+incompatible h1:XITZTrq+52tZyZxUOtFIahUf3aH367FLxJzt9vZeAF8=
github.com/docker/docker v25.0.4+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/cli v26.0.0+incompatible h1:90BKrx1a1HKYpSnnBFR6AgDq/FqkHxwlUyzJVPxD30I=
github.com/docker/cli v26.0.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
github.com/docker/docker v26.0.0+incompatible h1:Ng2qi+gdKADUa/VM+6b6YaY2nlZhk/lVJiKR/2bMudU=
github.com/docker/docker v26.0.0+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w=
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/go-acme/lego/v4 v4.16.1 h1:JxZ93s4KG0jL27rZ30UsIgxap6VGzKuREsSkkyzeoCQ=
github.com/go-acme/lego/v4 v4.16.1/go.mod h1:AVvwdPned/IWpD/ihHhMsKnveF7HHYAz/CmtXi7OZoE=
github.com/go-jose/go-jose/v4 v4.0.1 h1:QVEPDE3OluqXBQZDcnNvQrInro2h0e4eqNbnZSWqS6U=
github.com/go-jose/go-jose/v4 v4.0.1/go.mod h1:WVf9LFMHh/QVrmqrOfqun0C45tMe3RoiKJMPvgWwLfY=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ=
github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.0 h1:Wqo399gCIufwto+VfwCSvsnfGpF/w5E9CNxSwbpD6No=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.0/go.mod h1:qmOFXW2epJhM0qSnUUYpldc7gVz2KMQwJ/QYCDIa7XU=
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ=
github.com/hashicorp/go-hclog v1.2.0 h1:La19f8d7WIlm4ogzNHB0JGqs5AUDAZ2UfCY4sJXcJdM=
github.com/hashicorp/go-hclog v1.2.0/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ=
github.com/hashicorp/go-retryablehttp v0.7.5 h1:bJj+Pj19UZMIweq/iie+1u5YCdGrnxCT9yvm0e+Nd5M=
github.com/hashicorp/go-retryablehttp v0.7.5/go.mod h1:Jy/gPYAdjqffZ/yFGCFV2doI5wjtH1ewM9u8iYVjtX8=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40=
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/miekg/dns v1.1.58 h1:ca2Hdkz+cDg/7eNF6V56jjzuZ4aCAE+DbVkILdQWG/4=
github.com/miekg/dns v1.1.58/go.mod h1:Ypv+3b/KadlvW9vJfXOTf300O4UqaHFzFCuHz+rPkBY=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
@@ -50,12 +82,17 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
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.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
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/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
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=
@@ -77,6 +114,8 @@ go.opentelemetry.io/proto/otlp v1.1.0/go.mod h1:GpBHCBWiqvVLDqmHZsoMM3C5ySeKTC7e
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA=
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic=
@@ -114,6 +153,7 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/genproto v0.0.0-20230530153820-e85fd2cbaebc h1:8DyZCyvI8mE1IdLy/60bS+52xfymkE72wv1asokgtao=
google.golang.org/genproto/googleapis/api v0.0.0-20240102182953-50ed04b92917 h1:rcS6EyEaoCO52hQDupoSfrxI3R6C2Tq741is7X8OvnM=
google.golang.org/genproto/googleapis/api v0.0.0-20240102182953-50ed04b92917/go.mod h1:CmlNWB9lSezaYELKS5Ym1r44VrrbPUa7JTvw+6MbpJ0=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240102182953-50ed04b92917 h1:6G8oQ016D88m1xAKljMlBOOGWDZkes4kMhgGFlf8WcQ=
@@ -122,8 +162,9 @@ google.golang.org/grpc v1.61.1 h1:kLAiWrZs7YeDM6MumDe7m3y4aM6wacLzM1Y/wiLP9XY=
google.golang.org/grpc v1.61.1/go.mod h1:VUbo7IFqmF1QtCAstipjG0GIoq49KvMe9+h1jFLBNJs=
google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I=
google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -1,13 +1,25 @@
app: # alias
# 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
# 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

133
schema/config.schema.json Normal file
View File

@@ -0,0 +1,133 @@
{
"$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"
}
}
}
}
]
}
}
},
"timeout_shutdown": {
"title": "Shutdown timeout (in seconds)",
"type": "integer",
"minimum": 0
},
"redirect_to_https": {
"title": "Redirect to HTTPS",
"type": "boolean"
}
},
"additionalProperties": false,
"required": ["providers"]
}

View File

@@ -0,0 +1,175 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "go-proxy providers file",
"anyOf": [
{
"type":"object"
},
{
"type":"null"
}
],
"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

38
src/go-proxy/args.go Normal file
View File

@@ -0,0 +1,38 @@
package main
import (
"flag"
"github.com/sirupsen/logrus"
)
type Args struct {
Command string
}
const (
CommandStart = ""
CommandValidate = "validate"
CommandReload = "reload"
)
var ValidCommands = []string{CommandStart, CommandValidate, CommandReload}
func getArgs() Args {
var args Args
flag.Parse()
args.Command = flag.Arg(0)
if err := validateArgs(args.Command, ValidCommands); err != nil {
logrus.Fatal(err)
}
return args
}
func validateArgs[T comparable](arg T, validArgs []T) error {
for _, v := range validArgs {
if arg == v {
return nil
}
}
return NewNestedError("invalid argument").Subjectf("%v", arg)
}

309
src/go-proxy/autocert.go Normal file
View File

@@ -0,0 +1,309 @@
package main
import (
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"os"
"path"
"sync"
"time"
"github.com/go-acme/lego/v4/certcrypto"
"github.com/go-acme/lego/v4/certificate"
"github.com/go-acme/lego/v4/challenge"
"github.com/go-acme/lego/v4/lego"
"github.com/go-acme/lego/v4/providers/dns/clouddns"
"github.com/go-acme/lego/v4/providers/dns/cloudflare"
"github.com/go-acme/lego/v4/registration"
)
type ProviderOptions map[string]string
type ProviderGenerator func(ProviderOptions) (challenge.Provider, error)
type CertExpiries map[string]time.Time
type AutoCertConfig struct {
Email string `json:"email"`
Domains []string `yaml:",flow" json:"domains"`
Provider string `json:"provider"`
Options ProviderOptions `yaml:",flow" json:"options"`
}
type AutoCertUser struct {
Email string
Registration *registration.Resource
key crypto.PrivateKey
}
func (u *AutoCertUser) GetEmail() string {
return u.Email
}
func (u *AutoCertUser) GetRegistration() *registration.Resource {
return u.Registration
}
func (u *AutoCertUser) GetPrivateKey() crypto.PrivateKey {
return u.key
}
type AutoCertProvider interface {
GetCert(*tls.ClientHelloInfo) (*tls.Certificate, error)
GetName() string
GetExpiries() CertExpiries
LoadCert() bool
ObtainCert() NestedErrorLike
RenewalOn() time.Time
ScheduleRenewal()
}
func (cfg AutoCertConfig) GetProvider() (AutoCertProvider, error) {
ne := NewNestedError("invalid autocert config")
if len(cfg.Domains) == 0 {
ne.Extra("no domains specified")
}
if cfg.Provider == "" {
ne.Extra("no provider specified")
}
if cfg.Email == "" {
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, ne.With(NewNestedError("unable to generate private key").With(err))
}
user := &AutoCertUser{
Email: cfg.Email,
key: privKey,
}
legoCfg := lego.NewConfig(user)
legoCfg.Certificate.KeyType = certcrypto.RSA2048
legoClient, err := lego.NewClient(legoCfg)
if err != nil {
return nil, ne.With(NewNestedError("unable to create lego client").With(err))
}
base := &autoCertProvider{
name: cfg.Provider,
cfg: cfg,
user: user,
legoCfg: legoCfg,
client: legoClient,
}
legoProvider, err := gen(cfg.Options)
if err != nil {
return nil, ne.With(err)
}
err = legoClient.Challenge.SetDNS01Provider(legoProvider)
if err != nil {
return nil, ne.With(NewNestedError("unable to set challenge provider").With(err))
}
return base, nil
}
type autoCertProvider struct {
name string
cfg AutoCertConfig
user *AutoCertUser
legoCfg *lego.Config
client *lego.Client
tlsCert *tls.Certificate
certExpiries CertExpiries
mutex sync.Mutex
}
func (p *autoCertProvider) GetCert(_ *tls.ClientHelloInfo) (*tls.Certificate, error) {
if p.tlsCert == nil {
return nil, NewNestedError("no certificate available")
}
return p.tlsCert, nil
}
func (p *autoCertProvider) GetName() string {
return p.name
}
func (p *autoCertProvider) GetExpiries() CertExpiries {
return p.certExpiries
}
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 ne.With(NewNestedError("failed to register account").With(err))
}
p.user.Registration = reg
}
req := certificate.ObtainRequest{
Domains: p.cfg.Domains,
Bundle: true,
}
cert, err := client.Certificate.Obtain(req)
if err != nil {
return ne.With(err)
}
err = p.saveCert(cert)
if err != nil {
return ne.With(NewNestedError("failed to save certificate").With(err))
}
tlsCert, err := tls.X509KeyPair(cert.Certificate, cert.PrivateKey)
if err != nil {
return ne.With(NewNestedError("failed to parse obtained certificate").With(err))
}
expiries, err := getCertExpiries(&tlsCert)
if err != nil {
return ne.With(NewNestedError("failed to get certificate expiry").With(err))
}
p.tlsCert = &tlsCert
p.certExpiries = expiries
return nil
}
func (p *autoCertProvider) LoadCert() bool {
cert, err := tls.LoadX509KeyPair(certFileDefault, keyFileDefault)
if err != nil {
return false
}
expiries, err := getCertExpiries(&cert)
if err != nil {
return false
}
p.tlsCert = &cert
p.certExpiries = expiries
p.renewIfNeeded()
return true
}
func (p *autoCertProvider) RenewalOn() time.Time {
t := time.Now().AddDate(0, 0, 3)
for _, expiry := range p.certExpiries {
if expiry.Before(t) {
return time.Now()
}
return t
}
// this line should never be reached
panic("no certificate available")
}
func (p *autoCertProvider) ScheduleRenewal() {
for {
t := time.Until(p.RenewalOn())
aclog.Infof("next renewal in %v", t)
time.Sleep(t)
err := p.renewIfNeeded()
if err != nil {
aclog.Fatal(err)
}
}
}
func (p *autoCertProvider) saveCert(cert *certificate.Resource) NestedErrorLike {
err := os.MkdirAll(path.Dir(certFileDefault), 0644)
if err != nil {
return NewNestedError("unable to create cert directory").With(err)
}
err = os.WriteFile(keyFileDefault, cert.PrivateKey, 0600) // -rw-------
if err != nil {
return NewNestedError("unable to write key file").With(err)
}
err = os.WriteFile(certFileDefault, cert.Certificate, 0644) // -rw-r--r--
if err != nil {
return NewNestedError("unable to write cert file").With(err)
}
return nil
}
func (p *autoCertProvider) needRenewal() bool {
return time.Now().After(p.RenewalOn())
}
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()
if err == nil {
return nil
}
trials++
if trials > 3 {
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 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)
if err != nil {
return nil, err
}
p, err := newProvider(cfg)
if err != nil {
return nil, err
}
return p, nil
}
}
func getCertExpiries(cert *tls.Certificate) (CertExpiries, error) {
r := make(CertExpiries, len(cert.Certificate))
for _, cert := range cert.Certificate {
x509Cert, err := x509.ParseCertificate(cert)
if err != nil {
return nil, NewNestedError("unable to parse certificate").With(err)
}
if x509Cert.IsCA {
continue
}
r[x509Cert.Subject.CommonName] = x509Cert.NotAfter
}
return r, nil
}
func setOptions[T interface{}](cfg *T, opt ProviderOptions) error {
for k, v := range opt {
err := setFieldFromSnake(cfg, k, v)
if err != nil {
return NewNestedError("unable to set option").Subject(k).With(err)
}
}
return nil
}
var providersGenMap = map[string]ProviderGenerator{
"cloudflare": providerGenerator(cloudflare.NewDefaultConfig, cloudflare.NewDNSProviderConfig),
"clouddns": providerGenerator(clouddns.NewDefaultConfig, clouddns.NewDNSProviderConfig),
}

View File

@@ -1,60 +1,96 @@
package main
import (
"fmt"
"os"
"sync"
"time"
"gopkg.in/yaml.v3"
)
// commented out if unused
type Config interface {
Value() configModel
// Load() error
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) Value() configModel {
return *cfg.m
}
func (cfg *config) Load(reader ...Reader) error {
cfg.mutex.Lock()
defer cfg.mutex.Unlock()
// unload if any
if cfg.Providers != nil {
for _, p := range cfg.Providers {
p.StopAllRoutes()
if cfg.reader == nil {
panic("config reader not set")
}
data, err := cfg.reader.Read()
if err != nil {
return NewNestedError("unable to read config file").With(err)
}
model := defaultConfig()
if err := yaml.Unmarshal(data, model); err != nil {
return NewNestedError("unable to parse config file").With(err)
}
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 {
pErrs.ExtraError(
NewNestedError("provider file validation error").
Subject(name).
With(err),
)
}
}
cfg.Providers = make(map[string]*Provider)
data, err := os.ReadFile(configPath)
if err != nil {
return fmt.Errorf("unable to read config file: %v", err)
}
if err = yaml.Unmarshal(data, &cfg); err != nil {
return fmt.Errorf("unable to parse config file: %v", err)
}
for name, p := range cfg.Providers {
p.name = name
if pErrs.HasExtras() {
ne.With(pErrs)
}
if ne.HasInner() {
return ne
}
cfg.m = model
return nil
}
@@ -64,34 +100,102 @@ func (cfg *config) MustLoad() {
}
}
func (cfg *config) GetAutoCertProvider() (AutoCertProvider, error) {
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() {
// Providers have their own mutex, no lock needed
ParallelForEachValue(cfg.Providers, (*Provider).StartAllRoutes)
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)
}
}
func (cfg *config) StopProviders() {
// 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"`
watcher Watcher
mutex sync.Mutex
type configModel struct {
Providers map[string]*Provider `yaml:",flow" json:"providers"`
AutoCert AutoCertConfig `yaml:",flow" json:"autocert"`
TimeoutShutdown time.Duration `yaml:"timeout_shutdown" json:"timeout_shutdown"`
RedirectToHTTPS bool `yaml:"redirect_to_https" json:"redirect_to_https"`
}
func defaultConfig() *configModel {
return &configModel{
TimeoutShutdown: 3 * time.Second,
RedirectToHTTPS: false,
}
}
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"
)
@@ -63,35 +64,80 @@ const (
ProviderKind_File = "file"
)
const (
certPath = "certs/cert.crt"
keyPath = "certs/priv.key"
// TODO: default + per proxy
var (
transport = &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{
Timeout: 60 * time.Second,
KeepAlive: 60 * time.Second,
}).DialContext,
MaxIdleConns: 1000,
MaxIdleConnsPerHost: 1000,
}
transportNoTLS = func() *http.Transport {
var clone = transport.Clone()
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,
},
}
)
// TODO: default + per proxy
var transport = &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{
Timeout: 60 * time.Second,
KeepAlive: 60 * time.Second,
}).DialContext,
MaxIdleConns: 1000,
MaxIdleConnsPerHost: 1000,
}
var transportNoTLS = func() *http.Transport {
var clone = transport.Clone()
clone.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
return clone
}()
const wildcardLabelPrefix = "proxy.*."
const clientUrlFromEnv = "FROM_ENV"
const configPath = "config.yml"
const (
certBasePath = "certs/"
certFileDefault = certBasePath + "cert.crt"
keyFileDefault = certBasePath + "priv.key"
const StreamStopListenTimeout = 1 * time.Second
configBasePath = "config/"
configPath = configBasePath + "config.yml"
const templateFile = "templates/panel.html"
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"
)
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
@@ -101,6 +147,4 @@ var logLevel = func() logrus.Level {
logrus.SetLevel(logrus.DebugLevel)
}
return logrus.GetLevel()
}()
var redirectHTTP = os.Getenv("GOPROXY_REDIRECT_HTTP") != "0" && os.Getenv("GOPROXY_REDIRECT_HTTP") != "false"
}()

View File

@@ -3,8 +3,8 @@ package main
import (
"fmt"
"net/http"
"reflect"
"strings"
"time"
"github.com/docker/cli/cli/connhelper"
"github.com/docker/docker/api/types"
@@ -16,52 +16,55 @@ import (
func (p *Provider) setConfigField(c *ProxyConfig, label string, value string, prefix string) error {
if strings.HasPrefix(label, prefix) {
field := strings.TrimPrefix(label, prefix)
field = utils.snakeToCamel(field)
prop := reflect.ValueOf(c).Elem().FieldByName(field)
if prop.Kind() == 0 {
return fmt.Errorf("ignoring unknown field %s", field)
if err := setFieldFromSnake(c, field, value); err != nil {
return err
}
prop.Set(reflect.ValueOf(value))
}
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, wildcardPrefix)
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.Info("no ports exposed, ignored")
l.Debugf("no ports exposed, ignored")
continue
}
if config.Scheme == "" {
@@ -107,35 +110,26 @@ 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
}
func (p *Provider) getDockerProxyConfigs() ([]*ProxyConfig, error) {
var clientIP string
var opts []client.Opt
var err error
func (p *Provider) getDockerClient() (*client.Client, error) {
var dockerOpts []client.Opt
if p.Value == clientUrlFromEnv {
clientIP = ""
opts = []client.Opt{
dockerOpts = []client.Opt{
client.WithHostFromEnv(),
client.WithAPIVersionNegotiation(),
}
} else {
url, err := client.ParseHostURL(p.Value)
if err != nil {
return nil, fmt.Errorf("unable to parse docker host url: %v", err)
}
clientIP = strings.Split(url.Host, ":")[0]
helper, err := connhelper.GetConnectionHelper(p.Value)
if err != nil {
return nil, fmt.Errorf("unexpected error: %v", err)
p.l.Fatal("unexpected error: ", err)
}
if helper != nil {
httpClient := &http.Client{
@@ -143,31 +137,49 @@ func (p *Provider) getDockerProxyConfigs() ([]*ProxyConfig, error) {
DialContext: helper.Dialer,
},
}
opts = []client.Opt{
dockerOpts = []client.Opt{
client.WithHTTPClient(httpClient),
client.WithHost(helper.Host),
client.WithAPIVersionNegotiation(),
client.WithDialContext(helper.Dialer),
}
} else {
opts = []client.Opt{
dockerOpts = []client.Opt{
client.WithHost(p.Value),
client.WithAPIVersionNegotiation(),
}
}
}
return client.NewClientWithOpts(dockerOpts...)
}
p.dockerClient, err = client.NewClientWithOpts(opts...)
if err != nil {
return nil, fmt.Errorf("unable to create docker client: %v", err)
func (p *Provider) getDockerProxyConfigs() (ProxyConfigSlice, error) {
var clientIP string
if p.Value == clientUrlFromEnv {
clientIP = ""
} else {
url, err := client.ParseHostURL(p.Value)
if err != nil {
return nil, NewNestedError("invalid host url").Subject(p.Value).With(err)
}
clientIP = strings.Split(url.Host, ":")[0]
}
containerSlice, err := p.dockerClient.ContainerList(context.Background(), container.ListOptions{})
dockerClient, err := p.getDockerClient()
if err != nil {
return nil, fmt.Errorf("unable to list containers: %v", err)
return nil, NewNestedError("unable to create docker client").With(err)
}
cfgs := make([]*ProxyConfig, 0)
ctx, _ := context.WithTimeout(context.Background(), 3*time.Second)
containerSlice, err := dockerClient.ContainerList(ctx, container.ListOptions{All: true})
if err != nil {
return nil, NewNestedError("unable to list containers").With(err)
}
cfgs := make(ProxyConfigSlice, 0)
for _, container := range containerSlice {
cfgs = append(cfgs, p.getContainerProxyConfigs(container, clientIP)...)
@@ -196,5 +208,3 @@ func selectPortInternal(c types.Container, getPort func(types.Port) uint16) uint
}
return 0
}
const wildcardPrefix = "proxy.*."

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

@@ -20,3 +20,10 @@ func (p *httpLoadBalancePool) Add(route *HTTPRoute) {
func (p *httpLoadBalancePool) Iterator() []*HTTPRoute {
return p.pool
}
func (p *httpLoadBalancePool) Pick() *HTTPRoute {
// round-robin
index := int(p.curentIndex.Load())
defer p.curentIndex.Add(1)
return p.pool[index%len(p.pool)]
}

View File

@@ -10,41 +10,6 @@ import (
"github.com/sirupsen/logrus"
)
/**
A small mod on net/http/httputil.ReverseProxy
Before mod:
root@http-benchmark-client:~# wrk -t 10 -c 200 -d 10s --latency http://bench.6uo.me/bench
Running 10s test @ http://bench.6uo.me/bench
10 threads and 200 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 3.02ms 4.34ms 102.70ms 94.90%
Req/Sec 8.06k 1.17k 9.99k 79.86%
Latency Distribution
50% 2.38ms
75% 4.00ms
90% 5.93ms
99% 11.90ms
808813 requests in 10.10s, 78.68MB read
Requests/sec: 80079.47
Transfer/sec: 7.79MB
After mod:
root@http-benchmark-client:~# wrk -t 10 -c 200 -d 10s --latency http://bench.6uo.me/bench
Running 10s test @ http://bench.6uo.me/bench
10 threads and 200 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 1.77ms 5.64ms 118.14ms 99.07%
Req/Sec 16.59k 2.22k 19.65k 87.30%
Latency Distribution
50% 1.11ms
75% 1.85ms
90% 2.74ms
99% 6.68ms
1665286 requests in 10.10s, 200.11MB read
Requests/sec: 164880.11
Transfer/sec: 19.81MB
**/
type HTTPRoute struct {
Alias string
Url *url.URL
@@ -56,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
@@ -70,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,
@@ -82,61 +44,49 @@ func NewHTTPRoute(config *ProxyConfig) (*HTTPRoute, error) {
PathMode: config.PathMode,
l: hrlog.WithFields(logrus.Fields{
"alias": config.Alias,
"path": config.Path,
"path_mode": config.PathMode,
// "path": config.Path,
// "path_mode": config.PathMode,
}),
}
var rewriteBegin = proxy.Rewrite
var rewrite func(*ProxyRequest)
var modifyResponse func(*http.Response) error
switch {
case config.Path == "", config.PathMode == ProxyPathMode_Forward:
rewrite = proxy.Rewrite
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) {
proxy.Rewrite(pr)
rewriteBegin(pr)
// disable compression
pr.Out.Header.Set("Accept-Encoding", "identity")
// remove path prefix
pr.Out.URL.Path = strings.TrimPrefix(pr.Out.URL.Path, config.Path)
}
route.Proxy.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) {
proxy.Rewrite(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 {
route.Proxy.Rewrite = func(pr *ProxyRequest) {
rewrite(pr)
route.l.Debug("Request headers: ", pr.In.Header)
route.l.Debug("request URL: ", pr.In.Host, pr.In.URL.Path)
route.l.Debug("request headers: ", pr.In.Header)
}
route.Proxy.ModifyResponse = func(r *http.Response) error {
route.l.Debug("response URL: ", r.Request.URL.String())
route.l.Debug("response headers: ", r.Header)
if modifyResponse != nil {
return modifyResponse(r)
}
return nil
}
} else {
route.Proxy.Rewrite = rewrite
@@ -145,32 +95,14 @@ func NewHTTPRoute(config *ProxyConfig) (*HTTPRoute, error) {
return route, nil
}
func (p *httpLoadBalancePool) Pick() *HTTPRoute {
// round-robin
index := int(p.curentIndex.Load())
defer p.curentIndex.Add(1)
return p.pool[index%len(p.pool)]
func (r *HTTPRoute) Start() {
// dummy
}
func (r *HTTPRoute) RemoveFromRoutes() {
func (r *HTTPRoute) Stop() {
httpRoutes.Delete(r.Alias)
}
// dummy implementation for Route interface
func (r *HTTPRoute) SetupListen() {}
func (r *HTTPRoute) Listen() {}
func (r *HTTPRoute) StopListening() {}
func isValidProxyPathMode(mode string) bool {
switch mode {
case ProxyPathMode_Forward, ProxyPathMode_Sub, ProxyPathMode_RemovedPath:
return true
default:
return false
}
}
func redirectToTLS(w http.ResponseWriter, r *http.Request) {
func redirectToTLSHandler(w http.ResponseWriter, r *http.Request) {
// Redirect to the same host but with HTTPS
var redirectCode int
if r.Method == http.MethodGet {
@@ -187,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 httpProxyHandler(w http.ResponseWriter, r *http.Request) {
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

@@ -8,3 +8,4 @@ var cfgl = logrus.WithField("component", "config")
var hrlog = logrus.WithField("component", "http_proxy")
var srlog = logrus.WithField("component", "stream")
var wlog = logrus.WithField("component", "watcher")
var aclog = logrus.WithField("component", "autocert")

View File

@@ -1,81 +1,128 @@
package main
import (
"flag"
"net/http"
"os"
"os/signal"
"runtime"
"sync"
"syscall"
"time"
log "github.com/sirupsen/logrus"
"github.com/sirupsen/logrus"
)
func main() {
var err error
var cfg Config
flag.Parse()
func main() {
runtime.GOMAXPROCS(runtime.NumCPU())
log.SetFormatter(&log.TextFormatter{
ForceColors: true,
DisableColors: false,
FullTimestamp: true,
args := getArgs()
logrus.SetFormatter(&logrus.TextFormatter{
ForceColors: true,
DisableColors: false,
FullTimestamp: true,
TimestampFormat: "01-02 15:04:05",
})
InitFSWatcher()
InitDockerWatcher()
if args.Command == CommandReload {
err := utils.reloadServer()
if err != nil {
logrus.Fatal(err)
}
return
}
cfg := NewConfig()
cfg = NewConfig(configPath)
cfg.MustLoad()
logrus.Info(cfg.Value())
if args.Command == CommandValidate {
logrus.Printf("config OK")
return
}
autoCertProvider, err := cfg.GetAutoCertProvider()
if err != nil {
aclog.Warn(err)
autoCertProvider = nil // TODO: remove, it is expected to be nil if error is not nil, but it is not for now
}
var proxyServer *Server
var panelServer *Server
if autoCertProvider != nil {
ok := autoCertProvider.LoadCert()
if !ok {
if ne := autoCertProvider.ObtainCert(); ne != nil {
aclog.Fatal(ne)
}
}
for name, expiry := range autoCertProvider.GetExpiries() {
aclog.Infof("certificate %q: expire on %v", name, expiry)
}
go autoCertProvider.ScheduleRenewal()
}
proxyServer = NewServer(ServerOptions{
Name: "proxy",
CertProvider: autoCertProvider,
HTTPAddr: ":80",
HTTPSAddr: ":443",
Handler: http.HandlerFunc(proxyHandler),
RedirectToHTTPS: cfg.Value().RedirectToHTTPS,
})
panelServer = NewServer(ServerOptions{
Name: "panel",
CertProvider: autoCertProvider,
HTTPAddr: ":8080",
HTTPSAddr: ":8443",
Handler: panelHandler,
RedirectToHTTPS: cfg.Value().RedirectToHTTPS,
})
proxyServer.Start()
panelServer.Start()
InitFSWatcher()
cfg.StartProviders()
cfg.WatchChanges()
var certAvailable = utils.fileOK(certPath) && utils.fileOK(keyPath)
go func() {
log.Info("starting http server on port 80")
if certAvailable && redirectHTTP {
err = http.ListenAndServe(":80", http.HandlerFunc(redirectToTLS))
} else {
err = http.ListenAndServe(":80", http.HandlerFunc(httpProxyHandler))
}
if err != nil {
log.Fatal("HTTP server error: ", err)
}
}()
go func() {
log.Infof("starting http panel on port 8080")
err := http.ListenAndServe(":8080", http.HandlerFunc(panelHandler))
if err != nil {
log.Warning("HTTP panel error: ", err)
}
}()
if certAvailable {
go func() {
log.Info("starting https server on port 443")
err = http.ListenAndServeTLS(":443", certPath, keyPath, http.HandlerFunc(httpProxyHandler))
if err != nil {
log.Fatal("https server error: ", err)
}
}()
go func() {
log.Info("starting https panel on port 8443")
err := http.ListenAndServeTLS(":8443", certPath, keyPath, http.HandlerFunc(panelHandler))
if err != nil {
log.Warning("http panel error: ", err)
}
}()
}
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGINT)
signal.Notify(sig, syscall.SIGTERM)
signal.Notify(sig, syscall.SIGHUP)
<-sig
cfg.StopProviders()
close(fsWatcherStop)
close(dockerWatcherStop)
logrus.Info("shutting down")
done := make(chan struct{}, 1)
var wg sync.WaitGroup
wg.Add(3)
go func() {
StopFSWatcher()
StopDockerWatcher()
cfg.StopProviders()
wg.Done()
}()
go func() {
panelServer.Stop()
proxyServer.Stop()
wg.Done()
}()
go func() {
wg.Wait()
close(done)
}()
select {
case <-done:
logrus.Info("shutdown complete")
case <-time.After(cfg.Value().TimeoutShutdown * time.Second):
logrus.Info("timeout waiting for shutdown")
}
}

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,54 @@
package main
import (
"errors"
"fmt"
"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(templateFile)
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 +65,89 @@ 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.Value().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
}
p = path.Join(configBasePath, p)
_, err = os.Stat(p)
exists := !errors.Is(err, os.ErrNotExist)
err = os.WriteFile(p, content, 0644)
if err != nil {
panelHandleErr(w, r, NewNestedError("unable to write config file").With(err))
return
}
w.WriteHeader(http.StatusOK)
if !exists {
w.Write([]byte(fmt.Sprintf("Config file %s created, remember to add it to config.yml!", p)))
return
}
w.Write([]byte(fmt.Sprintf("Config file %s updated", p)))
}
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,81 +1,42 @@
package main
import (
"fmt"
"sync"
"github.com/docker/docker/client"
"github.com/sirupsen/logrus"
)
type Provider struct {
Kind string // docker, file
Value string
Kind string `json:"kind"` // docker, file
Value string `json:"value"`
name string
watcher Watcher
routes map[string]Route // id -> Route
dockerClient *client.Client
mutex sync.Mutex
l logrus.FieldLogger
watcher Watcher
routes map[string]Route // id -> Route
mutex sync.Mutex
l logrus.FieldLogger
}
func (p *Provider) Setup() error {
var cfgs []*ProxyConfig
var err error
// 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()
p.l = prlog.WithFields(logrus.Fields{"kind": p.Kind, "name": p.name})
switch p.Kind {
case ProviderKind_Docker:
cfgs, err = p.getDockerProxyConfigs()
p.watcher = NewDockerWatcher(p.dockerClient, p.ReloadRoutes)
case ProviderKind_File:
cfgs, err = p.getFileProxyConfigs()
p.watcher = NewFileWatcher(p.Value, p.ReloadRoutes, p.StopAllRoutes)
default:
// this line should never be reached
return fmt.Errorf("unknown provider kind")
}
if err != nil {
if err := p.loadProxyConfig(); err != nil {
return err
}
p.l.Infof("loaded %d proxy configurations", len(cfgs))
for _, cfg := range cfgs {
r, err := NewRoute(cfg)
if err != nil {
p.l.Errorf("error creating route %s: %v", cfg.Alias, err)
continue
}
r.SetupListen()
r.Listen()
p.routes[cfg.GetID()] = r
}
return nil
}
func (p *Provider) StartAllRoutes() {
p.routes = make(map[string]Route)
err := p.Setup()
if err != nil {
p.l.Error(err)
return
}
ParallelForEachValue(p.routes, Route.Start)
p.watcher.Start()
}
func (p *Provider) StopAllRoutes() {
p.watcher.Stop()
p.dockerClient = nil
ParallelForEachValue(p.routes, func(r Route) {
r.StopListening()
r.RemoveFromRoutes()
})
p.routes = make(map[string]Route)
ParallelForEachValue(p.routes, Route.Stop)
p.routes = nil
}
func (p *Provider) ReloadRoutes() {
@@ -83,5 +44,62 @@ func (p *Provider) ReloadRoutes() {
defer p.mutex.Unlock()
p.StopAllRoutes()
err := p.loadProxyConfig()
if err != nil {
p.l.Error("failed to reload routes: ", err)
return
}
p.StartAllRoutes()
}
}
func (p *Provider) loadProxyConfig() error {
var cfgs ProxyConfigSlice
var err error
switch p.Kind {
case ProviderKind_Docker:
cfgs, err = p.getDockerProxyConfigs()
case ProviderKind_File:
cfgs, err = p.ValidateFile()
default:
// this line should never be reached
return NewNestedError("unknown provider kind")
}
if err != nil {
return err
}
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)
if err != nil {
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:
dockerClient, err := p.getDockerClient()
if err != nil {
return NewNestedError("unable to create docker client").With(err)
}
p.watcher = NewDockerWatcher(dockerClient, p.ReloadRoutes)
case ProviderKind_File:
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,25 +1,19 @@
package main
import (
"fmt"
)
type Route interface {
SetupListen()
Listen()
StopListening()
RemoveFromRoutes()
Start()
Stop()
}
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
@@ -27,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,6 +47,6 @@ func isStreamScheme(s string) bool {
}
// id -> target
type StreamRoutes = SafeMap[string, StreamRoute]
type StreamRoutes SafeMap[string, StreamRoute]
var streamRoutes = NewSafeMap[string, StreamRoute]()
var streamRoutes StreamRoutes = NewSafeMapOf[StreamRoutes]()

140
src/go-proxy/server.go Normal file
View File

@@ -0,0 +1,140 @@
package main
import (
"crypto/tls"
"log"
"net/http"
"time"
"github.com/sirupsen/logrus"
"golang.org/x/net/context"
)
type Server struct {
Name string
KeyFile string
CertFile string
CertProvider AutoCertProvider
http *http.Server
https *http.Server
httpStarted bool
httpsStarted bool
}
type ServerOptions struct {
Name string
HTTPAddr string
HTTPSAddr string
CertProvider AutoCertProvider
RedirectToHTTPS bool
Handler http.Handler
}
type LogrusWrapper struct {
*logrus.Entry
}
func (l LogrusWrapper) Write(b []byte) (int, error) {
return l.Logger.WriterLevel(logrus.ErrorLevel).Write(b)
}
func NewServer(opt ServerOptions) *Server {
var httpHandler http.Handler
var s *Server
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 {
s = &Server{
Name: opt.Name,
CertProvider: opt.CertProvider,
http: &http.Server{
Addr: opt.HTTPAddr,
Handler: httpHandler,
ErrorLog: logger,
},
https: &http.Server{
Addr: opt.HTTPSAddr,
Handler: opt.Handler,
ErrorLog: logger,
TLSConfig: &tls.Config{
GetCertificate: opt.CertProvider.GetCert,
},
},
}
}
s = &Server{
Name: opt.Name,
KeyFile: keyFileDefault,
CertFile: certFileDefault,
http: &http.Server{
Addr: opt.HTTPAddr,
Handler: httpHandler,
ErrorLog: logger,
},
https: &http.Server{
Addr: opt.HTTPSAddr,
Handler: opt.Handler,
ErrorLog: logger,
},
}
if !s.certsOK() {
s.http.Handler = opt.Handler
}
return s
}
func (s *Server) Start() {
if s.http != nil {
s.httpStarted = true
logrus.Printf("starting http %s server on %s", s.Name, s.http.Addr)
go func() {
err := s.http.ListenAndServe()
s.handleErr("http", err)
}()
}
if s.https != nil && (s.CertProvider != nil || s.certsOK()) {
s.httpsStarted = true
logrus.Printf("starting https %s server on %s", s.Name, s.https.Addr)
go func() {
err := s.https.ListenAndServeTLS(s.CertFile, s.KeyFile)
s.handleErr("https", err)
}()
}
}
func (s *Server) Stop() {
ctx, _ := context.WithTimeout(context.Background(), 3*time.Second)
if s.httpStarted {
errHTTP := s.http.Shutdown(ctx)
s.handleErr("http", errHTTP)
s.httpStarted = false
}
if s.httpsStarted {
errHTTPS := s.https.Shutdown(ctx)
s.handleErr("https", errHTTPS)
s.httpsStarted = false
}
}
func (s *Server) handleErr(scheme string, err error) {
switch err {
case nil, http.ErrServerClosed:
return
default:
logrus.Fatalf("failed to start %s %s server: %v", scheme, s.Name, err)
}
}
func (s *Server) certsOK() bool {
return utils.fileOK(s.CertFile) && utils.fileOK(s.KeyFile)
}

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,27 +33,29 @@ 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) {
var streamType string = StreamType_TCP
var srcPort string
var dstPort string
var srcScheme string
var dstScheme string
var srcPort, dstPort string
var srcScheme, dstScheme string
port_split := strings.Split(config.Port, ":")
if len(port_split) != 2 {
cfgl.Warnf("Invalid port %s, assuming it is target port", config.Port)
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 +64,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,13 +92,15 @@ func newStreamRouteBase(config *ProxyConfig) (*StreamRouteBase, error) {
TargetHost: config.Host,
TargetPort: dstPortInt,
id: config.GetID(),
wg: sync.WaitGroup{},
stopChann: make(chan struct{}),
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),
"dst": fmt.Sprintf("%s://%s:%d", dstScheme, config.Host, dstPortInt),
// "src": fmt.Sprintf("%s://:%d", srcScheme, srcPortInt),
// "dst": fmt.Sprintf("%s://%s:%d", dstScheme, config.Host, dstPortInt),
}),
}, nil
}
@@ -112,7 +112,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 +128,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 {
@@ -136,47 +174,49 @@ func (route *StreamRouteBase) SetupListen() {
return
}
route.ListeningPort = freePort
route.l.Info("Assigned free port", route.ListeningPort)
}
route.l.Info("Listening on", route.ListeningUrl())
}
func (route *StreamRouteBase) RemoveFromRoutes() {
streamRoutes.Delete(route.id)
}
func (route *StreamRouteBase) wait() {
route.wg.Wait()
}
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")
route.closeChannel()
route.closeListeners()
done := make(chan struct{})
go func() {
route.wait()
close(done)
route.unmarkPort()
}()
select {
case <-done:
l.Info("Stopped listening")
return
case <-time.After(StreamStopListenTimeout):
l.Error("timed out waiting for connections")
route.l.Info("listening on free port ", route.ListeningPort)
return
}
route.l.Info("listening on ", route.ListeningUrl())
}
func (route *StreamRouteBase) grAcceptConnections() {
defer route.wg.Done()
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)
}
}()
}
}
}

View File

@@ -3,92 +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) Listen() {
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) StopListening() {
stopListening(route)
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()
@@ -97,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,95 +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) Listen() {
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) StopListening() {
stopListening(route)
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()
}
if route.targetConn != nil {
route.targetConn.Close()
}
route.listeningConn = nil
route.targetConn = nil
for _, conn := range route.connMap {
conn.(*net.UDPConn).Close() // TODO: change on non udp target
}
}
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()
@@ -151,7 +114,7 @@ func (route *UDPRoute) handleConnection(conn *UDPConn) error {
for {
select {
case <-route.stopChann:
case <-route.stopCh:
return nil
default:
// receive from target
@@ -178,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) {
@@ -236,4 +192,4 @@ func (route *UDPRoute) forwardReceivedDebug(receivedConn *UDPConn, dest net.Conn
dest.RemoteAddr().String(),
)
return route.forwardReceivedReal(receivedConn, dest)
}
}

View File

@@ -2,6 +2,7 @@ package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net"
@@ -9,13 +10,15 @@ import (
"os"
"path"
"path/filepath"
"reflect"
"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 {
@@ -51,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) {
@@ -83,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
}
@@ -91,7 +94,19 @@ func (*Utils) healthCheckStream(scheme, host string) error {
return nil
}
func (*Utils) snakeToCamel(s string) string {
func (*Utils) reloadServer() error {
resp, err := healthCheckHttpClient.Post("http://localhost:8080/reload", "", nil)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return NewNestedError("server reload failed").Subjectf("%d", resp.StatusCode)
}
return nil
}
func (*Utils) snakeToPascal(s string) string {
toHyphenCamel := http.CanonicalHeaderKey(strings.ReplaceAll(s, "_", "-"))
return strings.ReplaceAll(toHyphenCamel, "-", "")
}
@@ -192,3 +207,38 @@ func (*Utils) fileOK(path string) bool {
_, err := os.Stat(path)
return err == nil
}
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 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

@@ -18,6 +18,7 @@ import (
type Watcher interface {
Start()
Stop()
Dispose()
}
type watcherBase struct {
@@ -25,6 +26,7 @@ type watcherBase struct {
kind string // for log / error output
onChange func()
l logrus.FieldLogger
sync.Mutex
}
type fileWatcher struct {
@@ -36,7 +38,7 @@ type fileWatcher struct {
type dockerWatcher struct {
*watcherBase
client *client.Client
stop chan struct{}
stopCh chan struct{}
wg sync.WaitGroup
}
@@ -60,43 +62,64 @@ func NewDockerWatcher(c *client.Client, onChange func()) Watcher {
return &dockerWatcher{
watcherBase: newWatcher("Docker", c.DaemonHost(), onChange),
client: c,
stop: make(chan struct{}, 1),
stopCh: make(chan struct{}, 1),
}
}
func (w *fileWatcher) Start() {
w.Lock()
defer w.Unlock()
if fsWatcher == nil {
return
}
err := fsWatcher.Add(w.path)
if err != nil {
w.l.Error("failed to start: ", err)
return
}
fileWatchMap.Set(w.path, w)
}
func (w *fileWatcher) Stop() {
w.Lock()
defer w.Unlock()
if fsWatcher == nil {
return
}
fileWatchMap.Delete(w.path)
err := fsWatcher.Remove(w.path)
if err != nil {
w.l.WithField("action", "stop").Error(err)
w.l.Error(err)
}
}
func (w *fileWatcher) Dispose() {
w.Stop()
}
func (w *dockerWatcher) Start() {
w.Lock()
defer w.Unlock()
dockerWatchMap.Set(w.name, w)
w.wg.Add(1)
go func() {
w.watch()
w.wg.Done()
}()
go w.watch()
}
func (w *dockerWatcher) Stop() {
close(w.stop)
w.stop = nil
dockerWatchMap.Delete(w.name)
w.Lock()
defer w.Unlock()
if w.stopCh == nil {
return
}
close(w.stopCh)
w.wg.Wait()
w.stopCh = nil
dockerWatchMap.Delete(w.name)
}
func (w *dockerWatcher) Dispose() {
w.Stop()
w.client.Close()
}
func InitFSWatcher() {
@@ -106,35 +129,30 @@ func InitFSWatcher() {
return
}
fsWatcher = w
fsWatcherWg.Add(1)
go watchFiles()
}
func InitDockerWatcher() {
// stop all docker client on watcher stop
go func() {
<-dockerWatcherStop
stopAllDockerClients()
}()
func StopFSWatcher() {
close(fsWatcherStop)
fsWatcherWg.Wait()
}
func stopAllDockerClients() {
func StopDockerWatcher() {
ParallelForEachValue(
dockerWatchMap.Iterator(),
func(w *dockerWatcher) {
w.Stop()
err := w.client.Close()
if err != nil {
w.l.WithField("action", "stop").Error(err)
}
w.client = nil
},
(*dockerWatcher).Dispose,
)
}
func watchFiles() {
defer fsWatcher.Close()
defer fsWatcherWg.Done()
for {
select {
case <-fsWatcherStop:
return
case event, ok := <-fsWatcher.Events:
if !ok {
wlog.Error("file watcher channel closed")
@@ -146,11 +164,11 @@ func watchFiles() {
}
switch {
case event.Has(fsnotify.Write):
w.l.Info("File change detected")
w.onChange()
w.l.Info("file changed")
go w.onChange()
case event.Has(fsnotify.Remove), event.Has(fsnotify.Rename):
w.l.Info("File renamed / deleted")
w.onDelete()
w.l.Info("file renamed / deleted")
go w.onDelete()
}
case err := <-fsWatcher.Errors:
wlog.Error(err)
@@ -159,6 +177,8 @@ func watchFiles() {
}
func (w *dockerWatcher) watch() {
defer w.wg.Done()
filter := filters.NewArgs(
filters.Arg("type", "container"),
filters.Arg("event", "start"),
@@ -171,25 +191,39 @@ func (w *dockerWatcher) watch() {
for {
select {
case <-w.stop:
case <-w.stopCh:
return
case msg := <-msgChan:
w.l.Info("container", msg.Actor.Attributes["name"], msg.Action)
w.onChange()
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
)

1
templates/codemirror Submodule

Submodule templates/codemirror added at 0c8456c3bc

View File

@@ -0,0 +1,35 @@
<!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}}
<li id="new-file">
<a class="unselectable">+</a>
</li>
</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,114 @@
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 setCurrentFile(filename) {
let old_nav_item = document.getElementById(`file-${currentFile}`);
if (old_nav_item !== null) {
old_nav_item.classList.remove("active");
}
currentFile = filename;
document.title = `${currentFile} - Config Editor`;
let new_nav_item = document.getElementById(`file-${currentFile}`);
if (new_nav_item === null) {
new_file_btn = document.getElementById("new-file");
file_list = document.getElementById("file-list");
new_nav_item = document.createElement("li");
new_nav_item.id = `file-${currentFile}`;
new_nav_item.innerHTML = `<a class="unselectable">${currentFile}</a>`;
file_list.insertBefore(new_nav_item, new_file_btn);
}
new_nav_item.classList.add("active");
}
function loadFile(filename) {
if (filename === undefined) {
return;
}
if (filename === '+') {
newFile();
return;
}
let req = new XMLHttpRequest();
req.open("GET", `/config/${filename}`, true);
req.onreadystatechange = function () {
if (req.readyState == 4) {
if (req.status == 200) {
editor.setValue(req.responseText);
setCurrentFile(filename);
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(req.responseText);
} else {
alert("Error:\n" + req.responseText);
}
}
};
}
function newFile() {
let filename = prompt("Enter filename:");
if (filename === undefined || filename === "") {
alert("File name cannot be empty");
return;
}
if (!filename.endsWith(".yml") && !filename.endsWith(".yaml")) {
alert("File name must end with .yml or .yaml");
return;
}
let files = document.getElementById("file-list").children;
for (let i = 0; i < files.length; i++) {
if (files[i].id === `file-${filename}`) {
alert("File already exists");
return;
}
}
editor.setValue("");
setCurrentFile(filename);
}
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,64 @@
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;
}
#new-file {
color: #f8f8f2 !important;
font-weight: bold;
}
.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;
}