Compare commits

...

39 Commits

Author SHA1 Message Date
yusing
090b73d287 fixed tcp/udp I/O, deadlock, nil dereference; improved docker watcher, idlewatcher, loading page 2024-09-23 00:49:46 +08:00
yusing
96bce79e4b changed env GOPROXY_*_PORT to GOPROXY_*_ADDR, changed api server default to listen on localhost only, readme update 2024-09-22 06:06:24 +08:00
yusing
d9fd399e43 fix stuck loading in some scenerios for ls-* command line options 2024-09-22 05:01:36 +08:00
yusing
46281aa3b0 renamed ProxyEntry to RawEntry to avoid confusion with src/proxy/entry.go 2024-09-22 04:13:42 +08:00
yusing
d39b68bfd8 fixed possible resource leak 2024-09-22 04:11:02 +08:00
yusing
a11ce46028 added some docker compose examples; fixed defaults to wrong host; updated watcher behavior to retry connection every 3 secs until success or until cancelled 2024-09-22 04:00:08 +08:00
yusing
6388d9d44d fixed outputing error in ls-config, ls-routes, etc. 2024-09-21 18:47:38 +08:00
yusing
69361aea1b fixed host set to localhost even on remote docker, fixed one error in provider causing all routes not to load 2024-09-21 18:23:20 +08:00
yusing
26e2154c64 fixed startup crash for file provider 2024-09-21 17:22:17 +08:00
Yuzerion
a29bf880bc Update docker.md
Too sleepy...
2024-09-21 16:08:11 +08:00
Yuzerion
1f6d03bdbb Update compose.example.yml 2024-09-21 16:07:12 +08:00
Yuzerion
4a7d898b8e Update docker.md 2024-09-21 16:06:32 +08:00
Yuzerion
521b694aec Update docker.md 2024-09-21 15:56:39 +08:00
yusing
a351de7441 github CI fix attempt 2024-09-21 14:32:52 +08:00
yusing
ab2dc26b76 fixing udp stream listening on wrong port 2024-09-21 14:18:29 +08:00
yusing
9a81b13b67 fixing tcp/udp error on closing 2024-09-21 13:40:20 +08:00
yusing
626bd9666b check release 2024-09-21 12:45:56 +08:00
yusing
d7eab2ebcd fixing idlewatcher 2024-09-21 09:42:40 +08:00
yusing
e48b9bbb0a 新增繁中README (未完成) 2024-09-19 21:16:38 +08:00
yusing
339411530b v0.5.0-rc5: merge 2024-09-19 20:42:12 +08:00
yusing
4a2d42bfa9 v0.5.0-rc5: check release 2024-09-19 20:40:03 +08:00
Yuzerion
81da9ad83a small fix 2024-09-18 09:10:41 +08:00
yusing
be7a766cb2 v0.5.0-rc5: added proxy.exclude label, refactored some code 2024-09-17 17:56:41 +08:00
yusing
83d1d027c6 added TZ env to docker compose example 2024-09-17 12:36:13 +08:00
yusing
21fcceb391 v0.5.0-rc4: initial support for ovh, provider generator implementation update, replaced all interface{} to any 2024-09-17 12:06:58 +08:00
yusing
82f06374f7 v0.5.0-rc4: fixing autocert issue, cache ACME registration, added ls-config option 2024-09-17 08:41:36 +08:00
yusing
04fd6543fd README update for sonarcloud badges, simplify some test code, fixed some sonarlint issues 2024-09-17 04:51:26 +08:00
yusing
409a18df38 update default branch for setup script 2024-09-17 03:54:55 +08:00
yusing
4e5a8d0985 v0.5-rc3: version bump 2024-09-17 03:19:21 +08:00
yusing
16b507bc7c v0.5-rc3: update docker port detect mechanism, docker compose file and doc update 2024-09-17 03:11:04 +08:00
yusing
1120991019 v0.5-rc2: fixed port being overridden to 80 or 443 2024-09-17 00:30:26 +08:00
yusing
c0ebd9f8c0 v0.5-rc2: added reload cooldown, fixed auto reload, updated API 2024-09-17 00:10:25 +08:00
yusing
996b418ea9 v0.5-rc1: updated Dockerfile to conform latest format 2024-09-16 13:24:53 +08:00
yusing
4cddd4ff71 v0.5-rc1: schema fixes, provider file example update 2024-09-16 13:19:24 +08:00
yusing
7a0478164f v0.5: (BREAKING) replacing path with path_patterns, improved docker monitoring mechanism, bug fixes 2024-09-16 13:05:04 +08:00
yusing
2e7ba51521 v0.5: (BREAKING) new syntax for set_headers and hide_headers, updated label parser, error.Nil().String() will now return 'nil', better readme 2024-09-16 07:21:45 +08:00
yusing
5be8659a99 v0.5: (BREAKING) simplified config format, improved output formatting, fixed docker watcher 2024-09-16 03:48:39 +08:00
default
719693deb7 v0.5: (BREAKING) simplified config format, improved error output, updated proxy entry default value for 'port' 2024-08-14 02:41:11 +08:00
default
23e7d06081 v0.5: removed system service env, log output format fix 2024-08-13 06:00:22 +08:00
115 changed files with 4461 additions and 2615 deletions

View File

@@ -8,7 +8,14 @@ jobs:
build_and_push:
runs-on: ubuntu-latest
steps:
- name: Build and Push Container to ghcr.io
- name: Set up Docker Build and Push
id: docker_build_push
uses: GlueOps/github-actions-build-push-containers@v0.3.7
with:
tags: latest,${{ github.ref_name }}
tags: ${{ github.ref_name }}
- name: Tag as latest
if: startsWith(github.ref, 'refs/tags/') && !contains(github.ref_name, '-')
run: |
docker tag ghcr.io/${{ github.repository }}:${{ github.ref_name }} ghcr.io/${{ github.repository }}:latest
docker push ghcr.io/${{ github.repository }}:latest

8
.gitignore vendored
View File

@@ -1,7 +1,7 @@
compose.yml
config/
certs/
config*/
certs*/
bin/
templates/codemirror/
@@ -13,4 +13,6 @@ log/
go.work.sum
!src/config/
!src/**/
todo.md

View File

@@ -1,12 +1,13 @@
{
"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"
]
}
}
"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",
"providers.yml"
]
}
}

View File

@@ -1,25 +1,24 @@
FROM golang:1.22.6-alpine as builder
FROM golang:1.23.1-alpine AS builder
COPY src /src
ENV GOCACHE=/root/.cache/go-build
WORKDIR /src
RUN --mount=type=cache,target="/go/pkg/mod" \
go mod download
RUN --mount=type=cache,target="/go/pkg/mod" \
--mount=type=cache,target="/root/.cache/go-build" \
go mod download && \
CGO_ENABLED=0 GOOS=linux go build -pgo=auto -o go-proxy github.com/yusing/go-proxy
FROM alpine:latest
FROM alpine:3.20
LABEL maintainer="yusing@6uo.me"
RUN apk add --no-cache tzdata
COPY schema/ /app/schema
# copy binary
COPY --from=builder /src/go-proxy /app/
COPY schema/ /app/schema
RUN chmod +x /app/go-proxy
ENV DOCKER_HOST unix:///var/run/docker.sock
ENV GOPROXY_DEBUG 0
ENV DOCKER_HOST=unix:///var/run/docker.sock
ENV GOPROXY_DEBUG=0
EXPOSE 80
EXPOSE 8888

View File

@@ -12,7 +12,7 @@ build:
CGO_ENABLED=0 GOOS=linux go build -pgo=auto -o bin/go-proxy github.com/yusing/go-proxy
test:
cd src && go test ./... && cd ..
go test ./src/...
up:
docker compose up -d
@@ -27,7 +27,7 @@ get:
cd src && go get -u && go mod tidy && cd ..
debug:
make build && GOPROXY_DEBUG=1 bin/go-proxy
make build && sudo GOPROXY_DEBUG=1 bin/go-proxy
archive:
git archive HEAD -o ../go-proxy-$$(date +"%Y%m%d%H%M").zip
@@ -36,4 +36,12 @@ repush:
git reset --soft HEAD^
git add -A
git commit -m "repush"
git push gitlab dev --force
git push gitlab dev --force
rapid-crash:
sudo docker run --restart=always --name test_crash debian:bookworm-slim /bin/cat &&\
sleep 3 &&\
sudo docker rm -f test_crash
debug-list-containers:
bash -c 'echo -e "GET /containers/json HTTP/1.0\r\n" | sudo netcat -U /var/run/docker.sock | tail -n +9 | jq'

View File

