Compare commits

...

45 Commits
0.9 ... v0.9.8

Author SHA1 Message Date
yusing
c5cf867cd9 update default repo to main 2025-02-23 13:31:27 +08:00
yusing
03ea9bb760 update default image name 2025-02-23 13:28:35 +08:00
yusing
a1a5bf921e workflow update 2025-02-23 13:27:47 +08:00
yusing
f1bfd13da3 fix cloudflare real ip middleware resolving local addresses 2025-02-19 00:36:44 +08:00
yusing
b8900999a4 deps upgrade 2025-02-18 16:39:25 +08:00
yusing
e6f77376b9 fix args.go affected from cherry-pick 2025-02-18 16:35:23 +08:00
yusing
b2a6a20f10 simplify setup with script 2025-02-18 05:43:33 +08:00
yusing
05cbf99237 trim and convert alias and host to lowercase 2025-02-18 02:32:31 +08:00
yusing
d5c0e62be1 autocert: add porkbun cert provider 2025-02-13 23:48:35 +08:00
yusing
a21bdedbc1 go.modL add comments explaining dependencies usage 2025-02-13 19:40:15 +08:00
yusing
797ebd7771 update next release md 2025-02-13 19:30:23 +08:00
yusing
04e9ecbc76 README.md: Update README.md 2025-02-13 19:26:23 +08:00
yusing
9626b65593 deps upgrade 2025-02-11 00:56:17 +08:00
yusing
c9b5516330 fix wildcard alias and some tests 2025-02-11 00:47:43 +08:00
yusing
4363ca88aa fix file server validation 2025-02-11 00:47:43 +08:00
Yuzerion
3353060ad4 Update README.md 2025-02-07 15:58:51 +08:00
yusing
ddc3b8575e fix startup panic when no notification provider is set 2025-02-07 03:07:21 +08:00
yusing
136a2ec89f remove some debug logging 2025-02-07 01:08:42 +08:00
yusing
021c68f2a7 update README 2025-02-06 18:31:49 +08:00
yusing
989a09274f restore notification 2025-02-06 18:25:39 +08:00
yusing
39c5886d7a make rules.name optional 2025-02-06 18:25:39 +08:00
Yuzerion
1a5f3735cf Feat/fileserver (#60)
* cleanup code for URL type

* fix makefile for trace mode

* refactor, merge Entry, RawEntry and Route into one. 

* Implement fileserver.

* refactor: rename HTTPRoute to ReverseProxyRoute to avoid confusion

* refactor: move metrics logger to middleware package

- fix prometheus metrics for load balanced routes
  - route will now fail when health monitor fail to start

* fix extra output of ls-* commands by defer initializaing stuff, speed up start time

* add test for path traversal attack, small fix on FileServer.Start method

* rename rule.on.bypass to pass

* refactor and fixed map-to-map  deserialization

* updated route loading logic

* schemas: add "add_prefix" option to modify_request middleware


* updated route JSONMarshalling

---------

Co-authored-by: yusing <yusing@6uo.me>
2025-02-06 18:23:10 +08:00
yusing
4d47eb0e91 update compose example 2025-02-06 05:59:21 +08:00
yusing
af7c59b5c2 add tests for rules.on 2025-02-06 05:50:03 +08:00
yusing
693bf68864 rules: updated help message, make values optional, fixes tests 2025-02-06 05:13:47 +08:00
Yuzerion
c9ddf3d165 Create FUNDING.yml 2025-02-06 04:44:19 +08:00
yusing
1549b56866 README: move auth docs to wiki 2025-02-06 03:12:34 +08:00
yusing
2cd1f22e68 add test for the previous commit 2025-02-06 02:33:30 +08:00
yusing
688f38943d fix single line yaml list treated as comma seperated list 2025-02-06 01:58:45 +08:00
yusing
043bbd7a11 readme and docker compose example amendment 2025-02-06 00:56:11 +08:00
yusing
f997423fd7 fix error formatting 2025-02-04 07:04:49 +08:00
yusing
1871ef3d38 clearer error message when config reload failed 2025-02-04 07:04:27 +08:00
yusing
7c56c88dd4 fix server not being restarted after config reload 2025-02-04 07:04:15 +08:00
yusing
4d7422dd90 adjusted and simplified default config and compose.yml 2025-02-04 07:04:05 +08:00
yusing
eccabc0588 remove incorrectly added pnpn lockfile 2025-02-04 07:04:05 +08:00
yusing
0c7b188587 api: fix search icon returning null when no match 2025-02-02 03:31:52 +08:00
yusing
4c97b79adf log prometheus enabled 2025-02-02 03:21:39 +08:00
yusing
8ae9573b07 add timeout to notification context 2025-02-01 14:42:21 +08:00
yusing
43fce6e739 fix two tests 2025-02-01 14:41:22 +08:00
Yuzerion
78900772bb Feat/ntfy (#57)
* implement ntfy notification

* fix notification fields order

* fix schema for ntfy

---------

Co-authored-by: yusing <yusing@6uo.me>
2025-02-01 13:07:44 +08:00
yusing
c16a0444ca fix main.go and update next release doc 2025-02-01 12:51:52 +08:00
yusing
0d518166ee api: move prometheus handler inside api handler /v1/metrics 2025-02-01 02:09:43 +08:00
yusing
6ae391a3c9 make POST and JSON as notification defaults 2025-01-31 14:56:55 +08:00
yusing
357897a0cd remove schema stuff from code 2025-01-31 05:21:32 +08:00
yusing
10a0a8fe09 update readme 2025-01-31 03:33:20 +08:00
129 changed files with 2723 additions and 2520 deletions

View File

@@ -42,8 +42,8 @@ GODOXY_HTTPS_ADDR=:443
# API listening address
GODOXY_API_ADDR=127.0.0.1:8888
# Prometheus Metrics listening address (uncomment to enable)
#GODOXY_PROMETHEUS_ADDR=:8889
# Prometheus Metrics
GODOXY_PROMETHEUS_ENABLED=true
# Debug mode
GODOXY_DEBUG=false

15
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1,15 @@
# These are supported funding model platforms
github: yusing # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: yusingwysq # Replace with a single Buy Me a Coffee username
thanks_dev: # Replace with a single thanks.dev username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']

View File

@@ -0,0 +1,22 @@
name: Docker Image CI (nightly)
on:
push:
branches:
- "*" # matches every branch that doesn't contain a '/'
- "*/*" # matches every branch containing a single '/'
- "**" # matches every branch
- "!main" # excludes main
jobs:
build-nightly:
uses: ./.github/workflows/docker-image.yml
with:
image_name: ${{ github.repository_owner }}/godoxy
tag: nightly
build-nightly-agent:
uses: ./.github/workflows/docker-image.yml
with:
image_name: ${{ github.repository_owner }}/godoxy-agent
tag: nightly
agent: true

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

@@ -0,0 +1,20 @@
name: Docker Image CI
on:
push:
tags:
- v*
jobs:
build-prod:
uses: ./.github/workflows/docker-image.yml
with:
image_name: ${{ github.repository_owner }}/godoxy
old_image_name: ${{ github.repository_owner }}/go-proxy
tag: latest
build-prod-agent:
uses: ./.github/workflows/docker-image.yml
with:
image_name: ${{ github.repository_owner }}/godoxy-agent
tag: latest
agent: true

View File

@@ -1,128 +1,163 @@
name: Docker Image CI
on:
push:
tags: ["*"]
workflow_call:
inputs:
tag:
required: true
type: string
image_name:
required: true
type: string
old_image_name:
required: false
type: string
agent:
required: false
default: false
type: boolean
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
REGISTRY: ghcr.io
MAKE_ARGS: agent=${{ inputs.agent && '1' || '0' }}
DIGEST_PATH: /tmp/digests/${{ inputs.agent && 'agent' || 'main' }}
DIGEST_NAME_SUFFIX: ${{ inputs.agent && 'agent' || 'main' }}
jobs:
build:
name: Build multi-platform Docker image
runs-on: ubuntu-22.04
build:
strategy:
fail-fast: false
matrix:
include:
- runner: ubuntu-latest
platform: linux/amd64
- runner: ubuntu-24.04-arm
platform: linux/arm64
permissions:
contents: read
packages: write
id-token: write
attestations: write
name: Build ${{ matrix.platform }}
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
platform:
- linux/amd64
# - linux/arm/v6
# - linux/arm/v7
- linux/arm64
steps:
- name: Prepare
run: |
platform=${{ matrix.platform }}
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
permissions:
contents: read
packages: write
id-token: write
attestations: write
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
steps:
- name: Prepare
run: |
platform=${{ matrix.platform }}
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ inputs.image_name }}
tags: |
type=raw,value=${{ inputs.tag }},event=branch
type=ref,event=tag
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
platforms: ${{ matrix.platform }}
- name: Login to registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push by digest
id: build
uses: docker/build-push-action@v6
with:
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
outputs: type=image,name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
cache-from: type=gha
cache-to: type=gha,mode=max
build-args: |
VERSION=${{ github.ref_name }}
- name: Build and push by digest
id: build
uses: docker/build-push-action@v6
with:
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
outputs: type=image,name=${{ env.REGISTRY }}/${{ inputs.image_name }},push-by-digest=true,name-canonical=true,push=true
cache-from: type=gha
cache-to: type=gha,mode=max
build-args: |
VERSION=${{ github.ref_name }}
MAKE_ARGS=${{ env.MAKE_ARGS }}
- name: Generate artifact attestation
uses: actions/attest-build-provenance@v1
with:
subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME}}
subject-digest: ${{ steps.build.outputs.digest }}
push-to-registry: true
- name: Generate artifact attestation
uses: actions/attest-build-provenance@v1
with:
subject-name: ${{ env.REGISTRY }}/${{ inputs.image_name }}
subject-digest: ${{ steps.build.outputs.digest }}
push-to-registry: true
- name: Export digest
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Export digest
run: |
mkdir -p ${{ env.DIGEST_PATH }}
digest="${{ steps.build.outputs.digest }}"
touch "${{ env.DIGEST_PATH }}/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-${{ env.PLATFORM_PAIR }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
merge:
runs-on: ubuntu-22.04
needs:
- build
permissions:
contents: read
packages: write
id-token: write
steps:
- name: Download digests
uses: actions/download-artifact@v4
with:
path: /tmp/digests
pattern: digests-*
merge-multiple: true
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-${{ env.PLATFORM_PAIR }}-${{ env.DIGEST_NAME_SUFFIX }}
path: ${{ env.DIGEST_PATH }}/*
if-no-files-found: error
retention-days: 1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
merge:
needs: build
runs-on: ubuntu-latest
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
permissions:
contents: read
packages: write
id-token: write
- name: Login to registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
steps:
- name: Download digests
uses: actions/download-artifact@v4
with:
path: ${{ env.DIGEST_PATH }}
pattern: digests-*-${{ env.DIGEST_NAME_SUFFIX }}
merge-multiple: true
- name: Create manifest list and push
id: push
working-directory: /tmp/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@sha256:%s ' *)
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Inspect image
run: |
docker buildx imagetools inspect ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }}
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ inputs.image_name }}
tags: |
type=raw,value=${{ inputs.tag }},event=branch
type=ref,event=tag
- name: Login to registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Create manifest list and push
id: push
working-directory: ${{ env.DIGEST_PATH }}
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ env.REGISTRY }}/${{ inputs.image_name }}@sha256:%s ' *)
- name: Old image name
if: inputs.old_image_name != ''
run: |
docker buildx imagetools create -t ${{ env.REGISTRY }}/${{ inputs.old_image_name }}:${{ steps.meta.outputs.version }}\
${{ env.REGISTRY }}/${{ inputs.image_name }}:${{ steps.meta.outputs.version }}
- name: Inspect image
run: |
docker buildx imagetools inspect ${{ env.REGISTRY }}/${{ inputs.image_name }}:${{ steps.meta.outputs.version }}
- name: Inspect image (old)
if: inputs.old_image_name != ''
run: |
docker buildx imagetools inspect ${{ env.REGISTRY }}/${{ inputs.old_image_name }}:${{ steps.meta.outputs.version }}

View File

@@ -9,9 +9,6 @@ linters-settings:
- fieldalignment
gocyclo:
min-complexity: 14
goconst:
min-len: 3
min-occurrences: 4
misspell:
locale: US
funlen:
@@ -102,13 +99,14 @@ linters:
- depguard # Not relevant
- nakedret # Too strict
- lll # Not relevant
- gocyclo # FIXME must be fixed
- gocyclo # must be fixed
- gocognit # Too strict
- nestif # Too many false-positive.
- prealloc # Too many false-positive.
- makezero # Not relevant
- dupl # Too strict
- gci # I don't care
- goconst # Too annoying
- gosec # Too strict
- gochecknoinits
- gochecknoglobals

View File

@@ -2,12 +2,12 @@
# To learn more about the format of this file, see https://docs.trunk.io/reference/trunk-yaml
version: 0.1
cli:
version: 1.22.8
version: 1.22.10
# Trunk provides extensibility via plugins. (https://docs.trunk.io/plugins)
plugins:
sources:
- id: trunk
ref: v1.6.6
ref: v1.6.7
uri: https://github.com/trunk-io/plugins
# Many linters and tools depend on runtimes - configure them here. (https://docs.trunk.io/runtimes)
runtimes:
@@ -22,17 +22,17 @@ lint:
- yamllint
enabled:
- hadolint@2.12.1-beta
- actionlint@1.7.6
- checkov@3.2.352
- actionlint@1.7.7
- checkov@3.2.370
- git-diff-check
- gofmt@1.20.4
- golangci-lint@1.63.4
- golangci-lint@1.64.5
- osv-scanner@1.9.2
- oxipng@9.1.3
- prettier@3.4.2
- oxipng@9.1.4
- prettier@3.5.1
- shellcheck@0.10.0
- shfmt@3.6.0
- trufflehog@3.88.2
- trufflehog@3.88.9
actions:
disabled:
- trunk-announce

View File

@@ -1,10 +1,10 @@
{
"yaml.schemas": {
"https://github.com/yusing/go-proxy/raw/v0.8/schemas/config.schema.json": [
"https://github.com/yusing/go-proxy/raw/main/schemas/config.schema.json": [
"config.example.yml",
"config.yml"
],
"https://github.com/yusing/go-proxy/raw/v0.8/schemas/routes.schema.json": [
"https://github.com/yusing/go-proxy/raw/main/schemas/routes.schema.json": [
"providers.example.yml"
]
}

View File

@@ -1,5 +1,5 @@
# Stage 1: Builder
FROM golang:1.23.5-alpine AS builder
FROM golang:1.23.6-alpine AS builder
HEALTHCHECK NONE
# package version does not matter

View File

@@ -5,6 +5,7 @@ export GOOS = linux
LDFLAGS = -X github.com/yusing/go-proxy/pkg.version=${VERSION}
ifeq ($(trace), 1)
debug = 1
GODOXY_TRACE ?= 1
endif

View File

@@ -1,21 +1,27 @@
<div align="center">
# GoDoxy
[![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)
![GitHub last commit](https://img.shields.io/github/last-commit/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)
[![](https://dcbadge.limes.pink/api/server/umReR62nRd?style=flat)](https://discord.gg/umReR62nRd)
A lightweight, simple, and [performant](https://github.com/yusing/go-proxy/wiki/Benchmarks) reverse proxy with WebUI.
For full documentation, check out **[Wiki](https://github.com/yusing/go-proxy/wiki)**
**EN** | <a href="README_CHT.md">中文</a>
**Currently working on [feat/godoxy-agent](https://github.com/yusing/go-proxy/tree/feat/godoxy-agent).<br/>Fork this instead of default branch.**
<!-- [![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)
[![](https://dcbadge.limes.pink/api/server/umReR62nRd)](https://discord.gg/umReR62nRd)
[![Vulnerabilities](https://sonarcloud.io/api/project_badges/measure?project=yusing_go-proxy&metric=vulnerabilities)](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy) -->
[繁體中文文檔請看此](README_CHT.md)
<img src="https://github.com/user-attachments/assets/4bb371f4-6e4c-425c-89b2-b9e962bdd46f" style="max-width: 650">
A lightweight, easy-to-use, and [performant](https://github.com/yusing/go-proxy/wiki/Benchmarks) reverse proxy with a Web UI and dashboard.
**v0.9 will be out soon, with a brand new [WebUI](next-release.md)!!! Below one is the old one**
![Screenshot](screenshots/webui.png)
_Join our [Discord](https://discord.gg/umReR62nRd) for help and discussions_
</div>
## Table of content
@@ -24,12 +30,10 @@ _Join our [Discord](https://discord.gg/umReR62nRd) for help and discussions_
- [GoDoxy](#godoxy)
- [Table of content](#table-of-content)
- [Key Features](#key-features)
- [Getting Started](#getting-started)
- [Prerequisites](#prerequisites)
- [Setup](#setup)
- [Prerequisites](#prerequisites)
- [Setup](#setup)
- [Manual Setup](#manual-setup)
- [Folder structrue](#folder-structrue)
- [Use JSON Schema in VSCode](#use-json-schema-in-vscode)
- [Screenshots](#screenshots)
- [idlesleeper](#idlesleeper)
- [Build it yourself](#build-it-yourself)
@@ -45,6 +49,7 @@ _Join our [Discord](https://discord.gg/umReR62nRd) for help and discussions_
- Auto hot-reload on container state / config file changes
- **idlesleeper**: stop containers on idle, wake it up on traffic _(optional, see [screenshots](#idlesleeper))_
- HTTP(s) reserve proxy
- OpenID Connect support
- [HTTP middleware support](https://github.com/yusing/go-proxy/wiki/Middlewares)
- [Custom error pages support](https://github.com/yusing/go-proxy/wiki/Middlewares#custom-error-pages)
- TCP and UDP port forwarding
@@ -54,54 +59,28 @@ _Join our [Discord](https://discord.gg/umReR62nRd) for help and discussions_
[🔼Back to top](#table-of-content)
## Getting Started
For full documentation, **[See Wiki](https://github.com/yusing/go-proxy/wiki)**
### Prerequisites
## Prerequisites
Setup DNS Records point to machine which runs `GoDoxy`, e.g.
- A Record: `*.y.z` -> `10.0.10.1`
- AAAA Record: `*.y.z` -> `::ffff:a00:a01`
### Setup
## Setup
1. Pull the latest docker images
**NOTE:** GoDoxy is designed to be (and only works when) running in `host` network mode, do not change it. To change listening ports, modify `.env`.
1. Prepare a new directory for docker compose and config files.
2. Run setup script inside the directory, or [set up manually](#manual-setup)
```shell
docker pull ghcr.io/yusing/go-proxy:latest
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/yusing/go-proxy/main/scripts/setup.sh)"
```
2. Create new directory, `cd` into it, then run setup, or [set up manually](#manual-setup)
3. Start the container `docker compose up -d` and wait for it to be ready
```shell
docker run --rm -v .:/setup ghcr.io/yusing/go-proxy /app/godoxy setup
```
3. _(Optional)_ setup WebUI login
- set random JWT secret
```shell
sed -i "s|API_JWT_SECRET=.*|API_JWT_SECRET=$(openssl rand -base64 32)|g" .env
```
- change username and password for WebUI authentication
```shell
USERNAME=admin
PASSWORD=some-password
sed -i "s|API_USERNAME=.*|API_USERNAME=${USERNAME}|g" .env
sed -i "s|API_PASSWORD=.*|API_PASSWORD=${PASSWORD}|g" .env
```
4. _(Optional)_ setup `docker-socket-proxy` other docker nodes (see [Multi docker nodes setup](https://github.com/yusing/go-proxy/wiki/Configurations#multi-docker-nodes-setup)) then add them inside `config.yml`
5. Start the container `docker compose up -d`
6. You may now do some extra configuration
- With text editor (e.g. Visual Studio Code)
- With Web UI via `https://gp.y.z`
4. You may now do some extra configuration on WebUI `https://godoxy.yourdomain.com`
[🔼Back to top](#table-of-content)
@@ -109,15 +88,15 @@ Setup DNS Records point to machine which runs `GoDoxy`, e.g.
1. Make `config` directory then grab `config.example.yml` into `config/config.yml`
`mkdir -p config && wget https://raw.githubusercontent.com/yusing/go-proxy/v0.8/config.example.yml -O config/config.yml`
`mkdir -p config && wget https://raw.githubusercontent.com/yusing/go-proxy/main/config.example.yml -O config/config.yml`
2. Grab `.env.example` into `.env`
`wget https://raw.githubusercontent.com/yusing/go-proxy/v0.8/.env.example -O .env`
`wget https://raw.githubusercontent.com/yusing/go-proxy/main/.env.example -O .env`
3. Grab `compose.example.yml` into `compose.yml`
`wget https://raw.githubusercontent.com/yusing/go-proxy/v0.8/compose.example.yml -O compose.yml`
`wget https://raw.githubusercontent.com/yusing/go-proxy/main/compose.example.yml -O compose.yml`
### Folder structrue
@@ -133,15 +112,13 @@ Setup DNS Records point to machine which runs `GoDoxy`, e.g.
│ │ ├── middleware2.yml
│ ├── provider1.yml
│ └── provider2.yml
├── data
│ ├── metrics # metrics data
│ │ ├── uptime.json
│ │ └── system_info.json
└── .env
```
### Use JSON Schema in VSCode
Copy [`.vscode/settings.example.json`](.vscode/settings.example.json) to `.vscode/settings.json` and modify it to fit your needs
[🔼Back to top](#table-of-content)
## Screenshots
### idlesleeper

View File

@@ -1,19 +1,25 @@
<div align="center">
# GoDoxy
[![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)
![GitHub last commit](https://img.shields.io/github/last-commit/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)
[![](https://dcbadge.limes.pink/api/server/umReR62nRd?style=flat)](https://discord.gg/umReR62nRd)
輕量、易用、 [高效能](https://github.com/yusing/go-proxy/wiki/Benchmarks),且帶有主頁和配置面板的反向代理
完整文檔請查閱 **[Wiki](https://github.com/yusing/go-proxy/wiki)**(暫未有中文翻譯)
<!-- [![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)
[![](https://dcbadge.limes.pink/api/server/umReR62nRd)](https://discord.gg/umReR62nRd)
[![Vulnerabilities](https://sonarcloud.io/api/project_badges/measure?project=yusing_go-proxy&metric=vulnerabilities)](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy) -->
[English Documentation](README.md)
<a href="README.md">EN</a> | **中文**
一個輕量級、易於使用且[高效能](https://github.com/yusing/go-proxy/wiki/Benchmarks)的反向代理,具有網頁介面和儀表板。
<img src="https://github.com/user-attachments/assets/4bb371f4-6e4c-425c-89b2-b9e962bdd46f" style="max-width: 650">
![截圖](screenshots/webui.png)
_加入我們的 [Discord](https://discord.gg/umReR62nRd) 獲取幫助和討論_
</div>
## 目錄
@@ -22,12 +28,10 @@ _加入我們的 [Discord](https://discord.gg/umReR62nRd) 獲取幫助和討論_
- [GoDoxy](#godoxy)
- [目錄](#目錄)
- [主要特點](#主要特點)
- [入門指南](#入門指南)
- [前置需求](#前置需求)
- [安裝](#安裝)
- [前置需求](#前置需求)
- [安裝](#安裝)
- [手動安裝](#手動安裝)
- [資料夾結構](#資料夾結構)
- [在 VSCode 中使用 JSON Schema](#在-vscode-中使用-json-schema)
- [截圖](#截圖)
- [閒置休眠](#閒置休眠)
- [自行編譯](#自行編譯)
@@ -43,6 +47,7 @@ _加入我們的 [Discord](https://discord.gg/umReR62nRd) 獲取幫助和討論_
- 容器狀態/配置文件變更時自動熱重載
- **閒置休眠**在閒置時停止容器有流量時喚醒_可選參見[截圖](#閒置休眠)_
- HTTP(s) 反向代理
- OpenID Connect 支持
- [HTTP 中介軟體支援](https://github.com/yusing/go-proxy/wiki/Middlewares)
- [自訂錯誤頁面支援](https://github.com/yusing/go-proxy/wiki/Middlewares#custom-error-pages)
- TCP 和 UDP 埠轉發
@@ -52,54 +57,28 @@ _加入我們的 [Discord](https://discord.gg/umReR62nRd) 獲取幫助和討論_
[🔼回到頂部](#目錄)
## 入門指南
完整文檔請參見 **[Wiki](https://github.com/yusing/go-proxy/wiki)**
### 前置需求
## 前置需求
設置 DNS 記錄指向運行 `GoDoxy` 的機器,例如:
- A 記錄:`*.y.z` -> `10.0.10.1`
- AAAA 記錄:`*.y.z` -> `::ffff:a00:a01`
### 安裝
## 安裝
1. 拉取最新的 Docker 映像
**注意:** GoDoxy 設計為(且僅在)`host` 網路模式下運作,請勿更改。如需更改監聽埠,請修改 `.env`
1. 準備一個新目錄用於 docker compose 和配置文件。
2. 在目錄內運行安裝腳本,或[手動安裝](#手動安裝)
```shell
docker pull ghcr.io/yusing/go-proxy:latest
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/yusing/go-proxy/main/scripts/setup.sh)"
```
2. 建立新目錄,`cd` 進入後運行安裝,或[手動安裝](#手動安裝)
3. 啟動容器 `docker compose up -d` 並等待就緒
```shell
docker run --rm -v .:/setup ghcr.io/yusing/go-proxy /app/godoxy setup
```
3. _可選_ 設置網頁介面登入
- 設置隨機 JWT 密鑰
```shell
sed -i "s|API_JWT_SECRET=.*|API_JWT_SECRET=$(openssl rand -base64 32)|g" .env
```
- 更改網頁介面認證的使用者名稱和密碼
```shell
USERNAME=admin
PASSWORD=some-password
sed -i "s|API_USERNAME=.*|API_USERNAME=${USERNAME}|g" .env
sed -i "s|API_PASSWORD=.*|API_PASSWORD=${PASSWORD}|g" .env
```
4. _可選_ 設置其他 Docker 節點的 `docker-socket-proxy`(參見 [多 Docker 節點設置](https://github.com/yusing/go-proxy/wiki/Configurations#multi-docker-nodes-setup)),然後在 `config.yml` 中添加它們
5. 啟動容器 `docker compose up -d`
6. 現在您可以進行額外的配置
- 使用文字編輯器(如 Visual Studio Code
- 通過網頁介面 `https://gp.y.z`
4. 現在可以在 WebUI `https://godoxy.yourdomain.com` 進行額外配置
[🔼回到頂部](#目錄)
@@ -107,15 +86,15 @@ _加入我們的 [Discord](https://discord.gg/umReR62nRd) 獲取幫助和討論_
1. 建立 `config` 目錄,然後將 `config.example.yml` 下載到 `config/config.yml`
`mkdir -p config && wget https://raw.githubusercontent.com/yusing/go-proxy/v0.8/config.example.yml -O config/config.yml`
`mkdir -p config && wget https://raw.githubusercontent.com/yusing/go-proxy/main/config.example.yml -O config/config.yml`
2. 將 `.env.example` 下載到 `.env`
`wget https://raw.githubusercontent.com/yusing/go-proxy/v0.8/.env.example -O .env`
`wget https://raw.githubusercontent.com/yusing/go-proxy/main/.env.example -O .env`
3. 將 `compose.example.yml` 下載到 `compose.yml`
`wget https://raw.githubusercontent.com/yusing/go-proxy/v0.8/compose.example.yml -O compose.yml`
`wget https://raw.githubusercontent.com/yusing/go-proxy/main/compose.example.yml -O compose.yml`
### 資料夾結構
@@ -131,15 +110,13 @@ _加入我們的 [Discord](https://discord.gg/umReR62nRd) 獲取幫助和討論_
│ │ ├── middleware2.yml
│ ├── provider1.yml
│ └── provider2.yml
├── data
│ ├── metrics # metrics data
│ │ ├── uptime.json
│ │ └── system_info.json
└── .env
```
### 在 VSCode 中使用 JSON Schema
複製 [`.vscode/settings.example.json`](.vscode/settings.example.json) 到 `.vscode/settings.json` 並根據需要修改
[🔼回到頂部](#目錄)
## 截圖
### 閒置休眠

View File

@@ -35,9 +35,6 @@ func init() {
}
logging.InitLogger(out)
// logging.AddHook(v1.GetMemLogger())
internal.InitIconListCache()
homepage.InitOverridesConfig()
favicon.InitIconCache()
}
func main() {
@@ -45,9 +42,6 @@ func main() {
args := common.GetArgs()
switch args.Command {
case common.CommandSetup:
internal.Setup()
return
case common.CommandReload:
if err := query.ReloadServer(); err != nil {
E.LogFatal("server reload error", err)
@@ -120,16 +114,19 @@ func main() {
printJSON(cfg.Value())
return
case common.CommandDebugListEntries:
printJSON(cfg.DumpEntries())
printJSON(cfg.DumpRoutes())
return
case common.CommandDebugListProviders:
printJSON(cfg.DumpRouteProviders())
return
}
go internal.InitIconListCache()
go homepage.InitOverridesConfig()
go favicon.InitIconCache()
cfg.Start(&config.StartServersOptions{
Proxy: true,
Metrics: true,
Proxy: true,
})
if err := auth.Initialize(); err != nil {
logging.Fatal().Err(err).Msg("failed to initialize authentication")

View File

@@ -1,7 +1,7 @@
---
services:
frontend:
image: ghcr.io/yusing/go-proxy-frontend:latest
image: ghcr.io/yusing/godoxy-frontend:latest
container_name: godoxy-frontend
restart: unless-stopped
network_mode: host
@@ -10,17 +10,18 @@ services:
- app
# modify below to fit your needs
labels:
proxy.aliases: gp
proxy.#1.port: 3000
# proxy.#1.middlewares.cidr_whitelist.status: 403
# proxy.#1.middlewares.cidr_whitelist.message: IP not allowed
# proxy.#1.middlewares.cidr_whitelist.allow: |
proxy.aliases: godoxy
proxy.godoxy.port: 3000
# proxy.godoxy.middlewares.cidr_whitelist: |
# status: 403
# message: IP not allowed
# allow:
# - 127.0.0.1
# - 10.0.0.0/8
# - 192.168.0.0/16
# - 172.16.0.0/12
app:
image: ghcr.io/yusing/go-proxy:latest
image: ghcr.io/yusing/godoxy:latest
container_name: godoxy
restart: always
network_mode: host
@@ -28,15 +29,13 @@ services:
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./config:/app/config
- ./logs:/app/logs
- ./error_pages:/app/error_pages
# (Optional) choose one of below to enable https
# 1. use existing certificate
# To use autocert, certs will be stored in "./certs".
# You can also use a docker volume to store it
- ./certs:/app/certs
# remove "./certs:/app/certs" and uncomment below to use existing certificate
# - /path/to/certs/cert.crt:/app/certs/cert.crt
# - /path/to/certs/priv.key:/app/certs/priv.key
# 2. use autocert, certs will be stored in ./certs
# you can also use a docker volume to store it
# - ./certs:/app/certs

View File

@@ -1,78 +1,42 @@
# Autocert (choose one below and uncomment to enable)
#
# 1. use existing cert
#
# autocert:
# provider: local
#
# cert_path: certs/cert.crt # optional, uncomment only if you need to change it
# key_path: certs/priv.key # optional, uncomment only if you need to change it
#
# 2. cloudflare
#
# autocert:
# provider: cloudflare
# email: abc@gmail.com # ACME Email
# domains: # a list of domains for cert registration
# - "*.y.z" # remember to use double quotes to surround wildcard domain
# email: abc@gmail.com # ACME Email
# domains: # a list of domains for cert registration
# - "*.domain.com"
# - "domain.com"
# options:
# auth_token: c1234565789-abcdefghijklmnopqrst # your zone API token
#
# 3. other providers, check docs/dns_providers.md for more
# auth_token: c1234565789-abcdefghijklmnopqrst # your zone API token
# 3. other providers, see https://github.com/yusing/go-proxy/wiki/Supported-DNS%E2%80%9001-Providers#supported-dns-01-providers
entrypoint:
middlewares:
# this part blocks all non-LAN HTTP traffic
# remove if you don't want this
- use: CIDRWhitelist
allow:
- "127.0.0.1"
- "10.0.0.0/8"
- "172.16.0.0/12"
- "192.168.0.0/16"
status: 403
message: "Forbidden"
# end of CIDRWhitelist
# Below define an example of middleware config
# 1. block non local IP connections
# 2. redirect HTTP to HTTPS
#
# middlewares:
# - use: CIDRWhitelist
# allow:
# - "127.0.0.1"
# - "10.0.0.0/8"
# - "172.16.0.0/12"
# - "192.168.0.0/16"
# status: 403
# message: "Forbidden"
# - use: RedirectHTTP
# this part redirects HTTP to HTTPS
# remove if you don't want this
- use: RedirectHTTP
# access_log:
# buffer_size: 1024
# path: /var/log/example.log
# filters:
# status_codes:
# values:
# - 200-299
# - 101
# method:
# values:
# - GET
# host:
# values:
# - example.y.z
# headers:
# negative: true
# values:
# - foo=bar
# - baz
# cidr:
# values:
# - 192.168.10.0/24
# fields:
# headers:
# default: keep
# config:
# foo: redact
# query:
# default: drop
# config:
# foo: keep
# cookies:
# default: redact
# config:
# foo: keep
# below enables access log
access_log:
format: combined
path: /app/logs/entrypoint.log
providers:
# include files are standalone yaml files under `config/` directory
@@ -84,6 +48,7 @@ providers:
docker:
# $DOCKER_HOST implies environment variable `DOCKER_HOST` or unix:///var/run/docker.sock by default
local: $DOCKER_HOST
# explicit only mode
# only containers with explicit aliases will be proxied
# add "!" after provider name to enable explicit only mode
@@ -106,28 +71,10 @@ providers:
# - name: discord
# provider: webhook
# url: https://discord.com/api/webhooks/...
# template: discord
# # payload: | # discord template implies the following
# # {
# # "embeds": [
# # {
# # "title": $title,
# # "fields": $fields,
# # "color": "$color"
# # }
# # ]
# # }
# if match_domains not defined
# any host = alias+[any domain] will match
# i.e. https://app1.y.z will match alias app1 for any domain y.z
# but https://app1.node1.y.z will only match alias "app.node1"
#
# if match_domains defined
# only host = alias+[one of match_domains] will match
# i.e. match_domains = [node1.my.app, my.site]
# https://app1.my.app, https://app1.my.net, etc. will not match even if app1 exists
# only https://*.node1.my.app and https://*.my.site will match
#
# template: discord # this means use payload template from internal/notif/templates/discord.json
# Check https://github.com/yusing/go-proxy/wiki/Certificates-and-domain-matching#domain-matching
# for explaination of `match_domains`
#
# match_domains:
# - my.site

59
go.mod
View File

@@ -1,30 +1,30 @@
module github.com/yusing/go-proxy
go 1.23.5
go 1.23.6
require (
github.com/PuerkitoBio/goquery v1.10.1
github.com/coder/websocket v1.8.12
github.com/coreos/go-oidc/v3 v3.12.0
github.com/docker/cli v27.5.1+incompatible
github.com/docker/docker v27.5.1+incompatible
github.com/fsnotify/fsnotify v1.8.0
github.com/go-acme/lego/v4 v4.21.0
github.com/go-playground/validator/v10 v10.24.0
github.com/gobwas/glob v0.2.3
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/gotify/server/v2 v2.6.1
github.com/lithammer/fuzzysearch v1.1.8
github.com/prometheus/client_golang v1.20.5
github.com/puzpuzpuz/xsync/v3 v3.5.0
github.com/rs/zerolog v1.33.0
github.com/vincent-petithory/dataurl v1.0.0
golang.org/x/crypto v0.32.0
golang.org/x/net v0.34.0
golang.org/x/oauth2 v0.25.0
golang.org/x/text v0.21.0
golang.org/x/time v0.9.0
gopkg.in/yaml.v3 v3.0.1
github.com/PuerkitoBio/goquery v1.10.2 // parsing HTML for extract fav icon
github.com/coder/websocket v1.8.12 // websocket for API and agent
github.com/coreos/go-oidc/v3 v3.12.0 // oidc authentication
github.com/docker/cli v27.5.1+incompatible // docker CLI
github.com/docker/docker v27.5.1+incompatible // docker daemon
github.com/fsnotify/fsnotify v1.8.0 // file watcher
github.com/go-acme/lego/v4 v4.22.2 // acme client
github.com/go-playground/validator/v10 v10.25.0 // validator
github.com/gobwas/glob v0.2.3 // glob matcher for route rules
github.com/golang-jwt/jwt/v5 v5.2.1 // jwt for default auth
github.com/gotify/server/v2 v2.6.1 // reference the Message struct for json response
github.com/lithammer/fuzzysearch v1.1.8 // fuzzy search for searching icons and filtering metrics
github.com/prometheus/client_golang v1.20.5 // metrics
github.com/puzpuzpuz/xsync/v3 v3.5.1 // lock free map for concurrent operations
github.com/rs/zerolog v1.33.0 // logging
github.com/vincent-petithory/dataurl v1.0.0 // data url for fav icon
golang.org/x/crypto v0.33.0 // encrypting password with bcrypt
golang.org/x/net v0.35.0 // HTTP header utilities
golang.org/x/oauth2 v0.26.0 // oauth2 authentication
golang.org/x/text v0.22.0 // string utilities
golang.org/x/time v0.10.0 // time utilities
gopkg.in/yaml.v3 v3.0.1 // yaml parsing for different config files
)
require (
@@ -57,9 +57,10 @@ require (
github.com/moby/term v0.5.0 // indirect
github.com/morikuni/aec v1.0.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/nrdcg/porkbun v0.4.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/ovh/go-ovh v1.7.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.62.0 // indirect
@@ -72,11 +73,11 @@ require (
go.opentelemetry.io/otel/metric v1.34.0 // indirect
go.opentelemetry.io/otel/sdk v1.30.0 // indirect
go.opentelemetry.io/otel/trace v1.34.0 // indirect
golang.org/x/mod v0.22.0 // indirect
golang.org/x/sync v0.10.0 // indirect
golang.org/x/sys v0.29.0 // indirect
golang.org/x/tools v0.29.0 // indirect
google.golang.org/protobuf v1.36.4 // indirect
golang.org/x/mod v0.23.0 // indirect
golang.org/x/sync v0.11.0 // indirect
golang.org/x/sys v0.30.0 // indirect
golang.org/x/tools v0.30.0 // indirect
google.golang.org/protobuf v1.36.5 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gotest.tools/v3 v3.5.1 // indirect
)

60
go.sum
View File

@@ -2,8 +2,8 @@ github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOEl
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/PuerkitoBio/goquery v1.10.1 h1:Y8JGYUkXWTGRB6Ars3+j3kN0xg1YqqlwvdTV8WTFQcU=
github.com/PuerkitoBio/goquery v1.10.1/go.mod h1:IYiHrOMps66ag56LEH7QYDDupKXyo5A8qrjIx3ZtujY=
github.com/PuerkitoBio/goquery v1.10.2 h1:7fh2BdHcG6VFZsK7toXBT/Bh1z5Wmy8Q9MV9HqT2AM8=
github.com/PuerkitoBio/goquery v1.10.2/go.mod h1:0guWGjcLu9AYC7C1GHnpysHy056u9aEkUHwhdnePMCU=
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
@@ -41,8 +41,8 @@ github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/
github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/go-acme/lego/v4 v4.21.0 h1:arEW+8o5p7VI8Bk1kr/PDlgD1DrxtTH1gJ4b7mehL8o=
github.com/go-acme/lego/v4 v4.21.0/go.mod h1:HrSWzm3Ckj45Ie3i+p1zKVobbQoMOaGu9m4up0dUeDI=
github.com/go-acme/lego/v4 v4.22.2 h1:ck+HllWrV/rZGeYohsKQ5iKNnU/WAZxwOdiu6cxky+0=
github.com/go-acme/lego/v4 v4.22.2/go.mod h1:E2FndyI3Ekv0usNJt46mFb9LVpV/XBYT+4E3tz02Tzo=
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=
@@ -56,8 +56,8 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.24.0 h1:KHQckvo8G6hlWnrPX4NJJ+aBfWNAE/HH+qdL2cBpCmg=
github.com/go-playground/validator/v10 v10.24.0/go.mod h1:GGzBIJMuE98Ic/kJsBXbz1x/7cByt++cQ+YOuDM5wus=
github.com/go-playground/validator/v10 v10.25.0 h1:5Dh7cjvzR7BRZadnsVOzPhWsrwUr0nmsZJxEAnFLNO8=
github.com/go-playground/validator/v10 v10.25.0/go.mod h1:GGzBIJMuE98Ic/kJsBXbz1x/7cByt++cQ+YOuDM5wus=
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
@@ -113,12 +113,14 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/nrdcg/porkbun v0.4.0 h1:rWweKlwo1PToQ3H+tEO9gPRW0wzzgmI/Ob3n2Guticw=
github.com/nrdcg/porkbun v0.4.0/go.mod h1:/QMskrHEIM0IhC/wY7iTCUgINsxdT2WcOphktJ9+Q54=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
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/ovh/go-ovh v1.7.0 h1:V14nF7FwDjQrZt9g7jzcvAAQ3HN6DNShRFRMC3jLoPw=
github.com/ovh/go-ovh v1.7.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/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
@@ -132,8 +134,8 @@ github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ
github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I=
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
github.com/puzpuzpuz/xsync/v3 v3.5.0 h1:i+cMcpEDY1BkNm7lPDkCtE4oElsYLn+EKF8kAu2vXT4=
github.com/puzpuzpuz/xsync/v3 v3.5.0/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA=
github.com/puzpuzpuz/xsync/v3 v3.5.1 h1:GJYJZwO6IdxN/IKbneznS6yPkVC+c3zyY/j19c++5Fg=
github.com/puzpuzpuz/xsync/v3 v3.5.1/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
@@ -176,8 +178,8 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
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.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
@@ -185,8 +187,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4=
golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
golang.org/x/mod v0.23.0 h1:Zb7khfcRGKk+kqfxFaP5tZqCnDZMjC5VtUBs87Hr6QM=
golang.org/x/mod v0.23.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=
@@ -199,10 +201,10 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
golang.org/x/oauth2 v0.25.0 h1:CY4y7XT9v0cRI9oupztF8AgiIu99L/ksR/Xp/6jrZ70=
golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
golang.org/x/oauth2 v0.26.0 h1:afQXWNNaeC4nvZ0Ed9XvCCzXM6UHJG7iCg0W4fPqSBE=
golang.org/x/oauth2 v0.26.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=
@@ -211,8 +213,9 @@ golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -229,8 +232,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -248,10 +251,11 @@ golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4=
golang.org/x/time v0.10.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=
@@ -260,8 +264,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.29.0 h1:Xx0h3TtM9rzQpQuR4dKLrdglAmCEN5Oi+P74JdhdzXE=
golang.org/x/tools v0.29.0/go.mod h1:KMQVMRsVxU6nHCFXrBPhDB8XncLNLM0lIy/F14RP588=
golang.org/x/tools v0.30.0 h1:BgcpHewrV5AUp2G9MebG4XPFI1E2W41zU1SaqVA9vJY=
golang.org/x/tools v0.30.0/go.mod h1:c347cR/OJfw5TI+GfX7RUPNMdDRRbjvYTS0jPyvsVtY=
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=
@@ -273,8 +277,8 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576 h1:
google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576/go.mod h1:5uTbfoYQed2U9p3KIj2/Zzm02PYhndfdmML0qC3q3FU=
google.golang.org/grpc v1.67.1 h1:zWnc1Vrcno+lHZCOofnIMvycFcc0QRGIzm9dhnDX68E=
google.golang.org/grpc v1.67.1/go.mod h1:1gLDyUQU7CTLJI90u3nXZ9ekeghjeM7pTDZlqFNg2AA=
google.golang.org/protobuf v1.36.4 h1:6A3ZDJHn/eNqc1i+IdefRzy/9PokBTPvcqMySR7NNIM=
google.golang.org/protobuf v1.36.4/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM=
google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
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=

View File

@@ -3,10 +3,13 @@ package api
import (
"net/http"
"github.com/prometheus/client_golang/prometheus/promhttp"
v1 "github.com/yusing/go-proxy/internal/api/v1"
"github.com/yusing/go-proxy/internal/api/v1/auth"
"github.com/yusing/go-proxy/internal/api/v1/favicon"
"github.com/yusing/go-proxy/internal/common"
config "github.com/yusing/go-proxy/internal/config/types"
"github.com/yusing/go-proxy/internal/logging"
"github.com/yusing/go-proxy/internal/utils/strutils"
)
@@ -36,6 +39,11 @@ func NewHandler(cfg config.ConfigInstance) http.Handler {
mux.HandleFunc("GET", "/v1/favicon", auth.RequireAuth(favicon.GetFavIcon))
mux.HandleFunc("POST", "/v1/homepage/set", auth.RequireAuth(v1.SetHomePageOverrides))
if common.PrometheusEnabled {
mux.Handle("GET /v1/metrics", promhttp.Handler())
logging.Info().Msg("prometheus metrics enabled")
}
defaultAuth := auth.GetDefaultAuth()
if defaultAuth != nil {
mux.HandleFunc("GET", "/v1/auth/redirect", defaultAuth.RedirectLoginPage)

View File

@@ -14,7 +14,6 @@ import (
"github.com/coreos/go-oidc/v3/oidc"
"github.com/golang-jwt/jwt/v5"
"github.com/yusing/go-proxy/internal/common"
E "github.com/yusing/go-proxy/internal/error"
"golang.org/x/oauth2"
. "github.com/yusing/go-proxy/internal/utils/testing"
@@ -227,7 +226,7 @@ func TestOIDCCallbackHandler(t *testing.T) {
}
if tt.wantStatus == http.StatusTemporaryRedirect {
setCookie := E.Must(http.ParseSetCookie(w.Header().Get("Set-Cookie")))
setCookie := Must(http.ParseSetCookie(w.Header().Get("Set-Cookie")))
ExpectEqual(t, setCookie.Name, defaultAuth.TokenCookieName())
ExpectTrue(t, setCookie.Value != "")
ExpectEqual(t, setCookie.Path, "/")

View File

@@ -9,7 +9,6 @@ import (
"testing"
"time"
E "github.com/yusing/go-proxy/internal/error"
. "github.com/yusing/go-proxy/internal/utils/testing"
"golang.org/x/crypto/bcrypt"
)
@@ -17,7 +16,7 @@ import (
func newMockUserPassAuth() *UserPassAuth {
return &UserPassAuth{
username: "username",
pwdHash: E.Must(bcrypt.GenerateFromPassword([]byte("password"), bcrypt.DefaultCost)),
pwdHash: Must(bcrypt.GenerateFromPassword([]byte("password"), bcrypt.DefaultCost)),
secret: []byte("abcdefghijklmnopqrstuvwxyz"),
tokenTTL: time.Hour,
}
@@ -97,13 +96,13 @@ func TestUserPassLoginCallbackHandler(t *testing.T) {
w := httptest.NewRecorder()
req := &http.Request{
Host: "app.example.com",
Body: io.NopCloser(bytes.NewReader(E.Must(json.Marshal(tt.creds)))),
Body: io.NopCloser(bytes.NewReader(Must(json.Marshal(tt.creds)))),
}
auth.LoginCallbackHandler(w, req)
if tt.wantErr {
ExpectEqual(t, w.Code, http.StatusUnauthorized)
} else {
setCookie := E.Must(http.ParseSetCookie(w.Header().Get("Set-Cookie")))
setCookie := Must(http.ParseSetCookie(w.Header().Get("Set-Cookie")))
ExpectTrue(t, setCookie.Name == auth.TokenCookieName())
ExpectTrue(t, setCookie.Value != "")
ExpectEqual(t, setCookie.Domain, "example.com")

View File

@@ -29,6 +29,9 @@ const (
)
func InitIconCache() {
iconCacheMu.Lock()
defer iconCacheMu.Unlock()
err := utils.LoadJSONIfExist(common.IconCachePath, &iconCache)
if err != nil {
logging.Error().Err(err).Msg("failed to load icon cache")
@@ -78,7 +81,7 @@ func pruneExpiredIconCache() {
}
func routeKey(r route.HTTPRoute) string {
return r.RawEntry().Provider + ":" + r.TargetName()
return r.ProviderName() + ":" + r.TargetName()
}
func PruneRouteIconCache(route route.HTTPRoute) {

View File

@@ -87,7 +87,7 @@ func GetFavIcon(w http.ResponseWriter, req *http.Request) {
}
var result *fetchResult
hp := r.RawEntry().Homepage.GetOverride()
hp := r.HomepageConfig().GetOverride()
if !hp.IsEmpty() && hp.Icon != nil {
if hp.Icon.IconSource == homepage.IconSourceRelative {
result = findIcon(r, req, hp.Icon.Value)
@@ -189,7 +189,7 @@ func findIcon(r route.HTTPRoute, req *http.Request, uri string) *fetchResult {
}
result := fetchIcon("png", sanitizeName(r.TargetName()))
cont := r.RawEntry().Container
cont := r.ContainerInfo()
if !result.OK() && cont != nil {
result = fetchIcon("png", sanitizeName(cont.ImageName))
}

View File

@@ -71,6 +71,9 @@ func List(cfg config.ConfigInstance, w http.ResponseWriter, r *http.Request) {
U.RespondError(w, err)
return
}
if icons == nil {
icons = []string{}
}
U.RespondJSON(w, r, icons)
case ListTasks:
U.RespondJSON(w, r, task.DebugTaskList())

View File

@@ -5,6 +5,7 @@ import (
"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/go-acme/lego/v4/providers/dns/porkbun"
)
const (
@@ -20,6 +21,7 @@ const (
ProviderClouddns = "clouddns"
ProviderDuckdns = "duckdns"
ProviderOVH = "ovh"
ProviderPorkbun = "porkbun"
)
var providersGenMap = map[string]ProviderGenerator{
@@ -28,4 +30,5 @@ var providersGenMap = map[string]ProviderGenerator{
ProviderClouddns: providerGenerator(clouddns.NewDefaultConfig, clouddns.NewDNSProviderConfig),
ProviderDuckdns: providerGenerator(duckdns.NewDefaultConfig, duckdns.NewDNSProviderConfig),
ProviderOVH: providerGenerator(ovh.NewDefaultConfig, ovh.NewDNSProviderConfig),
ProviderPorkbun: providerGenerator(porkbun.NewDefaultConfig, porkbun.NewDNSProviderConfig),
}

View File

@@ -12,7 +12,6 @@ type Args struct {
const (
CommandStart = ""
CommandSetup = "setup"
CommandValidate = "validate"
CommandListConfigs = "ls-config"
CommandListRoutes = "ls-routes"
@@ -25,7 +24,6 @@ const (
var ValidCommands = []string{
CommandStart,
CommandSetup,
CommandValidate,
CommandListConfigs,
CommandListRoutes,
@@ -36,6 +34,15 @@ var ValidCommands = []string{
CommandDebugListMTrace,
}
func validateArg(arg string) error {
for _, v := range ValidCommands {
if arg == v {
return nil
}
}
return fmt.Errorf("invalid command %q", arg)
}
func GetArgs() Args {
var args Args
flag.Parse()
@@ -45,12 +52,3 @@ func GetArgs() Args {
}
return args
}
func validateArg(arg string) error {
for _, v := range ValidCommands {
if arg == v {
return nil
}
}
return fmt.Errorf("invalid command %q", arg)
}

View File

@@ -26,10 +26,6 @@ const (
MiddlewareComposeBasePath = ConfigBasePath + "/middlewares"
SchemasBasePath = "schemas"
ConfigSchemaPath = SchemasBasePath + "/config.schema.json"
FileProviderSchemaPath = SchemasBasePath + "/providers.schema.json"
ComposeFileName = "compose.yml"
ComposeExampleFileName = "compose.example.yml"
@@ -38,7 +34,6 @@ const (
var RequiredDirectories = []string{
ConfigBasePath,
SchemasBasePath,
ErrorPagesBasePath,
MiddlewareComposeBasePath,
}

View File

@@ -38,11 +38,7 @@ var (
APIHTTPPort,
APIHTTPURL = GetAddrEnv("API_ADDR", "127.0.0.1:8888", "http")
MetricsHTTPAddr,
MetricsHTTPHost,
MetricsHTTPPort,
MetricsHTTPURL = GetAddrEnv("PROMETHEUS_ADDR", "", "http")
PrometheusEnabled = MetricsHTTPURL != ""
PrometheusEnabled = GetEnvBool("PROMETHEUS_ENABLED", false)
APIJWTSecret = decodeJWTKey(GetEnvString("API_JWT_SECRET", ""))
APIJWTTokenTTL = GetDurationEnv("API_JWT_TOKEN_TTL", time.Hour)

View File

@@ -15,7 +15,6 @@ import (
"github.com/yusing/go-proxy/internal/entrypoint"
E "github.com/yusing/go-proxy/internal/error"
"github.com/yusing/go-proxy/internal/logging"
"github.com/yusing/go-proxy/internal/metrics"
"github.com/yusing/go-proxy/internal/net/http/server"
"github.com/yusing/go-proxy/internal/notif"
proxy "github.com/yusing/go-proxy/internal/route/provider"
@@ -104,7 +103,6 @@ func OnConfigChange(ev []events.Event) {
}
if err := Reload(); err != nil {
logging.Warn().Msg("using last config")
// recovered in event queue
panic(err)
}
@@ -119,14 +117,14 @@ func Reload() E.Error {
err := newCfg.load()
if err != nil {
newCfg.task.Finish(err)
return err
return E.New("using last config").With(err)
}
// cancel all current subtasks -> wait
// -> replace config -> start new subtasks
instance.task.Finish("config changed")
instance = newCfg
instance.Start()
instance.Start(StartAllServers)
return nil
}
@@ -182,9 +180,11 @@ func (cfg *Config) StartProxyProviders() {
}
type StartServersOptions struct {
Proxy, API, Metrics bool
Proxy, API bool
}
var StartAllServers = &StartServersOptions{true, true}
func (cfg *Config) StartServers(opts ...*StartServersOptions) {
if len(opts) == 0 {
opts = append(opts, &StartServersOptions{})
@@ -207,14 +207,6 @@ func (cfg *Config) StartServers(opts ...*StartServersOptions) {
Handler: api.NewHandler(cfg),
})
}
if opt.Metrics && common.PrometheusEnabled {
server.StartServer(cfg.task, server.Options{
Name: "metrics",
CertProvider: cfg.AutoCertProvider(),
HTTPAddr: common.MetricsHTTPAddr,
Handler: metrics.NewHandler(),
})
}
}
func (cfg *Config) load() E.Error {

View File

@@ -1,16 +1,15 @@
package config
import (
route "github.com/yusing/go-proxy/internal/route"
"github.com/yusing/go-proxy/internal/route"
"github.com/yusing/go-proxy/internal/route/provider"
"github.com/yusing/go-proxy/internal/route/types"
)
func (cfg *Config) DumpEntries() map[string]*types.RawEntry {
entries := make(map[string]*types.RawEntry)
func (cfg *Config) DumpRoutes() map[string]*route.Route {
entries := make(map[string]*route.Route)
cfg.providers.RangeAll(func(_ string, p *provider.Provider) {
p.RangeRoutes(func(alias string, r *route.Route) {
entries[alias] = r.Entry
entries[alias] = r
})
})
return entries

View File

@@ -12,7 +12,7 @@ import (
)
type (
PortMapping = map[string]types.Port
PortMapping = map[int]types.Port
Container struct {
_ U.NoCopy

View File

@@ -44,7 +44,7 @@ func (c containerHelper) getPublicPortMapping() PortMapping {
if v.PublicPort == 0 {
continue
}
res[strutils.PortString(v.PublicPort)] = v
res[int(v.PublicPort)] = v
}
return res
}
@@ -52,7 +52,7 @@ func (c containerHelper) getPublicPortMapping() PortMapping {
func (c containerHelper) getPrivatePortMapping() PortMapping {
res := make(PortMapping)
for _, v := range c.Ports {
res[strutils.PortString(v.PrivatePort)] = v
res[int(v.PrivatePort)] = v
}
return res
}
@@ -66,14 +66,6 @@ var databaseMPs = map[string]struct{}{
"/var/lib/rabbitmq": {},
}
var databasePrivPorts = map[uint16]struct{}{
5432: {}, // postgres
3306: {}, // mysql, mariadb
6379: {}, // redis
11211: {}, // memcached
27017: {}, // mongodb
}
func (c containerHelper) isDatabase() bool {
for _, m := range c.Mounts {
if _, ok := databaseMPs[m.Destination]; ok {
@@ -82,7 +74,9 @@ func (c containerHelper) isDatabase() bool {
}
for _, v := range c.Ports {
if _, ok := databasePrivPorts[v.PrivatePort]; ok {
switch v.PrivatePort {
// postgres, mysql or mariadb, redis, memcached, mongodb
case 5432, 3306, 6379, 11211, 27017:
return true
}
}

View File

@@ -38,32 +38,32 @@ const (
// TODO: support stream
func newWaker(parent task.Parent, entry route.Entry, rp *reverseproxy.ReverseProxy, stream net.Stream) (Waker, E.Error) {
hcCfg := entry.RawEntry().HealthCheck
func newWaker(parent task.Parent, route route.Route, rp *reverseproxy.ReverseProxy, stream net.Stream) (Waker, E.Error) {
hcCfg := route.HealthCheckConfig()
hcCfg.Timeout = idleWakerCheckTimeout
waker := &waker{
rp: rp,
stream: stream,
}
task := parent.Subtask("idlewatcher." + entry.TargetName())
watcher, err := registerWatcher(task, entry, waker)
task := parent.Subtask("idlewatcher." + route.TargetName())
watcher, err := registerWatcher(task, route, waker)
if err != nil {
return nil, E.Errorf("register watcher: %w", err)
}
switch {
case rp != nil:
waker.hc = monitor.NewHTTPHealthChecker(entry.TargetURL(), hcCfg)
waker.hc = monitor.NewHTTPHealthChecker(route.TargetURL(), hcCfg)
case stream != nil:
waker.hc = monitor.NewRawHealthChecker(entry.TargetURL(), hcCfg)
waker.hc = monitor.NewRawHealthChecker(route.TargetURL(), hcCfg)
default:
panic("both nil")
}
if common.PrometheusEnabled {
m := metrics.GetServiceMetrics()
fqn := parent.Name() + "/" + entry.TargetName()
fqn := parent.Name() + "/" + route.TargetName()
waker.metric = m.HealthStatus.With(metrics.HealthMetricLabels(fqn))
waker.metric.Set(float64(watcher.Status()))
}
@@ -71,12 +71,12 @@ func newWaker(parent task.Parent, entry route.Entry, rp *reverseproxy.ReversePro
}
// lifetime should follow route provider.
func NewHTTPWaker(parent task.Parent, entry route.Entry, rp *reverseproxy.ReverseProxy) (Waker, E.Error) {
return newWaker(parent, entry, rp, nil)
func NewHTTPWaker(parent task.Parent, route route.Route, rp *reverseproxy.ReverseProxy) (Waker, E.Error) {
return newWaker(parent, route, rp, nil)
}
func NewStreamWaker(parent task.Parent, entry route.Entry, stream net.Stream) (Waker, E.Error) {
return newWaker(parent, entry, nil, stream)
func NewStreamWaker(parent task.Parent, route route.Route, stream net.Stream) (Waker, E.Error) {
return newWaker(parent, route, nil, stream)
}
// Start implements health.HealthMonitor.
@@ -155,7 +155,7 @@ func (w *Watcher) getStatusUpdateReady() health.Status {
// MarshalJSON implements health.HealthMonitor.
func (w *Watcher) MarshalJSON() ([]byte, error) {
var url net.URL
var url *net.URL
if w.hc.URL().Port() != "0" {
url = w.hc.URL()
}

View File

@@ -50,8 +50,8 @@ var (
const dockerReqTimeout = 3 * time.Second
func registerWatcher(watcherTask *task.Task, entry route.Entry, waker *waker) (*Watcher, error) {
cfg := entry.IdlewatcherConfig()
func registerWatcher(watcherTask *task.Task, route route.Route, waker *waker) (*Watcher, error) {
cfg := route.IdlewatcherConfig()
if cfg.IdleTimeout == 0 {
panic(errShouldNotReachHere)

View File

@@ -9,7 +9,7 @@ import (
)
var (
r route.HTTPRoute
r route.ReveseProxyRoute
ep = NewEntrypoint()
)

View File

@@ -75,8 +75,10 @@ func (err *nestedError) Error() string {
lines := make([]string, 0, 1+len(err.Extras))
if err.Err != nil {
lines = append(lines, makeLine(err.Err.Error(), 0))
lines = append(lines, makeLines(err.Extras, 1)...)
} else {
lines = append(lines, makeLines(err.Extras, 0)...)
}
lines = append(lines, makeLines(err.Extras, 1)...)
return strutils.JoinLines(lines)
}

View File

@@ -40,13 +40,6 @@ func From(err error) Error {
return &baseError{err}
}
func Must[T any](v T, err error) T {
if err != nil {
LogPanic("must failed", err)
}
return v
}
func Join(errors ...error) Error {
n := 0
for _, err := range errors {

View File

@@ -17,15 +17,17 @@ type OverrideConfig struct {
mu sync.RWMutex
}
var overrideConfigInstance *OverrideConfig
var overrideConfigInstance = &OverrideConfig{
ItemOverrides: make(map[string]*ItemConfig),
DisplayOrder: make(map[string]int),
CategoryOrder: make(map[string]int),
ItemVisibility: make(map[string]bool),
}
func InitOverridesConfig() {
overrideConfigInstance = &OverrideConfig{
ItemOverrides: make(map[string]*ItemConfig),
DisplayOrder: make(map[string]int),
CategoryOrder: make(map[string]int),
ItemVisibility: make(map[string]bool),
}
overrideConfigInstance.mu.Lock()
defer overrideConfigInstance.mu.Unlock()
err := utils.LoadJSONIfExist(common.HomepageJSONConfigPath, overrideConfigInstance)
if err != nil {
logging.Error().Err(err).Msg("failed to load homepage overrides config")

View File

@@ -52,6 +52,9 @@ const (
)
func InitIconListCache() {
iconsCahceMu.Lock()
defer iconsCahceMu.Unlock()
iconsCache = &Cache{
WalkxCode: make(IconsMap),
Selfhst: make(IconsMap),

View File

@@ -9,7 +9,6 @@ import (
"testing"
"time"
E "github.com/yusing/go-proxy/internal/error"
. "github.com/yusing/go-proxy/internal/net/http/accesslog"
"github.com/yusing/go-proxy/internal/task"
. "github.com/yusing/go-proxy/internal/utils/testing"
@@ -30,7 +29,7 @@ const (
var (
testTask = task.RootTask("test", false)
testURL = E.Must(url.Parse("http://" + host + uri))
testURL = Must(url.Parse("http://" + host + uri))
req = &http.Request{
RemoteAddr: remote,
Method: method,

View File

@@ -4,14 +4,13 @@ import (
"testing"
"github.com/yusing/go-proxy/internal/net/http/loadbalancer/types"
loadbalance "github.com/yusing/go-proxy/internal/net/http/loadbalancer/types"
. "github.com/yusing/go-proxy/internal/utils/testing"
)
func TestRebalance(t *testing.T) {
t.Parallel()
t.Run("zero", func(t *testing.T) {
lb := New(new(loadbalance.Config))
lb := New(new(types.Config))
for range 10 {
lb.AddServer(types.TestNewServer(0))
}
@@ -19,7 +18,7 @@ func TestRebalance(t *testing.T) {
ExpectEqual(t, lb.sumWeight, maxWeight)
})
t.Run("less", func(t *testing.T) {
lb := New(new(loadbalance.Config))
lb := New(new(types.Config))
lb.AddServer(types.TestNewServer(float64(maxWeight) * .1))
lb.AddServer(types.TestNewServer(float64(maxWeight) * .2))
lb.AddServer(types.TestNewServer(float64(maxWeight) * .3))
@@ -30,7 +29,7 @@ func TestRebalance(t *testing.T) {
ExpectEqual(t, lb.sumWeight, maxWeight)
})
t.Run("more", func(t *testing.T) {
lb := New(new(loadbalance.Config))
lb := New(new(types.Config))
lb.AddServer(types.TestNewServer(float64(maxWeight) * .1))
lb.AddServer(types.TestNewServer(float64(maxWeight) * .2))
lb.AddServer(types.TestNewServer(float64(maxWeight) * .3))

View File

@@ -4,7 +4,7 @@ import (
"net/http"
idlewatcher "github.com/yusing/go-proxy/internal/docker/idlewatcher/types"
"github.com/yusing/go-proxy/internal/net/types"
net "github.com/yusing/go-proxy/internal/net/types"
U "github.com/yusing/go-proxy/internal/utils"
F "github.com/yusing/go-proxy/internal/utils/functional"
"github.com/yusing/go-proxy/internal/watcher/health"
@@ -15,7 +15,7 @@ type (
_ U.NoCopy
name string
url types.URL
url *net.URL
weight Weight
http.Handler `json:"-"`
@@ -26,7 +26,7 @@ type (
http.Handler
health.HealthMonitor
Name() string
URL() types.URL
URL() *net.URL
Weight() Weight
SetWeight(weight Weight)
TryWake() error
@@ -37,7 +37,7 @@ type (
var NewServerPool = F.NewMap[Pool]
func NewServer(name string, url types.URL, weight Weight, handler http.Handler, healthMon health.HealthMonitor) Server {
func NewServer(name string, url *net.URL, weight Weight, handler http.Handler, healthMon health.HealthMonitor) Server {
srv := &server{
name: name,
url: url,
@@ -59,7 +59,7 @@ func (srv *server) Name() string {
return srv.name
}
func (srv *server) URL() types.URL {
func (srv *server) URL() *net.URL {
return srv.url
}

View File

@@ -2,19 +2,19 @@ package http
import "net/http"
var validMethods = map[string]struct{}{
http.MethodGet: {},
http.MethodHead: {},
http.MethodPost: {},
http.MethodPut: {},
http.MethodPatch: {},
http.MethodDelete: {},
http.MethodConnect: {},
http.MethodOptions: {},
http.MethodTrace: {},
}
func IsMethodValid(method string) bool {
_, ok := validMethods[method]
return ok
switch method {
case http.MethodGet,
http.MethodHead,
http.MethodPost,
http.MethodPut,
http.MethodPatch,
http.MethodDelete,
http.MethodConnect,
http.MethodOptions,
http.MethodTrace:
return true
default:
return false
}
}

View File

@@ -30,6 +30,14 @@ const (
var (
cfCIDRsLastUpdate time.Time
cfCIDRsMu sync.Mutex
// RFC 1918.
localCIDRs = []*types.CIDR{
{IP: net.IPv4(127, 0, 0, 1), Mask: net.IPv4Mask(255, 255, 255, 255)}, // 127.0.0.1/32
{IP: net.IPv4(10, 0, 0, 0), Mask: net.IPv4Mask(255, 0, 0, 0)}, // 10.0.0.0/8
{IP: net.IPv4(172, 16, 0, 0), Mask: net.IPv4Mask(255, 240, 0, 0)}, // 172.16.0.0/12
{IP: net.IPv4(192, 168, 0, 0), Mask: net.IPv4Mask(255, 255, 0, 0)}, // 192.168.0.0/16
}
)
var CloudflareRealIP = NewMiddleware[cloudflareRealIP]()
@@ -37,7 +45,7 @@ var CloudflareRealIP = NewMiddleware[cloudflareRealIP]()
// setup implements MiddlewareWithSetup.
func (cri *cloudflareRealIP) setup() {
cri.realIP.RealIPOpts = RealIPOpts{
Header: "Cf-Connecting-Ip",
Header: "CF-Connecting-IP",
Recursive: cri.Recursive,
}
}
@@ -72,12 +80,7 @@ func tryFetchCFCIDR() (cfCIDRs []*types.CIDR) {
}
if common.IsTest {
cfCIDRs = []*types.CIDR{
{IP: net.IPv4(127, 0, 0, 1), Mask: net.IPv4Mask(255, 0, 0, 0)},
{IP: net.IPv4(10, 0, 0, 0), Mask: net.IPv4Mask(255, 0, 0, 0)},
{IP: net.IPv4(172, 16, 0, 0), Mask: net.IPv4Mask(255, 255, 0, 0)},
{IP: net.IPv4(192, 168, 0, 0), Mask: net.IPv4Mask(255, 255, 255, 0)},
}
cfCIDRs = localCIDRs
} else {
cfCIDRs = make([]*types.CIDR, 0, 30)
err := errors.Join(
@@ -122,6 +125,6 @@ func fetchUpdateCFIPRange(endpoint string, cfCIDRs *[]*types.CIDR) error {
*cfCIDRs = append(*cfCIDRs, (*types.CIDR)(cidr))
}
*cfCIDRs = append(*cfCIDRs, localCIDRs...)
return nil
}

View File

@@ -0,0 +1,44 @@
package metricslogger
import (
"net"
"net/http"
"github.com/yusing/go-proxy/internal/metrics"
)
type MetricsLogger struct {
ServiceName string `json:"service_name"`
}
func NewMetricsLogger(serviceName string) *MetricsLogger {
return &MetricsLogger{serviceName}
}
func (m *MetricsLogger) GetHandler(next http.Handler) http.HandlerFunc {
return func(rw http.ResponseWriter, req *http.Request) {
m.ServeHTTP(rw, req, next.ServeHTTP)
}
}
func (m *MetricsLogger) ServeHTTP(rw http.ResponseWriter, req *http.Request, next http.HandlerFunc) {
visitorIP, _, err := net.SplitHostPort(req.RemoteAddr)
if err != nil {
visitorIP = req.RemoteAddr
}
// req.RemoteAddr had been modified by middleware (if any)
lbls := &metrics.HTTPRouteMetricLabels{
Service: m.ServiceName,
Method: req.Method,
Host: req.Host,
Visitor: visitorIP,
Path: req.URL.Path,
}
next.ServeHTTP(newHTTPMetricLogger(rw, lbls), req)
}
func (m *MetricsLogger) ResetMetrics() {
metrics.GetRouteMetrics().UnregisterService(m.ServiceName)
}

View File

@@ -0,0 +1,47 @@
package metricslogger
import (
"net/http"
"time"
"github.com/yusing/go-proxy/internal/metrics"
)
type httpMetricLogger struct {
http.ResponseWriter
timestamp time.Time
labels *metrics.HTTPRouteMetricLabels
}
// WriteHeader implements http.ResponseWriter.
func (l *httpMetricLogger) WriteHeader(status int) {
l.ResponseWriter.WriteHeader(status)
duration := time.Since(l.timestamp)
go func() {
m := metrics.GetRouteMetrics()
m.HTTPReqTotal.Inc()
m.HTTPReqElapsed.With(l.labels).Set(float64(duration.Milliseconds()))
// ignore 1xx
switch {
case status >= 500:
m.HTTP5xx.With(l.labels).Inc()
case status >= 400:
m.HTTP4xx.With(l.labels).Inc()
case status >= 200:
m.HTTP2xx3xx.With(l.labels).Inc()
}
}()
}
func (l *httpMetricLogger) Unwrap() http.ResponseWriter {
return l.ResponseWriter
}
func newHTTPMetricLogger(w http.ResponseWriter, labels *metrics.HTTPRouteMetricLabels) *httpMetricLogger {
return &httpMetricLogger{
ResponseWriter: w,
timestamp: time.Now(),
labels: labels,
}
}

View File

@@ -196,34 +196,6 @@ func (m *Middleware) ServeHTTP(next http.HandlerFunc, w http.ResponseWriter, r *
next(w, r)
}
// TODO: check conflict or duplicates.
func compileMiddlewares(middlewaresMap map[string]OptionsRaw) ([]*Middleware, E.Error) {
middlewares := make([]*Middleware, 0, len(middlewaresMap))
errs := E.NewBuilder("middlewares compile error")
invalidOpts := E.NewBuilder("options compile error")
for name, opts := range middlewaresMap {
m, err := Get(name)
if err != nil {
errs.Add(err)
continue
}
m, err = m.New(opts)
if err != nil {
invalidOpts.Add(err.Subject(name))
continue
}
middlewares = append(middlewares, m)
}
if invalidOpts.HasError() {
errs.Add(invalidOpts.Error())
}
return middlewares, errs.Error()
}
func PatchReverseProxy(rp *ReverseProxy, middlewaresMap map[string]OptionsRaw) (err E.Error) {
var middlewares []*Middleware
middlewares, err = compileMiddlewares(middlewaresMap)

View File

@@ -4,6 +4,7 @@ import (
"fmt"
"os"
"path"
"sort"
E "github.com/yusing/go-proxy/internal/error"
"gopkg.in/yaml.v3"
@@ -39,6 +40,43 @@ func BuildMiddlewaresFromYAML(source string, data []byte, eb *E.Builder) map[str
return middlewares
}
func compileMiddlewares(middlewaresMap map[string]OptionsRaw) ([]*Middleware, E.Error) {
middlewares := make([]*Middleware, 0, len(middlewaresMap))
errs := E.NewBuilder("middlewares compile error")
invalidOpts := E.NewBuilder("options compile error")
for name, opts := range middlewaresMap {
m, err := Get(name)
if err != nil {
errs.Add(err)
continue
}
m, err = m.New(opts)
if err != nil {
invalidOpts.Add(err.Subject(name))
continue
}
middlewares = append(middlewares, m)
}
if invalidOpts.HasError() {
errs.Add(invalidOpts.Error())
}
sort.Sort(ByPriority(middlewares))
return middlewares, errs.Error()
}
func BuildMiddlewareFromMap(name string, middlewaresMap map[string]OptionsRaw) (*Middleware, E.Error) {
compiled, err := compileMiddlewares(middlewaresMap)
if err != nil {
return nil, err
}
return NewMiddlewareChain(name, compiled), nil
}
// TODO: check conflict or duplicates.
func BuildMiddlewareFromChainRaw(name string, defs []map[string]any) (*Middleware, E.Error) {
chainErr := E.NewBuilder("")
chain := make([]*Middleware, 0, len(defs))

View File

@@ -16,7 +16,7 @@ func TestBuild(t *testing.T) {
errs := E.NewBuilder("")
middlewares := BuildMiddlewaresFromYAML("", testMiddlewareCompose, errs)
ExpectNoError(t, errs.Error())
E.Must(json.MarshalIndent(middlewares, "", " "))
Must(json.MarshalIndent(middlewares, "", " "))
// t.Log(string(data))
// TODO: test
}

View File

@@ -12,6 +12,7 @@ import (
E "github.com/yusing/go-proxy/internal/error"
"github.com/yusing/go-proxy/internal/net/http/reverseproxy"
"github.com/yusing/go-proxy/internal/net/types"
. "github.com/yusing/go-proxy/internal/utils/testing"
)
//go:embed test_data/sample_headers.json
@@ -79,11 +80,11 @@ type TestResult struct {
type testArgs struct {
middlewareOpt OptionsRaw
upstreamURL types.URL
upstreamURL *types.URL
realRoundTrip bool
reqURL types.URL
reqURL *types.URL
reqMethod string
headers http.Header
body []byte
@@ -94,14 +95,14 @@ type testArgs struct {
}
func (args *testArgs) setDefaults() {
if args.reqURL.Nil() {
args.reqURL = E.Must(types.ParseURL("https://example.com"))
if args.reqURL == nil {
args.reqURL = Must(types.ParseURL("https://example.com"))
}
if args.reqMethod == "" {
args.reqMethod = http.MethodGet
}
if args.upstreamURL.Nil() {
args.upstreamURL = E.Must(types.ParseURL("https://10.0.0.1:8443")) // dummy url, no actual effect
if args.upstreamURL == nil {
args.upstreamURL = Must(types.ParseURL("https://10.0.0.1:8443")) // dummy url, no actual effect
}
if args.respHeaders == nil {
args.respHeaders = http.Header{}

View File

@@ -23,12 +23,9 @@ import (
"net/url"
"strings"
"sync"
"time"
"github.com/rs/zerolog"
"github.com/yusing/go-proxy/internal/common"
"github.com/yusing/go-proxy/internal/logging"
"github.com/yusing/go-proxy/internal/metrics"
gphttp "github.com/yusing/go-proxy/internal/net/http"
"github.com/yusing/go-proxy/internal/net/http/accesslog"
"github.com/yusing/go-proxy/internal/net/types"
@@ -96,38 +93,7 @@ type ReverseProxy struct {
HandlerFunc http.HandlerFunc
TargetName string
TargetURL types.URL
}
type httpMetricLogger struct {
http.ResponseWriter
timestamp time.Time
labels *metrics.HTTPRouteMetricLabels
}
// WriteHeader implements http.ResponseWriter.
func (l *httpMetricLogger) WriteHeader(status int) {
l.ResponseWriter.WriteHeader(status)
duration := time.Since(l.timestamp)
go func() {
m := metrics.GetRouteMetrics()
m.HTTPReqTotal.Inc()
m.HTTPReqElapsed.With(l.labels).Set(float64(duration.Milliseconds()))
// ignore 1xx
switch {
case status >= 500:
m.HTTP5xx.With(l.labels).Inc()
case status >= 400:
m.HTTP4xx.With(l.labels).Inc()
case status >= 200:
m.HTTP2xx3xx.With(l.labels).Inc()
}
}()
}
func (l *httpMetricLogger) Unwrap() http.ResponseWriter {
return l.ResponseWriter
TargetURL *types.URL
}
func singleJoiningSlash(a, b string) string {
@@ -167,7 +133,7 @@ func joinURLPath(a, b *url.URL) (path, rawpath string) {
// URLs to the scheme, host, and base path provided in target. If the
// target's path is "/base" and the incoming request was for "/dir",
// the target request will be for /base/dir.
func NewReverseProxy(name string, target types.URL, transport http.RoundTripper) *ReverseProxy {
func NewReverseProxy(name string, target *types.URL, transport http.RoundTripper) *ReverseProxy {
if transport == nil {
panic("nil transport")
}
@@ -181,15 +147,11 @@ func NewReverseProxy(name string, target types.URL, transport http.RoundTripper)
return rp
}
func (p *ReverseProxy) UnregisterMetrics() {
metrics.GetRouteMetrics().UnregisterService(p.TargetName)
}
func (p *ReverseProxy) rewriteRequestURL(req *http.Request) {
targetQuery := p.TargetURL.RawQuery
req.URL.Scheme = p.TargetURL.Scheme
req.URL.Host = p.TargetURL.Host
req.URL.Path, req.URL.RawPath = joinURLPath(p.TargetURL.URL, req.URL)
req.URL.Path, req.URL.RawPath = joinURLPath(&p.TargetURL.URL, req.URL)
if targetQuery == "" || req.URL.RawQuery == "" {
req.URL.RawQuery = targetQuery + req.URL.RawQuery
} else {
@@ -255,28 +217,6 @@ func (p *ReverseProxy) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
}
func (p *ReverseProxy) handler(rw http.ResponseWriter, req *http.Request) {
visitorIP, _, err := net.SplitHostPort(req.RemoteAddr)
if err != nil {
visitorIP = req.RemoteAddr
}
if common.PrometheusEnabled {
t := time.Now()
// req.RemoteAddr had been modified by middleware (if any)
lbls := &metrics.HTTPRouteMetricLabels{
Service: p.TargetName,
Method: req.Method,
Host: req.Host,
Visitor: visitorIP,
Path: req.URL.Path,
}
rw = &httpMetricLogger{
ResponseWriter: rw,
timestamp: t,
labels: lbls,
}
}
transport := p.Transport
ctx := req.Context()
@@ -360,7 +300,11 @@ func (p *ReverseProxy) handler(rw http.ResponseWriter, req *http.Request) {
// separated list and fold multiple headers into one.
prior, ok := outreq.Header[gphttp.HeaderXForwardedFor]
omit := ok && prior == nil // Issue 38079: nil now means don't populate the header
xff := visitorIP
xff, _, err := net.SplitHostPort(req.RemoteAddr)
if err != nil {
xff = req.RemoteAddr
}
if len(prior) > 0 {
xff = strings.Join(prior, ", ") + ", " + xff
}

View File

@@ -2,13 +2,16 @@ package types
import (
urlPkg "net/url"
"github.com/yusing/go-proxy/internal/utils"
)
type URL struct {
*urlPkg.URL
_ utils.NoCopy
urlPkg.URL
}
func MustParseURL(url string) URL {
func MustParseURL(url string) *URL {
u, err := ParseURL(url)
if err != nil {
panic(err)
@@ -16,40 +19,38 @@ func MustParseURL(url string) URL {
return u
}
func ParseURL(url string) (URL, error) {
u, err := urlPkg.Parse(url)
func ParseURL(url string) (*URL, error) {
u := &URL{}
return u, u.Parse(url)
}
func NewURL(url *urlPkg.URL) *URL {
return &URL{URL: *url}
}
func (u *URL) Parse(url string) error {
uu, err := urlPkg.Parse(url)
if err != nil {
return URL{}, err
return err
}
return URL{URL: u}, nil
u.URL = *uu
return nil
}
func NewURL(url *urlPkg.URL) URL {
return URL{url}
}
func (u URL) Nil() bool {
return u.URL == nil
}
func (u URL) String() string {
if u.URL == nil {
func (u *URL) String() string {
if u == nil {
return "nil"
}
return u.URL.String()
}
func (u URL) MarshalJSON() (text []byte, err error) {
if u.URL == nil {
func (u *URL) MarshalJSON() (text []byte, err error) {
if u == nil {
return []byte("null"), nil
}
return []byte("\"" + u.URL.String() + "\""), nil
}
func (u URL) Equals(other *URL) bool {
return u.URL == other.URL || u.String() == other.String()
}
func (u URL) JoinPath(path string) URL {
return URL{u.URL.JoinPath(path)}
func (u *URL) Equals(other *URL) bool {
return u.String() == other.String()
}

View File

@@ -1,6 +1,8 @@
package notif
import (
"io"
"net/http"
"net/url"
"strings"
@@ -45,3 +47,23 @@ func (base *ProviderBase) GetURL() string {
func (base *ProviderBase) GetToken() string {
return base.Token
}
func (base *ProviderBase) GetMethod() string {
return http.MethodPost
}
func (base *ProviderBase) GetMIMEType() string {
return "application/json"
}
func (base *ProviderBase) SetHeaders(logMsg *LogMessage, headers http.Header) {
// no-op by default
}
func (base *ProviderBase) makeRespError(resp *http.Response) error {
body, err := io.ReadAll(resp.Body)
if err == nil {
return E.Errorf("%s status %d: %s", base.Name, resp.StatusCode, body)
}
return E.Errorf("%s status %d", base.Name, resp.StatusCode)
}

View File

@@ -5,9 +5,9 @@ import "fmt"
type Color uint
const (
Red Color = 0xff0000
Green Color = 0x00ff00
Blue Color = 0x0000ff
ColorError Color = 0xff0000
ColorSuccess Color = 0x00ff00
ColorInfo Color = 0x0000ff
)
func (c Color) HexString() string {

View File

@@ -38,6 +38,8 @@ func (cfg *NotificationConfig) UnmarshalMap(m map[string]any) (err E.Error) {
cfg.Provider = &Webhook{}
case ProviderGotify:
cfg.Provider = &GotifyClient{}
case ProviderNtfy:
cfg.Provider = &Ntfy{}
default:
return ErrUnknownNotifProvider.
Subject(cfg.ProviderName).

View File

@@ -14,10 +14,15 @@ type (
logCh chan *LogMessage
providers F.Set[Provider]
}
LogField struct {
Name string `json:"name"`
Value string `json:"value"`
}
LogFields []LogField
LogMessage struct {
Level zerolog.Level
Title string
Extras map[string]any
Extras LogFields
Color Color
}
)
@@ -48,6 +53,10 @@ func Notify(msg *LogMessage) {
}
}
func (f *LogFields) Add(name, value string) {
*f = append(*f, LogField{Name: name, Value: value})
}
func (disp *Dispatcher) RegisterProvider(cfg *NotificationConfig) {
disp.providers.Add(cfg.Provider)
}
@@ -74,6 +83,9 @@ func (disp *Dispatcher) start() {
}
func (disp *Dispatcher) dispatch(msg *LogMessage) {
if true {
return
}
task := disp.task.Subtask("dispatcher")
defer task.Finish("notif dispatched")

View File

@@ -3,32 +3,22 @@ package notif
import (
"bytes"
"encoding/json"
"fmt"
)
func formatMarkdown(extras map[string]interface{}) string {
func formatMarkdown(extras LogFields) string {
msg := bytes.NewBufferString("")
for k, v := range extras {
for _, field := range extras {
msg.WriteString("#### ")
msg.WriteString(k)
msg.WriteString(field.Name)
msg.WriteRune('\n')
msg.WriteString(fmt.Sprintf("%v", v))
msg.WriteString(field.Value)
msg.WriteRune('\n')
}
return msg.String()
}
func formatDiscord(extras map[string]interface{}) (string, error) {
fieldsMap := make([]map[string]any, len(extras))
i := 0
for k, extra := range extras {
fieldsMap[i] = map[string]any{
"name": k,
"value": extra,
}
i++
}
fields, err := json.Marshal(fieldsMap)
func formatDiscord(extras LogFields) (string, error) {
fields, err := json.Marshal(extras)
if err != nil {
return "", err
}

View File

@@ -24,16 +24,6 @@ func (client *GotifyClient) GetURL() string {
return client.URL + gotifyMsgEndpoint
}
// GetMethod implements Provider.
func (client *GotifyClient) GetMethod() string {
return http.MethodPost
}
// GetMIMEType implements Provider.
func (client *GotifyClient) GetMIMEType() string {
return "application/json"
}
// MakeBody implements Provider.
func (client *GotifyClient) MakeBody(logMsg *LogMessage) (io.Reader, error) {
var priority int
@@ -71,7 +61,7 @@ func (client *GotifyClient) makeRespError(resp *http.Response) error {
var errm model.Error
err := json.NewDecoder(resp.Body).Decode(&errm)
if err != nil {
return fmt.Errorf(ProviderGotify+" status %d, but failed to decode err response: %w", resp.StatusCode, err)
return fmt.Errorf("%s status %d, but failed to decode err response: %w", client.Name, resp.StatusCode, err)
}
return fmt.Errorf(ProviderGotify+" status %d %s: %s", resp.StatusCode, errm.Error, errm.ErrorDescription)
return fmt.Errorf("%s status %d %s: %s", client.Name, resp.StatusCode, errm.Error, errm.ErrorDescription)
}

89
internal/notif/ntfy.go Normal file
View File

@@ -0,0 +1,89 @@
package notif
import (
"bytes"
"io"
"net/http"
"strings"
"github.com/rs/zerolog"
E "github.com/yusing/go-proxy/internal/error"
)
// See https://docs.ntfy.sh/publish
type Ntfy struct {
ProviderBase
Topic string `json:"topic"`
Style NtfyStyle `json:"style"`
}
type NtfyStyle string
const (
NtfyStyleMarkdown NtfyStyle = "markdown"
NtfyStylePlain NtfyStyle = "plain"
)
func (n *Ntfy) Validate() E.Error {
if n.URL == "" {
return E.New("url is required")
}
if n.Topic == "" {
return E.New("topic is required")
}
if n.Topic[0] == '/' {
return E.New("topic should not start with a slash")
}
switch n.Style {
case "":
n.Style = NtfyStyleMarkdown
case NtfyStyleMarkdown, NtfyStylePlain:
default:
return E.Errorf("invalid style, expecting %q or %q, got %q", NtfyStyleMarkdown, NtfyStylePlain, n.Style)
}
return nil
}
func (n *Ntfy) GetURL() string {
if n.URL[len(n.URL)-1] == '/' {
return n.URL + n.Topic
}
return n.URL + "/" + n.Topic
}
func (n *Ntfy) GetMIMEType() string {
return ""
}
func (n *Ntfy) GetToken() string {
return n.Token
}
func (n *Ntfy) MakeBody(logMsg *LogMessage) (io.Reader, error) {
switch n.Style {
case NtfyStyleMarkdown:
return strings.NewReader(formatMarkdown(logMsg.Extras)), nil
default:
return &bytes.Buffer{}, nil
}
}
func (n *Ntfy) SetHeaders(logMsg *LogMessage, headers http.Header) {
headers.Set("Title", logMsg.Title)
switch logMsg.Level {
// warning (or other unspecified) uses default priority
case zerolog.FatalLevel:
headers.Set("Priority", "urgent")
case zerolog.ErrorLevel:
headers.Set("Priority", "high")
case zerolog.InfoLevel:
headers.Set("Priority", "low")
case zerolog.DebugLevel:
headers.Set("Priority", "min")
}
if n.Style == NtfyStyleMarkdown {
headers.Set("Markdown", "yes")
}
}

View File

@@ -4,6 +4,7 @@ import (
"context"
"io"
"net/http"
"time"
E "github.com/yusing/go-proxy/internal/error"
gphttp "github.com/yusing/go-proxy/internal/net/http"
@@ -21,6 +22,7 @@ type (
GetMIMEType() string
MakeBody(logMsg *LogMessage) (io.Reader, error)
SetHeaders(logMsg *LogMessage, headers http.Header)
makeRespError(resp *http.Response) error
}
@@ -30,6 +32,7 @@ type (
const (
ProviderGotify = "gotify"
ProviderNtfy = "ntfy"
ProviderWebhook = "webhook"
)
@@ -38,6 +41,10 @@ func notifyProvider(ctx context.Context, provider Provider, msg *LogMessage) err
if err != nil {
return E.PrependSubject(provider.GetName(), err)
}
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(
ctx,
http.MethodPost,
@@ -52,6 +59,7 @@ func notifyProvider(ctx context.Context, provider Provider, msg *LogMessage) err
if provider.GetToken() != "" {
req.Header.Set("Authorization", "Bearer "+provider.GetToken())
}
provider.SetHeaders(msg, req.Header)
resp, err := http.DefaultClient.Do(req)
if err != nil {

View File

@@ -92,12 +92,12 @@ func (webhook *Webhook) GetMIMEType() string {
func (webhook *Webhook) makeRespError(resp *http.Response) error {
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("webhook status %d, failed to read body: %w", resp.StatusCode, err)
return fmt.Errorf("%s status %d, failed to read body: %w", webhook.Name, resp.StatusCode, err)
}
if len(body) > 0 {
return fmt.Errorf("webhook status %d: %s", resp.StatusCode, body)
return fmt.Errorf("%s status %d: %s", webhook.Name, resp.StatusCode, body)
}
return fmt.Errorf("webhook status %d", resp.StatusCode)
return fmt.Errorf("%s status %d", webhook.Name, resp.StatusCode)
}
func (webhook *Webhook) MakeBody(logMsg *LogMessage) (io.Reader, error) {

View File

@@ -1,62 +0,0 @@
package entry
import (
E "github.com/yusing/go-proxy/internal/error"
route "github.com/yusing/go-proxy/internal/route/types"
)
type Entry = route.Entry
func ValidateEntry(m *route.RawEntry) (Entry, E.Error) {
scheme, err := route.NewScheme(m.Scheme)
if err != nil {
return nil, E.From(err)
}
var entry Entry
errs := E.NewBuilder("entry validation failed")
if scheme.IsStream() {
entry = validateStreamEntry(m, errs)
} else {
entry = validateRPEntry(m, scheme, errs)
}
if errs.HasError() {
return nil, errs.Error()
}
if !UseHealthCheck(entry) && (UseLoadBalance(entry) || UseIdleWatcher(entry)) {
return nil, E.New("healthCheck.disable cannot be true when loadbalancer or idlewatcher is enabled")
}
return entry, nil
}
func IsDocker(entry Entry) bool {
iw := entry.IdlewatcherConfig()
return iw != nil && iw.ContainerID != ""
}
func IsZeroPort(entry Entry) bool {
return entry.TargetURL().Port() == "0"
}
func ShouldNotServe(entry Entry) bool {
return IsZeroPort(entry) && !UseIdleWatcher(entry)
}
func UseLoadBalance(entry Entry) bool {
lb := entry.RawEntry().LoadBalance
return lb != nil && lb.Link != ""
}
func UseIdleWatcher(entry Entry) bool {
iw := entry.IdlewatcherConfig()
return iw != nil && iw.IdleTimeout > 0
}
func UseHealthCheck(entry Entry) bool {
hc := entry.RawEntry().HealthCheck
return hc != nil && !hc.Disable
}
func UseAccessLog(entry Entry) bool {
return entry.RawEntry().AccessLog != nil
}

View File

@@ -1,61 +0,0 @@
package entry
import (
"fmt"
"net/url"
"github.com/yusing/go-proxy/internal/docker"
idlewatcher "github.com/yusing/go-proxy/internal/docker/idlewatcher/types"
E "github.com/yusing/go-proxy/internal/error"
net "github.com/yusing/go-proxy/internal/net/types"
route "github.com/yusing/go-proxy/internal/route/types"
)
type ReverseProxyEntry struct { // real model after validation
Raw *route.RawEntry `json:"raw"`
URL net.URL `json:"url"`
/* Docker only */
Idlewatcher *idlewatcher.Config `json:"idlewatcher,omitempty"`
}
func (rp *ReverseProxyEntry) TargetName() string {
return rp.Raw.Alias
}
func (rp *ReverseProxyEntry) TargetURL() net.URL {
return rp.URL
}
func (rp *ReverseProxyEntry) RawEntry() *route.RawEntry {
return rp.Raw
}
func (rp *ReverseProxyEntry) IdlewatcherConfig() *idlewatcher.Config {
return rp.Idlewatcher
}
func validateRPEntry(m *route.RawEntry, s route.Scheme, errs *E.Builder) *ReverseProxyEntry {
cont := m.Container
if cont == nil {
cont = docker.DummyContainer
}
if m.LoadBalance != nil && m.LoadBalance.Link == "" {
m.LoadBalance = nil
}
port := E.Collect(errs, route.ValidatePort, m.Port)
url := E.Collect(errs, url.Parse, fmt.Sprintf("%s://%s:%d", s, m.Host, port))
iwCfg := E.Collect(errs, idlewatcher.ValidateConfig, cont)
if errs.HasError() {
return nil
}
return &ReverseProxyEntry{
Raw: m,
URL: net.NewURL(url),
Idlewatcher: iwCfg,
}
}

