Compare commits

..

1 Commits

Author SHA1 Message Date
default
d15ea3cefe dependencies upgrade 2024-05-29 16:46:07 +00:00
508 changed files with 6792 additions and 38963 deletions

View File

@@ -1,55 +0,0 @@
# set timezone to get correct log timestamp
TZ=ETC/UTC
# API/WebUI user password login credentials (optional)
# These fields are not required for OIDC authentication
GODOXY_API_USER=admin
GODOXY_API_PASSWORD=password
# Enable `secure` cookie flag
GODOXY_API_JWT_SECURE=true
# generate secret with `openssl rand -base64 32`
GODOXY_API_JWT_SECRET=
# the JWT token time-to-live
GODOXY_API_JWT_TOKEN_TTL=1h
# OIDC Configuration (optional)
# Uncomment and configure these values to enable OIDC authentication.
# GODOXY_OIDC_ISSUER_URL=https://accounts.google.com
# GODOXY_OIDC_CLIENT_ID=your-client-id
# GODOXY_OIDC_CLIENT_SECRET=your-client-secret
# Keep /api/auth/callback as the redirect URL, change the domain to match your setup.
# GODOXY_OIDC_REDIRECT_URL=https://your-domain/api/auth/callback
# Comma-separated list of scopes
# GODOXY_OIDC_SCOPES=openid, profile, email
#
# User definitions: Uncomment and configure these values to restrict access to specific users or groups.
# These two fields act as a logical AND operator. For example, given the following membership:
# user1, group1
# user2, group1
# user3, group2
# user1, group2
# You can allow access to user3 AND all users of group1 by providing:
# # GODOXY_OIDC_ALLOWED_USERS=user3
# # GODOXY_OIDC_ALLOWED_GROUPS=group1
#
# Comma-separated list of allowed users.
# GODOXY_OIDC_ALLOWED_USERS=user1,user2
# Optional: Comma-separated list of allowed groups.
# GODOXY_OIDC_ALLOWED_GROUPS=group1,group2
# Proxy listening address
GODOXY_HTTP_ADDR=:80
GODOXY_HTTPS_ADDR=:443
# API listening address
GODOXY_API_ADDR=127.0.0.1:8888
# Frontend listening port
GODOXY_FRONTEND_PORT=3000
# Prometheus Metrics
GODOXY_PROMETHEUS_ENABLED=true
# Debug mode
GODOXY_DEBUG=false

15
.github/FUNDING.yml vendored
View File

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

@@ -1,51 +0,0 @@
name: GoDoxy agent binary
on:
push:
tags:
- v*
paths:
- "agent/**"
jobs:
build:
strategy:
matrix:
include:
- runner: ubuntu-latest
platform: linux/amd64
binary_name: godoxy-agent-linux-amd64
- runner: ubuntu-24.04-arm
platform: linux/arm64
binary_name: godoxy-agent-linux-arm64
name: Build ${{ matrix.platform }}
runs-on: ${{ matrix.runner }}
permissions:
contents: write
id-token: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Verify dependencies
run: go mod verify
- name: Build
run: |
make agent=1 NAME=${{ matrix.binary_name }} build
- name: Check binary
run: |
file bin/${{ matrix.binary_name }}
- name: Test
run: |
go test -v ./agent/...
- name: Upload
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.binary_name }}
path: bin/${{ matrix.binary_name }}
- name: Upload to release
uses: softprops/action-gh-release@v2
if: startsWith(github.ref, 'refs/tags/')
with:
files: bin/${{ matrix.binary_name }}

View File

@@ -1,23 +0,0 @@
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
- "!dependabot/*"
- "!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

View File

