mirror of
https://github.com/yusing/godoxy.git
synced 2025-08-11 18:17:02 +00:00
Compare commits
316 commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
fce9ce21c9 | ||
![]() |
475e697490 | ||
![]() |
68ac4f952d | ||
![]() |
a2e6688056 | ||
![]() |
e02cacdf2a | ||
![]() |
46c7ee4d84 | ||
![]() |
f39513483b | ||
![]() |
731121595c | ||
![]() |
8025af6067 | ||
![]() |
47910774dd | ||
![]() |
b6bfd19cc2 | ||
![]() |
e3b53a548d | ||
![]() |
a954ac8946 | ||
![]() |
814ff33352 | ||
![]() |
b1d5c4b091 | ||
![]() |
72dc783e23 | ||
![]() |
1c95bbba6e | ||
![]() |
0c552c9cea | ||
![]() |
5631b1540a | ||
![]() |
24f949f053 | ||
![]() |
9d712b91ff | ||
![]() |
4189ffa1db | ||
![]() |
e906b358fa | ||
![]() |
f179de9231 | ||
![]() |
1d546624de | ||
![]() |
ecc9d306d1 | ||
![]() |
5ce1c7865e | ||
![]() |
7d17a01de1 | ||
![]() |
cabb840a91 | ||
![]() |
4825f768f3 | ||
![]() |
5fdb023188 | ||
![]() |
4abf61a421 | ||
![]() |
96b7c3fcec | ||
![]() |
f8c57d930f | ||
![]() |
880d66c75e | ||
![]() |
4649c8d479 | ||
![]() |
20021b3cae | ||
![]() |
cfa9201f82 | ||
![]() |
b5328fe5e7 | ||
![]() |
25fbcc4ab9 | ||
![]() |
421aaecba4 | ||
![]() |
01773976d1 | ||
![]() |
2263d6063e | ||
![]() |
cfe0f6bb70 | ||
![]() |
a90d2b90d1 | ||
![]() |
af9629424e | ||
![]() |
ee6cf29bc1 | ||
![]() |
c4a780e061 | ||
![]() |
09c244ef3c | ||
![]() |
bd0fe36c53 | ||
![]() |
d240da4393 | ||
![]() |
9470a14fe8 | ||
![]() |
d3568d9c35 | ||
![]() |
44ef351840 | ||
![]() |
a39d527fc1 | ||
![]() |
22ab043e06 | ||
![]() |
b670cdbd49 | ||
![]() |
45e34d691a | ||
![]() |
e82480a639 | ||
![]() |
e39407886d | ||
![]() |
3135e377a9 | ||
![]() |
bdb3343a7c | ||
![]() |
b411c6d504 | ||
![]() |
966a59b5c9 | ||
![]() |
58db228e25 | ||
![]() |
e737737415 | ||
![]() |
9087c4f195 | ||
![]() |
4705989f4b | ||
![]() |
cb506120dd | ||
![]() |
88aaf956e5 | ||
![]() |
ecfd018b0b | ||
![]() |
54bf84dcba | ||
![]() |
57200bc1e9 | ||
![]() |
6f9bb410f5 | ||
![]() |
e62e667b49 | ||
![]() |
abe81541db | ||
![]() |
9e5d33714c | ||
![]() |
93a81fd558 | ||
![]() |
72923b8cfa | ||
![]() |
24ba4c2a46 | ||
![]() |
ed07bf42ce | ||
![]() |
371e756307 | ||
![]() |
32d8292b17 | ||
![]() |
717fd0e58c | ||
![]() |
2628d9e8a8 | ||
![]() |
c90795e614 | ||
![]() |
4a6bed7728 | ||
![]() |
216c03c5ff | ||
![]() |
2e9f113224 | ||
![]() |
9d58977fa6 | ||
![]() |
8469b6406c | ||
![]() |
b163771956 | ||
![]() |
c1221e61d4 | ||
![]() |
4a8bd48ad5 | ||
![]() |
ade93d49a3 | ||
![]() |
82ee75daab | ||
![]() |
f0ab14cb1e | ||
![]() |
5b7c392297 | ||
![]() |
1f1ae38e4d | ||
![]() |
22d44a6bb0 | ||
![]() |
6a5cd1266b | ||
![]() |
1cf18657b6 | ||
![]() |
63c4bdc73d | ||
![]() |
20a1649275 | ||
![]() |
0f3b8e68ce | ||
![]() |
5a3e3f19c7 | ||
![]() |
df193a42fc | ||
![]() |
f1e204f7fd | ||
![]() |
ff08c40403 | ||
![]() |
d8266f779f | ||
![]() |
9711867fbe | ||
![]() |
fc8592ab45 | ||
![]() |
3dbab118af | ||
![]() |
1f50ee7f2f | ||
![]() |
cee6eaecff | ||
![]() |
67a6b89ea5 | ||
![]() |
78be9b1c71 | ||
![]() |
26856b612a | ||
![]() |
36ceba3ae7 | ||
![]() |
f45f3fba79 | ||
![]() |
4bbff323e3 | ||
![]() |
2e68baa93e | ||
![]() |
a162371ec5 | ||
![]() |
8f9c76daa5 | ||
![]() |
8b3e058885 | ||
![]() |
023cbc81bc | ||
![]() |
b490e8c475 | ||
![]() |
8e27886235 | ||
![]() |
7435b8e485 | ||
![]() |
21724c037f | ||
![]() |
44b4cff35e | ||
![]() |
1e24765b17 | ||
![]() |
a1f2a84a16 | ||
![]() |
453262832a | ||
![]() |
99e975145c | ||
![]() |
e300170c51 | ||
![]() |
1382137f20 | ||
![]() |
54d7508f5d | ||
![]() |
71ca8c738e | ||
![]() |
f1eefde964 | ||
![]() |
84e7a6591e | ||
![]() |
30c76cfc5f | ||
![]() |
a8ba42e360 | ||
![]() |
cd291556fc | ||
![]() |
0d41809630 | ||
![]() |
53acf75c04 | ||
![]() |
cf30fe6cfc | ||
![]() |
55bbcae911 | ||
![]() |
b30c0d7dc0 | ||
![]() |
198ae2cd02 | ||
![]() |
26938eb6ed | ||
![]() |
48823a860f | ||
![]() |
985ff0a74d | ||
![]() |
43b493c60e | ||
![]() |
e0e0fab127 | ||
![]() |
fc0dbd940c | ||
![]() |
0208e6286f | ||
![]() |
2c0b68c8c2 | ||
![]() |
c05059765d | ||
![]() |
a06787593c | ||
![]() |
8fe94d6d14 | ||
![]() |
4ddfb48b9d | ||
![]() |
31dc112591 | ||
![]() |
6797897814 | ||
![]() |
99eccd0b95 | ||
![]() |
0387739b94 | ||
![]() |
ead27c72f1 | ||
![]() |
455a85e6a0 | ||
![]() |
8424fd9f1a | ||
![]() |
75ee0e63bd | ||
![]() |
1ce607029a | ||
![]() |
1e80ad2a44 | ||
![]() |
4daefa19d1 | ||
![]() |
491231e439 | ||
![]() |
c90ec8caa1 | ||
![]() |
9eb674029e | ||
![]() |
e41c6530ab | ||
![]() |
afd35c183d | ||
![]() |
f190483b4e | ||
![]() |
7b0ed09772 | ||
![]() |
4415bffc35 | ||
![]() |
ddab2766b4 | ||
![]() |
ef95682116 | ||
![]() |
dd65a8d04b | ||
![]() |
aa23b5b595 | ||
![]() |
c55c6c84bc | ||
![]() |
a45e5e17db | ||
![]() |
b8c0961de3 | ||
![]() |
62d3d200e6 | ||
![]() |
bf32cafd90 | ||
![]() |
1c182b5a7d | ||
![]() |
ad60f377ba | ||
![]() |
75db09b1f3 | ||
![]() |
6dd849f480 | ||
![]() |
e2ae29795d | ||
![]() |
92fa0f8168 | ||
![]() |
b090598b68 | ||
![]() |
2cec88d3ce | ||
![]() |
4df31263b5 | ||
![]() |
9eae809690 | ||
![]() |
f1ba554a24 | ||
![]() |
f9a8aede20 | ||
![]() |
e275ee634c | ||
![]() |
797d88772f | ||
![]() |
8ef8015a7f | ||
![]() |
5fce4b445b | ||
![]() |
7552a706a7 | ||
![]() |
e1bc6d1f44 | ||
![]() |
56850a9580 | ||
![]() |
5f780f4902 | ||
![]() |
ccb4639f43 | ||
![]() |
ac1470d81d | ||
![]() |
efaabfa63a | ||
![]() |
9043cf25c5 | ||
![]() |
98e90d7a0b | ||
![]() |
82c829de18 | ||
![]() |
2fe4fef779 | ||
![]() |
91302ceed7 | ||
![]() |
7fa7b55b18 | ||
![]() |
69ee8495d8 | ||
![]() |
28d9a72908 | ||
![]() |
770c698332 | ||
![]() |
cd4c843025 | ||
![]() |
f0cf89060b | ||
![]() |
f79a15bac6 | ||
![]() |
2b4a70a550 | ||
![]() |
f06741428c | ||
![]() |
16e6e72454 | ||
![]() |
100d2c392f | ||
![]() |
829eb08e37 | ||
![]() |
53d54a09b0 | ||
![]() |
62c551c7fe | ||
![]() |
80e59bb481 | ||
![]() |
7a5afc3612 | ||
![]() |
2c0349c11c | ||
![]() |
8e3c2cc8d4 | ||
![]() |
d35afdb3c9 | ||
![]() |
ae093ebf40 | ||
![]() |
aa8af4185b | ||
![]() |
0029cf69d6 | ||
![]() |
33e400a17e | ||
![]() |
1d22bcfed9 | ||
![]() |
978d82060e | ||
![]() |
7aa1215491 | ||
![]() |
0b69589586 | ||
![]() |
bca3cd84d1 | ||
![]() |
ce4bf2f646 | ||
![]() |
c49016f22c | ||
![]() |
8da63daf02 | ||
![]() |
c5fd21552e | ||
![]() |
27409abc24 | ||
![]() |
21c9e46274 | ||
![]() |
22a12d3116 | ||
![]() |
89d93dd878 | ||
![]() |
66853dfc52 | ||
![]() |
c72f66d64b | ||
![]() |
59bc342a40 | ||
![]() |
e11579df10 | ||
![]() |
6a8f6fb4b5 | ||
![]() |
8f20bd3840 | ||
![]() |
f1abb745fe | ||
![]() |
cb2990f6e8 | ||
![]() |
fb2f850311 | ||
![]() |
2b9c0f09ee | ||
![]() |
efe3eb4ce7 | ||
![]() |
a1c1a79976 | ||
![]() |
90ba355d16 | ||
![]() |
01179adfa8 | ||
![]() |
e4be403bef | ||
![]() |
e1cdf4da0f | ||
![]() |
5148cb3b8b | ||
![]() |
56c6a9f8fe | ||
![]() |
be257b0532 | ||
![]() |
0534bc38b2 | ||
![]() |
604e2481a6 | ||
![]() |
4f557043a5 | ||
![]() |
03d609e4e1 | ||
![]() |
db6fc65876 | ||
![]() |
c6a05f7b35 | ||
![]() |
9e4aa32120 | ||
![]() |
759995972d | ||
![]() |
03401488f6 | ||
![]() |
1e790be70c | ||
![]() |
4410637f8b | ||
![]() |
3947152336 | ||
![]() |
af8d2c74f6 | ||
![]() |
e107f8d476 | ||
![]() |
b427ff1f88 | ||
![]() |
e513db62b0 | ||
![]() |
2f33ee02d9 | ||
![]() |
59490dcac0 | ||
![]() |
5afa93a8f1 | ||
![]() |
c8e9ed8440 | ||
![]() |
8363dfe257 | ||
![]() |
080bbc18eb | ||
![]() |
1a0edc8bfe | ||
![]() |
e8d1d524b9 | ||
![]() |
edada22ac0 | ||
![]() |
76fb0cfdbb | ||
![]() |
5df2553774 | ||
![]() |
31812430f1 | ||
![]() |
d668b03175 | ||
![]() |
663a107c06 | ||
![]() |
806184e98b | ||
![]() |
08ee82d7b0 | ||
![]() |
bcc19167d4 | ||
![]() |
858f65ee5a | ||
![]() |
43566bbcfd | ||
![]() |
ec8cca1245 | ||
![]() |
4a65de99a8 | ||
![]() |
7461344004 | ||
![]() |
b815c6fd69 | ||
![]() |
28c9a2e9d0 | ||
![]() |
9e0bdd964c | ||
![]() |
077641beaa | ||
![]() |
ef483403da |
489 changed files with 27627 additions and 11218 deletions
.env.example
.github/workflows
agent-binary.ymldocker-image-nightly.ymldocker-image-prod.ymldocker-image-socket-proxy.ymldocker-image.yml
.gitignore.golangci.yml.trunk
.vscode
DockerfileLICENSEMakefileREADME.mdREADME_CHT.mdagent
cmd
compose.example.ymlconfig.example.ymlgo.modgo.suminternal
acl
api
handler.go
v1
auth
auth.goblock_page.goblock_page.htmloauth_refresh.gooidc.gooidc_test.goprovider.gouserpass.gouserpass_test.goutils.go
autocert
common
config
46
.env.example
46
.env.example
|
@ -1,24 +1,35 @@
|
|||
# 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
|
||||
|
||||
# Set GODOXY_API_JWT_SECURE=false to allow http
|
||||
GODOXY_API_JWT_SECURE=true
|
||||
# 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
|
||||
# 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
|
||||
# GODOXY_OIDC_SCOPES=openid, profile, email, groups # you may also include `offline_access` if your Idp supports it (e.g. Authentik, Pocket ID)
|
||||
#
|
||||
# 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:
|
||||
|
@ -39,14 +50,29 @@ GODOXY_API_JWT_TOKEN_TTL=1h
|
|||
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
|
||||
|
||||
# Prometheus Metrics
|
||||
GODOXY_PROMETHEUS_ENABLED=true
|
||||
# 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
|
||||
|
||||
# Debug mode
|
||||
GODOXY_DEBUG=false
|
3
.github/workflows/agent-binary.yml
vendored
3
.github/workflows/agent-binary.yml
vendored
|
@ -36,9 +36,6 @@ jobs:
|
|||
- name: Check binary
|
||||
run: |
|
||||
file bin/${{ matrix.binary_name }}
|
||||
- name: Test
|
||||
run: |
|
||||
go test -v ./agent/...
|
||||
- name: Upload
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
|
|
3
.github/workflows/docker-image-nightly.yml
vendored
3
.github/workflows/docker-image-nightly.yml
vendored
|
@ -15,9 +15,10 @@ jobs:
|
|||
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
|
||||
agent: true
|
||||
target: agent
|
||||
|
|
3
.github/workflows/docker-image-prod.yml
vendored
3
.github/workflows/docker-image-prod.yml
vendored
|
@ -12,9 +12,10 @@ jobs:
|
|||
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
|
||||
agent: true
|
||||
target: agent
|
||||
|
|
23
.github/workflows/docker-image-socket-proxy.yml
vendored
Normal file
23
.github/workflows/docker-image-socket-proxy.yml
vendored
Normal file
|
@ -0,0 +1,23 @@
|
|||
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
|
23
.github/workflows/docker-image.yml
vendored
23
.github/workflows/docker-image.yml
vendored
|
@ -12,16 +12,20 @@ on:
|
|||
old_image_name:
|
||||
required: false
|
||||
type: string
|
||||
agent:
|
||||
target:
|
||||
required: true
|
||||
type: string
|
||||
dockerfile:
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
type: string
|
||||
default: Dockerfile
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
MAKE_ARGS: agent=${{ inputs.agent && '1' || '0' }}
|
||||
DIGEST_PATH: /tmp/digests/${{ inputs.agent && 'agent' || 'main' }}
|
||||
DIGEST_NAME_SUFFIX: ${{ inputs.agent && 'agent' || 'main' }}
|
||||
MAKE_ARGS: ${{ inputs.target }}=1
|
||||
DIGEST_PATH: /tmp/digests/${{ inputs.target }}
|
||||
DIGEST_NAME_SUFFIX: ${{ inputs.target }}
|
||||
DOCKERFILE: ${{ inputs.dockerfile }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
@ -76,11 +80,14 @@ 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 }}-${{ inputs.tag }}
|
||||
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 }}-${{ inputs.tag }},mode=max
|
||||
type=registry,ref=${{ env.REGISTRY }}/${{ inputs.image_name }}:buildcache-${{ env.PLATFORM_PAIR }},mode=max
|
||||
# type=gha,scope=${{ github.workflow }}-${{ env.PLATFORM_PAIR }},mode=max
|
||||
build-args: |
|
||||
VERSION=${{ github.ref_name }}
|
||||
MAKE_ARGS=${{ env.MAKE_ARGS }}
|
||||
|
|
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -30,6 +30,7 @@ todo.md
|
|||
mtrace.json
|
||||
.env
|
||||
.cursorrules
|
||||
.cursor/
|
||||
.windsurfrules
|
||||
test.Dockerfile
|
||||
|
||||
|
|
202
.golangci.yml
202
.golangci.yml
|
@ -1,43 +1,75 @@
|
|||
run:
|
||||
timeout: 10m
|
||||
|
||||
linters-settings:
|
||||
govet:
|
||||
enable-all: true
|
||||
version: "2"
|
||||
linters:
|
||||
default: all
|
||||
disable:
|
||||
- shadow
|
||||
- fieldalignment
|
||||
gocyclo:
|
||||
min-complexity: 14
|
||||
misspell:
|
||||
locale: US
|
||||
# - bodyclose
|
||||
- containedctx
|
||||
# - contextcheck
|
||||
- cyclop
|
||||
- depguard
|
||||
# - dupl
|
||||
- err113
|
||||
- exhaustive
|
||||
- exhaustruct
|
||||
- funcorder
|
||||
- forcetypeassert
|
||||
- gochecknoglobals
|
||||
- gochecknoinits
|
||||
- gocognit
|
||||
- goconst
|
||||
- gocyclo
|
||||
- gomoddirectives
|
||||
- gosmopolitan
|
||||
- ireturn
|
||||
- lll
|
||||
- maintidx
|
||||
- makezero
|
||||
- mnd
|
||||
- nakedret
|
||||
- nestif
|
||||
- nlreturn
|
||||
- nonamedreturns
|
||||
- paralleltest
|
||||
- revive
|
||||
- rowserrcheck
|
||||
- sqlclosecheck
|
||||
- tagliatelle
|
||||
- testpackage
|
||||
- tparallel
|
||||
- varnamelen
|
||||
- wrapcheck
|
||||
- wsl
|
||||
settings:
|
||||
errcheck:
|
||||
exclude-functions:
|
||||
- fmt.Fprintln
|
||||
forbidigo:
|
||||
forbid:
|
||||
- pattern: ^print(ln)?$
|
||||
funlen:
|
||||
lines: -1
|
||||
statements: 120
|
||||
forbidigo:
|
||||
forbid:
|
||||
- ^print(ln)?$
|
||||
gocyclo:
|
||||
min-complexity: 14
|
||||
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
|
||||
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
|
||||
govet:
|
||||
disable:
|
||||
- shadow
|
||||
- fieldalignment
|
||||
enable-all: true
|
||||
misspell:
|
||||
locale: US
|
||||
revive:
|
||||
rules:
|
||||
- name: struct-tag
|
||||
|
@ -67,69 +99,51 @@ linters-settings:
|
|||
disabled: true
|
||||
- name: unreachable-code
|
||||
- name: redefines-builtin-id
|
||||
gomoddirectives:
|
||||
replace-allow-list:
|
||||
- github.com/abbot/go-http-auth
|
||||
- github.com/gorilla/mux
|
||||
- github.com/mailgun/minheap
|
||||
- github.com/mailgun/multibuf
|
||||
- github.com/jaguilar/vt100
|
||||
- github.com/cucumber/godog
|
||||
- github.com/http-wasm/http-wasm-host-go
|
||||
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
|
||||
testifylint:
|
||||
disable:
|
||||
- suite-dont-use-pkg
|
||||
- require-error
|
||||
- go-require
|
||||
staticcheck:
|
||||
checks:
|
||||
- all
|
||||
- -SA1019
|
||||
errcheck:
|
||||
exclude-functions:
|
||||
- fmt.Fprintln
|
||||
linters:
|
||||
enable-all: true
|
||||
disable:
|
||||
- execinquery # deprecated
|
||||
- gomnd # deprecated
|
||||
- sqlclosecheck # not relevant (SQL)
|
||||
- rowserrcheck # not relevant (SQL)
|
||||
- cyclop # duplicate of gocyclo
|
||||
- depguard # Not relevant
|
||||
- nakedret # Too strict
|
||||
- lll # Not relevant
|
||||
- gocyclo # must be fixed
|
||||
- gocognit # Too strict
|
||||
- nestif # Too many false-positive.
|
||||
- prealloc # Too many false-positive.
|
||||
- makezero # Not relevant
|
||||
- dupl # Too strict
|
||||
- gci # I don't care
|
||||
- goconst # Too annoying
|
||||
- gosec # Too strict
|
||||
- gochecknoinits
|
||||
- gochecknoglobals
|
||||
- wsl # Too strict
|
||||
- nlreturn # Not relevant
|
||||
- mnd # Too strict
|
||||
- testpackage # Too strict
|
||||
- tparallel # Not relevant
|
||||
- paralleltest # Not relevant
|
||||
- exhaustive # Not relevant
|
||||
- exhaustruct # Not relevant
|
||||
- err113 # Too strict
|
||||
- wrapcheck # Too strict
|
||||
- noctx # Too strict
|
||||
- bodyclose # too many false-positive
|
||||
- forcetypeassert # Too strict
|
||||
- tagliatelle # Too strict
|
||||
- varnamelen # Not relevant
|
||||
- nilnil # Not relevant
|
||||
- ireturn # Not relevant
|
||||
- contextcheck # too many false-positive
|
||||
- containedctx # too many false-positive
|
||||
- maintidx # kind of duplicate of gocyclo
|
||||
- nonamedreturns # Too strict
|
||||
- gosmopolitan # not relevant
|
||||
- exportloopref # Not relevant since go1.22
|
||||
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$
|
||||
|
|
|
@ -2,36 +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.10
|
||||
version: 1.22.15
|
||||
# Trunk provides extensibility via plugins. (https://docs.trunk.io/plugins)
|
||||
plugins:
|
||||
sources:
|
||||
- id: trunk
|
||||
ref: v1.6.7
|
||||
ref: v1.6.8
|
||||
uri: https://github.com/trunk-io/plugins
|
||||
# Many linters and tools depend on runtimes - configure them here. (https://docs.trunk.io/runtimes)
|
||||
runtimes:
|
||||
enabled:
|
||||
- node@18.20.5
|
||||
- python@3.10.8
|
||||
- go@1.23.2
|
||||
- go@1.24.3
|
||||
# This is the section where you manage your linters. (https://docs.trunk.io/check/configuration)
|
||||
lint:
|
||||
disabled:
|
||||
- markdownlint
|
||||
- yamllint
|
||||
enabled:
|
||||
- checkov@3.2.432
|
||||
- golangci-lint2@2.1.6
|
||||
- hadolint@2.12.1-beta
|
||||
- actionlint@1.7.7
|
||||
- git-diff-check
|
||||
- gofmt@1.20.4
|
||||
- golangci-lint@1.64.5
|
||||
- osv-scanner@1.9.2
|
||||
- oxipng@9.1.4
|
||||
- prettier@3.5.1
|
||||
- osv-scanner@2.0.2
|
||||
- oxipng@9.1.5
|
||||
- prettier@3.5.3
|
||||
- shellcheck@0.10.0
|
||||
- shfmt@3.6.0
|
||||
- trufflehog@3.88.9
|
||||
- trufflehog@3.88.33
|
||||
actions:
|
||||
disabled:
|
||||
- trunk-announce
|
||||
|
|
4
.vscode/settings.example.json
vendored
4
.vscode/settings.example.json
vendored
|
@ -1,10 +1,10 @@
|
|||
{
|
||||
"yaml.schemas": {
|
||||
"https://github.com/yusing/go-proxy/raw/main/schemas/config.schema.json": [
|
||||
"https://github.com/yusing/godoxy-webui/raw/refs/heads/main/src/types/godoxy/config.schema.json": [
|
||||
"config.example.yml",
|
||||
"config.yml"
|
||||
],
|
||||
"https://github.com/yusing/go-proxy/raw/main/schemas/routes.schema.json": [
|
||||
"https://github.com/yusing/godoxy-webui/raw/refs/heads/main/src/types/godoxy/routes.schema.json": [
|
||||
"providers.example.yml"
|
||||
]
|
||||
}
|
||||
|
|
26
Dockerfile
26
Dockerfile
|
@ -1,29 +1,33 @@
|
|||
# Stage 1: deps
|
||||
FROM golang:1.24.2-alpine AS deps
|
||||
FROM golang:1.24.5-alpine AS deps
|
||||
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
|
||||
|
||||
# Only copy go.mod and go.sum initially for better caching
|
||||
COPY go.mod go.sum /src/
|
||||
COPY go.mod go.sum ./
|
||||
|
||||
ENV GOPATH=/root/go
|
||||
RUN go mod download -x
|
||||
# remove godoxy stuff from go.mod first
|
||||
RUN sed -i '/^module github\.com\/yusing\/go-proxy/!{/github\.com\/yusing\/go-proxy/d}' go.mod && \
|
||||
go mod download -x
|
||||
|
||||
# Stage 2: builder
|
||||
FROM deps AS builder
|
||||
|
||||
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
|
||||
|
||||
ARG VERSION
|
||||
ENV VERSION=${VERSION}
|
||||
|
@ -33,9 +37,10 @@ ENV MAKE_ARGS=${MAKE_ARGS}
|
|||
|
||||
ENV GOCACHE=/root/.cache/go-build
|
||||
ENV GOPATH=/root/go
|
||||
RUN make ${MAKE_ARGS} build link-binary && \
|
||||
mv bin /app/ && \
|
||||
mkdir -p /app/error_pages /app/certs
|
||||
|
||||
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
--mount=type=cache,target=/root/go/pkg/mod \
|
||||
make ${MAKE_ARGS} docker=1 build
|
||||
|
||||
# Stage 3: Final image
|
||||
FROM scratch
|
||||
|
@ -47,10 +52,7 @@ LABEL proxy.exclude=1
|
|||
COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo
|
||||
|
||||
# copy binary
|
||||
COPY --from=builder /app /app
|
||||
|
||||
# copy example config
|
||||
COPY config.example.yml /app/config/config.yml
|
||||
COPY --from=builder /app/run /app/run
|
||||
|
||||
# copy certs
|
||||
COPY --from=builder /etc/ssl/certs /etc/ssl/certs
|
||||
|
|
26
LICENSE
26
LICENSE
|
@ -1,6 +1,6 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2024 [fullname]
|
||||
Copyright (c) 2024 - present Yusing
|
||||
|
||||
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,3 +19,27 @@ 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.
|
||||
|
|
129
Makefile
129
Makefile
|
@ -1,3 +1,4 @@
|
|||
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
|
||||
|
@ -7,10 +8,13 @@ LDFLAGS = -X github.com/yusing/go-proxy/pkg.version=${VERSION}
|
|||
|
||||
ifeq ($(agent), 1)
|
||||
NAME = godoxy-agent
|
||||
CMD_PATH = ./agent/cmd
|
||||
PWD = ${shell pwd}/agent
|
||||
else ifeq ($(socket-proxy), 1)
|
||||
NAME = godoxy-socket-proxy
|
||||
PWD = ${shell pwd}/socket-proxy
|
||||
else
|
||||
NAME = godoxy
|
||||
CMD_PATH = ./cmd
|
||||
PWD = ${shell pwd}
|
||||
endif
|
||||
|
||||
ifeq ($(trace), 1)
|
||||
|
@ -25,26 +29,24 @@ ifeq ($(race), 1)
|
|||
endif
|
||||
|
||||
ifeq ($(debug), 1)
|
||||
CGO_ENABLED = 0
|
||||
CGO_ENABLED = 1
|
||||
GODOXY_DEBUG = 1
|
||||
BUILD_FLAGS += -gcflags=all='-N -l'
|
||||
endif
|
||||
|
||||
ifeq ($(pprof), 1)
|
||||
BUILD_FLAGS += -gcflags=all='-N -l' -tags debug -asan
|
||||
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
|
||||
BUILD_FLAGS += -tags pprof
|
||||
VERSION := ${VERSION}-pprof
|
||||
else
|
||||
CGO_ENABLED = 0
|
||||
LDFLAGS += -s -w
|
||||
BUILD_FLAGS = -pgo=auto -tags production
|
||||
BUILD_FLAGS += -pgo=auto -tags production
|
||||
endif
|
||||
|
||||
BUILD_FLAGS += -ldflags='$(LDFLAGS)'
|
||||
BIN_PATH := $(shell pwd)/bin/${NAME}
|
||||
|
||||
export NAME
|
||||
export CMD_PATH
|
||||
export CGO_ENABLED
|
||||
export GODOXY_DEBUG
|
||||
export GODOXY_TRACE
|
||||
|
@ -52,25 +54,69 @@ 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
|
||||
|
||||
test:
|
||||
GODOXY_TEST=1 go test ./internal/...
|
||||
|
||||
get:
|
||||
go get -u ./cmd && go mod tidy
|
||||
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
|
||||
|
||||
build:
|
||||
mkdir -p bin
|
||||
go build ${BUILD_FLAGS} -o bin/${NAME} ${CMD_PATH}
|
||||
if [ $(shell id -u) -eq 0 ]; \
|
||||
then setcap CAP_NET_BIND_SERVICE=+eip bin/${NAME}; \
|
||||
else sudo setcap CAP_NET_BIND_SERVICE=+eip bin/${NAME}; \
|
||||
fi
|
||||
mkdir -p $(shell dirname ${BIN_PATH})
|
||||
cd ${PWD} && go build ${BUILD_FLAGS} -o ${BIN_PATH} ./cmd
|
||||
${POST_BUILD}
|
||||
|
||||
run:
|
||||
[ -f .env ] && godotenv -f .env go run ${BUILD_FLAGS} ${CMD_PATH}
|
||||
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'
|
||||
|
||||
mtrace:
|
||||
bin/godoxy debug-ls-mtrace > mtrace.json
|
||||
${BIN_PATH} debug-ls-mtrace > mtrace.json
|
||||
|
||||
rapid-crash:
|
||||
docker run --restart=always --name test_crash -p 80 debian:bookworm-slim /bin/cat &&\
|
||||
|
@ -85,48 +131,7 @@ ci-test:
|
|||
act -n --artifact-server-path /tmp/artifacts -s GITHUB_TOKEN="$$(gh auth token)"
|
||||
|
||||
cloc:
|
||||
cloc --not-match-f '_test.go$$' cmd internal pkg
|
||||
|
||||
link-binary:
|
||||
ln -s /app/${NAME} bin/run
|
||||
|
||||
# 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:
|
||||
cd schemas && 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
|
||||
cd ..
|
||||
|
||||
publish-schema:
|
||||
cd schemas && bun publish && cd ..
|
||||
|
||||
update-schema-generator:
|
||||
pnpm up -g typescript-json-schema
|
||||
cloc --include-lang=Go --not-match-f '_test.go$$' .
|
||||
|
||||
push-github:
|
||||
git push origin $(shell git rev-parse --abbrev-ref HEAD)
|
110
README.md
110
README.md
|
@ -2,20 +2,21 @@
|
|||
|
||||
# GoDoxy
|
||||
|
||||
[](https://sonarcloud.io/summary/new_code?id=yusing_godoxy)
|
||||
[](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy)
|
||||

|
||||
[](https://sonarcloud.io/summary/new_code?id=yusing_godoxy)
|
||||
[](https://sonarcloud.io/summary/new_code?id=go-proxy)
|
||||

|
||||
[](https://discord.gg/umReR62nRd)
|
||||
|
||||
A lightweight, simple, and [performant](https://github.com/yusing/godoxy/wiki/Benchmarks) reverse proxy with WebUI.
|
||||
A lightweight, simple, and performant reverse proxy with WebUI.
|
||||
|
||||
For full documentation, check out **[Wiki](https://github.com/yusing/godoxy/wiki)**
|
||||
<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>
|
||||
|
||||
**EN** | <a href="README_CHT.md">中文</a>
|
||||
<h5>EN | <a href="README_CHT.md">中文</a></h5>
|
||||
|
||||
<!-- [](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy)
|
||||
[](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy)
|
||||
[](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy) -->
|
||||
Have questions? Ask [ChatGPT](https://chatgpt.com/g/g-6825390374b481919ad482f2e48936a1-godoxy-assistant)! (Thanks to [@ismesid](https://github.com/arevindh))
|
||||
|
||||
<img src="screenshots/webui.jpg" style="max-width: 650">
|
||||
|
||||
|
@ -27,10 +28,11 @@ For full documentation, check out **[Wiki](https://github.com/yusing/godoxy/wiki
|
|||
|
||||
- [GoDoxy](#godoxy)
|
||||
- [Table of content](#table-of-content)
|
||||
- [Running demo](#running-demo)
|
||||
- [Key Features](#key-features)
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [How does GoDoxy work](#how-does-godoxy-work)
|
||||
- [Setup](#setup)
|
||||
- [How does GoDoxy work](#how-does-godoxy-work)
|
||||
- [Screenshots](#screenshots)
|
||||
- [idlesleeper](#idlesleeper)
|
||||
- [Metrics and Logs](#metrics-and-logs)
|
||||
|
@ -38,46 +40,60 @@ For full documentation, check out **[Wiki](https://github.com/yusing/godoxy/wiki
|
|||
- [Folder structrue](#folder-structrue)
|
||||
- [Build it yourself](#build-it-yourself)
|
||||
|
||||
## Running demo
|
||||
|
||||
<https://demo.godoxy.dev>
|
||||
|
||||
[](https://zeabur.com/referral?referralCode=yusing&utm_source=yusing&utm_campaign=oss)
|
||||
|
||||
## Key Features
|
||||
|
||||
- Easy to use
|
||||
- Effortless configuration
|
||||
- Simple multi-node setup with GoDoxy agents or Docker Socket Proxies
|
||||
- Error messages is clear and detailed, easy troubleshooting
|
||||
- Auto SSL with Let's Encrypt (See [Supported DNS-01 Challenge Providers](https://github.com/yusing/go-proxy/wiki/Supported-DNS%E2%80%9001-Providers))
|
||||
- Auto hot-reload on container state / config file changes
|
||||
- Container aware: create routes dynamically from running docker containers
|
||||
- **idlesleeper**: stop and wake containers based on traffic _(optional, see [screenshots](#idlesleeper))_
|
||||
- HTTP reserve proxy and TCP/UDP port forwarding
|
||||
- OpenID Connect integration: SSO and secure your apps easily
|
||||
- [HTTP middleware](https://github.com/yusing/go-proxy/wiki/Middlewares) and [Custom error pages support](https://github.com/yusing/go-proxy/wiki/Middlewares#custom-error-pages)
|
||||
- **Web UI with App dashboard, config editor, _uptime and system metrics_, _docker logs viewer_**
|
||||
- Supports linux/amd64 and linux/arm64
|
||||
- **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)**
|
||||
|
||||
[🔼Back to top](#table-of-content)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Setup Wildcard DNS Record(s) for machine running `GoDoxy`, e.g.
|
||||
Configure Wildcard DNS Record(s) to point to machine running `GoDoxy`, e.g.
|
||||
|
||||
- A Record: `*.domain.com` -> `10.0.10.1`
|
||||
- AAAA Record (if you use IPv6): `*.domain.com` -> `::ffff:a00:a01`
|
||||
|
||||
## How does GoDoxy work
|
||||
|
||||
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
|
||||
|
||||
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`.
|
||||
|
||||
## Setup
|
||||
|
||||
**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`.
|
||||
> [!NOTE]
|
||||
> GoDoxy is designed to be 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.
|
||||
|
||||
|
@ -87,11 +103,25 @@ For example, with the label `proxy.aliases: qbt` you can access your app via `qb
|
|||
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/yusing/godoxy/main/scripts/setup.sh)"
|
||||
```
|
||||
|
||||
3. Start the container `docker compose up -d` and wait for it to be ready
|
||||
3. Start the docker compose service from generated `compose.yml`:
|
||||
|
||||
```shell
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
4. You may now do some extra configuration on WebUI `https://godoxy.yourdomain.com`
|
||||
|
||||
[🔼Back to top](#table-of-content)
|
||||
## How does GoDoxy work
|
||||
|
||||
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
|
||||
|
||||
> [!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`.
|
||||
|
||||
## Screenshots
|
||||
|
||||
|
@ -124,8 +154,6 @@ For example, with the label `proxy.aliases: qbt` you can access your app via `qb
|
|||
</table>
|
||||
</div>
|
||||
|
||||
[🔼Back to top](#table-of-content)
|
||||
|
||||
## Manual Setup
|
||||
|
||||
1. Make `config` directory then grab `config.example.yml` into `config/config.yml`
|
||||
|
|
|
@ -5,17 +5,18 @@
|
|||
[](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy)
|
||||

|
||||
[](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy)
|
||||
[](https://discord.gg/umReR62nRd)
|
||||

|
||||
[](https://discord.gg/umReR62nRd)
|
||||
|
||||
輕量、易用、 [高效能](https://github.com/yusing/godoxy/wiki/Benchmarks),且帶有主頁和配置面板的反向代理
|
||||
輕量、易用、 高效能,且帶有主頁和配置面板的反向代理
|
||||
|
||||
完整文檔請查閱 **[Wiki](https://github.com/yusing/godoxy/wiki)**(暫未有中文翻譯)
|
||||
<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>
|
||||
|
||||
<!-- [](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy)
|
||||
[](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy)
|
||||
[](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy) -->
|
||||
<h5><a href="README.md">EN</a> | 中文</h5>
|
||||
|
||||
<a href="README.md">EN</a> | **中文**
|
||||
有疑問? 問 [ChatGPT](https://chatgpt.com/g/g-6825390374b481919ad482f2e48936a1-godoxy-assistant)!(鳴謝 [@ismesid](https://github.com/arevindh))
|
||||
|
||||
<img src="https://github.com/user-attachments/assets/4bb371f4-6e4c-425c-89b2-b9e962bdd46f" style="max-width: 650">
|
||||
|
||||
|
@ -27,6 +28,7 @@
|
|||
|
||||
- [GoDoxy](#godoxy)
|
||||
- [目錄](#目錄)
|
||||
- [運行示例](#運行示例)
|
||||
- [主要特點](#主要特點)
|
||||
- [前置需求](#前置需求)
|
||||
- [安裝](#安裝)
|
||||
|
@ -37,22 +39,46 @@
|
|||
- [監控](#監控)
|
||||
- [自行編譯](#自行編譯)
|
||||
|
||||
## 運行示例
|
||||
|
||||
<https://demo.godoxy.dev>
|
||||
|
||||
[](https://zeabur.com/referral?referralCode=yusing&utm_source=yusing&utm_campaign=oss)
|
||||
|
||||
## 主要特點
|
||||
|
||||
- 容易使用
|
||||
- 輕鬆配置
|
||||
- 簡單的多節點設置
|
||||
- 錯誤訊息清晰詳細,易於排除故障
|
||||
- 自動 SSL 憑證管理(參見 [支援的 DNS-01 驗證提供商](https://github.com/yusing/godoxy/wiki/Supported-DNS%E2%80%9001-Providers))
|
||||
- 自動配置 Docker 容器
|
||||
- 容器狀態/配置文件變更時自動熱重載
|
||||
- **閒置休眠**:在閒置時停止容器,有流量時喚醒(_可選,參見[截圖](#閒置休眠)_)
|
||||
- OpenID Connect:輕鬆實現單點登入
|
||||
- HTTP(s) 反向代理和TCP 和 UDP 埠轉發
|
||||
- [HTTP 中介軟體](https://github.com/yusing/godoxy/wiki/Middlewares) 和 [自定義錯誤頁面](https://github.com/yusing/godoxy/wiki/Middlewares#custom-error-pages)
|
||||
- **網頁介面,具有應用儀表板和配置編輯器**
|
||||
- 支援 linux/amd64、linux/arm64
|
||||
- 使用 **[Go](https://go.dev)** 編寫
|
||||
- **簡單易用**
|
||||
- 透過 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)** 語言編寫
|
||||
|
||||
[🔼 回到頂部](#目錄)
|
||||
|
||||
|
@ -65,7 +91,10 @@
|
|||
|
||||
## 安裝
|
||||
|
||||
**注意:** GoDoxy 設計為(且僅在)`host` 網路模式下運作,請勿更改。如需更改監聽埠,請修改 `.env`。
|
||||
> [!NOTE]
|
||||
> GoDoxy 僅在 `host` 網路模式下運作,請勿更改。
|
||||
>
|
||||
> 如需更改監聽埠,請修改 `.env`。
|
||||
|
||||
1. 準備一個新目錄用於 docker compose 和配置文件。
|
||||
|
||||
|
@ -75,9 +104,7 @@
|
|||
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/yusing/godoxy/main/scripts/setup.sh)"
|
||||
```
|
||||
|
||||
3. 啟動容器 `docker compose up -d` 並等待就緒
|
||||
|
||||
4. 現在可以在 WebUI `https://godoxy.yourdomain.com` 進行額外配置
|
||||
3. 現在可以在 WebUI `https://godoxy.yourdomain.com` 進行額外配置
|
||||
|
||||
[🔼 回到頂部](#目錄)
|
||||
|
||||
|
|
|
@ -3,20 +3,26 @@ package main
|
|||
import (
|
||||
"os"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/yusing/go-proxy/agent/pkg/agent"
|
||||
"github.com/yusing/go-proxy/agent/pkg/env"
|
||||
"github.com/yusing/go-proxy/agent/pkg/server"
|
||||
"github.com/yusing/go-proxy/internal/gperr"
|
||||
"github.com/yusing/go-proxy/internal/logging"
|
||||
"github.com/yusing/go-proxy/internal/logging/memlogger"
|
||||
"github.com/yusing/go-proxy/internal/metrics/systeminfo"
|
||||
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() {
|
||||
logging.InitLogger(os.Stderr, memlogger.GetMemLogger())
|
||||
|
||||
writer := zerolog.ConsoleWriter{
|
||||
Out: os.Stderr,
|
||||
TimeFormat: "01-02 15:04",
|
||||
}
|
||||
zerolog.TimeFieldFormat = writer.TimeFormat
|
||||
log.Logger = zerolog.New(writer).Level(zerolog.InfoLevel).With().Timestamp().Logger()
|
||||
ca := &agent.PEMPair{}
|
||||
err := ca.Load(env.AgentCACert)
|
||||
if err != nil {
|
||||
|
@ -37,11 +43,11 @@ func main() {
|
|||
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)
|
||||
log.Info().Msgf("GoDoxy Agent version %s", pkg.GetVersion())
|
||||
log.Info().Msgf("Agent name: %s", env.AgentName)
|
||||
log.Info().Msgf("Agent port: %d", env.AgentPort)
|
||||
|
||||
logging.Info().Msg(`
|
||||
log.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.
|
||||
|
@ -55,6 +61,17 @@ Tips:
|
|||
}
|
||||
|
||||
server.StartAgentServer(t, opts)
|
||||
|
||||
if socketproxy.ListenAddr != "" {
|
||||
log.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)
|
||||
|
|
87
agent/go.mod
Normal file
87
agent/go.mod
Normal file
|
@ -0,0 +1,87 @@
|
|||
module github.com/yusing/go-proxy/agent
|
||||
|
||||
go 1.24.5
|
||||
|
||||
replace github.com/yusing/go-proxy => ..
|
||||
|
||||
replace github.com/yusing/go-proxy/socketproxy => ../socket-proxy
|
||||
|
||||
replace github.com/yusing/go-proxy/internal/utils => ../internal/utils
|
||||
|
||||
replace github.com/docker/docker => github.com/godoxy-app/docker v0.0.0-20250523125835-a2474a6ebe30
|
||||
|
||||
replace github.com/shirou/gopsutil/v4 => github.com/godoxy-app/gopsutil/v4 v4.0.0-20250607110153-34d627ba1b5d
|
||||
|
||||
require (
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/pion/dtls/v3 v3.0.6
|
||||
github.com/puzpuzpuz/xsync/v4 v4.1.0
|
||||
github.com/rs/zerolog v1.34.0
|
||||
github.com/stretchr/testify v1.10.0
|
||||
github.com/yusing/go-proxy v0.16.1
|
||||
github.com/yusing/go-proxy/internal/utils v0.0.0
|
||||
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/containerd/errdefs v1.0.0 // indirect
|
||||
github.com/containerd/errdefs/pkg v0.3.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/distribution/reference v0.6.0 // indirect
|
||||
github.com/docker/cli v28.3.2+incompatible // indirect
|
||||
github.com/docker/docker v28.3.2+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.4 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.9 // 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.27.0 // indirect
|
||||
github.com/goccy/go-yaml v1.18.0 // indirect
|
||||
github.com/gogo/protobuf v1.3.2 // indirect
|
||||
github.com/gorilla/mux v1.8.1 // indirect
|
||||
github.com/gotify/server/v2 v2.6.3 // indirect
|
||||
github.com/kr/pretty v0.1.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/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/moby/docker-image-spec v1.3.1 // 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/pion/logging v0.2.4 // indirect
|
||||
github.com/pion/transport/v3 v3.0.7 // 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/quic-go/qpack v0.5.1 // indirect
|
||||
github.com/quic-go/quic-go v0.54.0 // indirect
|
||||
github.com/samber/lo v1.51.0 // indirect
|
||||
github.com/samber/slog-common v0.19.0 // indirect
|
||||
github.com/samber/slog-zerolog/v2 v2.7.3 // indirect
|
||||
github.com/shirou/gopsutil/v4 v4.25.6 // 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/mock v0.5.2 // indirect
|
||||
golang.org/x/crypto v0.40.0 // indirect
|
||||
golang.org/x/mod v0.26.0 // indirect
|
||||
golang.org/x/net v0.42.0 // indirect
|
||||
golang.org/x/sync v0.16.0 // indirect
|
||||
golang.org/x/sys v0.34.0 // indirect
|
||||
golang.org/x/text v0.27.0 // indirect
|
||||
golang.org/x/time v0.12.0 // indirect
|
||||
golang.org/x/tools v0.35.0 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
292
agent/go.sum
Normal file
292
agent/go.sum
Normal file
|
@ -0,0 +1,292 @@
|
|||
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/cenkalti/backoff/v5 v5.0.2 h1:rIfFVxEf1QsI7E1ZHfp/B4DF/6QBAUhmgkxc0H7Zss8=
|
||||
github.com/cenkalti/backoff/v5 v5.0.2/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
|
||||
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
|
||||
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
|
||||
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
|
||||
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/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||
github.com/docker/cli v28.3.2+incompatible h1:mOt9fcLE7zaACbxW1GeS65RI67wIJrTnqS3hP2huFsY=
|
||||
github.com/docker/cli v28.3.2+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.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw=
|
||||
github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
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-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/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.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
|
||||
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
||||
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
||||
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/godoxy-app/docker v0.0.0-20250523125835-a2474a6ebe30 h1:+5pYG8clUrZbUDP+x149jkRfYAGaNpAXOwut0jluoYA=
|
||||
github.com/godoxy-app/docker v0.0.0-20250523125835-a2474a6ebe30/go.mod h1:7VkicOZ3VrlxOe/EP/8uwsWLGKI2wt2MV7CgxTDIYgA=
|
||||
github.com/godoxy-app/gopsutil/v4 v4.0.0-20250607110153-34d627ba1b5d h1:kih+Q38BQKBsbQmv7mZb7OP3YuSPN78ENTZUxCu4T6M=
|
||||
github.com/godoxy-app/gopsutil/v4 v4.0.0-20250607110153-34d627ba1b5d/go.mod h1:2nclxpbWQUvbTR33HI8Z/RXUG4SaF67X/pMaI/fUMa8=
|
||||
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/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.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI=
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
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/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/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/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/pion/dtls/v3 v3.0.6 h1:7Hkd8WhAJNbRgq9RgdNh1aaWlZlGpYTzdqjy9x9sK2E=
|
||||
github.com/pion/dtls/v3 v3.0.6/go.mod h1:iJxNQ3Uhn1NZWOMWlLxEEHAN5yX7GyPvvKw04v9bzYU=
|
||||
github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
|
||||
github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so=
|
||||
github.com/pion/transport/v3 v3.0.7 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1o0=
|
||||
github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
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/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.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
|
||||
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
|
||||
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.51.0 h1:kysRYLbHy/MB7kQZf5DSN50JHmMsNEdeY24VzJFu7wI=
|
||||
github.com/samber/lo v1.51.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
|
||||
github.com/samber/slog-common v0.19.0 h1:fNcZb8B2uOLooeYwFpAlKjkQTUafdjfqKcwcC89G9YI=
|
||||
github.com/samber/slog-common v0.19.0/go.mod h1:dTz+YOU76aH007YUU0DffsXNsGFQRQllPQh9XyNoA3M=
|
||||
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/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.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
|
||||
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.36.0 h1:dNzwXjZKpMpE2JhmO+9HsPl42NIXFIFSUSSs0fiqra0=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.36.0/go.mod h1:90PoxvaEB5n6AOdZvi+yWJQoE95U8Dhhw2bSyRqnTD0=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.36.0 h1:nRVXXvf78e00EwY6Wp0YII8ww2JVWshZ20HfTlE11AM=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.36.0/go.mod h1:r49hO7CgrxY9Voaj3Xe8pANWtr0Oq916d0XAmOoCZAQ=
|
||||
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
|
||||
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
|
||||
go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs=
|
||||
go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY=
|
||||
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
|
||||
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
|
||||
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/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.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
|
||||
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
|
||||
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.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg=
|
||||
golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ=
|
||||
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.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
|
||||
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
|
||||
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.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
||||
golang.org/x/sync v0.16.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-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-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.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
|
||||
golang.org/x/sys v0.34.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.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
|
||||
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
|
||||
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
|
||||
golang.org/x/time v0.12.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.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0=
|
||||
golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw=
|
||||
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-20250505200425-f936aa4a68b2 h1:1tXaIXCracvtsRxSBsYDiSBN0cuJvM7QYW+MrpIRY78=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250519155744-55703ea1f237 h1:Kog3KlB4xevJlAcbbbzPfRG0+X9fdoGM+UBRKVz6Wr0=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250519155744-55703ea1f237/go.mod h1:ezi0AVyMKDWy5xAncvjLWH7UcLBB5n7y2fQ8MzjJcto=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250721164621-a45f3dfb1074 h1:qJW29YvkiJmXOYMu5Tf8lyrTp3dOS+K4z6IixtLaCf8=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250721164621-a45f3dfb1074/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
|
||||
google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4=
|
||||
google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM=
|
||||
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-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/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=
|
57
agent/pkg/agent/agent_pool.go
Normal file
57
agent/pkg/agent/agent_pool.go
Normal file
|
@ -0,0 +1,57 @@
|
|||
package agent
|
||||
|
||||
import (
|
||||
"github.com/yusing/go-proxy/internal/common"
|
||||
"github.com/yusing/go-proxy/internal/utils/functional"
|
||||
)
|
||||
|
||||
var agentPool = functional.NewMapOf[string, *AgentConfig]()
|
||||
|
||||
func init() {
|
||||
if common.IsTest {
|
||||
agentPool.Store("test-agent", &AgentConfig{
|
||||
Addr: "test-agent",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func GetAgent(agentAddrOrDockerHost string) (*AgentConfig, bool) {
|
||||
if !IsDockerHostAgent(agentAddrOrDockerHost) {
|
||||
return getAgentByAddr(agentAddrOrDockerHost)
|
||||
}
|
||||
return getAgentByAddr(GetAgentAddrFromDockerHost(agentAddrOrDockerHost))
|
||||
}
|
||||
|
||||
func GetAgentByName(name string) (*AgentConfig, bool) {
|
||||
for _, agent := range agentPool.Range {
|
||||
if agent.Name() == name {
|
||||
return agent, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func AddAgent(agent *AgentConfig) {
|
||||
agentPool.Store(agent.Addr, agent)
|
||||
}
|
||||
|
||||
func RemoveAgent(agent *AgentConfig) {
|
||||
agentPool.Delete(agent.Addr)
|
||||
}
|
||||
|
||||
func RemoveAllAgents() {
|
||||
agentPool.Clear()
|
||||
}
|
||||
|
||||
func ListAgents() []*AgentConfig {
|
||||
agents := make([]*AgentConfig, 0, agentPool.Size())
|
||||
for _, agent := range agentPool.Range {
|
||||
agents = append(agents, agent)
|
||||
}
|
||||
return agents
|
||||
}
|
||||
|
||||
func getAgentByAddr(addr string) (agent *AgentConfig, ok bool) {
|
||||
agent, ok = agentPool.Load(addr)
|
||||
return
|
||||
}
|
|
@ -10,7 +10,7 @@ var (
|
|||
AGENT_PORT="{{.Port}}" \
|
||||
AGENT_CA_CERT="{{.CACert}}" \
|
||||
AGENT_SSL_CERT="{{.SSLCert}}" \
|
||||
bash -c "$(curl -fsSL https://raw.githubusercontent.com/yusing/go-proxy/main/scripts/install-agent.sh)"`
|
||||
bash -c "$(curl -fsSL https://raw.githubusercontent.com/yusing/godoxy/main/scripts/install-agent.sh)"`
|
||||
installScriptTemplate = template.Must(template.New("install.sh").Parse(installScript))
|
||||
)
|
||||
|
||||
|
|
|
@ -5,19 +5,18 @@ import (
|
|||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/yusing/go-proxy/agent/pkg/certs"
|
||||
"github.com/yusing/go-proxy/internal/gperr"
|
||||
"github.com/yusing/go-proxy/internal/logging"
|
||||
gphttp "github.com/yusing/go-proxy/internal/net/gphttp"
|
||||
"github.com/yusing/go-proxy/internal/net/types"
|
||||
"github.com/yusing/go-proxy/internal/task"
|
||||
"github.com/yusing/go-proxy/pkg"
|
||||
)
|
||||
|
||||
|
@ -27,6 +26,7 @@ type AgentConfig struct {
|
|||
httpClient *http.Client
|
||||
tlsConfig *tls.Config
|
||||
name string
|
||||
version string
|
||||
l zerolog.Logger
|
||||
}
|
||||
|
||||
|
@ -49,9 +49,17 @@ const (
|
|||
FakeDockerHostPrefixLen = len(FakeDockerHostPrefix)
|
||||
)
|
||||
|
||||
func mustParseURL(urlStr string) *url.URL {
|
||||
u, err := url.Parse(urlStr)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
var (
|
||||
AgentURL = types.MustParseURL(APIBaseURL)
|
||||
HTTPProxyURL = types.MustParseURL(APIBaseURL + EndpointProxyHTTP)
|
||||
AgentURL = mustParseURL(APIBaseURL)
|
||||
HTTPProxyURL = mustParseURL(APIBaseURL + EndpointProxyHTTP)
|
||||
HTTPProxyURLPrefixLen = len(APIEndpointBase + EndpointProxyHTTP)
|
||||
)
|
||||
|
||||
|
@ -72,15 +80,9 @@ func (cfg *AgentConfig) Parse(addr string) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func withoutBuildTime(version string) string {
|
||||
return strings.Split(version, "-")[0]
|
||||
}
|
||||
var serverVersion = pkg.GetVersion()
|
||||
|
||||
func checkVersion(a, b string) bool {
|
||||
return withoutBuildTime(a) == withoutBuildTime(b)
|
||||
}
|
||||
|
||||
func (cfg *AgentConfig) StartWithCerts(parent task.Parent, ca, crt, key []byte) error {
|
||||
func (cfg *AgentConfig) StartWithCerts(ctx context.Context, ca, crt, key []byte) error {
|
||||
clientCert, err := tls.X509KeyPair(crt, key)
|
||||
if err != nil {
|
||||
return err
|
||||
|
@ -90,7 +92,7 @@ func (cfg *AgentConfig) StartWithCerts(parent task.Parent, ca, crt, key []byte)
|
|||
caCertPool := x509.NewCertPool()
|
||||
ok := caCertPool.AppendCertsFromPEM(ca)
|
||||
if !ok {
|
||||
return gperr.New("invalid ca certificate")
|
||||
return errors.New("invalid ca certificate")
|
||||
}
|
||||
|
||||
cfg.tlsConfig = &tls.Config{
|
||||
|
@ -102,21 +104,9 @@ func (cfg *AgentConfig) StartWithCerts(parent task.Parent, ca, crt, key []byte)
|
|||
// create transport and http client
|
||||
cfg.httpClient = cfg.NewHTTPClient()
|
||||
|
||||
ctx, cancel := context.WithTimeout(parent.Context(), 5*time.Second)
|
||||
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// check agent version
|
||||
version, _, err := cfg.Fetch(ctx, EndpointVersion)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
versionStr := string(version)
|
||||
// skip version check for dev versions
|
||||
if strings.HasPrefix(versionStr, "v") && !checkVersion(versionStr, pkg.GetVersion()) {
|
||||
return gperr.Errorf("agent version mismatch: server: %s, agent: %s", pkg.GetVersion(), versionStr)
|
||||
}
|
||||
|
||||
// get agent name
|
||||
name, _, err := cfg.Fetch(ctx, EndpointName)
|
||||
if err != nil {
|
||||
|
@ -124,29 +114,43 @@ func (cfg *AgentConfig) StartWithCerts(parent task.Parent, ca, crt, key []byte)
|
|||
}
|
||||
|
||||
cfg.name = string(name)
|
||||
cfg.l = logging.With().Str("agent", cfg.name).Logger()
|
||||
|
||||
logging.Info().Msgf("agent %q initialized", cfg.name)
|
||||
cfg.l = log.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) {
|
||||
log.Warn().Msgf("agent %s major version mismatch: server: %s, agent: %s", cfg.name, serverVersion, agentVersion)
|
||||
}
|
||||
|
||||
log.Info().Msgf("agent %q initialized", cfg.name)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cfg *AgentConfig) Start(parent task.Parent) gperr.Error {
|
||||
func (cfg *AgentConfig) Start(ctx context.Context) error {
|
||||
filepath, ok := certs.AgentCertsFilepath(cfg.Addr)
|
||||
if !ok {
|
||||
return gperr.New("invalid agent host").Subject(cfg.Addr)
|
||||
return fmt.Errorf("invalid agent host: %s", cfg.Addr)
|
||||
}
|
||||
|
||||
certData, err := os.ReadFile(filepath)
|
||||
if err != nil {
|
||||
return gperr.Wrap(err, "failed to read agent certs")
|
||||
return fmt.Errorf("failed to read agent certs: %w", err)
|
||||
}
|
||||
|
||||
ca, crt, key, err := certs.ExtractCert(certData)
|
||||
if err != nil {
|
||||
return gperr.Wrap(err, "failed to extract agent certs")
|
||||
return fmt.Errorf("failed to extract agent certs: %w", err)
|
||||
}
|
||||
|
||||
return gperr.Wrap(cfg.StartWithCerts(parent, ca, crt, key))
|
||||
return cfg.StartWithCerts(ctx, ca, crt, key)
|
||||
}
|
||||
|
||||
func (cfg *AgentConfig) NewHTTPClient() *http.Client {
|
||||
|
@ -170,8 +174,10 @@ func (cfg *AgentConfig) Transport() *http.Transport {
|
|||
}
|
||||
}
|
||||
|
||||
var dialer = &net.Dialer{Timeout: 5 * time.Second}
|
||||
|
||||
func (cfg *AgentConfig) DialContext(ctx context.Context) (net.Conn, error) {
|
||||
return gphttp.DefaultDialer.DialContext(ctx, "tcp", cfg.Addr)
|
||||
return dialer.DialContext(ctx, "tcp", cfg.Addr)
|
||||
}
|
||||
|
||||
func (cfg *AgentConfig) Name() string {
|
||||
|
@ -186,5 +192,6 @@ func (cfg *AgentConfig) MarshalJSON() ([]byte, error) {
|
|||
return json.Marshal(map[string]string{
|
||||
"name": cfg.Name(),
|
||||
"addr": cfg.Addr,
|
||||
"version": cfg.version,
|
||||
})
|
||||
}
|
||||
|
|
|
@ -2,7 +2,6 @@ package agent
|
|||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
|
@ -12,20 +11,37 @@ import (
|
|||
"math/big"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
const (
|
||||
CertsDNSName = "godoxy.agent"
|
||||
KeySize = 2048
|
||||
)
|
||||
|
||||
func toPEMPair(certDER []byte, key *rsa.PrivateKey) *PEMPair {
|
||||
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: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}),
|
||||
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)
|
||||
}
|
||||
|
@ -63,10 +79,23 @@ func (p *PEMPair) ToTLSCert() (*tls.Certificate, error) {
|
|||
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: big.NewInt(1),
|
||||
SerialNumber: caSerialNumber,
|
||||
Subject: pkix.Name{
|
||||
Organization: []string{"GoDoxy"},
|
||||
CommonName: CertsDNSName,
|
||||
|
@ -76,9 +105,12 @@ func NewAgent() (ca, srv, client *PEMPair, err error) {
|
|||
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
|
||||
BasicConstraintsValid: true,
|
||||
IsCA: true,
|
||||
MaxPathLen: 0,
|
||||
MaxPathLenZero: true,
|
||||
SignatureAlgorithm: x509.ECDSAWithSHA256,
|
||||
}
|
||||
|
||||
caKey, err := rsa.GenerateKey(rand.Reader, KeySize)
|
||||
caKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
@ -91,20 +123,29 @@ func NewAgent() (ca, srv, client *PEMPair, err error) {
|
|||
ca = toPEMPair(caDER, caKey)
|
||||
|
||||
// Generate a new private key for the server certificate
|
||||
serverKey, err := rsa.GenerateKey(rand.Reader, KeySize)
|
||||
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: big.NewInt(2),
|
||||
SerialNumber: serverSerialNumber,
|
||||
Issuer: caTemplate.Subject,
|
||||
Subject: 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)
|
||||
|
@ -114,20 +155,29 @@ func NewAgent() (ca, srv, client *PEMPair, err error) {
|
|||
|
||||
srv = toPEMPair(srvCertDER, serverKey)
|
||||
|
||||
clientKey, err := rsa.GenerateKey(rand.Reader, KeySize)
|
||||
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: big.NewInt(3),
|
||||
SerialNumber: clientSerialNumber,
|
||||
Issuer: caTemplate.Subject,
|
||||
Subject: 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 {
|
||||
|
|
|
@ -8,59 +8,59 @@ import (
|
|||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
. "github.com/yusing/go-proxy/internal/utils/testing"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewAgent(t *testing.T) {
|
||||
ca, srv, client, err := NewAgent()
|
||||
ExpectNoError(t, err)
|
||||
ExpectTrue(t, ca != nil)
|
||||
ExpectTrue(t, srv != nil)
|
||||
ExpectTrue(t, client != nil)
|
||||
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()
|
||||
ExpectNoError(t, err)
|
||||
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())
|
||||
ExpectNoError(t, err)
|
||||
ExpectEqual(t, p.Cert, pp.Cert)
|
||||
ExpectEqual(t, p.Key, pp.Key)
|
||||
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()
|
||||
ExpectNoError(t, err)
|
||||
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()
|
||||
ExpectNoError(t, err)
|
||||
ExpectTrue(t, cert != nil)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, cert)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerClient(t *testing.T) {
|
||||
ca, srv, client, err := NewAgent()
|
||||
ExpectNoError(t, err)
|
||||
require.NoError(t, err)
|
||||
|
||||
srvTLS, err := srv.ToTLSCert()
|
||||
ExpectNoError(t, err)
|
||||
ExpectTrue(t, srvTLS != nil)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, srvTLS)
|
||||
|
||||
clientTLS, err := client.ToTLSCert()
|
||||
ExpectNoError(t, err)
|
||||
ExpectTrue(t, clientTLS != nil)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, clientTLS)
|
||||
|
||||
caPool := x509.NewCertPool()
|
||||
ExpectTrue(t, caPool.AppendCertsFromPEM(ca.Cert))
|
||||
require.True(t, caPool.AppendCertsFromPEM(ca.Cert))
|
||||
|
||||
srvTLSConfig := &tls.Config{
|
||||
Certificates: []tls.Certificate{*srvTLS},
|
||||
|
@ -86,6 +86,6 @@ func TestServerClient(t *testing.T) {
|
|||
}
|
||||
|
||||
resp, err := httpClient.Get(server.URL)
|
||||
ExpectNoError(t, err)
|
||||
ExpectEqual(t, resp.StatusCode, http.StatusOK)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, resp.StatusCode, http.StatusOK)
|
||||
}
|
||||
|
|
|
@ -5,7 +5,7 @@ import (
|
|||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
func (cfg *AgentConfig) Do(ctx context.Context, method, endpoint string, body io.Reader) (*http.Response, error) {
|
||||
|
@ -42,8 +42,12 @@ func (cfg *AgentConfig) Fetch(ctx context.Context, endpoint string) ([]byte, int
|
|||
}
|
||||
|
||||
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,
|
||||
transport := cfg.Transport()
|
||||
dialer := websocket.Dialer{
|
||||
NetDialContext: transport.DialContext,
|
||||
NetDialTLSContext: transport.DialTLSContext,
|
||||
}
|
||||
return dialer.DialContext(ctx, APIBaseURL+endpoint, http.Header{
|
||||
"Host": {AgentHost},
|
||||
})
|
||||
}
|
||||
|
|
|
@ -9,6 +9,36 @@ services:
|
|||
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
|
||||
|
|
|
@ -6,10 +6,11 @@ import (
|
|||
"io"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/yusing/go-proxy/internal/common"
|
||||
"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,
|
||||
|
@ -59,7 +60,7 @@ func AgentCertsFilepath(host string) (filepathOut string, ok bool) {
|
|||
if !isValidAgentHost(host) {
|
||||
return "", false
|
||||
}
|
||||
return filepath.Join(common.AgentCertsBasePath, host+".zip"), true
|
||||
return filepath.Join(AgentCertsBasePath, host+".zip"), true
|
||||
}
|
||||
|
||||
func ExtractCert(data []byte) (ca, crt, key []byte, err error) {
|
||||
|
|
|
@ -1,19 +1,20 @@
|
|||
package certs
|
||||
package certs_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
. "github.com/yusing/go-proxy/internal/utils/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 := ZipCert(ca, crt, key)
|
||||
ExpectNoError(t, err)
|
||||
zipData, err := certs.ZipCert(ca, crt, key)
|
||||
require.NoError(t, err)
|
||||
|
||||
ca2, crt2, key2, err := ExtractCert(zipData)
|
||||
ExpectNoError(t, err)
|
||||
ExpectEqual(t, ca, ca2)
|
||||
ExpectEqual(t, crt, crt2)
|
||||
ExpectEqual(t, key, key2)
|
||||
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)
|
||||
}
|
||||
|
|
16
agent/pkg/env/env.go
vendored
16
agent/pkg/env/env.go
vendored
|
@ -15,10 +15,24 @@ func DefaultAgentName() string {
|
|||
}
|
||||
|
||||
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", "")
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,14 +1,13 @@
|
|||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/yusing/go-proxy/internal/net/gphttp"
|
||||
"github.com/yusing/go-proxy/internal/net/types"
|
||||
"github.com/yusing/go-proxy/internal/watcher/health"
|
||||
"github.com/yusing/go-proxy/internal/watcher/health/monitor"
|
||||
)
|
||||
|
@ -19,7 +18,7 @@ func CheckHealth(w http.ResponseWriter, r *http.Request) {
|
|||
query := r.URL.Query()
|
||||
scheme := query.Get("scheme")
|
||||
if scheme == "" {
|
||||
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
||||
http.Error(w, "missing scheme", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -29,7 +28,7 @@ func CheckHealth(w http.ResponseWriter, r *http.Request) {
|
|||
case "fileserver":
|
||||
path := query.Get("path")
|
||||
if path == "" {
|
||||
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
||||
http.Error(w, "missing path", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
_, err := os.Stat(path)
|
||||
|
@ -41,32 +40,33 @@ func CheckHealth(w http.ResponseWriter, r *http.Request) {
|
|||
host := query.Get("host")
|
||||
path := query.Get("path")
|
||||
if host == "" {
|
||||
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
||||
http.Error(w, "missing host", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
result, err = monitor.NewHTTPHealthChecker(types.NewURL(&url.URL{
|
||||
result, err = monitor.NewHTTPHealthMonitor(&url.URL{
|
||||
Scheme: scheme,
|
||||
Host: host,
|
||||
Path: path,
|
||||
}), defaultHealthConfig).CheckHealth()
|
||||
}, defaultHealthConfig).CheckHealth()
|
||||
case "tcp", "udp":
|
||||
host := query.Get("host")
|
||||
if host == "" {
|
||||
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
||||
http.Error(w, "missing host", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
hasPort := strings.Contains(host, ":")
|
||||
port := query.Get("port")
|
||||
if port != "" && !hasPort {
|
||||
host = fmt.Sprintf("%s:%s", host, port)
|
||||
} else {
|
||||
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
||||
if port != "" && hasPort {
|
||||
http.Error(w, "port and host with port cannot both be provided", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
result, err = monitor.NewRawHealthChecker(types.NewURL(&url.URL{
|
||||
if port != "" {
|
||||
host = fmt.Sprintf("%s:%s", host, port)
|
||||
}
|
||||
result, err = monitor.NewRawHealthMonitor(&url.URL{
|
||||
Scheme: scheme,
|
||||
Host: host,
|
||||
}), defaultHealthConfig).CheckHealth()
|
||||
}, defaultHealthConfig).CheckHealth()
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
|
@ -74,5 +74,7 @@ func CheckHealth(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
gphttp.RespondJSON(w, r, result)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(result)
|
||||
}
|
||||
|
|
|
@ -172,9 +172,9 @@ func TestCheckHealthTCPUDP(t *testing.T) {
|
|||
{
|
||||
name: "InvalidHost",
|
||||
scheme: "tcp",
|
||||
host: "invalid",
|
||||
host: "",
|
||||
port: 8080,
|
||||
expectedStatus: http.StatusOK,
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
expectedHealthy: false,
|
||||
},
|
||||
{
|
||||
|
@ -188,9 +188,17 @@ func TestCheckHealthTCPUDP(t *testing.T) {
|
|||
{
|
||||
name: "InvalidHost",
|
||||
scheme: "udp",
|
||||
host: "invalid",
|
||||
host: "",
|
||||
port: 8080,
|
||||
expectedStatus: http.StatusOK,
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
expectedHealthy: false,
|
||||
},
|
||||
{
|
||||
name: "Port in both host and port",
|
||||
scheme: "tcp",
|
||||
host: "localhost:1234",
|
||||
port: 1234,
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
expectedHealthy: false,
|
||||
},
|
||||
}
|
||||
|
@ -208,9 +216,11 @@ func TestCheckHealthTCPUDP(t *testing.T) {
|
|||
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,31 +0,0 @@
|
|||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/docker/docker/client"
|
||||
"github.com/yusing/go-proxy/internal/common"
|
||||
"github.com/yusing/go-proxy/internal/docker"
|
||||
"github.com/yusing/go-proxy/internal/logging"
|
||||
"github.com/yusing/go-proxy/internal/net/gphttp/reverseproxy"
|
||||
"github.com/yusing/go-proxy/internal/net/types"
|
||||
)
|
||||
|
||||
func serviceUnavailable(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "docker socket is not available", http.StatusServiceUnavailable)
|
||||
}
|
||||
|
||||
func DockerSocketHandler() http.HandlerFunc {
|
||||
dockerClient, err := docker.NewClient(common.DockerHostFromEnv)
|
||||
if err != nil {
|
||||
logging.Warn().Err(err).Msg("failed to connect to docker client")
|
||||
return serviceUnavailable
|
||||
}
|
||||
rp := reverseproxy.NewReverseProxy("docker", types.NewURL(&url.URL{
|
||||
Scheme: "http",
|
||||
Host: client.DummyHost,
|
||||
}), dockerClient.HTTPClient().Transport)
|
||||
|
||||
return rp.ServeHTTP
|
||||
}
|
|
@ -2,48 +2,35 @@ package handler
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/yusing/go-proxy/agent/pkg/agent"
|
||||
"github.com/yusing/go-proxy/agent/pkg/env"
|
||||
v1 "github.com/yusing/go-proxy/internal/api/v1"
|
||||
"github.com/yusing/go-proxy/internal/logging/memlogger"
|
||||
"github.com/yusing/go-proxy/internal/metrics/systeminfo"
|
||||
"github.com/yusing/go-proxy/internal/utils/strutils"
|
||||
"github.com/yusing/go-proxy/pkg"
|
||||
socketproxy "github.com/yusing/go-proxy/socketproxy/pkg"
|
||||
)
|
||||
|
||||
type ServeMux struct{ *http.ServeMux }
|
||||
|
||||
func (mux ServeMux) HandleMethods(methods, endpoint string, handler http.HandlerFunc) {
|
||||
for _, m := range strutils.CommaSeperatedList(methods) {
|
||||
mux.ServeMux.HandleFunc(m+" "+agent.APIEndpointBase+endpoint, handler)
|
||||
}
|
||||
func (mux ServeMux) 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)
|
||||
}
|
||||
|
||||
type NopWriteCloser struct {
|
||||
io.Writer
|
||||
}
|
||||
|
||||
func (NopWriteCloser) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewAgentHandler() http.Handler {
|
||||
mux := ServeMux{http.NewServeMux()}
|
||||
|
||||
mux.HandleFunc(agent.EndpointProxyHTTP+"/{path...}", ProxyHTTP)
|
||||
mux.HandleMethods("GET", agent.EndpointVersion, v1.GetVersion)
|
||||
mux.HandleMethods("GET", agent.EndpointName, func(w http.ResponseWriter, r *http.Request) {
|
||||
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.HandleMethods("GET", agent.EndpointHealth, CheckHealth)
|
||||
mux.HandleMethods("GET", agent.EndpointLogs, memlogger.HandlerFunc())
|
||||
mux.HandleMethods("GET", agent.EndpointSystemInfo, systeminfo.Poller.ServeHTTP)
|
||||
mux.ServeMux.HandleFunc("/", DockerSocketHandler())
|
||||
mux.HandleEndpoint("GET", agent.EndpointHealth, CheckHealth)
|
||||
mux.HandleEndpoint("GET", agent.EndpointSystemInfo, systeminfo.Poller.ServeHTTP)
|
||||
mux.ServeMux.HandleFunc("/", socketproxy.DockerSocketHandler(env.DockerSocket))
|
||||
return mux
|
||||
}
|
||||
|
|
|
@ -3,23 +3,30 @@ package handler
|
|||
import (
|
||||
"crypto/tls"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"net/http/httputil"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/yusing/go-proxy/agent/pkg/agent"
|
||||
"github.com/yusing/go-proxy/agent/pkg/agentproxy"
|
||||
"github.com/yusing/go-proxy/internal/logging"
|
||||
"github.com/yusing/go-proxy/internal/net/gphttp"
|
||||
"github.com/yusing/go-proxy/internal/net/gphttp/reverseproxy"
|
||||
"github.com/yusing/go-proxy/internal/net/types"
|
||||
"github.com/yusing/go-proxy/internal/utils/strutils"
|
||||
)
|
||||
|
||||
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 := strutils.ParseBool(r.Header.Get(agentproxy.HeaderXProxyHTTPS))
|
||||
skipTLSVerify := strutils.ParseBool(r.Header.Get(agentproxy.HeaderXProxySkipTLSVerify))
|
||||
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
|
||||
|
@ -35,11 +42,9 @@ func ProxyHTTP(w http.ResponseWriter, r *http.Request) {
|
|||
scheme = "https"
|
||||
}
|
||||
|
||||
var transport *http.Transport
|
||||
transport := NewTransport()
|
||||
if skipTLSVerify {
|
||||
transport = gphttp.NewTransportWithTLSConfig(&tls.Config{InsecureSkipVerify: true})
|
||||
} else {
|
||||
transport = gphttp.NewTransport()
|
||||
transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
|
||||
}
|
||||
|
||||
if responseHeaderTimeout > 0 {
|
||||
|
@ -50,14 +55,13 @@ func ProxyHTTP(w http.ResponseWriter, r *http.Request) {
|
|||
r.URL.Host = ""
|
||||
r.URL.Path = r.URL.Path[agent.HTTPProxyURLPrefixLen:] // strip the {API_BASE}/proxy/http prefix
|
||||
r.RequestURI = r.URL.String()
|
||||
r.URL.Host = host
|
||||
|
||||
rp := &httputil.ReverseProxy{
|
||||
Director: func(r *http.Request) {
|
||||
r.URL.Scheme = scheme
|
||||
|
||||
logging.Debug().Msgf("proxy http request: %s %s", r.Method, r.URL.String())
|
||||
|
||||
rp := reverseproxy.NewReverseProxy("agent", types.NewURL(&url.URL{
|
||||
Scheme: scheme,
|
||||
Host: host,
|
||||
}), transport)
|
||||
r.URL.Host = host
|
||||
},
|
||||
Transport: transport,
|
||||
}
|
||||
rp.ServeHTTP(w, r)
|
||||
}
|
||||
|
|
|
@ -6,9 +6,9 @@ import (
|
|||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
"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"
|
||||
)
|
||||
|
@ -33,12 +33,11 @@ func StartAgentServer(parent task.Parent, opt Options) {
|
|||
tlsConfig.ClientAuth = tls.NoClientCert
|
||||
}
|
||||
|
||||
logger := logging.GetLogger()
|
||||
agentServer := &http.Server{
|
||||
Addr: fmt.Sprintf(":%d", opt.Port),
|
||||
Handler: handler.NewAgentHandler(),
|
||||
TLSConfig: tlsConfig,
|
||||
}
|
||||
|
||||
server.Start(parent, agentServer, logger)
|
||||
server.Start(parent, agentServer, nil, &log.Logger)
|
||||
}
|
||||
|
|
109
cmd/main.go
109
cmd/main.go
|
@ -1,17 +1,14 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
"github.com/yusing/go-proxy/internal"
|
||||
"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/rs/zerolog/log"
|
||||
"github.com/yusing/go-proxy/internal/auth"
|
||||
"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"
|
||||
"github.com/yusing/go-proxy/internal/homepage"
|
||||
"github.com/yusing/go-proxy/internal/logging"
|
||||
|
@ -19,13 +16,10 @@ import (
|
|||
"github.com/yusing/go-proxy/internal/metrics/systeminfo"
|
||||
"github.com/yusing/go-proxy/internal/metrics/uptime"
|
||||
"github.com/yusing/go-proxy/internal/net/gphttp/middleware"
|
||||
"github.com/yusing/go-proxy/internal/route/routes/routequery"
|
||||
"github.com/yusing/go-proxy/internal/task"
|
||||
"github.com/yusing/go-proxy/pkg"
|
||||
)
|
||||
|
||||
var rawLogger = log.New(os.Stdout, "", 0)
|
||||
|
||||
func parallel(fns ...func()) {
|
||||
var wg sync.WaitGroup
|
||||
for _, fn := range fns {
|
||||
|
@ -40,105 +34,36 @@ func parallel(fns ...func()) {
|
|||
|
||||
func main() {
|
||||
initProfiling()
|
||||
args := pkg.GetArgs(common.MainServerCommandValidator{})
|
||||
|
||||
switch args.Command {
|
||||
case common.CommandReload:
|
||||
if err := query.ReloadServer(); err != nil {
|
||||
gperr.LogFatal("server reload error", err)
|
||||
}
|
||||
rawLogger.Println("ok")
|
||||
return
|
||||
case common.CommandListIcons:
|
||||
icons, err := 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.InitLogger(os.Stderr, memlogger.GetMemLogger())
|
||||
logging.Info().Msgf("GoDoxy version %s", pkg.GetVersion())
|
||||
logging.Trace().Msg("trace enabled")
|
||||
log.Info().Msgf("GoDoxy version %s", pkg.GetVersion())
|
||||
log.Trace().Msg("trace enabled")
|
||||
parallel(
|
||||
internal.InitIconListCache,
|
||||
homepage.InitOverridesConfig,
|
||||
favicon.InitIconCache,
|
||||
dnsproviders.InitProviders,
|
||||
homepage.InitIconListCache,
|
||||
systeminfo.Poller.Start,
|
||||
middleware.LoadComposeFiles,
|
||||
)
|
||||
|
||||
if common.APIJWTSecret == nil {
|
||||
logging.Warn().Msg("API_JWT_SECRET is not set, using random key")
|
||||
log.Warn().Msg("API_JWT_SECRET is not set, using random key")
|
||||
common.APIJWTSecret = common.RandomJWTKey()
|
||||
}
|
||||
} else {
|
||||
logging.DiscardLogger()
|
||||
}
|
||||
|
||||
if args.Command == common.CommandValidate {
|
||||
data, err := os.ReadFile(common.ConfigPath)
|
||||
if err == nil {
|
||||
err = config.Validate(data)
|
||||
}
|
||||
if err != nil {
|
||||
log.Fatal("config error: ", err)
|
||||
}
|
||||
log.Print("config OK")
|
||||
return
|
||||
}
|
||||
|
||||
for _, dir := range common.RequiredDirectories {
|
||||
prepareDirectory(dir)
|
||||
}
|
||||
|
||||
middleware.LoadComposeFiles()
|
||||
|
||||
var cfg *config.Config
|
||||
var err gperr.Error
|
||||
if cfg, err = config.Load(); err != nil {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
gperr.LogWarn("errors in config", err)
|
||||
err = nil
|
||||
}
|
||||
|
||||
switch args.Command {
|
||||
case common.CommandListRoutes:
|
||||
cfg.StartProxyProviders()
|
||||
printJSON(routequery.RoutesByAlias())
|
||||
return
|
||||
case common.CommandListConfigs:
|
||||
printJSON(cfg.Value())
|
||||
return
|
||||
case common.CommandDebugListEntries:
|
||||
printJSON(cfg.DumpRoutes())
|
||||
return
|
||||
case common.CommandDebugListProviders:
|
||||
printJSON(cfg.DumpRouteProviders())
|
||||
return
|
||||
}
|
||||
|
||||
cfg.Start(&config.StartServersOptions{
|
||||
Proxy: true,
|
||||
})
|
||||
if err := auth.Initialize(); err != nil {
|
||||
logging.Fatal().Err(err).Msg("failed to initialize authentication")
|
||||
log.Fatal().Err(err).Msg("failed to initialize authentication")
|
||||
}
|
||||
// API Handler needs to start after auth is initialized.
|
||||
cfg.StartServers(&config.StartServersOptions{
|
||||
|
@ -154,15 +79,7 @@ func main() {
|
|||
func prepareDirectory(dir string) {
|
||||
if _, err := os.Stat(dir); os.IsNotExist(err) {
|
||||
if err = os.MkdirAll(dir, 0o755); err != nil {
|
||||
logging.Fatal().Msgf("failed to create directory %s: %v", dir, err)
|
||||
log.Fatal().Msgf("failed to create directory %s: %v", dir, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func printJSON(obj any) {
|
||||
j, err := json.MarshalIndent(obj, "", " ")
|
||||
if err != nil {
|
||||
logging.Fatal().Err(err).Send()
|
||||
}
|
||||
rawLogger.Print(string(j)) // raw output for convenience using "jq"
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
//go:build production
|
||||
//go:build !pprof
|
||||
|
||||
package main
|
||||
|
||||
|
|
|
@ -3,18 +3,43 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
_ "net/http/pprof"
|
||||
"runtime"
|
||||
"runtime/debug"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/yusing/go-proxy/internal/utils/strutils"
|
||||
)
|
||||
|
||||
const mb = 1024 * 1024
|
||||
|
||||
func initProfiling() {
|
||||
runtime.GOMAXPROCS(2)
|
||||
debug.SetMemoryLimit(100 * 1024 * 1024)
|
||||
debug.SetMaxStack(15 * 1024 * 1024)
|
||||
debug.SetGCPercent(-1)
|
||||
debug.SetMemoryLimit(50 * mb)
|
||||
debug.SetMaxStack(4 * mb)
|
||||
|
||||
go func() {
|
||||
log.Println(http.ListenAndServe(":7777", nil))
|
||||
log.Info().Msgf("pprof server started at http://localhost:7777/debug/pprof/")
|
||||
log.Error().Err(http.ListenAndServe(":7777", nil)).Msg("pprof server failed")
|
||||
}()
|
||||
go func() {
|
||||
ticker := time.NewTicker(time.Second * 10)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
var m runtime.MemStats
|
||||
runtime.ReadMemStats(&m)
|
||||
log.Info().Msgf("-----------------------------------------------------")
|
||||
log.Info().Msgf("Timestamp: %s", time.Now().Format(time.RFC3339))
|
||||
log.Info().Msgf(" Go Heap - In Use (Alloc/HeapAlloc): %s", strutils.FormatByteSize(m.Alloc))
|
||||
log.Info().Msgf(" Go Heap - Reserved from OS (HeapSys): %s", strutils.FormatByteSize(m.HeapSys))
|
||||
log.Info().Msgf(" Go Stacks - In Use (StackInuse): %s", strutils.FormatByteSize(m.StackInuse))
|
||||
log.Info().Msgf(" Go Runtime - Other Sys (MSpanInuse, MCacheInuse, BuckHashSys, GCSys, OtherSys): %s", strutils.FormatByteSize(m.MSpanInuse+m.MCacheInuse+m.BuckHashSys+m.GCSys+m.OtherSys))
|
||||
log.Info().Msgf(" Go Runtime - Total from OS (Sys): %s", strutils.FormatByteSize(m.Sys))
|
||||
log.Info().Msgf(" Number of Goroutines: %d", runtime.NumGoroutine())
|
||||
log.Info().Msgf(" Number of GCs: %d", m.NumGC)
|
||||
log.Info().Msg("-----------------------------------------------------")
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
|
|
@ -1,21 +1,46 @@
|
|||
---
|
||||
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:latest
|
||||
image: ghcr.io/yusing/godoxy-frontend:${TAG:-latest}
|
||||
container_name: godoxy-frontend
|
||||
restart: unless-stopped
|
||||
network_mode: host # do not change this
|
||||
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
|
||||
proxy.godoxy.port: ${GODOXY_FRONTEND_PORT:-3000}
|
||||
# proxy.godoxy.middlewares.cidr_whitelist: |
|
||||
proxy.aliases: ${GODOXY_FRONTEND_ALIASES:-godoxy}
|
||||
proxy.#1.port: ${GODOXY_FRONTEND_PORT:-3000}
|
||||
# proxy.#1.middlewares.cidr_whitelist: |
|
||||
# status: 403
|
||||
# message: IP not allowed
|
||||
# allow:
|
||||
|
@ -24,16 +49,27 @@ services:
|
|||
# - 192.168.0.0/16
|
||||
# - 172.16.0.0/12
|
||||
app:
|
||||
image: ghcr.io/yusing/godoxy:latest
|
||||
container_name: godoxy
|
||||
image: ghcr.io/yusing/godoxy:${TAG:-latest}
|
||||
container_name: godoxy-proxy
|
||||
restart: always
|
||||
network_mode: host # do not change this
|
||||
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
|
||||
- ./error_pages:/app/error_pages:ro
|
||||
- ./data:/app/data
|
||||
|
||||
# To use autocert, certs will be stored in "./certs".
|
||||
|
|
|
@ -15,14 +15,48 @@
|
|||
# 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
|
||||
# 3. other providers, see https://docs.godoxy.dev/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)
|
||||
|
||||
entrypoint:
|
||||
# Below define an example of middleware config
|
||||
# 1. block non local IP connections
|
||||
# 2. redirect HTTP to HTTPS
|
||||
# 1. set security headers
|
||||
# 2. block non local IP connections
|
||||
# 3. redirect HTTP to HTTPS
|
||||
#
|
||||
# middlewares:
|
||||
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
|
||||
# - use: CIDRWhitelist
|
||||
# allow:
|
||||
# - "127.0.0.1"
|
||||
|
@ -73,7 +107,15 @@ providers:
|
|||
# url: https://discord.com/api/webhooks/...
|
||||
# template: discord # this means use payload template from internal/notif/templates/discord.json
|
||||
|
||||
# Check https://github.com/yusing/godoxy/wiki/Certificates-and-domain-matching#domain-matching
|
||||
# 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://docs.godoxy.dev/Certificates-and-domain-matching
|
||||
# for explaination of `match_domains`
|
||||
#
|
||||
# match_domains:
|
||||
|
|
245
go.mod
245
go.mod
|
@ -1,98 +1,247 @@
|
|||
module github.com/yusing/go-proxy
|
||||
|
||||
go 1.24.2
|
||||
go 1.24.5
|
||||
|
||||
replace github.com/yusing/go-proxy/agent => ./agent
|
||||
|
||||
replace github.com/yusing/go-proxy/internal/dnsproviders => ./internal/dnsproviders
|
||||
|
||||
replace github.com/yusing/go-proxy/internal/utils => ./internal/utils
|
||||
|
||||
replace github.com/coreos/go-oidc/v3 => github.com/godoxy-app/go-oidc/v3 v3.0.0-20250523122447-f078841dec22
|
||||
|
||||
replace github.com/docker/docker => github.com/godoxy-app/docker v0.0.0-20250523125835-a2474a6ebe30
|
||||
|
||||
replace github.com/shirou/gopsutil/v4 => github.com/godoxy-app/gopsutil/v4 v4.0.0-20250607110153-34d627ba1b5d
|
||||
|
||||
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/docker/docker v28.3.2+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/go-acme/lego/v4 v4.25.1 // acme client
|
||||
github.com/go-playground/validator/v10 v10.27.0 // validator
|
||||
github.com/gobwas/glob v0.2.3 // glob matcher for route rules
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2 // jwt for default auth
|
||||
github.com/gotify/server/v2 v2.6.1 // reference the Message struct for json response
|
||||
github.com/gorilla/websocket v1.5.3 // websocket for API and agent
|
||||
github.com/gotify/server/v2 v2.6.3 // reference the Message struct for json response
|
||||
github.com/lithammer/fuzzysearch v1.1.8 // fuzzy search for searching icons and filtering metrics
|
||||
github.com/prometheus/client_golang v1.22.0 // metrics
|
||||
github.com/puzpuzpuz/xsync/v3 v3.5.1 // lock free map for concurrent operations
|
||||
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.3 // system info metrics
|
||||
github.com/shirou/gopsutil/v4 v4.25.6 // system info metrics
|
||||
github.com/vincent-petithory/dataurl v1.0.0 // data url for fav icon
|
||||
golang.org/x/crypto v0.37.0 // encrypting password with bcrypt
|
||||
golang.org/x/net v0.39.0 // HTTP header utilities
|
||||
golang.org/x/oauth2 v0.29.0 // oauth2 authentication
|
||||
golang.org/x/text v0.24.0 // string utilities
|
||||
golang.org/x/time v0.11.0 // time utilities
|
||||
gopkg.in/yaml.v3 v3.0.1 // yaml parsing for different config files
|
||||
golang.org/x/crypto v0.40.0 // encrypting password with bcrypt
|
||||
golang.org/x/net v0.42.0 // HTTP header utilities
|
||||
golang.org/x/oauth2 v0.30.0 // oauth2 authentication
|
||||
golang.org/x/sync v0.16.0
|
||||
golang.org/x/time v0.12.0 // time utilities
|
||||
)
|
||||
|
||||
replace github.com/coreos/go-oidc/v3 => github.com/godoxy-app/go-oidc/v3 v3.14.1
|
||||
|
||||
require (
|
||||
github.com/docker/cli v28.1.1+incompatible
|
||||
github.com/docker/go-connections v0.5.0
|
||||
github.com/docker/cli v28.3.2+incompatible
|
||||
github.com/goccy/go-yaml v1.18.0 // yaml parsing for different config files
|
||||
github.com/golang-jwt/jwt/v5 v5.2.3
|
||||
github.com/luthermonson/go-proxmox v0.2.2
|
||||
github.com/oschwald/maxminddb-golang v1.13.1
|
||||
github.com/quic-go/quic-go v0.54.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-20250721013917-68ac4f952d1e
|
||||
github.com/yusing/go-proxy/internal/dnsproviders v0.0.0-20250721013917-68ac4f952d1e
|
||||
github.com/yusing/go-proxy/internal/utils v0.0.0
|
||||
)
|
||||
|
||||
require (
|
||||
cloud.google.com/go/auth v0.16.3 // 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.1 // indirect
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1 // 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/andybalholm/cascadia v1.3.3 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2 v1.36.6 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/config v1.29.18 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.17.71 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.33 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.37 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.37 // 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.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.18 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/lightsail v1.43.5 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/route53 v1.53.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.25.6 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.34.1 // indirect
|
||||
github.com/aws/smithy-go v1.22.5 // indirect
|
||||
github.com/baidubce/bce-sdk-go v0.9.236 // indirect
|
||||
github.com/benbjohnson/clock v1.3.5 // indirect
|
||||
github.com/boombuler/barcode v1.1.0 // indirect
|
||||
github.com/buger/goterm v1.0.4 // indirect
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cloudflare/cloudflare-go v0.115.0 // indirect
|
||||
github.com/containerd/log v0.1.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/diskfs/go-diskfs v1.6.0 // indirect
|
||||
github.com/distribution/reference v0.6.0 // indirect
|
||||
github.com/djherbis/times v1.6.0 // indirect
|
||||
github.com/docker/go-connections v0.5.0
|
||||
github.com/docker/go-units v0.5.0 // indirect
|
||||
github.com/ebitengine/purego v0.8.2 // indirect
|
||||
github.com/ebitengine/purego v0.8.4 // indirect
|
||||
github.com/exoscale/egoscale/v3 v3.1.24 // indirect
|
||||
github.com/fatih/structs v1.1.0 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.0 // indirect
|
||||
github.com/go-logr/logr v1.4.2 // indirect
|
||||
github.com/go-errors/errors v1.5.1 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.1 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // 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/goccy/go-json v0.10.5 // indirect
|
||||
github.com/go-resty/resty/v2 v2.16.5 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
github.com/gofrs/flock v0.12.1 // indirect
|
||||
github.com/gogo/protobuf v1.3.2 // indirect
|
||||
github.com/google/go-querystring v1.1.0 // 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.15.0 // indirect
|
||||
github.com/gophercloud/gophercloud v1.14.1 // indirect
|
||||
github.com/gophercloud/utils v0.0.0-20231010081019-80377eca5d56 // indirect
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
|
||||
github.com/hashicorp/go-retryablehttp v0.7.8 // indirect
|
||||
github.com/hashicorp/go-uuid v1.0.3 // indirect
|
||||
github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.160 // 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/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/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/linode/linodego v1.53.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.65 // indirect
|
||||
github.com/miekg/dns v1.1.67 // 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/moby/docker-image-spec v1.3.1 // indirect
|
||||
github.com/moby/sys/atomicwriter 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/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // 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/nrdcg/porkbun v0.4.0 // indirect
|
||||
github.com/nzdjb/go-metaname v1.0.0 // indirect
|
||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||
github.com/ovh/go-ovh v1.7.0 // indirect
|
||||
github.com/ovh/go-ovh v1.9.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/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.63.0 // indirect
|
||||
github.com/prometheus/procfs v0.16.1 // indirect
|
||||
github.com/sirupsen/logrus v1.9.3 // indirect
|
||||
github.com/pquerna/otp v1.5.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.3.2 // indirect
|
||||
github.com/sacloud/go-http v0.1.9 // indirect
|
||||
github.com/sacloud/iaas-api-go v1.16.1 // indirect
|
||||
github.com/sacloud/packages-go v0.0.11 // indirect
|
||||
github.com/sagikazarmark/locafero v0.9.0 // indirect
|
||||
github.com/samber/lo v1.51.0 // indirect
|
||||
github.com/samber/slog-common v0.19.0 // indirect
|
||||
github.com/scaleway/scaleway-sdk-go v1.0.0-beta.34 // indirect
|
||||
github.com/selectel/domains-go v1.1.0 // 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.9.2 // indirect
|
||||
github.com/spf13/pflag v1.0.7 // 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.1214 // 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.1-20250722213956-faef419 // indirect
|
||||
github.com/vinyldns/go-vinyldns v0.9.16 // indirect
|
||||
github.com/volcengine/volc-sdk-golang v1.0.216 // indirect
|
||||
github.com/vultr/govultr/v3 v3.21.1 // 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.4 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect
|
||||
go.opentelemetry.io/otel v1.35.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.30.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.35.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.35.0 // indirect
|
||||
golang.org/x/mod v0.24.0 // indirect
|
||||
golang.org/x/sync v0.13.0 // indirect
|
||||
golang.org/x/sys v0.32.0 // indirect
|
||||
golang.org/x/tools v0.32.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 // indirect
|
||||
go.opentelemetry.io/otel v1.37.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.37.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.37.0 // indirect
|
||||
go.uber.org/atomic v1.11.0
|
||||
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.26.0 // indirect
|
||||
golang.org/x/sys v0.34.0 // indirect
|
||||
golang.org/x/text v0.27.0 // indirect
|
||||
golang.org/x/tools v0.35.0 // indirect
|
||||
google.golang.org/api v0.243.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250721164621-a45f3dfb1074 // indirect
|
||||
google.golang.org/grpc v1.74.2 // indirect
|
||||
google.golang.org/protobuf v1.36.6 // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
gotest.tools/v3 v3.5.1 // indirect
|
||||
gopkg.in/ns1/ns1-go.v2 v2.14.4 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5 // indirect
|
||||
github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.9 // indirect
|
||||
github.com/alibabacloud-go/debug v1.0.1 // indirect
|
||||
github.com/alibabacloud-go/endpoint-util v1.1.1 // indirect
|
||||
github.com/alibabacloud-go/tea v1.3.10 // indirect
|
||||
github.com/alibabacloud-go/tea-utils/v2 v2.0.7 // indirect
|
||||
github.com/aliyun/credentials-go v1.4.6 // indirect
|
||||
github.com/clbanning/mxj/v2 v2.7.0 // indirect
|
||||
github.com/containerd/errdefs v1.0.0 // indirect
|
||||
github.com/containerd/errdefs/pkg v0.3.0 // indirect
|
||||
github.com/dnsimple/dnsimple-go/v4 v4.0.0 // indirect
|
||||
github.com/go-acme/alidns-20150109/v4 v4.5.11 // indirect
|
||||
github.com/go-acme/tencentclouddnspod v1.0.1208 // indirect
|
||||
github.com/namedotcom/go/v4 v4.0.2 // indirect
|
||||
github.com/nrdcg/oci-go-sdk/common/v1065 v1065.96.0 // indirect
|
||||
github.com/nrdcg/oci-go-sdk/dns/v1065 v1065.96.0 // indirect
|
||||
github.com/onsi/ginkgo/v2 v2.23.3 // indirect
|
||||
github.com/onsi/gomega v1.36.3 // indirect
|
||||
github.com/selectel/go-selvpcclient/v4 v4.1.0 // indirect
|
||||
)
|
||||
|
|
166
internal/acl/config.go
Normal file
166
internal/acl/config.go
Normal file
|
@ -0,0 +1,166 @@
|
|||
package acl
|
||||
|
||||
import (
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"github.com/puzpuzpuz/xsync/v4"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/yusing/go-proxy/internal/common"
|
||||
"github.com/yusing/go-proxy/internal/gperr"
|
||||
"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
|
||||
}
|
||||
log.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
|
||||
}
|
112
internal/acl/matcher.go
Normal file
112
internal/acl/matcher.go
Normal file
|
@ -0,0 +1,112 @@
|
|||
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
|
||||
}
|
||||
}
|
49
internal/acl/matcher_test.go
Normal file
49
internal/acl/matcher_test.go
Normal file
|
@ -0,0 +1,49 @@
|
|||
package acl
|
||||
|
||||
import (
|
||||
"net"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
maxmind "github.com/yusing/go-proxy/internal/maxmind/types"
|
||||
"github.com/yusing/go-proxy/internal/serialization"
|
||||
)
|
||||
|
||||
func TestMatchers(t *testing.T) {
|
||||
strMatchers := []string{
|
||||
"ip:127.0.0.1",
|
||||
"cidr:10.0.0.0/8",
|
||||
}
|
||||
|
||||
var mathers Matchers
|
||||
err := serialization.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)
|
||||
}
|
||||
}
|
||||
}
|
59
internal/acl/tcp_listener.go
Normal file
59
internal/acl/tcp_listener.go
Normal file
|
@ -0,0 +1,59 @@
|
|||
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 (c *Config) WrapTCP(lis net.Listener) net.Listener {
|
||||
if c == nil {
|
||||
return lis
|
||||
}
|
||||
return &TCPListener{
|
||||
acl: c,
|
||||
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()
|
||||
}
|
79
internal/acl/udp_listener.go
Normal file
79
internal/acl/udp_listener.go
Normal file
|
@ -0,0 +1,79 @@
|
|||
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()
|
||||
}
|
|
@ -4,19 +4,18 @@ 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/auth"
|
||||
"github.com/yusing/go-proxy/internal/api/v1/certapi"
|
||||
"github.com/yusing/go-proxy/internal/api/v1/dockerapi"
|
||||
"github.com/yusing/go-proxy/internal/api/v1/favicon"
|
||||
"github.com/yusing/go-proxy/internal/common"
|
||||
"github.com/yusing/go-proxy/internal/auth"
|
||||
config "github.com/yusing/go-proxy/internal/config/types"
|
||||
"github.com/yusing/go-proxy/internal/logging"
|
||||
"github.com/yusing/go-proxy/internal/logging/memlogger"
|
||||
"github.com/yusing/go-proxy/internal/metrics/uptime"
|
||||
"github.com/yusing/go-proxy/internal/net/gphttp/gpwebsocket"
|
||||
"github.com/yusing/go-proxy/internal/net/gphttp/httpheaders"
|
||||
"github.com/yusing/go-proxy/internal/utils/strutils"
|
||||
"github.com/yusing/go-proxy/pkg"
|
||||
)
|
||||
|
||||
type (
|
||||
|
@ -47,7 +46,7 @@ func (mux ServeMux) HandleFunc(methods, endpoint string, h any, requireAuth ...b
|
|||
origHandler := handler
|
||||
handler = func(w http.ResponseWriter, r *http.Request) {
|
||||
if httpheaders.IsWebsocket(r.Header) {
|
||||
httpheaders.SetWebsocketAllowedDomains(r.Header, matchDomains)
|
||||
gpwebsocket.SetWebsocketAllowedDomains(r.Header, matchDomains)
|
||||
}
|
||||
origHandler(w, r)
|
||||
}
|
||||
|
@ -68,13 +67,19 @@ func (mux ServeMux) HandleFunc(methods, endpoint string, h any, requireAuth ...b
|
|||
func NewHandler(cfg config.ConfigInstance) http.Handler {
|
||||
mux := ServeMux{http.NewServeMux(), cfg}
|
||||
mux.HandleFunc("GET", "/v1", v1.Index)
|
||||
mux.HandleFunc("GET", "/v1/version", v1.GetVersion)
|
||||
mux.HandleFunc("GET", "/v1/version", pkg.GetVersionHTTPHandler())
|
||||
|
||||
mux.HandleFunc("GET", "/v1/stats", v1.Stats, true)
|
||||
mux.HandleFunc("POST", "/v1/reload", v1.Reload, true)
|
||||
mux.HandleFunc("GET", "/v1/list", v1.List, true)
|
||||
mux.HandleFunc("GET", "/v1/list/{what}", v1.List, true)
|
||||
mux.HandleFunc("GET", "/v1/list/{what}/{which}", v1.List, true)
|
||||
mux.HandleFunc("GET", "/v1/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)
|
||||
|
@ -93,26 +98,14 @@ func NewHandler(cfg config.ConfigInstance) http.Handler {
|
|||
mux.HandleFunc("GET", "/v1/docker/logs/{server}/{container}", dockerapi.Logs, true)
|
||||
mux.HandleFunc("GET", "/v1/docker/containers", dockerapi.Containers, true)
|
||||
|
||||
if common.PrometheusEnabled {
|
||||
mux.Handle("GET /v1/metrics", promhttp.Handler())
|
||||
logging.Info().Msg("prometheus metrics enabled")
|
||||
}
|
||||
|
||||
defaultAuth := auth.GetDefaultAuth()
|
||||
if defaultAuth != nil {
|
||||
mux.HandleFunc("GET", "/v1/auth/redirect", defaultAuth.RedirectLoginPage)
|
||||
mux.HandleFunc("GET", "/v1/auth/check", func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := defaultAuth.CheckToken(r); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
})
|
||||
mux.HandleFunc("GET,POST", "/v1/auth/callback", defaultAuth.LoginCallbackHandler)
|
||||
mux.HandleFunc("GET,POST", "/v1/auth/logout", defaultAuth.LogoutCallbackHandler)
|
||||
} else {
|
||||
mux.HandleFunc("GET", "/v1/auth/check", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
}
|
||||
if defaultAuth == nil {
|
||||
return mux
|
||||
}
|
||||
|
||||
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)
|
||||
return mux
|
||||
}
|
||||
|
|
|
@ -1,274 +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/logging"
|
||||
"github.com/yusing/go-proxy/internal/net/gphttp"
|
||||
"github.com/yusing/go-proxy/internal/utils"
|
||||
"github.com/yusing/go-proxy/internal/utils/strutils"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
type (
|
||||
OIDCProvider struct {
|
||||
oauthConfig *oauth2.Config
|
||||
oidcProvider *oidc.Provider
|
||||
oidcVerifier *oidc.IDTokenVerifier
|
||||
oidcEndSessionURL *url.URL
|
||||
allowedUsers []string
|
||||
allowedGroups []string
|
||||
isMiddleware bool
|
||||
}
|
||||
|
||||
providerJSON struct {
|
||||
oidc.ProviderConfig
|
||||
EndSessionURL string `json:"end_session_endpoint"`
|
||||
}
|
||||
)
|
||||
|
||||
const CookieOauthState = "godoxy_oidc_state"
|
||||
|
||||
const (
|
||||
OIDCMiddlewareCallbackPath = "/auth/callback"
|
||||
OIDCLogoutPath = "/auth/logout"
|
||||
)
|
||||
|
||||
func NewOIDCProvider(issuerURL, clientID, clientSecret, redirectURL string, allowedUsers, allowedGroups []string) (*OIDCProvider, error) {
|
||||
if len(allowedUsers)+len(allowedGroups) == 0 {
|
||||
return nil, errors.New("OIDC users, groups, or both must not be empty")
|
||||
}
|
||||
provider, err := oidc.NewProvider(context.Background(), 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: redirectURL,
|
||||
Endpoint: provider.Endpoint(),
|
||||
Scopes: strutils.CommaSeperatedList(common.OIDCScopes),
|
||||
},
|
||||
oidcProvider: provider,
|
||||
oidcVerifier: provider.Verifier(&oidc.Config{
|
||||
ClientID: clientID,
|
||||
}),
|
||||
oidcEndSessionURL: endSessionURL,
|
||||
allowedUsers: allowedUsers,
|
||||
allowedGroups: allowedGroups,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// NewOIDCProviderFromEnv creates a new OIDCProvider from environment variables.
|
||||
func NewOIDCProviderFromEnv() (*OIDCProvider, error) {
|
||||
return NewOIDCProvider(
|
||||
common.OIDCIssuerURL,
|
||||
common.OIDCClientID,
|
||||
common.OIDCClientSecret,
|
||||
common.OIDCRedirectURL,
|
||||
common.OIDCAllowedUsers,
|
||||
common.OIDCAllowedGroups,
|
||||
)
|
||||
}
|
||||
|
||||
func (auth *OIDCProvider) TokenCookieName() string {
|
||||
return "godoxy_oidc_token"
|
||||
}
|
||||
|
||||
func (auth *OIDCProvider) SetIsMiddleware(enabled bool) {
|
||||
auth.isMiddleware = enabled
|
||||
auth.oauthConfig.RedirectURL = ""
|
||||
}
|
||||
|
||||
func (auth *OIDCProvider) SetAllowedUsers(users []string) {
|
||||
auth.allowedUsers = users
|
||||
}
|
||||
|
||||
func (auth *OIDCProvider) SetAllowedGroups(groups []string) {
|
||||
auth.allowedGroups = groups
|
||||
}
|
||||
|
||||
func (auth *OIDCProvider) CheckToken(r *http.Request) error {
|
||||
token, err := r.Cookie(auth.TokenCookieName())
|
||||
if err != nil {
|
||||
return ErrMissingToken
|
||||
}
|
||||
|
||||
// checks for Expiry, Audience == ClientID, Issuer, etc.
|
||||
idToken, err := auth.oidcVerifier.Verify(r.Context(), token.Value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to verify ID token: %w: %w", ErrInvalidToken, err)
|
||||
}
|
||||
|
||||
if len(idToken.Audience) == 0 {
|
||||
return ErrInvalidToken
|
||||
}
|
||||
|
||||
var claims struct {
|
||||
Email string `json:"email"`
|
||||
Username string `json:"preferred_username"`
|
||||
Groups []string `json:"groups"`
|
||||
}
|
||||
if err := idToken.Claims(&claims); err != nil {
|
||||
return fmt.Errorf("failed to parse claims: %w", err)
|
||||
}
|
||||
|
||||
// Logical AND between allowed users and groups.
|
||||
allowedUser := slices.Contains(auth.allowedUsers, claims.Username)
|
||||
allowedGroup := len(utils.Intersect(claims.Groups, auth.allowedGroups)) > 0
|
||||
if !allowedUser && !allowedGroup {
|
||||
return ErrUserNotAllowed
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateState generates a random string for OIDC state.
|
||||
const oidcStateLength = 32
|
||||
|
||||
func generateState() (string, error) {
|
||||
b := make([]byte, oidcStateLength)
|
||||
_, err := rand.Read(b)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.URLEncoding.EncodeToString(b)[:oidcStateLength], nil
|
||||
}
|
||||
|
||||
// RedirectOIDC initiates the OIDC login flow.
|
||||
func (auth *OIDCProvider) RedirectLoginPage(w http.ResponseWriter, r *http.Request) {
|
||||
state, err := generateState()
|
||||
if err != nil {
|
||||
gphttp.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: CookieOauthState,
|
||||
Value: state,
|
||||
MaxAge: 300,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Secure: common.APIJWTSecure,
|
||||
Path: "/",
|
||||
})
|
||||
|
||||
var redirURL string
|
||||
if auth.isMiddleware {
|
||||
optOverrideRedirectURL := oauth2.SetAuthURLParam("redirect_uri", "https://"+r.Host+OIDCMiddlewareCallbackPath)
|
||||
redirURL = auth.oauthConfig.AuthCodeURL(state, optOverrideRedirectURL)
|
||||
} else {
|
||||
redirURL = auth.oauthConfig.AuthCodeURL(state)
|
||||
}
|
||||
http.Redirect(w, r, redirURL, http.StatusTemporaryRedirect)
|
||||
}
|
||||
|
||||
func (auth *OIDCProvider) exchange(r *http.Request) (*oauth2.Token, error) {
|
||||
if auth.isMiddleware {
|
||||
cfg := *auth.oauthConfig
|
||||
cfg.RedirectURL = "https://" + r.Host + OIDCMiddlewareCallbackPath
|
||||
return cfg.Exchange(r.Context(), r.URL.Query().Get("code"))
|
||||
}
|
||||
return auth.oauthConfig.Exchange(r.Context(), r.URL.Query().Get("code"))
|
||||
}
|
||||
|
||||
// OIDCCallbackHandler handles the OIDC callback.
|
||||
func (auth *OIDCProvider) LoginCallbackHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// For testing purposes, skip provider verification
|
||||
if common.IsTest {
|
||||
auth.handleTestCallback(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
state, err := r.Cookie(CookieOauthState)
|
||||
if err != nil {
|
||||
gphttp.BadRequest(w, "missing state cookie")
|
||||
return
|
||||
}
|
||||
|
||||
query := r.URL.Query()
|
||||
if query.Get("state") != state.Value {
|
||||
gphttp.BadRequest(w, "invalid oauth state")
|
||||
return
|
||||
}
|
||||
|
||||
oauth2Token, err := auth.exchange(r)
|
||||
if err != nil {
|
||||
gphttp.ServerError(w, r, fmt.Errorf("failed to exchange token: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
rawIDToken, ok := oauth2Token.Extra("id_token").(string)
|
||||
if !ok {
|
||||
gphttp.BadRequest(w, "missing id_token")
|
||||
return
|
||||
}
|
||||
|
||||
idToken, err := auth.oidcVerifier.Verify(r.Context(), rawIDToken)
|
||||
if err != nil {
|
||||
gphttp.ServerError(w, r, fmt.Errorf("failed to verify ID token: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
setTokenCookie(w, r, auth.TokenCookieName(), rawIDToken, time.Until(idToken.Expiry))
|
||||
|
||||
// Redirect to home page
|
||||
http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
|
||||
}
|
||||
|
||||
func (auth *OIDCProvider) LogoutCallbackHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if auth.oidcEndSessionURL == nil {
|
||||
DefaultLogoutCallbackHandler(auth, w, r)
|
||||
return
|
||||
}
|
||||
|
||||
token, err := r.Cookie(auth.TokenCookieName())
|
||||
if err != nil {
|
||||
gphttp.BadRequest(w, "missing token cookie")
|
||||
return
|
||||
}
|
||||
clearTokenCookie(w, r, auth.TokenCookieName())
|
||||
|
||||
logoutURL := *auth.oidcEndSessionURL
|
||||
logoutURL.Query().Add("id_token_hint", token.Value)
|
||||
|
||||
http.Redirect(w, r, logoutURL.String(), http.StatusFound)
|
||||
}
|
||||
|
||||
// handleTestCallback handles OIDC callback in test environment.
|
||||
func (auth *OIDCProvider) handleTestCallback(w http.ResponseWriter, r *http.Request) {
|
||||
state, err := r.Cookie(CookieOauthState)
|
||||
if err != nil {
|
||||
gphttp.BadRequest(w, "missing state cookie")
|
||||
return
|
||||
}
|
||||
|
||||
if r.URL.Query().Get("state") != state.Value {
|
||||
gphttp.BadRequest(w, "invalid oauth state")
|
||||
return
|
||||
}
|
||||
|
||||
// Create test JWT token
|
||||
setTokenCookie(w, r, auth.TokenCookieName(), "test", time.Hour)
|
||||
|
||||
http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
|
||||
}
|
|
@ -1,13 +0,0 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type Provider interface {
|
||||
TokenCookieName() string
|
||||
CheckToken(r *http.Request) error
|
||||
RedirectLoginPage(w http.ResponseWriter, r *http.Request)
|
||||
LoginCallbackHandler(w http.ResponseWriter, r *http.Request)
|
||||
LogoutCallbackHandler(w http.ResponseWriter, r *http.Request)
|
||||
}
|
|
@ -1,80 +0,0 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/yusing/go-proxy/internal/common"
|
||||
"github.com/yusing/go-proxy/internal/gperr"
|
||||
"github.com/yusing/go-proxy/internal/utils/strutils"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrMissingToken = gperr.New("missing token")
|
||||
ErrInvalidToken = gperr.New("invalid token")
|
||||
ErrUserNotAllowed = gperr.New("user not allowed")
|
||||
)
|
||||
|
||||
// cookieFQDN returns the fully qualified domain name of the request host
|
||||
// with subdomain stripped.
|
||||
//
|
||||
// If the request host does not have a subdomain,
|
||||
// an empty string is returned
|
||||
//
|
||||
// "abc.example.com" -> "example.com"
|
||||
// "example.com" -> ""
|
||||
func cookieFQDN(r *http.Request) string {
|
||||
var host string
|
||||
// check if it's from backend
|
||||
switch r.Host {
|
||||
case common.APIHTTPAddr:
|
||||
// use XFH
|
||||
host = r.Header.Get("X-Forwarded-Host")
|
||||
default:
|
||||
var err error
|
||||
host, _, err = net.SplitHostPort(r.Host)
|
||||
if err != nil {
|
||||
host = r.Host
|
||||
}
|
||||
}
|
||||
|
||||
parts := strutils.SplitRune(host, '.')
|
||||
if len(parts) < 2 {
|
||||
return ""
|
||||
}
|
||||
parts[0] = ""
|
||||
return strutils.JoinRune(parts, '.')
|
||||
}
|
||||
|
||||
func setTokenCookie(w http.ResponseWriter, r *http.Request, name, value string, ttl time.Duration) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: name,
|
||||
Value: value,
|
||||
MaxAge: int(ttl.Seconds()),
|
||||
Domain: cookieFQDN(r),
|
||||
HttpOnly: true,
|
||||
Secure: common.APIJWTSecure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Path: "/",
|
||||
})
|
||||
}
|
||||
|
||||
func clearTokenCookie(w http.ResponseWriter, r *http.Request, name string) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: name,
|
||||
Value: "",
|
||||
MaxAge: -1,
|
||||
Domain: cookieFQDN(r),
|
||||
HttpOnly: true,
|
||||
Secure: common.APIJWTSecure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Path: "/",
|
||||
})
|
||||
}
|
||||
|
||||
// DefaultLogoutCallbackHandler clears the token cookie and redirects to the login page..
|
||||
func DefaultLogoutCallbackHandler(auth Provider, w http.ResponseWriter, r *http.Request) {
|
||||
clearTokenCookie(w, r, auth.TokenCookieName())
|
||||
auth.RedirectLoginPage(w, r)
|
||||
}
|
|
@ -3,9 +3,9 @@ package certapi
|
|||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
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"
|
||||
)
|
||||
|
@ -19,11 +19,9 @@ func RenewCert(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
conn, err := gpwebsocket.Initiate(w, r)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
//nolint:errcheck
|
||||
defer conn.CloseNow()
|
||||
defer conn.Close()
|
||||
|
||||
logs, cancel := memlogger.Events()
|
||||
defer cancel()
|
||||
|
@ -35,9 +33,9 @@ func RenewCert(w http.ResponseWriter, r *http.Request) {
|
|||
err = autocert.ObtainCert()
|
||||
if err != nil {
|
||||
gperr.LogError("failed to obtain cert", err)
|
||||
gpwebsocket.WriteText(r, conn, err.Error())
|
||||
_ = gpwebsocket.WriteText(conn, err.Error())
|
||||
} else {
|
||||
logging.Info().Msg("cert obtained successfully")
|
||||
log.Info().Msg("cert obtained successfully")
|
||||
}
|
||||
}()
|
||||
for {
|
||||
|
@ -46,7 +44,7 @@ func RenewCert(w http.ResponseWriter, r *http.Request) {
|
|||
if err != nil {
|
||||
return
|
||||
}
|
||||
if !gpwebsocket.WriteText(r, conn, string(l)) {
|
||||
if err := gpwebsocket.WriteText(conn, string(l)); err != nil {
|
||||
return
|
||||
}
|
||||
case <-done:
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
package v1
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
|
@ -51,12 +52,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 = gphttp.ErrInvalidKey("type")
|
||||
err = fmt.Errorf("invalid file type: %s", fileType)
|
||||
return
|
||||
}
|
||||
filename = r.PathValue("filename")
|
||||
if filename == "" {
|
||||
err = gphttp.ErrMissingKey("filename")
|
||||
err = fmt.Errorf("missing filename")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
|
|
@ -1,5 +0,0 @@
|
|||
package dockerapi
|
||||
|
||||
import "time"
|
||||
|
||||
const reqTimeout = 10 * time.Second
|
|
@ -18,7 +18,7 @@ type Container struct {
|
|||
}
|
||||
|
||||
func Containers(w http.ResponseWriter, r *http.Request) {
|
||||
serveHTTP[Container, []Container](w, r, GetContainers)
|
||||
serveHTTP[Container](w, r, GetContainers)
|
||||
}
|
||||
|
||||
func GetContainers(ctx context.Context, dockerClients DockerClients) ([]Container, gperr.Error) {
|
||||
|
|
|
@ -25,7 +25,7 @@ func (d *dockerInfo) MarshalJSON() ([]byte, error) {
|
|||
},
|
||||
"images": d.Images,
|
||||
"n_cpu": d.NCPU,
|
||||
"memory": strutils.FormatByteSizeWithUnit(d.MemTotal),
|
||||
"memory": strutils.FormatByteSize(d.MemTotal),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
@ -1,28 +1,32 @@
|
|||
package dockerapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"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/gorilla/websocket"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/yusing/go-proxy/internal/net/gphttp"
|
||||
"github.com/yusing/go-proxy/internal/net/gphttp/gpwebsocket"
|
||||
"github.com/yusing/go-proxy/internal/utils/strutils"
|
||||
"github.com/yusing/go-proxy/internal/task"
|
||||
)
|
||||
|
||||
// FIXME: agent logs not updating.
|
||||
func Logs(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query()
|
||||
server := r.PathValue("server")
|
||||
containerID := r.PathValue("container")
|
||||
stdout := strutils.ParseBool(query.Get("stdout"))
|
||||
stderr := strutils.ParseBool(query.Get("stderr"))
|
||||
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(w, server)
|
||||
dockerClient, found, err := getDockerClient(server)
|
||||
if err != nil {
|
||||
gphttp.BadRequest(w, err.Error())
|
||||
return
|
||||
|
@ -31,6 +35,7 @@ func Logs(w http.ResponseWriter, r *http.Request) {
|
|||
gphttp.NotFound(w, "server not found")
|
||||
return
|
||||
}
|
||||
defer dockerClient.Close()
|
||||
|
||||
opts := container.LogsOptions{
|
||||
ShowStdout: stdout,
|
||||
|
@ -56,12 +61,15 @@ func Logs(w http.ResponseWriter, r *http.Request) {
|
|||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.CloseNow()
|
||||
defer conn.Close()
|
||||
|
||||
writer := gpwebsocket.NewWriter(r.Context(), conn, websocket.MessageText)
|
||||
writer := gpwebsocket.NewWriter(r.Context(), conn, websocket.TextMessage)
|
||||
_, err = stdcopy.StdCopy(writer, writer, logs) // de-multiplex logs
|
||||
if err != nil {
|
||||
logging.Err(err).
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, task.ErrProgramExiting) {
|
||||
return
|
||||
}
|
||||
log.Err(err).
|
||||
Str("server", server).
|
||||
Str("container", containerID).
|
||||
Msg("failed to de-multiplex logs")
|
||||
|
|
|
@ -6,8 +6,8 @@ import (
|
|||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
"github.com/coder/websocket/wsjson"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/yusing/go-proxy/agent/pkg/agent"
|
||||
config "github.com/yusing/go-proxy/internal/config/types"
|
||||
"github.com/yusing/go-proxy/internal/docker"
|
||||
"github.com/yusing/go-proxy/internal/gperr"
|
||||
|
@ -44,7 +44,7 @@ func getDockerClients() (DockerClients, gperr.Error) {
|
|||
dockerClients[name] = dockerClient
|
||||
}
|
||||
|
||||
for _, agent := range cfg.ListAgents() {
|
||||
for _, agent := range agent.ListAgents() {
|
||||
dockerClient, err := docker.NewClient(agent.FakeDockerHost())
|
||||
if err != nil {
|
||||
connErrs.Add(err)
|
||||
|
@ -56,7 +56,7 @@ func getDockerClients() (DockerClients, gperr.Error) {
|
|||
return dockerClients, connErrs.Error()
|
||||
}
|
||||
|
||||
func getDockerClient(w http.ResponseWriter, server string) (*docker.SharedClient, bool, error) {
|
||||
func getDockerClient(server string) (*docker.SharedClient, bool, error) {
|
||||
cfg := config.GetInstance()
|
||||
var host string
|
||||
for name, h := range cfg.Value().Providers.Docker {
|
||||
|
@ -65,12 +65,14 @@ func getDockerClient(w http.ResponseWriter, server string) (*docker.SharedClient
|
|||
break
|
||||
}
|
||||
}
|
||||
for _, agent := range cfg.ListAgents() {
|
||||
if host == "" {
|
||||
for _, agent := range agent.ListAgents() {
|
||||
if agent.Name() == server {
|
||||
host = agent.FakeDockerHost()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if host == "" {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
@ -98,7 +100,7 @@ func handleResult[V any, T ResultType[V]](w http.ResponseWriter, errs error, res
|
|||
return
|
||||
}
|
||||
}
|
||||
json.NewEncoder(w).Encode(result)
|
||||
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)) {
|
||||
|
@ -115,10 +117,10 @@ func serveHTTP[V any, T ResultType[V]](w http.ResponseWriter, r *http.Request, g
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return wsjson.Write(r.Context(), conn, result)
|
||||
return conn.WriteJSON(result)
|
||||
})
|
||||
} else {
|
||||
result, err := getResult(r.Context(), dockerClients)
|
||||
handleResult[V, T](w, err, result)
|
||||
handleResult[V](w, err, result)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,138 +0,0 @@
|
|||
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 = 3 * 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 if len(iconCache) > 0 {
|
||||
logging.Info().Int("count", len(iconCache)).Msg("icon cache loaded")
|
||||
}
|
||||
|
||||
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++
|
||||
}
|
||||
}
|
||||
if nPruned > 0 {
|
||||
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
|
||||
}
|
|
@ -1,49 +1,11 @@
|
|||
package favicon
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
"github.com/vincent-petithory/dataurl"
|
||||
"github.com/yusing/go-proxy/internal/gperr"
|
||||
"github.com/yusing/go-proxy/internal/homepage"
|
||||
"github.com/yusing/go-proxy/internal/logging"
|
||||
gphttp "github.com/yusing/go-proxy/internal/net/gphttp"
|
||||
"github.com/yusing/go-proxy/internal/net/gphttp"
|
||||
"github.com/yusing/go-proxy/internal/route/routes"
|
||||
route "github.com/yusing/go-proxy/internal/route/types"
|
||||
"github.com/yusing/go-proxy/internal/utils/strutils"
|
||||
)
|
||||
|
||||
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"
|
||||
}
|
||||
return "image/x-icon"
|
||||
}
|
||||
return res.contentType
|
||||
}
|
||||
|
||||
const (
|
||||
MaxRedirectDepth = 5
|
||||
)
|
||||
|
||||
// GetFavIcon returns the favicon of the route
|
||||
|
@ -57,11 +19,11 @@ const (
|
|||
func GetFavIcon(w http.ResponseWriter, req *http.Request) {
|
||||
url, alias := req.FormValue("url"), req.FormValue("alias")
|
||||
if url == "" && alias == "" {
|
||||
gphttp.ClientError(w, gphttp.ErrMissingKey("url or alias"), http.StatusBadRequest)
|
||||
gphttp.MissingKey(w, "url or alias")
|
||||
return
|
||||
}
|
||||
if url != "" && alias != "" {
|
||||
gphttp.ClientError(w, gperr.New("url and alias are mutually exclusive"), http.StatusBadRequest)
|
||||
gphttp.BadRequest(w, "url and alias are mutually exclusive")
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -69,216 +31,51 @@ func GetFavIcon(w http.ResponseWriter, req *http.Request) {
|
|||
if url != "" {
|
||||
var iconURL homepage.IconURL
|
||||
if err := iconURL.Parse(url); err != nil {
|
||||
gphttp.ClientError(w, err, http.StatusBadRequest)
|
||||
gphttp.ClientError(w, req, err, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
fetchResult := getFavIconFromURL(&iconURL)
|
||||
fetchResult := homepage.FetchFavIconFromURL(req.Context(), &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)
|
||||
gphttp.WriteBody(w, fetchResult.Icon)
|
||||
return
|
||||
}
|
||||
|
||||
// try with route.Homepage.Icon
|
||||
r, ok := routes.GetHTTPRoute(alias)
|
||||
// try with alias
|
||||
GetFavIconFromAlias(w, req, alias)
|
||||
return
|
||||
}
|
||||
|
||||
func GetFavIconFromAlias(w http.ResponseWriter, req *http.Request, alias string) {
|
||||
// try with route.Icon
|
||||
r, ok := routes.HTTP.Get(alias)
|
||||
if !ok {
|
||||
gphttp.ClientError(w, errors.New("no such route"), http.StatusNotFound)
|
||||
gphttp.ValueNotFound(w, "route", alias)
|
||||
return
|
||||
}
|
||||
|
||||
var result *fetchResult
|
||||
var result *homepage.FetchResult
|
||||
hp := r.HomepageItem()
|
||||
if hp.Icon != nil {
|
||||
if hp.Icon.IconSource == homepage.IconSourceRelative {
|
||||
result = findIcon(r, req, hp.Icon.Value)
|
||||
result = homepage.FindIcon(req.Context(), r, *hp.Icon.FullURL)
|
||||
} else {
|
||||
result = getFavIconFromURL(hp.Icon)
|
||||
result = homepage.FetchFavIconFromURL(req.Context(), hp.Icon)
|
||||
}
|
||||
} else {
|
||||
// try extract from "link[rel=icon]"
|
||||
result = findIcon(r, req, "/")
|
||||
result = homepage.FindIcon(req.Context(), r, "/")
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
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 := gphttp.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.Image.Name))
|
||||
}
|
||||
if !result.OK() {
|
||||
// fallback to parse html
|
||||
result = findIconSlow(r, req, uri, 0)
|
||||
}
|
||||
if result.OK() {
|
||||
storeIconCache(key, result.icon)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func findIconSlow(r route.HTTPRoute, req *http.Request, uri string, depth int) *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
|
||||
u, err := url.ParseRequestURI(strutils.SanitizeURI(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 != "" {
|
||||
if depth > MaxRedirectDepth {
|
||||
return &fetchResult{statusCode: http.StatusBadGateway, errMsg: "too many redirects"}
|
||||
}
|
||||
loc = strutils.SanitizeURI(loc)
|
||||
if loc == "/" || loc == newReq.URL.Path {
|
||||
return &fetchResult{statusCode: http.StatusBadGateway, errMsg: "circular redirect"}
|
||||
}
|
||||
return findIconSlow(r, req, loc, depth+1)
|
||||
}
|
||||
}
|
||||
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, href, 0)
|
||||
}
|
||||
gphttp.WriteBody(w, result.Icon)
|
||||
}
|
||||
|
|
|
@ -4,20 +4,19 @@ import (
|
|||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
"github.com/coder/websocket/wsjson"
|
||||
"github.com/gorilla/websocket"
|
||||
"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/routequery"
|
||||
"github.com/yusing/go-proxy/internal/route/routes"
|
||||
)
|
||||
|
||||
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, routequery.HealthMap())
|
||||
return conn.WriteJSON(routes.HealthMap())
|
||||
})
|
||||
} else {
|
||||
gphttp.RespondJSON(w, r, routequery.HealthMap())
|
||||
gphttp.RespondJSON(w, r, routes.HealthMap())
|
||||
}
|
||||
}
|
||||
|
|
|
@ -43,7 +43,7 @@ func SetHomePageOverrides(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
data, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
gphttp.ClientError(w, err, http.StatusBadRequest)
|
||||
gphttp.ClientError(w, r, err, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
r.Body.Close()
|
||||
|
@ -53,21 +53,21 @@ func SetHomePageOverrides(w http.ResponseWriter, r *http.Request) {
|
|||
case HomepageOverrideItem:
|
||||
var params HomepageOverrideItemParams
|
||||
if err := json.Unmarshal(data, ¶ms); err != nil {
|
||||
gphttp.ClientError(w, err, http.StatusBadRequest)
|
||||
gphttp.ClientError(w, r, err, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
overrides.OverrideItem(params.Which, ¶ms.Value)
|
||||
case HomepageOverrideItemsBatch:
|
||||
var params HomepageOverrideItemsBatchParams
|
||||
if err := json.Unmarshal(data, ¶ms); err != nil {
|
||||
gphttp.ClientError(w, err, http.StatusBadRequest)
|
||||
gphttp.ClientError(w, r, 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, ¶ms); err != nil {
|
||||
gphttp.ClientError(w, err, http.StatusBadRequest)
|
||||
gphttp.ClientError(w, r, err, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if params.Value {
|
||||
|
@ -78,7 +78,7 @@ func SetHomePageOverrides(w http.ResponseWriter, r *http.Request) {
|
|||
case HomepageOverrideCategoryOrder:
|
||||
var params HomepageOverrideCategoryOrderParams
|
||||
if err := json.Unmarshal(data, ¶ms); err != nil {
|
||||
gphttp.ClientError(w, err, http.StatusBadRequest)
|
||||
gphttp.ClientError(w, r, err, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
overrides.SetCategoryOrder(params.Which, params.Value)
|
||||
|
|
|
@ -1,128 +0,0 @@
|
|||
package v1
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/yusing/go-proxy/internal"
|
||||
"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/net/gphttp/middleware"
|
||||
"github.com/yusing/go-proxy/internal/route/routes/routequery"
|
||||
route "github.com/yusing/go-proxy/internal/route/types"
|
||||
"github.com/yusing/go-proxy/internal/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:
|
||||
route := listRoute(which)
|
||||
if route == nil {
|
||||
http.NotFound(w, r)
|
||||
} else {
|
||||
gphttp.RespondJSON(w, r, route)
|
||||
}
|
||||
case ListRoutes:
|
||||
gphttp.RespondJSON(w, r, routequery.RoutesByAlias(route.RouteType(r.FormValue("type"))))
|
||||
case ListFiles:
|
||||
listFiles(w, r)
|
||||
case ListMiddlewares:
|
||||
gphttp.RespondJSON(w, r, middleware.All())
|
||||
case ListMiddlewareTraces:
|
||||
gphttp.RespondJSON(w, r, middleware.GetAllTrace())
|
||||
case ListMatchDomains:
|
||||
gphttp.RespondJSON(w, r, cfg.Value().MatchDomains)
|
||||
case ListHomepageConfig:
|
||||
gphttp.RespondJSON(w, r, routequery.HomepageConfig(r.FormValue("category"), r.FormValue("provider")))
|
||||
case ListRouteProviders:
|
||||
gphttp.RespondJSON(w, r, cfg.RouteProviderList())
|
||||
case ListHomepageCategories:
|
||||
gphttp.RespondJSON(w, r, routequery.HomepageCategories())
|
||||
case ListIcons:
|
||||
limit, err := strconv.Atoi(r.FormValue("limit"))
|
||||
if err != nil {
|
||||
limit = 0
|
||||
}
|
||||
icons, err := internal.SearchIcons(r.FormValue("keyword"), limit)
|
||||
if err != nil {
|
||||
gphttp.ClientError(w, err)
|
||||
return
|
||||
}
|
||||
if icons == nil {
|
||||
icons = []string{}
|
||||
}
|
||||
gphttp.RespondJSON(w, r, icons)
|
||||
case ListTasks:
|
||||
gphttp.RespondJSON(w, r, task.DebugTaskList())
|
||||
default:
|
||||
gphttp.BadRequest(w, fmt.Sprintf("invalid what: %s", what))
|
||||
}
|
||||
}
|
||||
|
||||
// if which is "all" or empty, return map[string]Route of all routes
|
||||
// otherwise, return a single Route with alias which or nil if not found.
|
||||
func listRoute(which string) any {
|
||||
if which == "" || which == "all" {
|
||||
return routequery.RoutesByAlias()
|
||||
}
|
||||
routes := routequery.RoutesByAlias()
|
||||
route, ok := routes[which]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return route
|
||||
}
|
||||
|
||||
func listFiles(w http.ResponseWriter, r *http.Request) {
|
||||
files, err := utils.ListFiles(common.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)
|
||||
}
|
|
@ -4,21 +4,19 @@ import (
|
|||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
"github.com/coder/websocket/wsjson"
|
||||
config "github.com/yusing/go-proxy/internal/config/types"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/yusing/go-proxy/agent/pkg/agent"
|
||||
"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) {
|
||||
func ListAgents(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
|
||||
return conn.WriteJSON(agent.ListAgents())
|
||||
})
|
||||
} else {
|
||||
gphttp.RespondJSON(w, r, cfg.ListAgents())
|
||||
gphttp.RespondJSON(w, r, agent.ListAgents())
|
||||
}
|
||||
}
|
41
internal/api/v1/list_files.go
Normal file
41
internal/api/v1/list_files.go
Normal file
|
@ -0,0 +1,41 @@
|
|||
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)
|
||||
}
|
13
internal/api/v1/list_homepage_categories.go
Normal file
13
internal/api/v1/list_homepage_categories.go
Normal file
|
@ -0,0 +1,13 @@
|
|||
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())
|
||||
}
|
13
internal/api/v1/list_homepage_config.go
Normal file
13
internal/api/v1/list_homepage_config.go
Normal file
|
@ -0,0 +1,13 @@
|
|||
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")))
|
||||
}
|
23
internal/api/v1/list_icons.go
Normal file
23
internal/api/v1/list_icons.go
Normal file
|
@ -0,0 +1,23 @@
|
|||
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)
|
||||
}
|
19
internal/api/v1/list_route.go
Normal file
19
internal/api/v1/list_route.go
Normal file
|
@ -0,0 +1,19 @@
|
|||
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)
|
||||
}
|
||||
}
|
22
internal/api/v1/list_route_providers.go
Normal file
22
internal/api/v1/list_route_providers.go
Normal file
|
@ -0,0 +1,22 @@
|
|||
package v1
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
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 conn.WriteJSON(cfgInstance.RouteProviderList())
|
||||
})
|
||||
} else {
|
||||
gphttp.RespondJSON(w, r, cfgInstance.RouteProviderList())
|
||||
}
|
||||
}
|
25
internal/api/v1/list_routes.go
Normal file
25
internal/api/v1/list_routes.go
Normal file
|
@ -0,0 +1,25 @@
|
|||
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)
|
||||
}
|
13
internal/api/v1/list_routes_by_provider.go
Normal file
13
internal/api/v1/list_routes_by_provider.go
Normal file
|
@ -0,0 +1,13 @@
|
|||
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())
|
||||
}
|
|
@ -14,34 +14,33 @@ import (
|
|||
"github.com/yusing/go-proxy/agent/pkg/certs"
|
||||
config "github.com/yusing/go-proxy/internal/config/types"
|
||||
"github.com/yusing/go-proxy/internal/net/gphttp"
|
||||
"github.com/yusing/go-proxy/internal/utils/strutils"
|
||||
)
|
||||
|
||||
func NewAgent(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
name := q.Get("name")
|
||||
if name == "" {
|
||||
gphttp.ClientError(w, gphttp.ErrMissingKey("name"))
|
||||
gphttp.MissingKey(w, "name")
|
||||
return
|
||||
}
|
||||
host := q.Get("host")
|
||||
if host == "" {
|
||||
gphttp.ClientError(w, gphttp.ErrMissingKey("host"))
|
||||
gphttp.MissingKey(w, "host")
|
||||
return
|
||||
}
|
||||
portStr := q.Get("port")
|
||||
if portStr == "" {
|
||||
gphttp.ClientError(w, gphttp.ErrMissingKey("port"))
|
||||
gphttp.MissingKey(w, "port")
|
||||
return
|
||||
}
|
||||
port, err := strconv.Atoi(portStr)
|
||||
if err != nil || port < 1 || port > 65535 {
|
||||
gphttp.ClientError(w, gphttp.ErrInvalidKey("port"))
|
||||
gphttp.InvalidKey(w, "port")
|
||||
return
|
||||
}
|
||||
hostport := fmt.Sprintf("%s:%d", host, port)
|
||||
if _, ok := config.GetInstance().GetAgent(hostport); ok {
|
||||
gphttp.ClientError(w, gphttp.ErrAlreadyExists("agent", hostport), http.StatusConflict)
|
||||
if _, ok := agent.GetAgent(hostport); ok {
|
||||
gphttp.KeyAlreadyExists(w, "agent", hostport)
|
||||
return
|
||||
}
|
||||
t := q.Get("type")
|
||||
|
@ -49,14 +48,14 @@ func NewAgent(w http.ResponseWriter, r *http.Request) {
|
|||
case "docker", "system":
|
||||
break
|
||||
case "":
|
||||
gphttp.ClientError(w, gphttp.ErrMissingKey("type"))
|
||||
gphttp.MissingKey(w, "type")
|
||||
return
|
||||
default:
|
||||
gphttp.ClientError(w, gphttp.ErrInvalidKey("type"))
|
||||
gphttp.InvalidKey(w, "type")
|
||||
return
|
||||
}
|
||||
|
||||
nightly := strutils.ParseBool(q.Get("nightly"))
|
||||
nightly, _ := strconv.ParseBool(q.Get("nightly"))
|
||||
var image string
|
||||
if nightly {
|
||||
image = agent.DockerImageNightly
|
||||
|
@ -110,13 +109,13 @@ func VerifyNewAgent(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
if err := json.Unmarshal(clientPEMData, &data); err != nil {
|
||||
gphttp.ClientError(w, err, http.StatusBadRequest)
|
||||
gphttp.ClientError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
nRoutesAdded, err := config.GetInstance().VerifyNewAgent(data.Host, data.CA, data.Client)
|
||||
if err != nil {
|
||||
gphttp.ClientError(w, err)
|
||||
gphttp.ClientError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -128,7 +127,7 @@ func VerifyNewAgent(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
filename, ok := certs.AgentCertsFilepath(data.Host)
|
||||
if !ok {
|
||||
gphttp.ClientError(w, gphttp.ErrInvalidKey("host"))
|
||||
gphttp.InvalidKey(w, "host")
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
@ -1,64 +0,0 @@
|
|||
package query
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
v1 "github.com/yusing/go-proxy/internal/api/v1"
|
||||
"github.com/yusing/go-proxy/internal/common"
|
||||
"github.com/yusing/go-proxy/internal/gperr"
|
||||
"github.com/yusing/go-proxy/internal/net/gphttp"
|
||||
"github.com/yusing/go-proxy/internal/net/gphttp/middleware"
|
||||
)
|
||||
|
||||
func ReloadServer() gperr.Error {
|
||||
resp, err := gphttp.Post(common.APIHTTPURL+"/v1/reload", "", nil)
|
||||
if err != nil {
|
||||
return gperr.Wrap(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
failure := gperr.Errorf("server reload status %v", resp.StatusCode)
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return failure.With(err)
|
||||
}
|
||||
reloadErr := string(body)
|
||||
return failure.Withf(reloadErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func List[T any](what string) (_ T, outErr gperr.Error) {
|
||||
resp, err := gphttp.Get(fmt.Sprintf("%s/v1/list/%s", common.APIHTTPURL, what))
|
||||
if err != nil {
|
||||
outErr = gperr.Wrap(err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
outErr = gperr.Errorf("list %s: failed, status %v", what, resp.StatusCode)
|
||||
return
|
||||
}
|
||||
var res T
|
||||
err = json.NewDecoder(resp.Body).Decode(&res)
|
||||
if err != nil {
|
||||
outErr = gperr.Wrap(err)
|
||||
return
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func ListRoutes() (map[string]map[string]any, gperr.Error) {
|
||||
return List[map[string]map[string]any](v1.ListRoutes)
|
||||
}
|
||||
|
||||
func ListMiddlewareTraces() (middleware.Traces, gperr.Error) {
|
||||
return List[middleware.Traces](v1.ListMiddlewareTraces)
|
||||
}
|
||||
|
||||
func DebugListTasks() (map[string]any, gperr.Error) {
|
||||
return List[map[string]any](v1.ListTasks)
|
||||
}
|
|
@ -4,8 +4,7 @@ import (
|
|||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
"github.com/coder/websocket/wsjson"
|
||||
"github.com/gorilla/websocket"
|
||||
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"
|
||||
|
@ -16,7 +15,7 @@ import (
|
|||
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 {
|
||||
return wsjson.Write(r.Context(), conn, getStats(cfg))
|
||||
return conn.WriteJSON(getStats(cfg))
|
||||
})
|
||||
} else {
|
||||
gphttp.RespondJSON(w, r, getStats(cfg))
|
||||
|
|
|
@ -4,15 +4,15 @@ 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"
|
||||
nettypes "github.com/yusing/go-proxy/internal/net/types"
|
||||
)
|
||||
|
||||
func SystemInfo(cfg config.ConfigInstance, w http.ResponseWriter, r *http.Request) {
|
||||
func SystemInfo(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query()
|
||||
agentAddr := query.Get("agent_addr")
|
||||
query.Del("agent_addr")
|
||||
|
@ -21,7 +21,7 @@ func SystemInfo(cfg config.ConfigInstance, w http.ResponseWriter, r *http.Reques
|
|||
return
|
||||
}
|
||||
|
||||
agent, ok := cfg.GetAgent(agentAddr)
|
||||
agent, ok := agentPkg.GetAgent(agentAddr)
|
||||
if !ok {
|
||||
gphttp.NotFound(w, "agent_addr")
|
||||
return
|
||||
|
@ -40,7 +40,7 @@ func SystemInfo(cfg config.ConfigInstance, w http.ResponseWriter, r *http.Reques
|
|||
}
|
||||
gphttp.WriteBody(w, respData)
|
||||
} else {
|
||||
rp := reverseproxy.NewReverseProxy("agent", agentPkg.AgentURL, agent.Transport())
|
||||
rp := reverseproxy.NewReverseProxy("agent", nettypes.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 {
|
||||
|
|
|
@ -1,12 +0,0 @@
|
|||
package v1
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/yusing/go-proxy/internal/net/gphttp"
|
||||
"github.com/yusing/go-proxy/pkg"
|
||||
)
|
||||
|
||||
func GetVersion(w http.ResponseWriter, r *http.Request) {
|
||||
gphttp.WriteBody(w, []byte(pkg.GetVersion()))
|
||||
}
|
|
@ -38,15 +38,36 @@ func IsOIDCEnabled() bool {
|
|||
return common.OIDCIssuerURL != ""
|
||||
}
|
||||
|
||||
type nextHandler struct{}
|
||||
|
||||
var nextHandlerContextKey = nextHandler{}
|
||||
|
||||
func RequireAuth(next http.HandlerFunc) http.HandlerFunc {
|
||||
if IsEnabled() {
|
||||
if !IsEnabled() {
|
||||
return next
|
||||
}
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := defaultAuth.CheckToken(r); err != nil {
|
||||
gphttp.ClientError(w, err, http.StatusUnauthorized)
|
||||
} else {
|
||||
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)
|
||||
}
|
||||
return next
|
||||
}
|
221
internal/auth/oauth_refresh.go
Normal file
221
internal/auth/oauth_refresh.go
Normal file
|
@ -0,0 +1,221 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/yusing/go-proxy/internal/common"
|
||||
"github.com/yusing/go-proxy/internal/jsonstore"
|
||||
"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),
|
||||
})
|
||||
log.Debug().Str("username", username).Msg("stored oauth refresh token")
|
||||
}
|
||||
|
||||
func invalidateOAuthRefreshToken(sessionID sessionID) {
|
||||
log.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 {
|
||||
log.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()
|
||||
|
||||
log.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
|
||||
}
|
351
internal/auth/oidc.go
Normal file
351
internal/auth/oidc.go
Normal file
|
@ -0,0 +1,351 @@
|
|||
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/rs/zerolog/log"
|
||||
"github.com/yusing/go-proxy/internal/common"
|
||||
"github.com/yusing/go-proxy/internal/gperr"
|
||||
"github.com/yusing/go-proxy/internal/net/gphttp"
|
||||
"github.com/yusing/go-proxy/internal/utils"
|
||||
"golang.org/x/oauth2"
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
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" //nolint:gosec
|
||||
CookieOauthSessionToken = "godoxy_session_token" //nolint:gosec
|
||||
)
|
||||
|
||||
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
|
||||
log.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)
|
||||
}
|
||||
}
|
||||
|
||||
var rateLimit = rate.NewLimiter(rate.Every(time.Second), 1)
|
||||
|
||||
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
|
||||
log.Err(err).Msg("failed to refresh token")
|
||||
auth.clearCookie(w, r)
|
||||
http.Redirect(w, r, "/", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
if !rateLimit.Allow() {
|
||||
http.Error(w, "auth rate limit exceeded", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
|
||||
state := generateState()
|
||||
SetTokenCookie(w, r, CookieOauthState, state, 300*time.Second)
|
||||
// redirect user to Idp
|
||||
url := auth.oauthConfig.AuthCodeURL(state, optRedirectPostAuth(r))
|
||||
if IsFrontend(r) {
|
||||
w.Header().Set("X-Redirect-To", url)
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
} else {
|
||||
http.Redirect(w, r, url, 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 true
|
||||
}
|
||||
if len(auth.allowedGroups) == 0 {
|
||||
// user is not allowed, but no groups are allowed
|
||||
return false
|
||||
}
|
||||
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)
|
||||
}
|
|
@ -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(context.TODO())
|
||||
provider := (&oidc.ProviderConfig{}).NewProvider(t.Context())
|
||||
defaultAuth = &OIDCProvider{
|
||||
oauthConfig: &oauth2.Config{
|
||||
ClientID: "test-client",
|
||||
|
@ -35,6 +35,7 @@ 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",
|
||||
|
@ -102,7 +103,7 @@ func setupProvider(t *testing.T) *provider {
|
|||
t.Cleanup(ts.Close)
|
||||
|
||||
// Create a test OIDCProvider.
|
||||
providerCtx := oidc.ClientContext(context.Background(), ts.Client())
|
||||
providerCtx := oidc.ClientContext(t.Context(), ts.Client())
|
||||
keySet := oidc.NewRemoteKeySet(providerCtx, ts.URL+"/.well-known/jwks.json")
|
||||
|
||||
return &provider{
|
||||
|
@ -148,17 +149,17 @@ func TestOIDCLoginHandler(t *testing.T) {
|
|||
}{
|
||||
{
|
||||
name: "Success - Redirects to provider",
|
||||
wantStatus: http.StatusTemporaryRedirect,
|
||||
wantStatus: http.StatusFound,
|
||||
wantRedirect: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/auth/redirect", nil)
|
||||
req := httptest.NewRequest(http.MethodGet, OIDCAuthInitPath, nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
defaultAuth.RedirectLoginPage(w, req)
|
||||
defaultAuth.(*OIDCProvider).HandleAuth(w, req)
|
||||
|
||||
if got := w.Code; got != tt.wantStatus {
|
||||
t.Errorf("OIDCLoginHandler() status = %v, want %v", got, tt.wantStatus)
|
||||
|
@ -194,7 +195,7 @@ func TestOIDCCallbackHandler(t *testing.T) {
|
|||
state: "valid-state",
|
||||
code: "valid-code",
|
||||
setupMocks: true,
|
||||
wantStatus: http.StatusTemporaryRedirect,
|
||||
wantStatus: http.StatusFound,
|
||||
},
|
||||
{
|
||||
name: "Failure - Missing state",
|
||||
|
@ -219,7 +220,7 @@ func TestOIDCCallbackHandler(t *testing.T) {
|
|||
}
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
defaultAuth.LoginCallbackHandler(w, req)
|
||||
defaultAuth.(*OIDCProvider).PostAuthCallbackHandler(w, req)
|
||||
|
||||
if got := w.Code; got != tt.wantStatus {
|
||||
t.Errorf("OIDCCallbackHandler() status = %v, want %v", got, tt.wantStatus)
|
||||
|
@ -227,7 +228,7 @@ func TestOIDCCallbackHandler(t *testing.T) {
|
|||
|
||||
if tt.wantStatus == http.StatusTemporaryRedirect {
|
||||
setCookie := Must(http.ParseSetCookie(w.Header().Get("Set-Cookie")))
|
||||
ExpectEqual(t, setCookie.Name, defaultAuth.TokenCookieName())
|
||||
ExpectEqual(t, setCookie.Name, CookieOauthToken)
|
||||
ExpectTrue(t, setCookie.Value != "")
|
||||
ExpectEqual(t, setCookie.Path, "/")
|
||||
ExpectEqual(t, setCookie.SameSite, http.SameSiteLaxMode)
|
||||
|
@ -270,7 +271,6 @@ 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,7 +279,6 @@ 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,
|
||||
},
|
||||
|
@ -288,7 +287,6 @@ 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"},
|
||||
|
@ -299,14 +297,13 @@ 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.redirectURL, tt.allowedUsers, tt.allowedGroups)
|
||||
_, err := NewOIDCProvider(tt.issuerURL, tt.clientID, tt.clientSecret, tt.allowedUsers, tt.allowedGroups)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("InitOIDC() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
|
@ -400,7 +397,7 @@ func TestCheckToken(t *testing.T) {
|
|||
"preferred_username": "user1",
|
||||
"groups": []string{"group1"},
|
||||
},
|
||||
wantErr: ErrInvalidToken,
|
||||
wantErr: ErrInvalidOAuthToken,
|
||||
},
|
||||
{
|
||||
name: "Error - Server returns incorrect audience",
|
||||
|
@ -411,7 +408,7 @@ func TestCheckToken(t *testing.T) {
|
|||
"preferred_username": "user1",
|
||||
"groups": []string{"group1"},
|
||||
},
|
||||
wantErr: ErrInvalidToken,
|
||||
wantErr: ErrInvalidOAuthToken,
|
||||
},
|
||||
{
|
||||
name: "Error - Server returns expired token",
|
||||
|
@ -422,7 +419,7 @@ func TestCheckToken(t *testing.T) {
|
|||
"preferred_username": "user1",
|
||||
"groups": []string{"group1"},
|
||||
},
|
||||
wantErr: ErrInvalidToken,
|
||||
wantErr: ErrInvalidOAuthToken,
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
|
@ -438,7 +435,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: auth.TokenCookieName(),
|
||||
Name: CookieOauthToken,
|
||||
Value: signedToken,
|
||||
})
|
||||
|
||||
|
@ -452,3 +449,35 @@ 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")
|
||||
}
|
||||
}
|
10
internal/auth/provider.go
Normal file
10
internal/auth/provider.go
Normal file
|
@ -0,0 +1,10 @@
|
|||
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)
|
||||
}
|
|
@ -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 ErrMissingToken
|
||||
return ErrMissingSessionToken
|
||||
}
|
||||
var claims UserPassClaims
|
||||
token, err := jwt.ParseWithClaims(jwtCookie.Value, &claims, func(t *jwt.Token) (interface{}, error) {
|
||||
|
@ -90,7 +90,7 @@ func (auth *UserPassAuth) CheckToken(r *http.Request) error {
|
|||
}
|
||||
switch {
|
||||
case !token.Valid:
|
||||
return ErrInvalidToken
|
||||
return ErrInvalidSessionToken
|
||||
case claims.Username != auth.username:
|
||||
return ErrUserNotAllowed.Subject(claims.Username)
|
||||
case claims.ExpiresAt.Before(time.Now()):
|
||||
|
@ -100,11 +100,7 @@ func (auth *UserPassAuth) CheckToken(r *http.Request) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (auth *UserPassAuth) RedirectLoginPage(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/login", http.StatusTemporaryRedirect)
|
||||
}
|
||||
|
||||
func (auth *UserPassAuth) LoginCallbackHandler(w http.ResponseWriter, r *http.Request) {
|
||||
func (auth *UserPassAuth) PostAuthCallbackHandler(w http.ResponseWriter, r *http.Request) {
|
||||
var creds struct {
|
||||
User string `json:"username"`
|
||||
Pass string `json:"password"`
|
||||
|
@ -123,12 +119,18 @@ func (auth *UserPassAuth) LoginCallbackHandler(w http.ResponseWriter, r *http.Re
|
|||
gphttp.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
setTokenCookie(w, r, auth.TokenCookieName(), token, auth.tokenTTL)
|
||||
SetTokenCookie(w, r, auth.TokenCookieName(), token, auth.tokenTTL)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func (auth *UserPassAuth) LogoutCallbackHandler(w http.ResponseWriter, r *http.Request) {
|
||||
DefaultLogoutCallbackHandler(auth, w, r)
|
||||
func (auth *UserPassAuth) LoginHandler(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("X-Redirect-To", "/login")
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
}
|
||||
|
||||
func (auth *UserPassAuth) LogoutHandler(w http.ResponseWriter, r *http.Request) {
|
||||
ClearTokenCookie(w, r, auth.TokenCookieName())
|
||||
http.Redirect(w, r, "/", http.StatusFound)
|
||||
}
|
||||
|
||||
func (auth *UserPassAuth) validatePassword(user, pass string) error {
|
|
@ -98,7 +98,7 @@ func TestUserPassLoginCallbackHandler(t *testing.T) {
|
|||
Host: "app.example.com",
|
||||
Body: io.NopCloser(bytes.NewReader(Must(json.Marshal(tt.creds)))),
|
||||
}
|
||||
auth.LoginCallbackHandler(w, req)
|
||||
auth.PostAuthCallbackHandler(w, req)
|
||||
if tt.wantErr {
|
||||
ExpectEqual(t, w.Code, http.StatusUnauthorized)
|
||||
} else {
|
80
internal/auth/utils.go
Normal file
80
internal/auth/utils.go
Normal file
|
@ -0,0 +1,80 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/yusing/go-proxy/internal/common"
|
||||
"github.com/yusing/go-proxy/internal/gperr"
|
||||
"github.com/yusing/go-proxy/internal/utils/strutils"
|
||||
)
|
||||
|
||||
var (
|
||||
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 requestRemoteIP(r) == "127.0.0.1"
|
||||
}
|
||||
|
||||
func requestRemoteIP(r *http.Request) string {
|
||||
ip, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return ip
|
||||
}
|
||||
|
||||
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: "/",
|
||||
})
|
||||
}
|
|
@ -5,42 +5,54 @@ import (
|
|||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
|
||||
"github.com/go-acme/lego/v4/certcrypto"
|
||||
"github.com/go-acme/lego/v4/challenge"
|
||||
"github.com/go-acme/lego/v4/lego"
|
||||
"github.com/rs/zerolog/log"
|
||||
"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/utils"
|
||||
"github.com/yusing/go-proxy/internal/utils/strutils"
|
||||
)
|
||||
|
||||
type (
|
||||
AutocertConfig struct {
|
||||
type Config struct {
|
||||
Email string `json:"email,omitempty"`
|
||||
Domains []string `json:"domains,omitempty"`
|
||||
CertPath string `json:"cert_path,omitempty"`
|
||||
KeyPath string `json:"key_path,omitempty"`
|
||||
ACMEKeyPath string `json:"acme_key_path,omitempty"`
|
||||
Provider string `json:"provider,omitempty"`
|
||||
Options ProviderOpt `json:"options,omitempty"`
|
||||
CADirURL string `json:"ca_dir_url,omitempty"`
|
||||
CACerts []string `json:"ca_certs,omitempty"`
|
||||
Options map[string]any `json:"options,omitempty"`
|
||||
|
||||
HTTPClient *http.Client `json:"-"` // for tests only
|
||||
|
||||
challengeProvider challenge.Provider
|
||||
}
|
||||
ProviderOpt map[string]any
|
||||
)
|
||||
|
||||
var (
|
||||
ErrMissingDomain = gperr.New("missing field 'domains'")
|
||||
ErrMissingEmail = gperr.New("missing field 'email'")
|
||||
ErrMissingProvider = gperr.New("missing field 'provider'")
|
||||
ErrMissingCADirURL = gperr.New("missing field 'ca_dir_url'")
|
||||
ErrInvalidDomain = gperr.New("invalid domain")
|
||||
ErrUnknownProvider = gperr.New("unknown provider")
|
||||
)
|
||||
|
||||
const (
|
||||
ProviderLocal = "local"
|
||||
ProviderPseudo = "pseudo"
|
||||
ProviderCustom = "custom"
|
||||
)
|
||||
|
||||
var domainOrWildcardRE = regexp.MustCompile(`^\*?([^.]+\.)+[^.]+$`)
|
||||
|
||||
// Validate implements the utils.CustomValidator interface.
|
||||
func (cfg *AutocertConfig) Validate() gperr.Error {
|
||||
func (cfg *Config) Validate() gperr.Error {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
|
@ -51,6 +63,10 @@ func (cfg *AutocertConfig) Validate() gperr.Error {
|
|||
}
|
||||
|
||||
b := gperr.NewBuilder("autocert errors")
|
||||
if cfg.Provider == ProviderCustom && cfg.CADirURL == "" {
|
||||
b.Add(ErrMissingCADirURL)
|
||||
}
|
||||
|
||||
if cfg.Provider != ProviderLocal && cfg.Provider != ProviderPseudo {
|
||||
if len(cfg.Domains) == 0 {
|
||||
b.Add(ErrMissingDomain)
|
||||
|
@ -58,34 +74,40 @@ func (cfg *AutocertConfig) Validate() gperr.Error {
|
|||
if cfg.Email == "" {
|
||||
b.Add(ErrMissingEmail)
|
||||
}
|
||||
if cfg.Provider != ProviderCustom {
|
||||
for i, d := range cfg.Domains {
|
||||
if !domainOrWildcardRE.MatchString(d) {
|
||||
b.Add(ErrInvalidDomain.Subjectf("domains[%d]", i))
|
||||
}
|
||||
}
|
||||
}
|
||||
// check if provider is implemented
|
||||
providerConstructor, ok := providersGenMap[cfg.Provider]
|
||||
providerConstructor, ok := Providers[cfg.Provider]
|
||||
if !ok {
|
||||
if cfg.Provider != ProviderCustom {
|
||||
b.Add(ErrUnknownProvider.
|
||||
Subject(cfg.Provider).
|
||||
Withf(strutils.DoYouMean(utils.NearestField(cfg.Provider, providersGenMap))))
|
||||
With(gperr.DoYouMean(utils.NearestField(cfg.Provider, Providers))))
|
||||
}
|
||||
} else {
|
||||
_, err := providerConstructor(cfg.Options)
|
||||
provider, err := providerConstructor(cfg.Options)
|
||||
if err != nil {
|
||||
b.Add(err)
|
||||
} else {
|
||||
cfg.challengeProvider = provider
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.challengeProvider == nil {
|
||||
cfg.challengeProvider, _ = Providers[ProviderLocal](nil)
|
||||
}
|
||||
return b.Error()
|
||||
}
|
||||
|
||||
func (cfg *AutocertConfig) GetProvider() (*Provider, gperr.Error) {
|
||||
if cfg == nil {
|
||||
cfg = new(AutocertConfig)
|
||||
}
|
||||
|
||||
func (cfg *Config) GetLegoConfig() (*User, *lego.Config, gperr.Error) {
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
if cfg.CertPath == "" {
|
||||
|
@ -102,35 +124,49 @@ func (cfg *AutocertConfig) GetProvider() (*Provider, gperr.Error) {
|
|||
var err error
|
||||
|
||||
if cfg.Provider != ProviderLocal && cfg.Provider != ProviderPseudo {
|
||||
if privKey, err = cfg.loadACMEKey(); err != nil {
|
||||
logging.Info().Err(err).Msg("load ACME private key failed")
|
||||
logging.Info().Msg("generate new ACME private key")
|
||||
if privKey, err = cfg.LoadACMEKey(); err != nil {
|
||||
log.Info().Err(err).Msg("failed to load ACME private key, generating a now one")
|
||||
privKey, err = ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
return nil, gperr.New("generate ACME private key").With(err)
|
||||
return nil, nil, gperr.New("generate ACME private key").With(err)
|
||||
}
|
||||
if err = cfg.saveACMEKey(privKey); err != nil {
|
||||
return nil, gperr.New("save ACME private key").With(err)
|
||||
if err = cfg.SaveACMEKey(privKey); err != nil {
|
||||
return nil, nil, gperr.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
|
||||
legoCfg.Certificate.KeyType = certcrypto.EC256
|
||||
|
||||
return &Provider{
|
||||
cfg: cfg,
|
||||
user: user,
|
||||
legoCfg: legoCfg,
|
||||
}, nil
|
||||
if cfg.HTTPClient != nil {
|
||||
legoCfg.HTTPClient = cfg.HTTPClient
|
||||
}
|
||||
|
||||
func (cfg *AutocertConfig) loadACMEKey() (*ecdsa.PrivateKey, error) {
|
||||
if cfg.CADirURL != "" {
|
||||
legoCfg.CADirURL = cfg.CADirURL
|
||||
}
|
||||
|
||||
if len(cfg.CACerts) > 0 {
|
||||
certPool, err := lego.CreateCertPool(cfg.CACerts, true)
|
||||
if err != nil {
|
||||
return nil, nil, gperr.New("failed to create cert pool").With(err)
|
||||
}
|
||||
legoCfg.HTTPClient.Transport.(*http.Transport).TLSClientConfig.RootCAs = certPool
|
||||
}
|
||||
|
||||
return user, legoCfg, nil
|
||||
}
|
||||
|
||||
func (cfg *Config) LoadACMEKey() (*ecdsa.PrivateKey, error) {
|
||||
if common.IsTest {
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
data, err := os.ReadFile(cfg.ACMEKeyPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
@ -138,7 +174,10 @@ func (cfg *AutocertConfig) loadACMEKey() (*ecdsa.PrivateKey, error) {
|
|||
return x509.ParseECPrivateKey(data)
|
||||
}
|
||||
|
||||
func (cfg *AutocertConfig) saveACMEKey(key *ecdsa.PrivateKey) error {
|
||||
func (cfg *Config) SaveACMEKey(key *ecdsa.PrivateKey) error {
|
||||
if common.IsTest {
|
||||
return nil
|
||||
}
|
||||
data, err := x509.MarshalECPrivateKey(key)
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
|
@ -1,36 +0,0 @@
|
|||
package autocert
|
||||
|
||||
import (
|
||||
"github.com/go-acme/lego/v4/providers/dns/clouddns"
|
||||
"github.com/go-acme/lego/v4/providers/dns/cloudflare"
|
||||
"github.com/go-acme/lego/v4/providers/dns/duckdns"
|
||||
"github.com/go-acme/lego/v4/providers/dns/ovh"
|
||||
"github.com/go-acme/lego/v4/providers/dns/porkbun"
|
||||
)
|
||||
|
||||
const (
|
||||
certBasePath = "certs/"
|
||||
CertFileDefault = certBasePath + "cert.crt"
|
||||
KeyFileDefault = certBasePath + "priv.key"
|
||||
ACMEKeyFileDefault = certBasePath + "acme.key"
|
||||
)
|
||||
|
||||
const (
|
||||
ProviderLocal = "local"
|
||||
ProviderCloudflare = "cloudflare"
|
||||
ProviderClouddns = "clouddns"
|
||||
ProviderDuckdns = "duckdns"
|
||||
ProviderOVH = "ovh"
|
||||
ProviderPseudo = "pseudo" // for testing
|
||||
ProviderPorkbun = "porkbun"
|
||||
)
|
||||
|
||||
var providersGenMap = map[string]ProviderGenerator{
|
||||
ProviderLocal: providerGenerator(NewDummyDefaultConfig, NewDummyDNSProviderConfig),
|
||||
ProviderCloudflare: providerGenerator(cloudflare.NewDefaultConfig, cloudflare.NewDNSProviderConfig),
|
||||
ProviderClouddns: providerGenerator(clouddns.NewDefaultConfig, clouddns.NewDNSProviderConfig),
|
||||
ProviderDuckdns: providerGenerator(duckdns.NewDefaultConfig, duckdns.NewDNSProviderConfig),
|
||||
ProviderOVH: providerGenerator(ovh.NewDefaultConfig, ovh.NewDNSProviderConfig),
|
||||
ProviderPseudo: providerGenerator(NewDummyDefaultConfig, NewDummyDNSProviderConfig),
|
||||
ProviderPorkbun: providerGenerator(porkbun.NewDefaultConfig, porkbun.NewDNSProviderConfig),
|
||||
}
|
9
internal/autocert/paths.go
Normal file
9
internal/autocert/paths.go
Normal file
|
@ -0,0 +1,9 @@
|
|||
package autocert
|
||||
|
||||
const (
|
||||
certBasePath = "certs/"
|
||||
CertFileDefault = certBasePath + "cert.crt"
|
||||
KeyFileDefault = certBasePath + "priv.key"
|
||||
ACMEKeyFileDefault = certBasePath + "acme.key"
|
||||
LastFailureFile = certBasePath + ".last_failure"
|
||||
)
|
|
@ -5,44 +5,58 @@ import (
|
|||
"crypto/x509"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"os"
|
||||
"path"
|
||||
"reflect"
|
||||
"sort"
|
||||
"sync"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-acme/lego/v4/certificate"
|
||||
"github.com/go-acme/lego/v4/challenge"
|
||||
"github.com/go-acme/lego/v4/lego"
|
||||
"github.com/go-acme/lego/v4/registration"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
"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/notif"
|
||||
"github.com/yusing/go-proxy/internal/task"
|
||||
U "github.com/yusing/go-proxy/internal/utils"
|
||||
"github.com/yusing/go-proxy/internal/utils/strutils"
|
||||
)
|
||||
|
||||
type (
|
||||
Provider struct {
|
||||
cfg *AutocertConfig
|
||||
cfg *Config
|
||||
user *User
|
||||
legoCfg *lego.Config
|
||||
client *lego.Client
|
||||
lastFailure time.Time
|
||||
|
||||
legoCert *certificate.Resource
|
||||
tlsCert *tls.Certificate
|
||||
certExpiries CertExpiries
|
||||
|
||||
obtainMu sync.Mutex
|
||||
}
|
||||
ProviderGenerator func(ProviderOpt) (challenge.Provider, gperr.Error)
|
||||
|
||||
CertExpiries map[string]time.Time
|
||||
)
|
||||
|
||||
var ErrGetCertFailure = errors.New("get certificate failed")
|
||||
|
||||
const (
|
||||
// renew failed for whatever reason, 1 hour cooldown
|
||||
renewalCooldownDuration = 1 * time.Hour
|
||||
// prevents cert request docker compose across restarts with `restart: always` (non-zero exit code)
|
||||
requestCooldownDuration = 15 * time.Second
|
||||
)
|
||||
|
||||
func NewProvider(cfg *Config, user *User, legoCfg *lego.Config) *Provider {
|
||||
return &Provider{
|
||||
cfg: cfg,
|
||||
user: user,
|
||||
legoCfg: legoCfg,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Provider) GetCert(_ *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||
if p.tlsCert == nil {
|
||||
return nil, ErrGetCertFailure
|
||||
|
@ -66,28 +80,64 @@ func (p *Provider) GetExpiries() CertExpiries {
|
|||
return p.certExpiries
|
||||
}
|
||||
|
||||
func (p *Provider) GetLastFailure() (time.Time, error) {
|
||||
if p.lastFailure.IsZero() {
|
||||
data, err := os.ReadFile(LastFailureFile)
|
||||
if err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
return time.Time{}, err
|
||||
}
|
||||
} else {
|
||||
p.lastFailure, _ = time.Parse(time.RFC3339, string(data))
|
||||
}
|
||||
}
|
||||
return p.lastFailure, nil
|
||||
}
|
||||
|
||||
func (p *Provider) UpdateLastFailure() error {
|
||||
t := time.Now()
|
||||
p.lastFailure = t
|
||||
return os.WriteFile(LastFailureFile, t.AppendFormat(nil, time.RFC3339), 0o600)
|
||||
}
|
||||
|
||||
func (p *Provider) ClearLastFailure() error {
|
||||
p.lastFailure = time.Time{}
|
||||
return os.Remove(LastFailureFile)
|
||||
}
|
||||
|
||||
func (p *Provider) ObtainCert() error {
|
||||
if p.cfg.Provider == ProviderLocal {
|
||||
return nil
|
||||
}
|
||||
|
||||
if p.cfg.Provider == ProviderPseudo {
|
||||
t := time.NewTicker(1000 * time.Millisecond)
|
||||
defer t.Stop()
|
||||
logging.Info().Msg("init client for pseudo provider")
|
||||
<-t.C
|
||||
logging.Info().Msg("registering acme for pseudo provider")
|
||||
<-t.C
|
||||
logging.Info().Msg("obtained cert for pseudo provider")
|
||||
log.Info().Msg("init client for pseudo provider")
|
||||
<-time.After(time.Second)
|
||||
log.Info().Msg("registering acme for pseudo provider")
|
||||
<-time.After(time.Second)
|
||||
log.Info().Msg("obtained cert for pseudo provider")
|
||||
return nil
|
||||
}
|
||||
|
||||
if lastFailure, err := p.GetLastFailure(); err != nil {
|
||||
return err
|
||||
} else if time.Since(lastFailure) < requestCooldownDuration {
|
||||
return fmt.Errorf("%w: still in cooldown until %s", ErrGetCertFailure, strutils.FormatTime(lastFailure.Add(requestCooldownDuration).Local()))
|
||||
}
|
||||
|
||||
if p.client == nil {
|
||||
if err := p.initClient(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// mark it as failed first, clear it later if successful
|
||||
// in case the process crashed / failed to renew, we put it on a cooldown
|
||||
// this prevents rate limiting by the ACME server
|
||||
if err := p.UpdateLastFailure(); err != nil {
|
||||
return fmt.Errorf("failed to update last failure: %w", err)
|
||||
}
|
||||
|
||||
if p.user.Registration == nil {
|
||||
if err := p.registerACME(); err != nil {
|
||||
return err
|
||||
|
@ -103,7 +153,7 @@ func (p *Provider) ObtainCert() error {
|
|||
})
|
||||
if err != nil {
|
||||
p.legoCert = nil
|
||||
logging.Err(err).Msg("cert renew failed, fallback to obtain")
|
||||
log.Err(err).Msg("cert renew failed, fallback to obtain")
|
||||
} else {
|
||||
p.legoCert = cert
|
||||
}
|
||||
|
@ -135,6 +185,9 @@ func (p *Provider) ObtainCert() error {
|
|||
p.tlsCert = &tlsCert
|
||||
p.certExpiries = expiries
|
||||
|
||||
if err := p.ClearLastFailure(); err != nil {
|
||||
return fmt.Errorf("failed to clear last failure: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
@ -150,7 +203,7 @@ func (p *Provider) LoadCert() error {
|
|||
p.tlsCert = &cert
|
||||
p.certExpiries = expiries
|
||||
|
||||
logging.Info().Msgf("next renewal in %v", strutils.FormatDuration(time.Until(p.ShouldRenewOn())))
|
||||
log.Info().Msgf("next cert renewal in %s", strutils.FormatDuration(time.Until(p.ShouldRenewOn())))
|
||||
return p.renewIfNeeded()
|
||||
}
|
||||
|
||||
|
@ -168,12 +221,11 @@ func (p *Provider) ScheduleRenewal(parent task.Parent) {
|
|||
return
|
||||
}
|
||||
go func() {
|
||||
lastErrOn := time.Time{}
|
||||
renewalTime := p.ShouldRenewOn()
|
||||
timer := time.NewTimer(time.Until(renewalTime))
|
||||
defer timer.Stop()
|
||||
|
||||
task := parent.Subtask("cert-renew-scheduler")
|
||||
task := parent.Subtask("cert-renew-scheduler", true)
|
||||
defer task.Finish(nil)
|
||||
|
||||
for {
|
||||
|
@ -182,16 +234,35 @@ func (p *Provider) ScheduleRenewal(parent task.Parent) {
|
|||
return
|
||||
case <-timer.C:
|
||||
// Retry after 1 hour on failure
|
||||
if !lastErrOn.IsZero() && time.Now().Before(lastErrOn.Add(time.Hour)) {
|
||||
lastFailure, err := p.GetLastFailure()
|
||||
if err != nil {
|
||||
gperr.LogWarn("autocert: failed to get last failure", err)
|
||||
continue
|
||||
}
|
||||
if !lastFailure.IsZero() && time.Since(lastFailure) < renewalCooldownDuration {
|
||||
continue
|
||||
}
|
||||
if err := p.renewIfNeeded(); err != nil {
|
||||
gperr.LogWarn("cert renew failed", err)
|
||||
lastErrOn = time.Now()
|
||||
gperr.LogWarn("autocert: cert renew failed", err)
|
||||
if err := p.UpdateLastFailure(); err != nil {
|
||||
gperr.LogWarn("autocert: failed to update last failure", err)
|
||||
}
|
||||
notif.Notify(¬if.LogMessage{
|
||||
Level: zerolog.ErrorLevel,
|
||||
Title: "SSL certificate renewal failed",
|
||||
Body: notif.MessageBody(err.Error()),
|
||||
})
|
||||
continue
|
||||
}
|
||||
notif.Notify(¬if.LogMessage{
|
||||
Level: zerolog.InfoLevel,
|
||||
Title: "SSL certificate renewed",
|
||||
Body: notif.ListBody(p.cfg.Domains),
|
||||
})
|
||||
// Reset on success
|
||||
lastErrOn = time.Time{}
|
||||
if err := p.ClearLastFailure(); err != nil {
|
||||
gperr.LogWarn("autocert: failed to clear last failure", err)
|
||||
}
|
||||
renewalTime = p.ShouldRenewOn()
|
||||
timer.Reset(time.Until(renewalTime))
|
||||
}
|
||||
|
@ -205,13 +276,7 @@ func (p *Provider) initClient() error {
|
|||
return err
|
||||
}
|
||||
|
||||
generator := providersGenMap[p.cfg.Provider]
|
||||
legoProvider, pErr := generator(p.cfg.Options)
|
||||
if pErr != nil {
|
||||
return pErr
|
||||
}
|
||||
|
||||
err = legoClient.Challenge.SetDNS01Provider(legoProvider)
|
||||
err = legoClient.Challenge.SetDNS01Provider(p.cfg.challengeProvider)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -226,7 +291,7 @@ func (p *Provider) registerACME() error {
|
|||
}
|
||||
if reg, err := p.client.Registration.ResolveAccountByKey(); err == nil {
|
||||
p.user.Registration = reg
|
||||
logging.Info().Msg("reused acme registration from private key")
|
||||
log.Info().Msg("reused acme registration from private key")
|
||||
return nil
|
||||
}
|
||||
|
||||
|
@ -235,11 +300,14 @@ func (p *Provider) registerACME() error {
|
|||
return err
|
||||
}
|
||||
p.user.Registration = reg
|
||||
logging.Info().Interface("reg", reg).Msg("acme registered")
|
||||
log.Info().Interface("reg", reg).Msg("acme registered")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Provider) saveCert(cert *certificate.Resource) error {
|
||||
if common.IsTest {
|
||||
return nil
|
||||
}
|
||||
/* This should have been done in setup
|
||||
but double check is always a good choice.*/
|
||||
_, err := os.Stat(path.Dir(p.cfg.CertPath))
|
||||
|
@ -269,22 +337,19 @@ func (p *Provider) certState() CertState {
|
|||
return CertStateExpired
|
||||
}
|
||||
|
||||
certDomains := make([]string, len(p.certExpiries))
|
||||
wantedDomains := make([]string, len(p.cfg.Domains))
|
||||
i := 0
|
||||
for domain := range p.certExpiries {
|
||||
certDomains[i] = domain
|
||||
i++
|
||||
}
|
||||
copy(wantedDomains, p.cfg.Domains)
|
||||
sort.Strings(wantedDomains)
|
||||
sort.Strings(certDomains)
|
||||
|
||||
if !reflect.DeepEqual(certDomains, wantedDomains) {
|
||||
logging.Info().Msgf("cert domains mismatch: %v != %v", certDomains, p.cfg.Domains)
|
||||
if len(p.certExpiries) != len(p.cfg.Domains) {
|
||||
return CertStateMismatch
|
||||
}
|
||||
|
||||
for i := range len(p.cfg.Domains) {
|
||||
if _, ok := p.certExpiries[p.cfg.Domains[i]]; !ok {
|
||||
log.Info().Msgf("autocert domains mismatch: cert: %s, wanted: %s",
|
||||
strings.Join(slices.Collect(maps.Keys(p.certExpiries)), ", "),
|
||||
strings.Join(p.cfg.Domains, ", "))
|
||||
return CertStateMismatch
|
||||
}
|
||||
}
|
||||
|
||||
return CertStateValid
|
||||
}
|
||||
|
||||
|
@ -295,9 +360,9 @@ func (p *Provider) renewIfNeeded() error {
|
|||
|
||||
switch p.certState() {
|
||||
case CertStateExpired:
|
||||
logging.Info().Msg("certs expired, renewing")
|
||||
log.Info().Msg("certs expired, renewing")
|
||||
case CertStateMismatch:
|
||||
logging.Info().Msg("cert domains mismatch with config, renewing")
|
||||
log.Info().Msg("cert domains mismatch with config, renewing")
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
@ -322,18 +387,3 @@ func getCertExpiries(cert *tls.Certificate) (CertExpiries, error) {
|
|||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func providerGenerator[CT any, PT challenge.Provider](
|
||||
defaultCfg func() *CT,
|
||||
newProvider func(*CT) (PT, error),
|
||||
) ProviderGenerator {
|
||||
return func(opt ProviderOpt) (challenge.Provider, gperr.Error) {
|
||||
cfg := defaultCfg()
|
||||
err := U.Deserialize(opt, &cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p, pErr := newProvider(cfg)
|
||||
return p, gperr.Wrap(pErr)
|
||||
}
|
||||
}
|
||||
|
|
468
internal/autocert/provider_test/custom_test.go
Normal file
468
internal/autocert/provider_test/custom_test.go
Normal file
|
@ -0,0 +1,468 @@
|
|||
//nolint:errchkjson,errcheck
|
||||
package provider_test
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"io"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/yusing/go-proxy/internal/autocert"
|
||||
"github.com/yusing/go-proxy/internal/dnsproviders"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
dnsproviders.InitProviders()
|
||||
m.Run()
|
||||
}
|
||||
|
||||
func TestCustomProvider(t *testing.T) {
|
||||
t.Run("valid custom provider with step-ca", func(t *testing.T) {
|
||||
cfg := &autocert.Config{
|
||||
Email: "test@example.com",
|
||||
Domains: []string{"example.com", "*.example.com"},
|
||||
Provider: autocert.ProviderCustom,
|
||||
CADirURL: "https://ca.example.com:9000/acme/acme/directory",
|
||||
CertPath: "certs/custom.crt",
|
||||
KeyPath: "certs/custom.key",
|
||||
ACMEKeyPath: "certs/custom-acme.key",
|
||||
}
|
||||
|
||||
err := cfg.Validate()
|
||||
require.NoError(t, err)
|
||||
|
||||
user, legoCfg, err := cfg.GetLegoConfig()
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, user)
|
||||
require.NotNil(t, legoCfg)
|
||||
require.Equal(t, "https://ca.example.com:9000/acme/acme/directory", legoCfg.CADirURL)
|
||||
require.Equal(t, "test@example.com", user.Email)
|
||||
})
|
||||
|
||||
t.Run("custom provider missing CADirURL", func(t *testing.T) {
|
||||
cfg := &autocert.Config{
|
||||
Email: "test@example.com",
|
||||
Domains: []string{"example.com"},
|
||||
Provider: autocert.ProviderCustom,
|
||||
// CADirURL is missing
|
||||
}
|
||||
|
||||
err := cfg.Validate()
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "missing field 'ca_dir_url'")
|
||||
})
|
||||
|
||||
t.Run("custom provider with step-ca internal CA", func(t *testing.T) {
|
||||
cfg := &autocert.Config{
|
||||
Email: "admin@internal.com",
|
||||
Domains: []string{"internal.example.com", "api.internal.example.com"},
|
||||
Provider: autocert.ProviderCustom,
|
||||
CADirURL: "https://step-ca.internal:443/acme/acme/directory",
|
||||
CertPath: "certs/internal.crt",
|
||||
KeyPath: "certs/internal.key",
|
||||
ACMEKeyPath: "certs/internal-acme.key",
|
||||
}
|
||||
|
||||
err := cfg.Validate()
|
||||
require.NoError(t, err)
|
||||
|
||||
user, legoCfg, err := cfg.GetLegoConfig()
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, user)
|
||||
require.NotNil(t, legoCfg)
|
||||
require.Equal(t, "https://step-ca.internal:443/acme/acme/directory", legoCfg.CADirURL)
|
||||
require.Equal(t, "admin@internal.com", user.Email)
|
||||
|
||||
provider := autocert.NewProvider(cfg, user, legoCfg)
|
||||
require.NotNil(t, provider)
|
||||
require.Equal(t, autocert.ProviderCustom, provider.GetName())
|
||||
require.Equal(t, "certs/internal.crt", provider.GetCertPath())
|
||||
require.Equal(t, "certs/internal.key", provider.GetKeyPath())
|
||||
})
|
||||
}
|
||||
|
||||
func TestObtainCertFromCustomProvider(t *testing.T) {
|
||||
// Create a test ACME server
|
||||
acmeServer := newTestACMEServer(t)
|
||||
defer acmeServer.Close()
|
||||
|
||||
t.Run("obtain cert from custom step-ca server", func(t *testing.T) {
|
||||
cfg := &autocert.Config{
|
||||
Email: "test@example.com",
|
||||
Domains: []string{"test.example.com"},
|
||||
Provider: autocert.ProviderCustom,
|
||||
CADirURL: acmeServer.URL() + "/acme/acme/directory",
|
||||
CertPath: "certs/stepca-test.crt",
|
||||
KeyPath: "certs/stepca-test.key",
|
||||
ACMEKeyPath: "certs/stepca-test-acme.key",
|
||||
HTTPClient: acmeServer.httpClient(),
|
||||
}
|
||||
|
||||
err := error(cfg.Validate())
|
||||
require.NoError(t, err)
|
||||
|
||||
user, legoCfg, err := cfg.GetLegoConfig()
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, user)
|
||||
require.NotNil(t, legoCfg)
|
||||
|
||||
provider := autocert.NewProvider(cfg, user, legoCfg)
|
||||
require.NotNil(t, provider)
|
||||
|
||||
// Test obtaining certificate
|
||||
err = provider.ObtainCert()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify certificate was obtained
|
||||
cert, err := provider.GetCert(nil)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, cert)
|
||||
|
||||
// Verify certificate properties
|
||||
x509Cert, err := x509.ParseCertificate(cert.Certificate[0])
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, x509Cert.DNSNames, "test.example.com")
|
||||
require.True(t, time.Now().Before(x509Cert.NotAfter))
|
||||
require.True(t, time.Now().After(x509Cert.NotBefore))
|
||||
})
|
||||
}
|
||||
|
||||
// testACMEServer implements a minimal ACME server for testing.
|
||||
type testACMEServer struct {
|
||||
server *httptest.Server
|
||||
caCert *x509.Certificate
|
||||
caKey *rsa.PrivateKey
|
||||
clientCSRs map[string]*x509.CertificateRequest
|
||||
orderID string
|
||||
}
|
||||
|
||||
func newTestACMEServer(t *testing.T) *testACMEServer {
|
||||
t.Helper()
|
||||
|
||||
// Generate CA certificate and key
|
||||
caKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
require.NoError(t, err)
|
||||
|
||||
caTemplate := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{
|
||||
Organization: []string{"Test CA"},
|
||||
Country: []string{"US"},
|
||||
Province: []string{""},
|
||||
Locality: []string{"Test"},
|
||||
StreetAddress: []string{""},
|
||||
PostalCode: []string{""},
|
||||
},
|
||||
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
|
||||
NotBefore: time.Now(),
|
||||
NotAfter: time.Now().Add(365 * 24 * time.Hour),
|
||||
IsCA: true,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth},
|
||||
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign,
|
||||
BasicConstraintsValid: true,
|
||||
}
|
||||
|
||||
caCertDER, err := x509.CreateCertificate(rand.Reader, caTemplate, caTemplate, &caKey.PublicKey, caKey)
|
||||
require.NoError(t, err)
|
||||
|
||||
caCert, err := x509.ParseCertificate(caCertDER)
|
||||
require.NoError(t, err)
|
||||
|
||||
acme := &testACMEServer{
|
||||
caCert: caCert,
|
||||
caKey: caKey,
|
||||
clientCSRs: make(map[string]*x509.CertificateRequest),
|
||||
orderID: "test-order-123",
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
acme.setupRoutes(mux)
|
||||
|
||||
acme.server = httptest.NewUnstartedServer(mux)
|
||||
acme.server.TLS = &tls.Config{
|
||||
Certificates: []tls.Certificate{
|
||||
{
|
||||
Certificate: [][]byte{caCert.Raw},
|
||||
PrivateKey: caKey,
|
||||
},
|
||||
},
|
||||
MinVersion: tls.VersionTLS12,
|
||||
}
|
||||
acme.server.StartTLS()
|
||||
return acme
|
||||
}
|
||||
|
||||
func (s *testACMEServer) Close() {
|
||||
s.server.Close()
|
||||
}
|
||||
|
||||
func (s *testACMEServer) URL() string {
|
||||
return s.server.URL
|
||||
}
|
||||
|
||||
func (s *testACMEServer) httpClient() *http.Client {
|
||||
certPool := x509.NewCertPool()
|
||||
certPool.AddCert(s.caCert)
|
||||
|
||||
return &http.Client{
|
||||
Transport: &http.Transport{
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: 30 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
}).DialContext,
|
||||
TLSHandshakeTimeout: 30 * time.Second,
|
||||
ResponseHeaderTimeout: 30 * time.Second,
|
||||
TLSClientConfig: &tls.Config{
|
||||
RootCAs: certPool,
|
||||
MinVersion: tls.VersionTLS12,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *testACMEServer) setupRoutes(mux *http.ServeMux) {
|
||||
// ACME directory endpoint
|
||||
mux.HandleFunc("/acme/acme/directory", s.handleDirectory)
|
||||
|
||||
// ACME endpoints
|
||||
mux.HandleFunc("/acme/new-nonce", s.handleNewNonce)
|
||||
mux.HandleFunc("/acme/new-account", s.handleNewAccount)
|
||||
mux.HandleFunc("/acme/new-order", s.handleNewOrder)
|
||||
mux.HandleFunc("/acme/authz/", s.handleAuthorization)
|
||||
mux.HandleFunc("/acme/chall/", s.handleChallenge)
|
||||
mux.HandleFunc("/acme/order/", s.handleOrder)
|
||||
mux.HandleFunc("/acme/cert/", s.handleCertificate)
|
||||
}
|
||||
|
||||
func (s *testACMEServer) handleDirectory(w http.ResponseWriter, r *http.Request) {
|
||||
directory := map[string]interface{}{
|
||||
"newNonce": s.server.URL + "/acme/new-nonce",
|
||||
"newAccount": s.server.URL + "/acme/new-account",
|
||||
"newOrder": s.server.URL + "/acme/new-order",
|
||||
"keyChange": s.server.URL + "/acme/key-change",
|
||||
"meta": map[string]interface{}{
|
||||
"termsOfService": s.server.URL + "/terms",
|
||||
},
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(directory)
|
||||
}
|
||||
|
||||
func (s *testACMEServer) handleNewNonce(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Replay-Nonce", "test-nonce-12345")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func (s *testACMEServer) handleNewAccount(w http.ResponseWriter, r *http.Request) {
|
||||
account := map[string]interface{}{
|
||||
"status": "valid",
|
||||
"contact": []string{"mailto:test@example.com"},
|
||||
"orders": s.server.URL + "/acme/orders",
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Location", s.server.URL+"/acme/account/1")
|
||||
w.Header().Set("Replay-Nonce", "test-nonce-67890")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(account)
|
||||
}
|
||||
|
||||
func (s *testACMEServer) handleNewOrder(w http.ResponseWriter, r *http.Request) {
|
||||
authzID := "test-authz-456"
|
||||
|
||||
order := map[string]interface{}{
|
||||
"status": "ready", // Skip pending state for simplicity
|
||||
"expires": time.Now().Add(24 * time.Hour).Format(time.RFC3339),
|
||||
"identifiers": []map[string]string{{"type": "dns", "value": "test.example.com"}},
|
||||
"authorizations": []string{s.server.URL + "/acme/authz/" + authzID},
|
||||
"finalize": s.server.URL + "/acme/order/" + s.orderID + "/finalize",
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Location", s.server.URL+"/acme/order/"+s.orderID)
|
||||
w.Header().Set("Replay-Nonce", "test-nonce-order")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(order)
|
||||
}
|
||||
|
||||
func (s *testACMEServer) handleAuthorization(w http.ResponseWriter, r *http.Request) {
|
||||
authz := map[string]interface{}{
|
||||
"status": "valid", // Skip challenge validation for simplicity
|
||||
"expires": time.Now().Add(24 * time.Hour).Format(time.RFC3339),
|
||||
"identifier": map[string]string{"type": "dns", "value": "test.example.com"},
|
||||
"challenges": []map[string]interface{}{
|
||||
{
|
||||
"type": "dns-01",
|
||||
"status": "valid",
|
||||
"url": s.server.URL + "/acme/chall/test-chall-789",
|
||||
"token": "test-token-abc123",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Replay-Nonce", "test-nonce-authz")
|
||||
json.NewEncoder(w).Encode(authz)
|
||||
}
|
||||
|
||||
func (s *testACMEServer) handleChallenge(w http.ResponseWriter, r *http.Request) {
|
||||
challenge := map[string]interface{}{
|
||||
"type": "dns-01",
|
||||
"status": "valid",
|
||||
"url": r.URL.String(),
|
||||
"token": "test-token-abc123",
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Replay-Nonce", "test-nonce-chall")
|
||||
json.NewEncoder(w).Encode(challenge)
|
||||
}
|
||||
|
||||
func (s *testACMEServer) handleOrder(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasSuffix(r.URL.Path, "/finalize") {
|
||||
s.handleFinalize(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
certURL := s.server.URL + "/acme/cert/" + s.orderID
|
||||
order := map[string]interface{}{
|
||||
"status": "valid",
|
||||
"expires": time.Now().Add(24 * time.Hour).Format(time.RFC3339),
|
||||
"identifiers": []map[string]string{{"type": "dns", "value": "test.example.com"}},
|
||||
"certificate": certURL,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Replay-Nonce", "test-nonce-order-get")
|
||||
json.NewEncoder(w).Encode(order)
|
||||
}
|
||||
|
||||
func (s *testACMEServer) handleFinalize(w http.ResponseWriter, r *http.Request) {
|
||||
// Read the JWS payload
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to read request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Extract CSR from JWS payload
|
||||
csr, err := s.extractCSRFromJWS(body)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid CSR: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Store the CSR for certificate generation
|
||||
s.clientCSRs[s.orderID] = csr
|
||||
|
||||
certURL := s.server.URL + "/acme/cert/" + s.orderID
|
||||
order := map[string]interface{}{
|
||||
"status": "valid",
|
||||
"expires": time.Now().Add(24 * time.Hour).Format(time.RFC3339),
|
||||
"identifiers": []map[string]string{{"type": "dns", "value": "test.example.com"}},
|
||||
"certificate": certURL,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Location", strings.TrimSuffix(r.URL.String(), "/finalize"))
|
||||
w.Header().Set("Replay-Nonce", "test-nonce-finalize")
|
||||
json.NewEncoder(w).Encode(order)
|
||||
}
|
||||
|
||||
func (s *testACMEServer) extractCSRFromJWS(jwsData []byte) (*x509.CertificateRequest, error) {
|
||||
// Parse the JWS structure
|
||||
var jws struct {
|
||||
Protected string `json:"protected"`
|
||||
Payload string `json:"payload"`
|
||||
Signature string `json:"signature"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(jwsData, &jws); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Decode the payload
|
||||
payloadBytes, err := base64.RawURLEncoding.DecodeString(jws.Payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Parse the finalize request
|
||||
var finalizeReq struct {
|
||||
CSR string `json:"csr"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(payloadBytes, &finalizeReq); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Decode the CSR
|
||||
csrBytes, err := base64.RawURLEncoding.DecodeString(finalizeReq.CSR)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Parse the CSR
|
||||
csr, err := x509.ParseCertificateRequest(csrBytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return csr, nil
|
||||
}
|
||||
|
||||
func (s *testACMEServer) handleCertificate(w http.ResponseWriter, r *http.Request) {
|
||||
// Extract order ID from URL
|
||||
orderID := strings.TrimPrefix(r.URL.Path, "/acme/cert/")
|
||||
|
||||
// Get the CSR for this order
|
||||
csr, exists := s.clientCSRs[orderID]
|
||||
if !exists {
|
||||
http.Error(w, "No CSR found for order", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Create certificate using the public key from the client's CSR
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(2),
|
||||
Subject: pkix.Name{
|
||||
Organization: []string{"Test Cert"},
|
||||
Country: []string{"US"},
|
||||
},
|
||||
DNSNames: csr.DNSNames,
|
||||
NotBefore: time.Now(),
|
||||
NotAfter: time.Now().Add(90 * 24 * time.Hour),
|
||||
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
BasicConstraintsValid: true,
|
||||
}
|
||||
|
||||
// Use the public key from the CSR and sign with CA key
|
||||
certDER, err := x509.CreateCertificate(rand.Reader, template, s.caCert, csr.PublicKey, s.caKey)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Return certificate chain
|
||||
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
|
||||
caPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: s.caCert.Raw})
|
||||
|
||||
w.Header().Set("Content-Type", "application/pem-certificate-chain")
|
||||
w.Header().Set("Replay-Nonce", "test-nonce-cert")
|
||||
w.Write(append(certPEM, caPEM...))
|
||||
}
|
|
@ -4,9 +4,9 @@ import (
|
|||
"testing"
|
||||
|
||||
"github.com/go-acme/lego/v4/providers/dns/ovh"
|
||||
U "github.com/yusing/go-proxy/internal/utils"
|
||||
. "github.com/yusing/go-proxy/internal/utils/testing"
|
||||
"gopkg.in/yaml.v3"
|
||||
"github.com/goccy/go-yaml"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/yusing/go-proxy/internal/serialization"
|
||||
)
|
||||
|
||||
// type Config struct {
|
||||
|
@ -44,7 +44,7 @@ oauth2_config:
|
|||
}
|
||||
testYaml = testYaml[1:] // remove first \n
|
||||
opt := make(map[string]any)
|
||||
ExpectNoError(t, yaml.Unmarshal([]byte(testYaml), opt))
|
||||
ExpectNoError(t, U.Deserialize(opt, cfg))
|
||||
ExpectEqual(t, cfg, cfgExpected)
|
||||
require.NoError(t, yaml.Unmarshal([]byte(testYaml), &opt))
|
||||
require.NoError(t, serialization.MapUnmarshalValidate(opt, cfg))
|
||||
require.Equal(t, cfgExpected, cfg)
|
||||
}
|
||||
|
|
28
internal/autocert/providers.go
Normal file
28
internal/autocert/providers.go
Normal file
|
@ -0,0 +1,28 @@
|
|||
package autocert
|
||||
|
||||
import (
|
||||
"github.com/go-acme/lego/v4/challenge"
|
||||
"github.com/yusing/go-proxy/internal/gperr"
|
||||
"github.com/yusing/go-proxy/internal/serialization"
|
||||
)
|
||||
|
||||
type Generator func(map[string]any) (challenge.Provider, gperr.Error)
|
||||
|
||||
var Providers = make(map[string]Generator)
|
||||
|
||||
func DNSProvider[CT any, PT challenge.Provider](
|
||||
defaultCfg func() *CT,
|
||||
newProvider func(*CT) (PT, error),
|
||||
) Generator {
|
||||
return func(opt map[string]any) (challenge.Provider, gperr.Error) {
|
||||
cfg := defaultCfg()
|
||||
if len(opt) > 0 {
|
||||
err := serialization.MapUnmarshalValidate(opt, &cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
p, pErr := newProvider(cfg)
|
||||
return p, gperr.Wrap(pErr)
|
||||
}
|
||||
}
|
|
@ -4,7 +4,7 @@ import (
|
|||
"errors"
|
||||
"os"
|
||||
|
||||
"github.com/yusing/go-proxy/internal/logging"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/yusing/go-proxy/internal/utils/strutils"
|
||||
)
|
||||
|
||||
|
@ -13,14 +13,14 @@ func (p *Provider) Setup() (err error) {
|
|||
if !errors.Is(err, os.ErrNotExist) { // ignore if cert doesn't exist
|
||||
return err
|
||||
}
|
||||
logging.Debug().Msg("obtaining cert due to error loading cert")
|
||||
log.Debug().Msg("obtaining cert due to error loading cert")
|
||||
if err = p.ObtainCert(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for _, expiry := range p.GetExpiries() {
|
||||
logging.Info().Msg("certificate expire on " + strutils.FormatTime(expiry))
|
||||
log.Info().Msg("certificate expire on " + strutils.FormatTime(expiry))
|
||||
break
|
||||
}
|
||||
|
||||
|
|
14
internal/autocert/types/provider.go
Normal file
14
internal/autocert/types/provider.go
Normal file
|
@ -0,0 +1,14 @@
|
|||
package autocert
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
|
||||
"github.com/yusing/go-proxy/internal/task"
|
||||
)
|
||||
|
||||
type Provider interface {
|
||||
Setup() error
|
||||
GetCert(*tls.ClientHelloInfo) (*tls.Certificate, error)
|
||||
ScheduleRenewal(task.Parent)
|
||||
ObtainCert() error
|
||||
}
|
|
@ -9,7 +9,7 @@ import (
|
|||
type User struct {
|
||||
Email string
|
||||
Registration *registration.Resource
|
||||
key crypto.PrivateKey
|
||||
Key crypto.PrivateKey
|
||||
}
|
||||
|
||||
func (u *User) GetEmail() string {
|
||||
|
@ -21,5 +21,5 @@ func (u *User) GetRegistration() *registration.Resource {
|
|||
}
|
||||
|
||||
func (u *User) GetPrivateKey() crypto.PrivateKey {
|
||||
return u.key
|
||||
return u.Key
|
||||
}
|
||||
|
|
|
@ -14,22 +14,23 @@ const (
|
|||
ConfigFileName = "config.yml"
|
||||
ConfigExampleFileName = "config.example.yml"
|
||||
ConfigPath = ConfigBasePath + "/" + ConfigFileName
|
||||
HomepageJSONConfigPath = ConfigBasePath + "/.homepage.json"
|
||||
IconListCachePath = ConfigBasePath + "/.icon_list_cache.json"
|
||||
IconCachePath = ConfigBasePath + "/.icon_cache.json"
|
||||
|
||||
DataDir = "data"
|
||||
IconListCachePath = DataDir + "/.icon_list_cache.json"
|
||||
|
||||
NamespaceHomepageOverrides = ".homepage"
|
||||
NamespaceIconCache = ".icon_cache"
|
||||
|
||||
MiddlewareComposeBasePath = ConfigBasePath + "/middlewares"
|
||||
|
||||
ComposeFileName = "compose.yml"
|
||||
ComposeExampleFileName = "compose.example.yml"
|
||||
|
||||
ErrorPagesBasePath = "error_pages"
|
||||
|
||||
AgentCertsBasePath = "certs"
|
||||
)
|
||||
|
||||
var RequiredDirectories = []string{
|
||||
ConfigBasePath,
|
||||
DataDir,
|
||||
ErrorPagesBasePath,
|
||||
MiddlewareComposeBasePath,
|
||||
}
|
||||
|
@ -39,8 +40,9 @@ const DockerHostFromEnv = "$DOCKER_HOST"
|
|||
const (
|
||||
HealthCheckIntervalDefault = 5 * time.Second
|
||||
HealthCheckTimeoutDefault = 5 * time.Second
|
||||
HealthCheckDownNotifyDelayDefault = 15 * time.Second
|
||||
|
||||
WakeTimeoutDefault = "30s"
|
||||
StopTimeoutDefault = "30s"
|
||||
WakeTimeoutDefault = "3m"
|
||||
StopTimeoutDefault = "3m"
|
||||
StopMethodDefault = "stop"
|
||||
)
|
||||
|
|
|
@ -3,8 +3,7 @@ package common
|
|||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
"log"
|
||||
)
|
||||
|
||||
func decodeJWTKey(key string) []byte {
|
||||
|
@ -13,7 +12,7 @@ func decodeJWTKey(key string) []byte {
|
|||
}
|
||||
bytes, err := base64.StdEncoding.DecodeString(key)
|
||||
if err != nil {
|
||||
log.Panic().Err(err).Msg("failed to decode jwt key")
|
||||
log.Fatalf("failed to decode secret: %s", err)
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
|
@ -22,7 +21,7 @@ func RandomJWTKey() []byte {
|
|||
key := make([]byte, 32)
|
||||
_, err := rand.Read(key)
|
||||
if err != nil {
|
||||
log.Panic().Err(err).Msg("failed to generate random jwt key")
|
||||
log.Fatalf("failed to generate random jwt key: %s", err)
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
|
|
@ -2,13 +2,13 @@ package common
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/yusing/go-proxy/internal/utils/strutils"
|
||||
)
|
||||
|
||||
|
@ -19,6 +19,8 @@ var (
|
|||
IsDebug = GetEnvBool("DEBUG", IsTest)
|
||||
IsTrace = GetEnvBool("TRACE", false) && IsDebug
|
||||
|
||||
HTTP3Enabled = GetEnvBool("HTTP3_ENABLED", true)
|
||||
|
||||
ProxyHTTPAddr,
|
||||
ProxyHTTPHost,
|
||||
ProxyHTTPPort,
|
||||
|
@ -34,11 +36,9 @@ var (
|
|||
APIHTTPPort,
|
||||
APIHTTPURL = GetAddrEnv("API_ADDR", "127.0.0.1:8888", "http")
|
||||
|
||||
PrometheusEnabled = GetEnvBool("PROMETHEUS_ENABLED", false)
|
||||
|
||||
APIJWTSecure = GetEnvBool("API_JWT_SECURE", true)
|
||||
APIJWTSecret = decodeJWTKey(GetEnvString("API_JWT_SECRET", ""))
|
||||
APIJWTTokenTTL = GetDurationEnv("API_JWT_TOKEN_TTL", time.Hour)
|
||||
APIJWTTokenTTL = GetDurationEnv("API_JWT_TOKEN_TTL", 24*time.Hour)
|
||||
APIUser = GetEnvString("API_USER", "admin")
|
||||
APIPassword = GetEnvString("API_PASSWORD", "password")
|
||||
|
||||
|
@ -48,8 +48,7 @@ var (
|
|||
OIDCIssuerURL = GetEnvString("OIDC_ISSUER_URL", "")
|
||||
OIDCClientID = GetEnvString("OIDC_CLIENT_ID", "")
|
||||
OIDCClientSecret = GetEnvString("OIDC_CLIENT_SECRET", "")
|
||||
OIDCRedirectURL = GetEnvString("OIDC_REDIRECT_URL", "")
|
||||
OIDCScopes = GetEnvString("OIDC_SCOPES", "openid, profile, email")
|
||||
OIDCScopes = GetCommaSepEnv("OIDC_SCOPES", "openid, profile, email, groups")
|
||||
OIDCAllowedUsers = GetCommaSepEnv("OIDC_ALLOWED_USERS", "")
|
||||
OIDCAllowedGroups = GetCommaSepEnv("OIDC_ALLOWED_GROUPS", "")
|
||||
|
||||
|
@ -59,6 +58,8 @@ var (
|
|||
MetricsDisableDisk = GetEnvBool("METRICS_DISABLE_DISK", false)
|
||||
MetricsDisableNetwork = GetEnvBool("METRICS_DISABLE_NETWORK", false)
|
||||
MetricsDisableSensors = GetEnvBool("METRICS_DISABLE_SENSORS", false)
|
||||
|
||||
ForceResolveCountry = GetEnvBool("FORCE_RESOLVE_COUNTRY", false)
|
||||
)
|
||||
|
||||
func GetEnv[T any](key string, defaultValue T, parser func(string) (T, error)) T {
|
||||
|
@ -77,14 +78,16 @@ func GetEnv[T any](key string, defaultValue T, parser func(string) (T, error)) T
|
|||
if err == nil {
|
||||
return parsed
|
||||
}
|
||||
log.Fatal().Err(err).Msgf("env %s: invalid %T value: %s", key, parsed, value)
|
||||
log.Fatalf("env %s: invalid %T value: %s", key, parsed, value)
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
func GetEnvString(key string, defaultValue string) string {
|
||||
return GetEnv(key, defaultValue, func(s string) (string, error) {
|
||||
func stringstring(s string) (string, error) {
|
||||
return s, nil
|
||||
})
|
||||
}
|
||||
|
||||
func GetEnvString(key string, defaultValue string) string {
|
||||
return GetEnv(key, defaultValue, stringstring)
|
||||
}
|
||||
|
||||
func GetEnvBool(key string, defaultValue bool) bool {
|
||||
|
@ -102,7 +105,7 @@ func GetAddrEnv(key, defaultValue, scheme string) (addr, host string, portInt in
|
|||
}
|
||||
host, port, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
log.Fatal().Msgf("env %s: invalid address: %s", key, addr)
|
||||
log.Fatalf("env %s: invalid address: %s", key, addr)
|
||||
}
|
||||
if host == "" {
|
||||
host = "localhost"
|
||||
|
@ -110,7 +113,7 @@ func GetAddrEnv(key, defaultValue, scheme string) (addr, host string, portInt in
|
|||
fullURL = fmt.Sprintf("%s://%s:%s", scheme, host, port)
|
||||
portInt, err = strconv.Atoi(port)
|
||||
if err != nil {
|
||||
log.Fatal().Msgf("env %s: invalid port: %s", key, port)
|
||||
log.Fatalf("env %s: invalid port: %s", key, port)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
|
|
@ -1,66 +0,0 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"slices"
|
||||
|
||||
"github.com/yusing/go-proxy/agent/pkg/agent"
|
||||
"github.com/yusing/go-proxy/internal/gperr"
|
||||
"github.com/yusing/go-proxy/internal/route/provider"
|
||||
"github.com/yusing/go-proxy/internal/utils/functional"
|
||||
)
|
||||
|
||||
var agentPool = functional.NewMapOf[string, *agent.AgentConfig]()
|
||||
|
||||
func addAgent(agent *agent.AgentConfig) {
|
||||
agentPool.Store(agent.Addr, agent)
|
||||
}
|
||||
|
||||
func removeAllAgents() {
|
||||
agentPool.Clear()
|
||||
}
|
||||
|
||||
func GetAgent(addr string) (agent *agent.AgentConfig, ok bool) {
|
||||
agent, ok = agentPool.Load(addr)
|
||||
return
|
||||
}
|
||||
|
||||
func (cfg *Config) GetAgent(agentAddrOrDockerHost string) (*agent.AgentConfig, bool) {
|
||||
if !agent.IsDockerHostAgent(agentAddrOrDockerHost) {
|
||||
return GetAgent(agentAddrOrDockerHost)
|
||||
}
|
||||
return GetAgent(agent.GetAgentAddrFromDockerHost(agentAddrOrDockerHost))
|
||||
}
|
||||
|
||||
func (cfg *Config) VerifyNewAgent(host string, ca agent.PEMPair, client agent.PEMPair) (int, gperr.Error) {
|
||||
if slices.ContainsFunc(cfg.value.Providers.Agents, func(a *agent.AgentConfig) bool {
|
||||
return a.Addr == host
|
||||
}) {
|
||||
return 0, gperr.New("agent already exists")
|
||||
}
|
||||
|
||||
var agentCfg agent.AgentConfig
|
||||
agentCfg.Addr = host
|
||||
err := agentCfg.StartWithCerts(cfg.Task(), ca.Cert, client.Cert, client.Key)
|
||||
if err != nil {
|
||||
return 0, gperr.Wrap(err, "failed to start agent")
|
||||
}
|
||||
addAgent(&agentCfg)
|
||||
|
||||
provider := provider.NewAgentProvider(&agentCfg)
|
||||
if err := cfg.errIfExists(provider); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
err = provider.LoadRoutes()
|
||||
if err != nil {
|
||||
return 0, gperr.Wrap(err, "failed to load routes")
|
||||
}
|
||||
return provider.NumRoutes(), nil
|
||||
}
|
||||
|
||||
func (cfg *Config) ListAgents() []*agent.AgentConfig {
|
||||
agents := make([]*agent.AgentConfig, 0, agentPool.Size())
|
||||
agentPool.RangeAll(func(key string, value *agent.AgentConfig) {
|
||||
agents = append(agents, value)
|
||||
})
|
||||
return agents
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue