Compare commits

...

80 Commits

Author SHA1 Message Date
yusing
351bf84559 tcp/udp fix 2024-03-31 11:26:39 +00:00
yusing
cbe23d2ed1 tcp/udp fix 2024-03-31 07:04:08 +00:00
yusing
6e45f3683c docs fix 2024-03-30 00:39:05 +00:00
yusing
581894c05b binary setup script fix 2024-03-30 00:15:05 +00:00
yusing
2657b1f726 binary setup script fix 2024-03-29 23:07:17 +00:00
yusing
3505e8ff7e systemd service param update 2024-03-29 23:03:56 +00:00
yusing
2314e39291 workflow update 2024-03-29 22:53:20 +00:00
yusing
bd19f443d4 Merge branch 'main' of github.com:yusing/go-proxy 2024-03-29 22:43:13 +00:00
yusing
ce433f0c51 script update for auto version discovery, dockerfile fix for CI 2024-03-29 22:40:11 +00:00
Yuzerion
47877e5119 Merge pull request #12 from yusing/dev
0.4.4
2024-03-30 06:07:34 +08:00
yusing
486122f3d8 no timestamp, color and sorting in systemd mode 2024-03-29 21:45:29 +00:00
yusing
a0be1f11d3 script systemd auto restart on crash 2024-03-29 21:43:43 +00:00
yusing
662190e09e scripts fix 2024-03-29 21:29:06 +00:00
yusing
ce1e5da72e scripts fix 2024-03-29 21:22:51 +00:00
yusing
eb7e744a75 scripts fix 2024-03-29 21:20:18 +00:00
yusing
ac26baf97f scripts fix 2024-03-29 21:12:38 +00:00
yusing
5a8c11de16 docs update, added setup scripts 2024-03-29 21:02:21 +00:00
yusing
a8ecafcd09 workflow updte 2024-03-29 19:21:05 +00:00
Yuzerion
af37d1f29e Merge pull request #10 from yusing/test-go-workflow
Update go.yml
2024-03-30 03:06:16 +08:00
Yuzerion
8cfd24e6bd Update go.yml 2024-03-30 00:09:08 +08:00
Yuzerion
7bf5784016 Merge pull request #9 from yusing/test-go-workflow
Create go.yml
2024-03-30 00:01:49 +08:00
Yuzerion
25930a1a73 Create go.yml 2024-03-30 00:00:35 +08:00
Yuzerion
f20a1ff523 Merge pull request #7 from yusing/test-docker-image
Create docker-image.yml
2024-03-29 23:58:03 +08:00
Yuzerion
ba51796a64 Create docker-image.yml 2024-03-29 23:56:55 +08:00
yusing
c445d50221 smarter port selection 2024-03-29 13:55:28 +00:00
yusing
73dfc17a82 smarter port selection 2024-03-29 13:35:10 +00:00
yusing
fdab026a3b fix docker port discovery 2024-03-29 13:20:44 +00:00
yusing
c789c69c86 codemirror 5 fix for config edit 2024-03-29 13:13:26 +00:00
Yuzerion
2b298aa7fa Update README.md 2024-03-29 21:01:18 +08:00
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
yusing
a52b1bcadd large refactoring, bug fixes, performance improvement 2024-03-18 04:51:59 +00:00
yusing
eee6ff4f15 fix output and config reload 2024-03-14 02:46:01 +00:00
yusing
e14eeb914f fix output formatting 2024-03-13 17:41:10 +00:00
yusing
a0f890fed7 add delay after error while watching change fix 2024-03-12 20:33:59 +00:00
yusing
0aef8f2931 port selection strategy fix 2024-03-12 19:10:39 +00:00
yusing
a3bb3e3991 port selection strategy fix 2024-03-12 19:10:25 +00:00
yusing
206412d0ca port selection strategy fix 2024-03-12 19:02:45 +00:00
yusing
1fd7b50010 fix port discovery for docker 2024-03-12 16:46:17 +00:00
yusing
110bb362b3 provider update race fix attempt 2024-03-12 16:42:09 +00:00
yusing
a2ada5c7ea provider update race fix attempt 2024-03-12 07:18:47 +00:00
yusing
76d2cc2871 docker provider fix test 2024-03-11 16:51:03 +00:00
yusing
2aa7c22011 docker ssh provider fix test 2024-03-11 16:42:24 +00:00
yusing
c271c4d0e2 small fix 2024-03-11 16:29:32 +00:00
yusing
304b2e9ba6 rebuild 2024-03-11 10:50:39 +00:00
yusing
0a34a23ea2 small refactoring 2024-03-11 10:50:06 +00:00
yusing
d3684b62b7 allow multiple docker providers, added file provider support 2024-03-11 10:31:01 +00:00
yusing
e736fe1f1e test default host overrid 2024-03-09 21:22:33 +00:00
63 changed files with 5080 additions and 1226 deletions

14
.github/workflows/docker-image.yml vendored Normal file
View File

@@ -0,0 +1,14 @@
name: Docker Image CI
on:
push:
tags:
- "*"
jobs:
build_and_push:
runs-on: ubuntu-latest
steps:
- name: Build and Push Container to ghcr.io
uses: GlueOps/github-actions-build-push-containers@v0.3.7
with:
tags: latest,${{ github.ref_name }}

30
.github/workflows/go.yml vendored Normal file
View File

@@ -0,0 +1,30 @@
# This workflow will build a golang project
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go
name: Go
on:
push:
tags:
- "*"
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: "1.22.1"
- name: Build
run: make build
- name: Release
uses: softprops/action-gh-release@v2
with:
files: bin/go-proxy
#- name: Test
# run: go test -v ./...

8
.gitignore vendored
View File

@@ -1,5 +1,9 @@
compose.yml
go-proxy.yml
bin/go-proxy.bak
config/
certs/
bin/
templates/codemirror/
logs/
log/

0
.gitmodules vendored Normal file
View File

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

@@ -1,19 +1,39 @@
FROM alpine:latest AS codemirror
RUN apk add --no-cache unzip wget make
COPY Makefile .
RUN make setup-codemirror
FROM golang:1.22.1-alpine as builder
COPY src/ /src
COPY go.mod go.sum /src/go-proxy
WORKDIR /src/go-proxy
RUN --mount=type=cache,target="/go/pkg/mod" \
go mod download
ENV GOCACHE=/root/.cache/go-build
RUN --mount=type=cache,target="/go/pkg/mod" \
--mount=type=cache,target="/root/.cache/go-build" \
CGO_ENABLED=0 GOOS=linux go build -pgo=auto -o go-proxy
FROM alpine:latest
LABEL maintainer="yusing@6uo.me"
RUN apk add --no-cache bash tzdata
RUN mkdir /app
COPY bin/go-proxy entrypoint.sh /app/
RUN apk add --no-cache tzdata
RUN mkdir -p /app/templates
COPY --from=codemirror templates/codemirror/ /app/templates/codemirror
COPY templates/ /app/templates
COPY schema/ /app/schema
COPY --from=builder /src/go-proxy /app/
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
EXPOSE 80
EXPOSE 8080
EXPOSE 443
EXPOSE 8443
WORKDIR /app
ENTRYPOINT /app/entrypoint.sh
CMD ["/app/go-proxy"]

View File