@@ -1,20 +0,0 @@
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,165 +1,14 @@
name: Docker Image CI
on:
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
MAKE_ARGS: agent=${{ inputs.agent && '1' || '0' }}
DIGEST_PATH: /tmp/digests/${{ inputs.agent && 'agent' || 'main' }}
DIGEST_NAME_SUFFIX: ${{ inputs.agent && 'agent' || 'main' }}
push:
tags:
- "*"
jobs:
build:
strategy:
fail-fast: false
matrix:
include:
- runner: ubuntu-latest
platform: linux/amd64
- runner: ubuntu-24.04-arm
platform: linux/arm64
name: Build ${{ matrix.platform }}
runs-on: ${{ matrix.runner }}
permissions:
contents: read
packages: write
id-token: write
attestations: write
steps:
- name: Prepare
run: |
platform=${{ matrix.platform }}
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- 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
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: 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=registry,ref=${{ env.REGISTRY }}/${{ inputs.image_name }}:buildcache-${{ env.PLATFORM_PAIR }}-${{ inputs.tag }}
cache-to: |
type=registry,ref=${{ env.REGISTRY }}/${{ inputs.image_name }}:buildcache-${{ env.PLATFORM_PAIR }}-${{ inputs.tag }},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 }}/${{ inputs.image_name }}
subject-digest: ${{ steps.build.outputs.digest }}
push-to-registry: true
- 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 }}-${{ env.DIGEST_NAME_SUFFIX }}
path: ${{ env.DIGEST_PATH }}/*
if-no-files-found: error
retention-days: 1
merge:
needs: build
build_and_push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
id-token: write
steps:
- name: Download digests
uses: actions/download-artifact@v4
- name: Build and Push Container to ghcr.io
uses: GlueOps/github-actions-build-push-containers@v0.3.7
with:
path: ${{ env.DIGEST_PATH }}
pattern: digests-*-${{ env.DIGEST_NAME_SUFFIX }}
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-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: 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 }}
tags: latest,${{ github.ref_name }}

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

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

36
.gitignore vendored
View File

@@ -1,38 +1,10 @@
compose.yml
*.compose.yml
config
certs
config*/
!schemas/**
certs*/
config/
certs/
bin/
error_pages/
!examples/error_pages/
profiles/
data/
debug/
templates/codemirror/
logs/
log/
.vscode/settings.json
go.work.sum
!cmd/**/
!internal/**/
todo.md
.*.swp
.aider*
mtrace.json
.env
test.Dockerfile
node_modules/
tsconfig.tsbuildinfo
!agent.compose.yml
!agent/pkg/**
.vscode/settings.json

View File

@@ -11,5 +11,5 @@ build-image:
- echo $CI_REGISTRY_PASSWORD | docker login -u $CI_REGISTRY_USER $CI_REGISTRY --password-stdin
script:
- echo building $CI_REGISTRY_IMAGE
- docker build --no-cache --build-arg VERSION=$CI_COMMIT_REF_NAME -t $CI_REGISTRY_IMAGE .
- docker push $CI_REGISTRY_IMAGE
- docker build --pull -t $CI_REGISTRY_IMAGE .
- docker push $CI_REGISTRY_IMAGE

0
.gitmodules vendored Normal file
View File

View File

@@ -1,135 +0,0 @@
run:
timeout: 10m
linters-settings:
govet:
enable-all: true
disable:
- shadow
- fieldalignment
gocyclo:
min-complexity: 14
misspell:
locale: US
funlen:
lines: -1
statements: 120
forbidigo:
forbid:
- ^print(ln)?$
godox:
keywords:
- FIXME
tagalign:
align: false
sort: true
order:
- description
- json
- toml
- yaml
- yml
- label
- label-slice-as-struct
- file
- kv
- export
stylecheck:
dot-import-whitelist:
- github.com/yusing/go-proxy/internal/utils/testing # go tests only
- github.com/yusing/go-proxy/internal/api/v1/utils # api only
revive:
rules:
- name: struct-tag
- name: blank-imports
- name: context-as-argument
- name: context-keys-type
- name: error-return
- name: error-strings
- name: error-naming
- name: exported
disabled: true
- name: if-return
- name: increment-decrement
- name: var-naming
- name: var-declaration
- name: package-comments
disabled: true
- name: range
- name: receiver-naming
- name: time-naming
- name: unexported-return
- name: indent-error-flow
- name: errorf
- name: empty-block
- name: superfluous-else
- name: unused-parameter
disabled: true
- name: unreachable-code
- name: redefines-builtin-id
gomoddirectives:
replace-allow-list:
- github.com/abbot/go-http-auth
- github.com/gorilla/mux
- github.com/mailgun/minheap
- github.com/mailgun/multibuf
- github.com/jaguilar/vt100
- github.com/cucumber/godog
- github.com/http-wasm/http-wasm-host-go
testifylint:
disable:
- suite-dont-use-pkg
- require-error
- go-require
staticcheck:
checks:
- all
- -SA1019
errcheck:
exclude-functions:
- fmt.Fprintln
linters:
enable-all: true
disable:
- execinquery # deprecated
- gomnd # deprecated
- sqlclosecheck # not relevant (SQL)
- rowserrcheck # not relevant (SQL)
- cyclop # duplicate of gocyclo
- depguard # Not relevant
- nakedret # Too strict
- lll # Not relevant
- 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
- wsl # Too strict
- nlreturn # Not relevant
- mnd # Too strict
- testpackage # Too strict
- tparallel # Not relevant
- paralleltest # Not relevant
- exhaustive # Not relevant
- exhaustruct # Not relevant
- err113 # Too strict
- wrapcheck # Too strict
- noctx # Too strict
- bodyclose # too many false-positive
- forcetypeassert # Too strict
- tagliatelle # Too strict
- varnamelen # Not relevant
- nilnil # Not relevant
- ireturn # Not relevant
- contextcheck # too many false-positive
- containedctx # too many false-positive
- maintidx # kind of duplicate of gocyclo
- nonamedreturns # Too strict
- gosmopolitan # not relevant
- exportloopref # Not relevant since go1.22

9
.trunk/.gitignore vendored
View File

@@ -1,9 +0,0 @@
*out
*logs
*actions
*notifications
*tools
plugins
user_trunk.yaml
user.yaml
tmp

View File

@@ -1,41 +0,0 @@
# This file controls the behavior of Trunk: https://docs.trunk.io/cli
# To learn more about the format of this file, see https://docs.trunk.io/reference/trunk-yaml
version: 0.1
cli:
version: 1.22.10
# Trunk provides extensibility via plugins. (https://docs.trunk.io/plugins)
plugins:
sources:
- id: trunk
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:
enabled:
- node@18.20.5
- python@3.10.8
- go@1.23.2
# This is the section where you manage your linters. (https://docs.trunk.io/check/configuration)
lint:
disabled:
- markdownlint
- yamllint
enabled:
- hadolint@2.12.1-beta
- actionlint@1.7.7
- git-diff-check
- gofmt@1.20.4
- golangci-lint@1.64.5
- osv-scanner@1.9.2
- oxipng@9.1.4
- prettier@3.5.1
- shellcheck@0.10.0
- shfmt@3.6.0
- trufflehog@3.88.9
actions:
disabled:
- trunk-announce
- trunk-check-pre-push
- trunk-fmt-pre-commit
enabled:
- trunk-upgrade-available

View File

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

View File

@@ -1,63 +1,39 @@
# Stage 1: deps
FROM golang:1.24.2-alpine AS deps
HEALTHCHECK NONE
FROM alpine:latest AS codemirror
RUN apk add --no-cache unzip wget make
COPY Makefile .
RUN make setup-codemirror
# package version does not matter
# trunk-ignore(hadolint/DL3018)
RUN apk add --no-cache tzdata make libcap-setcap
WORKDIR /src
# Only copy go.mod and go.sum initially for better caching
COPY go.mod go.sum /src/
ENV GOPATH=/root/go
RUN go mod download -x
# Stage 2: builder
FROM deps AS builder
WORKDIR /src
COPY Makefile ./
COPY cmd ./cmd
COPY internal ./internal
COPY pkg ./pkg
COPY agent ./agent
COPY migrations ./migrations
ARG VERSION
ENV VERSION=${VERSION}
ARG MAKE_ARGS
ENV MAKE_ARGS=${MAKE_ARGS}
FROM golang:1.22.2-alpine as builder
COPY src/ /src
COPY go.mod go.sum /src/go-proxy
WORKDIR /src/go-proxy
RUN --mount=type=cache,target="/go/pkg/mod" \
go mod download
ENV GOCACHE=/root/.cache/go-build
ENV GOPATH=/root/go
RUN make ${MAKE_ARGS} build link-binary && \
mv bin /app/ && \
mkdir -p /app/error_pages /app/certs
RUN --mount=type=cache,target="/go/pkg/mod" \
--mount=type=cache,target="/root/.cache/go-build" \
CGO_ENABLED=0 GOOS=linux go build -pgo=auto -o go-proxy
# Stage 3: Final image
FROM scratch
FROM alpine:latest
LABEL maintainer="yusing@6uo.me"
LABEL proxy.exclude=1
# copy timezone data
COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo
RUN apk add --no-cache tzdata
RUN mkdir -p /app/templates
COPY --from=codemirror templates/codemirror/ /app/templates/codemirror
COPY templates/ /app/templates
COPY schema/ /app/schema
COPY --from=builder /src/go-proxy /app/
# copy binary
COPY --from=builder /app /app
RUN chmod +x /app/go-proxy
ENV DOCKER_HOST unix:///var/run/docker.sock
ENV GOPROXY_DEBUG 0
# copy example config
COPY config.example.yml /app/config/config.yml
# copy certs
COPY --from=builder /etc/ssl/certs /etc/ssl/certs
ENV DOCKER_HOST=unix:///var/run/docker.sock
EXPOSE 80
EXPOSE 8080
EXPOSE 443
EXPOSE 8443
WORKDIR /app
CMD ["/app/run"]
CMD ["/app/go-proxy"]

121
Makefile
View File

@@ -1,98 +1,49 @@
export VERSION ?= $(shell git describe --tags --abbrev=0)
export BUILD_DATE ?= $(shell date -u +'%Y%m%d-%H%M')
export GOOS = linux
.PHONY: all build up quick-restart restart logs get udp-server
LDFLAGS = -X github.com/yusing/go-proxy/pkg.version=${VERSION}
all: build quick-restart logs
setup:
mkdir -p config certs
[ -f config/config.yml ] || cp config.example.yml config/config.yml
[ -f config/providers.yml ] || touch config/providers.yml
ifeq ($(agent), 1)
NAME = godoxy-agent
CMD_PATH = ./agent/cmd
else
NAME = godoxy
CMD_PATH = ./cmd
endif
ifeq ($(trace), 1)
debug = 1
GODOXY_TRACE ?= 1
GODEBUG = gctrace=1 inittrace=1 schedtrace=3000
endif
ifeq ($(race), 1)
debug = 1
BUILD_FLAGS += -race
endif
ifeq ($(debug), 1)
CGO_ENABLED = 0
GODOXY_DEBUG = 1
BUILD_FLAGS += -gcflags=all='-N -l' -tags debug
else ifeq ($(pprof), 1)
CGO_ENABLED = 1
GORACE = log_path=logs/pprof strip_path_prefix=$(shell pwd)/ halt_on_error=1
BUILD_FLAGS += -tags pprof
VERSION := ${VERSION}-pprof
else
CGO_ENABLED = 0
LDFLAGS += -s -w
BUILD_FLAGS += -pgo=auto -tags production
endif
BUILD_FLAGS += -ldflags='$(LDFLAGS)'
export NAME
export CMD_PATH
export CGO_ENABLED
export GODOXY_DEBUG
export GODOXY_TRACE
export GODEBUG
export GORACE
export BUILD_FLAGS
.PHONY: debug
test:
GODOXY_TEST=1 go test ./internal/...
get:
go get -u ./cmd && go mod tidy
setup-codemirror:
wget https://codemirror.net/5/codemirror.zip
unzip codemirror.zip
rm codemirror.zip
mkdir -p templates
mv codemirror-* templates/codemirror
build:
mkdir -p bin
go build ${BUILD_FLAGS} -o bin/${NAME} ${CMD_PATH}
if [ $(shell id -u) -eq 0 ]; \
then setcap CAP_NET_BIND_SERVICE=+eip bin/${NAME}; \
else sudo setcap CAP_NET_BIND_SERVICE=+eip bin/${NAME}; \
fi
CGO_ENABLED=0 GOOS=linux go build -pgo=auto -o bin/go-proxy src/go-proxy/*.go
run:
[ -f .env ] && godotenv -f .env go run ${BUILD_FLAGS} ${CMD_PATH}
test:
go test src/go-proxy/*.go
debug:
make NAME="godoxy-test" debug=1 build
sh -c 'HTTP_ADDR=:81 HTTPS_ADDR=:8443 API_ADDR=:8899 DEBUG=1 bin/godoxy-test'
up:
docker compose up -d
mtrace:
bin/godoxy debug-ls-mtrace > mtrace.json
restart:
docker compose restart -t 0
rapid-crash:
docker run --restart=always --name test_crash -p 80 debian:bookworm-slim /bin/cat &&\
sleep 3 &&\
docker rm -f test_crash
logs:
tail -f log/go-proxy.log
debug-list-containers:
bash -c 'echo -e "GET /containers/json HTTP/1.0\r\n" | sudo netcat -U /var/run/docker.sock | tail -n +9 | jq'
get:
go get -d -u ./src/go-proxy
ci-test:
mkdir -p /tmp/artifacts
act -n --artifact-server-path /tmp/artifacts -s GITHUB_TOKEN="$$(gh auth token)"
repush:
git reset --soft HEAD^
git add -A
git commit -m "repush"
git push gitlab dev --force
cloc:
cloc --not-match-f '_test.go$$' cmd internal pkg
link-binary:
ln -s /app/${NAME} bin/run
push-github:
git push origin $(shell git rev-parse --abbrev-ref HEAD)
udp-server:
docker run -it --rm \
-p 9999:9999/udp \
--label proxy.test-udp.scheme=udp \
--label proxy.test-udp.port=20003:9999 \
--network host \
--name test-udp \
$$(docker build -q -f udp-test-server.Dockerfile .)

426
README.md
View File

@@ -1,176 +1,346 @@
<div align="center">
# go-proxy
# GoDoxy
A simple auto docker reverse proxy for home use. **Written in _Go_**
[![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_godoxy)
![GitHub last commit](https://img.shields.io/github/last-commit/yusing/godoxy)
[![Lines of Code](https://sonarcloud.io/api/project_badges/measure?project=yusing_go-proxy&metric=ncloc)](https://sonarcloud.io/summary/new_code?id=yusing_godoxy)
![Demo](https://img.shields.io/website?url=https%3A%2F%2Fgodoxy.demo.6uo.me&label=Demo&link=https%3A%2F%2Fgodoxy.demo.6uo.me)
[![Discord](https://dcbadge.limes.pink/api/server/umReR62nRd?style=flat)](https://discord.gg/umReR62nRd)
A lightweight, simple, and [performant](https://github.com/yusing/godoxy/wiki/Benchmarks) reverse proxy with WebUI.
For full documentation, check out **[Wiki](https://github.com/yusing/godoxy/wiki)**
**EN** | <a href="README_CHT.md">中文</a>
<img src="screenshots/webui.jpg" style="max-width: 650">
</div>
In the examples domain `x.y.z` is used, replace them with your domain
## Table of content
<!-- TOC -->
- [Table of content](#table-of-content)
- [Key Points](#key-points)
- [How to use](#how-to-use)
- [Tested Services](#tested-services)
- [HTTP/HTTPs Reverse Proxy](#httphttps-reverse-proxy)
- [TCP Proxy](#tcp-proxy)
- [UDP Proxy](#udp-proxy)
- [Command-line args](#command-line-args)
- [Commands](#commands)
- [Use JSON Schema in VSCode](#use-json-schema-in-vscode)
- [Environment variables](#environment-variables)
- [Config File](#config-file)
- [Fields](#fields)
- [Provider Kinds](#provider-kinds)
- [Provider File](#provider-file)
- [Supported DNS Challenge Providers](#supported-dns-challenge-providers)
- [Troubleshooting](#troubleshooting)
- [Benchmarks](#benchmarks)
- [Known issues](#known-issues)
- [Memory usage](#memory-usage)
- [Build it yourself](#build-it-yourself)
<!-- /TOC -->
- [GoDoxy](#godoxy)
- [Table of content](#table-of-content)
- [Running demo](#running-demo)
- [Key Features](#key-features)
- [Prerequisites](#prerequisites)
- [How does GoDoxy work](#how-does-godoxy-work)
- [Setup](#setup)
- [Screenshots](#screenshots)
- [idlesleeper](#idlesleeper)
- [Metrics and Logs](#metrics-and-logs)
- [Manual Setup](#manual-setup)
- [Folder structrue](#folder-structrue)
- [Build it yourself](#build-it-yourself)
## Key Points
## Running demo
- Fast (See [benchmarks](#benchmarks))
- Auto certificate obtaining and renewal (See [Config File](#config-file) and [Supported DNS Challenge Providers](#supported-dns-challenge-providers))
- Auto detect reverse proxies from docker
- Auto hot-reload on container `start` / `die` / `stop` or config file changes
- Custom proxy entries with `config.yml` and additional provider files
- Subdomain matching + Path matching **(domain name doesn't matter)**
- HTTP(s) reverse proxy + TCP/UDP Proxy
- HTTP(s) round robin load balance support (same subdomain and path across different hosts)
- Web UI on port 8080 (http) and port 8443 (https)
<https://godoxy.demo.6uo.me>
- a simple panel to see all reverse proxies and health
[![Deployed on Zeabur](https://zeabur.com/deployed-on-zeabur-dark.svg)](https://zeabur.com/referral?referralCode=yusing&utm_source=yusing&utm_campaign=oss)
![panel screenshot](screenshots/panel.png)
## Key Features
- a config editor to edit config and provider files with validation
- Easy to use
- Effortless configuration
- Simple multi-node setup with GoDoxy agents or Docker Socket Proxies
- Error messages is clear and detailed, easy troubleshooting
- **Auto SSL** with Let's Encrypt (See [Supported DNS-01 Challenge Providers](https://github.com/yusing/go-proxy/wiki/Supported-DNS%E2%80%9001-Providers))
- **Auto hot-reload** on container state / config file changes
- **Container aware**: create routes dynamically from running docker containers
- **idlesleeper**: stop and wake containers based on traffic _(optional, see [screenshots](#idlesleeper))_
- HTTP reserve proxy and TCP/UDP port forwarding
- **OpenID Connect integration**: SSO and secure your apps easily
- [HTTP middleware](https://github.com/yusing/go-proxy/wiki/Middlewares) and [Custom error pages support](https://github.com/yusing/go-proxy/wiki/Middlewares#custom-error-pages)
- **Web UI with App dashboard, config editor, _uptime and system metrics_, _docker logs viewer_**
- Supports **linux/amd64** and **linux/arm64**
- Written in **[Go](https://go.dev)**
**Validate and save file with Ctrl+S**
## Prerequisites
![config editor screenshot](screenshots/config_editor.png)
Setup Wildcard DNS Record(s) for machine running `GoDoxy`, e.g.
[🔼Back to top](#table-of-content)
- A Record: `*.domain.com` -> `10.0.10.1`
- AAAA Record (if you use IPv6): `*.domain.com` -> `::ffff:a00:a01`
## How to use
## How does GoDoxy work
1. Setup DNS Records to your machine's IP address
1. List all the containers
2. Read container name, labels and port configurations for each of them
3. Create a route if applicable (a route is like a "Virtual Host" in NPM)
4. Watch for container / config changes and update automatically
- A Record: `*.y.z` -> `10.0.10.1`
- AAAA Record: `*.y.z` -> `::ffff:a00:a01`
> [!NOTE]
> GoDoxy uses the label `proxy.aliases` as the subdomain(s), if unset it defaults to the `container_name` field in docker compose.
>
> For example, with the label `proxy.aliases: qbt` you can access your app via `qbt.domain.com`.
2. Start `go-proxy` by
## Setup
- [Running from binary or as a system service](docs/binary.md)
- [Running as a docker container](docs/docker.md)
> [!NOTE]
> GoDoxy is designed to be running in `host` network mode, do not change it.
>
> To change listening ports, modify `.env`.
3. Start editing config files
- with text editor (i.e. Visual Studio Code)
- or with web config editor by navigate to `http://ip:8080`
1. Prepare a new directory for docker compose and config files.
[🔼Back to top](#table-of-content)
2. Run setup script inside the directory, or [set up manually](#manual-setup)
## Tested Services
```shell
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/yusing/godoxy/main/scripts/setup.sh)"
```
### HTTP/HTTPs Reverse Proxy
3. You may now do some extra configuration on WebUI `https://godoxy.yourdomain.com`
- Nginx
- Minio
- AdguardHome Dashboard
- etc.
## Screenshots
### TCP Proxy
### idlesleeper
- Minecraft server
- PostgreSQL
- MariaDB
![idlesleeper](screenshots/idlesleeper.webp)
### UDP Proxy
### Metrics and Logs
- Adguardhome DNS
- Palworld Dedicated Server
<div align="center">
<table>
<tr>
<td align="center"><img src="screenshots/uptime.png" alt="Uptime Monitor" width="250"/></td>
<td align="center"><img src="screenshots/docker-logs.jpg" alt="Docker Logs" width="250"/></td>
<td align="center"><img src="screenshots/docker.jpg" alt="Server Overview" width="250"/></td>
</tr>
<tr>
<td align="center"><b>Uptime Monitor</b></td>
<td align="center"><b>Docker Logs</b></td>
<td align="center"><b>Server Overview</b></td>
</tr>
<tr>
<td align="center"><img src="screenshots/system-monitor.jpg" alt="System Monitor" width="250"/></td>
<td align="center"><img src="screenshots/system-info-graphs.jpg" alt="Graphs" width="250"/></td>
</tr>
<tr>
<td align="center"><b>System Monitor</b></td>
<td align="center"><b>Graphs</b></td>
</tr>
</table>
</div>
[🔼Back to top](#table-of-content)
## Manual Setup
## Command-line args
1. Make `config` directory then grab `config.example.yml` into `config/config.yml`
`go-proxy [command]`
`mkdir -p config && wget https://raw.githubusercontent.com/yusing/godoxy/main/config.example.yml -O config/config.yml`
### Commands
2. Grab `.env.example` into `.env`
- empty: start proxy server
- validate: validate config and exit
- reload: trigger a force reload of config
`wget https://raw.githubusercontent.com/yusing/godoxy/main/.env.example -O .env`
Examples:
3. Grab `compose.example.yml` into `compose.yml`
- Binary: `go-proxy reload`
- Docker: `docker exec -it go-proxy /app/go-proxy reload`
`wget https://raw.githubusercontent.com/yusing/godoxy/main/compose.example.yml -O compose.yml`
[🔼Back to top](#table-of-content)
### Folder structrue
## Use JSON Schema in VSCode
```shell
├── certs
│ ├── cert.crt
│ └── priv.key
├── compose.yml
├── config
├── config.yml
├── middlewares
│ │ ├── middleware1.yml
│ ├── middleware2.yml
├── provider1.yml
└── provider2.yml
├── data
│ ├── metrics # metrics data
│ │ ├── uptime.json
│ │ └── system_info.json
└── .env
Copy [`.vscode/settings.example.json`](.vscode/settings.example.json) to `.vscode/settings.json` and modify to fit your needs
```json
{
"yaml.schemas": {
"https://github.com/yusing/go-proxy/raw/main/schema/config.schema.json": [
"config.example.yml",
"config.yml"
],
"https://github.com/yusing/go-proxy/raw/main/schema/providers.schema.json": [
"providers.example.yml",
"*.providers.yml"
]
}
}
```
[🔼Back to top](#table-of-content)
## Environment variables
- `GOPROXY_DEBUG`: set to `1` or `true` to enable debug behaviors (i.e. output, etc.)
- `GOPROXY_HOST_NETWORK`: _(Docker only)_ set to `1` when `network_mode: host`
- `GOPROXY_NO_SCHEMA_VALIDATION`: disable schema validation on config load / reload **(for testing new DNS Challenge providers)**
[🔼Back to top](#table-of-content)
## Config File
See [config.example.yml](config.example.yml) for more
### Fields
- `autocert`: autocert configuration
- `email`: ACME Email
- `domains`: a list of domains for cert registration
- `provider`: DNS Challenge provider, see [Supported DNS Challenge Providers](#supported-dns-challenge-providers)
- `options`: [provider specific options](#supported-dns-challenge-providers)
- `providers`: reverse proxy providers configuration
- `kind`: provider kind (string), see [Provider Kinds](#provider-kinds)
- `value`: provider specific value
[🔼Back to top](#table-of-content)
### Provider Kinds
- `docker`: load reverse proxies from docker
values:
- `FROM_ENV`: value from environment (`DOCKER_HOST`)
- full url to docker host (i.e. `tcp://host:2375`)
- `file`: load reverse proxies from provider file
value: relative path of file to `config/`
[🔼Back to top](#table-of-content)
### Provider File
Fields are same as [docker labels](docs/docker.md#labels) starting from `scheme`
See [providers.example.yml](providers.example.yml) for examples
[🔼Back to top](#table-of-content)
### Supported DNS Challenge Providers
- Cloudflare
- `auth_token`: your zone API token
Follow [this guide](https://cloudkul.com/blog/automcatic-renew-and-generate-ssl-on-your-website-using-lego-client/) to create a new token with `Zone.DNS` read and edit permissions
- CloudDNS
- `client_id`
- `email`
- `password`
- DuckDNS (thanks [earvingad](https://github.com/earvingad))
- `token`: DuckDNS Token
To add more provider support, see [this](docs/add_dns_provider.md)
[🔼Back to top](#table-of-content)
## Troubleshooting
Q: How to fix when it shows "no matching route for subdomain \<subdomain>"?
A: Make sure the container is running, and \<subdomain> matches any container name / alias
[🔼Back to top](#table-of-content)
## Benchmarks
Benchmarked with `wrk` connecting `traefik/whoami`'s `/bench` endpoint
Remote benchmark (client running wrk and `go-proxy` server are different devices)
- Direct connection
```shell
root@yusing-pc:~# wrk -t 10 -c 200 -d 10s -H "Host: bench.6uo.me" --latency http://10.0.100.3:8003/bench
Running 10s test @ http://10.0.100.3:8003/bench
10 threads and 200 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 94.75ms 199.92ms 1.68s 91.27%
Req/Sec 4.24k 1.79k 18.79k 72.13%
Latency Distribution
50% 1.14ms
75% 120.23ms
90% 245.63ms
99% 1.03s
423444 requests in 10.10s, 50.88MB read
Socket errors: connect 0, read 0, write 0, timeout 29
Requests/sec: 41926.32
Transfer/sec: 5.04MB
```
- With reverse proxy
```shell
root@yusing-pc:~# wrk -t 10 -c 200 -d 10s -H "Host: bench.6uo.me" --latency http://10.0.1.7/bench
Running 10s test @ http://10.0.1.7/bench
10 threads and 200 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 79.35ms 169.79ms 1.69s 92.55%
Req/Sec 4.27k 1.90k 19.61k 75.81%
Latency Distribution
50% 1.12ms
75% 105.66ms
90% 200.22ms
99% 814.59ms
409836 requests in 10.10s, 49.25MB read
Socket errors: connect 0, read 0, write 0, timeout 18
Requests/sec: 40581.61
Transfer/sec: 4.88MB
```
Local benchmark (client running wrk and `go-proxy` server are under same proxmox host but different LXCs)
- Direct connection
```shell
root@http-benchmark-client:~# wrk -t 10 -c 200 -d 10s --latency http://10.0.100.1/bench
Running 10s test @ http://10.0.100.1/bench
10 threads and 200 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 434.08us 539.35us 8.76ms 85.28%
Req/Sec 67.71k 6.31k 87.21k 71.20%
Latency Distribution
50% 153.00us
75% 646.00us
90% 1.18ms
99% 2.38ms
6739591 requests in 10.01s, 809.85MB read
Requests/sec: 673608.15
Transfer/sec: 80.94MB
```
- With `go-proxy` reverse proxy
```shell
root@http-benchmark-client:~# wrk -t 10 -c 200 -d 10s -H "Host: bench.6uo.me" --latency http://10.0.1.7/bench
Running 10s test @ http://10.0.1.7/bench
10 threads and 200 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 1.23ms 0.96ms 11.43ms 72.09%
Req/Sec 17.48k 1.76k 21.48k 70.20%
Latency Distribution
50% 0.98ms
75% 1.76ms
90% 2.54ms
99% 4.24ms
1739079 requests in 10.01s, 208.97MB read
Requests/sec: 173779.44
Transfer/sec: 20.88MB
```
- With `traefik-v3`
```shell
root@traefik-benchmark:~# wrk -t10 -c200 -d10s -H "Host: benchmark.whoami" --latency http://127.0.0.1:8000/bench
Running 10s test @ http://127.0.0.1:8000/bench
10 threads and 200 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 2.81ms 10.36ms 180.26ms 98.57%
Req/Sec 11.35k 1.74k 13.76k 85.54%
Latency Distribution
50% 1.59ms
75% 2.27ms
90% 3.17ms
99% 37.91ms
1125723 requests in 10.01s, 109.50MB read
Requests/sec: 112499.59
Transfer/sec: 10.94MB
```
[🔼Back to top](#table-of-content)
## Known issues
- Cert "renewal" is actually obtaining a new cert instead of renewing the existing one
[🔼Back to top](#table-of-content)
## Memory usage
It takes ~15 MB for 50 proxy entries
[🔼Back to top](#table-of-content)
## Build it yourself
1. Clone the repository `git clone https://github.com/yusing/godoxy --depth=1`
1. Install / Upgrade [go (>=1.22)](https://go.dev/doc/install) and `make` if not already
2. Install / Upgrade [go (>=1.22)](https://go.dev/doc/install) and `make` if not already
2. Clear cache if you have built this before (go < 1.22) with `go clean -cache`
3. Clear cache if you have built this before (go < 1.22) with `go clean -cache`
3. get dependencies with `make get`
4. get dependencies with `make get`
4. build binary with `make build`
5. build binary with `make build`
5. start your container with `make up` (docker) or `bin/go-proxy` (binary)
[🔼Back to top](#table-of-content)

View File

@@ -1,169 +0,0 @@
<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/godoxy)
[![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)
![Demo](https://img.shields.io/website?url=https%3A%2F%2Fgodoxy.demo.6uo.me&label=Demo&link=https%3A%2F%2Fgodoxy.demo.6uo.me)
[![Discord](https://dcbadge.limes.pink/api/server/umReR62nRd?style=flat)](https://discord.gg/umReR62nRd)
輕量、易用、 [高效能](https://github.com/yusing/godoxy/wiki/Benchmarks),且帶有主頁和配置面板的反向代理
完整文檔請查閱 **[Wiki](https://github.com/yusing/godoxy/wiki)**(暫未有中文翻譯)
<a href="README.md">EN</a> | **中文**
<img src="https://github.com/user-attachments/assets/4bb371f4-6e4c-425c-89b2-b9e962bdd46f" style="max-width: 650">
</div>
## 目錄
<!-- TOC -->
- [GoDoxy](#godoxy)
- [目錄](#目錄)
- [運行示例](#運行示例)
- [主要特點](#主要特點)
- [前置需求](#前置需求)
- [安裝](#安裝)
- [手動安裝](#手動安裝)
- [資料夾結構](#資料夾結構)
- [截圖](#截圖)
- [閒置休眠](#閒置休眠)
- [監控](#監控)
- [自行編譯](#自行編譯)
## 運行示例
<https://godoxy.demo.6uo.me>
[![Deployed on Zeabur](https://zeabur.com/deployed-on-zeabur-dark.svg)](https://zeabur.com/referral?referralCode=yusing&utm_source=yusing&utm_campaign=oss)
## 主要特點
- 容易使用
- 輕鬆配置
- 簡單的多節點設置
- 錯誤訊息清晰詳細,易於排除故障
- 自動 SSL 憑證管理(參見 [支援的 DNS-01 驗證提供商](https://github.com/yusing/godoxy/wiki/Supported-DNS%E2%80%9001-Providers)
- 自動配置 Docker 容器
- 容器狀態/配置文件變更時自動熱重載
- **閒置休眠**在閒置時停止容器有流量時喚醒_可選參見[截圖](#閒置休眠)_
- OpenID Connect輕鬆實現單點登入
- HTTP(s) 反向代理和TCP 和 UDP 埠轉發
- [HTTP 中介軟體](https://github.com/yusing/godoxy/wiki/Middlewares) 和 [自定義錯誤頁面](https://github.com/yusing/godoxy/wiki/Middlewares#custom-error-pages)
- **網頁介面,具有應用儀表板和配置編輯器**
- 支援 linux/amd64、linux/arm64
- 使用 **[Go](https://go.dev)** 編寫
[🔼回到頂部](#目錄)
## 前置需求
設置 DNS 記錄指向運行 `GoDoxy` 的機器,例如:
- A 記錄:`*.y.z` -> `10.0.10.1`
- AAAA 記錄:`*.y.z` -> `::ffff:a00:a01`
## 安裝
> [!NOTE]
> GoDoxy 僅在 `host` 網路模式下運作,請勿更改。
>
> 如需更改監聽埠,請修改 `.env`。
1. 準備一個新目錄用於 docker compose 和配置文件。
2. 在目錄內運行安裝腳本,或[手動安裝](#手動安裝)
```shell
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/yusing/godoxy/main/scripts/setup.sh)"
```
3. 現在可以在 WebUI `https://godoxy.yourdomain.com` 進行額外配置
[🔼回到頂部](#目錄)
### 手動安裝
1. 建立 `config` 目錄,然後將 `config.example.yml` 下載到 `config/config.yml`
`mkdir -p config && wget https://raw.githubusercontent.com/yusing/godoxy/main/config.example.yml -O config/config.yml`
2. 將 `.env.example` 下載到 `.env`
`wget https://raw.githubusercontent.com/yusing/godoxy/main/.env.example -O .env`
3. 將 `compose.example.yml` 下載到 `compose.yml`
`wget https://raw.githubusercontent.com/yusing/godoxy/main/compose.example.yml -O compose.yml`
### 資料夾結構
```shell
├── certs
│ ├── cert.crt
│ └── priv.key
├── compose.yml
├── config
│ ├── config.yml
│ ├── middlewares
│ │ ├── middleware1.yml
│ │ ├── middleware2.yml
│ ├── provider1.yml
│ └── provider2.yml
├── data
│ ├── metrics # metrics data
│ │ ├── uptime.json
│ │ └── system_info.json
└── .env
```
## 截圖
### 閒置休眠
![閒置休眠](screenshots/idlesleeper.webp)
[🔼回到頂部](#目錄)
### 監控
<div align="center">
<table>
<tr>
<td align="center"><img src="screenshots/uptime.png" alt="Uptime Monitor" width="250"/></td>
<td align="center"><img src="screenshots/docker-logs.jpg" alt="Docker Logs" width="250"/></td>
<td align="center"><img src="screenshots/docker.jpg" alt="Server Overview" width="250"/></td>
</tr>
<tr>
<td align="center"><b>運行時間監控</b></td>
<td align="center"><b>Docker 日誌</b></td>
<td align="center"><b>伺服器概覽</b></td>
</tr>
<tr>
<td align="center"><img src="screenshots/system-monitor.jpg" alt="System Monitor" width="250"/></td>
<td align="center"><img src="screenshots/system-info-graphs.jpg" alt="Graphs" width="250"/></td>
</tr>
<tr>
<td align="center"><b>系統監控</b></td>
<td align="center"><b>圖表</b></td>
</tr>
</table>
</div>
## 自行編譯
1. 克隆儲存庫 `git clone https://github.com/yusing/godoxy --depth=1`
2. 如果尚未安裝,請安裝/升級 [go (>=1.22)](https://go.dev/doc/install) 和 `make`
3. 如果之前編譯過go < 1.22),請使用 `go clean -cache` 清除快取
4. 使用 `make get` 獲取依賴
5. 使用 `make build` 編譯二進制檔案
[🔼回到頂部](#目錄)

View File

@@ -1,61 +0,0 @@
package main
import (
"os"
"github.com/yusing/go-proxy/agent/pkg/agent"
"github.com/yusing/go-proxy/agent/pkg/env"
"github.com/yusing/go-proxy/agent/pkg/server"
"github.com/yusing/go-proxy/internal/gperr"
"github.com/yusing/go-proxy/internal/logging"
"github.com/yusing/go-proxy/internal/logging/memlogger"
"github.com/yusing/go-proxy/internal/metrics/systeminfo"
"github.com/yusing/go-proxy/internal/task"
"github.com/yusing/go-proxy/pkg"
)
func main() {
logging.InitLogger(os.Stderr, memlogger.GetMemLogger())
ca := &agent.PEMPair{}
err := ca.Load(env.AgentCACert)
if err != nil {
gperr.LogFatal("init CA error", err)
}
caCert, err := ca.ToTLSCert()
if err != nil {
gperr.LogFatal("init CA error", err)
}
srv := &agent.PEMPair{}
srv.Load(env.AgentSSLCert)
if err != nil {
gperr.LogFatal("init SSL error", err)
}
srvCert, err := srv.ToTLSCert()
if err != nil {
gperr.LogFatal("init SSL error", err)
}
logging.Info().Msgf("GoDoxy Agent version %s", pkg.GetVersion())
logging.Info().Msgf("Agent name: %s", env.AgentName)
logging.Info().Msgf("Agent port: %d", env.AgentPort)
logging.Info().Msg(`
Tips:
1. To change the agent name, you can set the AGENT_NAME environment variable.
2. To change the agent port, you can set the AGENT_PORT environment variable.
`)
t := task.RootTask("agent", false)
opts := server.Options{
CACert: caCert,
ServerCert: srvCert,
Port: env.AgentPort,
}
server.StartAgentServer(t, opts)
systeminfo.Poller.Start()
task.WaitExit(3)
}

View File

@@ -1,16 +0,0 @@
package agent
import (
"github.com/yusing/go-proxy/internal/utils/pool"
)
type agents struct{ pool.Pool[*AgentConfig] }
var Agents = agents{pool.New[*AgentConfig]("agents")}
func (agents agents) Get(agentAddrOrDockerHost string) (*AgentConfig, bool) {
if !IsDockerHostAgent(agentAddrOrDockerHost) {
return agents.Base().Load(agentAddrOrDockerHost)
}
return agents.Base().Load(GetAgentAddrFromDockerHost(agentAddrOrDockerHost))
}

View File

@@ -1,23 +0,0 @@
package agent
import (
"bytes"
"text/template"
)
var (
installScript = `AGENT_NAME="{{.Name}}" \
AGENT_PORT="{{.Port}}" \
AGENT_CA_CERT="{{.CACert}}" \
AGENT_SSL_CERT="{{.SSLCert}}" \
bash -c "$(curl -fsSL https://raw.githubusercontent.com/yusing/go-proxy/main/scripts/install-agent.sh)"`
installScriptTemplate = template.Must(template.New("install.sh").Parse(installScript))
)
func (c *AgentEnvConfig) Generate() (string, error) {
buf := bytes.NewBuffer(make([]byte, 0, 4096))
if err := installScriptTemplate.Execute(buf, c); err != nil {
return "", err
}
return buf.String(), nil
}

View File

@@ -1,193 +0,0 @@
package agent
import (
"context"
"crypto/tls"
"crypto/x509"
"net"
"net/http"
"net/url"
"os"
"strings"
"time"
"github.com/yusing/go-proxy/agent/pkg/certs"
"github.com/yusing/go-proxy/internal/gperr"
"github.com/yusing/go-proxy/internal/net/gphttp"
"github.com/yusing/go-proxy/pkg"
)
type AgentConfig struct {
Addr string
httpClient *http.Client
tlsConfig *tls.Config
name string
}
const (
EndpointVersion = "/version"
EndpointName = "/name"
EndpointProxyHTTP = "/proxy/http"
EndpointHealth = "/health"
EndpointLogs = "/logs"
EndpointSystemInfo = "/system_info"
AgentHost = CertsDNSName
APIEndpointBase = "/godoxy/agent"
APIBaseURL = "https://" + AgentHost + APIEndpointBase
DockerHost = "https://" + AgentHost
FakeDockerHostPrefix = "agent://"
FakeDockerHostPrefixLen = len(FakeDockerHostPrefix)
)
var (
AgentURL, _ = url.Parse(APIBaseURL)
HTTPProxyURL, _ = url.Parse(APIBaseURL + EndpointProxyHTTP)
HTTPProxyURLPrefixLen = len(APIEndpointBase + EndpointProxyHTTP)
)
// TestAgentConfig is a helper function to create an AgentConfig for testing purposes.
// Not used in production.
func TestAgentConfig(name string, addr string) *AgentConfig {
return &AgentConfig{
name: name,
Addr: addr,
}
}
func IsDockerHostAgent(dockerHost string) bool {
return strings.HasPrefix(dockerHost, FakeDockerHostPrefix)
}
func GetAgentAddrFromDockerHost(dockerHost string) string {
return dockerHost[FakeDockerHostPrefixLen:]
}
// Key implements pool.Object
func (cfg *AgentConfig) Key() string {
return cfg.Addr
}
func (cfg *AgentConfig) FakeDockerHost() string {
return FakeDockerHostPrefix + cfg.Addr
}
func (cfg *AgentConfig) Parse(addr string) error {
cfg.Addr = addr
return nil
}
func (cfg *AgentConfig) InitWithCerts(ctx context.Context, ca, crt, key []byte) error {
clientCert, err := tls.X509KeyPair(crt, key)
if err != nil {
return err
}
// create tls config
caCertPool := x509.NewCertPool()
ok := caCertPool.AppendCertsFromPEM(ca)
if !ok {
return gperr.New("invalid ca certificate")
}
cfg.tlsConfig = &tls.Config{
Certificates: []tls.Certificate{clientCert},
RootCAs: caCertPool,
ServerName: CertsDNSName,
}
// create transport and http client
cfg.httpClient = cfg.NewHTTPClient()
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
// check agent version
version, _, err := cfg.Fetch(ctx, EndpointVersion)
if err != nil {
return err
}
agentVer := pkg.ParseVersion(string(version))
serverVer := pkg.GetVersion()
if !agentVer.IsEqual(serverVer) {
return gperr.Errorf("agent version mismatch: server: %s, agent: %s", serverVer, agentVer)
}
// get agent name
name, _, err := cfg.Fetch(ctx, EndpointName)
if err != nil {
return err
}
cfg.name = string(name)
return nil
}
func (cfg *AgentConfig) Init(ctx context.Context) gperr.Error {
filepath, ok := certs.AgentCertsFilepath(cfg.Addr)
if !ok {
return gperr.New("invalid agent host").Subject(cfg.Addr)
}
certData, err := os.ReadFile(filepath)
if err != nil {
return gperr.Wrap(err, "failed to read agent certs")
}
ca, crt, key, err := certs.ExtractCert(certData)
if err != nil {
return gperr.Wrap(err, "failed to extract agent certs")
}
return gperr.Wrap(cfg.InitWithCerts(ctx, ca, crt, key))
}
func (cfg *AgentConfig) NewHTTPClient() *http.Client {
return &http.Client{
Transport: cfg.Transport(),
}
}
func (cfg *AgentConfig) Transport() *http.Transport {
return &http.Transport{
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
if addr != AgentHost+":443" {
return nil, &net.AddrError{Err: "invalid address", Addr: addr}
}
if network != "tcp" {
return nil, &net.OpError{Op: "dial", Net: network, Source: nil, Addr: nil}
}
return cfg.DialContext(ctx)
},
TLSClientConfig: cfg.tlsConfig,
}
}
func (cfg *AgentConfig) DialContext(ctx context.Context) (net.Conn, error) {
return gphttp.DefaultDialer.DialContext(ctx, "tcp", cfg.Addr)
}
func (cfg *AgentConfig) IsInitialized() bool {
return cfg.name != ""
}
func (cfg *AgentConfig) Name() string {
return cfg.name
}
func (cfg *AgentConfig) String() string {
return cfg.name + "@" + cfg.Addr
}
// MarshalMap implements pool.Object
func (cfg *AgentConfig) MarshalMap() map[string]any {
return map[string]any{
"name": cfg.Name(),
"addr": cfg.Addr,
}
}

View File

@@ -1,27 +0,0 @@
package agent
import (
"bytes"
"text/template"
_ "embed"
)
var (
//go:embed templates/agent.compose.yml
agentComposeYAML string
agentComposeYAMLTemplate = template.Must(template.New("agent.compose.yml").Parse(agentComposeYAML))
)
const (
DockerImageProduction = "ghcr.io/yusing/godoxy-agent:latest"
DockerImageNightly = "ghcr.io/yusing/godoxy-agent:nightly"
)
func (c *AgentComposeConfig) Generate() (string, error) {
buf := bytes.NewBuffer(make([]byte, 0, 1024))
if err := agentComposeYAMLTemplate.Execute(buf, c); err != nil {
return "", err
}
return buf.String(), nil
}

View File

@@ -1,17 +0,0 @@
package agent
type (
AgentEnvConfig struct {
Name string
Port int
CACert string
SSLCert string
}
AgentComposeConfig struct {
Image string
*AgentEnvConfig
}
Generator interface {
Generate() (string, error)
}
)

View File

@@ -1,139 +0,0 @@
package agent
import (
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/base64"
"encoding/pem"
"errors"
"math/big"
"strings"
"time"
)
const (
CertsDNSName = "godoxy.agent"
KeySize = 2048
)
func toPEMPair(certDER []byte, key *rsa.PrivateKey) *PEMPair {
return &PEMPair{
Cert: pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}),
Key: pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}),
}
}
func b64Encode(data []byte) string {
return base64.StdEncoding.EncodeToString(data)
}
func b64Decode(data string) ([]byte, error) {
return base64.StdEncoding.DecodeString(data)
}
type PEMPair struct {
Cert, Key []byte
}
func (p *PEMPair) String() string {
return b64Encode(p.Cert) + ";" + b64Encode(p.Key)
}
func (p *PEMPair) Load(data string) (err error) {
parts := strings.Split(data, ";")
if len(parts) != 2 {
return errors.New("invalid PEM pair")
}
p.Cert, err = b64Decode(parts[0])
if err != nil {
return err
}
p.Key, err = b64Decode(parts[1])
if err != nil {
return err
}
return nil
}
func (p *PEMPair) ToTLSCert() (*tls.Certificate, error) {
cert, err := tls.X509KeyPair(p.Cert, p.Key)
return &cert, err
}
func NewAgent() (ca, srv, client *PEMPair, err error) {
// Create the CA's certificate
caTemplate := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{
Organization: []string{"GoDoxy"},
CommonName: CertsDNSName,
},
NotBefore: time.Now(),
NotAfter: time.Now().AddDate(1000, 0, 0), // 1000 years
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
BasicConstraintsValid: true,
IsCA: true,
}
caKey, err := rsa.GenerateKey(rand.Reader, KeySize)
if err != nil {
return nil, nil, nil, err
}
caDER, err := x509.CreateCertificate(rand.Reader, caTemplate, caTemplate, &caKey.PublicKey, caKey)
if err != nil {
return nil, nil, nil, err
}
ca = toPEMPair(caDER, caKey)
// Generate a new private key for the server certificate
serverKey, err := rsa.GenerateKey(rand.Reader, KeySize)
if err != nil {
return nil, nil, nil, err
}
srvTemplate := &x509.Certificate{
SerialNumber: big.NewInt(2),
Issuer: caTemplate.Subject,
Subject: caTemplate.Subject,
DNSNames: []string{CertsDNSName},
NotBefore: time.Now(),
NotAfter: time.Now().AddDate(1000, 0, 0), // Add validity period
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
}
srvCertDER, err := x509.CreateCertificate(rand.Reader, srvTemplate, caTemplate, &serverKey.PublicKey, caKey)
if err != nil {
return nil, nil, nil, err
}
srv = toPEMPair(srvCertDER, serverKey)
clientKey, err := rsa.GenerateKey(rand.Reader, KeySize)
if err != nil {
return nil, nil, nil, err
}
clientTemplate := &x509.Certificate{
SerialNumber: big.NewInt(3),
Issuer: caTemplate.Subject,
Subject: caTemplate.Subject,
DNSNames: []string{CertsDNSName},
NotBefore: time.Now(),
NotAfter: time.Now().AddDate(1000, 0, 0),
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
}
clientCertDER, err := x509.CreateCertificate(rand.Reader, clientTemplate, caTemplate, &clientKey.PublicKey, caKey)
if err != nil {
return nil, nil, nil, err
}
client = toPEMPair(clientCertDER, clientKey)
return
}

View File

@@ -1,91 +0,0 @@
package agent
import (
"crypto/tls"
"crypto/x509"
"fmt"
"net/http"
"net/http/httptest"
"testing"
. "github.com/yusing/go-proxy/internal/utils/testing"
)
func TestNewAgent(t *testing.T) {
ca, srv, client, err := NewAgent()
ExpectNoError(t, err)
ExpectTrue(t, ca != nil)
ExpectTrue(t, srv != nil)
ExpectTrue(t, client != nil)
}
func TestPEMPair(t *testing.T) {
ca, srv, client, err := NewAgent()
ExpectNoError(t, err)
for i, p := range []*PEMPair{ca, srv, client} {
t.Run(fmt.Sprintf("load-%d", i), func(t *testing.T) {
var pp PEMPair
err := pp.Load(p.String())
ExpectNoError(t, err)
ExpectEqual(t, p.Cert, pp.Cert)
ExpectEqual(t, p.Key, pp.Key)
})
}
}
func TestPEMPairToTLSCert(t *testing.T) {
ca, srv, client, err := NewAgent()
ExpectNoError(t, err)
for i, p := range []*PEMPair{ca, srv, client} {
t.Run(fmt.Sprintf("toTLSCert-%d", i), func(t *testing.T) {
cert, err := p.ToTLSCert()
ExpectNoError(t, err)
ExpectTrue(t, cert != nil)
})
}
}
func TestServerClient(t *testing.T) {
ca, srv, client, err := NewAgent()
ExpectNoError(t, err)
srvTLS, err := srv.ToTLSCert()
ExpectNoError(t, err)
ExpectTrue(t, srvTLS != nil)
clientTLS, err := client.ToTLSCert()
ExpectNoError(t, err)
ExpectTrue(t, clientTLS != nil)
caPool := x509.NewCertPool()
ExpectTrue(t, caPool.AppendCertsFromPEM(ca.Cert))
srvTLSConfig := &tls.Config{
Certificates: []tls.Certificate{*srvTLS},
ClientCAs: caPool,
ClientAuth: tls.RequireAndVerifyClientCert,
}
clientTLSConfig := &tls.Config{
Certificates: []tls.Certificate{*clientTLS},
RootCAs: caPool,
ServerName: CertsDNSName,
}
server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
server.TLS = srvTLSConfig
server.StartTLS()
defer server.Close()
httpClient := &http.Client{
Transport: &http.Transport{TLSClientConfig: clientTLSConfig},
}
resp, err := httpClient.Get(server.URL)
ExpectNoError(t, err)
ExpectEqual(t, resp.StatusCode, http.StatusOK)
}

View File

@@ -1,49 +0,0 @@
package agent
import (
"context"
"io"
"net/http"
"github.com/coder/websocket"
)
func (cfg *AgentConfig) Do(ctx context.Context, method, endpoint string, body io.Reader) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, method, APIBaseURL+endpoint, body)
if err != nil {
return nil, err
}
return cfg.httpClient.Do(req)
}
func (cfg *AgentConfig) Forward(req *http.Request, endpoint string) ([]byte, int, error) {
req = req.WithContext(req.Context())
req.URL.Host = AgentHost
req.URL.Scheme = "https"
req.URL.Path = APIEndpointBase + endpoint
req.RequestURI = ""
resp, err := cfg.httpClient.Do(req)
if err != nil {
return nil, 0, err
}
defer resp.Body.Close()
data, _ := io.ReadAll(resp.Body)
return data, resp.StatusCode, nil
}
func (cfg *AgentConfig) Fetch(ctx context.Context, endpoint string) ([]byte, int, error) {
resp, err := cfg.Do(ctx, "GET", endpoint, nil)
if err != nil {
return nil, 0, err
}
defer resp.Body.Close()
data, _ := io.ReadAll(resp.Body)
return data, resp.StatusCode, nil
}
func (cfg *AgentConfig) Websocket(ctx context.Context, endpoint string) (*websocket.Conn, *http.Response, error) {
return websocket.Dial(ctx, APIBaseURL+endpoint, &websocket.DialOptions{
HTTPClient: cfg.NewHTTPClient(),
Host: AgentHost,
})
}

View File

@@ -1,14 +0,0 @@
services:
agent:
image: "{{.Image}}"
container_name: godoxy-agent
restart: always
network_mode: host # do not change this
environment:
AGENT_NAME: "{{.Name}}"
AGENT_PORT: "{{.Port}}"
AGENT_CA_CERT: "{{.CACert}}"
AGENT_SSL_CERT: "{{.SSLCert}}"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./data:/app/data

View File

@@ -1,27 +0,0 @@
package agentproxy
import (
"net/http"
"strconv"
)
const (
HeaderXProxyHost = "X-Proxy-Host"
HeaderXProxyHTTPS = "X-Proxy-Https"
HeaderXProxySkipTLSVerify = "X-Proxy-Skip-Tls-Verify"
HeaderXProxyResponseHeaderTimeout = "X-Proxy-Response-Header-Timeout"
)
type AgentProxyHeaders struct {
Host string
IsHTTPS bool
SkipTLSVerify bool
ResponseHeaderTimeout int
}
func SetAgentProxyHeaders(r *http.Request, headers *AgentProxyHeaders) {
r.Header.Set(HeaderXProxyHost, headers.Host)
r.Header.Set(HeaderXProxyHTTPS, strconv.FormatBool(headers.IsHTTPS))
r.Header.Set(HeaderXProxySkipTLSVerify, strconv.FormatBool(headers.SkipTLSVerify))
r.Header.Set(HeaderXProxyResponseHeaderTimeout, strconv.Itoa(headers.ResponseHeaderTimeout))
}

View File

@@ -1,84 +0,0 @@
package certs
import (
"archive/zip"
"bytes"
"io"
"path/filepath"
"github.com/yusing/go-proxy/internal/common"
"github.com/yusing/go-proxy/internal/utils/strutils"
)
func writeFile(zipWriter *zip.Writer, name string, data []byte) error {
w, err := zipWriter.CreateHeader(&zip.FileHeader{
Name: name,
Method: zip.Store,
})
if err != nil {
return err
}
_, err = w.Write(data)
return err
}
func readFile(f *zip.File) ([]byte, error) {
r, err := f.Open()
if err != nil {
return nil, err
}
defer r.Close()
return io.ReadAll(r)
}
func ZipCert(ca, crt, key []byte) ([]byte, error) {
data := bytes.NewBuffer(make([]byte, 0, 6144))
zipWriter := zip.NewWriter(data)
defer zipWriter.Close()
if err := writeFile(zipWriter, "ca.pem", ca); err != nil {
return nil, err
}
if err := writeFile(zipWriter, "cert.pem", crt); err != nil {
return nil, err
}
if err := writeFile(zipWriter, "key.pem", key); err != nil {
return nil, err
}
if err := zipWriter.Close(); err != nil {
return nil, err
}
return data.Bytes(), nil
}
func isValidAgentHost(host string) bool {
return strutils.IsValidFilename(host + ".zip")
}
func AgentCertsFilepath(host string) (filepathOut string, ok bool) {
if !isValidAgentHost(host) {
return "", false
}
return filepath.Join(common.CertsDir, host+".zip"), true
}
func ExtractCert(data []byte) (ca, crt, key []byte, err error) {
zipReader, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
return nil, nil, nil, err
}
for _, file := range zipReader.File {
switch file.Name {
case "ca.pem":
ca, err = readFile(file)
case "cert.pem":
crt, err = readFile(file)
case "key.pem":
key, err = readFile(file)
}
if err != nil {
return nil, nil, nil, err
}
}
return ca, crt, key, nil
}

View File

@@ -1,19 +0,0 @@
package certs
import (
"testing"
. "github.com/yusing/go-proxy/internal/utils/testing"
)
func TestZipCert(t *testing.T) {
ca, crt, key := []byte("test1"), []byte("test2"), []byte("test3")
zipData, err := ZipCert(ca, crt, key)
ExpectNoError(t, err)
ca2, crt2, key2, err := ExtractCert(zipData)
ExpectNoError(t, err)
ExpectEqual(t, ca, ca2)
ExpectEqual(t, crt, crt2)
ExpectEqual(t, key, key2)
}

24
agent/pkg/env/env.go vendored
View File

@@ -1,24 +0,0 @@
package env
import (
"os"
"github.com/yusing/go-proxy/internal/common"
)
func DefaultAgentName() string {
name, err := os.Hostname()
if err != nil {
return "agent"
}
return name
}
var (
AgentName = common.GetEnvString("AGENT_NAME", DefaultAgentName())
AgentPort = common.GetEnvInt("AGENT_PORT", 8890)
AgentSkipClientCertCheck = common.GetEnvBool("AGENT_SKIP_CLIENT_CERT_CHECK", false)
AgentCACert = common.GetEnvString("AGENT_CA_CERT", "")
AgentSSLCert = common.GetEnvString("AGENT_SSL_CERT", "")
)

View File

@@ -1,77 +0,0 @@
package handler
import (
"fmt"
"net/http"
"net/url"
"os"
"strings"
"github.com/yusing/go-proxy/internal/net/gphttp"
"github.com/yusing/go-proxy/internal/watcher/health"
"github.com/yusing/go-proxy/internal/watcher/health/monitor"
)
var defaultHealthConfig = health.DefaultHealthConfig()
func CheckHealth(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query()
scheme := query.Get("scheme")
if scheme == "" {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
var result *health.HealthCheckResult
var err error
switch scheme {
case "fileserver":
path := query.Get("path")
if path == "" {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
_, err := os.Stat(path)
result = &health.HealthCheckResult{Healthy: err == nil}
if err != nil {
result.Detail = err.Error()
}
case "http", "https": // path is optional
host := query.Get("host")
path := query.Get("path")
if host == "" {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
result, err = monitor.NewHTTPHealthMonitor(&url.URL{
Scheme: scheme,
Host: host,
Path: path,
}, defaultHealthConfig).CheckHealth()
case "tcp", "udp":
host := query.Get("host")
if host == "" {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
hasPort := strings.Contains(host, ":")
port := query.Get("port")
if port != "" && !hasPort {
host = fmt.Sprintf("%s:%s", host, port)
} else {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
result, err = monitor.NewRawHealthMonitor(&url.URL{
Scheme: scheme,
Host: host,
}, defaultHealthConfig).CheckHealth()
}
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
gphttp.RespondJSON(w, r, result)
}

View File

@@ -1,217 +0,0 @@
package handler_test
import (
"net"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"testing"
"github.com/yusing/go-proxy/pkg/json"
"github.com/stretchr/testify/require"
"github.com/yusing/go-proxy/agent/pkg/agent"
"github.com/yusing/go-proxy/agent/pkg/handler"
"github.com/yusing/go-proxy/internal/watcher/health"
)
func TestCheckHealthHTTP(t *testing.T) {
tests := []struct {
name string
setupServer func() *httptest.Server
queryParams map[string]string
expectedStatus int
expectedHealthy bool
}{
{
name: "Valid",
setupServer: func() *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
},
queryParams: map[string]string{
"scheme": "http",
"host": "localhost",
"path": "/",
},
expectedStatus: http.StatusOK,
expectedHealthy: true,
},
{
name: "InvalidQuery",
setupServer: nil,
queryParams: map[string]string{
"scheme": "http",
},
expectedStatus: http.StatusBadRequest,
},
{
name: "ConnectionError",
setupServer: nil,
queryParams: map[string]string{
"scheme": "http",
"host": "localhost:12345",
},
expectedStatus: http.StatusOK,
expectedHealthy: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var server *httptest.Server
if tt.setupServer != nil {
server = tt.setupServer()
defer server.Close()
u, _ := url.Parse(server.URL)
tt.queryParams["scheme"] = u.Scheme
tt.queryParams["host"] = u.Host
tt.queryParams["path"] = u.Path
}
recorder := httptest.NewRecorder()
query := url.Values{}
for key, value := range tt.queryParams {
query.Set(key, value)
}
request := httptest.NewRequest(http.MethodGet, agent.APIEndpointBase+agent.EndpointHealth+"?"+query.Encode(), nil)
handler.CheckHealth(recorder, request)
require.Equal(t, recorder.Code, tt.expectedStatus)
if tt.expectedStatus == http.StatusOK {
var result health.HealthCheckResult
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &result))
require.Equal(t, result.Healthy, tt.expectedHealthy)
}
})
}
}
func TestCheckHealthFileServer(t *testing.T) {
tests := []struct {
name string
path string
expectedStatus int
expectedHealthy bool
expectedDetail string
}{
{
name: "ValidPath",
path: t.TempDir(),
expectedStatus: http.StatusOK,
expectedHealthy: true,
expectedDetail: "",
},
{
name: "InvalidPath",
path: "/invalid",
expectedStatus: http.StatusOK,
expectedHealthy: false,
expectedDetail: "stat /invalid: no such file or directory",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
query := url.Values{}
query.Set("scheme", "fileserver")
query.Set("path", tt.path)
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, agent.APIEndpointBase+agent.EndpointHealth+"?"+query.Encode(), nil)
handler.CheckHealth(recorder, request)
require.Equal(t, recorder.Code, tt.expectedStatus)
var result health.HealthCheckResult
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &result))
require.Equal(t, result.Healthy, tt.expectedHealthy)
require.Equal(t, result.Detail, tt.expectedDetail)
})
}
}
func TestCheckHealthTCPUDP(t *testing.T) {
tcp, err := net.Listen("tcp", "localhost:0")
require.NoError(t, err)
go func() {
conn, err := tcp.Accept()
require.NoError(t, err)
conn.Close()
}()
udp, err := net.ListenPacket("udp", "localhost:0")
require.NoError(t, err)
go func() {
buf := make([]byte, 1024)
n, addr, err := udp.ReadFrom(buf)
require.NoError(t, err)
require.Equal(t, string(buf[:n]), "ping")
_, _ = udp.WriteTo([]byte("pong"), addr)
udp.Close()
}()
tests := []struct {
name string
scheme string
host string
port int
expectedStatus int
expectedHealthy bool
}{
{
name: "ValidTCP",
scheme: "tcp",
host: "localhost",
port: tcp.Addr().(*net.TCPAddr).Port,
expectedStatus: http.StatusOK,
expectedHealthy: true,
},
{
name: "InvalidHost",
scheme: "tcp",
host: "invalid",
port: 8080,
expectedStatus: http.StatusOK,
expectedHealthy: false,
},
{
name: "ValidUDP",
scheme: "udp",
host: "localhost",
port: udp.LocalAddr().(*net.UDPAddr).Port,
expectedStatus: http.StatusOK,
expectedHealthy: true,
},
{
name: "InvalidHost",
scheme: "udp",
host: "invalid",
port: 8080,
expectedStatus: http.StatusOK,
expectedHealthy: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
query := url.Values{}
query.Set("scheme", tt.scheme)
query.Set("host", tt.host)
query.Set("port", strconv.Itoa(tt.port))
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, agent.APIEndpointBase+agent.EndpointHealth+"?"+query.Encode(), nil)
handler.CheckHealth(recorder, request)
require.Equal(t, recorder.Code, tt.expectedStatus)
var result health.HealthCheckResult
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &result))
require.Equal(t, result.Healthy, tt.expectedHealthy)
})
}
}

View File

@@ -1,30 +0,0 @@
package handler
import (
"net/http"
"net/url"
"github.com/docker/docker/client"
"github.com/yusing/go-proxy/internal/common"
"github.com/yusing/go-proxy/internal/docker"
"github.com/yusing/go-proxy/internal/logging"
"github.com/yusing/go-proxy/internal/net/gphttp/reverseproxy"
)
func serviceUnavailable(w http.ResponseWriter, r *http.Request) {
http.Error(w, "docker socket is not available", http.StatusServiceUnavailable)
}
func DockerSocketHandler() http.HandlerFunc {
dockerClient, err := docker.NewClient(common.DockerHostFromEnv)
if err != nil {
logging.Warn().Err(err).Msg("failed to connect to docker client")
return serviceUnavailable
}
rp := reverseproxy.NewReverseProxy("docker", &url.URL{
Scheme: "http",
Host: client.DummyHost,
}, dockerClient.HTTPClient().Transport)
return rp.ServeHTTP
}

View File

@@ -1,49 +0,0 @@
package handler
import (
"fmt"
"io"
"net/http"
"github.com/yusing/go-proxy/agent/pkg/agent"
"github.com/yusing/go-proxy/agent/pkg/env"
v1 "github.com/yusing/go-proxy/internal/api/v1"
"github.com/yusing/go-proxy/internal/logging/memlogger"
"github.com/yusing/go-proxy/internal/metrics/systeminfo"
"github.com/yusing/go-proxy/internal/utils/strutils"
)
type ServeMux struct{ *http.ServeMux }
func (mux ServeMux) HandleMethods(methods, endpoint string, handler http.HandlerFunc) {
for _, m := range strutils.CommaSeperatedList(methods) {
mux.ServeMux.HandleFunc(m+" "+agent.APIEndpointBase+endpoint, handler)
}
}
func (mux ServeMux) HandleFunc(endpoint string, handler http.HandlerFunc) {
mux.ServeMux.HandleFunc(agent.APIEndpointBase+endpoint, handler)
}
type NopWriteCloser struct {
io.Writer
}
func (NopWriteCloser) Close() error {
return nil
}
func NewAgentHandler() http.Handler {
mux := ServeMux{http.NewServeMux()}
mux.HandleFunc(agent.EndpointProxyHTTP+"/{path...}", ProxyHTTP)
mux.HandleMethods("GET", agent.EndpointVersion, v1.GetVersion)
mux.HandleMethods("GET", agent.EndpointName, func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, env.AgentName)
})
mux.HandleMethods("GET", agent.EndpointHealth, CheckHealth)
mux.HandleMethods("GET", agent.EndpointLogs, memlogger.HandlerFunc())
mux.HandleMethods("GET", agent.EndpointSystemInfo, systeminfo.Poller.ServeHTTP)
mux.ServeMux.HandleFunc("/", DockerSocketHandler())
return mux
}

View File

@@ -1,62 +0,0 @@
package handler
import (
"crypto/tls"
"net/http"
"net/url"
"strconv"
"time"
"github.com/yusing/go-proxy/agent/pkg/agent"
"github.com/yusing/go-proxy/agent/pkg/agentproxy"
"github.com/yusing/go-proxy/internal/logging"
"github.com/yusing/go-proxy/internal/net/gphttp"
"github.com/yusing/go-proxy/internal/net/gphttp/reverseproxy"
"github.com/yusing/go-proxy/internal/utils/strutils"
)
func ProxyHTTP(w http.ResponseWriter, r *http.Request) {
host := r.Header.Get(agentproxy.HeaderXProxyHost)
isHTTPS := strutils.ParseBool(r.Header.Get(agentproxy.HeaderXProxyHTTPS))
skipTLSVerify := strutils.ParseBool(r.Header.Get(agentproxy.HeaderXProxySkipTLSVerify))
responseHeaderTimeout, err := strconv.Atoi(r.Header.Get(agentproxy.HeaderXProxyResponseHeaderTimeout))
if err != nil {
responseHeaderTimeout = 0
}
if host == "" {
http.Error(w, "missing required headers", http.StatusBadRequest)
return
}
scheme := "http"
if isHTTPS {
scheme = "https"
}
var transport *http.Transport
if skipTLSVerify {
transport = gphttp.NewTransportWithTLSConfig(&tls.Config{InsecureSkipVerify: true})
} else {
transport = gphttp.NewTransport()
}
if responseHeaderTimeout > 0 {
transport.ResponseHeaderTimeout = time.Duration(responseHeaderTimeout) * time.Second
}
r.URL.Scheme = ""
r.URL.Host = ""
r.URL.Path = r.URL.Path[agent.HTTPProxyURLPrefixLen:] // strip the {API_BASE}/proxy/http prefix
r.RequestURI = r.URL.String()
r.URL.Host = host
r.URL.Scheme = scheme
logging.Debug().Msgf("proxy http request: %s %s", r.Method, r.URL.String())
rp := reverseproxy.NewReverseProxy("agent", &url.URL{
Scheme: scheme,
Host: host,
}, transport)
rp.ServeHTTP(w, r)
}

View File

@@ -1,44 +0,0 @@
package server
import (
"crypto/tls"
"crypto/x509"
"fmt"
"net/http"
"github.com/yusing/go-proxy/agent/pkg/env"
"github.com/yusing/go-proxy/agent/pkg/handler"
"github.com/yusing/go-proxy/internal/logging"
"github.com/yusing/go-proxy/internal/net/gphttp/server"
"github.com/yusing/go-proxy/internal/task"
)
type Options struct {
CACert, ServerCert *tls.Certificate
Port int
}
func StartAgentServer(parent task.Parent, opt Options) {
caCertPool := x509.NewCertPool()
caCertPool.AddCert(opt.CACert.Leaf)
// Configure TLS
tlsConfig := &tls.Config{
Certificates: []tls.Certificate{*opt.ServerCert},
ClientCAs: caCertPool,
ClientAuth: tls.RequireAndVerifyClientCert,
}
if env.AgentSkipClientCertCheck {
tlsConfig.ClientAuth = tls.NoClientCert
}
logger := logging.GetLogger()
agentServer := &http.Server{
Addr: fmt.Sprintf(":%d", opt.Port),
Handler: handler.NewAgentHandler(),
TLSConfig: tlsConfig,
}
server.Start(parent, agentServer, logger)
}

View File

@@ -1,173 +0,0 @@
package main
import (
"encoding/json"
"log"
"os"
"sync"
"github.com/yusing/go-proxy/internal/api/v1/auth"
debugapi "github.com/yusing/go-proxy/internal/api/v1/debug"
"github.com/yusing/go-proxy/internal/api/v1/query"
"github.com/yusing/go-proxy/internal/common"
"github.com/yusing/go-proxy/internal/config"
"github.com/yusing/go-proxy/internal/gperr"
"github.com/yusing/go-proxy/internal/homepage"
"github.com/yusing/go-proxy/internal/logging"
"github.com/yusing/go-proxy/internal/logging/memlogger"
"github.com/yusing/go-proxy/internal/metrics/systeminfo"
"github.com/yusing/go-proxy/internal/metrics/uptime"
"github.com/yusing/go-proxy/internal/net/gphttp/middleware"
"github.com/yusing/go-proxy/internal/route/routes/routequery"
"github.com/yusing/go-proxy/internal/task"
"github.com/yusing/go-proxy/migrations"
"github.com/yusing/go-proxy/pkg"
)
var rawLogger = log.New(os.Stdout, "", 0)
func parallel(fns ...func()) {
var wg sync.WaitGroup
for _, fn := range fns {
wg.Add(1)
go func() {
defer wg.Done()
fn()
}()
}
wg.Wait()
}
func main() {
initProfiling()
if err := migrations.RunMigrations(); err != nil {
gperr.LogFatal("migration error", err)
}
args := pkg.GetArgs(common.MainServerCommandValidator{})
switch args.Command {
case common.CommandReload:
if err := query.ReloadServer(); err != nil {
gperr.LogFatal("server reload error", err)
}
rawLogger.Println("ok")
return
case common.CommandListIcons:
icons, err := homepage.ListAvailableIcons()
if err != nil {
rawLogger.Fatal(err)
}
printJSON(icons)
return
case common.CommandListRoutes:
routes, err := query.ListRoutes()
if err != nil {
log.Printf("failed to connect to api server: %s", err)
log.Printf("falling back to config file")
} else {
printJSON(routes)
return
}
case common.CommandDebugListMTrace:
trace, err := query.ListMiddlewareTraces()
if err != nil {
log.Fatal(err)
}
printJSON(trace)
return
}
if args.Command == common.CommandStart {
logging.InitLogger(os.Stderr, memlogger.GetMemLogger())
logging.Info().Msgf("GoDoxy version %s", pkg.GetVersion())
logging.Trace().Msg("trace enabled")
parallel(
homepage.InitIconListCache,
homepage.InitIconCache,
homepage.InitOverridesConfig,
systeminfo.Poller.Start,
)
if common.APIJWTSecret == nil {
logging.Warn().Msg("API_JWT_SECRET is not set, using random key")
common.APIJWTSecret = common.RandomJWTKey()
}
} else {
logging.DiscardLogger()
}
if args.Command == common.CommandValidate {
data, err := os.ReadFile(common.ConfigPath)
if err == nil {
err = config.Validate(data)
}
if err != nil {
log.Fatal("config error: ", err)
}
log.Print("config OK")
return
}
for _, dir := range common.RequiredDirectories {
prepareDirectory(dir)
}
middleware.LoadComposeFiles()
var cfg *config.Config
var err gperr.Error
if cfg, err = config.Load(); err != nil {
gperr.LogWarn("errors in config", err)
err = nil
}
switch args.Command {
case common.CommandListRoutes:
cfg.StartProxyProviders()
printJSON(routequery.RoutesByAlias())
return
case common.CommandListConfigs:
printJSON(cfg.Value())
return
case common.CommandDebugListEntries:
printJSON(cfg.DumpRoutes())
return
case common.CommandDebugListProviders:
printJSON(cfg.DumpRouteProviders())
return
}
cfg.Start(&config.StartServersOptions{
Proxy: true,
})
if err := auth.Initialize(); err != nil {
logging.Fatal().Err(err).Msg("failed to initialize authentication")
}
// API Handler needs to start after auth is initialized.
cfg.StartServers(&config.StartServersOptions{
API: true,
})
uptime.Poller.Start()
config.WatchChanges()
debugapi.StartServer(cfg)
task.WaitExit(cfg.Value().TimeoutShutdown)
}
func prepareDirectory(dir string) {
if _, err := os.Stat(dir); os.IsNotExist(err) {
if err = os.MkdirAll(dir, 0o755); err != nil {
logging.Fatal().Msgf("failed to create directory %s: %v", dir, err)
}
}
}
func printJSON(obj any) {
j, err := json.MarshalIndent(obj, "", " ")
if err != nil {
logging.Fatal().Err(err).Send()
}
rawLogger.Print(string(j)) // raw output for convenience using "jq"
}

View File

@@ -1,7 +0,0 @@
//go:build !pprof
package main
func initProfiling() {
// no profiling in production
}

View File

@@ -1,20 +0,0 @@
//go:build pprof
package main
import (
"log"
"net/http"
_ "net/http/pprof"
"runtime"
"runtime/debug"
)
func initProfiling() {
runtime.GOMAXPROCS(2)
debug.SetMemoryLimit(100 * 1024 * 1024)
debug.SetMaxStack(15 * 1024 * 1024)
go func() {
log.Println(http.ListenAndServe(":7777", nil))
}()
}

View File

@@ -1,45 +1,45 @@
---
version: '3'
services:
frontend:
image: ghcr.io/yusing/godoxy-frontend:latest
container_name: godoxy-frontend
restart: unless-stopped
network_mode: host # do not change this
env_file: .env
depends_on:
- app
environment:
PORT: ${GODOXY_FRONTEND_PORT:-3000}
# modify below to fit your needs
labels:
proxy.aliases: godoxy
proxy.godoxy.port: ${GODOXY_FRONTEND_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/godoxy:latest
container_name: godoxy
image: ghcr.io/yusing/go-proxy:latest
container_name: go-proxy
restart: always
network_mode: host # do not change this
env_file: .env
networks: # ^also add here
- default
ports:
- 80:80 # http proxy
- 8080:8080 # http panel
# - 443:443 # optional, https proxy
# - 8443:8443 # optional, https panel
# optional, if you declared any tcp/udp proxy, set a range you want to use
# - 20000:20100/tcp
# - 20000:20100/udp
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./config:/app/config
- ./logs:/app/logs
- ./error_pages:/app/error_pages
- ./data:/app/data
# To use autocert, certs will be stored in "./certs".
# You can also use a docker volume to store it
- ./certs:/app/certs
# if local docker provider is used
- /var/run/docker.sock:/var/run/docker.sock:ro
# use existing certificate
# - /path/to/cert.pem:/app/certs/cert.crt:ro
# - /path/to/privkey.pem:/app/certs/priv.key:ro
# 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
# store autocert obtained cert
# - ./certs:/app/certs
# workaround for "lookup: no such host"
# dns:
# - 127.0.0.1
# if you have container running in "host" network mode
# extra_hosts:
# - host.docker.internal:host-gateway
logging:
driver: 'json-file'
options:
max-file: '1'
max-size: 128k
networks: # ^you may add other external networks
default:
driver: bridge

View File

@@ -1,91 +1,21 @@
# Autocert (choose one below and uncomment to enable)
#
# 1. use existing cert
# autocert:
# provider: local
# 2. cloudflare
# autocert:
# provider: cloudflare
# 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, see https://github.com/yusing/godoxy/wiki/Supported-DNS%E2%80%9001-Providers#supported-dns-01-providers
entrypoint:
# 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
# below enables access log
access_log:
format: combined
path: /app/logs/entrypoint.log
# Autocert (uncomment to enable)
# autocert: # (optional, if you need autocert feature)
# email: "user@domain.com" # (required) email for acme certificate
# domains: # (required)
# - "*.y.z" # domain for acme certificate, use wild card to allow all subdomains
# provider: cloudflare # (required) dns challenge provider (string)
# options: # provider specific options
# auth_token: "YOUR_ZONE_API_TOKEN"
providers:
# include files are standalone yaml files under `config/` directory
#
# include:
# - file1.yml
# - file2.yml
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
#
# local!: $DOCKER_HOST
#
# add more docker providers if needed
local:
kind: docker
# for value format, see https://docs.docker.com/reference/cli/dockerd/
#
# remote-1: tcp://10.0.2.1:2375
# remote-2: ssh://root:1234@10.0.2.2
# i.e. FROM_ENV, ssh://user@10.0.1.1:22, tcp://10.0.2.1:2375
value: FROM_ENV
providers:
kind: file
value: providers.yml
# notification providers (notify when service health changes)
#
# notification:
# - name: gotify
# provider: gotify
# url: https://gotify.domain.tld
# token: abcd
# - name: discord
# provider: webhook
# url: https://discord.com/api/webhooks/...
# template: discord # this means use payload template from internal/notif/templates/discord.json
# Check https://github.com/yusing/godoxy/wiki/Certificates-and-domain-matching#domain-matching
# for explaination of `match_domains`
#
# match_domains:
# - my.site
# - node1.my.app
# homepage config
homepage:
# use default app categories detected from alias or docker image name
use_default_categories: true
# Below are fixed options (non hot-reloadable)
# timeout for shutdown (in seconds)
timeout_shutdown: 5
# Fixed options (optional, non hot-reloadable)
# timeout_shutdown: 5
# redirect_to_https: false

41
docs/add_dns_provider.md Normal file
View File

@@ -0,0 +1,41 @@
# Adding provider support
## **CloudDNS** as an example
1. Fork this repo, modify [autocert.go](../src/go-proxy/autocert.go#L305)
```go
var providersGenMap = map[string]ProviderGenerator{
"cloudflare": providerGenerator(cloudflare.NewDefaultConfig, cloudflare.NewDNSProviderConfig),
// add here, i.e.
"clouddns": providerGenerator(clouddns.NewDefaultConfig, clouddns.NewDNSProviderConfig),
}
```
2. Go to [https://go-acme.github.io/lego/dns/clouddns](https://go-acme.github.io/lego/dns/clouddns/) and check for required config
3. Build `go-proxy` with `make build`
4. Set required config in `config.yml` `autocert` -> `options` section
```shell
# From https://go-acme.github.io/lego/dns/clouddns/
CLOUDDNS_CLIENT_ID=bLsdFAks23429841238feb177a572aX \
CLOUDDNS_EMAIL=you@example.com \
CLOUDDNS_PASSWORD=b9841238feb177a84330f \
lego --email you@example.com --dns clouddns --domains my.example.org run
```
Should turn into:
```yaml
autocert:
...
options:
client_id: bLsdFAks23429841238feb177a572aX
email: you@example.com
password: b9841238feb177a84330f
```
5. Run with `GOPROXY_NO_SCHEMA_VALIDATION=1` and test if it works
6. Commit and create pull request

59
docs/binary.md Normal file
View File

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

365
docs/docker.md Normal file
View File

@@ -0,0 +1,365 @@
# Docker container guide
## Table of content
<!-- TOC -->
- [Table of content](#table-of-content)
- [Setup](#setup)
- [Labels](#labels)
- [Labels (docker specific)](#labels-docker-specific)
- [Troubleshooting](#troubleshooting)
- [Docker compose examples](#docker-compose-examples)
- [Local docker provider in bridge network](#local-docker-provider-in-bridge-network)
- [Remote docker provider](#remote-docker-provider)
- [Explaination](#explaination)
- [Remote setup](#remote-setup)
- [Proxy setup](#proxy-setup)
- [Local docker provider in host network](#local-docker-provider-in-host-network)
- [Proxy setup](#proxy-setup)
- [Services URLs for above examples](#services-urls-for-above-examples)
<!-- /TOC -->
## Setup
1. Install `wget` if not already
2. Run setup script
`bash <(wget -qO- https://6uo.me/go-proxy-setup-docker)`
What it does:
- Create required directories
- Setup `config.yml` and `compose.yml`
3. Verify folder structure and then `cd go-proxy`
```plain
go-proxy
├── certs
├── compose.yml
└── config
├── config.yml
└── providers.yml
```
4. Enable HTTPs _(optional)_
- To use autocert feature
- completing `autocert` section in `config/config.yml`
- mount `certs/` to `/app/certs` to store obtained certs
- To use existing certificate
mount your wildcard (`*.y.z`) SSL cert
- cert / chain / fullchain -> `/app/certs/cert.crt`
- private key -> `/app/certs/priv.key`
5. Modify `compose.yml` fit your needs
Add networks to make sure it is in the same network with other containers, or make sure `proxy.<alias>.host` is reachable
6. Run `docker compose up -d` to start the container
7. Start editing config files in `http://<ip>:8080`
[🔼Back to top](#table-of-content)
## Labels
- `proxy.aliases`: comma separated aliases for subdomain matching
- default: container name
- `proxy.*.<field>`: wildcard label for all aliases
Below labels has a **`proxy.<alias>.`** prefix (i.e. `proxy.nginx.scheme: http`)
- `scheme`: proxy protocol
- default: `http`
- allowed: `http`, `https`, `tcp`, `udp`
- `host`: proxy host
- default: `container_name`
- `port`: proxy port
- default: first expose port (declared in `Dockerfile` or `docker-compose.yml`)
- `http(s)`: number in range og `0 - 65535`
- `tcp/udp`: `[<listeningPort>:]<targetPort>`
- `listeningPort`: number, when it is omitted (not suggested), a free port starting from 20000 will be used.
- `targetPort`: number, or predefined names (see [constants.go:14](src/go-proxy/constants.go#L14))
- `no_tls_verify`: whether skip tls verify when scheme is https
- default: `false`
- `path`: proxy path _(http(s) proxy only)_
- default: empty
- `path_mode`: mode for path handling
- default: empty
- allowed: empty, `forward`, `sub`
- `empty`: remove path prefix from URL when proxying
1. apps.y.z/webdav -> webdav:80
2. apps.y.z./webdav/path/to/file -> webdav:80/path/to/file
- `forward`: path remain unchanged
1. apps.y.z/webdav -> webdav:80/webdav
2. apps.y.z./webdav/path/to/file -> webdav:80/webdav/path/to/file
- `sub`: **(experimental)** remove path prefix from URL and also append path to HTML link attributes (`src`, `href` and `action`) and Javascript `fetch(url)` by response body substitution
e.g. apps.y.z/app1 -> webdav:80, `href="/app1/path/to/file"` -> `href="/path/to/file"`
- `set_headers`: a list of header to set, (key:value, one by line)
Duplicated keys will be treated as multiple-value headers
```yaml
labels:
proxy.app.set_headers: |
X-Custom-Header1: value1
X-Custom-Header1: value2
X-Custom-Header2: value2
```
- `hide_headers`: comma seperated list of headers to hide
[🔼Back to top](#table-of-content)
## Labels (docker specific)
Below labels has a **`proxy.<alias>.`** prefix (i.e. `proxy.app.load_balance=1`)
- `load_balance`: enable load balance
- allowed: `1`, `true`
[🔼Back to top](#table-of-content)
## Troubleshooting
- Firewall issues
If you are using `ufw` with vpn that drop all inbound traffic except vpn, run below:
`sudo ufw allow from 172.16.0.0/16 to 100.64.0.0/10`
Explaination:
Docker network is usually `172.16.0.0/16`
Tailscale is used as an example, `100.64.0.0/10` will be the CIDR
You can also list CIDRs of all docker bridge networks by:
`docker network inspect $(docker network ls | awk '$3 == "bridge" { print $1}') | jq -r '.[] | .Name + " " + .IPAM.Config[0].Subnet' -`
[🔼Back to top](#table-of-content)
## Docker compose examples
### Local docker provider in bridge network
```yaml
volumes:
adg-work:
adg-conf:
mc-data:
palworld:
nginx:
services:
adg:
image: adguard/adguardhome
restart: unless-stopped
labels:
- proxy.aliases=adg,adg-dns,adg-setup
- proxy.adg.port=80
- proxy.adg-setup.port=3000
- proxy.adg-dns.scheme=udp
- proxy.adg-dns.port=20000:dns
volumes:
- adg-work:/opt/adguardhome/work
- adg-conf:/opt/adguardhome/conf
mc:
image: itzg/minecraft-server
tty: true
stdin_open: true
container_name: mc
restart: unless-stopped
labels:
- proxy.mc.scheme=tcp
- proxy.mc.port=20001:25565
environment:
- EULA=TRUE
volumes:
- mc-data:/data
palworld:
image: thijsvanloef/palworld-server-docker:latest
restart: unless-stopped
container_name: pal
stop_grace_period: 30s
labels:
- proxy.aliases=pal1,pal2
- proxy.*.scheme=udp
- proxy.pal1.port=20002:8211
- proxy.pal2.port=20003:27015
environment: ...
volumes:
- palworld:/palworld
nginx:
image: nginx
container_name: nginx
volumes:
- nginx:/usr/share/nginx/html
go-proxy:
image: ghcr.io/yusing/go-proxy
container_name: go-proxy
restart: always
ports:
- 80:80 # http
- 443:443 # optional, https
- 8080:8080 # http panel
- 8443:8443 # optional, https panel
- 53:20000/udp # adguardhome
- 25565:20001/tcp # minecraft
- 8211:20002/udp # palworld
- 27015:20003/udp # palworld
volumes:
- ./config:/app/config
- /var/run/docker.sock:/var/run/docker.sock:ro
labels:
- proxy.aliases=gp
- proxy.gp.port=8080
```
[🔼Back to top](#table-of-content)
### Remote docker provider
#### Explaination
- Expose container ports to random port in remote host
- Use container port with an asterisk sign **(\*)** before to find remote port automatically
#### Remote setup
```yaml
volumes:
adg-work:
adg-conf:
mc-data:
palworld:
nginx:
services:
adg:
image: adguard/adguardhome
restart: unless-stopped
ports: # map container ports
- 80
- 3000
- 53/udp
- 53/tcp
labels:
- proxy.aliases=adg,adg-dns,adg-setup
# add an asterisk (*) before to find host port automatically
- proxy.adg.port=*80
- proxy.adg-setup.port=*3000
- proxy.adg-dns.scheme=udp
- proxy.adg-dns.port=*53
volumes:
- adg-work:/opt/adguardhome/work
- adg-conf:/opt/adguardhome/conf
mc:
image: itzg/minecraft-server
tty: true
stdin_open: true
container_name: mc
restart: unless-stopped
ports:
- 25565
labels:
- proxy.mc.scheme=tcp
- proxy.mc.port=*25565
environment:
- EULA=TRUE
volumes:
- mc-data:/data
palworld:
image: thijsvanloef/palworld-server-docker:latest
restart: unless-stopped
container_name: pal
stop_grace_period: 30s
ports:
- 8211/udp
- 27015/udp
labels:
- proxy.aliases=pal1,pal2
- proxy.*.scheme=udp
- proxy.pal1.port=*8211
- proxy.pal2.port=*27015
environment: ...
volumes:
- palworld:/palworld
nginx:
image: nginx
container_name: nginx
# for single port container, host port will be found automatically
ports:
- 80
volumes:
- nginx:/usr/share/nginx/html
```
[🔼Back to top](#table-of-content)
#### Proxy setup
```yaml
go-proxy:
image: ghcr.io/yusing/go-proxy
container_name: go-proxy
restart: always
network_mode: host
volumes:
- ./config:/app/config
- /var/run/docker.sock:/var/run/docker.sock:ro
labels:
- proxy.aliases=gp
- proxy.gp.port=8080
```
[🔼Back to top](#table-of-content)
### Local docker provider in host network
Mostly as remote docker setup, see [remote setup](#remote-setup)
With `GOPROXY_HOST_NETWORK=1` to treat it as remote docker provider
#### Proxy setup
```yaml
go-proxy:
image: ghcr.io/yusing/go-proxy
container_name: go-proxy
restart: always
network_mode: host
environment: # this part is needed for local docker in host mode
- GOPROXY_HOST_NETWORK=1
volumes:
- ./config:/app/config
- /var/run/docker.sock:/var/run/docker.sock:ro
labels:
- proxy.aliases=gp
- proxy.gp.port=8080
```
[🔼Back to top](#table-of-content)
### Services URLs for above examples
- `gp.yourdomain.com`: go-proxy web panel
- `adg-setup.yourdomain.com`: adguard setup (first time setup)
- `adg.yourdomain.com`: adguard dashboard
- `nginx.yourdomain.com`: nginx
- `yourdomain.com:53`: adguard dns
- `yourdomain.com:25565`: minecraft server
- `yourdomain.com:8211`: palworld server
[🔼Back to top](#table-of-content)

View File

@@ -1,27 +0,0 @@
---
services:
n8n:
image: n8nio/n8n
container_name: n8n
restart: always
expose:
- 5678
labels:
proxy.n8n.middlewares.request.set_headers: |
SSLRedirect: true
STSSeconds: 315360000
browserXSSFilter: true
contentTypeNosniff: true
forceSTSHeader: true
SSLHost: ${DOMAIN_NAME}
STSIncludeSubdomains: true
STSPreload: true
environment:
- N8N_HOST=${SUBDOMAIN}.${DOMAIN_NAME}
- N8N_PORT=5678
- N8N_PROTOCOL=https
- NODE_ENV=production
- WEBHOOK_URL=https://${SUBDOMAIN}.${DOMAIN_NAME}/
- GENERIC_TIMEZONE=${GENERIC_TIMEZONE}
volumes:
- ./data:/home/node/.n8n

View File

@@ -1,288 +0,0 @@
@import url("https://fonts.googleapis.com/css?family=Audiowide&display=swap");
html,
body {
margin: 0px;
overflow: hidden;
}
div {
position: absolute;
top: 0%;
left: 0%;
height: 100%;
width: 100%;
margin: 0px;
background: radial-gradient(circle, #240015 0%, #12000b 100%);
overflow: hidden;
}
.wrap {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
}
h2 {
position: absolute;
top: 50%;
left: 50%;
margin-top: 150px;
font-size: 32px;
text-transform: uppercase;
transform: translate(-50%, -50%);
display: block;
color: #12000a;
font-weight: 300;
font-family: Audiowide;
text-shadow: 0px 0px 4px #12000a;
animation: fadeInText 3s ease-in 3.5s forwards,
flicker4 5s linear 7.5s infinite, hueRotate 6s ease-in-out 3s infinite;
}
#svgWrap_1,
#svgWrap_2 {
position: absolute;
height: auto;
width: 600px;
max-width: 100%;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
#svgWrap_1,
#svgWrap_2,
div {
animation: hueRotate 6s ease-in-out 3s infinite;
}
#id1_1,
#id2_1,
#id3_1 {
stroke: #ff005d;
stroke-width: 3px;
fill: transparent;
filter: url(#glow);
}
#id1_2,
#id2_2,
#id3_2 {
stroke: #12000a;
stroke-width: 3px;
fill: transparent;
filter: url(#glow);
}
#id3_1 {
stroke-dasharray: 940px;
stroke-dashoffset: -940px;
animation: drawLine3 2.5s ease-in-out 0s forwards,
flicker3 4s linear 4s infinite;
}
#id2_1 {
stroke-dasharray: 735px;
stroke-dashoffset: -735px;
animation: drawLine2 2.5s ease-in-out 0.5s forwards,
flicker2 4s linear 4.5s infinite;
}
#id1_1 {
stroke-dasharray: 940px;
stroke-dashoffset: -940px;
animation: drawLine1 2.5s ease-in-out 1s forwards,
flicker1 4s linear 5s infinite;
}
@keyframes drawLine1 {
0% {
stroke-dashoffset: -940px;
}
100% {
stroke-dashoffset: 0px;
}
}
@keyframes drawLine2 {
0% {
stroke-dashoffset: -735px;
}
100% {
stroke-dashoffset: 0px;
}
}
@keyframes drawLine3 {
0% {
stroke-dashoffset: -940px;
}
100% {
stroke-dashoffset: 0px;
}
}
@keyframes flicker1 {
0% {
stroke: #ff005d;
}
1% {
stroke: transparent;
}
3% {
stroke: transparent;
}
4% {
stroke: #ff005d;
}
6% {
stroke: #ff005d;
}
7% {
stroke: transparent;
}
13% {
stroke: transparent;
}
14% {
stroke: #ff005d;
}
100% {
stroke: #ff005d;
}
}
@keyframes flicker2 {
0% {
stroke: #ff005d;
}
50% {
stroke: #ff005d;
}
51% {
stroke: transparent;
}
61% {
stroke: transparent;
}
62% {
stroke: #ff005d;
}
100% {
stroke: #ff005d;
}
}
@keyframes flicker3 {
0% {
stroke: #ff005d;
}
1% {
stroke: transparent;
}
10% {
stroke: transparent;
}
11% {
stroke: #ff005d;
}
40% {
stroke: #ff005d;
}
41% {
stroke: transparent;
}
45% {
stroke: transparent;
}
46% {
stroke: #ff005d;
}
100% {
stroke: #ff005d;
}
}
@keyframes flicker4 {
0% {
color: #ff005d;
text-shadow: 0px 0px 4px #ff005d;
}
30% {
color: #ff005d;
text-shadow: 0px 0px 4px #ff005d;
}
31% {
color: #12000a;
text-shadow: 0px 0px 4px #12000a;
}
32% {
color: #ff005d;
text-shadow: 0px 0px 4px #ff005d;
}
36% {
color: #ff005d;
text-shadow: 0px 0px 4px #ff005d;
}
37% {
color: #12000a;
text-shadow: 0px 0px 4px #12000a;
}
41% {
color: #12000a;
text-shadow: 0px 0px 4px #12000a;
}
42% {
color: #ff005d;
text-shadow: 0px 0px 4px #ff005d;
}
85% {
color: #ff005d;
text-shadow: 0px 0px 4px #ff005d;
}
86% {
color: #12000a;
text-shadow: 0px 0px 4px #12000a;
}
95% {
color: #12000a;
text-shadow: 0px 0px 4px #12000a;
}
96% {
color: #ff005d;
text-shadow: 0px 0px 4px #ff005d;
}
100% {
color: #ff005d;
text-shadow: 0px 0px 4px #ff005d;
}
}
@keyframes fadeInText {
1% {
color: #12000a;
text-shadow: 0px 0px 4px #12000a;
}
70% {
color: #ff005d;
text-shadow: 0px 0px 14px #ff005d;
}
100% {
color: #ff005d;
text-shadow: 0px 0px 4px #ff005d;
}
}
@keyframes hueRotate {
0% {
filter: hue-rotate(0deg);
}
50% {
filter: hue-rotate(-120deg);
}
100% {
filter: hue-rotate(0deg);
}
}

View File

@@ -1,51 +0,0 @@
{{/* Credit: https://codepen.io/code2rithik/pen/XWpVvYL */}}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Page Not Found</title>
<link rel="stylesheet" href="/$gperrorpage/404.css" type="text/css">
<!-- <script src="/$gperrorpage/404.js"> </script> -->
</head>
<body>
<script>0</script>
<div></div>
<svg id="svgWrap_2" xmlns="http://www.w3.org/2000/svg" x="0px" y="0px" viewBox="0 0 700 250">
<g>
<path id="id3_2"
d="M195.7 232.67h-37.1V149.7H27.76c-2.64 0-5.1-.5-7.36-1.49-2.27-.99-4.23-2.31-5.88-3.96-1.65-1.65-2.95-3.61-3.89-5.88s-1.42-4.67-1.42-7.22V29.62h36.82v82.98H158.6V29.62h37.1v203.05z" />
<path id="id2_2"
d="M470.69 147.71c0 8.31-1.06 16.17-3.19 23.58-2.12 7.41-5.12 14.28-8.99 20.6-3.87 6.33-8.45 11.99-13.74 16.99-5.29 5-11.07 9.28-17.35 12.81a85.146 85.146 0 0 1-20.04 8.14 83.637 83.637 0 0 1-21.67 2.83H319.3c-7.46 0-14.73-.94-21.81-2.83-7.08-1.89-13.76-4.6-20.04-8.14a88.292 88.292 0 0 1-17.35-12.81c-5.29-5-9.84-10.67-13.66-16.99-3.82-6.32-6.8-13.19-8.92-20.6-2.12-7.41-3.19-15.27-3.19-23.58v-33.13c0-12.46 2.34-23.88 7.01-34.27 4.67-10.38 10.92-19.33 18.76-26.83 7.83-7.5 16.87-13.36 27.12-17.56 10.24-4.2 20.93-6.3 32.07-6.3h66.41c7.36 0 14.58.94 21.67 2.83 7.08 1.89 13.76 4.6 20.04 8.14a88.292 88.292 0 0 1 17.35 12.81c5.29 5 9.86 10.67 13.74 16.99 3.87 6.33 6.87 13.19 8.99 20.6 2.13 7.41 3.19 15.27 3.19 23.58v33.14zm-37.1-33.13c0-7.27-1.32-13.88-3.96-19.82-2.64-5.95-6.16-11.04-10.55-15.29-4.39-4.25-9.46-7.5-15.22-9.77-5.76-2.27-11.8-3.35-18.13-3.26h-66.41c-6.14-.09-12.11.97-17.91 3.19-5.81 2.22-10.95 5.43-15.44 9.63-4.48 4.2-8.07 9.3-10.76 15.29-2.69 6-4.04 12.67-4.04 20.04v33.13c0 7.36 1.32 14.02 3.96 19.97 2.64 5.95 6.18 11.02 10.62 15.22 4.44 4.2 9.56 7.43 15.36 9.7 5.8 2.27 11.87 3.35 18.2 3.26h66.41c7.27 0 13.85-1.2 19.75-3.61s10.93-5.73 15.08-9.98 7.36-9.32 9.63-15.22c2.27-5.9 3.4-12.34 3.4-19.33v-33.15zm-16-26.91a17.89 17.89 0 0 1 2.83 6.73c.47 2.41.47 4.77 0 7.08-.47 2.31-1.39 4.48-2.76 6.51-1.37 2.03-3.14 3.75-5.31 5.17l-99.4 66.41c-1.61 1.23-3.26 2.08-4.96 2.55-1.7.47-3.45.71-5.24.71-3.02 0-5.9-.71-8.64-2.12-2.74-1.42-4.96-3.44-6.66-6.09a17.89 17.89 0 0 1-2.83-6.73c-.47-2.41-.5-4.77-.07-7.08.43-2.31 1.3-4.48 2.62-6.51 1.32-2.03 3.07-3.75 5.24-5.17l99.69-66.41a17.89 17.89 0 0 1 6.73-2.83c2.41-.47 4.77-.47 7.08 0 2.31.47 4.48 1.37 6.51 2.69 2.03 1.32 3.75 3.02 5.17 5.09z" />
<path id="id1_2"
d="M688.33 232.67h-37.1V149.7H520.39c-2.64 0-5.1-.5-7.36-1.49-2.27-.99-4.23-2.31-5.88-3.96-1.65-1.65-2.95-3.61-3.89-5.88s-1.42-4.67-1.42-7.22V29.62h36.82v82.98h112.57V29.62h37.1v203.05z" />
</g>
</svg>
<svg id="svgWrap_1" xmlns="http://www.w3.org/2000/svg" x="0px" y="0px" viewBox="0 0 700 250">
<g>
<path id="id3_1"
d="M195.7 232.67h-37.1V149.7H27.76c-2.64 0-5.1-.5-7.36-1.49-2.27-.99-4.23-2.31-5.88-3.96-1.65-1.65-2.95-3.61-3.89-5.88s-1.42-4.67-1.42-7.22V29.62h36.82v82.98H158.6V29.62h37.1v203.05z" />
<path id="id2_1"
d="M470.69 147.71c0 8.31-1.06 16.17-3.19 23.58-2.12 7.41-5.12 14.28-8.99 20.6-3.87 6.33-8.45 11.99-13.74 16.99-5.29 5-11.07 9.28-17.35 12.81a85.146 85.146 0 0 1-20.04 8.14 83.637 83.637 0 0 1-21.67 2.83H319.3c-7.46 0-14.73-.94-21.81-2.83-7.08-1.89-13.76-4.6-20.04-8.14a88.292 88.292 0 0 1-17.35-12.81c-5.29-5-9.84-10.67-13.66-16.99-3.82-6.32-6.8-13.19-8.92-20.6-2.12-7.41-3.19-15.27-3.19-23.58v-33.13c0-12.46 2.34-23.88 7.01-34.27 4.67-10.38 10.92-19.33 18.76-26.83 7.83-7.5 16.87-13.36 27.12-17.56 10.24-4.2 20.93-6.3 32.07-6.3h66.41c7.36 0 14.58.94 21.67 2.83 7.08 1.89 13.76 4.6 20.04 8.14a88.292 88.292 0 0 1 17.35 12.81c5.29 5 9.86 10.67 13.74 16.99 3.87 6.33 6.87 13.19 8.99 20.6 2.13 7.41 3.19 15.27 3.19 23.58v33.14zm-37.1-33.13c0-7.27-1.32-13.88-3.96-19.82-2.64-5.95-6.16-11.04-10.55-15.29-4.39-4.25-9.46-7.5-15.22-9.77-5.76-2.27-11.8-3.35-18.13-3.26h-66.41c-6.14-.09-12.11.97-17.91 3.19-5.81 2.22-10.95 5.43-15.44 9.63-4.48 4.2-8.07 9.3-10.76 15.29-2.69 6-4.04 12.67-4.04 20.04v33.13c0 7.36 1.32 14.02 3.96 19.97 2.64 5.95 6.18 11.02 10.62 15.22 4.44 4.2 9.56 7.43 15.36 9.7 5.8 2.27 11.87 3.35 18.2 3.26h66.41c7.27 0 13.85-1.2 19.75-3.61s10.93-5.73 15.08-9.98 7.36-9.32 9.63-15.22c2.27-5.9 3.4-12.34 3.4-19.33v-33.15zm-16-26.91a17.89 17.89 0 0 1 2.83 6.73c.47 2.41.47 4.77 0 7.08-.47 2.31-1.39 4.48-2.76 6.51-1.37 2.03-3.14 3.75-5.31 5.17l-99.4 66.41c-1.61 1.23-3.26 2.08-4.96 2.55-1.7.47-3.45.71-5.24.71-3.02 0-5.9-.71-8.64-2.12-2.74-1.42-4.96-3.44-6.66-6.09a17.89 17.89 0 0 1-2.83-6.73c-.47-2.41-.5-4.77-.07-7.08.43-2.31 1.3-4.48 2.62-6.51 1.32-2.03 3.07-3.75 5.24-5.17l99.69-66.41a17.89 17.89 0 0 1 6.73-2.83c2.41-.47 4.77-.47 7.08 0 2.31.47 4.48 1.37 6.51 2.69 2.03 1.32 3.75 3.02 5.17 5.09z" />
<path id="id1_1"
d="M688.33 232.67h-37.1V149.7H520.39c-2.64 0-5.1-.5-7.36-1.49-2.27-.99-4.23-2.31-5.88-3.96-1.65-1.65-2.95-3.61-3.89-5.88s-1.42-4.67-1.42-7.22V29.62h36.82v82.98h112.57V29.62h37.1v203.05z" />
</g>
</svg>
<svg>
<defs>
<filter id="glow">
<fegaussianblur class="blur" result="coloredBlur" stddeviation="4"></fegaussianblur>
<femerge>
<femergenode in="coloredBlur"></femergenode>
<femergenode in="SourceGraphic"></femergenode>
</femerge>
</filter>
</defs>
</svg>
<h2>Page Not Found</h2>
</body>
</html>

File diff suppressed because it is too large Load Diff

155
go.mod Normal file → Executable file
View File

@@ -1,149 +1,54 @@
module github.com/yusing/go-proxy
go 1.24.2
// misc
go 1.22
require (
github.com/bytedance/sonic v1.13.2 // faster json unmarshal (for marshal it's using custom implementation)
github.com/fsnotify/fsnotify v1.9.0 // file watcher
github.com/go-acme/lego/v4 v4.22.2 // acme client
github.com/go-playground/validator/v10 v10.26.0 // validator
github.com/gobwas/glob v0.2.3 // glob matcher for route rules
github.com/goccy/go-yaml v1.17.1 // yaml parsing for different config files
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/puzpuzpuz/xsync/v3 v3.5.1 // lock free map for concurrent operations
golang.org/x/text v0.24.0 // string utilities
golang.org/x/time v0.11.0 // time utilities
github.com/docker/cli v26.1.3+incompatible
github.com/docker/docker v26.1.3+incompatible
github.com/fsnotify/fsnotify v1.7.0
github.com/go-acme/lego/v4 v4.17.3
github.com/santhosh-tekuri/jsonschema v1.2.4
github.com/sirupsen/logrus v1.9.3
golang.org/x/net v0.25.0
gopkg.in/yaml.v3 v3.0.1
)
// http
require (
github.com/coder/websocket v1.8.13 // websocket for API and agent
github.com/quic-go/quic-go v0.50.1 // http3 server
golang.org/x/net v0.39.0 // HTTP header utilities
)
// authentication
require (
github.com/coreos/go-oidc/v3 v3.14.1 // oidc authentication
github.com/golang-jwt/jwt/v5 v5.2.2 // jwt for default auth
golang.org/x/crypto v0.37.0 // encrypting password with bcrypt
golang.org/x/oauth2 v0.29.0 // oauth2 authentication
)
// favicon extraction
require (
github.com/PuerkitoBio/goquery v1.10.3 // parsing HTML for extract fav icon
github.com/vincent-petithory/dataurl v1.0.0 // data url for fav icon
)
// docker
require (
github.com/docker/cli v28.0.4+incompatible // docker cli
github.com/docker/docker v28.0.4+incompatible // docker daemon
github.com/docker/go-connections v0.5.0 // docker connection utilities
)
// logging
require (
github.com/rs/zerolog v1.34.0 // logging
github.com/samber/slog-zerolog/v2 v2.7.3 // zerlog to slog adapter for quic-go
)
// metrics
require (
github.com/prometheus/client_golang v1.22.0 // metrics
github.com/shirou/gopsutil/v4 v4.25.3 // system info metrics
github.com/stretchr/testify v1.10.0 // testing utilities
)
require github.com/luthermonson/go-proxmox v0.2.2
require (
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/andybalholm/cascadia v1.3.3 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/buger/goterm v1.0.4 // indirect
github.com/bytedance/sonic/loader v0.2.4 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudflare/cloudflare-go v0.115.0 // indirect
github.com/cloudwego/base64x v0.1.5 // indirect
github.com/cloudflare/cloudflare-go v0.96.0 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/diskfs/go-diskfs v1.6.0 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/djherbis/times v1.6.0 // indirect
github.com/docker/go-connections v0.5.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/ebitengine/purego v0.8.2 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/go-jose/go-jose/v4 v4.1.0 // indirect
github.com/go-jose/go-jose/v4 v4.0.2 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-json v0.10.3 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/jinzhu/copier v0.4.0 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 // indirect
github.com/magefile/mage v1.15.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/miekg/dns v1.1.65 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-retryablehttp v0.7.6 // indirect
github.com/miekg/dns v1.1.59 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/term v0.5.0 // indirect
github.com/morikuni/aec v1.0.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/nrdcg/porkbun v0.4.0 // indirect
github.com/onsi/ginkgo/v2 v2.23.4 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/ovh/go-ovh v1.7.0 // indirect
github.com/opencontainers/image-spec v1.1.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.63.0 // indirect
github.com/prometheus/procfs v0.16.0 // indirect
github.com/quic-go/qpack v0.5.1 // indirect
github.com/samber/lo v1.49.1 // indirect
github.com/samber/slog-common v0.18.1 // indirect
github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af // indirect
github.com/tklauser/go-sysconf v0.3.15 // indirect
github.com/tklauser/numcpus v0.10.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect
go.opentelemetry.io/otel v1.35.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.30.0 // indirect
go.opentelemetry.io/otel/metric v1.35.0 // indirect
go.opentelemetry.io/otel/trace v1.35.0 // indirect
go.uber.org/automaxprocs v1.6.0 // indirect
go.uber.org/mock v0.5.1 // indirect
golang.org/x/arch v0.16.0 // indirect
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
golang.org/x/mod v0.24.0 // indirect
golang.org/x/sync v0.13.0 // indirect
golang.org/x/sys v0.32.0 // indirect
golang.org/x/tools v0.32.0 // indirect
google.golang.org/protobuf v1.36.6 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.52.0 // indirect
go.opentelemetry.io/otel v1.27.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.24.0 // indirect
go.opentelemetry.io/otel/metric v1.27.0 // indirect
go.opentelemetry.io/otel/sdk v1.24.0 // indirect
go.opentelemetry.io/otel/trace v1.27.0 // indirect
golang.org/x/crypto v0.23.0 // indirect
golang.org/x/mod v0.17.0 // indirect
golang.org/x/sync v0.7.0 // indirect
golang.org/x/sys v0.20.0 // indirect
golang.org/x/text v0.15.0 // indirect
golang.org/x/time v0.5.0 // indirect
golang.org/x/tools v0.21.0 // indirect
gotest.tools/v3 v3.5.1 // indirect
)

380
go.sum Normal file → Executable file
View File

@@ -2,393 +2,167 @@ 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.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo=
github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y=
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=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/buger/goterm v1.0.4 h1:Z9YvGmOih81P0FbVtEYTFF6YsSgxSUKEhf/f9bTMXbY=
github.com/buger/goterm v1.0.4/go.mod h1:HiFWV3xnkolgrBV3mY8m0X0Pumt4zg4QhbdOzQtB8tE=
github.com/bytedance/sonic v1.13.2 h1:8/H1FempDZqC4VqjptGo14QQlJx8VdZJegxs6wwfqpQ=
github.com/bytedance/sonic v1.13.2/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY=
github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudflare/cloudflare-go v0.115.0 h1:84/dxeeXweCc0PN5Cto44iTA8AkG1fyT11yPO5ZB7sM=
github.com/cloudflare/cloudflare-go v0.115.0/go.mod h1:Ds6urDwn/TF2uIU24mu7H91xkKP8gSAHxQ44DSZgVmU=
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/coder/websocket v1.8.13 h1:f3QZdXy7uGVz+4uCJy2nTZyM0yTBj8yANEHhqlXZ9FE=
github.com/coder/websocket v1.8.13/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
github.com/cloudflare/cloudflare-go v0.96.0 h1:wd+qrnyw+C2eXUUujE6BzFEOREkEfoCvogpO5h33FxI=
github.com/cloudflare/cloudflare-go v0.96.0/go.mod h1:gLP9fJT8ROgRCjHNKxISNNKeU1JEg2yT5uPEEI8x9Ec=
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
github.com/coreos/go-oidc/v3 v3.14.1 h1:9ePWwfdwC4QKRlCXsJGou56adA/owXczOzwKdOumLqk=
github.com/coreos/go-oidc/v3 v3.14.1/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/diskfs/go-diskfs v1.6.0 h1:YmK5+vLSfkwC6kKKRTRPGaDGNF+Xh8FXeiNHwryDfu4=
github.com/diskfs/go-diskfs v1.6.0/go.mod h1:bRFumZeGFCO8C2KNswrQeuj2m1WCVr4Ms5IjWMczMDk=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c=
github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0=
github.com/docker/cli v28.0.4+incompatible h1:pBJSJeNd9QeIWPjRcV91RVJihd/TXB77q1ef64XEu4A=
github.com/docker/cli v28.0.4+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
github.com/docker/docker v28.0.4+incompatible h1:JNNkBctYKurkw6FrHfKqY0nKIDf5nrbxjVBtS+cdcok=
github.com/docker/docker v28.0.4+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/cli v26.1.3+incompatible h1:bUpXT/N0kDE3VUHI2r5VMsYQgi38kYuoC0oL9yt3lqc=
github.com/docker/cli v26.1.3+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
github.com/docker/docker v26.1.3+incompatible h1:lLCzRbrVZrljpVNobJu1J2FHk8V0s4BawoZippkc+xo=
github.com/docker/docker v26.1.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/ebitengine/purego v0.8.2 h1:jPPGWs2sZ1UgOSgD2bClL0MJIqu58nOmIcBuXr62z1I=
github.com/ebitengine/purego v0.8.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/elliotwutingfeng/asciiset v0.0.0-20230602022725-51bbb787efab h1:h1UgjJdAAhj+uPL68n7XASS6bU+07ZX1WJvVS2eyoeY=
github.com/elliotwutingfeng/asciiset v0.0.0-20230602022725-51bbb787efab/go.mod h1:GLo/8fDswSAniFG+BFIaiSPcK610jyzgEhWYPQwuQdw=
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.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.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.1.0 h1:cYSYxd3pw5zd2FSXk2vGdn9igQU2PS8MuxrCOCl0FdY=
github.com/go-jose/go-jose/v4 v4.1.0/go.mod h1:GG/vqmYm3Von2nYiB2vGTXzdoNKE5tix5tuc6iAd+sw=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/go-acme/lego/v4 v4.17.3 h1:5our7Qdyik0abag40abdmQuytq97iweaNHFMT4pYDnQ=
github.com/go-acme/lego/v4 v4.17.3/go.mod h1:Ol6l04hnmavqVHKYS/ByhXXqE64x8yVYhomha82uAUk=
github.com/go-jose/go-jose/v4 v4.0.2 h1:R3l3kkBds16bO7ZFAEEcofK0MkrAJt3jlJznWZG0nvk=
github.com/go-jose/go-jose/v4 v4.0.2/go.mod h1:WVf9LFMHh/QVrmqrOfqun0C45tMe3RoiKJMPvgWwLfY=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
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.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k=
github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
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=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.17.1 h1:LI34wktB2xEE3ONG/2Ar54+/HJVBriAGJ55PHls4YuY=
github.com/goccy/go-yaml v1.17.1/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8=
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gotify/server/v2 v2.6.1 h1:Kf7v5fzBxzELzZa/jonWfwJMkqYqh1LBzBpCmt5QIAI=
github.com/gotify/server/v2 v2.6.1/go.mod h1:Dk8HLyTVDqmXM8YEg6tjROBen6mxyHZFRggJFHTwZLc=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0 h1:asbCHRVmodnJTuQ3qamDwqVOIjwqUPTYmYuemVOx+Ys=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0/go.mod h1:ggCgvZ2r7uOoQjOyu2Y1NhHmEPPzzuhWgcza5M1Ji1I=
github.com/h2non/gock v1.2.0 h1:K6ol8rfrRkUOefooBC8elXoaNGYkpp7y2qcxGG6BzUE=
github.com/h2non/gock v1.2.0/go.mod h1:tNhoxHYW2W42cYkYb1WqzdbYIieALC99kpYr7rH/BQk=
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw=
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI=
github.com/jarcoal/httpmock v1.3.0 h1:2RJ8GP0IIaWwcC9Fp2BmVi8Kog3v2Hn7VXM3fTd+nuc=
github.com/jarcoal/httpmock v1.3.0/go.mod h1:3yb8rc4BI7TCBhFY8ng0gjuLKJNquuDNiPaZjnENuYg=
github.com/jinzhu/copier v0.4.0 h1:w3ciUoD19shMCRargcpm0cm91ytaBhDvuRpz1ODO/U8=
github.com/jinzhu/copier v0.4.0/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.0 h1:Wqo399gCIufwto+VfwCSvsnfGpF/w5E9CNxSwbpD6No=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.0/go.mod h1:qmOFXW2epJhM0qSnUUYpldc7gVz2KMQwJ/QYCDIa7XU=
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
github.com/hashicorp/go-retryablehttp v0.7.6 h1:TwRYfx2z2C4cLbXmT8I5PgP/xmuqASDyiVuGYfs9GZM=
github.com/hashicorp/go-retryablehttp v0.7.6/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4=
github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4=
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 h1:PpXWgLPs+Fqr325bN2FD2ISlRRztXibcX6e8f5FR5Dc=
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/luthermonson/go-proxmox v0.2.2 h1:BZ7VEj302wxw2i/EwTcyEiBzQib8teocB2SSkLHyySY=
github.com/luthermonson/go-proxmox v0.2.2/go.mod h1:oyFgg2WwTEIF0rP6ppjiixOHa5ebK1p8OaRiFhvICBQ=
github.com/magefile/mage v1.15.0 h1:BvGheCMAsG3bWUDbZ8AyXXpCNwU9u5CB6sM+HNb9HYg=
github.com/magefile/mage v1.15.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/maxatome/go-testdeep v1.12.0 h1:Ql7Go8Tg0C1D/uMMX59LAoYK7LffeJQ6X2T04nTH68g=
github.com/maxatome/go-testdeep v1.12.0/go.mod h1:lPZc/HAcJMP92l7yI6TRz1aZN5URwUBUAfUNvrclaNM=
github.com/miekg/dns v1.1.65 h1:0+tIPHzUW0GCge7IiK3guGP57VAw7hoPDfApjkMD1Fc=
github.com/miekg/dns v1.1.65/go.mod h1:Dzw9769uoKVaLuODMDZz9M6ynFU6Em65csPuoi8G0ck=
github.com/miekg/dns v1.1.59 h1:C9EXc/UToRwKLhK5wKU/I4QVsBUc8kE6MkHBkeypWZs=
github.com/miekg/dns v1.1.59/go.mod h1:nZpewl5p6IvctfgrckopVx2OlSEHPRO/U4SYkRklrEk=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
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/onsi/ginkgo/v2 v2.23.4 h1:ktYTpKJAVZnDT4VjxSbiBenUjmlL/5QkBEocaWXiQus=
github.com/onsi/ginkgo/v2 v2.23.4/go.mod h1:Bt66ApGPBFzHyR+JO10Zbt0Gsp4uWxu5mIOTusL46e8=
github.com/onsi/gomega v1.36.3 h1:hID7cr8t3Wp26+cYnfcjR6HpJ00fdogN6dqZ1t6IylU=
github.com/onsi/gomega v1.36.3/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0=
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.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
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/pierrec/lz4/v4 v4.1.17 h1:kV4Ip+/hUBC+8T6+2EgburRtkE9ef4nbY3f4dFhGjMc=
github.com/pierrec/lz4/v4 v4.1.17/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/xattr v0.4.9 h1:5883YPCtkSd8LFbs13nXplj9g9tlrwoJRjgpgMu1/fE=
github.com/pkg/xattr v0.4.9/go.mod h1:di8WF84zAKk8jzR1UBTEWh9AUlIZZ7M/JNt8e9B6ktU=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g=
github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U=
github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q=
github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.63.0 h1:YR/EIY1o3mEFP/kZCD7iDMnLPlGyuU2Gb3HIcXnA98k=
github.com/prometheus/common v0.63.0/go.mod h1:VVFF/fBIoToEnWRVkYoXEkq3R3paCoxG9PXP74SnV18=
github.com/prometheus/procfs v0.16.0 h1:xh6oHhKwnOJKMYiYBDWmkHqQPyiY40sny36Cmx2bbsM=
github.com/prometheus/procfs v0.16.0/go.mod h1:8veyXUu3nGP7oaCxhX6yeaM5u4stL2FeMXnCqhDthZg=
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/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
github.com/quic-go/quic-go v0.50.1 h1:unsgjFIUqW8a2oopkY7YNONpV1gYND6Nt9hnt1PN94Q=
github.com/quic-go/quic-go v0.50.1/go.mod h1:Vim6OmUvlYdwBhXP9ZVrtGmCMWa3wEqhq3NgYrI8b4E=
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.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
github.com/samber/slog-common v0.18.1 h1:c0EipD/nVY9HG5shgm/XAs67mgpWDMF+MmtptdJNCkQ=
github.com/samber/slog-common v0.18.1/go.mod h1:QNZiNGKakvrfbJ2YglQXLCZauzkI9xZBjOhWFKS3IKk=
github.com/samber/slog-zerolog/v2 v2.7.3 h1:/MkPDl/tJhijN2GvB1MWwBn2FU8RiL3rQ8gpXkQm2EY=
github.com/samber/slog-zerolog/v2 v2.7.3/go.mod h1:oWU7WHof4Xp8VguiNO02r1a4VzkgoOyOZhY5CuRke60=
github.com/shirou/gopsutil/v4 v4.25.3 h1:SeA68lsu8gLggyMbmCn8cmp97V1TI9ld9sVzAUcKcKE=
github.com/shirou/gopsutil/v4 v4.25.3/go.mod h1:xbuxyoZj+UsgnZrENu3lQivsngRR5BdjbJwf2fv4szA=
github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af h1:Sp5TG9f7K39yfB+If0vjp97vuT74F72r8hfRpP8jLU0=
github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/rogpeppe/go-internal v1.8.1 h1:geMPLpDpQOgVyCg5z5GoRwLHepNdb71NXb67XFkP+Eg=
github.com/rogpeppe/go-internal v1.8.1/go.mod h1:JeRgkft04UBgHMgCIwADu4Pn6Mtm5d4nPKWu0nJ5d+o=
github.com/santhosh-tekuri/jsonschema v1.2.4 h1:hNhW8e7t+H1vgY+1QeEQpveR6D4+OwKPXCfD2aieJis=
github.com/santhosh-tekuri/jsonschema v1.2.4/go.mod h1:TEAUOeZSmIxTTuHatJzrvARHiuO9LYd+cIxzgEHCQI4=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4=
github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4=
github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso=
github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ulikunitz/xz v0.5.11 h1:kpFauv27b6ynzBNT/Xy+1k+fK4WswhN/6PN5WhFAGw8=
github.com/ulikunitz/xz v0.5.11/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/vincent-petithory/dataurl v1.0.0 h1:cXw+kPto8NLuJtlMsI152irrVw9fRDX8AbShPRpg2CI=
github.com/vincent-petithory/dataurl v1.0.0/go.mod h1:FHafX5vmDzyP+1CQATJn7WFKc9CvnvxyvZy6I1MrG/U=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ=
go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ=
go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.30.0 h1:lsInsfvhVIfOI6qHVyysXMNDnjO9Npvl7tlDPJFBVd4=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.30.0/go.mod h1:KQsVNh4OjgjTG0G6EiNi1jVpnaeeKsKMRwbLN+f1+8M=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.30.0 h1:umZgi92IyxfXd/l4kaDhnKgY8rnN/cZcF1LKc6I8OQ8=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.30.0/go.mod h1:4lVs6obhSVRb1EW5FhOuBTyiQhtRtAnnva9vD3yRfq8=
go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M=
go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE=
go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY=
go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg=
go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o=
go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w=
go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs=
go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc=
go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0=
go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8=
go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
go.uber.org/mock v0.5.1 h1:ASgazW/qBmR+A32MYFDB6E2POoTgOwT509VP0CT/fjs=
go.uber.org/mock v0.5.1/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
golang.org/x/arch v0.16.0 h1:foMtLTdyOmIniqWCHjY6+JxuC54XP1fDwx4N0ASyW+U=
golang.org/x/arch v0.16.0/go.mod h1:JmwW7aLIoRUKgaTzhkiEFxvcEiQGyOg9BMonBJUS7EE=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.52.0 h1:9l89oX4ba9kHbBol3Xin3leYJ+252h0zszDtBwyKe2A=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.52.0/go.mod h1:XLZfZboOJWHNKUv7eH0inh0E9VV6eWDFB/9yJyTLPp0=
go.opentelemetry.io/otel v1.27.0 h1:9BZoF3yMK/O1AafMiQTVu0YDj5Ea4hPhxCs7sGva+cg=
go.opentelemetry.io/otel v1.27.0/go.mod h1:DMpAK8fzYRzs+bi3rS5REupisuqTheUlSZJ1WnZaPAQ=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.24.0 h1:t6wl9SPayj+c7lEIFgm4ooDBZVb01IhLB4InpomhRw8=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.24.0/go.mod h1:iSDOcsnSA5INXzZtwaBPrKp/lWu/V14Dd+llD0oI2EA=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.24.0 h1:Xw8U6u2f8DK2XAkGRFV7BBLENgnTGX9i4rQRxJf+/vs=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.24.0/go.mod h1:6KW1Fm6R/s6Z3PGXwSJN2K4eT6wQB3vXX6CVnYX9NmM=
go.opentelemetry.io/otel/metric v1.27.0 h1:hvj3vdEKyeCi4YaYfNjv2NUje8FqKqUY8IlF0FxV/ik=
go.opentelemetry.io/otel/metric v1.27.0/go.mod h1:mVFgmRlhljgBiuk/MP/oKylr4hs85GZAylncepAX/ak=
go.opentelemetry.io/otel/sdk v1.24.0 h1:YMPPDNymmQN3ZgczicBY3B6sf9n62Dlj9pWD3ucgoDw=
go.opentelemetry.io/otel/sdk v1.24.0/go.mod h1:KVrIYw6tEubO9E96HQpcmpTKDVn9gdv35HoYiQWGDFg=
go.opentelemetry.io/otel/trace v1.27.0 h1:IqYb813p7cmbHk0a5y6pD5JPakbVfftRXABGt5/Rscw=
go.opentelemetry.io/otel/trace v1.27.0/go.mod h1:6RiD1hkAprV4/q+yd2ln1HG9GoPx39SuvvstaLBl+l4=
go.opentelemetry.io/proto/otlp v1.1.0 h1:2Di21piLrCqJ3U3eXGCTPHE9R8Nh+0uglSnOyxikMeI=
go.opentelemetry.io/proto/otlp v1.1.0/go.mod h1:GpBHCBWiqvVLDqmHZsoMM3C5ySeKTC7ej/RNTae6MdY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
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.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
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=
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 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
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 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
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.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
golang.org/x/oauth2 v0.29.0 h1:WdYw2tdTK1S8olAzWHdgeqfy+Mtm9XNhv/xJsY65d98=
golang.org/x/oauth2 v0.29.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
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=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
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 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
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-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210331175145-43e1dd70ce54/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
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 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
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.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
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=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
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 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.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.32.0 h1:Q7N1vhpkQv7ybVzLFtTjvQya2ewbwNDZzUgfXGqtMWU=
golang.org/x/tools v0.32.0/go.mod h1:ZxrU41P/wAbZD8EDa6dDCa6XfpkhJ7HFMjHJXfBDu8s=
golang.org/x/tools v0.21.0 h1:qc0xYgIbsSDt9EyWz05J5wfa7LOVW0YTLOXrqdLAWIw=
golang.org/x/tools v0.21.0/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
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=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/genproto v0.0.0-20241021214115-324edc3d5d38 h1:Q3nlH8iSQSRUwOskjbcSMcF2jiYMNiQYZ0c2KEJLKKU=
google.golang.org/genproto/googleapis/api v0.0.0-20241118233622-e639e219e697 h1:pgr/4QbFyktUv9CtQ/Fq4gzEE6/Xs7iCXbktaGzLHbQ=
google.golang.org/genproto/googleapis/api v0.0.0-20241118233622-e639e219e697/go.mod h1:+D9ySVjN8nY8YCVjc5O7PZDIdZporIDY3KaGfJunh88=
google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576 h1:8ZmaLZE4XWrtU3MyClkYqqtl6Oegr3235h7jxsDyqCY=
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.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
google.golang.org/genproto v0.0.0-20240227224415-6ceb2ff114de h1:F6qOa9AZTYJXOUEr4jDysRDLrm4PHePlge4v4TGAlxY=
google.golang.org/genproto/googleapis/api v0.0.0-20240311132316-a219d84964c2 h1:rIo7ocm2roD9DcFIX67Ym8icoGCKSARAiPljFhh5suQ=
google.golang.org/genproto/googleapis/api v0.0.0-20240311132316-a219d84964c2/go.mod h1:O1cOfN1Cy6QEYr7VxtjOyP5AdAuR0aJ/MYZaaof623Y=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240318140521-94a12d6c2237 h1:NnYq6UN9ReLM9/Y01KWNOWyI5xQ9kbIms5GGJVwS/Yc=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240318140521-94a12d6c2237/go.mod h1:WtryC6hu0hhx87FDGxWCDptyssuo68sk10vYjF+T9fY=
google.golang.org/grpc v1.63.1 h1:pNClQmvdlyNUiwFETOux/PYqfhmA7BrswEdGRnib1fA=
google.golang.org/grpc v1.63.1/go.mod h1:WAX/8DgncnokcFUldAxq7GeB5DXHDbMF+lLvDomNkRA=
google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU=
gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=

