Compare commits

..

No commits in common. "main" and "0.9.7" have entirely different histories.
main ... 0.9.7

512 changed files with 11265 additions and 28239 deletions

View file

@ -1,33 +1,24 @@
# docker image tag (latest, nightly)
TAG=latest
# set timezone to get correct log timestamp
TZ=ETC/UTC
# container uid and gid (must match the owner of mounted directories)
GODOXY_UID=1000
GODOXY_GID=1000
# API JWT Configuration (common)
# generate secret with `openssl rand -base64 32`
GODOXY_API_JWT_SECRET=
# the JWT token time-to-live
# leave empty to use default (24 hours)
# format: https://pkg.go.dev/time#Duration
GODOXY_API_JWT_TOKEN_TTL=
# API/WebUI user password login credentials (optional)
# These fields are not required for OIDC authentication
GODOXY_API_USER=admin
GODOXY_API_PASSWORD=password
# 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
# GODOXY_OIDC_SCOPES=openid, profile, email, groups # you may also include `offline_access` if your Idp supports it (e.g. Authentik, Pocket ID)
# 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:
@ -48,29 +39,11 @@ GODOXY_API_PASSWORD=password
GODOXY_HTTP_ADDR=:80
GODOXY_HTTPS_ADDR=:443
# Enable HTTP3
GODOXY_HTTP3_ENABLED=true
# API listening address
GODOXY_API_ADDR=127.0.0.1:8888
# Metrics
GODOXY_METRICS_DISABLE_CPU=false
GODOXY_METRICS_DISABLE_MEMORY=false
GODOXY_METRICS_DISABLE_DISK=false
GODOXY_METRICS_DISABLE_NETWORK=false
GODOXY_METRICS_DISABLE_SENSORS=false
# Frontend listening port
GODOXY_FRONTEND_PORT=3000
# Frontend aliases (subdomains / FQDNs, e.g. godoxy, godoxy.domain.com)
GODOXY_FRONTEND_ALIASES=godoxy
# Docker socket
# /var/run/podman/podman.sock for podman
DOCKER_SOCKET=/var/run/docker.sock
SOCKET_PROXY_LISTEN_ADDR=127.0.0.1:2375
# Prometheus Metrics
GODOXY_PROMETHEUS_ENABLED=true
# Debug mode
GODOXY_DEBUG=false

View file

@ -1,48 +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: 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,24 +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
target: main
build-nightly-agent:
uses: ./.github/workflows/docker-image.yml
with:
image_name: ${{ github.repository_owner }}/godoxy-agent
tag: nightly
target: agent

View file

@ -1,21 +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
target: main
build-prod-agent:
uses: ./.github/workflows/docker-image.yml
with:
image_name: ${{ github.repository_owner }}/godoxy-agent
tag: latest
target: agent

View file

@ -1,23 +0,0 @@
name: Docker Image CI (socket-proxy)
on:
push:
branches:
- main
paths:
- "socket-proxy/**"
tags-ignore:
- '**'
workflow_dispatch:
permissions:
contents: read
jobs:
build:
uses: ./.github/workflows/docker-image.yml
with:
image_name: ${{ github.repository_owner }}/socket-proxy
tag: latest
target: socket-proxy
dockerfile: socket-proxy.Dockerfile

View file

@ -1,45 +1,17 @@
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
target:
required: true
type: string
dockerfile:
required: false
type: string
default: Dockerfile
push:
tags: ["*"]
env:
REGISTRY: ghcr.io
MAKE_ARGS: ${{ inputs.target }}=1
DIGEST_PATH: /tmp/digests/${{ inputs.target }}
DIGEST_NAME_SUFFIX: ${{ inputs.target }}
DOCKERFILE: ${{ inputs.dockerfile }}
IMAGE_NAME: ${{ github.repository }}
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 }}
name: Build multi-platform Docker image
runs-on: ubuntu-22.04
permissions:
contents: read
@ -47,6 +19,14 @@ jobs:
id-token: write
attestations: write
strategy:
fail-fast: false
matrix:
platform:
- linux/amd64
# - linux/arm/v6
# - linux/arm/v7
- linux/arm64
steps:
- name: Prepare
run: |
@ -57,15 +37,13 @@ jobs:
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
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
platforms: ${{ matrix.platform }}
- name: Login to registry
uses: docker/login-action@v3
@ -80,54 +58,46 @@ jobs:
with:
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
file: ${{ env.DOCKERFILE }}
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 }}
type=gha,scope=${{ github.workflow }}-${{ env.PLATFORM_PAIR }}
cache-to: |
type=registry,ref=${{ env.REGISTRY }}/${{ inputs.image_name }}:buildcache-${{ env.PLATFORM_PAIR }},mode=max
type=gha,scope=${{ github.workflow }}-${{ env.PLATFORM_PAIR }},mode=max
outputs: type=image,name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
cache-from: type=gha
cache-to: type=gha,mode=max
build-args: |
VERSION=${{ github.ref_name }}
MAKE_ARGS=${{ env.MAKE_ARGS }}
- name: Generate artifact attestation
uses: actions/attest-build-provenance@v1
with:
subject-name: ${{ env.REGISTRY }}/${{ inputs.image_name }}
subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME}}
subject-digest: ${{ steps.build.outputs.digest }}
push-to-registry: true
- name: Export digest
run: |
mkdir -p ${{ env.DIGEST_PATH }}
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "${{ env.DIGEST_PATH }}/${digest#sha256:}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-${{ env.PLATFORM_PAIR }}-${{ env.DIGEST_NAME_SUFFIX }}
path: ${{ env.DIGEST_PATH }}/*
name: digests-${{ env.PLATFORM_PAIR }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
merge:
needs: build
runs-on: ubuntu-latest
runs-on: ubuntu-22.04
needs:
- build
permissions:
contents: read
packages: write
id-token: write
steps:
- name: Download digests
uses: actions/download-artifact@v4
with:
path: ${{ env.DIGEST_PATH }}
pattern: digests-*-${{ env.DIGEST_NAME_SUFFIX }}
path: /tmp/digests
pattern: digests-*
merge-multiple: true
- name: Set up Docker Buildx
@ -137,10 +107,7 @@ jobs:
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
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
- name: Login to registry
uses: docker/login-action@v3
@ -151,22 +118,11 @@ jobs:
- name: Create manifest list and push
id: push
working-directory: ${{ env.DIGEST_PATH }}
working-directory: /tmp/digests
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 }}
$(printf '${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@sha256:%s ' *)
- 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 }}
docker buildx imagetools inspect ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }}

8
.gitignore vendored
View file

@ -9,9 +9,6 @@ certs*/
bin/
error_pages/
!examples/error_pages/
profiles/
data/
debug/
logs/
log/
@ -29,12 +26,7 @@ todo.md
.aider*
mtrace.json
.env
.cursorrules
.windsurfrules
test.Dockerfile
node_modules/
tsconfig.tsbuildinfo
!agent.compose.yml
!agent/pkg/**

0
.gitmodules vendored Normal file
View file

View file

@ -1,77 +1,43 @@
version: "2"
linters:
default: all
disable:
- bodyclose
- containedctx
- contextcheck
- cyclop
- depguard
- dupl
- err113
- exhaustive
- exhaustruct
- forcetypeassert
- gochecknoglobals
- gochecknoinits
- gocognit
- goconst
- gocyclo
- gomoddirectives
- gosec
- gosmopolitan
- ireturn
- lll
- maintidx
- makezero
- mnd
- nakedret
- nestif
- nilnil
- nlreturn
- noctx
- nonamedreturns
- paralleltest
- prealloc
- rowserrcheck
- sqlclosecheck
- tagliatelle
- testpackage
- tparallel
- varnamelen
- wrapcheck
- wsl
settings:
errcheck:
exclude-functions:
- fmt.Fprintln
forbidigo:
forbid:
- pattern: ^print(ln)?$
funlen:
lines: -1
statements: 120
gocyclo:
min-complexity: 14
godox:
keywords:
- FIXME
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
run:
timeout: 10m
linters-settings:
govet:
enable-all: true
disable:
- shadow
- fieldalignment
enable-all: true
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
@ -101,51 +67,69 @@ linters:
disabled: true
- name: unreachable-code
- name: redefines-builtin-id
staticcheck:
checks:
- all
- -SA1019
dot-import-whitelist:
- github.com/yusing/go-proxy/internal/utils/testing
- github.com/yusing/go-proxy/internal/api/v1/utils
tagalign:
align: false
sort: true
order:
- description
- json
- toml
- yaml
- yml
- label
- label-slice-as-struct
- file
- kv
- export
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
exclusions:
generated: lax
presets:
- comments
- common-false-positives
- legacy
- std-error-handling
paths:
- third_party$
- builtin$
- examples$
formatters:
enable:
- gofmt
- gofumpt
- goimports
exclusions:
generated: lax
paths:
- third_party$
- builtin$
- examples$
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

View file

@ -2,37 +2,37 @@
# To learn more about the format of this file, see https://docs.trunk.io/reference/trunk-yaml
version: 0.1
cli:
version: 1.22.15
version: 1.22.9
# Trunk provides extensibility via plugins. (https://docs.trunk.io/plugins)
plugins:
sources:
- id: trunk
ref: v1.6.8
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.24.3
- go@1.23.2
# This is the section where you manage your linters. (https://docs.trunk.io/check/configuration)
lint:
disabled:
- markdownlint
- yamllint
enabled:
- checkov@3.2.416
- golangci-lint2@2.1.6
- hadolint@2.12.1-beta
- actionlint@1.7.7
- checkov@3.2.360
- git-diff-check
- gofmt@1.20.4
- osv-scanner@2.0.2
- oxipng@9.1.5
- prettier@3.5.3
- golangci-lint@1.63.4
- osv-scanner@1.9.2
- oxipng@9.1.3
- prettier@3.4.2
- shellcheck@0.10.0
- shfmt@3.6.0
- trufflehog@3.88.29
- trufflehog@3.88.4
actions:
disabled:
- trunk-announce

View file

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

View file

@ -1,48 +1,40 @@
# Stage 1: deps
FROM golang:1.24.3-alpine AS deps
# Stage 1: Builder
FROM golang:1.23.5-alpine AS builder
HEALTHCHECK NONE
# package version does not matter
# trunk-ignore(hadolint/DL3018)
RUN apk add --no-cache tzdata make libcap-setcap
ENV GOPATH=/root/go
WORKDIR /src
COPY go.mod go.sum ./
# Only copy go.mod and go.sum initially for better caching
COPY go.mod go.sum /src/
# remove godoxy stuff from go.mod first
RUN sed -i '/^module github\.com\/yusing\/go-proxy/!{/github\.com\/yusing\/go-proxy/d}' go.mod && \
# Utilize build cache
RUN --mount=type=cache,target="/go/pkg/mod" \
go mod download -x
# Stage 2: builder
FROM deps AS builder
ENV GOCACHE=/root/.cache/go-build
WORKDIR /src
COPY go.mod go.sum ./
COPY Makefile ./
COPY cmd ./cmd
COPY internal ./internal
COPY pkg ./pkg
COPY agent ./agent
COPY socket-proxy ./socket-proxy
COPY Makefile /src/
COPY cmd /src/cmd
COPY internal /src/internal
COPY pkg /src/pkg
ARG VERSION
ENV VERSION=${VERSION}
ARG MAKE_ARGS
ENV MAKE_ARGS=${MAKE_ARGS}
ARG BUILD_FLAGS
ENV BUILD_FLAGS=${BUILD_FLAGS}
ENV GOCACHE=/root/.cache/go-build
ENV GOPATH=/root/go
RUN --mount=type=cache,target="/go/pkg/mod" \
--mount=type=cache,target="/root/.cache/go-build" \
make build && \
mkdir -p /app/error_pages /app/certs && \
mv bin/godoxy /app/godoxy
RUN --mount=type=cache,target=/root/.cache/go-build \
--mount=type=cache,target=/root/go/pkg/mod \
make ${MAKE_ARGS} docker=1 build
# Stage 3: Final image
# Stage 2: Final image
FROM scratch
LABEL maintainer="yusing@6uo.me"
@ -52,13 +44,21 @@ LABEL proxy.exclude=1
COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo
# copy binary
COPY --from=builder /app/run /app/run
COPY --from=builder /app /app
# 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
ENV GODOXY_DEBUG=0
EXPOSE 80
EXPOSE 8888
EXPOSE 443
WORKDIR /app
CMD ["/app/run"]
CMD ["/app/godoxy"]

26
LICENSE
View file

@ -1,6 +1,6 @@
MIT License
Copyright (c) 2024 - present Yusing
Copyright (c) 2024 [fullname]
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
@ -19,27 +19,3 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
---
internal/net/gphttp/reverseproxy/reverse_proxy_mod.go is copied from et/http/httputil/reverseproxy.go with modifications to adapt to this project.
Copyright 2011 The Go Authors. All rights reserved.
Use of this source code is governed by a BSD-style
license that can be found in the LICENSE file.
---
internal/utils/io.go has a modified version of io.Copy with context and HTTP flusher handling.
Copyright 2009 The Go Authors. All rights reserved.
Use of this source code is governed by a BSD-style
license that can be found in the LICENSE file.
---
internal/utils/strutils/split_join.go is copied from strings.Split and strings.Join with modifications to adapt to this project.
Copyright 2009 The Go Authors. All rights reserved.
Use of this source code is governed by a BSD-style
license that can be found in the LICENSE file.

151
Makefile
View file

@ -1,122 +1,61 @@
shell := /bin/sh
export VERSION ?= $(shell git describe --tags --abbrev=0)
export BUILD_DATE ?= $(shell date -u +'%Y%m%d-%H%M')
export GOOS = linux
LDFLAGS = -X github.com/yusing/go-proxy/pkg.version=${VERSION}
ifeq ($(agent), 1)
NAME = godoxy-agent
PWD = ${shell pwd}/agent
else ifeq ($(socket-proxy), 1)
NAME = godoxy-socket-proxy
PWD = ${shell pwd}/socket-proxy
else
NAME = godoxy
PWD = ${shell pwd}
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
BUILD_FLAGS = -tags production
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
GODEBUG = gctrace=1 inittrace=1 schedtrace=3000
GORACE = log_path=logs/pprof strip_path_prefix=$(shell pwd)/
BUILD_FLAGS = -race -gcflags=all='-N -l' -tags pprof
DOCKER_TAG = pprof
VERSION += -pprof
else
CGO_ENABLED = 0
LDFLAGS += -s -w
BUILD_FLAGS += -pgo=auto -tags production
BUILD_FLAGS = -pgo=auto -tags production
DOCKER_TAG = latest
endif
BUILD_FLAGS += -ldflags='$(LDFLAGS)'
BIN_PATH := $(shell pwd)/bin/${NAME}
export NAME
export CGO_ENABLED
export GODOXY_DEBUG
export GODOXY_TRACE
export GODEBUG
export GORACE
export BUILD_FLAGS
ifeq ($(shell id -u), 0)
SETCAP_CMD = setcap
else
SETCAP_CMD = sudo setcap
endif
# CAP_NET_BIND_SERVICE: permission for binding to :80 and :443
POST_BUILD = $(SETCAP_CMD) CAP_NET_BIND_SERVICE=+ep ${BIN_PATH};
ifeq ($(docker), 1)
POST_BUILD += mkdir -p /app && mv ${BIN_PATH} /app/run;
endif
.PHONY: debug
export DOCKER_TAG
test:
GODOXY_TEST=1 go test ./internal/...
docker-build-test:
docker build -t godoxy .
docker build --build-arg=MAKE_ARGS=agent=1 -t godoxy-agent .
go_ver := $(shell go version | cut -d' ' -f3 | cut -d'o' -f2)
files := $(shell find . -name go.mod -type f -or -name Dockerfile -type f)
gomod_paths := $(shell find . -name go.mod -type f | xargs dirname)
update-go:
for file in ${files}; do \
echo "updating $$file"; \
sed -i 's|go \([0-9]\+\.[0-9]\+\.[0-9]\+\)|go ${go_ver}|g' $$file; \
sed -i 's|FROM golang:.*-alpine|FROM golang:${go_ver}-alpine|g' $$file; \
done
for path in ${gomod_paths}; do \
echo "go mod tidy $$path"; \
cd ${PWD}/$$path && go mod tidy; \
done
update-deps:
for path in ${gomod_paths}; do \
echo "go get -u $$path"; \
cd ${PWD}/$$path && go get -u ./... && go mod tidy; \
done
mod-tidy:
for path in ${gomod_paths}; do \
echo "go mod tidy $$path"; \
cd ${PWD}/$$path && go mod tidy; \
done
get:
go get -u ./cmd && go mod tidy
build:
mkdir -p $(shell dirname ${BIN_PATH})
cd ${PWD} && go build ${BUILD_FLAGS} -o ${BIN_PATH} ./cmd
${POST_BUILD}
mkdir -p bin
go build ${BUILD_FLAGS} -o bin/godoxy ./cmd
if [ $(shell id -u) -eq 0 ]; \
then setcap CAP_NET_BIND_SERVICE=+eip bin/godoxy; \
else sudo setcap CAP_NET_BIND_SERVICE=+eip bin/godoxy; \
fi
run:
cd ${PWD} && [ -f .env ] && godotenv -f .env go run ${BUILD_FLAGS} ./cmd
debug:
make NAME="godoxy-test" debug=1 build
sh -c 'HTTP_ADDR=:81 HTTPS_ADDR=:8443 API_ADDR=:8899 DEBUG=1 bin/godoxy-test'
[ -f .env ] && godotenv -f .env go run ${BUILD_FLAGS} ./cmd
mtrace:
${BIN_PATH} debug-ls-mtrace > mtrace.json
bin/godoxy debug-ls-mtrace > mtrace.json
rapid-crash:
docker run --restart=always --name test_crash -p 80 debian:bookworm-slim /bin/cat &&\
@ -131,7 +70,57 @@ ci-test:
act -n --artifact-server-path /tmp/artifacts -s GITHUB_TOKEN="$$(gh auth token)"
cloc:
cloc --include-lang=Go --not-match-f '_test.go$$' .
cloc --not-match-f '_test.go$$' cmd internal pkg
push-docker-io:
BUILDER=build docker buildx build \
--platform linux/arm64,linux/amd64 \
-f Dockerfile \
-t docker.io/yusing/godoxy-nightly:${DOCKER_TAG} \
-t docker.io/yusing/godoxy-nightly:${VERSION}-${BUILD_DATE} \
--build-arg VERSION="${VERSION}-nightly-${BUILD_DATE}" \
--build-arg BUILD_FLAGS="${BUILD_FLAGS}" \
--push .
build-docker:
docker build -t godoxy-nightly \
--build-arg VERSION="${VERSION}-nightly-${BUILD_DATE}" \
--build-arg BUILD_FLAGS="${BUILD_FLAGS}" \
.
# To generate schema
# comment out this part from typescript-json-schema.js#L884
#
# if (indexType.flags !== ts.TypeFlags.Number && !isIndexedObject) {
# throw new Error("Not supported: IndexSignatureDeclaration with index symbol other than a number or a string");
# }
gen-schema-single:
bun --bun run typescript-json-schema --noExtraProps --required --skipLibCheck --tsNodeRegister=true -o schemas/${OUT} schemas/${IN} ${CLASS}
# minify
python3 -c "import json; f=open('schemas/${OUT}', 'r'); j=json.load(f); f.close(); f=open('schemas/${OUT}', 'w'); json.dump(j, f, separators=(',', ':'));"
gen-schema:
bun --bun tsc
make IN=config/config.ts \
CLASS=Config \
OUT=config.schema.json \
gen-schema-single
make IN=providers/routes.ts \
CLASS=Routes \
OUT=routes.schema.json \
gen-schema-single
make IN=middlewares/middleware_compose.ts \
CLASS=MiddlewareCompose \
OUT=middleware_compose.schema.json \
gen-schema-single
make IN=docker.ts \
CLASS=DockerRoutes \
OUT=docker_routes.schema.json \
gen-schema-single
update-schema-generator:
pnpm up -g typescript-json-schema
push-github:
git push origin $(shell git rev-parse --abbrev-ref HEAD)

174
README.md
View file

@ -3,20 +3,23 @@
# 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=go-proxy)
![Demo](https://img.shields.io/website?url=https%3A%2F%2Fdemo.godoxy.dev&label=Demo&link=https%3A%2F%2Fdemo.godoxy.dev)
[![Discord](https://dcbadge.limes.pink/api/server/umReR62nRd?style=flat)](https://discord.gg/umReR62nRd)
![GitHub last commit](https://img.shields.io/github/last-commit/yusing/go-proxy)
[![Lines of Code](https://sonarcloud.io/api/project_badges/measure?project=yusing_go-proxy&metric=ncloc)](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy)
[![](https://dcbadge.limes.pink/api/server/umReR62nRd?style=flat)](https://discord.gg/umReR62nRd)
A lightweight, simple, and performant reverse proxy with WebUI.
A lightweight, simple, and [performant](https://github.com/yusing/go-proxy/wiki/Benchmarks) reverse proxy with WebUI.
<h5>
<a href="https://docs.godoxy.dev">Website</a> | <a href="https://docs.godoxy.dev/Home.html">Wiki</a> | <a href="https://discord.gg/umReR62nRd">Discord</a>
</h5>
For full documentation, check out **[Wiki](https://github.com/yusing/go-proxy/wiki)**
<h5>EN | <a href="README_CHT.md">中文</a></h5>
**EN** | <a href="README_CHT.md">中文</a>
<img src="screenshots/webui.jpg" style="max-width: 650">
**Currently working on [feat/godoxy-agent](https://github.com/yusing/go-proxy/tree/feat/godoxy-agent).<br/>Fork this instead of default branch.**
<!-- [![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=yusing_go-proxy&metric=security_rating)](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy)
[![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=yusing_go-proxy&metric=sqale_rating)](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy)
[![Vulnerabilities](https://sonarcloud.io/api/project_badges/measure?project=yusing_go-proxy&metric=vulnerabilities)](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy) -->
<img src="https://github.com/user-attachments/assets/4bb371f4-6e4c-425c-89b2-b9e962bdd46f" style="max-width: 650">
</div>
@ -26,145 +29,80 @@ A lightweight, simple, and performant reverse proxy with WebUI.
- [GoDoxy](#godoxy)
- [Table of content](#table-of-content)
- [Running demo](#running-demo)
- [Key Features](#key-features)
- [Prerequisites](#prerequisites)
- [Setup](#setup)
- [How does GoDoxy work](#how-does-godoxy-work)
- [Screenshots](#screenshots)
- [idlesleeper](#idlesleeper)
- [Metrics and Logs](#metrics-and-logs)
- [Manual Setup](#manual-setup)
- [Folder structrue](#folder-structrue)
- [Screenshots](#screenshots)
- [idlesleeper](#idlesleeper)
- [Build it yourself](#build-it-yourself)
## Running demo
<https://demo.godoxy.dev>
[![Deployed on Zeabur](https://zeabur.com/deployed-on-zeabur-dark.svg)](https://zeabur.com/referral?referralCode=yusing&utm_source=yusing&utm_campaign=oss)
## Key Features
- **Simple**
- Effortless configuration with [simple labels](https://docs.godoxy.dev/Docker-labels-and-Route-Files) or WebUI
- [Simple multi-node setup](https://docs.godoxy.dev/Configurations#multi-docker-nodes-setup)
- Detailed error messages for easy troubleshooting.
- **ACL**: connection / request level access control
- IP/CIDR
- Country **(Maxmind account required)**
- Timezone **(Maxmind account required)**
- **Access logging**
- **Advanced Automation**
- Automatic SSL certificate management with Let's Encrypt ([using DNS-01 Challenge](https://docs.godoxy.dev/DNS-01-Providers))
- Auto-configuration for Docker containers
- Hot-reloading of configurations and container state changes
- **Idle-sleep**: stop and wake containers based on traffic _(see [screenshots](#idlesleeper))_
- Docker containers
- Proxmox LXCs
- **Traffic Management**
- HTTP reserve proxy
- TCP/UDP port forwarding
- **OpenID Connect support**: SSO and secure your apps easily
- **Customization**
- [HTTP middlewares](https://docs.godoxy.dev/Middlewares)
- [Custom error pages support](https://docs.godoxy.dev/Custom-Error-Pages)
- **Web UI**
- App Dashboard
- Config Editor
- Uptime and System Metrics
- Docker Logs Viewer
- **Cross-Platform support**
- Supports **linux/amd64** and **linux/arm64**
- **Efficient and Performant**
- Written in **[Go](https://go.dev)**
- Easy to use
- Effortless configuration
- Simple multi-node setup
- Error messages is clear and detailed, easy troubleshooting
- Auto SSL cert management (See [Supported DNS-01 Challenge Providers](https://github.com/yusing/go-proxy/wiki/Supported-DNS%E2%80%9001-Providers))
- Auto configuration for docker containers
- Auto hot-reload on container state / config file changes
- **idlesleeper**: stop containers on idle, wake it up on traffic _(optional, see [screenshots](#idlesleeper))_
- HTTP(s) reserve proxy
- OpenID Connect support
- [HTTP middleware support](https://github.com/yusing/go-proxy/wiki/Middlewares)
- [Custom error pages support](https://github.com/yusing/go-proxy/wiki/Middlewares#custom-error-pages)
- TCP and UDP port forwarding
- **Web UI with App dashboard and config editor**
- Supports linux/amd64, linux/arm64
- Written in **[Go](https://go.dev)**
[🔼Back to top](#table-of-content)
## Prerequisites
Configure Wildcard DNS Record(s) to point to machine running `GoDoxy`, e.g.
Setup DNS Records point to machine which runs `GoDoxy`, e.g.
- A Record: `*.domain.com` -> `10.0.10.1`
- AAAA Record (if you use IPv6): `*.domain.com` -> `::ffff:a00:a01`
- A Record: `*.y.z` -> `10.0.10.1`
- AAAA Record: `*.y.z` -> `::ffff:a00:a01`
## Setup
> [!NOTE]
> GoDoxy is designed to be running in `host` network mode, do not change it.
>
> To change listening ports, modify `.env`.
**NOTE:** GoDoxy is designed to be (and only works when) running in `host` network mode, do not change it. To change listening ports, modify `.env`.
1. Prepare a new directory for docker compose and config files.
2. Run setup script inside the directory, or [set up manually](#manual-setup)
1. Pull the latest docker images
```shell
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/yusing/godoxy/main/scripts/setup.sh)"
docker pull ghcr.io/yusing/go-proxy:latest
```
3. Start the docker compose service from generated `compose.yml`:
2. Create new directory, `cd` into it, then run setup, or [set up manually](#manual-setup)
```shell
docker compose up -d
docker run --rm -v .:/setup ghcr.io/yusing/go-proxy /app/godoxy setup
```
4. You may now do some extra configuration on WebUI `https://godoxy.yourdomain.com`
3. _(Optional)_ setup `docker-socket-proxy` other docker nodes (see [Multi docker nodes setup](https://github.com/yusing/go-proxy/wiki/Configurations#multi-docker-nodes-setup)) then add them inside `config.yml`
## How does GoDoxy work
4. Start the container `docker compose up -d`
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
5. You may now do some extra configuration on WebUI `https://godoxy.domain.com`
> [!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`.
[🔼Back to top](#table-of-content)
## Screenshots
### idlesleeper
![idlesleeper](screenshots/idlesleeper.webp)
### Metrics and Logs
<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>
## Manual Setup
### Manual Setup
1. Make `config` directory then grab `config.example.yml` into `config/config.yml`
`mkdir -p config && wget https://raw.githubusercontent.com/yusing/godoxy/main/config.example.yml -O config/config.yml`
`mkdir -p config && wget https://raw.githubusercontent.com/yusing/go-proxy/v0.9/config.example.yml -O config/config.yml`
2. Grab `.env.example` into `.env`
`wget https://raw.githubusercontent.com/yusing/godoxy/main/.env.example -O .env`
`wget https://raw.githubusercontent.com/yusing/go-proxy/v0.9/.env.example -O .env`
3. Grab `compose.example.yml` into `compose.yml`
`wget https://raw.githubusercontent.com/yusing/godoxy/main/compose.example.yml -O compose.yml`
`wget https://raw.githubusercontent.com/yusing/go-proxy/v0.9/compose.example.yml -O compose.yml`
### Folder structrue
@ -180,16 +118,20 @@ Configure Wildcard DNS Record(s) to point to machine running `GoDoxy`, e.g.
│ │ ├── middleware2.yml
│ ├── provider1.yml
│ └── provider2.yml
├── data
│ ├── metrics # metrics data
│ │ ├── uptime.json
│ │ └── system_info.json
└── .env
```
## Screenshots
### idlesleeper
![idlesleeper](screenshots/idlesleeper.webp)
[🔼Back to top](#table-of-content)
## Build it yourself
1. Clone the repository `git clone https://github.com/yusing/godoxy --depth=1`
1. Clone the repository `git clone https://github.com/yusing/go-proxy --depth=1`
2. Install / Upgrade [go (>=1.22)](https://go.dev/doc/install) and `make` if not already

View file

@ -3,18 +3,19 @@
# 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)
![GitHub last commit](https://img.shields.io/github/last-commit/yusing/go-proxy)
[![Lines of Code](https://sonarcloud.io/api/project_badges/measure?project=yusing_go-proxy&metric=ncloc)](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy)
![Demo](https://img.shields.io/website?url=https%3A%2F%2Fdemo.godoxy.dev&label=Demo&link=https%3A%2F%2Fdemo.godoxy.dev)
[![Discord](https://dcbadge.limes.pink/api/server/umReR62nRd?style=flat)](https://discord.gg/umReR62nRd)
[![](https://dcbadge.limes.pink/api/server/umReR62nRd?style=flat)](https://discord.gg/umReR62nRd)
輕量、易用、 高效能,且帶有主頁和配置面板的反向代理
輕量、易用、 [高效能](https://github.com/yusing/go-proxy/wiki/Benchmarks),且帶有主頁和配置面板的反向代理
<h5>
<a href="https://docs.godoxy.dev">網站</a> | <a href="https://docs.godoxy.dev/Home.html">文檔</a> | <a href="https://discord.gg/umReR62nRd">Discord</a>
</h5>
完整文檔請查閱 **[Wiki](https://github.com/yusing/go-proxy/wiki)**(暫未有中文翻譯)
<h5><a href="README.md">EN</a> | 中文</h5>
<!-- [![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=yusing_go-proxy&metric=security_rating)](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy)
[![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=yusing_go-proxy&metric=sqale_rating)](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy)
[![Vulnerabilities](https://sonarcloud.io/api/project_badges/measure?project=yusing_go-proxy&metric=vulnerabilities)](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy) -->
<a href="README.md">EN</a> | **中文**
<img src="https://github.com/user-attachments/assets/4bb371f4-6e4c-425c-89b2-b9e962bdd46f" style="max-width: 650">
@ -26,7 +27,6 @@
- [GoDoxy](#godoxy)
- [目錄](#目錄)
- [運行示例](#運行示例)
- [主要特點](#主要特點)
- [前置需求](#前置需求)
- [安裝](#安裝)
@ -34,51 +34,28 @@
- [資料夾結構](#資料夾結構)
- [截圖](#截圖)
- [閒置休眠](#閒置休眠)
- [監控](#監控)
- [自行編譯](#自行編譯)
## 運行示例
<https://demo.godoxy.dev>
[![Deployed on Zeabur](https://zeabur.com/deployed-on-zeabur-dark.svg)](https://zeabur.com/referral?referralCode=yusing&utm_source=yusing&utm_campaign=oss)
## 主要特點
- **簡單易用**
- 透過 Docker[標籤](https://docs.godoxy.dev/Docker-labels-and-Route-Files)或 WebUI 輕鬆設定
- [簡單的多節點設置](https://docs.godoxy.dev/Configurations#multi-docker-nodes-setup)
- 詳細的錯誤訊息,便於故障排除
- **存取控制 (ACL)**:連線/請求層級存取控制
- IP/CIDR
- 國家 **(需要 Maxmind 帳戶)**
- 時區 **(需要 Maxmind 帳戶)**
- **存取日誌記錄**
- **自動化**
- 使用 Let's Encrypt 自動管理 SSL 憑證 ([使用 DNS-01 驗證](https://docs.godoxy.dev/DNS-01-Providers))
- Docker 容器自動配置
- 設定檔與容器狀態變更時自動熱重載
- **閒置休眠**:根據流量停止和喚醒容器 _(參見[截圖](#閒置休眠))_
- Docker 容器
- Proxmox LXC 容器
- **流量管理**
- HTTP 反向代理
- TCP/UDP 連接埠轉送
- **OpenID Connect 支援**:輕鬆實現單點登入 (SSO) 並保護您的應用程式
- **客製化**
- [HTTP 中介軟體](https://docs.godoxy.dev/Middlewares)
- [支援自訂錯誤頁面](https://docs.godoxy.dev/Custom-Error-Pages)
- **網頁使用者介面 (Web UI)**
- 應用程式一覽
- 設定編輯器
- 執行時間與系統指標
- Docker 日誌檢視器
- **跨平台支援**
- 支援 **linux/amd64** 與 **linux/arm64**
- **高效能**
- 以 **[Go](https://go.dev)** 語言編寫
- 容易使用
- 輕鬆配置
- 簡單的多節點設置
- 錯誤訊息清晰詳細,易於排除故障
- 自動 SSL 憑證管理(參見 [支援的 DNS-01 驗證提供商](https://github.com/yusing/go-proxy/wiki/Supported-DNS%E2%80%9001-Providers)
- 自動配置 Docker 容器
- 容器狀態/配置文件變更時自動熱重載
- **閒置休眠**在閒置時停止容器有流量時喚醒_可選參見[截圖](#閒置休眠)_
- HTTP(s) 反向代理
- OpenID Connect 支持
- [HTTP 中介軟體支援](https://github.com/yusing/go-proxy/wiki/Middlewares)
- [自訂錯誤頁面支援](https://github.com/yusing/go-proxy/wiki/Middlewares#custom-error-pages)
- TCP 和 UDP 埠轉發
- **網頁介面,具有應用儀表板和配置編輯器**
- 支援 linux/amd64、linux/arm64
- 使用 **[Go](https://go.dev)** 編寫
[🔼 回到頂部](#目錄)
[🔼回到頂部](#目錄)
## 前置需求
@ -89,36 +66,39 @@
## 安裝
> [!NOTE]
> GoDoxy 僅在 `host` 網路模式下運作,請勿更改。
>
> 如需更改監聽埠,請修改 `.env`
1. 準備一個新目錄用於 docker compose 和配置文件。
2. 在目錄內運行安裝腳本,或[手動安裝](#手動安裝)
1. 拉取最新的 Docker 映像
```shell
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/yusing/godoxy/main/scripts/setup.sh)"
docker pull ghcr.io/yusing/go-proxy:latest
```
3. 現在可以在 WebUI `https://godoxy.yourdomain.com` 進行額外配置
2. 建立新目錄,`cd` 進入後運行安裝,或[手動安裝](#手動安裝)
[🔼 回到頂部](#目錄)
```shell
docker run --rm -v .:/setup ghcr.io/yusing/go-proxy /app/godoxy setup
```
3. _可選_ 設置其他 Docker 節點的 `docker-socket-proxy`(參見 [多 Docker 節點設置](https://github.com/yusing/go-proxy/wiki/Configurations#multi-docker-nodes-setup)),然後在 `config.yml` 中添加它們
4. 啟動容器 `docker compose up -d`
5. 大功告成!可前往WebUI `https://gp.domain.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`
`mkdir -p config && wget https://raw.githubusercontent.com/yusing/go-proxy/v0.9/config.example.yml -O config/config.yml`
2. 將 `.env.example` 下載到 `.env`
`wget https://raw.githubusercontent.com/yusing/godoxy/main/.env.example -O .env`
`wget https://raw.githubusercontent.com/yusing/go-proxy/v0.9/.env.example -O .env`
3. 將 `compose.example.yml` 下載到 `compose.yml`
`wget https://raw.githubusercontent.com/yusing/godoxy/main/compose.example.yml -O compose.yml`
`wget https://raw.githubusercontent.com/yusing/go-proxy/v0.9/compose.example.yml -O compose.yml`
### 資料夾結構
@ -134,10 +114,6 @@
│ │ ├── middleware2.yml
│ ├── provider1.yml
│ └── provider2.yml
├── data
│ ├── metrics # metrics data
│ │ ├── uptime.json
│ │ └── system_info.json
└── .env
```
@ -147,36 +123,11 @@
![閒置休眠](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`
1. 克隆儲存庫 `git clone https://github.com/yusing/go-proxy --depth=1`
2. 如果尚未安裝,請安裝/升級 [go (>=1.22)](https://go.dev/doc/install) 和 `make`
@ -186,4 +137,4 @@
5. 使用 `make build` 編譯二進制檔案
[🔼 回到頂部](#目錄)
[🔼回到頂部](#目錄)

View file

@ -1,69 +0,0 @@
package main
import (
"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/metrics/systeminfo"
httpServer "github.com/yusing/go-proxy/internal/net/gphttp/server"
"github.com/yusing/go-proxy/internal/task"
"github.com/yusing/go-proxy/pkg"
socketproxy "github.com/yusing/go-proxy/socketproxy/pkg"
)
func main() {
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)
if socketproxy.ListenAddr != "" {
logging.Info().Msgf("Docker socket listening on: %s", socketproxy.ListenAddr)
opts := httpServer.Options{
Name: "docker",
HTTPAddr: socketproxy.ListenAddr,
Handler: socketproxy.NewHandler(),
}
httpServer.StartServer(t, opts)
}
systeminfo.Poller.Start()
task.WaitExit(3)
}

View file

@ -1,92 +0,0 @@
module github.com/yusing/go-proxy/agent
go 1.24.3
replace github.com/yusing/go-proxy => ..
replace github.com/yusing/go-proxy/socketproxy => ../socket-proxy
replace github.com/docker/docker => github.com/godoxy-app/docker v0.0.0-20250425105916-b2ad800de7a1
replace github.com/shirou/gopsutil/v4 => github.com/godoxy-app/gopsutil/v4 v4.0.0-20250502022742-408a348f1b97
require (
github.com/coder/websocket v1.8.13
github.com/rs/zerolog v1.34.0
github.com/stretchr/testify v1.10.0
github.com/yusing/go-proxy v0.0.0-00010101000000-000000000000
github.com/yusing/go-proxy/socketproxy v0.0.0-00010101000000-000000000000
)
require (
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/PuerkitoBio/goquery v1.10.3 // indirect
github.com/andybalholm/cascadia v1.3.3 // indirect
github.com/buger/goterm v1.0.4 // indirect
github.com/cenkalti/backoff/v4 v4.3.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/cli v28.1.1+incompatible // indirect
github.com/docker/docker v28.1.1+incompatible // 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.3 // indirect
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
github.com/go-acme/lego/v4 v4.23.1 // indirect
github.com/go-jose/go-jose/v4 v4.1.0 // 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-playground/validator/v10 v10.26.0 // indirect
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
github.com/goccy/go-yaml v1.17.1 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/google/pprof v0.0.0-20250501235452-c0086092b71a // indirect
github.com/gorilla/mux v1.8.1 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/gotify/server/v2 v2.6.3 // indirect
github.com/jinzhu/copier v0.4.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/lithammer/fuzzysearch v1.1.8 // indirect
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 // indirect
github.com/luthermonson/go-proxmox v0.2.2 // 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.66 // indirect
github.com/moby/docker-image-spec v1.3.1 // 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/oschwald/maxminddb-golang v1.13.1 // 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/puzpuzpuz/xsync/v4 v4.1.0 // indirect
github.com/quic-go/qpack v0.5.1 // indirect
github.com/quic-go/quic-go v0.51.0 // indirect
github.com/samber/lo v1.50.0 // indirect
github.com/samber/slog-common v0.18.1 // indirect
github.com/samber/slog-zerolog/v2 v2.7.3 // indirect
github.com/shirou/gopsutil/v4 v4.25.4 // indirect
github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af // indirect
github.com/spf13/afero v1.14.0 // indirect
github.com/tklauser/go-sysconf v0.3.15 // indirect
github.com/tklauser/numcpus v0.10.0 // indirect
github.com/vincent-petithory/dataurl v1.0.0 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.uber.org/atomic v1.11.0 // indirect
go.uber.org/automaxprocs v1.6.0 // indirect
go.uber.org/mock v0.5.2 // indirect
golang.org/x/crypto v0.38.0 // indirect
golang.org/x/mod v0.24.0 // indirect
golang.org/x/net v0.40.0 // indirect
golang.org/x/sync v0.14.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.25.0 // indirect
golang.org/x/time v0.11.0 // indirect
golang.org/x/tools v0.33.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

View file

@ -1,330 +0,0 @@
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/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/buger/goterm v1.0.4 h1:Z9YvGmOih81P0FbVtEYTFF6YsSgxSUKEhf/f9bTMXbY=
github.com/buger/goterm v1.0.4/go.mod h1:HiFWV3xnkolgrBV3mY8m0X0Pumt4zg4QhbdOzQtB8tE=
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/coder/websocket v1.8.13 h1:f3QZdXy7uGVz+4uCJy2nTZyM0yTBj8yANEHhqlXZ9FE=
github.com/coder/websocket v1.8.13/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
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-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/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.1.1+incompatible h1:eyUemzeI45DY7eDPuwUcmDyDj1pM98oD5MdSpiItp8k=
github.com/docker/cli v28.1.1+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
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.3 h1:K+0AjQp63JEZTEMZiwsI9g0+hAMNohwUOtY0RPGexmc=
github.com/ebitengine/purego v0.8.3/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/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
github.com/go-acme/lego/v4 v4.23.1 h1:lZ5fGtGESA2L9FB8dNTvrQUq3/X4QOb8ExkKyY7LSV4=
github.com/go-acme/lego/v4 v4.23.1/go.mod h1:7UMVR7oQbIYw6V7mTgGwi4Er7B6Ww0c+c8feiBM0EgI=
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/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/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/godoxy-app/docker v0.0.0-20250425105916-b2ad800de7a1 h1:fsSqE28vU0PRkq9FdekirRoDBeYJ+UaJ9dTErdXflWg=
github.com/godoxy-app/docker v0.0.0-20250425105916-b2ad800de7a1/go.mod h1:av6ggKWQz6SEkFyShjDEgVqiIB0RHvEQNIkPeqgJEeE=
github.com/godoxy-app/gopsutil/v4 v4.0.0-20250502022742-408a348f1b97 h1:i52gBYamrKs4DHT1+SiobW2im5UgTMVXK1KIL1djSeA=
github.com/godoxy-app/gopsutil/v4 v4.0.0-20250502022742-408a348f1b97/go.mod h1:XvbfPmmrdpLrsKwj3irYkxt5ygyMcDsTQTJ7cnZ9RNQ=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
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/pprof v0.0.0-20250501235452-c0086092b71a h1:rDA3FfmxwXR+BVKKdz55WwMJ1pD2hJQNW31d+l3mPk4=
github.com/google/pprof v0.0.0-20250501235452-c0086092b71a/go.mod h1:5hDyRhoBCxViHszMt12TnOpEI4VVi+U8Gm9iphldiMA=
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/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
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.3 h1:2sLDRsQ/No1+hcFwFDvjNtwKepfCSIR8L3BkXl/Vz1I=
github.com/gotify/server/v2 v2.6.3/go.mod h1:IyeQ/iL3vetcuqUAzkCMVObIMGGJx4zb13/mVatIwE8=
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/jinzhu/copier v0.4.0 h1:w3ciUoD19shMCRargcpm0cm91ytaBhDvuRpz1ODO/U8=
github.com/jinzhu/copier v0.4.0/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg=
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/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
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/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/miekg/dns v1.1.66 h1:FeZXOS3VCVsKnEAd+wBkjMC3D2K+ww66Cq3VnCINuJE=
github.com/miekg/dns v1.1.66/go.mod h1:jGFzBsSNbJw6z1HYut1RKBKHA9PBdxeHrZG8J+gC2WE=
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/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=
github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs=
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
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/oschwald/maxminddb-golang v1.13.1 h1:G3wwjdN9JmIK2o/ermkHM+98oX5fS+k5MbwsmL4MRQE=
github.com/oschwald/maxminddb-golang v1.13.1/go.mod h1:K4pgV9N/GcK694KSTmVSDTODk4IsCNThNdTmnaBZ/F8=
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/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/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/puzpuzpuz/xsync/v4 v4.1.0 h1:x9eHRl4QhZFIPJ17yl4KKW9xLyVWbb3/Yq4SXpjF71U=
github.com/puzpuzpuz/xsync/v4 v4.1.0/go.mod h1:VJDmTCJMBt8igNxnkQd86r+8KUeN1quSfNKu5bLYFQo=
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.51.0 h1:K8exxe9zXxeRKxaXxi/GpUqYiTrtdiWP8bo1KFya6Wc=
github.com/quic-go/quic-go v0.51.0/go.mod h1:MFlGGpcpJqRAfmYi6NC2cptDPSxRWTOGNuP4wqrWmzQ=
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.50.0 h1:XrG0xOeHs+4FQ8gJR97zDz5uOFMW7OwFWiFVzqopKgY=
github.com/samber/lo v1.50.0/go.mod h1:RjZyNk6WSnUFRKK6EyOhsRJMqft3G+pg7dCWHQCWvsc=
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/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/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA=
github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
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/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/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/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.35.0 h1:1fTNlAIJZGWLP5FVu0fikVry1IsiUnXjf7QFvoNN3Xw=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0/go.mod h1:zjPK58DtkqQFn+YUMbx0M2XV3QgKU0gS9LeGohREyK4=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.31.0 h1:lUsI2TYsQw2r1IASwoROaCnjdj2cvC2+Jbxvk6nHnWU=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.31.0/go.mod h1:2HpZxxQurfGxJlJDblybejHB6RX6pmExPNe517hREw4=
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.31.0 h1:xLY3abVHYZ5HSfOg3l2E5LUj2Cwva5Y7yGxnSW9H5Gk=
go.opentelemetry.io/otel/sdk v1.31.0/go.mod h1:TfRbMdhvxIIr/B2N2LQW2S5v9m3gOQ/08KsbbO5BPT0=
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.6.0 h1:jQjP+AQyTf+Fe7OKj/MfkDrmK4MNVtw2NpXsf9fefDI=
go.opentelemetry.io/proto/otlp v1.6.0/go.mod h1:cicgGehlFuNdgZkcALOCh3VE6K/u2tAjzlRhDwmVpZc=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
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.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko=
go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=
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/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
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/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/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
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/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
golang.org/x/sync v0.14.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/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.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/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
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.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc=
golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI=
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-20250106144421-5f5ef82da422 h1:GVIKPyP/kLIyVOgOnTwFOrvQaQUzOzGMCxgFUOEmm24=
google.golang.org/genproto/googleapis/api v0.0.0-20250106144421-5f5ef82da422/go.mod h1:b6h1vNKhxaSoEI+5jc3PJUCustfli/mRab7295pY7rw=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250512202823-5a2f75b736a9 h1:IkAfh6J/yllPtpYFU0zZN1hUPYdT0ogkBT/9hMxHjvg=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250512202823-5a2f75b736a9/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
google.golang.org/grpc v1.72.1 h1:HR03wO6eyZ7lknl75XlxABNVLLFc2PAb6mHlYh756mA=
google.golang.org/grpc v1.72.1/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
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.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=

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/godoxy/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,197 +0,0 @@
package agent
import (
"context"
"crypto/tls"
"crypto/x509"
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"net/url"
"os"
"strings"
"time"
"github.com/rs/zerolog"
"github.com/yusing/go-proxy/agent/pkg/certs"
"github.com/yusing/go-proxy/internal/logging"
"github.com/yusing/go-proxy/pkg"
)
type AgentConfig struct {
Addr string
httpClient *http.Client
tlsConfig *tls.Config
name string
version string
l zerolog.Logger
}
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)
)
func mustParseURL(urlStr string) *url.URL {
u, err := url.Parse(urlStr)
if err != nil {
panic(err)
}
return u
}
var (
AgentURL = mustParseURL(APIBaseURL)
HTTPProxyURL = mustParseURL(APIBaseURL + EndpointProxyHTTP)
HTTPProxyURLPrefixLen = len(APIEndpointBase + EndpointProxyHTTP)
)
func IsDockerHostAgent(dockerHost string) bool {
return strings.HasPrefix(dockerHost, FakeDockerHostPrefix)
}
func GetAgentAddrFromDockerHost(dockerHost string) string {
return dockerHost[FakeDockerHostPrefixLen:]
}
func (cfg *AgentConfig) FakeDockerHost() string {
return FakeDockerHostPrefix + cfg.Addr
}
func (cfg *AgentConfig) Parse(addr string) error {
cfg.Addr = addr
return nil
}
var serverVersion = pkg.GetVersion()
func (cfg *AgentConfig) StartWithCerts(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 errors.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()
// get agent name
name, _, err := cfg.Fetch(ctx, EndpointName)
if err != nil {
return err
}
cfg.name = string(name)
cfg.l = logging.With().Str("agent", cfg.name).Logger()
// check agent version
agentVersionBytes, _, err := cfg.Fetch(ctx, EndpointVersion)
if err != nil {
return err
}
cfg.version = string(agentVersionBytes)
agentVersion := pkg.ParseVersion(cfg.version)
if serverVersion.IsNewerMajorThan(agentVersion) {
logging.Warn().Msgf("agent %s major version mismatch: server: %s, agent: %s", cfg.name, serverVersion, agentVersion)
}
logging.Info().Msgf("agent %q initialized", cfg.name)
return nil
}
func (cfg *AgentConfig) Start(ctx context.Context) error {
filepath, ok := certs.AgentCertsFilepath(cfg.Addr)
if !ok {
return fmt.Errorf("invalid agent host: %s", cfg.Addr)
}
certData, err := os.ReadFile(filepath)
if err != nil {
return fmt.Errorf("failed to read agent certs: %w", err)
}
ca, crt, key, err := certs.ExtractCert(certData)
if err != nil {
return fmt.Errorf("failed to extract agent certs: %w", err)
}
return cfg.StartWithCerts(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,
}
}
var dialer = &net.Dialer{Timeout: 5 * time.Second}
func (cfg *AgentConfig) DialContext(ctx context.Context) (net.Conn, error) {
return dialer.DialContext(ctx, "tcp", cfg.Addr)
}
func (cfg *AgentConfig) Name() string {
return cfg.name
}
func (cfg *AgentConfig) String() string {
return cfg.name + "@" + cfg.Addr
}
func (cfg *AgentConfig) MarshalJSON() ([]byte, error) {
return json.Marshal(map[string]string{
"name": cfg.Name(),
"addr": cfg.Addr,
"version": cfg.version,
})
}

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,189 +0,0 @@
package agent
import (
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/base64"
"encoding/pem"
"errors"
"math/big"
"strings"
"time"
"crypto/ecdsa"
"crypto/elliptic"
"fmt"
)
const (
CertsDNSName = "godoxy.agent"
)
func toPEMPair(certDER []byte, key *ecdsa.PrivateKey) *PEMPair {
marshaledKey, err := marshalECPrivateKey(key)
if err != nil {
// This is a critical internal error during PEM encoding of a newly generated key.
// Panicking is acceptable here as it indicates a fundamental issue.
panic(fmt.Sprintf("failed to marshal EC private key for PEM encoding: %v", err))
}
return &PEMPair{
Cert: pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}),
Key: pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: marshaledKey}),
}
}
func marshalECPrivateKey(key *ecdsa.PrivateKey) ([]byte, error) {
derBytes, err := x509.MarshalECPrivateKey(key)
if err != nil {
return nil, fmt.Errorf("failed to marshal EC private key: %w", err)
}
return derBytes, nil
}
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 newSerialNumber() (*big.Int, error) {
serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128) // 128-bit random number
serialNumber, err := rand.Int(rand.Reader, serialNumberLimit)
if err != nil {
return nil, fmt.Errorf("failed to generate serial number: %w", err)
}
return serialNumber, nil
}
func NewAgent() (ca, srv, client *PEMPair, err error) {
caSerialNumber, err := newSerialNumber()
if err != nil {
return nil, nil, nil, err
}
// Create the CA's certificate
caTemplate := &x509.Certificate{
SerialNumber: caSerialNumber,
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,
MaxPathLen: 0,
MaxPathLenZero: true,
SignatureAlgorithm: x509.ECDSAWithSHA256,
}
caKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
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 := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, nil, nil, err
}
serverSerialNumber, err := newSerialNumber()
if err != nil {
return nil, nil, nil, err
}
srvTemplate := &x509.Certificate{
SerialNumber: serverSerialNumber,
Issuer: caTemplate.Subject,
Subject: pkix.Name{
Organization: caTemplate.Subject.Organization,
OrganizationalUnit: []string{"Server"},
CommonName: CertsDNSName,
},
DNSNames: []string{CertsDNSName},
NotBefore: time.Now(),
NotAfter: time.Now().AddDate(1000, 0, 0), // Add validity period
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
SignatureAlgorithm: x509.ECDSAWithSHA256,
}
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 := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, nil, nil, err
}
clientSerialNumber, err := newSerialNumber()
if err != nil {
return nil, nil, nil, err
}
clientTemplate := &x509.Certificate{
SerialNumber: clientSerialNumber,
Issuer: caTemplate.Subject,
Subject: pkix.Name{
Organization: caTemplate.Subject.Organization,
OrganizationalUnit: []string{"Client"},
CommonName: CertsDNSName,
},
DNSNames: []string{CertsDNSName},
NotBefore: time.Now(),
NotAfter: time.Now().AddDate(1000, 0, 0),
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
SignatureAlgorithm: x509.ECDSAWithSHA256,
}
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/stretchr/testify/require"
)
func TestNewAgent(t *testing.T) {
ca, srv, client, err := NewAgent()
require.NoError(t, err)
require.NotNil(t, ca)
require.NotNil(t, srv)
require.NotNil(t, client)
}
func TestPEMPair(t *testing.T) {
ca, srv, client, err := NewAgent()
require.NoError(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())
require.NoError(t, err)
require.Equal(t, p.Cert, pp.Cert)
require.Equal(t, p.Key, pp.Key)
})
}
}
func TestPEMPairToTLSCert(t *testing.T) {
ca, srv, client, err := NewAgent()
require.NoError(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()
require.NoError(t, err)
require.NotNil(t, cert)
})
}
}
func TestServerClient(t *testing.T) {
ca, srv, client, err := NewAgent()
require.NoError(t, err)
srvTLS, err := srv.ToTLSCert()
require.NoError(t, err)
require.NotNil(t, srvTLS)
clientTLS, err := client.ToTLSCert()
require.NoError(t, err)
require.NotNil(t, clientTLS)
caPool := x509.NewCertPool()
require.True(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)
require.NoError(t, err)
require.Equal(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,44 +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}}"
# use agent as a docker socket proxy: [host]:port
# set LISTEN_ADDR to enable (e.g. 127.0.0.1:2375)
LISTEN_ADDR:
POST: false
ALLOW_RESTARTS: false
ALLOW_START: false
ALLOW_STOP: false
AUTH: false
BUILD: false
COMMIT: false
CONFIGS: false
CONTAINERS: false
DISTRIBUTION: false
EVENTS: true
EXEC: false
GRPC: false
IMAGES: false
INFO: false
NETWORKS: false
NODES: false
PING: true
PLUGINS: false
SECRETS: false
SERVICES: false
SESSION: false
SWARM: false
SYSTEM: false
TASKS: false
VERSION: true
VOLUMES: false
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,85 +0,0 @@
package certs
import (
"archive/zip"
"bytes"
"io"
"path/filepath"
"github.com/yusing/go-proxy/internal/utils/strutils"
)
const AgentCertsBasePath = "certs"
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(AgentCertsBasePath, 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,20 +0,0 @@
package certs_test
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/yusing/go-proxy/agent/pkg/certs"
)
func TestZipCert(t *testing.T) {
ca, crt, key := []byte("test1"), []byte("test2"), []byte("test3")
zipData, err := certs.ZipCert(ca, crt, key)
require.NoError(t, err)
ca2, crt2, key2, err := certs.ExtractCert(zipData)
require.NoError(t, err)
require.Equal(t, ca, ca2)
require.Equal(t, crt, crt2)
require.Equal(t, key, key2)
}

38
agent/pkg/env/env.go vendored
View file

@ -1,38 +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 string
AgentPort int
AgentSkipClientCertCheck bool
AgentCACert string
AgentSSLCert string
DockerSocket string
)
func init() {
Load()
}
func Load() {
DockerSocket = common.GetEnvString("DOCKER_SOCKET", "/var/run/docker.sock")
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,80 +0,0 @@
package handler
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
"strings"
"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, "missing scheme", http.StatusBadRequest)
return
}
var result *health.HealthCheckResult
var err error
switch scheme {
case "fileserver":
path := query.Get("path")
if path == "" {
http.Error(w, "missing path", 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, "missing host", 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, "missing host", http.StatusBadRequest)
return
}
hasPort := strings.Contains(host, ":")
port := query.Get("port")
if port != "" && hasPort {
http.Error(w, "port and host with port cannot both be provided", http.StatusBadRequest)
return
}
if port != "" {
host = fmt.Sprintf("%s:%s", host, port)
}
result, err = monitor.NewRawHealthMonitor(&url.URL{
Scheme: scheme,
Host: host,
}, defaultHealthConfig).CheckHealth()
}
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(result)
}

View file

@ -1,216 +0,0 @@
package handler_test
import (
"encoding/json"
"net"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"testing"
"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,57 +0,0 @@
package handler
import (
"context"
"fmt"
"net"
"net/http"
"net/http/httputil"
"net/url"
"time"
"github.com/yusing/go-proxy/agent/pkg/agent"
"github.com/yusing/go-proxy/agent/pkg/env"
"github.com/yusing/go-proxy/internal/metrics/systeminfo"
"github.com/yusing/go-proxy/pkg"
)
type ServeMux struct{ *http.ServeMux }
func (mux ServeMux) HandleEndpoint(method, endpoint string, handler http.HandlerFunc) {
mux.ServeMux.HandleFunc(method+" "+agent.APIEndpointBase+endpoint, handler)
}
func (mux ServeMux) HandleFunc(endpoint string, handler http.HandlerFunc) {
mux.ServeMux.HandleFunc(agent.APIEndpointBase+endpoint, handler)
}
var dialer = &net.Dialer{KeepAlive: 1 * time.Second}
func dialDockerSocket(ctx context.Context, _, _ string) (net.Conn, error) {
return dialer.DialContext(ctx, "unix", env.DockerSocket)
}
func dockerSocketHandler() http.HandlerFunc {
rp := httputil.NewSingleHostReverseProxy(&url.URL{
Scheme: "http",
Host: "api.moby.localhost",
})
rp.Transport = &http.Transport{
DialContext: dialDockerSocket,
}
return rp.ServeHTTP
}
func NewAgentHandler() http.Handler {
mux := ServeMux{http.NewServeMux()}
mux.HandleFunc(agent.EndpointProxyHTTP+"/{path...}", ProxyHTTP)
mux.HandleEndpoint("GET", agent.EndpointVersion, pkg.GetVersionHTTPHandler())
mux.HandleEndpoint("GET", agent.EndpointName, func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, env.AgentName)
})
mux.HandleEndpoint("GET", agent.EndpointHealth, CheckHealth)
mux.HandleEndpoint("GET", agent.EndpointSystemInfo, systeminfo.Poller.ServeHTTP)
mux.ServeMux.HandleFunc("/", dockerSocketHandler())
return mux
}

View file

@ -1,67 +0,0 @@
package handler
import (
"crypto/tls"
"net/http"
"net/http/httputil"
"strconv"
"time"
"github.com/yusing/go-proxy/agent/pkg/agent"
"github.com/yusing/go-proxy/agent/pkg/agentproxy"
)
func NewTransport() *http.Transport {
return &http.Transport{
MaxIdleConnsPerHost: 100,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
ResponseHeaderTimeout: 60 * time.Second,
WriteBufferSize: 16 * 1024, // 16KB
ReadBufferSize: 16 * 1024, // 16KB
}
}
func ProxyHTTP(w http.ResponseWriter, r *http.Request) {
host := r.Header.Get(agentproxy.HeaderXProxyHost)
isHTTPS, _ := strconv.ParseBool(r.Header.Get(agentproxy.HeaderXProxyHTTPS))
skipTLSVerify, _ := strconv.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"
}
transport := NewTransport()
if skipTLSVerify {
transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
}
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()
rp := &httputil.ReverseProxy{
Director: func(r *http.Request) {
r.URL.Scheme = scheme
r.URL.Host = host
},
Transport: 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, nil, logger)
}

120
bun.lock Normal file
View file

@ -0,0 +1,120 @@
{
"lockfileVersion": 1,
"workspaces": {
"": {
"name": "godoxy-types",
"devDependencies": {
"prettier": "^3.4.2",
"typescript": "^5.7.3",
"typescript-json-schema": "^0.65.1",
},
},
},
"packages": {
"@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="],
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="],
"@tsconfig/node10": ["@tsconfig/node10@1.0.11", "", {}, "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw=="],
"@tsconfig/node12": ["@tsconfig/node12@1.0.11", "", {}, "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag=="],
"@tsconfig/node14": ["@tsconfig/node14@1.0.3", "", {}, "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow=="],
"@tsconfig/node16": ["@tsconfig/node16@1.0.4", "", {}, "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA=="],
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
"@types/node": ["@types/node@18.19.74", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-HMwEkkifei3L605gFdV+/UwtpxP6JSzM+xFk2Ia6DNFSwSVBRh9qp5Tgf4lNFOMfPVuU0WnkcWpXZpgn5ufO4A=="],
"acorn": ["acorn@8.14.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA=="],
"acorn-walk": ["acorn-walk@8.3.4", "", { "dependencies": { "acorn": "^8.11.0" } }, "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g=="],
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"arg": ["arg@4.1.3", "", {}, "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA=="],
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"brace-expansion": ["brace-expansion@1.1.11", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA=="],
"cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
"create-require": ["create-require@1.1.1", "", {}, "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ=="],
"diff": ["diff@4.0.2", "", {}, "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A=="],
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="],
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
"glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
"inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="],
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
"make-error": ["make-error@1.3.6", "", {}, "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw=="],
"minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
"path-equal": ["path-equal@1.2.5", "", {}, "sha512-i73IctDr3F2W+bsOWDyyVm/lqsXO47aY9nsFZUjTT/aljSbkxHxxCoyZ9UUrM8jK0JVod+An+rl48RCsvWM+9g=="],
"path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="],
"prettier": ["prettier@3.4.2", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-e9MewbtFo+Fevyuxn/4rrcDAaq0IYxPGLvObpQjiZBMAzB9IGmzlnG9RZy3FFas+eBMu2vA0CszMeduow5dIuQ=="],
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
"safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="],
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"ts-node": ["ts-node@10.9.2", "", { "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", "@tsconfig/node12": "^1.0.7", "@tsconfig/node14": "^1.0.0", "@tsconfig/node16": "^1.0.2", "acorn": "^8.4.1", "acorn-walk": "^8.1.1", "arg": "^4.1.0", "create-require": "^1.1.0", "diff": "^4.0.1", "make-error": "^1.1.1", "v8-compile-cache-lib": "^3.0.1", "yn": "3.1.1" }, "peerDependencies": { "@swc/core": ">=1.2.50", "@swc/wasm": ">=1.2.50", "@types/node": "*", "typescript": ">=2.7" }, "optionalPeers": ["@swc/core", "@swc/wasm"], "bin": { "ts-node": "dist/bin.js", "ts-script": "dist/bin-script-deprecated.js", "ts-node-cwd": "dist/bin-cwd.js", "ts-node-esm": "dist/bin-esm.js", "ts-node-script": "dist/bin-script.js", "ts-node-transpile-only": "dist/bin-transpile.js" } }, "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ=="],
"typescript": ["typescript@5.7.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw=="],
"typescript-json-schema": ["typescript-json-schema@0.65.1", "", { "dependencies": { "@types/json-schema": "^7.0.9", "@types/node": "^18.11.9", "glob": "^7.1.7", "path-equal": "^1.2.5", "safe-stable-stringify": "^2.2.0", "ts-node": "^10.9.1", "typescript": "~5.5.0", "yargs": "^17.1.1" }, "bin": { "typescript-json-schema": "bin/typescript-json-schema" } }, "sha512-tuGH7ff2jPaUYi6as3lHyHcKpSmXIqN7/mu50x3HlYn0EHzLpmt3nplZ7EuhUkO0eqDRc9GqWNkfjgBPIS9kxg=="],
"undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
"v8-compile-cache-lib": ["v8-compile-cache-lib@3.0.1", "", {}, "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg=="],
"wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
"yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
"yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
"yn": ["yn@3.1.1", "", {}, "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q=="],
"typescript-json-schema/typescript": ["typescript@5.5.4", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q=="],
}
}

View file

@ -1,63 +1,133 @@
package main
import (
"encoding/json"
"io"
"log"
"os"
"sync"
"os/signal"
"syscall"
"time"
"github.com/yusing/go-proxy/internal/auth"
"github.com/rs/zerolog"
"github.com/yusing/go-proxy/internal"
v1 "github.com/yusing/go-proxy/internal/api/v1"
"github.com/yusing/go-proxy/internal/api/v1/auth"
"github.com/yusing/go-proxy/internal/api/v1/favicon"
"github.com/yusing/go-proxy/internal/api/v1/query"
"github.com/yusing/go-proxy/internal/common"
"github.com/yusing/go-proxy/internal/config"
"github.com/yusing/go-proxy/internal/dnsproviders"
"github.com/yusing/go-proxy/internal/gperr"
E "github.com/yusing/go-proxy/internal/error"
"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/net/http/middleware"
"github.com/yusing/go-proxy/internal/route/routes/routequery"
"github.com/yusing/go-proxy/internal/task"
"github.com/yusing/go-proxy/pkg"
)
func parallel(fns ...func()) {
var wg sync.WaitGroup
for _, fn := range fns {
wg.Add(1)
go func() {
defer wg.Done()
fn()
}()
var rawLogger = log.New(os.Stdout, "", 0)
func init() {
var out io.Writer = os.Stderr
if common.EnableLogStreaming {
out = zerolog.MultiLevelWriter(out, v1.GetMemLogger())
}
wg.Wait()
logging.InitLogger(out)
// logging.AddHook(v1.GetMemLogger())
}
func main() {
initProfiling()
args := common.GetArgs()
logging.InitLogger(os.Stderr, memlogger.GetMemLogger())
switch args.Command {
case common.CommandSetup:
internal.Setup()
return
case common.CommandReload:
if err := query.ReloadServer(); err != nil {
E.LogFatal("server reload error", err)
}
rawLogger.Println("ok")
return
case common.CommandListIcons:
icons, err := internal.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.Info().Msgf("GoDoxy version %s", pkg.GetVersion())
logging.Trace().Msg("trace enabled")
parallel(
dnsproviders.InitProviders,
homepage.InitIconListCache,
systeminfo.Poller.Start,
middleware.LoadComposeFiles,
)
// logging.AddHook(notif.GetDispatcher())
} else {
logging.DiscardLogger()
}
if common.APIJWTSecret == nil {
logging.Warn().Msg("API_JWT_SECRET is not set, using random key")
common.APIJWTSecret = common.RandomJWTKey()
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)
}
cfg, err := config.Load()
if err != nil {
gperr.LogWarn("errors in config", err)
middleware.LoadComposeFiles()
var cfg *config.Config
var err E.Error
if cfg, err = config.Load(); err != nil {
E.LogWarn("errors in config", err)
}
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
}
go internal.InitIconListCache()
go homepage.InitOverridesConfig()
go favicon.InitIconCache()
cfg.Start(&config.StartServersOptions{
Proxy: true,
})
@ -69,10 +139,19 @@ func main() {
API: true,
})
uptime.Poller.Start()
config.WatchChanges()
task.WaitExit(cfg.Value().TimeoutShutdown)
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGINT)
signal.Notify(sig, syscall.SIGTERM)
signal.Notify(sig, syscall.SIGHUP)
// wait for signal
<-sig
// gracefully shutdown
logging.Info().Msg("shutting down")
_ = task.GracefulShutdown(time.Second * time.Duration(cfg.Value().TimeoutShutdown))
}
func prepareDirectory(dir string) {
@ -82,3 +161,11 @@ func prepareDirectory(dir string) {
}
}
}
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,4 +1,4 @@
//go:build !pprof
//go:build production
package main

View file

@ -1,46 +1,18 @@
---
services:
socket-proxy:
container_name: socket-proxy
image: ghcr.io/yusing/socket-proxy:latest
environment:
- ALLOW_START=1
- ALLOW_STOP=1
- ALLOW_RESTARTS=1
- CONTAINERS=1
- EVENTS=1
- INFO=1
- PING=1
- POST=1
- VERSION=1
volumes:
- ${DOCKER_SOCKET:-/var/run/docker.sock}:/var/run/docker.sock
restart: unless-stopped
tmpfs:
- /run
ports:
- ${SOCKET_PROXY_LISTEN_ADDR:-127.0.0.1:2375}:2375
frontend:
image: ghcr.io/yusing/godoxy-frontend:${TAG:-latest}
image: ghcr.io/yusing/go-proxy-frontend:latest
container_name: godoxy-frontend
restart: unless-stopped
network_mode: host # do not change this
network_mode: host
env_file: .env
user: ${GODOXY_UID:-1000}:${GODOXY_GID:-1000}
read_only: true
security_opt:
- no-new-privileges:true
cap_drop:
- all
depends_on:
- app
environment:
HOSTNAME: 127.0.0.1
PORT: ${GODOXY_FRONTEND_PORT:-3000}
# modify below to fit your needs
labels:
proxy.aliases: ${GODOXY_FRONTEND_ALIASES:-godoxy}
proxy.#1.port: ${GODOXY_FRONTEND_PORT:-3000}
# proxy.#1.middlewares.cidr_whitelist: |
proxy.aliases: godoxy
proxy.godoxy.port: 3000
# proxy.godoxy.middlewares.cidr_whitelist: |
# status: 403
# message: IP not allowed
# allow:
@ -49,28 +21,16 @@ services:
# - 192.168.0.0/16
# - 172.16.0.0/12
app:
image: ghcr.io/yusing/godoxy:${TAG:-latest}
image: ghcr.io/yusing/go-proxy:latest
container_name: godoxy
restart: always
network_mode: host # do not change this
network_mode: host
env_file: .env
user: ${GODOXY_UID:-1000}:${GODOXY_GID:-1000}
depends_on:
socket-proxy:
condition: service_started
security_opt:
- no-new-privileges:true
cap_drop:
- all
cap_add:
- NET_BIND_SERVICE
environment:
- DOCKER_HOST=tcp://${SOCKET_PROXY_LISTEN_ADDR:-127.0.0.1:2375}
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./config:/app/config
- ./logs:/app/logs
- ./error_pages:/app/error_pages:ro
- ./data:/app/data
- ./error_pages:/app/error_pages
# To use autocert, certs will be stored in "./certs".
# You can also use a docker volume to store it

View file

@ -15,48 +15,14 @@
# 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
# acl:
# default: allow # or deny (default: allow)
# allow_local: true # or false (default: true)
# allow:
# - ip:1.2.3.4
# - cidr:1.2.3.4/32
# - country:US
# - timezone:Asia/Shanghai
# deny:
# - ip:1.2.3.4
# - cidr:1.2.3.4/32
# - country:US
# - timezone:Asia/Shanghai
# log: # warning: logging ACL can be slow based on the number of incoming connections and configured rules
# buffer_size: 65536 # (default: 64KB)
# path: /app/logs/acl.log # (default: none)
# stdout: false # (default: false)
# keep: last 10 # (default: none)
# 3. other providers, see https://github.com/yusing/go-proxy/wiki/Supported-DNS%E2%80%9001-Providers#supported-dns-01-providers
entrypoint:
# Below define an example of middleware config
# 1. set security headers
# 2. block non local IP connections
# 3. redirect HTTP to HTTPS
# 1. block non local IP connections
# 2. redirect HTTP to HTTPS
#
middlewares:
- use: CloudflareRealIP
- use: ModifyResponse
set_headers:
Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS, HEAD
Access-Control-Allow-Headers: "*"
Access-Control-Allow-Origin: "*"
Access-Control-Max-Age: 180
Vary: "*"
X-XSS-Protection: 1; mode=block
Content-Security-Policy: "object-src 'self'; frame-ancestors 'self';"
X-Content-Type-Options: nosniff
X-Frame-Options: SAMEORIGIN
Referrer-Policy: same-origin
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
# middlewares:
# - use: CIDRWhitelist
# allow:
# - "127.0.0.1"
@ -107,15 +73,7 @@ providers:
# url: https://discord.com/api/webhooks/...
# template: discord # this means use payload template from internal/notif/templates/discord.json
# Proxmox providers (for idlesleep support for proxmox LXCs)
#
# proxmox:
# - url: https://pve.domain.com:8006/api2/json
# token_id: root@pam!abcdef
# secret: aaaa-bbbb-cccc-dddd
# no_tls_verify: true
# Check https://github.com/yusing/godoxy/wiki/Certificates-and-domain-matching#domain-matching
# Check https://github.com/yusing/go-proxy/wiki/Certificates-and-domain-matching#domain-matching
# for explaination of `match_domains`
#
# match_domains:

266
go.mod
View file

@ -1,251 +1,83 @@
module github.com/yusing/go-proxy
go 1.24.3
replace github.com/yusing/go-proxy/agent => ./agent
replace github.com/yusing/go-proxy/internal/dnsproviders => ./internal/dnsproviders
replace github.com/coreos/go-oidc/v3 => github.com/godoxy-app/go-oidc/v3 v3.14.2
replace github.com/docker/docker => github.com/godoxy-app/docker v0.0.0-20250425105916-b2ad800de7a1
replace github.com/shirou/gopsutil/v4 => github.com/godoxy-app/gopsutil/v4 v4.0.0-20250502022742-408a348f1b97
go 1.23.5
require (
github.com/PuerkitoBio/goquery v1.10.3 // parsing HTML for extract fav icon
github.com/coder/websocket v1.8.13 // websocket for API and agent
github.com/coreos/go-oidc/v3 v3.14.1 // oidc authentication
github.com/docker/docker v28.1.1+incompatible // docker daemon
github.com/fsnotify/fsnotify v1.9.0 // file watcher
github.com/go-acme/lego/v4 v4.23.1 // acme client
github.com/go-playground/validator/v10 v10.26.0 // validator
github.com/PuerkitoBio/goquery v1.10.1 // parsing HTML for extract fav icon
github.com/coder/websocket v1.8.12 // websocket for API and agent
github.com/coreos/go-oidc/v3 v3.12.0 // oidc authentication
github.com/docker/cli v27.5.1+incompatible // docker CLI
github.com/docker/docker v27.5.1+incompatible // docker daemon
github.com/fsnotify/fsnotify v1.8.0 // file watcher
github.com/go-acme/lego/v4 v4.21.0 // acme client
github.com/go-playground/validator/v10 v10.24.0 // validator
github.com/gobwas/glob v0.2.3 // glob matcher for route rules
github.com/gotify/server/v2 v2.6.3 // reference the Message struct for json response
github.com/golang-jwt/jwt/v5 v5.2.1 // jwt for default auth
github.com/gotify/server/v2 v2.6.1 // reference the Message struct for json response
github.com/lithammer/fuzzysearch v1.1.8 // fuzzy search for searching icons and filtering metrics
github.com/puzpuzpuz/xsync/v4 v4.1.0 // lock free map for concurrent operations
github.com/rs/zerolog v1.34.0 // logging
github.com/shirou/gopsutil/v4 v4.25.4 // system info metrics
github.com/prometheus/client_golang v1.20.5 // metrics
github.com/puzpuzpuz/xsync/v3 v3.5.0 // lock free map for concurrent operations
github.com/rs/zerolog v1.33.0 // logging
github.com/vincent-petithory/dataurl v1.0.0 // data url for fav icon
golang.org/x/crypto v0.38.0 // encrypting password with bcrypt
golang.org/x/net v0.40.0 // HTTP header utilities
golang.org/x/oauth2 v0.30.0 // oauth2 authentication
golang.org/x/time v0.11.0 // time utilities
golang.org/x/crypto v0.33.0 // encrypting password with bcrypt
golang.org/x/net v0.35.0 // HTTP header utilities
golang.org/x/oauth2 v0.26.0 // oauth2 authentication
golang.org/x/text v0.22.0 // string utilities
golang.org/x/time v0.10.0 // time utilities
gopkg.in/yaml.v3 v3.0.1 // yaml parsing for different config files
)
require (
github.com/docker/cli v28.1.1+incompatible
github.com/goccy/go-yaml v1.17.1 // yaml parsing for different config files
github.com/golang-jwt/jwt/v5 v5.2.2
github.com/luthermonson/go-proxmox v0.2.2
github.com/oschwald/maxminddb-golang v1.13.1
github.com/quic-go/quic-go v0.51.0
github.com/samber/slog-zerolog/v2 v2.7.3
github.com/spf13/afero v1.14.0
github.com/stretchr/testify v1.10.0
github.com/yusing/go-proxy/agent v0.0.0-00010101000000-000000000000
github.com/yusing/go-proxy/internal/dnsproviders v0.0.0-00010101000000-000000000000
go.uber.org/atomic v1.11.0
)
require (
cloud.google.com/go/auth v0.16.1 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/compute/metadata v0.7.0 // indirect
github.com/AdamSLevy/jsonrpc2/v14 v14.1.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 // indirect
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns v1.2.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/privatedns/armprivatedns v1.3.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph v0.9.0 // indirect
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/OpenDNS/vegadns2client v0.0.0-20180418235048-a3fa4a771d87 // indirect
github.com/akamai/AkamaiOPEN-edgegrid-golang v1.2.2 // indirect
github.com/aliyun/alibaba-cloud-sdk-go v1.63.107 // indirect
github.com/andybalholm/cascadia v1.3.3 // indirect
github.com/aws/aws-sdk-go-v2 v1.36.3 // indirect
github.com/aws/aws-sdk-go-v2/config v1.29.14 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.17.67 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 // indirect
github.com/aws/aws-sdk-go-v2/service/lightsail v1.43.2 // indirect
github.com/aws/aws-sdk-go-v2/service/route53 v1.51.1 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.25.3 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.33.19 // indirect
github.com/aws/smithy-go v1.22.3 // indirect
github.com/baidubce/bce-sdk-go v0.9.226 // indirect
github.com/benbjohnson/clock v1.3.5 // indirect
github.com/boombuler/barcode v1.0.2 // indirect
github.com/buger/goterm v1.0.4 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/civo/civogo v0.5.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudflare/cloudflare-go v0.115.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/containerd/log v0.1.0 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/djherbis/times v1.6.0 // indirect
github.com/dnsimple/dnsimple-go v1.7.0 // indirect
github.com/docker/go-connections v0.5.0
github.com/docker/go-connections v0.5.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/ebitengine/purego v0.8.3 // indirect
github.com/exoscale/egoscale/v3 v3.1.17 // indirect
github.com/fatih/structs v1.1.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fxamacker/cbor/v2 v2.8.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
github.com/go-errors/errors v1.5.1 // indirect
github.com/go-jose/go-jose/v4 v4.1.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/go-jose/go-jose/v4 v4.0.4 // 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-resty/resty/v2 v2.16.5 // indirect
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect; indirectindirect
github.com/gofrs/flock v0.12.1 // indirect
github.com/goccy/go-json v0.10.5 // 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-20250501235452-c0086092b71a // indirect
github.com/google/s2a-go v0.1.9 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect
github.com/googleapis/gax-go/v2 v2.14.2 // indirect
github.com/gophercloud/gophercloud v1.14.1 // indirect
github.com/gophercloud/utils v0.0.0-20231010081019-80377eca5d56 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-retryablehttp v0.7.7 // indirect
github.com/hashicorp/go-uuid v1.0.3 // indirect
github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.149 // indirect
github.com/iij/doapi v0.0.0-20190504054126-0bbf12d6d7df // indirect
github.com/infobloxopen/infoblox-go-client/v2 v2.10.0 // indirect
github.com/jinzhu/copier v0.4.0 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213 // indirect
github.com/kolo/xmlrpc v0.0.0-20220921171641-a4b6fa1dd06b // indirect
github.com/kylelemons/godebug v1.1.0 // indirect
github.com/labbsr0x/bindman-dns-webhook v1.0.2 // indirect
github.com/labbsr0x/goh v1.0.1 // indirect
github.com/klauspost/compress v1.17.11 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/linode/linodego v1.50.0 // indirect
github.com/liquidweb/liquidweb-cli v0.7.0 // indirect
github.com/liquidweb/liquidweb-go v1.6.4 // 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.66 // indirect
github.com/mimuret/golang-iij-dpf v0.9.1 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/miekg/dns v1.1.63 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/namedotcom/go v0.0.0-20180403034216-08470befbe04 // indirect
github.com/nrdcg/auroradns v1.1.0 // indirect
github.com/nrdcg/bunny-go v0.0.0-20250327222614-988a091fc7ea // indirect
github.com/nrdcg/desec v0.11.0 // indirect
github.com/nrdcg/freemyip v0.3.0 // indirect
github.com/nrdcg/goacmedns v0.2.0 // indirect
github.com/nrdcg/goinwx v0.11.0 // indirect
github.com/nrdcg/mailinabox v0.2.0 // indirect
github.com/nrdcg/namesilo v0.2.1 // indirect
github.com/nrdcg/nodion v0.1.0 // 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/nzdjb/go-metaname v1.0.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/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b // indirect
github.com/oracle/oci-go-sdk/v65 v65.91.0 // indirect
github.com/opencontainers/image-spec v1.1.0 // indirect
github.com/ovh/go-ovh v1.7.0 // indirect
github.com/patrickmn/go-cache v2.1.0+incompatible // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/peterhellberg/link v1.2.0 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // 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/pquerna/otp v1.4.0 // indirect
github.com/quic-go/qpack v0.5.1 // indirect
github.com/regfish/regfish-dnsapi-go v0.1.1 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/sacloud/api-client-go v0.2.10 // indirect
github.com/sacloud/go-http v0.1.9 // indirect
github.com/sacloud/iaas-api-go v1.15.0 // indirect
github.com/sacloud/packages-go v0.0.11 // indirect
github.com/sagikazarmark/locafero v0.9.0 // indirect
github.com/samber/lo v1.50.0 // indirect
github.com/samber/slog-common v0.18.1 // indirect
github.com/scaleway/scaleway-sdk-go v1.0.0-beta.33 // indirect
github.com/selectel/domains-go v1.1.0 // indirect
github.com/selectel/go-selvpcclient/v3 v3.2.1 // indirect
github.com/shopspring/decimal v1.4.0 // indirect
github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af // indirect
github.com/smartystreets/go-aws-auth v0.0.0-20180515143844-0c1422d1fdb9 // indirect
github.com/softlayer/softlayer-go v1.1.7 // indirect
github.com/softlayer/xmlrpc v0.0.0-20200409220501-5f089df7cb7e // indirect
github.com/sony/gobreaker v1.0.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/cast v1.8.0 // indirect
github.com/spf13/pflag v1.0.6 // indirect
github.com/spf13/viper v1.20.1 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.1164 // indirect
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dnspod v1.0.1136 // indirect
github.com/tjfoc/gmsm v1.4.1 // indirect
github.com/tklauser/go-sysconf v0.3.15 // indirect
github.com/tklauser/numcpus v0.10.0 // indirect
github.com/transip/gotransip/v6 v6.26.0 // indirect
github.com/ultradns/ultradns-go-sdk v1.8.0-20241010134910-243eeec // indirect
github.com/vinyldns/go-vinyldns v0.9.16 // indirect
github.com/volcengine/volc-sdk-golang v1.0.207 // indirect
github.com/vultr/govultr/v3 v3.20.0 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.mongodb.org/mongo-driver v1.17.3 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.62.0 // indirect
github.com/prometheus/procfs v0.15.1 // indirect
github.com/sirupsen/logrus v1.9.3 // 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 v1.35.0 // indirect
go.opentelemetry.io/otel/metric v1.35.0 // indirect
go.opentelemetry.io/otel/trace v1.35.0 // indirect
go.opentelemetry.io/proto/otlp v1.6.0 // indirect
go.uber.org/automaxprocs v1.6.0 // indirect
go.uber.org/mock v0.5.2 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/ratelimit v0.3.1 // indirect
golang.org/x/mod v0.24.0 // indirect
golang.org/x/sync v0.14.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.25.0
golang.org/x/tools v0.33.0 // indirect
google.golang.org/api v0.233.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250512202823-5a2f75b736a9 // indirect
google.golang.org/grpc v1.72.1 // indirect
google.golang.org/protobuf v1.36.6 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 // indirect
go.opentelemetry.io/otel v1.34.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.30.0 // indirect
go.opentelemetry.io/otel/metric v1.34.0 // indirect
go.opentelemetry.io/otel/sdk v1.30.0 // indirect
go.opentelemetry.io/otel/trace v1.34.0 // indirect
golang.org/x/mod v0.23.0 // indirect
golang.org/x/sync v0.11.0 // indirect
golang.org/x/sys v0.30.0 // indirect
golang.org/x/tools v0.29.0 // indirect
google.golang.org/protobuf v1.36.5 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/ns1/ns1-go.v2 v2.14.3 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
k8s.io/api v0.33.0 // indirect
k8s.io/apimachinery v0.33.0 // indirect
k8s.io/klog/v2 v2.130.1 // indirect
k8s.io/utils v0.0.0-20250502105355-0f33e8f1c979 // indirect
sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect
sigs.k8s.io/randfill v1.0.0 // indirect
sigs.k8s.io/structured-merge-diff/v4 v4.7.0 // indirect
sigs.k8s.io/yaml v1.4.0 // indirect
gotest.tools/v3 v3.5.1 // indirect
)

2532
go.sum

File diff suppressed because it is too large Load diff

View file

@ -1,166 +0,0 @@
package acl
import (
"net"
"time"
"github.com/puzpuzpuz/xsync/v4"
"github.com/yusing/go-proxy/internal/common"
"github.com/yusing/go-proxy/internal/gperr"
"github.com/yusing/go-proxy/internal/logging"
"github.com/yusing/go-proxy/internal/logging/accesslog"
"github.com/yusing/go-proxy/internal/maxmind"
"github.com/yusing/go-proxy/internal/task"
"github.com/yusing/go-proxy/internal/utils"
)
type Config struct {
Default string `json:"default" validate:"omitempty,oneof=allow deny"` // default: allow
AllowLocal *bool `json:"allow_local"` // default: true
Allow Matchers `json:"allow"`
Deny Matchers `json:"deny"`
Log *accesslog.ACLLoggerConfig `json:"log"`
config
valErr gperr.Error
}
type config struct {
defaultAllow bool
allowLocal bool
ipCache *xsync.Map[string, *checkCache]
logAllowed bool
logger *accesslog.AccessLogger
}
type checkCache struct {
*maxmind.IPInfo
allow bool
created time.Time
}
const cacheTTL = 1 * time.Minute
func (c *checkCache) Expired() bool {
return c.created.Add(cacheTTL).Before(utils.TimeNow())
}
//TODO: add stats
const (
ACLAllow = "allow"
ACLDeny = "deny"
)
func (c *Config) Validate() gperr.Error {
switch c.Default {
case "", ACLAllow:
c.defaultAllow = true
case ACLDeny:
c.defaultAllow = false
default:
c.valErr = gperr.New("invalid default value").Subject(c.Default)
return c.valErr
}
if c.AllowLocal != nil {
c.allowLocal = *c.AllowLocal
} else {
c.allowLocal = true
}
if c.Log != nil {
c.logAllowed = c.Log.LogAllowed
}
if !c.allowLocal && !c.defaultAllow && len(c.Allow) == 0 {
c.valErr = gperr.New("allow_local is false and default is deny, but no allow rules are configured")
return c.valErr
}
c.ipCache = xsync.NewMap[string, *checkCache]()
return nil
}
func (c *Config) Valid() bool {
return c != nil && c.valErr == nil
}
func (c *Config) Start(parent *task.Task) gperr.Error {
if c.Log != nil {
logger, err := accesslog.NewAccessLogger(parent, c.Log)
if err != nil {
return gperr.New("failed to start access logger").With(err)
}
c.logger = logger
}
if c.valErr != nil {
return c.valErr
}
logging.Info().
Str("default", c.Default).
Bool("allow_local", c.allowLocal).
Int("allow_rules", len(c.Allow)).
Int("deny_rules", len(c.Deny)).
Msg("ACL started")
return nil
}
func (c *Config) cacheRecord(info *maxmind.IPInfo, allow bool) {
if common.ForceResolveCountry && info.City == nil {
maxmind.LookupCity(info)
}
c.ipCache.Store(info.Str, &checkCache{
IPInfo: info,
allow: allow,
created: utils.TimeNow(),
})
}
func (c *config) log(info *maxmind.IPInfo, allowed bool) {
if c.logger == nil {
return
}
if !allowed || c.logAllowed {
c.logger.LogACL(info, !allowed)
}
}
func (c *Config) IPAllowed(ip net.IP) bool {
if ip == nil {
return false
}
// always allow loopback, not logged
if ip.IsLoopback() {
return true
}
if c.allowLocal && ip.IsPrivate() {
c.log(&maxmind.IPInfo{IP: ip, Str: ip.String()}, true)
return true
}
ipStr := ip.String()
record, ok := c.ipCache.Load(ipStr)
if ok && !record.Expired() {
c.log(record.IPInfo, record.allow)
return record.allow
}
ipAndStr := &maxmind.IPInfo{IP: ip, Str: ipStr}
if c.Allow.Match(ipAndStr) {
c.log(ipAndStr, true)
c.cacheRecord(ipAndStr, true)
return true
}
if c.Deny.Match(ipAndStr) {
c.log(ipAndStr, false)
c.cacheRecord(ipAndStr, false)
return false
}
c.log(ipAndStr, c.defaultAllow)
c.cacheRecord(ipAndStr, c.defaultAllow)
return c.defaultAllow
}

View file

@ -1,112 +0,0 @@
package acl
import (
"net"
"strings"
"github.com/yusing/go-proxy/internal/gperr"
"github.com/yusing/go-proxy/internal/maxmind"
)
type MatcherFunc func(*maxmind.IPInfo) bool
type Matcher struct {
match MatcherFunc
}
type Matchers []Matcher
const (
MatcherTypeIP = "ip"
MatcherTypeCIDR = "cidr"
MatcherTypeTimeZone = "tz"
MatcherTypeCountry = "country"
)
// TODO: use this error in the future
//
//nolint:unused
var errMatcherFormat = gperr.Multiline().AddLines(
"invalid matcher format, expect {type}:{value}",
"Available types: ip|cidr|tz|country",
"ip:127.0.0.1",
"cidr:127.0.0.0/8",
"tz:Asia/Shanghai",
"country:GB",
)
var (
errSyntax = gperr.New("syntax error")
errInvalidIP = gperr.New("invalid IP")
errInvalidCIDR = gperr.New("invalid CIDR")
)
func (matcher *Matcher) Parse(s string) error {
parts := strings.Split(s, ":")
if len(parts) != 2 {
return errSyntax
}
switch parts[0] {
case MatcherTypeIP:
ip := net.ParseIP(parts[1])
if ip == nil {
return errInvalidIP
}
matcher.match = matchIP(ip)
case MatcherTypeCIDR:
_, net, err := net.ParseCIDR(parts[1])
if err != nil {
return errInvalidCIDR
}
matcher.match = matchCIDR(net)
case MatcherTypeTimeZone:
matcher.match = matchTimeZone(parts[1])
case MatcherTypeCountry:
matcher.match = matchISOCode(parts[1])
default:
return errSyntax
}
return nil
}
func (matchers Matchers) Match(ip *maxmind.IPInfo) bool {
for _, m := range matchers {
if m.match(ip) {
return true
}
}
return false
}
func matchIP(ip net.IP) MatcherFunc {
return func(ip2 *maxmind.IPInfo) bool {
return ip.Equal(ip2.IP)
}
}
func matchCIDR(n *net.IPNet) MatcherFunc {
return func(ip *maxmind.IPInfo) bool {
return n.Contains(ip.IP)
}
}
func matchTimeZone(tz string) MatcherFunc {
return func(ip *maxmind.IPInfo) bool {
city, ok := maxmind.LookupCity(ip)
if !ok {
return false
}
return city.Location.TimeZone == tz
}
}
func matchISOCode(iso string) MatcherFunc {
return func(ip *maxmind.IPInfo) bool {
city, ok := maxmind.LookupCity(ip)
if !ok {
return false
}
return city.Country.IsoCode == iso
}
}

View file

@ -1,49 +0,0 @@
package acl
import (
"net"
"reflect"
"testing"
maxmind "github.com/yusing/go-proxy/internal/maxmind/types"
"github.com/yusing/go-proxy/internal/utils"
)
func TestMatchers(t *testing.T) {
strMatchers := []string{
"ip:127.0.0.1",
"cidr:10.0.0.0/8",
}
var mathers Matchers
err := utils.Convert(reflect.ValueOf(strMatchers), reflect.ValueOf(&mathers), false)
if err != nil {
t.Fatal(err)
}
tests := []struct {
ip string
want bool
}{
{"127.0.0.1", true},
{"10.0.0.1", true},
{"127.0.0.2", false},
{"192.168.0.1", false},
{"11.0.0.1", false},
}
for _, test := range tests {
ip := net.ParseIP(test.ip)
if ip == nil {
t.Fatalf("invalid ip: %s", test.ip)
}
got := mathers.Match(&maxmind.IPInfo{
IP: ip,
Str: test.ip,
})
if got != test.want {
t.Errorf("mathers.Match(%s) = %v, want %v", test.ip, got, test.want)
}
}
}

View file

@ -1,59 +0,0 @@
package acl
import (
"io"
"net"
"time"
)
type TCPListener struct {
acl *Config
lis net.Listener
}
type noConn struct{}
func (noConn) Read(b []byte) (int, error) { return 0, io.EOF }
func (noConn) Write(b []byte) (int, error) { return 0, io.EOF }
func (noConn) Close() error { return nil }
func (noConn) LocalAddr() net.Addr { return nil }
func (noConn) RemoteAddr() net.Addr { return nil }
func (noConn) SetDeadline(t time.Time) error { return nil }
func (noConn) SetReadDeadline(t time.Time) error { return nil }
func (noConn) SetWriteDeadline(t time.Time) error { return nil }
func (cfg *Config) WrapTCP(lis net.Listener) net.Listener {
if cfg == nil {
return lis
}
return &TCPListener{
acl: cfg,
lis: lis,
}
}
func (s *TCPListener) Addr() net.Addr {
return s.lis.Addr()
}
func (s *TCPListener) Accept() (net.Conn, error) {
c, err := s.lis.Accept()
if err != nil {
return nil, err
}
addr, ok := c.RemoteAddr().(*net.TCPAddr)
if !ok {
// Not a TCPAddr, drop
c.Close()
return noConn{}, nil
}
if !s.acl.IPAllowed(addr.IP) {
c.Close()
return noConn{}, nil
}
return c, nil
}
func (s *TCPListener) Close() error {
return s.lis.Close()
}

View file

@ -1,79 +0,0 @@
package acl
import (
"net"
"time"
)
type UDPListener struct {
acl *Config
lis net.PacketConn
}
func (c *Config) WrapUDP(lis net.PacketConn) net.PacketConn {
if c == nil {
return lis
}
return &UDPListener{
acl: c,
lis: lis,
}
}
func (s *UDPListener) LocalAddr() net.Addr {
return s.lis.LocalAddr()
}
func (s *UDPListener) ReadFrom(p []byte) (int, net.Addr, error) {
for {
n, addr, err := s.lis.ReadFrom(p)
if err != nil {
return n, addr, err
}
udpAddr, ok := addr.(*net.UDPAddr)
if !ok {
// Not a UDPAddr, drop
continue
}
if !s.acl.IPAllowed(udpAddr.IP) {
// Drop packet from disallowed IP
continue
}
return n, addr, nil
}
}
func (s *UDPListener) WriteTo(p []byte, addr net.Addr) (int, error) {
for {
n, err := s.lis.WriteTo(p, addr)
if err != nil {
return n, err
}
udpAddr, ok := addr.(*net.UDPAddr)
if !ok {
// Not a UDPAddr, drop
continue
}
if !s.acl.IPAllowed(udpAddr.IP) {
// Drop packet to disallowed IP
continue
}
return n, nil
}
}
func (s *UDPListener) SetDeadline(t time.Time) error {
return s.lis.SetDeadline(t)
}
func (s *UDPListener) SetReadDeadline(t time.Time) error {
return s.lis.SetReadDeadline(t)
}
func (s *UDPListener) SetWriteDeadline(t time.Time) error {
return s.lis.SetWriteDeadline(t)
}
func (s *UDPListener) Close() error {
return s.lis.Close()
}

View file

@ -1,111 +1,70 @@
package api
import (
"fmt"
"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/certapi"
"github.com/yusing/go-proxy/internal/api/v1/dockerapi"
"github.com/yusing/go-proxy/internal/api/v1/auth"
"github.com/yusing/go-proxy/internal/api/v1/favicon"
"github.com/yusing/go-proxy/internal/auth"
"github.com/yusing/go-proxy/internal/common"
config "github.com/yusing/go-proxy/internal/config/types"
"github.com/yusing/go-proxy/internal/logging/memlogger"
"github.com/yusing/go-proxy/internal/metrics/uptime"
"github.com/yusing/go-proxy/internal/net/gphttp/gpwebsocket"
"github.com/yusing/go-proxy/internal/net/gphttp/httpheaders"
"github.com/yusing/go-proxy/internal/logging"
"github.com/yusing/go-proxy/internal/utils/strutils"
"github.com/yusing/go-proxy/pkg"
)
type (
ServeMux struct {
*http.ServeMux
cfg config.ConfigInstance
}
WithCfgHandler = func(config.ConfigInstance, http.ResponseWriter, *http.Request)
)
type ServeMux struct{ *http.ServeMux }
func (mux ServeMux) HandleFunc(methods, endpoint string, h any, requireAuth ...bool) {
var handler http.HandlerFunc
switch h := h.(type) {
case func(http.ResponseWriter, *http.Request):
handler = h
case http.Handler:
handler = h.ServeHTTP
case WithCfgHandler:
handler = func(w http.ResponseWriter, r *http.Request) {
h(mux.cfg, w, r)
}
default:
panic(fmt.Errorf("unsupported handler type: %T", h))
}
matchDomains := mux.cfg.Value().MatchDomains
if len(matchDomains) > 0 {
origHandler := handler
handler = func(w http.ResponseWriter, r *http.Request) {
if httpheaders.IsWebsocket(r.Header) {
gpwebsocket.SetWebsocketAllowedDomains(r.Header, matchDomains)
}
origHandler(w, r)
}
}
if len(requireAuth) > 0 && requireAuth[0] {
handler = auth.RequireAuth(handler)
}
if methods == "" {
mux.ServeMux.HandleFunc(endpoint, handler)
} else {
func (mux ServeMux) HandleFunc(methods, endpoint string, handler http.HandlerFunc) {
for _, m := range strutils.CommaSeperatedList(methods) {
mux.ServeMux.HandleFunc(m+" "+endpoint, handler)
}
}
}
func NewHandler(cfg config.ConfigInstance) http.Handler {
mux := ServeMux{http.NewServeMux(), cfg}
mux := ServeMux{http.NewServeMux()}
mux.HandleFunc("GET", "/v1", v1.Index)
mux.HandleFunc("GET", "/v1/version", pkg.GetVersionHTTPHandler())
mux.HandleFunc("GET", "/v1/version", v1.GetVersion)
mux.HandleFunc("POST", "/v1/reload", useCfg(cfg, v1.Reload))
mux.HandleFunc("GET", "/v1/list", auth.RequireAuth(useCfg(cfg, v1.List)))
mux.HandleFunc("GET", "/v1/list/{what}", auth.RequireAuth(useCfg(cfg, v1.List)))
mux.HandleFunc("GET", "/v1/list/{what}/{which}", auth.RequireAuth(useCfg(cfg, v1.List)))
mux.HandleFunc("GET", "/v1/file/{type}/{filename}", auth.RequireAuth(v1.GetFileContent))
mux.HandleFunc("POST,PUT", "/v1/file/{type}/{filename}", auth.RequireAuth(v1.SetFileContent))
mux.HandleFunc("POST", "/v1/file/validate/{type}", auth.RequireAuth(v1.ValidateFile))
mux.HandleFunc("GET", "/v1/stats", useCfg(cfg, v1.Stats))
mux.HandleFunc("GET", "/v1/stats/ws", useCfg(cfg, v1.StatsWS))
mux.HandleFunc("GET", "/v1/health/ws", auth.RequireAuth(useCfg(cfg, v1.HealthWS)))
mux.HandleFunc("GET", "/v1/logs/ws", auth.RequireAuth(useCfg(cfg, v1.LogsWS())))
mux.HandleFunc("GET", "/v1/favicon", auth.RequireAuth(favicon.GetFavIcon))
mux.HandleFunc("POST", "/v1/homepage/set", auth.RequireAuth(v1.SetHomePageOverrides))
mux.HandleFunc("GET", "/v1/stats", v1.Stats, true)
mux.HandleFunc("POST", "/v1/reload", v1.Reload, true)
mux.HandleFunc("GET", "/v1/list", v1.ListRoutesHandler, true)
mux.HandleFunc("GET", "/v1/list/routes", v1.ListRoutesHandler, true)
mux.HandleFunc("GET", "/v1/list/route/{which}", v1.ListRouteHandler, true)
mux.HandleFunc("GET", "/v1/list/routes_by_provider", v1.ListRoutesByProviderHandler, true)
mux.HandleFunc("GET", "/v1/list/files", v1.ListFilesHandler, true)
mux.HandleFunc("GET", "/v1/list/homepage_config", v1.ListHomepageConfigHandler, true)
mux.HandleFunc("GET", "/v1/list/route_providers", v1.ListRouteProvidersHandler, true)
mux.HandleFunc("GET", "/v1/list/homepage_categories", v1.ListHomepageCategoriesHandler, true)
mux.HandleFunc("GET", "/v1/list/icons", v1.ListIconsHandler, 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)
defaultAuth := auth.GetDefaultAuth()
if defaultAuth == nil {
return mux
if common.PrometheusEnabled {
mux.Handle("GET /v1/metrics", promhttp.Handler())
logging.Info().Msg("prometheus metrics enabled")
}
mux.HandleFunc("GET", "/v1/auth/check", auth.AuthCheckHandler)
mux.HandleFunc("GET,POST", "/v1/auth/redirect", defaultAuth.LoginHandler)
mux.HandleFunc("GET,POST", "/v1/auth/callback", defaultAuth.PostAuthCallbackHandler)
mux.HandleFunc("GET,POST", "/v1/auth/logout", defaultAuth.LogoutHandler)
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
}
func useCfg(cfg config.ConfigInstance, handler func(cfg config.ConfigInstance, w http.ResponseWriter, r *http.Request)) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
handler(cfg, w, r)
}
}

View file

@ -1,24 +0,0 @@
package v1
import (
"net/http"
"time"
"github.com/coder/websocket"
"github.com/coder/websocket/wsjson"
config "github.com/yusing/go-proxy/internal/config/types"
"github.com/yusing/go-proxy/internal/net/gphttp"
"github.com/yusing/go-proxy/internal/net/gphttp/gpwebsocket"
"github.com/yusing/go-proxy/internal/net/gphttp/httpheaders"
)
func ListAgents(cfg config.ConfigInstance, w http.ResponseWriter, r *http.Request) {
if httpheaders.IsWebsocket(r.Header) {
gpwebsocket.Periodic(w, r, 10*time.Second, func(conn *websocket.Conn) error {
wsjson.Write(r.Context(), conn, cfg.ListAgents())
return nil
})
} else {
gphttp.RespondJSON(w, r, cfg.ListAgents())
}
}

View file

@ -0,0 +1,54 @@
package auth
import (
"net/http"
U "github.com/yusing/go-proxy/internal/api/v1/utils"
"github.com/yusing/go-proxy/internal/common"
"github.com/yusing/go-proxy/internal/logging"
)
var defaultAuth Provider
// Initialize sets up authentication providers.
func Initialize() error {
if !IsEnabled() {
logging.Warn().Msg("authentication is disabled, please set API_JWT_SECRET or OIDC_* to enable authentication")
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.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 {
U.RespondError(w, err, http.StatusUnauthorized)
} else {
next(w, r)
}
}
}
return next
}

View file

@ -0,0 +1,274 @@
package auth
import (
"context"
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"net/http"
"net/url"
"slices"
"time"
"github.com/coreos/go-oidc/v3/oidc"
U "github.com/yusing/go-proxy/internal/api/v1/utils"
"github.com/yusing/go-proxy/internal/common"
E "github.com/yusing/go-proxy/internal/error"
CE "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
oidcLogoutURL *url.URL
allowedUsers []string
allowedGroups []string
isMiddleware bool
}
const CookieOauthState = "godoxy_oidc_state"
const (
OIDCMiddlewareCallbackPath = "/auth/callback"
OIDCLogoutPath = "/auth/logout"
)
func NewOIDCProvider(issuerURL, clientID, clientSecret, redirectURL, logoutURL 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")
}
var logout *url.URL
var err error
if logoutURL != "" {
logout, err = url.Parse(logoutURL)
if err != nil {
return nil, fmt.Errorf("failed to parse logout URL: %w", err)
}
}
provider, err := oidc.NewProvider(context.Background(), issuerURL)
if err != nil {
return nil, fmt.Errorf("failed to initialize OIDC provider: %w", err)
}
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,
}),
oidcLogoutURL: logout,
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.OIDCLogoutURL,
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(CE.Intersect(claims.Groups, auth.allowedGroups)) > 0
if !allowedUser && !allowedGroup {
return ErrUserNotAllowed.Subject(claims.Username)
}
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 {
U.HandleErr(w, r, err, http.StatusInternalServerError)
return
}
http.SetCookie(w, &http.Cookie{
Name: CookieOauthState,
Value: state,
MaxAge: 300,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: true,
Path: "/",
})
redirURL := auth.oauthConfig.AuthCodeURL(state)
if auth.isMiddleware {
u, err := r.URL.Parse(redirURL)
if err != nil {
U.HandleErr(w, r, err, http.StatusInternalServerError)
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 {
U.HandleErr(w, r, E.New("missing state cookie"), http.StatusBadRequest)
return
}
query := r.URL.Query()
if query.Get("state") != state.Value {
U.HandleErr(w, r, E.New("invalid oauth state"), http.StatusBadRequest)
return
}
oauth2Token, err := auth.exchange(r)
if err != nil {
U.HandleErr(w, r, fmt.Errorf("failed to exchange token: %w", err), http.StatusInternalServerError)
return
}
rawIDToken, ok := oauth2Token.Extra("id_token").(string)
if !ok {
U.HandleErr(w, r, E.New("missing id_token"), http.StatusInternalServerError)
return
}
idToken, err := auth.oidcVerifier.Verify(r.Context(), rawIDToken)
if err != nil {
U.HandleErr(w, r, fmt.Errorf("failed to verify ID token: %w", err), http.StatusInternalServerError)
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.oidcLogoutURL == nil {
DefaultLogoutCallbackHandler(auth, w, r)
return
}
token, err := r.Cookie(auth.TokenCookieName())
if err != nil {
U.HandleErr(w, r, E.New("missing token cookie"), http.StatusBadRequest)
return
}
clearTokenCookie(w, r, auth.TokenCookieName())
logoutURL := *auth.oidcLogoutURL
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 {
U.HandleErr(w, r, E.New("missing state cookie"), http.StatusBadRequest)
return
}
if r.URL.Query().Get("state") != state.Value {
U.HandleErr(w, r, E.New("invalid oauth state"), http.StatusBadRequest)
return
}
// Create test JWT token
setTokenCookie(w, r, auth.TokenCookieName(), "test", time.Hour)
http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
}

View file

@ -1,13 +1,13 @@
package auth
import (
"context"
"crypto/rand"
"crypto/rsa"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"time"
@ -23,7 +23,7 @@ import (
func setupMockOIDC(t *testing.T) {
t.Helper()
provider := (&oidc.ProviderConfig{}).NewProvider(t.Context())
provider := (&oidc.ProviderConfig{}).NewProvider(context.TODO())
defaultAuth = &OIDCProvider{
oauthConfig: &oauth2.Config{
ClientID: "test-client",
@ -35,7 +35,6 @@ func setupMockOIDC(t *testing.T) {
},
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
},
endSessionURL: Must(url.Parse("http://mock-provider/logout")),
oidcProvider: provider,
oidcVerifier: provider.Verifier(&oidc.Config{
ClientID: "test-client",
@ -103,7 +102,7 @@ func setupProvider(t *testing.T) *provider {
t.Cleanup(ts.Close)
// Create a test OIDCProvider.
providerCtx := oidc.ClientContext(t.Context(), ts.Client())
providerCtx := oidc.ClientContext(context.Background(), ts.Client())
keySet := oidc.NewRemoteKeySet(providerCtx, ts.URL+"/.well-known/jwks.json")
return &provider{
@ -149,17 +148,17 @@ func TestOIDCLoginHandler(t *testing.T) {
}{
{
name: "Success - Redirects to provider",
wantStatus: http.StatusFound,
wantStatus: http.StatusTemporaryRedirect,
wantRedirect: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, OIDCAuthInitPath, nil)
req := httptest.NewRequest(http.MethodGet, "/auth/redirect", nil)
w := httptest.NewRecorder()
defaultAuth.(*OIDCProvider).HandleAuth(w, req)
defaultAuth.RedirectLoginPage(w, req)
if got := w.Code; got != tt.wantStatus {
t.Errorf("OIDCLoginHandler() status = %v, want %v", got, tt.wantStatus)
@ -195,7 +194,7 @@ func TestOIDCCallbackHandler(t *testing.T) {
state: "valid-state",
code: "valid-code",
setupMocks: true,
wantStatus: http.StatusFound,
wantStatus: http.StatusTemporaryRedirect,
},
{
name: "Failure - Missing state",
@ -220,7 +219,7 @@ func TestOIDCCallbackHandler(t *testing.T) {
}
w := httptest.NewRecorder()
defaultAuth.(*OIDCProvider).PostAuthCallbackHandler(w, req)
defaultAuth.LoginCallbackHandler(w, req)
if got := w.Code; got != tt.wantStatus {
t.Errorf("OIDCCallbackHandler() status = %v, want %v", got, tt.wantStatus)
@ -228,7 +227,7 @@ func TestOIDCCallbackHandler(t *testing.T) {
if tt.wantStatus == http.StatusTemporaryRedirect {
setCookie := Must(http.ParseSetCookie(w.Header().Get("Set-Cookie")))
ExpectEqual(t, setCookie.Name, CookieOauthToken)
ExpectEqual(t, setCookie.Name, defaultAuth.TokenCookieName())
ExpectTrue(t, setCookie.Value != "")
ExpectEqual(t, setCookie.Path, "/")
ExpectEqual(t, setCookie.SameSite, http.SameSiteLaxMode)
@ -271,6 +270,7 @@ func TestInitOIDC(t *testing.T) {
issuerURL: server.URL,
clientID: "client_id",
clientSecret: "client_secret",
redirectURL: "https://example.com/callback",
allowedUsers: []string{"user1", "user2"},
wantErr: false,
},
@ -279,6 +279,7 @@ func TestInitOIDC(t *testing.T) {
issuerURL: server.URL,
clientID: "client_id",
clientSecret: "client_secret",
redirectURL: "https://example.com/callback",
allowedGroups: []string{"group1", "group2"},
wantErr: false,
},
@ -287,6 +288,7 @@ func TestInitOIDC(t *testing.T) {
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"},
@ -297,13 +299,14 @@ func TestInitOIDC(t *testing.T) {
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.allowedUsers, tt.allowedGroups)
_, err := NewOIDCProvider(tt.issuerURL, tt.clientID, tt.clientSecret, tt.redirectURL, tt.logoutURL, tt.allowedUsers, tt.allowedGroups)
if (err != nil) != tt.wantErr {
t.Errorf("InitOIDC() error = %v, wantErr %v", err, tt.wantErr)
}
@ -397,7 +400,7 @@ func TestCheckToken(t *testing.T) {
"preferred_username": "user1",
"groups": []string{"group1"},
},
wantErr: ErrInvalidOAuthToken,
wantErr: ErrInvalidToken,
},
{
name: "Error - Server returns incorrect audience",
@ -408,7 +411,7 @@ func TestCheckToken(t *testing.T) {
"preferred_username": "user1",
"groups": []string{"group1"},
},
wantErr: ErrInvalidOAuthToken,
wantErr: ErrInvalidToken,
},
{
name: "Error - Server returns expired token",
@ -419,7 +422,7 @@ func TestCheckToken(t *testing.T) {
"preferred_username": "user1",
"groups": []string{"group1"},
},
wantErr: ErrInvalidOAuthToken,
wantErr: ErrInvalidToken,
},
}
for _, tc := range tests {
@ -435,7 +438,7 @@ func TestCheckToken(t *testing.T) {
// Craft a test HTTP request that includes the token as a cookie.
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.AddCookie(&http.Cookie{
Name: CookieOauthToken,
Name: auth.TokenCookieName(),
Value: signedToken,
})
@ -449,35 +452,3 @@ func TestCheckToken(t *testing.T) {
})
}
}
func TestLogoutHandler(t *testing.T) {
t.Helper()
setupMockOIDC(t)
req := httptest.NewRequest(http.MethodGet, OIDCLogoutPath, nil)
w := httptest.NewRecorder()
req.AddCookie(&http.Cookie{
Name: CookieOauthToken,
Value: "test-token",
})
req.AddCookie(&http.Cookie{
Name: CookieOauthSessionToken,
Value: "test-session-token",
})
defaultAuth.(*OIDCProvider).LogoutHandler(w, req)
if got := w.Code; got != http.StatusFound {
t.Errorf("LogoutHandler() status = %v, want %v", got, http.StatusFound)
}
if got := w.Header().Get("Location"); got == "" {
t.Error("LogoutHandler() missing redirect location")
}
if len(w.Header().Values("Set-Cookie")) != 2 {
t.Error("LogoutHandler() did not clear all cookies")
}
}

View file

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

@ -7,16 +7,16 @@ import (
"time"
"github.com/golang-jwt/jwt/v5"
U "github.com/yusing/go-proxy/internal/api/v1/utils"
"github.com/yusing/go-proxy/internal/common"
"github.com/yusing/go-proxy/internal/gperr"
"github.com/yusing/go-proxy/internal/net/gphttp"
E "github.com/yusing/go-proxy/internal/error"
"github.com/yusing/go-proxy/internal/utils/strutils"
"golang.org/x/crypto/bcrypt"
)
var (
ErrInvalidUsername = gperr.New("invalid username")
ErrInvalidPassword = gperr.New("invalid password")
ErrInvalidUsername = E.New("invalid username")
ErrInvalidPassword = E.New("invalid password")
)
type (
@ -76,7 +76,7 @@ func (auth *UserPassAuth) NewToken() (token string, err error) {
func (auth *UserPassAuth) CheckToken(r *http.Request) error {
jwtCookie, err := r.Cookie(auth.TokenCookieName())
if err != nil {
return ErrMissingSessionToken
return ErrMissingToken
}
var claims UserPassClaims
token, err := jwt.ParseWithClaims(jwtCookie.Value, &claims, func(t *jwt.Token) (interface{}, error) {
@ -90,46 +90,46 @@ func (auth *UserPassAuth) CheckToken(r *http.Request) error {
}
switch {
case !token.Valid:
return ErrInvalidSessionToken
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 E.Errorf("token expired on %s", strutils.FormatTime(claims.ExpiresAt.Time))
}
return nil
}
func (auth *UserPassAuth) PostAuthCallbackHandler(w http.ResponseWriter, r *http.Request) {
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")
U.HandleErr(w, r, err, http.StatusBadRequest)
return
}
if err := auth.validatePassword(creds.User, creds.Pass); err != nil {
gphttp.Unauthorized(w, "invalid credentials")
U.LogError(r).Err(err).Msg("auth: invalid credentials")
U.RespondError(w, E.New("invalid credentials"), http.StatusUnauthorized)
return
}
token, err := auth.NewToken()
if err != nil {
gphttp.ServerError(w, r, err)
U.HandleErr(w, r, err, http.StatusInternalServerError)
return
}
SetTokenCookie(w, r, auth.TokenCookieName(), token, auth.tokenTTL)
setTokenCookie(w, r, auth.TokenCookieName(), token, auth.tokenTTL)
w.WriteHeader(http.StatusOK)
}
func (auth *UserPassAuth) LoginHandler(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/login", http.StatusFound) // redirects to WebUI login page
}
func (auth *UserPassAuth) LogoutHandler(w http.ResponseWriter, r *http.Request) {
ClearTokenCookie(w, r, auth.TokenCookieName())
http.Redirect(w, r, "/", http.StatusFound)
func (auth *UserPassAuth) LogoutCallbackHandler(w http.ResponseWriter, r *http.Request) {
DefaultLogoutCallbackHandler(auth, w, r)
}
func (auth *UserPassAuth) validatePassword(user, pass string) error {

View file

@ -98,7 +98,7 @@ func TestUserPassLoginCallbackHandler(t *testing.T) {
Host: "app.example.com",
Body: io.NopCloser(bytes.NewReader(Must(json.Marshal(tt.creds)))),
}
auth.PostAuthCallbackHandler(w, req)
auth.LoginCallbackHandler(w, req)
if tt.wantErr {
ExpectEqual(t, w.Code, http.StatusUnauthorized)
} else {

View file

@ -0,0 +1,69 @@
package auth
import (
"net"
"net/http"
"time"
E "github.com/yusing/go-proxy/internal/error"
"github.com/yusing/go-proxy/internal/utils/strutils"
)
var (
ErrMissingToken = E.New("missing token")
ErrInvalidToken = E.New("invalid token")
ErrUserNotAllowed = E.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: true,
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: true,
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,41 +0,0 @@
package certapi
import (
"encoding/json"
"net/http"
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,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,56 +0,0 @@
package dockerapi
import (
"context"
"encoding/json"
"net/http"
"sort"
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) MarshalJSON() ([]byte, error) {
return json.Marshal(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),
})
}
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"
"strconv"
"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"
)
func Logs(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query()
server := r.PathValue("server")
containerID := r.PathValue("container")
stdout, _ := strconv.ParseBool(query.Get("stdout"))
stderr, _ := strconv.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,124 +0,0 @@
package dockerapi
import (
"context"
"encoding/json"
"net/http"
"time"
"github.com/coder/websocket"
"github.com/coder/websocket/wsjson"
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 cfg.ListAgents() {
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 cfg.ListAgents() {
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) //nolint
}
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

@ -0,0 +1,136 @@
package favicon
import (
"encoding/json"
"sync"
"time"
"github.com/yusing/go-proxy/internal/common"
"github.com/yusing/go-proxy/internal/logging"
route "github.com/yusing/go-proxy/internal/route/types"
"github.com/yusing/go-proxy/internal/task"
"github.com/yusing/go-proxy/internal/utils"
)
type cacheEntry struct {
Icon []byte `json:"icon"`
LastAccess time.Time `json:"lastAccess"`
}
// cache key can be absolute url or route name.
var (
iconCache = make(map[string]*cacheEntry)
iconCacheMu sync.RWMutex
)
const (
iconCacheTTL = 24 * time.Hour
cleanUpInterval = time.Hour
)
func InitIconCache() {
iconCacheMu.Lock()
defer iconCacheMu.Unlock()
err := utils.LoadJSONIfExist(common.IconCachePath, &iconCache)
if err != nil {
logging.Error().Err(err).Msg("failed to load icon cache")
} else {
logging.Info().Msgf("icon cache loaded (%d icons)", len(iconCache))
}
go func() {
cleanupTicker := time.NewTicker(cleanUpInterval)
defer cleanupTicker.Stop()
for {
select {
case <-task.RootContextCanceled():
return
case <-cleanupTicker.C:
pruneExpiredIconCache()
}
}
}()
task.OnProgramExit("save_favicon_cache", func() {
iconCacheMu.Lock()
defer iconCacheMu.Unlock()
if len(iconCache) == 0 {
return
}
if err := utils.SaveJSON(common.IconCachePath, &iconCache, 0o644); err != nil {
logging.Error().Err(err).Msg("failed to save icon cache")
}
})
}
func pruneExpiredIconCache() {
iconCacheMu.Lock()
defer iconCacheMu.Unlock()
nPruned := 0
for key, icon := range iconCache {
if icon.IsExpired() {
delete(iconCache, key)
nPruned++
}
}
logging.Info().Int("pruned", nPruned).Msg("pruned expired icon cache")
}
func routeKey(r route.HTTPRoute) string {
return r.ProviderName() + ":" + r.TargetName()
}
func PruneRouteIconCache(route route.HTTPRoute) {
iconCacheMu.Lock()
defer iconCacheMu.Unlock()
delete(iconCache, routeKey(route))
}
func loadIconCache(key string) *fetchResult {
iconCacheMu.RLock()
defer iconCacheMu.RUnlock()
icon, ok := iconCache[key]
if ok && icon != nil {
logging.Debug().
Str("key", key).
Msg("icon found in cache")
icon.LastAccess = time.Now()
return &fetchResult{icon: icon.Icon}
}
return nil
}
func storeIconCache(key string, icon []byte) {
iconCacheMu.Lock()
defer iconCacheMu.Unlock()
iconCache[key] = &cacheEntry{Icon: icon, LastAccess: time.Now()}
}
func (e *cacheEntry) IsExpired() bool {
return time.Since(e.LastAccess) > iconCacheTTL
}
func (e *cacheEntry) UnmarshalJSON(data []byte) error {
attempt := struct {
Icon []byte `json:"icon"`
LastAccess time.Time `json:"lastAccess"`
}{}
err := json.Unmarshal(data, &attempt)
if err == nil {
e.Icon = attempt.Icon
e.LastAccess = attempt.LastAccess
return nil
}
// fallback to bytes
err = json.Unmarshal(data, &e.Icon)
if err == nil {
e.LastAccess = time.Now()
return nil
}
return err
}

View file

@ -1,4 +1,4 @@
package homepage
package favicon
import (
"bufio"

View file

@ -1,13 +1,48 @@
package favicon
import (
"bytes"
"context"
"errors"
"io"
"net/http"
"net/url"
"path"
"strings"
"time"
"github.com/PuerkitoBio/goquery"
"github.com/vincent-petithory/dataurl"
U "github.com/yusing/go-proxy/internal/api/v1/utils"
"github.com/yusing/go-proxy/internal/homepage"
"github.com/yusing/go-proxy/internal/net/gphttp"
"github.com/yusing/go-proxy/internal/logging"
gphttp "github.com/yusing/go-proxy/internal/net/http"
"github.com/yusing/go-proxy/internal/route/routes"
route "github.com/yusing/go-proxy/internal/route/types"
)
type fetchResult struct {
icon []byte
contentType string
statusCode int
errMsg string
}
func (res *fetchResult) OK() bool {
return res.icon != nil
}
func (res *fetchResult) ContentType() string {
if res.contentType == "" {
if bytes.HasPrefix(res.icon, []byte("<svg")) || bytes.HasPrefix(res.icon, []byte("<?xml")) {
return "image/svg+xml"
} else {
return "image/x-icon"
}
}
return res.contentType
}
// GetFavIcon returns the favicon of the route
//
// Returns:
@ -19,11 +54,11 @@ import (
func GetFavIcon(w http.ResponseWriter, req *http.Request) {
url, alias := req.FormValue("url"), req.FormValue("alias")
if url == "" && alias == "" {
gphttp.MissingKey(w, "url or alias")
U.RespondError(w, U.ErrMissingKey("url or alias"), http.StatusBadRequest)
return
}
if url != "" && alias != "" {
gphttp.BadRequest(w, "url and alias are mutually exclusive")
U.RespondError(w, U.ErrInvalidKey("url and alias are mutually exclusive"), http.StatusBadRequest)
return
}
@ -31,45 +66,219 @@ func GetFavIcon(w http.ResponseWriter, req *http.Request) {
if url != "" {
var iconURL homepage.IconURL
if err := iconURL.Parse(url); err != nil {
gphttp.ClientError(w, req, err, http.StatusBadRequest)
U.RespondError(w, err, http.StatusBadRequest)
return
}
fetchResult := homepage.FetchFavIconFromURL(req.Context(), &iconURL)
fetchResult := getFavIconFromURL(&iconURL)
if !fetchResult.OK() {
http.Error(w, fetchResult.ErrMsg, fetchResult.StatusCode)
http.Error(w, fetchResult.errMsg, fetchResult.statusCode)
return
}
w.Header().Set("Content-Type", fetchResult.ContentType())
gphttp.WriteBody(w, fetchResult.Icon)
U.WriteBody(w, fetchResult.icon)
return
}
// try with route.Icon
r, ok := routes.HTTP.Get(alias)
// try with route.Homepage.Icon
r, ok := routes.GetHTTPRoute(alias)
if !ok {
gphttp.ValueNotFound(w, "route", alias)
U.RespondError(w, errors.New("no such route"), http.StatusNotFound)
return
}
var result *homepage.FetchResult
hp := r.HomepageItem()
if hp.Icon != nil {
var result *fetchResult
hp := r.HomepageConfig().GetOverride()
if !hp.IsEmpty() && hp.Icon != nil {
if hp.Icon.IconSource == homepage.IconSourceRelative {
result = homepage.FindIcon(req.Context(), r, *hp.Icon.FullURL)
result = findIcon(r, req, hp.Icon.Value)
} else {
result = homepage.FetchFavIconFromURL(req.Context(), hp.Icon)
result = getFavIconFromURL(hp.Icon)
}
} else {
// try extract from "link[rel=icon]"
result = homepage.FindIcon(req.Context(), r, "/")
result = findIcon(r, req, "/")
}
if result.StatusCode == 0 {
result.StatusCode = http.StatusOK
if result.statusCode == 0 {
result.statusCode = http.StatusOK
}
if !result.OK() {
http.Error(w, result.ErrMsg, result.StatusCode)
http.Error(w, result.errMsg, result.statusCode)
return
}
w.Header().Set("Content-Type", result.ContentType())
gphttp.WriteBody(w, result.Icon)
U.WriteBody(w, result.icon)
}
func getFavIconFromURL(iconURL *homepage.IconURL) *fetchResult {
switch iconURL.IconSource {
case homepage.IconSourceAbsolute:
return fetchIconAbsolute(iconURL.URL())
case homepage.IconSourceRelative:
return &fetchResult{statusCode: http.StatusBadRequest, errMsg: "unexpected relative icon"}
case homepage.IconSourceWalkXCode, homepage.IconSourceSelfhSt:
return fetchKnownIcon(iconURL)
}
return &fetchResult{statusCode: http.StatusBadRequest, errMsg: "invalid icon source"}
}
func fetchIconAbsolute(url string) *fetchResult {
if result := loadIconCache(url); result != nil {
return result
}
resp, err := U.Get(url)
if err != nil || resp.StatusCode != http.StatusOK {
if err == nil {
err = errors.New(resp.Status)
}
logging.Error().Err(err).
Str("url", url).
Msg("failed to get icon")
return &fetchResult{statusCode: http.StatusBadGateway, errMsg: "connection error"}
}
defer resp.Body.Close()
icon, err := io.ReadAll(resp.Body)
if err != nil {
logging.Error().Err(err).
Str("url", url).
Msg("failed to read icon")
return &fetchResult{statusCode: http.StatusInternalServerError, errMsg: "internal error"}
}
storeIconCache(url, icon)
return &fetchResult{icon: icon}
}
var nameSanitizer = strings.NewReplacer(
"_", "-",
" ", "-",
"(", "",
")", "",
)
func sanitizeName(name string) string {
return strings.ToLower(nameSanitizer.Replace(name))
}
func fetchKnownIcon(url *homepage.IconURL) *fetchResult {
// if icon isn't in the list, no need to fetch
if !url.HasIcon() {
logging.Debug().
Str("value", url.String()).
Str("url", url.URL()).
Msg("no such icon")
return &fetchResult{statusCode: http.StatusNotFound, errMsg: "no such icon"}
}
return fetchIconAbsolute(url.URL())
}
func fetchIcon(filetype, filename string) *fetchResult {
result := fetchKnownIcon(homepage.NewSelfhStIconURL(filename, filetype))
if result.icon == nil {
return result
}
return fetchKnownIcon(homepage.NewWalkXCodeIconURL(filename, filetype))
}
func findIcon(r route.HTTPRoute, req *http.Request, uri string) *fetchResult {
key := routeKey(r)
if result := loadIconCache(key); result != nil {
return result
}
result := fetchIcon("png", sanitizeName(r.TargetName()))
cont := r.ContainerInfo()
if !result.OK() && cont != nil {
result = fetchIcon("png", sanitizeName(cont.ImageName))
}
if !result.OK() {
// fallback to parse html
result = findIconSlow(r, req, uri)
}
if result.OK() {
storeIconCache(key, result.icon)
}
return result
}
func findIconSlow(r route.HTTPRoute, req *http.Request, uri string) *fetchResult {
ctx, cancel := context.WithTimeoutCause(req.Context(), 3*time.Second, errors.New("favicon request timeout"))
defer cancel()
newReq := req.WithContext(ctx)
newReq.Header.Set("Accept-Encoding", "identity") // disable compression
if !strings.HasPrefix(uri, "/") {
uri = "/" + uri
}
u, err := url.ParseRequestURI(uri)
if err != nil {
logging.Error().Err(err).
Str("route", r.TargetName()).
Str("path", uri).
Msg("failed to parse uri")
return &fetchResult{statusCode: http.StatusInternalServerError, errMsg: "cannot parse uri"}
}
newReq.URL.Path = u.Path
newReq.URL.RawPath = u.RawPath
newReq.URL.RawQuery = u.RawQuery
newReq.RequestURI = u.String()
c := newContent()
r.ServeHTTP(c, newReq)
if c.status != http.StatusOK {
switch c.status {
case 0:
return &fetchResult{statusCode: http.StatusBadGateway, errMsg: "connection error"}
default:
if loc := c.Header().Get("Location"); loc != "" {
loc = path.Clean(loc)
if !strings.HasPrefix(loc, "/") {
loc = "/" + loc
}
if loc == newReq.URL.Path {
return &fetchResult{statusCode: http.StatusBadGateway, errMsg: "circular redirect"}
}
return findIconSlow(r, req, loc)
}
}
return &fetchResult{statusCode: c.status, errMsg: "upstream error: " + string(c.data)}
}
// return icon data
if !gphttp.GetContentType(c.header).IsHTML() {
return &fetchResult{icon: c.data, contentType: c.header.Get("Content-Type")}
}
// try extract from "link[rel=icon]" from path "/"
doc, err := goquery.NewDocumentFromReader(bytes.NewBuffer(c.data))
if err != nil {
logging.Error().Err(err).
Str("route", r.TargetName()).
Msg("failed to parse html")
return &fetchResult{statusCode: http.StatusInternalServerError, errMsg: "internal error"}
}
ele := doc.Find("head > link[rel=icon]").First()
if ele.Length() == 0 {
return &fetchResult{statusCode: http.StatusNotFound, errMsg: "icon element not found"}
}
href := ele.AttrOr("href", "")
if href == "" {
return &fetchResult{statusCode: http.StatusNotFound, errMsg: "icon href not found"}
}
// https://en.wikipedia.org/wiki/Data_URI_scheme
if strings.HasPrefix(href, "data:image/") {
dataURI, err := dataurl.DecodeString(href)
if err != nil {
logging.Error().Err(err).
Str("route", r.TargetName()).
Msg("failed to decode favicon")
return &fetchResult{statusCode: http.StatusInternalServerError, errMsg: "internal error"}
}
return &fetchResult{icon: dataURI.Data, contentType: dataURI.ContentType()}
}
switch {
case strings.HasPrefix(href, "http://"), strings.HasPrefix(href, "https://"):
return fetchIconAbsolute(href)
default:
return findIconSlow(r, req, path.Clean(href))
}
}

View file

@ -1,18 +1,17 @@
package v1
import (
"fmt"
"io"
"net/http"
"os"
"path"
"strings"
U "github.com/yusing/go-proxy/internal/api/v1/utils"
"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"
E "github.com/yusing/go-proxy/internal/error"
"github.com/yusing/go-proxy/internal/net/http/middleware"
"github.com/yusing/go-proxy/internal/route/provider"
)
@ -52,12 +51,12 @@ func (t FileType) GetPath(filename string) string {
func getArgs(r *http.Request) (fileType FileType, filename string, err error) {
fileType = FileType(r.PathValue("type"))
if !fileType.IsValid() {
err = fmt.Errorf("invalid file type: %s", fileType)
err = U.ErrInvalidKey("type")
return
}
filename = r.PathValue("filename")
if filename == "" {
err = fmt.Errorf("missing filename")
err = U.ErrMissingKey("filename")
}
return
}
@ -65,23 +64,23 @@ func getArgs(r *http.Request) (fileType FileType, filename string, err error) {
func GetFileContent(w http.ResponseWriter, r *http.Request) {
fileType, filename, err := getArgs(r)
if err != nil {
gphttp.BadRequest(w, err.Error())
U.RespondError(w, err, http.StatusBadRequest)
return
}
content, err := os.ReadFile(fileType.GetPath(filename))
if err != nil {
gphttp.ServerError(w, r, err)
U.HandleErr(w, r, err)
return
}
gphttp.WriteBody(w, content)
U.WriteBody(w, content)
}
func validateFile(fileType FileType, content []byte) gperr.Error {
func validateFile(fileType FileType, content []byte) error {
switch fileType {
case FileTypeConfig:
return config.Validate(content)
case FileTypeMiddleware:
errs := gperr.NewBuilder("middleware errors")
errs := E.NewBuilder("middleware errors")
middleware.BuildMiddlewaresFromYAML("", content, errs)
return errs.Error()
}
@ -91,17 +90,18 @@ func validateFile(fileType FileType, content []byte) gperr.Error {
func ValidateFile(w http.ResponseWriter, r *http.Request) {
fileType := FileType(r.PathValue("type"))
if !fileType.IsValid() {
gphttp.BadRequest(w, "invalid file type")
U.RespondError(w, U.ErrInvalidKey("type"), http.StatusBadRequest)
return
}
content, err := io.ReadAll(r.Body)
if err != nil {
gphttp.ServerError(w, r, err)
U.HandleErr(w, r, err)
return
}
r.Body.Close()
if valErr := validateFile(fileType, content); valErr != nil {
gphttp.JSONError(w, valErr, http.StatusBadRequest)
err = validateFile(fileType, content)
if err != nil {
U.RespondError(w, err, http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusOK)
@ -110,23 +110,23 @@ func ValidateFile(w http.ResponseWriter, r *http.Request) {
func SetFileContent(w http.ResponseWriter, r *http.Request) {
fileType, filename, err := getArgs(r)
if err != nil {
gphttp.BadRequest(w, err.Error())
U.RespondError(w, err, http.StatusBadRequest)
return
}
content, err := io.ReadAll(r.Body)
if err != nil {
gphttp.ServerError(w, r, err)
U.HandleErr(w, r, err)
return
}
if valErr := validateFile(fileType, content); valErr != nil {
gphttp.JSONError(w, valErr, http.StatusBadRequest)
U.RespondError(w, valErr, http.StatusBadRequest)
return
}
err = os.WriteFile(fileType.GetPath(filename), content, 0o644)
if err != nil {
gphttp.ServerError(w, r, err)
U.HandleErr(w, r, err)
return
}
w.WriteHeader(http.StatusOK)

View file

@ -6,18 +6,13 @@ import (
"github.com/coder/websocket"
"github.com/coder/websocket/wsjson"
"github.com/yusing/go-proxy/internal/net/gphttp"
"github.com/yusing/go-proxy/internal/net/gphttp/gpwebsocket"
"github.com/yusing/go-proxy/internal/net/gphttp/httpheaders"
"github.com/yusing/go-proxy/internal/route/routes"
U "github.com/yusing/go-proxy/internal/api/v1/utils"
config "github.com/yusing/go-proxy/internal/config/types"
"github.com/yusing/go-proxy/internal/route/routes/routequery"
)
func Health(w http.ResponseWriter, r *http.Request) {
if httpheaders.IsWebsocket(r.Header) {
gpwebsocket.Periodic(w, r, 1*time.Second, func(conn *websocket.Conn) error {
return wsjson.Write(r.Context(), conn, routes.HealthMap())
func HealthWS(cfg config.ConfigInstance, w http.ResponseWriter, r *http.Request) {
U.PeriodicWS(cfg, w, r, 1*time.Second, func(conn *websocket.Conn) error {
return wsjson.Write(r.Context(), conn, routequery.HealthMap())
})
} else {
gphttp.RespondJSON(w, r, routes.HealthMap())
}
}

View file

@ -5,8 +5,8 @@ import (
"io"
"net/http"
"github.com/yusing/go-proxy/internal/api/v1/utils"
"github.com/yusing/go-proxy/internal/homepage"
"github.com/yusing/go-proxy/internal/net/gphttp"
)
const (
@ -37,13 +37,13 @@ type (
func SetHomePageOverrides(w http.ResponseWriter, r *http.Request) {
what := r.FormValue("what")
if what == "" {
gphttp.BadRequest(w, "missing what or which")
http.Error(w, "missing what or which", http.StatusBadRequest)
return
}
data, err := io.ReadAll(r.Body)
if err != nil {
gphttp.ClientError(w, r, err, http.StatusBadRequest)
utils.RespondError(w, err, http.StatusBadRequest)
return
}
r.Body.Close()
@ -53,32 +53,32 @@ func SetHomePageOverrides(w http.ResponseWriter, r *http.Request) {
case HomepageOverrideItem:
var params HomepageOverrideItemParams
if err := json.Unmarshal(data, &params); err != nil {
gphttp.ClientError(w, r, err, http.StatusBadRequest)
utils.RespondError(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, r, err, http.StatusBadRequest)
utils.RespondError(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, r, err, http.StatusBadRequest)
utils.RespondError(w, err, http.StatusBadRequest)
return
}
if params.Value {
overrides.UnhideItems(params.Which)
overrides.UnhideItems(params.Which...)
} else {
overrides.HideItems(params.Which)
overrides.HideItems(params.Which...)
}
case HomepageOverrideCategoryOrder:
var params HomepageOverrideCategoryOrderParams
if err := json.Unmarshal(data, &params); err != nil {
gphttp.ClientError(w, r, err, http.StatusBadRequest)
utils.RespondError(w, err, http.StatusBadRequest)
return
}
overrides.SetCategoryOrder(params.Which, params.Value)

View file

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

127
internal/api/v1/list.go Normal file
View file

@ -0,0 +1,127 @@
package v1
import (
"net/http"
"strconv"
"strings"
"github.com/yusing/go-proxy/internal"
U "github.com/yusing/go-proxy/internal/api/v1/utils"
"github.com/yusing/go-proxy/internal/common"
config "github.com/yusing/go-proxy/internal/config/types"
"github.com/yusing/go-proxy/internal/net/http/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/task"
"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"
ListTasks = "tasks"
)
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:
if route := listRoute(which); route == nil {
http.NotFound(w, r)
return
} else {
U.RespondJSON(w, r, route)
}
case ListRoutes:
U.RespondJSON(w, r, routequery.RoutesByAlias(route.RouteType(r.FormValue("type"))))
case ListFiles:
listFiles(w, r)
case ListMiddlewares:
U.RespondJSON(w, r, middleware.All())
case ListMiddlewareTraces:
U.RespondJSON(w, r, middleware.GetAllTrace())
case ListMatchDomains:
U.RespondJSON(w, r, cfg.Value().MatchDomains)
case ListHomepageConfig:
U.RespondJSON(w, r, routequery.HomepageConfig(cfg.Value().Homepage.UseDefaultCategories, r.FormValue("category"), r.FormValue("provider")))
case ListRouteProviders:
U.RespondJSON(w, r, cfg.RouteProviderList())
case ListHomepageCategories:
U.RespondJSON(w, r, routequery.HomepageCategories())
case ListIcons:
limit, err := strconv.Atoi(r.FormValue("limit"))
if err != nil {
limit = 0
}
icons, err := internal.SearchIcons(r.FormValue("keyword"), limit)
if err != nil {
U.RespondError(w, err)
return
}
if icons == nil {
icons = []string{}
}
U.RespondJSON(w, r, icons)
case ListTasks:
U.RespondJSON(w, r, task.DebugTaskList())
default:
U.HandleErr(w, r, U.ErrInvalidKey("what"), http.StatusBadRequest)
}
}
// 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.ConfigBasePath, 0, true)
if err != nil {
U.HandleErr(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.ConfigBasePath+"/")
resp[t] = append(resp[t], file)
}
mids, err := utils.ListFiles(common.MiddlewareComposeBasePath, 0, true)
if err != nil {
U.HandleErr(w, r, err)
return
}
for _, mid := range mids {
mid = strings.TrimPrefix(mid, common.MiddlewareComposeBasePath+"/")
resp[FileTypeMiddleware] = append(resp[FileTypeMiddleware], mid)
}
U.RespondJSON(w, r, resp)
}

View file

@ -1,41 +0,0 @@
package v1
import (
"net/http"
"strings"
"github.com/yusing/go-proxy/internal/common"
config "github.com/yusing/go-proxy/internal/config/types"
"github.com/yusing/go-proxy/internal/net/gphttp"
"github.com/yusing/go-proxy/internal/utils"
)
func ListFilesHandler(cfg config.ConfigInstance, w http.ResponseWriter, r *http.Request) {
files, err := utils.ListFiles(common.ConfigBasePath, 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.ConfigBasePath+"/")
resp[t] = append(resp[t], file)
}
mids, err := utils.ListFiles(common.MiddlewareComposeBasePath, 0, true)
if err != nil {
gphttp.ServerError(w, r, err)
return
}
for _, mid := range mids {
mid = strings.TrimPrefix(mid, common.MiddlewareComposeBasePath+"/")
resp[FileTypeMiddleware] = append(resp[FileTypeMiddleware], mid)
}
gphttp.RespondJSON(w, r, resp)
}

View file

@ -1,13 +0,0 @@
package v1
import (
"net/http"
config "github.com/yusing/go-proxy/internal/config/types"
"github.com/yusing/go-proxy/internal/net/gphttp"
"github.com/yusing/go-proxy/internal/route/routes"
)
func ListHomepageCategoriesHandler(cfg config.ConfigInstance, w http.ResponseWriter, r *http.Request) {
gphttp.RespondJSON(w, r, routes.HomepageCategories())
}

View file

@ -1,13 +0,0 @@
package v1
import (
"net/http"
config "github.com/yusing/go-proxy/internal/config/types"
"github.com/yusing/go-proxy/internal/net/gphttp"
"github.com/yusing/go-proxy/internal/route/routes"
)
func ListHomepageConfigHandler(cfg config.ConfigInstance, w http.ResponseWriter, r *http.Request) {
gphttp.RespondJSON(w, r, routes.HomepageConfig(r.FormValue("category"), r.FormValue("provider")))
}

View file

@ -1,23 +0,0 @@
package v1
import (
"net/http"
"strconv"
config "github.com/yusing/go-proxy/internal/config/types"
"github.com/yusing/go-proxy/internal/homepage"
"github.com/yusing/go-proxy/internal/net/gphttp"
)
func ListIconsHandler(cfg config.ConfigInstance, w http.ResponseWriter, r *http.Request) {
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, r, err)
return
}
gphttp.RespondJSON(w, r, icons)
}

View file

@ -1,19 +0,0 @@
package v1
import (
"net/http"
config "github.com/yusing/go-proxy/internal/config/types"
"github.com/yusing/go-proxy/internal/net/gphttp"
"github.com/yusing/go-proxy/internal/route/routes"
)
func ListRouteHandler(cfg config.ConfigInstance, w http.ResponseWriter, r *http.Request) {
which := r.PathValue("which")
route, ok := routes.Get(which)
if ok {
gphttp.RespondJSON(w, r, route)
} else {
gphttp.RespondJSON(w, r, nil)
}
}

View file

@ -1,23 +0,0 @@
package v1
import (
"net/http"
"time"
"github.com/coder/websocket"
"github.com/coder/websocket/wsjson"
config "github.com/yusing/go-proxy/internal/config/types"
"github.com/yusing/go-proxy/internal/net/gphttp"
"github.com/yusing/go-proxy/internal/net/gphttp/gpwebsocket"
"github.com/yusing/go-proxy/internal/net/gphttp/httpheaders"
)
func ListRouteProvidersHandler(cfgInstance config.ConfigInstance, w http.ResponseWriter, r *http.Request) {
if httpheaders.IsWebsocket(r.Header) {
gpwebsocket.Periodic(w, r, 5*time.Second, func(conn *websocket.Conn) error {
return wsjson.Write(r.Context(), conn, cfgInstance.RouteProviderList())
})
} else {
gphttp.RespondJSON(w, r, cfgInstance.RouteProviderList())
}
}

View file

@ -1,25 +0,0 @@
package v1
import (
"net/http"
"slices"
config "github.com/yusing/go-proxy/internal/config/types"
"github.com/yusing/go-proxy/internal/net/gphttp"
"github.com/yusing/go-proxy/internal/route/routes"
)
func ListRoutesHandler(cfg config.ConfigInstance, w http.ResponseWriter, r *http.Request) {
rts := make([]routes.Route, 0)
provider := r.FormValue("provider")
if provider == "" {
gphttp.RespondJSON(w, r, slices.Collect(routes.Iter))
return
}
for r := range routes.Iter {
if r.ProviderName() == provider {
rts = append(rts, r)
}
}
gphttp.RespondJSON(w, r, rts)
}

View file

@ -1,13 +0,0 @@
package v1
import (
"net/http"
config "github.com/yusing/go-proxy/internal/config/types"
"github.com/yusing/go-proxy/internal/net/gphttp"
"github.com/yusing/go-proxy/internal/route/routes"
)
func ListRoutesByProviderHandler(cfg config.ConfigInstance, w http.ResponseWriter, r *http.Request) {
gphttp.RespondJSON(w, r, routes.ByProvider())
}

View file

@ -0,0 +1,233 @@
package v1
import (
"bytes"
"context"
"io"
"net/http"
"sync"
"time"
"github.com/coder/websocket"
"github.com/rs/zerolog"
"github.com/yusing/go-proxy/internal/api/v1/utils"
"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/task"
F "github.com/yusing/go-proxy/internal/utils/functional"
)
type logEntryRange struct {
Start, End int
}
type memLogger struct {
bytes.Buffer
sync.RWMutex
notifyLock sync.RWMutex
connChans F.Map[chan *logEntryRange, struct{}]
bufPool sync.Pool // used in hook mode
}
type MemLogger interface {
io.Writer
// TODO: hook does not pass in fields, looking for a workaround to do server side log rendering
zerolog.Hook
}
type buffer struct {
data []byte
}
const (
maxMemLogSize = 16 * 1024
truncateSize = maxMemLogSize / 2
initialWriteChunkSize = 4 * 1024
hookModeBufSize = 256
)
var memLoggerInstance = &memLogger{
connChans: F.NewMapOf[chan *logEntryRange, struct{}](),
bufPool: sync.Pool{
New: func() any {
return &buffer{
data: make([]byte, 0, hookModeBufSize),
}
},
},
}
func init() {
if !common.EnableLogStreaming {
return
}
memLoggerInstance.Grow(maxMemLogSize)
if common.DebugMemLogger {
ticker := time.NewTicker(1 * time.Second)
go func() {
defer ticker.Stop()
for {
select {
case <-task.RootContextCanceled():
return
case <-ticker.C:
logging.Info().Msgf("mem logger size: %d, active conns: %d",
memLoggerInstance.Len(),
memLoggerInstance.connChans.Size())
}
}
}()
}
}
func LogsWS() func(config config.ConfigInstance, w http.ResponseWriter, r *http.Request) {
return memLoggerInstance.ServeHTTP
}
func GetMemLogger() MemLogger {
return memLoggerInstance
}
func (m *memLogger) truncateIfNeeded(n int) {
m.RLock()
needTruncate := m.Len()+n > maxMemLogSize
m.RUnlock()
if needTruncate {
m.Lock()
defer m.Unlock()
needTruncate = m.Len()+n > maxMemLogSize
if !needTruncate {
return
}
m.Truncate(truncateSize)
}
}
func (m *memLogger) notifyWS(pos, n int) {
if m.connChans.Size() > 0 {
timeout := time.NewTimer(1 * time.Second)
defer timeout.Stop()
m.notifyLock.RLock()
defer m.notifyLock.RUnlock()
m.connChans.Range(func(ch chan *logEntryRange, _ struct{}) bool {
select {
case ch <- &logEntryRange{pos, pos + n}:
return true
case <-timeout.C:
logging.Warn().Msg("mem logger: timeout logging to channel")
return false
}
})
return
}
}
func (m *memLogger) writeBuf(b []byte) (pos int, err error) {
m.Lock()
defer m.Unlock()
pos = m.Len()
_, err = m.Buffer.Write(b)
return
}
// Run implements zerolog.Hook.
func (m *memLogger) Run(e *zerolog.Event, level zerolog.Level, message string) {
bufStruct := m.bufPool.Get().(*buffer)
buf := bufStruct.data
defer func() {
bufStruct.data = bufStruct.data[:0]
m.bufPool.Put(bufStruct)
}()
buf = logging.FormatLogEntryHTML(level, message, buf)
n := len(buf)
m.truncateIfNeeded(n)
pos, err := m.writeBuf(buf)
if err != nil {
// not logging the error here, it will cause Run to be called again = infinite loop
return
}
m.notifyWS(pos, n)
}
// Write implements io.Writer.
func (m *memLogger) Write(p []byte) (n int, err error) {
n = len(p)
m.truncateIfNeeded(n)
pos, err := m.writeBuf(p)
if err != nil {
// not logging the error here, it will cause Run to be called again = infinite loop
return
}
m.notifyWS(pos, n)
return
}
func (m *memLogger) ServeHTTP(config config.ConfigInstance, w http.ResponseWriter, r *http.Request) {
conn, err := utils.InitiateWS(config, w, r)
if err != nil {
utils.HandleErr(w, r, err)
return
}
logCh := make(chan *logEntryRange)
m.connChans.Store(logCh, struct{}{})
/* trunk-ignore(golangci-lint/errcheck) */
defer func() {
_ = conn.CloseNow()
m.notifyLock.Lock()
m.connChans.Delete(logCh)
close(logCh)
m.notifyLock.Unlock()
}()
if err := m.wsInitial(r.Context(), conn); err != nil {
utils.HandleErr(w, r, err)
return
}
m.wsStreamLog(r.Context(), conn, logCh)
}
func (m *memLogger) writeBytes(ctx context.Context, conn *websocket.Conn, b []byte) error {
return conn.Write(ctx, websocket.MessageText, b)
}
func (m *memLogger) wsInitial(ctx context.Context, conn *websocket.Conn) error {
m.Lock()
defer m.Unlock()
return m.writeBytes(ctx, conn, m.Buffer.Bytes())
}
func (m *memLogger) wsStreamLog(ctx context.Context, conn *websocket.Conn, ch <-chan *logEntryRange) {
for {
select {
case <-ctx.Done():
return
case logRange := <-ch:
m.RLock()
msg := m.Buffer.Bytes()[logRange.Start:logRange.End]
err := m.writeBytes(ctx, conn, msg)
m.RUnlock()
if err != nil {
return
}
}
}
}