@@ -2,22 +2,28 @@
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
setup-codemirror:
wget https://codemirror.net/5/codemirror.zip
unzip codemirror.zip
rm codemirror.zip
mkdir -p templates
mv codemirror-* templates/codemirror
build:
mkdir -p bin
CGO_ENABLED=0 GOOS=linux go build -o bin/go-proxy src/go-proxy/*.go
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
@@ -30,6 +36,6 @@ udp-server:
-p 9999:9999/udp \
--label proxy.test-udp.scheme=udp \
--label proxy.test-udp.port=20003:9999 \
--network data_default \
--network host \
--name test-udp \
$$(docker build -q -f udp-test-server.Dockerfile .)

379
README.md
View File

@@ -1,107 +1,205 @@
# 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
- [Features](#features)
- [Why am I making this](#why-am-i-making-this)
- [How to use](#how-to-use)
- [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)
- [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)
## Features
## Key Points
- 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 containers replicas)
- Auto hot-reload when container start / die / stop.
- 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)
- Web UI on port 8080 (http) and port 8443 (https)
- a simple panel to see all reverse proxies and health
![panel screenshot](screenshots/panel.png)
## Why am I making this
- a config editor to edit config and provider files with validation
1. It's fun.
2. I have tried different reverse proxy services, i.e. [nginx proxy manager](https://nginxproxymanager.com/), [traefik](https://github.com/traefik/traefik), [nginx-proxy](https://github.com/nginx-proxy/nginx-proxy). I have found that `traefik` is not easy to use, and I don't want to click buttons every time I spin up a new container (`nginx proxy manager`). For `nginx-proxy` I found it buggy and quite unusable.
**Validate and save file with Ctrl+S**
![config editor screenshot](screenshots/config_editor.png)
## How to use
1. Clone the repo git clone `https://github.com/yusing/go-proxy`
1. Setup DNS Records to your machine's IP address
2. Copy content from [compose.example.yml](compose.example.yml) and create your own `compose.yml`
- A Record: `*.y.z` -> `10.0.10.1`
- AAAA Record: `*.y.z` -> `::ffff:a00:a01`
3. Add networks to make sure it is in the same network with other containers, or make sure `proxy.<alias>.host` is reachable
2. Start `go-proxy` (see [Binary](docs/binary.md) or [docker](docs/docker.md))
4. Modify the path to your SSL certs. See [Getting SSL Certs](#getting-ssl-certs)
3. Start editing config files
- with text editor (i.e. Visual Studio Code)
- or with web config editor by navigate to `ip:8080`
5. Start `go-proxy` with `docker compose up -d` or `make up`.
## Command-line args
6. (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`
`go-proxy [command]`
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)
### Commands
`sudo ufw allow from 172.16.0.0/16 to 100.64.0.0/10`
- empty: start proxy server
- validate: validate config and exit
- reload: trigger a force reload of config
You can also list CIDRs of all docker bridge networks by:
Examples:
`docker network inspect $(docker network ls | awk '$3 == "bridge" { print $1}') | jq -r '.[] | .Name + " " + .IPAM.Config[0].Subnet' -`
- Binary: `go-proxy reload`
- Docker: `docker exec -it go-proxy /app/go-proxy reload`
7. start your docker app, and visit <container_name>.y.z
## Use JSON Schema in VSCode
8. 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
Modify `.vscode/settings.json` to fit your needs
## Known issues
None
```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))
- `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.)
### 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 (`DOCKER_HOST`)
- 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, see [this](docs/add_dns_provider.md)
## Examples
### Single port configuration example
```yaml
@@ -150,7 +248,7 @@ app-db:
- proxy.app-db.scheme=tcp
# Optional (first free port will be used for listening port)
- proxy.app-db.port=20000:postgres
- proxy.app-db.port=20000:postgres
# In go-proxy
go-proxy:
@@ -158,7 +256,7 @@ go-proxy:
ports:
- 80:80
...
- 20000:20000/tcp
- <your desired port>:20000/tcp
# or 20000-20010:20000-20010/tcp to declare large range at once
# access app-db via <*>.y.z:20000
@@ -186,60 +284,123 @@ A: Make sure the container is running, and \<subdomain> matches any container na
Benchmarked with `wrk` connecting `traefik/whoami`'s `/bench` endpoint
Direct connection
Remote benchmark (client running wrk and `go-proxy` server are different devices)
```shell
% wrk -t20 -c100 -d10s --latency http://homelab:4999/bench
Running 10s test @ http://homelab:4999/bench
20 threads and 100 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 3.74ms 1.19ms 19.94ms 81.53%
Req/Sec 1.35k 103.96 1.60k 73.60%
Latency Distribution
50% 3.46ms
75% 4.16ms
90% 4.98ms
99% 8.04ms
269696 requests in 10.01s, 32.41MB read
Requests/sec: 26950.35
Transfer/sec: 3.24MB
```
- Direct connection
With **go-proxy** reverse proxy
```shell
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 94.75ms 199.92ms 1.68s 91.27%
Req/Sec 4.24k 1.79k 18.79k 72.13%
Latency Distribution
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
```
```shell
% wrk -t20 -c100 -d10s --latency https://whoami.mydomain.com/bench
Running 10s test @ https://whoami.6uo.me/bench
20 threads and 100 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 4.02ms 2.13ms 47.49ms 95.14%
Req/Sec 1.28k 139.15 1.47k 91.67%
Latency Distribution
50% 3.60ms
75% 4.36ms
90% 5.29ms
99% 8.83ms
253874 requests in 10.02s, 24.70MB read
Requests/sec: 25342.46
Transfer/sec: 2.47MB
```
- With reverse proxy
```shell
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 79.35ms 169.79ms 1.69s 92.55%
Req/Sec 4.27k 1.90k 19.61k 75.81%
Latency Distribution
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
Thread Stats Avg Stdev Max +/- Stdev
Latency 434.08us 539.35us 8.76ms 85.28%
Req/Sec 67.71k 6.31k 87.21k 71.20%
Latency Distribution
50% 153.00us
75% 646.00us
90% 1.18ms
99% 2.38ms
6739591 requests in 10.01s, 809.85MB read
Requests/sec: 673608.15
Transfer/sec: 80.94MB
```
- 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.23ms 0.96ms 11.43ms 72.09%
Req/Sec 17.48k 1.76k 21.48k 70.20%
Latency Distribution
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

@@ -1,30 +1,40 @@
version: '3'
services:
app:
build: .
image: ghcr.io/yusing/go-proxy:latest
container_name: go-proxy
hostname: go-proxy # set hostname to prevent adding itself to proxy list
restart: always
networks: # ^also add here
- default
environment:
- VERBOSITY=1 # LOG LEVEL (optional, defaults to 1)
- DEBUG=1 # (optional enable only for debug)
ports:
- 80:80 # http
- 443:443 # https
- 8443:8443 # panel
- 20000:20100/tcp # tcp (optional, if you have proxy.<app>.scheme == tcp)
- 20000:20100/udp # tcp (optional, if you have proxy.<app>.scheme == udp)
- 80:80 # http proxy
- 8080:8080 # http panel
# - 443:443 # optional, https proxy
# - 8443:8443 # optional, https panel
# optional, if you declared any tcp/udp proxy, set a range you want to use
# - 20000:20100/tcp
# - 20000:20100/udp
volumes:
- /path/to/cert.pem:/certs/cert.crt:ro
- /path/to/privkey.pem:/certs/priv.key:ro
- ./log:/app/log # path to logs
- ./config:/app/config
# if local docker provider is used
- /var/run/docker.sock:/var/run/docker.sock:ro
dns:
- 127.0.0.1 # workaround for "lookup: no such host"
extra_hosts:
- host.docker.internal:host-gateway # required if you have containers in `host` network_mode
# use existing certificate
# - /path/to/cert.pem:/app/certs/cert.crt:ro
# - /path/to/privkey.pem:/app/certs/priv.key:ro
# store autocert obtained cert
# - ./certs:/app/certs
# workaround for "lookup: no such host"
# dns:
# - 127.0.0.1
# if you have container running in "host" network mode
# extra_hosts:
# - host.docker.internal:host-gateway
logging:
driver: 'json-file'
options:

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

41
docs/add_dns_provider.md Normal file
View File

@@ -0,0 +1,41 @@
# Adding 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

59
docs/binary.md Normal file
View File

@@ -0,0 +1,59 @@
# Getting started with `go-proxy` (binary)
## Setup
1. Install `bash`, `make` and `wget` if not already
2. Run setup script
To specitfy a version _(optional)_
```shell
export VERSION=latest # will be resolved into real version number
export VERSION=<version>
```
If you don't need web config editor
```shell
export SETUP_CODEMIRROR=0
```
Setup
```shell
wget -qO- https://6uo.me/go-proxy-setup-docker | sudo bash
```
What it does:
- Download source file and binary into /opt/go-proxy/$VERSION
- Setup `config.yml` and `providers.yml`
- Setup `template/codemirror` which is a dependency for web config editor
- Create a systemd service (if available) in `/etc/systemd/system/go-proxy.service`
- Enable and start `go-proxy` service
3. Start editing config files in `http://<ip>:8080`
4. Check logs / status with `systemctl status go-proxy`
## Setup (alternative method)
1. Download the latest release and extract somewhere
2. Run `make setup` and _(optional) `make setup-codemirror`_
3. Enable HTTPS _(optional)_
- To use autocert feature
complete `autocert` in `config/config.yml`
- To use existing certificate
Prepare your wildcard (`*.y.z`) SSL cert in `certs/`
- cert / chain / fullchain: `certs/cert.crt`
- private key: `certs/priv.key`
4. Run the binary `bin/go-proxy`

63
docs/docker.md Normal file
View File

@@ -0,0 +1,63 @@
# Getting started with `go-proxy` docker container
## Setup
1. Install `wget` if not already
2. Run setup script
`bash <(wget -qO- https://6uo.me/go-proxy-setup-docker)`
What it does:
- Create required directories
- Setup `config.yml` and `compose.yml`
3. Verify folder structure and then `cd go-proxy`
```plain
go-proxy
├── certs
├── compose.yml
└── config
├── config.yml
└── providers.yml
```
4. Enable HTTPs _(optional)_
- To use autocert feature
- completing `autocert` section in `config/config.yml`
- mount `certs/` to `/app/certs` to store obtained certs
- To use existing certificate
mount your wildcard (`*.y.z`) SSL cert
- cert / chain / fullchain -> `/app/certs/cert.crt`
- private key -> `/app/certs/priv.key`
5. Modify `compose.yml` fit your needs
Add networks to make sure it is in the same network with other containers, or make sure `proxy.<alias>.host` is reachable
6. Run `docker compose up -d` to start the container
7. Start editing config files in `http://<ip>:8080`
## Troubleshooting
- Firewall issues
If you are using `ufw` with vpn that drop all inbound traffic except vpn, run below:
`sudo ufw allow from 172.16.0.0/16 to 100.64.0.0/10`
Explaination:
Docker network is usually `172.16.0.0/16`
Tailscale is used as an example, `100.64.0.0/10` will be the CIDR
You can also list CIDRs of all docker bridge networks by:
`docker network inspect $(docker network ls | awk '$3 == "bridge" { print $1}') | jq -r '.[] | .Name + " " + .IPAM.Config[0].Subnet' -`

View File

@@ -1,15 +0,0 @@
#!/bin/bash
if [ "$1" == "restart" ]; then
echo "restarting"
killall go-proxy
fi
if [ -z "$VERBOSITY" ]; then
VERBOSITY=1
fi
echo "starting with verbosity $VERBOSITY" > log/go-proxy.log
if [ "$DEBUG" == "1" ]; then
/app/go-proxy -v=$VERBOSITY -log_dir=log --stderrthreshold=0 2>> log/go-proxy.log &
tail -f /dev/null
else
/app/go-proxy -v=$VERBOSITY -log_dir=log --stderrthreshold=0 2>> log/go-proxy.log
fi

46
go.mod
View File

@@ -1,39 +1,53 @@
module github.com/yusing/go-proxy
go 1.21.7
require github.com/docker/docker v25.0.3+incompatible
require github.com/golang/glog v1.2.0
go 1.22
require (
github.com/containerd/log v0.1.0 // indirect
github.com/moby/term v0.5.0 // indirect
github.com/morikuni/aec v1.0.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.24.0 // indirect
go.opentelemetry.io/otel/sdk v1.24.0 // indirect
golang.org/x/mod v0.16.0 // indirect
golang.org/x/time v0.5.0 // indirect
golang.org/x/tools v0.19.0 // indirect
gotest.tools/v3 v3.5.1 // indirect
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
)
require (
github.com/Microsoft/go-winio v0.6.1 // indirect
github.com/distribution/reference v0.5.0 // 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.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
github.com/opencontainers/image-spec v1.1.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect
go.opentelemetry.io/otel v1.24.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.24.0 // indirect
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/net v0.22.0
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
golang.org/x/time v0.5.0 // indirect
golang.org/x/tools v0.19.0 // indirect
gotest.tools/v3 v3.5.1 // indirect
)

70
go.sum
View File

@@ -2,39 +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/docker v25.0.3+incompatible h1:D5fy/lYmY7bvZa0XTZ5/UJPljor41F+vdyJG5luQLfQ=
github.com/docker/docker v25.0.3+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/glog v1.2.0 h1:uCdmnmatrKCgMBlM4rMuJZWOkPDqdbZPnrMXDY4gI68=
github.com/golang/glog v1.2.0/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w=
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=
@@ -47,10 +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/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
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.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=
@@ -72,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=
@@ -90,6 +134,7 @@ golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -108,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=
@@ -116,6 +162,10 @@ 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/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=
gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU=

25
providers.example.yml Normal file
View File

@@ -0,0 +1,25 @@
example: # matching `app.y.z`
# optional, defaults to http
scheme:
# required, proxy target
host: 10.0.0.1
# optional, defaults to 80 for http, 443 for https
port: 80
# optional, defaults to empty
path:
# optional, defaults to sub
path_mode:
# 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

114
setup-binary.sh Normal file
View File

@@ -0,0 +1,114 @@
#!/bin/bash
set -e
REPO_URL=https://github.com/yusing/go-proxy
BIN_URL="${REPO_URL}/releases/download/${VERSION}/go-proxy"
SRC_URL="${REPO_URL}/archive/refs/tags/${VERSION}.tar.gz"
APP_ROOT="/opt/go-proxy/${VERSION}"
LOG_FILE="/tmp/go-proxy-setup.log"
if [ -z "$VERSION" ] || [ "$VERSION" = "latest" ]; then
VERSION_URL="${REPO_URL}/raw/main/version.txt"
VERSION=$(wget -qO- "$VERSION_URL")
fi
if [ -d "$APP_ROOT" ]; then
echo "$APP_ROOT already exists"
exit 1
fi
# check if wget exists
if ! [ -x "$(command -v wget)" ]; then
echo "wget is not installed"
exit 1
fi
# check if make exists
if ! [ -x "$(command -v make)" ]; then
echo "make is not installed"
exit 1
fi
dl_source() {
cd /tmp
echo "Downloading go-proxy source ${VERSION}"
wget -c "${SRC_URL}" -O go-proxy.tar.gz &> $LOG_FILE
if [ $? -gt 0 ]; then
echo "Source download failed, check your internet connection and version number"
exit 1
fi
echo "Done"
echo "Extracting go-proxy source ${VERSION}"
tar xzf go-proxy.tar.gz &> $LOG_FILE
if [ $? -gt 0 ]; then
echo "failed to untar go-proxy.tar.gz"
exit 1
fi
rm go-proxy.tar.gz
mkdir -p "$(dirname "${APP_ROOT}")"
mv "go-proxy-${VERSION}" "$APP_ROOT"
cd "$APP_ROOT"
echo "Done"
}
dl_binary() {
mkdir -p bin
echo "Downloading go-proxy binary ${VERSION}"
wget -c "${BIN_URL}" -O bin/go-proxy &> $LOG_FILE
if [ $? -gt 0 ]; then
echo "Binary download failed, check your internet connection and version number"
exit 1
fi
chmod +x bin/go-proxy
echo "Done"
}
setup() {
make setup &> $LOG_FILE
if [ $? -gt 0 ]; then
echo "make setup failed"
exit 1
fi
# SETUP_CODEMIRROR = 1
if [ "$SETUP_CODEMIRROR" != "0" ]; then
make setup-codemirror &> $LOG_FILE || echo "make setup-codemirror failed, ignored"
fi
}
dl_source
dl_binary
setup
# setup systemd
# check if systemctl exists
if ! command -v systemctl is-system-running > /dev/null 2>&1; then
echo "systemctl not found, skipping systemd setup"
exit 0
fi
systemctl_failed() {
echo "Failed to enable and start go-proxy"
systemctl status go-proxy
exit 1
}
echo "Setting up systemd service"
cat <<EOF > /etc/systemd/system/go-proxy.service
[Unit]
Description=go-proxy reverse proxy
After=network-online.target
Wants=network-online.target systemd-networkd-wait-online.service
[Service]
Type=simple
ExecStart=${APP_ROOT}/bin/go-proxy
WorkingDirectory=${APP_ROOT}
Environment="IS_SYSTEMD=1"
Restart=on-failure
RestartSec=1s
KillMode=process
KillSignal=SIGINT
TimeoutStartSec=5s
TimeoutStopSec=5s
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload &>$LOG_FILE || systemctl_failed
systemctl enable --now go-proxy &>$LOG_FILE || systemctl_failed
echo "Done"
echo "Setup complete"

14
setup-docker.sh Normal file
View File

@@ -0,0 +1,14 @@
#!/bin/sh
set -e
if [ -z "$BRANCH" ]; then
BRANCH="main"
fi
BASE_URL="https://github.com/yusing/go-proxy/raw/${BRANCH}"
mkdir -p go-proxy
cd go-proxy
mkdir -p config
mkdir -p certs
[ -f compose.yml ] || wget -cO - ${BASE_URL}/compose.example.yml > compose.yml
[ -f config/config.yml ] || wget -cO - ${BASE_URL}/config.example.yml > config/config.yml
[ -f config/providers.yml ] || touch config/providers.yml

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),
}

201
src/go-proxy/config.go Normal file
View File

@@ -0,0 +1,201 @@
package main
import (
"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
StartProviders()
StopProviders()
WatchChanges()
StopWatching()
}
func NewConfig(path string) Config {
cfg := &config{
reader: &FileReader{Path: path},
}
cfg.watcher = NewFileWatcher(
path,
cfg.MustReload, // OnChange
func() { os.Exit(1) }, // OnDelete
)
return cfg
}
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()
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),
)
}
}
if pErrs.HasExtras() {
ne.With(pErrs)
}
if ne.HasInner() {
return ne
}
cfg.m = model
return nil
}
func (cfg *config) MustLoad() {
if err := cfg.Load(); err != nil {
cfgl.Fatal(err)
}
}
func (cfg *config) GetAutoCertProvider() (AutoCertProvider, error) {
return cfg.m.AutoCert.GetProvider()
}
func (cfg *config) Reload() error {
cfg.StopProviders()
if err := cfg.Load(); err != nil {
return err
}
cfg.StartProviders()
return nil
}
func (cfg *config) MustReload() {
if err := cfg.Reload(); err != nil {
cfgl.Fatal(err)
}
}
func (cfg *config) StartProviders() {
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() {
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 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

@@ -1,9 +1,18 @@
package main
import "time"
import (
"crypto/tls"
"net"
"net/http"
"os"
"time"
"github.com/santhosh-tekuri/jsonschema"
"github.com/sirupsen/logrus"
)
var (
ImageNamePortMap = map[string]string{
ImageNamePortMapTCP = map[string]string{
"postgres": "5432",
"mysql": "3306",
"mariadb": "3306",
@@ -13,7 +22,7 @@ var (
"rabbitmq": "5672",
"mongo": "27017",
}
ExtraNamePortMap = map[string]string{
ExtraNamePortMapTCP = map[string]string{
"dns": "53",
"ssh": "22",
"ftp": "21",
@@ -21,27 +30,53 @@ var (
"pop3": "110",
"imap": "143",
}
NamePortMap = func() map[string]string {
NamePortMapTCP = func() map[string]string {
m := make(map[string]string)
for k, v := range ImageNamePortMap {
for k, v := range ImageNamePortMapTCP {
m[k] = v
}
for k, v := range ExtraNamePortMap {
for k, v := range ExtraNamePortMapTCP {
m[k] = v
}
return m
}()
)
var ImageNamePortMapHTTP = map[string]uint16{
"nginx": 80,
"httpd": 80,
"adguardhome": 3000,
"gogs": 3000,
"gitea": 3000,
"portainer": 9000,
"portainer-ce": 9000,
"home-assistant": 8123,
"homebridge": 8581,
"uptime-kuma": 3001,
"changedetection.io": 3000,
"prometheus": 9090,
"grafana": 3000,
"dockge": 5001,
"nginx-proxy-manager": 81,
}
var wellKnownHTTPPorts = map[uint16]bool{
80: true,
8000: true,
8008: true,
8080: true,
3000: true,
}
var (
StreamSchemes = []string{TCPStreamType, UDPStreamType} // TODO: support "tcp:udp", "udp:tcp"
StreamSchemes = []string{StreamType_TCP, StreamType_UDP} // TODO: support "tcp:udp", "udp:tcp"
HTTPSchemes = []string{"http", "https"}
ValidSchemes = append(StreamSchemes, HTTPSchemes...)
)
const (
UDPStreamType = "udp"
TCPStreamType = "tcp"
StreamType_UDP = "udp"
StreamType_TCP = "tcp"
)
const (
@@ -50,8 +85,98 @@ const (
ProxyPathMode_RemovedPath = ""
)
const StreamStopListenTimeout = 1 * time.Second
const (
ProviderKind_Docker = "docker"
ProviderKind_File = "file"
)
const templateFile = "/app/templates/panel.html"
// 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,
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
},
}
)
const wildcardLabelPrefix = "proxy.*."
const clientUrlFromEnv = "FROM_ENV"
const (
certBasePath = "certs/"
certFileDefault = certBasePath + "cert.crt"
keyFileDefault = certBasePath + "priv.key"
configBasePath = "config/"
configPath = configBasePath + "config.yml"
templatesBasePath = "templates/"
panelTemplatePath = templatesBasePath + "panel/index.html"
configEditorTemplatePath = templatesBasePath + "config_editor/index.html"
schemaBasePath = "schema/"
configSchemaPath = schemaBasePath + "config.schema.json"
providersSchemaPath = schemaBasePath + "providers.schema.json"
)
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
var logLevel = func() logrus.Level {
switch os.Getenv("GOPROXY_DEBUG") {
case "1", "true":
logrus.SetLevel(logrus.DebugLevel)
}
return logrus.GetLevel()
}()
var isRunningAsService = func() bool {
v := os.Getenv("IS_SYSTEMD")
return v == "1"
}()

View File

@@ -1,139 +0,0 @@
package main
import (
"fmt"
"os"
"reflect"
"sort"
"strings"
"sync"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/client"
"github.com/golang/glog"
"golang.org/x/net/context"
)
var dockerClient *client.Client
func buildContainerRoute(container types.Container) {
var aliases []string
var wg sync.WaitGroup
container_name := strings.TrimPrefix(container.Names[0], "/")
aliases_label, ok := container.Labels["proxy.aliases"]
if !ok {
aliases = []string{container_name}
} else {
aliases = strings.Split(aliases_label, ",")
}
for _, alias := range aliases {
config := NewProxyConfig()
prefix := fmt.Sprintf("proxy.%s.", alias)
for label, value := range container.Labels {
if strings.HasPrefix(label, prefix) {
field := strings.TrimPrefix(label, prefix)
field = utils.snakeToCamel(field)
prop := reflect.ValueOf(&config).Elem().FieldByName(field)
if prop.Kind() == 0 {
glog.Infof("[Build] %s: ignoring unknown field %s", alias, field)
continue
}
prop.Set(reflect.ValueOf(value))
}
}
if config.Port == "" {
// usually the smaller port is the http one
// so make it the last one to be set (if 80 or 8080 are not exposed)
sort.Slice(container.Ports, func(i, j int) bool {
return container.Ports[i].PrivatePort > container.Ports[j].PrivatePort
})
for _, port := range container.Ports {
// set first, but keep trying
config.Port = fmt.Sprintf("%d", port.PrivatePort)
// until we find 80 or 8080
if port.PrivatePort == 80 || port.PrivatePort == 8080 {
break
}
}
}
if config.Port == "" {
// no ports exposed or specified
glog.Infof("[Build] %s has no port exposed", alias)
return
}
if config.Scheme == "" {
if strings.HasSuffix(config.Port, "443") {
config.Scheme = "https"
} else if strings.HasPrefix(container.Image, "sha256:") {
config.Scheme = "http"
} else {
imageSplit := strings.Split(container.Image, "/")
imageSplit = strings.Split(imageSplit[len(imageSplit)-1], ":")
imageName := imageSplit[0]
_, isKnownImage := ImageNamePortMap[imageName]
if isKnownImage {
config.Scheme = "tcp"
} else {
config.Scheme = "http"
}
}
}
if !isValidScheme(config.Scheme) {
glog.Infof("%s: unsupported scheme: %s, using http", container_name, config.Scheme)
config.Scheme = "http"
}
if config.Host == "" {
switch {
case container.HostConfig.NetworkMode == "host":
config.Host = "host.docker.internal"
case config.LoadBalance == "true":
case config.LoadBalance == "1":
for _, network := range container.NetworkSettings.Networks {
config.Host = network.IPAddress
break
}
default:
for _, network := range container.NetworkSettings.Networks {
for _, alias := range network.Aliases {
config.Host = alias
break
}
}
}
}
if config.Host == "" {
config.Host = container_name
}
config.Alias = alias
config.UpdateId()
wg.Add(1)
go func() {
CreateRoute(&config)
wg.Done()
}()
}
wg.Wait()
}
func buildRoutes() {
InitRoutes()
containerSlice, err := dockerClient.ContainerList(context.Background(), container.ListOptions{})
if err != nil {
glog.Fatal(err)
}
hostname, err := os.Hostname()
if err != nil {
hostname = "go-proxy"
}
for _, container := range containerSlice {
if container.Names[0] == hostname { // skip self
glog.Infof("[Build] Skipping %s", container.Names[0])
continue
}
buildContainerRoute(container)
}
}

234
src/go-proxy/docker_provider.go Executable file
View File

@@ -0,0 +1,234 @@
package main
import (
"fmt"
"net/http"
"strings"
"time"
"github.com/docker/cli/cli/connhelper"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/client"
"golang.org/x/net/context"
)
func (p *Provider) setConfigField(c *ProxyConfig, label string, value string, prefix string) error {
if strings.HasPrefix(label, prefix) {
field := strings.TrimPrefix(label, prefix)
if err := setFieldFromSnake(c, field, value); err != nil {
return err
}
}
return nil
}
func (p *Provider) getContainerProxyConfigs(container *types.Container, clientIP string) ProxyConfigSlice {
var aliases []string
cfgs := make(ProxyConfigSlice, 0)
containerName := strings.TrimPrefix(container.Names[0], "/")
aliasesLabel, ok := container.Labels["proxy.aliases"]
if !ok {
aliases = []string{containerName}
} else {
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", 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 {
ne.ExtraError(NewNestedErrorFrom(err).Subjectf("alias %s", alias))
}
err = p.setConfigField(&config, label, value, wildcardLabelPrefix)
if err != nil {
ne.ExtraError(NewNestedErrorFrom(err).Subjectf("alias %s", alias))
}
}
if config.Port == "" {
config.Port = fmt.Sprintf("%d", selectPort(container))
}
if config.Port == "0" {
l.Infof("no ports exposed, ignored")
continue
}
if config.Scheme == "" {
switch {
case strings.HasSuffix(config.Port, "443"):
config.Scheme = "https"
case strings.HasPrefix(container.Image, "sha256:"):
config.Scheme = "http"
default:
imageName := getImageName(container)
_, isKnownImage := ImageNamePortMapTCP[imageName]
if isKnownImage {
config.Scheme = "tcp"
} else {
config.Scheme = "http"
}
}
}
if !isValidScheme(config.Scheme) {
l.Warnf("unsupported scheme: %s, using http", config.Scheme)
config.Scheme = "http"
}
if config.Host == "" {
switch {
case isRemote:
config.Host = clientIP
case container.HostConfig.NetworkMode == "host":
config.Host = "host.docker.internal"
case config.LoadBalance == "true", config.LoadBalance == "1":
for _, network := range container.NetworkSettings.Networks {
config.Host = network.IPAddress
break
}
default:
for _, network := range container.NetworkSettings.Networks {
for _, alias := range network.Aliases {
config.Host = alias
break
}
}
}
}
if config.Host == "" {
config.Host = containerName
}
config.Alias = alias
cfgs = append(cfgs, config)
}
return cfgs
}
func (p *Provider) getDockerClient() (*client.Client, error) {
var dockerOpts []client.Opt
if p.Value == clientUrlFromEnv {
dockerOpts = []client.Opt{
client.WithHostFromEnv(),
client.WithAPIVersionNegotiation(),
}
} else {
helper, err := connhelper.GetConnectionHelper(p.Value)
if err != nil {
p.l.Fatal("unexpected error: ", err)
}
if helper != nil {
httpClient := &http.Client{
Transport: &http.Transport{
DialContext: helper.Dialer,
},
}
dockerOpts = []client.Opt{
client.WithHTTPClient(httpClient),
client.WithHost(helper.Host),
client.WithAPIVersionNegotiation(),
client.WithDialContext(helper.Dialer),
}
} else {
dockerOpts = []client.Opt{
client.WithHost(p.Value),
client.WithAPIVersionNegotiation(),
}
}
}
return client.NewClientWithOpts(dockerOpts...)
}
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]
}
dockerClient, err := p.getDockerClient()
if err != nil {
return nil, NewNestedError("unable to create docker client").With(err)
}
ctx, _ := context.WithTimeout(context.Background(), 3*time.Second)
containerSlice, err := dockerClient.ContainerList(ctx, container.ListOptions{All: true})
if err != nil {
return nil, NewNestedError("unable to list containers").With(err)
}
cfgs := make(ProxyConfigSlice, 0)
for _, container := range containerSlice {
cfgs = append(cfgs, p.getContainerProxyConfigs(&container, clientIP)...)
}
return cfgs, nil
}
// var dockerUrlRegex = regexp.MustCompile(`^(?P<scheme>\w+)://(?P<host>[^:]+)(?P<port>:\d+)?(?P<path>/.*)?$`)
func getImageName(c *types.Container) string {
imageSplit := strings.Split(c.Image, "/")
imageSplit = strings.Split(imageSplit[len(imageSplit)-1], ":")
return imageSplit[0]
}
func getPublicPort(p types.Port) uint16 { return p.PublicPort }
func getPrivatePort(p types.Port) uint16 { return p.PrivatePort }
func selectPort(c *types.Container) uint16 {
if c.HostConfig.NetworkMode == "host" {
return selectPortInternal(c, getPublicPort)
}
return selectPortInternal(c, getPrivatePort)
}
func selectPortInternal(c *types.Container, getPort func(types.Port) uint16) uint16 {
imageName := getImageName(c)
// if is known image -> use known port
if port, isKnown := ImageNamePortMapHTTP[imageName]; isKnown {
for _, p := range c.Ports {
if p.PrivatePort == port {
return getPort(p)
}
}
}
// if it has known http port -> use it
for _, p := range c.Ports {
if isWellKnownHTTPPort(p.PrivatePort) {
return getPort(p)
}
}
// if it has any port -> use it
for _, p := range c.Ports {
if port := getPort(p); port != 0 {
return port
}
}
return 0
}
func isWellKnownHTTPPort(port uint16) bool {
_, ok := wellKnownHTTPPorts[port]
return ok
}

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

@@ -0,0 +1,54 @@
package main
import (
"os"
"path"
"gopkg.in/yaml.v3"
)
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,39 @@
package main
import "sync"
func ParallelForEach[T interface{}](obj []T, do func(T)) {
var wg sync.WaitGroup
wg.Add(len(obj))
for _, v := range obj {
go func(v T) {
do(v)
wg.Done()
}(v)
}
wg.Wait()
}
func ParallelForEachValue[K comparable, V interface{}](obj map[K]V, do func(V)) {
var wg sync.WaitGroup
wg.Add(len(obj))
for _, v := range obj {
go func(v V) {
do(v)
wg.Done()
}(v)
}
wg.Wait()
}
func ParallelForEachKeyValue[K comparable, V interface{}](obj map[K]V, do func(K, V)) {
var wg sync.WaitGroup
wg.Add(len(obj))
for k, v := range obj {
go func(k K, v V) {
do(k, v)
wg.Done()
}(k, v)
}
wg.Wait()
}

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

@@ -2,112 +2,91 @@ package main
import (
"fmt"
"net"
"net/http"
"net/http/httputil"
"net/url"
"strings"
"time"
"github.com/golang/glog"
"github.com/sirupsen/logrus"
)
type HTTPRoute struct {
Alias string
Url *url.URL
Path string
PathMode string
Proxy *httputil.ReverseProxy
}
Proxy *ReverseProxy
func isValidProxyPathMode(mode string) bool {
switch mode {
case ProxyPathMode_Forward, ProxyPathMode_Sub, ProxyPathMode_RemovedPath:
return true
default:
return false
}
l logrus.FieldLogger
}
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 {
glog.Infoln(err)
return nil, err
return nil, NewNestedErrorf("invalid url").Subject(u).With(err)
}
proxy := httputil.NewSingleHostReverseProxy(url)
proxy.Transport = transport
if !isValidProxyPathMode(config.PathMode) {
return nil, fmt.Errorf("invalid path mode: %s", config.PathMode)
var tr *http.Transport
if config.NoTLSVerify {
tr = transportNoTLS
} else {
tr = transport
}
proxy := NewSingleHostReverseProxy(url, tr)
route := &HTTPRoute{
Alias: config.Alias,
Url: url,
Path: config.Path,
Proxy: proxy,
PathMode: config.PathMode,
l: hrlog.WithFields(logrus.Fields{
"alias": config.Alias,
// "path": config.Path,
// "path_mode": config.PathMode,
}),
}
director := proxy.Director
proxy.Director = nil
initRewrite := func(pr *httputil.ProxyRequest) {
director(pr.Out)
}
rewrite := initRewrite
var rewriteBegin = proxy.Rewrite
var rewrite func(*ProxyRequest)
var modifyResponse func(*http.Response) error
switch {
case config.Path == "", config.PathMode == ProxyPathMode_Forward:
break
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 *httputil.ProxyRequest) {
initRewrite(pr)
rewrite = func(pr *ProxyRequest) {
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 {
if glog.V(3) {
glog.Infof("[Path sub] unknown content type for %s", 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:
glog.V(4).Infof("[Path sub] unknown content type(s): %s", contentType)
}
if err != nil {
err = fmt.Errorf("[Path sub] failed to remove path prefix %s: %v", config.Path, err)
r.Status = err.Error()
r.StatusCode = http.StatusInternalServerError
}
return err
}
modifyResponse = config.pathSubModResp
default:
rewrite = func(pr *httputil.ProxyRequest) {
initRewrite(pr)
pr.Out.URL.Path = strings.TrimPrefix(pr.Out.URL.Path, config.Path)
}
return nil, NewNestedError("invalid path mode").Subject(config.PathMode)
}
if glog.V(3) {
route.Proxy.Rewrite = func(pr *httputil.ProxyRequest) {
if logLevel == logrus.DebugLevel {
route.Proxy.Rewrite = func(pr *ProxyRequest) {
rewrite(pr)
r := pr.In
glog.Infof("[Request] %s %s%s", r.Method, r.Host, r.URL.Path)
glog.V(5).InfoDepthf(1, "Headers: %v", r.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
@@ -116,14 +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) Stop() {
httpRoutes.Delete(r.Alias)
}
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 {
@@ -136,35 +115,48 @@ func redirectToTLS(w http.ResponseWriter, r *http.Request) {
func findHTTPRoute(host string, path string) (*HTTPRoute, error) {
subdomain := strings.Split(host, ".")[0]
routeMap, ok := routes.HTTPRoutes.UnsafeGet(subdomain)
if !ok {
return nil, fmt.Errorf("no matching route for subdomain %s", subdomain)
routeMap, ok := httpRoutes.UnsafeGet(subdomain)
if ok {
return routeMap.FindMatch(path)
}
return routeMap.FindMatch(path)
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, err.Error(), http.StatusNotFound)
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)
return
}
route.Proxy.ServeHTTP(w, r)
}
// 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,
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 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

@@ -0,0 +1,833 @@
package main
// A small mod on net/http/httputils
// that doubled the performance
import (
"context"
"errors"
"fmt"
"io"
"log"
"net"
"net/http"
"net/http/httptrace"
"net/textproto"
"net/url"
"strings"
"time"
"golang.org/x/net/http/httpguts"
)
// A ProxyRequest contains a request to be rewritten by a [ReverseProxy].
type ProxyRequest struct {
// In is the request received by the proxy.
// The Rewrite function must not modify In.
In *http.Request
// Out is the request which will be sent by the proxy.
// The Rewrite function may modify or replace this request.
// Hop-by-hop headers are removed from this request
// before Rewrite is called.
Out *http.Request
}
// SetURL routes the outbound request to the scheme, host, and base path
// provided in target. If the target's path is "/base" and the incoming
// request was for "/dir", the target request will be for "/base/dir".
//
// SetURL rewrites the outbound Host header to match the target's host.
// To preserve the inbound request's Host header (the default behavior
// of [NewSingleHostReverseProxy]):
//
// rewriteFunc := func(r *httputil.ProxyRequest) {
// r.SetURL(url)
// r.Out.Host = r.In.Host
// }
func (r *ProxyRequest) SetURL(target *url.URL) {
rewriteRequestURL(r.Out, target)
r.Out.Host = ""
}
// SetXForwarded sets the X-Forwarded-For, X-Forwarded-Host, and
// X-Forwarded-Proto headers of the outbound request.
//
// - The X-Forwarded-For header is set to the client IP address.
// - The X-Forwarded-Host header is set to the host name requested
// by the client.
// - The X-Forwarded-Proto header is set to "http" or "https", depending
// on whether the inbound request was made on a TLS-enabled connection.
//
// If the outbound request contains an existing X-Forwarded-For header,
// SetXForwarded appends the client IP address to it. To append to the
// inbound request's X-Forwarded-For header (the default behavior of
// [ReverseProxy] when using a Director function), copy the header
// from the inbound request before calling SetXForwarded:
//
// rewriteFunc := func(r *httputil.ProxyRequest) {
// r.Out.Header["X-Forwarded-For"] = r.In.Header["X-Forwarded-For"]
// r.SetXForwarded()
// }
func (r *ProxyRequest) SetXForwarded() {
clientIP, _, err := net.SplitHostPort(r.In.RemoteAddr)
if err == nil {
prior := r.Out.Header["X-Forwarded-For"]
if len(prior) > 0 {
clientIP = strings.Join(prior, ", ") + ", " + clientIP
}
r.Out.Header.Set("X-Forwarded-For", clientIP)
} else {
r.Out.Header.Del("X-Forwarded-For")
}
r.Out.Header.Set("X-Forwarded-Host", r.In.Host)
if r.In.TLS == nil {
r.Out.Header.Set("X-Forwarded-Proto", "http")
} else {
r.Out.Header.Set("X-Forwarded-Proto", "https")
}
}
// ReverseProxy is an HTTP Handler that takes an incoming request and
// sends it to another server, proxying the response back to the
// client.
//
// 1xx responses are forwarded to the client if the underlying
// transport supports ClientTrace.Got1xxResponse.
type ReverseProxy struct {
// Rewrite must be a function which modifies
// the request into a new request to be sent
// using Transport. Its response is then copied
// back to the original client unmodified.
// Rewrite must not access the provided ProxyRequest
// or its contents after returning.
//
// The Forwarded, X-Forwarded, X-Forwarded-Host,
// and X-Forwarded-Proto headers are removed from the
// outbound request before Rewrite is called. See also
// the ProxyRequest.SetXForwarded method.
//
// Unparsable query parameters are removed from the
// outbound request before Rewrite is called.
// The Rewrite function may copy the inbound URL's
// RawQuery to the outbound URL to preserve the original
// parameter string. Note that this can lead to security
// issues if the proxy's interpretation of query parameters
// does not match that of the downstream server.
//
// At most one of Rewrite or Director may be set.
Rewrite func(*ProxyRequest)
// The transport used to perform proxy requests.
// If nil, http.DefaultTransport is used.
Transport http.RoundTripper
// FlushInterval specifies the flush interval
// to flush to the client while copying the
// response body.
// If zero, no periodic flushing is done.
// A negative value means to flush immediately
// after each write to the client.
// The FlushInterval is ignored when ReverseProxy
// recognizes a response as a streaming response, or
// if its ContentLength is -1; for such responses, writes
// are flushed to the client immediately.
FlushInterval time.Duration
// ErrorLog specifies an optional logger for errors
// that occur when attempting to proxy the request.
// If nil, logging is done via the log package's standard logger.
ErrorLog *log.Logger
// BufferPool optionally specifies a buffer pool to
// get byte slices for use by io.CopyBuffer when
// copying HTTP response bodies.
BufferPool BufferPool
// ModifyResponse is an optional function that modifies the
// Response from the backend. It is called if the backend
// returns a response at all, with any HTTP status code.
// If the backend is unreachable, the optional ErrorHandler is
// called without any call to ModifyResponse.
//
// If ModifyResponse returns an error, ErrorHandler is called
// with its error value. If ErrorHandler is nil, its default
// implementation is used.
ModifyResponse func(*http.Response) error
// ErrorHandler is an optional function that handles errors
// reaching the backend or errors from ModifyResponse.
//
// If nil, the default is to log the provided error and return
// a 502 Status Bad Gateway response.
ErrorHandler func(http.ResponseWriter, *http.Request, error)
}
// A BufferPool is an interface for getting and returning temporary
// byte slices for use by [io.CopyBuffer].
type BufferPool interface {
Get() []byte
Put([]byte)
}
func singleJoiningSlash(a, b string) string {
aslash := strings.HasSuffix(a, "/")
bslash := strings.HasPrefix(b, "/")
switch {
case aslash && bslash:
return a + b[1:]
case !aslash && !bslash:
return a + "/" + b
}
return a + b
}
func joinURLPath(a, b *url.URL) (path, rawpath string) {
if a.RawPath == "" && b.RawPath == "" {
return singleJoiningSlash(a.Path, b.Path), ""
}
// Same as singleJoiningSlash, but uses EscapedPath to determine
// whether a slash should be added
apath := a.EscapedPath()
bpath := b.EscapedPath()
aslash := strings.HasSuffix(apath, "/")
bslash := strings.HasPrefix(bpath, "/")
switch {
case aslash && bslash:
return a.Path + b.Path[1:], apath + bpath[1:]
case !aslash && !bslash:
return a.Path + "/" + b.Path, apath + "/" + bpath
}
return a.Path + b.Path, apath + bpath
}
// NewSingleHostReverseProxy returns a new [ReverseProxy] that routes
// URLs to the scheme, host, and base path provided in target. If the
// target's path is "/base" and the incoming request was for "/dir",
// the target request will be for /base/dir.
//
// NewSingleHostReverseProxy does not rewrite the Host header.
//
// To customize the ReverseProxy behavior beyond what
// NewSingleHostReverseProxy provides, use ReverseProxy directly
// with a Rewrite function. The ProxyRequest SetURL method
// may be used to route the outbound request. (Note that SetURL,
// unlike NewSingleHostReverseProxy, rewrites the Host header
// of the outbound request by default.)
//
// proxy := &ReverseProxy{
// Rewrite: func(r *ProxyRequest) {
// r.SetURL(target)
// r.Out.Host = r.In.Host // if desired
// },
// }
func NewSingleHostReverseProxy(target *url.URL, transport *http.Transport) *ReverseProxy {
return &ReverseProxy{Rewrite: func(pr *ProxyRequest) {
rewriteRequestURL(pr.Out, target)
}, Transport: transport}
}
func rewriteRequestURL(req *http.Request, target *url.URL) {
targetQuery := target.RawQuery
req.URL.Scheme = target.Scheme
req.URL.Host = target.Host
req.URL.Path, req.URL.RawPath = joinURLPath(target, req.URL)
if targetQuery == "" || req.URL.RawQuery == "" {
req.URL.RawQuery = targetQuery + req.URL.RawQuery
} else {
req.URL.RawQuery = targetQuery + "&" + req.URL.RawQuery
}
}
func copyHeader(dst, src http.Header) {
for k, vv := range src {
for _, v := range vv {
dst.Add(k, v)
}
}
}
// Hop-by-hop headers. These are removed when sent to the backend.
// As of RFC 7230, hop-by-hop headers are required to appear in the
// Connection header field. These are the headers defined by the
// obsoleted RFC 2616 (section 13.5.1) and are used for backward
// compatibility.
// var hopHeaders = []string{
// "Connection",
// "Proxy-Connection", // non-standard but still sent by libcurl and rejected by e.g. google
// "Keep-Alive",
// "Proxy-Authenticate",
// "Proxy-Authorization",
// "Te", // canonicalized version of "TE"
// "Trailer", // not Trailers per URL above; https://www.rfc-editor.org/errata_search.php?eid=4522
// "Transfer-Encoding",
// "Upgrade",
// }
// NOTE: getErrorHandler and DefaultErrorHandler removed
func (p *ReverseProxy) errorHandler(rw http.ResponseWriter, _ *http.Request, err error) {
p.logf("http: proxy error: %v", err)
rw.WriteHeader(http.StatusBadGateway)
}
// modifyResponse conditionally runs the optional ModifyResponse hook
// and reports whether the request should proceed.
func (p *ReverseProxy) modifyResponse(rw http.ResponseWriter, res *http.Response, req *http.Request) bool {
if p.ModifyResponse == nil {
return true
}
if err := p.ModifyResponse(res); err != nil {
res.Body.Close()
p.errorHandler(rw, req, err)
return false
}
return true
}
func (p *ReverseProxy) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
transport := p.Transport
// Note: removed
// if transport == nil {
// transport = http.DefaultTransport
// }
ctx := req.Context()
if ctx.Done() != nil {
// CloseNotifier predates context.Context, and has been
// entirely superseded by it. If the request contains
// a Context that carries a cancellation signal, don't
// bother spinning up a goroutine to watch the CloseNotify
// channel (if any).
//
// If the request Context has a nil Done channel (which
// means it is either context.Background, or a custom
// Context implementation with no cancellation signal),
// then consult the CloseNotifier if available.
} else if cn, ok := rw.(http.CloseNotifier); ok {
var cancel context.CancelFunc
ctx, cancel = context.WithCancel(ctx)
defer cancel()
notifyChan := cn.CloseNotify()
go func() {
select {
case <-notifyChan:
cancel()
case <-ctx.Done():
}
}()
}
outreq := req.Clone(ctx)
if req.ContentLength == 0 {
outreq.Body = nil // Issue 16036: nil Body for http.Transport retries
}
if outreq.Body != nil {
// Reading from the request body after returning from a handler is not
// allowed, and the RoundTrip goroutine that reads the Body can outlive
// this handler. This can lead to a crash if the handler panics (see
// Issue 46866). Although calling Close doesn't guarantee there isn't
// any Read in flight after the handle returns, in practice it's safe to
// read after closing it.
defer outreq.Body.Close()
}
if outreq.Header == nil {
outreq.Header = make(http.Header) // Issue 33142: historical behavior was to always allocate
}
// NOTE: removed
// if (p.Director != nil) == (p.Rewrite != nil) {
// p.errorHandler(rw, req, errors.New("ReverseProxy must have exactly one of Director or Rewrite set"))
// return
// }
// if p.Director != nil {
// p.Director(outreq)
// if outreq.Form != nil {
// outreq.URL.RawQuery = cleanQueryParams(outreq.URL.RawQuery)
// }
// }
outreq.Close = false
reqUpType := upgradeType(outreq.Header)
if !IsPrint(reqUpType) {
p.errorHandler(rw, req, fmt.Errorf("client tried to switch to invalid protocol %q", reqUpType))
return
}
// NOTE: removed
// removeHopByHopHeaders(outreq.Header)
// Issue 21096: tell backend applications that care about trailer support
// that we support trailers. (We do, but we don't go out of our way to
// advertise that unless the incoming client request thought it was worth
// mentioning.) Note that we look at req.Header, not outreq.Header, since
// the latter has passed through removeHopByHopHeaders.
if httpguts.HeaderValuesContainsToken(req.Header["Te"], "trailers") {
outreq.Header.Set("Te", "trailers")
}
// After stripping all the hop-by-hop connection headers above, add back any
// necessary for protocol upgrades, such as for websockets.
if reqUpType != "" {
outreq.Header.Set("Connection", "Upgrade")
outreq.Header.Set("Upgrade", reqUpType)
}
// NOTE: removed
// if p.Rewrite != nil {
// Strip client-provided forwarding headers.
// The Rewrite func may use SetXForwarded to set new values
// for these or copy the previous values from the inbound request.
// outreq.Header.Del("Forwarded")
// outreq.Header.Del("X-Forwarded-For")
// outreq.Header.Del("X-Forwarded-Host")
// outreq.Header.Del("X-Forwarded-Proto")
// NOTE: removed
// Remove unparsable query parameters from the outbound request.
// outreq.URL.RawQuery = cleanQueryParams(outreq.URL.RawQuery)
pr := &ProxyRequest{
In: req,
Out: outreq,
}
pr.SetXForwarded() // NOTE: added
p.Rewrite(pr)
outreq = pr.Out
// NOTE: removed
// } else {
// if clientIP, _, err := net.SplitHostPort(req.RemoteAddr); err == nil {
// // If we aren't the first proxy retain prior
// // X-Forwarded-For information as a comma+space
// // separated list and fold multiple headers into one.
// prior, ok := outreq.Header["X-Forwarded-For"]
// omit := ok && prior == nil // Issue 38079: nil now means don't populate the header
// if len(prior) > 0 {
// clientIP = strings.Join(prior, ", ") + ", " + clientIP
// }
// if !omit {
// outreq.Header.Set("X-Forwarded-For", clientIP)
// }
// }
// }
if _, ok := outreq.Header["User-Agent"]; !ok {
// If the outbound request doesn't have a User-Agent header set,
// don't send the default Go HTTP client User-Agent.
outreq.Header.Set("User-Agent", "")
}
trace := &httptrace.ClientTrace{
Got1xxResponse: func(code int, header textproto.MIMEHeader) error {
h := rw.Header()
// copyHeader(h, http.Header(header))
for k, vv := range header {
for _, v := range vv {
h.Add(k, v)
}
}
rw.WriteHeader(code)
// Clear headers, it's not automatically done by ResponseWriter.WriteHeader() for 1xx responses
clear(h)
return nil
},
}
outreq = outreq.WithContext(httptrace.WithClientTrace(outreq.Context(), trace))
res, err := transport.RoundTrip(outreq)
if err != nil {
p.errorHandler(rw, outreq, err)
return
}
// Deal with 101 Switching Protocols responses: (WebSocket, h2c, etc)
if res.StatusCode == http.StatusSwitchingProtocols {
if !p.modifyResponse(rw, res, outreq) {
return
}
p.handleUpgradeResponse(rw, outreq, res)
return
}
// NOTE: removed
// removeHopByHopHeaders(res.Header)
if !p.modifyResponse(rw, res, outreq) {
return
}
copyHeader(rw.Header(), res.Header)
// The "Trailer" header isn't included in the Transport's response,
// at least for *http.Transport. Build it up from Trailer.
announcedTrailers := len(res.Trailer)
if announcedTrailers > 0 {
trailerKeys := make([]string, 0, len(res.Trailer))
for k := range res.Trailer {
trailerKeys = append(trailerKeys, k)
}
rw.Header().Add("Trailer", strings.Join(trailerKeys, ", "))
}
rw.WriteHeader(res.StatusCode)
// 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 {
defer res.Body.Close()
// note: removed
// Since we're streaming the response, if we run into an error all we can do
// is abort the request. Issue 23643: ReverseProxy should use ErrAbortHandler
// on read error while copying body.
// if !shouldPanicOnCopyError(req) {
// p.logf("suppressing panic for copyResponse error in test; copy error: %v", err)
// return
// }
panic(http.ErrAbortHandler)
}
res.Body.Close() // close now, instead of defer, to populate res.Trailer
if len(res.Trailer) > 0 {
// Force chunking if we saw a response trailer.
// This prevents net/http from calculating the length for short
// bodies and adding a Content-Length.
http.NewResponseController(rw).Flush()
}
if len(res.Trailer) == announcedTrailers {
copyHeader(rw.Header(), res.Trailer)
return
}
for k, vv := range res.Trailer {
k = http.TrailerPrefix + k
for _, v := range vv {
rw.Header().Add(k, v)
}
}
}
// var inOurTests bool // whether we're in our own tests
// NOTE: removed
// shouldPanicOnCopyError reports whether the reverse proxy should
// panic with http.ErrAbortHandler. This is the right thing to do by
// default, but Go 1.10 and earlier did not, so existing unit tests
// weren't expecting panics. Only panic in our own tests, or when
// running under the HTTP server.
// func shouldPanicOnCopyError(req *http.Request) bool {
// if inOurTests {
// // Our tests know to handle this panic.
// return true
// }
// if req.Context().Value(http.ServerContextKey) != nil {
// // We seem to be running under an HTTP server, so
// // it'll recover the panic.
// return true
// }
// // Otherwise act like Go 1.10 and earlier to not break
// // existing tests.
// return false
// }
// removeHopByHopHeaders removes hop-by-hop headers.
//
// func removeHopByHopHeaders(h http.Header) {
// // RFC 7230, section 6.1: Remove headers listed in the "Connection" header.
// for _, f := range h["Connection"] {
// for _, sf := range strings.Split(f, ",") {
// if sf = textproto.TrimString(sf); sf != "" {
// h.Del(sf)
// }
// }
// }
// // RFC 2616, section 13.5.1: Remove a set of known hop-by-hop headers.
// // This behavior is superseded by the RFC 7230 Connection header, but
// // preserve it for backwards compatibility.
// for _, f := range hopHeaders {
// h.Del(f)
// }
// }
// NOTE: removed
// flushInterval returns the p.FlushInterval value, conditionally
// overriding its value for a specific request/response.
// func (p *ReverseProxy) flushInterval(res *http.Response) time.Duration {
// resCT := res.Header.Get("Content-Type")
// // For Server-Sent Events responses, flush immediately.
// // The MIME type is defined in https://www.w3.org/TR/eventsource/#text-event-stream
// if baseCT, _, _ := mime.ParseMediaType(resCT); baseCT == "text/event-stream" {
// return -1 // negative means immediately
// }
// // We might have the case of streaming for which Content-Length might be unset.
// if res.ContentLength == -1 {
// return -1
// }
// return p.FlushInterval
// }
// NOTE: removed
// func (p *ReverseProxy) copyResponse(dst http.ResponseWriter, src io.Reader, flushInterval time.Duration) error {
// var w io.Writer = dst
// if flushInterval != 0 {
// mlw := &maxLatencyWriter{
// dst: dst,
// flush: http.NewResponseController(dst).Flush,
// latency: flushInterval,
// }
// defer mlw.stop()
// // set up initial timer so headers get flushed even if body writes are delayed
// mlw.flushPending = true
// mlw.t = time.AfterFunc(flushInterval, mlw.delayedFlush)
// w = mlw
// }
// var buf []byte
// if p.BufferPool != nil {
// buf = p.BufferPool.Get()
// defer p.BufferPool.Put(buf)
// }
// _, err := p.copyBuffer(w, src, buf)
// return err
// }
// copyBuffer returns any write errors or non-EOF read errors, and the amount
// of bytes written.
// NOTE: removed
// func (p *ReverseProxy) copyBuffer(dst io.Writer, src io.Reader, buf []byte) (int64, error) {
// if len(buf) == 0 {
// buf = make([]byte, 32*1024)
// }
// var written int64
// for {
// nr, rerr := src.Read(buf)
// if rerr != nil && rerr != io.EOF && rerr != context.Canceled {
// p.logf("httputil: ReverseProxy read error during body copy: %v", rerr)
// }
// if nr > 0 {
// nw, werr := dst.Write(buf[:nr])
// if nw > 0 {
// written += int64(nw)
// }
// if werr != nil {
// return written, werr
// }
// if nr != nw {
// return written, io.ErrShortWrite
// }
// }
// if rerr != nil {
// if rerr == io.EOF {
// rerr = nil
// }
// return written, rerr
// }
// }
// }
func (p *ReverseProxy) logf(format string, args ...any) {
if p.ErrorLog != nil {
p.ErrorLog.Printf(format, args...)
} else {
hrlog.Printf(format, args...)
}
}
// NOTE: removed
// type maxLatencyWriter struct {
// dst io.Writer
// flush func() error
// latency time.Duration // non-zero; negative means to flush immediately
// mu sync.Mutex // protects t, flushPending, and dst.Flush
// t *time.Timer
// flushPending bool
// }
// NOTE: removed
// func (m *maxLatencyWriter) Write(p []byte) (n int, err error) {
// m.mu.Lock()
// defer m.mu.Unlock()
// n, err = m.dst.Write(p)
// if m.latency < 0 {
// m.flush()
// return
// }
// if m.flushPending {
// return
// }
// if m.t == nil {
// m.t = time.AfterFunc(m.latency, m.delayedFlush)
// } else {
// m.t.Reset(m.latency)
// }
// m.flushPending = true
// return
// }
// func (m *maxLatencyWriter) delayedFlush() {
// m.mu.Lock()
// defer m.mu.Unlock()
// if !m.flushPending { // if stop was called but AfterFunc already started this goroutine
// return
// }
// m.flush()
// m.flushPending = false
// }
// func (m *maxLatencyWriter) stop() {
// m.mu.Lock()
// defer m.mu.Unlock()
// m.flushPending = false
// if m.t != nil {
// m.t.Stop()
// }
// }
func upgradeType(h http.Header) string {
if !httpguts.HeaderValuesContainsToken(h["Connection"], "Upgrade") {
return ""
}
return h.Get("Upgrade")
}
func (p *ReverseProxy) handleUpgradeResponse(rw http.ResponseWriter, req *http.Request, res *http.Response) {
reqUpType := upgradeType(req.Header)
resUpType := upgradeType(res.Header)
if !IsPrint(resUpType) { // We know reqUpType is ASCII, it's checked by the caller.
p.errorHandler(rw, req, fmt.Errorf("backend tried to switch to invalid protocol %q", resUpType))
}
if !strings.EqualFold(reqUpType, resUpType) {
p.errorHandler(rw, req, fmt.Errorf("backend tried to switch protocol %q when %q was requested", resUpType, reqUpType))
return
}
backConn, ok := res.Body.(io.ReadWriteCloser)
if !ok {
p.errorHandler(rw, req, fmt.Errorf("internal error: 101 switching protocols response with non-writable body"))
return
}
rc := http.NewResponseController(rw)
conn, brw, hijackErr := rc.Hijack()
if errors.Is(hijackErr, http.ErrNotSupported) {
p.errorHandler(rw, req, fmt.Errorf("can't switch protocols using non-Hijacker ResponseWriter type %T", rw))
return
}
backConnCloseCh := make(chan bool)
go func() {
// Ensure that the cancellation of a request closes the backend.
// See issue https://golang.org/issue/35559.
select {
case <-req.Context().Done():
case <-backConnCloseCh:
}
backConn.Close()
}()
defer close(backConnCloseCh)
if hijackErr != nil {
p.errorHandler(rw, req, fmt.Errorf("hijack failed on protocol switch: %v", hijackErr))
return
}
defer conn.Close()
copyHeader(rw.Header(), res.Header)
res.Header = rw.Header()
res.Body = nil // so res.Write only writes the headers; we have res.Body in backConn above
if err := res.Write(brw); err != nil {
p.errorHandler(rw, req, fmt.Errorf("response write: %v", err))
return
}
if err := brw.Flush(); err != nil {
p.errorHandler(rw, req, fmt.Errorf("response flush: %v", err))
return
}
errc := make(chan error, 1)
// NOTE: removed
// spc := switchProtocolCopier{user: conn, backend: backConn}
// go spc.copyToBackend(errc)
// go spc.copyFromBackend(errc)
go func() {
_, err := io.Copy(conn, backConn)
errc <- err
}()
go func() {
_, err := io.Copy(backConn, conn)
errc <- err
}()
<-errc
}
// NOTE: removed
// switchProtocolCopier exists so goroutines proxying data back and
// forth have nice names in stacks.
// type switchProtocolCopier struct {
// user, backend io.ReadWriter
// }
// func (c switchProtocolCopier) copyFromBackend(errc chan<- error) {
// _, err := io.Copy(c.user, c.backend)
// errc <- err
// }
// func (c switchProtocolCopier) copyToBackend(errc chan<- error) {
// _, err := io.Copy(c.backend, c.user)
// errc <- err
// }
// NOTE: removed
// func cleanQueryParams(s string) string {
// reencode := func(s string) string {
// v, _ := url.ParseQuery(s)
// return v.Encode()
// }
// for i := 0; i < len(s); {
// switch s[i] {
// case ';':
// return reencode(s)
// case '%':
// if i+2 >= len(s) || !ishex(s[i+1]) || !ishex(s[i+2]) {
// return reencode(s)
// }
// i += 3
// default:
// i++
// }
// }
// return s
// }
// func ishex(c byte) bool {
// switch {
// case '0' <= c && c <= '9':
// return true
// case 'a' <= c && c <= 'f':
// return true
// case 'A' <= c && c <= 'F':
// return true
// }
// return false
// }
func IsPrint(s string) bool {
for i := 0; i < len(s); i++ {
if s[i] < ' ' || s[i] > '~' {
return false
}
}
return true
}

127
src/go-proxy/io.go Normal file
View File

@@ -0,0 +1,127 @@
package main
import (
"context"
"errors"
"fmt"
"io"
"os"
"sync/atomic"
)
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
}
type ReadCloser struct {
ctx context.Context
r io.ReadCloser
closed atomic.Bool
}
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 {
if r.closed.Load() {
return nil
}
r.closed.Store(true)
return r.r.Close()
}
type Pipe struct {
r ReadCloser
w io.WriteCloser
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: ctx, r: r},
w: w,
ctx: ctx,
cancel: cancel,
}
}
func (p *Pipe) Start() error {
return Copy(p.ctx, p.w, &p.r)
}
func (p *Pipe) Stop() error {
p.cancel()
return errors.Join(fmt.Errorf("read: %w", p.r.Close()), fmt.Errorf("write: %w", p.w.Close()))
}
func (p *Pipe) Write(b []byte) (int, error) {
return p.w.Write(b)
}
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 NewBidirectionalPipeIntermediate(ctx context.Context, listener io.ReadCloser, client io.ReadWriteCloser, target io.ReadWriteCloser) *BidirectionalPipe {
return &BidirectionalPipe{
pSrcDst: *NewPipe(ctx, listener, client),
pDstSrc: *NewPipe(ctx, client, target),
}
}
func (p *BidirectionalPipe) Start() error {
errCh := make(chan error, 2)
go func() {
errCh <- p.pSrcDst.Start()
}()
go func() {
errCh <- p.pDstSrc.Start()
}()
for err := range errCh {
if err != nil {
return err
}
}
return nil
}
func (p *BidirectionalPipe) Stop() error {
return errors.Join(p.pSrcDst.Stop(), p.pDstSrc.Stop())
}
func Copy(ctx context.Context, dst io.WriteCloser, src io.ReadCloser) error {
_, err := io.Copy(dst, &ReadCloser{ctx: ctx, r: src})
return err
}

11
src/go-proxy/loggers.go Normal file
View File

@@ -0,0 +1,11 @@
package main
import "github.com/sirupsen/logrus"
var palog = logrus.WithField("component", "panel")
var prlog = logrus.WithField("component", "provider")
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,84 +1,135 @@
package main
import (
"flag"
"net/http"
"os"
"os/signal"
"runtime"
"sync"
"syscall"
"time"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/filters"
"github.com/docker/docker/client"
"github.com/golang/glog"
"golang.org/x/net/context"
"github.com/sirupsen/logrus"
)
func main() {
var err error
flag.Parse()
runtime.GOMAXPROCS(runtime.NumCPU())
time.Now().Zone()
var cfg Config
dockerClient, err = client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
if err != nil {
glog.Fatal(err)
func main() {
runtime.GOMAXPROCS(runtime.NumCPU())
args := getArgs()
if isRunningAsService {
logrus.SetFormatter(&logrus.TextFormatter{
DisableColors: true,
DisableTimestamp: true,
DisableSorting: true,
})
} else {
logrus.SetFormatter(&logrus.TextFormatter{
ForceColors: true,
DisableColors: false,
DisableSorting: true,
FullTimestamp: true,
TimestampFormat: "01-02 15:04:05",
})
}
buildRoutes()
glog.Infof("[Build] built %v reverse proxies", CountRoutes())
BeginListenStreams()
if args.Command == CommandReload {
err := utils.reloadServer()
if err != nil {
logrus.Fatal(err)
}
return
}
go func() {
filter := filters.NewArgs(
filters.Arg("type", "container"),
filters.Arg("event", "start"),
filters.Arg("event", "die"), // stop seems like triggering die
// filters.Arg("event", "stop"),
)
msgChan, errChan := dockerClient.Events(context.Background(), types.EventsOptions{Filters: filter})
cfg = NewConfig(configPath)
cfg.MustLoad()
for {
select {
case msg := <-msgChan:
// TODO: handle actor only
glog.Infof("[Event] %s %s caused rebuild", msg.Action, msg.Actor.Attributes["name"])
EndListenStreams()
buildRoutes()
glog.Infof("[Build] rebuilt %v reverse proxies", CountRoutes())
BeginListenStreams()
case err := <-errChan:
glog.Infof("[Event] %s", err)
msgChan, errChan = dockerClient.Events(context.Background(), types.EventsOptions{Filters: filter})
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()
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGINT)
signal.Notify(sig, syscall.SIGTERM)
signal.Notify(sig, syscall.SIGHUP)
<-sig
logrus.Info("shutting down")
done := make(chan struct{}, 1)
var wg sync.WaitGroup
wg.Add(3)
go func() {
for range time.Tick(100 * time.Millisecond) {
glog.Flush()
}
}()
mux := http.NewServeMux()
mux.HandleFunc("/", httpProxyHandler)
go func() {
glog.Infoln("Starting HTTP server on port 80")
err := http.ListenAndServe(":80", http.HandlerFunc(redirectToTLS))
if err != nil {
glog.Fatal("HTTP server error", err)
}
StopFSWatcher()
StopDockerWatcher()
cfg.StopProviders()
wg.Done()
}()
go func() {
glog.Infoln("Starting HTTPS panel on port 8443")
err := http.ListenAndServeTLS(":8443", "/certs/cert.crt", "/certs/priv.key", http.HandlerFunc(panelHandler))
if err != nil {
glog.Fatal("HTTP server error", err)
}
panelServer.Stop()
proxyServer.Stop()
wg.Done()
}()
glog.Infoln("Starting HTTPS server on port 443")
err = http.ListenAndServeTLS(":443", "/certs/cert.crt", "/certs/priv.key", mux)
if err != nil {
glog.Fatal("HTTPS Server error: ", err)
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

@@ -2,11 +2,19 @@ package main
import "sync"
type SafeMapInterface[KT comparable, VT interface{}] interface {
type safeMap[KT comparable, VT interface{}] struct {
SafeMap[KT, VT]
m map[KT]VT
defaultFactory func() VT
sync.RWMutex
}
type SafeMap[KT comparable, VT interface{}] interface {
Set(key KT, value VT)
Ensure(key KT)
Get(key KT) VT
TryGet(key KT) (VT, bool)
UnsafeGet(key KT) (VT, bool)
Delete(key KT)
Clear()
Size() int
Contains(key KT) bool
@@ -14,79 +22,77 @@ type SafeMapInterface[KT comparable, VT interface{}] interface {
Iterator() map[KT]VT
}
type SafeMap[KT comparable, VT interface{}] struct {
SafeMapInterface[KT, VT]
m map[KT]VT
mutex sync.Mutex
defaultFactory func() 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]{
return &safeMap[KT, VT]{
m: make(map[KT]VT),
}
}
return &SafeMap[KT, VT]{
return &safeMap[KT, VT]{
m: make(map[KT]VT),
defaultFactory: df[0],
}
}
func (m *SafeMap[KT, VT]) Set(key KT, value VT) {
m.mutex.Lock()
func (m *safeMap[KT, VT]) Set(key KT, value VT) {
m.Lock()
m.m[key] = value
m.mutex.Unlock()
m.Unlock()
}
func (m *SafeMap[KT, VT]) Ensure(key KT) {
m.mutex.Lock()
func (m *safeMap[KT, VT]) Ensure(key KT) {
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()
func (m *safeMap[KT, VT]) Get(key KT) VT {
m.RLock()
value := m.m[key]
m.mutex.Unlock()
m.RUnlock()
return value
}
func (m *SafeMap[KT, VT]) UnsafeGet(key KT) (VT, bool) {
func (m *safeMap[KT, VT]) UnsafeGet(key KT) (VT, bool) {
value, ok := m.m[key]
return value, ok
}
func (m *SafeMap[KT, VT]) Clear() {
m.mutex.Lock()
func (m *safeMap[KT, VT]) Delete(key KT) {
m.Lock()
delete(m.m, key)
m.Unlock()
}
func (m *safeMap[KT, VT]) Clear() {
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
func (m *safeMap[KT, VT]) Size() int {
m.RLock()
defer m.RUnlock()
return len(m.m)
}
func (m *SafeMap[KT, VT]) Contains(key KT) bool {
m.mutex.Lock()
func (m *safeMap[KT, VT]) Contains(key KT) bool {
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()
func (m *safeMap[KT, VT]) ForEach(fn func(key KT, value VT)) {
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 {
func (m *safeMap[KT, VT]) Iterator() map[KT]VT {
return m.m
}

View File

@@ -1,78 +1,54 @@
package main
import (
"errors"
"fmt"
"html/template"
"net"
"net/http"
"net/url"
"time"
"github.com/golang/glog"
"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:
http.NotFound(w, r)
return
}
}
func panelPage(w http.ResponseWriter, r *http.Request) {
resp := struct {
HTTPRoutes HTTPRoutes
StreamRoutes StreamRoutes
}{httpRoutes, streamRoutes}
func panelIndex(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
tmpl, err := template.ParseFiles(templateFile)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
err = tmpl.Execute(w, &routes)
if err != nil {
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 {
glog.Infof("[Panel] failed to parse %s, 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
@@ -89,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,18 +1,15 @@
package main
import (
"fmt"
"strings"
)
type pathPoolMap struct {
*SafeMap[string, *httpLoadBalancePool]
SafeMap[string, *httpLoadBalancePool]
}
func newPathPoolMap() pathPoolMap {
return pathPoolMap{
NewSafeMap[string](NewHTTPLoadBalancePool),
}
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) {
for pathWant, v := range m.m {
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)
}

105
src/go-proxy/provider.go Normal file
View File

@@ -0,0 +1,105 @@
package main
import (
"sync"
"github.com/sirupsen/logrus"
)
type Provider struct {
Kind string `json:"kind"` // docker, file
Value string `json:"value"`
watcher Watcher
routes map[string]Route // id -> Route
mutex sync.Mutex
l logrus.FieldLogger
}
// Init is called after LoadProxyConfig
func (p *Provider) Init(name string) error {
p.l = prlog.WithFields(logrus.Fields{"kind": p.Kind, "name": name})
defer p.initWatcher()
if err := p.loadProxyConfig(); err != nil {
return err
}
return nil
}
func (p *Provider) StartAllRoutes() {
ParallelForEachValue(p.routes, Route.Start)
p.watcher.Start()
}
func (p *Provider) StopAllRoutes() {
p.watcher.Stop()
ParallelForEachValue(p.routes, Route.Stop)
p.routes = nil
}
func (p *Provider) ReloadRoutes() {
p.mutex.Lock()
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,20 +3,56 @@ package main
import "fmt"
type ProxyConfig struct {
id string
Alias string
Scheme string
Host string
Port string
LoadBalance string
Path string // http proxy only
PathMode string // 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
}
func NewProxyConfig() ProxyConfig {
return ProxyConfig{}
type ProxyConfigMap map[string]ProxyConfig
type ProxyConfigSlice []ProxyConfig
func NewProxyConfig(provider *Provider) ProxyConfig {
return ProxyConfig{
provider: provider,
}
}
func (cfg *ProxyConfig) UpdateId() {
cfg.id = fmt.Sprintf("%s-%s-%s-%s-%s", cfg.Alias, cfg.Scheme, cfg.Host, cfg.Port, cfg.Path)
// used by `GetFileProxyConfigs`
func (cfg *ProxyConfig) SetDefaults() error {
err := NewNestedError("invalid proxy config").Subject(cfg.Alias)
if cfg.Alias == "" {
err.Extra("alias is required")
}
if cfg.Scheme == "" {
cfg.Scheme = "http"
}
if cfg.Host == "" {
err.Extra("host is required")
}
if cfg.Port == "" {
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
}
func (cfg *ProxyConfig) GetID() string {
return fmt.Sprintf("%s-%s-%s-%s-%s", cfg.Alias, cfg.Scheme, cfg.Host, cfg.Port, cfg.Path)
}

View File

@@ -1,66 +1,51 @@
package main
import (
"sync"
"github.com/golang/glog"
)
type Routes struct {
HTTPRoutes *SafeMap[string, pathPoolMap] // id -> (path -> routes)
StreamRoutes *SafeMap[string, StreamRoute] // id -> target
Mutex sync.Mutex
type Route interface {
Start()
Stop()
}
var routes = Routes{}
func isValidScheme(scheme string) bool {
for _, v := range ValidSchemes {
if v == scheme {
return true
func NewRoute(cfg *ProxyConfig) (Route, error) {
if isStreamScheme(cfg.Scheme) {
id := cfg.GetID()
if streamRoutes.Contains(id) {
return nil, NewNestedError("duplicated stream").Subject(cfg.Alias)
}
}
return false
}
func isStreamScheme(scheme string) bool {
for _, v := range StreamSchemes {
if v == scheme {
return true
}
}
return false
}
func InitRoutes() {
utils.resetPortsInUse()
routes.HTTPRoutes = NewSafeMap[string](newPathPoolMap)
routes.StreamRoutes = NewSafeMap[string, StreamRoute]()
}
func CountRoutes() int {
return routes.HTTPRoutes.Size() + routes.StreamRoutes.Size()
}
func CreateRoute(config *ProxyConfig) {
if isStreamScheme(config.Scheme) {
if routes.StreamRoutes.Contains(config.id) {
glog.Infof("[Build] Duplicated %s stream %s, ignoring", config.Scheme, config.id)
return
}
route, err := NewStreamRoute(config)
route, err := NewStreamRoute(cfg)
if err != nil {
glog.Infoln(err)
return
return nil, NewNestedErrorFrom(err).Subject(cfg.Alias)
}
routes.StreamRoutes.Set(config.id, route)
return route, nil
} else {
routes.HTTPRoutes.Ensure(config.Alias)
route, err := NewHTTPRoute(config)
httpRoutes.Ensure(cfg.Alias)
route, err := NewHTTPRoute(cfg)
if err != nil {
glog.Infoln(err)
return
return nil, NewNestedErrorFrom(err).Subject(cfg.Alias)
}
routes.HTTPRoutes.Get(config.Alias).Add(config.Path, route)
httpRoutes.Get(cfg.Alias).Add(cfg.Path, route)
return route, nil
}
}
func isValidScheme(s string) bool {
for _, v := range ValidSchemes {
if v == s {
return true
}
}
return false
}
func isStreamScheme(s string) bool {
for _, v := range StreamSchemes {
if v == s {
return true
}
}
return false
}
// id -> target
type StreamRoutes SafeMap[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,216 +1,232 @@
package main
import (
"errors"
"fmt"
"strconv"
"strings"
"sync"
"time"
"github.com/golang/glog"
"github.com/sirupsen/logrus"
)
type StreamImpl interface {
Setup() error
Accept() (interface{}, error)
Handle(interface{}) error
CloseListeners()
}
type StreamRoute interface {
SetupListen()
Listen()
StopListening()
Logf(string, ...interface{})
PrintError(error)
Route
ListeningUrl() string
TargetUrl() string
closeListeners()
closeChannel()
wait()
Logger() logrus.FieldLogger
}
type StreamRouteBase struct {
Alias string // to show in panel
Type string
ListeningScheme string
ListeningPort string
ListeningPort int
TargetScheme string
TargetHost string
TargetPort string
TargetPort int
wg sync.WaitGroup
stopChann chan struct{}
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 = TCPStreamType
var srcPort string
var dstPort string
var srcScheme string
var dstScheme string
var streamType string = StreamType_TCP
var srcPort, dstPort string
var srcScheme, dstScheme string
port_split := strings.Split(config.Port, ":")
if len(port_split) != 2 {
glog.Infof(`[Build] %s: Invalid stream port %s, `+
`assuming it's targetPort`, config.Alias, 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]
}
port, hasName := NamePortMap[dstPort]
if hasName {
if port, hasName := NamePortMapTCP[dstPort]; hasName {
dstPort = port
}
srcPortInt, err := strconv.Atoi(srcPort)
if err != nil {
return nil, fmt.Errorf(
"[Build] %s: Unrecognized stream source port %s, ignoring",
config.Alias, srcPort,
)
return nil, NewNestedError("invalid stream source port").Subject(srcPort)
}
utils.markPortInUse(srcPortInt)
_, err = strconv.Atoi(dstPort)
dstPortInt, err := strconv.Atoi(dstPort)
if err != nil {
return nil, fmt.Errorf(
"[Build] %s: Unrecognized stream target port %s, ignoring",
config.Alias, 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
}
if srcScheme != dstScheme {
return nil, NewNestedError("unsupported").Subjectf("%v -> %v", srcScheme, dstScheme)
}
return &StreamRouteBase{
Alias: config.Alias,
Type: streamType,
ListeningScheme: srcScheme,
ListeningPort: srcPort,
ListeningPort: srcPortInt,
TargetScheme: dstScheme,
TargetHost: config.Host,
TargetPort: dstPort,
TargetPort: dstPortInt,
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),
}),
}, nil
}
func NewStreamRoute(config *ProxyConfig) (StreamRoute, error) {
base, err := newStreamRouteBase(config)
if err != nil {
return nil, err
}
switch config.Scheme {
case TCPStreamType:
return NewTCPRoute(config)
case UDPStreamType:
return NewUDPRoute(config)
case StreamType_TCP:
base.StreamImpl = NewTCPRoute(base)
case StreamType_UDP:
base.StreamImpl = NewUDPRoute(base)
default:
return nil, errors.New("unknown stream type")
return nil, NewNestedError("invalid stream type").Subject(config.Scheme)
}
}
func (route *StreamRouteBase) PrintError(err error) {
if err == nil {
return
}
glog.Errorf("[%s -> %s] %s: %v",
route.ListeningScheme,
route.TargetScheme,
route.Alias,
err,
)
}
func (route *StreamRouteBase) Logf(format string, v ...interface{}) {
glog.Infof("[%s -> %s] %s: "+format,
append([]interface{}{
route.ListeningScheme,
route.TargetScheme,
route.Alias},
v...,
)...,
)
return base, nil
}
func (route *StreamRouteBase) ListeningUrl() string {
return fmt.Sprintf("%s:%s", route.ListeningScheme, route.ListeningPort)
return fmt.Sprintf("%s:%v", route.ListeningScheme, route.ListeningPort)
}
func (route *StreamRouteBase) TargetUrl() string {
return fmt.Sprintf("%s://%s:%s", route.TargetScheme, route.TargetHost, route.TargetPort)
return fmt.Sprintf("%s://%s:%v", route.TargetScheme, route.TargetHost, route.TargetPort)
}
func (route *StreamRouteBase) SetupListen() {
if route.ListeningPort == "0" {
freePort, err := utils.findUseFreePort(20000)
if err != nil {
route.PrintError(err)
return
}
route.ListeningPort = fmt.Sprintf("%d", freePort)
route.Logf("Assigned free port %s", route.ListeningPort)
func (route *StreamRouteBase) Logger() logrus.FieldLogger {
return route.l
}
func (route *StreamRouteBase) Start() {
route.ensurePort()
if err := route.Setup(); err != nil {
route.l.Errorf("failed to setup: %v", err)
return
}
route.Logf("Listening on %s", route.ListeningUrl())
streamRoutes.Set(route.id, route)
route.started = true
route.wg.Add(2)
go route.grAcceptConnections()
go route.grHandleConnections()
}
func (route *StreamRouteBase) wait() {
route.wg.Wait()
}
func (route *StreamRouteBase) closeChannel() {
close(route.stopChann)
}
func stopListening(route StreamRoute) {
route.Logf("Stopping listening")
route.closeChannel()
route.closeListeners()
done := make(chan struct{})
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.wait()
route.wg.Wait()
close(done)
}()
select {
case <-done:
route.Logf("Stopped listening")
return
case <-time.After(StreamStopListenTimeout):
route.Logf("timed out waiting for connections")
return
l.Info("stopped listening")
case <-time.After(streamStopListenTimeout):
l.Error("timed out waiting for connections")
}
utils.unmarkPortInUse(route.ListeningPort)
streamRoutes.Delete(route.id)
}
func allStreamsDo(msg string, fn ...func(StreamRoute)) {
glog.Infof("[Stream] %s", msg)
func (route *StreamRouteBase) ensurePort() {
if route.ListeningPort == 0 {
freePort, err := utils.findUseFreePort(20000)
if err != nil {
route.l.Error(err)
return
}
route.ListeningPort = freePort
route.l.Info("listening on free port ", route.ListeningPort)
return
}
route.l.Info("listening on ", route.ListeningUrl())
}
var wg sync.WaitGroup
func (route *StreamRouteBase) grAcceptConnections() {
defer route.wg.Done()
for _, route := range routes.StreamRoutes.Iterator() {
wg.Add(1)
go func(r StreamRoute) {
for _, f := range fn {
f(r)
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
}
}
wg.Done()
}(route)
route.connCh <- conn
}
}
wg.Wait()
glog.Infof("[Stream] Finished %s", msg)
}
func BeginListenStreams() {
allStreamsDo("Start", StreamRoute.SetupListen, StreamRoute.Listen)
}
func (route *StreamRouteBase) grHandleConnections() {
defer route.wg.Done()
func EndListenStreams() {
allStreamsDo("Stop", StreamRoute.StopListening)
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,130 +3,81 @@ package main
import (
"context"
"fmt"
"io"
"net"
"sync"
"time"
"github.com/golang/glog"
)
const tcpDialTimeout = 5 * time.Second
type Pipes []*BidirectionalPipe
type TCPRoute struct {
*StreamRouteBase
listener net.Listener
connChan chan net.Conn
pipe Pipes
mu sync.Mutex
}
func NewTCPRoute(config *ProxyConfig) (StreamRoute, error) {
base, err := newStreamRouteBase(config)
if err != nil {
return nil, err
}
if base.TargetScheme != TCPStreamType {
return nil, fmt.Errorf("tcp to %s not yet supported", base.TargetScheme)
}
func NewTCPRoute(base *StreamRouteBase) StreamImpl {
return &TCPRoute{
StreamRouteBase: base,
listener: nil,
connChan: make(chan net.Conn),
}, nil
pipe: make(Pipes, 0),
}
}
func (route *TCPRoute) Listen() {
in, err := net.Listen("tcp", ":"+route.ListeningPort)
func (route *TCPRoute) Setup() error {
in, err := net.Listen("tcp", fmt.Sprintf(":%v", route.ListeningPort))
if err != nil {
route.PrintError(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() {
func (route *TCPRoute) Handle(c interface{}) error {
clientConn := c.(net.Conn)
defer clientConn.Close()
ctx, cancel := context.WithTimeout(context.Background(), tcpDialTimeout)
defer cancel()
serverAddr := fmt.Sprintf("%s:%v", route.TargetHost, route.TargetPort)
dialer := &net.Dialer{}
serverConn, err := dialer.DialContext(ctx, route.TargetScheme, serverAddr)
if err != nil {
return err
}
pipeCtx, pipeCancel := context.WithCancel(context.Background())
go func() {
<-route.stopCh
pipeCancel()
}()
route.mu.Lock()
pipe := NewBidirectionalPipe(pipeCtx, clientConn, serverConn)
route.pipe = append(route.pipe, pipe)
route.mu.Unlock()
return pipe.Start()
}
func (route *TCPRoute) CloseListeners() {
if route.listener == nil {
return
}
route.listener.Close()
route.listener = nil
}
func (route *TCPRoute) grAcceptConnections() {
defer route.wg.Done()
for {
select {
case <-route.stopChann:
return
default:
conn, err := route.listener.Accept()
if err != nil {
route.PrintError(err)
continue
}
route.connChan <- conn
for _, pipe := range route.pipe {
if err := pipe.Stop(); err != nil {
route.l.Error(err)
}
}
}
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()
ctx, cancel := context.WithTimeout(context.Background(), tcpDialTimeout)
defer cancel()
serverAddr := fmt.Sprintf("%s:%s", route.TargetHost, route.TargetPort)
dialer := &net.Dialer{}
serverConn, err := dialer.DialContext(ctx, route.TargetScheme, serverAddr)
if err != nil {
glog.Infof("[Stream Dial] %v", err)
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.PrintError(err)
close()
wg.Done()
}()
go func() {
_, err := io.Copy(dest, src)
route.PrintError(err)
close()
wg.Done()
}()
wg.Wait()
}

View File

@@ -1,6 +1,7 @@
package main
import (
"context"
"fmt"
"io"
"net"
@@ -10,165 +11,49 @@ import (
type UDPRoute struct {
*StreamRouteBase
connMap map[net.Addr]net.Conn
connMap UDPConnMap
connMapMutex sync.Mutex
listeningConn *net.UDPConn
targetConn *net.UDPConn
connChan chan *UDPConn
targetAddr *net.UDPAddr
}
type UDPConn struct {
remoteAddr net.Addr
buffer []byte
bytesReceived []byte
nReceived int
src *net.UDPConn
dst *net.UDPConn
*BidirectionalPipe
}
func NewUDPRoute(config *ProxyConfig) (StreamRoute, error) {
base, err := newStreamRouteBase(config)
if err != nil {
return nil, err
}
if base.TargetScheme != UDPStreamType {
return nil, fmt.Errorf("udp to %s not yet supported", base.TargetScheme)
}
type UDPConnMap map[net.Addr]*UDPConn
func NewUDPRoute(base *StreamRouteBase) StreamImpl {
return &UDPRoute{
StreamRouteBase: base,
connMap: make(map[net.Addr]net.Conn),
connChan: make(chan *UDPConn),
}, nil
}
func (route *UDPRoute) Listen() {
source, err := net.ListenPacket(route.ListeningScheme, fmt.Sprintf(":%s", route.ListeningPort))
if err != nil {
route.PrintError(err)
return
}
target, err := net.Dial(route.TargetScheme, fmt.Sprintf("%s:%s", route.TargetHost, route.TargetPort))
if err != nil {
route.PrintError(err)
source.Close()
return
}
route.listeningConn = source.(*net.UDPConn)
route.targetConn = target.(*net.UDPConn)
route.wg.Add(2)
go route.grAcceptConnections()
go route.grHandleConnections()
}
func (route *UDPRoute) StopListening() {
stopListening(route)
}
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
connMap: make(UDPConnMap),
}
}
func (route *UDPRoute) grAcceptConnections() {
defer route.wg.Done()
for {
select {
case <-route.stopChann:
return
default:
conn, err := route.accept()
if err != nil {
route.PrintError(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.PrintError(err)
}
}()
}
}
}
func (route *UDPRoute) handleConnection(conn *UDPConn) error {
var err error
srcConn, ok := route.connMap[conn.remoteAddr]
if !ok {
route.connMapMutex.Lock()
srcConn, err = net.DialUDP("udp", nil, conn.remoteAddr.(*net.UDPAddr))
if err != nil {
return err
}
route.connMap[conn.remoteAddr] = srcConn
route.connMapMutex.Unlock()
}
// initiate connection to target
err = route.forwardReceived(conn, route.targetConn)
func (route *UDPRoute) Setup() error {
laddr, err := net.ResolveUDPAddr(route.ListeningScheme, fmt.Sprintf(":%v", route.ListeningPort))
if err != nil {
return err
}
for {
select {
case <-route.stopChann:
return nil
default:
// receive from target
conn, err = route.readFrom(route.targetConn, conn.buffer)
if err != nil {
return err
}
// forward to source
err = route.forwardReceived(conn, srcConn)
if err != nil {
return err
}
// read from source
conn, err = route.readFrom(srcConn, conn.buffer)
if err != nil {
continue
}
// forward to target
err = route.forwardReceived(conn, route.targetConn)
if err != nil {
return err
}
}
source, err := net.ListenUDP(route.ListeningScheme, laddr)
if err != nil {
return err
}
raddr, err := net.ResolveUDPAddr(route.TargetScheme, fmt.Sprintf("%s:%v", route.TargetHost, route.TargetPort))
if err != nil {
source.Close()
return err
}
route.listeningConn = source
route.targetAddr = raddr
return nil
}
func (route *UDPRoute) accept() (*UDPConn, error) {
func (route *UDPRoute) Accept() (interface{}, error) {
in := route.listeningConn
buffer := make([]byte, udpBufferSize)
@@ -182,45 +67,68 @@ func (route *UDPRoute) accept() (*UDPConn, error) {
return nil, io.ErrShortBuffer
}
return &UDPConn{
remoteAddr: srcAddr,
buffer: buffer,
bytesReceived: buffer[:nRead],
nReceived: nRead},
nil
}
conn, ok := route.connMap[srcAddr]
func (route *UDPRoute) readFrom(src net.Conn, buffer []byte) (*UDPConn, error) {
nRead, err := src.Read(buffer)
if err != nil {
return nil, err
if !ok {
route.connMapMutex.Lock()
srcConn, err := net.DialUDP("udp", nil, srcAddr)
if err != nil {
return nil, err
}
dstConn, err := net.DialUDP("udp", nil, route.targetAddr)
if err != nil {
srcConn.Close()
return nil, err
}
pipeCtx, pipeCancel := context.WithCancel(context.Background())
go func() {
<-route.stopCh
pipeCancel()
}()
conn = &UDPConn{
srcConn,
dstConn,
NewBidirectionalPipe(pipeCtx, sourceRWCloser{in, dstConn}, sourceRWCloser{in, srcConn}),
}
route.connMap[srcAddr] = conn
route.connMapMutex.Unlock()
}
if nRead == 0 {
return nil, io.ErrShortBuffer
}
return &UDPConn{
remoteAddr: src.RemoteAddr(),
buffer: buffer,
bytesReceived: buffer[:nRead],
nReceived: nRead,
}, nil
_, err = conn.dst.Write(buffer[:nRead])
return conn, err
}
func (route *UDPRoute) forwardReceived(receivedConn *UDPConn, dest net.Conn) error {
route.Logf(
"forwarding %d bytes %s -> %s",
receivedConn.nReceived,
receivedConn.remoteAddr.String(),
dest.RemoteAddr().String(),
)
nWritten, err := dest.Write(receivedConn.bytesReceived)
if nWritten != receivedConn.nReceived {
err = io.ErrShortWrite
}
return err
func (route *UDPRoute) Handle(c interface{}) error {
return c.(*UDPConn).Start()
}
func (route *UDPRoute) CloseListeners() {
if route.listeningConn != nil {
route.listeningConn.Close()
route.listeningConn = nil
}
for _, conn := range route.connMap {
if err := conn.dst.Close(); err != nil {
route.l.Error(err)
}
}
route.connMap = make(UDPConnMap)
}
type sourceRWCloser struct {
server *net.UDPConn
target *net.UDPConn
}
func (w sourceRWCloser) Read(p []byte) (int, error) {
n, _, err := w.target.ReadFrom(p)
return n, err
}
func (w sourceRWCloser) Write(p []byte) (int, error) {
return w.server.WriteToUDP(p, w.target.RemoteAddr().(*net.UDPAddr)) // TODO: support non udp
}
func (w sourceRWCloser) Close() error {
return w.target.Close()
}

View File

@@ -2,28 +2,32 @@ package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"os"
"path"
"path/filepath"
"reflect"
"regexp"
"strings"
"sync"
"time"
"github.com/golang/glog"
"github.com/santhosh-tekuri/jsonschema"
"github.com/sirupsen/logrus"
xhtml "golang.org/x/net/html"
"gopkg.in/yaml.v3"
)
type Utils struct {
PortsInUse map[int]bool
portsInUse map[int]bool
portsInUseMutex sync.Mutex
}
var utils = &Utils{
PortsInUse: make(map[int]bool),
portsInUse: make(map[int]bool),
portsInUseMutex: sync.Mutex{},
}
@@ -31,13 +35,13 @@ func (u *Utils) findUseFreePort(startingPort int) (int, error) {
u.portsInUseMutex.Lock()
defer u.portsInUseMutex.Unlock()
for port := startingPort; port <= startingPort+100 && port <= 65535; port++ {
if u.PortsInUse[port] {
if u.portsInUse[port] {
continue
}
addr := fmt.Sprintf(":%d", port)
l, err := net.Listen("tcp", addr)
if err == nil {
u.PortsInUse[port] = true
u.portsInUse[port] = true
l.Close()
return port, nil
}
@@ -46,24 +50,22 @@ func (u *Utils) findUseFreePort(startingPort int) (int, error) {
if err == nil {
// NOTE: may not be after 20000
port := l.Addr().(*net.TCPAddr).Port
u.PortsInUse[port] = true
u.portsInUse[port] = true
l.Close()
return port, nil
}
return -1, fmt.Errorf("unable to find free port: %v", err)
}
func (u *Utils) resetPortsInUse() {
u.portsInUseMutex.Lock()
for port := range u.PortsInUse {
u.PortsInUse[port] = false
}
u.portsInUseMutex.Unlock()
return -1, NewNestedError("unable to find free port").With(err)
}
func (u *Utils) markPortInUse(port int) {
u.portsInUseMutex.Lock()
u.PortsInUse[port] = true
u.portsInUse[port] = true
u.portsInUseMutex.Unlock()
}
func (u *Utils) unmarkPortInUse(port int) {
u.portsInUseMutex.Lock()
delete(u.portsInUse, port)
u.portsInUseMutex.Unlock()
}
@@ -84,7 +86,7 @@ func (*Utils) healthCheckHttp(targetUrl string) error {
}
func (*Utils) healthCheckStream(scheme, host string) error {
conn, err := net.DialTimeout(scheme, host, 5*time.Second)
conn, err := net.DialTimeout(scheme, host, streamDialTimeout)
if err != nil {
return err
}
@@ -92,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, "-", "")
}
@@ -112,10 +126,9 @@ func tryAppendPathPrefixImpl(pOrig, pAppend string) string {
var tryAppendPathPrefix func(string, string) string
var _ = func() int {
if glog.V(4) {
if logLevel == logrus.DebugLevel {
tryAppendPathPrefix = func(s1, s2 string) string {
replaced := tryAppendPathPrefixImpl(s1, s2)
glog.Infof("[Path sub] %s -> %s", s1, replaced)
return replaced
}
} else {
@@ -189,3 +202,43 @@ func (*Utils) respJSSubPath(r *http.Response, p string) error {
r.Body = io.NopCloser(strings.NewReader(js))
return nil
}
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
}

229
src/go-proxy/watcher.go Normal file
View File

@@ -0,0 +1,229 @@
package main
import (
"path"
"sync"
"time"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/events"
"github.com/docker/docker/api/types/filters"
"github.com/docker/docker/client"
"github.com/fsnotify/fsnotify"
"github.com/sirupsen/logrus"
"golang.org/x/net/context"
)
type Watcher interface {
Start()
Stop()
Dispose()
}
type watcherBase struct {
name string // for log / error output
kind string // for log / error output
onChange func()
l logrus.FieldLogger
sync.Mutex
}
type fileWatcher struct {
*watcherBase
path string
onDelete func()
}
type dockerWatcher struct {
*watcherBase
client *client.Client
stopCh chan struct{}
wg sync.WaitGroup
}
func newWatcher(kind string, name string, onChange func()) *watcherBase {
return &watcherBase{
kind: kind,
name: name,
onChange: onChange,
l: wlog.WithFields(logrus.Fields{"kind": kind, "name": name}),
}
}
func NewFileWatcher(p string, onChange func(), onDelete func()) Watcher {
return &fileWatcher{
watcherBase: newWatcher("File", path.Base(p), onChange),
path: p,
onDelete: onDelete,
}
}
func NewDockerWatcher(c *client.Client, onChange func()) Watcher {
return &dockerWatcher{
watcherBase: newWatcher("Docker", c.DaemonHost(), onChange),
client: c,
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.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 w.watch()
}
func (w *dockerWatcher) Stop() {
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() {
w, err := fsnotify.NewWatcher()
if err != nil {
wlog.Errorf("unable to create file watcher: %v", err)
return
}
fsWatcher = w
fsWatcherWg.Add(1)
go watchFiles()
}
func StopFSWatcher() {
close(fsWatcherStop)
fsWatcherWg.Wait()
}
func StopDockerWatcher() {
ParallelForEachValue(
dockerWatchMap.Iterator(),
(*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")
return
}
w, ok := fileWatchMap.UnsafeGet(event.Name)
if !ok {
wlog.Errorf("watcher for %s not found", event.Name)
}
switch {
case event.Has(fsnotify.Write):
w.l.Info("file changed")
go w.onChange()
case event.Has(fsnotify.Remove), event.Has(fsnotify.Rename):
w.l.Info("file renamed / deleted")
go w.onDelete()
}
case err := <-fsWatcher.Errors:
wlog.Error(err)
}
}
}
func (w *dockerWatcher) watch() {
defer w.wg.Done()
filter := filters.NewArgs(
filters.Arg("type", "container"),
filters.Arg("event", "start"),
filters.Arg("event", "die"), // 'stop' already triggering 'die'
)
listen := func() (<-chan events.Message, <-chan error) {
return w.client.Events(context.Background(), types.EventsOptions{Filters: filter})
}
msgChan, errChan := listen()
for {
select {
case <-w.stopCh:
return
case msg := <-msgChan:
w.l.Infof("container %s %s", msg.Actor.Attributes["name"], msg.Action)
go w.onChange()
case err := <-errChan:
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 FileWatcherMap = NewSafeMapOf[FileWatcherMap]()
dockerWatchMap DockerWatcherMap = NewSafeMapOf[DockerWatcherMap]()
)
var (
fsWatcherStop = make(chan struct{}, 1)
)
var (
fsWatcherWg sync.WaitGroup
)

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;
}

1
version.txt Normal file
View File

@@ -0,0 +1 @@
0.4.4