View File

@@ -1,70 +0,0 @@
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/certapi"
"github.com/yusing/go-proxy/internal/api/v1/dockerapi"
"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/logging/memlogger"
"github.com/yusing/go-proxy/internal/metrics/uptime"
"github.com/yusing/go-proxy/internal/net/gphttp/servemux"
)
func NewHandler(cfg config.ConfigInstance) http.Handler {
mux := servemux.NewServeMux(cfg)
mux.HandleFunc("GET", "/v1", v1.Index)
mux.HandleFunc("GET", "/v1/version", v1.GetVersion)
mux.HandleFunc("GET", "/v1/stats", v1.Stats, true)
mux.HandleFunc("POST", "/v1/reload", v1.Reload, true)
mux.HandleFunc("GET", "/v1/list", v1.List, true)
mux.HandleFunc("GET", "/v1/list/{what}", v1.List, true)
mux.HandleFunc("GET", "/v1/list/{what}/{which}", v1.List, true)
mux.HandleFunc("GET", "/v1/file/{type}/{filename}", v1.GetFileContent, true)
mux.HandleFunc("POST,PUT", "/v1/file/{type}/{filename}", v1.SetFileContent, true)
mux.HandleFunc("POST", "/v1/file/validate/{type}", v1.ValidateFile, true)
mux.HandleFunc("GET", "/v1/health", v1.Health, true)
mux.HandleFunc("GET", "/v1/logs", memlogger.Handler(), true)
mux.HandleFunc("GET", "/v1/favicon", favicon.GetFavIcon, true)
mux.HandleFunc("POST", "/v1/homepage/set", v1.SetHomePageOverrides, true)
mux.HandleFunc("GET", "/v1/agents", v1.ListAgents, true)
mux.HandleFunc("GET", "/v1/agents/new", v1.NewAgent, true)
mux.HandleFunc("POST", "/v1/agents/verify", v1.VerifyNewAgent, true)
mux.HandleFunc("GET", "/v1/metrics/system_info", v1.SystemInfo, true)
mux.HandleFunc("GET", "/v1/metrics/uptime", uptime.Poller.ServeHTTP, true)
mux.HandleFunc("GET", "/v1/cert/info", certapi.GetCertInfo, true)
mux.HandleFunc("", "/v1/cert/renew", certapi.RenewCert, true)
mux.HandleFunc("GET", "/v1/docker/info", dockerapi.DockerInfo, true)
mux.HandleFunc("GET", "/v1/docker/logs/{server}/{container}", dockerapi.Logs, true)
mux.HandleFunc("GET", "/v1/docker/containers", dockerapi.Containers, true)
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)
mux.HandleFunc("GET", "/v1/auth/check", func(w http.ResponseWriter, r *http.Request) {
if err := defaultAuth.CheckToken(r); err != nil {
http.Error(w, err.Error(), http.StatusUnauthorized)
return
}
})
mux.HandleFunc("GET,POST", "/v1/auth/callback", defaultAuth.LoginCallbackHandler)
mux.HandleFunc("GET,POST", "/v1/auth/logout", defaultAuth.LogoutCallbackHandler)
} else {
mux.HandleFunc("GET", "/v1/auth/check", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
}
return mux
}

View File

@@ -1,14 +0,0 @@
package v1
import (
"net/http"
"time"
"github.com/yusing/go-proxy/agent/pkg/agent"
config "github.com/yusing/go-proxy/internal/config/types"
"github.com/yusing/go-proxy/internal/net/gphttp/gpwebsocket"
)
func ListAgents(cfg config.ConfigInstance, w http.ResponseWriter, r *http.Request) {
gpwebsocket.DynamicJSONHandler(w, r, agent.Agents.Slice, 10*time.Second)
}

View File

@@ -1,52 +0,0 @@
package auth
import (
"net/http"
"github.com/yusing/go-proxy/internal/common"
"github.com/yusing/go-proxy/internal/net/gphttp"
)
var defaultAuth Provider
// Initialize sets up authentication providers.
func Initialize() error {
if !IsEnabled() {
return nil
}
var err error
// Initialize OIDC if configured.
if common.OIDCIssuerURL != "" {
defaultAuth, err = NewOIDCProviderFromEnv()
} else {
defaultAuth, err = NewUserPassAuthFromEnv()
}
return err
}
func GetDefaultAuth() Provider {
return defaultAuth
}
func IsEnabled() bool {
return !common.DebugDisableAuth && (common.APIJWTSecret != nil || IsOIDCEnabled())
}
func IsOIDCEnabled() bool {
return common.OIDCIssuerURL != ""
}
func RequireAuth(next http.HandlerFunc) http.HandlerFunc {
if IsEnabled() {
return func(w http.ResponseWriter, r *http.Request) {
if err := defaultAuth.CheckToken(r); err != nil {
gphttp.ClientError(w, err, http.StatusUnauthorized)
} else {
next(w, r)
}
}
}
return next
}

View File

@@ -1,22 +0,0 @@
package auth
import (
"html/template"
"net/http"
_ "embed"
)
//go:embed block_page.html
var blockPageHTML string
var blockPageTemplate = template.Must(template.New("block_page").Parse(blockPageHTML))
func WriteBlockPage(w http.ResponseWriter, status int, error string, logoutURL string) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
blockPageTemplate.Execute(w, map[string]string{
"StatusText": http.StatusText(status),
"Error": error,
"LogoutURL": logoutURL,
})
}

View File

@@ -1,14 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Access Denied</title>
</head>
<body>
<h1>{{.StatusText}}</h1>
<p>{{.Error}}</p>
<a href="{{.LogoutURL}}">Logout</a>
</body>
</html>

View File

@@ -1,309 +0,0 @@
package auth
import (
"context"
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"io"
"mime"
"net/http"
"net/url"
"slices"
"strings"
"time"
"github.com/yusing/go-proxy/pkg/json"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/yusing/go-proxy/internal/common"
"github.com/yusing/go-proxy/internal/net/gphttp"
"github.com/yusing/go-proxy/internal/utils"
"github.com/yusing/go-proxy/internal/utils/strutils"
"golang.org/x/oauth2"
)
type (
OIDCProvider struct {
oauthConfig *oauth2.Config
oidcProvider *oidc.Provider
oidcVerifier *oidc.IDTokenVerifier
oidcEndSessionURL *url.URL
allowedUsers []string
allowedGroups []string
isMiddleware bool
}
providerJSON struct {
oidc.ProviderConfig
EndSessionURL string `json:"end_session_endpoint"`
}
)
const CookieOauthState = "godoxy_oidc_state"
const (
OIDCMiddlewareCallbackPath = "/auth/callback"
OIDCLogoutPath = "/auth/logout"
)
func NewOIDCProvider(issuerURL, clientID, clientSecret, redirectURL string, allowedUsers, allowedGroups []string) (*OIDCProvider, error) {
if len(allowedUsers)+len(allowedGroups) == 0 {
return nil, errors.New("OIDC users, groups, or both must not be empty")
}
wellKnown := strings.TrimSuffix(issuerURL, "/") + "/.well-known/openid-configuration"
resp, err := gphttp.Get(wellKnown)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("oidc: unable to read response body: %v", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("oidc: %s: %s", resp.Status, body)
}
var p providerJSON
err = json.Unmarshal(body, &p)
if err != nil {
mimeType, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type"))
if err == nil && mimeType != "application/json" {
return nil, fmt.Errorf("oidc: unexpected content type: %q from OIDC provider discovery, have you configured the correct issuer URL?", mimeType)
}
return nil, fmt.Errorf("oidc: failed to decode provider discovery object: %v", err)
}
if p.IssuerURL != issuerURL {
return nil, fmt.Errorf("oidc: issuer did not match the issuer returned by provider, expected %q got %q", issuerURL, p.IssuerURL)
}
var endSessionURL *url.URL
if p.EndSessionURL != "" {
endSessionURL, err = url.Parse(p.EndSessionURL)
if err != nil {
return nil, fmt.Errorf("oidc: failed to parse end session URL: %w", err)
}
}
provider := p.NewProvider(context.Background())
return &OIDCProvider{
oauthConfig: &oauth2.Config{
ClientID: clientID,
ClientSecret: clientSecret,
RedirectURL: redirectURL,
Endpoint: provider.Endpoint(),
Scopes: strutils.CommaSeperatedList(common.OIDCScopes),
},
oidcProvider: provider,
oidcVerifier: provider.Verifier(&oidc.Config{
ClientID: clientID,
}),
oidcEndSessionURL: endSessionURL,
allowedUsers: allowedUsers,
allowedGroups: allowedGroups,
}, nil
}
// NewOIDCProviderFromEnv creates a new OIDCProvider from environment variables.
func NewOIDCProviderFromEnv() (*OIDCProvider, error) {
return NewOIDCProvider(
common.OIDCIssuerURL,
common.OIDCClientID,
common.OIDCClientSecret,
common.OIDCRedirectURL,
common.OIDCAllowedUsers,
common.OIDCAllowedGroups,
)
}
func (auth *OIDCProvider) TokenCookieName() string {
return "godoxy_oidc_token"
}
func (auth *OIDCProvider) SetIsMiddleware(enabled bool) {
auth.isMiddleware = enabled
auth.oauthConfig.RedirectURL = ""
}
func (auth *OIDCProvider) SetAllowedUsers(users []string) {
auth.allowedUsers = users
}
func (auth *OIDCProvider) SetAllowedGroups(groups []string) {
auth.allowedGroups = groups
}
func (auth *OIDCProvider) CheckToken(r *http.Request) error {
token, err := r.Cookie(auth.TokenCookieName())
if err != nil {
return ErrMissingToken
}
// checks for Expiry, Audience == ClientID, Issuer, etc.
idToken, err := auth.oidcVerifier.Verify(r.Context(), token.Value)
if err != nil {
return fmt.Errorf("failed to verify ID token: %w: %w", ErrInvalidToken, err)
}
if len(idToken.Audience) == 0 {
return ErrInvalidToken
}
var claims struct {
Email string `json:"email"`
Username string `json:"preferred_username"`
Groups []string `json:"groups"`
}
if err := idToken.Claims(&claims); err != nil {
return fmt.Errorf("failed to parse claims: %w", err)
}
// Logical AND between allowed users and groups.
allowedUser := slices.Contains(auth.allowedUsers, claims.Username)
allowedGroup := len(utils.Intersect(claims.Groups, auth.allowedGroups)) > 0
if !allowedUser && !allowedGroup {
return ErrUserNotAllowed
}
return nil
}
// generateState generates a random string for OIDC state.
const oidcStateLength = 32
func generateState() (string, error) {
b := make([]byte, oidcStateLength)
_, err := rand.Read(b)
if err != nil {
return "", err
}
return base64.URLEncoding.EncodeToString(b)[:oidcStateLength], nil
}
// RedirectOIDC initiates the OIDC login flow.
func (auth *OIDCProvider) RedirectLoginPage(w http.ResponseWriter, r *http.Request) {
state, err := generateState()
if err != nil {
gphttp.ServerError(w, r, err)
return
}
http.SetCookie(w, &http.Cookie{
Name: CookieOauthState,
Value: state,
MaxAge: 300,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: common.APIJWTSecure,
Path: "/",
})
redirURL := auth.oauthConfig.AuthCodeURL(state)
if auth.isMiddleware {
u, err := r.URL.Parse(redirURL)
if err != nil {
gphttp.ServerError(w, r, err)
return
}
q := u.Query()
q.Set("redirect_uri", "https://"+r.Host+OIDCMiddlewareCallbackPath+q.Get("redirect_uri"))
u.RawQuery = q.Encode()
redirURL = u.String()
}
http.Redirect(w, r, redirURL, http.StatusTemporaryRedirect)
}
func (auth *OIDCProvider) exchange(r *http.Request) (*oauth2.Token, error) {
if auth.isMiddleware {
cfg := *auth.oauthConfig
cfg.RedirectURL = "https://" + r.Host + OIDCMiddlewareCallbackPath
return cfg.Exchange(r.Context(), r.URL.Query().Get("code"))
}
return auth.oauthConfig.Exchange(r.Context(), r.URL.Query().Get("code"))
}
// OIDCCallbackHandler handles the OIDC callback.
func (auth *OIDCProvider) LoginCallbackHandler(w http.ResponseWriter, r *http.Request) {
// For testing purposes, skip provider verification
if common.IsTest {
auth.handleTestCallback(w, r)
return
}
state, err := r.Cookie(CookieOauthState)
if err != nil {
gphttp.BadRequest(w, "missing state cookie")
return
}
query := r.URL.Query()
if query.Get("state") != state.Value {
gphttp.BadRequest(w, "invalid oauth state")
return
}
oauth2Token, err := auth.exchange(r)
if err != nil {
gphttp.ServerError(w, r, fmt.Errorf("failed to exchange token: %w", err))
return
}
rawIDToken, ok := oauth2Token.Extra("id_token").(string)
if !ok {
gphttp.BadRequest(w, "missing id_token")
return
}
idToken, err := auth.oidcVerifier.Verify(r.Context(), rawIDToken)
if err != nil {
gphttp.ServerError(w, r, fmt.Errorf("failed to verify ID token: %w", err))
return
}
setTokenCookie(w, r, auth.TokenCookieName(), rawIDToken, time.Until(idToken.Expiry))
// Redirect to home page
http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
}
func (auth *OIDCProvider) LogoutCallbackHandler(w http.ResponseWriter, r *http.Request) {
if auth.oidcEndSessionURL == nil {
DefaultLogoutCallbackHandler(auth, w, r)
return
}
token, err := r.Cookie(auth.TokenCookieName())
if err != nil {
gphttp.BadRequest(w, "missing token cookie")
return
}
clearTokenCookie(w, r, auth.TokenCookieName())
logoutURL := *auth.oidcEndSessionURL
logoutURL.Query().Add("id_token_hint", token.Value)
http.Redirect(w, r, logoutURL.String(), http.StatusFound)
}
// handleTestCallback handles OIDC callback in test environment.
func (auth *OIDCProvider) handleTestCallback(w http.ResponseWriter, r *http.Request) {
state, err := r.Cookie(CookieOauthState)
if err != nil {
gphttp.BadRequest(w, "missing state cookie")
return
}
if r.URL.Query().Get("state") != state.Value {
gphttp.BadRequest(w, "invalid oauth state")
return
}
// Create test JWT token
setTokenCookie(w, r, auth.TokenCookieName(), "test", time.Hour)
http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
}

View File