View File

@@ -1,65 +0,0 @@
package entry
import (
"fmt"
"github.com/yusing/go-proxy/internal/docker"
idlewatcher "github.com/yusing/go-proxy/internal/docker/idlewatcher/types"
E "github.com/yusing/go-proxy/internal/error"
net "github.com/yusing/go-proxy/internal/net/types"
route "github.com/yusing/go-proxy/internal/route/types"
)
type StreamEntry struct {
Raw *route.RawEntry `json:"raw"`
Scheme route.StreamScheme `json:"scheme"`
URL net.URL `json:"url"`
ListenURL net.URL `json:"listening_url"`
Port route.StreamPort `json:"port,omitempty"`
/* Docker only */
Idlewatcher *idlewatcher.Config `json:"idlewatcher,omitempty"`
}
func (s *StreamEntry) TargetName() string {
return s.Raw.Alias
}
func (s *StreamEntry) TargetURL() net.URL {
return s.URL
}
func (s *StreamEntry) RawEntry() *route.RawEntry {
return s.Raw
}
func (s *StreamEntry) IdlewatcherConfig() *idlewatcher.Config {
return s.Idlewatcher
}
func validateStreamEntry(m *route.RawEntry, errs *E.Builder) *StreamEntry {
cont := m.Container
if cont == nil {
cont = docker.DummyContainer
}
port := E.Collect(errs, route.ValidateStreamPort, m.Port)
scheme := E.Collect(errs, route.ValidateStreamScheme, m.Scheme)
url := E.Collect(errs, net.ParseURL, fmt.Sprintf("%s://%s:%d", scheme.ProxyScheme, m.Host, port.ProxyPort))
listenURL := E.Collect(errs, net.ParseURL, fmt.Sprintf("%s://:%d", scheme.ListeningScheme, port.ListeningPort))
idleWatcherCfg := E.Collect(errs, idlewatcher.ValidateConfig, cont)
if errs.HasError() {
return nil
}
return &StreamEntry{
Raw: m,
Scheme: *scheme,
URL: url,
ListenURL: listenURL,
Port: port,
Idlewatcher: idleWatcherCfg,
}
}