View file

@ -1,141 +0,0 @@
package v1
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strconv"
_ "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"
)
func NewAgent(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
name := q.Get("name")
if name == "" {
gphttp.MissingKey(w, "name")
return
}
host := q.Get("host")
if host == "" {
gphttp.MissingKey(w, "host")
return
}
portStr := q.Get("port")
if portStr == "" {
gphttp.MissingKey(w, "port")
return
}
port, err := strconv.Atoi(portStr)
if err != nil || port < 1 || port > 65535 {
gphttp.InvalidKey(w, "port")
return
}
hostport := fmt.Sprintf("%s:%d", host, port)
if _, ok := config.GetInstance().GetAgent(hostport); ok {
gphttp.KeyAlreadyExists(w, "agent", hostport)
return
}
t := q.Get("type")
switch t {
case "docker", "system":
break
case "":
gphttp.MissingKey(w, "type")
return
default:
gphttp.InvalidKey(w, "type")
return
}
nightly, _ := strconv.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, r, err)
return
}
nRoutesAdded, err := config.GetInstance().VerifyNewAgent(data.Host, data.CA, data.Client)
if err != nil {
gphttp.ClientError(w, r, 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.InvalidKey(w, "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

@ -0,0 +1,64 @@
package query
import (
"encoding/json"
"fmt"
"io"
"net/http"
v1 "github.com/yusing/go-proxy/internal/api/v1"
U "github.com/yusing/go-proxy/internal/api/v1/utils"
"github.com/yusing/go-proxy/internal/common"
E "github.com/yusing/go-proxy/internal/error"
"github.com/yusing/go-proxy/internal/net/http/middleware"
)
func ReloadServer() E.Error {
resp, err := U.Post(common.APIHTTPURL+"/v1/reload", "", nil)
if err != nil {
return E.From(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
failure := E.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 E.Error) {
resp, err := U.Get(fmt.Sprintf("%s/v1/list/%s", common.APIHTTPURL, what))
if err != nil {
outErr = E.From(err)
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
outErr = E.Errorf("list %s: failed, status %v", what, resp.StatusCode)
return
}
var res T
err = json.NewDecoder(resp.Body).Decode(&res)
if err != nil {
outErr = E.From(err)
return
}
return res, nil
}
func ListRoutes() (map[string]map[string]any, E.Error) {
return List[map[string]map[string]any](v1.ListRoutes)
}
func ListMiddlewareTraces() (middleware.Traces, E.Error) {
return List[middleware.Traces](v1.ListMiddlewareTraces)
}
func DebugListTasks() (map[string]any, E.Error) {
return List[map[string]any](v1.ListTasks)
}

View file

@ -3,14 +3,14 @@ package v1
import (
"net/http"
U "github.com/yusing/go-proxy/internal/api/v1/utils"
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)
U.HandleErr(w, r, err)
return
}
gphttp.WriteBody(w, []byte("OK"))
U.WriteBody(w, []byte("OK"))
}

View file

@ -6,21 +6,19 @@ import (
"github.com/coder/websocket"
"github.com/coder/websocket/wsjson"
U "github.com/yusing/go-proxy/internal/api/v1/utils"
config "github.com/yusing/go-proxy/internal/config/types"
"github.com/yusing/go-proxy/internal/net/gphttp"
"github.com/yusing/go-proxy/internal/net/gphttp/gpwebsocket"
"github.com/yusing/go-proxy/internal/net/gphttp/httpheaders"
"github.com/yusing/go-proxy/internal/utils/strutils"
)
func Stats(cfg config.ConfigInstance, w http.ResponseWriter, r *http.Request) {
if httpheaders.IsWebsocket(r.Header) {
gpwebsocket.Periodic(w, r, 1*time.Second, func(conn *websocket.Conn) error {
U.RespondJSON(w, r, getStats(cfg))
}
func StatsWS(cfg config.ConfigInstance, w http.ResponseWriter, r *http.Request) {
U.PeriodicWS(cfg, w, r, 1*time.Second, func(conn *websocket.Conn) error {
return wsjson.Write(r.Context(), conn, getStats(cfg))
})
} else {
gphttp.RespondJSON(w, r, getStats(cfg))
}
}
var startTime = time.Now()

View file

@ -1,54 +0,0 @@
package v1
import (
"net/http"
agentPkg "github.com/yusing/go-proxy/agent/pkg/agent"
config "github.com/yusing/go-proxy/internal/config/types"
"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"
"github.com/yusing/go-proxy/internal/net/types"
)
func SystemInfo(cfg config.ConfigInstance, 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 := cfg.GetAgent(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", types.NewURL(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

@ -0,0 +1,55 @@
package utils
import (
"encoding/json"
"net/http"
E "github.com/yusing/go-proxy/internal/error"
"github.com/yusing/go-proxy/internal/utils/strutils/ansi"
)
// HandleErr logs the error and returns an error code to the client.
// If code is specified, it will be used as the HTTP status code; otherwise,
// http.StatusInternalServerError is used.
//
// The error is only logged but not returned to the client.
func HandleErr(w http.ResponseWriter, r *http.Request, err error, code ...int) {
if err == nil {
return
}
LogError(r).Msg(err.Error())
if len(code) == 0 {
code = []int{http.StatusInternalServerError}
}
http.Error(w, http.StatusText(code[0]), code[0])
}
// RespondError returns error details to the client.
// If code is specified, it will be used as the HTTP status code; otherwise,
// http.StatusBadRequest is used.
func RespondError(w http.ResponseWriter, err error, code ...int) {
if len(code) == 0 {
code = []int{http.StatusBadRequest}
}
buf, err := json.Marshal(err)
if err != nil { // just in case
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
http.Error(w, ansi.StripANSI(err.Error()), code[0])
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(code[0])
_, _ = w.Write(buf)
}
func ErrMissingKey(k string) error {
return E.New("missing key '" + k + "' in query or request body")
}
func ErrInvalidKey(k string) error {
return E.New("invalid key '" + k + "' in query or request body")
}
func ErrNotFound(k, v string) error {
return E.Errorf("key %q with value %q not found", k, v)
}

View file

@ -1,21 +1,22 @@
package gphttp
package utils
import (
"crypto/tls"
"net"
"net/http"
"time"
"github.com/yusing/go-proxy/internal/common"
)
var (
httpClient = &http.Client{
Timeout: 5 * time.Second,
Timeout: common.ConnectionTimeout,
Transport: &http.Transport{
DisableKeepAlives: true,
ForceAttemptHTTP2: false,
DialContext: (&net.Dialer{
Timeout: 3 * time.Second,
KeepAlive: 60 * time.Second, // this is different from DisableKeepAlives
Timeout: common.DialTimeout,
KeepAlive: common.KeepAlive, // this is different from DisableKeepAlives
}).DialContext,
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
},
@ -24,5 +25,4 @@ var (
Get = httpClient.Get
Post = httpClient.Post
Head = httpClient.Head
Do = httpClient.Do
)

View file

@ -1,4 +1,4 @@
package gphttp
package utils
import (
"net/http"
@ -9,6 +9,7 @@ import (
func reqLogger(r *http.Request, level zerolog.Level) *zerolog.Event {
return logging.WithLevel(level).
Str("module", "api").
Str("remote", r.RemoteAddr).
Str("host", r.Host).
Str("uri", r.Method+" "+r.RequestURI)

View file

@ -1,47 +1,47 @@
package gphttp
package utils
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"github.com/yusing/go-proxy/internal/logging"
"github.com/yusing/go-proxy/internal/utils/strutils/ansi"
)
func WriteBody(w http.ResponseWriter, body []byte) {
if _, err := w.Write(body); err != nil {
switch {
case errors.Is(err, http.ErrHandlerTimeout),
errors.Is(err, context.DeadlineExceeded):
logging.Err(err).Msg("timeout writing body")
default:
logging.Err(err).Msg("failed to write body")
}
}
}
func RespondJSON(w http.ResponseWriter, r *http.Request, data any, code ...int) (canProceed bool) {
if data == nil {
http.NotFound(w, r)
return false
}
if len(code) > 0 {
w.WriteHeader(code[0])
}
w.Header().Set("Content-Type", "application/json")
var j []byte
var err error
switch data := data.(type) {
case string:
j = []byte(fmt.Sprintf("%q", data))
case []byte:
panic("use WriteBody instead")
j = data
case error:
j, err = json.Marshal(ansi.StripANSI(data.Error()))
default:
err = json.NewEncoder(w).Encode(data)
j, err = json.MarshalIndent(data, "", " ")
}
if err != nil {
LogError(r).Err(err).Msg("failed to encode json")
logging.Panic().Err(err).Msg("failed to marshal json")
return false
}
_, err = w.Write(j)
if err != nil {
HandleErr(w, r, err)
return false
}
return true

View file

@ -0,0 +1,68 @@
package utils
import (
"net/http"
"sync"
"time"
"github.com/coder/websocket"
"github.com/yusing/go-proxy/internal/common"
config "github.com/yusing/go-proxy/internal/config/types"
"github.com/yusing/go-proxy/internal/logging"
)
func warnNoMatchDomains() {
logging.Warn().Msg("no match domains configured, accepting websocket API request from all origins")
}
var warnNoMatchDomainOnce sync.Once
func InitiateWS(cfg config.ConfigInstance, w http.ResponseWriter, r *http.Request) (*websocket.Conn, error) {
var originPats []string
localAddresses := []string{"127.0.0.1", "10.0.*.*", "172.16.*.*", "192.168.*.*"}
if len(cfg.Value().MatchDomains) == 0 {
warnNoMatchDomainOnce.Do(warnNoMatchDomains)
originPats = []string{"*"}
} else {
originPats = make([]string, len(cfg.Value().MatchDomains))
for i, domain := range cfg.Value().MatchDomains {
originPats[i] = "*" + domain
}
originPats = append(originPats, localAddresses...)
}
if common.IsDebug {
originPats = []string{"*"}
}
return websocket.Accept(w, r, &websocket.AcceptOptions{
OriginPatterns: originPats,
})
}
func PeriodicWS(cfg config.ConfigInstance, w http.ResponseWriter, r *http.Request, interval time.Duration, do func(conn *websocket.Conn) error) {
conn, err := InitiateWS(cfg, w, r)
if err != nil {
HandleErr(w, r, err)
return
}
/* trunk-ignore(golangci-lint/errcheck) */
defer conn.CloseNow()
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-cfg.Context().Done():
return
case <-r.Context().Done():
return
case <-ticker.C:
if err := do(conn); err != nil {
LogError(r).Msg(err.Error())
return
}
}
}
}

View file

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

View file

@ -1,73 +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 != ""
}
type nextHandler struct{}
var nextHandlerContextKey = nextHandler{}
func RequireAuth(next http.HandlerFunc) http.HandlerFunc {
if !IsEnabled() {
return next
}
return func(w http.ResponseWriter, r *http.Request) {
if err := defaultAuth.CheckToken(r); err != nil {
gphttp.Unauthorized(w, err.Error())
return
}
next(w, r)
}
}
func ProceedNext(w http.ResponseWriter, r *http.Request) {
next, ok := r.Context().Value(nextHandlerContextKey).(http.HandlerFunc)
if ok {
next(w, r)
} else {
w.WriteHeader(http.StatusOK)
}
}
func AuthCheckHandler(w http.ResponseWriter, r *http.Request) {
if err := defaultAuth.CheckToken(r); err != nil {
defaultAuth.LoginHandler(w, r)
} else {
w.WriteHeader(http.StatusOK)
}
}

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,221 +0,0 @@
package auth
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"net/http"
"sync"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/yusing/go-proxy/internal/common"
"github.com/yusing/go-proxy/internal/jsonstore"
"github.com/yusing/go-proxy/internal/logging"
"golang.org/x/oauth2"
)
type oauthRefreshToken struct {
Username string `json:"username"`
RefreshToken string `json:"refresh_token"`
Expiry time.Time `json:"expiry"`
result *RefreshResult
err error
mu sync.Mutex
}
type Session struct {
SessionID sessionID `json:"session_id"`
Username string `json:"username"`
Groups []string `json:"groups"`
}
type RefreshResult struct {
newSession Session
jwt string
jwtExpiry time.Time
}
type sessionClaims struct {
Session
jwt.RegisteredClaims
}
type sessionID string
var oauthRefreshTokens jsonstore.MapStore[*oauthRefreshToken]
var (
defaultRefreshTokenExpiry = 30 * 24 * time.Hour // 1 month
sessionInvalidateDelay = 3 * time.Second
)
var (
errNoRefreshToken = errors.New("no refresh token")
ErrRefreshTokenFailure = errors.New("failed to refresh token")
)
const sessionTokenIssuer = "GoDoxy"
func init() {
if IsOIDCEnabled() {
oauthRefreshTokens = jsonstore.Store[*oauthRefreshToken]("oauth_refresh_tokens")
}
}
func (token *oauthRefreshToken) expired() bool {
return time.Now().After(token.Expiry)
}
func newSessionID() sessionID {
b := make([]byte, 32)
_, _ = rand.Read(b)
return sessionID(hex.EncodeToString(b))
}
func newSession(username string, groups []string) Session {
return Session{
SessionID: newSessionID(),
Username: username,
Groups: groups,
}
}
// getOAuthRefreshToken returns the refresh token for the given session.
func getOAuthRefreshToken(claims *Session) (*oauthRefreshToken, bool) {
token, ok := oauthRefreshTokens.Load(string(claims.SessionID))
if !ok {
return nil, false
}
if token.expired() {
invalidateOAuthRefreshToken(claims.SessionID)
return nil, false
}
if claims.Username != token.Username {
return nil, false
}
return token, true
}
func storeOAuthRefreshToken(sessionID sessionID, username, token string) {
oauthRefreshTokens.Store(string(sessionID), &oauthRefreshToken{
Username: username,
RefreshToken: token,
Expiry: time.Now().Add(defaultRefreshTokenExpiry),
})
logging.Debug().Str("username", username).Msg("stored oauth refresh token")
}
func invalidateOAuthRefreshToken(sessionID sessionID) {
logging.Debug().Str("session_id", string(sessionID)).Msg("invalidating oauth refresh token")
oauthRefreshTokens.Delete(string(sessionID))
}
func (auth *OIDCProvider) setSessionTokenCookie(w http.ResponseWriter, r *http.Request, session Session) {
claims := &sessionClaims{
Session: session,
RegisteredClaims: jwt.RegisteredClaims{
Issuer: sessionTokenIssuer,
ExpiresAt: jwt.NewNumericDate(time.Now().Add(common.APIJWTTokenTTL)),
},
}
jwtToken := jwt.NewWithClaims(jwt.SigningMethodHS512, claims)
signed, err := jwtToken.SignedString(common.APIJWTSecret)
if err != nil {
logging.Err(err).Msg("failed to sign session token")
return
}
SetTokenCookie(w, r, CookieOauthSessionToken, signed, common.APIJWTTokenTTL)
}
func (auth *OIDCProvider) parseSessionJWT(sessionJWT string) (claims *sessionClaims, valid bool, err error) {
claims = &sessionClaims{}
sessionToken, err := jwt.ParseWithClaims(sessionJWT, 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 common.APIJWTSecret, nil
})
if err != nil {
return nil, false, err
}
return claims, sessionToken.Valid && claims.Issuer == sessionTokenIssuer, nil
}
func (auth *OIDCProvider) TryRefreshToken(ctx context.Context, sessionJWT string) (*RefreshResult, error) {
// verify the session cookie
claims, valid, err := auth.parseSessionJWT(sessionJWT)
if err != nil {
return nil, fmt.Errorf("session: %s - %w: %w", claims.SessionID, ErrInvalidSessionToken, err)
}
if !valid {
return nil, ErrInvalidSessionToken
}
// check if refresh is possible
refreshToken, ok := getOAuthRefreshToken(&claims.Session)
if !ok {
return nil, errNoRefreshToken
}
if !auth.checkAllowed(claims.Username, claims.Groups) {
return nil, ErrUserNotAllowed
}
return auth.doRefreshToken(ctx, refreshToken, &claims.Session)
}
func (auth *OIDCProvider) doRefreshToken(ctx context.Context, refreshToken *oauthRefreshToken, claims *Session) (*RefreshResult, error) {
refreshToken.mu.Lock()
defer refreshToken.mu.Unlock()
// already refreshed
// this must be called after refresh but before invalidate
if refreshToken.result != nil || refreshToken.err != nil {
return refreshToken.result, refreshToken.err
}
// this step refreshes the token
// see https://cs.opensource.google/go/x/oauth2/+/refs/tags/v0.29.0:oauth2.go;l=313
newToken, err := auth.oauthConfig.TokenSource(ctx, &oauth2.Token{
RefreshToken: refreshToken.RefreshToken,
}).Token()
if err != nil {
refreshToken.err = fmt.Errorf("session: %s - %w: %w", claims.SessionID, ErrRefreshTokenFailure, err)
return nil, refreshToken.err
}
idTokenJWT, idToken, err := auth.getIdToken(ctx, newToken)
if err != nil {
refreshToken.err = fmt.Errorf("session: %s - %w: %w", claims.SessionID, ErrRefreshTokenFailure, err)
return nil, refreshToken.err
}
// in case there're multiple requests for the same session to refresh
// invalidate the token after a short delay
go func() {
<-time.After(sessionInvalidateDelay)
invalidateOAuthRefreshToken(claims.SessionID)
}()
sessionID := newSessionID()
logging.Debug().Str("username", claims.Username).Time("expiry", newToken.Expiry).Msg("refreshed token")
storeOAuthRefreshToken(sessionID, claims.Username, newToken.RefreshToken)
refreshToken.result = &RefreshResult{
newSession: Session{
SessionID: sessionID,
Username: claims.Username,
Groups: claims.Groups,
},
jwt: idTokenJWT,
jwtExpiry: idToken.Expiry,
}
return refreshToken.result, nil
}

View file

@ -1,336 +0,0 @@
package auth
import (
"context"
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"net/http"
"net/url"
"slices"
"time"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/yusing/go-proxy/internal/common"
"github.com/yusing/go-proxy/internal/gperr"
"github.com/yusing/go-proxy/internal/logging"
"github.com/yusing/go-proxy/internal/net/gphttp"
"github.com/yusing/go-proxy/internal/utils"
"golang.org/x/oauth2"
)
type (
OIDCProvider struct {
oauthConfig *oauth2.Config
oidcProvider *oidc.Provider
oidcVerifier *oidc.IDTokenVerifier
endSessionURL *url.URL
allowedUsers []string
allowedGroups []string
}
IDTokenClaims struct {
Username string `json:"preferred_username"`
Groups []string `json:"groups"`
}
)
const (
CookieOauthState = "godoxy_oidc_state"
CookieOauthToken = "godoxy_oauth_token"
CookieOauthSessionToken = "godoxy_session_token"
)
const (
OIDCAuthInitPath = "/"
OIDCPostAuthPath = "/auth/callback"
OIDCLogoutPath = "/auth/logout"
)
var (
errMissingIDToken = errors.New("missing id_token field from oauth token")
ErrMissingOAuthToken = gperr.New("missing oauth token")
ErrInvalidOAuthToken = gperr.New("invalid oauth token")
)
// generateState generates a random string for OIDC state.
const oidcStateLength = 32
func generateState() string {
b := make([]byte, oidcStateLength)
_, _ = rand.Read(b)
return base64.URLEncoding.EncodeToString(b)[:oidcStateLength]
}
func NewOIDCProvider(issuerURL, clientID, clientSecret string, allowedUsers, allowedGroups []string) (*OIDCProvider, error) {
if len(allowedUsers)+len(allowedGroups) == 0 {
return nil, errors.New("oidc.allowed_users or oidc.allowed_groups are both empty")
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
provider, err := oidc.NewProvider(ctx, issuerURL)
if err != nil {
return nil, fmt.Errorf("failed to initialize OIDC provider: %w", err)
}
endSessionURL, err := url.Parse(provider.EndSessionEndpoint())
if err != nil && provider.EndSessionEndpoint() != "" {
// non critical, just warn
logging.Warn().
Str("issuer", issuerURL).
Err(err).
Msg("failed to parse end session URL")
}
return &OIDCProvider{
oauthConfig: &oauth2.Config{
ClientID: clientID,
ClientSecret: clientSecret,
RedirectURL: "",
Endpoint: provider.Endpoint(),
Scopes: common.OIDCScopes,
},
oidcProvider: provider,
oidcVerifier: provider.Verifier(&oidc.Config{
ClientID: clientID,
}),
endSessionURL: 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.OIDCAllowedUsers,
common.OIDCAllowedGroups,
)
}
func (auth *OIDCProvider) SetAllowedUsers(users []string) {
auth.allowedUsers = users
}
func (auth *OIDCProvider) SetAllowedGroups(groups []string) {
auth.allowedGroups = groups
}
// optRedirectPostAuth returns an oauth2 option that sets the "redirect_uri"
// parameter of the authorization URL to the post auth path of the current
// request host.
func optRedirectPostAuth(r *http.Request) oauth2.AuthCodeOption {
return oauth2.SetAuthURLParam("redirect_uri", "https://"+requestHost(r)+OIDCPostAuthPath)
}
func (auth *OIDCProvider) getIdToken(ctx context.Context, oauthToken *oauth2.Token) (string, *oidc.IDToken, error) {
idTokenJWT, ok := oauthToken.Extra("id_token").(string)
if !ok {
return "", nil, errMissingIDToken
}
idToken, err := auth.oidcVerifier.Verify(ctx, idTokenJWT)
if err != nil {
return "", nil, fmt.Errorf("failed to verify ID token: %w", err)
}
return idTokenJWT, idToken, nil
}
func (auth *OIDCProvider) HandleAuth(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "" {
r.URL.Path = OIDCAuthInitPath
}
if r.TLS == nil && r.Header.Get("X-Forwarded-Proto") != "https" {
r.URL.Scheme = "https"
http.Redirect(w, r, r.URL.String(), http.StatusFound)
return
}
switch r.URL.Path {
case OIDCAuthInitPath:
auth.LoginHandler(w, r)
case OIDCPostAuthPath:
auth.PostAuthCallbackHandler(w, r)
case OIDCLogoutPath:
auth.LogoutHandler(w, r)
default:
http.Redirect(w, r, OIDCAuthInitPath, http.StatusFound)
}
}
func (auth *OIDCProvider) LoginHandler(w http.ResponseWriter, r *http.Request) {
// check for session token
sessionToken, err := r.Cookie(CookieOauthSessionToken)
if err == nil { // session token exists
result, err := auth.TryRefreshToken(r.Context(), sessionToken.Value)
// redirect back to where they requested
// when token refresh is ok
if err == nil {
auth.setIDTokenCookie(w, r, result.jwt, time.Until(result.jwtExpiry))
auth.setSessionTokenCookie(w, r, result.newSession)
ProceedNext(w, r)
return
}
// clear cookies then redirect to home
logging.Err(err).Msg("failed to refresh token")
auth.clearCookie(w, r)
http.Redirect(w, r, "/", http.StatusFound)
return
}
state := generateState()
SetTokenCookie(w, r, CookieOauthState, state, 300*time.Second)
// redirect user to Idp
http.Redirect(w, r, auth.oauthConfig.AuthCodeURL(state, optRedirectPostAuth(r)), http.StatusFound)
}
func parseClaims(idToken *oidc.IDToken) (*IDTokenClaims, error) {
var claim IDTokenClaims
if err := idToken.Claims(&claim); err != nil {
return nil, fmt.Errorf("failed to parse claims: %w", err)
}
if claim.Username == "" {
return nil, errors.New("missing username in ID token")
}
return &claim, nil
}
func (auth *OIDCProvider) checkAllowed(user string, groups []string) bool {
userAllowed := slices.Contains(auth.allowedUsers, user)
if !userAllowed {
return false
}
if len(auth.allowedGroups) == 0 {
return true
}
return len(utils.Intersect(groups, auth.allowedGroups)) > 0
}
func (auth *OIDCProvider) CheckToken(r *http.Request) error {
tokenCookie, err := r.Cookie(CookieOauthToken)
if err != nil {
return ErrMissingOAuthToken
}
idToken, err := auth.oidcVerifier.Verify(r.Context(), tokenCookie.Value)
if err != nil {
return fmt.Errorf("%w: %w", ErrInvalidOAuthToken, err)
}
claims, err := parseClaims(idToken)
if err != nil {
return fmt.Errorf("%w: %w", ErrInvalidOAuthToken, err)
}
if !auth.checkAllowed(claims.Username, claims.Groups) {
return ErrUserNotAllowed
}
return nil
}
func (auth *OIDCProvider) PostAuthCallbackHandler(w http.ResponseWriter, r *http.Request) {
// For testing purposes, skip provider verification
if common.IsTest {
auth.handleTestCallback(w, r)
return
}
// verify state
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
}
code := r.URL.Query().Get("code")
oauth2Token, err := auth.oauthConfig.Exchange(r.Context(), code, optRedirectPostAuth(r))
if err != nil {
gphttp.ServerError(w, r, fmt.Errorf("failed to exchange token: %w", err))
return
}
idTokenJWT, idToken, err := auth.getIdToken(r.Context(), oauth2Token)
if err != nil {
gphttp.ServerError(w, r, err)
return
}
if oauth2Token.RefreshToken != "" {
claims, err := parseClaims(idToken)
if err != nil {
gphttp.ServerError(w, r, err)
return
}
session := newSession(claims.Username, claims.Groups)
storeOAuthRefreshToken(session.SessionID, claims.Username, oauth2Token.RefreshToken)
auth.setSessionTokenCookie(w, r, session)
}
auth.setIDTokenCookie(w, r, idTokenJWT, time.Until(idToken.Expiry))
// Redirect to home page
http.Redirect(w, r, "/", http.StatusFound)
}
func (auth *OIDCProvider) LogoutHandler(w http.ResponseWriter, r *http.Request) {
oauthToken, _ := r.Cookie(CookieOauthToken)
sessionToken, _ := r.Cookie(CookieOauthSessionToken)
auth.clearCookie(w, r)
if sessionToken != nil {
claims, _, err := auth.parseSessionJWT(sessionToken.Value)
if err == nil {
invalidateOAuthRefreshToken(claims.SessionID)
}
}
url := "/"
if auth.endSessionURL != nil && oauthToken != nil {
query := auth.endSessionURL.Query()
query.Set("id_token_hint", oauthToken.Value)
query.Set("post_logout_redirect_uri", "https://"+requestHost(r))
clone := *auth.endSessionURL
clone.RawQuery = query.Encode()
url = clone.String()
} else if auth.endSessionURL != nil {
url = auth.endSessionURL.String()
}
http.Redirect(w, r, url, http.StatusFound)
}
func (auth *OIDCProvider) setIDTokenCookie(w http.ResponseWriter, r *http.Request, jwt string, ttl time.Duration) {
SetTokenCookie(w, r, CookieOauthToken, jwt, ttl)
}
func (auth *OIDCProvider) clearCookie(w http.ResponseWriter, r *http.Request) {
ClearTokenCookie(w, r, CookieOauthToken)
ClearTokenCookie(w, r, CookieOauthSessionToken)
}
// 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, CookieOauthToken, "test", time.Hour)
http.Redirect(w, r, "/", http.StatusFound)
}

View file

@ -1,10 +0,0 @@
package auth
import "net/http"
type Provider interface {
CheckToken(r *http.Request) error
LoginHandler(w http.ResponseWriter, r *http.Request)
PostAuthCallbackHandler(w http.ResponseWriter, r *http.Request)
LogoutHandler(w http.ResponseWriter, r *http.Request)
}

View file

@ -1,71 +0,0 @@
package auth
import (
"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 (
ErrMissingSessionToken = gperr.New("missing session token")
ErrInvalidSessionToken = gperr.New("invalid session token")
ErrUserNotAllowed = gperr.New("user not allowed")
)
func IsFrontend(r *http.Request) bool {
return r.Host == common.APIHTTPAddr
}
func requestHost(r *http.Request) string {
// check if it's from backend
if IsFrontend(r) {
return r.Header.Get("X-Forwarded-Host")
}
return r.Host
}
// cookieDomain 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" (cross subdomain)
// "example.com" -> "" (same domain only)
func cookieDomain(r *http.Request) string {
parts := strutils.SplitRune(requestHost(r), '.')
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: cookieDomain(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: cookieDomain(r),
HttpOnly: true,
Secure: common.APIJWTSecure,
SameSite: http.SameSiteLaxMode,
Path: "/",
})
}

View file

@ -10,38 +10,37 @@ import (
"github.com/go-acme/lego/v4/certcrypto"
"github.com/go-acme/lego/v4/lego"
"github.com/yusing/go-proxy/internal/gperr"
E "github.com/yusing/go-proxy/internal/error"
"github.com/yusing/go-proxy/internal/logging"
"github.com/yusing/go-proxy/internal/utils"
"github.com/yusing/go-proxy/internal/utils/strutils"
)
type Config struct {
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 map[string]any `json:"options,omitempty"`
}
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")
Options ProviderOpt `json:"options,omitempty"`
}
ProviderOpt map[string]any
)
const (
ProviderLocal = "local"
ProviderPseudo = "pseudo"
var (
ErrMissingDomain = E.New("missing field 'domains'")
ErrMissingEmail = E.New("missing field 'email'")
ErrMissingProvider = E.New("missing field 'provider'")
ErrInvalidDomain = E.New("invalid domain")
ErrUnknownProvider = E.New("unknown provider")
)
var domainOrWildcardRE = regexp.MustCompile(`^\*?([^.]+\.)+[^.]+$`)
// Validate implements the utils.CustomValidator interface.
func (cfg *Config) Validate() gperr.Error {
func (cfg *AutocertConfig) Validate() E.Error {
if cfg == nil {
return nil
}
@ -51,8 +50,8 @@ func (cfg *Config) Validate() gperr.Error {
return nil
}
b := gperr.NewBuilder("autocert errors")
if cfg.Provider != ProviderLocal && cfg.Provider != ProviderPseudo {
b := E.NewBuilder("autocert errors")
if cfg.Provider != ProviderLocal {
if len(cfg.Domains) == 0 {
b.Add(ErrMissingDomain)
}
@ -65,11 +64,11 @@ func (cfg *Config) Validate() gperr.Error {
}
}
// check if provider is implemented
providerConstructor, ok := Providers[cfg.Provider]
providerConstructor, ok := providersGenMap[cfg.Provider]
if !ok {
b.Add(ErrUnknownProvider.
Subject(cfg.Provider).
With(gperr.DoYouMean(utils.NearestField(cfg.Provider, Providers))))
Withf(strutils.DoYouMean(utils.NearestField(cfg.Provider, providersGenMap))))
} else {
_, err := providerConstructor(cfg.Options)
if err != nil {
@ -80,9 +79,13 @@ func (cfg *Config) Validate() gperr.Error {
return b.Error()
}
func (cfg *Config) GetLegoConfig() (*User, *lego.Config, gperr.Error) {
func (cfg *AutocertConfig) GetProvider() (*Provider, E.Error) {
if cfg == nil {
cfg = new(AutocertConfig)
}
if err := cfg.Validate(); err != nil {
return nil, nil, err
return nil, err
}
if cfg.CertPath == "" {
@ -98,32 +101,36 @@ func (cfg *Config) GetLegoConfig() (*User, *lego.Config, gperr.Error) {
var privKey *ecdsa.PrivateKey
var err error
if cfg.Provider != ProviderLocal && cfg.Provider != ProviderPseudo {
if privKey, err = cfg.LoadACMEKey(); err != nil {
if cfg.Provider != ProviderLocal {
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, nil, gperr.New("generate ACME private key").With(err)
return nil, E.New("generate ACME private key").With(err)
}
if err = cfg.SaveACMEKey(privKey); err != nil {
return nil, nil, gperr.New("save ACME private key").With(err)
if err = cfg.saveACMEKey(privKey); err != nil {
return nil, E.New("save ACME private key").With(err)
}
}
}
user := &User{
Email: cfg.Email,
Key: privKey,
key: privKey,
}
legoCfg := lego.NewConfig(user)
legoCfg.Certificate.KeyType = certcrypto.RSA2048
return user, legoCfg, nil
return &Provider{
cfg: cfg,
user: user,
legoCfg: legoCfg,
}, nil
}
func (cfg *Config) LoadACMEKey() (*ecdsa.PrivateKey, error) {
func (cfg *AutocertConfig) loadACMEKey() (*ecdsa.PrivateKey, error) {
data, err := os.ReadFile(cfg.ACMEKeyPath)
if err != nil {
return nil, err
@ -131,7 +138,7 @@ func (cfg *Config) LoadACMEKey() (*ecdsa.PrivateKey, error) {
return x509.ParseECPrivateKey(data)
}
func (cfg *Config) SaveACMEKey(key *ecdsa.PrivateKey) error {
func (cfg *AutocertConfig) saveACMEKey(key *ecdsa.PrivateKey) error {
data, err := x509.MarshalECPrivateKey(key)
if err != nil {
return err

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