Compare commits

...

46 Commits
0.3 ... 0.4.6

Author SHA1 Message Date
yusing
03bf425a38 fix selecting wrong port on remote docker provide 2024-03-31 21:33:09 +00:00
yusing
5fafa619ee version bump and binary doc fix 2024-03-31 16:28:48 +00:00
yusing
bebf99ed6c docker example update' 2024-03-31 11:45:23 +00:00
yusing
8483263d01 readme update 2024-03-31 11:32:16 +00:00
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
59 changed files with 3024 additions and 1209 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 ./...

10
.gitignore vendored
View File

@@ -1,7 +1,9 @@
compose.yml
go-proxy.yml
config.yml
providers.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,17 +1,34 @@
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 config.example.yml /app/config.yml
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 GOPROXY_DEBUG 0
ENV GOPROXY_REDIRECT_HTTP 1
EXPOSE 80
EXPOSE 8080
@@ -19,4 +36,4 @@ 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 -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 .)

296
README.md
View File

@@ -6,185 +6,202 @@ In the examples domain `x.y.z` is used, replace them with your domain
## Table of content
- [Key Points](#key-points)
- [How to use](#how-to-use)
- [Binary](#binary)
- [Docker](#docker)
- [Configuration](#configuration)
- [Labels](#labels)
- [Environment Variables](#environment-variables)
- [Config File](#config-file)
- [Provider File](#provider-file)
- [Supported Cert Providers](#supported-cert-providers)
- [Examples](#examples)
- [Single Port Configuration](#single-port-configuration-example)
- [Multiple Ports Configuration](#multiple-ports-configuration-example)
- [TCP/UDP Configuration](#tcpudp-configuration-example)
- [Load balancing Configuration](#load-balancing-configuration-example)
- [Troubleshooting](#troubleshooting)
- [Benchmarks](#benchmarks)
- [Memory usage](#memory-usage)
- [Build it yourself](#build-it-yourself)
- [go-proxy](#go-proxy)
- [Table of content](#table-of-content)
- [Key Points](#key-points)
- [How to use](#how-to-use)
- [Command-line args](#command-line-args)
- [Commands](#commands)
- [Use JSON Schema in VSCode](#use-json-schema-in-vscode)
- [Configuration](#configuration)
- [Labels (docker)](#labels-docker)
- [Environment variables](#environment-variables)
- [Config File](#config-file)
- [Fields](#fields)
- [Provider Kinds](#provider-kinds)
- [Provider File](#provider-file)
- [Supported DNS Challenge Providers](#supported-dns-challenge-providers)
- [Examples](#examples)
- [Single port configuration example](#single-port-configuration-example)
- [Multiple ports configuration example](#multiple-ports-configuration-example)
- [TCP/UDP configuration example](#tcpudp-configuration-example)
- [Load balancing Configuration Example](#load-balancing-configuration-example)
- [Troubleshooting](#troubleshooting)
- [Benchmarks](#benchmarks)
- [Known issues](#known-issues)
- [Memory usage](#memory-usage)
- [Build it yourself](#build-it-yourself)
## Key Points
- fast, nearly no performance penalty for end users when comparing to direct IP connections (See [benchmarks](#benchmarks))
- auto detect reverse proxies from docker
- additional reverse proxies from provider yaml file
- allow multiple docker / file providers by custom `config.yml` file
- auto certificate obtaining and renewal (See [Config File](#config-file) and [Supported Cert Providers](#supported-cert-providers))
- subdomain matching **(domain name doesn't matter)**
- path matching
- HTTP proxy
- TCP/UDP Proxy
- HTTP round robin load balance support (same subdomain and path across different hosts)
- Auto hot-reload on container start / die / stop or config changes.
- Simple panel to see all reverse proxies and health (visit port [panel port] of go-proxy `https://*.y.z:[panel port]`)
- 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)
- you can customize it by modifying [templates/panel.html](templates/panel.html)
- a simple panel to see all reverse proxies and health
![panel screenshot](screenshots/panel.png)
![panel screenshot](screenshots/panel.png)
- a config editor to edit config and provider files with validation
**Validate and save file with Ctrl+S**
![config editor screenshot](screenshots/config_editor.png)
## How to use
1. Download and extract the latest release (or clone the repository if you want to try out experimental features)
1. Setup DNS Records to your machine's IP address
2. Copy `config.example.yml` to `config.yml` and modify the content to fit your needs
- A Record: `*.y.z` -> `10.0.10.1`
- AAAA Record: `*.y.z` -> `::ffff:a00:a01`
3. Do the same for `providers.example.yml`
2. Start `go-proxy` (see [Binary](docs/binary.md) or [docker](docs/docker.md))
4. See [Binary](#binary) or [docker](#docker)
3. Start editing config files
- with text editor (i.e. Visual Studio Code)
- or with web config editor by navigate to `ip:8080`
### Binary
## Command-line args
1. (Optional) enabled HTTPS
`go-proxy [command]`
- Use autocert feature by completing `autocert` in `config.yml`
### Commands
- Use existing certificate
- empty: start proxy server
- validate: validate config and exit
- reload: trigger a force reload of config
Prepare your wildcard (`*.y.z`) SSL cert in `certs/`
Examples:
- cert / chain / fullchain: `./certs/cert.crt`
- private key: `./certs/priv.key`
- Binary: `go-proxy reload`
- Docker: `docker exec -it go-proxy /app/go-proxy reload`
2. run the binary `bin/go-proxy`
## Use JSON Schema in VSCode
3. enjoy
Modify `.vscode/settings.json` to fit your needs
### Docker
1. Copy content from [compose.example.yml](compose.example.yml) and create your own `compose.yml`
2. Add networks to make sure it is in the same network with other containers, or make sure `proxy.<alias>.host` is reachable
3. (Optional) enable HTTPS
- Use autocert feature
1. mount `./certs` to `/app/certs`
```yaml
go-proxy:
...
volumes:
- ./certs:/app/certs
```
2. complete `autocert` in `config.yml`
- Use existing certificate
Mount your wildcard (`*.y.z`) SSL cert to enable https. See [Getting SSL Certs](#getting-ssl-certs)
- cert / chain / fullchain -> `/app/certs/cert.crt`
- private key -> `/app/certs/priv.key`
4. Start `go-proxy` with `docker compose up -d` or `make up`.
5. (Optional) If you are using ufw with vpn that drop all inbound traffic except vpn, run below to allow docker containers to connect to `go-proxy`
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)
`sudo ufw allow from 172.16.0.0/16 to 100.64.0.0/10`
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' -`
6. start your docker app, and visit <container_name>.y.z
7. check the logs with `docker compose logs` or `make logs` to see if there is any error, check panel at [panel port] for active proxies
## 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, most of the time no label needs to be added.
With container name, no label needs to be added _(most of the time)_.
### Labels
### Labels (docker)
See [compose.example.yml](compose.example.yml) for more
- `proxy.aliases`: comma separated aliases for subdomain matching
- defaults to `container_name`
- `proxy.*.<field>`: wildcard config for all aliases
- `proxy.<alias>.scheme`: container port protocol (`http` or `https`)
- defaults to `http`
- `proxy.<alias>.host`: proxy host
- defaults to `container_name`
- `proxy.<alias>.port`: proxy port
- http/https: defaults to first expose port (declared in `Dockerfile` or `docker-compose.yml`)
- tcp/udp: is in format of `[<listeningPort>:]<targetPort>`
- when `listeningPort` is omitted (not suggested), a free port will be used automatically.
- `targetPort` must be a number, or the predefined names (see [constants.go:14](src/go-proxy/constants.go#L14))
- `proxy.<alias>.no_tls_verify`: whether skip tls verify when scheme is https
- defaults to false
- `proxy.<alias>.path`: path matching (for http proxy only)
- defaults to empty
- `proxy.<alias>.path_mode`: mode for path handling
- defaults to empty
- allowed: \<empty>, forward, sub
- empty: remove path prefix from URL when proxying
- default: container name
- `proxy.*.<field>`: wildcard label for all aliases
Below labels has a **`proxy.<alias>.`** prefix (i.e. `proxy.nginx.scheme: http`)
- `scheme`: proxy protocol
- default: `http`
- allowed: `http`, `https`, `tcp`, `udp`
- `host`: proxy host
- default: `container_name`
- `port`: proxy port
- default: first expose port (declared in `Dockerfile` or `docker-compose.yml`)
- `http(s)`: number in range og `0 - 65535`
- `tcp/udp`: `[<listeningPort>:]<targetPort>`
- `listeningPort`: number, when it is omitted (not suggested), a free port starting from 20000 will be used.
- `targetPort`: number, or predefined names (see [constants.go:14](src/go-proxy/constants.go#L14))
- `no_tls_verify`: whether skip tls verify when scheme is https
- default: `false`
- `path`: proxy path _(http(s) proxy only)_
- default: empty
- `path_mode`: mode for path handling
- default: empty
- allowed: empty, `forward`, `sub`
- `empty`: remove path prefix from URL when proxying
1. apps.y.z/webdav -> webdav:80
2. apps.y.z./webdav/path/to/file -> webdav:80/path/to/file
- forward: path remain unchanged
- `forward`: path remain unchanged
1. apps.y.z/webdav -> webdav:80/webdav
2. apps.y.z./webdav/path/to/file -> webdav:80/webdav/path/to/file
- sub: (experimental) remove path prefix from URL and also append path to HTML link attributes (`src`, `href` and `action`) and Javascript `fetch(url)` by response body substitution
e.g. apps.y.z/app1 -> webdav:80, `href="/path/to/file"` -> `href="/app1/path/to/file"`
- `sub`: (experimental) remove path prefix from URL and also append path to HTML link attributes (`src`, `href` and `action`) and Javascript `fetch(url)` by response body substitution
e.g. apps.y.z/app1 -> webdav:80, `href="/app1/path/to/file"` -> `href="/path/to/file"`
- `proxy.<alias>.load_balance`: enable load balance
- `load_balance`: enable load balance (docker only)
- allowed: `1`, `true`
### Environment variables
- `GOPROXY_DEBUG`: set to `1` or `true` to enable debug behaviors (i.e. output, etc.)
- `GOPROXY_REDIRECT_HTTP`: set to `0` or `false` to disable http to https redirect (only when certs are located)
### Config File
See [config.example.yml](config.example.yml)
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
See [providers.example.yml](providers.example.yml)
Fields are same as [docker labels](#labels-docker) starting from `scheme`
### Supported cert providers
See [providers.example.yml](providers.example.yml) for examples
### Supported DNS Challenge Providers
- Cloudflare
```yaml
autocert:
...
options:
auth_token: "YOUR_ZONE_API_TOKEN"
```
- `auth_token`: your zone API token
Follow [this guide](https://cloudkul.com/blog/automcatic-renew-and-generate-ssl-on-your-website-using-lego-client/) to create a new token with `Zone.DNS` read and edit permissions
To add more provider support, see [this](docs/add_dns_provider.md)
## Examples
See [docker.md](docs/docker.md#docker-compose-example) for complete examples
### Single port configuration example
```yaml
@@ -241,7 +258,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
@@ -315,7 +332,7 @@ Local benchmark (client running wrk and `go-proxy` server are under same proxmox
- Direct connection
```
```shell
root@http-benchmark-client:~# wrk -t 10 -c 200 -d 10s --latency http://10.0.100.1/bench
Running 10s test @ http://10.0.100.1/bench
10 threads and 200 connections
@@ -334,7 +351,7 @@ Local benchmark (client running wrk and `go-proxy` server are under same proxmox
- With `go-proxy` reverse proxy
```
```shell
root@http-benchmark-client:~# wrk -t 10 -c 200 -d 10s -H "Host: bench.6uo.me" --latency http://10.0.1.7/bench
Running 10s test @ http://10.0.1.7/bench
10 threads and 200 connections
@@ -352,7 +369,8 @@ Local benchmark (client running wrk and `go-proxy` server are under same proxmox
```
- With `traefik-v3`
```
```shell
root@traefik-benchmark:~# wrk -t10 -c200 -d10s -H "Host: benchmark.whoami" --latency http://127.0.0.1:8000/bench
Running 10s test @ http://127.0.0.1:8000/bench
10 threads and 200 connections
@@ -369,18 +387,22 @@ Local benchmark (client running wrk and `go-proxy` server are under same proxmox
Transfer/sec: 10.94MB
```
## Known issues
UDP proxy does not work for PalWorld Dedicated Server
## Memory usage
It takes ~30 MB for 50 proxy entries
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 `make up` (docker) or `bin/go-proxy` (binary)
4. build binary with `make build`
[panel port]: 8443
5. start your container with `make up` (docker) or `bin/go-proxy` (binary)

Binary file not shown.

View File

@@ -1,46 +1,40 @@
version: '3'
services:
app:
build: .
image: ghcr.io/yusing/go-proxy:latest
container_name: go-proxy
restart: always
networks: # ^also add here
- default
# environment:
# - GOPROXY_DEBUG=1 # (optional, enable only for debug)
# - GOPROXY_REDIRECT_HTTP=0 # (optional, uncomment to disable http redirect (http -> https))
ports:
- 80:80 # http
# - 443:443 # optional, https
- 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:
- ./config:/app/config
# if local docker provider is used
- /var/run/docker.sock:/var/run/docker.sock:ro
# use existing certificate
# - /path/to/cert.pem:/app/certs/cert.crt:ro
# - /path/to/privkey.pem:/app/certs/priv.key:ro
# use autocert feature
# store autocert obtained cert
# - ./certs:/app/certs
# workaround for "lookup: no such host"
# dns:
# - 127.0.0.1
# if local docker provider is used (by default)
- /var/run/docker.sock:/var/run/docker.sock:ro
# to use custom config
# - path/to/config.yml:/app/config.yml
# mount file provider yaml files
# - path/to/provider1.yml:/app/provider1.yml
# - path/to/provider2.yml:/app/provider2.yml
# etc.
dns:
- 127.0.0.1 # workaround for "lookup: no such host"
extra_hosts:
# required if you use local docker provider and have containers in `host` network_mode
- host.docker.internal:host-gateway
# if you have container running in "host" network mode
# extra_hosts:
# - host.docker.internal:host-gateway
logging:
driver: 'json-file'
options:

View File

@@ -1,25 +1,21 @@
# uncomment to use autocert
# autocert:
# email: "user@y.z" # email for acme certificate
# domains:
# 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
# options:
# 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
# remote1:
# kind: docker
# value: ssh://user@10.0.1.1
# remote2:
# kind: docker
# value: tcp://10.0.1.1:2375
# provider1:
# kind: file
# value: provider1.yml
# provider2:
# kind: file
# value: provider2.yml
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-binary | 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`

124
docs/docker.md Normal file
View File

@@ -0,0 +1,124 @@
# 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' -`
## Docker compose example
```yaml
volumes:
adg-work:
adg-conf:
mc-data:
services:
adg:
image: adguard/adguardhome
restart: unless-stopped
labels:
- proxy.aliases=adg,adg-dns,adg-setup
- proxy.adg.port=80
- proxy.adg-setup.port=3000
- proxy.adg-dns.scheme=udp
- proxy.adg-dns.port=20000:dns
volumes:
- adg-work:/opt/adguardhome/work
- adg-conf:/opt/adguardhome/conf
mc:
image: itzg/minecraft-server
tty: true
stdin_open: true
container_name: mc
restart: unless-stopped
labels:
- proxy.mc.scheme=tcp
- proxy.mc.port=20001:25565
environment:
EULA: "TRUE"
volumes:
- mc-data:/data
go-proxy:
image: ghcr.io/yusing/go-proxy
container_name: go-proxy
restart: always
ports:
- 80:80 # http
- 443:443 # optional, https
- 8080:8080 # http panel
- 8443:8443 # optional, https panel
- 53:20000/udp # adguardhome
- 25565:20001/tcp # minecraft
volumes:
- ./config:/app/config
- /var/run/docker.sock:/var/run/docker.sock:ro
labels:
- proxy.aliases=gp
- proxy.panel.port=8080
```
### Services URLs
- `gp.yourdomain.com`: go-proxy web panel
- `adg-setup.yourdomain.com`: adguard setup (first time running)
- `adg.yourdomain.com`: adguard dashboard
- `yourdomain.com:53`: adguard dns
- `yourdomain.com:25565`: minecraft server

View File

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

18
go.mod
View File

@@ -1,23 +1,25 @@
module github.com/yusing/go-proxy
go 1.21.7
go 1.22
require (
github.com/docker/cli v26.0.0+incompatible
github.com/docker/docker v26.0.0+incompatible
github.com/fsnotify/fsnotify v1.7.0
github.com/go-acme/lego/v4 v4.16.1
github.com/santhosh-tekuri/jsonschema v1.2.4
github.com/sirupsen/logrus v1.9.3
golang.org/x/net v0.22.0
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect
github.com/Microsoft/go-winio v0.6.1 // indirect
github.com/cenkalti/backoff/v4 v4.2.1 // indirect
github.com/cloudflare/cloudflare-go v0.91.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.5.0 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/go-connections v0.5.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
@@ -26,7 +28,9 @@ require (
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/golang/protobuf v1.5.3 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.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
@@ -38,15 +42,21 @@ require (
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 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
go.opentelemetry.io/proto/otlp v1.1.0 // indirect
golang.org/x/crypto v0.21.0 // indirect
golang.org/x/mod v0.16.0 // indirect
golang.org/x/sys v0.18.0 // indirect
golang.org/x/text v0.14.0 // indirect
golang.org/x/time v0.5.0 // indirect
golang.org/x/tools v0.19.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20240102182953-50ed04b92917 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240102182953-50ed04b92917 // indirect
google.golang.org/grpc v1.61.1 // indirect
google.golang.org/protobuf v1.32.0 // indirect
gotest.tools/v3 v3.5.1 // indirect
)

36
go.sum
View File

@@ -1,22 +1,18 @@
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8=
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Microsoft/go-winio v0.4.14 h1:+hMXMk01us9KgxGb7ftKQt2Xpf5hH/yky+TDA+qxleU=
github.com/Microsoft/go-winio v0.4.14/go.mod h1:qXqCSQ3Xa7+6tgxaGTIe4Kpcdsi+P8jBhyzoq1bpyYA=
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/cloudflare/cloudflare-go v0.86.0 h1:jEKN5VHNYNYtfDL2lUFLTRo+nOVNPFxpXTstVx0rqHI=
github.com/cloudflare/cloudflare-go v0.86.0/go.mod h1:wYW/5UP02TUfBToa/yKbQHV+r6h1NnJ1Je7XjuGM4Jw=
github.com/cloudflare/cloudflare-go v0.91.0 h1:L7IR+86qrZuEMSjGFg4cwRwtHqC8uCPmMUkP7BD4CPw=
github.com/cloudflare/cloudflare-go v0.91.0/go.mod h1:nUqvBUUDRxNzsDSQjbqUNWHEIYAoUlgRmcAzMKlFdKs=
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/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=
@@ -44,9 +40,11 @@ github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
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.5.5/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=
@@ -62,7 +60,6 @@ github.com/hashicorp/go-retryablehttp v0.7.5 h1:bJj+Pj19UZMIweq/iie+1u5YCdGrnxCT
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/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
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=
@@ -83,23 +80,21 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
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/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q=
github.com/santhosh-tekuri/jsonschema v1.2.4 h1:hNhW8e7t+H1vgY+1QeEQpveR6D4+OwKPXCfD2aieJis=
github.com/santhosh-tekuri/jsonschema v1.2.4/go.mod h1:TEAUOeZSmIxTTuHatJzrvARHiuO9LYd+cIxzgEHCQI4=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk=
@@ -125,8 +120,6 @@ 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.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0=
golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic=
golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
@@ -140,11 +133,10 @@ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
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-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
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=
@@ -158,8 +150,6 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.17.0 h1:FvmRgNOcs3kOa+T20R1uhfP9F6HgG2mfxDv1vrx1Htc=
golang.org/x/tools v0.17.0/go.mod h1:xsh6VxdV005rRVaS6SSAf9oiAqljS7UZUacMZ8Bnsps=
golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw=
golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -173,6 +163,8 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20240102182953-50ed04b92917 h1:
google.golang.org/genproto/googleapis/rpc v0.0.0-20240102182953-50ed04b92917/go.mod h1:xtjpI3tXFPP051KaWnhvxkiubL/6dJ18vLVf7q2pTOU=
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.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
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=

View File

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

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

View File

@@ -7,7 +7,6 @@ import (
"crypto/rand"
"crypto/tls"
"crypto/x509"
"fmt"
"os"
"path"
"sync"
@@ -15,16 +14,22 @@ import (
"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
Domains []string `yaml:",flow"`
Provider string
Options map[string]string `yaml:",flow"`
Email string `json:"email"`
Domains []string `yaml:",flow" json:"domains"`
Provider string `json:"provider"`
Options ProviderOptions `yaml:",flow" json:"options"`
}
type AutoCertUser struct {
@@ -46,27 +51,37 @@ func (u *AutoCertUser) GetPrivateKey() crypto.PrivateKey {
type AutoCertProvider interface {
GetCert(*tls.ClientHelloInfo) (*tls.Certificate, error)
GetName() string
GetExpiry() time.Time
GetExpiries() CertExpiries
LoadCert() bool
ObtainCert() error
needRenew() bool
ObtainCert() NestedErrorLike
RenewalOn() time.Time
ScheduleRenewal()
}
func (cfg AutoCertConfig) GetProvider() (AutoCertProvider, error) {
ne := NewNestedError("invalid autocert config")
if len(cfg.Domains) == 0 {
return nil, fmt.Errorf("no domains specified")
ne.Extra("no domains specified")
}
if cfg.Provider == "" {
return nil, fmt.Errorf("no provider specified")
ne.Extra("no provider specified")
}
if cfg.Email == "" {
return nil, fmt.Errorf("no email specified")
ne.Extra("no email specified")
}
gen, ok := providersGenMap[cfg.Provider]
if !ok {
ne.Extraf("unknown provider: %s", cfg.Provider)
}
if ne.HasExtras() {
return nil, ne
}
ne = NewNestedError("unable to create provider")
privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, fmt.Errorf("unable to generate private key: %v", err)
return nil, ne.With(NewNestedError("unable to generate private key").With(err))
}
user := &AutoCertUser{
Email: cfg.Email,
@@ -76,65 +91,61 @@ func (cfg AutoCertConfig) GetProvider() (AutoCertProvider, error) {
legoCfg.Certificate.KeyType = certcrypto.RSA2048
legoClient, err := lego.NewClient(legoCfg)
if err != nil {
return nil, fmt.Errorf("unable to create lego client: %v", err)
return nil, ne.With(NewNestedError("unable to create lego client").With(err))
}
base := &AutoCertProviderBase{
base := &autoCertProvider{
name: cfg.Provider,
cfg: cfg,
user: user,
legoCfg: legoCfg,
client: legoClient,
}
switch cfg.Provider {
case "cloudflare":
return NewAutoCertCFProvider(base, cfg.Options)
legoProvider, err := gen(cfg.Options)
if err != nil {
return nil, ne.With(err)
}
return nil, fmt.Errorf("unknown provider: %s", cfg.Provider)
err = legoClient.Challenge.SetDNS01Provider(legoProvider)
if err != nil {
return nil, ne.With(NewNestedError("unable to set challenge provider").With(err))
}
return base, nil
}
type AutoCertProviderBase struct {
type autoCertProvider struct {
name string
cfg AutoCertConfig
user *AutoCertUser
legoCfg *lego.Config
client *lego.Client
tlsCert *tls.Certificate
expiry time.Time
mutex sync.Mutex
tlsCert *tls.Certificate
certExpiries CertExpiries
mutex sync.Mutex
}
func (p *AutoCertProviderBase) GetCert(_ *tls.ClientHelloInfo) (*tls.Certificate, error) {
func (p *autoCertProvider) GetCert(_ *tls.ClientHelloInfo) (*tls.Certificate, error) {
if p.tlsCert == nil {
aclog.Fatal("no certificate available")
}
if p.needRenew() {
p.mutex.Lock()
defer p.mutex.Unlock()
if p.needRenew() {
err := p.ObtainCert()
if err != nil {
return nil, err
}
}
return nil, NewNestedError("no certificate available")
}
return p.tlsCert, nil
}
func (p *AutoCertProviderBase) GetName() string {
func (p *autoCertProvider) GetName() string {
return p.name
}
func (p *AutoCertProviderBase) GetExpiry() time.Time {
return p.expiry
func (p *autoCertProvider) GetExpiries() CertExpiries {
return p.certExpiries
}
func (p *AutoCertProviderBase) ObtainCert() error {
func (p *autoCertProvider) ObtainCert() NestedErrorLike {
ne := NewNestedError("failed to obtain certificate")
client := p.client
if p.user.Registration == nil {
reg, err := client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true})
if err != nil {
return err
return ne.With(NewNestedError("failed to register account").With(err))
}
p.user.Registration = reg
}
@@ -144,90 +155,155 @@ func (p *AutoCertProviderBase) ObtainCert() error {
}
cert, err := client.Certificate.Obtain(req)
if err != nil {
return err
return ne.With(err)
}
err = p.saveCert(cert)
if err != nil {
return err
return ne.With(NewNestedError("failed to save certificate").With(err))
}
tlsCert, err := tls.X509KeyPair(cert.Certificate, cert.PrivateKey)
if err != nil {
return err
return ne.With(NewNestedError("failed to parse obtained certificate").With(err))
}
expiries, err := getCertExpiries(&tlsCert)
if err != nil {
return ne.With(NewNestedError("failed to get certificate expiry").With(err))
}
p.tlsCert = &tlsCert
x509Cert, err := x509.ParseCertificate(tlsCert.Certificate[len(tlsCert.Certificate)-1])
if err != nil {
return err
}
p.expiry = x509Cert.NotAfter
p.certExpiries = expiries
return nil
}
func (p *AutoCertProviderBase) LoadCert() bool {
func (p *autoCertProvider) LoadCert() bool {
cert, err := tls.LoadX509KeyPair(certFileDefault, keyFileDefault)
if err != nil {
return false
}
x509Cert, err := x509.ParseCertificate(cert.Certificate[len(cert.Certificate)-1])
expiries, err := getCertExpiries(&cert)
if err != nil {
return false
}
p.tlsCert = &cert
p.expiry = x509Cert.NotAfter
p.certExpiries = expiries
p.renewIfNeeded()
return true
}
func (p *AutoCertProviderBase) saveCert(cert *certificate.Resource) error {
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 fmt.Errorf("unable to create cert directory: %v", err)
return NewNestedError("unable to create cert directory").With(err)
}
err = os.WriteFile(keyFileDefault, cert.PrivateKey, 0600) // -rw-------
if err != nil {
return fmt.Errorf("unable to write key file: %v", err)
return NewNestedError("unable to write key file").With(err)
}
err = os.WriteFile(certFileDefault, cert.Certificate, 0644) // -rw-r--r--
if err != nil {
return fmt.Errorf("unable to write cert file: %v", err)
return NewNestedError("unable to write cert file").With(err)
}
return nil
}
func (p *AutoCertProviderBase) needRenew() bool {
return p.expiry.Before(time.Now().Add(24 * time.Hour))
func (p *autoCertProvider) needRenewal() bool {
return time.Now().After(p.RenewalOn())
}
type AutoCertCFProvider struct {
*AutoCertProviderBase
*cloudflare.Config
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 NewAutoCertCFProvider(base *AutoCertProviderBase, opt map[string]string) (*AutoCertCFProvider, error) {
p := &AutoCertCFProvider{
base,
cloudflare.NewDefaultConfig(),
}
err := setOptions(p.Config, opt)
if err != nil {
return nil, err
}
legoProvider, err := cloudflare.NewDNSProviderConfig(p.Config)
if err != nil {
return nil, fmt.Errorf("unable to create cloudflare provider: %v", err)
}
err = p.client.Challenge.SetDNS01Provider(legoProvider)
if err != nil {
return nil, fmt.Errorf("unable to set challenge provider: %v", err)
}
return p, nil
}
func setOptions[T interface{}](cfg *T, opt map[string]string) error {
for k, v := range opt {
err := SetFieldFromSnake(cfg, k, v)
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 err
return nil, err
}
p, err := newProvider(cfg)
if err != nil {
return nil, err
}
return p, nil
}
}
func getCertExpiries(cert *tls.Certificate) (CertExpiries, error) {
r := make(CertExpiries, len(cert.Certificate))
for _, cert := range cert.Certificate {
x509Cert, err := x509.ParseCertificate(cert)
if err != nil {
return nil, NewNestedError("unable to parse certificate").With(err)
}
if x509Cert.IsCA {
continue
}
r[x509Cert.Subject.CommonName] = x509Cert.NotAfter
}
return r, nil
}
func setOptions[T interface{}](cfg *T, opt ProviderOptions) error {
for k, v := range opt {
err := setFieldFromSnake(cfg, k, v)
if err != nil {
return NewNestedError("unable to set option").Subject(k).With(err)
}
}
return nil
}
var providersGenMap = map[string]ProviderGenerator{
"cloudflare": providerGenerator(cloudflare.NewDefaultConfig, cloudflare.NewDNSProviderConfig),
"clouddns": providerGenerator(clouddns.NewDefaultConfig, clouddns.NewDNSProviderConfig),
}

View File

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

View File

@@ -7,11 +7,12 @@ import (
"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",
@@ -21,7 +22,7 @@ var (
"rabbitmq": "5672",
"mongo": "27017",
}
ExtraNamePortMap = map[string]string{
ExtraNamePortMapTCP = map[string]string{
"dns": "53",
"ssh": "22",
"ftp": "21",
@@ -29,18 +30,44 @@ 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{StreamType_TCP, StreamType_UDP} // TODO: support "tcp:udp", "udp:tcp"
HTTPSchemes = []string{"http", "https"}
@@ -80,6 +107,20 @@ var (
clone.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
return clone
}()
healthCheckHttpClient = &http.Client{
Timeout: 5 * time.Second,
Transport: &http.Transport{
Proxy: http.ProxyFromEnvironment,
DisableKeepAlives: true,
ForceAttemptHTTP2: true,
DialContext: (&net.Dialer{
Timeout: 5 * time.Second,
KeepAlive: 5 * time.Second,
}).DialContext,
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
},
}
)
const wildcardLabelPrefix = "proxy.*."
@@ -87,13 +128,43 @@ const wildcardLabelPrefix = "proxy.*."
const clientUrlFromEnv = "FROM_ENV"
const (
certFileDefault = "certs/cert.crt"
keyFileDefault = "certs/priv.key"
configPath = "config.yml"
templatePath = "templates/panel.html"
certBasePath = "certs/"
certFileDefault = certBasePath + "cert.crt"
keyFileDefault = certBasePath + "priv.key"
configBasePath = "config/"
configPath = configBasePath + "config.yml"
templatesBasePath = "templates/"
panelTemplatePath = templatesBasePath + "panel/index.html"
configEditorTemplatePath = templatesBasePath + "config_editor/index.html"
schemaBasePath = "schema/"
configSchemaPath = schemaBasePath + "config.schema.json"
providersSchemaPath = schemaBasePath + "providers.schema.json"
)
const StreamStopListenTimeout = 1 * time.Second
var (
configSchema *jsonschema.Schema
providersSchema *jsonschema.Schema
_ = func() *jsonschema.Compiler {
c := jsonschema.NewCompiler()
c.Draft = jsonschema.Draft7
var err error
if configSchema, err = c.Compile(configSchemaPath); err != nil {
panic(err)
}
if providersSchema, err = c.Compile(providersSchemaPath); err != nil {
panic(err)
}
return c
}()
)
const (
streamStopListenTimeout = 1 * time.Second
streamDialTimeout = 3 * time.Second
)
const udpBufferSize = 1500
@@ -105,4 +176,7 @@ var logLevel = func() logrus.Level {
return logrus.GetLevel()
}()
var redirectHTTP = os.Getenv("GOPROXY_REDIRECT_HTTP") != "0" && os.Getenv("GOPROXY_REDIRECT_HTTP") != "false"
var isRunningAsService = func() bool {
v := os.Getenv("IS_SYSTEMD")
return v == "1"
}()

View File

@@ -16,47 +16,55 @@ import (
func (p *Provider) setConfigField(c *ProxyConfig, label string, value string, prefix string) error {
if strings.HasPrefix(label, prefix) {
field := strings.TrimPrefix(label, prefix)
SetFieldFromSnake(c, field, value)
if err := setFieldFromSnake(c, field, value); err != nil {
return err
}
}
return nil
}
func (p *Provider) getContainerProxyConfigs(container types.Container, clientIP string) []*ProxyConfig {
func (p *Provider) getContainerProxyConfigs(container *types.Container, clientIP string) ProxyConfigSlice {
var aliases []string
cfgs := make([]*ProxyConfig, 0)
cfgs := make(ProxyConfigSlice, 0)
container_name := strings.TrimPrefix(container.Names[0], "/")
aliases_label, ok := container.Labels["proxy.aliases"]
containerName := strings.TrimPrefix(container.Names[0], "/")
aliasesLabel, ok := container.Labels["proxy.aliases"]
if !ok {
aliases = []string{container_name}
aliases = []string{containerName}
} else {
aliases = strings.Split(aliases_label, ",")
aliases = strings.Split(aliasesLabel, ",")
}
isRemote := clientIP != ""
ne := NewNestedError("invalid label config").Subjectf("container %s", containerName)
defer func() {
if ne.HasExtras() {
p.l.Error(ne)
}
}()
for _, alias := range aliases {
l := p.l.WithField("container", container_name).WithField("alias", alias)
l := p.l.WithField("container", containerName).WithField("alias", alias)
config := NewProxyConfig(p)
prefix := fmt.Sprintf("proxy.%s.", alias)
for label, value := range container.Labels {
err := p.setConfigField(&config, label, value, prefix)
if err != nil {
l.Error(err)
ne.ExtraError(NewNestedErrorFrom(err).Subjectf("alias %s", alias))
}
err = p.setConfigField(&config, label, value, wildcardLabelPrefix)
if err != nil {
l.Error(err)
ne.ExtraError(NewNestedErrorFrom(err).Subjectf("alias %s", alias))
}
}
if config.Port == "" {
config.Port = fmt.Sprintf("%d", selectPort(container))
config.Port = fmt.Sprintf("%d", selectPort(container, isRemote))
}
if config.Port == "0" {
// no ports exposed or specified
l.Debugf("no ports exposed, ignored")
l.Infof("no ports exposed, ignored")
continue
}
if config.Scheme == "" {
@@ -66,10 +74,8 @@ func (p *Provider) getContainerProxyConfigs(container types.Container, clientIP
case strings.HasPrefix(container.Image, "sha256:"):
config.Scheme = "http"
default:
imageSplit := strings.Split(container.Image, "/")
imageSplit = strings.Split(imageSplit[len(imageSplit)-1], ":")
imageName := imageSplit[0]
_, isKnownImage := ImageNamePortMap[imageName]
imageName := getImageName(container)
_, isKnownImage := ImageNamePortMapTCP[imageName]
if isKnownImage {
config.Scheme = "tcp"
} else {
@@ -102,11 +108,11 @@ func (p *Provider) getContainerProxyConfigs(container types.Container, clientIP
}
}
if config.Host == "" {
config.Host = container_name
config.Host = containerName
}
config.Alias = alias
cfgs = append(cfgs, &config)
cfgs = append(cfgs, config)
}
return cfgs
}
@@ -145,7 +151,7 @@ func (p *Provider) getDockerClient() (*client.Client, error) {
return client.NewClientWithOpts(dockerOpts...)
}
func (p *Provider) getDockerProxyConfigs() ([]*ProxyConfig, error) {
func (p *Provider) getDockerProxyConfigs() (ProxyConfigSlice, error) {
var clientIP string
if p.Value == clientUrlFromEnv {
@@ -153,7 +159,7 @@ func (p *Provider) getDockerProxyConfigs() ([]*ProxyConfig, error) {
} else {
url, err := client.ParseHostURL(p.Value)
if err != nil {
return nil, fmt.Errorf("unable to parse docker host url: %v", err)
return nil, NewNestedError("invalid host url").Subject(p.Value).With(err)
}
clientIP = strings.Split(url.Host, ":")[0]
}
@@ -161,38 +167,59 @@ func (p *Provider) getDockerProxyConfigs() ([]*ProxyConfig, error) {
dockerClient, err := p.getDockerClient()
if err != nil {
return nil, fmt.Errorf("unable to create docker client: %v", err)
return nil, NewNestedError("unable to create docker client").With(err)
}
ctx, _ := context.WithTimeout(context.Background(), 3*time.Second)
containerSlice, err := dockerClient.ContainerList(ctx, container.ListOptions{All: true})
if err != nil {
return nil, fmt.Errorf("unable to list containers: %v", err)
return nil, NewNestedError("unable to list containers").With(err)
}
cfgs := make([]*ProxyConfig, 0)
cfgs := make(ProxyConfigSlice, 0)
for _, container := range containerSlice {
cfgs = append(cfgs, p.getContainerProxyConfigs(container, clientIP)...)
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, getPrivatePort)
func selectPort(c *types.Container, isRemote bool) uint16 {
if isRemote || c.HostConfig.NetworkMode == "host" {
return selectPortInternal(c, getPublicPort)
}
return selectPortInternal(c, getPublicPort)
return selectPortInternal(c, getPrivatePort)
}
func selectPortInternal(c types.Container, getPort func(types.Port) uint16) uint16 {
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
@@ -200,3 +227,8 @@ func selectPortInternal(c types.Container, getPort func(types.Port) uint16) uint
}
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

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

View File

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

View File

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

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
}

View File

@@ -5,77 +5,95 @@ import (
"os"
"os/signal"
"runtime"
"sync"
"syscall"
"time"
"github.com/sirupsen/logrus"
)
var cfg Config
func main() {
// flag.Parse()
runtime.GOMAXPROCS(runtime.NumCPU())
logrus.SetFormatter(&logrus.TextFormatter{
ForceColors: true,
DisableColors: false,
FullTimestamp: true,
})
args := getArgs()
cfg := NewConfig()
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",
})
}
if args.Command == CommandReload {
err := utils.reloadServer()
if err != nil {
logrus.Fatal(err)
}
return
}
cfg = NewConfig(configPath)
cfg.MustLoad()
if args.Command == CommandValidate {
logrus.Printf("config OK")
return
}
autoCertProvider, err := cfg.GetAutoCertProvider()
if err != nil {
aclog.Warn(err)
autoCertProvider = nil
autoCertProvider = nil // TODO: remove, it is expected to be nil if error is not nil, but it is not for now
}
var httpProxyHandler http.Handler
var httpPanelHandler http.Handler
var proxyServer *Server
var panelServer *Server
if redirectHTTP {
httpProxyHandler = http.HandlerFunc(redirectToTLSHandler)
httpPanelHandler = http.HandlerFunc(redirectToTLSHandler)
} else {
httpProxyHandler = http.HandlerFunc(proxyHandler)
httpPanelHandler = http.HandlerFunc(panelHandler)
}
if autoCertProvider != nil {
ok := autoCertProvider.LoadCert()
if !ok {
err := autoCertProvider.ObtainCert()
if err != nil {
aclog.Fatal("error obtaining certificate ", err)
if ne := autoCertProvider.ObtainCert(); ne != nil {
aclog.Fatal(ne)
}
}
aclog.Infof("certificate will be expired at %v and get renewed", autoCertProvider.GetExpiry())
for name, expiry := range autoCertProvider.GetExpiries() {
aclog.Infof("certificate %q: expire on %v", name, expiry)
}
go autoCertProvider.ScheduleRenewal()
}
proxyServer = NewServer(
"proxy",
autoCertProvider,
":80",
httpProxyHandler,
":443",
http.HandlerFunc(proxyHandler),
)
panelServer = NewServer(
"panel",
autoCertProvider,
":8080",
httpPanelHandler,
":8443",
http.HandlerFunc(panelHandler),
)
proxyServer = NewServer(ServerOptions{
Name: "proxy",
CertProvider: autoCertProvider,
HTTPAddr: ":80",
HTTPSAddr: ":443",
Handler: http.HandlerFunc(proxyHandler),
RedirectToHTTPS: 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()
InitDockerWatcher()
cfg.StartProviders()
cfg.WatchChanges()
@@ -86,10 +104,32 @@ func main() {
signal.Notify(sig, syscall.SIGHUP)
<-sig
cfg.StopWatching()
StopFSWatcher()
StopDockerWatcher()
cfg.StopProviders()
panelServer.Stop()
proxyServer.Stop()
logrus.Info("shutting down")
done := make(chan struct{}, 1)
var wg sync.WaitGroup
wg.Add(3)
go func() {
StopFSWatcher()
StopDockerWatcher()
cfg.StopProviders()
wg.Done()
}()
go func() {
panelServer.Stop()
proxyServer.Stop()
wg.Done()
}()
go func() {
wg.Wait()
close(done)
}()
select {
case <-done:
logrus.Info("shutdown complete")
case <-time.After(cfg.Value().TimeoutShutdown * time.Second):
logrus.Info("timeout waiting for shutdown")
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,6 +2,7 @@ package main
import (
"crypto/tls"
"log"
"net/http"
"time"
@@ -20,37 +21,73 @@ type Server struct {
httpsStarted bool
}
func NewServer(name string, provider AutoCertProvider, httpAddr string, httpHandler http.Handler, httpsAddr string, httpsHandler http.Handler) *Server {
if provider != nil {
return &Server{
Name: name,
CertProvider: provider,
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: httpAddr,
Handler: httpHandler,
Addr: opt.HTTPAddr,
Handler: httpHandler,
ErrorLog: logger,
},
https: &http.Server{
Addr: httpsAddr,
Handler: httpsHandler,
Addr: opt.HTTPSAddr,
Handler: opt.Handler,
ErrorLog: logger,
TLSConfig: &tls.Config{
GetCertificate: provider.GetCert,
GetCertificate: opt.CertProvider.GetCert,
},
},
}
}
return &Server{
Name: name,
s = &Server{
Name: opt.Name,
KeyFile: keyFileDefault,
CertFile: certFileDefault,
http: &http.Server{
Addr: httpAddr,
Handler: httpHandler,
Addr: opt.HTTPAddr,
Handler: httpHandler,
ErrorLog: logger,
},
https: &http.Server{
Addr: httpsAddr,
Handler: httpsHandler,
Addr: opt.HTTPSAddr,
Handler: opt.Handler,
ErrorLog: logger,
},
}
if !s.certsOK() {
s.http.Handler = opt.Handler
}
return s
}
func (s *Server) Start() {
@@ -63,7 +100,7 @@ func (s *Server) Start() {
}()
}
if s.https != nil && (s.CertProvider != nil || utils.fileOK(s.CertFile) && utils.fileOK(s.KeyFile)) {
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() {
@@ -79,11 +116,13 @@ func (s *Server) Stop() {
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
}
}
@@ -95,3 +134,7 @@ func (s *Server) handleErr(scheme string, err error) {
logrus.Fatalf("failed to start %s %s server: %v", scheme, s.Name, err)
}
}
func (s *Server) certsOK() bool {
return utils.fileOK(s.CertFile) && utils.fileOK(s.KeyFile)
}

View File

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

View File

@@ -3,7 +3,6 @@ package main
import (
"context"
"fmt"
"io"
"net"
"sync"
"time"
@@ -11,122 +10,74 @@ import (
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 != StreamType_TCP {
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) Start() {
route.setupListen()
func (route *TCPRoute) Setup() error {
in, err := net.Listen("tcp", fmt.Sprintf(":%v", route.ListeningPort))
if err != nil {
route.l.Error(err)
return
return err
}
route.listener = in
route.wg.Add(2)
go route.grAcceptConnections()
go route.grHandleConnections()
return nil
}
func (route *TCPRoute) Stop() {
stopListening(route)
streamRoutes.Delete(route.id)
func (route *TCPRoute) Accept() (interface{}, error) {
return route.listener.Accept()
}
func (route *TCPRoute) closeListeners() {
if route.listener == nil {
return
}
route.listener.Close()
route.listener = nil
}
func (route *TCPRoute) Handle(c interface{}) error {
clientConn := c.(net.Conn)
func (route *TCPRoute) grAcceptConnections() {
defer route.wg.Done()
for {
select {
case <-route.stopChann:
return
default:
conn, err := route.listener.Accept()
if err != nil {
route.l.Error(err)
continue
}
route.connChan <- conn
}
}
}
func (route *TCPRoute) grHandleConnections() {
defer route.wg.Done()
for {
select {
case <-route.stopChann:
return
case conn := <-route.connChan:
route.wg.Add(1)
go route.grHandleConnection(conn)
}
}
}
func (route *TCPRoute) grHandleConnection(clientConn net.Conn) {
defer clientConn.Close()
defer route.wg.Done()
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 {
route.l.WithField("stage", "dial").Infof("%v", err)
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.tcpPipe(clientConn, serverConn)
}
func (route *TCPRoute) tcpPipe(src net.Conn, dest net.Conn) {
close := func() {
src.Close()
dest.Close()
route.listener.Close()
route.listener = nil
for _, pipe := range route.pipe {
if err := pipe.Stop(); err != nil {
route.l.Error(err)
}
}
var wg sync.WaitGroup
wg.Add(2) // Number of goroutines
go func() {
_, err := io.Copy(src, dest)
route.l.Error(err)
close()
wg.Done()
}()
go func() {
_, err := io.Copy(dest, src)
route.l.Error(err)
close()
wg.Done()
}()
wg.Wait()
}

View File

@@ -1,188 +1,59 @@
package main
import (
"context"
"fmt"
"io"
"net"
"sync"
"github.com/sirupsen/logrus"
)
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 != StreamType_UDP {
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) Start() {
route.setupListen()
source, err := net.ListenPacket(route.ListeningScheme, fmt.Sprintf(":%v", route.ListeningPort))
if err != nil {
route.l.Error(err)
return
}
target, err := net.Dial(route.TargetScheme, fmt.Sprintf("%s:%v", route.TargetHost, route.TargetPort))
if err != nil {
route.l.Error(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) Stop() {
stopListening(route)
streamRoutes.Delete(route.id)
}
func (route *UDPRoute) closeListeners() {
if route.listeningConn != nil {
route.listeningConn.Close()
route.listeningConn = nil
}
if route.targetConn != nil {
route.targetConn.Close()
route.targetConn = nil
}
for _, conn := range route.connMap {
conn.(*net.UDPConn).Close() // TODO: change on non udp target
}
route.connMap = make(map[net.Addr]net.Conn)
}
func (route *UDPRoute) grAcceptConnections() {
defer route.wg.Done()
for {
select {
case <-route.stopChann:
return
default:
conn, err := route.accept()
if err != nil {
route.l.Error(err)
continue
}
route.connChan <- conn
}
connMap: make(UDPConnMap),
}
}
func (route *UDPRoute) grHandleConnections() {
defer route.wg.Done()
for {
select {
case <-route.stopChann:
return
case conn := <-route.connChan:
go func() {
err := route.handleConnection(conn)
if err != nil {
route.l.Error(err)
}
}()
}
}
}
func (route *UDPRoute) handleConnection(conn *UDPConn) error {
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()
}
var forwarder func(*UDPConn, net.Conn) error
if logLevel == logrus.DebugLevel {
forwarder = route.forwardReceivedDebug
} else {
forwarder = route.forwardReceivedReal
}
// initiate connection to target
err = forwarder(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 = forwarder(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 = forwarder(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)
@@ -196,48 +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
_, err = conn.dst.Write(buffer[:nRead])
return conn, 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
}
return &UDPConn{
remoteAddr: src.RemoteAddr(),
buffer: buffer,
bytesReceived: buffer[:nRead],
nReceived: nRead,
}, nil
}
func (route *UDPRoute) forwardReceivedReal(receivedConn *UDPConn, dest net.Conn) error {
nWritten, err := dest.Write(receivedConn.bytesReceived)
if nWritten != receivedConn.nReceived {
err = io.ErrShortWrite
for _, conn := range route.connMap {
if err := conn.dst.Close(); err != nil {
route.l.Error(err)
}
}
return err
route.connMap = make(UDPConnMap)
}
func (route *UDPRoute) forwardReceivedDebug(receivedConn *UDPConn, dest net.Conn) error {
route.l.WithField("size", receivedConn.nReceived).Debugf(
"forwarding from %s to %s",
receivedConn.remoteAddr.String(),
dest.RemoteAddr().String(),
)
return route.forwardReceivedReal(receivedConn, dest)
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,6 +2,7 @@ package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net"
@@ -13,10 +14,11 @@ import (
"regexp"
"strings"
"sync"
"time"
"github.com/santhosh-tekuri/jsonschema"
"github.com/sirupsen/logrus"
xhtml "golang.org/x/net/html"
"gopkg.in/yaml.v3"
)
type Utils struct {
@@ -52,7 +54,7 @@ func (u *Utils) findUseFreePort(startingPort int) (int, error) {
l.Close()
return port, nil
}
return -1, fmt.Errorf("unable to find free port: %v", err)
return -1, NewNestedError("unable to find free port").With(err)
}
func (u *Utils) markPortInUse(port int) {
@@ -84,7 +86,7 @@ func (*Utils) healthCheckHttp(targetUrl string) error {
}
func (*Utils) healthCheckStream(scheme, host string) error {
conn, err := net.DialTimeout(scheme, host, 5*time.Second)
conn, err := net.DialTimeout(scheme, host, streamDialTimeout)
if err != nil {
return err
}
@@ -92,6 +94,18 @@ func (*Utils) healthCheckStream(scheme, host string) error {
return nil
}
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, "-", "")
@@ -194,12 +208,37 @@ func (*Utils) fileOK(path string) bool {
return err == nil
}
func SetFieldFromSnake[T interface{}, VT interface{}](obj *T, field string, value VT) error {
func setFieldFromSnake[T interface{}, VT interface{}](obj *T, field string, value VT) error {
field = utils.snakeToPascal(field)
prop := reflect.ValueOf(obj).Elem().FieldByName(field)
if prop.Kind() == 0 {
return fmt.Errorf("unknown field %s", field)
return NewNestedError("unknown field").Subject(field)
}
prop.Set(reflect.ValueOf(value))
return nil
}
func validateYaml(schema *jsonschema.Schema, data []byte) error {
var i interface{}
err := yaml.Unmarshal(data, &i)
if err != nil {
return NewNestedError("unable to unmarshal yaml").With(err)
}
m, err := json.Marshal(i)
if err != nil {
return NewNestedError("unable to marshal json").With(err)
}
err = schema.Validate(bytes.NewReader(m))
if err != nil {
valErr := err.(*jsonschema.ValidationError)
ne := NewNestedError("validation error")
for _, e := range valErr.Causes {
ne.ExtraError(e)
}
return ne
}
return nil
}

View File

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

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.5