@@ -1,455 +0,0 @@
package auth
import (
"context"
"crypto/rand"
"crypto/rsa"
"encoding/base64"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/yusing/go-proxy/pkg/json"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/golang-jwt/jwt/v5"
"github.com/yusing/go-proxy/internal/common"
"golang.org/x/oauth2"
. "github.com/yusing/go-proxy/internal/utils/testing"
)
// setupMockOIDC configures mock OIDC provider for testing.
func setupMockOIDC(t *testing.T) {
t.Helper()
provider := (&oidc.ProviderConfig{}).NewProvider(context.TODO())
defaultAuth = &OIDCProvider{
oauthConfig: &oauth2.Config{
ClientID: "test-client",
ClientSecret: "test-secret",
RedirectURL: "http://localhost/callback",
Endpoint: oauth2.Endpoint{
AuthURL: "http://mock-provider/auth",
TokenURL: "http://mock-provider/token",
},
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
},
oidcProvider: provider,
oidcVerifier: provider.Verifier(&oidc.Config{
ClientID: "test-client",
}),
allowedUsers: []string{"test-user"},
allowedGroups: []string{"test-group1", "test-group2"},
}
}
// discoveryDocument returns a mock OIDC discovery document.
func discoveryDocument(t *testing.T, server *httptest.Server) map[string]any {
t.Helper()
discovery := map[string]any{
"issuer": server.URL,
"authorization_endpoint": server.URL + "/auth",
"token_endpoint": server.URL + "/token",
}
return discovery
}
const (
keyID = "test-key-id"
clientID = "test-client-id"
)
type provider struct {
ts *httptest.Server
key *rsa.PrivateKey
verifier *oidc.IDTokenVerifier
}
func (j *provider) SignClaims(t *testing.T, claims jwt.Claims) string {
t.Helper()
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
token.Header["kid"] = keyID
signed, err := token.SignedString(j.key)
ExpectNoError(t, err)
return signed
}
func setupProvider(t *testing.T) *provider {
t.Helper()
// Generate an RSA key pair for the test.
privKey, err := rsa.GenerateKey(rand.Reader, 2048)
ExpectNoError(t, err)
// Build the matching public JWK that will be served by the endpoint.
jwk := buildRSAJWK(t, &privKey.PublicKey, keyID)
// Start a test server that serves the JWKS endpoint.
ts := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/.well-known/jwks.json":
_ = json.NewEncoder(w).Encode(map[string]any{
"keys": []any{jwk},
})
default:
http.NotFound(w, r)
}
}))
t.Cleanup(ts.Close)
// Create a test OIDCProvider.
providerCtx := oidc.ClientContext(context.Background(), ts.Client())
keySet := oidc.NewRemoteKeySet(providerCtx, ts.URL+"/.well-known/jwks.json")
return &provider{
ts: ts,
key: privKey,
verifier: oidc.NewVerifier(ts.URL, keySet, &oidc.Config{
ClientID: clientID, // matches audience in the token
}),
}
}
// buildRSAJWK is a helper to construct a minimal JWK for the JWKS endpoint.
func buildRSAJWK(t *testing.T, pub *rsa.PublicKey, kid string) map[string]any {
t.Helper()
nBytes := pub.N.Bytes()
eBytes := []byte{0x01, 0x00, 0x01} // Usually 65537
return map[string]any{
"kty": "RSA",
"alg": "RS256",
"use": "sig",
"kid": kid,
"n": base64.RawURLEncoding.EncodeToString(nBytes),
"e": base64.RawURLEncoding.EncodeToString(eBytes),
}
}
func cleanup() {
defaultAuth = nil
}
func TestOIDCLoginHandler(t *testing.T) {
// Setup
common.APIJWTSecret = []byte("test-secret")
t.Cleanup(cleanup)
setupMockOIDC(t)
tests := []struct {
name string
wantStatus int
wantRedirect bool
}{
{
name: "Success - Redirects to provider",
wantStatus: http.StatusTemporaryRedirect,
wantRedirect: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/auth/redirect", nil)
w := httptest.NewRecorder()
defaultAuth.RedirectLoginPage(w, req)
if got := w.Code; got != tt.wantStatus {
t.Errorf("OIDCLoginHandler() status = %v, want %v", got, tt.wantStatus)
}
if tt.wantRedirect {
if loc := w.Header().Get("Location"); loc == "" {
t.Error("OIDCLoginHandler() missing redirect location")
}
cookie := w.Header().Get("Set-Cookie")
if cookie == "" {
t.Error("OIDCLoginHandler() missing state cookie")
}
}
})
}
}
func TestOIDCCallbackHandler(t *testing.T) {
// Setup
common.APIJWTSecret = []byte("test-secret")
t.Cleanup(cleanup)
tests := []struct {
name string
state string
code string
setupMocks bool
wantStatus int
}{
{
name: "Success - Valid callback",
state: "valid-state",
code: "valid-code",
setupMocks: true,
wantStatus: http.StatusTemporaryRedirect,
},
{
name: "Failure - Missing state",
code: "valid-code",
setupMocks: true,
wantStatus: http.StatusBadRequest,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.setupMocks {
setupMockOIDC(t)
}
req := httptest.NewRequest(http.MethodGet, "/auth/callback?code="+tt.code+"&state="+tt.state, nil)
if tt.state != "" {
req.AddCookie(&http.Cookie{
Name: CookieOauthState,
Value: tt.state,
})
}
w := httptest.NewRecorder()
defaultAuth.LoginCallbackHandler(w, req)
if got := w.Code; got != tt.wantStatus {
t.Errorf("OIDCCallbackHandler() status = %v, want %v", got, tt.wantStatus)
}
if tt.wantStatus == http.StatusTemporaryRedirect {
setCookie := Must(http.ParseSetCookie(w.Header().Get("Set-Cookie")))
ExpectEqual(t, setCookie.Name, defaultAuth.TokenCookieName())
ExpectTrue(t, setCookie.Value != "")
ExpectEqual(t, setCookie.Path, "/")
ExpectEqual(t, setCookie.SameSite, http.SameSiteLaxMode)
ExpectEqual(t, setCookie.HttpOnly, true)
}
})
}
}
func TestInitOIDC(t *testing.T) {
setupMockOIDC(t)
// Create a test server that serves the discovery document
var server *httptest.Server
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
ExpectNoError(t, json.NewEncoder(w).Encode(discoveryDocument(t, server)))
})
server = httptest.NewServer(mux)
t.Cleanup(server.Close)
t.Cleanup(cleanup)
tests := []struct {
name string
issuerURL string
clientID string
clientSecret string
redirectURL string
logoutURL string
allowedUsers []string
allowedGroups []string
wantErr bool
}{
{
name: "Fail - Empty configuration",
wantErr: true,
},
{
name: "Success - Valid configuration with users",
issuerURL: server.URL,
clientID: "client_id",
clientSecret: "client_secret",
redirectURL: "https://example.com/callback",
allowedUsers: []string{"user1", "user2"},
wantErr: false,
},
{
name: "Success - Valid configuration with groups",
issuerURL: server.URL,
clientID: "client_id",
clientSecret: "client_secret",
redirectURL: "https://example.com/callback",
allowedGroups: []string{"group1", "group2"},
wantErr: false,
},
{
name: "Success - Valid configuration with users, groups and logout URL",
issuerURL: server.URL,
clientID: "client_id",
clientSecret: "client_secret",
redirectURL: "https://example.com/callback",
logoutURL: "https://example.com/logout",
allowedUsers: []string{"user1", "user2"},
allowedGroups: []string{"group1", "group2"},
wantErr: false,
},
{
name: "Fail - No allowed users or allowed groups",
issuerURL: "https://example.com",
clientID: "client_id",
clientSecret: "client_secret",
redirectURL: "https://example.com/callback",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := NewOIDCProvider(tt.issuerURL, tt.clientID, tt.clientSecret, tt.redirectURL, tt.allowedUsers, tt.allowedGroups)
if (err != nil) != tt.wantErr {
t.Errorf("InitOIDC() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
func TestCheckToken(t *testing.T) {
provider := setupProvider(t)
tests := []struct {
name string
allowedUsers []string
allowedGroups []string
claims jwt.Claims
wantErr error
}{
{
name: "Success - Valid token with allowed user",
allowedUsers: []string{"user1"},
claims: jwt.MapClaims{
"iss": provider.ts.URL,
"aud": clientID,
"exp": time.Now().Add(time.Hour).Unix(),
"preferred_username": "user1",
"groups": []string{"group1"},
},
},
{
name: "Success - Valid token with allowed group",
allowedGroups: []string{"group1"},
claims: jwt.MapClaims{
"iss": provider.ts.URL,
"aud": clientID,
"exp": time.Now().Add(time.Hour).Unix(),
"preferred_username": "user1",
"groups": []string{"group1"},
},
},
{
name: "Success - Server omits groups, but user is allowed",
allowedUsers: []string{"user1"},
claims: jwt.MapClaims{
"iss": provider.ts.URL,
"aud": clientID,
"exp": time.Now().Add(time.Hour).Unix(),
"preferred_username": "user1",
},
},
{
name: "Success - Server omits preferred_username, but group is allowed",
allowedGroups: []string{"group1"},
claims: jwt.MapClaims{
"iss": provider.ts.URL,
"aud": clientID,
"exp": time.Now().Add(time.Hour).Unix(),
"groups": []string{"group1"},
},
},
{
name: "Success - Valid token with allowed user and group",
allowedUsers: []string{"user1"},
allowedGroups: []string{"group1"},
claims: jwt.MapClaims{
"iss": provider.ts.URL,
"aud": clientID,
"exp": time.Now().Add(time.Hour).Unix(),
"preferred_username": "user1",
"groups": []string{"group1"},
},
},
{
name: "Error - User not allowed",
allowedUsers: []string{"user2", "user3"},
allowedGroups: []string{"group2", "group3"},
claims: jwt.MapClaims{
"iss": provider.ts.URL,
"aud": clientID,
"exp": time.Now().Add(time.Hour).Unix(),
"preferred_username": "user1",
"groups": []string{"group1"},
},
wantErr: ErrUserNotAllowed,
},
{
name: "Error - Server returns incorrect issuer",
claims: jwt.MapClaims{
"iss": "https://example.com",
"aud": clientID,
"exp": time.Now().Add(time.Hour).Unix(),
"preferred_username": "user1",
"groups": []string{"group1"},
},
wantErr: ErrInvalidToken,
},
{
name: "Error - Server returns incorrect audience",
claims: jwt.MapClaims{
"iss": provider.ts.URL,
"aud": "some-other-audience",
"exp": time.Now().Add(time.Hour).Unix(),
"preferred_username": "user1",
"groups": []string{"group1"},
},
wantErr: ErrInvalidToken,
},
{
name: "Error - Server returns expired token",
claims: jwt.MapClaims{
"iss": provider.ts.URL,
"aud": clientID,
"exp": time.Now().Add(-time.Hour).Unix(),
"preferred_username": "user1",
"groups": []string{"group1"},
},
wantErr: ErrInvalidToken,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
// Create the Auth Provider.
auth := &OIDCProvider{
oidcVerifier: provider.verifier,
allowedUsers: tc.allowedUsers,
allowedGroups: tc.allowedGroups,
}
// Sign the claims to create a token.
signedToken := provider.SignClaims(t, tc.claims)
// Craft a test HTTP request that includes the token as a cookie.
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.AddCookie(&http.Cookie{
Name: auth.TokenCookieName(),
Value: signedToken,
})
// Call CheckToken and verify the result.
err := auth.CheckToken(req)
if tc.wantErr == nil {
ExpectNoError(t, err)
} else {
ExpectError(t, tc.wantErr, err)
}
})
}
}

View File

@@ -1,13 +0,0 @@
package auth
import (
"net/http"
)
type Provider interface {
TokenCookieName() string
CheckToken(r *http.Request) error
RedirectLoginPage(w http.ResponseWriter, r *http.Request)
LoginCallbackHandler(w http.ResponseWriter, r *http.Request)
LogoutCallbackHandler(w http.ResponseWriter, r *http.Request)
}

View File

@@ -1,143 +0,0 @@
package auth
import (
"fmt"
"net/http"
"time"
"github.com/yusing/go-proxy/pkg/json"
"github.com/golang-jwt/jwt/v5"
"github.com/yusing/go-proxy/internal/common"
"github.com/yusing/go-proxy/internal/gperr"
"github.com/yusing/go-proxy/internal/net/gphttp"
"github.com/yusing/go-proxy/internal/utils/strutils"
"golang.org/x/crypto/bcrypt"
)
var (
ErrInvalidUsername = gperr.New("invalid username")
ErrInvalidPassword = gperr.New("invalid password")
)
type (
UserPassAuth struct {
username string
pwdHash []byte
secret []byte
tokenTTL time.Duration
}
UserPassClaims struct {
Username string `json:"username"`
jwt.RegisteredClaims
}
)
func NewUserPassAuth(username, password string, secret []byte, tokenTTL time.Duration) (*UserPassAuth, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return nil, err
}
return &UserPassAuth{
username: username,
pwdHash: hash,
secret: secret,
tokenTTL: tokenTTL,
}, nil
}
func NewUserPassAuthFromEnv() (*UserPassAuth, error) {
return NewUserPassAuth(
common.APIUser,
common.APIPassword,
common.APIJWTSecret,
common.APIJWTTokenTTL,
)
}
func (auth *UserPassAuth) TokenCookieName() string {
return "godoxy_token"
}
func (auth *UserPassAuth) NewToken() (token string, err error) {
claim := &UserPassClaims{
Username: auth.username,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(auth.tokenTTL)),
},
}
tok := jwt.NewWithClaims(jwt.SigningMethodHS512, claim)
token, err = tok.SignedString(auth.secret)
if err != nil {
return "", err
}
return token, nil
}
func (auth *UserPassAuth) CheckToken(r *http.Request) error {
jwtCookie, err := r.Cookie(auth.TokenCookieName())
if err != nil {
return ErrMissingToken
}
var claims UserPassClaims
token, err := jwt.ParseWithClaims(jwtCookie.Value, &claims, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
}
return auth.secret, nil
})
if err != nil {
return err
}
switch {
case !token.Valid:
return ErrInvalidToken
case claims.Username != auth.username:
return ErrUserNotAllowed.Subject(claims.Username)
case claims.ExpiresAt.Before(time.Now()):
return gperr.Errorf("token expired on %s", strutils.FormatTime(claims.ExpiresAt.Time))
}
return nil
}
func (auth *UserPassAuth) RedirectLoginPage(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/login", http.StatusTemporaryRedirect)
}
func (auth *UserPassAuth) LoginCallbackHandler(w http.ResponseWriter, r *http.Request) {
var creds struct {
User string `json:"username"`
Pass string `json:"password"`
}
err := json.NewDecoder(r.Body).Decode(&creds)
if err != nil {
gphttp.Unauthorized(w, "invalid credentials")
return
}
if err := auth.validatePassword(creds.User, creds.Pass); err != nil {
gphttp.Unauthorized(w, "invalid credentials")
return
}
token, err := auth.NewToken()
if err != nil {
gphttp.ServerError(w, r, err)
return
}
setTokenCookie(w, r, auth.TokenCookieName(), token, auth.tokenTTL)
w.WriteHeader(http.StatusOK)
}
func (auth *UserPassAuth) LogoutCallbackHandler(w http.ResponseWriter, r *http.Request) {
DefaultLogoutCallbackHandler(auth, w, r)
}
func (auth *UserPassAuth) validatePassword(user, pass string) error {
if user != auth.username {
return ErrInvalidUsername.Subject(user)
}
if err := bcrypt.CompareHashAndPassword(auth.pwdHash, []byte(pass)); err != nil {
return ErrInvalidPassword.With(err).Subject(pass)
}
return nil
}

View File

@@ -1,116 +0,0 @@
package auth
import (
"bytes"
"io"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/yusing/go-proxy/pkg/json"
. "github.com/yusing/go-proxy/internal/utils/testing"
"golang.org/x/crypto/bcrypt"
)
func newMockUserPassAuth() *UserPassAuth {
return &UserPassAuth{
username: "username",
pwdHash: Must(bcrypt.GenerateFromPassword([]byte("password"), bcrypt.DefaultCost)),
secret: []byte("abcdefghijklmnopqrstuvwxyz"),
tokenTTL: time.Hour,
}
}
func TestUserPassValidateCredentials(t *testing.T) {
auth := newMockUserPassAuth()
err := auth.validatePassword("username", "password")
ExpectNoError(t, err)
err = auth.validatePassword("username", "wrong-password")
ExpectError(t, ErrInvalidPassword, err)
err = auth.validatePassword("wrong-username", "password")
ExpectError(t, ErrInvalidUsername, err)
}
func TestUserPassCheckToken(t *testing.T) {
auth := newMockUserPassAuth()
token, err := auth.NewToken()
ExpectNoError(t, err)
tests := []struct {
token string
wantErr bool
}{
{
token: token,
wantErr: false,
},
{
token: "invalid-token",
wantErr: true,
},
{
token: "",
wantErr: true,
},
}
for _, tt := range tests {
req := &http.Request{Header: http.Header{}}
if tt.token != "" {
req.Header.Set("Cookie", auth.TokenCookieName()+"="+tt.token)
}
err = auth.CheckToken(req)
if tt.wantErr {
ExpectTrue(t, err != nil)
} else {
ExpectNoError(t, err)
}
}
}
func TestUserPassLoginCallbackHandler(t *testing.T) {
type cred struct {
User string `json:"username"`
Pass string `json:"password"`
}
auth := newMockUserPassAuth()
tests := []struct {
creds cred
wantErr bool
}{
{
creds: cred{
User: "username",
Pass: "password",
},
wantErr: false,
},
{
creds: cred{
User: "username",
Pass: "wrong-password",
},
wantErr: true,
},
}
for _, tt := range tests {
w := httptest.NewRecorder()
req := &http.Request{
Host: "app.example.com",
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 := Must(http.ParseSetCookie(w.Header().Get("Set-Cookie")))
ExpectTrue(t, setCookie.Name == auth.TokenCookieName())
ExpectTrue(t, setCookie.Value != "")
ExpectEqual(t, setCookie.Domain, "example.com")
ExpectEqual(t, setCookie.Path, "/")
ExpectEqual(t, setCookie.SameSite, http.SameSiteLaxMode)
ExpectEqual(t, setCookie.HttpOnly, true)
ExpectEqual(t, w.Code, http.StatusOK)
}
}
}

View File

@@ -1,70 +0,0 @@
package auth
import (
"net"
"net/http"
"time"
"github.com/yusing/go-proxy/internal/common"
"github.com/yusing/go-proxy/internal/gperr"
"github.com/yusing/go-proxy/internal/utils/strutils"
)
var (
ErrMissingToken = gperr.New("missing token")
ErrInvalidToken = gperr.New("invalid token")
ErrUserNotAllowed = gperr.New("user not allowed")
)
// cookieFQDN returns the fully qualified domain name of the request host
// with subdomain stripped.
//
// If the request host does not have a subdomain,
// an empty string is returned
//
// "abc.example.com" -> "example.com"
// "example.com" -> ""
func cookieFQDN(r *http.Request) string {
host, _, err := net.SplitHostPort(r.Host)
if err != nil {
host = r.Host
}
parts := strutils.SplitRune(host, '.')
if len(parts) < 2 {
return ""
}
parts[0] = ""
return strutils.JoinRune(parts, '.')
}
func setTokenCookie(w http.ResponseWriter, r *http.Request, name, value string, ttl time.Duration) {
http.SetCookie(w, &http.Cookie{
Name: name,
Value: value,
MaxAge: int(ttl.Seconds()),
Domain: cookieFQDN(r),
HttpOnly: true,
Secure: common.APIJWTSecure,
SameSite: http.SameSiteLaxMode,
Path: "/",
})
}
func clearTokenCookie(w http.ResponseWriter, r *http.Request, name string) {
http.SetCookie(w, &http.Cookie{
Name: name,
Value: "",
MaxAge: -1,
Domain: cookieFQDN(r),
HttpOnly: true,
Secure: common.APIJWTSecure,
SameSite: http.SameSiteLaxMode,
Path: "/",
})
}
// DefaultLogoutCallbackHandler clears the token cookie and redirects to the login page..
func DefaultLogoutCallbackHandler(auth Provider, w http.ResponseWriter, r *http.Request) {
clearTokenCookie(w, r, auth.TokenCookieName())
auth.RedirectLoginPage(w, r)
}

View File

@@ -1,42 +0,0 @@
package certapi
import (
"net/http"
"github.com/yusing/go-proxy/pkg/json"
config "github.com/yusing/go-proxy/internal/config/types"
)
type CertInfo struct {
Subject string `json:"subject"`
Issuer string `json:"issuer"`
NotBefore int64 `json:"not_before"`
NotAfter int64 `json:"not_after"`
DNSNames []string `json:"dns_names"`
EmailAddresses []string `json:"email_addresses"`
}
func GetCertInfo(w http.ResponseWriter, r *http.Request) {
autocert := config.GetInstance().AutoCertProvider()
if autocert == nil {
http.Error(w, "autocert is not enabled", http.StatusNotFound)
return
}
cert, err := autocert.GetCert(nil)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
certInfo := CertInfo{
Subject: cert.Leaf.Subject.CommonName,
Issuer: cert.Leaf.Issuer.CommonName,
NotBefore: cert.Leaf.NotBefore.Unix(),
NotAfter: cert.Leaf.NotAfter.Unix(),
DNSNames: cert.Leaf.DNSNames,
EmailAddresses: cert.Leaf.EmailAddresses,
}
json.NewEncoder(w).Encode(&certInfo)
}

View File

@@ -1,56 +0,0 @@
package certapi
import (
"net/http"
config "github.com/yusing/go-proxy/internal/config/types"
"github.com/yusing/go-proxy/internal/gperr"
"github.com/yusing/go-proxy/internal/logging"
"github.com/yusing/go-proxy/internal/logging/memlogger"
"github.com/yusing/go-proxy/internal/net/gphttp/gpwebsocket"
)
func RenewCert(w http.ResponseWriter, r *http.Request) {
autocert := config.GetInstance().AutoCertProvider()
if autocert == nil {
http.Error(w, "autocert is not enabled", http.StatusNotFound)
return
}
conn, err := gpwebsocket.Initiate(w, r)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
//nolint:errcheck
defer conn.CloseNow()
logs, cancel := memlogger.Events()
defer cancel()
done := make(chan struct{})
go func() {
defer close(done)
err = autocert.ObtainCert()
if err != nil {
gperr.LogError("failed to obtain cert", err)
gpwebsocket.WriteText(r, conn, err.Error())
} else {
logging.Info().Msg("cert obtained successfully")
}
}()
for {
select {
case l := <-logs:
if err != nil {
return
}
if !gpwebsocket.WriteText(r, conn, string(l)) {
return
}
case <-done:
return
}
}
}

View File

@@ -1,132 +0,0 @@
package v1
import (
"io"
"net/http"
"os"
"path"
"strings"
"github.com/yusing/go-proxy/internal/common"
config "github.com/yusing/go-proxy/internal/config/types"
"github.com/yusing/go-proxy/internal/gperr"
"github.com/yusing/go-proxy/internal/net/gphttp"
"github.com/yusing/go-proxy/internal/net/gphttp/middleware"
"github.com/yusing/go-proxy/internal/route/provider"
)
type FileType string
const (
FileTypeConfig FileType = "config"
FileTypeProvider FileType = "provider"
FileTypeMiddleware FileType = "middleware"
)
func fileType(file string) FileType {
switch {
case strings.HasPrefix(path.Base(file), "config."):
return FileTypeConfig
case strings.HasPrefix(file, common.MiddlewareComposeDir):
return FileTypeMiddleware
}
return FileTypeProvider
}
func (t FileType) IsValid() bool {
switch t {
case FileTypeConfig, FileTypeProvider, FileTypeMiddleware:
return true
}
return false
}
func (t FileType) GetPath(filename string) string {
if t == FileTypeMiddleware {
return path.Join(common.MiddlewareComposeDir, filename)
}
return path.Join(common.ConfigDir, filename)
}
func getArgs(r *http.Request) (fileType FileType, filename string, err error) {
fileType = FileType(r.PathValue("type"))
if !fileType.IsValid() {
err = gphttp.ErrInvalidKey("type")
return
}
filename = r.PathValue("filename")
if filename == "" {
err = gphttp.ErrMissingKey("filename")
}
return
}
func GetFileContent(w http.ResponseWriter, r *http.Request) {
fileType, filename, err := getArgs(r)
if err != nil {
gphttp.BadRequest(w, err.Error())
return
}
content, err := os.ReadFile(fileType.GetPath(filename))
if err != nil {
gphttp.ServerError(w, r, err)
return
}
gphttp.WriteBody(w, content)
}
func validateFile(fileType FileType, content []byte) gperr.Error {
switch fileType {
case FileTypeConfig:
return config.Validate(content)
case FileTypeMiddleware:
errs := gperr.NewBuilder("middleware errors")
middleware.BuildMiddlewaresFromYAML("", content, errs)
return errs.Error()
}
return provider.Validate(content)
}
func ValidateFile(w http.ResponseWriter, r *http.Request) {
fileType := FileType(r.PathValue("type"))
if !fileType.IsValid() {
gphttp.BadRequest(w, "invalid file type")
return
}
content, err := io.ReadAll(r.Body)
if err != nil {
gphttp.ServerError(w, r, err)
return
}
r.Body.Close()
if valErr := validateFile(fileType, content); valErr != nil {
gphttp.JSONError(w, valErr, http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusOK)
}
func SetFileContent(w http.ResponseWriter, r *http.Request) {
fileType, filename, err := getArgs(r)
if err != nil {
gphttp.BadRequest(w, err.Error())
return
}
content, err := io.ReadAll(r.Body)
if err != nil {
gphttp.ServerError(w, r, err)
return
}
if valErr := validateFile(fileType, content); valErr != nil {
gphttp.JSONError(w, valErr, http.StatusBadRequest)
return
}
err = os.WriteFile(fileType.GetPath(filename), content, 0o644)
if err != nil {
gphttp.ServerError(w, r, err)
return
}
w.WriteHeader(http.StatusOK)
}

View File

@@ -1,75 +0,0 @@
//go:build debug
package debugapi
import (
"iter"
"net/http"
"sort"
"time"
"github.com/yusing/go-proxy/agent/pkg/agent"
config "github.com/yusing/go-proxy/internal/config/types"
"github.com/yusing/go-proxy/internal/docker"
"github.com/yusing/go-proxy/internal/idlewatcher"
"github.com/yusing/go-proxy/internal/net/gphttp/gpwebsocket"
"github.com/yusing/go-proxy/internal/net/gphttp/servemux"
"github.com/yusing/go-proxy/internal/net/gphttp/server"
"github.com/yusing/go-proxy/internal/proxmox"
"github.com/yusing/go-proxy/internal/task"
)
func StartServer(cfg config.ConfigInstance) {
srv := server.NewServer(server.Options{
Name: "debug",
HTTPAddr: "127.0.0.1:7777",
Handler: newHandler(cfg),
})
srv.Start(task.RootTask("debug_server", false))
}
type debuggable interface {
MarshalMap() map[string]any
Key() string
}
func toSortedSlice[T debuggable](data iter.Seq2[string, T]) []map[string]any {
s := make([]map[string]any, 0)
for _, v := range data {
m := v.MarshalMap()
m["key"] = v.Key()
s = append(s, m)
}
sort.Slice(s, func(i, j int) bool {
return s[i]["key"].(string) < s[j]["key"].(string)
})
return s
}
func jsonHandler[T debuggable](getData iter.Seq2[string, T]) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
gpwebsocket.DynamicJSONHandler(w, r, func() []map[string]any {
return toSortedSlice(getData)
}, 200*time.Millisecond)
}
}
func iterMap[K comparable, V debuggable](m func() map[K]V) iter.Seq2[K, V] {
return func(yield func(K, V) bool) {
for k, v := range m() {
if !yield(k, v) {
break
}
}
}
}
func newHandler(cfg config.ConfigInstance) http.Handler {
mux := servemux.NewServeMux(cfg)
mux.HandleFunc("GET", "/tasks", jsonHandler(task.AllTasks()))
mux.HandleFunc("GET", "/idlewatcher", jsonHandler(idlewatcher.Watchers()))
mux.HandleFunc("GET", "/agents", jsonHandler(agent.Agents.Iter))
mux.HandleFunc("GET", "/proxmox", jsonHandler(proxmox.Clients.Iter))
mux.HandleFunc("GET", "/docker", jsonHandler(iterMap(docker.Clients)))
return mux
}

View File

@@ -1,11 +0,0 @@
//go:build !debug
package debugapi
import (
config "github.com/yusing/go-proxy/internal/config/types"
)
func StartServer(cfg config.ConfigInstance) {
// do nothing
}

View File

@@ -1,5 +0,0 @@
package dockerapi
import "time"
const reqTimeout = 10 * time.Second

View File

@@ -1,54 +0,0 @@
package dockerapi
import (
"context"
"net/http"
"sort"
"github.com/docker/docker/api/types/container"
"github.com/yusing/go-proxy/internal/gperr"
)
type Container struct {
Server string `json:"server"`
Name string `json:"name"`
ID string `json:"id"`
Image string `json:"image"`
State string `json:"state"`
}
func Containers(w http.ResponseWriter, r *http.Request) {
serveHTTP[Container](w, r, GetContainers)
}
func GetContainers(ctx context.Context, dockerClients DockerClients) ([]Container, gperr.Error) {
errs := gperr.NewBuilder("failed to get containers")
containers := make([]Container, 0)
for server, dockerClient := range dockerClients {
conts, err := dockerClient.ContainerList(ctx, container.ListOptions{All: true})
if err != nil {
errs.Add(err)
continue
}
for _, cont := range conts {
containers = append(containers, Container{
Server: server,
Name: cont.Names[0],
ID: cont.ID,
Image: cont.Image,
State: cont.State,
})
}
}
sort.Slice(containers, func(i, j int) bool {
return containers[i].Name < containers[j].Name
})
if err := errs.Error(); err != nil {
gperr.LogError("failed to get containers", err)
if len(containers) == 0 {
return nil, err
}
return containers, nil
}
return containers, nil
}

View File

@@ -1,57 +0,0 @@
package dockerapi
import (
"context"
"net/http"
"sort"
"github.com/yusing/go-proxy/pkg/json"
dockerSystem "github.com/docker/docker/api/types/system"
"github.com/yusing/go-proxy/internal/gperr"
"github.com/yusing/go-proxy/internal/utils/strutils"
)
type dockerInfo dockerSystem.Info
func (d *dockerInfo) MarshalJSONTo(buf []byte) []byte {
return json.MarshalTo(map[string]any{
"name": d.Name,
"version": d.ServerVersion,
"containers": map[string]int{
"total": d.Containers,
"running": d.ContainersRunning,
"paused": d.ContainersPaused,
"stopped": d.ContainersStopped,
},
"images": d.Images,
"n_cpu": d.NCPU,
"memory": strutils.FormatByteSize(d.MemTotal),
}, buf)
}
func DockerInfo(w http.ResponseWriter, r *http.Request) {
serveHTTP[dockerInfo](w, r, GetDockerInfo)
}
func GetDockerInfo(ctx context.Context, dockerClients DockerClients) ([]dockerInfo, gperr.Error) {
errs := gperr.NewBuilder("failed to get docker info")
dockerInfos := make([]dockerInfo, len(dockerClients))
i := 0
for name, dockerClient := range dockerClients {
info, err := dockerClient.Info(ctx)
if err != nil {
errs.Add(err)
continue
}
info.Name = name
dockerInfos[i] = dockerInfo(info)
i++
}
sort.Slice(dockerInfos, func(i, j int) bool {
return dockerInfos[i].Name < dockerInfos[j].Name
})
return dockerInfos, errs.Error()
}

View File

@@ -1,69 +0,0 @@
package dockerapi
import (
"net/http"
"github.com/coder/websocket"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/pkg/stdcopy"
"github.com/yusing/go-proxy/internal/logging"
"github.com/yusing/go-proxy/internal/net/gphttp"
"github.com/yusing/go-proxy/internal/net/gphttp/gpwebsocket"
"github.com/yusing/go-proxy/internal/utils/strutils"
)
func Logs(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query()
server := r.PathValue("server")
containerID := r.PathValue("container")
stdout := strutils.ParseBool(query.Get("stdout"))
stderr := strutils.ParseBool(query.Get("stderr"))
since := query.Get("from")
until := query.Get("to")
levels := query.Get("levels") // TODO: implement levels
dockerClient, found, err := getDockerClient(server)
if err != nil {
gphttp.BadRequest(w, err.Error())
return
}
if !found {
gphttp.NotFound(w, "server not found")
return
}
opts := container.LogsOptions{
ShowStdout: stdout,
ShowStderr: stderr,
Since: since,
Until: until,
Timestamps: true,
Follow: true,
Tail: "100",
}
if levels != "" {
opts.Details = true
}
logs, err := dockerClient.ContainerLogs(r.Context(), containerID, opts)
if err != nil {
gphttp.BadRequest(w, err.Error())
return
}
defer logs.Close()
conn, err := gpwebsocket.Initiate(w, r)
if err != nil {
return
}
defer conn.CloseNow()
writer := gpwebsocket.NewWriter(r.Context(), conn, websocket.MessageText)
_, err = stdcopy.StdCopy(writer, writer, logs) // de-multiplex logs
if err != nil {
logging.Err(err).
Str("server", server).
Str("container", containerID).
Msg("failed to de-multiplex logs")
}
}

View File