View File

@@ -0,0 +1,134 @@
package route
import (
"net/http"
"path"
"path/filepath"
"github.com/yusing/go-proxy/internal/common"
gphttp "github.com/yusing/go-proxy/internal/net/http"
"github.com/yusing/go-proxy/internal/net/http/accesslog"
"github.com/yusing/go-proxy/internal/net/http/middleware"
metricslogger "github.com/yusing/go-proxy/internal/net/http/middleware/metrics_logger"
"github.com/yusing/go-proxy/internal/route/routes"
"github.com/yusing/go-proxy/internal/task"
"github.com/yusing/go-proxy/internal/watcher/health"
"github.com/yusing/go-proxy/internal/watcher/health/monitor"
E "github.com/yusing/go-proxy/internal/error"
)
type (
FileServer struct {
*Route
Health *monitor.FileServerHealthMonitor `json:"health"`
task *task.Task
middleware *middleware.Middleware
handler http.Handler
accessLogger *accesslog.AccessLogger
}
)
func handler(root string) http.Handler {
return http.FileServer(http.Dir(root))
}
func NewFileServer(base *Route) (*FileServer, E.Error) {
s := &FileServer{Route: base}
s.Root = filepath.Clean(s.Root)
if !path.IsAbs(s.Root) {
return nil, E.New("`root` must be an absolute path")
}
s.handler = handler(s.Root)
if len(s.Middlewares) > 0 {
mid, err := middleware.BuildMiddlewareFromMap(s.Alias, s.Middlewares)
if err != nil {
return nil, err
}
s.middleware = mid
}
return s, nil
}
// Start implements task.TaskStarter.
func (s *FileServer) Start(parent task.Parent) E.Error {
s.task = parent.Subtask("fileserver."+s.TargetName(), false)
pathPatterns := s.PathPatterns
switch {
case len(pathPatterns) == 0:
case len(pathPatterns) == 1 && pathPatterns[0] == "/":
default:
mux := gphttp.NewServeMux()
patErrs := E.NewBuilder("invalid path pattern(s)")
for _, p := range pathPatterns {
patErrs.Add(mux.Handle(p, s.handler))
}
if err := patErrs.Error(); err != nil {
s.task.Finish(err)
return err
}
s.handler = mux
}
if s.middleware != nil {
s.handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
s.middleware.ServeHTTP(s.handler.ServeHTTP, w, r)
})
}
if s.UseAccessLog() {
var err error
s.accessLogger, err = accesslog.NewFileAccessLogger(s.task, s.AccessLog)
if err != nil {
s.task.Finish(err)
return E.Wrap(err)
}
}
if common.PrometheusEnabled {
metricsLogger := metricslogger.NewMetricsLogger(s.TargetName())
s.handler = metricsLogger.GetHandler(s.handler)
s.task.OnCancel("reset_metrics", metricsLogger.ResetMetrics)
}
if s.UseHealthCheck() {
s.Health = monitor.NewFileServerHealthMonitor(s.TargetName(), s.HealthCheck, s.Root)
if err := s.Health.Start(s.task); err != nil {
return err
}
}
routes.SetHTTPRoute(s.TargetName(), s)
s.task.OnCancel("entrypoint_remove_route", func() {
routes.DeleteHTTPRoute(s.TargetName())
})
return nil
}
func (s *FileServer) Task() *task.Task {
return s.task
}
// Finish implements task.TaskFinisher.
func (s *FileServer) Finish(reason any) {
s.task.Finish(reason)
}
// ServeHTTP implements http.Handler.
func (s *FileServer) ServeHTTP(w http.ResponseWriter, req *http.Request) {
s.handler.ServeHTTP(w, req)
if s.accessLogger != nil {
s.accessLogger.Log(req, req.Response)
}
}
func (s *FileServer) HealthMonitor() health.HealthMonitor {
return s.Health
}