@@ -1,15 +1,25 @@
# go-proxy
A [lightweight](docs/benchmark_result.md), easy-to-use, and efficient reverse proxy and load balancer with a web UI.
[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=yusing_go-proxy&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy)
[![Lines of Code](https://sonarcloud.io/api/project_badges/measure?project=yusing_go-proxy&metric=ncloc)](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy)
[![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=yusing_go-proxy&metric=security_rating)](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy)
[![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=yusing_go-proxy&metric=sqale_rating)](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy)
[![Vulnerabilities](https://sonarcloud.io/api/project_badges/measure?project=yusing_go-proxy&metric=vulnerabilities)](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy)
**Table of content**
[繁體中文文檔請看此](README_CHT.md)
A lightweight, easy-to-use, and [performant](docs/benchmark_result.md) reverse proxy with a web UI.
## Table of content
<!-- TOC -->
- [go-proxy](#go-proxy)
- [Table of content](#table-of-content)
- [Key Points](#key-points)
- [Getting Started](#getting-started)
- [Commands](#commands)
- [Setup](#setup)
- [Commands line arguments](#commands-line-arguments)
- [Environment variables](#environment-variables)
- [Use JSON Schema in VSCode](#use-json-schema-in-vscode)
- [Config File](#config-file)
@@ -20,45 +30,59 @@ A [lightweight](docs/benchmark_result.md), easy-to-use, and efficient reverse pr
## Key Points
- Easy to use
- Effortless configuration
- Error messages is clear and detailed, easy troubleshooting
- Auto certificate obtaining and renewal (See [Supported DNS Challenge Providers](docs/dns_providers.md))
- Auto configuration for docker contaienrs
- Auto configuration for docker containers
- Auto hot-reload on container state / config file changes
- Support HTTP(s), TCP and UDP
- Support HTTP(s) round robin load balancing
- Web UI for configuration and monitoring (See [screenshots](screeenshots))
- Stop containers on idle, wake it up on traffic _(optional)_
- HTTP(s) reserve proxy
- TCP and UDP port forwarding
- Web UI for configuration and monitoring (See [screenshots](https://github.com/yusing/go-proxy-frontend?tab=readme-ov-file#screenshots))
- Written in **[Go](https://go.dev)**
[🔼Back to top](#table-of-content)
## Getting Started
1. Setup DNS Records
### Setup
1. Setup DNS Records, e.g.
- A Record: `*.y.z` -> `10.0.10.1`
- AAAA Record: `*.y.z` -> `::ffff:a00:a01`
2. Setup `go-proxy` [See here](docs/docker.md)
3. Configure `go-proxy`
- with text editor (i.e. Visual Studio Code)
3. Setup `docker-socket-proxy` (see [example](docs/docker_socket_proxy.md) other machine that is running docker (if any)
4. Configure `go-proxy`
- with text editor (e.g. Visual Studio Code)
- or with web config editor via `http://gp.y.z`
[🔼Back to top](#table-of-content)
### Commands
### Commands line arguments
- `go-proxy` start proxy server
- `go-proxy validate` validate config and exit
- `go-proxy reload` trigger a force reload of config
| Argument | Description | Example |
| ----------- | -------------------------------- | -------------------------- |
| empty | start proxy server | |
| `validate` | validate config and exit | |
| `reload` | trigger a force reload of config | |
| `ls-config` | list config and exit | `go-proxy ls-config \| jq` |
| `ls-route` | list proxy entries and exit | `go-proxy ls-route \| jq` |
**For docker containers, run `docker exec -it go-proxy /app/go-proxy <command>`**
**run with `docker exec <container_name> /app/go-proxy <command>`**
### Environment variables
Booleans:
- `GOPROXY_DEBUG` enable debug behaviors
- `GOPROXY_NO_SCHEMA_VALIDATION`: disable schema validation **(useful for testing new DNS Challenge providers)**
| Environment Variable | Description | Default | Values |
| ------------------------------ | ------------------------------------------- | ---------------- | ------------- |
| `GOPROXY_NO_SCHEMA_VALIDATION` | disable schema validation | `false` | boolean |
| `GOPROXY_DEBUG` | enable debug behaviors | `false` | boolean |
| `GOPROXY_HTTP_ADDR` | http server listening address | `:80` | `[host]:port` |
| `GOPROXY_HTTPS_ADDR` | https server listening address (if enabled) | `:443` | `[host]:port` |
| `GOPROXY_API_ADDR` | api server listening address | `127.0.0.1:8888` | `[host]:port` |
### Use JSON Schema in VSCode
@@ -80,19 +104,21 @@ autocert:
- ...
# reverse proxy providers configuration
providers:
entry_1:
kind: docker
value: # `FROM_ENV` or full url to docker host
entry_2:
kind: file
value: # relative path of file to `config/`
include:
- providers.yml
- other_file_1.yml
- ...
docker:
local: $DOCKER_HOST
remote-1: tcp://10.0.2.1:2375
remote-2: ssh://root:1234@10.0.2.2
```
[🔼Back to top](#table-of-content)
### Provider File
Fields are same as [docker labels](docs/docker.md#labels) starting from `scheme`
See [Fields](docs/docker.md#fields)
See [providers.example.yml](providers.example.yml) for examples
@@ -100,20 +126,20 @@ See [providers.example.yml](providers.example.yml) for examples
## Known issues
- Cert "renewal" is actually obtaining a new cert instead of renewing the existing one
- `autocert` config is not hot-reloadable
[🔼Back to top](#table-of-content)
## Build it yourself
1. Install / Upgrade [go (>=1.22)](https://go.dev/doc/install) and `make` if not already
1. Clone the repository `git clone https://github.com/yusing/go-proxy --depth=1`
2. Clear cache if you have built this before (go < 1.22) with `go clean -cache`
2. Install / Upgrade [go (>=1.22)](https://go.dev/doc/install) and `make` if not already
3. get dependencies with `make get`
3. Clear cache if you have built this before (go < 1.22) with `go clean -cache`
4. build binary with `make build`
4. get dependencies with `make get`
5. start your container with `make up` (docker) or `bin/go-proxy` (binary)
5. build binary with `make build`
[🔼Back to top](#table-of-content)

141
README_CHT.md Normal file
View File

@@ -0,0 +1,141 @@
# go-proxy
[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=yusing_go-proxy&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy)
[![Lines of Code](https://sonarcloud.io/api/project_badges/measure?project=yusing_go-proxy&metric=ncloc)](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy)
[![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=yusing_go-proxy&metric=security_rating)](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy)
[![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=yusing_go-proxy&metric=sqale_rating)](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy)
[![Vulnerabilities](https://sonarcloud.io/api/project_badges/measure?project=yusing_go-proxy&metric=vulnerabilities)](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy)
一個輕量化、易用且[高效](docs/benchmark_result.md)的反向代理和端口轉發工具
## 目錄
<!-- TOC -->
- [go-proxy](#go-proxy)
- [目錄](#目錄)
- [重點](#重點)
- [入門指南](#入門指南)
- [安裝](#安裝)
- [命令行參數](#命令行參數)
- [環境變量](#環境變量)
- [VSCode 中使用 JSON Schema](#vscode-中使用-json-schema)
- [配置文件](#配置文件)
- [透過文件配置](#透過文件配置)
- [已知問題](#已知問題)
- [源碼編譯](#源碼編譯)
## 重點
- 易用
- 不需花費太多時間就能輕鬆配置
- 除錯簡單
- 自動處理 HTTPS 證書(參見[可用的 DNS 供應商](docs/dns_providers.md)
- 透過 Docker 容器自動配置
- 容器狀態變更時自動熱重載
- 容器閒置時自動暫停/停止,入站時自動喚醒
- HTTP(s)反向代理
- TCP/UDP 端口轉發
- 用於配置和監控的前端 Web 面板([截圖](https://github.com/yusing/go-proxy-frontend?tab=readme-ov-file#screenshots)
- 使用 **[Go](https://go.dev)** 編寫
[🔼 返回頂部](#目錄)
## 入門指南
### 安裝
1. 設置 DNS 記錄,例如:
- A 記錄: `*.y.z` -> `10.0.10.1`
- AAAA 記錄: `*.y.z` -> `::ffff:a00:a01`
2. 安裝 `go-proxy` [參見這裡](docs/docker.md)
3. 配置 `go-proxy`
- 使用文本編輯器 (推薦 Visual Studio Code [參見 VSCode 使用 schema](#vscode-中使用-json-schema))
- 或通過 `http://gp.y.z` 使用網頁配置編輯器
[🔼 返回頂部](#目錄)
### 命令行參數
| 參數 | 描述 | 示例 |
| ----------- | -------------- | -------------------------- |
| 空 | 啟動代理服務器 | |
| `validate` | 驗證配置並退出 | |
| `reload` | 強制刷新配置 | |
| `ls-config` | 列出配置並退出 | `go-proxy ls-config \| jq` |
| `ls-route` | 列出路由並退出 | `go-proxy ls-route \| jq` |
**使用 `docker exec <容器名稱> /app/go-proxy <參數>` 運行**
### 環境變量
| 環境變量 | 描述 | 默認 | 格式 |
| ------------------------------ | ---------------- | ---------------- | ------------- |
| `GOPROXY_NO_SCHEMA_VALIDATION` | 禁用 schema 驗證 | `false` | boolean |
| `GOPROXY_DEBUG` | 啟用調試輸出 | `false` | boolean |
| `GOPROXY_HTTP_ADDR` | http 收聽地址 | `:80` | `[host]:port` |
| `GOPROXY_HTTPS_ADDR` | https 收聽地址 | `:443` | `[host]:port` |
| `GOPROXY_API_ADDR` | api 收聽地址 | `127.0.0.1:8888` | `[host]:port` |
### VSCode 中使用 JSON Schema
複製 [`.vscode/settings.example.json`](.vscode/settings.example.json) 到 `.vscode/settings.json` 並根據需求修改
[🔼 返回頂部](#目錄)
### 配置文件
參見 [config.example.yml](config.example.yml) 了解更多
```yaml
# autocert 配置
autocert:
email: # ACME 電子郵件
domains: # 域名列表
provider: # DNS 供應商
options: # 供應商個別配置
- ...
# 配置文件 / docker
providers:
include:
- providers.yml
- other_file_1.yml
- ...
docker:
local: $DOCKER_HOST
remote-1: tcp://10.0.2.1:2375
remote-2: ssh://root:1234@10.0.2.2
```
[🔼 返回頂部](#目錄)
### 透過文件配置
參見 [Fields](docs/docker.md#fields)
參見範例 [providers.example.yml](providers.example.yml)
[🔼 返回頂部](#目錄)
## 已知問題
- `autocert` 配置不能熱重載
[🔼 返回頂部](#目錄)
## 源碼編譯
1. 獲取源碼 `git clone https://github.com/yusing/go-proxy --depth=1`
2. 安裝/升級 [go 版本 (>=1.22)](https://go.dev/doc/install) 和 `make`(如果尚未安裝)
3. 如果之前編譯過go 版本 < 1.22),請使用 `go clean -cache` 清除緩存
4. 使用 `make get` 獲取依賴項
5. 使用 `make build` 編譯
[🔼 返回頂部](#目錄)

View File

@@ -5,7 +5,8 @@ services:
restart: unless-stopped
network_mode: host
labels:
- proxy.*.aliases=gp
- proxy.aliases=gp
- proxy.gp.port=3000
depends_on:
- app
app:
@@ -13,8 +14,11 @@ services:
container_name: go-proxy
restart: always
network_mode: host
environment:
# (Optional) change this to your timezone to get correct log timestamp
TZ: ETC/UTC
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- /var/run/docker.sock:/var/run/docker.sock
- ./config:/app/config
# (Optional) choose one of below to enable https

View File

@@ -11,22 +11,26 @@
# provider: cloudflare
# email: # ACME Email
# domains: # a list of domains for cert registration
# -
# - x.y.z
# options:
# - auth_token: # your zone API token
# - auth_token: c1234565789-abcdefghijklmnopqrst # your zone API token
# 3. other providers, check readme for more
providers:
local:
kind: docker
include:
- providers.yml # config/providers.yml
# add some more below if you want
# - file1.yml # config/file_1.yml
# - file2.yml
docker:
# for value format, see https://docs.docker.com/reference/cli/dockerd/
# i.e. ssh://user@10.0.1.1:22, tcp://10.0.2.1:2375
# use FROM_ENV if you have binded docker socket to /var/run/docker.sock
value: FROM_ENV
providers:
kind: file
value: providers.yml
# $DOCKER_HOST implies unix:///var/run/docker.sock by default
local: $DOCKER_HOST
# add more docker providers if needed
# remote-1: tcp://10.0.2.1:2375
# remote-2: ssh://root:1234@10.0.2.2
# Fixed options (optional, non hot-reloadable)
# timeout_shutdown: 5

View File

@@ -7,7 +7,7 @@
```go
var providersGenMap = map[string]ProviderGenerator{
"cloudflare": providerGenerator(cloudflare.NewDefaultConfig, cloudflare.NewDNSProviderConfig),
// add here, i.e.
// add here, e.g.
"clouddns": providerGenerator(clouddns.NewDefaultConfig, clouddns.NewDNSProviderConfig),
}
```

View File

@@ -1,11 +1,13 @@
# Supported DNS Providers
<!-- TOC -->
- [Cloudflare](#cloudflare)
- [CloudDNS](#clouddns)
- [DuckDNS](#duckdns)
- [Implement other DNS providers](#implement-other-dns-providers)
<!-- /TOC -->
- [Supported DNS Providers](#supported-dns-providers)
- [Cloudflare](#cloudflare)
- [CloudDNS](#clouddns)
- [DuckDNS](#duckdns)
- [OVHCloud](#ovhcloud)
- [Implement other DNS providers](#implement-other-dns-providers)
## Cloudflare
@@ -23,10 +25,29 @@ Follow [this guide](https://cloudkul.com/blog/automcatic-renew-and-generate-ssl-
## DuckDNS
`token`: DuckDNS Token
- `token`: DuckDNS Token
Tested by [earvingad](https://github.com/earvingad)
## OVHCloud
_Note, `application_key` and `oauth2_config` **CANNOT** be used together_
- `api_endpoint`: Endpoint URL, or one of
- `ovh-eu`,
- `ovh-ca`,
- `ovh-us`,
- `kimsufi-eu`,
- `kimsufi-ca`,
- `soyoustart-eu`,
- `soyoustart-ca`
- `application_secret`
- `application_key`
- `consumer_key`
- `oauth2_config`: Client ID and Client Secret
- `client_id`
- `client_secret`
## Implement other DNS providers
See [add_dns_provider.md](docs/add_dns_provider.md)

View File

@@ -1,131 +1,200 @@
# Docker container guide
# Docker compose guide
## Table of content
<!-- TOC -->
- [Docker container guide](#docker-container-guide)
- [Docker compose guide](#docker-compose-guide)
- [Table of content](#table-of-content)
- [Setup](#setup)
- [Labels](#labels)
- [Syntax](#syntax)
- [Fields](#fields)
- [Key-value mapping example](#key-value-mapping-example)
- [List example](#list-example)
- [Troubleshooting](#troubleshooting)
- [Docker compose examples](#docker-compose-examples)
- [Local docker provider in bridge network](#local-docker-provider-in-bridge-network)
- [Proxy setup](#proxy-setup)
- [Services URLs for above examples](#services-urls-for-above-examples)
## Setup
1. Install `wget` if not already
1. Install `wget` if not already
2. Run setup script
- Ubuntu based: `sudo apt install -y wget`
- Fedora based: `sudo yum install -y wget`
- Arch based: `sudo pacman -Sy wget`
`bash <(wget -qO- https://github.com/yusing/go-proxy/raw/main/setup-docker.sh)`
2. Run setup script
What it does:
`bash <(wget -qO- https://github.com/yusing/go-proxy/raw/main/setup-docker.sh)`
- Create required directories
- Setup `config.yml` and `compose.yml`
It will setup folder structure and required config files
3. Verify folder structure and then `cd go-proxy`
3. Verify folder structure and then `cd go-proxy`
```plain
go-proxy
├── certs
├── compose.yml
└── config
├── config.yml
└── providers.yml
```
```plain
go-proxy
├── certs
├── compose.yml
└── config
├── config.yml
└── providers.yml
```
4. Enable HTTPs _(optional)_
4. Enable HTTPs _(optional)_
- To use autocert feature
Mount a folder (to store obtained certs) or (containing existing cert)
- completing `autocert` section in `config/config.yml`
- mount `certs/` to `/app/certs` to store obtained certs
```yaml
services:
go-proxy:
...
volumes:
- ./certs:/app/certs
```
- To use existing certificate
To use **autocert**, complete that section in `config.yml`, e.g.
mount your wildcard (`*.y.z`) SSL cert
```yaml
autocert:
email: john.doe@x.y.z # ACME Email
domains: # a list of domains for cert registration
- x.y.z
provider: cloudflare
options:
- auth_token: c1234565789-abcdefghijklmnopqrst # your zone API token
```
- cert / chain / fullchain -> `/app/certs/cert.crt`
- private key -> `/app/certs/priv.key`
To use **existing certificate**, set path for cert and key in `config.yml`, e.g.
5. Modify `compose.yml` fit your needs
```yaml
autocert:
cert_path: /app/certs/cert.crt
key_path: /app/certs/priv.key
```
Add networks to make sure it is in the same network with other containers, or make sure `proxy.<alias>.host` is reachable
5. Modify `compose.yml` to fit your needs
6. Run `docker compose up -d` to start the container
6. Run `docker compose up -d` to start the container
7. Start editing config files in `http://<ip>:8080`
7. Navigate to Web panel `http://gp.yourdomain.com` or use **Visual Studio Code (provides schema check)** to edit proxy config
[🔼Back to top](#table-of-content)
## Labels
- `proxy.aliases`: comma separated aliases for subdomain matching
### Syntax
- default: container name
| Label | Description | Default | Accepted values |
| ------------------------ | --------------------------------------------------------------------- | -------------------- | ------------------------------------------------------------------------- |
| `proxy.aliases` | comma separated aliases for subdomain and label matching | `container_name` | any |
| `proxy.exclude` | to be excluded from `go-proxy` | false | boolean |
| `proxy.idle_timeout` | time for idle (no traffic) before put it into sleep **(http/s only)** | empty **(disabled)** | `number[unit]...`, e.g. `1m30s` |
| `proxy.wake_timeout` | time to wait for container to start before responding a loading page | empty | `number[unit]...` |
| `proxy.stop_method` | method to stop after `idle_timeout` | `stop` | `stop`, `pause`, `kill` |
| `proxy.stop_timeout` | time to wait for stop command | `10s` | `number[unit]...` |
| `proxy.stop_signal` | signal sent to container for `stop` and `kill` methods | docker's default | `SIGINT`, `SIGTERM`, `SIGHUP`, `SIGQUIT` and those without **SIG** prefix |
| `proxy.<alias>.<field>` | set field for specific alias | N/A | N/A |
| `proxy.$<index>.<field>` | set field for specific alias at index (starting from **1**) | N/A | N/A |
| `proxy.*.<field>` | set field for all aliases | N/A | N/A |
- `proxy.*.<field>`: wildcard label for all aliases
### Fields
_Labels below should have a **`proxy.<alias>.`** prefix._
| Field | Description | Default | Allowed Values / Syntax |
| --------------------- | ---------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `scheme` | proxy protocol | <ul><li>`http` for numeric port</li><li>`tcp` for `x:y` port</li></ul> | `http`, `https`, `tcp`, `udp` |
| `host` | proxy host | <ul><li>Docker: docker client IP / hostname </li><li>File: `localhost`</li></ul> | IP address, hostname |
| `port` | proxy port **(http/s)** | first port in `ports:` | number in range of `1 - 65535` |
| `port` **(required)** | proxy port **(tcp/udp)** | N/A | `x:y` <br><ul><li>x: port for `go-proxy` to listen on</li><li>y: port or [_service name_](../src/common/constants.go#L55) of target container</li></ul> |
| `no_tls_verify` | whether skip tls verify **(https only)** | `false` | boolean |
| `path_patterns` | proxy path patterns **(http/s only)**<br> only requests that matched a pattern will be proxied | empty **(proxy all requests)** | yaml style list[<sup>1</sup>](#list-example) of path patterns ([syntax](https://pkg.go.dev/net/http#hdr-Patterns-ServeMux)) |
| `set_headers` | header to set **(http/s only)** | empty | yaml style key-value mapping[<sup>2</sup>](#key-value-mapping-example) of header-value pairs |
| `hide_headers` | header to hide **(http/s only)** | empty | yaml style list[<sup>1</sup>](#list-example) of headers |
_i.e. `proxy.nginx.scheme: http`_
[🔼Back to top](#table-of-content)
- `scheme`: proxy protocol
- default:
- if `port` is like `x:y`: `tcp`
- if `port` is a number: `http`
- allowed: `http`, `https`, `tcp`, `udp`
- `host`: proxy host
- default: `container_name`
- allowed: IP address, hostname
- `port`: proxy port
- default: first port in `ports:`
- `http(s)`: number in range og `0 - 65535`
- `tcp`, `udp`: `x:y`
- `x`: port for `go-proxy` to listen on
- `y`: port, or _service name_ of target container
see [constants.go:14 for _service names_](../src/common/constants.go#L74)
- `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
#### Key-value mapping example
- default: empty
- allowed: empty, `forward`
Docker Compose
- `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
1. apps.y.z/webdav -> webdav:80/webdav
2. apps.y.z./webdav/path/to/file -> webdav:80/webdav/path/to/file
```yaml
services:
nginx:
...
labels:
# values from duplicated header keys will be combined
proxy.nginx.set_headers: | # remember to add the '|'
X-Custom-Header1: value1, value2
X-Custom-Header2: value3
X-Custom-Header2: value4
# X-Custom-Header2 will be "value3, value4"
```
- `set_headers`: a list of header to set, (key:value, one by line)
File Provider
Duplicated keys will be treated as multiple-value headers
```yaml
service_a:
host: service_a.internal
set_headers:
# do not duplicate header keys, as it is not allowed in YAML
X-Custom-Header1: value1, value2
X-Custom-Header2: value3
```
```yaml
labels:
proxy.app.set_headers: |
X-Custom-Header1: value1
X-Custom-Header1: value2
X-Custom-Header2: value2
```
[🔼Back to top](#table-of-content)
- `hide_headers`: comma seperated list of headers to hide
#### List example
- `load_balance`: enable load balance
- allowed: `1`, `true`
Docker Compose
```yaml
services:
nginx:
...
labels:
proxy.nginx.path_patterns: | # remember to add the '|'
- GET /
- POST /auth
proxy.nginx.hide_headers: | # remember to add the '|'
- X-Custom-Header1
- X-Custom-Header2
```
File Provider
```yaml
service_a:
host: service_a.internal
path_patterns:
- GET /
- POST /auth
hide_headers:
- X-Custom-Header1
- X-Custom-Header2
```
[🔼Back to top](#table-of-content)
## Troubleshooting
- Container not showing up in proxies list
Please check that either `ports` or label `proxy.<alias>.port` is declared, e.g.
```yaml
services:
nginx-1: # Option 1
...
ports:
- 80
nginx-2: # Option 2
...
container_name: nginx-2
network_mode: host
labels:
proxy.nginx-2.port: 80
```
- Firewall issues
If you are using `ufw` with vpn that drop all inbound traffic except vpn, run below:
@@ -146,7 +215,7 @@ _i.e. `proxy.nginx.scheme: http`_
## Docker compose examples
### Local docker provider in bridge network
More examples in [here](examples/)
```yaml
volumes:
@@ -161,21 +230,26 @@ services:
restart: unless-stopped
labels:
- proxy.aliases=adg,adg-dns,adg-setup
- proxy.adg.port=80
- proxy.adg-setup.port=3000
- proxy.adg-dns.scheme=udp
- proxy.adg-dns.port=20000:dns
- proxy.$1.port=80
- proxy.$2.scheme=udp
- proxy.$2.port=20000:dns
- proxy.$3.port=3000
volumes:
- adg-work:/opt/adguardhome/work
- adg-conf:/opt/adguardhome/conf
ports:
- 80
- 3000
- 53/udp
mc:
image: itzg/minecraft-server
tty: true
stdin_open: true
container_name: mc
restart: unless-stopped
ports:
- 25565
labels:
- proxy.mc.scheme=tcp
- proxy.mc.port=20001:25565
environment:
- EULA=TRUE
@@ -186,11 +260,14 @@ services:
restart: unless-stopped
container_name: pal
stop_grace_period: 30s
ports:
- 8211/udp
- 27015/udp
labels:
- proxy.aliases=pal1,pal2
- proxy.*.scheme=udp
- proxy.pal1.port=20002:8211
- proxy.pal2.port=20003:27015
- proxy.$1.port=20002:8211
- proxy.$2.port=20003:27015
environment: ...
volumes:
- palworld:/palworld
@@ -199,6 +276,10 @@ services:
container_name: nginx
volumes:
- nginx:/usr/share/nginx/html
ports:
- 80
labels:
proxy.idle_timeout: 1m
go-proxy:
image: ghcr.io/yusing/go-proxy:latest
container_name: go-proxy
@@ -206,46 +287,29 @@ services:
network_mode: host
volumes:
- ./config:/app/config
- /var/run/docker.sock:/var/run/docker.sock:ro
- /var/run/docker.sock:/var/run/docker.sock
go-proxy-frontend:
image: ghcr.io/yusing/go-proxy-frontend:latest
container_name: go-proxy-frontend
restart: unless-stopped
network_mode: host
labels:
- proxy.*.aliases=gp
- proxy.aliases=gp
- proxy.gp.port=3000
depends_on:
- go-proxy
```
[🔼Back to top](#table-of-content)
#### Proxy setup
```yaml
go-proxy:
image: ghcr.io/yusing/go-proxy
container_name: go-proxy
restart: always
network_mode: host
volumes:
- ./config:/app/config
- /var/run/docker.sock:/var/run/docker.sock:ro
labels:
- proxy.aliases=gp
- proxy.gp.port=8080
```
[🔼Back to top](#table-of-content)
### Services URLs for above examples
- `gp.yourdomain.com`: go-proxy web panel
- `adg-setup.yourdomain.com`: adguard setup (first time setup)
- `adg.yourdomain.com`: adguard dashboard
- `nginx.yourdomain.com`: nginx
- `yourdomain.com:53`: adguard dns
- `yourdomain.com:25565`: minecraft server
- `yourdomain.com:8211`: palworld server
- `yourdomain.com:2000`: adguard dns (udp)
- `yourdomain.com:20001`: minecraft server
- `yourdomain.com:20002`: palworld server
[🔼Back to top](#table-of-content)

View File

@@ -0,0 +1,40 @@
## Docker Socket Proxy
For docker client on other machine, set this up, then add `name: tcp://<machine_ip>:2375` to `config.yml` under `docker` section
```yml
# compose.yml on remote machine (e.g. server1)
docker-proxy:
container_name: docker-proxy
image: tecnativa/docker-socket-proxy
privileged: true
environment:
- ALLOW_START=1
- ALLOW_STOP=1
- ALLOW_RESTARTS=1
- CONTAINERS=1
- EVENTS=1
- PING=1
- POST=1
- VERSION=1
volumes:
- /var/run/docker.sock:/var/run/docker.sock
restart: always
ports:
- 2375:2375
# or more secure
- <machine_ip>:2375:2375
```
```yml
# config.yml on go-proxy machine
autocert:
... # your config
providers:
include:
...
docker:
...
server1: tcp://<machine_ip>:2375
```

16
examples/microbin.yml Normal file
View File

@@ -0,0 +1,16 @@
services:
app:
container_name: microbin
cpu_shares: 10
deploy:
resources:
limits:
memory: 256M
env_file: .env
image: docker.i.sh/danielszabo99/microbin:latest
ports:
- 8080
restart: unless-stopped
volumes:
- ./data:/app/microbin_data
# microbin.domain.tld

16
examples/siyuan.yml Normal file
View File

@@ -0,0 +1,16 @@
services:
main:
image: b3log/siyuan:v3.1.0
container_name: siyuan
command:
- --workspace=/siyuan/workspace/
- --accessAuthCode=<some password>
user: 1000:1000
volumes:
- ./workspace:/siyuan/workspace
restart: unless-stopped
environment:
- TZ=Asia/Hong_Kong
ports:
- 6806
# siyuan.domain.tld

View File

@@ -1,3 +1,5 @@
go 1.22
go 1.22.0
toolchain go1.23.1
use ./src

View File

@@ -1,25 +1,20 @@
example: # matching `app.y.z`
# optional, defaults to http
scheme: http
# required, proxy target
scheme: https
host: 10.0.0.1
# optional, defaults to 80 for http, 443 for https
port: "80"
# optional, defaults to empty
path:
# optional (scheme=https only)
# no_tls_verify: false
# optional headers to set / override (http(s) only)
port: 80
path_patterns: # Check https://pkg.go.dev/net/http#hdr-Patterns-ServeMux for syntax
- GET / # accept any GET request
- POST /auth # for /auth and /auth/* accept only POST
- GET /home/{$}
- /b/{bucket}/o/{any}
no_tls_verify: false
set_headers:
HEADER_A:
- VALUE_1
- VALUE_2
HEADER_B: [VALUE_3]
# optional headers to hide (http(s) only)
HEADER_A: VALUE_A, VALUE_B
HEADER_B: VALUE_C
hide_headers:
- HEADER_C
- HEADER_D
app1: # matching `app1.y.z` -> http://some_host
app1:
host: some_host
app2:
scheme: tcp

View File

@@ -23,23 +23,21 @@
},
"cert_path": {
"title": "path of cert file to load/store",
"description": "default: certs/cert.crt",
"default": "certs/cert.crt",
"markdownDescription": "default: `certs/cert.crt`",
"type": "string"
},
"key_path": {
"title": "path of key file to load/store",
"description": "default: certs/priv.key",
"default": "certs/priv.key",
"markdownDescription": "default: `certs/priv.key`",
"type": "string"
},
"provider": {
"title": "DNS Challenge Provider",
"default": "local",
"type": "string",
"enum": [
"local",
"cloudflare",
"clouddns",
"duckdns"
]
"enum": ["local", "cloudflare", "clouddns", "duckdns", "ovh"]
},
"options": {
"title": "Provider specific options",
@@ -49,20 +47,16 @@
"allOf": [
{
"if": {
"properties": {
"provider": {
"not": true,
"const": "local"
"not": {
"properties": {
"provider": {
"const": "local"
}
}
}
},
"then": {
"required": [
"email",
"domains",
"provider",
"options"
]
"required": ["email", "domains", "provider", "options"]
}
},
{
@@ -76,9 +70,7 @@
"then": {
"properties": {
"options": {
"required": [
"auth_token"
],
"required": ["auth_token"],
"additionalProperties": false,
"properties": {
"auth_token": {
@@ -101,11 +93,7 @@
"then": {
"properties": {
"options": {
"required": [
"client_id",
"email",
"password"
],
"required": ["client_id", "email", "password"],
"additionalProperties": false,
"properties": {
"client_id": {
@@ -136,9 +124,7 @@
"then": {
"properties": {
"options": {
"required": [
"token"
],
"required": ["token"],
"additionalProperties": false,
"properties": {
"token": {
@@ -149,79 +135,136 @@
}
}
}
},
{
"if": {
"properties": {
"provider": {
"const": "ovh"
}
}
},
"then": {
"properties": {
"options": {
"required": ["application_secret", "consumer_key"],
"additionalProperties": false,
"oneOf": [
{
"required": ["application_key"]
},
{
"required": ["oauth2_config"]
}
],
"properties": {
"api_endpoint": {
"description": "OVH API endpoint",
"default": "ovh-eu",
"anyOf": [
{
"enum": [
"ovh-eu",
"ovh-ca",
"ovh-us",
"kimsufi-eu",
"kimsufi-ca",
"soyoustart-eu",
"soyoustart-ca"
]
},
{
"type": "string",
"format": "uri"
}
]
},
"application_secret": {
"description": "OVH Application Secret",
"type": "string"
},
"consumer_key": {
"description": "OVH Consumer Key",
"type": "string"
},
"application_key": {
"description": "OVH Application Key",
"type": "string"
},
"oauth2_config": {
"description": "OVH OAuth2 config",
"type": "object",
"additionalProperties": false,
"properties": {
"client_id": {
"description": "OVH Client ID",
"type": "string"
},
"client_secret": {
"description": "OVH Client Secret",
"type": "string"
}
},
"required": ["client_id", "client_secret"]
}
}
}
}
}
}
]
},
"providers": {
"title": "Proxy providers configuration",
"type": "object",
"patternProperties": {
"^[a-zA-Z0-9_-]+$": {
"description": "Proxy provider",
"additionalProperties": false,
"properties": {
"include": {
"title": "Proxy providers configuration files",
"description": "relative path to 'config'",
"type": "array",
"items": {
"type": "string",
"pattern": "^[a-zA-Z0-9_-]+\\.(yml|yaml)$",
"patternErrorMessage": "Invalid file name"
}
},
"docker": {
"title": "Docker provider configuration",
"description": "docker clients (name-address pairs)",
"type": "object",
"properties": {
"kind": {
"description": "Proxy provider kind",
"patternProperties": {
"^[a-zA-Z0-9-_]+$": {
"type": "string",
"enum": [
"docker",
"file"
"examples": [
"unix:///var/run/docker.sock",
"tcp://127.0.0.1:2375",
"ssh://user@host:port"
],
"oneOf": [
{
"const": "$DOCKER_HOST",
"description": "Use DOCKER_HOST environment variable"
},
{
"pattern": "^unix://.+$",
"description": "A Unix socket for local Docker communication."
},
{
"pattern": "^ssh://.+$",
"description": "An SSH connection to a remote Docker host."
},
{
"pattern": "^fd://.+$",
"description": "A file descriptor for Docker communication."
},
{
"pattern": "^tcp://.+$",
"description": "A TCP connection to a remote Docker host."
}
]
},
"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"
}
}
}
}
]
}
}
}
},
@@ -231,12 +274,10 @@
"minimum": 0
},
"redirect_to_https": {
"title": "Redirect to HTTPS",
"title": "Redirect to HTTPS on HTTP requests",
"type": "boolean"
}
},
"additionalProperties": false,
"required": [
"providers"
]
}
"required": ["providers"]
}

View File

@@ -1,7 +1,7 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "go-proxy providers file",
"anyOf": [
"oneOf": [
{
"type": "object"
},
@@ -16,7 +16,7 @@
"properties": {
"scheme": {
"title": "Proxy scheme (http, https, tcp, udp)",
"anyOf": [
"oneOf": [
{
"type": "string",
"enum": [
@@ -32,12 +32,17 @@
},
{
"type": "null",
"description": "Auto detect base on port number"
"description": "Auto detect base on port format"
}
]
},
"host": {
"anyOf": [
"default": "localhost",
"oneOf": [
{
"type": "null",
"description": "localhost (default)"
},
{
"type": "string",
"format": "ipv4",
@@ -56,64 +61,38 @@
],
"title": "Proxy host (ipv4 / ipv6 / hostname)"
},
"port": {
"title": "Proxy port"
},
"path": {
"title": "Proxy path pattern (See https://pkg.go.dev/net/http#ServeMux)"
},
"no_tls_verify": {
"description": "Disable TLS verification for https proxy",
"type": "boolean"
},
"port": {},
"no_tls_verify": {},
"path_patterns": {},
"set_headers": {},
"hide_headers": {}
},
"required": [
"host"
],
"additionalProperties": false,
"allOf": [
{
"if": {
"anyOf": [
{
"properties": {
"scheme": {
"enum": [
"http",
"https"
]
}
}
},
{
"properties": {
"scheme": {
"not": true
}
}
},
{
"properties": {
"scheme": {
"properties": {
"scheme": {
"anyOf": [
{
"enum": ["http", "https"]
},
{
"type": "null"
}
}
]
}
]
}
},
"then": {
"properties": {
"port": {
"anyOf": [
"markdownDescription": "Proxy port from **1** to **65535**",
"oneOf": [
{
"type": "string",
"pattern": "^[0-9]{1,5}$",
"minimum": 1,
"maximum": 65535,
"markdownDescription": "Proxy port from **1** to **65535**",
"patternErrorMessage": "'port' must be a number"
"pattern": "^\\d{1,5}$",
"patternErrorMessage": "`port` must be a number"
},
{
"type": "integer",
@@ -122,11 +101,16 @@
}
]
},
"path": {
"anyOf": [
"path_patterns": {
"oneOf": [
{
"type": "string",
"description": "Proxy path"
"type": "array",
"markdownDescription": "A list of [path patterns](https://pkg.go.dev/net/http#hdr-Patterns-ServeMux)",
"items": {
"type": "string",
"pattern": "^((GET|POST|DELETE|PUT|PATCH|HEAD|OPTIONS|CONNECT)\\s)?(/(\\w*|{\\w*}|{\\$}))+/?$",
"patternErrorMessage": "invalid path pattern"
}
},
{
"type": "null",
@@ -138,9 +122,11 @@
"type": "object",
"description": "Proxy headers to set",
"additionalProperties": {
"type": "array",
"items": {
"type": "string"
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
},
@@ -156,12 +142,15 @@
"else": {
"properties": {
"port": {
"markdownDescription": "`listening port`:`proxy port | service name`",
"markdownDescription": "`listening port:proxy port` or `listening port:service name`",
"type": "string",
"pattern": "^[0-9]+\\:[0-9a-z]+$",
"patternErrorMessage": "'port' must be in the format of '<listening port>:<proxy port | service name>'"
"patternErrorMessage": "invalid syntax"
},
"path": {
"no_tls_verify": {
"not": true
},
"path_patterns": {
"not": true
},
"set_headers": {
@@ -171,22 +160,27 @@
"not": true
}
},
"required": [
"port"
]
"required": ["port"]
}
},
{
"if": {
"not": {
"properties": {
"scheme": {
"const": "https"
}
"properties": {
"scheme": {
"const": "https"
}
}
},
"then": {
"properties": {
"no_tls_verify": {
"description": "Disable TLS verification for https proxy",
"type": "boolean",
"default": false
}
}
},
"else": {
"properties": {
"no_tls_verify": {
"not": true
@@ -198,4 +192,4 @@
}
},
"additionalProperties": false
}
}

View File

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

View File

@@ -2,7 +2,7 @@
set -e
if [ -z "$BRANCH" ]; then
BRANCH="main"
BRANCH="v0.5"
fi
BASE_URL="https://github.com/yusing/go-proxy/raw/${BRANCH}"
mkdir -p go-proxy

View File

@@ -3,6 +3,7 @@ package v1
import (
"fmt"
"net/http"
"strings"
U "github.com/yusing/go-proxy/api/v1/utils"
"github.com/yusing/go-proxy/config"
@@ -17,27 +18,19 @@ func CheckHealth(cfg *config.Config, w http.ResponseWriter, r *http.Request) {
}
var ok bool
route := cfg.FindRoute(target)
switch route := cfg.FindRoute(target).(type) {
case nil:
switch {
case route == nil:
U.HandleErr(w, r, U.ErrNotFound("target", target), http.StatusNotFound)
return
case *R.HTTPRoute:
path := r.FormValue("path")
if path == "" {
U.HandleErr(w, r, U.ErrMissingKey("path"), http.StatusBadRequest)
return
}
sr, hasSr := route.GetSubroute(path)
if !hasSr {
U.HandleErr(w, r, U.ErrNotFound("path", path), http.StatusNotFound)
return
}
ok = U.IsSiteHealthy(sr.TargetURL.String())
case *R.StreamRoute:
case route.Type() == R.RouteTypeReverseProxy:
ok = U.IsSiteHealthy(route.URL().String())
case route.Type() == R.RouteTypeStream:
entry := route.Entry()
ok = U.IsStreamHealthy(
route.Scheme.ProxyScheme.String(),
fmt.Sprintf("%s:%v", route.Host, route.Port.ProxyPort),
strings.Split(entry.Scheme, ":")[1], // target scheme
fmt.Sprintf("%s:%v", entry.Host, strings.Split(entry.Port, ":")[1]),
)
}

View File

@@ -9,7 +9,6 @@ import (
U "github.com/yusing/go-proxy/api/v1/utils"
"github.com/yusing/go-proxy/common"
"github.com/yusing/go-proxy/config"
E "github.com/yusing/go-proxy/error"
"github.com/yusing/go-proxy/proxy/provider"
)
@@ -32,25 +31,25 @@ func SetFileContent(w http.ResponseWriter, r *http.Request) {
U.HandleErr(w, r, U.ErrMissingKey("filename"), http.StatusBadRequest)
return
}
content, err := E.Check(io.ReadAll(r.Body))
if err.IsNotNil() {
content, err := io.ReadAll(r.Body)
if err != nil {
U.HandleErr(w, r, err)
return
}
if filename == common.ConfigFileName {
err = config.Validate(content)
err = config.Validate(content).Error()
} else {
err = provider.Validate(content)
err = provider.Validate(content).Error()
}
if err.IsNotNil() {
if err != nil {
U.HandleErr(w, r, err, http.StatusBadRequest)
return
}
err = E.From(os.WriteFile(path.Join(common.ConfigBasePath, filename), content, 0644))
if err.IsNotNil() {
err = os.WriteFile(path.Join(common.ConfigBasePath, filename), content, 0644)
if err != nil {
U.HandleErr(w, r, err)
return
}

View File

@@ -29,10 +29,10 @@ func List(cfg *config.Config, w http.ResponseWriter, r *http.Request) {
func listRoutes(cfg *config.Config, w http.ResponseWriter, r *http.Request) {
routes := cfg.RoutesByAlias()
type_filter := r.FormValue("type")
if type_filter != "" {
typeFilter := r.FormValue("type")
if typeFilter != "" {
for k, v := range routes {
if v["type"] != type_filter {
if v["type"] != typeFilter {
delete(routes, k)
}
}

View File

@@ -8,7 +8,7 @@ import (
)
func Reload(cfg *config.Config, w http.ResponseWriter, r *http.Request) {
if err := cfg.Reload(); err.IsNotNil() {
if err := cfg.Reload().Error(); err != nil {
U.HandleErr(w, r, err)
return
}

View File

@@ -10,7 +10,7 @@ import (
)
func Stats(cfg *config.Config, w http.ResponseWriter, r *http.Request) {
stats := map[string]interface{}{
stats := map[string]any{
"proxies": cfg.Statistics(),
"uptime": utils.FormatDuration(server.GetProxyServer().Uptime()),
}

View File

@@ -9,14 +9,14 @@ import (
E "github.com/yusing/go-proxy/error"
)
func HandleErr(w http.ResponseWriter, r *http.Request, err error, code ...int) {
err = E.From(err).Subjectf("%s %s", r.Method, r.URL)
logrus.WithField("?", "api").Error(err)
func HandleErr(w http.ResponseWriter, r *http.Request, origErr error, code ...int) {
err := E.From(origErr).Subjectf("%s %s", r.Method, r.URL)
logrus.WithField("module", "api").Error(err)
if len(code) > 0 {
http.Error(w, err.Error(), code[0])
http.Error(w, err.String(), code[0])
return
}
http.Error(w, err.Error(), http.StatusInternalServerError)
http.Error(w, err.String(), http.StatusInternalServerError)
}
func ErrMissingKey(k string) error {

View File

@@ -36,7 +36,7 @@ func IsStreamHealthy(scheme, address string) bool {
}
func ReloadServer() E.NestedError {
resp, err := HttpClient.Post(fmt.Sprintf("http://localhost%v/reload", common.APIHTTPPort), "", nil)
resp, err := HttpClient.Post(fmt.Sprintf("http://localhost%v/reload", common.APIHTTPAddr), "", nil)
if err != nil {
return E.From(err)
}
@@ -44,7 +44,7 @@ func ReloadServer() E.NestedError {
if resp.StatusCode != http.StatusOK {
return E.Failure("server reload").Subjectf("status code: %v", resp.StatusCode)
}
return E.Nil()
return nil
}
var HttpClient = &http.Client{

View File

@@ -20,59 +20,56 @@ func NewConfig(cfg *M.AutoCertConfig) *Config {
if cfg.KeyPath == "" {
cfg.KeyPath = KeyFileDefault
}
if cfg.Provider == "" {
cfg.Provider = ProviderLocal
}
return (*Config)(cfg)
}
func (cfg *Config) GetProvider() (*Provider, E.NestedError) {
errors := E.NewBuilder("cannot create autocert provider")
func (cfg *Config) GetProvider() (provider *Provider, res E.NestedError) {
b := E.NewBuilder("unable to initialize autocert")
defer b.To(&res)
if cfg.Provider != ProviderLocal {
if len(cfg.Domains) == 0 {
errors.Addf("no domains specified")
b.Addf("no domains specified")
}
if cfg.Provider == "" {
errors.Addf("no provider specified")
b.Addf("no provider specified")
}
if cfg.Email == "" {
errors.Addf("no email specified")
b.Addf("no email specified")
}
// check if provider is implemented
_, ok := providersGenMap[cfg.Provider]
if !ok {
b.Addf("unknown provider: %q", cfg.Provider)
}
}
gen, ok := providersGenMap[cfg.Provider]
if !ok {
errors.Addf("unknown provider: %q", cfg.Provider)
}
if err := errors.Build(); err.IsNotNil() {
return nil, err
if b.HasError() {
return
}
privKey, err := E.Check(ecdsa.GenerateKey(elliptic.P256(), rand.Reader))
if err.IsNotNil() {
return nil, E.Failure("generate private key").With(err)
if err.HasError() {
b.Add(E.FailWith("generate private key", err))
return
}
user := &User{
Email: cfg.Email,
key: privKey,
}
legoCfg := lego.NewConfig(user)
legoCfg.Certificate.KeyType = certcrypto.RSA2048
legoClient, err := E.Check(lego.NewClient(legoCfg))
if err.IsNotNil() {
return nil, E.Failure("create lego client").With(err)
}
base := &Provider{
provider = &Provider{
cfg: cfg,
user: user,
legoCfg: legoCfg,
client: legoClient,
}
legoProvider, err := E.Check(gen(cfg.Options))
if err.IsNotNil() {
return nil, E.Failure("create lego provider").With(err)
}
err = E.From(legoClient.Challenge.SetDNS01Provider(legoProvider))
if err.IsNotNil() {
return nil, E.Failure("set challenge provider").With(err)
}
return base, E.Nil()
return
}

View File

@@ -1,16 +1,20 @@
package autocert
import (
"errors"
"github.com/go-acme/lego/v4/providers/dns/clouddns"
"github.com/go-acme/lego/v4/providers/dns/cloudflare"
"github.com/go-acme/lego/v4/providers/dns/duckdns"
"github.com/go-acme/lego/v4/providers/dns/ovh"
"github.com/sirupsen/logrus"
)
const (
certBasePath = "certs/"
CertFileDefault = certBasePath + "cert.crt"
KeyFileDefault = certBasePath + "priv.key"
certBasePath = "certs/"
CertFileDefault = certBasePath + "cert.crt"
KeyFileDefault = certBasePath + "priv.key"
RegistrationFile = certBasePath + "registration.json"
)
const (
@@ -18,14 +22,19 @@ const (
ProviderCloudflare = "cloudflare"
ProviderClouddns = "clouddns"
ProviderDuckdns = "duckdns"
ProviderOVH = "ovh"
)
var providersGenMap = map[string]ProviderGenerator{
"": providerGenerator(NewDummyDefaultConfig, NewDummyDNSProviderConfig),
ProviderLocal: providerGenerator(NewDummyDefaultConfig, NewDummyDNSProviderConfig),
ProviderCloudflare: providerGenerator(cloudflare.NewDefaultConfig, cloudflare.NewDNSProviderConfig),
ProviderClouddns: providerGenerator(clouddns.NewDefaultConfig, clouddns.NewDNSProviderConfig),
ProviderDuckdns: providerGenerator(duckdns.NewDefaultConfig, duckdns.NewDNSProviderConfig),
ProviderOVH: providerGenerator(ovh.NewDefaultConfig, ovh.NewDNSProviderConfig),
}
var Logger = logrus.WithField("?", "autocert")
var (
ErrGetCertFailure = errors.New("get certificate failed")
)
var logger = logrus.WithField("module", "autocert")

View File

@@ -5,18 +5,17 @@ import (
"crypto/tls"
"crypto/x509"
"os"
"slices"
"sync"
"reflect"
"sort"
"time"
"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/registration"
"github.com/sirupsen/logrus"
E "github.com/yusing/go-proxy/error"
M "github.com/yusing/go-proxy/models"
"github.com/yusing/go-proxy/utils"
U "github.com/yusing/go-proxy/utils"
)
type Provider struct {
@@ -27,15 +26,14 @@ type Provider struct {
tlsCert *tls.Certificate
certExpiries CertExpiries
mutex sync.Mutex
}
type ProviderGenerator func(M.AutocertProviderOpt) (challenge.Provider, error)
type ProviderGenerator func(M.AutocertProviderOpt) (challenge.Provider, E.NestedError)
type CertExpiries map[string]time.Time
func (p *Provider) GetCert(_ *tls.ClientHelloInfo) (*tls.Certificate, error) {
if p.tlsCert == nil {
return nil, E.Failure("get certificate")
return nil, ErrGetCertFailure
}
return p.tlsCert, nil
}
@@ -56,60 +54,81 @@ func (p *Provider) GetExpiries() CertExpiries {
return p.certExpiries
}
func (p *Provider) ObtainCert() E.NestedError {
ne := E.Failure("obtain certificate")
func (p *Provider) ObtainCert() (res E.NestedError) {
b := E.NewBuilder("failed to obtain certificate")
defer b.To(&res)
if p.cfg.Provider == ProviderLocal {
b.Addf("provider is set to %q", ProviderLocal).WithSeverity(E.SeverityWarning)
return
}
if p.client == nil {
if err := p.initClient(); err.HasError() {
b.Add(E.FailWith("init autocert client", err))
return
}
}
if p.user.Registration == nil {
if err := p.loadRegistration(); err.HasError() {
if err := p.registerACME(); err.HasError() {
b.Add(E.FailWith("register ACME", err))
return
}
}
}
client := p.client
if p.user.Registration == nil {
reg, err := E.Check(client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true}))
if err.IsNotNil() {
return ne.With(E.Failure("register account").With(err))
}
p.user.Registration = reg
}
req := certificate.ObtainRequest{
Domains: p.cfg.Domains,
Bundle: true,
}
cert, err := E.Check(client.Certificate.Obtain(req))
if err.IsNotNil() {
return ne.With(err)
if err.HasError() {
b.Add(err)
return
}
err = p.saveCert(cert)
if err.IsNotNil() {
return ne.With(E.Failure("save certificate").With(err))
if err.HasError() {
b.Add(E.FailWith("save certificate", err))
return
}
tlsCert, err := E.Check(tls.X509KeyPair(cert.Certificate, cert.PrivateKey))
if err.IsNotNil() {
return ne.With(E.Failure("parse obtained certificate").With(err))
if err.HasError() {
b.Add(E.FailWith("parse obtained certificate", err))
return
}
expiries, err := getCertExpiries(&tlsCert)
if err.IsNotNil() {
return ne.With(E.Failure("get certificate expiry").With(err))
if err.HasError() {
b.Add(E.FailWith("get certificate expiry", err))
return
}
p.tlsCert = &tlsCert
p.certExpiries = expiries
return E.Nil()
return nil
}
func (p *Provider) LoadCert() E.NestedError {
cert, err := E.Check(tls.LoadX509KeyPair(p.cfg.CertPath, p.cfg.KeyPath))
if err.IsNotNil() {
if err.HasError() {
return err
}
expiries, err := getCertExpiries(&cert)
if err.IsNotNil() {
if err.HasError() {
return err
}
p.tlsCert = &cert
p.certExpiries = expiries
p.renewIfNeeded()
return E.Nil()
logger.Infof("next renewal in %v", U.FormatDuration(time.Until(p.ShouldRenewOn())))
return p.renewIfNeeded()
}
func (p *Provider) ShouldRenewOn() time.Time {
for _, expiry := range p.certExpiries {
return expiry.AddDate(0, -1, 0)
return expiry.AddDate(0, -1, 0) // 1 month before
}
// this line should never be reached
panic("no certificate available")
@@ -120,139 +139,161 @@ func (p *Provider) ScheduleRenewal(ctx context.Context) {
return
}
logger.Debug("starting renewal scheduler")
logger.Debug("started renewal scheduler")
defer logger.Debug("renewal scheduler stopped")
stop := make(chan struct{})
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
default:
t := time.Until(p.ShouldRenewOn())
Logger.Infof("next renewal in %v", t.Round(time.Second))
go func() {
<-time.After(t)
close(stop)
}()
select {
case <-ctx.Done():
return
case <-stop:
if err := p.renewIfNeeded(); err.IsNotNil() {
Logger.Fatal(err)
}
case <-ticker.C: // check every 5 seconds
if err := p.renewIfNeeded(); err.HasError() {
logger.Warn(err)
}
}
}
}
func (p *Provider) saveCert(cert *certificate.Resource) E.NestedError {
err := os.WriteFile(p.cfg.KeyPath, cert.PrivateKey, 0600) // -rw-------
if err != nil {
return E.Failure("write key file").With(err)
func (p *Provider) initClient() E.NestedError {
legoClient, err := E.Check(lego.NewClient(p.legoCfg))
if err.HasError() {
return E.FailWith("create lego client", err)
}
err = os.WriteFile(p.cfg.CertPath, cert.Certificate, 0644) // -rw-r--r--
if err != nil {
return E.Failure("write cert file").With(err)
legoProvider, err := providersGenMap[p.cfg.Provider](p.cfg.Options)
if err.HasError() {
return E.FailWith("create lego provider", err)
}
return E.Nil()
err = E.From(legoClient.Challenge.SetDNS01Provider(legoProvider))
if err.HasError() {
return E.FailWith("set challenge provider", err)
}
p.client = legoClient
return nil
}
func (p *Provider) needRenewal() bool {
expired := time.Now().After(p.ShouldRenewOn())
if expired {
return true
func (p *Provider) registerACME() E.NestedError {
if p.user.Registration != nil {
return nil
}
if len(p.cfg.Domains) != len(p.certExpiries) {
return true
reg, err := E.Check(p.client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true}))
if err.HasError() {
return err
}
wantedDomains := make([]string, len(p.cfg.Domains))
p.user.Registration = reg
if err := p.saveRegistration(); err.HasError() {
logger.Warn(err)
}
return nil
}
func (p *Provider) loadRegistration() E.NestedError {
if p.user.Registration != nil {
return nil
}
reg := &registration.Resource{}
err := U.LoadJson(RegistrationFile, reg)
if err.HasError() {
return E.FailWith("parse registration file", err)
}
p.user.Registration = reg
return nil
}
func (p *Provider) saveRegistration() E.NestedError {
return U.SaveJson(RegistrationFile, p.user.Registration, 0o600)
}
func (p *Provider) saveCert(cert *certificate.Resource) E.NestedError {
err := os.WriteFile(p.cfg.KeyPath, cert.PrivateKey, 0o600) // -rw-------
if err != nil {
return E.FailWith("write key file", err)
}
err = os.WriteFile(p.cfg.CertPath, cert.Certificate, 0o644) // -rw-r--r--
if err != nil {
return E.FailWith("write cert file", err)
}
return nil
}
func (p *Provider) certState() CertState {
if time.Now().After(p.ShouldRenewOn()) {
return CertStateExpired
}
certDomains := make([]string, len(p.certExpiries))
copy(wantedDomains, p.cfg.Domains)
wantedDomains := make([]string, len(p.cfg.Domains))
i := 0
for domain := range p.certExpiries {
certDomains[i] = domain
i++
}
slices.Sort(wantedDomains)
slices.Sort(certDomains)
for i, domain := range certDomains {
if domain != wantedDomains[i] {
return true
}
copy(wantedDomains, p.cfg.Domains)
sort.Strings(wantedDomains)
sort.Strings(certDomains)
if !reflect.DeepEqual(certDomains, wantedDomains) {
logger.Debugf("cert domains mismatch: %v != %v", certDomains, p.cfg.Domains)
return CertStateMismatch
}
return false
return CertStateValid
}
func (p *Provider) renewIfNeeded() E.NestedError {
if !p.needRenewal() {
return E.Nil()
switch p.certState() {
case CertStateExpired:
logger.Info("certs expired, renewing")
case CertStateMismatch:
logger.Info("cert domains mismatch with config, renewing")
default:
return nil
}
p.mutex.Lock()
defer p.mutex.Unlock()
if !p.needRenewal() {
return E.Nil()
}
trials := 0
for {
err := p.ObtainCert()
if err.IsNotNil() {
return E.Nil()
}
trials++
if trials > 3 {
return E.Failure("renew certificate").With(err)
}
time.Sleep(5 * time.Second)
if err := p.ObtainCert(); err.HasError() {
return E.FailWith("renew certificate", err)
}
return nil
}
func getCertExpiries(cert *tls.Certificate) (CertExpiries, E.NestedError) {
r := make(CertExpiries, len(cert.Certificate))
for _, cert := range cert.Certificate {
x509Cert, err := E.Check(x509.ParseCertificate(cert))
if err.IsNotNil() {
return nil, E.Failure("parse certificate").With(err)
if err.HasError() {
return nil, E.FailWith("parse certificate", err)
}
if x509Cert.IsCA {
continue
}
r[x509Cert.Subject.CommonName] = x509Cert.NotAfter
}
return r, E.Nil()
}
func setOptions[T interface{}](cfg *T, opt M.AutocertProviderOpt) E.NestedError {
for k, v := range opt {
err := utils.SetFieldFromSnake(cfg, k, v)
if err.IsNotNil() {
return E.Failure("set autocert option").Subject(k).With(err)
for i := range x509Cert.DNSNames {
r[x509Cert.DNSNames[i]] = x509Cert.NotAfter
}
}
return E.Nil()
return r, nil
}
func providerGenerator[CT any, PT challenge.Provider](
defaultCfg func() *CT,
newProvider func(*CT) (PT, error),
) ProviderGenerator {
return func(opt M.AutocertProviderOpt) (challenge.Provider, error) {
return func(opt M.AutocertProviderOpt) (challenge.Provider, E.NestedError) {
cfg := defaultCfg()
err := setOptions(cfg, opt)
if err.IsNotNil() {
err := U.Deserialize(opt, cfg)
if err.HasError() {
return nil, err
}
p, err := E.Check(newProvider(cfg))
if err.IsNotNil() {
if err.HasError() {
return nil, err
}
return p, nil
}
}
var logger = logrus.WithField("?", "autocert")

View File

@@ -0,0 +1,50 @@
package provider_test
import (
"testing"
"github.com/go-acme/lego/v4/providers/dns/ovh"
U "github.com/yusing/go-proxy/utils"
. "github.com/yusing/go-proxy/utils/testing"
"gopkg.in/yaml.v3"
)
// type Config struct {
// APIEndpoint string
// ApplicationKey string
// ApplicationSecret string
// ConsumerKey string
// OAuth2Config *OAuth2Config
// PropagationTimeout time.Duration
// PollingInterval time.Duration
// TTL int
// HTTPClient *http.Client
// }
func TestOVH(t *testing.T) {
cfg := &ovh.Config{}
testYaml := `
api_endpoint: https://eu.api.ovh.com
application_key: <application_key>
application_secret: <application_secret>
consumer_key: <consumer_key>
oauth2_config:
client_id: <client_id>
client_secret: <client_secret>
`
cfgExpected := &ovh.Config{
APIEndpoint: "https://eu.api.ovh.com",
ApplicationKey: "<application_key>",
ApplicationSecret: "<application_secret>",
ConsumerKey: "<consumer_key>",
OAuth2Config: &ovh.OAuth2Config{ClientID: "<client_id>", ClientSecret: "<client_secret>"},
}
testYaml = testYaml[1:] // remove first \n
opt := make(map[string]any)
ExpectNoError(t, yaml.Unmarshal([]byte(testYaml), opt))
ExpectTrue(t, U.Deserialize(opt, cfg).NoError())
ExpectDeepEqual(t, cfg, cfgExpected)
}

29
src/autocert/setup.go Normal file
View File

@@ -0,0 +1,29 @@
package autocert
import (
"context"
"os"
E "github.com/yusing/go-proxy/error"
)
func (p *Provider) Setup(ctx context.Context) (err E.NestedError) {
if err = p.LoadCert(); err != nil {
if !err.Is(os.ErrNotExist) { // ignore if cert doesn't exist
return err
}
logger.Debug("obtaining cert due to error loading cert")
if err = p.ObtainCert(); err != nil {
return err.Warn()
}
}
go p.ScheduleRenewal(ctx)
for _, expiry := range p.GetExpiries() {
logger.Infof("certificate expire on %s", expiry)
break
}
return nil
}

9
src/autocert/state.go Normal file
View File

@@ -0,0 +1,9 @@
package autocert
type CertState int
const (
CertStateValid CertState = 0
CertStateExpired CertState = iota
CertStateMismatch CertState = iota
)

View File

@@ -12,27 +12,39 @@ type Args struct {
}
const (
CommandStart = ""
CommandValidate = "validate"
CommandReload = "reload"
CommandStart = ""
CommandValidate = "validate"
CommandListConfigs = "ls-config"
CommandListRoutes = "ls-routes"
CommandReload = "reload"
CommandDebugListEntries = "debug-ls-entries"
CommandDebugListProviders = "debug-ls-providers"
)
var ValidCommands = []string{CommandStart, CommandValidate, CommandReload}
var ValidCommands = []string{
CommandStart,
CommandValidate,
CommandListConfigs,
CommandListRoutes,
CommandReload,
CommandDebugListEntries,
CommandDebugListProviders,
}
func GetArgs() Args {
var args Args
flag.Parse()
args.Command = flag.Arg(0)
if err := validateArgs(args.Command, ValidCommands); err.IsNotNil() {
if err := validateArg(args.Command); err.HasError() {
logrus.Fatal(err)
}
return args
}
func validateArgs[T comparable](arg T, validArgs []T) E.NestedError {
for _, v := range validArgs {
func validateArg(arg string) E.NestedError {
for _, v := range ValidCommands {
if arg == v {
return E.Nil()
return nil
}
}
return E.Invalid("argument", arg)

View File

@@ -30,19 +30,12 @@ const (
)
const (
SchemaBasePath = "schema/"
ConfigSchemaPath = SchemaBasePath + "config.schema.json"
ProvidersSchemaPath = SchemaBasePath + "providers.schema.json"
SchemaBasePath = "schema/"
ConfigSchemaPath = SchemaBasePath + "config.schema.json"
FileProviderSchemaPath = SchemaBasePath + "providers.schema.json"
)
const DockerHostFromEnv = "FROM_ENV"
const (
ProxyHTTPPort = ":80"
ProxyHTTPSPort = ":443"
APIHTTPPort = ":8888"
PanelHTTPPort = ":8080"
)
const DockerHostFromEnv = "$DOCKER_HOST"
var WellKnownHTTPPorts = map[uint16]bool{
80: true,
@@ -53,17 +46,17 @@ var WellKnownHTTPPorts = map[uint16]bool{
}
var (
ImageNamePortMapTCP = map[string]int{
"postgres": 5432,
"mysql": 3306,
"mariadb": 3306,
"redis": 6379,
"mssql": 1433,
"memcached": 11211,
"rabbitmq": 5672,
"mongo": 27017,
}
ExtraNamePortMapTCP = map[string]int{
ServiceNamePortMapTCP = map[string]int{
"postgres": 5432,
"mysql": 3306,
"mariadb": 3306,
"redis": 6379,
"mssql": 1433,
"memcached": 11211,
"rabbitmq": 5672,
"mongo": 27017,
"minecraft-server": 25565,
"dns": 53,
"ssh": 22,
"ftp": 21,
@@ -71,20 +64,9 @@ var (
"pop3": 110,
"imap": 143,
}
NamePortMapTCP = func() map[string]int {
m := make(map[string]int)
for k, v := range ImageNamePortMapTCP {
m[k] = v
}
for k, v := range ExtraNamePortMapTCP {
m[k] = v
}
return m
}()
)
// docker library uses uint16, so followed here
var ImageNamePortMapHTTP = map[string]uint16{
var ImageNamePortMapHTTP = map[string]int{
"nginx": 80,
"httpd": 80,
"adguardhome": 3000,
@@ -101,3 +83,10 @@ var ImageNamePortMapHTTP = map[string]uint16{
"dockge": 5001,
"nginx-proxy-manager": 81,
}
const (
IdleTimeoutDefault = "0"
WakeTimeoutDefault = "10s"
StopTimeoutDefault = "10s"
StopMethodDefault = "stop"
)

View File

@@ -2,23 +2,26 @@ package common
import (
"os"
"strings"
"github.com/sirupsen/logrus"
U "github.com/yusing/go-proxy/utils"
)
var IsRunningAsService = getEnvBool("GOPROXY_IS_SYSTEMD")
var NoSchemaValidation = getEnvBool("GOPROXY_NO_SCHEMA_VALIDATION")
var IsDebug = getEnvBool("GOPROXY_DEBUG")
var LogLevel = func() logrus.Level {
if IsDebug {
logrus.SetLevel(logrus.DebugLevel)
}
return logrus.GetLevel()
}()
var (
NoSchemaValidation = getEnvBool("GOPROXY_NO_SCHEMA_VALIDATION")
IsDebug = getEnvBool("GOPROXY_DEBUG")
ProxyHTTPAddr = getEnv("GOPROXY_HTTP_ADDR", ":80")
ProxyHTTPSAddr = getEnv("GOPROXY_HTTPS_ADDR", ":443")
APIHTTPAddr = getEnv("GOPROXY_API_ADDR", "127.0.0.1:8888")
)
func getEnvBool(key string) bool {
v := os.Getenv(key)
return v == "1" || strings.ToLower(v) == "true" || strings.ToLower(v) == "yes" || strings.ToLower(v) == "on"
return U.ParseBool(os.Getenv(key))
}
func getEnv(key string, defaultValue string) string {
value, ok := os.LookupEnv(key)
if !ok {
value = defaultValue
}
return value
}

View File

@@ -2,6 +2,7 @@ package config
import (
"context"
"os"
"github.com/sirupsen/logrus"
"github.com/yusing/go-proxy/autocert"
@@ -13,35 +14,31 @@ import (
U "github.com/yusing/go-proxy/utils"
F "github.com/yusing/go-proxy/utils/functional"
W "github.com/yusing/go-proxy/watcher"
"github.com/yusing/go-proxy/watcher/events"
"gopkg.in/yaml.v3"
)
type Config struct {
value *M.Config
l logrus.FieldLogger
reader U.Reader
proxyProviders *F.Map[string, *PR.Provider]
value *M.Config
proxyProviders F.Map[string, *PR.Provider]
autocertProvider *autocert.Provider
l logrus.FieldLogger
watcher W.Watcher
watcherCtx context.Context
watcherCancel context.CancelFunc
reloadReq chan struct{}
}
func New() (*Config, E.NestedError) {
func Load() (*Config, E.NestedError) {
cfg := &Config{
l: logrus.WithField("?", "config"),
reader: U.NewFileReader(common.ConfigPath),
watcher: W.NewFileWatcher(common.ConfigFileName),
reloadReq: make(chan struct{}),
proxyProviders: F.NewMapOf[string, *PR.Provider](),
l: logrus.WithField("module", "config"),
watcher: W.NewFileWatcher(common.ConfigFileName),
reloadReq: make(chan struct{}, 1),
}
if err := cfg.load(); err.IsNotNil() {
return nil, err
}
cfg.watchChanges()
return cfg, E.Nil()
return cfg, cfg.load()
}
func Validate(data []byte) E.NestedError {
@@ -57,104 +54,27 @@ func (cfg *Config) GetAutoCertProvider() *autocert.Provider {
}
func (cfg *Config) Dispose() {
cfg.watcherCancel()
cfg.l.Debug("stopped watcher")
if cfg.watcherCancel != nil {
cfg.watcherCancel()
cfg.l.Debug("stopped watcher")
}
cfg.stopProviders()
cfg.l.Debug("stopped providers")
}
func (cfg *Config) Reload() E.NestedError {
cfg.stopProviders()
if err := cfg.load(); err.IsNotNil() {
if err := cfg.load(); err.HasError() {
return err
}
cfg.startProviders()
return E.Nil()
cfg.StartProxyProviders()
return nil
}
func (cfg *Config) FindRoute(alias string) R.Route {
r := cfg.proxyProviders.Find(
func(p *PR.Provider) (any, bool) {
rs := p.GetCurrentRoutes()
if rs.Contains(alias) {
return rs.Get(alias), true
}
return nil, false
},
)
if r == nil {
return nil
}
return r.(R.Route)
func (cfg *Config) StartProxyProviders() {
cfg.controlProviders("start", (*PR.Provider).StartAllRoutes)
}
func (cfg *Config) RoutesByAlias() map[string]U.SerializedObject {
routes := make(map[string]U.SerializedObject)
cfg.proxyProviders.Each(func(p *PR.Provider) {
prName := p.GetName()
p.GetCurrentRoutes().EachKV(func(a string, r R.Route) {
obj, err := U.Serialize(r)
if err != nil {
cfg.l.Error(err)
return
}
obj["provider"] = prName
switch r.(type) {
case *R.StreamRoute:
obj["type"] = "stream"
case *R.HTTPRoute:
obj["type"] = "reverse_proxy"
default:
panic("bug: should not reach here")
}
routes[a] = obj
})
})
return routes
}
func (cfg *Config) Statistics() map[string]interface{} {
nTotalStreams := 0
nTotalRPs := 0
providerStats := make(map[string]interface{})
cfg.proxyProviders.Each(func(p *PR.Provider) {
stats := make(map[string]interface{})
nStreams := 0
nRPs := 0
p.GetCurrentRoutes().EachKV(func(a string, r R.Route) {
switch r.(type) {
case *R.StreamRoute:
nStreams++
nTotalStreams++
case *R.HTTPRoute:
nRPs++
nTotalRPs++
default:
panic("bug: should not reach here")
}
})
stats["num_streams"] = nStreams
stats["num_reverse_proxies"] = nRPs
switch p.ProviderImpl.(type) {
case *PR.DockerProvider:
stats["type"] = "docker"
case *PR.FileProvider:
stats["type"] = "file"
default:
panic("bug: should not reach here")
}
providerStats[p.GetName()] = stats
})
return map[string]interface{}{
"num_total_streams": nTotalStreams,
"num_total_reverse_proxies": nTotalRPs,
"providers": providerStats,
}
}
func (cfg *Config) watchChanges() {
func (cfg *Config) WatchChanges() {
cfg.watcherCtx, cfg.watcherCancel = context.WithCancel(context.Background())
go func() {
for {
@@ -162,7 +82,7 @@ func (cfg *Config) watchChanges() {
case <-cfg.watcherCtx.Done():
return
case <-cfg.reloadReq:
if err := cfg.Reload(); err.IsNotNil() {
if err := cfg.Reload(); err.HasError() {
cfg.l.Error(err)
}
}
@@ -175,7 +95,7 @@ func (cfg *Config) watchChanges() {
case <-cfg.watcherCtx.Done():
return
case event := <-eventCh:
if event.Action.IsDelete() {
if event.Action == events.ActionFileDeleted {
cfg.stopProviders()
} else {
cfg.reloadReq <- struct{}{}
@@ -188,75 +108,106 @@ func (cfg *Config) watchChanges() {
}()
}
func (cfg *Config) load() E.NestedError {
func (cfg *Config) forEachRoute(do func(alias string, r R.Route, p *PR.Provider)) {
cfg.proxyProviders.RangeAll(func(_ string, p *PR.Provider) {
p.RangeRoutes(func(a string, r R.Route) {
do(a, r, p)
})
})
}
func (cfg *Config) load() (res E.NestedError) {
b := E.NewBuilder("errors loading config")
defer b.To(&res)
cfg.l.Debug("loading config")
defer cfg.l.Debug("loaded config")
data, err := cfg.reader.Read()
if err.IsNotNil() {
return E.Failure("read config").With(err)
}
model := M.DefaultConfig()
if err := E.From(yaml.Unmarshal(data, model)); err.IsNotNil() {
return E.Failure("parse config").With(err)
data, err := E.Check(os.ReadFile(common.ConfigPath))
if err.HasError() {
b.Add(E.FailWith("read config", err))
return
}
if !common.NoSchemaValidation {
if err := Validate(data); err.IsNotNil() {
return err
if err = Validate(data); err.HasError() {
b.Add(E.FailWith("schema validation", err))
return
}
}
warnings := E.NewBuilder("errors validating config")
cfg.l.Debug("starting autocert")
ap, err := autocert.NewConfig(&model.AutoCert).GetProvider()
if err.IsNotNil() {
warnings.Add(E.Failure("autocert provider").With(err))
} else {
cfg.l.Debug("started autocert")
model := M.DefaultConfig()
if err := E.From(yaml.Unmarshal(data, model)); err.HasError() {
b.Add(E.FailWith("parse config", err))
return
}
cfg.autocertProvider = ap
cfg.l.Debug("starting providers")
cfg.proxyProviders = F.NewMap[string, *PR.Provider]()
for name, pm := range model.Providers {
p := PR.NewProvider(name, pm)
cfg.proxyProviders.Set(name, p)
if err := p.StartAllRoutes(); err.IsNotNil() {
warnings.Add(E.Failure("start routes").Subjectf("provider %s", name).With(err))
}
}
cfg.l.Debug("started providers")
// errors are non fatal below
b.WithSeverity(E.SeverityWarning)
b.Add(cfg.initAutoCert(&model.AutoCert))
b.Add(cfg.loadProviders(&model.Providers))
cfg.value = model
return
}
if err := warnings.Build(); err.IsNotNil() {
cfg.l.Warn(err)
func (cfg *Config) initAutoCert(autocertCfg *M.AutoCertConfig) (err E.NestedError) {
if cfg.autocertProvider != nil {
return
}
cfg.l.Debug("loaded config")
return E.Nil()
cfg.l.Debug("initializing autocert")
defer cfg.l.Debug("initialized autocert")
cfg.autocertProvider, err = autocert.NewConfig(autocertCfg).GetProvider()
if err.HasError() {
err = E.FailWith("autocert provider", err)
}
return
}
func (cfg *Config) loadProviders(providers *M.ProxyProviders) (res E.NestedError) {
cfg.l.Debug("loading providers")
defer cfg.l.Debug("loaded providers")
b := E.NewBuilder("errors loading providers")
defer b.To(&res)
for _, filename := range providers.Files {
p, err := PR.NewFileProvider(filename)
if err != nil {
b.Add(err.Subject(filename))
continue
}
cfg.proxyProviders.Store(p.GetName(), p)
b.Add(p.LoadRoutes().Subject(filename))
}
for name, dockerHost := range providers.Docker {
p, err := PR.NewDockerProvider(name, dockerHost)
if err != nil {
b.Add(err.Subjectf("%s (%s)", name, dockerHost))
continue
}
cfg.proxyProviders.Store(p.GetName(), p)
b.Add(p.LoadRoutes().Subject(dockerHost))
}
return
}
func (cfg *Config) controlProviders(action string, do func(*PR.Provider) E.NestedError) {
errors := E.NewBuilder("cannot %s these providers", action)
errors := E.NewBuilder("errors in %s these providers", action)
cfg.proxyProviders.EachKVParallel(func(name string, p *PR.Provider) {
if err := do(p); err.IsNotNil() {
errors.Add(E.From(err).Subjectf("provider %s", name))
cfg.proxyProviders.RangeAll(func(name string, p *PR.Provider) {
if err := do(p); err.HasError() {
errors.Add(err.Subject(p))
}
})
if err := errors.Build(); err.IsNotNil() {
if err := errors.Build(); err.HasError() {
cfg.l.Error(err)
}
}
func (cfg *Config) startProviders() {
cfg.controlProviders("start", (*PR.Provider).StartAllRoutes)
}
func (cfg *Config) stopProviders() {
cfg.controlProviders("stop", (*PR.Provider).StopAllRoutes)
cfg.controlProviders("stop routes", (*PR.Provider).StopAllRoutes)
}

82
src/config/query.go Normal file
View File

@@ -0,0 +1,82 @@
package config
import (
M "github.com/yusing/go-proxy/models"
PR "github.com/yusing/go-proxy/proxy/provider"
R "github.com/yusing/go-proxy/route"
U "github.com/yusing/go-proxy/utils"
F "github.com/yusing/go-proxy/utils/functional"
)
func (cfg *Config) DumpEntries() map[string]*M.RawEntry {
entries := make(map[string]*M.RawEntry)
cfg.forEachRoute(func(alias string, r R.Route, p *PR.Provider) {
entries[alias] = r.Entry()
})
return entries
}
func (cfg *Config) DumpProviders() map[string]*PR.Provider {
entries := make(map[string]*PR.Provider)
cfg.proxyProviders.RangeAll(func(name string, p *PR.Provider) {
entries[name] = p
})
return entries
}
func (cfg *Config) RoutesByAlias() map[string]U.SerializedObject {
routes := make(map[string]U.SerializedObject)
cfg.forEachRoute(func(alias string, r R.Route, p *PR.Provider) {
obj, err := U.Serialize(r)
if err.HasError() {
cfg.l.Error(err)
return
}
obj["provider"] = p.GetName()
obj["type"] = string(r.Type())
routes[alias] = obj
})
return routes
}
func (cfg *Config) Statistics() map[string]any {
nTotalStreams := 0
nTotalRPs := 0
providerStats := make(map[string]any)
cfg.forEachRoute(func(alias string, r R.Route, p *PR.Provider) {
s, ok := providerStats[p.GetName()]
if !ok {
s = make(map[string]int)
}
stats := s.(map[string]int)
switch r.Type() {
case R.RouteTypeStream:
stats["num_streams"]++
nTotalStreams++
case R.RouteTypeReverseProxy:
stats["num_reverse_proxies"]++
nTotalRPs++
default:
panic("bug: should not reach here")
}
})
return map[string]any{
"num_total_streams": nTotalStreams,
"num_total_reverse_proxies": nTotalRPs,
"providers": providerStats,
}
}
func (cfg *Config) FindRoute(alias string) R.Route {
return F.MapFind(cfg.proxyProviders,
func(p *PR.Provider) (R.Route, bool) {
if route, ok := p.GetRoute(alias); ok {
return route, true
}
return nil, false
},
)
}

View File

@@ -3,22 +3,71 @@ package docker
import (
"net/http"
"sync"
"sync/atomic"
"github.com/docker/cli/cli/connhelper"
"github.com/docker/docker/client"
"github.com/sirupsen/logrus"
"github.com/yusing/go-proxy/common"
E "github.com/yusing/go-proxy/error"
F "github.com/yusing/go-proxy/utils/functional"
)
type Client = *client.Client
type Client struct {
key string
refCount *atomic.Int32
*client.Client
l logrus.FieldLogger
}
func ParseDockerHostname(host string) (string, E.NestedError) {
switch host {
case common.DockerHostFromEnv, "":
return "localhost", nil
}
url, err := E.Check(client.ParseHostURL(host))
if err != nil {
return "", E.Invalid("host", host).With(err)
}
return url.Hostname(), nil
}
func (c Client) DaemonHostname() string {
// DaemonHost should always return a valid host
hostname, _ := ParseDockerHostname(c.DaemonHost())
return hostname
}
func (c Client) Connected() bool {
return c.Client != nil
}
// if the client is still referenced, this is no-op
func (c *Client) Close() error {
if c.refCount.Add(-1) > 0 {
return nil
}
clientMap.Delete(c.key)
client := c.Client
c.Client = nil
c.l.Debugf("client closed")
if client != nil {
return client.Close()
}
return nil
}
// ConnectClient creates a new Docker client connection to the specified host.
//
// Returns existing client if available.
//
// Parameters:
// - host: the host to connect to (either a URL or "FROM_ENV").
// - host: the host to connect to (either a URL or common.DockerHostFromEnv).
//
// Returns:
// - Client: the Docker client connection.
@@ -28,8 +77,9 @@ func ConnectClient(host string) (Client, E.NestedError) {
defer clientMapMu.Unlock()
// check if client exists
if client, ok := clientMap[host]; ok {
return client, E.Nil()
if client, ok := clientMap.Load(host); ok {
client.refCount.Add(1)
return client, nil
}
// create client
@@ -40,8 +90,8 @@ func ConnectClient(host string) (Client, E.NestedError) {
opt = clientOptEnvHost
default:
helper, err := E.Check(connhelper.GetConnectionHelper(host))
if err.IsNotNil() {
logger.Fatalf("unexpected error: %s", err)
if err.HasError() {
return Client{}, E.UnexpectedError(err.Error())
}
if helper != nil {
httpClient := &http.Client{
@@ -65,30 +115,39 @@ func ConnectClient(host string) (Client, E.NestedError) {
client, err := E.Check(client.NewClientWithOpts(opt...))
if err.IsNotNil() {
return nil, err
if err.HasError() {
return Client{}, err
}
clientMap[host] = client
return client, E.Nil()
c := Client{
Client: client,
key: host,
refCount: &atomic.Int32{},
l: logger.WithField("docker_client", client.DaemonHost()),
}
c.refCount.Add(1)
c.l.Debugf("client connected")
clientMap.Store(host, c)
return c, nil
}
func CloseAllClients() {
clientMapMu.Lock()
defer clientMapMu.Unlock()
for _, client := range clientMap {
client.Close()
}
clientMap = make(map[string]Client)
clientMap.RangeAll(func(_ string, c Client) {
c.Client.Close()
})
clientMap.Clear()
logger.Debug("closed all clients")
}
var clientMap map[string]Client = make(map[string]Client)
var clientMapMu sync.Mutex
var (
clientMap F.Map[string, Client] = F.NewMapOf[string, Client]()
clientMapMu sync.Mutex
var clientOptEnvHost = []client.Opt{
client.WithHostFromEnv(),
client.WithAPIVersionNegotiation(),
}
clientOptEnvHost = []client.Opt{
client.WithHostFromEnv(),
client.WithAPIVersionNegotiation(),
}
var logger = logrus.WithField("?", "docker")
logger = logrus.WithField("module", "docker")
)

View File

@@ -12,35 +12,41 @@ import (
)
type ClientInfo struct {
Host string
Client Client
Containers []types.Container
}
func GetClientInfo(clientHost string) (*ClientInfo, E.NestedError) {
var listOptions = container.ListOptions{
// Filters: filters.NewArgs(
// filters.Arg("health", "healthy"),
// filters.Arg("health", "none"),
// filters.Arg("health", "starting"),
// ),
All: true,
}
func GetClientInfo(clientHost string, getContainer bool) (*ClientInfo, E.NestedError) {
dockerClient, err := ConnectClient(clientHost)
if err.IsNotNil() {
return nil, E.Failure("create docker client").With(err)
if err.HasError() {
return nil, E.FailWith("connect to docker", err)
}
defer dockerClient.Close()
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
containers, err := E.Check(dockerClient.ContainerList(ctx, container.ListOptions{All: true}))
if err.IsNotNil() {
return nil, E.Failure("list containers").With(err)
var containers []types.Container
if getContainer {
containers, err = E.Check(dockerClient.ContainerList(ctx, listOptions))
if err.HasError() {
return nil, E.FailWith("list containers", err)
}
}
// extract host from docker client url
// since the services being proxied to
// should have the same IP as the docker client
url, err := E.Check(client.ParseHostURL(dockerClient.DaemonHost()))
if err.IsNotNil() {
return nil, E.Invalid("host url", dockerClient.DaemonHost()).With(err)
}
if url.Scheme == "unix" {
return &ClientInfo{Host: "localhost", Containers: containers}, E.Nil()
}
return &ClientInfo{Host: url.Hostname(), Containers: containers}, E.Nil()
return &ClientInfo{
Client: dockerClient,
Containers: containers,
}, nil
}
func IsErrConnectionFailed(err error) bool {

111
src/docker/container.go Normal file
View File

@@ -0,0 +1,111 @@
package docker
import (
"fmt"
"strconv"
"strings"
"github.com/docker/docker/api/types"
U "github.com/yusing/go-proxy/utils"
)
type ProxyProperties struct {
DockerHost string `yaml:"-" json:"docker_host"`
ContainerName string `yaml:"-" json:"container_name"`
ImageName string `yaml:"-" json:"image_name"`
Aliases []string `yaml:"-" json:"aliases"`
IsExcluded bool `yaml:"-" json:"is_excluded"`
FirstPort string `yaml:"-" json:"first_port"`
IdleTimeout string `yaml:"-" json:"idle_timeout"`
WakeTimeout string `yaml:"-" json:"wake_timeout"`
StopMethod string `yaml:"-" json:"stop_method"`
StopTimeout string `yaml:"-" json:"stop_timeout"` // stop_method = "stop" only
StopSignal string `yaml:"-" json:"stop_signal"` // stop_method = "stop" | "kill" only
Running bool `yaml:"-" json:"running"`
}
type Container struct {
*types.Container
*ProxyProperties
}
func FromDocker(c *types.Container, dockerHost string) (res Container) {
res.Container = c
res.ProxyProperties = &ProxyProperties{
DockerHost: dockerHost,
ContainerName: res.getName(),
ImageName: res.getImageName(),
Aliases: res.getAliases(),
IsExcluded: U.ParseBool(res.getDeleteLabel(LableExclude)),
FirstPort: res.firstPortOrEmpty(),
IdleTimeout: res.getDeleteLabel(LabelIdleTimeout),
WakeTimeout: res.getDeleteLabel(LabelWakeTimeout),
StopMethod: res.getDeleteLabel(LabelStopMethod),
StopTimeout: res.getDeleteLabel(LabelStopTimeout),
StopSignal: res.getDeleteLabel(LabelStopSignal),
Running: c.Status == "running" || c.State == "running",
}
return
}
func FromJson(json types.ContainerJSON, dockerHost string) Container {
ports := make([]types.Port, 0)
for k, bindings := range json.NetworkSettings.Ports {
for _, v := range bindings {
pubPort, _ := strconv.Atoi(v.HostPort)
privPort, _ := strconv.Atoi(k.Port())
ports = append(ports, types.Port{
IP: v.HostIP,
PublicPort: uint16(pubPort),
PrivatePort: uint16(privPort),
})
}
}
return FromDocker(&types.Container{
ID: json.ID,
Names: []string{json.Name},
Image: json.Image,
Ports: ports,
Labels: json.Config.Labels,
State: json.State.Status,
Status: json.State.Status,
}, dockerHost)
}
func (c Container) getDeleteLabel(label string) string {
if l, ok := c.Labels[label]; ok {
delete(c.Labels, label)
return l
}
return ""
}
func (c Container) getAliases() []string {
if l := c.getDeleteLabel(LableAliases); l != "" {
return U.CommaSeperatedList(l)
} else {
return []string{c.getName()}
}
}
func (c Container) getName() string {
return strings.TrimPrefix(c.Names[0], "/")
}
func (c Container) getImageName() string {
colonSep := strings.Split(c.Image, ":")
slashSep := strings.Split(colonSep[0], "/")
return slashSep[len(slashSep)-1]
}
func (c Container) firstPortOrEmpty() string {
if len(c.Ports) == 0 {
return ""
}
for _, p := range c.Ports {
if p.PublicPort != 0 {
return fmt.Sprint(p.PublicPort)
}
}
return ""
}

View File

@@ -9,7 +9,7 @@ type (
Icon string
Category string
Description string
WidgetConfig map[string]interface{}
WidgetConfig map[string]any
}
)

View File

@@ -0,0 +1,87 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{.Title}}</title>
<style>
/* Global Styles */
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: Inter, Arial, sans-serif;
font-size: 16px;
line-height: 1.5;
color: #fff;
background-color: #212121;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
}
/* Spinner Styles */
.spinner {
width: 120px;
height: 120px;
border: 16px solid #333;
border-radius: 50%;
border-top: 16px solid #66d9ef;
animation: spin 2s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
/* Error Styles */
.error {
display: inline-block;
text-align: center;
justify-content: center;
}
.error::before {
content: "\26A0"; /* Unicode for warning symbol */
font-size: 40px;
color: #ff9900;
}
/* Message Styles */
.message {
font-size: 24px;
font-weight: bold;
padding-left: 32px;
text-align: center;
}
</style>
</head>
<body>
<script>
window.onload = async function () {
let result = await fetch(window.location.href, {
headers: {
{{ range $key, $value := .RequestHeaders }}
'{{ $key }}' : {{ $value }}
{{ end }}
},
}).then((resp) => resp.text())
.catch((err) => {
document.getElementById("message").innerText = err;
});
if (result) {
document.documentElement.innerHTML = result
}
};
</script>
<div class="{{.SpinnerClass}}"></div>
<div class="message">{{.Message}}</div>
</body>
</html>

View File

@@ -0,0 +1,93 @@
package idlewatcher
import (
"bytes"
_ "embed"
"fmt"
"io"
"net/http"
"strings"
"text/template"
)
type templateData struct {
Title string
Message string
RequestHeaders http.Header
SpinnerClass string
}
//go:embed html/loading_page.html
var loadingPage []byte
var loadingPageTmpl = func() *template.Template {
tmpl, err := template.New("loading").Parse(string(loadingPage))
if err != nil {
panic(err)
}
return tmpl
}()
const (
htmlContentType = "text/html; charset=utf-8"
errPrefix = "\u1000"
headerGoProxyTargetURL = "X-GoProxy-Target"
headerContentType = "Content-Type"
spinnerClassSpinner = "spinner"
spinnerClassErrorSign = "error"
)
func (w *watcher) makeSuccResp(redirectURL string, resp *http.Response) (*http.Response, error) {
h := make(http.Header)
h.Set("Location", redirectURL)
h.Set("Content-Length", "0")
h.Set(headerContentType, htmlContentType)
return &http.Response{
StatusCode: http.StatusTemporaryRedirect,
Header: h,
Body: http.NoBody,
TLS: resp.TLS,
}, nil
}
func (w *watcher) makeErrResp(errFmt string, args ...any) (*http.Response, error) {
return w.makeResp(errPrefix+errFmt, args...)
}
func (w *watcher) makeResp(format string, args ...any) (*http.Response, error) {
msg := fmt.Sprintf(format, args...)
data := new(templateData)
data.Title = w.ContainerName
data.Message = strings.ReplaceAll(msg, "\n", "<br>")
data.Message = strings.ReplaceAll(data.Message, " ", "&ensp;")
data.RequestHeaders = make(http.Header)
data.RequestHeaders.Add(headerGoProxyTargetURL, "window.location.href")
if strings.HasPrefix(data.Message, errPrefix) {
data.Message = strings.TrimLeft(data.Message, errPrefix)
data.SpinnerClass = spinnerClassErrorSign
} else {
data.SpinnerClass = spinnerClassSpinner
}
buf := bytes.NewBuffer(make([]byte, 128)) // more than enough
err := loadingPageTmpl.Execute(buf, data)
if err != nil { // should never happen
panic(err)
}
return &http.Response{
StatusCode: http.StatusAccepted,
Header: http.Header{
headerContentType: {htmlContentType},
"Cache-Control": {
"no-cache",
"no-store",
"must-revalidate",
},
},
Body: io.NopCloser(buf),
ContentLength: int64(buf.Len()),
}, nil
}

View File

@@ -0,0 +1,78 @@
package idlewatcher
import (
"context"
"net/http"
"time"
)
type (
roundTripper struct {
patched roundTripFunc
}
roundTripFunc func(*http.Request) (*http.Response, error)
)
func (rt roundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
return rt.patched(req)
}
func (w *watcher) roundTrip(origRoundTrip roundTripFunc, req *http.Request) (*http.Response, error) {
// target site is ready, passthrough
if w.ready.Load() {
return origRoundTrip(req)
}
// wake the container
w.wakeCh <- struct{}{}
// initial request
targetUrl := req.Header.Get(headerGoProxyTargetURL)
if targetUrl == "" {
return w.makeResp(
"%s is starting... Please wait",
w.ContainerName,
)
}
w.l.Debug("serving event")
// stream request
rtDone := make(chan *http.Response, 1)
ctx, cancel := context.WithTimeout(req.Context(), w.WakeTimeout)
defer cancel()
// loop original round trip until success in a goroutine
go func() {
for {
select {
case <-ctx.Done():
return
case <-w.ctx.Done():
return
default:
resp, err := origRoundTrip(req)
if err == nil {
w.ready.Store(true)
rtDone <- resp
return
}
time.Sleep(time.Millisecond * 200)
}
}
}()
for {
select {
case resp := <-rtDone:
return w.makeSuccResp(targetUrl, resp)
case <-ctx.Done():
if ctx.Err() == context.DeadlineExceeded {
return w.makeErrResp("Timed out waiting for %s to fully wake", w.ContainerName)
}
return w.makeErrResp("idlewatcher has stopped\n%s", w.ctx.Err().Error())
case <-w.ctx.Done():
return w.makeErrResp("idlewatcher has stopped\n%s", w.ctx.Err().Error())
}
}
}

View File

@@ -0,0 +1,276 @@
package idlewatcher
import (
"context"
"net/http"
"sync"
"sync/atomic"
"time"
"github.com/docker/docker/api/types/container"
"github.com/sirupsen/logrus"
D "github.com/yusing/go-proxy/docker"
E "github.com/yusing/go-proxy/error"
P "github.com/yusing/go-proxy/proxy"
PT "github.com/yusing/go-proxy/proxy/fields"
W "github.com/yusing/go-proxy/watcher"
)
type (
watcher struct {
*P.ReverseProxyEntry
client D.Client
ready atomic.Bool // whether the site is ready to accept connection
stopByMethod StopCallback // send a docker command w.r.t. `stop_method`
wakeCh chan struct{}
wakeDone chan E.NestedError
ctx context.Context
cancel context.CancelFunc
refCount *sync.WaitGroup
l logrus.FieldLogger
}
WakeDone <-chan error
WakeFunc func() WakeDone
StopCallback func() E.NestedError
)
var (
mainLoopCtx context.Context
mainLoopCancel context.CancelFunc
mainLoopWg sync.WaitGroup
watcherMap = make(map[string]*watcher)
watcherMapMu sync.Mutex
newWatcherCh = make(chan *watcher)
logger = logrus.WithField("module", "idle_watcher")
)
func Register(entry *P.ReverseProxyEntry) (*watcher, E.NestedError) {
failure := E.Failure("idle_watcher register")
if entry.IdleTimeout == 0 {
return nil, failure.With(E.Invalid("idle_timeout", 0))
}
watcherMapMu.Lock()
defer watcherMapMu.Unlock()
if w, ok := watcherMap[entry.ContainerName]; ok {
w.refCount.Add(1)
w.ReverseProxyEntry = entry
return w, nil
}
client, err := D.ConnectClient(entry.DockerHost)
if err.HasError() {
return nil, failure.With(err)
}
w := &watcher{
ReverseProxyEntry: entry,
client: client,
refCount: &sync.WaitGroup{},
wakeCh: make(chan struct{}, 1),
wakeDone: make(chan E.NestedError, 1),
l: logger.WithField("container", entry.ContainerName),
}
w.refCount.Add(1)
w.stopByMethod = w.getStopCallback()
watcherMap[w.ContainerName] = w
go func() {
newWatcherCh <- w
}()
return w, nil
}
func Unregister(containerName string) {
if w, ok := watcherMap[containerName]; ok {
w.refCount.Add(-1)
}
}
func Start() {
logger.Debug("started")
defer logger.Debug("stopped")
mainLoopCtx, mainLoopCancel = context.WithCancel(context.Background())
for {
select {
case <-mainLoopCtx.Done():
return
case w := <-newWatcherCh:
w.l.Debug("registered")
mainLoopWg.Add(1)
go func() {
w.watchUntilCancel()
w.refCount.Wait() // wait for 0 ref count
w.client.Close()
delete(watcherMap, w.ContainerName)
w.l.Debug("unregistered")
mainLoopWg.Done()
}()
}
}
}
func Stop() {
mainLoopCancel()
mainLoopWg.Wait()
}
func (w *watcher) PatchRoundTripper(rtp http.RoundTripper) roundTripper {
return roundTripper{patched: func(r *http.Request) (*http.Response, error) {
return w.roundTrip(rtp.RoundTrip, r)
}}
}
func (w *watcher) containerStop() error {
return w.client.ContainerStop(w.ctx, w.ContainerName, container.StopOptions{
Signal: string(w.StopSignal),
Timeout: &w.StopTimeout})
}
func (w *watcher) containerPause() error {
return w.client.ContainerPause(w.ctx, w.ContainerName)
}
func (w *watcher) containerKill() error {
return w.client.ContainerKill(w.ctx, w.ContainerName, string(w.StopSignal))
}
func (w *watcher) containerUnpause() error {
return w.client.ContainerUnpause(w.ctx, w.ContainerName)
}
func (w *watcher) containerStart() error {
return w.client.ContainerStart(w.ctx, w.ContainerName, container.StartOptions{})
}
func (w *watcher) containerStatus() (string, E.NestedError) {
json, err := w.client.ContainerInspect(w.ctx, w.ContainerName)
if err != nil {
return "", E.FailWith("inspect container", err)
}
return json.State.Status, nil
}
func (w *watcher) wakeIfStopped() E.NestedError {
status, err := w.containerStatus()
if err.HasError() {
return err
}
// "created", "running", "paused", "restarting", "removing", "exited", or "dead"
switch status {
case "exited", "dead":
return E.From(w.containerStart())
case "paused":
return E.From(w.containerUnpause())
case "running":
return nil
default:
return E.Unexpected("container state", status)
}
}
func (w *watcher) getStopCallback() StopCallback {
var cb func() error
switch w.StopMethod {
case PT.StopMethodPause:
cb = w.containerPause
case PT.StopMethodStop:
cb = w.containerStop
case PT.StopMethodKill:
cb = w.containerKill
default:
panic("should not reach here")
}
return func() E.NestedError {
status, err := w.containerStatus()
if err.HasError() {
return err
}
if status != "running" {
return nil
}
return E.From(cb())
}
}
func (w *watcher) watchUntilCancel() {
defer close(w.wakeCh)
w.ctx, w.cancel = context.WithCancel(context.Background())
dockerWatcher := W.NewDockerWatcherWithClient(w.client)
dockerEventCh, dockerEventErrCh := dockerWatcher.EventsWithOptions(w.ctx, W.DockerListOptions{
Filters: W.NewDockerFilter(
W.DockerFilterContainer,
W.DockerrFilterContainerName(w.ContainerName),
W.DockerFilterStart,
W.DockerFilterStop,
W.DockerFilterDie,
W.DockerFilterKill,
W.DockerFilterPause,
W.DockerFilterUnpause,
),
})
ticker := time.NewTicker(w.IdleTimeout)
defer ticker.Stop()
for {
select {
case <-mainLoopCtx.Done():
w.cancel()
case <-w.ctx.Done():
w.l.Debug("stopped")
return
case err := <-dockerEventErrCh:
if err != nil && err.IsNot(context.Canceled) {
w.l.Error(E.FailWith("docker watcher", err))
}
case e := <-dockerEventCh:
switch {
// create / start / unpause
case e.Action.IsContainerWake():
ticker.Reset(w.IdleTimeout)
w.l.Info(e)
default: // stop / pause / kill
ticker.Stop()
w.ready.Store(false)
w.l.Info(e)
}
case <-ticker.C:
w.l.Debug("idle timeout")
ticker.Stop()
if err := w.stopByMethod(); err != nil && err.IsNot(context.Canceled) {
w.l.Error(E.FailWith("stop", err).Extraf("stop method: %s", w.StopMethod))
}
case <-w.wakeCh:
w.l.Debug("wake signal received")
ticker.Reset(w.IdleTimeout)
err := w.wakeIfStopped()
if err != nil && err.IsNot(context.Canceled) {
w.l.Error(E.FailWith("wake", err))
}
select {
case w.wakeDone <- err: // this is passed to roundtrip
default:
}
}
}
}

19
src/docker/inspect.go Normal file
View File

@@ -0,0 +1,19 @@
package docker
import (
"context"
"time"
E "github.com/yusing/go-proxy/error"
)
func (c Client) Inspect(containerID string) (Container, E.NestedError) {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
json, err := c.ContainerInspect(ctx, containerID)
if err != nil {
return Container{}, E.From(err)
}
return FromJson(json, c.key), nil
}

View File

@@ -23,7 +23,7 @@ type Label struct {
// Returns:
// - error: an error if the field does not exist.
func ApplyLabel[T any](obj *T, l *Label) E.NestedError {
return U.SetFieldFromSnake(obj, l.Attribute, l.Value)
return U.Deserialize(map[string]any{l.Attribute: l.Value}, obj)
}
type ValueParser func(string) (any, E.NestedError)
@@ -36,7 +36,7 @@ func ParseLabel(label string, value string) (*Label, E.NestedError) {
return &Label{
Namespace: label,
Value: value,
}, E.Nil()
}, nil
}
l := &Label{
@@ -54,20 +54,20 @@ func ParseLabel(label string, value string) (*Label, E.NestedError) {
// find if namespace has value parser
pm, ok := labelValueParserMap[l.Namespace]
if !ok {
return l, E.Nil()
return l, nil
}
// find if attribute has value parser
p, ok := pm[l.Attribute]
if !ok {
return l, E.Nil()
return l, nil
}
// try to parse value
v, err := p(value)
if err.IsNotNil() {
if err.HasError() {
return nil, err
}
l.Value = v
return l, E.Nil()
return l, nil
}
func RegisterNamespace(namespace string, pm ValueParserMap) {

View File

@@ -1,16 +1,26 @@
package docker
import (
"net/http"
"strings"
E "github.com/yusing/go-proxy/error"
"gopkg.in/yaml.v3"
)
func setHeadersParser(value string) (any, E.NestedError) {
func yamlListParser(value string) (any, E.NestedError) {
value = strings.TrimSpace(value)
if value == "" {
return []string{}, nil
}
var data []string
err := E.From(yaml.Unmarshal([]byte(value), &data))
return data, err
}
func yamlStringMappingParser(value string) (any, E.NestedError) {
value = strings.TrimSpace(value)
lines := strings.Split(value, "\n")
h := make(http.Header)
h := make(map[string]string)
for _, line := range lines {
parts := strings.SplitN(line, ":", 2)
if len(parts) != 2 {
@@ -18,25 +28,21 @@ func setHeadersParser(value string) (any, E.NestedError) {
}
key := strings.TrimSpace(parts[0])
val := strings.TrimSpace(parts[1])
h.Add(key, val)
if existing, ok := h[key]; ok {
h[key] = existing + ", " + val
} else {
h[key] = val
}
}
return h, E.Nil()
}
func commaSepParser(value string) (any, E.NestedError) {
v := strings.Split(value, ",")
for i := range v {
v[i] = strings.TrimSpace(v[i])
}
return v, E.Nil()
return h, nil
}
func boolParser(value string) (any, E.NestedError) {
switch strings.ToLower(value) {
case "true", "yes", "1":
return true, E.Nil()
return true, nil
case "false", "no", "0":
return false, E.Nil()
return false, nil
default:
return nil, E.Invalid("boolean value", value)
}
@@ -46,9 +52,9 @@ const NSProxy = "proxy"
var _ = func() int {
RegisterNamespace(NSProxy, ValueParserMap{
"aliases": commaSepParser,
"set_headers": setHeadersParser,
"hide_headers": commaSepParser,
"path_patterns": yamlListParser,
"set_headers": yamlStringMappingParser,
"hide_headers": yamlListParser,
"no_tls_verify": boolParser,
})
return 0

View File

@@ -0,0 +1,140 @@
package docker
import (
"fmt"
"reflect"
"strings"
"testing"
E "github.com/yusing/go-proxy/error"
. "github.com/yusing/go-proxy/utils/testing"
)
func makeLabel(namespace string, alias string, field string) string {
return fmt.Sprintf("%s.%s.%s", namespace, alias, field)
}
func TestHomePageLabel(t *testing.T) {
alias := "foo"
field := "ip"
v := "bar"
pl, err := ParseLabel(makeLabel(NSHomePage, alias, field), v)
ExpectNoError(t, err.Error())
if pl.Target != alias {
t.Errorf("Expected alias=%s, got %s", alias, pl.Target)
}
if pl.Attribute != field {
t.Errorf("Expected field=%s, got %s", field, pl.Target)
}
if pl.Value != v {
t.Errorf("Expected value=%q, got %s", v, pl.Value)
}
}
func TestStringProxyLabel(t *testing.T) {
v := "bar"
pl, err := ParseLabel(makeLabel(NSProxy, "foo", "ip"), v)
ExpectNoError(t, err.Error())
ExpectEqual(t, pl.Value.(string), v)
}
func TestBoolProxyLabelValid(t *testing.T) {
tests := map[string]bool{
"true": true,
"TRUE": true,
"yes": true,
"1": true,
"false": false,
"FALSE": false,
"no": false,
"0": false,
}
for k, v := range tests {
pl, err := ParseLabel(makeLabel(NSProxy, "foo", "no_tls_verify"), k)
ExpectNoError(t, err.Error())
ExpectEqual(t, pl.Value.(bool), v)
}
}
func TestBoolProxyLabelInvalid(t *testing.T) {
alias := "foo"
field := "no_tls_verify"
_, err := ParseLabel(makeLabel(NSProxy, alias, field), "invalid")
if !err.Is(E.ErrInvalid) {
t.Errorf("Expected err InvalidProxyLabel, got %s", err.Error())
}
}
func TestSetHeaderProxyLabelValid(t *testing.T) {
v := `
X-Custom-Header1: foo, bar
X-Custom-Header1: baz
X-Custom-Header2: boo`
v = strings.TrimPrefix(v, "\n")
h := map[string]string{
"X-Custom-Header1": "foo, bar, baz",
"X-Custom-Header2": "boo",
}
pl, err := ParseLabel(makeLabel(NSProxy, "foo", "set_headers"), v)
ExpectNoError(t, err.Error())
hGot := ExpectType[map[string]string](t, pl.Value)
if hGot != nil && !reflect.DeepEqual(h, hGot) {
t.Errorf("Expected %v, got %v", h, hGot)
}
}
func TestSetHeaderProxyLabelInvalid(t *testing.T) {
tests := []string{
"X-Custom-Header1 = bar",
"X-Custom-Header1",
"- X-Custom-Header1",
}
for _, v := range tests {
_, err := ParseLabel(makeLabel(NSProxy, "foo", "set_headers"), v)
if !err.Is(E.ErrInvalid) {
t.Errorf("Expected invalid err for %q, got %s", v, err.Error())
}
}
}
func TestHideHeadersProxyLabel(t *testing.T) {
v := `
- X-Custom-Header1
- X-Custom-Header2
- X-Custom-Header3
`
v = strings.TrimPrefix(v, "\n")
pl, err := ParseLabel(makeLabel(NSProxy, "foo", "hide_headers"), v)
ExpectNoError(t, err.Error())
sGot := ExpectType[[]string](t, pl.Value)
sWant := []string{"X-Custom-Header1", "X-Custom-Header2", "X-Custom-Header3"}
if sGot != nil {
ExpectDeepEqual(t, sGot, sWant)
}
}
// func TestCommaSepProxyLabelSingle(t *testing.T) {
// v := "a"
// pl, err := ParseLabel("proxy.aliases", v)
// ExpectNoError(t, err)
// sGot := ExpectType[[]string](t, pl.Value)
// sWant := []string{"a"}
// if sGot != nil {
// ExpectEqual(t, sGot, sWant)
// }
// }
// func TestCommaSepProxyLabelMulti(t *testing.T) {
// v := "X-Custom-Header1, X-Custom-Header2,X-Custom-Header3"
// pl, err := ParseLabel("proxy.aliases", v)
// ExpectNoError(t, err)
// sGot := ExpectType[[]string](t, pl.Value)
// sWant := []string{"X-Custom-Header1", "X-Custom-Header2", "X-Custom-Header3"}
// if sGot != nil {
// ExpectEqual(t, sGot, sWant)
// }
// }

13
src/docker/labels.go Normal file
View File

@@ -0,0 +1,13 @@
package docker
const (
WildcardAlias = "*"
LableAliases = NSProxy + ".aliases"
LableExclude = NSProxy + ".exclude"
LabelIdleTimeout = NSProxy + ".idle_timeout"
LabelWakeTimeout = NSProxy + ".wake_timeout"
LabelStopMethod = NSProxy + ".stop_method"
LabelStopTimeout = NSProxy + ".stop_timeout"
LabelStopSignal = NSProxy + ".stop_signal"
)

View File

@@ -1,192 +0,0 @@
package docker
import (
"fmt"
"net/http"
"reflect"
"testing"
E "github.com/yusing/go-proxy/error"
)
func makeLabel(namespace string, alias string, field string) string {
return fmt.Sprintf("%s.%s.%s", namespace, alias, field)
}
func TestHomePageLabel(t *testing.T) {
alias := "foo"
field := "ip"
v := "bar"
pl, err := ParseLabel(makeLabel(NSHomePage, alias, field), v)
if err.IsNotNil() {
t.Errorf("expected err=nil, got %s", err.Error())
}
if pl.Target != alias {
t.Errorf("expected alias=%s, got %s", alias, pl.Target)
}
if pl.Attribute != field {
t.Errorf("expected field=%s, got %s", field, pl.Target)
}
if pl.Value != v {
t.Errorf("expected value=%q, got %s", v, pl.Value)
}
}
func TestStringProxyLabel(t *testing.T) {
alias := "foo"
field := "ip"
v := "bar"
pl, err := ParseLabel(makeLabel(NSProxy, alias, field), v)
if err.IsNotNil() {
t.Errorf("expected err=nil, got %s", err.Error())
}
if pl.Target != alias {
t.Errorf("expected alias=%s, got %s", alias, pl.Target)
}
if pl.Attribute != field {
t.Errorf("expected field=%s, got %s", field, pl.Target)
}
if pl.Value != v {
t.Errorf("expected value=%q, got %s", v, pl.Value)
}
}
func TestBoolProxyLabelValid(t *testing.T) {
alias := "foo"
field := "no_tls_verify"
tests := map[string]bool{
"true": true,
"TRUE": true,
"yes": true,
"1": true,
"false": false,
"FALSE": false,
"no": false,
"0": false,
}
for k, v := range tests {
pl, err := ParseLabel(makeLabel(NSProxy, alias, field), k)
if err.IsNotNil() {
t.Errorf("expected err=nil, got %s", err.Error())
}
if pl.Target != alias {
t.Errorf("expected alias=%s, got %s", alias, pl.Target)
}
if pl.Attribute != field {
t.Errorf("expected field=%s, got %s", field, pl.Attribute)
}
if pl.Value != v {
t.Errorf("expected value=%v, got %v", v, pl.Value)
}
}
}
func TestBoolProxyLabelInvalid(t *testing.T) {
alias := "foo"
field := "no_tls_verify"
_, err := ParseLabel(makeLabel(NSProxy, alias, field), "invalid")
if !err.Is(E.ErrInvalid) {
t.Errorf("expected err InvalidProxyLabel, got %v", reflect.TypeOf(err))
}
}
func TestHeaderProxyLabelValid(t *testing.T) {
alias := "foo"
field := "set_headers"
v := `
X-Custom-Header1: foo
X-Custom-Header1: bar
X-Custom-Header2: baz
`
h := make(http.Header, 0)
h.Set("X-Custom-Header1", "foo")
h.Add("X-Custom-Header1", "bar")
h.Set("X-Custom-Header2", "baz")
pl, err := ParseLabel(makeLabel(NSProxy, alias, field), v)
if err.IsNotNil() {
t.Errorf("expected err=nil, got %s", err.Error())
}
if pl.Target != alias {
t.Errorf("expected alias=%s, got %s", alias, pl.Target)
}
if pl.Attribute != field {
t.Errorf("expected field=%s, got %s", field, pl.Attribute)
}
hGot, ok := pl.Value.(http.Header)
if !ok {
t.Error("value is not http.Header")
return
}
for k, vWant := range h {
vGot := hGot[k]
if !reflect.DeepEqual(vGot, vWant) {
t.Errorf("expected %s=%q, got %q", k, vWant, vGot)
}
}
}
func TestHeaderProxyLabelInvalid(t *testing.T) {
alias := "foo"
field := "set_headers"
tests := []string{
"X-Custom-Header1 = bar",
"X-Custom-Header1",
}
for _, v := range tests {
_, err := ParseLabel(makeLabel(NSProxy, alias, field), v)
if !err.Is(E.ErrInvalid) {
t.Errorf("expected err InvalidProxyLabel for %q, got %v", v, err)
}
}
}
func TestCommaSepProxyLabelSingle(t *testing.T) {
alias := "foo"
field := "hide_headers"
v := "X-Custom-Header1"
pl, err := ParseLabel(makeLabel(NSProxy, alias, field), v)
if err.IsNotNil() {
t.Errorf("expected err=nil, got %s", err.Error())
}
if pl.Target != alias {
t.Errorf("expected alias=%s, got %s", alias, pl.Target)
}
if pl.Attribute != field {
t.Errorf("expected field=%s, got %s", field, pl.Attribute)
}
sGot, ok := pl.Value.([]string)
sWant := []string{"X-Custom-Header1"}
if !ok {
t.Error("value is not []string")
}
if !reflect.DeepEqual(sGot, sWant) {
t.Errorf("expected %q, got %q", sWant, sGot)
}
}
func TestCommaSepProxyLabelMulti(t *testing.T) {
alias := "foo"
field := "hide_headers"
v := "X-Custom-Header1, X-Custom-Header2,X-Custom-Header3"
pl, err := ParseLabel(makeLabel(NSProxy, alias, field), v)
if err.IsNotNil() {
t.Errorf("expected err=nil, got %s", err.Error())
}
if pl.Target != alias {
t.Errorf("expected alias=%s, got %s", alias, pl.Target)
}
if pl.Attribute != field {
t.Errorf("expected field=%s, got %s", field, pl.Attribute)
}
sGot, ok := pl.Value.([]string)
sWant := []string{"X-Custom-Header1", "X-Custom-Header2", "X-Custom-Header3"}
if !ok {
t.Error("value is not []string")
}
if !reflect.DeepEqual(sGot, sWant) {
t.Errorf("expected %q, got %q", sWant, sGot)
}
}

View File

@@ -6,26 +6,43 @@ import (
)
type Builder struct {
message string
errors []error
*builder
}
type builder struct {
message string
errors []NestedError
severity Severity
sync.Mutex
}
func NewBuilder(format string, args ...any) *Builder {
return &Builder{message: fmt.Sprintf(format, args...)}
func NewBuilder(format string, args ...any) Builder {
return Builder{&builder{message: fmt.Sprintf(format, args...)}}
}
func (b *Builder) Add(err error) *Builder {
// adding nil / nil is no-op,
// you may safely pass expressions returning error to it
func (b Builder) Add(err NestedError) Builder {
if err != nil {
b.Lock()
// TODO: if err severity is higher than b.severity, update b.severity
b.errors = append(b.errors, err)
b.Unlock()
}
return b
}
func (b *Builder) Addf(format string, args ...any) *Builder {
return b.Add(fmt.Errorf(format, args...))
func (b Builder) AddE(err error) Builder {
return b.Add(From(err))
}
func (b Builder) Addf(format string, args ...any) Builder {
return b.Add(errorf(format, args...))
}
func (b Builder) WithSeverity(s Severity) Builder {
b.severity = s
return b
}
// Build builds a NestedError based on the errors collected in the Builder.
@@ -35,9 +52,21 @@ func (b *Builder) Addf(format string, args ...any) *Builder {
//
// Returns:
// - NestedError: the built NestedError.
func (b *Builder) Build() NestedError {
func (b Builder) Build() NestedError {
if len(b.errors) == 0 {
return Nil()
return nil
}
return Join(b.message, b.errors...)
return Join(b.message, b.errors...).Severity(b.severity)
}
func (b Builder) To(ptr *NestedError) {
if *ptr == nil {
*ptr = b.Build()
} else {
**ptr = *b.Build()
}
}
func (b Builder) HasError() bool {
return len(b.errors) > 0
}

View File

@@ -1,27 +1,52 @@
package error
import "testing"
import (
"testing"
func TestBuilder(t *testing.T) {
. "github.com/yusing/go-proxy/utils/testing"
)
func TestBuilderEmpty(t *testing.T) {
eb := NewBuilder("qwer")
ExpectTrue(t, eb.Build() == nil)
ExpectTrue(t, eb.Build().NoError())
ExpectFalse(t, eb.HasError())
}
func TestBuilderAddNil(t *testing.T) {
eb := NewBuilder("asdf")
var err NestedError
for range 3 {
eb.Add(nil)
}
for range 3 {
eb.Add(err)
}
ExpectTrue(t, eb.Build() == nil)
ExpectTrue(t, eb.Build().NoError())
ExpectFalse(t, eb.HasError())
}
func TestBuilderNested(t *testing.T) {
eb := NewBuilder("error occurred")
eb.Add(Failure("Action 1").With(Invalid("Inner", "1")).With(Invalid("Inner", "2")))
eb.Add(Failure("Action 2").With(Invalid("Inner", "3")))
got := eb.Build().Error()
got := eb.Build().String()
expected1 :=
(`error occurred:
- Action 1 failed:
- invalid Inner - 1
- invalid Inner - 2
- invalid Inner: 1
- invalid Inner: 2
- Action 2 failed:
- invalid Inner - 3`)
- invalid Inner: 3`)
expected2 :=
(`error occurred:
- Action 1 failed:
- invalid Inner - 2
- invalid Inner - 1
- invalid Inner: 2
- invalid Inner: 1
- Action 2 failed:
- invalid Inner - 3`)
- invalid Inner: 3`)
if got != expected1 && got != expected2 {
t.Errorf("expected \n%s, got \n%s", expected1, got)
}

View File

@@ -7,36 +7,26 @@ import (
)
type (
// NestedError is an error with an inner error
// and a list of extra nested errors.
//
// It is designed to be non nil.
//
// You can use it to join multiple errors,
// or to set a inner reason for a nested error.
//
// When a method returns both valid values and errors,
// You should return (Slice/Map, NestedError).
// Caller then should handle the nested error,
// and continue with the valid values.
NestedError struct {
subject any
err error // can be nil
extras []NestedError
NestedError = *nestedError
nestedError struct {
subject string
err error
extras []nestedError
severity Severity
}
Severity uint8
)
func Nil() NestedError { return NestedError{} }
const (
SeverityWarning Severity = iota
SeverityFatal
)
func From(err error) NestedError {
switch err := err.(type) {
case nil:
return Nil()
case NestedError:
return err
default:
return NestedError{err: err}
if IsNil(err) {
return nil
}
return &nestedError{err: err}
}
// Check is a helper function that
@@ -45,42 +35,86 @@ func Check[T any](obj T, err error) (T, NestedError) {
return obj, From(err)
}
func Join(message string, err ...error) NestedError {
extras := make([]NestedError, 0, len(err))
func Join(message string, err ...NestedError) NestedError {
extras := make([]nestedError, len(err))
nErr := 0
for _, e := range err {
if err == nil {
for i, e := range err {
if e == nil {
continue
}
extras = append(extras, From(e))
extras[i] = *e
nErr += 1
}
if nErr == 0 {
return Nil()
return nil
}
return NestedError{
return &nestedError{
err: errors.New(message),
extras: extras,
}
}
func (ne NestedError) Error() string {
func JoinE(message string, err ...error) NestedError {
b := NewBuilder(message)
for _, e := range err {
b.AddE(e)
}
return b.Build()
}
func IsNil(err error) bool {
return err == nil
}
func IsNotNil(err error) bool {
return err != nil
}
func (ne NestedError) String() string {
var buf strings.Builder
ne.writeToSB(&buf, 0, "")
return buf.String()
}
func (ne NestedError) Is(err error) bool {
return errors.Is(ne.err, err)
if ne == nil {
return err == nil
}
// return errors.Is(ne.err, err)
if errors.Is(ne.err, err) {
return true
}
for _, e := range ne.extras {
if e.Is(err) {
return true
}
}
return false
}
func (ne NestedError) IsNot(err error) bool {
return !ne.Is(err)
}
func (ne NestedError) Error() error {
if ne == nil {
return nil
}
return ne.buildError(0, "")
}
func (ne NestedError) With(s any) NestedError {
if ne == nil {
return ne
}
var msg string
switch ss := s.(type) {
case nil:
return ne
case error:
case NestedError:
return ne.withError(ss)
case error:
return ne.withError(From(ss))
case string:
msg = ss
case fmt.Stringer:
@@ -88,58 +122,99 @@ func (ne NestedError) With(s any) NestedError {
default:
msg = fmt.Sprint(s)
}
return ne.withError(errors.New(msg))
return ne.withError(From(errors.New(msg)))
}
func (ne NestedError) Extraf(format string, args ...any) NestedError {
return ne.With(fmt.Errorf(format, args...))
return ne.With(errorf(format, args...))
}
func (ne NestedError) Subject(s any) NestedError {
ne.subject = s
if ne == nil {
return ne
}
switch ss := s.(type) {
case string:
ne.subject = ss
case fmt.Stringer:
ne.subject = ss.String()
default:
ne.subject = fmt.Sprint(s)
}
return ne
}
func (ne NestedError) Subjectf(format string, args ...any) NestedError {
if ne == nil {
return ne
}
if strings.Contains(format, "%q") {
panic("Subjectf format should not contain %q")
}
if strings.Contains(format, "%w") {
panic("Subjectf format should not contain %w")
}
return ne.Subject(fmt.Sprintf(format, args...))
ne.subject = fmt.Sprintf(format, args...)
return ne
}
func (ne NestedError) IsNil() bool {
return ne.err == nil
func (ne NestedError) Severity(s Severity) NestedError {
if ne == nil {
return ne
}
ne.severity = s
return ne
}
func (ne NestedError) IsNotNil() bool {
return ne.err != nil
func (ne NestedError) Warn() NestedError {
if ne == nil {
return ne
}
ne.severity = SeverityWarning
return ne
}
func (ne NestedError) NoError() bool {
return ne == nil
}
func (ne NestedError) HasError() bool {
return ne != nil
}
func (ne NestedError) IsFatal() bool {
return ne != nil && ne.severity == SeverityFatal
}
func (ne NestedError) IsWarning() bool {
return ne != nil && ne.severity == SeverityWarning
}
func errorf(format string, args ...any) NestedError {
return From(fmt.Errorf(format, args...))
}
func (ne NestedError) withError(err error) NestedError {
ne.extras = append(ne.extras, From(err))
func (ne NestedError) withError(err NestedError) NestedError {
if ne != nil && err != nil {
ne.extras = append(ne.extras, *err)
}
return ne
}
func (ne *NestedError) writeToSB(sb *strings.Builder, level int, prefix string) {
ne.writeIndents(sb, level)
func (ne NestedError) writeToSB(sb *strings.Builder, level int, prefix string) {
for i := 0; i < level; i++ {
sb.WriteString(" ")
}
sb.WriteString(prefix)
if ne.err != nil {
sb.WriteString(ne.err.Error())
if ne.NoError() {
sb.WriteString("nil")
return
}
if ne.subject != nil {
if ne.err != nil {
sb.WriteString(fmt.Sprintf(" for %q", ne.subject))
} else {
sb.WriteString(fmt.Sprint(ne.subject))
}
sb.WriteString(ne.err.Error())
if ne.subject != "" {
sb.WriteString(fmt.Sprintf(" for %q", ne.subject))
}
if len(ne.extras) > 0 {
sb.WriteRune(':')
@@ -150,8 +225,32 @@ func (ne *NestedError) writeToSB(sb *strings.Builder, level int, prefix string)
}
}
func (ne *NestedError) writeIndents(sb *strings.Builder, level int) {
func (ne NestedError) buildError(level int, prefix string) error {
var res error
var sb strings.Builder
for i := 0; i < level; i++ {
sb.WriteString(" ")
}
sb.WriteString(prefix)
if ne.NoError() {
sb.WriteString("nil")
return errors.New(sb.String())
}
res = fmt.Errorf("%s%w", sb.String(), ne.err)
sb.Reset()
if ne.subject != "" {
sb.WriteString(fmt.Sprintf(" for %q", ne.subject))
}
if len(ne.extras) > 0 {
sb.WriteRune(':')
res = fmt.Errorf("%w%s", res, sb.String())
for _, extra := range ne.extras {
res = errors.Join(res, extra.buildError(level+1, "- "))
}
}
return res
}

View File

@@ -1,36 +1,74 @@
package error
package error_test
import (
"errors"
"testing"
. "github.com/yusing/go-proxy/error"
. "github.com/yusing/go-proxy/utils/testing"
)
func AssertEq[T comparable](t *testing.T, got, want T) {
t.Helper()
if got != want {
t.Errorf("expected:\n%v, got\n%v", want, got)
}
func TestErrorIs(t *testing.T) {
ExpectTrue(t, Failure("foo").Is(ErrFailure))
ExpectTrue(t, Failure("foo").With("bar").Is(ErrFailure))
ExpectFalse(t, Failure("foo").With("bar").Is(ErrInvalid))
ExpectFalse(t, Failure("foo").With("bar").With("baz").Is(ErrInvalid))
ExpectTrue(t, Invalid("foo", "bar").Is(ErrInvalid))
ExpectFalse(t, Invalid("foo", "bar").Is(ErrFailure))
ExpectFalse(t, Invalid("foo", "bar").Is(nil))
ExpectTrue(t, errors.Is(Failure("foo").Error(), ErrFailure))
ExpectTrue(t, errors.Is(Failure("foo").With(Invalid("bar", "baz")).Error(), ErrInvalid))
ExpectTrue(t, errors.Is(Failure("foo").With(Invalid("bar", "baz")).Error(), ErrFailure))
ExpectFalse(t, errors.Is(Failure("foo").With(Invalid("bar", "baz")).Error(), ErrNotExists))
}
func TestErrorIs(t *testing.T) {
AssertEq(t, Failure("foo").Is(ErrFailure), true)
AssertEq(t, Failure("foo").With("bar").Is(ErrFailure), true)
AssertEq(t, Failure("foo").With("bar").Is(ErrInvalid), false)
AssertEq(t, Failure("foo").With("bar").With("baz").Is(ErrInvalid), false)
func TestErrorNestedIs(t *testing.T) {
var err NestedError
ExpectTrue(t, err.Is(nil))
AssertEq(t, Invalid("foo", "bar").Is(ErrInvalid), true)
AssertEq(t, Invalid("foo", "bar").Is(ErrFailure), false)
err = Failure("some reason")
ExpectTrue(t, err.Is(ErrFailure))
ExpectFalse(t, err.Is(ErrAlreadyExist))
err.With(AlreadyExist("something", ""))
ExpectTrue(t, err.Is(ErrFailure))
ExpectTrue(t, err.Is(ErrAlreadyExist))
ExpectFalse(t, err.Is(ErrInvalid))
}
func TestIsNil(t *testing.T) {
var err NestedError
ExpectTrue(t, err.Is(nil))
ExpectFalse(t, err.HasError())
ExpectTrue(t, err == nil)
ExpectTrue(t, err.NoError())
eb := NewBuilder("")
returnNil := func() error {
return eb.Build().Error()
}
ExpectTrue(t, IsNil(returnNil()))
ExpectTrue(t, returnNil() == nil)
ExpectTrue(t, (err.
Subject("any").
With("something").
Extraf("foo %s", "bar")) == nil)
}
func TestErrorSimple(t *testing.T) {
ne := Failure("foo bar")
AssertEq(t, ne.Error(), "foo bar failed")
ExpectEqual(t, ne.String(), "foo bar failed")
ne = ne.Subject("baz")
AssertEq(t, ne.Error(), "foo bar failed for \"baz\"")
ExpectEqual(t, ne.String(), "foo bar failed for \"baz\"")
}
func TestErrorWith(t *testing.T) {
ne := Failure("foo").With("bar").With("baz")
AssertEq(t, ne.Error(), "foo failed:\n - bar\n - baz")
ExpectEqual(t, ne.String(), "foo failed:\n - bar\n - baz")
}
func TestErrorNested(t *testing.T) {
@@ -66,5 +104,6 @@ func TestErrorNested(t *testing.T) {
- inner3 failed for "action 3":
- 3
- 3`
AssertEq(t, ne.Error(), want)
ExpectEqual(t, ne.String(), want)
ExpectEqual(t, ne.Error().Error(), want)
}

View File

@@ -5,29 +5,48 @@ import (
)
var (
ErrFailure = stderrors.New("failed")
ErrInvalid = stderrors.New("invalid")
ErrUnsupported = stderrors.New("unsupported")
ErrNotExists = stderrors.New("does not exist")
ErrDuplicated = stderrors.New("duplicated")
ErrFailure = stderrors.New("failed")
ErrInvalid = stderrors.New("invalid")
ErrUnsupported = stderrors.New("unsupported")
ErrUnexpected = stderrors.New("unexpected")
ErrNotExists = stderrors.New("does not exist")
ErrAlreadyExist = stderrors.New("already exist")
)
const fmtSubjectWhat = "%w %v: %v"
func Failure(what string) NestedError {
return errorf("%s %w", what, ErrFailure)
}
func FailedWhy(what string, why string) NestedError {
return Failure(what).With(why)
}
func FailWith(what string, err any) NestedError {
return Failure(what).With(err)
}
func Invalid(subject, what any) NestedError {
return errorf("%w %v - %v", ErrInvalid, subject, what)
return errorf(fmtSubjectWhat, ErrInvalid, subject, what)
}
func Unsupported(subject, what any) NestedError {
return errorf("%w %v - %v", ErrUnsupported, subject, what)
return errorf(fmtSubjectWhat, ErrUnsupported, subject, what)
}
func NotExists(subject, what any) NestedError {
return errorf("%s %v - %v", subject, ErrNotExists, what)
func Unexpected(subject, what any) NestedError {
return errorf(fmtSubjectWhat, ErrUnexpected, subject, what)
}
func Duplicated(subject, what any) NestedError {
return errorf("%w %v: %v", ErrDuplicated, subject, what)
func UnexpectedError(err error) NestedError {
return errorf("%w error: %w", ErrUnexpected, err)
}
func NotExist(subject, what any) NestedError {
return errorf("%v %w: %v", subject, ErrNotExists, what)
}
func AlreadyExist(subject, what any) NestedError {
return errorf("%v %w: %v", subject, ErrAlreadyExist, what)
}

View File

@@ -1,22 +1,23 @@
module github.com/yusing/go-proxy
go 1.22
go 1.22.0
require (
github.com/docker/cli v27.1.1+incompatible
github.com/docker/docker v27.1.1+incompatible
github.com/docker/cli v27.3.1+incompatible
github.com/docker/docker v27.3.1+incompatible
github.com/fsnotify/fsnotify v1.7.0
github.com/go-acme/lego/v4 v4.17.4
github.com/go-acme/lego/v4 v4.18.0
github.com/puzpuzpuz/xsync/v3 v3.4.0
github.com/santhosh-tekuri/jsonschema v1.2.4
github.com/sirupsen/logrus v1.9.3
golang.org/x/net v0.28.0
golang.org/x/net v0.29.0
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cloudflare/cloudflare-go v0.101.0 // indirect
github.com/cloudflare/cloudflare-go v0.104.0 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/go-connections v0.5.0 // indirect
@@ -28,25 +29,28 @@ require (
github.com/goccy/go-json v0.10.3 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/miekg/dns v1.1.61 // indirect
github.com/miekg/dns v1.1.62 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/term v0.5.0 // indirect
github.com/morikuni/aec v1.0.0 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.0 // indirect
github.com/ovh/go-ovh v1.6.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0 // indirect
go.opentelemetry.io/otel v1.28.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.55.0 // indirect
go.opentelemetry.io/otel v1.30.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.24.0 // indirect
go.opentelemetry.io/otel/metric v1.28.0 // indirect
go.opentelemetry.io/otel/metric v1.30.0 // indirect
go.opentelemetry.io/otel/sdk v1.24.0 // indirect
go.opentelemetry.io/otel/trace v1.28.0 // indirect
golang.org/x/crypto v0.26.0 // indirect
golang.org/x/mod v0.20.0 // indirect
go.opentelemetry.io/otel/trace v1.30.0 // indirect
golang.org/x/crypto v0.27.0 // indirect
golang.org/x/mod v0.21.0 // indirect
golang.org/x/oauth2 v0.23.0 // indirect
golang.org/x/sync v0.8.0 // indirect
golang.org/x/sys v0.24.0 // indirect
golang.org/x/text v0.17.0 // indirect
golang.org/x/sys v0.25.0 // indirect
golang.org/x/text v0.18.0 // indirect
golang.org/x/time v0.6.0 // indirect
golang.org/x/tools v0.24.0 // indirect
golang.org/x/tools v0.25.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gotest.tools/v3 v3.5.1 // indirect
)

View File

@@ -4,8 +4,8 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERo
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
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.101.0 h1:SXWNSEDkbdY84iFIZGyTdWQwDfd98ljv0/4UubpleBQ=
github.com/cloudflare/cloudflare-go v0.101.0/go.mod h1:xXQHnoXKR48JlWbFS42i2al3nVqimVhcYvKnIdXLw9g=
github.com/cloudflare/cloudflare-go v0.104.0 h1:R/lB0dZupaZbOgibAH/BRrkFbZ6Acn/WsKg2iX2xXuY=
github.com/cloudflare/cloudflare-go v0.104.0/go.mod h1:pfUQ4PIG4ISI0/Mmc21Bp86UnFU0ktmPf3iTgbSL+cM=
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=
@@ -13,10 +13,10 @@ 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.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/cli v27.1.1+incompatible h1:goaZxOqs4QKxznZjjBWKONQci/MywhtRv2oNn0GkeZE=
github.com/docker/cli v27.1.1+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
github.com/docker/docker v27.1.1+incompatible h1:hO/M4MtV36kzKldqnA37IWhebRA+LnqqcqDja6kVaKY=
github.com/docker/docker v27.1.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/cli v27.3.1+incompatible h1:qEGdFBF3Xu6SCvCYhc7CzaQTlBmqDuzxPDpigSyeKQQ=
github.com/docker/cli v27.3.1+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
github.com/docker/docker v27.3.1+incompatible h1:KttF0XoteNTicmUtBO0L2tP+J7FGRFTjaEF4k6WdhfI=
github.com/docker/docker v27.3.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
@@ -25,8 +25,8 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2
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.17.4 h1:h0nePd3ObP6o7kAkndtpTzCw8shOZuWckNYeUQwo36Q=
github.com/go-acme/lego/v4 v4.17.4/go.mod h1:dU94SvPNqimEeb7EVilGGSnS0nU1O5Exir0pQ4QFL4U=
github.com/go-acme/lego/v4 v4.18.0 h1:2hH8KcdRBSb+p5o9VZIm61GAOXYALgILUCSs1Q+OYsk=
github.com/go-acme/lego/v4 v4.18.0/go.mod h1:Blkg3izvXpl3zxk7WKngIuwR2I/hvYVP3vRnvgBp7m8=
github.com/go-jose/go-jose/v4 v4.0.4 h1:VsjPI33J0SB9vQM6PLmNjoHqMQNGPiZ0rHL7Ni7Q6/E=
github.com/go-jose/go-jose/v4 v4.0.4/go.mod h1:NKb5HO1EZccyMpiZNbdUw/14tiXNyUJh188dfnMCAfc=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
@@ -45,14 +45,18 @@ github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD
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/jarcoal/httpmock v1.3.0 h1:2RJ8GP0IIaWwcC9Fp2BmVi8Kog3v2Hn7VXM3fTd+nuc=
github.com/jarcoal/httpmock v1.3.0/go.mod h1:3yb8rc4BI7TCBhFY8ng0gjuLKJNquuDNiPaZjnENuYg=
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/miekg/dns v1.1.61 h1:nLxbwF3XxhwVSm8g9Dghm9MHPaUZuqhPiGL+675ZmEs=
github.com/miekg/dns v1.1.61/go.mod h1:mnAarhS3nWaW+NVP2wTkYVIZyHNJ098SJZUki3eykwQ=
github.com/maxatome/go-testdeep v1.12.0 h1:Ql7Go8Tg0C1D/uMMX59LAoYK7LffeJQ6X2T04nTH68g=
github.com/maxatome/go-testdeep v1.12.0/go.mod h1:lPZc/HAcJMP92l7yI6TRz1aZN5URwUBUAfUNvrclaNM=
github.com/miekg/dns v1.1.62 h1:cN8OuEF1/x5Rq6Np+h1epln8OiyPWV+lROx9LxcGgIQ=
github.com/miekg/dns v1.1.62/go.mod h1:mvDlcItzm+br7MToIKqkglaGhlFMHJ9DTNNWONWXbNQ=
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=
@@ -63,10 +67,14 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
github.com/ovh/go-ovh v1.6.0 h1:ixLOwxQdzYDx296sXcgS35TOPEahJkpjMGtzPadCjQI=
github.com/ovh/go-ovh v1.6.0/go.mod h1:cTVDnl94z4tl8pP1uZ/8jlVxntjSIf09bNcQ5TJSC7c=
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/puzpuzpuz/xsync/v3 v3.4.0 h1:DuVBAdXuGFHv8adVXjWWZ63pJq+NRXOWVXlKDBZ+mJ4=
github.com/puzpuzpuz/xsync/v3 v3.4.0/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA=
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=
@@ -79,37 +87,39 @@ github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsT
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.53.0 h1:4K4tsIXefpVJtvA/8srF4V4y0akAoPHkIslgAkjixJA=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0/go.mod h1:jjdQuTGVsXV4vSs+CJ2qYDeDPf9yIJV23qlIzBm73Vg=
go.opentelemetry.io/otel v1.28.0 h1:/SqNcYk+idO0CxKEUOtKQClMK/MimZihKYMruSMViUo=
go.opentelemetry.io/otel v1.28.0/go.mod h1:q68ijF8Fc8CnMHKyzqL6akLO46ePnjkgfIMIjUIX9z4=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.55.0 h1:ZIg3ZT/aQ7AfKqdwp7ECpOK6vHqquXXuyTjIO8ZdmPs=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.55.0/go.mod h1:DQAwmETtZV00skUwgD6+0U89g80NKsJE3DCKeLLPQMI=
go.opentelemetry.io/otel v1.30.0 h1:F2t8sK4qf1fAmY9ua4ohFS/K+FUuOPemHUIXHtktrts=
go.opentelemetry.io/otel v1.30.0/go.mod h1:tFw4Br9b7fOS+uEao81PJjVMjW/5fvNCbpsDIXqP0pc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.24.0 h1:t6wl9SPayj+c7lEIFgm4ooDBZVb01IhLB4InpomhRw8=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.24.0/go.mod h1:iSDOcsnSA5INXzZtwaBPrKp/lWu/V14Dd+llD0oI2EA=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.24.0 h1:Xw8U6u2f8DK2XAkGRFV7BBLENgnTGX9i4rQRxJf+/vs=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.24.0/go.mod h1:6KW1Fm6R/s6Z3PGXwSJN2K4eT6wQB3vXX6CVnYX9NmM=
go.opentelemetry.io/otel/metric v1.28.0 h1:f0HGvSl1KRAU1DLgLGFjrwVyismPlnuU6JD6bOeuA5Q=
go.opentelemetry.io/otel/metric v1.28.0/go.mod h1:Fb1eVBFZmLVTMb6PPohq3TO9IIhUisDsbJoL/+uQW4s=
go.opentelemetry.io/otel/metric v1.30.0 h1:4xNulvn9gjzo4hjg+wzIKG7iNFEaBMX00Qd4QIZs7+w=
go.opentelemetry.io/otel/metric v1.30.0/go.mod h1:aXTfST94tswhWEb+5QjlSqG+cZlmyXy/u8jFpor3WqQ=
go.opentelemetry.io/otel/sdk v1.24.0 h1:YMPPDNymmQN3ZgczicBY3B6sf9n62Dlj9pWD3ucgoDw=
go.opentelemetry.io/otel/sdk v1.24.0/go.mod h1:KVrIYw6tEubO9E96HQpcmpTKDVn9gdv35HoYiQWGDFg=
go.opentelemetry.io/otel/trace v1.28.0 h1:GhQ9cUuQGmNDd5BTCP2dAvv75RdMxEfTmYejp+lkx9g=
go.opentelemetry.io/otel/trace v1.28.0/go.mod h1:jPyXzNPg6da9+38HEwElrQiHlVMTnVfM3/yv2OlIHaI=
go.opentelemetry.io/otel/trace v1.30.0 h1:7UBkkYzeg3C7kQX8VAidWh2biiQbtAKjyIML8dQ9wmc=
go.opentelemetry.io/otel/trace v1.30.0/go.mod h1:5EyKqTzzmyqB9bwtCCq6pDLktPK6fmGf/Dph+8VI02o=
go.opentelemetry.io/proto/otlp v1.1.0 h1:2Di21piLrCqJ3U3eXGCTPHE9R8Nh+0uglSnOyxikMeI=
go.opentelemetry.io/proto/otlp v1.1.0/go.mod h1:GpBHCBWiqvVLDqmHZsoMM3C5ySeKTC7ej/RNTae6MdY=
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.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw=
golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54=
golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A=
golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=
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.20.0 h1:utOm6MM3R3dnawAiJgn0y+xvuYRsm1RKM/4giyfDgV0=
golang.org/x/mod v0.20.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0=
golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE=
golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg=
golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo=
golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0=
golang.org/x/oauth2 v0.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs=
golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -119,20 +129,20 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg=
golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc=
golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224=
golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U=
golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.24.0 h1:J1shsA93PJUEVaUSaay7UXAyE8aimq3GW0pjlolpa24=
golang.org/x/tools v0.24.0/go.mod h1:YhNqVBIfWHdzvTLs0d8LCuMhkKUgSUKldakyV7W/WDQ=
golang.org/x/tools v0.25.0 h1:oFU9pkj/iJgs+0DT+VMHrx+oBKs/LJMV+Uvg78sl+fE=
golang.org/x/tools v0.25.0/go.mod h1:/vtpO8WL1N9cQC3FN5zPqb//fRXskFHbLKk4OW1Q7rg=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
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=
@@ -149,6 +159,8 @@ google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHh
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/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -2,10 +2,15 @@ package main
import (
"context"
"encoding/json"
"io"
"log"
"net/http"
"os"
"os/signal"
"reflect"
"runtime"
"strings"
"sync"
"syscall"
"time"
@@ -16,6 +21,7 @@ import (
"github.com/yusing/go-proxy/common"
"github.com/yusing/go-proxy/config"
"github.com/yusing/go-proxy/docker"
"github.com/yusing/go-proxy/docker/idlewatcher"
E "github.com/yusing/go-proxy/error"
R "github.com/yusing/go-proxy/route"
"github.com/yusing/go-proxy/server"
@@ -23,61 +29,76 @@ import (
)
func main() {
runtime.GOMAXPROCS(runtime.NumCPU())
args := common.GetArgs()
l := logrus.WithField("?", "init")
l := logrus.WithField("module", "main")
onShutdown := F.NewSlice[func()]()
if common.IsDebug {
logrus.SetLevel(logrus.DebugLevel)
}
if common.IsRunningAsService {
logrus.SetFormatter(&logrus.TextFormatter{
DisableColors: true,
DisableTimestamp: true,
DisableSorting: true,
})
if args.Command != common.CommandStart {
logrus.SetOutput(io.Discard)
} else {
logrus.SetFormatter(&logrus.TextFormatter{
DisableSorting: true,
FullTimestamp: true,
TimestampFormat: "01-02 15:04:05",
DisableSorting: true,
DisableLevelTruncation: true,
FullTimestamp: true,
TimestampFormat: "01-02 15:04:05",
})
}
if args.Command == common.CommandReload {
if err := apiUtils.ReloadServer(); err.IsNotNil() {
l.Fatal(err)
if err := apiUtils.ReloadServer(); err.HasError() {
log.Fatal(err)
}
log.Print("ok")
return
}
onShutdown := F.NewSlice[func()]()
// exit if only validate config
if args.Command == common.CommandValidate {
var err E.NestedError
data, err := E.Check(os.ReadFile(common.ConfigPath))
if err.IsNotNil() {
l.WithError(err).Fatalf("config error")
data, err := os.ReadFile(common.ConfigPath)
if err == nil {
err = config.Validate(data).Error()
}
if err = config.Validate(data); err.IsNotNil() {
l.WithError(err).Fatalf("config error")
if err != nil {
log.Fatal("config error: ", err)
}
l.Printf("config OK")
log.Print("config OK")
return
}
cfg, err := config.New()
if err.IsNotNil() {
l.Fatalf("config error: %s", err)
cfg, err := config.Load()
if err.IsFatal() {
log.Fatal(err)
}
onShutdown.Add(func() {
docker.CloseAllClients()
cfg.Dispose()
})
switch args.Command {
case common.CommandListConfigs:
printJSON(cfg.Value())
return
case common.CommandListRoutes:
printJSON(cfg.RoutesByAlias())
return
case common.CommandDebugListEntries:
printJSON(cfg.DumpEntries())
return
case common.CommandDebugListProviders:
printJSON(cfg.DumpProviders())
return
}
cfg.StartProxyProviders()
if err.HasError() {
l.Warn(err)
}
cfg.WatchChanges()
onShutdown.Add(docker.CloseAllClients)
onShutdown.Add(cfg.Dispose)
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGINT)
@@ -87,37 +108,31 @@ func main() {
autocert := cfg.GetAutoCertProvider()
if autocert != nil {
err = autocert.LoadCert()
if err.IsNotNil() {
l.Error(err)
l.Info("Now attempting to obtain a new certificate...")
if err = autocert.ObtainCert(); err.IsNotNil() {
ctx, certRenewalCancel := context.WithCancel(context.Background())
go autocert.ScheduleRenewal(ctx)
onShutdown.Add(certRenewalCancel)
} else {
l.Warn(err)
}
ctx, cancel := context.WithCancel(context.Background())
if err = autocert.Setup(ctx); err != nil && err.IsWarning() {
cancel()
l.Warn(err)
} else if err.IsFatal() {
l.Fatal(err)
} else {
for name, expiry := range autocert.GetExpiries() {
l.Infof("certificate %q: expire on %s", name, expiry)
}
onShutdown.Add(cancel)
}
} else {
l.Info("autocert not configured")
}
proxyServer := server.InitProxyServer(server.Options{
Name: "proxy",
CertProvider: autocert,
HTTPPort: common.ProxyHTTPPort,
HTTPSPort: common.ProxyHTTPSPort,
HTTPAddr: common.ProxyHTTPAddr,
HTTPSAddr: common.ProxyHTTPSAddr,
Handler: http.HandlerFunc(R.ProxyHandler),
RedirectToHTTPS: cfg.Value().RedirectToHTTPS,
})
apiServer := server.InitAPIServer(server.Options{
Name: "api",
CertProvider: autocert,
HTTPPort: common.APIHTTPPort,
HTTPAddr: common.APIHTTPAddr,
Handler: api.NewHandler(cfg),
RedirectToHTTPS: cfg.Value().RedirectToHTTPS,
})
@@ -127,6 +142,9 @@ func main() {
onShutdown.Add(proxyServer.Stop)
onShutdown.Add(apiServer.Stop)
go idlewatcher.Start()
onShutdown.Add(idlewatcher.Stop)
// wait for signal
<-sig
@@ -138,7 +156,9 @@ func main() {
wg.Add(onShutdown.Size())
onShutdown.ForEach(func(f func()) {
go func() {
l.Debugf("waiting for %s to complete...", funcName(f))
f()
l.Debugf("%s done", funcName(f))
wg.Done()
}()
})
@@ -147,10 +167,28 @@ func main() {
close(done)
}()
timeout := time.After(time.Duration(cfg.Value().TimeoutShutdown) * time.Second)
select {
case <-done:
logrus.Info("shutdown complete")
case <-time.After(time.Duration(cfg.Value().TimeoutShutdown) * time.Second):
case <-timeout:
logrus.Info("timeout waiting for shutdown")
onShutdown.ForEach(func(f func()) {
l.Warnf("%s() is still running", funcName(f))
})
}
}
func funcName(f func()) string {
parts := strings.Split(runtime.FuncForPC(reflect.ValueOf(f).Pointer()).Name(), "/go-proxy/")
return parts[len(parts)-1]
}
func printJSON(obj any) {
j, err := E.Check(json.Marshal(obj))
if err.HasError() {
logrus.Fatal(err)
}
rawLogger := log.New(os.Stdout, "", 0)
rawLogger.Printf("%s", j) // raw output for convenience using "jq"
}

View File

@@ -9,5 +9,5 @@ type (
Provider string `json:"provider"`
Options AutocertProviderOpt `yaml:",flow" json:"options"`
}
AutocertProviderOpt map[string]string
AutocertProviderOpt map[string]any
)

View File

@@ -1,43 +0,0 @@
package model
import (
"net/http"
"strings"
F "github.com/yusing/go-proxy/utils/functional"
)
type (
ProxyEntry struct {
Alias string `yaml:"-" json:"-"`
Scheme string `yaml:"scheme" json:"scheme"`
Host string `yaml:"host" json:"host"`
Port string `yaml:"port" json:"port"`
NoTLSVerify bool `yaml:"no_tls_verify" json:"no_tls_verify"` // http proxy only
Path string `yaml:"path" json:"path"` // http proxy only
SetHeaders http.Header `yaml:"set_headers" json:"set_headers"` // http proxy only
HideHeaders []string `yaml:"hide_headers" json:"hide_headers"` // http proxy only
}
ProxyEntries = *F.Map[string, *ProxyEntry]
)
var NewProxyEntries = F.NewMap[string, *ProxyEntry]
func (e *ProxyEntry) SetDefaults() {
if e.Scheme == "" {
if strings.ContainsRune(e.Port, ':') {
e.Scheme = "tcp"
} else {
switch e.Port {
case "443", "8443":
e.Scheme = "https"
default:
e.Scheme = "http"
}
}
}
if e.Path == "" {
e.Path = "/"
}
}

View File

@@ -1,9 +0,0 @@
package model
type (
ProxyProvider struct {
Kind string `json:"kind"` // docker, file
Value string `json:"value"`
}
ProxyProviders = map[string]ProxyProvider
)

View File

@@ -0,0 +1,6 @@
package model
type ProxyProviders struct {
Files []string `yaml:"include" json:"include"` // docker, file
Docker map[string]string `yaml:"docker" json:"docker"`
}

90
src/models/raw_entry.go Normal file
View File

@@ -0,0 +1,90 @@
package model
import (
"strconv"
"strings"
. "github.com/yusing/go-proxy/common"
D "github.com/yusing/go-proxy/docker"
F "github.com/yusing/go-proxy/utils/functional"
)
type (
RawEntry struct {
// raw entry object before validation
// loaded from docker labels or yaml file
Alias string `yaml:"-" json:"-"`
Scheme string `yaml:"scheme" json:"scheme"`
Host string `yaml:"host" json:"host"`
Port string `yaml:"port" json:"port"`
NoTLSVerify bool `yaml:"no_tls_verify" json:"no_tls_verify"` // https proxy only
PathPatterns []string `yaml:"path_patterns" json:"path_patterns"` // http(s) proxy only
SetHeaders map[string]string `yaml:"set_headers" json:"set_headers"` // http(s) proxy only
HideHeaders []string `yaml:"hide_headers" json:"hide_headers"` // http(s) proxy only
/* Docker only */
*D.ProxyProperties `yaml:"-" json:"proxy_properties"`
}
RawEntries = F.Map[string, *RawEntry]
)
var NewProxyEntries = F.NewMapOf[string, *RawEntry]
func (e *RawEntry) SetDefaults() {
if e.ProxyProperties == nil {
e.ProxyProperties = &D.ProxyProperties{}
}
if e.Scheme == "" {
switch {
case strings.ContainsRune(e.Port, ':'):
e.Scheme = "tcp"
case e.ProxyProperties != nil:
if _, ok := ServiceNamePortMapTCP[e.ImageName]; ok {
e.Scheme = "tcp"
}
}
}
if e.Scheme == "" {
switch e.Port {
case "443", "8443":
e.Scheme = "https"
default:
e.Scheme = "http"
}
}
if e.Host == "" {
e.Host = "localhost"
}
if e.Port == "" {
e.Port = e.FirstPort
}
if e.Port == "" {
if port, ok := ServiceNamePortMapTCP[e.Port]; ok {
e.Port = strconv.Itoa(port)
} else if port, ok := ImageNamePortMapHTTP[e.Port]; ok {
e.Port = strconv.Itoa(port)
} else {
switch e.Scheme {
case "http":
e.Port = "80"
case "https":
e.Port = "443"
}
}
}
if e.IdleTimeout == "" {
e.IdleTimeout = IdleTimeoutDefault
}
if e.WakeTimeout == "" {
e.WakeTimeout = WakeTimeoutDefault
}
if e.StopTimeout == "" {
e.StopTimeout = StopTimeoutDefault
}
if e.StopMethod == "" {
e.StopMethod = StopMethodDefault
}
}

View File

@@ -1,10 +1,5 @@
package proxy
var (
PathMode_Forward = "forward"
PathMode_RemovedPath = ""
)
const (
StreamType_UDP string = "udp"
StreamType_TCP string = "tcp"
@@ -19,4 +14,3 @@ var (
HTTPSchemes = []string{"http", "https"}
ValidSchemes = append(StreamSchemes, HTTPSchemes...)
)

View File

@@ -1,9 +1,10 @@
package proxy
import (
"fmt"
"net/http"
"net/url"
"strconv"
"time"
E "github.com/yusing/go-proxy/error"
M "github.com/yusing/go-proxy/models"
@@ -11,16 +12,24 @@ import (
)
type (
Entry struct { // real model after validation
Alias T.Alias
Scheme T.Scheme
Host T.Host
Port T.Port
URL *url.URL
NoTLSVerify bool
Path T.Path
SetHeaders http.Header
HideHeaders []string
ReverseProxyEntry struct { // real model after validation
Alias T.Alias
Scheme T.Scheme
URL *url.URL
NoTLSVerify bool
PathPatterns T.PathPatterns
SetHeaders http.Header
HideHeaders []string
/* Docker only */
IdleTimeout time.Duration
WakeTimeout time.Duration
StopMethod T.StopMethod
StopTimeout int
StopSignal T.Signal
DockerHost string
ContainerName string
ContainerRunning bool
}
StreamEntry struct {
Alias T.Alias `json:"alias"`
@@ -30,65 +39,106 @@ type (
}
)
func NewEntry(m *M.ProxyEntry) (any, E.NestedError) {
func (rp *ReverseProxyEntry) UseIdleWatcher() bool {
return rp.IdleTimeout > 0 && rp.DockerHost != ""
}
func ValidateEntry(m *M.RawEntry) (any, E.NestedError) {
m.SetDefaults()
scheme, err := T.NewScheme(m.Scheme)
if err.IsNotNil() {
if err.HasError() {
return nil, err
}
var entry any
e := E.NewBuilder("error validating proxy entry")
if scheme.IsStream() {
return validateStreamEntry(m)
entry = validateStreamEntry(m, e)
} else {
entry = validateRPEntry(m, scheme, e)
}
return validateEntry(m, *scheme)
if err := e.Build(); err.HasError() {
return nil, err
}
return entry, nil
}
func validateEntry(m *M.ProxyEntry, s T.Scheme) (*Entry, E.NestedError) {
host, err := T.NewHost(m.Host)
if err.IsNotNil() {
return nil, err
func validateRPEntry(m *M.RawEntry, s T.Scheme, b E.Builder) *ReverseProxyEntry {
var stopTimeOut time.Duration
host, err := T.ValidateHost(m.Host)
b.Add(err)
port, err := T.ValidatePort(m.Port)
b.Add(err)
pathPatterns, err := T.ValidatePathPatterns(m.PathPatterns)
b.Add(err)
setHeaders, err := T.ValidateHTTPHeaders(m.SetHeaders)
b.Add(err)
url, err := E.Check(url.Parse(fmt.Sprintf("%s://%s:%d", s, host, port)))
b.Add(err)
idleTimeout, err := T.ValidateDurationPostitive(m.IdleTimeout)
b.Add(err)
wakeTimeout, err := T.ValidateDurationPostitive(m.WakeTimeout)
b.Add(err)
stopMethod, err := T.ValidateStopMethod(m.StopMethod)
b.Add(err)
if stopMethod == T.StopMethodStop {
stopTimeOut, err = T.ValidateDurationPostitive(m.StopTimeout)
b.Add(err)
}
port, err := T.NewPort(m.Port)
if err.IsNotNil() {
return nil, err
stopSignal, err := T.ValidateSignal(m.StopSignal)
b.Add(err)
if err.HasError() {
return nil
}
path, err := T.NewPath(m.Path)
if err.IsNotNil() {
return nil, err
return &ReverseProxyEntry{
Alias: T.NewAlias(m.Alias),
Scheme: s,
URL: url,
NoTLSVerify: m.NoTLSVerify,
PathPatterns: pathPatterns,
SetHeaders: setHeaders,
HideHeaders: m.HideHeaders,
IdleTimeout: idleTimeout,
WakeTimeout: wakeTimeout,
StopMethod: stopMethod,
StopTimeout: int(stopTimeOut.Seconds()), // docker api takes integer seconds for timeout argument
StopSignal: stopSignal,
DockerHost: m.DockerHost,
ContainerName: m.ContainerName,
ContainerRunning: m.Running,
}
url, err := E.Check(url.Parse(s.String() + "://" + host.String() + ":" + strconv.Itoa(int(port))))
if err.IsNotNil() {
return nil, err
}
return &Entry{
Alias: T.NewAlias(m.Alias),
Scheme: s,
Host: host,
Port: port,
URL: url,
NoTLSVerify: m.NoTLSVerify,
Path: path,
SetHeaders: m.SetHeaders,
HideHeaders: m.HideHeaders,
}, E.Nil()
}
func validateStreamEntry(m *M.ProxyEntry) (*StreamEntry, E.NestedError) {
host, err := T.NewHost(m.Host)
if err.IsNotNil() {
return nil, err
}
port, err := T.NewStreamPort(m.Port)
if err.IsNotNil() {
return nil, err
}
scheme, err := T.NewStreamScheme(m.Scheme)
if err.IsNotNil() {
return nil, err
func validateStreamEntry(m *M.RawEntry, b E.Builder) *StreamEntry {
host, err := T.ValidateHost(m.Host)
b.Add(err)
port, err := T.ValidateStreamPort(m.Port)
b.Add(err)
scheme, err := T.ValidateStreamScheme(m.Scheme)
b.Add(err)
if b.HasError() {
return nil
}
return &StreamEntry{
Alias: T.NewAlias(m.Alias),
Scheme: *scheme,
Host: host,
Port: port,
}, E.Nil()
}
}

View File

@@ -1,23 +1,6 @@
package fields
import (
"strings"
F "github.com/yusing/go-proxy/utils/functional"
type (
Alias string
NewAlias = Alias
)
type Alias struct{ F.Stringable }
type Aliases struct{ *F.Slice[Alias] }
func NewAlias(s string) Alias {
return Alias{F.NewStringable(s)}
}
func NewAliases(s string) Aliases {
split := strings.Split(s, ",")
a := Aliases{F.NewSliceN[Alias](len(split))}
for i, v := range split {
a.Set(i, NewAlias(v))
}
return a
}

View File

@@ -0,0 +1,19 @@
package fields
import (
"net/http"
"strings"
E "github.com/yusing/go-proxy/error"
)
func ValidateHTTPHeaders(headers map[string]string) (http.Header, E.NestedError) {
h := make(http.Header)
for k, v := range headers {
vSplit := strings.Split(v, ",")
for _, header := range vSplit {
h.Add(k, strings.TrimSpace(header))
}
}
return h, nil
}

View File

@@ -2,19 +2,11 @@ package fields
import (
E "github.com/yusing/go-proxy/error"
F "github.com/yusing/go-proxy/utils/functional"
)
type Host struct{ F.Stringable }
type Host string
type Subdomain = Alias
func NewHost(s string) (Host, E.NestedError) {
return Host{F.NewStringable(s)}, E.Nil()
}
func (h Host) Subdomain() (*Subdomain, E.NestedError) {
if i := h.IndexRune(':'); i != -1 {
return &Subdomain{h.SubStr(0, i)}, E.Nil()
}
return nil, E.Invalid("host", h)
func ValidateHost(s string) (Host, E.NestedError) {
return Host(s), nil
}

View File

@@ -1,15 +0,0 @@
package fields
import (
E "github.com/yusing/go-proxy/error"
F "github.com/yusing/go-proxy/utils/functional"
)
type Path struct{ F.Stringable }
func NewPath(s string) (Path, E.NestedError) {
if s == "" || s[0] == '/' {
return Path{F.NewStringable(s)}, E.Nil()
}
return Path{}, E.Invalid("path", s).With("must be empty or start with '/'")
}

View File

@@ -1,25 +1,24 @@
package fields
import (
F "github.com/yusing/go-proxy/utils/functional"
E "github.com/yusing/go-proxy/error"
)
type PathMode struct{ F.Stringable }
type PathMode string
func NewPathMode(pm string) (PathMode, E.NestedError) {
switch pm {
case "", "forward":
return PathMode{F.NewStringable(pm)}, E.Nil()
return PathMode(pm), nil
default:
return PathMode{}, E.Invalid("path mode", pm)
return "", E.Invalid("path mode", pm)
}
}
func (p PathMode) IsRemove() bool {
return p.String() == ""
return p == ""
}
func (p PathMode) IsForward() bool {
return p.String() == "forward"
return p == "forward"
}

View File

@@ -0,0 +1,37 @@
package fields
import (
"regexp"
E "github.com/yusing/go-proxy/error"
)
type PathPattern string
type PathPatterns = []PathPattern
func NewPathPattern(s string) (PathPattern, E.NestedError) {
if len(s) == 0 {
return "", E.Invalid("path", "must not be empty")
}
if !pathPattern.MatchString(string(s)) {
return "", E.Invalid("path pattern", s)
}
return PathPattern(s), nil
}
func ValidatePathPatterns(s []string) (PathPatterns, E.NestedError) {
if len(s) == 0 {
return []PathPattern{"/"}, nil
}
pp := make(PathPatterns, len(s))
for i, v := range s {
if pattern, err := NewPathPattern(v); err.HasError() {
return nil, err
} else {
pp[i] = pattern
}
}
return pp, nil
}
var pathPattern = regexp.MustCompile("^((GET|POST|DELETE|PUT|PATCH|HEAD|OPTIONS|CONNECT)\\s)?(/\\w*)+/?$")

View File

@@ -8,27 +8,27 @@ import (
type Port int
func NewPort(v string) (Port, E.NestedError) {
func ValidatePort(v string) (Port, E.NestedError) {
p, err := strconv.Atoi(v)
if err != nil {
return ErrPort, E.From(err)
return ErrPort, E.Invalid("port number", v).With(err)
}
return NewPortInt(p)
}
func NewPortInt(v int) (Port, E.NestedError) {
func NewPortInt[Int int | uint16](v Int) (Port, E.NestedError) {
pp := Port(v)
if err := pp.boundCheck(); err.IsNotNil() {
if err := pp.boundCheck(); err.HasError() {
return ErrPort, err
}
return pp, E.Nil()
return pp, nil
}
func (p Port) boundCheck() E.NestedError {
if p < MinPort || p > MaxPort {
return E.Invalid("port", p)
}
return E.Nil()
return nil
}
const (

View File

@@ -1,37 +1,21 @@
package fields
import (
"strings"
E "github.com/yusing/go-proxy/error"
F "github.com/yusing/go-proxy/utils/functional"
)
type Scheme struct{ F.Stringable }
type Scheme string
func NewScheme(s string) (*Scheme, E.NestedError) {
func NewScheme(s string) (Scheme, E.NestedError) {
switch s {
case "http", "https", "tcp", "udp":
return &Scheme{F.NewStringable(s)}, E.Nil()
return Scheme(s), nil
}
return nil, E.Invalid("scheme", s)
return "", E.Invalid("scheme", s)
}
func NewSchemeFromPort(p string) (*Scheme, E.NestedError) {
var s string
switch {
case strings.ContainsRune(p, ':'):
s = "tcp"
case strings.HasSuffix(p, "443"):
s = "https"
default:
s = "http"
}
return &Scheme{F.NewStringable(s)}, E.Nil()
}
func (s Scheme) IsHTTP() bool { return s.String() == "http" }
func (s Scheme) IsHTTPS() bool { return s.String() == "https" }
func (s Scheme) IsTCP() bool { return s.String() == "tcp" }
func (s Scheme) IsUDP() bool { return s.String() == "udp" }
func (s Scheme) IsHTTP() bool { return s == "http" }
func (s Scheme) IsHTTPS() bool { return s == "https" }
func (s Scheme) IsTCP() bool { return s == "tcp" }
func (s Scheme) IsUDP() bool { return s == "udp" }
func (s Scheme) IsStream() bool { return s.IsTCP() || s.IsUDP() }

View File

@@ -0,0 +1,17 @@
package fields
import (
E "github.com/yusing/go-proxy/error"
)
type Signal string
func ValidateSignal(s string) (Signal, E.NestedError) {
switch s {
case "", "SIGINT", "SIGTERM", "SIGHUP", "SIGQUIT",
"INT", "TERM", "HUP", "QUIT":
return Signal(s), nil
}
return "", E.Invalid("signal", s)
}

View File

@@ -0,0 +1,23 @@
package fields
import (
E "github.com/yusing/go-proxy/error"
)
type StopMethod string
const (
StopMethodPause StopMethod = "pause"
StopMethodStop StopMethod = "stop"
StopMethodKill StopMethod = "kill"
)
func ValidateStopMethod(s string) (StopMethod, E.NestedError) {
sm := StopMethod(s)
switch sm {
case StopMethodPause, StopMethodStop, StopMethodKill:
return sm, nil
default:
return "", E.Invalid("stop_method", sm)
}
}

View File

@@ -1,6 +1,7 @@
package fields
import (
"fmt"
"strings"
"github.com/yusing/go-proxy/common"
@@ -12,38 +13,38 @@ type StreamPort struct {
ProxyPort Port `json:"proxy"`
}
func NewStreamPort(p string) (StreamPort, E.NestedError) {
func ValidateStreamPort(p string) (StreamPort, E.NestedError) {
split := strings.Split(p, ":")
if len(split) != 2 {
return StreamPort{}, E.Invalid("stream port", p).With("should be in 'x:y' format")
return StreamPort{}, E.Invalid("stream port", fmt.Sprintf("%q", p)).With("should be in 'x:y' format")
}
listeningPort, err := NewPort(split[0])
if err.IsNotNil() {
listeningPort, err := ValidatePort(split[0])
if err.HasError() {
return StreamPort{}, err
}
if err = listeningPort.boundCheck(); err.IsNotNil() {
if err = listeningPort.boundCheck(); err.HasError() {
return StreamPort{}, err
}
proxyPort, err := NewPort(split[1])
if err.IsNotNil() {
proxyPort, err := ValidatePort(split[1])
if err.HasError() {
proxyPort, err = parseNameToPort(split[1])
if err.IsNotNil() {
if err.HasError() {
return StreamPort{}, err
}
}
if err = proxyPort.boundCheck(); err.IsNotNil() {
if err = proxyPort.boundCheck(); err.HasError() {
return StreamPort{}, err
}
return StreamPort{ListeningPort: listeningPort, ProxyPort: proxyPort}, E.Nil()
return StreamPort{ListeningPort: listeningPort, ProxyPort: proxyPort}, nil
}
func parseNameToPort(name string) (Port, E.NestedError) {
port, ok := common.NamePortMapTCP[name]
port, ok := common.ServiceNamePortMapTCP[name]
if !ok {
return -1, E.Unsupported("service", name)
}
return Port(port), E.Nil()
return Port(port), nil
}

View File

@@ -1,17 +1,18 @@
package fields
import (
"fmt"
"strings"
E "github.com/yusing/go-proxy/error"
)
type StreamScheme struct {
ListeningScheme *Scheme `json:"listening"`
ProxyScheme *Scheme `json:"proxy"`
ListeningScheme Scheme `json:"listening"`
ProxyScheme Scheme `json:"proxy"`
}
func NewStreamScheme(s string) (ss *StreamScheme, err E.NestedError) {
func ValidateStreamScheme(s string) (ss *StreamScheme, err E.NestedError) {
ss = &StreamScheme{}
parts := strings.Split(s, ":")
if len(parts) == 1 {
@@ -20,23 +21,23 @@ func NewStreamScheme(s string) (ss *StreamScheme, err E.NestedError) {
return nil, E.Invalid("stream scheme", s)
}
ss.ListeningScheme, err = NewScheme(parts[0])
if err.IsNotNil() {
if err.HasError() {
return nil, err
}
ss.ProxyScheme, err = NewScheme(parts[1])
if err.IsNotNil() {
if err.HasError() {
return nil, err
}
return ss, E.Nil()
return ss, nil
}
func (s StreamScheme) String() string {
return s.ListeningScheme.String() + " -> " + s.ProxyScheme.String()
return fmt.Sprintf("%s:%s", s.ListeningScheme, s.ProxyScheme)
}
// IsCoherent checks if the ListeningScheme and ProxyScheme of the StreamScheme are equal.
//
// It returns a boolean value indicating whether the ListeningScheme and ProxyScheme are equal.
func (s StreamScheme) IsCoherent() bool {
return *s.ListeningScheme == *s.ProxyScheme
return s.ListeningScheme == s.ProxyScheme
}

View File

@@ -0,0 +1,18 @@
package fields
import (
"time"
E "github.com/yusing/go-proxy/error"
)
func ValidateDurationPostitive(value string) (time.Duration, E.NestedError) {
d, err := time.ParseDuration(value)
if err != nil {
return 0, E.Invalid("duration", value)
}
if d < 0 {
return 0, E.Invalid("duration", "negative value")
}
return d, nil
}

View File

@@ -1,3 +0,0 @@
package provider
const wildcardAlias = "*"

View File

@@ -2,148 +2,209 @@ package provider
import (
"fmt"
"regexp"
"strconv"
"strings"
"github.com/docker/docker/api/types"
D "github.com/yusing/go-proxy/docker"
E "github.com/yusing/go-proxy/error"
M "github.com/yusing/go-proxy/models"
PT "github.com/yusing/go-proxy/proxy/fields"
R "github.com/yusing/go-proxy/route"
W "github.com/yusing/go-proxy/watcher"
)
type DockerProvider struct {
dockerHost string
dockerHost, hostname string
}
func DockerProviderImpl(model *M.ProxyProvider) ProviderImpl {
return &DockerProvider{dockerHost: model.Value}
var AliasRefRegex = regexp.MustCompile(`\$\d+`)
func DockerProviderImpl(dockerHost string) (ProviderImpl, E.NestedError) {
hostname, err := D.ParseDockerHostname(dockerHost)
if err.HasError() {
return nil, err
}
return &DockerProvider{dockerHost: dockerHost, hostname: hostname}, nil
}
// GetProxyEntries returns proxy entries from a docker client.
//
// It retrieves the docker client information using the dockerhelper.GetClientInfo method.
// Then, it iterates over the containers in the docker client information and calls
// the getEntriesFromLabels method to get the proxy entries for each container.
// Any errors encountered during the process are added to the ne error object.
// Finally, it returns the collected proxy entries and the ne error object.
//
// Parameters:
// - p: A pointer to the DockerProvider struct.
//
// Returns:
// - P.EntryModelSlice: A slice of EntryModel structs representing the proxy entries.
// - error: An error object if there was an error retrieving the docker client information or parsing the labels.
func (p DockerProvider) GetProxyEntries() (M.ProxyEntries, E.NestedError) {
info, err := D.GetClientInfo(p.dockerHost)
if err.IsNotNil() {
return nil, E.From(err)
}
entries := M.NewProxyEntries()
errors := E.NewBuilder("errors when parse docker labels for %q", p.dockerHost)
for _, container := range info.Containers {
en, err := p.getEntriesFromLabels(&container, info.Host)
if err.IsNotNil() {
errors.Add(err)
}
// although err is not nil
// there may be some valid entries in `en`
dups := entries.MergeWith(en)
// add the duplicate proxy entries to the error
dups.EachKV(func(k string, v *M.ProxyEntry) {
errors.Addf("duplicate alias %s", k)
})
}
return entries, errors.Build()
func (p *DockerProvider) String() string {
return fmt.Sprintf("docker:%s", p.dockerHost)
}
func (p *DockerProvider) NewWatcher() W.Watcher {
return W.NewDockerWatcher(p.dockerHost)
}
// Returns a list of proxy entries for a container.
// Always non-nil
func (p *DockerProvider) getEntriesFromLabels(container *types.Container, clientHost string) (M.ProxyEntries, E.NestedError) {
var mainAlias string
var aliases PT.Aliases
// set mainAlias to docker compose service name if available
if serviceName, ok := container.Labels["com.docker.compose.service"]; ok {
mainAlias = serviceName
}
// if mainAlias is not set,
// or container name is different from service name
// use container name
if containerName := strings.TrimPrefix(container.Names[0], "/"); containerName != mainAlias {
mainAlias = containerName
}
if l, ok := container.Labels["proxy.aliases"]; ok {
aliases = PT.NewAliases(l)
delete(container.Labels, "proxy.aliases")
} else {
aliases = PT.NewAliases(mainAlias)
}
func (p *DockerProvider) LoadRoutesImpl() (routes R.Routes, err E.NestedError) {
routes = R.NewRoutes()
entries := M.NewProxyEntries()
// find first port, return if no port exposed
defaultPort := findFirstPort(container)
if defaultPort == PT.NoPort {
return entries, E.Nil()
info, err := D.GetClientInfo(p.dockerHost, true)
if err.HasError() {
return routes, E.FailWith("connect to docker", err)
}
// init entries map for all aliases
aliases.ForEach(func(a PT.Alias) {
entries.Set(a.String(), &M.ProxyEntry{
Alias: a.String(),
Host: clientHost,
Port: fmt.Sprint(defaultPort),
errors := E.NewBuilder("errors when parse docker labels")
for _, c := range info.Containers {
container := D.FromDocker(&c, p.dockerHost)
if container.IsExcluded {
continue
}
newEntries, err := p.entriesFromContainerLabels(container)
if err.HasError() {
errors.Add(err)
}
// although err is not nil
// there may be some valid entries in `en`
dups := entries.MergeFrom(newEntries)
// add the duplicate proxy entries to the error
dups.RangeAll(func(k string, v *M.RawEntry) {
errors.Addf("duplicate alias %s", k)
})
}
entries.RangeAll(func(_ string, e *M.RawEntry) {
e.DockerHost = p.dockerHost
})
errors := E.NewBuilder("failed to apply label for %q", mainAlias)
for key, val := range container.Labels {
lbl, err := D.ParseLabel(key, val)
if err.IsNotNil() {
errors.Add(E.From(err).Subject(key))
continue
routes, err = R.FromEntries(entries)
errors.Add(err)
return routes, errors.Build()
}
func (p *DockerProvider) OnEvent(event W.Event, routes R.Routes) (res EventResult) {
b := E.NewBuilder("event %s error", event)
defer b.To(&res.err)
routes.RangeAll(func(k string, v R.Route) {
if v.Entry().ContainerName == event.ActorName {
b.Add(v.Stop())
routes.Delete(k)
res.nRemoved++
}
if lbl.Namespace != D.NSProxy {
continue
}
if lbl.Target == wildcardAlias {
// apply label for all aliases
entries.EachKV(func(a string, e *M.ProxyEntry) {
if err = D.ApplyLabel(e, lbl); err.IsNotNil() {
errors.Add(E.From(err).Subject(lbl.Target))
}
})
})
client, err := D.ConnectClient(p.dockerHost)
if err.HasError() {
b.Add(E.FailWith("connect to docker", err))
return
}
defer client.Close()
cont, err := client.Inspect(event.ActorID)
if err.HasError() {
b.Add(E.FailWith("inspect container", err))
return
}
entries, err := p.entriesFromContainerLabels(cont)
b.Add(err)
entries.RangeAll(func(alias string, entry *M.RawEntry) {
if routes.Has(alias) {
b.Add(E.AlreadyExist("alias", alias))
} else {
config, ok := entries.UnsafeGet(lbl.Target)
if route, err := R.NewRoute(entry); err.HasError() {
b.Add(err)
} else {
routes.Store(alias, route)
b.Add(route.Start())
res.nAdded++
}
}
})
return
}
// Returns a list of proxy entries for a container.
// Always non-nil
func (p *DockerProvider) entriesFromContainerLabels(container D.Container) (M.RawEntries, E.NestedError) {
entries := M.NewProxyEntries()
// init entries map for all aliases
for _, a := range container.Aliases {
entries.Store(a, &M.RawEntry{
Alias: a,
Host: p.hostname,
ProxyProperties: container.ProxyProperties,
})
}
errors := E.NewBuilder("failed to apply label")
for key, val := range container.Labels {
errors.Add(p.applyLabel(container, entries, key, val))
}
// selecting correct host port
if container.HostConfig.NetworkMode != "host" {
for _, a := range container.Aliases {
entry, ok := entries.Load(a)
if !ok {
errors.Add(E.NotExists("alias", lbl.Target))
continue
}
if err = D.ApplyLabel(config, lbl); err.IsNotNil() {
errors.Add(err.Subject(lbl.Target))
for _, p := range container.Ports {
containerPort := strconv.Itoa(int(p.PrivatePort))
publicPort := strconv.Itoa(int(p.PublicPort))
entryPortSplit := strings.Split(entry.Port, ":")
if len(entryPortSplit) == 2 && entryPortSplit[1] == containerPort {
entryPortSplit[1] = publicPort
} else if len(entryPortSplit) == 1 && entryPortSplit[0] == containerPort {
entryPortSplit[0] = publicPort
}
entry.Port = strings.Join(entryPortSplit, ":")
}
}
}
return entries, errors.Build()
return entries, errors.Build().Subject(container.ContainerName)
}
func findFirstPort(c *types.Container) (pp PT.Port) {
for _, p := range c.Ports {
if p.PublicPort != 0 || c.HostConfig.NetworkMode == "host" {
pp, _ = PT.NewPortInt(int(p.PublicPort))
func (p *DockerProvider) applyLabel(container D.Container, entries M.RawEntries, key, val string) (res E.NestedError) {
b := E.NewBuilder("errors in label %s", key)
defer b.To(&res)
lbl, err := D.ParseLabel(key, val)
if err.HasError() {
b.Add(err.Subject(key))
}
if lbl.Namespace != D.NSProxy {
return
}
if lbl.Target == D.WildcardAlias {
// apply label for all aliases
entries.RangeAll(func(a string, e *M.RawEntry) {
if err = D.ApplyLabel(e, lbl); err.HasError() {
b.Add(err.Subject(lbl.Target))
}
})
} else {
refErr := E.NewBuilder("errors parsing alias references")
lbl.Target = AliasRefRegex.ReplaceAllStringFunc(lbl.Target, func(ref string) string {
index, err := strconv.Atoi(ref[1:])
if err != nil {
refErr.Add(E.Invalid("integer", ref))
return ref
}
if index < 1 || index > len(container.Aliases) {
refErr.Add(E.Invalid("index", ref).Extraf("index out of range"))
return ref
}
return container.Aliases[index-1]
})
if refErr.HasError() {
b.Add(refErr.Build())
return
}
config, ok := entries.Load(lbl.Target)
if !ok {
b.Add(E.NotExist("alias", lbl.Target))
return
}
if err = D.ApplyLabel(config, lbl); err.HasError() {
b.Add(err.Subject(lbl.Target))
}
}
return PT.NoPort
return
}

View File

@@ -0,0 +1,167 @@
package provider
import (
"strings"
"testing"
"github.com/docker/docker/api/types"
"github.com/yusing/go-proxy/common"
D "github.com/yusing/go-proxy/docker"
E "github.com/yusing/go-proxy/error"
F "github.com/yusing/go-proxy/utils/functional"
. "github.com/yusing/go-proxy/utils/testing"
)
func get[KT comparable, VT any](m F.Map[KT, VT], key KT) VT {
v, _ := m.Load(key)
return v
}
var dummyNames = []string{"/a"}
func TestApplyLabelFieldValidity(t *testing.T) {
pathPatterns := `
- /
- POST /upload/{$}
- GET /static
`[1:]
pathPatternsExpect := []string{
"/",
"POST /upload/{$}",
"GET /static",
}
setHeaders := `
X_Custom_Header1: value1
X_Custom_Header1: value2
X_Custom_Header2: value3
`[1:]
setHeadersExpect := map[string]string{
"X_Custom_Header1": "value1, value2",
"X_Custom_Header2": "value3",
}
hideHeaders := `
- X-Custom-Header1
- X-Custom-Header2
`[1:]
hideHeadersExpect := []string{
"X-Custom-Header1",
"X-Custom-Header2",
}
var p DockerProvider
var c = D.FromDocker(&types.Container{
Names: dummyNames,
Labels: map[string]string{
D.LableAliases: "a,b",
D.LabelIdleTimeout: common.IdleTimeoutDefault,
D.LabelStopMethod: common.StopMethodDefault,
D.LabelStopSignal: "SIGTERM",
D.LabelStopTimeout: common.StopTimeoutDefault,
D.LabelWakeTimeout: common.WakeTimeoutDefault,
"proxy.*.no_tls_verify": "true",
"proxy.*.scheme": "https",
"proxy.*.host": "app",
"proxy.*.port": "4567",
"proxy.a.no_tls_verify": "true",
"proxy.a.path_patterns": pathPatterns,
"proxy.a.set_headers": setHeaders,
"proxy.a.hide_headers": hideHeaders,
}}, "")
entries, err := p.entriesFromContainerLabels(c)
ExpectNoError(t, err.Error())
a := get(entries, "a")
b := get(entries, "b")
ExpectEqual(t, a.Scheme, "https")
ExpectEqual(t, b.Scheme, "https")
ExpectEqual(t, a.Host, "app")
ExpectEqual(t, b.Host, "app")
ExpectEqual(t, a.Port, "4567")
ExpectEqual(t, b.Port, "4567")
ExpectTrue(t, a.NoTLSVerify)
ExpectTrue(t, b.NoTLSVerify)
ExpectDeepEqual(t, a.PathPatterns, pathPatternsExpect)
ExpectEqual(t, len(b.PathPatterns), 0)
ExpectDeepEqual(t, a.SetHeaders, setHeadersExpect)
ExpectEqual(t, len(b.SetHeaders), 0)
ExpectDeepEqual(t, a.HideHeaders, hideHeadersExpect)
ExpectEqual(t, len(b.HideHeaders), 0)
ExpectEqual(t, a.IdleTimeout, common.IdleTimeoutDefault)
ExpectEqual(t, b.IdleTimeout, common.IdleTimeoutDefault)
ExpectEqual(t, a.StopTimeout, common.StopTimeoutDefault)
ExpectEqual(t, b.StopTimeout, common.StopTimeoutDefault)
ExpectEqual(t, a.StopMethod, common.StopMethodDefault)
ExpectEqual(t, b.StopMethod, common.StopMethodDefault)
ExpectEqual(t, a.WakeTimeout, common.WakeTimeoutDefault)
ExpectEqual(t, b.WakeTimeout, common.WakeTimeoutDefault)
ExpectEqual(t, a.StopSignal, "SIGTERM")
ExpectEqual(t, b.StopSignal, "SIGTERM")
}
func TestApplyLabel(t *testing.T) {
var p DockerProvider
var c = D.FromDocker(&types.Container{
Names: dummyNames,
Labels: map[string]string{
D.LableAliases: "a,b,c",
"proxy.a.no_tls_verify": "true",
"proxy.b.port": "1234",
"proxy.c.scheme": "https",
}}, "")
entries, err := p.entriesFromContainerLabels(c)
ExpectNoError(t, err.Error())
ExpectEqual(t, get(entries, "a").NoTLSVerify, true)
ExpectEqual(t, get(entries, "b").Port, "1234")
ExpectEqual(t, get(entries, "c").Scheme, "https")
}
func TestApplyLabelWithRef(t *testing.T) {
var p DockerProvider
var c = D.FromDocker(&types.Container{
Names: dummyNames,
Labels: map[string]string{
D.LableAliases: "a,b,c",
"proxy.$1.host": "localhost",
"proxy.$2.port": "1234",
"proxy.$3.scheme": "https",
}}, "")
entries, err := p.entriesFromContainerLabels(c)
ExpectNoError(t, err.Error())
ExpectEqual(t, get(entries, "a").Host, "localhost")
ExpectEqual(t, get(entries, "b").Port, "1234")
ExpectEqual(t, get(entries, "c").Scheme, "https")
}
func TestApplyLabelWithRefIndexError(t *testing.T) {
var p DockerProvider
var c = D.FromDocker(&types.Container{
Names: dummyNames,
Labels: map[string]string{
D.LableAliases: "a,b",
"proxy.$1.host": "localhost",
"proxy.$4.scheme": "https",
}}, "")
_, err := p.entriesFromContainerLabels(c)
ExpectError(t, E.ErrInvalid, err.Error())
ExpectTrue(t, strings.Contains(err.String(), "index out of range"))
c = D.FromDocker(&types.Container{
Names: dummyNames,
Labels: map[string]string{
D.LableAliases: "a,b",
"proxy.$0.host": "localhost",
}}, "")
_, err = p.entriesFromContainerLabels(c)
ExpectError(t, E.ErrInvalid, err.Error())
ExpectTrue(t, strings.Contains(err.String(), "index out of range"))
}

View File

@@ -1,12 +1,14 @@
package provider
import (
"errors"
"os"
"path"
"github.com/yusing/go-proxy/common"
E "github.com/yusing/go-proxy/error"
M "github.com/yusing/go-proxy/models"
R "github.com/yusing/go-proxy/route"
U "github.com/yusing/go-proxy/utils"
W "github.com/yusing/go-proxy/watcher"
)
@@ -16,33 +18,79 @@ type FileProvider struct {
path string
}
func FileProviderImpl(m *M.ProxyProvider) ProviderImpl {
return &FileProvider{
fileName: m.Value,
path: path.Join(common.ConfigBasePath, m.Value),
func FileProviderImpl(filename string) (ProviderImpl, E.NestedError) {
impl := &FileProvider{
fileName: filename,
path: path.Join(common.ConfigBasePath, filename),
}
_, err := os.Stat(impl.path)
switch {
case err == nil:
return impl, nil
case errors.Is(err, os.ErrNotExist):
return nil, E.NotExist("file", impl.path)
default:
return nil, E.UnexpectedError(err)
}
}
func Validate(data []byte) E.NestedError {
return U.ValidateYaml(U.GetSchema(common.ProvidersSchemaPath), data)
return U.ValidateYaml(U.GetSchema(common.FileProviderSchemaPath), data)
}
func (p *FileProvider) GetProxyEntries() (M.ProxyEntries, E.NestedError) {
entries := M.NewProxyEntries()
data, err := E.Check(os.ReadFile(p.path))
if err.IsNotNil() {
return entries, E.Failure("read file").Subject(p.fileName).With(err)
func (p FileProvider) String() string {
return p.fileName
}
func (p FileProvider) OnEvent(event W.Event, routes R.Routes) (res EventResult) {
b := E.NewBuilder("event %s error", event)
defer b.To(&res.err)
newRoutes, err := p.LoadRoutesImpl()
if err.HasError() {
b.Add(err)
return
}
ne := E.Failure("validation").Subject(p.fileName)
routes.RangeAll(func(_ string, v R.Route) {
b.Add(v.Stop())
})
routes.Clear()
newRoutes.RangeAll(func(_ string, v R.Route) {
b.Add(v.Start())
})
routes.MergeFrom(newRoutes)
return
}
func (p *FileProvider) LoadRoutesImpl() (routes R.Routes, res E.NestedError) {
routes = R.NewRoutes()
b := E.NewBuilder("file %q validation failure", p.fileName)
defer b.To(&res)
entries := M.NewProxyEntries()
data, err := E.Check(os.ReadFile(p.path))
if err.HasError() {
b.Add(E.FailWith("read file", err))
return
}
if !common.NoSchemaValidation {
if err = Validate(data); err.IsNotNil() {
return entries, ne.With(err)
if err = Validate(data); err.HasError() {
b.Add(err)
return
}
}
if err = entries.UnmarshalFromYAML(data); err.IsNotNil() {
return entries, ne.With(err)
if err = entries.UnmarshalFromYAML(data); err.HasError() {
b.Add(err)
return
}
return entries, E.Nil()
return R.FromEntries(entries)
}
func (p *FileProvider) NewWatcher() W.Watcher {

View File

@@ -2,46 +2,74 @@ package provider
import (
"context"
"path"
"github.com/sirupsen/logrus"
"github.com/yusing/go-proxy/common"
E "github.com/yusing/go-proxy/error"
M "github.com/yusing/go-proxy/models"
R "github.com/yusing/go-proxy/route"
W "github.com/yusing/go-proxy/watcher"
)
type ProviderImpl interface {
GetProxyEntries() (M.ProxyEntries, E.NestedError)
NewWatcher() W.Watcher
}
type (
Provider struct {
ProviderImpl `json:"-"`
type Provider struct {
ProviderImpl
name string
t ProviderType
routes R.Routes
name string
routes *R.Routes
reloadReqCh chan struct{}
watcher W.Watcher
watcherCtx context.Context
watcherCancel context.CancelFunc
watcher W.Watcher
watcherCtx context.Context
watcherCancel context.CancelFunc
l *logrus.Entry
}
func NewProvider(name string, model M.ProxyProvider) (p *Provider) {
p = &Provider{
name: name,
routes: R.NewRoutes(),
reloadReqCh: make(chan struct{}, 1),
l: logrus.WithField("provider", name),
l *logrus.Entry
}
switch model.Kind {
case common.ProviderKind_Docker:
p.ProviderImpl = DockerProviderImpl(&model)
case common.ProviderKind_File:
p.ProviderImpl = FileProviderImpl(&model)
ProviderImpl interface {
NewWatcher() W.Watcher
// even returns error, routes must be non-nil
LoadRoutesImpl() (R.Routes, E.NestedError)
OnEvent(event W.Event, routes R.Routes) EventResult
String() string
}
ProviderType string
EventResult struct {
nRemoved int
nAdded int
err E.NestedError
}
)
const (
ProviderTypeDocker ProviderType = "docker"
ProviderTypeFile ProviderType = "file"
)
func newProvider(name string, t ProviderType) *Provider {
p := &Provider{
name: name,
t: t,
routes: R.NewRoutes(),
}
p.l = logrus.WithField("provider", p)
return p
}
func NewFileProvider(filename string) (p *Provider, err E.NestedError) {
name := path.Base(filename)
p = newProvider(name, ProviderTypeFile)
p.ProviderImpl, err = FileProviderImpl(filename)
if err != nil {
return nil, err
}
p.watcher = p.NewWatcher()
return
}
func NewDockerProvider(name string, dockerHost string) (p *Provider, err E.NestedError) {
p = newProvider(name, ProviderTypeDocker)
p.ProviderImpl, err = DockerProviderImpl(dockerHost)
if err != nil {
return nil, err
}
p.watcher = p.NewWatcher()
return
@@ -51,116 +79,100 @@ func (p *Provider) GetName() string {
return p.name
}
func (p *Provider) StartAllRoutes() E.NestedError {
err := p.loadRoutes()
func (p *Provider) GetType() ProviderType {
return p.t
}
// to work with json marshaller
func (p *Provider) MarshalText() ([]byte, error) {
return []byte(p.String()), nil
}
func (p *Provider) StartAllRoutes() (res E.NestedError) {
errors := E.NewBuilder("errors in routes")
defer errors.To(&res)
// start watcher no matter load success or not
p.watcherCtx, p.watcherCancel = context.WithCancel(context.Background())
go p.watchEvents()
if err.IsNotNil() {
return err
}
errors := E.NewBuilder("errors starting routes for provider %q", p.name)
nStarted := 0
p.routes.EachKVParallel(func(alias string, r R.Route) {
if err := r.Start(); err.IsNotNil() {
errors.Add(err.Subject(alias))
nFailed := 0
p.routes.RangeAll(func(alias string, r R.Route) {
if err := r.Start(); err.HasError() {
errors.Add(err.Subject(r))
nFailed++
} else {
nStarted++
}
})
if err := errors.Build(); err.IsNotNil() {
return err
}
p.l.Infof("%d routes started", nStarted)
return E.Nil()
p.l.Debugf("%d routes started, %d failed", nStarted, nFailed)
return
}
func (p *Provider) StopAllRoutes() E.NestedError {
defer p.routes.Clear()
func (p *Provider) StopAllRoutes() (res E.NestedError) {
if p.watcherCancel != nil {
p.watcherCancel()
p.watcherCancel = nil
}
errors := E.NewBuilder("errors stopping routes for provider %q", p.name)
defer errors.To(&res)
nStopped := 0
p.routes.EachKVParallel(func(alias string, r R.Route) {
if err := r.Stop(); err.IsNotNil() {
errors.Add(err.Subject(alias))
nFailed := 0
p.routes.RangeAll(func(alias string, r R.Route) {
if err := r.Stop(); err.HasError() {
errors.Add(err.Subject(r))
nFailed++
} else {
nStopped++
}
})
if err := errors.Build(); err.IsNotNil() {
p.l.Debugf("%d routes stopped, %d failed", nStopped, nFailed)
return
}
func (p *Provider) RangeRoutes(do func(string, R.Route)) {
p.routes.RangeAll(do)
}
func (p *Provider) GetRoute(alias string) (R.Route, bool) {
return p.routes.Load(alias)
}
func (p *Provider) LoadRoutes() E.NestedError {
var err E.NestedError
p.routes, err = p.LoadRoutesImpl()
if p.routes.Size() > 0 {
p.l.Infof("loaded %d routes", p.routes.Size())
return err
}
p.l.Infof("%d routes stopped", nStopped)
return E.Nil()
}
func (p *Provider) ReloadRoutes() {
defer p.l.Info("routes reloaded")
select {
case p.reloadReqCh <- struct{}{}:
defer func() {
<-p.reloadReqCh
}()
p.StopAllRoutes()
p.loadRoutes()
p.StartAllRoutes()
default:
return
}
}
func (p *Provider) GetCurrentRoutes() *R.Routes {
return p.routes
return E.FailWith("loading routes", err)
}
func (p *Provider) watchEvents() {
p.watcherCtx, p.watcherCancel = context.WithCancel(context.Background())
events, errs := p.watcher.Events(p.watcherCtx)
l := logrus.WithField("?", "watcher")
l := p.l.WithField("module", "watcher")
for {
select {
case <-p.reloadReqCh:
p.ReloadRoutes()
case event, ok := <-events:
if !ok {
return
case <-p.watcherCtx.Done():
return
case event := <-events:
res := p.OnEvent(event, p.routes)
l.Infof("%s event %q", event.Type, event)
l.Infof("%d route added, %d routes removed", res.nAdded, res.nRemoved)
if res.err.HasError() {
l.Error(res.err)
}
l.Infof("watcher event: %v", event)
p.reloadReqCh <- struct{}{}
case err, ok := <-errs:
if !ok {
return
case err := <-errs:
if err == nil || err.Is(context.Canceled) {
continue
}
l.Errorf("watcher error: %s", err)
}
}
}
func (p *Provider) loadRoutes() E.NestedError {
entries, err := p.GetProxyEntries()
if err.IsNotNil() {
p.l.Warn(err.Subjectf("provider %s", p.name))
}
p.routes = R.NewRoutes()
errors := E.NewBuilder("errors loading routes from provider %q", p.name)
entries.EachKV(func(a string, e *M.ProxyEntry) {
e.Alias = a
r, err := R.NewRoute(e)
if err.IsNotNil() {
errors.Addf("%s: %w", a, err)
p.l.Debugf("failed to load route: %s, %s", a, err)
} else {
p.routes.Set(a, r)
}
})
p.l.Debugf("loaded %d routes from %d entries", p.routes.Size(), entries.Size())
return errors.Build()
}

View File

@@ -207,7 +207,7 @@ func joinURLPath(a, b *url.URL) (path, rawpath string) {
// }
//
// TODO: headers in ModifyResponse
func NewReverseProxy(target *url.URL, transport *http.Transport, entry *Entry) *ReverseProxy {
func NewReverseProxy(target *url.URL, transport http.RoundTripper, entry *ReverseProxyEntry) *ReverseProxy {
// check on init rather than on request
var setHeaders = func(r *http.Request) {}
var hideHeaders = func(r *http.Request) {}
@@ -232,7 +232,7 @@ func NewReverseProxy(target *url.URL, transport *http.Transport, entry *Entry) *
}
return &ReverseProxy{Rewrite: func(pr *ProxyRequest) {
rewriteRequestURL(pr.Out, target)
pr.SetXForwarded()
// pr.SetXForwarded()
setHeaders(pr.Out)
hideHeaders(pr.Out)
}, Transport: transport}
@@ -348,9 +348,9 @@ func (p *ReverseProxy) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
}
outreq.Header.Del("Forwarded")
// outreq.Header.Del("X-Forwarded-For")
// outreq.Header.Del("X-Forwarded-Host")
// outreq.Header.Del("X-Forwarded-Proto")
outreq.Header.Del("X-Forwarded-For")
outreq.Header.Del("X-Forwarded-Host")
outreq.Header.Del("X-Forwarded-Proto")
pr := &ProxyRequest{
In: req,
@@ -535,4 +535,4 @@ func IsPrint(s string) bool {
return true
}
var logger = logrus.WithField("?", "http")
var logger = logrus.WithField("module", "http")

View File

@@ -1,102 +0,0 @@
package proxy
// import (
// "net/http"
// "net/url"
// "os"
// "reflect"
// "testing"
// "time"
// )
// var proxy Entry
// var proxyUrl, _ = url.Parse("http://127.0.0.1:8181")
// var proxyServer = NewServer(ServerOptions{
// Name: "proxy",
// HTTPAddr: ":8080",
// Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// NewReverseProxy(proxyUrl, &http.Transport{}, &proxy).ServeHTTP(w, r)
// }),
// })
// var testServer = NewServer(ServerOptions{
// Name: "test",
// HTTPAddr: ":8181",
// Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// h := r.Header
// for k, vv := range h {
// for _, v := range vv {
// w.Header().Add(k, v)
// }
// }
// w.WriteHeader(http.StatusOK)
// }),
// })
// var httpClient = http.DefaultClient
// func TestMain(m *testing.M) {
// proxyServer.Start()
// testServer.Start()
// time.Sleep(100 * time.Millisecond)
// code := m.Run()
// proxyServer.Stop()
// testServer.Stop()
// os.Exit(code)
// }
// func TestSetHeader(t *testing.T) {
// hWant := http.Header{"X-Test": []string{"foo", "bar"}, "X-Test2": []string{"baz"}}
// proxy = Entry{
// Alias: "test",
// Scheme: "http",
// Host: "127.0.0.1",
// Port: "8181",
// SetHeaders: hWant,
// }
// req, err := http.NewRequest("HEAD", "http://127.0.0.1:8080", nil)
// if err != nil {
// t.Fatal(err)
// }
// resp, err := httpClient.Do(req)
// if err != nil {
// t.Fatal(err)
// }
// hGot := resp.Header
// t.Log("headers: ", hGot)
// for k, v := range hWant {
// if !reflect.DeepEqual(hGot[k], v) {
// t.Errorf("header %s: expected %v, got %v", k, v, hGot[k])
// }
// }
// }
// func TestHideHeader(t *testing.T) {
// hHide := []string{"X-Test", "X-Test2"}
// proxy = Entry{
// Alias: "test",
// Scheme: "http",
// Host: "127.0.0.1",
// Port: "8181",
// HideHeaders: hHide,
// }
// req, err := http.NewRequest("HEAD", "http://127.0.0.1:8080", nil)
// for _, k := range hHide {
// req.Header.Set(k, "foo")
// }
// if err != nil {
// t.Fatal(err)
// }
// resp, err := httpClient.Do(req)
// if err != nil {
// t.Fatal(err)
// }
// hGot := resp.Header
// t.Log("headers: ", hGot)
// for _, v := range hHide {
// _, ok := hGot[v]
// if ok {
// t.Errorf("header %s: expected hidden, got %v", v, hGot[v])
// }
// }
// }

View File

@@ -4,5 +4,5 @@ import (
"time"
)
const udpBufferSize = 1500
const udpBufferSize = 8192
const streamStopListenTimeout = 1 * time.Second

View File

@@ -2,8 +2,8 @@ package route
import (
"crypto/tls"
"fmt"
"net"
"sync"
"time"
"net/http"
@@ -11,6 +11,7 @@ import (
"strings"
"github.com/sirupsen/logrus"
"github.com/yusing/go-proxy/docker/idlewatcher"
E "github.com/yusing/go-proxy/error"
P "github.com/yusing/go-proxy/proxy"
PT "github.com/yusing/go-proxy/proxy/fields"
@@ -19,148 +20,158 @@ import (
type (
HTTPRoute struct {
Alias PT.Alias `json:"alias"`
Subroutes HTTPSubroutes `json:"subroutes"`
Alias PT.Alias `json:"alias"`
TargetURL *URL `json:"target_url"`
PathPatterns PT.PathPatterns `json:"path_patterns"`
mux *http.ServeMux
entry *P.ReverseProxyEntry
mux *http.ServeMux
handler *P.ReverseProxy
regIdleWatcher func() E.NestedError
unregIdleWatcher func()
}
HTTPSubroute struct {
TargetURL URL `json:"targetURL"`
Path PathKey `json:"path"`
proxy *P.ReverseProxy
}
URL struct {
*url.URL
}
PathKey = string
SubdomainKey = string
HTTPSubroutes = map[PathKey]HTTPSubroute
URL url.URL
SubdomainKey = PT.Alias
)
var httpRoutes = F.NewMap[SubdomainKey, *HTTPRoute]()
func NewHTTPRoute(entry *P.ReverseProxyEntry) (*HTTPRoute, E.NestedError) {
var trans *http.Transport
var regIdleWatcher func() E.NestedError
var unregIdleWatcher func()
func NewHTTPRoute(entry *P.Entry) (*HTTPRoute, E.NestedError) {
var tr *http.Transport
if entry.NoTLSVerify {
tr = transportNoTLS
trans = transportNoTLS.Clone()
} else {
tr = transport
trans = transport.Clone()
}
rp := P.NewReverseProxy(entry.URL, tr, entry)
rp := P.NewReverseProxy(entry.URL, trans, entry)
httpRoutes.Lock()
var r *HTTPRoute
r, ok := httpRoutes.UnsafeGet(entry.Alias.String())
if !ok {
r = &HTTPRoute{
Alias: entry.Alias,
Subroutes: make(HTTPSubroutes),
mux: http.NewServeMux(),
if entry.UseIdleWatcher() {
// allow time for response header up to `WakeTimeout`
if entry.WakeTimeout > trans.ResponseHeaderTimeout {
trans.ResponseHeaderTimeout = entry.WakeTimeout
}
httpRoutes.UnsafeSet(entry.Alias.String(), r)
}
path := entry.Path.String()
if _, exists := r.Subroutes[path]; exists {
httpRoutes.Unlock()
return nil, E.Duplicated("path", path).Subject(entry.Alias)
}
r.mux.HandleFunc(path, rp.ServeHTTP)
if err := recover(); err != nil {
httpRoutes.Unlock()
switch t := err.(type) {
case error:
// NOTE: likely path pattern error
return nil, E.From(t).Subject(entry.Alias)
default:
return nil, E.From(fmt.Errorf("%v", t)).Subject(entry.Alias)
regIdleWatcher = func() E.NestedError {
watcher, err := idlewatcher.Register(entry)
if err.HasError() {
return err
}
// patch round-tripper
rp.Transport = watcher.PatchRoundTripper(trans)
return nil
}
unregIdleWatcher = func() {
idlewatcher.Unregister(entry.ContainerName)
rp.Transport = trans
}
}
sr := HTTPSubroute{
TargetURL: URL{entry.URL},
proxy: rp,
Path: path,
httpRoutesMu.Lock()
defer httpRoutesMu.Unlock()
_, exists := httpRoutes.Load(entry.Alias)
if exists {
return nil, E.AlreadyExist("HTTPRoute alias", entry.Alias)
}
rewrite := rp.Rewrite
if logrus.GetLevel() == logrus.DebugLevel {
l := logrus.WithField("alias", entry.Alias)
sr.proxy.Rewrite = func(pr *P.ProxyRequest) {
l.Debug("request URL: ", pr.In.Host, pr.In.URL.Path)
l.Debug("request headers: ", pr.In.Header)
rewrite(pr)
}
} else {
sr.proxy.Rewrite = rewrite
r := &HTTPRoute{
Alias: entry.Alias,
TargetURL: (*URL)(entry.URL),
PathPatterns: entry.PathPatterns,
entry: entry,
handler: rp,
regIdleWatcher: regIdleWatcher,
unregIdleWatcher: unregIdleWatcher,
}
return r, nil
}
r.Subroutes[path] = sr
httpRoutes.Unlock()
return r, E.Nil()
func (r *HTTPRoute) String() string {
return string(r.Alias)
}
func (r *HTTPRoute) Start() E.NestedError {
httpRoutes.Set(r.Alias.String(), r)
return E.Nil()
httpRoutesMu.Lock()
defer httpRoutesMu.Unlock()
if r.regIdleWatcher != nil {
if err := r.regIdleWatcher(); err.HasError() {
return err
}
}
r.mux = http.NewServeMux()
for _, p := range r.PathPatterns {
r.mux.HandleFunc(string(p), r.handler.ServeHTTP)
}
httpRoutes.Store(r.Alias, r)
return nil
}
func (r *HTTPRoute) Stop() E.NestedError {
httpRoutes.Delete(r.Alias.String())
return E.Nil()
httpRoutesMu.Lock()
defer httpRoutesMu.Unlock()
if r.unregIdleWatcher != nil {
r.unregIdleWatcher()
r.unregIdleWatcher = nil
}
r.mux = nil
httpRoutes.Delete(r.Alias)
return nil
}
func (r *HTTPRoute) GetSubroute(path PathKey) (HTTPSubroute, bool) {
sr, ok := r.Subroutes[path]
return sr, ok
func (u *URL) String() string {
return (*url.URL)(u).String()
}
func (u URL) MarshalText() (text []byte, err error) {
func (u *URL) MarshalText() (text []byte, err error) {
return []byte(u.String()), nil
}
func ProxyHandler(w http.ResponseWriter, r *http.Request) {
mux, err := findMux(r.Host, PathKey(r.URL.Path))
mux, err := findMux(r.Host)
if err != nil {
err = E.Failure("request").
Subjectf("%s %s%s", r.Method, r.Host, r.URL.Path).
With(err)
http.Error(w, err.Error(), http.StatusNotFound)
http.Error(w, err.String(), http.StatusNotFound)
logrus.Error(err)
return
}
mux.ServeHTTP(w, r)
}
func findMux(host string, path PathKey) (*http.ServeMux, error) {
func findMux(host string) (*http.ServeMux, E.NestedError) {
sd := strings.Split(host, ".")[0]
if r, ok := httpRoutes.UnsafeGet(sd); ok {
if r, ok := httpRoutes.Load(PT.Alias(sd)); ok {
return r.mux, nil
}
return nil, E.NotExists("route", fmt.Sprintf("subdomain: %s, path: %s", sd, path))
return nil, E.NotExist("route", sd)
}
// TODO: default + per proxy
var (
defaultDialer = net.Dialer{
Timeout: 60 * time.Second,
KeepAlive: 60 * time.Second,
}
transport = &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{
Timeout: 60 * time.Second,
KeepAlive: 60 * time.Second,
}).DialContext,
MaxIdleConns: 1000,
Proxy: http.ProxyFromEnvironment,
DialContext: defaultDialer.DialContext,
MaxIdleConnsPerHost: 1000,
}
transportNoTLS = func() *http.Transport {
var clone = transport.Clone()
clone.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
return clone
}()
httpRoutes = F.NewMapOf[SubdomainKey, *HTTPRoute]()
httpRoutesMu sync.Mutex
globalMux = http.NewServeMux()
)

View File

@@ -1,6 +1,9 @@
package route
import (
"fmt"
"net/url"
E "github.com/yusing/go-proxy/error"
M "github.com/yusing/go-proxy/models"
P "github.com/yusing/go-proxy/proxy"
@@ -9,26 +12,81 @@ import (
type (
Route interface {
RouteImpl
Entry() *M.RawEntry
Type() RouteType
URL() *url.URL
}
Routes = F.Map[string, Route]
RouteType string
RouteImpl interface {
Start() E.NestedError
Stop() E.NestedError
String() string
}
Routes = F.Map[string, Route]
route struct {
RouteImpl
type_ RouteType
entry *M.RawEntry
}
)
const (
RouteTypeStream RouteType = "stream"
RouteTypeReverseProxy RouteType = "reverse_proxy"
)
// function alias
var NewRoutes = F.NewMap[string, Route]
var NewRoutes = F.NewMapOf[string, Route]
func NewRoute(en *M.ProxyEntry) (Route, E.NestedError) {
entry, err := P.NewEntry(en)
if err.IsNotNil() {
func NewRoute(en *M.RawEntry) (Route, E.NestedError) {
rt, err := P.ValidateEntry(en)
if err.HasError() {
return nil, err
}
switch e := entry.(type) {
var t RouteType
switch e := rt.(type) {
case *P.StreamEntry:
return NewStreamRoute(e)
case *P.Entry:
return NewHTTPRoute(e)
rt, err = NewStreamRoute(e)
t = RouteTypeStream
case *P.ReverseProxyEntry:
rt, err = NewHTTPRoute(e)
t = RouteTypeReverseProxy
default:
panic("bug: should not reach here")
}
return &route{RouteImpl: rt.(RouteImpl), entry: en, type_: t}, err
}
func (rt *route) Entry() *M.RawEntry {
return rt.entry
}
func (rt *route) Type() RouteType {
return rt.type_
}
func (rt *route) URL() *url.URL {
url, _ := url.Parse(fmt.Sprintf("%s://%s", rt.entry.Scheme, rt.entry.Host))
return url
}
func FromEntries(entries M.RawEntries) (Routes, E.NestedError) {
b := E.NewBuilder("errors in routes")
routes := NewRoutes()
entries.RangeAll(func(alias string, entry *M.RawEntry) {
entry.Alias = alias
r, err := NewRoute(entry)
if err.HasError() {
b.Add(err.Subject(alias))
} else {
routes.Store(alias, r)
}
})
return routes, b.Build()
}

View File

@@ -1,6 +1,8 @@
package route
import (
"context"
"errors"
"fmt"
"sync"
"sync/atomic"
@@ -12,11 +14,13 @@ import (
)
type StreamRoute struct {
*P.StreamEntry
P.StreamEntry
StreamImpl `json:"-"`
wg sync.WaitGroup
stopCh chan struct{}
wg sync.WaitGroup
ctx context.Context
cancel context.CancelFunc
connCh chan any
started atomic.Bool
l logrus.FieldLogger
@@ -35,41 +39,44 @@ func NewStreamRoute(entry *P.StreamEntry) (*StreamRoute, E.NestedError) {
return nil, E.Unsupported("scheme", fmt.Sprintf("%v -> %v", entry.Scheme.ListeningScheme, entry.Scheme.ProxyScheme))
}
base := &StreamRoute{
StreamEntry: entry,
wg: sync.WaitGroup{},
stopCh: make(chan struct{}, 1),
connCh: make(chan any),
l: logger.WithField("alias", entry.Alias),
StreamEntry: *entry,
connCh: make(chan any, 100),
}
if entry.Scheme.ListeningScheme.IsTCP() {
base.StreamImpl = NewTCPRoute(base)
} else {
base.StreamImpl = NewUDPRoute(base)
}
return base, E.Nil()
base.l = logrus.WithField("route", base.StreamImpl)
return base, nil
}
func (r *StreamRoute) String() string {
return fmt.Sprintf("%s stream: %s", r.Scheme, r.Alias)
}
func (r *StreamRoute) Start() E.NestedError {
if r.started.Load() {
return E.Invalid("state", "already started")
return nil
}
r.ctx, r.cancel = context.WithCancel(context.Background())
r.wg.Wait()
if err := r.Setup(); err != nil {
return E.Failure("setup").With(err)
return E.FailWith("setup", err)
}
r.started.Store(true)
r.wg.Add(2)
go r.grAcceptConnections()
go r.grHandleConnections()
return E.Nil()
return nil
}
func (r *StreamRoute) Stop() E.NestedError {
if !r.started.Load() {
return E.Invalid("state", "not started")
return nil
}
l := r.l
close(r.stopCh)
r.cancel()
r.CloseListeners()
done := make(chan struct{}, 1)
@@ -78,13 +85,16 @@ func (r *StreamRoute) Stop() E.NestedError {
close(done)
}()
select {
case <-done:
l.Info("stopped listening")
case <-time.After(streamStopListenTimeout):
l.Error("timed out waiting for connections")
timeout := time.After(streamStopListenTimeout)
for {
select {
case <-done:
l.Debug("stopped listening")
return nil
case <-timeout:
return E.FailedWhy("stop", "timed out")
}
}
return E.Nil()
}
func (r *StreamRoute) grAcceptConnections() {
@@ -92,13 +102,13 @@ func (r *StreamRoute) grAcceptConnections() {
for {
select {
case <-r.stopCh:
case <-r.ctx.Done():
return
default:
conn, err := r.Accept()
if err != nil {
select {
case <-r.stopCh:
case <-r.ctx.Done():
return
default:
r.l.Error(err)
@@ -115,17 +125,15 @@ func (r *StreamRoute) grHandleConnections() {
for {
select {
case <-r.stopCh:
case <-r.ctx.Done():
return
case conn := <-r.connCh:
go func() {
err := r.Handle(conn)
if err != nil {
if err != nil && !errors.Is(err, context.Canceled) {
r.l.Error(err)
}
}()
}
}
}
var logger = logrus.WithField("?", "stream")

View File

@@ -12,19 +12,20 @@ import (
const tcpDialTimeout = 5 * time.Second
type Pipes []*U.BidirectionalPipe
type (
Pipes []U.BidirectionalPipe
type TCPRoute struct {
*StreamRoute
listener net.Listener
pipe Pipes
mu sync.Mutex
}
TCPRoute struct {
*StreamRoute
listener net.Listener
pipe Pipes
mu sync.Mutex
}
)
func NewTCPRoute(base *StreamRoute) StreamImpl {
return &TCPRoute{
StreamRoute: base,
listener: nil,
pipe: make(Pipes, 0),
}
}
@@ -38,35 +39,31 @@ func (route *TCPRoute) Setup() error {
return nil
}
func (route *TCPRoute) Accept() (interface{}, error) {
func (route *TCPRoute) Accept() (any, error) {
return route.listener.Accept()
}
func (route *TCPRoute) Handle(c interface{}) error {
func (route *TCPRoute) Handle(c any) error {
clientConn := c.(net.Conn)
defer clientConn.Close()
ctx, cancel := context.WithTimeout(context.Background(), tcpDialTimeout)
ctx, cancel := context.WithTimeout(route.ctx, tcpDialTimeout)
defer cancel()
serverAddr := fmt.Sprintf("%s:%v", route.Host, route.Port.ProxyPort)
dialer := &net.Dialer{}
serverConn, err := dialer.DialContext(ctx, route.Scheme.ProxyScheme.String(), serverAddr)
serverConn, err := dialer.DialContext(ctx, string(route.Scheme.ProxyScheme), serverAddr)
if err != nil {
return err
}
pipeCtx, pipeCancel := context.WithCancel(context.Background())
go func() {
<-route.stopCh
pipeCancel()
}()
route.mu.Lock()
pipe := U.NewBidirectionalPipe(pipeCtx, clientConn, serverConn)
pipe := U.NewBidirectionalPipe(route.ctx, clientConn, serverConn)
route.pipe = append(route.pipe, pipe)
route.mu.Unlock()
return pipe.Start()
}
@@ -77,9 +74,4 @@ func (route *TCPRoute) CloseListeners() {
}
route.listener.Close()
route.listener = nil
for _, pipe := range route.pipe {
if err := pipe.Stop(); err.IsNotNil() {
route.l.Error(err)
}
}
}

View File

@@ -1,50 +1,50 @@
package route
import (
"context"
"fmt"
"io"
"net"
"sync"
"github.com/yusing/go-proxy/utils"
U "github.com/yusing/go-proxy/utils"
F "github.com/yusing/go-proxy/utils/functional"
)
type UDPRoute struct {
*StreamRoute
type (
UDPRoute struct {
*StreamRoute
connMap UDPConnMap
connMapMutex sync.Mutex
connMap UDPConnMap
listeningConn *net.UDPConn
targetAddr *net.UDPAddr
}
listeningConn *net.UDPConn
targetAddr *net.UDPAddr
}
UDPConn struct {
src *net.UDPConn
dst *net.UDPConn
U.BidirectionalPipe
}
UDPConnMap = F.Map[string, *UDPConn]
)
type UDPConn struct {
src *net.UDPConn
dst *net.UDPConn
*utils.BidirectionalPipe
}
type UDPConnMap map[string]*UDPConn
var NewUDPConnMap = F.NewMapOf[string, *UDPConn]
func NewUDPRoute(base *StreamRoute) StreamImpl {
return &UDPRoute{
StreamRoute: base,
connMap: make(UDPConnMap),
connMap: NewUDPConnMap(),
}
}
func (route *UDPRoute) Setup() error {
laddr, err := net.ResolveUDPAddr(route.Scheme.ListeningScheme.String(), fmt.Sprintf(":%v", route.Port.ProxyPort))
laddr, err := net.ResolveUDPAddr(string(route.Scheme.ListeningScheme), fmt.Sprintf(":%v", route.Port.ListeningPort))
if err != nil {
return err
}
source, err := net.ListenUDP(route.Scheme.ListeningScheme.String(), laddr)
source, err := net.ListenUDP(string(route.Scheme.ListeningScheme), laddr)
if err != nil {
return err
}
raddr, err := net.ResolveUDPAddr(route.Scheme.ProxyScheme.String(), fmt.Sprintf("%s:%v", route.Host, route.Port.ProxyPort))
raddr, err := net.ResolveUDPAddr(string(route.Scheme.ProxyScheme), fmt.Sprintf("%s:%v", route.Host, route.Port.ProxyPort))
if err != nil {
source.Close()
return err
@@ -55,7 +55,7 @@ func (route *UDPRoute) Setup() error {
return nil
}
func (route *UDPRoute) Accept() (interface{}, error) {
func (route *UDPRoute) Accept() (any, error) {
in := route.listeningConn
buffer := make([]byte, udpBufferSize)
@@ -70,40 +70,31 @@ func (route *UDPRoute) Accept() (interface{}, error) {
}
key := srcAddr.String()
conn, ok := route.connMap[key]
conn, ok := route.connMap.Load(key)
if !ok {
route.connMapMutex.Lock()
if conn, ok = route.connMap[key]; !ok {
srcConn, err := net.DialUDP("udp", nil, srcAddr)
if err != nil {
return nil, err
}
dstConn, err := net.DialUDP("udp", nil, route.targetAddr)
if err != nil {
srcConn.Close()
return nil, err
}
pipeCtx, pipeCancel := context.WithCancel(context.Background())
go func() {
<-route.stopCh
pipeCancel()
}()
conn = &UDPConn{
srcConn,
dstConn,
utils.NewBidirectionalPipe(pipeCtx, sourceRWCloser{in, dstConn}, sourceRWCloser{in, srcConn}),
}
route.connMap[key] = conn
srcConn, err := net.DialUDP("udp", nil, srcAddr)
if err != nil {
return nil, err
}
route.connMapMutex.Unlock()
dstConn, err := net.DialUDP("udp", nil, route.targetAddr)
if err != nil {
srcConn.Close()
return nil, err
}
conn = &UDPConn{
srcConn,
dstConn,
U.NewBidirectionalPipe(route.ctx, sourceRWCloser{in, dstConn}, sourceRWCloser{in, srcConn}),
}
route.connMap.Store(key, conn)
}
_, err = conn.dst.Write(buffer[:nRead])
return conn, err
}
func (route *UDPRoute) Handle(c interface{}) error {
func (route *UDPRoute) Handle(c any) error {
return c.(*UDPConn).Start()
}
@@ -112,15 +103,15 @@ func (route *UDPRoute) CloseListeners() {
route.listeningConn.Close()
route.listeningConn = nil
}
for _, conn := range route.connMap {
route.connMap.RangeAll(func(_ string, conn *UDPConn) {
if err := conn.src.Close(); err != nil {
route.l.Errorf("error closing src conn: %s", err)
}
if err := conn.dst.Close(); err != nil {
route.l.Error("error closing dst conn: %s", err)
}
}
route.connMap = make(UDPConnMap)
})
route.connMap.Clear()
}
type sourceRWCloser struct {

View File

@@ -22,11 +22,9 @@ type server struct {
}
type Options struct {
Name string
// port (with leading colon)
HTTPPort string
// port (with leading colon)
HTTPSPort string
Name string
HTTPAddr string
HTTPSAddr string
CertProvider *autocert.Provider
RedirectToHTTPS bool
Handler http.Handler
@@ -55,22 +53,22 @@ func NewServer(opt Options) (s *server) {
certAvailable = err == nil
}
if certAvailable && opt.RedirectToHTTPS && opt.HTTPSPort != "" {
httpHandler = redirectToTLSHandler(opt.HTTPSPort)
if certAvailable && opt.RedirectToHTTPS && opt.HTTPSAddr != "" {
httpHandler = redirectToTLSHandler(opt.HTTPSAddr)
} else {
httpHandler = opt.Handler
}
if opt.HTTPPort != "" {
if opt.HTTPAddr != "" {
httpSer = &http.Server{
Addr: opt.HTTPPort,
Addr: opt.HTTPAddr,
Handler: httpHandler,
ErrorLog: logger,
}
}
if certAvailable && opt.HTTPSPort != "" {
if certAvailable && opt.HTTPSAddr != "" {
httpsSer = &http.Server{
Addr: opt.HTTPSPort,
Addr: opt.HTTPSAddr,
Handler: opt.Handler,
ErrorLog: logger,
TLSConfig: &tls.Config{
@@ -158,4 +156,4 @@ func redirectToTLSHandler(port string) http.HandlerFunc {
}
}
var logger = logrus.WithField("?", "server")
var logger = logrus.WithField("module", "server")

View File

@@ -20,16 +20,16 @@ func FormatDuration(d time.Duration) string {
var parts []string
if days > 0 {
parts = append(parts, fmt.Sprintf("%d Day%s", days, pluralize(days)))
parts = append(parts, fmt.Sprintf("%d day%s", days, pluralize(days)))
}
if hours > 0 {
parts = append(parts, fmt.Sprintf("%d Hour%s", hours, pluralize(hours)))
parts = append(parts, fmt.Sprintf("%d hour%s", hours, pluralize(hours)))
}
if minutes > 0 {
parts = append(parts, fmt.Sprintf("%d Minute%s", minutes, pluralize(minutes)))
parts = append(parts, fmt.Sprintf("%d minute%s", minutes, pluralize(minutes)))
}
if seconds > 0 {
parts = append(parts, fmt.Sprintf("%d Second%s", seconds, pluralize(seconds)))
parts = append(parts, fmt.Sprintf("%d second%s", seconds, pluralize(seconds)))
}
// Join the parts with appropriate connectors
@@ -42,6 +42,15 @@ func FormatDuration(d time.Duration) string {
return strings.Join(parts[:len(parts)-1], ", ") + " and " + parts[len(parts)-1]
}
func ParseBool(s string) bool {
switch strings.ToLower(s) {
case "1", "true", "yes", "on":
return true
default:
return false
}
}
func pluralize(n int64) string {
if n > 1 {
return "s"

View File

@@ -1,15 +0,0 @@
package utils
import (
"os"
"path"
)
func FileOK(p string) bool {
_, err := os.Stat(p)
return err == nil
}
func FileName(p string) string {
return path.Base(p)
}

View File

@@ -2,25 +2,25 @@ package functional
import "sync"
func ForEachKey[K comparable, V interface{}](obj map[K]V, do func(K)) {
func ForEachKey[K comparable, V any](obj map[K]V, do func(K)) {
for k := range obj {
do(k)
}
}
func ForEachValue[K comparable, V interface{}](obj map[K]V, do func(V)) {
func ForEachValue[K comparable, V any](obj map[K]V, do func(V)) {
for _, v := range obj {
do(v)
}
}
func ForEachKV[K comparable, V interface{}](obj map[K]V, do func(K, V)) {
func ForEachKV[K comparable, V any](obj map[K]V, do func(K, V)) {
for k, v := range obj {
do(k, v)
}
}
func ParallelForEach[T interface{}](obj []T, do func(T)) {
func ParallelForEach[T any](obj []T, do func(T)) {
var wg sync.WaitGroup
wg.Add(len(obj))
for _, v := range obj {
@@ -32,7 +32,7 @@ func ParallelForEach[T interface{}](obj []T, do func(T)) {
wg.Wait()
}
func ParallelForEachKey[K comparable, V interface{}](obj map[K]V, do func(K)) {
func ParallelForEachKey[K comparable, V any](obj map[K]V, do func(K)) {
var wg sync.WaitGroup
wg.Add(len(obj))
for k := range obj {
@@ -44,7 +44,7 @@ func ParallelForEachKey[K comparable, V interface{}](obj map[K]V, do func(K)) {
wg.Wait()
}
func ParallelForEachValue[K comparable, V interface{}](obj map[K]V, do func(V)) {
func ParallelForEachValue[K comparable, V any](obj map[K]V, do func(V)) {
var wg sync.WaitGroup
wg.Add(len(obj))
for _, v := range obj {
@@ -56,7 +56,7 @@ func ParallelForEachValue[K comparable, V interface{}](obj map[K]V, do func(V))
wg.Wait()
}
func ParallelForEachKV[K comparable, V interface{}](obj map[K]V, do func(K, V)) {
func ParallelForEachKV[K comparable, V any](obj map[K]V, do func(K, V)) {
var wg sync.WaitGroup
wg.Add(len(obj))
for k, v := range obj {

View File

@@ -1,225 +1,116 @@
package functional
import (
"context"
"sync"
"github.com/puzpuzpuz/xsync/v3"
"gopkg.in/yaml.v3"
E "github.com/yusing/go-proxy/error"
)
type Map[KT comparable, VT interface{}] struct {
m map[KT]VT
defVals map[KT]VT
sync.RWMutex
type Map[KT comparable, VT any] struct {
*xsync.MapOf[KT, VT]
}
// NewMap creates a new Map with the given map as its initial values.
//
// Parameters:
// - dv: optional default values for the Map
//
// Return:
// - *Map[KT, VT]: a pointer to the newly created Map.
func NewMap[KT comparable, VT interface{}](dv ...map[KT]VT) *Map[KT, VT] {
return NewMapFrom(make(map[KT]VT), dv...)
func NewMapOf[KT comparable, VT any](options ...func(*xsync.MapConfig)) Map[KT, VT] {
return Map[KT, VT]{xsync.NewMapOf[KT, VT](options...)}
}
// NewMapOf creates a new Map with the given map as its initial values.
//
// Type parameters:
// - M: type for the new map.
//
// Parameters:
// - dv: optional default values for the Map
//
// Return:
// - *Map[KT, VT]: a pointer to the newly created Map.
func NewMapOf[M Map[KT, VT], KT comparable, VT interface{}](dv ...map[KT]VT) *Map[KT, VT] {
return NewMapFrom(make(map[KT]VT), dv...)
}
// NewMapFrom creates a new Map with the given map as its initial values.
//
// Parameters:
// - from: a map of type KT to VT, which will be the initial values of the Map.
// - dv: optional default values for the Map
//
// Return:
// - *Map[KT, VT]: a pointer to the newly created Map.
func NewMapFrom[KT comparable, VT interface{}](from map[KT]VT, dv ...map[KT]VT) *Map[KT, VT] {
if len(dv) > 0 {
return &Map[KT, VT]{m: from, defVals: dv[0]}
func NewMapFrom[KT comparable, VT any](m map[KT]VT) (res Map[KT, VT]) {
res = NewMapOf[KT, VT](xsync.WithPresize(len(m)))
for k, v := range m {
res.Store(k, v)
}
return &Map[KT, VT]{m: from}
return
}
func (m *Map[KT, VT]) Set(key KT, value VT) {
m.Lock()
m.m[key] = value
m.Unlock()
}
func MapFind[KT comparable, VT, CT any](m Map[KT, VT], criteria func(VT) (CT, bool)) (_ CT) {
result := make(chan CT, 1)
func (m *Map[KT, VT]) Get(key KT) VT {
m.RLock()
defer m.RUnlock()
value, ok := m.m[key]
if !ok && m.defVals != nil {
return m.defVals[key]
}
return value
}
// Find searches for the first element in the map that satisfies the given criteria.
//
// Parameters:
// - criteria: a function that takes a value of type VT and returns a tuple of any type and a boolean.
//
// Return:
// - any: the first value that satisfies the criteria, or nil if no match is found.
func (m *Map[KT, VT]) Find(criteria func(VT) (any, bool)) any {
m.RLock()
defer m.RUnlock()
result := make(chan any)
wg := sync.WaitGroup{}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
for _, v := range m.m {
wg.Add(1)
go func(val VT) {
defer wg.Done()
if value, ok := criteria(val); ok {
select {
case result <- value:
cancel() // Cancel other goroutines if a result is found
case <-ctx.Done(): // If already cancelled
return
}
m.Range(func(key KT, value VT) bool {
select {
case <-result: // already have a result
return false // stop iteration
default:
if got, ok := criteria(value); ok {
result <- got
return false
}
}(v)
}
go func() {
wg.Wait()
close(result)
}()
// The first valid match, if any
select {
case res, ok := <-result:
if ok {
return res
return true
}
case <-ctx.Done():
})
select {
case v := <-result:
return v
default:
return
}
return nil // Return nil if no matches found
}
func (m *Map[KT, VT]) UnsafeGet(key KT) (VT, bool) {
value, ok := m.m[key]
return value, ok
}
func (m *Map[KT, VT]) UnsafeSet(key KT, value VT) {
m.m[key] = value
}
func (m *Map[KT, VT]) Delete(key KT) {
m.Lock()
delete(m.m, key)
m.Unlock()
}
// MergeWith merges the contents of another Map[KT, VT]
// into the current Map[KT, VT] and
// returns a map that were duplicated.
// MergeFrom add contents from another `Map`, ignore duplicated keys
//
// Parameters:
// - other: a pointer to another Map[KT, VT] to be merged into the current Map[KT, VT].
// - other: `Map` of values to add from
//
// Return:
// - Map[KT, VT]: a map of key-value pairs that were duplicated during the merge.
func (m *Map[KT, VT]) MergeWith(other *Map[KT, VT]) Map[KT, VT] {
dups := make(map[KT]VT)
// - Map: a `Map` of duplicated keys-value pairs
func (m Map[KT, VT]) MergeFrom(other Map[KT, VT]) Map[KT, VT] {
dups := NewMapOf[KT, VT]()
m.Lock()
for k, v := range other.m {
if _, isDup := m.m[k]; !isDup {
m.m[k] = v
other.Range(func(k KT, v VT) bool {
if _, ok := m.Load(k); ok {
dups.Store(k, v)
} else {
dups[k] = v
m.Store(k, v)
}
}
m.Unlock()
return Map[KT, VT]{m: dups}
return true
})
return dups
}
func (m *Map[KT, VT]) Clear() {
m.Lock()
m.m = make(map[KT]VT)
m.Unlock()
func (m Map[KT, VT]) RangeAll(do func(k KT, v VT)) {
m.Range(func(k KT, v VT) bool {
do(k, v)
return true
})
}
func (m *Map[KT, VT]) Size() int {
m.RLock()
defer m.RUnlock()
return len(m.m)
func (m Map[KT, VT]) RemoveAll(criteria func(VT) bool) {
m.Range(func(k KT, v VT) bool {
if criteria(v) {
m.Delete(k)
}
return true
})
}
func (m *Map[KT, VT]) Contains(key KT) bool {
m.RLock()
_, ok := m.m[key]
m.RUnlock()
func (m Map[KT, VT]) Has(k KT) bool {
_, ok := m.Load(k)
return ok
}
func (m *Map[KT, VT]) Clone() *Map[KT, VT] {
m.RLock()
defer m.RUnlock()
clone := make(map[KT]VT, len(m.m))
for k, v := range m.m {
clone[k] = v
func (m Map[KT, VT]) UnmarshalFromYAML(data []byte) E.NestedError {
if m.Size() != 0 {
return E.FailedWhy("unmarshal from yaml", "map is not empty")
}
return &Map[KT, VT]{m: clone, defVals: m.defVals}
}
func (m *Map[KT, VT]) EachKV(fn func(k KT, v VT)) {
m.Lock()
for k, v := range m.m {
fn(k, v)
tmp := make(map[KT]VT)
if err := E.From(yaml.Unmarshal(data, tmp)); err.HasError() {
return err
}
m.Unlock()
}
func (m *Map[KT, VT]) Each(fn func(v VT)) {
m.Lock()
for _, v := range m.m {
fn(v)
for k, v := range tmp {
m.Store(k, v)
}
m.Unlock()
return nil
}
func (m *Map[KT, VT]) EachParallel(fn func(v VT)) {
m.Lock()
ParallelForEachValue(m.m, fn)
m.Unlock()
}
func (m *Map[KT, VT]) EachKVParallel(fn func(k KT, v VT)) {
m.Lock()
ParallelForEachKV(m.m, fn)
m.Unlock()
}
func (m *Map[KT, VT]) UnmarshalFromYAML(data []byte) E.NestedError {
return E.From(yaml.Unmarshal(data, m.m))
}
func (m *Map[KT, VT]) Iterator() map[KT]VT {
return m.m
func (m Map[KT, VT]) String() string {
tmp := make(map[KT]VT, m.Size())
m.RangeAll(func(k KT, v VT) {
tmp[k] = v
})
data, err := yaml.Marshal(tmp)
if err != nil {
return err.Error()
}
return string(data)
}

Some files were not shown because too many files have changed in this diff Show More