@@ -1,126 +0,0 @@
package dockerapi
import (
"context"
"net/http"
"time"
"github.com/yusing/go-proxy/pkg/json"
"github.com/coder/websocket"
"github.com/coder/websocket/wsjson"
"github.com/yusing/go-proxy/agent/pkg/agent"
config "github.com/yusing/go-proxy/internal/config/types"
"github.com/yusing/go-proxy/internal/docker"
"github.com/yusing/go-proxy/internal/gperr"
"github.com/yusing/go-proxy/internal/net/gphttp/gpwebsocket"
"github.com/yusing/go-proxy/internal/net/gphttp/httpheaders"
)
type (
DockerClients map[string]*docker.SharedClient
ResultType[T any] interface {
map[string]T | []T
}
)
// getDockerClients returns a map of docker clients for the current config.
//
// Returns a map of docker clients by server name and an error if any.
//
// Even if there are errors, the map of docker clients might not be empty.
func getDockerClients() (DockerClients, gperr.Error) {
cfg := config.GetInstance()
dockerHosts := cfg.Value().Providers.Docker
dockerClients := make(DockerClients)
connErrs := gperr.NewBuilder("failed to connect to docker")
for name, host := range dockerHosts {
dockerClient, err := docker.NewClient(host)
if err != nil {
connErrs.Add(err)
continue
}
dockerClients[name] = dockerClient
}
for _, agent := range agent.Agents.Iter {
dockerClient, err := docker.NewClient(agent.FakeDockerHost())
if err != nil {
connErrs.Add(err)
continue
}
dockerClients[agent.Name()] = dockerClient
}
return dockerClients, connErrs.Error()
}
func getDockerClient(server string) (*docker.SharedClient, bool, error) {
cfg := config.GetInstance()
var host string
for name, h := range cfg.Value().Providers.Docker {
if name == server {
host = h
break
}
}
for _, agent := range agent.Agents.Iter {
if agent.Name() == server {
host = agent.FakeDockerHost()
break
}
}
if host == "" {
return nil, false, nil
}
dockerClient, err := docker.NewClient(host)
if err != nil {
return nil, false, err
}
return dockerClient, true, nil
}
// closeAllClients closes all docker clients after a delay.
//
// This is used to ensure that all docker clients are closed after the http handler returns.
func closeAllClients(dockerClients DockerClients) {
for _, dockerClient := range dockerClients {
dockerClient.Close()
}
}
func handleResult[V any, T ResultType[V]](w http.ResponseWriter, errs error, result T) {
if errs != nil {
gperr.LogError("docker errors", errs)
if len(result) == 0 {
http.Error(w, "docker errors", http.StatusInternalServerError)
return
}
}
json.NewEncoder(w).Encode(result)
}
func serveHTTP[V any, T ResultType[V]](w http.ResponseWriter, r *http.Request, getResult func(ctx context.Context, dockerClients DockerClients) (T, gperr.Error)) {
dockerClients, err := getDockerClients()
if err != nil {
handleResult[V, T](w, err, nil)
return
}
defer closeAllClients(dockerClients)
if httpheaders.IsWebsocket(r.Header) {
gpwebsocket.Periodic(w, r, 5*time.Second, func(conn *websocket.Conn) error {
result, err := getResult(r.Context(), dockerClients)
if err != nil {
return err
}
return wsjson.Write(r.Context(), conn, result)
})
} else {
result, err := getResult(r.Context(), dockerClients)
handleResult[V](w, err, result)
}
}

View File

@@ -1,77 +0,0 @@
package favicon
import (
"errors"
"net/http"
"github.com/yusing/go-proxy/internal/gperr"
"github.com/yusing/go-proxy/internal/homepage"
"github.com/yusing/go-proxy/internal/net/gphttp"
"github.com/yusing/go-proxy/internal/route/routes"
)
// GetFavIcon returns the favicon of the route
//
// Returns:
// - 200 OK: if icon found
// - 400 Bad Request: if alias is empty or route is not HTTPRoute
// - 404 Not Found: if route or icon not found
// - 500 Internal Server Error: if internal error
// - others: depends on route handler response
func GetFavIcon(w http.ResponseWriter, req *http.Request) {
url, alias := req.FormValue("url"), req.FormValue("alias")
if url == "" && alias == "" {
gphttp.ClientError(w, gphttp.ErrMissingKey("url or alias"), http.StatusBadRequest)
return
}
if url != "" && alias != "" {
gphttp.ClientError(w, gperr.New("url and alias are mutually exclusive"), http.StatusBadRequest)
return
}
// try with url
if url != "" {
var iconURL homepage.IconURL
if err := iconURL.Parse(url); err != nil {
gphttp.ClientError(w, err, http.StatusBadRequest)
return
}
fetchResult := homepage.FetchFavIconFromURL(&iconURL)
if !fetchResult.OK() {
http.Error(w, fetchResult.ErrMsg, fetchResult.StatusCode)
return
}
w.Header().Set("Content-Type", fetchResult.ContentType())
gphttp.WriteBody(w, fetchResult.Icon)
return
}
// try with route.Icon
r, ok := routes.GetHTTPRoute(alias)
if !ok {
gphttp.ClientError(w, errors.New("no such route"), http.StatusNotFound)
return
}
var result *homepage.FetchResult
hp := r.HomepageItem()
if hp.Icon != nil {
if hp.Icon.IconSource == homepage.IconSourceRelative {
result = homepage.FindIcon(req.Context(), r, hp.Icon.Value)
} else {
result = homepage.FetchFavIconFromURL(hp.Icon)
}
} else {
// try extract from "link[rel=icon]"
result = homepage.FindIcon(req.Context(), r, "/")
}
if result.StatusCode == 0 {
result.StatusCode = http.StatusOK
}
if !result.OK() {
http.Error(w, result.ErrMsg, result.StatusCode)
return
}
w.Header().Set("Content-Type", result.ContentType())
gphttp.WriteBody(w, result.Icon)
}

View File

@@ -1,13 +0,0 @@
package v1
import (
"net/http"
"time"
"github.com/yusing/go-proxy/internal/net/gphttp/gpwebsocket"
"github.com/yusing/go-proxy/internal/route/routes/routequery"
)
func Health(w http.ResponseWriter, r *http.Request) {
gpwebsocket.DynamicJSONHandler(w, r, routequery.HealthMap, 1*time.Second)
}

View File

@@ -1,90 +0,0 @@
package v1
import (
"io"
"net/http"
"github.com/yusing/go-proxy/internal/homepage"
"github.com/yusing/go-proxy/internal/net/gphttp"
"github.com/yusing/go-proxy/pkg/json"
)
const (
HomepageOverrideItem = "item"
HomepageOverrideItemsBatch = "items_batch"
HomepageOverrideCategoryOrder = "category_order"
HomepageOverrideItemVisible = "item_visible"
)
type (
HomepageOverrideItemParams struct {
Which string `json:"which"`
Value homepage.ItemConfig `json:"value"`
}
HomepageOverrideItemsBatchParams struct {
Value map[string]*homepage.ItemConfig `json:"value"`
}
HomepageOverrideCategoryOrderParams struct {
Which string `json:"which"`
Value int `json:"value"`
}
HomepageOverrideItemVisibleParams struct {
Which []string `json:"which"`
Value bool `json:"value"`
}
)
func SetHomePageOverrides(w http.ResponseWriter, r *http.Request) {
what := r.FormValue("what")
if what == "" {
gphttp.BadRequest(w, "missing what or which")
return
}
data, err := io.ReadAll(r.Body)
if err != nil {
gphttp.ClientError(w, err, http.StatusBadRequest)
return
}
r.Body.Close()
overrides := homepage.GetOverrideConfig()
switch what {
case HomepageOverrideItem:
var params HomepageOverrideItemParams
if err := json.Unmarshal(data, &params); err != nil {
gphttp.ClientError(w, err, http.StatusBadRequest)
return
}
overrides.OverrideItem(params.Which, &params.Value)
case HomepageOverrideItemsBatch:
var params HomepageOverrideItemsBatchParams
if err := json.Unmarshal(data, &params); err != nil {
gphttp.ClientError(w, err, http.StatusBadRequest)
return
}
overrides.OverrideItems(params.Value)
case HomepageOverrideItemVisible: // POST /v1/item_visible [a,b,c], false => hide a, b, c
var params HomepageOverrideItemVisibleParams
if err := json.Unmarshal(data, &params); err != nil {
gphttp.ClientError(w, err, http.StatusBadRequest)
return
}
if params.Value {
overrides.UnhideItems(params.Which)
} else {
overrides.HideItems(params.Which)
}
case HomepageOverrideCategoryOrder:
var params HomepageOverrideCategoryOrderParams
if err := json.Unmarshal(data, &params); err != nil {
gphttp.ClientError(w, err, http.StatusBadRequest)
return
}
overrides.SetCategoryOrder(params.Which, params.Value)
default:
http.Error(w, "invalid what", http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusOK)
}

View File

@@ -1,11 +0,0 @@
package v1
import (
"net/http"
"github.com/yusing/go-proxy/internal/net/gphttp"
)
func Index(w http.ResponseWriter, r *http.Request) {
gphttp.WriteBody(w, []byte("API ready"))
}

View File

@@ -1,124 +0,0 @@
package v1
import (
"fmt"
"net/http"
"strconv"
"strings"
"github.com/yusing/go-proxy/internal/common"
config "github.com/yusing/go-proxy/internal/config/types"
"github.com/yusing/go-proxy/internal/homepage"
"github.com/yusing/go-proxy/internal/net/gphttp"
"github.com/yusing/go-proxy/internal/net/gphttp/middleware"
"github.com/yusing/go-proxy/internal/route/routes/routequery"
route "github.com/yusing/go-proxy/internal/route/types"
"github.com/yusing/go-proxy/internal/utils"
)
const (
ListRoute = "route"
ListRoutes = "routes"
ListFiles = "files"
ListMiddlewares = "middlewares"
ListMiddlewareTraces = "middleware_trace"
ListMatchDomains = "match_domains"
ListHomepageConfig = "homepage_config"
ListRouteProviders = "route_providers"
ListHomepageCategories = "homepage_categories"
ListIcons = "icons"
)
func List(cfg config.ConfigInstance, w http.ResponseWriter, r *http.Request) {
what := r.PathValue("what")
if what == "" {
what = ListRoutes
}
which := r.PathValue("which")
switch what {
case ListRoute:
route := listRoute(which)
if route == nil {
http.NotFound(w, r)
} else {
gphttp.RespondJSON(w, r, route)
}
case ListRoutes:
gphttp.RespondJSON(w, r, routequery.RoutesByAlias(route.RouteType(r.FormValue("type"))))
case ListFiles:
listFiles(w, r)
case ListMiddlewares:
gphttp.RespondJSON(w, r, middleware.All())
case ListMiddlewareTraces:
gphttp.RespondJSON(w, r, middleware.GetAllTrace())
case ListMatchDomains:
gphttp.RespondJSON(w, r, cfg.Value().MatchDomains)
case ListHomepageConfig:
gphttp.RespondJSON(w, r, routequery.HomepageConfig(r.FormValue("category"), r.FormValue("provider")))
case ListRouteProviders:
gphttp.RespondJSON(w, r, cfg.RouteProviderList())
case ListHomepageCategories:
gphttp.RespondJSON(w, r, routequery.HomepageCategories())
case ListIcons:
limit, err := strconv.Atoi(r.FormValue("limit"))
if err != nil {
limit = 0
}
icons, err := homepage.SearchIcons(r.FormValue("keyword"), limit)
if err != nil {
gphttp.ClientError(w, err)
return
}
if icons == nil {
icons = []string{}
}
gphttp.RespondJSON(w, r, icons)
default:
gphttp.BadRequest(w, fmt.Sprintf("invalid what: %s", what))
}
}
// if which is "all" or empty, return map[string]Route of all routes
// otherwise, return a single Route with alias which or nil if not found.
func listRoute(which string) any {
if which == "" || which == "all" {
return routequery.RoutesByAlias()
}
routes := routequery.RoutesByAlias()
route, ok := routes[which]
if !ok {
return nil
}
return route
}
func listFiles(w http.ResponseWriter, r *http.Request) {
files, err := utils.ListFiles(common.ConfigDir, 0, true)
if err != nil {
gphttp.ServerError(w, r, err)
return
}
resp := map[FileType][]string{
FileTypeConfig: make([]string, 0),
FileTypeProvider: make([]string, 0),
FileTypeMiddleware: make([]string, 0),
}
for _, file := range files {
t := fileType(file)
file = strings.TrimPrefix(file, common.ConfigDir+"/")
resp[t] = append(resp[t], file)
}
mids, err := utils.ListFiles(common.MiddlewareComposeDir, 0, true)
if err != nil {
gphttp.ServerError(w, r, err)
return
}
for _, mid := range mids {
mid = strings.TrimPrefix(mid, common.MiddlewareComposeDir+"/")
resp[FileTypeMiddleware] = append(resp[FileTypeMiddleware], mid)
}
gphttp.RespondJSON(w, r, resp)
}

View File

@@ -1,143 +0,0 @@
package v1
import (
"fmt"
"io"
"net/http"
"os"
"strconv"
"github.com/yusing/go-proxy/pkg/json"
_ "embed"
"github.com/yusing/go-proxy/agent/pkg/agent"
"github.com/yusing/go-proxy/agent/pkg/certs"
config "github.com/yusing/go-proxy/internal/config/types"
"github.com/yusing/go-proxy/internal/net/gphttp"
"github.com/yusing/go-proxy/internal/utils/strutils"
)
func NewAgent(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
name := q.Get("name")
if name == "" {
gphttp.ClientError(w, gphttp.ErrMissingKey("name"))
return
}
host := q.Get("host")
if host == "" {
gphttp.ClientError(w, gphttp.ErrMissingKey("host"))
return
}
portStr := q.Get("port")
if portStr == "" {
gphttp.ClientError(w, gphttp.ErrMissingKey("port"))
return
}
port, err := strconv.Atoi(portStr)
if err != nil || port < 1 || port > 65535 {
gphttp.ClientError(w, gphttp.ErrInvalidKey("port"))
return
}
hostport := fmt.Sprintf("%s:%d", host, port)
if _, ok := agent.Agents.Get(hostport); ok {
gphttp.ClientError(w, gphttp.ErrAlreadyExists("agent", hostport), http.StatusConflict)
return
}
t := q.Get("type")
switch t {
case "docker", "system":
break
case "":
gphttp.ClientError(w, gphttp.ErrMissingKey("type"))
return
default:
gphttp.ClientError(w, gphttp.ErrInvalidKey("type"))
return
}
nightly := strutils.ParseBool(q.Get("nightly"))
var image string
if nightly {
image = agent.DockerImageNightly
} else {
image = agent.DockerImageProduction
}
ca, srv, client, err := agent.NewAgent()
if err != nil {
gphttp.ServerError(w, r, err)
return
}
var cfg agent.Generator = &agent.AgentEnvConfig{
Name: name,
Port: port,
CACert: ca.String(),
SSLCert: srv.String(),
}
if t == "docker" {
cfg = &agent.AgentComposeConfig{
Image: image,
AgentEnvConfig: cfg.(*agent.AgentEnvConfig),
}
}
template, err := cfg.Generate()
if err != nil {
gphttp.ServerError(w, r, err)
return
}
gphttp.RespondJSON(w, r, map[string]any{
"compose": template,
"ca": ca,
"client": client,
})
}
func VerifyNewAgent(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
clientPEMData, err := io.ReadAll(r.Body)
if err != nil {
gphttp.ServerError(w, r, err)
return
}
var data struct {
Host string `json:"host"`
CA agent.PEMPair `json:"ca"`
Client agent.PEMPair `json:"client"`
}
if err := json.Unmarshal(clientPEMData, &data); err != nil {
gphttp.ClientError(w, err, http.StatusBadRequest)
return
}
nRoutesAdded, err := config.GetInstance().VerifyNewAgent(data.Host, data.CA, data.Client)
if err != nil {
gphttp.ClientError(w, err)
return
}
zip, err := certs.ZipCert(data.CA.Cert, data.Client.Cert, data.Client.Key)
if err != nil {
gphttp.ServerError(w, r, err)
return
}
filename, ok := certs.AgentCertsFilepath(data.Host)
if !ok {
gphttp.ClientError(w, gphttp.ErrInvalidKey("host"))
return
}
if err := os.WriteFile(filename, zip, 0600); err != nil {
gphttp.ServerError(w, r, err)
return
}
w.WriteHeader(http.StatusOK)
w.Write(fmt.Appendf(nil, "Added %d routes", nRoutesAdded))
}

View File

@@ -1,61 +0,0 @@
package query
import (
"fmt"
"io"
"net/http"
"github.com/yusing/go-proxy/pkg/json"
v1 "github.com/yusing/go-proxy/internal/api/v1"
"github.com/yusing/go-proxy/internal/common"
"github.com/yusing/go-proxy/internal/gperr"
"github.com/yusing/go-proxy/internal/net/gphttp"
"github.com/yusing/go-proxy/internal/net/gphttp/middleware"
)
func ReloadServer() gperr.Error {
resp, err := gphttp.Post(common.APIHTTPURL+"/v1/reload", "", nil)
if err != nil {
return gperr.Wrap(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
failure := gperr.Errorf("server reload status %v", resp.StatusCode)
body, err := io.ReadAll(resp.Body)
if err != nil {
return failure.With(err)
}
reloadErr := string(body)
return failure.Withf(reloadErr)
}
return nil
}
func List[T any](what string) (_ T, outErr gperr.Error) {
resp, err := gphttp.Get(fmt.Sprintf("%s/v1/list/%s", common.APIHTTPURL, what))
if err != nil {
outErr = gperr.Wrap(err)
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
outErr = gperr.Errorf("list %s: failed, status %v", what, resp.StatusCode)
return
}
var res T
err = json.NewDecoder(resp.Body).Decode(&res)
if err != nil {
outErr = gperr.Wrap(err)
return
}
return res, nil
}
func ListRoutes() (map[string]map[string]any, gperr.Error) {
return List[map[string]map[string]any](v1.ListRoutes)
}
func ListMiddlewareTraces() (middleware.Traces, gperr.Error) {
return List[middleware.Traces](v1.ListMiddlewareTraces)
}

View File

@@ -1,16 +0,0 @@
package v1
import (
"net/http"
config "github.com/yusing/go-proxy/internal/config/types"
"github.com/yusing/go-proxy/internal/net/gphttp"
)
func Reload(cfg config.ConfigInstance, w http.ResponseWriter, r *http.Request) {
if err := cfg.Reload(); err != nil {
gphttp.ServerError(w, r, err)
return
}
gphttp.WriteBody(w, []byte("OK"))
}

View File

@@ -1,21 +0,0 @@
package v1
import (
"net/http"
"time"
config "github.com/yusing/go-proxy/internal/config/types"
"github.com/yusing/go-proxy/internal/net/gphttp/gpwebsocket"
"github.com/yusing/go-proxy/internal/utils/strutils"
)
func Stats(cfg config.ConfigInstance, w http.ResponseWriter, r *http.Request) {
gpwebsocket.DynamicJSONHandler(w, r, func() map[string]any {
return map[string]any{
"proxies": cfg.Statistics(),
"uptime": strutils.FormatDuration(time.Since(startTime)),
}
}, 1*time.Second)
}
var startTime = time.Now()

View File

@@ -1,53 +0,0 @@
package v1
import (
"net/http"
"github.com/yusing/go-proxy/agent/pkg/agent"
agentPkg "github.com/yusing/go-proxy/agent/pkg/agent"
"github.com/yusing/go-proxy/internal/gperr"
"github.com/yusing/go-proxy/internal/metrics/systeminfo"
"github.com/yusing/go-proxy/internal/net/gphttp"
"github.com/yusing/go-proxy/internal/net/gphttp/httpheaders"
"github.com/yusing/go-proxy/internal/net/gphttp/reverseproxy"
)
func SystemInfo(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query()
agentAddr := query.Get("agent_addr")
query.Del("agent_addr")
if agentAddr == "" {
systeminfo.Poller.ServeHTTP(w, r)
return
}
agent, ok := agent.Agents.Get(agentAddr)
if !ok {
gphttp.NotFound(w, "agent_addr")
return
}
isWS := httpheaders.IsWebsocket(r.Header)
if !isWS {
respData, status, err := agent.Forward(r, agentPkg.EndpointSystemInfo)
if err != nil {
gphttp.ServerError(w, r, gperr.Wrap(err, "failed to forward request to agent"))
return
}
if status != http.StatusOK {
http.Error(w, string(respData), status)
return
}
gphttp.WriteBody(w, respData)
} else {
rp := reverseproxy.NewReverseProxy("agent", agentPkg.AgentURL, agent.Transport())
header := r.Header.Clone()
r, err := http.NewRequestWithContext(r.Context(), r.Method, agentPkg.EndpointSystemInfo+"?"+query.Encode(), nil)
if err != nil {
gphttp.ServerError(w, r, gperr.Wrap(err, "failed to create request"))
return
}
r.Header = header
rp.ServeHTTP(w, r)
}
}

View File

@@ -1,12 +0,0 @@
package v1
import (
"net/http"
"github.com/yusing/go-proxy/internal/net/gphttp"
"github.com/yusing/go-proxy/pkg"
)
func GetVersion(w http.ResponseWriter, r *http.Request) {
gphttp.WriteBody(w, []byte(pkg.GetVersion().String()))
}

View File

@@ -1,147 +0,0 @@
package autocert
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"os"
"regexp"
"github.com/go-acme/lego/v4/certcrypto"
"github.com/go-acme/lego/v4/lego"
"github.com/yusing/go-proxy/internal/gperr"
"github.com/yusing/go-proxy/internal/logging"
"github.com/yusing/go-proxy/internal/utils"
"github.com/yusing/go-proxy/internal/utils/strutils"
)
type (
AutocertConfig struct {
Email string `json:"email,omitempty"`
Domains []string `json:"domains,omitempty"`
CertPath string `json:"cert_path,omitempty"`
KeyPath string `json:"key_path,omitempty"`
ACMEKeyPath string `json:"acme_key_path,omitempty"`
Provider string `json:"provider,omitempty"`
Options ProviderOpt `json:"options,omitempty"`
}
ProviderOpt map[string]any
)
var (
ErrMissingDomain = gperr.New("missing field 'domains'")
ErrMissingEmail = gperr.New("missing field 'email'")
ErrMissingProvider = gperr.New("missing field 'provider'")
ErrInvalidDomain = gperr.New("invalid domain")
ErrUnknownProvider = gperr.New("unknown provider")
)
var domainOrWildcardRE = regexp.MustCompile(`^\*?([^.]+\.)+[^.]+$`)
// Validate implements the utils.CustomValidator interface.
func (cfg *AutocertConfig) Validate() gperr.Error {
if cfg == nil {
return nil
}
if cfg.Provider == "" {
cfg.Provider = ProviderLocal
return nil
}
b := gperr.NewBuilder("autocert errors")
if cfg.Provider != ProviderLocal && cfg.Provider != ProviderPseudo {
if len(cfg.Domains) == 0 {
b.Add(ErrMissingDomain)
}
if cfg.Email == "" {
b.Add(ErrMissingEmail)
}
for i, d := range cfg.Domains {
if !domainOrWildcardRE.MatchString(d) {
b.Add(ErrInvalidDomain.Subjectf("domains[%d]", i))
}
}
// check if provider is implemented
providerConstructor, ok := providersGenMap[cfg.Provider]
if !ok {
b.Add(ErrUnknownProvider.
Subject(cfg.Provider).
Withf(strutils.DoYouMean(utils.NearestField(cfg.Provider, providersGenMap))))
} else {
_, err := providerConstructor(cfg.Options)
if err != nil {
b.Add(err)
}
}
}
return b.Error()
}
func (cfg *AutocertConfig) GetProvider() (*Provider, gperr.Error) {
if cfg == nil {
cfg = new(AutocertConfig)
}
if err := cfg.Validate(); err != nil {
return nil, err
}
if cfg.CertPath == "" {
cfg.CertPath = CertFileDefault
}
if cfg.KeyPath == "" {
cfg.KeyPath = KeyFileDefault
}
if cfg.ACMEKeyPath == "" {
cfg.ACMEKeyPath = ACMEKeyFileDefault
}
var privKey *ecdsa.PrivateKey
var err error
if cfg.Provider != ProviderLocal && cfg.Provider != ProviderPseudo {
if privKey, err = cfg.loadACMEKey(); err != nil {
logging.Info().Err(err).Msg("load ACME private key failed")
logging.Info().Msg("generate new ACME private key")
privKey, err = ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, gperr.New("generate ACME private key").With(err)
}
if err = cfg.saveACMEKey(privKey); err != nil {
return nil, gperr.New("save ACME private key").With(err)
}
}
}
user := &User{
Email: cfg.Email,
key: privKey,
}
legoCfg := lego.NewConfig(user)
legoCfg.Certificate.KeyType = certcrypto.RSA2048
return &Provider{
cfg: cfg,
user: user,
legoCfg: legoCfg,
}, nil
}
func (cfg *AutocertConfig) loadACMEKey() (*ecdsa.PrivateKey, error) {
data, err := os.ReadFile(cfg.ACMEKeyPath)
if err != nil {
return nil, err
}
return x509.ParseECPrivateKey(data)
}
func (cfg *AutocertConfig) saveACMEKey(key *ecdsa.PrivateKey) error {
data, err := x509.MarshalECPrivateKey(key)
if err != nil {
return err
}
return os.WriteFile(cfg.ACMEKeyPath, data, 0o600)
}

View File

@@ -1,38 +0,0 @@
package autocert
import (
"path/filepath"
"github.com/go-acme/lego/v4/providers/dns/clouddns"
"github.com/go-acme/lego/v4/providers/dns/cloudflare"
"github.com/go-acme/lego/v4/providers/dns/duckdns"
"github.com/go-acme/lego/v4/providers/dns/ovh"
"github.com/go-acme/lego/v4/providers/dns/porkbun"
"github.com/yusing/go-proxy/internal/common"
)
var (
CertFileDefault = filepath.Join(common.CertsDir, "cert.crt")
KeyFileDefault = filepath.Join(common.CertsDir, "priv.key")
ACMEKeyFileDefault = filepath.Join(common.CertsDir, "acme.key")
)
const (
ProviderLocal = "local"
ProviderCloudflare = "cloudflare"
ProviderClouddns = "clouddns"
ProviderDuckdns = "duckdns"
ProviderOVH = "ovh"
ProviderPseudo = "pseudo" // for testing
ProviderPorkbun = "porkbun"
)
var providersGenMap = map[string]ProviderGenerator{
ProviderLocal: providerGenerator(NewDummyDefaultConfig, NewDummyDNSProviderConfig),
ProviderCloudflare: providerGenerator(cloudflare.NewDefaultConfig, cloudflare.NewDNSProviderConfig),
ProviderClouddns: providerGenerator(clouddns.NewDefaultConfig, clouddns.NewDNSProviderConfig),
ProviderDuckdns: providerGenerator(duckdns.NewDefaultConfig, duckdns.NewDNSProviderConfig),
ProviderOVH: providerGenerator(ovh.NewDefaultConfig, ovh.NewDNSProviderConfig),
ProviderPseudo: providerGenerator(NewDummyDefaultConfig, NewDummyDNSProviderConfig),
ProviderPorkbun: providerGenerator(porkbun.NewDefaultConfig, porkbun.NewDNSProviderConfig),
}

View File

@@ -1,20 +0,0 @@
package autocert
type DummyConfig struct{}
type DummyProvider struct{}
func NewDummyDefaultConfig() *DummyConfig {
return &DummyConfig{}
}
func NewDummyDNSProviderConfig(*DummyConfig) (*DummyProvider, error) {
return &DummyProvider{}, nil
}
func (DummyProvider) Present(domain, token, keyAuth string) error {
return nil
}
func (DummyProvider) CleanUp(domain, token, keyAuth string) error {
return nil
}

View File