View File

@@ -0,0 +1,122 @@
//nolint:gofumpt
package route
import (
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"testing"
. "github.com/yusing/go-proxy/internal/utils/testing"
)
func TestPathTraversalAttack(t *testing.T) {
tmp := t.TempDir()
root := filepath.Join(tmp, "static")
if err := os.Mkdir(root, 0755); err != nil {
t.Fatalf("Failed to create root directory: %v", err)
}
// Create a file inside the root
validPath := "test.txt"
validContent := "test content"
if err := os.WriteFile(filepath.Join(root, validPath), []byte(validContent), 0644); err != nil {
t.Fatalf("Failed to create test file: %v", err)
}
// create one at ..
secretFile := "secret.txt"
if err := os.WriteFile(filepath.Join(tmp, secretFile), []byte(validContent), 0644); err != nil {
t.Fatalf("Failed to create test file: %v", err)
}
traversals := []string{
"../",
"./../",
"./.././",
"..%2f",
".%2f..%2f",
".%2f%2e%2e",
".%2e",
".%2e/",
".%2e%2f",
"%2e.",
"%2e%2e",
}
for _, traversal := range traversals {
traversals = append(traversals, "%2f"+traversal)
traversals = append(traversals, traversal+"%2f")
traversals = append(traversals, "%2f"+traversal+"%2f")
traversals = append(traversals, "/"+traversal)
traversals = append(traversals, traversal+"/")
traversals = append(traversals, "/"+traversal+"/")
}
// Setup the FileServer
fs, err := NewFileServer(&Route{Root: root})
if err != nil {
t.Fatalf("Failed to create FileServer: %v", err)
}
// Create a test server with the handler
ts := httptest.NewServer(fs.handler)
defer ts.Close()
// Test valid path
t.Run("valid path", func(t *testing.T) {
validURL := ts.URL + "/" + validPath
resp, err := http.Get(validURL)
if err != nil {
t.Errorf("Error making request to %s: %v", validURL, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("Expected 200 OK, got %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Errorf("Error reading response body: %v", err)
}
if string(body) != validContent {
t.Errorf("Expected %q, got %q", validContent, string(body))
}
})
// Test ../ path
// tsURL := Must(url.Parse(ts.URL))
for _, traversal := range traversals {
p := traversal + secretFile
t.Run(p, func(t *testing.T) {
u := &url.URL{Scheme: "http", Host: ts.Listener.Addr().String(), Path: p}
resp, err := http.DefaultClient.Do(&http.Request{
Method: http.MethodGet,
URL: u,
})
if err != nil {
t.Errorf("Error making request to %s: %v", p, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNotFound && resp.StatusCode != http.StatusBadRequest {
t.Errorf("Expected status 404 or 400, got %d in url %s", resp.StatusCode, u.String())
}
u = Must(url.Parse(ts.URL + "/" + p))
resp, err = http.DefaultClient.Do(&http.Request{
Method: http.MethodGet,
URL: u,
})
if err != nil {
t.Errorf("Error making request to %s: %v", u.String(), err)
}
defer resp.Body.Close()
})
}
}

View File

@@ -3,7 +3,6 @@ package provider
import (
"fmt"
"strconv"
"strings"
"github.com/docker/docker/client"
"github.com/rs/zerolog"
@@ -62,15 +61,13 @@ func (p *DockerProvider) NewWatcher() watcher.Watcher {
}
func (p *DockerProvider) loadRoutesImpl() (route.Routes, E.Error) {
routes := route.NewRoutes()
entries := route.NewProxyEntries()
containers, err := docker.ListContainers(p.dockerHost)
if err != nil {
return routes, E.From(err)
return nil, E.From(err)
}
errs := E.NewBuilder("")
routes := make(route.Routes)
for _, c := range containers {
container := docker.FromDocker(&c, p.dockerHost)
@@ -78,47 +75,38 @@ func (p *DockerProvider) loadRoutesImpl() (route.Routes, E.Error) {
continue
}
newEntries, err := p.entriesFromContainerLabels(container)
newEntries, err := p.routesFromContainerLabels(container)
if err != nil {
errs.Add(err.Subject(container.ContainerName))
}
// 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 *route.RawEntry) {
errs.Addf("duplicated alias %s", k)
})
for k, v := range newEntries {
if routes.Contains(k) {
errs.Addf("duplicated alias %s", k)
} else {
routes[k] = v
}
}
}
routes, err = route.FromEntries(p.ShortName(), entries)
errs.Add(err)
return routes, errs.Error()
}
func (p *DockerProvider) shouldIgnore(container *docker.Container) bool {
return container.IsExcluded ||
!container.IsExplicit && p.IsExplicitOnly() ||
!container.IsExplicit && container.IsDatabase ||
strings.HasSuffix(container.ContainerName, "-old")
}
// Returns a list of proxy entries for a container.
// Always non-nil.
func (p *DockerProvider) entriesFromContainerLabels(container *docker.Container) (entries route.RawEntries, _ E.Error) {
entries = route.NewProxyEntries()
if p.shouldIgnore(container) {
return
func (p *DockerProvider) routesFromContainerLabels(container *docker.Container) (route.Routes, E.Error) {
if !container.IsExplicit && p.IsExplicitOnly() {
return nil, nil
}
routes := make(route.Routes, len(container.Aliases))
// init entries map for all aliases
for _, a := range container.Aliases {
entries.Store(a, &route.RawEntry{
Alias: a,
Container: container,
})
routes[a] = &route.Route{
Metadata: route.Metadata{
Container: container,
},
}
}
errs := E.NewBuilder("label errors")
@@ -170,32 +158,32 @@ func (p *DockerProvider) entriesFromContainerLabels(container *docker.Container)
}
// init entry if not exist
en, ok := entries.Load(alias)
r, ok := routes[alias]
if !ok {
en = &route.RawEntry{
Alias: alias,
Container: container,
r = &route.Route{
Metadata: route.Metadata{
Container: container,
},
}
entries.Store(alias, en)
routes[alias] = r
}
// deserialize map into entry object
err := U.Deserialize(entryMap, en)
err := U.Deserialize(entryMap, r)
if err != nil {
errs.Add(err.Subject(alias))
} else {
entries.Store(alias, en)
routes[alias] = r
}
}
if wildcardProps != nil {
entries.Range(func(alias string, re *route.RawEntry) bool {
for _, re := range routes {
if err := U.Deserialize(wildcardProps, re); err != nil {
errs.Add(err.Subject(docker.WildcardAlias))
return false
break
}
return true
})
}
}
return entries, errs.Error()
return routes, errs.Error()
}

View File

@@ -20,7 +20,7 @@ func TestParseDockerLabels(t *testing.T) {
labels := make(map[string]string)
ExpectNoError(t, yaml.Unmarshal(testDockerLabelsYAML, &labels))
routes, err := provider.entriesFromContainerLabels(
routes, err := provider.routesFromContainerLabels(
docker.FromDocker(&types.Container{
Names: []string{"container"},
Labels: labels,
@@ -31,6 +31,6 @@ func TestParseDockerLabels(t *testing.T) {
}, "/var/run/docker.sock"),
)
ExpectNoError(t, err)
ExpectTrue(t, routes.Has("app"))
ExpectTrue(t, routes.Has("app1"))
ExpectTrue(t, routes.Contains("app"))
ExpectTrue(t, routes.Contains("app1"))
}

View File

@@ -9,9 +9,7 @@ import (
"github.com/docker/docker/client"
"github.com/yusing/go-proxy/internal/common"
D "github.com/yusing/go-proxy/internal/docker"
E "github.com/yusing/go-proxy/internal/error"
"github.com/yusing/go-proxy/internal/route"
"github.com/yusing/go-proxy/internal/route/entry"
T "github.com/yusing/go-proxy/internal/route/types"
. "github.com/yusing/go-proxy/internal/utils/testing"
)
@@ -23,7 +21,7 @@ const (
testDockerIP = "172.17.0.123"
)
func makeEntries(cont *types.Container, dockerHostIP ...string) route.RawEntries {
func makeRoutes(cont *types.Container, dockerHostIP ...string) route.Routes {
var p DockerProvider
var host string
if len(dockerHostIP) > 0 {
@@ -31,12 +29,13 @@ func makeEntries(cont *types.Container, dockerHostIP ...string) route.RawEntries
} else {
host = client.DefaultDockerHost
}
cont.ID = "test"
p.name = "test"
entries := E.Must(p.entriesFromContainerLabels(D.FromDocker(cont, host)))
entries.RangeAll(func(k string, v *route.RawEntry) {
v.Finalize()
})
return entries
routes := Must(p.routesFromContainerLabels(D.FromDocker(cont, host)))
for _, r := range routes {
r.Finalize()
}
return routes
}
func TestExplicitOnly(t *testing.T) {
@@ -57,43 +56,41 @@ func TestApplyLabel(t *testing.T) {
"GET /static",
}
middlewaresExpect := map[string]map[string]any{
"middleware1": {
"prop1": "value1",
"prop2": "value2",
},
"middleware2": {
"prop3": "value3",
"prop4": "value4",
"request": {
"set_headers": map[string]any{
"X-Header": "value1",
},
"add_headers": map[string]any{
"X-Header2": "value2",
},
},
}
entries := makeEntries(&types.Container{
entries := makeRoutes(&types.Container{
Names: dummyNames,
Labels: map[string]string{
D.LabelAliases: "a,b",
D.LabelIdleTimeout: "",
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.path_patterns": pathPatterns,
"proxy.a.middlewares.middleware1.prop1": "value1",
"proxy.a.middlewares.middleware1.prop2": "value2",
"proxy.a.middlewares.middleware2.prop3": "value3",
"proxy.a.middlewares.middleware2.prop4": "value4",
"proxy.a.homepage.show": "true",
"proxy.a.homepage.icon": "png/adguard-home.png",
"proxy.a.healthcheck.path": "/ping",
"proxy.a.healthcheck.interval": "10s",
D.LabelAliases: "a,b",
D.LabelIdleTimeout: "",
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.path_patterns": pathPatterns,
"proxy.a.middlewares.request.set_headers.X-Header": "value1",
"proxy.a.middlewares.request.add_headers.X-Header2": "value2",
"proxy.a.homepage.show": "true",
"proxy.a.homepage.icon": "png/adguard-home.png",
"proxy.a.healthcheck.path": "/ping",
"proxy.a.healthcheck.interval": "10s",
},
})
a, ok := entries.Load("a")
a, ok := entries["a"]
ExpectTrue(t, ok)
b, ok := entries.Load("b")
b, ok := entries["b"]
ExpectTrue(t, ok)
ExpectEqual(t, a.Scheme, "https")
@@ -102,8 +99,8 @@ func TestApplyLabel(t *testing.T) {
ExpectEqual(t, a.Host, "app")
ExpectEqual(t, b.Host, "app")
ExpectEqual(t, a.Port, "4567")
ExpectEqual(t, b.Port, "4567")
ExpectEqual(t, a.Port.Proxy, 4567)
ExpectEqual(t, b.Port.Proxy, 4567)
ExpectTrue(t, a.NoTLSVerify)
ExpectTrue(t, b.NoTLSVerify)
@@ -139,7 +136,7 @@ func TestApplyLabel(t *testing.T) {
}
func TestApplyLabelWithAlias(t *testing.T) {
entries := makeEntries(&types.Container{
entries := makeRoutes(&types.Container{
Names: dummyNames,
State: "running",
Labels: map[string]string{
@@ -150,23 +147,23 @@ func TestApplyLabelWithAlias(t *testing.T) {
"proxy.c.scheme": "https",
},
})
a, ok := entries.Load("a")
a, ok := entries["a"]
ExpectTrue(t, ok)
b, ok := entries.Load("b")
b, ok := entries["b"]
ExpectTrue(t, ok)
c, ok := entries.Load("c")
c, ok := entries["c"]
ExpectTrue(t, ok)
ExpectEqual(t, a.Scheme, "http")
ExpectEqual(t, a.Port, "3333")
ExpectEqual(t, a.Port.Proxy, 3333)
ExpectEqual(t, a.NoTLSVerify, true)
ExpectEqual(t, b.Scheme, "http")
ExpectEqual(t, b.Port, "1234")
ExpectEqual(t, b.Port.Proxy, 1234)
ExpectEqual(t, c.Scheme, "https")
}
func TestApplyLabelWithRef(t *testing.T) {
entries := makeEntries(&types.Container{
entries := makeRoutes(&types.Container{
Names: dummyNames,
State: "running",
Labels: map[string]string{
@@ -178,19 +175,19 @@ func TestApplyLabelWithRef(t *testing.T) {
"proxy.#3.scheme": "https",
},
})
a, ok := entries.Load("a")
a, ok := entries["a"]
ExpectTrue(t, ok)
b, ok := entries.Load("b")
b, ok := entries["b"]
ExpectTrue(t, ok)
c, ok := entries.Load("c")
c, ok := entries["c"]
ExpectTrue(t, ok)
ExpectEqual(t, a.Scheme, "http")
ExpectEqual(t, a.Host, "localhost")
ExpectEqual(t, a.Port, "4444")
ExpectEqual(t, b.Port, "9999")
ExpectEqual(t, a.Port.Proxy, 4444)
ExpectEqual(t, b.Port.Proxy, 9999)
ExpectEqual(t, c.Scheme, "https")
ExpectEqual(t, c.Port, "1111")
ExpectEqual(t, c.Port.Proxy, 1111)
}
func TestApplyLabelWithRefIndexError(t *testing.T) {
@@ -200,11 +197,12 @@ func TestApplyLabelWithRefIndexError(t *testing.T) {
Labels: map[string]string{
D.LabelAliases: "a,b",
"proxy.#1.host": "localhost",
"proxy.*.port": "4444",
"proxy.#4.scheme": "https",
},
}, "")
var p DockerProvider
_, err := p.entriesFromContainerLabels(c)
_, err := p.routesFromContainerLabels(c)
ExpectError(t, ErrAliasRefIndexOutOfRange, err)
c = D.FromDocker(&types.Container{
@@ -215,7 +213,7 @@ func TestApplyLabelWithRefIndexError(t *testing.T) {
"proxy.#0.host": "localhost",
},
}, "")
_, err = p.entriesFromContainerLabels(c)
_, err = p.routesFromContainerLabels(c)
ExpectError(t, ErrAliasRefIndexOutOfRange, err)
}
@@ -229,17 +227,17 @@ func TestDynamicAliases(t *testing.T) {
},
}
entries := makeEntries(c)
entries := makeRoutes(c)
raw, ok := entries.Load("app1")
r, ok := entries["app1"]
ExpectTrue(t, ok)
ExpectEqual(t, raw.Scheme, "http")
ExpectEqual(t, raw.Port, "1234")
ExpectEqual(t, r.Scheme, "http")
ExpectEqual(t, r.Port.Proxy, 1234)
raw, ok = entries.Load("app1_backend")
r, ok = entries["app1_backend"]
ExpectTrue(t, ok)
ExpectEqual(t, raw.Scheme, "http")
ExpectEqual(t, raw.Port, "5678")
ExpectEqual(t, r.Scheme, "http")
ExpectEqual(t, r.Port.Proxy, 5678)
}
func TestDisableHealthCheck(t *testing.T) {
@@ -251,22 +249,22 @@ func TestDisableHealthCheck(t *testing.T) {
"proxy.a.port": "1234",
},
}
raw, ok := makeEntries(c).Load("a")
r, ok := makeRoutes(c)["a"]
ExpectTrue(t, ok)
ExpectEqual(t, raw.HealthCheck, nil)
ExpectFalse(t, r.UseHealthCheck())
}
func TestPublicIPLocalhost(t *testing.T) {
c := &types.Container{Names: dummyNames, State: "running"}
raw, ok := makeEntries(c).Load("a")
r, ok := makeRoutes(c)["a"]
ExpectTrue(t, ok)
ExpectEqual(t, raw.Container.PublicIP, "127.0.0.1")
ExpectEqual(t, raw.Host, raw.Container.PublicIP)
ExpectEqual(t, r.Container.PublicIP, "127.0.0.1")
ExpectEqual(t, r.Host, r.Container.PublicIP)
}
func TestPublicIPRemote(t *testing.T) {
c := &types.Container{Names: dummyNames, State: "running"}
raw, ok := makeEntries(c, testIP).Load("a")
raw, ok := makeRoutes(c, testIP)["a"]
ExpectTrue(t, ok)
ExpectEqual(t, raw.Container.PublicIP, testIP)
ExpectEqual(t, raw.Host, raw.Container.PublicIP)
@@ -283,10 +281,10 @@ func TestPrivateIPLocalhost(t *testing.T) {
},
},
}
raw, ok := makeEntries(c).Load("a")
r, ok := makeRoutes(c)["a"]
ExpectTrue(t, ok)
ExpectEqual(t, raw.Container.PrivateIP, testDockerIP)
ExpectEqual(t, raw.Host, raw.Container.PrivateIP)
ExpectEqual(t, r.Container.PrivateIP, testDockerIP)
ExpectEqual(t, r.Host, r.Container.PrivateIP)
}
func TestPrivateIPRemote(t *testing.T) {
@@ -301,11 +299,11 @@ func TestPrivateIPRemote(t *testing.T) {
},
},
}
raw, ok := makeEntries(c, testIP).Load("a")
r, ok := makeRoutes(c, testIP)["a"]
ExpectTrue(t, ok)
ExpectEqual(t, raw.Container.PrivateIP, "")
ExpectEqual(t, raw.Container.PublicIP, testIP)
ExpectEqual(t, raw.Host, raw.Container.PublicIP)
ExpectEqual(t, r.Container.PrivateIP, "")
ExpectEqual(t, r.Container.PublicIP, testIP)
ExpectEqual(t, r.Host, r.Container.PublicIP)
}
func TestStreamDefaultValues(t *testing.T) {
@@ -328,59 +326,58 @@ func TestStreamDefaultValues(t *testing.T) {
}
t.Run("local", func(t *testing.T) {
raw, ok := makeEntries(cont).Load("a")
r, ok := makeRoutes(cont)["a"]
ExpectTrue(t, ok)
en := E.Must(entry.ValidateEntry(raw))
a := ExpectType[*entry.StreamEntry](t, en)
ExpectEqual(t, a.Scheme.ListeningScheme, T.Scheme("udp"))
ExpectEqual(t, a.Scheme.ProxyScheme, T.Scheme("udp"))
ExpectEqual(t, a.URL.Hostname(), privIP)
ExpectEqual(t, a.Port.ListeningPort, 0)
ExpectEqual(t, a.Port.ProxyPort, T.Port(privPort))
ExpectNoError(t, r.Validate())
ExpectEqual(t, r.Scheme, T.Scheme("udp"))
ExpectEqual(t, r.TargetURL().Hostname(), privIP)
ExpectEqual(t, r.Port.Listening, 0)
ExpectEqual(t, r.Port.Proxy, int(privPort))
})
t.Run("remote", func(t *testing.T) {
raw, ok := makeEntries(cont, testIP).Load("a")
r, ok := makeRoutes(cont, testIP)["a"]
ExpectTrue(t, ok)
en := E.Must(entry.ValidateEntry(raw))
a := ExpectType[*entry.StreamEntry](t, en)
ExpectEqual(t, a.Scheme.ListeningScheme, T.Scheme("udp"))
ExpectEqual(t, a.Scheme.ProxyScheme, T.Scheme("udp"))
ExpectEqual(t, a.URL.Hostname(), testIP)
ExpectEqual(t, a.Port.ListeningPort, 0)
ExpectEqual(t, a.Port.ProxyPort, T.Port(pubPort))
ExpectNoError(t, r.Validate())
ExpectEqual(t, r.Scheme, T.Scheme("udp"))
ExpectEqual(t, r.TargetURL().Hostname(), testIP)
ExpectEqual(t, r.Port.Listening, 0)
ExpectEqual(t, r.Port.Proxy, int(pubPort))
})
}
func TestExplicitExclude(t *testing.T) {
_, ok := makeEntries(&types.Container{
r, ok := makeRoutes(&types.Container{
Names: dummyNames,
Labels: map[string]string{
D.LabelAliases: "a",
D.LabelExclude: "true",
"proxy.a.no_tls_verify": "true",
},
}, "").Load("a")
ExpectFalse(t, ok)
}, "")["a"]
ExpectTrue(t, ok)
ExpectTrue(t, r.ShouldExclude())
}
func TestImplicitExcludeDatabase(t *testing.T) {
t.Run("mount path detection", func(t *testing.T) {
_, ok := makeEntries(&types.Container{
r, ok := makeRoutes(&types.Container{
Names: dummyNames,
Mounts: []types.MountPoint{
{Source: "/data", Destination: "/var/lib/postgresql/data"},
},
}).Load("a")
ExpectFalse(t, ok)
})["a"]
ExpectTrue(t, ok)
ExpectTrue(t, r.ShouldExclude())
})
t.Run("exposed port detection", func(t *testing.T) {
_, ok := makeEntries(&types.Container{
r, ok := makeRoutes(&types.Container{
Names: dummyNames,
Ports: []types.Port{
{Type: "tcp", PrivatePort: 5432, PublicPort: 5432},
},
}).Load("a")
ExpectFalse(t, ok)
})["a"]
ExpectTrue(t, ok)
ExpectTrue(t, r.ShouldExclude())
})
}

View File

@@ -4,7 +4,6 @@ import (
"github.com/yusing/go-proxy/internal/common"
E "github.com/yusing/go-proxy/internal/error"
"github.com/yusing/go-proxy/internal/route"
"github.com/yusing/go-proxy/internal/route/entry"
"github.com/yusing/go-proxy/internal/route/provider/types"
"github.com/yusing/go-proxy/internal/task"
"github.com/yusing/go-proxy/internal/watcher"
@@ -31,10 +30,10 @@ func (p *Provider) newEventHandler() *EventHandler {
func (handler *EventHandler) Handle(parent task.Parent, events []watcher.Event) {
oldRoutes := handler.provider.routes
newRoutes, err := handler.provider.loadRoutesImpl()
newRoutes, err := handler.provider.loadRoutes()
if err != nil {
handler.errs.Add(err)
if newRoutes.Size() == 0 {
if len(newRoutes) == 0 {
return
}
}
@@ -47,34 +46,32 @@ func (handler *EventHandler) Handle(parent task.Parent, events []watcher.Event)
E.LogDebug(eventsLog.About(), eventsLog.Error(), handler.provider.Logger())
oldRoutesLog := E.NewBuilder("old routes")
oldRoutes.RangeAllParallel(func(k string, r *route.Route) {
for k := range oldRoutes {
oldRoutesLog.Adds(k)
})
}
E.LogDebug(oldRoutesLog.About(), oldRoutesLog.Error(), handler.provider.Logger())
newRoutesLog := E.NewBuilder("new routes")
newRoutes.RangeAllParallel(func(k string, r *route.Route) {
for k := range newRoutes {
newRoutesLog.Adds(k)
})
}
E.LogDebug(newRoutesLog.About(), newRoutesLog.Error(), handler.provider.Logger())
}
oldRoutes.RangeAll(func(k string, oldr *route.Route) {
newr, ok := newRoutes.Load(k)
for k, oldr := range oldRoutes {
newr, ok := newRoutes[k]
switch {
case !ok:
handler.Remove(oldr)
case handler.matchAny(events, newr):
handler.Update(parent, oldr, newr)
case entry.ShouldNotServe(newr):
handler.Remove(oldr)
}
})
newRoutes.RangeAll(func(k string, newr *route.Route) {
if !(oldRoutes.Has(k) || entry.ShouldNotServe(newr)) {
}
for k, newr := range newRoutes {
if _, ok := oldRoutes[k]; !ok {
handler.Add(parent, newr)
}
})
}
}
func (handler *EventHandler) matchAny(events []watcher.Event, route *route.Route) bool {
@@ -89,8 +86,8 @@ func (handler *EventHandler) matchAny(events []watcher.Event, route *route.Route
func (handler *EventHandler) match(event watcher.Event, route *route.Route) bool {
switch handler.provider.GetType() {
case types.ProviderTypeDocker:
return route.Entry.Container.ContainerID == event.ActorID ||
route.Entry.Container.ContainerName == event.ActorName
return route.Container.ContainerID == event.ActorID ||
route.Container.ContainerName == event.ActorName
case types.ProviderTypeFile:
return true
}
@@ -103,14 +100,14 @@ func (handler *EventHandler) Add(parent task.Parent, route *route.Route) {
if err != nil {
handler.errs.Add(err.Subject("add"))
} else {
handler.added.Adds(route.Entry.Alias)
handler.added.Adds(route.Alias)
}
}
func (handler *EventHandler) Remove(route *route.Route) {
route.Finish("route removed")
handler.provider.routes.Delete(route.Entry.Alias)
handler.removed.Adds(route.Entry.Alias)
delete(handler.provider.routes, route.Alias)
handler.removed.Adds(route.Alias)
}
func (handler *EventHandler) Update(parent task.Parent, oldRoute *route.Route, newRoute *route.Route) {
@@ -119,7 +116,7 @@ func (handler *EventHandler) Update(parent task.Parent, oldRoute *route.Route, n
if err != nil {
handler.errs.Add(err.Subject("update"))
} else {
handler.updated.Adds(newRoute.Entry.Alias)
handler.updated.Adds(newRoute.Alias)
}
}

View File

@@ -33,16 +33,13 @@ func FileProviderImpl(filename string) (ProviderImpl, error) {
return impl, nil
}
func validate(provider string, data []byte) (route.Routes, E.Error) {
entries, err := utils.DeserializeYAMLMap[*route.RawEntry](data)
if err != nil {
return route.NewRoutes(), err
}
return route.FromEntries(provider, entries)
func validate(data []byte) (routes route.Routes, err E.Error) {
err = utils.DeserializeYAML(data, &routes)
return
}
func Validate(data []byte) (err E.Error) {
_, err = validate("", data)
_, err = validate(data)
return
}
@@ -63,14 +60,15 @@ func (p *FileProvider) Logger() *zerolog.Logger {
}
func (p *FileProvider) loadRoutesImpl() (route.Routes, E.Error) {
routes := route.NewRoutes()
data, err := os.ReadFile(p.path)
if err != nil {
return routes, E.From(err)
return nil, E.Wrap(err)
}
return validate(p.ShortName(), data)
routes, err := validate(data)
if err != nil && len(routes) == 0 {
return nil, E.Wrap(err)
}
return routes, E.Wrap(err)
}
func (p *FileProvider) NewWatcher() W.Watcher {

View File

@@ -12,6 +12,6 @@ import (
var testAllFieldsYAML []byte
func TestFile(t *testing.T) {
_, err := validate("", testAllFieldsYAML)
_, err := validate(testAllFieldsYAML)
ExpectNoError(t, err)
}

View File

@@ -8,7 +8,7 @@ import (
"github.com/rs/zerolog"
E "github.com/yusing/go-proxy/internal/error"
R "github.com/yusing/go-proxy/internal/route"
"github.com/yusing/go-proxy/internal/route"
"github.com/yusing/go-proxy/internal/route/provider/types"
"github.com/yusing/go-proxy/internal/task"
W "github.com/yusing/go-proxy/internal/watcher"
@@ -17,10 +17,10 @@ import (
type (
Provider struct {
ProviderImpl `json:"-"`
ProviderImpl
t types.ProviderType
routes R.Routes
routes route.Routes
watcher W.Watcher
}
@@ -28,7 +28,7 @@ type (
fmt.Stringer
ShortName() string
IsExplicitOnly() bool
loadRoutesImpl() (R.Routes, E.Error)
loadRoutesImpl() (route.Routes, E.Error)
NewWatcher() W.Watcher
Logger() *zerolog.Logger
}
@@ -41,10 +41,7 @@ const (
var ErrEmptyProviderName = errors.New("empty provider name")
func newProvider(t types.ProviderType) *Provider {
return &Provider{
t: t,
routes: R.NewRoutes(),
}
return &Provider{t: t}
}
func NewFileProvider(filename string) (p *Provider, err error) {
@@ -84,13 +81,13 @@ func (p *Provider) MarshalText() ([]byte, error) {
return []byte(p.String()), nil
}
func (p *Provider) startRoute(parent task.Parent, r *R.Route) E.Error {
func (p *Provider) startRoute(parent task.Parent, r *route.Route) E.Error {
err := r.Start(parent)
if err != nil {
return err.Subject(r.Entry.Alias)
delete(p.routes, r.Alias)
return err.Subject(r.Alias)
}
p.routes.Store(r.Entry.Alias, r)
p.routes[r.Alias] = r
return nil
}
@@ -98,11 +95,10 @@ func (p *Provider) startRoute(parent task.Parent, r *R.Route) E.Error {
func (p *Provider) Start(parent task.Parent) E.Error {
t := parent.Subtask("provider."+p.String(), false)
// routes and event queue will stop on config reload
errs := p.routes.CollectErrorsParallel(
func(alias string, r *R.Route) error {
return p.startRoute(t, r)
})
errs := E.NewBuilder("routes error")
for _, r := range p.routes {
errs.Add(p.startRoute(t, r))
}
eventQueue := events.NewEventQueue(
t.Subtask("event_queue", false),
@@ -119,32 +115,52 @@ func (p *Provider) Start(parent task.Parent) E.Error {
)
eventQueue.Start(p.watcher.Events(t.Context()))
if err := E.Join(errs...); err != nil {
if err := errs.Error(); err != nil {
return err.Subject(p.String())
}
return nil
}
func (p *Provider) RangeRoutes(do func(string, *R.Route)) {
p.routes.RangeAll(do)
func (p *Provider) RangeRoutes(do func(string, *route.Route)) {
for alias, r := range p.routes {
do(alias, r)
}
}
func (p *Provider) GetRoute(alias string) (*R.Route, bool) {
return p.routes.Load(alias)
func (p *Provider) GetRoute(alias string) (r *route.Route, ok bool) {
r, ok = p.routes[alias]
return
}
func (p *Provider) LoadRoutes() E.Error {
var err E.Error
p.routes, err = p.loadRoutesImpl()
if p.routes.Size() > 0 {
return err
func (p *Provider) loadRoutes() (routes route.Routes, err E.Error) {
routes, err = p.loadRoutesImpl()
if err != nil && len(routes) == 0 {
return route.Routes{}, err
}
if err == nil {
return nil
errs := E.NewBuilder("routes error")
errs.Add(err)
// check for exclusion
// set alias and provider, then validate
for alias, r := range routes {
r.Alias = alias
r.Provider = p.ShortName()
if err := r.Validate(); err != nil {
errs.Add(err.Subject(alias))
delete(routes, alias)
continue
}
if r.ShouldExclude() {
delete(routes, alias)
}
}
return err
return routes, errs.Error()
}
func (p *Provider) LoadRoutes() (err E.Error) {
p.routes, err = p.loadRoutes()
return
}
func (p *Provider) NumRoutes() int {
return p.routes.Size()
return len(p.routes)
}

View File

@@ -56,14 +56,14 @@ func (stats *RouteStats) AddOther(other RouteStats) {
func (p *Provider) Statistics() ProviderStats {
var rps, streams RouteStats
p.routes.RangeAll(func(_ string, r *R.Route) {
switch r.Type {
case route.RouteTypeReverseProxy:
for _, r := range p.routes {
switch r.Type() {
case route.RouteTypeHTTP:
rps.Add(r)
case route.RouteTypeStream:
streams.Add(r)
}
})
}
return ProviderStats{
Total: rps.Total + streams.Total,
RPs: rps,

View File

@@ -3,7 +3,6 @@ package route
import (
"net/http"
"github.com/rs/zerolog"
"github.com/yusing/go-proxy/internal/api/v1/favicon"
"github.com/yusing/go-proxy/internal/common"
"github.com/yusing/go-proxy/internal/docker"
@@ -15,37 +14,33 @@ import (
"github.com/yusing/go-proxy/internal/net/http/loadbalancer"
loadbalance "github.com/yusing/go-proxy/internal/net/http/loadbalancer/types"
"github.com/yusing/go-proxy/internal/net/http/middleware"
metricslogger "github.com/yusing/go-proxy/internal/net/http/middleware/metrics_logger"
"github.com/yusing/go-proxy/internal/net/http/reverseproxy"
"github.com/yusing/go-proxy/internal/route/entry"
"github.com/yusing/go-proxy/internal/route/routes"
route "github.com/yusing/go-proxy/internal/route/types"
"github.com/yusing/go-proxy/internal/task"
"github.com/yusing/go-proxy/internal/watcher/health"
"github.com/yusing/go-proxy/internal/watcher/health/monitor"
)
type (
HTTPRoute struct {
*entry.ReverseProxyEntry
ReveseProxyRoute struct {
*Route
HealthMon health.HealthMonitor `json:"health,omitempty"`
loadBalancer *loadbalancer.LoadBalancer
server loadbalancer.Server
handler http.Handler
rp *reverseproxy.ReverseProxy
task *task.Task
l zerolog.Logger
}
)
// var globalMux = http.NewServeMux() // TODO: support regex subdomain matching.
func NewHTTPRoute(entry *entry.ReverseProxyEntry) (impl, E.Error) {
func NewReverseProxyRoute(base *Route) (*ReveseProxyRoute, E.Error) {
trans := gphttp.DefaultTransport
httpConfig := entry.Raw.HTTPConfig
httpConfig := base.HTTPConfig
if httpConfig.NoTLSVerify {
trans = gphttp.DefaultTransportNoTLS
@@ -55,65 +50,57 @@ func NewHTTPRoute(entry *entry.ReverseProxyEntry) (impl, E.Error) {
trans.ResponseHeaderTimeout = httpConfig.ResponseHeaderTimeout
}
service := entry.TargetName()
rp := reverseproxy.NewReverseProxy(service, entry.URL, trans)
service := base.TargetName()
rp := reverseproxy.NewReverseProxy(service, base.ProxyURL, trans)
if len(entry.Raw.Middlewares) > 0 {
err := middleware.PatchReverseProxy(rp, entry.Raw.Middlewares)
if len(base.Middlewares) > 0 {
err := middleware.PatchReverseProxy(rp, base.Middlewares)
if err != nil {
return nil, err
}
}
r := &HTTPRoute{
ReverseProxyEntry: entry,
rp: rp,
l: logging.With().
Str("type", entry.URL.Scheme).
Str("name", service).
Logger(),
r := &ReveseProxyRoute{
Route: base,
rp: rp,
}
return r, nil
}
func (r *HTTPRoute) String() string {
func (r *ReveseProxyRoute) String() string {
return r.TargetName()
}
// Start implements task.TaskStarter.
func (r *HTTPRoute) Start(parent task.Parent) E.Error {
if entry.ShouldNotServe(r) {
return nil
}
func (r *ReveseProxyRoute) Start(parent task.Parent) E.Error {
r.task = parent.Subtask("http."+r.TargetName(), false)
switch {
case entry.UseIdleWatcher(r):
waker, err := idlewatcher.NewHTTPWaker(parent, r.ReverseProxyEntry, r.rp)
case r.UseIdleWatcher():
waker, err := idlewatcher.NewHTTPWaker(parent, r, r.rp)
if err != nil {
r.task.Finish(err)
return err
}
r.handler = waker
r.HealthMon = waker
case entry.UseHealthCheck(r):
if entry.IsDocker(r) {
case r.UseHealthCheck():
if r.IsDocker() {
client, err := docker.ConnectClient(r.Idlewatcher.DockerHost)
if err == nil {
fallback := monitor.NewHTTPHealthChecker(r.rp.TargetURL, r.Raw.HealthCheck)
r.HealthMon = monitor.NewDockerHealthMonitor(client, r.Idlewatcher.ContainerID, r.TargetName(), r.Raw.HealthCheck, fallback)
fallback := monitor.NewHTTPHealthChecker(r.rp.TargetURL, r.HealthCheck)
r.HealthMon = monitor.NewDockerHealthMonitor(client, r.Idlewatcher.ContainerID, r.TargetName(), r.HealthCheck, fallback)
r.task.OnCancel("close_docker_client", client.Close)
}
}
if r.HealthMon == nil {
r.HealthMon = monitor.NewHTTPHealthMonitor(r.rp.TargetURL, r.Raw.HealthCheck)
r.HealthMon = monitor.NewHTTPHealthMonitor(r.rp.TargetURL, r.HealthCheck)
}
}
if entry.UseAccessLog(r) {
if r.UseAccessLog() {
var err error
r.rp.AccessLogger, err = accesslog.NewFileAccessLogger(r.task, r.Raw.AccessLog)
r.rp.AccessLogger, err = accesslog.NewFileAccessLogger(r.task, r.AccessLog)
if err != nil {
r.task.Finish(err)
return E.From(err)
@@ -121,7 +108,7 @@ func (r *HTTPRoute) Start(parent task.Parent) E.Error {
}
if r.handler == nil {
pathPatterns := r.Raw.PathPatterns
pathPatterns := r.PathPatterns
switch {
case len(pathPatterns) == 0:
r.handler = r.rp
@@ -130,7 +117,7 @@ func (r *HTTPRoute) Start(parent task.Parent) E.Error {
default:
logging.Warn().
Str("route", r.TargetName()).
Msg("`path_patterns` is deprecated. Use `rules` instead.")
Msg("`path_patterns` for reverse proxy is deprecated. Use `rules` instead.")
mux := gphttp.NewServeMux()
patErrs := E.NewBuilder("invalid path pattern(s)")
for _, p := range pathPatterns {
@@ -144,17 +131,23 @@ func (r *HTTPRoute) Start(parent task.Parent) E.Error {
}
}
if len(r.Raw.Rules) > 0 {
r.handler = r.Raw.Rules.BuildHandler(r.TargetName(), r.handler)
if len(r.Rules) > 0 {
r.handler = r.Rules.BuildHandler(r.TargetName(), r.handler)
}
if r.HealthMon != nil {
if err := r.HealthMon.Start(r.task); err != nil {
E.LogWarn("health monitor error", err, &r.l)
return err
}
}
if entry.UseLoadBalance(r) {
if common.PrometheusEnabled {
metricsLogger := metricslogger.NewMetricsLogger(r.TargetName())
r.handler = metricsLogger.GetHandler(r.handler)
r.task.OnCancel("reset_metrics", metricsLogger.ResetMetrics)
}
if r.UseLoadBalance() {
r.addToLoadBalancer(parent)
} else {
routes.SetHTTPRoute(r.TargetName(), r)
@@ -163,55 +156,47 @@ func (r *HTTPRoute) Start(parent task.Parent) E.Error {
})
}
if common.PrometheusEnabled {
r.task.OnCancel("metrics_cleanup", r.rp.UnregisterMetrics)
}
r.task.OnCancel("reset_favicon", func() { favicon.PruneRouteIconCache(r) })
return nil
}
// Task implements task.TaskStarter.
func (r *HTTPRoute) Task() *task.Task {
func (r *ReveseProxyRoute) Task() *task.Task {
return r.task
}
// Finish implements task.TaskFinisher.
func (r *HTTPRoute) Finish(reason any) {
func (r *ReveseProxyRoute) Finish(reason any) {
r.task.Finish(reason)
}
func (r *HTTPRoute) ServeHTTP(w http.ResponseWriter, req *http.Request) {
func (r *ReveseProxyRoute) ServeHTTP(w http.ResponseWriter, req *http.Request) {
r.handler.ServeHTTP(w, req)
}
func (r *HTTPRoute) HealthMonitor() health.HealthMonitor {
func (r *ReveseProxyRoute) HealthMonitor() health.HealthMonitor {
return r.HealthMon
}
func (r *HTTPRoute) addToLoadBalancer(parent task.Parent) {
func (r *ReveseProxyRoute) addToLoadBalancer(parent task.Parent) {
var lb *loadbalancer.LoadBalancer
cfg := r.Raw.LoadBalance
cfg := r.LoadBalance
l, ok := routes.GetHTTPRoute(cfg.Link)
var linked *HTTPRoute
var linked *ReveseProxyRoute
if ok {
linked = l.(*HTTPRoute)
linked = l.(*ReveseProxyRoute)
lb = linked.loadBalancer
lb.UpdateConfigIfNeeded(cfg)
if linked.Raw.Homepage.IsEmpty() && !r.Raw.Homepage.IsEmpty() {
linked.Raw.Homepage = r.Raw.Homepage
if linked.Homepage.IsEmpty() && !r.Homepage.IsEmpty() {
linked.Homepage = r.Homepage
}
} else {
lb = loadbalancer.New(cfg)
if err := lb.Start(parent); err != nil {
panic(err) // should always return nil
}
linked = &HTTPRoute{
ReverseProxyEntry: &entry.ReverseProxyEntry{
Raw: &route.RawEntry{
Alias: cfg.Link,
Homepage: r.Raw.Homepage,
},
_ = lb.Start(parent) // always return nil
linked = &ReveseProxyRoute{
Route: &Route{
Alias: cfg.Link,
Homepage: r.Homepage,
},
HealthMon: lb,
loadBalancer: lb,
@@ -220,9 +205,10 @@ func (r *HTTPRoute) addToLoadBalancer(parent task.Parent) {
routes.SetHTTPRoute(cfg.Link, linked)
}
r.loadBalancer = lb
r.server = loadbalance.NewServer(r.task.Name(), r.rp.TargetURL, r.Raw.LoadBalance.Weight, r.handler, r.HealthMon)
lb.AddServer(r.server)
server := loadbalance.NewServer(r.task.Name(), r.rp.TargetURL, r.LoadBalance.Weight, r.handler, r.HealthMon)
lb.AddServer(server)
r.task.OnCancel("lb_remove_server", func() {
lb.RemoveServer(r.server)
lb.RemoveServer(server)
})
}

409
internal/route/route.go Executable file → Normal file
View File

@@ -1,104 +1,363 @@
package route
import (
"fmt"
"strconv"
"strings"
"github.com/yusing/go-proxy/internal/docker"
E "github.com/yusing/go-proxy/internal/error"
url "github.com/yusing/go-proxy/internal/net/types"
"github.com/yusing/go-proxy/internal/route/entry"
"github.com/yusing/go-proxy/internal/route/types"
idlewatcher "github.com/yusing/go-proxy/internal/docker/idlewatcher/types"
"github.com/yusing/go-proxy/internal/homepage"
net "github.com/yusing/go-proxy/internal/net/types"
"github.com/yusing/go-proxy/internal/task"
U "github.com/yusing/go-proxy/internal/utils"
F "github.com/yusing/go-proxy/internal/utils/functional"
"github.com/yusing/go-proxy/internal/watcher/health"
dockertypes "github.com/docker/docker/api/types"
"github.com/yusing/go-proxy/internal/common"
E "github.com/yusing/go-proxy/internal/error"
"github.com/yusing/go-proxy/internal/net/http/accesslog"
loadbalance "github.com/yusing/go-proxy/internal/net/http/loadbalancer/types"
"github.com/yusing/go-proxy/internal/route/rules"
"github.com/yusing/go-proxy/internal/route/types"
"github.com/yusing/go-proxy/internal/utils"
)
type (
Route struct {
_ U.NoCopy
impl
Type types.RouteType
Entry *RawEntry
}
Routes = F.Map[string, *Route]
_ utils.NoCopy
impl interface {
types.Route
task.TaskStarter
task.TaskFinisher
String() string
TargetURL() url.URL
Alias string `json:"alias"`
Scheme types.Scheme `json:"scheme,omitempty"`
Host string `json:"host,omitempty"`
Port types.Port `json:"port,omitempty"`
Root string `json:"root,omitempty"`
types.HTTPConfig
PathPatterns []string `json:"path_patterns,omitempty"`
Rules rules.Rules `json:"rules,omitempty" validate:"omitempty,unique=Name"`
HealthCheck *health.HealthCheckConfig `json:"healthcheck,omitempty"`
LoadBalance *loadbalance.Config `json:"load_balance,omitempty"`
Middlewares map[string]docker.LabelMap `json:"middlewares,omitempty"`
Homepage *homepage.Item `json:"homepage,omitempty"`
AccessLog *accesslog.Config `json:"access_log,omitempty"`
Metadata `deserialize:"-"`
}
RawEntry = types.RawEntry
RawEntries = types.RawEntries
Metadata struct {
/* Docker only */
Container *docker.Container `json:"container,omitempty"`
Provider string `json:"provider,omitempty"`
// private fields
LisURL *net.URL `json:"lurl,omitempty"`
ProxyURL *net.URL `json:"purl,omitempty"`
Idlewatcher *idlewatcher.Config `json:"idlewatcher,omitempty"`
impl types.Route
isValidated bool
}
Routes map[string]*Route
)
// function alias.
var (
NewRoutes = F.NewMap[Routes]
NewProxyEntries = types.NewProxyEntries
)
func (rt *Route) Container() *docker.Container {
if rt.Entry.Container == nil {
return docker.DummyContainer
}
return rt.Entry.Container
func (r Routes) Contains(alias string) bool {
_, ok := r[alias]
return ok
}
func NewRoute(raw *RawEntry) (*Route, E.Error) {
raw.Finalize()
en, err := entry.ValidateEntry(raw)
if err != nil {
return nil, err
func (r *Route) Validate() (err E.Error) {
if r.isValidated {
return nil
}
r.isValidated = true
r.Finalize()
var t types.RouteType
var rt impl
errs := E.NewBuilder("entry validation failed")
switch e := en.(type) {
case *entry.StreamEntry:
t = types.RouteTypeStream
rt, err = NewStreamRoute(e)
case *entry.ReverseProxyEntry:
t = types.RouteTypeReverseProxy
rt, err = NewHTTPRoute(e)
switch r.Scheme {
case types.SchemeFileServer:
r.impl, err = NewFileServer(r)
if err != nil {
errs.Add(err)
}
case types.SchemeHTTP, types.SchemeHTTPS:
if r.Port.Listening != 0 {
errs.Addf("unexpected listening port for %s scheme", r.Scheme)
}
fallthrough
case types.SchemeTCP, types.SchemeUDP:
r.LisURL = E.Collect(errs, net.ParseURL, fmt.Sprintf("%s://%s:%d", r.Scheme, r.Host, r.Port.Listening))
fallthrough
default:
panic("bug: should not reach here")
if r.LoadBalance != nil && r.LoadBalance.Link == "" {
r.LoadBalance = nil
}
r.ProxyURL = E.Collect(errs, net.ParseURL, fmt.Sprintf("%s://%s:%d", r.Scheme, r.Host, r.Port.Proxy))
r.Idlewatcher = E.Collect(errs, idlewatcher.ValidateConfig, r.Container)
}
if err != nil {
return nil, err
if !r.UseHealthCheck() && (r.UseLoadBalance() || r.UseIdleWatcher()) {
errs.Adds("healthCheck.disable cannot be true when loadbalancer or idlewatcher is enabled")
}
return &Route{
impl: rt,
Type: t,
Entry: raw,
}, nil
if errs.HasError() {
return errs.Error()
}
switch r.Scheme {
case types.SchemeFileServer:
r.impl, err = NewFileServer(r)
case types.SchemeHTTP, types.SchemeHTTPS:
r.impl, err = NewReverseProxyRoute(r)
case types.SchemeTCP, types.SchemeUDP:
r.impl, err = NewStreamRoute(r)
default:
panic(fmt.Errorf("unexpected scheme %s for alias %s", r.Scheme, r.Alias))
}
return err
}
func FromEntries(provider string, entries RawEntries) (Routes, E.Error) {
b := E.NewBuilder("errors in routes")
func (r *Route) Start(parent task.Parent) (err E.Error) {
if r.impl == nil {
return E.New("route not initialized")
}
return r.impl.Start(parent)
}
routes := NewRoutes()
entries.RangeAllParallel(func(alias string, en *RawEntry) {
if en == nil {
en = new(RawEntry)
}
en.Alias = alias
en.Provider = provider
if strings.HasPrefix(alias, "x-") { // x properties
return
}
r, err := NewRoute(en)
func (r *Route) Finish(reason any) {
if r.impl == nil {
return
}
r.impl.Finish(reason)
r.impl = nil
}
func (r *Route) Started() bool {
return r.impl != nil
}
func (r *Route) ProviderName() string {
return r.Provider
}
func (r *Route) TargetName() string {
return r.Alias
}
func (r *Route) TargetURL() *net.URL {
return r.ProxyURL
}
func (r *Route) Type() types.RouteType {
switch r.Scheme {
case types.SchemeHTTP, types.SchemeHTTPS, types.SchemeFileServer:
return types.RouteTypeHTTP
case types.SchemeTCP, types.SchemeUDP:
return types.RouteTypeStream
}
panic(fmt.Errorf("unexpected scheme %s for alias %s", r.Scheme, r.Alias))
}
func (r *Route) HealthMonitor() health.HealthMonitor {
return r.impl.HealthMonitor()
}
func (r *Route) IdlewatcherConfig() *idlewatcher.Config {
return r.Idlewatcher
}
func (r *Route) HealthCheckConfig() *health.HealthCheckConfig {
return r.HealthCheck
}
func (r *Route) LoadBalanceConfig() *loadbalance.Config {
return r.LoadBalance
}
func (r *Route) HomepageConfig() *homepage.Item {
return r.Homepage
}
func (r *Route) ContainerInfo() *docker.Container {
return r.Container
}
func (r *Route) IsDocker() bool {
if r.Container == nil {
return false
}
return r.Container.ContainerID != ""
}
func (r *Route) IsZeroPort() bool {
return r.Port.Proxy == 0
}
func (r *Route) ShouldExclude() bool {
if r.Container != nil {
switch {
case err != nil:
b.Add(err.Subject(alias))
case entry.ShouldNotServe(r):
return
default:
routes.Store(alias, r)
case r.Container.IsExcluded:
return true
case r.IsZeroPort() && !r.UseIdleWatcher():
return true
case r.Container.IsDatabase && !r.Container.IsExplicit:
return true
case strings.HasPrefix(r.Container.ContainerName, "buildx_"):
return true
}
})
return routes, b.Error()
} else if r.IsZeroPort() {
return true
}
if strings.HasPrefix(r.Alias, "x-") ||
strings.HasSuffix(r.Alias, "-old") {
return true
}
return false
}
func (r *Route) UseLoadBalance() bool {
return r.LoadBalance != nil && r.LoadBalance.Link != ""
}
func (r *Route) UseIdleWatcher() bool {
return r.Idlewatcher != nil && r.Idlewatcher.IdleTimeout > 0
}
func (r *Route) UseHealthCheck() bool {
return !r.HealthCheck.Disable
}
func (r *Route) UseAccessLog() bool {
return r.AccessLog != nil
}
func (r *Route) Finalize() {
r.Alias = strings.ToLower(strings.TrimSpace(r.Alias))
r.Host = strings.ToLower(strings.TrimSpace(r.Host))
isDocker := r.Container != nil
cont := r.Container
if r.Host == "" {
switch {
case !isDocker:
r.Host = "localhost"
case cont.PrivateIP != "":
r.Host = cont.PrivateIP
case cont.PublicIP != "":
r.Host = cont.PublicIP
}
}
lp, pp := r.Port.Listening, r.Port.Proxy
if isDocker {
if port, ok := common.ServiceNamePortMapTCP[cont.ImageName]; ok {
if pp == 0 {
pp = port
}
if r.Scheme == "" {
r.Scheme = "tcp"
}
} else if port, ok := common.ImageNamePortMap[cont.ImageName]; ok {
if pp == 0 {
pp = port
}
if r.Scheme == "" {
r.Scheme = "http"
}
}
}
if pp == 0 {
switch {
case r.Scheme == "https":
pp = 443
case !isDocker:
pp = 80
default:
pp = lowestPort(cont.PrivatePortMapping)
if pp == 0 {
pp = lowestPort(cont.PublicPortMapping)
}
}
}
if isDocker {
// replace private port with public port if using public IP.
if r.Host == cont.PublicIP {
if p, ok := cont.PrivatePortMapping[pp]; ok {
pp = int(p.PublicPort)
}
}
// replace public port with private port if using private IP.
if r.Host == cont.PrivateIP {
if p, ok := cont.PublicPortMapping[pp]; ok {
pp = int(p.PrivatePort)
}
}
if r.Scheme == "" {
switch {
case r.Host == cont.PublicIP && cont.PublicPortMapping[pp].Type == "udp":
r.Scheme = "udp"
case r.Host == cont.PrivateIP && cont.PrivatePortMapping[pp].Type == "udp":
r.Scheme = "udp"
}
}
}
if r.Scheme == "" {
switch {
case lp != 0:
r.Scheme = "tcp"
case strings.HasSuffix(strconv.Itoa(pp), "443"):
r.Scheme = "https"
default: // assume its http
r.Scheme = "http"
}
}
r.Port.Listening, r.Port.Proxy = lp, pp
if r.HealthCheck == nil {
r.HealthCheck = health.DefaultHealthConfig
}
if !r.HealthCheck.Disable {
if r.HealthCheck.Interval == 0 {
r.HealthCheck.Interval = common.HealthCheckIntervalDefault
}
if r.HealthCheck.Timeout == 0 {
r.HealthCheck.Timeout = common.HealthCheckTimeoutDefault
}
}
if isDocker && cont.IdleTimeout != "" {
if cont.WakeTimeout == "" {
cont.WakeTimeout = common.WakeTimeoutDefault
}
if cont.StopTimeout == "" {
cont.StopTimeout = common.StopTimeoutDefault
}
if cont.StopMethod == "" {
cont.StopMethod = common.StopMethodDefault
}
}
if r.Homepage.IsEmpty() {
r.Homepage = homepage.NewItem(r.Alias)
}
}
func lowestPort(ports map[int]dockertypes.Port) (res int) {
cmp := (uint16)(65535)
for port, v := range ports {
if v.PrivatePort < cmp {
cmp = v.PrivatePort
res = port
}
}
return
}

View File

@@ -6,7 +6,6 @@ import (
"github.com/yusing/go-proxy/internal"
"github.com/yusing/go-proxy/internal/homepage"
"github.com/yusing/go-proxy/internal/route/entry"
provider "github.com/yusing/go-proxy/internal/route/provider/types"
"github.com/yusing/go-proxy/internal/route/routes"
route "github.com/yusing/go-proxy/internal/route/types"
@@ -44,15 +43,15 @@ func HomepageCategories() []string {
check := make(map[string]struct{})
categories := make([]string, 0)
routes.GetHTTPRoutes().RangeAll(func(alias string, r route.HTTPRoute) {
en := r.RawEntry()
if en.Homepage.IsEmpty() || en.Homepage.Category == "" {
homepage := r.HomepageConfig()
if homepage.IsEmpty() || homepage.Category == "" {
return
}
if _, ok := check[en.Homepage.Category]; ok {
if _, ok := check[homepage.Category]; ok {
return
}
check[en.Homepage.Category] = struct{}{}
categories = append(categories, en.Homepage.Category)
check[homepage.Category] = struct{}{}
categories = append(categories, homepage.Category)
})
return categories
}
@@ -61,8 +60,7 @@ func HomepageConfig(useDefaultCategories bool, categoryFilter, providerFilter st
hpCfg := homepage.NewHomePageConfig()
routes.GetHTTPRoutes().RangeAll(func(alias string, r route.HTTPRoute) {
en := r.RawEntry()
item := en.Homepage
item := r.HomepageConfig()
if item.IsEmpty() {
item = homepage.NewItem(alias)
@@ -78,7 +76,7 @@ func HomepageConfig(useDefaultCategories bool, categoryFilter, providerFilter st
}
item.Alias = alias
item.Provider = r.RawEntry().Provider
item.Provider = r.ProviderName()
if providerFilter != "" && item.Provider != providerFilter {
return
@@ -86,7 +84,7 @@ func HomepageConfig(useDefaultCategories bool, categoryFilter, providerFilter st
if item.Name == "" {
reference := r.TargetName()
cont := r.RawEntry().Container
cont := r.ContainerInfo()
if cont != nil {
reference = cont.ImageName
}
@@ -104,8 +102,9 @@ func HomepageConfig(useDefaultCategories bool, categoryFilter, providerFilter st
}
if useDefaultCategories {
if en.Container != nil && item.Category == "" {
if category, ok := homepage.PredefinedCategories[en.Container.ImageName]; ok {
container := r.ContainerInfo()
if container != nil && item.Category == "" {
if category, ok := homepage.PredefinedCategories[container.ImageName]; ok {
item.Category = category
}
}
@@ -122,12 +121,12 @@ func HomepageConfig(useDefaultCategories bool, categoryFilter, providerFilter st
}
switch {
case entry.IsDocker(r):
case r.IsDocker():
if item.Category == "" {
item.Category = "Docker"
}
item.SourceType = string(provider.ProviderTypeDocker)
case entry.UseLoadBalance(r):
case r.UseLoadBalance():
if item.Category == "" {
item.Category = "Load-balanced"
}
@@ -148,11 +147,11 @@ func HomepageConfig(useDefaultCategories bool, categoryFilter, providerFilter st
func RoutesByAlias(typeFilter ...route.RouteType) map[string]route.Route {
rts := make(map[string]route.Route)
if len(typeFilter) == 0 || typeFilter[0] == "" {
typeFilter = []route.RouteType{route.RouteTypeReverseProxy, route.RouteTypeStream}
typeFilter = []route.RouteType{route.RouteTypeHTTP, route.RouteTypeStream}
}
for _, t := range typeFilter {
switch t {
case route.RouteTypeReverseProxy:
case route.RouteTypeHTTP:
routes.GetHTTPRoutes().RangeAll(func(alias string, r route.HTTPRoute) {
rts[alias] = r
})

View File

@@ -30,7 +30,8 @@ const (
CommandSet = "set"
CommandAdd = "add"
CommandRemove = "remove"
CommandBypass = "bypass"
CommandPass = "pass"
CommandPassAlt = "bypass"
)
var commands = map[string]struct {
@@ -94,7 +95,7 @@ var commands = map[string]struct {
},
validate: validateURL,
build: func(args any) CommandHandler {
target := args.(types.URL).String()
target := args.(*types.URL).String()
return ReturningCommand(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, target, http.StatusTemporaryRedirect)
})
@@ -159,7 +160,7 @@ var commands = map[string]struct {
},
validate: validateAbsoluteURL,
build: func(args any) CommandHandler {
target := args.(types.URL)
target := args.(*types.URL)
if target.Scheme == "" {
target.Scheme = "http"
}
@@ -231,7 +232,7 @@ func (cmd *Command) Parse(v string) error {
return err
}
if directive == CommandBypass {
if directive == CommandPass || directive == CommandPassAlt {
if len(args) != 0 {
return ErrInvalidArguments.Subject(directive)
}

View File

@@ -11,7 +11,8 @@ var (
ErrInvalidCommandSequence = E.New("invalid command sequence")
ErrInvalidSetTarget = E.New("invalid `rule.set` target")
ErrExpectNoArg = ErrInvalidArguments.Withf("expect no arg")
ErrExpectOneArg = ErrInvalidArguments.Withf("expect 1 arg")
ErrExpectTwoArgs = ErrInvalidArguments.Withf("expect 2 args")
ErrExpectNoArg = E.Wrap(ErrInvalidArguments, "expect no arg")
ErrExpectOneArg = E.Wrap(ErrInvalidArguments, "expect 1 arg")
ErrExpectTwoArgs = E.Wrap(ErrInvalidArguments, "expect 2 args")
ErrExpectKVOptionalV = E.Wrap(ErrInvalidArguments, "expect 'key' or 'key value'")
)

View File

@@ -20,9 +20,8 @@ func (h *Help) String() string {
sb.WriteString(h.command)
sb.WriteString(" ")
for arg := range h.args {
sb.WriteRune('<')
sb.WriteString(arg)
sb.WriteString("> ")
sb.WriteString(strings.ToUpper(arg))
sb.WriteRune(' ')
}
if h.description != "" {
sb.WriteString("\n\t")
@@ -32,7 +31,7 @@ func (h *Help) String() string {
sb.WriteRune('\n')
for arg, desc := range h.args {
sb.WriteRune('\t')
sb.WriteString(arg)
sb.WriteString(strings.ToUpper(arg))
sb.WriteString(": ")
sb.WriteString(desc)
sb.WriteRune('\n')

View File

@@ -34,15 +34,25 @@ var checkers = map[string]struct {
help: Help{
command: OnHeader,
args: map[string]string{
"key": "the header key",
"value": "the header value",
"key": "the header key",
"[value]": "the header value",
},
},
validate: toStrTuple,
validate: toKVOptionalV,
builder: func(args any) CheckFunc {
k, v := args.(*StrTuple).Unpack()
if v == "" {
return func(cached Cache, r *http.Request) bool {
return len(r.Header[k]) > 0
}
}
return func(cached Cache, r *http.Request) bool {
return r.Header.Get(k) == v
for _, vv := range r.Header[k] {
if v == vv {
return true
}
}
return false
}
},
},
@@ -50,13 +60,18 @@ var checkers = map[string]struct {
help: Help{
command: OnQuery,
args: map[string]string{
"key": "the query key",
"value": "the query value",
"key": "the query key",
"[value]": "the query value",
},
},
validate: toStrTuple,
validate: toKVOptionalV,
builder: func(args any) CheckFunc {
k, v := args.(*StrTuple).Unpack()
if v == "" {
return func(cached Cache, r *http.Request) bool {
return len(cached.GetQueries(r)[k]) > 0
}
}
return func(cached Cache, r *http.Request) bool {
queries := cached.GetQueries(r)[k]
for _, query := range queries {
@@ -72,13 +87,24 @@ var checkers = map[string]struct {
help: Help{
command: OnCookie,
args: map[string]string{
"key": "the cookie key",
"value": "the cookie value",
"key": "the cookie key",
"[value]": "the cookie value",
},
},
validate: toStrTuple,
validate: toKVOptionalV,
builder: func(args any) CheckFunc {
k, v := args.(*StrTuple).Unpack()
if v == "" {
return func(cached Cache, r *http.Request) bool {
cookies := cached.GetCookies(r)
for _, cookie := range cookies {
if cookie.Name == k {
return true
}
}
return false
}
}
return func(cached Cache, r *http.Request) bool {
cookies := cached.GetCookies(r)
for _, cookie := range cookies {
@@ -95,13 +121,18 @@ var checkers = map[string]struct {
help: Help{
command: OnForm,
args: map[string]string{
"key": "the form key",
"value": "the form value",
"key": "the form key",
"[value]": "the form value",
},
},
validate: toStrTuple,
validate: toKVOptionalV,
builder: func(args any) CheckFunc {
k, v := args.(*StrTuple).Unpack()
if v == "" {
return func(cached Cache, r *http.Request) bool {
return r.FormValue(k) != ""
}
}
return func(cached Cache, r *http.Request) bool {
return r.FormValue(k) == v
}
@@ -111,13 +142,18 @@ var checkers = map[string]struct {
help: Help{
command: OnPostForm,
args: map[string]string{
"key": "the form key",
"value": "the form value",
"key": "the form key",
"[value]": "the form value",
},
},
validate: toStrTuple,
validate: toKVOptionalV,
builder: func(args any) CheckFunc {
k, v := args.(*StrTuple).Unpack()
if v == "" {
return func(cached Cache, r *http.Request) bool {
return r.PostFormValue(k) != ""
}
}
return func(cached Cache, r *http.Request) bool {
return r.PostFormValue(k) == v
}

View File

@@ -1,10 +1,15 @@
package rules
import (
"encoding/base64"
"fmt"
"net/http"
"net/url"
"testing"
E "github.com/yusing/go-proxy/internal/error"
. "github.com/yusing/go-proxy/internal/utils/testing"
"golang.org/x/crypto/bcrypt"
)
func TestParseOn(t *testing.T) {
@@ -15,25 +20,50 @@ func TestParseOn(t *testing.T) {
}{
// header
{
name: "header_valid",
name: "header_valid_kv",
input: "header Connection Upgrade",
wantErr: nil,
},
{
name: "header_invalid",
name: "header_valid_k",
input: "header Connection",
wantErr: ErrInvalidArguments,
wantErr: nil,
},
{
name: "header_missing_arg",
input: "header",
wantErr: ErrExpectKVOptionalV,
},
// query
{
name: "query_valid",
name: "query_valid_kv",
input: "query key value",
wantErr: nil,
},
{
name: "query_invalid",
name: "query_valid_k",
input: "query key",
wantErr: ErrInvalidArguments,
wantErr: nil,
},
{
name: "query_missing_arg",
input: "query",
wantErr: ErrExpectKVOptionalV,
},
{
name: "cookie_valid_kv",
input: "cookie key value",
wantErr: nil,
},
{
name: "cookie_valid_k",
input: "cookie key",
wantErr: nil,
},
{
name: "cookie_missing_arg",
input: "cookie",
wantErr: ErrExpectKVOptionalV,
},
// method
{
@@ -43,9 +73,14 @@ func TestParseOn(t *testing.T) {
},
{
name: "method_invalid",
input: "method",
input: "method invalid",
wantErr: ErrInvalidArguments,
},
{
name: "method_missing_arg",
input: "method",
wantErr: ErrExpectOneArg,
},
// path
{
name: "path_valid",
@@ -53,9 +88,9 @@ func TestParseOn(t *testing.T) {
wantErr: nil,
},
{
name: "path_invalid",
name: "path_missing_arg",
input: "path",
wantErr: ErrInvalidArguments,
wantErr: ErrExpectOneArg,
},
// remote
{
@@ -65,9 +100,14 @@ func TestParseOn(t *testing.T) {
},
{
name: "remote_invalid",
input: "remote",
input: "remote abcd",
wantErr: ErrInvalidArguments,
},
{
name: "remote_missing_arg",
input: "remote",
wantErr: ErrExpectOneArg,
},
{
name: "unknown_target",
input: "unknown",
@@ -87,3 +127,152 @@ func TestParseOn(t *testing.T) {
})
}
}
type testCorrectness struct {
name string
checker string
input *http.Request
want bool
}
func genCorrectnessTestCases(field string, genRequest func(k, v string) *http.Request) []testCorrectness {
return []testCorrectness{
{
name: field + "_match",
checker: field + " foo bar",
input: genRequest("foo", "bar"),
want: true,
},
{
name: field + "_no_match",
checker: field + " foo baz",
input: genRequest("foo", "bar"),
want: false,
},
{
name: field + "_exists",
checker: field + " foo",
input: genRequest("foo", "abcd"),
want: true,
},
{
name: field + "_not_exists",
checker: field + " foo",
input: genRequest("bar", "abcd"),
want: false,
},
}
}
func TestOnCorrectness(t *testing.T) {
tests := []testCorrectness{
{
name: "method_match",
checker: "method GET",
input: &http.Request{Method: http.MethodGet},
want: true,
},
{
name: "method_no_match",
checker: "method GET",
input: &http.Request{Method: http.MethodPost},
want: false,
},
{
name: "path_exact_match",
checker: "path /example",
input: &http.Request{
URL: &url.URL{Path: "/example"},
},
want: true,
},
{
name: "path_wildcard_match",
checker: "path /example/*",
input: &http.Request{
URL: &url.URL{Path: "/example/123"},
},
want: true,
},
{
name: "remote_match",
checker: "remote 192.168.1.0/24",
input: &http.Request{
RemoteAddr: "192.168.1.5",
},
want: true,
},
{
name: "remote_no_match",
checker: "remote 192.168.1.0/24",
input: &http.Request{
RemoteAddr: "192.168.2.5",
},
want: false,
},
{
name: "basic_auth_correct",
checker: "basic_auth user " + string(Must(bcrypt.GenerateFromPassword([]byte("password"), bcrypt.DefaultCost))),
input: &http.Request{
Header: http.Header{
"Authorization": {"Basic " + base64.StdEncoding.EncodeToString([]byte("user:password"))}, // "user:password"
},
},
want: true,
},
{
name: "basic_auth_incorrect",
checker: "basic_auth user " + string(Must(bcrypt.GenerateFromPassword([]byte("password"), bcrypt.DefaultCost))),
input: &http.Request{
Header: http.Header{
"Authorization": {"Basic " + base64.StdEncoding.EncodeToString([]byte("user:incorrect"))}, // "user:wrong"
},
},
want: false,
},
}
tests = append(tests, genCorrectnessTestCases("header", func(k, v string) *http.Request {
return &http.Request{
Header: http.Header{k: []string{v}}}
})...)
tests = append(tests, genCorrectnessTestCases("query", func(k, v string) *http.Request {
return &http.Request{
URL: &url.URL{
RawQuery: fmt.Sprintf("%s=%s", k, v),
},
}
})...)
tests = append(tests, genCorrectnessTestCases("cookie", func(k, v string) *http.Request {
return &http.Request{
Header: http.Header{
"Cookie": {fmt.Sprintf("%s=%s", k, v)},
},
}
})...)
tests = append(tests, genCorrectnessTestCases("form", func(k, v string) *http.Request {
return &http.Request{
Form: url.Values{
k: []string{v},
},
}
})...)
tests = append(tests, genCorrectnessTestCases("postform", func(k, v string) *http.Request {
return &http.Request{
PostForm: url.Values{
k: []string{v},
},
}
})...)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
on, err := parseOn(tt.checker)
ExpectNoError(t, err)
got := on.Check(Cache{}, tt.input)
if tt.want != got {
t.Errorf("want %v, got %v", tt.want, got)
}
})
}
}

View File

@@ -39,7 +39,7 @@ type (
one match means this line is matched.
*/
Rule struct {
Name string `json:"name" validate:"required"`
Name string `json:"name"`
On RuleOn `json:"on"`
Do Command `json:"do"`
}

View File

@@ -36,6 +36,18 @@ func toStrTuple(args []string) (any, E.Error) {
return &StrTuple{args[0], args[1]}, nil
}
// toKVOptionalV returns *StrTuple that value is optional.
func toKVOptionalV(args []string) (any, E.Error) {
switch len(args) {
case 1:
return &StrTuple{args[0], ""}, nil
case 2:
return &StrTuple{args[0], args[1]}, nil
default:
return nil, ErrExpectKVOptionalV
}
}
// validateURL returns types.URL with the URL validated.
func validateURL(args []string) (any, E.Error) {
if len(args) != 1 {

View File

@@ -10,8 +10,8 @@ import (
E "github.com/yusing/go-proxy/internal/error"
"github.com/yusing/go-proxy/internal/logging"
net "github.com/yusing/go-proxy/internal/net/types"
"github.com/yusing/go-proxy/internal/route/entry"
"github.com/yusing/go-proxy/internal/route/routes"
route "github.com/yusing/go-proxy/internal/route/types"
"github.com/yusing/go-proxy/internal/task"
"github.com/yusing/go-proxy/internal/watcher/health"
"github.com/yusing/go-proxy/internal/watcher/health/monitor"
@@ -19,7 +19,7 @@ import (
// TODO: support stream load balance.
type StreamRoute struct {
*entry.StreamEntry
*Route
net.Stream `json:"-"`
@@ -30,16 +30,13 @@ type StreamRoute struct {
l zerolog.Logger
}
func NewStreamRoute(entry *entry.StreamEntry) (impl, E.Error) {
func NewStreamRoute(base *Route) (route.Route, E.Error) {
// TODO: support non-coherent scheme
if !entry.Scheme.IsCoherent() {
return nil, E.Errorf("unsupported scheme: %v -> %v", entry.Scheme.ListeningScheme, entry.Scheme.ProxyScheme)
}
return &StreamRoute{
StreamEntry: entry,
Route: base,
l: logging.With().
Str("type", string(entry.Scheme.ListeningScheme)).
Str("name", entry.TargetName()).
Str("type", string(base.Scheme)).
Str("name", base.TargetName()).
Logger(),
}, nil
}
@@ -50,10 +47,6 @@ func (r *StreamRoute) String() string {
// Start implements task.TaskStarter.
func (r *StreamRoute) Start(parent task.Parent) E.Error {
if entry.ShouldNotServe(r) {
return nil
}
r.task = parent.Subtask("stream." + r.TargetName())
r.Stream = NewStream(r)
parent.OnCancel("finish", func() {
@@ -61,25 +54,25 @@ func (r *StreamRoute) Start(parent task.Parent) E.Error {
})
switch {
case entry.UseIdleWatcher(r):
waker, err := idlewatcher.NewStreamWaker(parent, r.StreamEntry, r.Stream)
case r.UseIdleWatcher():
waker, err := idlewatcher.NewStreamWaker(parent, r, r.Stream)
if err != nil {
r.task.Finish(err)
return err
}
r.Stream = waker
r.HealthMon = waker
case entry.UseHealthCheck(r):
if entry.IsDocker(r) {
client, err := docker.ConnectClient(r.Idlewatcher.DockerHost)
case r.UseHealthCheck():
if r.IsDocker() {
client, err := docker.ConnectClient(r.IdlewatcherConfig().DockerHost)
if err == nil {
fallback := monitor.NewRawHealthChecker(r.TargetURL(), r.Raw.HealthCheck)
r.HealthMon = monitor.NewDockerHealthMonitor(client, r.Idlewatcher.ContainerID, r.TargetName(), r.Raw.HealthCheck, fallback)
fallback := monitor.NewRawHealthChecker(r.TargetURL(), r.HealthCheck)
r.HealthMon = monitor.NewDockerHealthMonitor(client, r.IdlewatcherConfig().ContainerID, r.TargetName(), r.HealthCheck, fallback)
r.task.OnCancel("close_docker_client", client.Close)
}
}
if r.HealthMon == nil {
r.HealthMon = monitor.NewRawHealthMonitor(r.TargetURL(), r.Raw.HealthCheck)
r.HealthMon = monitor.NewRawHealthMonitor(r.TargetURL(), r.HealthCheck)
}
}
@@ -88,9 +81,7 @@ func (r *StreamRoute) Start(parent task.Parent) E.Error {
return E.From(err)
}
r.l.Info().
Int("port", int(r.Port.ListeningPort)).
Msg("listening")
r.l.Info().Int("port", r.Port.Listening).Msg("listening")
if r.HealthMon != nil {
if err := r.HealthMon.Start(r.task); err != nil {

View File

@@ -8,7 +8,6 @@ import (
"time"
"github.com/yusing/go-proxy/internal/net/types"
T "github.com/yusing/go-proxy/internal/route/types"
U "github.com/yusing/go-proxy/internal/utils"
)
@@ -45,25 +44,25 @@ func (stream *Stream) Setup() error {
ctx := stream.task.Context()
switch stream.Scheme.ListeningScheme {
switch stream.Scheme {
case "tcp":
stream.targetAddr, err = net.ResolveTCPAddr("tcp", stream.URL.Host)
stream.targetAddr, err = net.ResolveTCPAddr("tcp", stream.ProxyURL.Host)
if err != nil {
return err
}
tcpListener, err := lcfg.Listen(ctx, "tcp", stream.ListenURL.Host)
tcpListener, err := lcfg.Listen(ctx, "tcp", stream.LisURL.Host)
if err != nil {
return err
}
// in case ListeningPort was zero, get the actual port
stream.Port.ListeningPort = T.Port(tcpListener.Addr().(*net.TCPAddr).Port)
stream.Port.Listening = tcpListener.Addr().(*net.TCPAddr).Port
stream.listener = types.NetListener(tcpListener)
case "udp":
stream.targetAddr, err = net.ResolveUDPAddr("udp", stream.URL.Host)
stream.targetAddr, err = net.ResolveUDPAddr("udp", stream.ProxyURL.Host)
if err != nil {
return err
}
udpListener, err := lcfg.ListenPacket(ctx, "udp", stream.ListenURL.Host)
udpListener, err := lcfg.ListenPacket(ctx, "udp", stream.LisURL.Host)
if err != nil {
return err
}
@@ -72,7 +71,7 @@ func (stream *Stream) Setup() error {
udpListener.Close()
return errors.New("udp listener is not *net.UDPConn")
}
stream.Port.ListeningPort = T.Port(udpConn.LocalAddr().(*net.UDPAddr).Port)
stream.Port.Listening = udpConn.LocalAddr().(*net.UDPAddr).Port
stream.listener = NewUDPForwarder(ctx, udpConn, stream.targetAddr)
default:
panic("should not reach here")

View File

@@ -1,13 +0,0 @@
package types
import (
idlewatcher "github.com/yusing/go-proxy/internal/docker/idlewatcher/types"
net "github.com/yusing/go-proxy/internal/net/types"
)
type Entry interface {
TargetName() string
TargetURL() net.URL
RawEntry() *RawEntry
IdlewatcherConfig() *idlewatcher.Config
}

View File

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

View File

@@ -1,9 +1,11 @@
package types
package types_test
import (
"testing"
"time"
. "github.com/yusing/go-proxy/internal/route"
"github.com/yusing/go-proxy/internal/route/types"
"github.com/yusing/go-proxy/internal/utils"
. "github.com/yusing/go-proxy/internal/utils/testing"
)
@@ -12,14 +14,14 @@ func TestHTTPConfigDeserialize(t *testing.T) {
tests := []struct {
name string
input map[string]any
expected HTTPConfig
expected types.HTTPConfig
}{
{
name: "no_tls_verify",
input: map[string]any{
"no_tls_verify": "true",
},
expected: HTTPConfig{
expected: types.HTTPConfig{
NoTLSVerify: true,
},
},
@@ -28,7 +30,7 @@ func TestHTTPConfigDeserialize(t *testing.T) {
input: map[string]any{
"response_header_timeout": "1s",
},
expected: HTTPConfig{
expected: types.HTTPConfig{
ResponseHeaderTimeout: 1 * time.Second,
},
},
@@ -36,12 +38,12 @@ func TestHTTPConfigDeserialize(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := RawEntry{}
cfg := Route{}
err := utils.Deserialize(tt.input, &cfg)
if err != nil {
ExpectNoError(t, err)
}
ExpectDeepEqual(t, cfg.HTTPConfig, &tt.expected)
ExpectDeepEqual(t, cfg.HTTPConfig, tt.expected)
})
}
}

View File

@@ -7,37 +7,55 @@ import (
"github.com/yusing/go-proxy/internal/utils/strutils"
)
type Port int
type Port struct {
Listening int `json:"listening"`
Proxy int `json:"proxy"`
}
var ErrPortOutOfRange = E.New("port out of range")
var (
ErrInvalidPortSyntax = E.New("invalid port syntax, expect [listening_port:]target_port")
ErrPortOutOfRange = E.New("port out of range")
)
// Parse implements strutils.Parser.
func (p *Port) Parse(v string) (err error) {
parts := strutils.SplitRune(v, ':')
switch len(parts) {
case 1:
p.Listening = 0
p.Proxy, err = strconv.Atoi(v)
case 2:
var err2 error
p.Listening, err = strconv.Atoi(parts[0])
p.Proxy, err2 = strconv.Atoi(parts[1])
err = E.Join(err, err2)
default:
return ErrInvalidPortSyntax.Subject(v)
}
func ValidatePort[String ~string](v String) (Port, error) {
p, err := strutils.Atoi(string(v))
if err != nil {
return ErrPort, err
return err
}
return ValidatePortInt(p)
}
func ValidatePortInt[Int int | uint16](v Int) (Port, error) {
p := Port(v)
if !p.inBound() {
return ErrPort, ErrPortOutOfRange.Subject(strconv.Itoa(int(p)))
if p.Listening < MinPort || p.Listening > MaxPort {
return ErrPortOutOfRange.Subjectf("%d", p.Listening)
}
return p, nil
if p.Proxy < MinPort || p.Proxy > MaxPort {
return ErrPortOutOfRange.Subjectf("%d", p.Proxy)
}
return nil
}
func (p Port) inBound() bool {
return p >= MinPort && p <= MaxPort
}
func (p Port) String() string {
return strconv.Itoa(int(p))
func (p *Port) String() string {
if p.Listening == 0 {
return strconv.Itoa(p.Proxy)
}
return strconv.Itoa(p.Listening) + ":" + strconv.Itoa(p.Proxy)
}
const (
MinPort = 0
MaxPort = 65535
ErrPort = Port(-1)
NoPort = Port(0)
)

View File

@@ -0,0 +1,106 @@
package types
import (
"errors"
"strconv"
"testing"
)
var invalidPorts = []string{
"",
"123:",
"0:",
":1234",
"qwerty",
"asdfgh:asdfgh",
"1234:asdfgh",
}
var tooManyColonsPorts = []string{
"1234:1234:1234",
}
var outOfRangePorts = []string{
"-1:1234",
"1234:-1",
"65536",
"0:65536",
}
func TestPortInvalid(t *testing.T) {
tests := []struct {
name string
inputs []string
wantErr error
}{
{
name: "invalid",
inputs: invalidPorts,
wantErr: strconv.ErrSyntax,
},
{
name: "too many colons",
inputs: tooManyColonsPorts,
wantErr: ErrInvalidPortSyntax,
},
{
name: "out of range",
inputs: outOfRangePorts,
wantErr: ErrPortOutOfRange,
},
}
for _, tc := range tests {
for _, input := range tc.inputs {
t.Run(tc.name, func(t *testing.T) {
p := &Port{}
err := p.Parse(input)
if !errors.Is(err, tc.wantErr) {
t.Errorf("expected error %v, got %v", tc.wantErr, err)
}
})
}
}
}
func TestPortValid(t *testing.T) {
tests := []struct {
name string
inputs string
expect Port
}{
{
name: "valid_lp",
inputs: "1234:5678",
expect: Port{
Listening: 1234,
Proxy: 5678,
},
},
{
name: "valid_p",
inputs: "5678",
expect: Port{
Listening: 0,
Proxy: 5678,
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
p := &Port{}
err := p.Parse(tc.inputs)
if err != nil {
t.Errorf("expected no error, got %v", err)
}
if p.Listening != tc.expect.Listening {
t.Errorf("expected listening port %d, got %d", tc.expect.Listening, p.Listening)
}
if p.Proxy != tc.expect.Proxy {
t.Errorf("expected proxy port %d, got %d", tc.expect.Proxy, p.Proxy)
}
})
}
}

View File

@@ -1,221 +0,0 @@
//nolint:goconst
package types
import (
"strconv"
"strings"
"github.com/docker/docker/api/types"
"github.com/yusing/go-proxy/internal/common"
"github.com/yusing/go-proxy/internal/docker"
"github.com/yusing/go-proxy/internal/homepage"
"github.com/yusing/go-proxy/internal/logging"
"github.com/yusing/go-proxy/internal/net/http/accesslog"
loadbalance "github.com/yusing/go-proxy/internal/net/http/loadbalancer/types"
"github.com/yusing/go-proxy/internal/route/rules"
U "github.com/yusing/go-proxy/internal/utils"
F "github.com/yusing/go-proxy/internal/utils/functional"
"github.com/yusing/go-proxy/internal/utils/strutils"
"github.com/yusing/go-proxy/internal/watcher/health"
)
type (
RawEntry struct {
_ U.NoCopy
// raw entry object before validation
// loaded from docker labels or yaml file
Alias string `json:"alias"`
Scheme string `json:"scheme,omitempty"`
Host string `json:"host,omitempty"`
Port string `json:"port,omitempty"`
HTTPConfig
PathPatterns []string `json:"path_patterns,omitempty"`
Rules rules.Rules `json:"rules,omitempty" validate:"omitempty,unique=Name"`
HealthCheck *health.HealthCheckConfig `json:"healthcheck,omitempty"`
LoadBalance *loadbalance.Config `json:"load_balance,omitempty"`
Middlewares map[string]docker.LabelMap `json:"middlewares,omitempty"`
Homepage *homepage.Item `json:"homepage,omitempty"`
AccessLog *accesslog.Config `json:"access_log,omitempty"`
/* Docker only */
Container *docker.Container `json:"container,omitempty"`
Provider string `json:"provider,omitempty"`
finalized bool
}
RawEntries = F.Map[string, *RawEntry]
)
var NewProxyEntries = F.NewMapOf[string, *RawEntry]
func (e *RawEntry) Finalize() {
if e.finalized {
return
}
isDocker := e.Container != nil
cont := e.Container
if !isDocker {
cont = docker.DummyContainer
}
if e.Host == "" {
switch {
case cont.PrivateIP != "":
e.Host = cont.PrivateIP
case cont.PublicIP != "":
e.Host = cont.PublicIP
case !isDocker:
e.Host = "localhost"
}
}
lp, pp, extra := e.splitPorts()
if port, ok := common.ServiceNamePortMapTCP[cont.ImageName]; ok {
if pp == "" {
pp = strconv.Itoa(port)
}
if e.Scheme == "" {
e.Scheme = "tcp"
}
} else if port, ok := common.ImageNamePortMap[cont.ImageName]; ok {
if pp == "" {
pp = strconv.Itoa(port)
}
if e.Scheme == "" {
e.Scheme = "http"
}
} else if pp == "" && e.Scheme == "https" {
pp = "443"
} else if pp == "" {
if p := lowestPort(cont.PrivatePortMapping); p != "" {
pp = p
} else if p := lowestPort(cont.PublicPortMapping); p != "" {
pp = p
} else if !isDocker {
pp = "80"
} else {
logging.Debug().Msg("no port found for " + e.Alias)
}
}
// replace private port with public port if using public IP.
if e.Host == cont.PublicIP {
if p, ok := cont.PrivatePortMapping[pp]; ok {
pp = strutils.PortString(p.PublicPort)
}
}
// replace public port with private port if using private IP.
if e.Host == cont.PrivateIP {
if p, ok := cont.PublicPortMapping[pp]; ok {
pp = strutils.PortString(p.PrivatePort)
}
}
if e.Scheme == "" && isDocker {
switch {
case e.Host == cont.PublicIP && cont.PublicPortMapping[pp].Type == "udp":
e.Scheme = "udp"
case e.Host == cont.PrivateIP && cont.PrivatePortMapping[pp].Type == "udp":
e.Scheme = "udp"
}
}
if e.Scheme == "" {
switch {
case lp != "":
e.Scheme = "tcp"
case strings.HasSuffix(pp, "443"):
e.Scheme = "https"
default: // assume its http
e.Scheme = "http"
}
}
if e.HealthCheck == nil {
e.HealthCheck = new(health.HealthCheckConfig)
}
if e.HealthCheck.Disable {
e.HealthCheck = nil
} else {
if e.HealthCheck.Interval == 0 {
e.HealthCheck.Interval = common.HealthCheckIntervalDefault
}
if e.HealthCheck.Timeout == 0 {
e.HealthCheck.Timeout = common.HealthCheckTimeoutDefault
}
}
if cont.IdleTimeout != "" {
if cont.WakeTimeout == "" {
cont.WakeTimeout = common.WakeTimeoutDefault
}
if cont.StopTimeout == "" {
cont.StopTimeout = common.StopTimeoutDefault
}
if cont.StopMethod == "" {
cont.StopMethod = common.StopMethodDefault
}
}
e.Port = joinPorts(lp, pp, extra)
if e.Port == "" || e.Host == "" {
if lp != "" {
e.Port = lp + ":0"
} else {
e.Port = "0"
}
}
if e.Homepage.IsEmpty() {
e.Homepage = homepage.NewItem(e.Alias)
}
e.finalized = true
}
func (e *RawEntry) splitPorts() (lp string, pp string, extra string) {
portSplit := strutils.SplitRune(e.Port, ':')
if len(portSplit) == 1 {
pp = portSplit[0]
} else {
lp = portSplit[0]
pp = portSplit[1]
if len(portSplit) > 2 {
extra = strutils.JoinRune(portSplit[2:], ':')
}
}
return
}
func joinPorts(lp string, pp string, extra string) string {
s := make([]string, 0, 3)
if lp != "" {
s = append(s, lp)
}
if pp != "" {
s = append(s, pp)
}
if extra != "" {
s = append(s, extra)
}
return strutils.JoinRune(s, ':')
}
func lowestPort(ports map[string]types.Port) string {
var cmp uint16
var res string
for port, v := range ports {
if v.PrivatePort < cmp || cmp == 0 {
cmp = v.PrivatePort
res = port
}
}
return res
}

View File

@@ -3,14 +3,39 @@ package types
import (
"net/http"
"github.com/yusing/go-proxy/internal/docker"
idlewatcher "github.com/yusing/go-proxy/internal/docker/idlewatcher/types"
"github.com/yusing/go-proxy/internal/homepage"
net "github.com/yusing/go-proxy/internal/net/types"
"github.com/yusing/go-proxy/internal/task"
"github.com/yusing/go-proxy/internal/watcher/health"
loadbalance "github.com/yusing/go-proxy/internal/net/http/loadbalancer/types"
)
type (
//nolint:interfacebloat // this is for avoiding circular imports
Route interface {
Entry
task.TaskStarter
task.TaskFinisher
ProviderName() string
TargetName() string
TargetURL() *net.URL
HealthMonitor() health.HealthMonitor
Started() bool
IdlewatcherConfig() *idlewatcher.Config
HealthCheckConfig() *health.HealthCheckConfig
LoadBalanceConfig() *loadbalance.Config
HomepageConfig() *homepage.Item
ContainerInfo() *docker.Container
IsDocker() bool
UseLoadBalance() bool
UseIdleWatcher() bool
UseHealthCheck() bool
UseAccessLog() bool
}
HTTPRoute interface {
Route

View File

@@ -3,6 +3,6 @@ package types
type RouteType string
const (
RouteTypeStream RouteType = "stream"
RouteTypeReverseProxy RouteType = "reverse_proxy"
RouteTypeStream RouteType = "stream"
RouteTypeHTTP RouteType = "http"
)

View File

@@ -8,16 +8,22 @@ type Scheme string
var ErrInvalidScheme = E.New("invalid scheme")
func NewScheme(s string) (Scheme, error) {
const (
SchemeHTTP Scheme = "http"
SchemeHTTPS Scheme = "https"
SchemeTCP Scheme = "tcp"
SchemeUDP Scheme = "udp"
SchemeFileServer Scheme = "fileserver"
)
func (s Scheme) Validate() E.Error {
switch s {
case "http", "https", "tcp", "udp":
return Scheme(s), nil
case SchemeHTTP, SchemeHTTPS,
SchemeTCP, SchemeUDP, SchemeFileServer:
return nil
}
return "", ErrInvalidScheme.Subject(s)
return ErrInvalidScheme.Subject(string(s))
}
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() }
func (s Scheme) IsReverseProxy() bool { return s == SchemeHTTP || s == SchemeHTTPS }
func (s Scheme) IsStream() bool { return s == SchemeTCP || s == SchemeUDP }

View File

@@ -1,34 +0,0 @@
package types
import (
E "github.com/yusing/go-proxy/internal/error"
"github.com/yusing/go-proxy/internal/utils/strutils"
)
type StreamPort struct {
ListeningPort Port `json:"listening"`
ProxyPort Port `json:"proxy"`
}
var ErrStreamPortTooManyColons = E.New("too many colons")
func ValidateStreamPort(p string) (StreamPort, error) {
split := strutils.SplitRune(p, ':')
switch len(split) {
case 1:
split = []string{"0", split[0]}
case 2:
break
default:
return StreamPort{}, ErrStreamPortTooManyColons.Subject(p)
}
listeningPort, lErr := ValidatePort(split[0])
proxyPort, pErr := ValidatePort(split[1])
if err := E.Join(lErr, pErr); err != nil {
return StreamPort{}, err
}
return StreamPort{listeningPort, proxyPort}, nil
}

View File

@@ -1,54 +0,0 @@
package types
import (
"strconv"
"testing"
. "github.com/yusing/go-proxy/internal/utils/testing"
)
var validPorts = []string{
"1234:5678",
"0:2345",
"2345",
}
var invalidPorts = []string{
"",
"123:",
"0:",
":1234",
"qwerty",
"asdfgh:asdfgh",
"1234:asdfgh",
}
var outOfRangePorts = []string{
"-1:1234",
"1234:-1",
"65536",
"0:65536",
}
var tooManyColonsPorts = []string{
"1234:1234:1234",
}
func TestStreamPort(t *testing.T) {
for _, port := range validPorts {
_, err := ValidateStreamPort(port)
ExpectNoError(t, err)
}
for _, port := range invalidPorts {
_, err := ValidateStreamPort(port)
ExpectError2(t, port, strconv.ErrSyntax, err)
}
for _, port := range outOfRangePorts {
_, err := ValidateStreamPort(port)
ExpectError2(t, port, ErrPortOutOfRange, err)
}
for _, port := range tooManyColonsPorts {
_, err := ValidateStreamPort(port)
ExpectError2(t, port, ErrStreamPortTooManyColons, err)
}
}

View File

@@ -1,42 +0,0 @@
package types
import (
E "github.com/yusing/go-proxy/internal/error"
"github.com/yusing/go-proxy/internal/utils/strutils"
)
type StreamScheme struct {
ListeningScheme Scheme `json:"listening"`
ProxyScheme Scheme `json:"proxy"`
}
func ValidateStreamScheme(s string) (*StreamScheme, error) {
ss := &StreamScheme{}
parts := strutils.SplitRune(s, ':')
if len(parts) == 1 {
parts = []string{s, s}
} else if len(parts) != 2 {
return nil, ErrInvalidScheme.Subject(s)
}
var lErr, pErr error
ss.ListeningScheme, lErr = NewScheme(parts[0])
ss.ProxyScheme, pErr = NewScheme(parts[1])
if err := E.Join(lErr, pErr); err != nil {
return nil, err
}
return ss, nil
}
func (s StreamScheme) String() string {
return string(s.ListeningScheme) + " -> " + string(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
}

View File

@@ -1,37 +0,0 @@
package types
import (
"testing"
. "github.com/yusing/go-proxy/internal/utils/testing"
)
var (
validStreamSchemes = []string{
"tcp:tcp",
"tcp:udp",
"udp:tcp",
"udp:udp",
"tcp",
"udp",
}
invalidStreamSchemes = []string{
"tcp:tcp:",
"tcp:",
":udp:",
":udp",
"top",
}
)
func TestNewStreamScheme(t *testing.T) {
for _, s := range validStreamSchemes {
_, err := ValidateStreamScheme(s)
ExpectNoError(t, err)
}
for _, s := range invalidStreamSchemes {
_, err := ValidateStreamScheme(s)
ExpectError(t, ErrInvalidScheme, err)
}
}

View File

@@ -1,127 +0,0 @@
package internal
import (
"io"
"log"
"net/http"
"net/url"
"os"
"path"
"github.com/yusing/go-proxy/internal/common"
)
var (
branch = common.GetEnvString("BRANCH", "v0.8")
baseURL = "https://github.com/yusing/go-proxy/raw/" + branch
requiredConfigs = []Config{
{common.ConfigBasePath, true, false, ""},
{common.DotEnvPath, false, true, common.DotEnvExamplePath},
{common.ComposeFileName, false, true, common.ComposeExampleFileName},
{path.Join(common.ConfigBasePath, common.ConfigFileName), false, true, common.ConfigExampleFileName},
}
)
type Config struct {
Pathname string
IsDir bool
NeedDownload bool
DownloadFileName string
}
func Setup() {
log.Println("setting up go-proxy")
log.Println("branch:", branch)
if err := os.Chdir("/setup"); err != nil {
log.Fatalf("failed: %s\n", err)
}
for _, config := range requiredConfigs {
config.setup()
}
log.Println("setup finished")
}
func (c *Config) setup() {
if c.IsDir {
mkdir(c.Pathname)
return
}
if !c.NeedDownload {
touch(c.Pathname)
return
}
fetch(c.DownloadFileName, c.Pathname)
}
func hasFileOrDir(path string) bool {
_, err := os.Stat(path)
return err == nil
}
func mkdir(pathname string) {
_, err := os.Stat(pathname)
if err != nil && os.IsNotExist(err) {
log.Printf("creating directory %q\n", pathname)
err := os.MkdirAll(pathname, 0o755)
if err != nil {
log.Fatalf("failed: %s\n", err)
}
return
}
if err != nil {
log.Fatalf("failed: %s\n", err)
}
}
func touch(pathname string) {
if hasFileOrDir(pathname) {
return
}
log.Printf("creating file %q\n", pathname)
_, err := os.Create(pathname)
if err != nil {
log.Fatalf("failed: %s\n", err)
}
}
func fetch(remoteFilename string, outFileName string) {
if hasFileOrDir(outFileName) {
if remoteFilename == outFileName {
log.Printf("%q already exists, not overwriting\n", outFileName)
return
}
log.Printf("%q already exists, downloading to %q\n", outFileName, remoteFilename)
outFileName = remoteFilename
}
log.Printf("downloading %q to %q\n", remoteFilename, outFileName)
url, err := url.JoinPath(baseURL, remoteFilename)
if err != nil {
log.Fatalf("unexpected error: %s\n", err)
}
resp, err := http.Get(url)
if err != nil {
log.Fatalf("http request failed: %s\n", err)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
resp.Body.Close()
log.Fatalf("error reading response body: %s\n", err)
}
err = os.WriteFile(outFileName, body, 0o644)
if err != nil {
resp.Body.Close()
log.Fatalf("failed to write to file: %s\n", err)
}
log.Print("done")
resp.Body.Close()
}

View File

@@ -31,6 +31,13 @@ var (
ErrUnknownField = E.New("unknown field")
)
var (
tagDeserialize = "deserialize" // `deserialize:"-"` to exclude from deserialization
tagJSON = "json" // share between Deserialize and json.Marshal
tagValidate = "validate" // uses go-playground/validator
tagAliases = "aliases" // declare aliases for fields
)
var mapUnmarshalerType = reflect.TypeFor[MapUnmarshaller]()
var defaultValues = functional.NewMapOf[reflect.Type, func() any]()
@@ -67,6 +74,9 @@ func extractFields(t reflect.Type) (all, anonymous []reflect.StructField) {
if !field.IsExported() {
continue
}
if field.Tag.Get(tagDeserialize) == "-" {
continue
}
if field.Anonymous {
f1, f2 := extractFields(field.Type)
fields = append(fields, f1...)
@@ -97,6 +107,33 @@ func ValidateWithFieldTags(s any) E.Error {
return errs.Error()
}
func ValidateWithCustomValidator(v reflect.Value) E.Error {
isStruct := false
for {
switch v.Kind() {
case reflect.Pointer, reflect.Interface:
if v.IsNil() {
return E.Errorf("validate: v is %w", ErrNilValue)
}
if validate, ok := v.Interface().(CustomValidator); ok {
return validate.Validate()
}
if isStruct {
return nil
}
v = v.Elem()
case reflect.Struct:
if !v.CanAddr() {
return nil
}
v = v.Addr()
isStruct = true
default:
return nil
}
}
}
func dive(dst reflect.Value) (v reflect.Value, t reflect.Type, err E.Error) {
dstT := dst.Type()
for {
@@ -186,7 +223,7 @@ func Deserialize(src SerializedObject, dst any) (err E.Error) {
}
for _, field := range fields {
var key string
if jsonTag, ok := field.Tag.Lookup("json"); ok {
if jsonTag, ok := field.Tag.Lookup(tagJSON); ok {
if jsonTag == "-" {
continue
}
@@ -198,10 +235,10 @@ func Deserialize(src SerializedObject, dst any) (err E.Error) {
mapping[key] = dstV.FieldByName(field.Name)
if !hasValidateTag {
_, hasValidateTag = field.Tag.Lookup("validate")
_, hasValidateTag = field.Tag.Lookup(tagValidate)
}
aliases, ok := field.Tag.Lookup("aliases")
aliases, ok := field.Tag.Lookup(tagAliases)
if ok {
for _, alias := range strutils.CommaSeperatedList(aliases) {
mapping[alias] = dstV.FieldByName(field.Name)
@@ -220,34 +257,28 @@ func Deserialize(src SerializedObject, dst any) (err E.Error) {
}
if hasValidateTag {
errs.Add(ValidateWithFieldTags(dstV.Interface()))
} else {
if dstV.CanAddr() {
dstV = dstV.Addr()
}
if validator, ok := dstV.Interface().(CustomValidator); ok {
errs.Add(validator.Validate())
}
}
if err := ValidateWithCustomValidator(dstV); err != nil {
errs.Add(err)
}
return errs.Error()
case reflect.Map:
if dstV.IsNil() {
dstV.Set(reflect.MakeMap(dstT))
}
for k := range src {
for k, v := range src {
mapVT := dstT.Elem()
tmp := New(mapVT).Elem()
err := Convert(reflect.ValueOf(src[k]), tmp)
if err == nil {
dstV.SetMapIndex(reflect.ValueOf(k), tmp)
} else {
err := Convert(reflect.ValueOf(v), tmp)
if err != nil {
errs.Add(err.Subject(k))
continue
}
if err := ValidateWithCustomValidator(tmp.Addr()); err != nil {
errs.Add(err.Subject(k))
} else {
dstV.SetMapIndex(reflect.ValueOf(k), tmp)
}
}
if dstV.CanAddr() {
dstV = dstV.Addr()
}
if validator, ok := dstV.Interface().(CustomValidator); ok {
errs.Add(validator.Validate())
if err := ValidateWithCustomValidator(dstV); err != nil {
errs.Add(err)
}
return errs.Error()
default:
@@ -428,7 +459,7 @@ func ConvertString(src string, dst reflect.Value) (convertible bool, convErr E.E
src = strings.TrimSpace(src)
isMultiline := strings.ContainsRune(src, '\n')
// one liner is comma separated list
if !isMultiline {
if !isMultiline && src[0] != '-' {
values := strutils.CommaSeperatedList(src)
dst.Set(reflect.MakeSlice(dst.Type(), len(values), len(values)))
errs := E.NewBuilder("invalid slice values")

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