mirror of
https://github.com/yusing/godoxy.git
synced 2026-01-11 21:10:30 +01:00
Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2314e39291 | ||
|
|
bd19f443d4 | ||
|
|
ce433f0c51 | ||
|
|
47877e5119 | ||
|
|
486122f3d8 | ||
|
|
a0be1f11d3 | ||
|
|
662190e09e | ||
|
|
ce1e5da72e | ||
|
|
eb7e744a75 | ||
|
|
ac26baf97f | ||
|
|
5a8c11de16 | ||
|
|
a8ecafcd09 | ||
|
|
af37d1f29e | ||
|
|
8cfd24e6bd | ||
|
|
7bf5784016 | ||
|
|
25930a1a73 | ||
|
|
f20a1ff523 | ||
|
|
ba51796a64 | ||
|
|
c445d50221 | ||
|
|
73dfc17a82 | ||
|
|
fdab026a3b | ||
|
|
c789c69c86 | ||
|
|
2b298aa7fa | ||
|
|
d20e4d435a | ||
|
|
15d9436d52 | ||
|
|
ca98b31458 | ||
|
|
77f957c7a8 | ||
|
|
51493c9fdd | ||
|
|
9b34dc994d | ||
|
|
6bc4c1c49a | ||
|
|
443dd99b5b | ||
|
|
db6f857aaf | ||
|
|
6a54fc85ac | ||
|
|
90f4aac946 | ||
|
|
539ef911de | ||
|
|
fff790b527 | ||
|
|
094f75ef46 | ||
|
|
43ecd80687 | ||
|
|
e7f6abf027 | ||
|
|
22f911c30f |
14
.github/workflows/docker-image.yml
vendored
Normal file
14
.github/workflows/docker-image.yml
vendored
Normal 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
30
.github/workflows/go.yml
vendored
Normal 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
10
.gitignore
vendored
@@ -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
0
.gitmodules
vendored
Normal file
15
.vscode/settings.json
vendored
15
.vscode/settings.json
vendored
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
21
Dockerfile
21
Dockerfile
@@ -1,17 +1,24 @@
|
||||
FROM golang:1.22.1 as builder
|
||||
|
||||
COPY go.mod /app/go.mod
|
||||
COPY src/ /app/src
|
||||
COPY Makefile /app
|
||||
WORKDIR /app
|
||||
RUN make get
|
||||
RUN make build
|
||||
|
||||
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
|
||||
COPY --from=builder /app/bin/go-proxy /app/
|
||||
COPY templates/ /app/templates
|
||||
COPY config.default.yml /app/config.yml
|
||||
COPY schema/ /app/schema
|
||||
|
||||
RUN chmod +x /app/go-proxy /app/entrypoint.sh
|
||||
RUN chmod +x /app/go-proxy
|
||||
ENV DOCKER_HOST unix:///var/run/docker.sock
|
||||
ENV GOPROXY_DEBUG 0
|
||||
ENV GOPROXY_REDIRECT_HTTP 1
|
||||
|
||||
EXPOSE 80
|
||||
EXPOSE 8080
|
||||
@@ -19,4 +26,4 @@ EXPOSE 443
|
||||
EXPOSE 8443
|
||||
|
||||
WORKDIR /app
|
||||
ENTRYPOINT /app/entrypoint.sh
|
||||
CMD ["/app/go-proxy"]
|
||||
21
Makefile
21
Makefile
@@ -2,22 +2,27 @@
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
283
README.md
283
README.md
@@ -6,131 +6,200 @@ In the examples domain `x.y.z` is used, replace them with your domain
|
||||
|
||||
## Table of content
|
||||
|
||||
- [Key Points](#key-points)
|
||||
- [How to use](#how-to-use)
|
||||
- [Binary](#binary)
|
||||
- [Docker](#docker)
|
||||
- [Configuration](#configuration)
|
||||
- [Single Port Configuration](#single-port-configuration-example)
|
||||
- [Multiple Ports Configuration](#multiple-ports-configuration-example)
|
||||
- [TCP/UDP Configuration](#tcpudp-configuration-example)
|
||||
- [Load balancing Configuration](#load-balancing-configuration-example)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
- [Benchmarks](#benchmarks)
|
||||
- [Memory usage](#memory-usage)
|
||||
- [Build it yourself](#build-it-yourself)
|
||||
- [Getting SSL certs](#getting-ssl-certs)
|
||||
- [go-proxy](#go-proxy)
|
||||
- [Table of content](#table-of-content)
|
||||
- [Key Points](#key-points)
|
||||
- [How to use](#how-to-use)
|
||||
- [Command-line args](#command-line-args)
|
||||
- [Commands](#commands)
|
||||
- [Use JSON Schema in VSCode](#use-json-schema-in-vscode)
|
||||
- [Configuration](#configuration)
|
||||
- [Labels (docker)](#labels-docker)
|
||||
- [Environment variables](#environment-variables)
|
||||
- [Config File](#config-file)
|
||||
- [Fields](#fields)
|
||||
- [Provider Kinds](#provider-kinds)
|
||||
- [Provider File](#provider-file)
|
||||
- [Supported DNS Challenge Providers](#supported-dns-challenge-providers)
|
||||
- [Examples](#examples)
|
||||
- [Single port configuration example](#single-port-configuration-example)
|
||||
- [Multiple ports configuration example](#multiple-ports-configuration-example)
|
||||
- [TCP/UDP configuration example](#tcpudp-configuration-example)
|
||||
- [Load balancing Configuration Example](#load-balancing-configuration-example)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
- [Benchmarks](#benchmarks)
|
||||
- [Known issues](#known-issues)
|
||||
- [Memory usage](#memory-usage)
|
||||
- [Build it yourself](#build-it-yourself)
|
||||
|
||||
## Key Points
|
||||
|
||||
- fast, nearly no performance penalty for end users when comparing to direct IP connections (See [benchmarks](#benchmarks))
|
||||
- auto detect reverse proxies from docker
|
||||
- additional reverse proxies from provider yaml file
|
||||
- allow multiple docker / file providers by custom `config.yml` file
|
||||
- subdomain matching **(domain name doesn't matter)**
|
||||
- path matching
|
||||
- HTTP proxy
|
||||
- TCP/UDP Proxy
|
||||
- HTTP round robin load balance support (same subdomain and path across different hosts)
|
||||
- Auto hot-reload on container start / die / stop or config changes.
|
||||
- Simple panel to see all reverse proxies and health (visit port [panel port] of go-proxy `https://*.y.z:[panel port]`)
|
||||
- Fast (See [benchmarks](#benchmarks))
|
||||
- Auto certificate obtaining and renewal (See [Config File](#config-file) and [Supported DNS Challenge Providers](#supported-dns-challenge-providers))
|
||||
- Auto detect reverse proxies from docker
|
||||
- Auto hot-reload on container `start` / `die` / `stop` or config file changes
|
||||
- Custom proxy entries with `config.yml` and additional provider files
|
||||
- Subdomain matching + Path matching **(domain name doesn't matter)**
|
||||
- HTTP(s) proxy + TCP/UDP Proxy (UDP is _experimental_)
|
||||
- HTTP(s) round robin load balance support (same subdomain and path across different hosts)
|
||||
- Web UI on port 8080 (http) and port 8443 (https)
|
||||
|
||||

|
||||
- a simple panel to see all reverse proxies and health
|
||||
|
||||

|
||||
|
||||
- a config editor to edit config and provider files with validation
|
||||
|
||||
**Validate and save file with Ctrl+S**
|
||||
|
||||

|
||||
|
||||
## 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) Prepare your certificates in `certs/` to enable https. See [Getting SSL Certs](#getting-ssl-certs)
|
||||
`go-proxy [command]`
|
||||
|
||||
### Commands
|
||||
|
||||
- cert / chain / fullchain: `./certs/cert.crt`
|
||||
- private key: `./certs/priv.key`
|
||||
- empty: start proxy server
|
||||
- validate: validate config and exit
|
||||
- reload: trigger a force reload of config
|
||||
|
||||
2. run the binary `bin/go-proxy`
|
||||
Examples:
|
||||
|
||||
3. enjoy
|
||||
- Binary: `go-proxy reload`
|
||||
- Docker: `docker exec -it go-proxy /app/go-proxy reload`
|
||||
|
||||
### Docker
|
||||
## Use JSON Schema in VSCode
|
||||
|
||||
1. Copy content from [compose.example.yml](compose.example.yml) and create your own `compose.yml`
|
||||
Modify `.vscode/settings.json` to fit your needs
|
||||
|
||||
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) Mount your SSL certs to enable https. See [Getting SSL Certs](#getting-ssl-certs)
|
||||
|
||||
|
||||
- cert / chain / fullchain -> `/app/certs/cert.crt`
|
||||
- private key -> `/app/certs/priv.key`
|
||||
|
||||
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, no label needs to be added.
|
||||
With container name, no label needs to be added _(most of the time)_.
|
||||
|
||||
However, there are some labels you can manipulate with:
|
||||
### Labels (docker)
|
||||
|
||||
See [compose.example.yml](compose.example.yml) for more
|
||||
|
||||
- `proxy.aliases`: comma separated aliases for subdomain matching
|
||||
- defaults to `container_name`
|
||||
- `proxy.*.<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 [stream.go](src/go-proxy/stream.go#L28))
|
||||
- `no_tls_verify`: whether skip tls verify when scheme is https
|
||||
- defaults to false
|
||||
- `proxy.<alias>.path`: path matching (for http proxy only)
|
||||
- defaults to empty
|
||||
- `proxy.<alias>.path_mode`: mode for path handling
|
||||
|
||||
- defaults to empty
|
||||
- allowed: \<empty>, forward, sub
|
||||
- empty: remove path prefix from URL when proxying
|
||||
- default: container name
|
||||
|
||||
- `proxy.*.<field>`: wildcard label for all aliases
|
||||
|
||||
Below labels has a **`proxy.<alias>.`** prefix (i.e. `proxy.nginx.scheme: http`)
|
||||
|
||||
- `scheme`: proxy protocol
|
||||
- default: `http`
|
||||
- allowed: `http`, `https`, `tcp`, `udp`
|
||||
- `host`: proxy host
|
||||
- default: `container_name`
|
||||
- `port`: proxy port
|
||||
- default: first expose port (declared in `Dockerfile` or `docker-compose.yml`)
|
||||
- `http(s)`: number in range og `0 - 65535`
|
||||
- `tcp/udp`: `[<listeningPort>:]<targetPort>`
|
||||
- `listeningPort`: number, when it is omitted (not suggested), a free port starting from 20000 will be used.
|
||||
- `targetPort`: number, or predefined names (see [constants.go:14](src/go-proxy/constants.go#L14))
|
||||
- `no_tls_verify`: whether skip tls verify when scheme is https
|
||||
- default: `false`
|
||||
- `path`: proxy path _(http(s) proxy only)_
|
||||
- default: empty
|
||||
- `path_mode`: mode for path handling
|
||||
|
||||
- default: empty
|
||||
- allowed: empty, `forward`, `sub`
|
||||
- `empty`: remove path prefix from URL when proxying
|
||||
1. apps.y.z/webdav -> webdav:80
|
||||
2. apps.y.z./webdav/path/to/file -> webdav:80/path/to/file
|
||||
- forward: path remain unchanged
|
||||
- `forward`: path remain unchanged
|
||||
1. apps.y.z/webdav -> webdav:80/webdav
|
||||
2. apps.y.z./webdav/path/to/file -> webdav:80/webdav/path/to/file
|
||||
- sub: (experimental) remove path prefix from URL and also append path to HTML link attributes (`src`, `href` and `action`) and Javascript `fetch(url)` by response body substitution
|
||||
e.g. apps.y.z/app1 -> webdav:80, `href="/path/to/file"` -> `href="/app1/path/to/file"`
|
||||
- `sub`: (experimental) remove path prefix from URL and also append path to HTML link attributes (`src`, `href` and `action`) and Javascript `fetch(url)` by response body substitution
|
||||
e.g. apps.y.z/app1 -> webdav:80, `href="/app1/path/to/file"` -> `href="/path/to/file"`
|
||||
|
||||
- `proxy.<alias>.load_balance`: enable load balance
|
||||
- `load_balance`: enable load balance (docker only)
|
||||
- allowed: `1`, `true`
|
||||
|
||||
### Environment variables
|
||||
|
||||
- `GOPROXY_DEBUG`: set to `1` or `true` to enable debug behaviors (i.e. output, etc.)
|
||||
|
||||
### Config File
|
||||
|
||||
See [config.example.yml](config.example.yml) for more
|
||||
|
||||
#### Fields
|
||||
|
||||
- `autocert`: autocert configuration
|
||||
|
||||
- `email`: ACME Email
|
||||
- `domains`: a list of domains for cert registration
|
||||
- `provider`: DNS Challenge provider, see [Supported DNS Challenge Providers](#supported-dns-challenge-providers)
|
||||
- `options`: provider specific options
|
||||
|
||||
- `providers`: reverse proxy providers configuration
|
||||
- `kind`: provider kind (string), see [Provider Kinds](#provider-kinds)
|
||||
- `value`: provider specific value
|
||||
|
||||
#### Provider Kinds
|
||||
|
||||
- `docker`: load reverse proxies from docker
|
||||
|
||||
values:
|
||||
|
||||
- `FROM_ENV`: value from environment (`DOCKER_HOST`)
|
||||
- full url to docker host (i.e. `tcp://host:2375`)
|
||||
|
||||
- `file`: load reverse proxies from provider file
|
||||
|
||||
value: relative path of file to `config/`
|
||||
|
||||
### Provider File
|
||||
|
||||
Fields are same as [docker labels](#labels-docker) starting from `scheme`
|
||||
|
||||
See [providers.example.yml](providers.example.yml) for examples
|
||||
|
||||
### Supported DNS Challenge Providers
|
||||
|
||||
- Cloudflare
|
||||
|
||||
- `auth_token`: your zone API token
|
||||
|
||||
Follow [this guide](https://cloudkul.com/blog/automcatic-renew-and-generate-ssl-on-your-website-using-lego-client/) to create a new token with `Zone.DNS` read and edit permissions
|
||||
|
||||
To add more provider support, see [this](docs/add_dns_provider.md)
|
||||
|
||||
## Examples
|
||||
|
||||
### Single port configuration example
|
||||
|
||||
```yaml
|
||||
@@ -187,7 +256,7 @@ go-proxy:
|
||||
ports:
|
||||
- 80:80
|
||||
...
|
||||
- 20000:20000/tcp
|
||||
- <your desired port>:20000/tcp
|
||||
# or 20000-20010:20000-20010/tcp to declare large range at once
|
||||
|
||||
# access app-db via <*>.y.z:20000
|
||||
@@ -261,7 +330,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
|
||||
@@ -279,7 +348,8 @@ 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
|
||||
@@ -297,7 +367,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
|
||||
@@ -314,22 +385,22 @@ Local benchmark (client running wrk and `go-proxy` server are under same proxmox
|
||||
Transfer/sec: 10.94MB
|
||||
```
|
||||
|
||||
## Known issues
|
||||
|
||||
None
|
||||
|
||||
## 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 `docker compose up -d`
|
||||
4. build binary with `make build`
|
||||
|
||||
## Getting SSL certs
|
||||
|
||||
I personally use `nginx-proxy-manager` to get SSL certs with auto renewal by Cloudflare DNS challenge. You may symlink the certs from `nginx-proxy-manager` to `certs/` folder relative to project root. (For docker) mount them to `go-proxy`'s `/app/certs`
|
||||
|
||||
[panel port]: 8443
|
||||
5. start your container with `make up` (docker) or `bin/go-proxy` (binary)
|
||||
|
||||
BIN
bin/go-proxy
BIN
bin/go-proxy
Binary file not shown.
@@ -1,47 +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:
|
||||
# if you want https
|
||||
- ./config:/app/config
|
||||
|
||||
# if local docker provider is used
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
|
||||
# use existing certificate
|
||||
# - /path/to/cert.pem:/app/certs/cert.crt:ro
|
||||
# - /path/to/privkey.pem:/app/certs/priv.key:ro
|
||||
|
||||
# path to logs
|
||||
- ./log:/app/log
|
||||
# store autocert obtained cert
|
||||
# - ./certs:/app/certs
|
||||
|
||||
# workaround for "lookup: no such host"
|
||||
# dns:
|
||||
# - 127.0.0.1
|
||||
|
||||
# if you use default config, or declared local docker provider
|
||||
# otherwise comment this line
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
|
||||
# to use custom config
|
||||
# - path/to/config.yml:/app/config.yml
|
||||
|
||||
# mount file provider yaml files
|
||||
# - path/to/provider1.yml:/app/provider1.yml
|
||||
# - path/to/provider2.yml:/app/provider2.yml
|
||||
# etc.
|
||||
dns:
|
||||
- 127.0.0.1 # workaround for "lookup: no such host"
|
||||
extra_hosts:
|
||||
# required if you use local docker provider and have containers in `host` network_mode
|
||||
- host.docker.internal:host-gateway
|
||||
# if you have container running in "host" network mode
|
||||
# extra_hosts:
|
||||
# - host.docker.internal:host-gateway
|
||||
logging:
|
||||
driver: 'json-file'
|
||||
options:
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
providers:
|
||||
local:
|
||||
kind: docker
|
||||
# for value format, see https://docs.docker.com/reference/cli/dockerd/
|
||||
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
|
||||
21
config.example.yml
Normal file
21
config.example.yml
Normal file
@@ -0,0 +1,21 @@
|
||||
# Autocert (uncomment to enable)
|
||||
# autocert: # (optional, if you need autocert feature)
|
||||
# email: "user@domain.com" # (required) email for acme certificate
|
||||
# domains: # (required)
|
||||
# - "*.y.z" # domain for acme certificate, use wild card to allow all subdomains
|
||||
# provider: cloudflare # (required) dns challenge provider (string)
|
||||
# options: # provider specific options
|
||||
# auth_token: "YOUR_ZONE_API_TOKEN"
|
||||
providers:
|
||||
local:
|
||||
kind: docker
|
||||
# for value format, see https://docs.docker.com/reference/cli/dockerd/
|
||||
# i.e. FROM_ENV, ssh://user@10.0.1.1:22, tcp://10.0.2.1:2375
|
||||
value: FROM_ENV
|
||||
providers:
|
||||
kind: file
|
||||
value: providers.yml
|
||||
|
||||
# Fixed options (optional, non hot-reloadable)
|
||||
# timeout_shutdown: 5
|
||||
# redirect_to_https: false
|
||||
41
docs/add_dns_provider.md
Normal file
41
docs/add_dns_provider.md
Normal 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
59
docs/binary.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# Getting started with `go-proxy` (binary)
|
||||
|
||||
## Setup
|
||||
|
||||
1. Install `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`
|
||||
63
docs/docker.md
Normal file
63
docs/docker.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# Getting started with `go-proxy` docker container
|
||||
|
||||
## Setup
|
||||
|
||||
1. Install `wget` if not already
|
||||
|
||||
2. Run setup script
|
||||
|
||||
`bash <(wget -qO- https://6uo.me/go-proxy-setup-docker)`
|
||||
|
||||
What it does:
|
||||
|
||||
- Create required directories
|
||||
- Setup `config.yml` and `compose.yml`
|
||||
|
||||
3. Verify folder structure and then `cd go-proxy`
|
||||
|
||||
```plain
|
||||
go-proxy
|
||||
├── certs
|
||||
├── compose.yml
|
||||
└── config
|
||||
├── config.yml
|
||||
└── providers.yml
|
||||
```
|
||||
|
||||
4. Enable HTTPs _(optional)_
|
||||
- To use autocert feature
|
||||
- completing `autocert` section in `config/config.yml`
|
||||
- mount `certs/` to `/app/certs` to store obtained certs
|
||||
|
||||
- To use existing certificate
|
||||
|
||||
mount your wildcard (`*.y.z`) SSL cert
|
||||
|
||||
- cert / chain / fullchain -> `/app/certs/cert.crt`
|
||||
- private key -> `/app/certs/priv.key`
|
||||
|
||||
5. Modify `compose.yml` fit your needs
|
||||
|
||||
Add networks to make sure it is in the same network with other containers, or make sure `proxy.<alias>.host` is reachable
|
||||
|
||||
6. Run `docker compose up -d` to start the container
|
||||
|
||||
7. Start editing config files in `http://<ip>:8080`
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- Firewall issues
|
||||
|
||||
If you are using `ufw` with vpn that drop all inbound traffic except vpn, run below:
|
||||
|
||||
`sudo ufw allow from 172.16.0.0/16 to 100.64.0.0/10`
|
||||
|
||||
Explaination:
|
||||
|
||||
Docker network is usually `172.16.0.0/16`
|
||||
|
||||
Tailscale is used as an example, `100.64.0.0/10` will be the CIDR
|
||||
|
||||
You can also list CIDRs of all docker bridge networks by:
|
||||
|
||||
`docker network inspect $(docker network ls | awk '$3 == "bridge" { print $1}') | jq -r '.[] | .Name + " " + .IPAM.Config[0].Subnet' -`
|
||||
@@ -1,11 +0,0 @@
|
||||
#!/bin/bash
|
||||
if [ "$1" == "restart" ]; then
|
||||
echo "restarting"
|
||||
killall go-proxy
|
||||
fi
|
||||
if [ "$DEBUG" == "1" ]; then
|
||||
/app/go-proxy 2> log/go-proxy.log &
|
||||
tail -f /dev/null
|
||||
else
|
||||
/app/go-proxy
|
||||
fi
|
||||
15
go.mod
15
go.mod
@@ -1,11 +1,13 @@
|
||||
module github.com/yusing/go-proxy
|
||||
|
||||
go 1.21.7
|
||||
go 1.22
|
||||
|
||||
require (
|
||||
github.com/docker/cli v26.0.0+incompatible
|
||||
github.com/docker/docker v26.0.0+incompatible
|
||||
github.com/fsnotify/fsnotify v1.7.0
|
||||
github.com/go-acme/lego/v4 v4.16.1
|
||||
github.com/santhosh-tekuri/jsonschema v1.2.4
|
||||
github.com/sirupsen/logrus v1.9.3
|
||||
golang.org/x/net v0.22.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
@@ -13,14 +15,22 @@ require (
|
||||
|
||||
require (
|
||||
github.com/Microsoft/go-winio v0.6.1 // indirect
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||
github.com/cloudflare/cloudflare-go v0.92.0 // indirect
|
||||
github.com/containerd/log v0.1.0 // indirect
|
||||
github.com/distribution/reference v0.5.0 // indirect
|
||||
github.com/distribution/reference v0.6.0 // indirect
|
||||
github.com/docker/go-connections v0.5.0 // indirect
|
||||
github.com/docker/go-units v0.5.0 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.0.1 // indirect
|
||||
github.com/go-logr/logr v1.4.1 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/gogo/protobuf v1.3.2 // indirect
|
||||
github.com/google/go-querystring v1.1.0 // indirect
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
|
||||
github.com/hashicorp/go-retryablehttp v0.7.5 // indirect
|
||||
github.com/miekg/dns v1.1.58 // indirect
|
||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||
github.com/moby/term v0.5.0 // indirect
|
||||
github.com/morikuni/aec v1.0.0 // indirect
|
||||
@@ -33,6 +43,7 @@ require (
|
||||
go.opentelemetry.io/otel/metric v1.24.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk v1.24.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.24.0 // indirect
|
||||
golang.org/x/crypto v0.21.0 // indirect
|
||||
golang.org/x/mod v0.16.0 // indirect
|
||||
golang.org/x/sys v0.18.0 // indirect
|
||||
golang.org/x/text v0.14.0 // indirect
|
||||
|
||||
53
go.sum
53
go.sum
@@ -2,15 +2,17 @@ github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOEl
|
||||
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||
github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow=
|
||||
github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM=
|
||||
github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM=
|
||||
github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||
github.com/cloudflare/cloudflare-go v0.92.0 h1:ltJvGvqZ4G6Fm2hHOYZ5RWpJQcrM0oDrsjjZydZhFJQ=
|
||||
github.com/cloudflare/cloudflare-go v0.92.0/go.mod h1:nUqvBUUDRxNzsDSQjbqUNWHEIYAoUlgRmcAzMKlFdKs=
|
||||
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
|
||||
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/distribution/reference v0.5.0 h1:/FUIFXtfc/x2gpa5/VGfiGLuOIdYa1t65IKK2OFGvA0=
|
||||
github.com/distribution/reference v0.5.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||
github.com/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=
|
||||
@@ -19,25 +21,53 @@ github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj
|
||||
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
|
||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||
github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w=
|
||||
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
|
||||
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
|
||||
github.com/go-acme/lego/v4 v4.16.1 h1:JxZ93s4KG0jL27rZ30UsIgxap6VGzKuREsSkkyzeoCQ=
|
||||
github.com/go-acme/lego/v4 v4.16.1/go.mod h1:AVvwdPned/IWpD/ihHhMsKnveF7HHYAz/CmtXi7OZoE=
|
||||
github.com/go-jose/go-jose/v4 v4.0.1 h1:QVEPDE3OluqXBQZDcnNvQrInro2h0e4eqNbnZSWqS6U=
|
||||
github.com/go-jose/go-jose/v4 v4.0.1/go.mod h1:WVf9LFMHh/QVrmqrOfqun0C45tMe3RoiKJMPvgWwLfY=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ=
|
||||
github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
|
||||
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
|
||||
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.0 h1:Wqo399gCIufwto+VfwCSvsnfGpF/w5E9CNxSwbpD6No=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.0/go.mod h1:qmOFXW2epJhM0qSnUUYpldc7gVz2KMQwJ/QYCDIa7XU=
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
|
||||
github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ=
|
||||
github.com/hashicorp/go-hclog v1.2.0 h1:La19f8d7WIlm4ogzNHB0JGqs5AUDAZ2UfCY4sJXcJdM=
|
||||
github.com/hashicorp/go-hclog v1.2.0/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ=
|
||||
github.com/hashicorp/go-retryablehttp v0.7.5 h1:bJj+Pj19UZMIweq/iie+1u5YCdGrnxCT9yvm0e+Nd5M=
|
||||
github.com/hashicorp/go-retryablehttp v0.7.5/go.mod h1:Jy/gPYAdjqffZ/yFGCFV2doI5wjtH1ewM9u8iYVjtX8=
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40=
|
||||
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/miekg/dns v1.1.58 h1:ca2Hdkz+cDg/7eNF6V56jjzuZ4aCAE+DbVkILdQWG/4=
|
||||
github.com/miekg/dns v1.1.58/go.mod h1:Ypv+3b/KadlvW9vJfXOTf300O4UqaHFzFCuHz+rPkBY=
|
||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
|
||||
@@ -52,12 +82,17 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rogpeppe/go-internal v1.8.1 h1:geMPLpDpQOgVyCg5z5GoRwLHepNdb71NXb67XFkP+Eg=
|
||||
github.com/rogpeppe/go-internal v1.8.1/go.mod h1:JeRgkft04UBgHMgCIwADu4Pn6Mtm5d4nPKWu0nJ5d+o=
|
||||
github.com/santhosh-tekuri/jsonschema v1.2.4 h1:hNhW8e7t+H1vgY+1QeEQpveR6D4+OwKPXCfD2aieJis=
|
||||
github.com/santhosh-tekuri/jsonschema v1.2.4/go.mod h1:TEAUOeZSmIxTTuHatJzrvARHiuO9LYd+cIxzgEHCQI4=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk=
|
||||
@@ -79,6 +114,8 @@ go.opentelemetry.io/proto/otlp v1.1.0/go.mod h1:GpBHCBWiqvVLDqmHZsoMM3C5ySeKTC7e
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA=
|
||||
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic=
|
||||
@@ -116,6 +153,7 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/genproto v0.0.0-20230530153820-e85fd2cbaebc h1:8DyZCyvI8mE1IdLy/60bS+52xfymkE72wv1asokgtao=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20240102182953-50ed04b92917 h1:rcS6EyEaoCO52hQDupoSfrxI3R6C2Tq741is7X8OvnM=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20240102182953-50ed04b92917/go.mod h1:CmlNWB9lSezaYELKS5Ym1r44VrrbPUa7JTvw+6MbpJ0=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240102182953-50ed04b92917 h1:6G8oQ016D88m1xAKljMlBOOGWDZkes4kMhgGFlf8WcQ=
|
||||
@@ -124,8 +162,9 @@ google.golang.org/grpc v1.61.1 h1:kLAiWrZs7YeDM6MumDe7m3y4aM6wacLzM1Y/wiLP9XY=
|
||||
google.golang.org/grpc v1.61.1/go.mod h1:VUbo7IFqmF1QtCAstipjG0GIoq49KvMe9+h1jFLBNJs=
|
||||
google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I=
|
||||
google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -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
133
schema/config.schema.json
Normal 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"]
|
||||
}
|
||||
175
schema/providers.schema.json
Normal file
175
schema/providers.schema.json
Normal 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
|
||||
}
|
||||
BIN
screenshots/config_editor.png
Normal file
BIN
screenshots/config_editor.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 191 KiB |
BIN
screenshots/panel.png
Executable file → Normal file
BIN
screenshots/panel.png
Executable file → Normal file
Binary file not shown.
|
Before Width: | Height: | Size: 149 KiB After Width: | Height: | Size: 304 KiB |
108
setup-binary.sh
Normal file
108
setup-binary.sh
Normal file
@@ -0,0 +1,108 @@
|
||||
#!/bin/sh
|
||||
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}"
|
||||
|
||||
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 2>&1
|
||||
echo "Done"
|
||||
if [ $? -gt 0 ]; then
|
||||
echo "Source download failed, check your internet connection and version number"
|
||||
exit 1
|
||||
fi
|
||||
echo "Extracting go-proxy source ${VERSION}"
|
||||
tar xzf go-proxy.tar.gz 2>&1
|
||||
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 2>&1
|
||||
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
|
||||
if [ $? -gt 0 ]; then
|
||||
echo "make setup failed"
|
||||
exit 1
|
||||
fi
|
||||
# SETUP_CODEMIRROR = 1
|
||||
if [ "$SETUP_CODEMIRROR" != "0" ]; then
|
||||
make setup-codemirror || 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
|
||||
}
|
||||
mkdir -p /etc/systemd/system
|
||||
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
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
systemctl daemon-reload || systemctl-failed
|
||||
systemctl enable --now go-proxy || systemctl-failed
|
||||
echo "Setup complete"
|
||||
14
setup-docker.sh
Normal file
14
setup-docker.sh
Normal 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
38
src/go-proxy/args.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type Args struct {
|
||||
Command string
|
||||
}
|
||||
|
||||
const (
|
||||
CommandStart = ""
|
||||
CommandValidate = "validate"
|
||||
CommandReload = "reload"
|
||||
)
|
||||
|
||||
var ValidCommands = []string{CommandStart, CommandValidate, CommandReload}
|
||||
|
||||
func getArgs() Args {
|
||||
var args Args
|
||||
flag.Parse()
|
||||
args.Command = flag.Arg(0)
|
||||
if err := validateArgs(args.Command, ValidCommands); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
func validateArgs[T comparable](arg T, validArgs []T) error {
|
||||
for _, v := range validArgs {
|
||||
if arg == v {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return NewNestedError("invalid argument").Subjectf("%v", arg)
|
||||
}
|
||||
309
src/go-proxy/autocert.go
Normal file
309
src/go-proxy/autocert.go
Normal file
@@ -0,0 +1,309 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"os"
|
||||
"path"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/go-acme/lego/v4/certcrypto"
|
||||
"github.com/go-acme/lego/v4/certificate"
|
||||
"github.com/go-acme/lego/v4/challenge"
|
||||
"github.com/go-acme/lego/v4/lego"
|
||||
"github.com/go-acme/lego/v4/providers/dns/clouddns"
|
||||
"github.com/go-acme/lego/v4/providers/dns/cloudflare"
|
||||
"github.com/go-acme/lego/v4/registration"
|
||||
)
|
||||
|
||||
type ProviderOptions map[string]string
|
||||
type ProviderGenerator func(ProviderOptions) (challenge.Provider, error)
|
||||
type CertExpiries map[string]time.Time
|
||||
|
||||
type AutoCertConfig struct {
|
||||
Email string `json:"email"`
|
||||
Domains []string `yaml:",flow" json:"domains"`
|
||||
Provider string `json:"provider"`
|
||||
Options ProviderOptions `yaml:",flow" json:"options"`
|
||||
}
|
||||
|
||||
type AutoCertUser struct {
|
||||
Email string
|
||||
Registration *registration.Resource
|
||||
key crypto.PrivateKey
|
||||
}
|
||||
|
||||
func (u *AutoCertUser) GetEmail() string {
|
||||
return u.Email
|
||||
}
|
||||
func (u *AutoCertUser) GetRegistration() *registration.Resource {
|
||||
return u.Registration
|
||||
}
|
||||
func (u *AutoCertUser) GetPrivateKey() crypto.PrivateKey {
|
||||
return u.key
|
||||
}
|
||||
|
||||
type AutoCertProvider interface {
|
||||
GetCert(*tls.ClientHelloInfo) (*tls.Certificate, error)
|
||||
GetName() string
|
||||
GetExpiries() CertExpiries
|
||||
LoadCert() bool
|
||||
ObtainCert() NestedErrorLike
|
||||
RenewalOn() time.Time
|
||||
ScheduleRenewal()
|
||||
}
|
||||
|
||||
func (cfg AutoCertConfig) GetProvider() (AutoCertProvider, error) {
|
||||
ne := NewNestedError("invalid autocert config")
|
||||
|
||||
if len(cfg.Domains) == 0 {
|
||||
ne.Extra("no domains specified")
|
||||
}
|
||||
if cfg.Provider == "" {
|
||||
ne.Extra("no provider specified")
|
||||
}
|
||||
if cfg.Email == "" {
|
||||
ne.Extra("no email specified")
|
||||
}
|
||||
gen, ok := providersGenMap[cfg.Provider]
|
||||
if !ok {
|
||||
ne.Extraf("unknown provider: %s", cfg.Provider)
|
||||
}
|
||||
if ne.HasExtras() {
|
||||
return nil, ne
|
||||
}
|
||||
|
||||
ne = NewNestedError("unable to create provider")
|
||||
privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
return nil, ne.With(NewNestedError("unable to generate private key").With(err))
|
||||
}
|
||||
user := &AutoCertUser{
|
||||
Email: cfg.Email,
|
||||
key: privKey,
|
||||
}
|
||||
legoCfg := lego.NewConfig(user)
|
||||
legoCfg.Certificate.KeyType = certcrypto.RSA2048
|
||||
legoClient, err := lego.NewClient(legoCfg)
|
||||
if err != nil {
|
||||
return nil, ne.With(NewNestedError("unable to create lego client").With(err))
|
||||
}
|
||||
base := &autoCertProvider{
|
||||
name: cfg.Provider,
|
||||
cfg: cfg,
|
||||
user: user,
|
||||
legoCfg: legoCfg,
|
||||
client: legoClient,
|
||||
}
|
||||
legoProvider, err := gen(cfg.Options)
|
||||
if err != nil {
|
||||
return nil, ne.With(err)
|
||||
}
|
||||
err = legoClient.Challenge.SetDNS01Provider(legoProvider)
|
||||
if err != nil {
|
||||
return nil, ne.With(NewNestedError("unable to set challenge provider").With(err))
|
||||
}
|
||||
return base, nil
|
||||
}
|
||||
|
||||
type autoCertProvider struct {
|
||||
name string
|
||||
cfg AutoCertConfig
|
||||
user *AutoCertUser
|
||||
legoCfg *lego.Config
|
||||
client *lego.Client
|
||||
|
||||
tlsCert *tls.Certificate
|
||||
certExpiries CertExpiries
|
||||
mutex sync.Mutex
|
||||
}
|
||||
|
||||
func (p *autoCertProvider) GetCert(_ *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||
if p.tlsCert == nil {
|
||||
return nil, NewNestedError("no certificate available")
|
||||
}
|
||||
return p.tlsCert, nil
|
||||
}
|
||||
|
||||
func (p *autoCertProvider) GetName() string {
|
||||
return p.name
|
||||
}
|
||||
|
||||
func (p *autoCertProvider) GetExpiries() CertExpiries {
|
||||
return p.certExpiries
|
||||
}
|
||||
|
||||
func (p *autoCertProvider) ObtainCert() NestedErrorLike {
|
||||
ne := NewNestedError("failed to obtain certificate")
|
||||
|
||||
client := p.client
|
||||
if p.user.Registration == nil {
|
||||
reg, err := client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true})
|
||||
if err != nil {
|
||||
return ne.With(NewNestedError("failed to register account").With(err))
|
||||
}
|
||||
p.user.Registration = reg
|
||||
}
|
||||
req := certificate.ObtainRequest{
|
||||
Domains: p.cfg.Domains,
|
||||
Bundle: true,
|
||||
}
|
||||
cert, err := client.Certificate.Obtain(req)
|
||||
if err != nil {
|
||||
return ne.With(err)
|
||||
}
|
||||
err = p.saveCert(cert)
|
||||
if err != nil {
|
||||
return ne.With(NewNestedError("failed to save certificate").With(err))
|
||||
}
|
||||
tlsCert, err := tls.X509KeyPair(cert.Certificate, cert.PrivateKey)
|
||||
if err != nil {
|
||||
return ne.With(NewNestedError("failed to parse obtained certificate").With(err))
|
||||
}
|
||||
expiries, err := getCertExpiries(&tlsCert)
|
||||
if err != nil {
|
||||
return ne.With(NewNestedError("failed to get certificate expiry").With(err))
|
||||
}
|
||||
p.tlsCert = &tlsCert
|
||||
p.certExpiries = expiries
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *autoCertProvider) LoadCert() bool {
|
||||
cert, err := tls.LoadX509KeyPair(certFileDefault, keyFileDefault)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
expiries, err := getCertExpiries(&cert)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
p.tlsCert = &cert
|
||||
p.certExpiries = expiries
|
||||
p.renewIfNeeded()
|
||||
return true
|
||||
}
|
||||
|
||||
func (p *autoCertProvider) RenewalOn() time.Time {
|
||||
t := time.Now().AddDate(0, 0, 3)
|
||||
for _, expiry := range p.certExpiries {
|
||||
if expiry.Before(t) {
|
||||
return time.Now()
|
||||
}
|
||||
return t
|
||||
}
|
||||
// this line should never be reached
|
||||
panic("no certificate available")
|
||||
}
|
||||
|
||||
func (p *autoCertProvider) ScheduleRenewal() {
|
||||
for {
|
||||
t := time.Until(p.RenewalOn())
|
||||
aclog.Infof("next renewal in %v", t)
|
||||
time.Sleep(t)
|
||||
err := p.renewIfNeeded()
|
||||
if err != nil {
|
||||
aclog.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *autoCertProvider) saveCert(cert *certificate.Resource) NestedErrorLike {
|
||||
err := os.MkdirAll(path.Dir(certFileDefault), 0644)
|
||||
if err != nil {
|
||||
return NewNestedError("unable to create cert directory").With(err)
|
||||
}
|
||||
err = os.WriteFile(keyFileDefault, cert.PrivateKey, 0600) // -rw-------
|
||||
if err != nil {
|
||||
return NewNestedError("unable to write key file").With(err)
|
||||
}
|
||||
err = os.WriteFile(certFileDefault, cert.Certificate, 0644) // -rw-r--r--
|
||||
if err != nil {
|
||||
return NewNestedError("unable to write cert file").With(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *autoCertProvider) needRenewal() bool {
|
||||
return time.Now().After(p.RenewalOn())
|
||||
}
|
||||
|
||||
func (p *autoCertProvider) renewIfNeeded() NestedErrorLike {
|
||||
if !p.needRenewal() {
|
||||
return nil
|
||||
}
|
||||
|
||||
p.mutex.Lock()
|
||||
defer p.mutex.Unlock()
|
||||
|
||||
if !p.needRenewal() {
|
||||
return nil
|
||||
}
|
||||
|
||||
trials := 0
|
||||
for {
|
||||
err := p.ObtainCert()
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
trials++
|
||||
if trials > 3 {
|
||||
return NewNestedError("failed to renew certificate after 3 trials").With(err)
|
||||
}
|
||||
aclog.Errorf("failed to renew certificate: %v, trying again in 5 seconds", err)
|
||||
time.Sleep(5 * time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
func providerGenerator[CT any, PT challenge.Provider](
|
||||
defaultCfg func() *CT,
|
||||
newProvider func(*CT) (PT, error),
|
||||
) ProviderGenerator {
|
||||
return func(opt ProviderOptions) (challenge.Provider, error) {
|
||||
cfg := defaultCfg()
|
||||
err := setOptions(cfg, opt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p, err := newProvider(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
}
|
||||
|
||||
func getCertExpiries(cert *tls.Certificate) (CertExpiries, error) {
|
||||
r := make(CertExpiries, len(cert.Certificate))
|
||||
for _, cert := range cert.Certificate {
|
||||
x509Cert, err := x509.ParseCertificate(cert)
|
||||
if err != nil {
|
||||
return nil, NewNestedError("unable to parse certificate").With(err)
|
||||
}
|
||||
if x509Cert.IsCA {
|
||||
continue
|
||||
}
|
||||
r[x509Cert.Subject.CommonName] = x509Cert.NotAfter
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func setOptions[T interface{}](cfg *T, opt ProviderOptions) error {
|
||||
for k, v := range opt {
|
||||
err := setFieldFromSnake(cfg, k, v)
|
||||
if err != nil {
|
||||
return NewNestedError("unable to set option").Subject(k).With(err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var providersGenMap = map[string]ProviderGenerator{
|
||||
"cloudflare": providerGenerator(cloudflare.NewDefaultConfig, cloudflare.NewDNSProviderConfig),
|
||||
"clouddns": providerGenerator(clouddns.NewDefaultConfig, clouddns.NewDNSProviderConfig),
|
||||
}
|
||||
@@ -1,60 +1,96 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// commented out if unused
|
||||
type Config interface {
|
||||
Value() configModel
|
||||
// Load() error
|
||||
MustLoad()
|
||||
GetAutoCertProvider() (AutoCertProvider, error)
|
||||
// MustReload()
|
||||
// Reload() error
|
||||
Reload() error
|
||||
StartProviders()
|
||||
StopProviders()
|
||||
WatchChanges()
|
||||
StopWatching()
|
||||
}
|
||||
|
||||
func NewConfig() Config {
|
||||
cfg := &config{}
|
||||
func NewConfig(path string) Config {
|
||||
cfg := &config{
|
||||
reader: &FileReader{Path: path},
|
||||
}
|
||||
cfg.watcher = NewFileWatcher(
|
||||
configPath,
|
||||
path,
|
||||
cfg.MustReload, // OnChange
|
||||
func() { os.Exit(1) }, // OnDelete
|
||||
)
|
||||
return cfg
|
||||
}
|
||||
|
||||
func (cfg *config) Load() error {
|
||||
func ValidateConfig(data []byte) error {
|
||||
cfg := &config{reader: &ByteReader{data}}
|
||||
return cfg.Load()
|
||||
}
|
||||
|
||||
func (cfg *config) Value() configModel {
|
||||
return *cfg.m
|
||||
}
|
||||
|
||||
func (cfg *config) Load(reader ...Reader) error {
|
||||
cfg.mutex.Lock()
|
||||
defer cfg.mutex.Unlock()
|
||||
|
||||
// unload if any
|
||||
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
|
||||
}
|
||||
|
||||
@@ -64,39 +100,102 @@ func (cfg *config) MustLoad() {
|
||||
}
|
||||
}
|
||||
|
||||
func (cfg *config) GetAutoCertProvider() (AutoCertProvider, error) {
|
||||
return cfg.m.AutoCert.GetProvider()
|
||||
}
|
||||
|
||||
func (cfg *config) Reload() error {
|
||||
return cfg.Load()
|
||||
cfg.StopProviders()
|
||||
if err := cfg.Load(); err != nil {
|
||||
return err
|
||||
}
|
||||
cfg.StartProviders()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cfg *config) MustReload() {
|
||||
cfg.MustLoad()
|
||||
if err := cfg.Reload(); err != nil {
|
||||
cfgl.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (cfg *config) StartProviders() {
|
||||
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"`
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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"}
|
||||
@@ -63,11 +90,6 @@ const (
|
||||
ProviderKind_File = "file"
|
||||
)
|
||||
|
||||
const (
|
||||
certPath = "certs/cert.crt"
|
||||
keyPath = "certs/priv.key"
|
||||
)
|
||||
|
||||
// TODO: default + per proxy
|
||||
var (
|
||||
transport = &http.Transport{
|
||||
@@ -85,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.*."
|
||||
@@ -92,11 +128,43 @@ const wildcardLabelPrefix = "proxy.*."
|
||||
const clientUrlFromEnv = "FROM_ENV"
|
||||
|
||||
const (
|
||||
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 = 2 * 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
|
||||
|
||||
@@ -108,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"
|
||||
}()
|
||||
@@ -3,7 +3,6 @@ package main
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -17,52 +16,55 @@ import (
|
||||
func (p *Provider) setConfigField(c *ProxyConfig, label string, value string, prefix string) error {
|
||||
if strings.HasPrefix(label, prefix) {
|
||||
field := strings.TrimPrefix(label, prefix)
|
||||
field = utils.snakeToCamel(field)
|
||||
prop := reflect.ValueOf(c).Elem().FieldByName(field)
|
||||
if prop.Kind() == 0 {
|
||||
return fmt.Errorf("ignoring unknown field %s", field)
|
||||
if err := setFieldFromSnake(c, field, value); err != nil {
|
||||
return err
|
||||
}
|
||||
prop.Set(reflect.ValueOf(value))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Provider) getContainerProxyConfigs(container types.Container, clientIP string) []*ProxyConfig {
|
||||
func (p *Provider) getContainerProxyConfigs(container *types.Container, clientIP string) ProxyConfigSlice {
|
||||
var aliases []string
|
||||
|
||||
cfgs := make([]*ProxyConfig, 0)
|
||||
cfgs := make(ProxyConfigSlice, 0)
|
||||
|
||||
container_name := strings.TrimPrefix(container.Names[0], "/")
|
||||
aliases_label, ok := container.Labels["proxy.aliases"]
|
||||
containerName := strings.TrimPrefix(container.Names[0], "/")
|
||||
aliasesLabel, ok := container.Labels["proxy.aliases"]
|
||||
|
||||
if !ok {
|
||||
aliases = []string{container_name}
|
||||
aliases = []string{containerName}
|
||||
} else {
|
||||
aliases = strings.Split(aliases_label, ",")
|
||||
aliases = strings.Split(aliasesLabel, ",")
|
||||
}
|
||||
|
||||
isRemote := clientIP != ""
|
||||
|
||||
ne := NewNestedError("invalid label config").Subjectf("container %s", containerName)
|
||||
defer func() {
|
||||
if ne.HasExtras() {
|
||||
p.l.Error(ne)
|
||||
}
|
||||
}()
|
||||
|
||||
for _, alias := range aliases {
|
||||
l := p.l.WithField("container", container_name).WithField("alias", alias)
|
||||
l := p.l.WithField("container", containerName).WithField("alias", alias)
|
||||
config := NewProxyConfig(p)
|
||||
prefix := fmt.Sprintf("proxy.%s.", alias)
|
||||
for label, value := range container.Labels {
|
||||
err := p.setConfigField(&config, label, value, prefix)
|
||||
if err != nil {
|
||||
l.Error(err)
|
||||
ne.ExtraError(NewNestedErrorFrom(err).Subjectf("alias %s", alias))
|
||||
}
|
||||
err = p.setConfigField(&config, label, value, wildcardLabelPrefix)
|
||||
if err != nil {
|
||||
l.Error(err)
|
||||
ne.ExtraError(NewNestedErrorFrom(err).Subjectf("alias %s", alias))
|
||||
}
|
||||
}
|
||||
if config.Port == "" {
|
||||
config.Port = fmt.Sprintf("%d", selectPort(container))
|
||||
}
|
||||
if config.Port == "0" {
|
||||
// no ports exposed or specified
|
||||
l.Debugf("no ports exposed, ignored")
|
||||
l.Infof("no ports exposed, ignored")
|
||||
continue
|
||||
}
|
||||
if config.Scheme == "" {
|
||||
@@ -72,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 {
|
||||
@@ -108,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
|
||||
}
|
||||
@@ -151,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 {
|
||||
@@ -159,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]
|
||||
}
|
||||
@@ -167,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 {
|
||||
func selectPort(c *types.Container) uint16 {
|
||||
if c.HostConfig.NetworkMode == "host" {
|
||||
return selectPortInternal(c, getPrivatePort)
|
||||
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
|
||||
@@ -206,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
194
src/go-proxy/error.go
Normal 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, "- ")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
23
src/go-proxy/file_reader.go
Normal file
23
src/go-proxy/file_reader.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package main
|
||||
|
||||
import "os"
|
||||
|
||||
type Reader interface {
|
||||
Read() ([]byte, error)
|
||||
}
|
||||
|
||||
type FileReader struct {
|
||||
Path string
|
||||
}
|
||||
|
||||
func (r *FileReader) Read() ([]byte, error) {
|
||||
return os.ReadFile(r.Path)
|
||||
}
|
||||
|
||||
type ByteReader struct {
|
||||
Data []byte
|
||||
}
|
||||
|
||||
func (r *ByteReader) Read() ([]byte, error) {
|
||||
return r.Data, nil
|
||||
}
|
||||
@@ -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,21 +95,14 @@ 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 redirectToTLS(w http.ResponseWriter, r *http.Request) {
|
||||
func redirectToTLSHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// Redirect to the same host but with HTTPS
|
||||
var redirectCode int
|
||||
if r.Method == http.MethodGet {
|
||||
@@ -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 httpProxyHandler(w http.ResponseWriter, r *http.Request) {
|
||||
func proxyHandler(w http.ResponseWriter, r *http.Request) {
|
||||
route, err := findHTTPRoute(r.Host, r.URL.Path)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("request failed %s %s%s, error: %v",
|
||||
r.Method,
|
||||
r.Host,
|
||||
r.URL.Path,
|
||||
err,
|
||||
)
|
||||
http.Error(w, "404 Not Found", http.StatusNotFound)
|
||||
err = NewNestedError("request failed").
|
||||
Subjectf("%s %s%s", r.Method, r.Host, r.URL.Path).
|
||||
With(err)
|
||||
logrus.Error(err)
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
route.Proxy.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// alias -> (path -> routes)
|
||||
type HTTPRoutes = SafeMap[string, *pathPoolMap]
|
||||
func (config *ProxyConfig) pathSubModResp(r *http.Response) error {
|
||||
contentType, ok := r.Header["Content-Type"]
|
||||
if !ok || len(contentType) == 0 {
|
||||
return nil
|
||||
}
|
||||
// disable cache
|
||||
r.Header.Set("Cache-Control", "no-store")
|
||||
|
||||
var httpRoutes HTTPRoutes = NewSafeMap[string](newPathPoolMap)
|
||||
var err error = nil
|
||||
switch {
|
||||
case strings.HasPrefix(contentType[0], "text/html"):
|
||||
err = utils.respHTMLSubPath(r, config.Path)
|
||||
case strings.HasPrefix(contentType[0], "application/javascript"):
|
||||
err = utils.respJSSubPath(r, config.Path)
|
||||
}
|
||||
if err != nil {
|
||||
err = NewNestedError("failed to remove path prefix").Subject(config.Path).With(err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// alias -> (path -> routes)
|
||||
type HTTPRoutes SafeMap[string, pathPoolMap]
|
||||
|
||||
var httpRoutes HTTPRoutes = NewSafeMapOf[HTTPRoutes](newPathPoolMap)
|
||||
|
||||
@@ -474,7 +474,7 @@ func (p *ReverseProxy) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
|
||||
|
||||
rw.WriteHeader(res.StatusCode)
|
||||
|
||||
// NOTE: changing this line extremely improve throughput
|
||||
// NOTE: changing this line extremely improve throughput
|
||||
// err = p.copyResponse(rw, res.Body, p.flushInterval(res))
|
||||
_, err = io.Copy(rw, res.Body)
|
||||
if err != nil {
|
||||
|
||||
100
src/go-proxy/io.go
Normal file
100
src/go-proxy/io.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type ReadCloser struct {
|
||||
ctx context.Context
|
||||
r io.ReadCloser
|
||||
}
|
||||
|
||||
func (r *ReadCloser) Read(p []byte) (int, error) {
|
||||
select {
|
||||
case <-r.ctx.Done():
|
||||
return 0, r.ctx.Err()
|
||||
default:
|
||||
return r.r.Read(p)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *ReadCloser) Close() error {
|
||||
return r.r.Close()
|
||||
}
|
||||
|
||||
type Pipe struct {
|
||||
r ReadCloser
|
||||
w io.WriteCloser
|
||||
wg sync.WaitGroup
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
func NewPipe(ctx context.Context, r io.ReadCloser, w io.WriteCloser) *Pipe {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
return &Pipe{
|
||||
r: ReadCloser{ctx, r},
|
||||
w: w,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Pipe) Start() {
|
||||
p.wg.Add(1)
|
||||
go func() {
|
||||
Copy(p.ctx, p.w, &p.r)
|
||||
p.wg.Done()
|
||||
}()
|
||||
}
|
||||
|
||||
func (p *Pipe) Stop() {
|
||||
p.cancel()
|
||||
p.wg.Wait()
|
||||
}
|
||||
|
||||
func (p *Pipe) Close() (error, error) {
|
||||
return p.r.Close(), p.w.Close()
|
||||
}
|
||||
|
||||
func (p *Pipe) Wait() {
|
||||
p.wg.Wait()
|
||||
}
|
||||
|
||||
type BidirectionalPipe struct {
|
||||
pSrcDst Pipe
|
||||
pDstSrc Pipe
|
||||
}
|
||||
|
||||
func NewBidirectionalPipe(ctx context.Context, rw1 io.ReadWriteCloser, rw2 io.ReadWriteCloser) *BidirectionalPipe {
|
||||
return &BidirectionalPipe{
|
||||
pSrcDst: *NewPipe(ctx, rw1, rw2),
|
||||
pDstSrc: *NewPipe(ctx, rw2, rw1),
|
||||
}
|
||||
}
|
||||
|
||||
func (p *BidirectionalPipe) Start() {
|
||||
p.pSrcDst.Start()
|
||||
p.pDstSrc.Start()
|
||||
}
|
||||
|
||||
func (p *BidirectionalPipe) Stop() {
|
||||
p.pSrcDst.Stop()
|
||||
p.pDstSrc.Stop()
|
||||
}
|
||||
|
||||
func (p *BidirectionalPipe) Close() (error, error) {
|
||||
return p.pSrcDst.Close()
|
||||
}
|
||||
|
||||
func (p *BidirectionalPipe) Wait() {
|
||||
p.pSrcDst.Wait()
|
||||
p.pDstSrc.Wait()
|
||||
}
|
||||
|
||||
func Copy(ctx context.Context, dst io.WriteCloser, src io.ReadCloser) error {
|
||||
_, err := io.Copy(dst, &ReadCloser{ctx, src})
|
||||
return err
|
||||
}
|
||||
@@ -8,3 +8,4 @@ var cfgl = logrus.WithField("component", "config")
|
||||
var hrlog = logrus.WithField("component", "http_proxy")
|
||||
var srlog = logrus.WithField("component", "stream")
|
||||
var wlog = logrus.WithField("component", "watcher")
|
||||
var aclog = logrus.WithField("component", "autocert")
|
||||
@@ -5,76 +5,131 @@ import (
|
||||
"os"
|
||||
"os/signal"
|
||||
"runtime"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
func main() {
|
||||
var err error
|
||||
var cfg Config
|
||||
|
||||
// flag.Parse()
|
||||
func main() {
|
||||
runtime.GOMAXPROCS(runtime.NumCPU())
|
||||
|
||||
log.SetFormatter(&log.TextFormatter{
|
||||
ForceColors: true,
|
||||
DisableColors: false,
|
||||
FullTimestamp: true,
|
||||
})
|
||||
InitFSWatcher()
|
||||
InitDockerWatcher()
|
||||
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 // TODO: remove, it is expected to be nil if error is not nil, but it is not for now
|
||||
}
|
||||
|
||||
var proxyServer *Server
|
||||
var panelServer *Server
|
||||
|
||||
if autoCertProvider != nil {
|
||||
ok := autoCertProvider.LoadCert()
|
||||
if !ok {
|
||||
if ne := autoCertProvider.ObtainCert(); ne != nil {
|
||||
aclog.Fatal(ne)
|
||||
}
|
||||
}
|
||||
for name, expiry := range autoCertProvider.GetExpiries() {
|
||||
aclog.Infof("certificate %q: expire on %v", name, expiry)
|
||||
}
|
||||
go autoCertProvider.ScheduleRenewal()
|
||||
}
|
||||
proxyServer = NewServer(ServerOptions{
|
||||
Name: "proxy",
|
||||
CertProvider: autoCertProvider,
|
||||
HTTPAddr: ":80",
|
||||
HTTPSAddr: ":443",
|
||||
Handler: http.HandlerFunc(proxyHandler),
|
||||
RedirectToHTTPS: cfg.Value().RedirectToHTTPS,
|
||||
})
|
||||
panelServer = NewServer(ServerOptions{
|
||||
Name: "panel",
|
||||
CertProvider: autoCertProvider,
|
||||
HTTPAddr: ":8080",
|
||||
HTTPSAddr: ":8443",
|
||||
Handler: panelHandler,
|
||||
RedirectToHTTPS: cfg.Value().RedirectToHTTPS,
|
||||
})
|
||||
|
||||
proxyServer.Start()
|
||||
panelServer.Start()
|
||||
|
||||
InitFSWatcher()
|
||||
|
||||
cfg.StartProviders()
|
||||
cfg.WatchChanges()
|
||||
|
||||
var certAvailable = utils.fileOK(certPath) && utils.fileOK(keyPath)
|
||||
|
||||
go func() {
|
||||
log.Info("starting http server on port 80")
|
||||
if certAvailable && redirectHTTP {
|
||||
err = http.ListenAndServe(":80", http.HandlerFunc(redirectToTLS))
|
||||
} else {
|
||||
err = http.ListenAndServe(":80", http.HandlerFunc(httpProxyHandler))
|
||||
}
|
||||
if err != nil {
|
||||
log.Fatal("http server error: ", err)
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
log.Infof("starting http panel on port 8080")
|
||||
err = http.ListenAndServe(":8080", http.HandlerFunc(panelHandler))
|
||||
if err != nil {
|
||||
log.Warning("http panel error: ", err)
|
||||
}
|
||||
}()
|
||||
|
||||
if certAvailable {
|
||||
go func() {
|
||||
log.Info("starting https server on port 443")
|
||||
err = http.ListenAndServeTLS(":443", certPath, keyPath, http.HandlerFunc(httpProxyHandler))
|
||||
if err != nil {
|
||||
log.Fatal("https server error: ", err)
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
log.Info("starting https panel on port 8443")
|
||||
err := http.ListenAndServeTLS(":8443", certPath, keyPath, http.HandlerFunc(panelHandler))
|
||||
if err != nil {
|
||||
log.Warning("http panel error: ", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
sig := make(chan os.Signal, 1)
|
||||
signal.Notify(sig, syscall.SIGINT)
|
||||
signal.Notify(sig, syscall.SIGTERM)
|
||||
signal.Notify(sig, syscall.SIGHUP)
|
||||
|
||||
<-sig
|
||||
cfg.StopWatching()
|
||||
cfg.StopProviders()
|
||||
StopFSWatcher()
|
||||
StopDockerWatcher()
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type Route interface {
|
||||
Start()
|
||||
Stop()
|
||||
@@ -13,11 +9,11 @@ func NewRoute(cfg *ProxyConfig) (Route, error) {
|
||||
if isStreamScheme(cfg.Scheme) {
|
||||
id := cfg.GetID()
|
||||
if streamRoutes.Contains(id) {
|
||||
return nil, fmt.Errorf("duplicated %s stream %s, ignoring", cfg.Scheme, id)
|
||||
return nil, NewNestedError("duplicated stream").Subject(cfg.Alias)
|
||||
}
|
||||
route, err := NewStreamRoute(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, NewNestedErrorFrom(err).Subject(cfg.Alias)
|
||||
}
|
||||
streamRoutes.Set(id, route)
|
||||
return route, nil
|
||||
@@ -25,7 +21,7 @@ func NewRoute(cfg *ProxyConfig) (Route, error) {
|
||||
httpRoutes.Ensure(cfg.Alias)
|
||||
route, err := NewHTTPRoute(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, NewNestedErrorFrom(err).Subject(cfg.Alias)
|
||||
}
|
||||
httpRoutes.Get(cfg.Alias).Add(cfg.Path, route)
|
||||
return route, nil
|
||||
@@ -51,6 +47,6 @@ func isStreamScheme(s string) bool {
|
||||
}
|
||||
|
||||
// id -> target
|
||||
type StreamRoutes = SafeMap[string, StreamRoute]
|
||||
type StreamRoutes SafeMap[string, StreamRoute]
|
||||
|
||||
var streamRoutes = NewSafeMap[string, StreamRoute]()
|
||||
var streamRoutes StreamRoutes = NewSafeMapOf[StreamRoutes]()
|
||||
|
||||
140
src/go-proxy/server.go
Normal file
140
src/go-proxy/server.go
Normal file
@@ -0,0 +1,140 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
"golang.org/x/net/context"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
Name string
|
||||
KeyFile string
|
||||
CertFile string
|
||||
CertProvider AutoCertProvider
|
||||
http *http.Server
|
||||
https *http.Server
|
||||
httpStarted bool
|
||||
httpsStarted bool
|
||||
}
|
||||
|
||||
type ServerOptions struct {
|
||||
Name string
|
||||
HTTPAddr string
|
||||
HTTPSAddr string
|
||||
CertProvider AutoCertProvider
|
||||
RedirectToHTTPS bool
|
||||
Handler http.Handler
|
||||
}
|
||||
|
||||
type LogrusWrapper struct {
|
||||
*logrus.Entry
|
||||
}
|
||||
|
||||
func (l LogrusWrapper) Write(b []byte) (int, error) {
|
||||
return l.Logger.WriterLevel(logrus.ErrorLevel).Write(b)
|
||||
}
|
||||
|
||||
func NewServer(opt ServerOptions) *Server {
|
||||
var httpHandler http.Handler
|
||||
var s *Server
|
||||
if opt.RedirectToHTTPS {
|
||||
httpHandler = http.HandlerFunc(redirectToTLSHandler)
|
||||
} else {
|
||||
httpHandler = opt.Handler
|
||||
}
|
||||
logger := log.Default()
|
||||
logger.SetOutput(LogrusWrapper{
|
||||
logrus.WithFields(logrus.Fields{"component": "server", "name": opt.Name}),
|
||||
})
|
||||
if opt.CertProvider != nil {
|
||||
s = &Server{
|
||||
Name: opt.Name,
|
||||
CertProvider: opt.CertProvider,
|
||||
http: &http.Server{
|
||||
Addr: opt.HTTPAddr,
|
||||
Handler: httpHandler,
|
||||
ErrorLog: logger,
|
||||
},
|
||||
https: &http.Server{
|
||||
Addr: opt.HTTPSAddr,
|
||||
Handler: opt.Handler,
|
||||
ErrorLog: logger,
|
||||
TLSConfig: &tls.Config{
|
||||
GetCertificate: opt.CertProvider.GetCert,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
s = &Server{
|
||||
Name: opt.Name,
|
||||
KeyFile: keyFileDefault,
|
||||
CertFile: certFileDefault,
|
||||
http: &http.Server{
|
||||
Addr: opt.HTTPAddr,
|
||||
Handler: httpHandler,
|
||||
ErrorLog: logger,
|
||||
},
|
||||
https: &http.Server{
|
||||
Addr: opt.HTTPSAddr,
|
||||
Handler: opt.Handler,
|
||||
ErrorLog: logger,
|
||||
},
|
||||
}
|
||||
if !s.certsOK() {
|
||||
s.http.Handler = opt.Handler
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *Server) Start() {
|
||||
if s.http != nil {
|
||||
s.httpStarted = true
|
||||
logrus.Printf("starting http %s server on %s", s.Name, s.http.Addr)
|
||||
go func() {
|
||||
err := s.http.ListenAndServe()
|
||||
s.handleErr("http", err)
|
||||
}()
|
||||
}
|
||||
|
||||
if s.https != nil && (s.CertProvider != nil || s.certsOK()) {
|
||||
s.httpsStarted = true
|
||||
logrus.Printf("starting https %s server on %s", s.Name, s.https.Addr)
|
||||
go func() {
|
||||
err := s.https.ListenAndServeTLS(s.CertFile, s.KeyFile)
|
||||
s.handleErr("https", err)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) Stop() {
|
||||
ctx, _ := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
|
||||
if s.httpStarted {
|
||||
errHTTP := s.http.Shutdown(ctx)
|
||||
s.handleErr("http", errHTTP)
|
||||
s.httpStarted = false
|
||||
}
|
||||
|
||||
if s.httpsStarted {
|
||||
errHTTPS := s.https.Shutdown(ctx)
|
||||
s.handleErr("https", errHTTPS)
|
||||
s.httpsStarted = false
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleErr(scheme string, err error) {
|
||||
switch err {
|
||||
case nil, http.ErrServerClosed:
|
||||
return
|
||||
default:
|
||||
logrus.Fatalf("failed to start %s %s server: %v", scheme, s.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) certsOK() bool {
|
||||
return utils.fileOK(s.CertFile) && utils.fileOK(s.KeyFile)
|
||||
}
|
||||
@@ -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,54 +33,51 @@ 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
|
||||
@@ -94,13 +92,15 @@ func newStreamRouteBase(config *ProxyConfig) (*StreamRouteBase, error) {
|
||||
TargetHost: config.Host,
|
||||
TargetPort: dstPortInt,
|
||||
|
||||
id: config.GetID(),
|
||||
wg: sync.WaitGroup{},
|
||||
stopChann: make(chan struct{}, 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
|
||||
}
|
||||
@@ -112,7 +112,7 @@ func NewStreamRoute(config *ProxyConfig) (StreamRoute, error) {
|
||||
case StreamType_UDP:
|
||||
return NewUDPRoute(config)
|
||||
default:
|
||||
return nil, errors.New("unknown stream type")
|
||||
return nil, NewNestedError("invalid stream type").Subject(config.Scheme)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,7 +128,45 @@ func (route *StreamRouteBase) Logger() logrus.FieldLogger {
|
||||
return route.l
|
||||
}
|
||||
|
||||
func (route *StreamRouteBase) setupListen() {
|
||||
func (route *StreamRouteBase) Start() {
|
||||
route.ensurePort()
|
||||
if err := route.Setup(); err != nil {
|
||||
route.l.Errorf("failed to setup: %v", err)
|
||||
return
|
||||
}
|
||||
route.started = true
|
||||
route.wg.Add(2)
|
||||
go route.grAcceptConnections()
|
||||
go route.grHandleConnections()
|
||||
}
|
||||
|
||||
func (route *StreamRouteBase) Stop() {
|
||||
if !route.started {
|
||||
return
|
||||
}
|
||||
l := route.Logger()
|
||||
l.Debug("stopping listening")
|
||||
close(route.stopCh)
|
||||
route.CloseListeners()
|
||||
|
||||
done := make(chan struct{}, 1)
|
||||
go func() {
|
||||
route.wg.Wait()
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
l.Info("stopped listening")
|
||||
case <-time.After(streamStopListenTimeout):
|
||||
l.Error("timed out waiting for connections")
|
||||
}
|
||||
|
||||
utils.unmarkPortInUse(route.ListeningPort)
|
||||
streamRoutes.Delete(route.id)
|
||||
}
|
||||
|
||||
func (route *StreamRouteBase) ensurePort() {
|
||||
if route.ListeningPort == 0 {
|
||||
freePort, err := utils.findUseFreePort(20000)
|
||||
if err != nil {
|
||||
@@ -142,40 +180,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()
|
||||
}
|
||||
|
||||
@@ -3,94 +3,50 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const tcpDialTimeout = 5 * time.Second
|
||||
|
||||
type Pipes []*BidirectionalPipe
|
||||
|
||||
type TCPRoute struct {
|
||||
*StreamRouteBase
|
||||
listener net.Listener
|
||||
connChan chan net.Conn
|
||||
}
|
||||
|
||||
func NewTCPRoute(config *ProxyConfig) (StreamRoute, error) {
|
||||
base, err := newStreamRouteBase(config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, NewNestedErrorFrom(err).Subject(config.Alias)
|
||||
}
|
||||
if base.TargetScheme != StreamType_TCP {
|
||||
return nil, fmt.Errorf("tcp to %s not yet supported", base.TargetScheme)
|
||||
return nil, NewNestedError("unsupported").Subjectf("tcp -> %s", base.TargetScheme)
|
||||
}
|
||||
return &TCPRoute{
|
||||
base.StreamImpl = &TCPRoute{
|
||||
StreamRouteBase: base,
|
||||
listener: nil,
|
||||
connChan: make(chan net.Conn),
|
||||
}, nil
|
||||
}
|
||||
return base, nil
|
||||
}
|
||||
|
||||
func (route *TCPRoute) Start() {
|
||||
route.setupListen()
|
||||
func (route *TCPRoute) Setup() error {
|
||||
in, err := net.Listen("tcp", fmt.Sprintf(":%v", route.ListeningPort))
|
||||
if err != nil {
|
||||
route.l.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
route.listener = in
|
||||
route.wg.Add(2)
|
||||
go route.grAcceptConnections()
|
||||
go route.grHandleConnections()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (route *TCPRoute) Stop() {
|
||||
stopListening(route)
|
||||
streamRoutes.Delete(route.id)
|
||||
func (route *TCPRoute) Accept() (interface{}, error) {
|
||||
return route.listener.Accept()
|
||||
}
|
||||
|
||||
func (route *TCPRoute) closeListeners() {
|
||||
if route.listener == nil {
|
||||
return
|
||||
}
|
||||
route.listener.Close()
|
||||
route.listener = nil
|
||||
}
|
||||
func (route *TCPRoute) HandleConnection(c interface{}) error {
|
||||
clientConn := c.(net.Conn)
|
||||
|
||||
func (route *TCPRoute) grAcceptConnections() {
|
||||
defer route.wg.Done()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-route.stopChann:
|
||||
return
|
||||
default:
|
||||
conn, err := route.listener.Accept()
|
||||
if err != nil {
|
||||
route.l.Error(err)
|
||||
continue
|
||||
}
|
||||
route.connChan <- conn
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (route *TCPRoute) grHandleConnections() {
|
||||
defer route.wg.Done()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-route.stopChann:
|
||||
return
|
||||
case conn := <-route.connChan:
|
||||
route.wg.Add(1)
|
||||
go route.grHandleConnection(conn)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (route *TCPRoute) grHandleConnection(clientConn net.Conn) {
|
||||
defer clientConn.Close()
|
||||
defer route.wg.Done()
|
||||
|
||||
@@ -99,34 +55,28 @@ func (route *TCPRoute) grHandleConnection(clientConn net.Conn) {
|
||||
|
||||
serverAddr := fmt.Sprintf("%s:%v", route.TargetHost, route.TargetPort)
|
||||
dialer := &net.Dialer{}
|
||||
|
||||
serverConn, err := dialer.DialContext(ctx, route.TargetScheme, serverAddr)
|
||||
if err != nil {
|
||||
route.l.WithField("stage", "dial").Infof("%v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
pipeCtx, pipeCancel := context.WithCancel(context.Background())
|
||||
go func() {
|
||||
<-route.stopCh
|
||||
pipeCancel()
|
||||
}()
|
||||
pipe := NewBidirectionalPipe(pipeCtx, clientConn, serverConn)
|
||||
pipe.Start()
|
||||
pipe.Wait()
|
||||
pipe.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (route *TCPRoute) CloseListeners() {
|
||||
if route.listener == nil {
|
||||
return
|
||||
}
|
||||
route.tcpPipe(clientConn, serverConn)
|
||||
}
|
||||
|
||||
func (route *TCPRoute) tcpPipe(src net.Conn, dest net.Conn) {
|
||||
close := func() {
|
||||
src.Close()
|
||||
dest.Close()
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2) // Number of goroutines
|
||||
|
||||
go func() {
|
||||
_, err := io.Copy(src, dest)
|
||||
route.l.Error(err)
|
||||
close()
|
||||
wg.Done()
|
||||
}()
|
||||
go func() {
|
||||
_, err := io.Copy(dest, src)
|
||||
route.l.Error(err)
|
||||
close()
|
||||
wg.Done()
|
||||
}()
|
||||
wg.Wait()
|
||||
route.listener.Close()
|
||||
route.listener = nil
|
||||
}
|
||||
|
||||
@@ -17,8 +17,6 @@ type UDPRoute struct {
|
||||
|
||||
listeningConn *net.UDPConn
|
||||
targetConn *net.UDPConn
|
||||
|
||||
connChan chan *UDPConn
|
||||
}
|
||||
|
||||
type UDPConn struct {
|
||||
@@ -35,99 +33,60 @@ func NewUDPRoute(config *ProxyConfig) (StreamRoute, error) {
|
||||
}
|
||||
|
||||
if base.TargetScheme != StreamType_UDP {
|
||||
return nil, fmt.Errorf("udp to %s not yet supported", base.TargetScheme)
|
||||
return nil, NewNestedError("unsupported").Subjectf("udp->%s", base.TargetScheme)
|
||||
}
|
||||
|
||||
return &UDPRoute{
|
||||
base.StreamImpl = &UDPRoute{
|
||||
StreamRouteBase: base,
|
||||
connMap: make(map[net.Addr]net.Conn),
|
||||
connChan: make(chan *UDPConn),
|
||||
}, nil
|
||||
}
|
||||
return base, nil
|
||||
}
|
||||
|
||||
func (route *UDPRoute) Start() {
|
||||
route.setupListen()
|
||||
|
||||
func (route *UDPRoute) Setup() error {
|
||||
source, err := net.ListenPacket(route.ListeningScheme, fmt.Sprintf(":%v", route.ListeningPort))
|
||||
if err != nil {
|
||||
route.l.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
target, err := net.Dial(route.TargetScheme, fmt.Sprintf("%s:%v", route.TargetHost, route.TargetPort))
|
||||
if err != nil {
|
||||
route.l.Error(err)
|
||||
source.Close()
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
route.listeningConn = source.(*net.UDPConn)
|
||||
route.targetConn = target.(*net.UDPConn)
|
||||
|
||||
route.wg.Add(2)
|
||||
go route.grAcceptConnections()
|
||||
go route.grHandleConnections()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (route *UDPRoute) Stop() {
|
||||
stopListening(route)
|
||||
streamRoutes.Delete(route.id)
|
||||
func (route *UDPRoute) Accept() (interface{}, error) {
|
||||
in := route.listeningConn
|
||||
|
||||
buffer := make([]byte, udpBufferSize)
|
||||
nRead, srcAddr, err := in.ReadFromUDP(buffer)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if nRead == 0 {
|
||||
return nil, io.ErrShortBuffer
|
||||
}
|
||||
|
||||
conn := &UDPConn{
|
||||
remoteAddr: srcAddr,
|
||||
buffer: buffer,
|
||||
bytesReceived: buffer[:nRead],
|
||||
nReceived: nRead,
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
func (route *UDPRoute) closeListeners() {
|
||||
if route.listeningConn != nil {
|
||||
route.listeningConn.Close()
|
||||
route.listeningConn = nil
|
||||
}
|
||||
if route.targetConn != nil {
|
||||
route.targetConn.Close()
|
||||
route.targetConn = nil
|
||||
}
|
||||
for _, conn := range route.connMap {
|
||||
conn.(*net.UDPConn).Close() // TODO: change on non udp target
|
||||
}
|
||||
route.connMap = make(map[net.Addr]net.Conn)
|
||||
}
|
||||
|
||||
func (route *UDPRoute) grAcceptConnections() {
|
||||
defer route.wg.Done()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-route.stopChann:
|
||||
return
|
||||
default:
|
||||
conn, err := route.accept()
|
||||
if err != nil {
|
||||
route.l.Error(err)
|
||||
continue
|
||||
}
|
||||
route.connChan <- conn
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (route *UDPRoute) grHandleConnections() {
|
||||
defer route.wg.Done()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-route.stopChann:
|
||||
return
|
||||
case conn := <-route.connChan:
|
||||
go func() {
|
||||
err := route.handleConnection(conn)
|
||||
if err != nil {
|
||||
route.l.Error(err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (route *UDPRoute) handleConnection(conn *UDPConn) error {
|
||||
func (route *UDPRoute) HandleConnection(c interface{}) error {
|
||||
var err error
|
||||
|
||||
conn := c.(*UDPConn)
|
||||
srcConn, ok := route.connMap[conn.remoteAddr]
|
||||
if !ok {
|
||||
route.connMapMutex.Lock()
|
||||
@@ -155,7 +114,7 @@ func (route *UDPRoute) handleConnection(conn *UDPConn) error {
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-route.stopChann:
|
||||
case <-route.stopCh:
|
||||
return nil
|
||||
default:
|
||||
// receive from target
|
||||
@@ -182,26 +141,19 @@ func (route *UDPRoute) handleConnection(conn *UDPConn) error {
|
||||
}
|
||||
}
|
||||
|
||||
func (route *UDPRoute) accept() (*UDPConn, error) {
|
||||
in := route.listeningConn
|
||||
|
||||
buffer := make([]byte, udpBufferSize)
|
||||
nRead, srcAddr, err := in.ReadFromUDP(buffer)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
func (route *UDPRoute) CloseListeners() {
|
||||
if route.listeningConn != nil {
|
||||
route.listeningConn.Close()
|
||||
route.listeningConn = nil
|
||||
}
|
||||
|
||||
if nRead == 0 {
|
||||
return nil, io.ErrShortBuffer
|
||||
if route.targetConn != nil {
|
||||
route.targetConn.Close()
|
||||
route.targetConn = nil
|
||||
}
|
||||
|
||||
return &UDPConn{
|
||||
remoteAddr: srcAddr,
|
||||
buffer: buffer,
|
||||
bytesReceived: buffer[:nRead],
|
||||
nReceived: nRead},
|
||||
nil
|
||||
for _, conn := range route.connMap {
|
||||
conn.(*net.UDPConn).Close() // TODO: change on non udp target
|
||||
}
|
||||
route.connMap = make(map[net.Addr]net.Conn)
|
||||
}
|
||||
|
||||
func (route *UDPRoute) readFrom(src net.Conn, buffer []byte) (*UDPConn, error) {
|
||||
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
@@ -9,13 +10,15 @@ import (
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/santhosh-tekuri/jsonschema"
|
||||
"github.com/sirupsen/logrus"
|
||||
xhtml "golang.org/x/net/html"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type Utils struct {
|
||||
@@ -51,7 +54,7 @@ func (u *Utils) findUseFreePort(startingPort int) (int, error) {
|
||||
l.Close()
|
||||
return port, nil
|
||||
}
|
||||
return -1, fmt.Errorf("unable to find free port: %v", err)
|
||||
return -1, NewNestedError("unable to find free port").With(err)
|
||||
}
|
||||
|
||||
func (u *Utils) markPortInUse(port int) {
|
||||
@@ -83,7 +86,7 @@ func (*Utils) healthCheckHttp(targetUrl string) error {
|
||||
}
|
||||
|
||||
func (*Utils) healthCheckStream(scheme, host string) error {
|
||||
conn, err := net.DialTimeout(scheme, host, 5*time.Second)
|
||||
conn, err := net.DialTimeout(scheme, host, streamDialTimeout)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -91,7 +94,19 @@ func (*Utils) healthCheckStream(scheme, host string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (*Utils) snakeToCamel(s string) string {
|
||||
func (*Utils) reloadServer() error {
|
||||
resp, err := healthCheckHttpClient.Post("http://localhost:8080/reload", "", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return NewNestedError("server reload failed").Subjectf("%d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (*Utils) snakeToPascal(s string) string {
|
||||
toHyphenCamel := http.CanonicalHeaderKey(strings.ReplaceAll(s, "_", "-"))
|
||||
return strings.ReplaceAll(toHyphenCamel, "-", "")
|
||||
}
|
||||
@@ -192,3 +207,38 @@ func (*Utils) fileOK(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func setFieldFromSnake[T interface{}, VT interface{}](obj *T, field string, value VT) error {
|
||||
field = utils.snakeToPascal(field)
|
||||
prop := reflect.ValueOf(obj).Elem().FieldByName(field)
|
||||
if prop.Kind() == 0 {
|
||||
return NewNestedError("unknown field").Subject(field)
|
||||
}
|
||||
prop.Set(reflect.ValueOf(value))
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateYaml(schema *jsonschema.Schema, data []byte) error {
|
||||
var i interface{}
|
||||
|
||||
err := yaml.Unmarshal(data, &i)
|
||||
if err != nil {
|
||||
return NewNestedError("unable to unmarshal yaml").With(err)
|
||||
}
|
||||
|
||||
m, err := json.Marshal(i)
|
||||
if err != nil {
|
||||
return NewNestedError("unable to marshal json").With(err)
|
||||
}
|
||||
|
||||
err = schema.Validate(bytes.NewReader(m))
|
||||
if err != nil {
|
||||
valErr := err.(*jsonschema.ValidationError)
|
||||
ne := NewNestedError("validation error")
|
||||
for _, e := range valErr.Causes {
|
||||
ne.ExtraError(e)
|
||||
}
|
||||
return ne
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
35
templates/config_editor/index.html
Normal file
35
templates/config_editor/index.html
Normal 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>
|
||||
114
templates/config_editor/index.js
Normal file
114
templates/config_editor/index.js
Normal 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);
|
||||
}
|
||||
64
templates/config_editor/style.css
Normal file
64
templates/config_editor/style.css
Normal 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
23
templates/index.html
Normal 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()"
|
||||
>×</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()">≡</a>
|
||||
<div id="main">
|
||||
<iframe id="content" src="/config_editor" title="panel"></iframe>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
27
templates/main.js
Normal file
27
templates/main.js
Normal 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";
|
||||
}
|
||||
@@ -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
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
79
templates/panel/index.html
Executable 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
34
templates/panel/index.js
Normal 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
43
templates/panel/style.css
Normal 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
68
templates/style.css
Normal 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
1
version.txt
Normal file
@@ -0,0 +1 @@
|
||||
0.4.4
|
||||
Reference in New Issue
Block a user