@@ -1,339 +0,0 @@
package autocert
import (
"crypto/tls"
"crypto/x509"
"errors"
"fmt"
"os"
"path"
"reflect"
"sort"
"sync"
"time"
"github.com/go-acme/lego/v4/certificate"
"github.com/go-acme/lego/v4/challenge"
"github.com/go-acme/lego/v4/lego"
"github.com/go-acme/lego/v4/registration"
"github.com/yusing/go-proxy/internal/gperr"
"github.com/yusing/go-proxy/internal/logging"
"github.com/yusing/go-proxy/internal/task"
"github.com/yusing/go-proxy/internal/utils"
"github.com/yusing/go-proxy/internal/utils/strutils"
)
type (
Provider struct {
cfg *AutocertConfig
user *User
legoCfg *lego.Config
client *lego.Client
legoCert *certificate.Resource
tlsCert *tls.Certificate
certExpiries CertExpiries
obtainMu sync.Mutex
}
ProviderGenerator func(ProviderOpt) (challenge.Provider, gperr.Error)
CertExpiries map[string]time.Time
)
var ErrGetCertFailure = errors.New("get certificate failed")
func (p *Provider) GetCert(_ *tls.ClientHelloInfo) (*tls.Certificate, error) {
if p.tlsCert == nil {
return nil, ErrGetCertFailure
}
return p.tlsCert, nil
}
func (p *Provider) GetName() string {
return p.cfg.Provider
}
func (p *Provider) GetCertPath() string {
return p.cfg.CertPath
}
func (p *Provider) GetKeyPath() string {
return p.cfg.KeyPath
}
func (p *Provider) GetExpiries() CertExpiries {
return p.certExpiries
}
func (p *Provider) ObtainCert() error {
if p.cfg.Provider == ProviderLocal {
return nil
}
if p.cfg.Provider == ProviderPseudo {
t := time.NewTicker(1000 * time.Millisecond)
defer t.Stop()
logging.Info().Msg("init client for pseudo provider")
<-t.C
logging.Info().Msg("registering acme for pseudo provider")
<-t.C
logging.Info().Msg("obtained cert for pseudo provider")
return nil
}
if p.client == nil {
if err := p.initClient(); err != nil {
return err
}
}
if p.user.Registration == nil {
if err := p.registerACME(); err != nil {
return err
}
}
var cert *certificate.Resource
var err error
if p.legoCert != nil {
cert, err = p.client.Certificate.RenewWithOptions(*p.legoCert, &certificate.RenewOptions{
Bundle: true,
})
if err != nil {
p.legoCert = nil
logging.Err(err).Msg("cert renew failed, fallback to obtain")
} else {
p.legoCert = cert
}
}
if cert == nil {
cert, err = p.client.Certificate.Obtain(certificate.ObtainRequest{
Domains: p.cfg.Domains,
Bundle: true,
})
if err != nil {
return err
}
}
if err = p.saveCert(cert); err != nil {
return err
}
tlsCert, err := tls.X509KeyPair(cert.Certificate, cert.PrivateKey)
if err != nil {
return err
}
expiries, err := getCertExpiries(&tlsCert)
if err != nil {
return err
}
p.tlsCert = &tlsCert
p.certExpiries = expiries
return nil
}
func (p *Provider) LoadCert() error {
cert, err := tls.LoadX509KeyPair(p.cfg.CertPath, p.cfg.KeyPath)
if err != nil {
return fmt.Errorf("load SSL certificate: %w", err)
}
expiries, err := getCertExpiries(&cert)
if err != nil {
return fmt.Errorf("parse SSL certificate: %w", err)
}
p.tlsCert = &cert
p.certExpiries = expiries
logging.Info().Msgf("next renewal in %v", strutils.FormatDuration(time.Until(p.ShouldRenewOn())))
return p.renewIfNeeded()
}
// ShouldRenewOn returns the time at which the certificate should be renewed.
func (p *Provider) ShouldRenewOn() time.Time {
for _, expiry := range p.certExpiries {
return expiry.AddDate(0, -1, 0) // 1 month before
}
// this line should never be reached
panic("no certificate available")
}
func (p *Provider) ScheduleRenewal(parent task.Parent) {
if p.GetName() == ProviderLocal || p.GetName() == ProviderPseudo {
return
}
go func() {
lastErrOn := time.Time{}
renewalTime := p.ShouldRenewOn()
timer := time.NewTimer(time.Until(renewalTime))
defer timer.Stop()
task := parent.Subtask("cert-renew-scheduler")
defer task.Finish(nil)
for {
select {
case <-task.Context().Done():
return
case <-timer.C:
// Retry after 1 hour on failure
if !lastErrOn.IsZero() && time.Now().Before(lastErrOn.Add(time.Hour)) {
continue
}
if err := p.renewIfNeeded(); err != nil {
gperr.LogWarn("cert renew failed", err)
lastErrOn = time.Now()
continue
}
// Reset on success
lastErrOn = time.Time{}
renewalTime = p.ShouldRenewOn()
timer.Reset(time.Until(renewalTime))
}
}
}()
}
func (p *Provider) initClient() error {
legoClient, err := lego.NewClient(p.legoCfg)
if err != nil {
return err
}
generator := providersGenMap[p.cfg.Provider]
legoProvider, pErr := generator(p.cfg.Options)
if pErr != nil {
return pErr
}
err = legoClient.Challenge.SetDNS01Provider(legoProvider)
if err != nil {
return err
}
p.client = legoClient
return nil
}
func (p *Provider) registerACME() error {
if p.user.Registration != nil {
return nil
}
if reg, err := p.client.Registration.ResolveAccountByKey(); err == nil {
p.user.Registration = reg
logging.Info().Msg("reused acme registration from private key")
return nil
}
reg, err := p.client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true})
if err != nil {
return err
}
p.user.Registration = reg
logging.Info().Interface("reg", reg).Msg("acme registered")
return nil
}
func (p *Provider) saveCert(cert *certificate.Resource) error {
/* This should have been done in setup
but double check is always a good choice.*/
_, err := os.Stat(path.Dir(p.cfg.CertPath))
if err != nil {
if os.IsNotExist(err) {
if err = os.MkdirAll(path.Dir(p.cfg.CertPath), 0o755); err != nil {
return err
}
} else {
return err
}
}
err = os.WriteFile(p.cfg.KeyPath, cert.PrivateKey, 0o600) // -rw-------
if err != nil {
return err
}
err = os.WriteFile(p.cfg.CertPath, cert.Certificate, 0o644) // -rw-r--r--
if err != nil {
return err
}
return nil
}
func (p *Provider) certState() CertState {
if time.Now().After(p.ShouldRenewOn()) {
return CertStateExpired
}
certDomains := make([]string, len(p.certExpiries))
wantedDomains := make([]string, len(p.cfg.Domains))
i := 0
for domain := range p.certExpiries {
certDomains[i] = domain
i++
}
copy(wantedDomains, p.cfg.Domains)
sort.Strings(wantedDomains)
sort.Strings(certDomains)
if !reflect.DeepEqual(certDomains, wantedDomains) {
logging.Info().Msgf("cert domains mismatch: %v != %v", certDomains, p.cfg.Domains)
return CertStateMismatch
}
return CertStateValid
}
func (p *Provider) renewIfNeeded() error {
if p.cfg.Provider == ProviderLocal {
return nil
}
switch p.certState() {
case CertStateExpired:
logging.Info().Msg("certs expired, renewing")
case CertStateMismatch:
logging.Info().Msg("cert domains mismatch with config, renewing")
default:
return nil
}
return p.ObtainCert()
}
func getCertExpiries(cert *tls.Certificate) (CertExpiries, error) {
r := make(CertExpiries, len(cert.Certificate))
for _, cert := range cert.Certificate {
x509Cert, err := x509.ParseCertificate(cert)
if err != nil {
return nil, err
}
if x509Cert.IsCA {
continue
}
r[x509Cert.Subject.CommonName] = x509Cert.NotAfter
for i := range x509Cert.DNSNames {
r[x509Cert.DNSNames[i]] = x509Cert.NotAfter
}
}
return r, nil
}
func providerGenerator[CT any, PT challenge.Provider](
defaultCfg func() *CT,
newProvider func(*CT) (PT, error),
) ProviderGenerator {
return func(opt ProviderOpt) (challenge.Provider, gperr.Error) {
cfg := defaultCfg()
err := utils.MapUnmarshalValidate(opt, &cfg)
if err != nil {
return nil, err
}
p, pErr := newProvider(cfg)
return p, gperr.Wrap(pErr)
}
}

View File

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

View File

@@ -1,28 +0,0 @@
package autocert
import (
"errors"
"os"
"github.com/yusing/go-proxy/internal/logging"
"github.com/yusing/go-proxy/internal/utils/strutils"
)
func (p *Provider) Setup() (err error) {
if err = p.LoadCert(); err != nil {
if !errors.Is(err, os.ErrNotExist) { // ignore if cert doesn't exist
return err
}
logging.Debug().Msg("obtaining cert due to error loading cert")
if err = p.ObtainCert(); err != nil {
return err
}
}
for _, expiry := range p.GetExpiries() {
logging.Info().Msg("certificate expire on " + strutils.FormatTime(expiry))
break
}
return nil
}

View File

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

View File

@@ -1,25 +0,0 @@
package autocert
import (
"crypto"
"github.com/go-acme/lego/v4/registration"
)
type User struct {
Email string
Registration *registration.Resource
key crypto.PrivateKey
}
func (u *User) GetEmail() string {
return u.Email
}
func (u *User) GetRegistration() *registration.Resource {
return u.Registration
}
func (u *User) GetPrivateKey() crypto.PrivateKey {
return u.key
}

View File

@@ -1,31 +0,0 @@
package common
const (
CommandStart = ""
CommandValidate = "validate"
CommandListConfigs = "ls-config"
CommandListRoutes = "ls-routes"
CommandListIcons = "ls-icons"
CommandReload = "reload"
CommandDebugListEntries = "debug-ls-entries"
CommandDebugListProviders = "debug-ls-providers"
CommandDebugListMTrace = "debug-ls-mtrace"
)
type MainServerCommandValidator struct{}
func (v MainServerCommandValidator) IsCommandValid(cmd string) bool {
switch cmd {
case CommandStart,
CommandValidate,
CommandListConfigs,
CommandListRoutes,
CommandListIcons,
CommandReload,
CommandDebugListEntries,
CommandDebugListProviders,
CommandDebugListMTrace:
return true
}
return false
}

View File

@@ -1,10 +0,0 @@
package common
import "time"
const DockerHostFromEnv = "$DOCKER_HOST"
const (
HealthCheckIntervalDefault = 5 * time.Second
HealthCheckTimeoutDefault = 5 * time.Second
)

View File

@@ -1,28 +0,0 @@
package common
import (
"crypto/rand"
"encoding/base64"
"github.com/rs/zerolog/log"
)
func decodeJWTKey(key string) []byte {
if key == "" {
return nil
}
bytes, err := base64.StdEncoding.DecodeString(key)
if err != nil {
log.Panic().Err(err).Msg("failed to decode jwt key")
}
return bytes
}
func RandomJWTKey() []byte {
key := make([]byte, 32)
_, err := rand.Read(key)
if err != nil {
log.Panic().Err(err).Msg("failed to generate random jwt key")
}
return key
}

View File

@@ -1,128 +0,0 @@
package common
import (
"fmt"
"net"
"os"
"strconv"
"strings"
"time"
"github.com/rs/zerolog/log"
"github.com/yusing/go-proxy/internal/utils/strutils"
)
var (
prefixes = []string{"GODOXY_", "GOPROXY_", ""}
IsTest = GetEnvBool("TEST", false) || strings.HasSuffix(os.Args[0], ".test")
IsDebug = GetEnvBool("DEBUG", IsTest)
IsTrace = GetEnvBool("TRACE", false) && IsDebug
RootDir = GetEnvString("ROOT_DIR", "./")
HTTP3Enabled = GetEnvBool("HTTP3_ENABLED", true)
ProxyHTTPAddr,
ProxyHTTPHost,
ProxyHTTPPort,
ProxyHTTPURL = GetAddrEnv("HTTP_ADDR", ":80", "http")
ProxyHTTPSAddr,
ProxyHTTPSHost,
ProxyHTTPSPort,
ProxyHTTPSURL = GetAddrEnv("HTTPS_ADDR", ":443", "https")
APIHTTPAddr,
APIHTTPHost,
APIHTTPPort,
APIHTTPURL = GetAddrEnv("API_ADDR", "127.0.0.1:8888", "http")
PrometheusEnabled = GetEnvBool("PROMETHEUS_ENABLED", false)
APIJWTSecure = GetEnvBool("API_JWT_SECURE", true)
APIJWTSecret = decodeJWTKey(GetEnvString("API_JWT_SECRET", ""))
APIJWTTokenTTL = GetDurationEnv("API_JWT_TOKEN_TTL", time.Hour)
APIUser = GetEnvString("API_USER", "admin")
APIPassword = GetEnvString("API_PASSWORD", "password")
DebugDisableAuth = GetEnvBool("DEBUG_DISABLE_AUTH", false)
// OIDC Configuration.
OIDCIssuerURL = GetEnvString("OIDC_ISSUER_URL", "")
OIDCClientID = GetEnvString("OIDC_CLIENT_ID", "")
OIDCClientSecret = GetEnvString("OIDC_CLIENT_SECRET", "")
OIDCRedirectURL = GetEnvString("OIDC_REDIRECT_URL", "")
OIDCScopes = GetEnvString("OIDC_SCOPES", "openid, profile, email")
OIDCAllowedUsers = GetCommaSepEnv("OIDC_ALLOWED_USERS", "")
OIDCAllowedGroups = GetCommaSepEnv("OIDC_ALLOWED_GROUPS", "")
// metrics configuration
MetricsDisableCPU = GetEnvBool("METRICS_DISABLE_CPU", false)
MetricsDisableMemory = GetEnvBool("METRICS_DISABLE_MEMORY", false)
MetricsDisableDisk = GetEnvBool("METRICS_DISABLE_DISK", false)
MetricsDisableNetwork = GetEnvBool("METRICS_DISABLE_NETWORK", false)
MetricsDisableSensors = GetEnvBool("METRICS_DISABLE_SENSORS", false)
)
func GetEnv[T any](key string, defaultValue T, parser func(string) (T, error)) T {
var value string
var ok bool
for _, prefix := range prefixes {
value, ok = os.LookupEnv(prefix + key)
if ok && value != "" {
break
}
}
if !ok || value == "" {
return defaultValue
}
parsed, err := parser(value)
if err == nil {
return parsed
}
log.Fatal().Err(err).Msgf("env %s: invalid %T value: %s", key, parsed, value)
return defaultValue
}
func GetEnvString(key string, defaultValue string) string {
return GetEnv(key, defaultValue, func(s string) (string, error) {
return s, nil
})
}
func GetEnvBool(key string, defaultValue bool) bool {
return GetEnv(key, defaultValue, strconv.ParseBool)
}
func GetEnvInt(key string, defaultValue int) int {
return GetEnv(key, defaultValue, strconv.Atoi)
}
func GetAddrEnv(key, defaultValue, scheme string) (addr, host string, portInt int, fullURL string) {
addr = GetEnvString(key, defaultValue)
if addr == "" {
return
}
host, port, err := net.SplitHostPort(addr)
if err != nil {
log.Fatal().Msgf("env %s: invalid address: %s", key, addr)
}
if host == "" {
host = "localhost"
}
fullURL = fmt.Sprintf("%s://%s:%s", scheme, host, port)
portInt, err = strconv.Atoi(port)
if err != nil {
log.Fatal().Msgf("env %s: invalid port: %s", key, port)
}
return
}
func GetDurationEnv(key string, defaultValue time.Duration) time.Duration {
return GetEnv(key, defaultValue, time.ParseDuration)
}
func GetCommaSepEnv(key string, defaultValue string) []string {
return strutils.CommaSeperatedList(GetEnvString(key, defaultValue))
}

View File

@@ -1,33 +0,0 @@
package common
import (
"path/filepath"
)
// file, folder structure
var (
ConfigDir = filepath.Join(RootDir, "config")
ConfigFileName = "config.yml"
ConfigExampleFileName = "config.example.yml"
ConfigPath = filepath.Join(ConfigDir, ConfigFileName)
MiddlewareComposeDir = filepath.Join(ConfigDir, "middlewares")
ErrorPagesDir = filepath.Join(RootDir, "error_pages")
CertsDir = filepath.Join(RootDir, "certs")
DataDir = filepath.Join(RootDir, "data")
MetricsDataDir = filepath.Join(DataDir, "metrics")
HomepageJSONConfigPath = filepath.Join(DataDir, "homepage.json")
IconListCachePath = filepath.Join(DataDir, "icon_list_cache.json")
IconCachePath = filepath.Join(DataDir, "icon_cache.json")
)
var RequiredDirectories = []string{
ConfigDir,
ErrorPagesDir,
MiddlewareComposeDir,
DataDir,
MetricsDataDir,
}

View File

@@ -1,363 +0,0 @@
package config
import (
"context"
"errors"
"os"
"strconv"
"strings"
"sync"
"time"
"github.com/yusing/go-proxy/agent/pkg/agent"
"github.com/yusing/go-proxy/internal/api"
"github.com/yusing/go-proxy/internal/autocert"
"github.com/yusing/go-proxy/internal/common"
config "github.com/yusing/go-proxy/internal/config/types"
"github.com/yusing/go-proxy/internal/entrypoint"
"github.com/yusing/go-proxy/internal/gperr"
"github.com/yusing/go-proxy/internal/logging"
"github.com/yusing/go-proxy/internal/net/gphttp/server"
"github.com/yusing/go-proxy/internal/notif"
"github.com/yusing/go-proxy/internal/proxmox"
proxy "github.com/yusing/go-proxy/internal/route/provider"
"github.com/yusing/go-proxy/internal/task"
"github.com/yusing/go-proxy/internal/utils"
F "github.com/yusing/go-proxy/internal/utils/functional"
"github.com/yusing/go-proxy/internal/watcher"
"github.com/yusing/go-proxy/internal/watcher/events"
)
type Config struct {
value *config.Config
providers F.Map[string, *proxy.Provider]
autocertProvider *autocert.Provider
entrypoint *entrypoint.Entrypoint
task *task.Task
}
var (
cfgWatcher watcher.Watcher
reloadMu sync.Mutex
)
const configEventFlushInterval = 500 * time.Millisecond
const (
cfgRenameWarn = `Config file renamed, not reloading.
Make sure you rename it back before next time you start.`
cfgDeleteWarn = `Config file deleted, not reloading.
You may run "ls-config" to show or dump the current config.`
)
var Validate = config.Validate
var ErrProviderNameConflict = gperr.New("provider name conflict")
func newConfig() *Config {
return &Config{
value: config.DefaultConfig(),
providers: F.NewMapOf[string, *proxy.Provider](),
entrypoint: entrypoint.NewEntrypoint(),
task: task.RootTask("config", false),
}
}
func Load() (*Config, gperr.Error) {
if config.HasInstance() {
panic(errors.New("config already loaded"))
}
cfg := newConfig()
config.SetInstance(cfg)
cfgWatcher = watcher.NewConfigFileWatcher(common.ConfigFileName)
return cfg, cfg.load()
}
func MatchDomains() []string {
return config.GetInstance().Value().MatchDomains
}
func WatchChanges() {
t := task.RootTask("config_watcher", true)
eventQueue := events.NewEventQueue(
t,
configEventFlushInterval,
OnConfigChange,
onReloadError,
)
eventQueue.Start(cfgWatcher.Events(t.Context()))
}
func OnConfigChange(ev []events.Event) {
// no matter how many events during the interval
// just reload once and check the last event
switch ev[len(ev)-1].Action {
case events.ActionFileRenamed:
logging.Warn().Msg(cfgRenameWarn)
return
case events.ActionFileDeleted:
logging.Warn().Msg(cfgDeleteWarn)
return
}
if err := Reload(); err != nil {
// recovered in event queue
panic(err)
}
}
func onReloadError(err gperr.Error) {
logging.Error().Msgf("config reload error: %s", err)
}
func Reload() gperr.Error {
// avoid race between config change and API reload request
reloadMu.Lock()
defer reloadMu.Unlock()
newCfg := newConfig()
err := newCfg.load()
if err != nil {
newCfg.task.Finish(err)
return gperr.New("using last config").With(err)
}
// cancel all current subtasks -> wait
// -> replace config -> start new subtasks
config.GetInstance().(*Config).Task().Finish("config changed")
newCfg.Start(StartAllServers)
config.SetInstance(newCfg)
return nil
}
func (cfg *Config) Value() *config.Config {
return cfg.value
}
func (cfg *Config) Reload() gperr.Error {
return Reload()
}
// AutoCertProvider returns the autocert provider.
//
// If the autocert provider is not configured, it returns nil.
func (cfg *Config) AutoCertProvider() *autocert.Provider {
return cfg.autocertProvider
}
func (cfg *Config) Task() *task.Task {
return cfg.task
}
func (cfg *Config) Context() context.Context {
return cfg.task.Context()
}
func (cfg *Config) Start(opts ...*StartServersOptions) {
cfg.StartAutoCert()
cfg.StartProxyProviders()
cfg.StartServers(opts...)
}
func (cfg *Config) StartAutoCert() {
autocert := cfg.autocertProvider
if autocert == nil {
logging.Info().Msg("autocert not configured")
return
}
if err := autocert.Setup(); err != nil {
gperr.LogFatal("autocert setup error", err)
} else {
autocert.ScheduleRenewal(cfg.task)
}
}
func (cfg *Config) StartProxyProviders() {
errs := cfg.providers.CollectErrors(
func(_ string, p *proxy.Provider) error {
return p.Start(cfg.task)
})
if err := gperr.Join(errs...); err != nil {
gperr.LogError("route provider errors", err)
}
}
type StartServersOptions struct {
Proxy, API bool
}
var StartAllServers = &StartServersOptions{true, true}
func (cfg *Config) StartServers(opts ...*StartServersOptions) {
if len(opts) == 0 {
opts = append(opts, &StartServersOptions{})
}
opt := opts[0]
if opt.Proxy {
server.StartServer(cfg.task, server.Options{
Name: "proxy",
CertProvider: cfg.AutoCertProvider(),
HTTPAddr: common.ProxyHTTPAddr,
HTTPSAddr: common.ProxyHTTPSAddr,
Handler: cfg.entrypoint,
})
}
if opt.API {
server.StartServer(cfg.task, server.Options{
Name: "api",
CertProvider: cfg.AutoCertProvider(),
HTTPAddr: common.APIHTTPAddr,
Handler: api.NewHandler(cfg),
})
}
}
func (cfg *Config) load() gperr.Error {
data, err := os.ReadFile(common.ConfigPath)
if err != nil {
gperr.LogFatal("error reading config", err)
}
model := config.DefaultConfig()
if err := utils.UnmarshalValidateYAML(data, model); err != nil {
gperr.LogFatal("error unmarshalling config", err)
}
// errors are non fatal below
errs := gperr.NewBuilder()
errs.Add(cfg.entrypoint.SetMiddlewares(model.Entrypoint.Middlewares))
errs.Add(cfg.entrypoint.SetAccessLogger(cfg.task, model.Entrypoint.AccessLog))
cfg.initNotification(model.Providers.Notification)
errs.Add(cfg.initProxmox(model.Providers.Proxmox))
errs.Add(cfg.initAutoCert(model.AutoCert))
errs.Add(cfg.loadRouteProviders(&model.Providers))
cfg.value = model
for i, domain := range model.MatchDomains {
if !strings.HasPrefix(domain, ".") {
model.MatchDomains[i] = "." + domain
}
}
cfg.entrypoint.SetFindRouteDomains(model.MatchDomains)
return errs.Error()
}
func (cfg *Config) initNotification(notifCfg []notif.NotificationConfig) {
if len(notifCfg) == 0 {
return
}
dispatcher := notif.StartNotifDispatcher(cfg.task)
for _, notifier := range notifCfg {
dispatcher.RegisterProvider(&notifier)
}
}
func (cfg *Config) initProxmox(proxmoxCfgs []proxmox.Config) (err gperr.Error) {
errs := gperr.NewBuilder("proxmox config errors")
for _, proxmoxCfg := range proxmoxCfgs {
if err := proxmoxCfg.Init(); err != nil {
errs.Add(err.Subject(proxmoxCfg.URL))
} else {
proxmox.Clients.Add(proxmoxCfg.Client())
}
}
return errs.Error()
}
func (cfg *Config) initAutoCert(autocertCfg *autocert.AutocertConfig) (err gperr.Error) {
if cfg.autocertProvider != nil {
return
}
cfg.autocertProvider, err = autocertCfg.GetProvider()
return
}
func (cfg *Config) errIfExists(p *proxy.Provider) gperr.Error {
if conflict, ok := cfg.providers.Load(p.String()); ok {
return ErrProviderNameConflict.
Subject(p.String()).
Withf("one is %q", conflict.Type()).
Withf("the other is %q", p.Type())
}
return nil
}
func (cfg *Config) initAgents(agentCfgs []*agent.AgentConfig) gperr.Error {
var wg sync.WaitGroup
errs := gperr.NewBuilderWithConcurrency()
wg.Add(len(agentCfgs))
for _, agentCfg := range agentCfgs {
go func(agentCfg *agent.AgentConfig) {
defer wg.Done()
if err := agentCfg.Init(cfg.task.Context()); err != nil {
errs.Add(err.Subject(agentCfg.String()))
} else {
agent.Agents.Add(agentCfg)
}
}(agentCfg)
}
wg.Wait()
return errs.Error()
}
func (cfg *Config) loadRouteProviders(providers *config.Providers) gperr.Error {
errs := gperr.NewBuilder("route provider errors")
results := gperr.NewBuilder("loaded route providers")
agent.Agents.Clear()
n := len(providers.Agents) + len(providers.Docker) + len(providers.Files)
if n == 0 {
return nil
}
routeProviders := make([]*proxy.Provider, 0, n)
errs.Add(cfg.initAgents(providers.Agents))
for _, a := range providers.Agents {
if !a.IsInitialized() { // failed to initialize
continue
}
agent.Agents.Add(a)
routeProviders = append(routeProviders, proxy.NewAgentProvider(a))
}
for _, filename := range providers.Files {
routeProviders = append(routeProviders, proxy.NewFileProvider(filename))
}
for name, dockerHost := range providers.Docker {
routeProviders = append(routeProviders, proxy.NewDockerProvider(name, dockerHost))
}
// check if all providers are unique (should not happen but just in case)
for _, p := range routeProviders {
if err := cfg.errIfExists(p); err != nil {
errs.Add(err)
continue
}
cfg.providers.Store(p.String(), p)
}
lenLongestName := 0
cfg.providers.RangeAll(func(k string, _ *proxy.Provider) {
if len(k) > lenLongestName {
lenLongestName = len(k)
}
})
errs.EnableConcurrency()
results.EnableConcurrency()
cfg.providers.RangeAllParallel(func(_ string, p *proxy.Provider) {
if err := p.LoadRoutes(); err != nil {
errs.Add(err.Subject(p.String()))
}
results.Addf("%-"+strconv.Itoa(lenLongestName)+"s %d routes", p.String(), p.NumRoutes())
})
logging.Info().Msg(results.String())
return errs.Error()
}

View File

@@ -1,202 +0,0 @@
package config
import (
"os"
"path"
"testing"
"github.com/goccy/go-yaml"
"github.com/stretchr/testify/assert"
"github.com/yusing/go-proxy/agent/pkg/agent"
"github.com/yusing/go-proxy/internal/common"
config "github.com/yusing/go-proxy/internal/config/types"
"github.com/yusing/go-proxy/internal/route/provider"
"github.com/yusing/go-proxy/internal/utils"
. "github.com/yusing/go-proxy/internal/utils/testing"
)
func TestFileProviderValidate(t *testing.T) {
tests := []struct {
name string
filenames []string
init, cleanup func(filepath string) error
expectedErrorContains string
}{
{
name: "file not exists",
filenames: []string{"not_exists.yaml"},
expectedErrorContains: "config_file_exists",
},
{
name: "file is a directory",
filenames: []string{"testdata"},
expectedErrorContains: "config_file_exists",
},
{
name: "same file exists multiple times",
filenames: []string{"test.yml", "test.yml"},
expectedErrorContains: "unique",
},
{
name: "file ok",
filenames: []string{"routes.yaml"},
init: func(filepath string) error {
os.MkdirAll(path.Dir(filepath), 0755)
_, err := os.Create(filepath)
return err
},
cleanup: func(filepath string) error {
return os.RemoveAll(path.Dir(filepath))
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := config.DefaultConfig()
if tt.init != nil {
for _, filename := range tt.filenames {
filepath := path.Join(common.ConfigDir, filename)
assert.NoError(t, tt.init(filepath))
}
}
err := utils.UnmarshalValidateYAML(Must(yaml.Marshal(map[string]any{
"providers": map[string]any{
"include": tt.filenames,
},
})), cfg)
if tt.cleanup != nil {
for _, filename := range tt.filenames {
filepath := path.Join(common.ConfigDir, filename)
assert.NoError(t, tt.cleanup(filepath))
}
}
if tt.expectedErrorContains != "" {
assert.ErrorContains(t, err, tt.expectedErrorContains)
} else {
assert.NoError(t, err)
}
})
}
}
func TestLoadRouteProviders(t *testing.T) {
tests := []struct {
name string
providers *config.Providers
expectedError bool
}{
{
name: "duplicate file provider",
providers: &config.Providers{
Files: []string{"routes.yaml", "routes.yaml"},
},
expectedError: true,
},
{
name: "duplicate docker provider",
providers: &config.Providers{
Docker: map[string]string{
"docker1": "unix:///var/run/docker.sock",
"docker2": "unix:///var/run/docker.sock",
},
},
expectedError: true,
},
{
name: "docker provider with different hosts",
providers: &config.Providers{
Docker: map[string]string{
"docker1": "unix:///var/run/docker1.sock",
"docker2": "unix:///var/run/docker2.sock",
},
},
expectedError: false,
},
{
name: "duplicate agent addresses",
providers: &config.Providers{
Agents: []*agent.AgentConfig{
{Addr: "192.168.1.100:8080"},
{Addr: "192.168.1.100:8080"},
},
},
expectedError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := utils.Validate(tt.providers)
if tt.expectedError {
assert.ErrorContains(t, err, "unique")
} else {
assert.NoError(t, err)
}
})
}
}
func TestProviderNameUniqueness(t *testing.T) {
file := provider.NewFileProvider("routes.yaml")
docker := provider.NewDockerProvider("routes", "unix:///var/run/docker.sock")
agent := provider.NewAgentProvider(agent.TestAgentConfig("routes", "192.168.1.100:8080"))
assert.True(t, file.String() != docker.String())
assert.True(t, file.String() != agent.String())
assert.True(t, docker.String() != agent.String())
}
func TestFileProviderNameFromFilename(t *testing.T) {
tests := []struct {
filename string
expectedName string
}{
{"routes.yaml", "routes"},
{"service.yml", "service"},
{"complex-name.yaml", "complex-name"},
}
for _, tt := range tests {
t.Run(tt.filename, func(t *testing.T) {
p := provider.NewFileProvider(tt.filename)
assert.Equal(t, tt.expectedName, p.ShortName())
})
}
}
func TestDockerProviderString(t *testing.T) {
tests := []struct {
name string
dockerHost string
expected string
}{
{"docker1", "unix:///var/run/docker.sock", "docker@docker1"},
{"host2", "tcp://192.168.1.100:2375", "docker@host2"},
{"explicit!", "unix:///var/run/docker.sock", "docker@explicit!"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
p := provider.NewDockerProvider(tt.name, tt.dockerHost)
assert.Equal(t, tt.expected, p.String())
})
}
}
func TestExplicitOnlyProvider(t *testing.T) {
tests := []struct {
name string
expectedFlag bool
}{
{"docker", false},
{"explicit!", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
p := provider.NewDockerProvider(tt.name, "unix:///var/run/docker.sock")
assert.Equal(t, tt.expectedFlag, p.IsExplicitOnly())
})
}
}

View File

@@ -1,86 +0,0 @@
package config
import (
"slices"
"github.com/yusing/go-proxy/agent/pkg/agent"
"github.com/yusing/go-proxy/internal/gperr"
"github.com/yusing/go-proxy/internal/route"
"github.com/yusing/go-proxy/internal/route/provider"
)
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
})
})
return entries
}
func (cfg *Config) DumpRouteProviders() map[string]*provider.Provider {
entries := make(map[string]*provider.Provider)
cfg.providers.RangeAll(func(_ string, p *provider.Provider) {
entries[p.ShortName()] = p
})
return entries
}
func (cfg *Config) RouteProviderList() []string {
var list []string
cfg.providers.RangeAll(func(_ string, p *provider.Provider) {
list = append(list, p.ShortName())
})
return list
}
func (cfg *Config) Statistics() map[string]any {
var rps, streams provider.RouteStats
var total uint16
providerStats := make(map[string]provider.ProviderStats)
cfg.providers.RangeAll(func(_ string, p *provider.Provider) {
stats := p.Statistics()
providerStats[p.ShortName()] = stats
rps.AddOther(stats.RPs)
streams.AddOther(stats.Streams)
total += stats.RPs.Total + stats.Streams.Total
})
return map[string]any{
"total": total,
"reverse_proxies": rps,
"streams": streams,
"providers": providerStats,
}
}
func (cfg *Config) VerifyNewAgent(host string, ca agent.PEMPair, client agent.PEMPair) (int, gperr.Error) {
if slices.ContainsFunc(cfg.value.Providers.Agents, func(a *agent.AgentConfig) bool {
return a.Addr == host
}) {
return 0, gperr.New("agent already exists")
}
agentCfg := new(agent.AgentConfig)
agentCfg.Addr = host
err := agentCfg.InitWithCerts(cfg.task.Context(), ca.Cert, client.Cert, client.Key)
if err != nil {
return 0, gperr.Wrap(err, "failed to start agent")
}
// must add it first to let LoadRoutes() reference from it
agent.Agents.Add(agentCfg)
provider := provider.NewAgentProvider(agentCfg)
if err := cfg.errIfExists(provider); err != nil {
agent.Agents.Del(agentCfg)
return 0, err
}
err = provider.LoadRoutes()
if err != nil {
agent.Agents.Del(agentCfg)
return 0, gperr.Wrap(err, "failed to load routes")
}
return provider.NumRoutes(), nil
}

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