mirror of
https://github.com/yusing/godoxy.git
synced 2026-01-11 22:30:47 +01:00
Compare commits
401 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f661907268 | ||
|
|
be85633c32 | ||
|
|
392946fe33 | ||
|
|
671024965f | ||
|
|
8d9aef3cd5 | ||
|
|
b5b4f0453a | ||
|
|
8ca6ac2752 | ||
|
|
27f7e08e18 | ||
|
|
f3e08dc9ea | ||
|
|
e3797ea96b | ||
|
|
146e7781be | ||
|
|
d2e2086540 | ||
|
|
d105f866ff | ||
|
|
1c001ed9df | ||
|
|
366c89164f | ||
|
|
36f13c61bb | ||
|
|
c8935102c3 | ||
|
|
a9e4f82e30 | ||
|
|
f966ca8b83 | ||
|
|
2da7ea56d5 | ||
|
|
232f720e77 | ||
|
|
2f476603d3 | ||
|
|
366fede517 | ||
|
|
7ef8354eb0 | ||
|
|
fbb07011f1 | ||
|
|
a7da8ffb90 | ||
|
|
95fe294f7d | ||
|
|
cdb3ffe439 | ||
|
|
7707fc6f36 | ||
|
|
765328affb | ||
|
|
3c515b0258 | ||
|
|
c6f65ba69f | ||
|
|
8c9a2b022b | ||
|
|
2e8248cd5b | ||
|
|
2b91d99ec6 | ||
|
|
f7688a942a | ||
|
|
574056a7e3 | ||
|
|
84e8dc0e06 | ||
|
|
fb8ce6c878 | ||
|
|
d961c11eb7 | ||
|
|
90f8e82f14 | ||
|
|
14bb66d12f | ||
|
|
7093985b57 | ||
|
|
a557684542 | ||
|
|
b0876331e6 | ||
|
|
cba7338d8d | ||
|
|
f72d9aee80 | ||
|
|
480fb4818c | ||
|
|
78a3c8a8e4 | ||
|
|
9cb7cc84ee | ||
|
|
2f24a1db41 | ||
|
|
4a2cc70b52 | ||
|
|
3021672de5 | ||
|
|
5d2df3550b | ||
|
|
c0c6e21a16 | ||
|
|
8c03c5e82e | ||
|
|
dfd2f3962c | ||
|
|
d315710310 | ||
|
|
3424cc4e51 | ||
|
|
361931ed96 | ||
|
|
e4f6994dfc | ||
|
|
827a27911c | ||
|
|
1e39d0b186 | ||
|
|
fd223c7542 | ||
|
|
40aa937f54 | ||
|
|
47ab6b8a92 | ||
|
|
7420abf175 | ||
|
|
e9a8194cf8 | ||
|
|
9006049d33 | ||
|
|
39381a17de | ||
|
|
9460549eff | ||
|
|
5ea82645ef | ||
|
|
597abc5b06 | ||
|
|
350265e31f | ||
|
|
5680a306ff | ||
|
|
16cb09bda5 | ||
|
|
9a3c40f6a6 | ||
|
|
821e4a225a | ||
|
|
939c99b0cf | ||
|
|
79b9c7011d | ||
|
|
e7ff7402b4 | ||
|
|
91f6369ba9 | ||
|
|
17ef5cb9a5 | ||
|
|
e8109f1b78 | ||
|
|
f3840d56af | ||
|
|
4a5e0b8d81 | ||
|
|
4ef29f027e | ||
|
|
d4d2efe925 | ||
|
|
1078731f2d | ||
|
|
1739afae24 | ||
|
|
9f0c29c009 | ||
|
|
6220d02f32 | ||
|
|
c166b12515 | ||
|
|
189c870630 | ||
|
|
cdead9ba8a | ||
|
|
21616f4d42 | ||
|
|
0a348278ca | ||
|
|
98d0c9a4f6 | ||
|
|
34a3739545 | ||
|
|
7bb34b8788 | ||
|
|
f6dc432419 | ||
|
|
9b2ee628aa | ||
|
|
357ad26a0e | ||
|
|
a3e705373c | ||
|
|
71ad13256e | ||
|
|
68929631f2 | ||
|
|
9c04065c33 | ||
|
|
09db57db8f | ||
|
|
f9b7e64d53 | ||
|
|
50262f2acc | ||
|
|
a4d99b54af | ||
|
|
485aa0f52b | ||
|
|
f8b732c9b8 | ||
|
|
ac72f77a74 | ||
|
|
626d48d151 | ||
|
|
07511281b8 | ||
|
|
7c11c9c91a | ||
|
|
2cabe4c416 | ||
|
|
dc88a037eb | ||
|
|
2fe8531e51 | ||
|
|
fddd2651fc | ||
|
|
deb0781871 | ||
|
|
8114b04ab6 | ||
|
|
767560804d | ||
|
|
8074b93992 | ||
|
|
588dd41244 | ||
|
|
61b0147a7c | ||
|
|
0d388a396c | ||
|
|
135c79d2ad | ||
|
|
9925b042d8 | ||
|
|
1d16d514c7 | ||
|
|
bda547198e | ||
|
|
5f1b78ec84 | ||
|
|
b7e9a85be0 | ||
|
|
080c1cee4f | ||
|
|
baebede816 | ||
|
|
f455251645 | ||
|
|
8d06f7cf02 | ||
|
|
4af2eaa6a3 | ||
|
|
f5b8879b87 | ||
|
|
7501fee448 | ||
|
|
b7b5090673 | ||
|
|
4f94a0f08a | ||
|
|
2281c8ac39 | ||
|
|
2cc152d0ab | ||
|
|
7b86bb262c | ||
|
|
ed2a4251f1 | ||
|
|
847811a52c | ||
|
|
d25d5b734c | ||
|
|
bc4792b7fd | ||
|
|
7850cbc4bf | ||
|
|
97fa648b2f | ||
|
|
c5cf867cd9 | ||
|
|
03ea9bb760 | ||
|
|
a1a5bf921e | ||
|
|
3e1a7a0dc5 | ||
|
|
2c21387ad9 | ||
|
|
5e8e4fa4a1 | ||
|
|
a41107d021 | ||
|
|
281523ee06 | ||
|
|
2504510c61 | ||
|
|
7153fc8bb5 | ||
|
|
3af094d788 | ||
|
|
785ea71a20 | ||
|
|
05d2f77c0c | ||
|
|
e22366e524 | ||
|
|
2b51c47846 | ||
|
|
dd6af9b8e0 | ||
|
|
c66b17583f | ||
|
|
3ce3520c45 | ||
|
|
8d1e7f4331 | ||
|
|
f0b04afa11 | ||
|
|
f1bfd13da3 | ||
|
|
161cd84150 | ||
|
|
da39593c15 | ||
|
|
571f36e405 | ||
|
|
a4b1200475 | ||
|
|
43807dcba9 | ||
|
|
99a72451d9 | ||
|
|
b8900999a4 | ||
|
|
e6f77376b9 | ||
|
|
b2a6a20f10 | ||
|
|
265b52dccb | ||
|
|
0c112e1db1 | ||
|
|
8eef7db1c6 | ||
|
|
05cbf99237 | ||
|
|
651a7cf83e | ||
|
|
ee27237083 | ||
|
|
72306e91a2 | ||
|
|
75d272be14 | ||
|
|
a8a209f0b0 | ||
|
|
1b7b6196c5 | ||
|
|
ed7937a026 | ||
|
|
f2de4692ea | ||
|
|
16b046bd44 | ||
|
|
7129e2cc9d | ||
|
|
01432fa778 | ||
|
|
9731d28ec3 | ||
|
|
99fbb31554 | ||
|
|
18d258aaa2 | ||
|
|
1af6dd9cf8 | ||
|
|
0da183f084 | ||
|
|
205726b045 | ||
|
|
9cd5237bb8 | ||
|
|
964e94b3ba | ||
|
|
9f54f40f5a | ||
|
|
7047d37f70 | ||
|
|
5b1d45a8fe | ||
|
|
a319957f3e | ||
|
|
816166a30a | ||
|
|
5dd2ea776a | ||
|
|
3b94c7bb43 | ||
|
|
f0198616ad | ||
|
|
267fd403da | ||
|
|
0a8bb7eae5 | ||
|
|
409048c206 | ||
|
|
f84bd6a1e8 | ||
|
|
d5c0e62be1 | ||
|
|
40c4344f73 | ||
|
|
3bd8aca2d2 | ||
|
|
a21bdedbc1 | ||
|
|
797ebd7771 | ||
|
|
04e9ecbc76 | ||
|
|
41d37579dc | ||
|
|
10d23828a7 | ||
|
|
19e3392825 | ||
|
|
6bf4846ae8 | ||
|
|
afcd37dac6 | ||
|
|
c2ff497cc9 | ||
|
|
decd2c2ded | ||
|
|
02d1c9ce98 | ||
|
|
5c9083a5df | ||
|
|
3c7fafa91f | ||
|
|
fd50f8fcab | ||
|
|
1a93df5886 | ||
|
|
bdc086c285 | ||
|
|
82042e0b99 | ||
|
|
c807b30c8f | ||
|
|
72dc76ec74 | ||
|
|
71619042fd | ||
|
|
429a77de8e | ||
|
|
b1f72620dc | ||
|
|
2a54aed135 | ||
|
|
040c1f6f78 | ||
|
|
07bce90521 | ||
|
|
508b093278 | ||
|
|
9bed5bf872 | ||
|
|
6d0a2cd301 | ||
|
|
e1ee08361d | ||
|
|
3332ce34c5 | ||
|
|
2c57e439d5 | ||
|
|
73e2660e59 | ||
|
|
9120bbea34 | ||
|
|
58ea9750d7 | ||
|
|
a59ad97e5e | ||
|
|
0a7b28caf5 | ||
|
|
eaf191e350 | ||
|
|
ecb89f80a0 | ||
|
|
9626b65593 | ||
|
|
c9b5516330 | ||
|
|
4363ca88aa | ||
|
|
3353060ad4 | ||
|
|
ddc3b8575e | ||
|
|
136a2ec89f | ||
|
|
021c68f2a7 | ||
|
|
989a09274f | ||
|
|
39c5886d7a | ||
|
|
1a5f3735cf | ||
|
|
4d47eb0e91 | ||
|
|
af7c59b5c2 | ||
|
|
693bf68864 | ||
|
|
c9ddf3d165 | ||
|
|
1549b56866 | ||
|
|
2cd1f22e68 | ||
|
|
688f38943d | ||
|
|
043bbd7a11 | ||
|
|
f997423fd7 | ||
|
|
1871ef3d38 | ||
|
|
7c56c88dd4 | ||
|
|
4d7422dd90 | ||
|
|
eccabc0588 | ||
|
|
0c7b188587 | ||
|
|
4c97b79adf | ||
|
|
8ae9573b07 | ||
|
|
43fce6e739 | ||
|
|
78900772bb | ||
|
|
c16a0444ca | ||
|
|
0d518166ee | ||
|
|
6ae391a3c9 | ||
|
|
357897a0cd | ||
|
|
10a0a8fe09 | ||
|
|
98443be80c | ||
|
|
bf7f6e99c5 | ||
|
|
b6e468e54e | ||
|
|
dfc634a362 | ||
|
|
d9b6b82f07 | ||
|
|
4ad6257dab | ||
|
|
e3e3f1dfdc | ||
|
|
60f83bb7bf | ||
|
|
bbc10cb105 | ||
|
|
83ea19dd92 | ||
|
|
7ec42dce4d | ||
|
|
a9da7ce6fc | ||
|
|
1586610a44 | ||
|
|
254224c0e8 | ||
|
|
9b66772a12 | ||
|
|
322878b0b7 | ||
|
|
9e181d25ce | ||
|
|
4c311fd78e | ||
|
|
9936b3af5b | ||
|
|
648fd23a57 | ||
|
|
7dd00d2424 | ||
|
|
9e83fe7329 | ||
|
|
166c9c75e9 | ||
|
|
b9882f8985 | ||
|
|
37a166731d | ||
|
|
66db583432 | ||
|
|
f7eb80a6ea | ||
|
|
79f40f3d22 | ||
|
|
ed3b26653c | ||
|
|
2bb13129de | ||
|
|
fc29e8f9fa | ||
|
|
495c2c7390 | ||
|
|
b984386bab | ||
|
|
3781bb93e1 | ||
|
|
3a4dc3f876 | ||
|
|
2c43f1412e | ||
|
|
5d3a93f103 | ||
|
|
5faba1b5a9 | ||
|
|
4e7bd3579b | ||
|
|
49da8a31d2 | ||
|
|
dd2b8f600d | ||
|
|
8b1a3a31ff | ||
|
|
d429374924 | ||
|
|
dd0bbdc7b4 | ||
|
|
64e85c3076 | ||
|
|
68771ce399 | ||
|
|
bcc7faa8e5 | ||
|
|
fb0dc7dea0 | ||
|
|
0fad7b3411 | ||
|
|
1adba05065 | ||
|
|
fe7740f1b0 | ||
|
|
b253dce7e1 | ||
|
|
589b3a7a13 | ||
|
|
26d259b952 | ||
|
|
04e118c081 | ||
|
|
2af2346e35 | ||
|
|
7cd44b5ad3 | ||
|
|
81d96394b9 | ||
|
|
76fe5345d8 | ||
|
|
ef277ef57f | ||
|
|
9a12dab600 | ||
|
|
51f6391ded | ||
|
|
e10e6cfe4d | ||
|
|
d887a37f60 | ||
|
|
1abd1e257f | ||
|
|
137b0820b0 | ||
|
|
3f85d7f813 | ||
|
|
6b6dae129f | ||
|
|
2c3672a7ea | ||
|
|
645a58464c | ||
|
|
fcbb51dce7 | ||
|
|
c7c6a097f0 | ||
|
|
0ce7f29976 | ||
|
|
f2df756c17 | ||
|
|
28b5d44e11 | ||
|
|
e7bb6bc798 | ||
|
|
c572382f6a | ||
|
|
e28c4a1b4d | ||
|
|
f5708fd539 | ||
|
|
5769abb626 | ||
|
|
4ebe0abba0 | ||
|
|
8109c9ac4f | ||
|
|
2ce1ceb460 | ||
|
|
9d701ad671 | ||
|
|
4aee44fe11 | ||
|
|
adb41a80c5 | ||
|
|
642e6ebdc8 | ||
|
|
74828943a6 | ||
|
|
f906e04581 | ||
|
|
b3c47e759f | ||
|
|
8bbb5d2e09 | ||
|
|
7fe03be73f | ||
|
|
abb0124011 | ||
|
|
a98b2bb71a | ||
|
|
bc1702e6cf | ||
|
|
577a5366e8 | ||
|
|
7fedd5729e | ||
|
|
35c0463829 | ||
|
|
1b40f81fcc | ||
|
|
afefd925ea | ||
|
|
0850562bf9 | ||
|
|
bc2335a54e | ||
|
|
5a9fc3ad18 | ||
|
|
29f85db022 | ||
|
|
6034908a95 | ||
|
|
ef3dbc217b | ||
|
|
01357617ae | ||
|
|
4775f4ea31 | ||
|
|
ae7b27e1c9 | ||
|
|
70c8c4b4aa |
41
.env.example
41
.env.example
@@ -1,15 +1,39 @@
|
||||
# set timezone to get correct log timestamp
|
||||
TZ=ETC/UTC
|
||||
|
||||
# API/WebUI user password login credentials (optional)
|
||||
# These fields are not required for OIDC authentication
|
||||
GODOXY_API_USER=admin
|
||||
GODOXY_API_PASSWORD=password
|
||||
# generate secret with `openssl rand -base64 32`
|
||||
GODOXY_API_JWT_SECRET=
|
||||
|
||||
# the JWT token time-to-live
|
||||
GODOXY_API_JWT_TOKEN_TTL=1h
|
||||
|
||||
# API/WebUI login credentials
|
||||
GODOXY_API_USER=admin
|
||||
GODOXY_API_PASSWORD=password
|
||||
# OIDC Configuration (optional)
|
||||
# Uncomment and configure these values to enable OIDC authentication.
|
||||
# GODOXY_OIDC_ISSUER_URL=https://accounts.google.com
|
||||
# GODOXY_OIDC_CLIENT_ID=your-client-id
|
||||
# GODOXY_OIDC_CLIENT_SECRET=your-client-secret
|
||||
# Keep /api/auth/callback as the redirect URL, change the domain to match your setup.
|
||||
# GODOXY_OIDC_REDIRECT_URL=https://your-domain/api/auth/callback
|
||||
# Comma-separated list of scopes
|
||||
# GODOXY_OIDC_SCOPES=openid, profile, email
|
||||
#
|
||||
# User definitions: Uncomment and configure these values to restrict access to specific users or groups.
|
||||
# These two fields act as a logical AND operator. For example, given the following membership:
|
||||
# user1, group1
|
||||
# user2, group1
|
||||
# user3, group2
|
||||
# user1, group2
|
||||
# You can allow access to user3 AND all users of group1 by providing:
|
||||
# # GODOXY_OIDC_ALLOWED_USERS=user3
|
||||
# # GODOXY_OIDC_ALLOWED_GROUPS=group1
|
||||
#
|
||||
# Comma-separated list of allowed users.
|
||||
# GODOXY_OIDC_ALLOWED_USERS=user1,user2
|
||||
# Optional: Comma-separated list of allowed groups.
|
||||
# GODOXY_OIDC_ALLOWED_GROUPS=group1,group2
|
||||
|
||||
# Proxy listening address
|
||||
GODOXY_HTTP_ADDR=:80
|
||||
@@ -18,8 +42,11 @@ GODOXY_HTTPS_ADDR=:443
|
||||
# API listening address
|
||||
GODOXY_API_ADDR=127.0.0.1:8888
|
||||
|
||||
# Prometheus Metrics listening address (uncomment to enable)
|
||||
#GODOXY_PROMETHEUS_ADDR=:8889
|
||||
# Frontend listening port
|
||||
GODOXY_FRONTEND_PORT=3000
|
||||
|
||||
# Prometheus Metrics
|
||||
GODOXY_PROMETHEUS_ENABLED=true
|
||||
|
||||
# Debug mode
|
||||
GODOXY_DEBUG=false
|
||||
GODOXY_DEBUG=false
|
||||
15
.github/FUNDING.yml
vendored
Normal file
15
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: yusing # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
|
||||
patreon: # Replace with a single Patreon username
|
||||
open_collective: # Replace with a single Open Collective username
|
||||
ko_fi: # Replace with a single Ko-fi username
|
||||
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||
liberapay: # Replace with a single Liberapay username
|
||||
issuehunt: # Replace with a single IssueHunt username
|
||||
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
|
||||
polar: # Replace with a single Polar username
|
||||
buy_me_a_coffee: yusingwysq # Replace with a single Buy Me a Coffee username
|
||||
thanks_dev: # Replace with a single thanks.dev username
|
||||
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||
51
.github/workflows/agent-binary.yml
vendored
Normal file
51
.github/workflows/agent-binary.yml
vendored
Normal file
@@ -0,0 +1,51 @@
|
||||
name: GoDoxy agent binary
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- v*
|
||||
paths:
|
||||
- "agent/**"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- runner: ubuntu-latest
|
||||
platform: linux/amd64
|
||||
binary_name: godoxy-agent-linux-amd64
|
||||
- runner: ubuntu-24.04-arm
|
||||
platform: linux/arm64
|
||||
binary_name: godoxy-agent-linux-arm64
|
||||
name: Build ${{ matrix.platform }}
|
||||
runs-on: ${{ matrix.runner }}
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- name: Verify dependencies
|
||||
run: go mod verify
|
||||
- name: Build
|
||||
run: |
|
||||
make agent=1 NAME=${{ matrix.binary_name }} build
|
||||
- name: Check binary
|
||||
run: |
|
||||
file bin/${{ matrix.binary_name }}
|
||||
- name: Test
|
||||
run: |
|
||||
go test -v ./agent/...
|
||||
- name: Upload
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.binary_name }}
|
||||
path: bin/${{ matrix.binary_name }}
|
||||
- name: Upload to release
|
||||
uses: softprops/action-gh-release@v2
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
with:
|
||||
files: bin/${{ matrix.binary_name }}
|
||||
23
.github/workflows/docker-image-nightly.yml
vendored
Normal file
23
.github/workflows/docker-image-nightly.yml
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
name: Docker Image CI (nightly)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "*" # matches every branch that doesn't contain a '/'
|
||||
- "*/*" # matches every branch containing a single '/'
|
||||
- "**" # matches every branch
|
||||
- "!dependabot/*"
|
||||
- "!main" # excludes main
|
||||
|
||||
jobs:
|
||||
build-nightly:
|
||||
uses: ./.github/workflows/docker-image.yml
|
||||
with:
|
||||
image_name: ${{ github.repository_owner }}/godoxy
|
||||
tag: nightly
|
||||
build-nightly-agent:
|
||||
uses: ./.github/workflows/docker-image.yml
|
||||
with:
|
||||
image_name: ${{ github.repository_owner }}/godoxy-agent
|
||||
tag: nightly
|
||||
agent: true
|
||||
20
.github/workflows/docker-image-prod.yml
vendored
Normal file
20
.github/workflows/docker-image-prod.yml
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
name: Docker Image CI
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- v*
|
||||
|
||||
jobs:
|
||||
build-prod:
|
||||
uses: ./.github/workflows/docker-image.yml
|
||||
with:
|
||||
image_name: ${{ github.repository_owner }}/godoxy
|
||||
old_image_name: ${{ github.repository_owner }}/go-proxy
|
||||
tag: latest
|
||||
build-prod-agent:
|
||||
uses: ./.github/workflows/docker-image.yml
|
||||
with:
|
||||
image_name: ${{ github.repository_owner }}/godoxy-agent
|
||||
tag: latest
|
||||
agent: true
|
||||
249
.github/workflows/docker-image.yml
vendored
249
.github/workflows/docker-image.yml
vendored
@@ -1,128 +1,165 @@
|
||||
name: Docker Image CI
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ["*"]
|
||||
workflow_call:
|
||||
inputs:
|
||||
tag:
|
||||
required: true
|
||||
type: string
|
||||
image_name:
|
||||
required: true
|
||||
type: string
|
||||
old_image_name:
|
||||
required: false
|
||||
type: string
|
||||
agent:
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
REGISTRY: ghcr.io
|
||||
MAKE_ARGS: agent=${{ inputs.agent && '1' || '0' }}
|
||||
DIGEST_PATH: /tmp/digests/${{ inputs.agent && 'agent' || 'main' }}
|
||||
DIGEST_NAME_SUFFIX: ${{ inputs.agent && 'agent' || 'main' }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build multi-platform Docker image
|
||||
runs-on: ubuntu-22.04
|
||||
build:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- runner: ubuntu-latest
|
||||
platform: linux/amd64
|
||||
- runner: ubuntu-24.04-arm
|
||||
platform: linux/arm64
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
id-token: write
|
||||
attestations: write
|
||||
name: Build ${{ matrix.platform }}
|
||||
runs-on: ${{ matrix.runner }}
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
platform:
|
||||
- linux/amd64
|
||||
# - linux/arm/v6
|
||||
# - linux/arm/v7
|
||||
- linux/arm64
|
||||
steps:
|
||||
- name: Prepare
|
||||
run: |
|
||||
platform=${{ matrix.platform }}
|
||||
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
id-token: write
|
||||
attestations: write
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
steps:
|
||||
- name: Prepare
|
||||
run: |
|
||||
platform=${{ matrix.platform }}
|
||||
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ inputs.image_name }}
|
||||
tags: |
|
||||
type=raw,value=${{ inputs.tag }},event=branch
|
||||
type=ref,event=tag
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
platforms: ${{ matrix.platform }}
|
||||
|
||||
- name: Login to registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Login to registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push by digest
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
platforms: ${{ matrix.platform }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
outputs: type=image,name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
build-args: |
|
||||
VERSION=${{ github.ref_name }}
|
||||
- name: Build and push by digest
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
platforms: ${{ matrix.platform }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
outputs: type=image,name=${{ env.REGISTRY }}/${{ inputs.image_name }},push-by-digest=true,name-canonical=true,push=true
|
||||
cache-from: |
|
||||
type=registry,ref=${{ env.REGISTRY }}/${{ inputs.image_name }}:buildcache-${{ env.PLATFORM_PAIR }}-${{ inputs.tag }}
|
||||
cache-to: |
|
||||
type=registry,ref=${{ env.REGISTRY }}/${{ inputs.image_name }}:buildcache-${{ env.PLATFORM_PAIR }}-${{ inputs.tag }},mode=max
|
||||
build-args: |
|
||||
VERSION=${{ github.ref_name }}
|
||||
MAKE_ARGS=${{ env.MAKE_ARGS }}
|
||||
|
||||
- name: Generate artifact attestation
|
||||
uses: actions/attest-build-provenance@v1
|
||||
with:
|
||||
subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME}}
|
||||
subject-digest: ${{ steps.build.outputs.digest }}
|
||||
push-to-registry: true
|
||||
- name: Generate artifact attestation
|
||||
uses: actions/attest-build-provenance@v1
|
||||
with:
|
||||
subject-name: ${{ env.REGISTRY }}/${{ inputs.image_name }}
|
||||
subject-digest: ${{ steps.build.outputs.digest }}
|
||||
push-to-registry: true
|
||||
|
||||
- name: Export digest
|
||||
run: |
|
||||
mkdir -p /tmp/digests
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
- name: Export digest
|
||||
run: |
|
||||
mkdir -p ${{ env.DIGEST_PATH }}
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
touch "${{ env.DIGEST_PATH }}/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digests-${{ env.PLATFORM_PAIR }}
|
||||
path: /tmp/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
merge:
|
||||
runs-on: ubuntu-22.04
|
||||
needs:
|
||||
- build
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: digests-*
|
||||
merge-multiple: true
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digests-${{ env.PLATFORM_PAIR }}-${{ env.DIGEST_NAME_SUFFIX }}
|
||||
path: ${{ env.DIGEST_PATH }}/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
merge:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
id-token: write
|
||||
|
||||
- name: Login to registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
steps:
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: ${{ env.DIGEST_PATH }}
|
||||
pattern: digests-*-${{ env.DIGEST_NAME_SUFFIX }}
|
||||
merge-multiple: true
|
||||
|
||||
- name: Create manifest list and push
|
||||
id: push
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf '${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@sha256:%s ' *)
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Inspect image
|
||||
run: |
|
||||
docker buildx imagetools inspect ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }}
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ inputs.image_name }}
|
||||
tags: |
|
||||
type=raw,value=${{ inputs.tag }},event=branch
|
||||
type=ref,event=tag
|
||||
|
||||
- name: Login to registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Create manifest list and push
|
||||
id: push
|
||||
working-directory: ${{ env.DIGEST_PATH }}
|
||||
run: |
|
||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf '${{ env.REGISTRY }}/${{ inputs.image_name }}@sha256:%s ' *)
|
||||
|
||||
- name: Old image name
|
||||
if: inputs.old_image_name != ''
|
||||
run: |
|
||||
docker buildx imagetools create -t ${{ env.REGISTRY }}/${{ inputs.old_image_name }}:${{ steps.meta.outputs.version }}\
|
||||
${{ env.REGISTRY }}/${{ inputs.image_name }}:${{ steps.meta.outputs.version }}
|
||||
|
||||
- name: Inspect image
|
||||
run: |
|
||||
docker buildx imagetools inspect ${{ env.REGISTRY }}/${{ inputs.image_name }}:${{ steps.meta.outputs.version }}
|
||||
|
||||
- name: Inspect image (old)
|
||||
if: inputs.old_image_name != ''
|
||||
run: |
|
||||
docker buildx imagetools inspect ${{ env.REGISTRY }}/${{ inputs.old_image_name }}:${{ steps.meta.outputs.version }}
|
||||
|
||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -4,10 +4,13 @@ compose.yml
|
||||
config
|
||||
certs
|
||||
config*/
|
||||
!schemas/**
|
||||
certs*/
|
||||
bin/
|
||||
error_pages/
|
||||
!examples/error_pages/
|
||||
profiles/
|
||||
data/
|
||||
|
||||
logs/
|
||||
log/
|
||||
@@ -26,3 +29,9 @@ todo.md
|
||||
mtrace.json
|
||||
.env
|
||||
test.Dockerfile
|
||||
|
||||
node_modules/
|
||||
tsconfig.tsbuildinfo
|
||||
|
||||
!agent.compose.yml
|
||||
!agent/pkg/**
|
||||
0
.gitmodules
vendored
0
.gitmodules
vendored
@@ -9,9 +9,6 @@ linters-settings:
|
||||
- fieldalignment
|
||||
gocyclo:
|
||||
min-complexity: 14
|
||||
goconst:
|
||||
min-len: 3
|
||||
min-occurrences: 4
|
||||
misspell:
|
||||
locale: US
|
||||
funlen:
|
||||
@@ -102,13 +99,14 @@ linters:
|
||||
- depguard # Not relevant
|
||||
- nakedret # Too strict
|
||||
- lll # Not relevant
|
||||
- gocyclo # FIXME must be fixed
|
||||
- gocyclo # must be fixed
|
||||
- gocognit # Too strict
|
||||
- nestif # Too many false-positive.
|
||||
- prealloc # Too many false-positive.
|
||||
- makezero # Not relevant
|
||||
- dupl # Too strict
|
||||
- gci # I don't care
|
||||
- goconst # Too annoying
|
||||
- gosec # Too strict
|
||||
- gochecknoinits
|
||||
- gochecknoglobals
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
# To learn more about the format of this file, see https://docs.trunk.io/reference/trunk-yaml
|
||||
version: 0.1
|
||||
cli:
|
||||
version: 1.22.8
|
||||
version: 1.22.10
|
||||
# Trunk provides extensibility via plugins. (https://docs.trunk.io/plugins)
|
||||
plugins:
|
||||
sources:
|
||||
- id: trunk
|
||||
ref: v1.6.6
|
||||
ref: v1.6.7
|
||||
uri: https://github.com/trunk-io/plugins
|
||||
# Many linters and tools depend on runtimes - configure them here. (https://docs.trunk.io/runtimes)
|
||||
runtimes:
|
||||
@@ -22,17 +22,16 @@ lint:
|
||||
- yamllint
|
||||
enabled:
|
||||
- hadolint@2.12.1-beta
|
||||
- actionlint@1.7.5
|
||||
- checkov@3.2.346
|
||||
- actionlint@1.7.7
|
||||
- git-diff-check
|
||||
- gofmt@1.20.4
|
||||
- golangci-lint@1.62.2
|
||||
- golangci-lint@1.64.5
|
||||
- osv-scanner@1.9.2
|
||||
- oxipng@9.1.3
|
||||
- prettier@3.4.2
|
||||
- oxipng@9.1.4
|
||||
- prettier@3.5.1
|
||||
- shellcheck@0.10.0
|
||||
- shfmt@3.6.0
|
||||
- trufflehog@3.88.0
|
||||
- trufflehog@3.88.9
|
||||
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/schema/config.schema.json": [
|
||||
"https://github.com/yusing/go-proxy/raw/main/schemas/config.schema.json": [
|
||||
"config.example.yml",
|
||||
"config.yml"
|
||||
],
|
||||
"https://github.com/yusing/go-proxy/raw/main/schema/providers.schema.json": [
|
||||
"https://github.com/yusing/go-proxy/raw/main/schemas/routes.schema.json": [
|
||||
"providers.example.yml"
|
||||
]
|
||||
}
|
||||
|
||||
48
Dockerfile
48
Dockerfile
@@ -1,38 +1,43 @@
|
||||
# Stage 1: Builder
|
||||
FROM golang:1.23.4-alpine AS builder
|
||||
# Stage 1: deps
|
||||
FROM golang:1.24.1-alpine AS deps
|
||||
HEALTHCHECK NONE
|
||||
|
||||
# package version does not matter
|
||||
# trunk-ignore(hadolint/DL3018)
|
||||
RUN apk add --no-cache tzdata make
|
||||
RUN apk add --no-cache tzdata make libcap-setcap
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
# Only copy go.mod and go.sum initially for better caching
|
||||
COPY go.mod go.sum /src/
|
||||
|
||||
# Utilize build cache
|
||||
RUN --mount=type=cache,target="/go/pkg/mod" \
|
||||
go mod download -x
|
||||
ENV GOPATH=/root/go
|
||||
RUN go mod download -x
|
||||
|
||||
ENV GOCACHE=/root/.cache/go-build
|
||||
# Stage 2: builder
|
||||
FROM deps AS builder
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
COPY Makefile ./
|
||||
COPY cmd ./cmd
|
||||
COPY internal ./internal
|
||||
COPY pkg ./pkg
|
||||
COPY agent ./agent
|
||||
|
||||
ARG VERSION
|
||||
ENV VERSION=${VERSION}
|
||||
|
||||
COPY scripts /src/scripts
|
||||
COPY Makefile /src/
|
||||
COPY cmd /src/cmd
|
||||
COPY internal /src/internal
|
||||
COPY pkg /src/pkg
|
||||
ARG MAKE_ARGS
|
||||
ENV MAKE_ARGS=${MAKE_ARGS}
|
||||
|
||||
RUN --mount=type=cache,target="/go/pkg/mod" \
|
||||
--mount=type=cache,target="/root/.cache/go-build" \
|
||||
make build && \
|
||||
mkdir -p /app/error_pages /app/certs && \
|
||||
mv bin/godoxy /app/godoxy
|
||||
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
|
||||
|
||||
# Stage 2: Final image
|
||||
# Stage 3: Final image
|
||||
FROM scratch
|
||||
|
||||
LABEL maintainer="yusing@6uo.me"
|
||||
@@ -51,12 +56,7 @@ COPY config.example.yml /app/config/config.yml
|
||||
COPY --from=builder /etc/ssl/certs /etc/ssl/certs
|
||||
|
||||
ENV DOCKER_HOST=unix:///var/run/docker.sock
|
||||
ENV GODOXY_DEBUG=0
|
||||
|
||||
EXPOSE 80
|
||||
EXPOSE 8888
|
||||
EXPOSE 443
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
CMD ["/app/godoxy"]
|
||||
CMD ["/app/run"]
|
||||
145
Makefile
145
Makefile
@@ -1,53 +1,81 @@
|
||||
VERSION ?= $(shell git describe --tags --abbrev=0)
|
||||
BUILD_FLAGS ?= -s -w
|
||||
BUILD_DATE ?= $(shell date -u +'%Y%m%d-%H%M')
|
||||
export VERSION
|
||||
export BUILD_FLAGS
|
||||
export CGO_ENABLED = 0
|
||||
export VERSION ?= $(shell git describe --tags --abbrev=0)
|
||||
export BUILD_DATE ?= $(shell date -u +'%Y%m%d-%H%M')
|
||||
export GOOS = linux
|
||||
|
||||
.PHONY: all setup build test up restart logs get debug run archive repush rapid-crash debug-list-containers
|
||||
LDFLAGS = -X github.com/yusing/go-proxy/pkg.version=${VERSION}
|
||||
|
||||
all: debug
|
||||
|
||||
build:
|
||||
scripts/build.sh
|
||||
ifeq ($(agent), 1)
|
||||
NAME = godoxy-agent
|
||||
CMD_PATH = ./agent/cmd
|
||||
else
|
||||
NAME = godoxy
|
||||
CMD_PATH = ./cmd
|
||||
endif
|
||||
|
||||
ifeq ($(trace), 1)
|
||||
debug = 1
|
||||
GODOXY_TRACE ?= 1
|
||||
GODEBUG = gctrace=1 inittrace=1 schedtrace=3000
|
||||
endif
|
||||
|
||||
ifeq ($(race), 1)
|
||||
debug = 1
|
||||
BUILD_FLAGS += -race
|
||||
endif
|
||||
|
||||
ifeq ($(debug), 1)
|
||||
CGO_ENABLED = 0
|
||||
GODOXY_DEBUG = 1
|
||||
BUILD_FLAGS += -gcflags=all='-N -l'
|
||||
endif
|
||||
|
||||
ifeq ($(pprof), 1)
|
||||
CGO_ENABLED = 1
|
||||
GORACE = log_path=logs/pprof strip_path_prefix=$(shell pwd)/ halt_on_error=1
|
||||
BUILD_FLAGS = -tags pprof
|
||||
VERSION := ${VERSION}-pprof
|
||||
else
|
||||
CGO_ENABLED = 0
|
||||
LDFLAGS += -s -w
|
||||
BUILD_FLAGS = -pgo=auto -tags production
|
||||
endif
|
||||
|
||||
BUILD_FLAGS += -ldflags='$(LDFLAGS)'
|
||||
|
||||
export NAME
|
||||
export CMD_PATH
|
||||
export CGO_ENABLED
|
||||
export GODOXY_DEBUG
|
||||
export GODOXY_TRACE
|
||||
export GODEBUG
|
||||
export GORACE
|
||||
export BUILD_FLAGS
|
||||
|
||||
test:
|
||||
GODOXY_TEST=1 go test ./internal/...
|
||||
|
||||
up:
|
||||
docker compose up -d
|
||||
|
||||
restart:
|
||||
docker compose restart -t 0
|
||||
|
||||
logs:
|
||||
docker compose logs -f
|
||||
|
||||
get:
|
||||
go get -u ./cmd && go mod tidy
|
||||
|
||||
debug:
|
||||
GODOXY_DEBUG=1 BUILD_FLAGS="" make run
|
||||
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
|
||||
|
||||
debug-trace:
|
||||
GODOXY_TRACE=1 make debug
|
||||
|
||||
profile:
|
||||
GODEBUG=gctrace=1 make debug
|
||||
|
||||
run: build
|
||||
sudo setcap CAP_NET_BIND_SERVICE=+eip bin/godoxy
|
||||
bin/godoxy
|
||||
run:
|
||||
[ -f .env ] && godotenv -f .env go run ${BUILD_FLAGS} ${CMD_PATH}
|
||||
|
||||
mtrace:
|
||||
bin/godoxy debug-ls-mtrace > mtrace.json
|
||||
|
||||
rapid-crash:
|
||||
sudo docker run --restart=always --name test_crash -p 80 debian:bookworm-slim /bin/cat &&\
|
||||
docker run --restart=always --name test_crash -p 80 debian:bookworm-slim /bin/cat &&\
|
||||
sleep 3 &&\
|
||||
sudo docker rm -f test_crash
|
||||
docker rm -f test_crash
|
||||
|
||||
debug-list-containers:
|
||||
bash -c 'echo -e "GET /containers/json HTTP/1.0\r\n" | sudo netcat -U /var/run/docker.sock | tail -n +9 | jq'
|
||||
@@ -59,15 +87,46 @@ ci-test:
|
||||
cloc:
|
||||
cloc --not-match-f '_test.go$$' cmd internal pkg
|
||||
|
||||
push-docker-io:
|
||||
BUILDER=build docker buildx build \
|
||||
--platform linux/arm64,linux/amd64 \
|
||||
-f Dockerfile \
|
||||
-t docker.io/yusing/godoxy-nightly \
|
||||
-t docker.io/yusing/godoxy-nightly:${VERSION}-${BUILD_DATE} \
|
||||
--build-arg VERSION="${VERSION}-nightly-${BUILD_DATE}" \
|
||||
--push .
|
||||
link-binary:
|
||||
ln -s /app/${NAME} bin/run
|
||||
|
||||
build-docker:
|
||||
docker build -t godoxy-nightly \
|
||||
--build-arg VERSION="${VERSION}-nightly-${BUILD_DATE}" .
|
||||
# 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
|
||||
|
||||
push-github:
|
||||
git push origin $(shell git rev-parse --abbrev-ref HEAD)
|
||||
177
README.md
177
README.md
@@ -1,19 +1,25 @@
|
||||
<div align="center">
|
||||
|
||||
# GoDoxy
|
||||
|
||||
[](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)
|
||||
[](https://sonarcloud.io/summary/new_code?id=yusing_godoxy)
|
||||

|
||||
[](https://sonarcloud.io/summary/new_code?id=yusing_godoxy)
|
||||
[](https://discord.gg/umReR62nRd)
|
||||
|
||||
A lightweight, simple, and [performant](https://github.com/yusing/godoxy/wiki/Benchmarks) reverse proxy with WebUI.
|
||||
|
||||
For full documentation, check out **[Wiki](https://github.com/yusing/godoxy/wiki)**
|
||||
|
||||
**EN** | <a href="README_CHT.md">中文</a>
|
||||
|
||||
<!-- [](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)
|
||||
[](https://discord.gg/umReR62nRd)
|
||||
[](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy) -->
|
||||
|
||||
[繁體中文文檔請看此](README_CHT.md)
|
||||
<img src="screenshots/webui.jpg" style="max-width: 650">
|
||||
|
||||
A lightweight, easy-to-use, and [performant](https://github.com/yusing/go-proxy/wiki/Benchmarks) reverse proxy with a Web UI and dashboard.
|
||||
|
||||

|
||||
|
||||
_Join our [Discord](https://discord.gg/umReR62nRd) for help and discussions_
|
||||
</div>
|
||||
|
||||
## Table of content
|
||||
|
||||
@@ -22,98 +28,117 @@ _Join our [Discord](https://discord.gg/umReR62nRd) for help and discussions_
|
||||
- [GoDoxy](#godoxy)
|
||||
- [Table of content](#table-of-content)
|
||||
- [Key Features](#key-features)
|
||||
- [Getting Started](#getting-started)
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [Setup](#setup)
|
||||
- [Manual Setup](#manual-setup)
|
||||
- [Folder structrue](#folder-structrue)
|
||||
- [Use JSON Schema in VSCode](#use-json-schema-in-vscode)
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [How does GoDoxy work](#how-does-godoxy-work)
|
||||
- [Setup](#setup)
|
||||
- [Screenshots](#screenshots)
|
||||
- [idlesleeper](#idlesleeper)
|
||||
- [Metrics and Logs](#metrics-and-logs)
|
||||
- [Manual Setup](#manual-setup)
|
||||
- [Folder structrue](#folder-structrue)
|
||||
- [Build it yourself](#build-it-yourself)
|
||||
|
||||
## Key Features
|
||||
|
||||
- Easy to use
|
||||
- Effortless configuration
|
||||
- Simple multi-node setup
|
||||
- Simple multi-node setup with GoDoxy agents or Docker Socket Proxies
|
||||
- Error messages is clear and detailed, easy troubleshooting
|
||||
- Auto SSL cert management (See [Supported DNS-01 Challenge Providers](https://github.com/yusing/go-proxy/wiki/Supported-DNS%E2%80%9001-Providers))
|
||||
- Auto configuration for docker containers
|
||||
- Auto 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
|
||||
- **idlesleeper**: stop containers on idle, wake it up on traffic _(optional, see [screenshots](#idlesleeper))_
|
||||
- HTTP(s) reserve proxy
|
||||
- [HTTP middleware support](https://github.com/yusing/go-proxy/wiki/Middlewares)
|
||||
- [Custom error pages support](https://github.com/yusing/go-proxy/wiki/Middlewares#custom-error-pages)
|
||||
- TCP and UDP port forwarding
|
||||
- **Web UI with App dashboard and config editor**
|
||||
- Supports linux/amd64, linux/arm64
|
||||
- Container aware: create routes dynamically from running docker containers
|
||||
- **idlesleeper**: stop and wake containers based on traffic _(optional, see [screenshots](#idlesleeper))_
|
||||
- HTTP reserve proxy and TCP/UDP port forwarding
|
||||
- OpenID Connect integration: SSO and secure your apps easily
|
||||
- [HTTP middleware](https://github.com/yusing/go-proxy/wiki/Middlewares) and [Custom error pages support](https://github.com/yusing/go-proxy/wiki/Middlewares#custom-error-pages)
|
||||
- **Web UI with App dashboard, config editor, _uptime and system metrics_, _docker logs viewer_**
|
||||
- Supports linux/amd64 and linux/arm64
|
||||
- Written in **[Go](https://go.dev)**
|
||||
|
||||
[🔼Back to top](#table-of-content)
|
||||
|
||||
## Getting Started
|
||||
## Prerequisites
|
||||
|
||||
For full documentation, **[See Wiki](https://github.com/yusing/go-proxy/wiki)**
|
||||
Setup Wildcard DNS Record(s) for machine running `GoDoxy`, e.g.
|
||||
|
||||
### Prerequisites
|
||||
- A Record: `*.domain.com` -> `10.0.10.1`
|
||||
- AAAA Record (if you use IPv6): `*.domain.com` -> `::ffff:a00:a01`
|
||||
|
||||
Setup DNS Records point to machine which runs `GoDoxy`, e.g.
|
||||
## How does GoDoxy work
|
||||
|
||||
- A Record: `*.y.z` -> `10.0.10.1`
|
||||
- AAAA Record: `*.y.z` -> `::ffff:a00:a01`
|
||||
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
|
||||
|
||||
### Setup
|
||||
GoDoxy uses the label `proxy.aliases` as the subdomain(s), if unset it defaults to the `container_name` field in docker compose.
|
||||
|
||||
1. Pull the latest docker images
|
||||
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`.
|
||||
|
||||
1. Prepare a new directory for docker compose and config files.
|
||||
|
||||
2. Run setup script inside the directory, or [set up manually](#manual-setup)
|
||||
|
||||
```shell
|
||||
docker pull ghcr.io/yusing/go-proxy:latest
|
||||
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/yusing/godoxy/main/scripts/setup.sh)"
|
||||
```
|
||||
|
||||
2. Create new directory, `cd` into it, then run setup, or [set up manually](#manual-setup)
|
||||
3. Start the container `docker compose up -d` and wait for it to be ready
|
||||
|
||||
```shell
|
||||
docker run --rm -v .:/setup ghcr.io/yusing/go-proxy /app/godoxy setup
|
||||
```
|
||||
|
||||
3. _(Optional)_ setup WebUI login
|
||||
|
||||
- set random JWT secret
|
||||
|
||||
```shell
|
||||
sed -i "s|API_JWT_SECRET=.*|API_JWT_SECRET=$(openssl rand -base64 32)|g" .env
|
||||
```
|
||||
|
||||
- change username and password for WebUI authentication
|
||||
```shell
|
||||
sed -i "s|API_USERNAME=.*|API_USERNAME=admin|g" .env
|
||||
sed -i "s|API_PASSWORD=.*|API_PASSWORD=some-strong-password|g" .env
|
||||
```
|
||||
|
||||
4. _(Optional)_ setup `docker-socket-proxy` other docker nodes (see [Multi docker nodes setup](https://github.com/yusing/go-proxy/wiki/Configurations#multi-docker-nodes-setup)) then add them inside `config.yml`
|
||||
|
||||
5. Start the container `docker compose up -d`
|
||||
|
||||
6. You may now do some extra configuration
|
||||
- With text editor (e.g. Visual Studio Code)
|
||||
- With Web UI via `https://gp.y.z`
|
||||
4. You may now do some extra configuration on WebUI `https://godoxy.yourdomain.com`
|
||||
|
||||
[🔼Back to top](#table-of-content)
|
||||
|
||||
### Manual Setup
|
||||
## Screenshots
|
||||
|
||||
### idlesleeper
|
||||
|
||||

|
||||
|
||||
### Metrics and Logs
|
||||
|
||||
<div align="center">
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center"><img src="screenshots/uptime.png" alt="Uptime Monitor" width="250"/></td>
|
||||
<td align="center"><img src="screenshots/docker-logs.jpg" alt="Docker Logs" width="250"/></td>
|
||||
<td align="center"><img src="screenshots/docker.jpg" alt="Server Overview" width="250"/></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><b>Uptime Monitor</b></td>
|
||||
<td align="center"><b>Docker Logs</b></td>
|
||||
<td align="center"><b>Server Overview</b></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><img src="screenshots/system-monitor.jpg" alt="System Monitor" width="250"/></td>
|
||||
<td align="center"><img src="screenshots/system-info-graphs.jpg" alt="Graphs" width="250"/></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><b>System Monitor</b></td>
|
||||
<td align="center"><b>Graphs</b></td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
[🔼Back to top](#table-of-content)
|
||||
|
||||
## Manual Setup
|
||||
|
||||
1. Make `config` directory then grab `config.example.yml` into `config/config.yml`
|
||||
|
||||
`mkdir -p config && wget https://raw.githubusercontent.com/yusing/go-proxy/v0.8/config.example.yml -O config/config.yml`
|
||||
`mkdir -p config && wget https://raw.githubusercontent.com/yusing/godoxy/main/config.example.yml -O config/config.yml`
|
||||
|
||||
2. Grab `.env.example` into `.env`
|
||||
|
||||
`wget https://raw.githubusercontent.com/yusing/go-proxy/v0.8/.env.example -O .env`
|
||||
`wget https://raw.githubusercontent.com/yusing/godoxy/main/.env.example -O .env`
|
||||
|
||||
3. Grab `compose.example.yml` into `compose.yml`
|
||||
|
||||
`wget https://raw.githubusercontent.com/yusing/go-proxy/v0.8/compose.example.yml -O compose.yml`
|
||||
`wget https://raw.githubusercontent.com/yusing/godoxy/main/compose.example.yml -O compose.yml`
|
||||
|
||||
### Folder structrue
|
||||
|
||||
@@ -129,26 +154,16 @@ Setup DNS Records point to machine which runs `GoDoxy`, e.g.
|
||||
│ │ ├── middleware2.yml
|
||||
│ ├── provider1.yml
|
||||
│ └── provider2.yml
|
||||
├── data
|
||||
│ ├── metrics # metrics data
|
||||
│ │ ├── uptime.json
|
||||
│ │ └── system_info.json
|
||||
└── .env
|
||||
```
|
||||
|
||||
### Use JSON Schema in VSCode
|
||||
|
||||
Copy [`.vscode/settings.example.json`](.vscode/settings.example.json) to `.vscode/settings.json` and modify it to fit your needs
|
||||
|
||||
[🔼Back to top](#table-of-content)
|
||||
|
||||
## Screenshots
|
||||
|
||||
### idlesleeper
|
||||
|
||||

|
||||
|
||||
[🔼Back to top](#table-of-content)
|
||||
|
||||
## Build it yourself
|
||||
|
||||
1. Clone the repository `git clone https://github.com/yusing/go-proxy --depth=1`
|
||||
1. Clone the repository `git clone https://github.com/yusing/godoxy --depth=1`
|
||||
|
||||
2. Install / Upgrade [go (>=1.22)](https://go.dev/doc/install) and `make` if not already
|
||||
|
||||
|
||||
125
README_CHT.md
125
README_CHT.md
@@ -1,19 +1,25 @@
|
||||
<div align="center">
|
||||
|
||||
# GoDoxy
|
||||
|
||||
[](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)
|
||||
[](https://discord.gg/umReR62nRd)
|
||||
|
||||
輕量、易用、 [高效能](https://github.com/yusing/godoxy/wiki/Benchmarks),且帶有主頁和配置面板的反向代理
|
||||
|
||||
完整文檔請查閱 **[Wiki](https://github.com/yusing/godoxy/wiki)**(暫未有中文翻譯)
|
||||
|
||||
<!-- [](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)
|
||||
[](https://discord.gg/umReR62nRd)
|
||||
[](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy) -->
|
||||
|
||||
[English Documentation](README.md)
|
||||
<a href="README.md">EN</a> | **中文**
|
||||
|
||||
一個輕量級、易於使用且[高效能](https://github.com/yusing/go-proxy/wiki/Benchmarks)的反向代理,具有網頁介面和儀表板。
|
||||
<img src="https://github.com/user-attachments/assets/4bb371f4-6e4c-425c-89b2-b9e962bdd46f" style="max-width: 650">
|
||||
|
||||

|
||||
|
||||
_加入我們的 [Discord](https://discord.gg/umReR62nRd) 獲取幫助和討論_
|
||||
</div>
|
||||
|
||||
## 目錄
|
||||
|
||||
@@ -22,14 +28,13 @@ _加入我們的 [Discord](https://discord.gg/umReR62nRd) 獲取幫助和討論_
|
||||
- [GoDoxy](#godoxy)
|
||||
- [目錄](#目錄)
|
||||
- [主要特點](#主要特點)
|
||||
- [入門指南](#入門指南)
|
||||
- [前置需求](#前置需求)
|
||||
- [安裝](#安裝)
|
||||
- [前置需求](#前置需求)
|
||||
- [安裝](#安裝)
|
||||
- [手動安裝](#手動安裝)
|
||||
- [資料夾結構](#資料夾結構)
|
||||
- [在 VSCode 中使用 JSON Schema](#在-vscode-中使用-json-schema)
|
||||
- [截圖](#截圖)
|
||||
- [閒置休眠](#閒置休眠)
|
||||
- [監控](#監控)
|
||||
- [自行編譯](#自行編譯)
|
||||
|
||||
## 主要特點
|
||||
@@ -38,66 +43,41 @@ _加入我們的 [Discord](https://discord.gg/umReR62nRd) 獲取幫助和討論_
|
||||
- 輕鬆配置
|
||||
- 簡單的多節點設置
|
||||
- 錯誤訊息清晰詳細,易於排除故障
|
||||
- 自動 SSL 憑證管理(參見 [支援的 DNS-01 驗證提供商](https://github.com/yusing/go-proxy/wiki/Supported-DNS%E2%80%9001-Providers))
|
||||
- 自動 SSL 憑證管理(參見 [支援的 DNS-01 驗證提供商](https://github.com/yusing/godoxy/wiki/Supported-DNS%E2%80%9001-Providers))
|
||||
- 自動配置 Docker 容器
|
||||
- 容器狀態/配置文件變更時自動熱重載
|
||||
- **閒置休眠**:在閒置時停止容器,有流量時喚醒(_可選,參見[截圖](#閒置休眠)_)
|
||||
- HTTP(s) 反向代理
|
||||
- [HTTP 中介軟體支援](https://github.com/yusing/go-proxy/wiki/Middlewares)
|
||||
- [自訂錯誤頁面支援](https://github.com/yusing/go-proxy/wiki/Middlewares#custom-error-pages)
|
||||
- TCP 和 UDP 埠轉發
|
||||
- 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)** 編寫
|
||||
|
||||
[🔼回到頂部](#目錄)
|
||||
|
||||
## 入門指南
|
||||
|
||||
完整文檔請參見 **[Wiki](https://github.com/yusing/go-proxy/wiki)**
|
||||
|
||||
### 前置需求
|
||||
## 前置需求
|
||||
|
||||
設置 DNS 記錄指向運行 `GoDoxy` 的機器,例如:
|
||||
|
||||
- A 記錄:`*.y.z` -> `10.0.10.1`
|
||||
- AAAA 記錄:`*.y.z` -> `::ffff:a00:a01`
|
||||
|
||||
### 安裝
|
||||
## 安裝
|
||||
|
||||
1. 拉取最新的 Docker 映像
|
||||
**注意:** GoDoxy 設計為(且僅在)`host` 網路模式下運作,請勿更改。如需更改監聽埠,請修改 `.env`。
|
||||
|
||||
1. 準備一個新目錄用於 docker compose 和配置文件。
|
||||
|
||||
2. 在目錄內運行安裝腳本,或[手動安裝](#手動安裝)
|
||||
|
||||
```shell
|
||||
docker pull ghcr.io/yusing/go-proxy:latest
|
||||
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/yusing/godoxy/main/scripts/setup.sh)"
|
||||
```
|
||||
|
||||
2. 建立新目錄,`cd` 進入後運行安裝,或[手動安裝](#手動安裝)
|
||||
3. 啟動容器 `docker compose up -d` 並等待就緒
|
||||
|
||||
```shell
|
||||
docker run --rm -v .:/setup ghcr.io/yusing/go-proxy /app/godoxy setup
|
||||
```
|
||||
|
||||
3. _(可選)_ 設置網頁介面登入
|
||||
|
||||
- 設置隨機 JWT 密鑰
|
||||
|
||||
```shell
|
||||
sed -i "s|API_JWT_SECRET=.*|API_JWT_SECRET=$(openssl rand -base64 32)|g" .env
|
||||
```
|
||||
|
||||
- 更改網頁介面認證的使用者名稱和密碼
|
||||
```shell
|
||||
sed -i "s|API_USERNAME=.*|API_USERNAME=admin|g" .env
|
||||
sed -i "s|API_PASSWORD=.*|API_PASSWORD=some-strong-password|g" .env
|
||||
```
|
||||
|
||||
4. _(可選)_ 設置其他 Docker 節點的 `docker-socket-proxy`(參見 [多 Docker 節點設置](https://github.com/yusing/go-proxy/wiki/Configurations#multi-docker-nodes-setup)),然後在 `config.yml` 中添加它們
|
||||
|
||||
5. 啟動容器 `docker compose up -d`
|
||||
|
||||
6. 現在您可以進行額外的配置
|
||||
- 使用文字編輯器(如 Visual Studio Code)
|
||||
- 通過網頁介面 `https://gp.y.z`
|
||||
4. 現在可以在 WebUI `https://godoxy.yourdomain.com` 進行額外配置
|
||||
|
||||
[🔼回到頂部](#目錄)
|
||||
|
||||
@@ -105,15 +85,15 @@ _加入我們的 [Discord](https://discord.gg/umReR62nRd) 獲取幫助和討論_
|
||||
|
||||
1. 建立 `config` 目錄,然後將 `config.example.yml` 下載到 `config/config.yml`
|
||||
|
||||
`mkdir -p config && wget https://raw.githubusercontent.com/yusing/go-proxy/v0.8/config.example.yml -O config/config.yml`
|
||||
`mkdir -p config && wget https://raw.githubusercontent.com/yusing/godoxy/main/config.example.yml -O config/config.yml`
|
||||
|
||||
2. 將 `.env.example` 下載到 `.env`
|
||||
|
||||
`wget https://raw.githubusercontent.com/yusing/go-proxy/v0.8/.env.example -O .env`
|
||||
`wget https://raw.githubusercontent.com/yusing/godoxy/main/.env.example -O .env`
|
||||
|
||||
3. 將 `compose.example.yml` 下載到 `compose.yml`
|
||||
|
||||
`wget https://raw.githubusercontent.com/yusing/go-proxy/v0.8/compose.example.yml -O compose.yml`
|
||||
`wget https://raw.githubusercontent.com/yusing/godoxy/main/compose.example.yml -O compose.yml`
|
||||
|
||||
### 資料夾結構
|
||||
|
||||
@@ -129,15 +109,13 @@ _加入我們的 [Discord](https://discord.gg/umReR62nRd) 獲取幫助和討論_
|
||||
│ │ ├── middleware2.yml
|
||||
│ ├── provider1.yml
|
||||
│ └── provider2.yml
|
||||
├── data
|
||||
│ ├── metrics # metrics data
|
||||
│ │ ├── uptime.json
|
||||
│ │ └── system_info.json
|
||||
└── .env
|
||||
```
|
||||
|
||||
### 在 VSCode 中使用 JSON Schema
|
||||
|
||||
複製 [`.vscode/settings.example.json`](.vscode/settings.example.json) 到 `.vscode/settings.json` 並根據需要修改
|
||||
|
||||
[🔼回到頂部](#目錄)
|
||||
|
||||
## 截圖
|
||||
|
||||
### 閒置休眠
|
||||
@@ -146,9 +124,34 @@ _加入我們的 [Discord](https://discord.gg/umReR62nRd) 獲取幫助和討論_
|
||||
|
||||
[🔼回到頂部](#目錄)
|
||||
|
||||
### 監控
|
||||
|
||||
<div align="center">
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center"><img src="screenshots/uptime.png" alt="Uptime Monitor" width="250"/></td>
|
||||
<td align="center"><img src="screenshots/docker-logs.jpg" alt="Docker Logs" width="250"/></td>
|
||||
<td align="center"><img src="screenshots/docker.jpg" alt="Server Overview" width="250"/></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><b>運行時間監控</b></td>
|
||||
<td align="center"><b>Docker 日誌</b></td>
|
||||
<td align="center"><b>伺服器概覽</b></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><img src="screenshots/system-monitor.jpg" alt="System Monitor" width="250"/></td>
|
||||
<td align="center"><img src="screenshots/system-info-graphs.jpg" alt="Graphs" width="250"/></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><b>系統監控</b></td>
|
||||
<td align="center"><b>圖表</b></td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
## 自行編譯
|
||||
|
||||
1. 克隆儲存庫 `git clone https://github.com/yusing/go-proxy --depth=1`
|
||||
1. 克隆儲存庫 `git clone https://github.com/yusing/godoxy --depth=1`
|
||||
|
||||
2. 如果尚未安裝,請安裝/升級 [go (>=1.22)](https://go.dev/doc/install) 和 `make`
|
||||
|
||||
|
||||
61
agent/cmd/main.go
Normal file
61
agent/cmd/main.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/yusing/go-proxy/agent/pkg/agent"
|
||||
"github.com/yusing/go-proxy/agent/pkg/env"
|
||||
"github.com/yusing/go-proxy/agent/pkg/server"
|
||||
"github.com/yusing/go-proxy/internal/gperr"
|
||||
"github.com/yusing/go-proxy/internal/logging"
|
||||
"github.com/yusing/go-proxy/internal/logging/memlogger"
|
||||
"github.com/yusing/go-proxy/internal/metrics/systeminfo"
|
||||
"github.com/yusing/go-proxy/internal/task"
|
||||
"github.com/yusing/go-proxy/pkg"
|
||||
)
|
||||
|
||||
func main() {
|
||||
logging.InitLogger(os.Stderr, memlogger.GetMemLogger())
|
||||
|
||||
ca := &agent.PEMPair{}
|
||||
err := ca.Load(env.AgentCACert)
|
||||
if err != nil {
|
||||
gperr.LogFatal("init CA error", err)
|
||||
}
|
||||
caCert, err := ca.ToTLSCert()
|
||||
if err != nil {
|
||||
gperr.LogFatal("init CA error", err)
|
||||
}
|
||||
|
||||
srv := &agent.PEMPair{}
|
||||
srv.Load(env.AgentSSLCert)
|
||||
if err != nil {
|
||||
gperr.LogFatal("init SSL error", err)
|
||||
}
|
||||
srvCert, err := srv.ToTLSCert()
|
||||
if err != nil {
|
||||
gperr.LogFatal("init SSL error", err)
|
||||
}
|
||||
|
||||
logging.Info().Msgf("GoDoxy Agent version %s", pkg.GetVersion())
|
||||
logging.Info().Msgf("Agent name: %s", env.AgentName)
|
||||
logging.Info().Msgf("Agent port: %d", env.AgentPort)
|
||||
|
||||
logging.Info().Msg(`
|
||||
Tips:
|
||||
1. To change the agent name, you can set the AGENT_NAME environment variable.
|
||||
2. To change the agent port, you can set the AGENT_PORT environment variable.
|
||||
`)
|
||||
|
||||
t := task.RootTask("agent", false)
|
||||
opts := server.Options{
|
||||
CACert: caCert,
|
||||
ServerCert: srvCert,
|
||||
Port: env.AgentPort,
|
||||
}
|
||||
|
||||
server.StartAgentServer(t, opts)
|
||||
systeminfo.Poller.Start()
|
||||
|
||||
task.WaitExit(3)
|
||||
}
|
||||
23
agent/pkg/agent/bare_metal.go
Normal file
23
agent/pkg/agent/bare_metal.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"text/template"
|
||||
)
|
||||
|
||||
var (
|
||||
installScript = `AGENT_NAME="{{.Name}}" \
|
||||
AGENT_PORT="{{.Port}}" \
|
||||
AGENT_CA_CERT="{{.CACert}}" \
|
||||
AGENT_SSL_CERT="{{.SSLCert}}" \
|
||||
bash -c "$(curl -fsSL https://raw.githubusercontent.com/yusing/go-proxy/main/scripts/install-agent.sh)"`
|
||||
installScriptTemplate = template.Must(template.New("install.sh").Parse(installScript))
|
||||
)
|
||||
|
||||
func (c *AgentEnvConfig) Generate() (string, error) {
|
||||
buf := bytes.NewBuffer(make([]byte, 0, 4096))
|
||||
if err := installScriptTemplate.Execute(buf, c); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return buf.String(), nil
|
||||
}
|
||||
190
agent/pkg/agent/config.go
Normal file
190
agent/pkg/agent/config.go
Normal file
@@ -0,0 +1,190 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"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"
|
||||
"golang.org/x/net/context"
|
||||
)
|
||||
|
||||
type AgentConfig struct {
|
||||
Addr string
|
||||
|
||||
httpClient *http.Client
|
||||
tlsConfig *tls.Config
|
||||
name string
|
||||
l zerolog.Logger
|
||||
}
|
||||
|
||||
const (
|
||||
EndpointVersion = "/version"
|
||||
EndpointName = "/name"
|
||||
EndpointProxyHTTP = "/proxy/http"
|
||||
EndpointHealth = "/health"
|
||||
EndpointLogs = "/logs"
|
||||
EndpointSystemInfo = "/system_info"
|
||||
|
||||
AgentHost = CertsDNSName
|
||||
|
||||
APIEndpointBase = "/godoxy/agent"
|
||||
APIBaseURL = "https://" + AgentHost + APIEndpointBase
|
||||
|
||||
DockerHost = "https://" + AgentHost
|
||||
|
||||
FakeDockerHostPrefix = "agent://"
|
||||
FakeDockerHostPrefixLen = len(FakeDockerHostPrefix)
|
||||
)
|
||||
|
||||
var (
|
||||
AgentURL = types.MustParseURL(APIBaseURL)
|
||||
HTTPProxyURL = types.MustParseURL(APIBaseURL + EndpointProxyHTTP)
|
||||
HTTPProxyURLPrefixLen = len(APIEndpointBase + EndpointProxyHTTP)
|
||||
)
|
||||
|
||||
func IsDockerHostAgent(dockerHost string) bool {
|
||||
return strings.HasPrefix(dockerHost, FakeDockerHostPrefix)
|
||||
}
|
||||
|
||||
func GetAgentAddrFromDockerHost(dockerHost string) string {
|
||||
return dockerHost[FakeDockerHostPrefixLen:]
|
||||
}
|
||||
|
||||
func (cfg *AgentConfig) FakeDockerHost() string {
|
||||
return FakeDockerHostPrefix + cfg.Addr
|
||||
}
|
||||
|
||||
func (cfg *AgentConfig) Parse(addr string) error {
|
||||
cfg.Addr = addr
|
||||
return nil
|
||||
}
|
||||
|
||||
func withoutBuildTime(version string) string {
|
||||
return strings.Split(version, "-")[0]
|
||||
}
|
||||
|
||||
func checkVersion(a, b string) bool {
|
||||
return withoutBuildTime(a) == withoutBuildTime(b)
|
||||
}
|
||||
|
||||
func (cfg *AgentConfig) StartWithCerts(parent task.Parent, ca, crt, key []byte) error {
|
||||
clientCert, err := tls.X509KeyPair(crt, key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// create tls config
|
||||
caCertPool := x509.NewCertPool()
|
||||
ok := caCertPool.AppendCertsFromPEM(ca)
|
||||
if !ok {
|
||||
return gperr.New("invalid ca certificate")
|
||||
}
|
||||
|
||||
cfg.tlsConfig = &tls.Config{
|
||||
Certificates: []tls.Certificate{clientCert},
|
||||
RootCAs: caCertPool,
|
||||
ServerName: CertsDNSName,
|
||||
}
|
||||
|
||||
// create transport and http client
|
||||
cfg.httpClient = cfg.NewHTTPClient()
|
||||
|
||||
ctx, cancel := context.WithTimeout(parent.Context(), 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 {
|
||||
return err
|
||||
}
|
||||
|
||||
cfg.name = string(name)
|
||||
cfg.l = logging.With().Str("agent", cfg.name).Logger()
|
||||
|
||||
logging.Info().Msgf("agent %q initialized", cfg.name)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cfg *AgentConfig) Start(parent task.Parent) gperr.Error {
|
||||
filepath, ok := certs.AgentCertsFilepath(cfg.Addr)
|
||||
if !ok {
|
||||
return gperr.New("invalid agent host").Subject(cfg.Addr)
|
||||
}
|
||||
|
||||
certData, err := os.ReadFile(filepath)
|
||||
if err != nil {
|
||||
return gperr.Wrap(err, "failed to read agent certs")
|
||||
}
|
||||
|
||||
ca, crt, key, err := certs.ExtractCert(certData)
|
||||
if err != nil {
|
||||
return gperr.Wrap(err, "failed to extract agent certs")
|
||||
}
|
||||
|
||||
return gperr.Wrap(cfg.StartWithCerts(parent, ca, crt, key))
|
||||
}
|
||||
|
||||
func (cfg *AgentConfig) NewHTTPClient() *http.Client {
|
||||
return &http.Client{
|
||||
Transport: cfg.Transport(),
|
||||
}
|
||||
}
|
||||
|
||||
func (cfg *AgentConfig) Transport() *http.Transport {
|
||||
return &http.Transport{
|
||||
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
if addr != AgentHost+":443" {
|
||||
return nil, &net.AddrError{Err: "invalid address", Addr: addr}
|
||||
}
|
||||
if network != "tcp" {
|
||||
return nil, &net.OpError{Op: "dial", Net: network, Source: nil, Addr: nil}
|
||||
}
|
||||
return cfg.DialContext(ctx)
|
||||
},
|
||||
TLSClientConfig: cfg.tlsConfig,
|
||||
}
|
||||
}
|
||||
|
||||
func (cfg *AgentConfig) DialContext(ctx context.Context) (net.Conn, error) {
|
||||
return gphttp.DefaultDialer.DialContext(ctx, "tcp", cfg.Addr)
|
||||
}
|
||||
|
||||
func (cfg *AgentConfig) Name() string {
|
||||
return cfg.name
|
||||
}
|
||||
|
||||
func (cfg *AgentConfig) String() string {
|
||||
return cfg.name + "@" + cfg.Addr
|
||||
}
|
||||
|
||||
func (cfg *AgentConfig) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(map[string]string{
|
||||
"name": cfg.Name(),
|
||||
"addr": cfg.Addr,
|
||||
})
|
||||
}
|
||||
27
agent/pkg/agent/docker_compose.go
Normal file
27
agent/pkg/agent/docker_compose.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"text/template"
|
||||
|
||||
_ "embed"
|
||||
)
|
||||
|
||||
var (
|
||||
//go:embed templates/agent.compose.yml
|
||||
agentComposeYAML string
|
||||
agentComposeYAMLTemplate = template.Must(template.New("agent.compose.yml").Parse(agentComposeYAML))
|
||||
)
|
||||
|
||||
const (
|
||||
DockerImageProduction = "ghcr.io/yusing/godoxy-agent:latest"
|
||||
DockerImageNightly = "ghcr.io/yusing/godoxy-agent:nightly"
|
||||
)
|
||||
|
||||
func (c *AgentComposeConfig) Generate() (string, error) {
|
||||
buf := bytes.NewBuffer(make([]byte, 0, 1024))
|
||||
if err := agentComposeYAMLTemplate.Execute(buf, c); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return buf.String(), nil
|
||||
}
|
||||
17
agent/pkg/agent/env.go
Normal file
17
agent/pkg/agent/env.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package agent
|
||||
|
||||
type (
|
||||
AgentEnvConfig struct {
|
||||
Name string
|
||||
Port int
|
||||
CACert string
|
||||
SSLCert string
|
||||
}
|
||||
AgentComposeConfig struct {
|
||||
Image string
|
||||
*AgentEnvConfig
|
||||
}
|
||||
Generator interface {
|
||||
Generate() (string, error)
|
||||
}
|
||||
)
|
||||
139
agent/pkg/agent/new_agent.go
Normal file
139
agent/pkg/agent/new_agent.go
Normal file
@@ -0,0 +1,139 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"math/big"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
CertsDNSName = "godoxy.agent"
|
||||
KeySize = 2048
|
||||
)
|
||||
|
||||
func toPEMPair(certDER []byte, key *rsa.PrivateKey) *PEMPair {
|
||||
return &PEMPair{
|
||||
Cert: pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}),
|
||||
Key: pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}),
|
||||
}
|
||||
}
|
||||
|
||||
func b64Encode(data []byte) string {
|
||||
return base64.StdEncoding.EncodeToString(data)
|
||||
}
|
||||
|
||||
func b64Decode(data string) ([]byte, error) {
|
||||
return base64.StdEncoding.DecodeString(data)
|
||||
}
|
||||
|
||||
type PEMPair struct {
|
||||
Cert, Key []byte
|
||||
}
|
||||
|
||||
func (p *PEMPair) String() string {
|
||||
return b64Encode(p.Cert) + ";" + b64Encode(p.Key)
|
||||
}
|
||||
|
||||
func (p *PEMPair) Load(data string) (err error) {
|
||||
parts := strings.Split(data, ";")
|
||||
if len(parts) != 2 {
|
||||
return errors.New("invalid PEM pair")
|
||||
}
|
||||
p.Cert, err = b64Decode(parts[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.Key, err = b64Decode(parts[1])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *PEMPair) ToTLSCert() (*tls.Certificate, error) {
|
||||
cert, err := tls.X509KeyPair(p.Cert, p.Key)
|
||||
return &cert, err
|
||||
}
|
||||
|
||||
func NewAgent() (ca, srv, client *PEMPair, err error) {
|
||||
// Create the CA's certificate
|
||||
caTemplate := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{
|
||||
Organization: []string{"GoDoxy"},
|
||||
CommonName: CertsDNSName,
|
||||
},
|
||||
NotBefore: time.Now(),
|
||||
NotAfter: time.Now().AddDate(1000, 0, 0), // 1000 years
|
||||
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
|
||||
BasicConstraintsValid: true,
|
||||
IsCA: true,
|
||||
}
|
||||
|
||||
caKey, err := rsa.GenerateKey(rand.Reader, KeySize)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
caDER, err := x509.CreateCertificate(rand.Reader, caTemplate, caTemplate, &caKey.PublicKey, caKey)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
ca = toPEMPair(caDER, caKey)
|
||||
|
||||
// Generate a new private key for the server certificate
|
||||
serverKey, err := rsa.GenerateKey(rand.Reader, KeySize)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
srvTemplate := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(2),
|
||||
Issuer: caTemplate.Subject,
|
||||
Subject: caTemplate.Subject,
|
||||
DNSNames: []string{CertsDNSName},
|
||||
NotBefore: time.Now(),
|
||||
NotAfter: time.Now().AddDate(1000, 0, 0), // Add validity period
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
}
|
||||
|
||||
srvCertDER, err := x509.CreateCertificate(rand.Reader, srvTemplate, caTemplate, &serverKey.PublicKey, caKey)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
srv = toPEMPair(srvCertDER, serverKey)
|
||||
|
||||
clientKey, err := rsa.GenerateKey(rand.Reader, KeySize)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
clientTemplate := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(3),
|
||||
Issuer: caTemplate.Subject,
|
||||
Subject: caTemplate.Subject,
|
||||
DNSNames: []string{CertsDNSName},
|
||||
NotBefore: time.Now(),
|
||||
NotAfter: time.Now().AddDate(1000, 0, 0),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
|
||||
}
|
||||
clientCertDER, err := x509.CreateCertificate(rand.Reader, clientTemplate, caTemplate, &clientKey.PublicKey, caKey)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
client = toPEMPair(clientCertDER, clientKey)
|
||||
return
|
||||
}
|
||||
91
agent/pkg/agent/new_agent_test.go
Normal file
91
agent/pkg/agent/new_agent_test.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
. "github.com/yusing/go-proxy/internal/utils/testing"
|
||||
)
|
||||
|
||||
func TestNewAgent(t *testing.T) {
|
||||
ca, srv, client, err := NewAgent()
|
||||
ExpectNoError(t, err)
|
||||
ExpectTrue(t, ca != nil)
|
||||
ExpectTrue(t, srv != nil)
|
||||
ExpectTrue(t, client != nil)
|
||||
}
|
||||
|
||||
func TestPEMPair(t *testing.T) {
|
||||
ca, srv, client, err := NewAgent()
|
||||
ExpectNoError(t, err)
|
||||
|
||||
for i, p := range []*PEMPair{ca, srv, client} {
|
||||
t.Run(fmt.Sprintf("load-%d", i), func(t *testing.T) {
|
||||
var pp PEMPair
|
||||
err := pp.Load(p.String())
|
||||
ExpectNoError(t, err)
|
||||
ExpectEqual(t, p.Cert, pp.Cert)
|
||||
ExpectEqual(t, p.Key, pp.Key)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPEMPairToTLSCert(t *testing.T) {
|
||||
ca, srv, client, err := NewAgent()
|
||||
ExpectNoError(t, err)
|
||||
|
||||
for i, p := range []*PEMPair{ca, srv, client} {
|
||||
t.Run(fmt.Sprintf("toTLSCert-%d", i), func(t *testing.T) {
|
||||
cert, err := p.ToTLSCert()
|
||||
ExpectNoError(t, err)
|
||||
ExpectTrue(t, cert != nil)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerClient(t *testing.T) {
|
||||
ca, srv, client, err := NewAgent()
|
||||
ExpectNoError(t, err)
|
||||
|
||||
srvTLS, err := srv.ToTLSCert()
|
||||
ExpectNoError(t, err)
|
||||
ExpectTrue(t, srvTLS != nil)
|
||||
|
||||
clientTLS, err := client.ToTLSCert()
|
||||
ExpectNoError(t, err)
|
||||
ExpectTrue(t, clientTLS != nil)
|
||||
|
||||
caPool := x509.NewCertPool()
|
||||
ExpectTrue(t, caPool.AppendCertsFromPEM(ca.Cert))
|
||||
|
||||
srvTLSConfig := &tls.Config{
|
||||
Certificates: []tls.Certificate{*srvTLS},
|
||||
ClientCAs: caPool,
|
||||
ClientAuth: tls.RequireAndVerifyClientCert,
|
||||
}
|
||||
|
||||
clientTLSConfig := &tls.Config{
|
||||
Certificates: []tls.Certificate{*clientTLS},
|
||||
RootCAs: caPool,
|
||||
ServerName: CertsDNSName,
|
||||
}
|
||||
|
||||
server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
server.TLS = srvTLSConfig
|
||||
server.StartTLS()
|
||||
defer server.Close()
|
||||
|
||||
httpClient := &http.Client{
|
||||
Transport: &http.Transport{TLSClientConfig: clientTLSConfig},
|
||||
}
|
||||
|
||||
resp, err := httpClient.Get(server.URL)
|
||||
ExpectNoError(t, err)
|
||||
ExpectEqual(t, resp.StatusCode, http.StatusOK)
|
||||
}
|
||||
49
agent/pkg/agent/requests.go
Normal file
49
agent/pkg/agent/requests.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
"golang.org/x/net/context"
|
||||
)
|
||||
|
||||
func (cfg *AgentConfig) Do(ctx context.Context, method, endpoint string, body io.Reader) (*http.Response, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, method, APIBaseURL+endpoint, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cfg.httpClient.Do(req)
|
||||
}
|
||||
|
||||
func (cfg *AgentConfig) Forward(req *http.Request, endpoint string) ([]byte, int, error) {
|
||||
req = req.WithContext(req.Context())
|
||||
req.URL.Host = AgentHost
|
||||
req.URL.Scheme = "https"
|
||||
req.URL.Path = APIEndpointBase + endpoint
|
||||
req.RequestURI = ""
|
||||
resp, err := cfg.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, _ := io.ReadAll(resp.Body)
|
||||
return data, resp.StatusCode, nil
|
||||
}
|
||||
|
||||
func (cfg *AgentConfig) Fetch(ctx context.Context, endpoint string) ([]byte, int, error) {
|
||||
resp, err := cfg.Do(ctx, "GET", endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, _ := io.ReadAll(resp.Body)
|
||||
return data, resp.StatusCode, nil
|
||||
}
|
||||
|
||||
func (cfg *AgentConfig) Websocket(ctx context.Context, endpoint string) (*websocket.Conn, *http.Response, error) {
|
||||
return websocket.Dial(ctx, APIBaseURL+endpoint, &websocket.DialOptions{
|
||||
HTTPClient: cfg.NewHTTPClient(),
|
||||
Host: AgentHost,
|
||||
})
|
||||
}
|
||||
14
agent/pkg/agent/templates/agent.compose.yml
Normal file
14
agent/pkg/agent/templates/agent.compose.yml
Normal file
@@ -0,0 +1,14 @@
|
||||
services:
|
||||
agent:
|
||||
image: "{{.Image}}"
|
||||
container_name: godoxy-agent
|
||||
restart: always
|
||||
network_mode: host # do not change this
|
||||
environment:
|
||||
AGENT_NAME: "{{.Name}}"
|
||||
AGENT_PORT: "{{.Port}}"
|
||||
AGENT_CA_CERT: "{{.CACert}}"
|
||||
AGENT_SSL_CERT: "{{.SSLCert}}"
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- ./data:/app/data
|
||||
27
agent/pkg/agentproxy/headers.go
Normal file
27
agent/pkg/agentproxy/headers.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package agentproxy
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
const (
|
||||
HeaderXProxyHost = "X-Proxy-Host"
|
||||
HeaderXProxyHTTPS = "X-Proxy-Https"
|
||||
HeaderXProxySkipTLSVerify = "X-Proxy-Skip-Tls-Verify"
|
||||
HeaderXProxyResponseHeaderTimeout = "X-Proxy-Response-Header-Timeout"
|
||||
)
|
||||
|
||||
type AgentProxyHeaders struct {
|
||||
Host string
|
||||
IsHTTPS bool
|
||||
SkipTLSVerify bool
|
||||
ResponseHeaderTimeout int
|
||||
}
|
||||
|
||||
func SetAgentProxyHeaders(r *http.Request, headers *AgentProxyHeaders) {
|
||||
r.Header.Set(HeaderXProxyHost, headers.Host)
|
||||
r.Header.Set(HeaderXProxyHTTPS, strconv.FormatBool(headers.IsHTTPS))
|
||||
r.Header.Set(HeaderXProxySkipTLSVerify, strconv.FormatBool(headers.SkipTLSVerify))
|
||||
r.Header.Set(HeaderXProxyResponseHeaderTimeout, strconv.Itoa(headers.ResponseHeaderTimeout))
|
||||
}
|
||||
84
agent/pkg/certs/zip.go
Normal file
84
agent/pkg/certs/zip.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package certs
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"io"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/yusing/go-proxy/internal/common"
|
||||
"github.com/yusing/go-proxy/internal/utils/strutils"
|
||||
)
|
||||
|
||||
func writeFile(zipWriter *zip.Writer, name string, data []byte) error {
|
||||
w, err := zipWriter.CreateHeader(&zip.FileHeader{
|
||||
Name: name,
|
||||
Method: zip.Store,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = w.Write(data)
|
||||
return err
|
||||
}
|
||||
|
||||
func readFile(f *zip.File) ([]byte, error) {
|
||||
r, err := f.Open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer r.Close()
|
||||
return io.ReadAll(r)
|
||||
}
|
||||
|
||||
func ZipCert(ca, crt, key []byte) ([]byte, error) {
|
||||
data := bytes.NewBuffer(make([]byte, 0, 6144))
|
||||
zipWriter := zip.NewWriter(data)
|
||||
defer zipWriter.Close()
|
||||
|
||||
if err := writeFile(zipWriter, "ca.pem", ca); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := writeFile(zipWriter, "cert.pem", crt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := writeFile(zipWriter, "key.pem", key); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := zipWriter.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return data.Bytes(), nil
|
||||
}
|
||||
|
||||
func isValidAgentHost(host string) bool {
|
||||
return strutils.IsValidFilename(host + ".zip")
|
||||
}
|
||||
|
||||
func AgentCertsFilepath(host string) (filepathOut string, ok bool) {
|
||||
if !isValidAgentHost(host) {
|
||||
return "", false
|
||||
}
|
||||
return filepath.Join(common.AgentCertsBasePath, host+".zip"), true
|
||||
}
|
||||
|
||||
func ExtractCert(data []byte) (ca, crt, key []byte, err error) {
|
||||
zipReader, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
for _, file := range zipReader.File {
|
||||
switch file.Name {
|
||||
case "ca.pem":
|
||||
ca, err = readFile(file)
|
||||
case "cert.pem":
|
||||
crt, err = readFile(file)
|
||||
case "key.pem":
|
||||
key, err = readFile(file)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
}
|
||||
return ca, crt, key, nil
|
||||
}
|
||||
19
agent/pkg/certs/zip_test.go
Normal file
19
agent/pkg/certs/zip_test.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package certs
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
. "github.com/yusing/go-proxy/internal/utils/testing"
|
||||
)
|
||||
|
||||
func TestZipCert(t *testing.T) {
|
||||
ca, crt, key := []byte("test1"), []byte("test2"), []byte("test3")
|
||||
zipData, err := ZipCert(ca, crt, key)
|
||||
ExpectNoError(t, err)
|
||||
|
||||
ca2, crt2, key2, err := ExtractCert(zipData)
|
||||
ExpectNoError(t, err)
|
||||
ExpectEqual(t, ca, ca2)
|
||||
ExpectEqual(t, crt, crt2)
|
||||
ExpectEqual(t, key, key2)
|
||||
}
|
||||
24
agent/pkg/env/env.go
vendored
Normal file
24
agent/pkg/env/env.go
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
package env
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/yusing/go-proxy/internal/common"
|
||||
)
|
||||
|
||||
func DefaultAgentName() string {
|
||||
name, err := os.Hostname()
|
||||
if err != nil {
|
||||
return "agent"
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
var (
|
||||
AgentName = common.GetEnvString("AGENT_NAME", DefaultAgentName())
|
||||
AgentPort = common.GetEnvInt("AGENT_PORT", 8890)
|
||||
AgentSkipClientCertCheck = common.GetEnvBool("AGENT_SKIP_CLIENT_CERT_CHECK", false)
|
||||
|
||||
AgentCACert = common.GetEnvString("AGENT_CA_CERT", "")
|
||||
AgentSSLCert = common.GetEnvString("AGENT_SSL_CERT", "")
|
||||
)
|
||||
78
agent/pkg/handler/check_health.go
Normal file
78
agent/pkg/handler/check_health.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"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"
|
||||
)
|
||||
|
||||
var defaultHealthConfig = health.DefaultHealthConfig()
|
||||
|
||||
func CheckHealth(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query()
|
||||
scheme := query.Get("scheme")
|
||||
if scheme == "" {
|
||||
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var result *health.HealthCheckResult
|
||||
var err error
|
||||
switch scheme {
|
||||
case "fileserver":
|
||||
path := query.Get("path")
|
||||
if path == "" {
|
||||
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
_, err := os.Stat(path)
|
||||
result = &health.HealthCheckResult{Healthy: err == nil}
|
||||
if err != nil {
|
||||
result.Detail = err.Error()
|
||||
}
|
||||
case "http", "https": // path is optional
|
||||
host := query.Get("host")
|
||||
path := query.Get("path")
|
||||
if host == "" {
|
||||
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
result, err = monitor.NewHTTPHealthChecker(types.NewURL(&url.URL{
|
||||
Scheme: scheme,
|
||||
Host: host,
|
||||
Path: path,
|
||||
}), defaultHealthConfig).CheckHealth()
|
||||
case "tcp", "udp":
|
||||
host := query.Get("host")
|
||||
if host == "" {
|
||||
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
hasPort := strings.Contains(host, ":")
|
||||
port := query.Get("port")
|
||||
if port != "" && !hasPort {
|
||||
host = fmt.Sprintf("%s:%s", host, port)
|
||||
} else {
|
||||
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
result, err = monitor.NewRawHealthChecker(types.NewURL(&url.URL{
|
||||
Scheme: scheme,
|
||||
Host: host,
|
||||
}), defaultHealthConfig).CheckHealth()
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
|
||||
gphttp.RespondJSON(w, r, result)
|
||||
}
|
||||
216
agent/pkg/handler/check_health_test.go
Normal file
216
agent/pkg/handler/check_health_test.go
Normal file
@@ -0,0 +1,216 @@
|
||||
package handler_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/yusing/go-proxy/agent/pkg/agent"
|
||||
"github.com/yusing/go-proxy/agent/pkg/handler"
|
||||
"github.com/yusing/go-proxy/internal/watcher/health"
|
||||
)
|
||||
|
||||
func TestCheckHealthHTTP(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setupServer func() *httptest.Server
|
||||
queryParams map[string]string
|
||||
expectedStatus int
|
||||
expectedHealthy bool
|
||||
}{
|
||||
{
|
||||
name: "Valid",
|
||||
setupServer: func() *httptest.Server {
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
},
|
||||
queryParams: map[string]string{
|
||||
"scheme": "http",
|
||||
"host": "localhost",
|
||||
"path": "/",
|
||||
},
|
||||
expectedStatus: http.StatusOK,
|
||||
expectedHealthy: true,
|
||||
},
|
||||
{
|
||||
name: "InvalidQuery",
|
||||
setupServer: nil,
|
||||
queryParams: map[string]string{
|
||||
"scheme": "http",
|
||||
},
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "ConnectionError",
|
||||
setupServer: nil,
|
||||
queryParams: map[string]string{
|
||||
"scheme": "http",
|
||||
"host": "localhost:12345",
|
||||
},
|
||||
expectedStatus: http.StatusOK,
|
||||
expectedHealthy: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var server *httptest.Server
|
||||
if tt.setupServer != nil {
|
||||
server = tt.setupServer()
|
||||
defer server.Close()
|
||||
u, _ := url.Parse(server.URL)
|
||||
tt.queryParams["scheme"] = u.Scheme
|
||||
tt.queryParams["host"] = u.Host
|
||||
tt.queryParams["path"] = u.Path
|
||||
}
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
query := url.Values{}
|
||||
for key, value := range tt.queryParams {
|
||||
query.Set(key, value)
|
||||
}
|
||||
request := httptest.NewRequest(http.MethodGet, agent.APIEndpointBase+agent.EndpointHealth+"?"+query.Encode(), nil)
|
||||
handler.CheckHealth(recorder, request)
|
||||
|
||||
require.Equal(t, recorder.Code, tt.expectedStatus)
|
||||
|
||||
if tt.expectedStatus == http.StatusOK {
|
||||
var result health.HealthCheckResult
|
||||
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &result))
|
||||
require.Equal(t, result.Healthy, tt.expectedHealthy)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckHealthFileServer(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
expectedStatus int
|
||||
expectedHealthy bool
|
||||
expectedDetail string
|
||||
}{
|
||||
{
|
||||
name: "ValidPath",
|
||||
path: t.TempDir(),
|
||||
expectedStatus: http.StatusOK,
|
||||
expectedHealthy: true,
|
||||
expectedDetail: "",
|
||||
},
|
||||
{
|
||||
name: "InvalidPath",
|
||||
path: "/invalid",
|
||||
expectedStatus: http.StatusOK,
|
||||
expectedHealthy: false,
|
||||
expectedDetail: "stat /invalid: no such file or directory",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
query := url.Values{}
|
||||
query.Set("scheme", "fileserver")
|
||||
query.Set("path", tt.path)
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, agent.APIEndpointBase+agent.EndpointHealth+"?"+query.Encode(), nil)
|
||||
handler.CheckHealth(recorder, request)
|
||||
|
||||
require.Equal(t, recorder.Code, tt.expectedStatus)
|
||||
|
||||
var result health.HealthCheckResult
|
||||
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &result))
|
||||
require.Equal(t, result.Healthy, tt.expectedHealthy)
|
||||
require.Equal(t, result.Detail, tt.expectedDetail)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckHealthTCPUDP(t *testing.T) {
|
||||
tcp, err := net.Listen("tcp", "localhost:0")
|
||||
require.NoError(t, err)
|
||||
go func() {
|
||||
conn, err := tcp.Accept()
|
||||
require.NoError(t, err)
|
||||
conn.Close()
|
||||
}()
|
||||
|
||||
udp, err := net.ListenPacket("udp", "localhost:0")
|
||||
require.NoError(t, err)
|
||||
go func() {
|
||||
buf := make([]byte, 1024)
|
||||
n, addr, err := udp.ReadFrom(buf)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, string(buf[:n]), "ping")
|
||||
_, _ = udp.WriteTo([]byte("pong"), addr)
|
||||
udp.Close()
|
||||
}()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
scheme string
|
||||
host string
|
||||
port int
|
||||
expectedStatus int
|
||||
expectedHealthy bool
|
||||
}{
|
||||
{
|
||||
name: "ValidTCP",
|
||||
scheme: "tcp",
|
||||
host: "localhost",
|
||||
port: tcp.Addr().(*net.TCPAddr).Port,
|
||||
expectedStatus: http.StatusOK,
|
||||
expectedHealthy: true,
|
||||
},
|
||||
{
|
||||
name: "InvalidHost",
|
||||
scheme: "tcp",
|
||||
host: "invalid",
|
||||
port: 8080,
|
||||
expectedStatus: http.StatusOK,
|
||||
expectedHealthy: false,
|
||||
},
|
||||
{
|
||||
name: "ValidUDP",
|
||||
scheme: "udp",
|
||||
host: "localhost",
|
||||
port: udp.LocalAddr().(*net.UDPAddr).Port,
|
||||
expectedStatus: http.StatusOK,
|
||||
expectedHealthy: true,
|
||||
},
|
||||
{
|
||||
name: "InvalidHost",
|
||||
scheme: "udp",
|
||||
host: "invalid",
|
||||
port: 8080,
|
||||
expectedStatus: http.StatusOK,
|
||||
expectedHealthy: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
query := url.Values{}
|
||||
query.Set("scheme", tt.scheme)
|
||||
query.Set("host", tt.host)
|
||||
query.Set("port", strconv.Itoa(tt.port))
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, agent.APIEndpointBase+agent.EndpointHealth+"?"+query.Encode(), nil)
|
||||
handler.CheckHealth(recorder, request)
|
||||
|
||||
require.Equal(t, recorder.Code, tt.expectedStatus)
|
||||
|
||||
var result health.HealthCheckResult
|
||||
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &result))
|
||||
require.Equal(t, result.Healthy, tt.expectedHealthy)
|
||||
})
|
||||
}
|
||||
}
|
||||
31
agent/pkg/handler/docker_socket.go
Normal file
31
agent/pkg/handler/docker_socket.go
Normal file
@@ -0,0 +1,31 @@
|
||||
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
|
||||
}
|
||||
49
agent/pkg/handler/handler.go
Normal file
49
agent/pkg/handler/handler.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/yusing/go-proxy/agent/pkg/agent"
|
||||
"github.com/yusing/go-proxy/agent/pkg/env"
|
||||
v1 "github.com/yusing/go-proxy/internal/api/v1"
|
||||
"github.com/yusing/go-proxy/internal/logging/memlogger"
|
||||
"github.com/yusing/go-proxy/internal/metrics/systeminfo"
|
||||
"github.com/yusing/go-proxy/internal/utils/strutils"
|
||||
)
|
||||
|
||||
type ServeMux struct{ *http.ServeMux }
|
||||
|
||||
func (mux ServeMux) HandleMethods(methods, endpoint string, handler http.HandlerFunc) {
|
||||
for _, m := range strutils.CommaSeperatedList(methods) {
|
||||
mux.ServeMux.HandleFunc(m+" "+agent.APIEndpointBase+endpoint, handler)
|
||||
}
|
||||
}
|
||||
|
||||
func (mux ServeMux) HandleFunc(endpoint string, handler http.HandlerFunc) {
|
||||
mux.ServeMux.HandleFunc(agent.APIEndpointBase+endpoint, handler)
|
||||
}
|
||||
|
||||
type NopWriteCloser struct {
|
||||
io.Writer
|
||||
}
|
||||
|
||||
func (NopWriteCloser) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewAgentHandler() http.Handler {
|
||||
mux := ServeMux{http.NewServeMux()}
|
||||
|
||||
mux.HandleFunc(agent.EndpointProxyHTTP+"/{path...}", ProxyHTTP)
|
||||
mux.HandleMethods("GET", agent.EndpointVersion, v1.GetVersion)
|
||||
mux.HandleMethods("GET", agent.EndpointName, func(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Fprint(w, env.AgentName)
|
||||
})
|
||||
mux.HandleMethods("GET", agent.EndpointHealth, CheckHealth)
|
||||
mux.HandleMethods("GET", agent.EndpointLogs, memlogger.HandlerFunc())
|
||||
mux.HandleMethods("GET", agent.EndpointSystemInfo, systeminfo.Poller.ServeHTTP)
|
||||
mux.ServeMux.HandleFunc("/", DockerSocketHandler())
|
||||
return mux
|
||||
}
|
||||
63
agent/pkg/handler/proxy_http.go
Normal file
63
agent/pkg/handler/proxy_http.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/yusing/go-proxy/agent/pkg/agent"
|
||||
"github.com/yusing/go-proxy/agent/pkg/agentproxy"
|
||||
"github.com/yusing/go-proxy/internal/logging"
|
||||
"github.com/yusing/go-proxy/internal/net/gphttp"
|
||||
"github.com/yusing/go-proxy/internal/net/gphttp/reverseproxy"
|
||||
"github.com/yusing/go-proxy/internal/net/types"
|
||||
"github.com/yusing/go-proxy/internal/utils/strutils"
|
||||
)
|
||||
|
||||
func ProxyHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
host := r.Header.Get(agentproxy.HeaderXProxyHost)
|
||||
isHTTPS := strutils.ParseBool(r.Header.Get(agentproxy.HeaderXProxyHTTPS))
|
||||
skipTLSVerify := strutils.ParseBool(r.Header.Get(agentproxy.HeaderXProxySkipTLSVerify))
|
||||
responseHeaderTimeout, err := strconv.Atoi(r.Header.Get(agentproxy.HeaderXProxyResponseHeaderTimeout))
|
||||
if err != nil {
|
||||
responseHeaderTimeout = 0
|
||||
}
|
||||
|
||||
if host == "" {
|
||||
http.Error(w, "missing required headers", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
scheme := "http"
|
||||
if isHTTPS {
|
||||
scheme = "https"
|
||||
}
|
||||
|
||||
var transport *http.Transport
|
||||
if skipTLSVerify {
|
||||
transport = gphttp.NewTransportWithTLSConfig(&tls.Config{InsecureSkipVerify: true})
|
||||
} else {
|
||||
transport = gphttp.NewTransport()
|
||||
}
|
||||
|
||||
if responseHeaderTimeout > 0 {
|
||||
transport.ResponseHeaderTimeout = time.Duration(responseHeaderTimeout) * time.Second
|
||||
}
|
||||
|
||||
r.URL.Scheme = ""
|
||||
r.URL.Host = ""
|
||||
r.URL.Path = r.URL.Path[agent.HTTPProxyURLPrefixLen:] // strip the {API_BASE}/proxy/http prefix
|
||||
r.RequestURI = r.URL.String()
|
||||
r.URL.Host = host
|
||||
r.URL.Scheme = scheme
|
||||
|
||||
logging.Debug().Msgf("proxy http request: %s %s", r.Method, r.URL.String())
|
||||
|
||||
rp := reverseproxy.NewReverseProxy("agent", types.NewURL(&url.URL{
|
||||
Scheme: scheme,
|
||||
Host: host,
|
||||
}), transport)
|
||||
rp.ServeHTTP(w, r)
|
||||
}
|
||||
44
agent/pkg/server/server.go
Normal file
44
agent/pkg/server/server.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/yusing/go-proxy/agent/pkg/env"
|
||||
"github.com/yusing/go-proxy/agent/pkg/handler"
|
||||
"github.com/yusing/go-proxy/internal/logging"
|
||||
"github.com/yusing/go-proxy/internal/net/gphttp/server"
|
||||
"github.com/yusing/go-proxy/internal/task"
|
||||
)
|
||||
|
||||
type Options struct {
|
||||
CACert, ServerCert *tls.Certificate
|
||||
Port int
|
||||
}
|
||||
|
||||
func StartAgentServer(parent task.Parent, opt Options) {
|
||||
caCertPool := x509.NewCertPool()
|
||||
caCertPool.AddCert(opt.CACert.Leaf)
|
||||
|
||||
// Configure TLS
|
||||
tlsConfig := &tls.Config{
|
||||
Certificates: []tls.Certificate{*opt.ServerCert},
|
||||
ClientCAs: caCertPool,
|
||||
ClientAuth: tls.RequireAndVerifyClientCert,
|
||||
}
|
||||
|
||||
if env.AgentSkipClientCertCheck {
|
||||
tlsConfig.ClientAuth = tls.NoClientCert
|
||||
}
|
||||
|
||||
logger := logging.GetLogger()
|
||||
agentServer := &http.Server{
|
||||
Addr: fmt.Sprintf(":%d", opt.Port),
|
||||
Handler: handler.NewAgentHandler(),
|
||||
TLSConfig: tlsConfig,
|
||||
}
|
||||
|
||||
server.Start(parent, agentServer, logger)
|
||||
}
|
||||
130
cmd/main.go
130
cmd/main.go
@@ -3,44 +3,56 @@ package main
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
"sync"
|
||||
|
||||
"github.com/yusing/go-proxy/internal"
|
||||
"github.com/yusing/go-proxy/internal/api"
|
||||
"github.com/yusing/go-proxy/internal/api/v1/auth"
|
||||
"github.com/yusing/go-proxy/internal/api/v1/favicon"
|
||||
"github.com/yusing/go-proxy/internal/api/v1/query"
|
||||
"github.com/yusing/go-proxy/internal/common"
|
||||
"github.com/yusing/go-proxy/internal/config"
|
||||
"github.com/yusing/go-proxy/internal/entrypoint"
|
||||
E "github.com/yusing/go-proxy/internal/error"
|
||||
"github.com/yusing/go-proxy/internal/gperr"
|
||||
"github.com/yusing/go-proxy/internal/homepage"
|
||||
"github.com/yusing/go-proxy/internal/logging"
|
||||
"github.com/yusing/go-proxy/internal/metrics"
|
||||
"github.com/yusing/go-proxy/internal/net/http/middleware"
|
||||
"github.com/yusing/go-proxy/internal/net/http/server"
|
||||
"github.com/yusing/go-proxy/internal/logging/memlogger"
|
||||
"github.com/yusing/go-proxy/internal/metrics/systeminfo"
|
||||
"github.com/yusing/go-proxy/internal/metrics/uptime"
|
||||
"github.com/yusing/go-proxy/internal/net/gphttp/middleware"
|
||||
"github.com/yusing/go-proxy/internal/route/routes/routequery"
|
||||
"github.com/yusing/go-proxy/internal/task"
|
||||
"github.com/yusing/go-proxy/pkg"
|
||||
)
|
||||
|
||||
var rawLogger = log.New(os.Stdout, "", 0)
|
||||
|
||||
func parallel(fns ...func()) {
|
||||
var wg sync.WaitGroup
|
||||
for _, fn := range fns {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
fn()
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func main() {
|
||||
args := common.GetArgs()
|
||||
initProfiling()
|
||||
args := pkg.GetArgs(common.MainServerCommandValidator{})
|
||||
|
||||
switch args.Command {
|
||||
case common.CommandSetup:
|
||||
internal.Setup()
|
||||
return
|
||||
case common.CommandReload:
|
||||
if err := query.ReloadServer(); err != nil {
|
||||
E.LogFatal("server reload error", err)
|
||||
gperr.LogFatal("server reload error", err)
|
||||
}
|
||||
logging.Info().Msg("ok")
|
||||
rawLogger.Println("ok")
|
||||
return
|
||||
case common.CommandListIcons:
|
||||
icons, err := internal.ListAvailableIcons()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
rawLogger.Fatal(err)
|
||||
}
|
||||
printJSON(icons)
|
||||
return
|
||||
@@ -63,9 +75,20 @@ func main() {
|
||||
}
|
||||
|
||||
if args.Command == common.CommandStart {
|
||||
logging.InitLogger(os.Stderr, memlogger.GetMemLogger())
|
||||
logging.Info().Msgf("GoDoxy version %s", pkg.GetVersion())
|
||||
logging.Trace().Msg("trace enabled")
|
||||
// logging.AddHook(notif.GetDispatcher())
|
||||
parallel(
|
||||
internal.InitIconListCache,
|
||||
homepage.InitOverridesConfig,
|
||||
favicon.InitIconCache,
|
||||
systeminfo.Poller.Start,
|
||||
)
|
||||
|
||||
if common.APIJWTSecret == nil {
|
||||
logging.Warn().Msg("API_JWT_SECRET is not set, using random key")
|
||||
common.APIJWTSecret = common.RandomJWTKey()
|
||||
}
|
||||
} else {
|
||||
logging.DiscardLogger()
|
||||
}
|
||||
@@ -89,77 +112,43 @@ func main() {
|
||||
middleware.LoadComposeFiles()
|
||||
|
||||
var cfg *config.Config
|
||||
var err E.Error
|
||||
var err gperr.Error
|
||||
if cfg, err = config.Load(); err != nil {
|
||||
E.LogWarn("errors in config", err)
|
||||
gperr.LogWarn("errors in config", err)
|
||||
err = nil
|
||||
}
|
||||
|
||||
switch args.Command {
|
||||
case common.CommandListRoutes:
|
||||
cfg.StartProxyProviders()
|
||||
printJSON(config.RoutesByAlias())
|
||||
printJSON(routequery.RoutesByAlias())
|
||||
return
|
||||
case common.CommandListConfigs:
|
||||
printJSON(config.Value())
|
||||
printJSON(cfg.Value())
|
||||
return
|
||||
case common.CommandDebugListEntries:
|
||||
printJSON(config.DumpEntries())
|
||||
printJSON(cfg.DumpRoutes())
|
||||
return
|
||||
case common.CommandDebugListProviders:
|
||||
printJSON(config.DumpProviders())
|
||||
printJSON(cfg.DumpRouteProviders())
|
||||
return
|
||||
}
|
||||
|
||||
if common.APIJWTSecret == nil {
|
||||
logging.Warn().Msg("API JWT secret is empty, authentication is disabled")
|
||||
cfg.Start(&config.StartServersOptions{
|
||||
Proxy: true,
|
||||
})
|
||||
if err := auth.Initialize(); err != nil {
|
||||
logging.Fatal().Err(err).Msg("failed to initialize authentication")
|
||||
}
|
||||
// API Handler needs to start after auth is initialized.
|
||||
cfg.StartServers(&config.StartServersOptions{
|
||||
API: true,
|
||||
})
|
||||
|
||||
cfg.StartProxyProviders()
|
||||
uptime.Poller.Start()
|
||||
config.WatchChanges()
|
||||
|
||||
sig := make(chan os.Signal, 1)
|
||||
signal.Notify(sig, syscall.SIGINT)
|
||||
signal.Notify(sig, syscall.SIGTERM)
|
||||
signal.Notify(sig, syscall.SIGHUP)
|
||||
|
||||
autocert := config.GetAutoCertProvider()
|
||||
if autocert != nil {
|
||||
if err := autocert.Setup(); err != nil {
|
||||
E.LogFatal("autocert setup error", err)
|
||||
}
|
||||
} else {
|
||||
logging.Info().Msg("autocert not configured")
|
||||
}
|
||||
|
||||
server.StartServer(server.Options{
|
||||
Name: "proxy",
|
||||
CertProvider: autocert,
|
||||
HTTPAddr: common.ProxyHTTPAddr,
|
||||
HTTPSAddr: common.ProxyHTTPSAddr,
|
||||
Handler: http.HandlerFunc(entrypoint.Handler),
|
||||
})
|
||||
server.StartServer(server.Options{
|
||||
Name: "api",
|
||||
CertProvider: autocert,
|
||||
HTTPAddr: common.APIHTTPAddr,
|
||||
Handler: api.NewHandler(),
|
||||
})
|
||||
|
||||
if common.PrometheusEnabled {
|
||||
server.StartServer(server.Options{
|
||||
Name: "metrics",
|
||||
CertProvider: autocert,
|
||||
HTTPAddr: common.MetricsHTTPAddr,
|
||||
Handler: metrics.NewHandler(),
|
||||
})
|
||||
}
|
||||
|
||||
// wait for signal
|
||||
<-sig
|
||||
|
||||
// grafully shutdown
|
||||
logging.Info().Msg("shutting down")
|
||||
_ = task.GracefulShutdown(time.Second * time.Duration(config.Value().TimeoutShutdown))
|
||||
task.WaitExit(cfg.Value().TimeoutShutdown)
|
||||
}
|
||||
|
||||
func prepareDirectory(dir string) {
|
||||
@@ -175,6 +164,5 @@ func printJSON(obj any) {
|
||||
if err != nil {
|
||||
logging.Fatal().Err(err).Send()
|
||||
}
|
||||
rawLogger := log.New(os.Stdout, "", 0)
|
||||
rawLogger.Print(string(j)) // raw output for convenience using "jq"
|
||||
}
|
||||
|
||||
7
cmd/pprof_production.go
Normal file
7
cmd/pprof_production.go
Normal file
@@ -0,0 +1,7 @@
|
||||
//go:build production
|
||||
|
||||
package main
|
||||
|
||||
func initProfiling() {
|
||||
// no profiling in production
|
||||
}
|
||||
20
cmd/pprof_prof.go
Normal file
20
cmd/pprof_prof.go
Normal file
@@ -0,0 +1,20 @@
|
||||
//go:build pprof
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
_ "net/http/pprof"
|
||||
"runtime"
|
||||
"runtime/debug"
|
||||
)
|
||||
|
||||
func initProfiling() {
|
||||
runtime.GOMAXPROCS(2)
|
||||
debug.SetMemoryLimit(100 * 1024 * 1024)
|
||||
debug.SetMaxStack(15 * 1024 * 1024)
|
||||
go func() {
|
||||
log.Println(http.ListenAndServe(":7777", nil))
|
||||
}()
|
||||
}
|
||||
@@ -1,42 +1,45 @@
|
||||
---
|
||||
services:
|
||||
frontend:
|
||||
image: ghcr.io/yusing/go-proxy-frontend:latest
|
||||
image: ghcr.io/yusing/godoxy-frontend:latest
|
||||
container_name: godoxy-frontend
|
||||
restart: unless-stopped
|
||||
network_mode: host
|
||||
network_mode: host # do not change this
|
||||
env_file: .env
|
||||
depends_on:
|
||||
- app
|
||||
environment:
|
||||
PORT: ${GODOXY_FRONTEND_PORT:-3000}
|
||||
|
||||
# modify below to fit your needs
|
||||
labels:
|
||||
proxy.aliases: gp
|
||||
proxy.#1.port: 3000
|
||||
# proxy.#1.middlewares.cidr_whitelist.status: 403
|
||||
# proxy.#1.middlewares.cidr_whitelist.message: IP not allowed
|
||||
# proxy.#1.middlewares.cidr_whitelist.allow: |
|
||||
proxy.aliases: godoxy
|
||||
proxy.godoxy.port: ${GODOXY_FRONTEND_PORT:-3000}
|
||||
# proxy.godoxy.middlewares.cidr_whitelist: |
|
||||
# status: 403
|
||||
# message: IP not allowed
|
||||
# allow:
|
||||
# - 127.0.0.1
|
||||
# - 10.0.0.0/8
|
||||
# - 192.168.0.0/16
|
||||
# - 172.16.0.0/12
|
||||
app:
|
||||
image: ghcr.io/yusing/go-proxy:latest
|
||||
image: ghcr.io/yusing/godoxy:latest
|
||||
container_name: godoxy
|
||||
restart: always
|
||||
network_mode: host
|
||||
network_mode: host # do not change this
|
||||
env_file: .env
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- ./config:/app/config
|
||||
- ./logs:/app/logs
|
||||
- ./error_pages:/app/error_pages
|
||||
- ./data:/app/data
|
||||
|
||||
# (Optional) choose one of below to enable https
|
||||
# 1. use existing certificate
|
||||
# To use autocert, certs will be stored in "./certs".
|
||||
# You can also use a docker volume to store it
|
||||
- ./certs:/app/certs
|
||||
|
||||
# remove "./certs:/app/certs" and uncomment below to use existing certificate
|
||||
# - /path/to/certs/cert.crt:/app/certs/cert.crt
|
||||
# - /path/to/certs/priv.key:/app/certs/priv.key
|
||||
|
||||
# 2. use autocert, certs will be stored in ./certs
|
||||
# you can also use a docker volume to store it
|
||||
|
||||
# - ./certs:/app/certs
|
||||
|
||||
@@ -1,70 +1,42 @@
|
||||
# Autocert (choose one below and uncomment to enable)
|
||||
#
|
||||
# 1. use existing cert
|
||||
#
|
||||
|
||||
# autocert:
|
||||
# provider: local
|
||||
#
|
||||
# cert_path: certs/cert.crt # optional, uncomment only if you need to change it
|
||||
# key_path: certs/priv.key # optional, uncomment only if you need to change it
|
||||
#
|
||||
|
||||
# 2. cloudflare
|
||||
#
|
||||
# autocert:
|
||||
# provider: cloudflare
|
||||
# email: abc@gmail.com # ACME Email
|
||||
# domains: # a list of domains for cert registration
|
||||
# - "*.y.z" # remember to use double quotes to surround wildcard domain
|
||||
# email: abc@gmail.com # ACME Email
|
||||
# domains: # a list of domains for cert registration
|
||||
# - "*.domain.com"
|
||||
# - "domain.com"
|
||||
# options:
|
||||
# auth_token: c1234565789-abcdefghijklmnopqrst # your zone API token
|
||||
#
|
||||
# 3. other providers, check docs/dns_providers.md for more
|
||||
# auth_token: c1234565789-abcdefghijklmnopqrst # your zone API token
|
||||
|
||||
# 3. other providers, see https://github.com/yusing/godoxy/wiki/Supported-DNS%E2%80%9001-Providers#supported-dns-01-providers
|
||||
|
||||
entrypoint:
|
||||
middlewares:
|
||||
- use: CIDRWhitelist
|
||||
allow:
|
||||
- "127.0.0.1"
|
||||
- "10.0.0.0/8"
|
||||
- "192.168.0.0/16"
|
||||
status: 403
|
||||
message: "Forbidden"
|
||||
- use: RedirectHTTP
|
||||
# access_log:
|
||||
# buffer_size: 1024
|
||||
# path: /var/log/example.log
|
||||
# filters:
|
||||
# status_codes:
|
||||
# values:
|
||||
# - 200-299
|
||||
# - 101
|
||||
# method:
|
||||
# values:
|
||||
# - GET
|
||||
# host:
|
||||
# values:
|
||||
# - example.y.z
|
||||
# headers:
|
||||
# negative: true
|
||||
# values:
|
||||
# - foo=bar
|
||||
# - baz
|
||||
# cidr:
|
||||
# values:
|
||||
# - 192.168.10.0/24
|
||||
# fields:
|
||||
# headers:
|
||||
# default: keep
|
||||
# config:
|
||||
# foo: redact
|
||||
# query:
|
||||
# default: drop
|
||||
# config:
|
||||
# foo: keep
|
||||
# cookies:
|
||||
# default: redact
|
||||
# config:
|
||||
# foo: keep
|
||||
# Below define an example of middleware config
|
||||
# 1. block non local IP connections
|
||||
# 2. redirect HTTP to HTTPS
|
||||
#
|
||||
# middlewares:
|
||||
# - use: CIDRWhitelist
|
||||
# allow:
|
||||
# - "127.0.0.1"
|
||||
# - "10.0.0.0/8"
|
||||
# - "172.16.0.0/12"
|
||||
# - "192.168.0.0/16"
|
||||
# status: 403
|
||||
# message: "Forbidden"
|
||||
# - use: RedirectHTTP
|
||||
|
||||
# below enables access log
|
||||
access_log:
|
||||
format: combined
|
||||
path: /app/logs/entrypoint.log
|
||||
|
||||
providers:
|
||||
# include files are standalone yaml files under `config/` directory
|
||||
@@ -76,6 +48,7 @@ providers:
|
||||
docker:
|
||||
# $DOCKER_HOST implies environment variable `DOCKER_HOST` or unix:///var/run/docker.sock by default
|
||||
local: $DOCKER_HOST
|
||||
|
||||
# explicit only mode
|
||||
# only containers with explicit aliases will be proxied
|
||||
# add "!" after provider name to enable explicit only mode
|
||||
@@ -98,28 +71,10 @@ providers:
|
||||
# - name: discord
|
||||
# provider: webhook
|
||||
# url: https://discord.com/api/webhooks/...
|
||||
# template: discord
|
||||
# # payload: | # discord template implies the following
|
||||
# # {
|
||||
# # "embeds": [
|
||||
# # {
|
||||
# # "title": $title,
|
||||
# # "fields": $fields,
|
||||
# # "color": "$color"
|
||||
# # }
|
||||
# # ]
|
||||
# # }
|
||||
# if match_domains not defined
|
||||
# any host = alias+[any domain] will match
|
||||
# i.e. https://app1.y.z will match alias app1 for any domain y.z
|
||||
# but https://app1.node1.y.z will only match alias "app.node1"
|
||||
#
|
||||
# if match_domains defined
|
||||
# only host = alias+[one of match_domains] will match
|
||||
# i.e. match_domains = [node1.my.app, my.site]
|
||||
# https://app1.my.app, https://app1.my.net, etc. will not match even if app1 exists
|
||||
# only https://*.node1.my.app and https://*.my.site will match
|
||||
#
|
||||
# template: discord # this means use payload template from internal/notif/templates/discord.json
|
||||
|
||||
# Check https://github.com/yusing/godoxy/wiki/Certificates-and-domain-matching#domain-matching
|
||||
# for explaination of `match_domains`
|
||||
#
|
||||
# match_domains:
|
||||
# - my.site
|
||||
|
||||
98
go.mod
98
go.mod
@@ -1,76 +1,96 @@
|
||||
module github.com/yusing/go-proxy
|
||||
|
||||
go 1.23.4
|
||||
go 1.24.1
|
||||
|
||||
require (
|
||||
github.com/coder/websocket v1.8.12
|
||||
github.com/docker/cli v27.4.1+incompatible
|
||||
github.com/docker/docker v27.4.1+incompatible
|
||||
github.com/fsnotify/fsnotify v1.8.0
|
||||
github.com/go-acme/lego/v4 v4.21.0
|
||||
github.com/go-playground/validator/v10 v10.23.0
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||
github.com/gotify/server/v2 v2.6.1
|
||||
github.com/prometheus/client_golang v1.20.5
|
||||
github.com/puzpuzpuz/xsync/v3 v3.4.0
|
||||
github.com/rs/zerolog v1.33.0
|
||||
golang.org/x/net v0.33.0
|
||||
golang.org/x/text v0.21.0
|
||||
golang.org/x/time v0.9.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
github.com/PuerkitoBio/goquery v1.10.2 // parsing HTML for extract fav icon
|
||||
github.com/coder/websocket v1.8.13 // websocket for API and agent
|
||||
github.com/coreos/go-oidc/v3 v3.13.0 // oidc authentication
|
||||
github.com/docker/docker v28.0.4+incompatible // docker daemon
|
||||
github.com/fsnotify/fsnotify v1.8.0 // file watcher
|
||||
github.com/go-acme/lego/v4 v4.22.2 // acme client
|
||||
github.com/go-playground/validator/v10 v10.25.0 // validator
|
||||
github.com/gobwas/glob v0.2.3 // glob matcher for route rules
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2 // jwt for default auth
|
||||
github.com/gotify/server/v2 v2.6.1 // reference the Message struct for json response
|
||||
github.com/lithammer/fuzzysearch v1.1.8 // fuzzy search for searching icons and filtering metrics
|
||||
github.com/prometheus/client_golang v1.21.1 // metrics
|
||||
github.com/puzpuzpuz/xsync/v3 v3.5.1 // lock free map for concurrent operations
|
||||
github.com/rs/zerolog v1.34.0 // logging
|
||||
github.com/shirou/gopsutil/v4 v4.25.2 // system info metrics
|
||||
github.com/vincent-petithory/dataurl v1.0.0 // data url for fav icon
|
||||
golang.org/x/crypto v0.36.0 // encrypting password with bcrypt
|
||||
golang.org/x/net v0.38.0 // HTTP header utilities
|
||||
golang.org/x/oauth2 v0.28.0 // oauth2 authentication
|
||||
golang.org/x/text v0.23.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
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/docker/cli v28.0.4+incompatible
|
||||
github.com/docker/go-connections v0.5.0
|
||||
github.com/stretchr/testify v1.10.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/andybalholm/cascadia v1.3.3 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cloudflare/cloudflare-go v0.113.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/distribution/reference v0.6.0 // indirect
|
||||
github.com/docker/go-connections v0.5.0 // indirect
|
||||
github.com/docker/go-units v0.5.0 // indirect
|
||||
github.com/ebitengine/purego v0.8.2 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.0.4 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.0.5 // indirect
|
||||
github.com/go-logr/logr v1.4.2 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/goccy/go-json v0.10.4 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/gogo/protobuf v1.3.2 // indirect
|
||||
github.com/google/go-querystring v1.1.0 // indirect
|
||||
github.com/klauspost/compress v1.17.11 // indirect
|
||||
github.com/klauspost/compress v1.18.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // 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/miekg/dns v1.1.62 // indirect
|
||||
github.com/miekg/dns v1.1.64 // indirect
|
||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||
github.com/moby/term v0.5.0 // indirect
|
||||
github.com/morikuni/aec v1.0.0 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/nrdcg/porkbun v0.4.0 // indirect
|
||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||
github.com/opencontainers/image-spec v1.1.0 // indirect
|
||||
github.com/ovh/go-ovh v1.6.0 // indirect
|
||||
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||
github.com/ovh/go-ovh v1.7.0 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
||||
github.com/prometheus/client_model v0.6.1 // indirect
|
||||
github.com/prometheus/common v0.61.0 // indirect
|
||||
github.com/prometheus/procfs v0.15.1 // indirect
|
||||
github.com/prometheus/common v0.63.0 // indirect
|
||||
github.com/prometheus/procfs v0.16.0 // indirect
|
||||
github.com/sirupsen/logrus v1.9.3 // indirect
|
||||
github.com/tklauser/go-sysconf v0.3.15 // indirect
|
||||
github.com/tklauser/numcpus v0.10.0 // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 // indirect
|
||||
go.opentelemetry.io/otel v1.33.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.33.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk v1.30.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.33.0 // indirect
|
||||
golang.org/x/crypto v0.31.0 // indirect
|
||||
golang.org/x/mod v0.22.0 // indirect
|
||||
golang.org/x/oauth2 v0.25.0 // indirect
|
||||
golang.org/x/sync v0.10.0 // indirect
|
||||
golang.org/x/sys v0.29.0 // indirect
|
||||
golang.org/x/tools v0.28.0 // indirect
|
||||
google.golang.org/protobuf v1.36.1 // 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.12.0 // indirect
|
||||
golang.org/x/sys v0.31.0 // indirect
|
||||
golang.org/x/tools v0.31.0 // indirect
|
||||
google.golang.org/protobuf v1.36.6 // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
gotest.tools/v3 v3.5.1 // indirect
|
||||
)
|
||||
|
||||
222
go.sum
222
go.sum
@@ -2,18 +2,24 @@ github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOEl
|
||||
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/PuerkitoBio/goquery v1.10.2 h1:7fh2BdHcG6VFZsK7toXBT/Bh1z5Wmy8Q9MV9HqT2AM8=
|
||||
github.com/PuerkitoBio/goquery v1.10.2/go.mod h1:0guWGjcLu9AYC7C1GHnpysHy056u9aEkUHwhdnePMCU=
|
||||
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
|
||||
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cloudflare/cloudflare-go v0.113.0 h1:qnOXmA6RbgZ4rg5gNBK5QGk0Pzbv8pnUYV3C4+8CU6w=
|
||||
github.com/cloudflare/cloudflare-go v0.113.0/go.mod h1:Dlm4BAnycHc0i8yLxQZb9b+OlMwYOAoDJsUOEFgpVvo=
|
||||
github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo=
|
||||
github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
|
||||
github.com/cloudflare/cloudflare-go v0.115.0 h1:84/dxeeXweCc0PN5Cto44iTA8AkG1fyT11yPO5ZB7sM=
|
||||
github.com/cloudflare/cloudflare-go v0.115.0/go.mod h1:Ds6urDwn/TF2uIU24mu7H91xkKP8gSAHxQ44DSZgVmU=
|
||||
github.com/coder/websocket v1.8.13 h1:f3QZdXy7uGVz+4uCJy2nTZyM0yTBj8yANEHhqlXZ9FE=
|
||||
github.com/coder/websocket v1.8.13/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
|
||||
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
|
||||
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
|
||||
github.com/coreos/go-oidc/v3 v3.13.0 h1:M66zd0pcc5VxvBNM4pB331Wrsanby+QomQYjN8HamW8=
|
||||
github.com/coreos/go-oidc/v3 v3.13.0/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU=
|
||||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -21,47 +27,55 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
|
||||
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 v27.4.1+incompatible h1:VzPiUlRJ/xh+otB75gva3r05isHMo5wXDfPRi5/b4hI=
|
||||
github.com/docker/cli v27.4.1+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
|
||||
github.com/docker/docker v27.4.1+incompatible h1:ZJvcY7gfwHn1JF48PfbyXg7Jyt9ZCWDW+GGXOIxEwp4=
|
||||
github.com/docker/docker v27.4.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||
github.com/docker/cli v28.0.4+incompatible h1:pBJSJeNd9QeIWPjRcV91RVJihd/TXB77q1ef64XEu4A=
|
||||
github.com/docker/cli v28.0.4+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
|
||||
github.com/docker/docker v28.0.4+incompatible h1:JNNkBctYKurkw6FrHfKqY0nKIDf5nrbxjVBtS+cdcok=
|
||||
github.com/docker/docker v28.0.4+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
|
||||
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
|
||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||
github.com/ebitengine/purego v0.8.2 h1:jPPGWs2sZ1UgOSgD2bClL0MJIqu58nOmIcBuXr62z1I=
|
||||
github.com/ebitengine/purego v0.8.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
|
||||
github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
||||
github.com/go-acme/lego/v4 v4.21.0 h1:arEW+8o5p7VI8Bk1kr/PDlgD1DrxtTH1gJ4b7mehL8o=
|
||||
github.com/go-acme/lego/v4 v4.21.0/go.mod h1:HrSWzm3Ckj45Ie3i+p1zKVobbQoMOaGu9m4up0dUeDI=
|
||||
github.com/go-jose/go-jose/v4 v4.0.4 h1:VsjPI33J0SB9vQM6PLmNjoHqMQNGPiZ0rHL7Ni7Q6/E=
|
||||
github.com/go-jose/go-jose/v4 v4.0.4/go.mod h1:NKb5HO1EZccyMpiZNbdUw/14tiXNyUJh188dfnMCAfc=
|
||||
github.com/go-acme/lego/v4 v4.22.2 h1:ck+HllWrV/rZGeYohsKQ5iKNnU/WAZxwOdiu6cxky+0=
|
||||
github.com/go-acme/lego/v4 v4.22.2/go.mod h1:E2FndyI3Ekv0usNJt46mFb9LVpV/XBYT+4E3tz02Tzo=
|
||||
github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE=
|
||||
github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
||||
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.23.0 h1:/PwmTwZhS0dPkav3cdK9kV1FsAmrL8sThn8IHr/sO+o=
|
||||
github.com/go-playground/validator/v10 v10.23.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||
github.com/goccy/go-json v0.10.4 h1:JSwxQzIqKfmFX1swYPpUThQZp/Ka4wzJdK0LWVytLPM=
|
||||
github.com/goccy/go-json v0.10.4/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/go-playground/validator/v10 v10.25.0 h1:5Dh7cjvzR7BRZadnsVOzPhWsrwUr0nmsZJxEAnFLNO8=
|
||||
github.com/go-playground/validator/v10 v10.25.0/go.mod h1:GGzBIJMuE98Ic/kJsBXbz1x/7cByt++cQ+YOuDM5wus=
|
||||
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
|
||||
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
|
||||
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
@@ -74,8 +88,8 @@ github.com/jarcoal/httpmock v1.3.0 h1:2RJ8GP0IIaWwcC9Fp2BmVi8Kog3v2Hn7VXM3fTd+nu
|
||||
github.com/jarcoal/httpmock v1.3.0/go.mod h1:3yb8rc4BI7TCBhFY8ng0gjuLKJNquuDNiPaZjnENuYg=
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
|
||||
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
|
||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
@@ -84,16 +98,21 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
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/maxatome/go-testdeep v1.12.0 h1:Ql7Go8Tg0C1D/uMMX59LAoYK7LffeJQ6X2T04nTH68g=
|
||||
github.com/maxatome/go-testdeep v1.12.0/go.mod h1:lPZc/HAcJMP92l7yI6TRz1aZN5URwUBUAfUNvrclaNM=
|
||||
github.com/miekg/dns v1.1.62 h1:cN8OuEF1/x5Rq6Np+h1epln8OiyPWV+lROx9LxcGgIQ=
|
||||
github.com/miekg/dns v1.1.62/go.mod h1:mvDlcItzm+br7MToIKqkglaGhlFMHJ9DTNNWONWXbNQ=
|
||||
github.com/miekg/dns v1.1.64 h1:wuZgD9wwCE6XMT05UU/mlSko71eRSXEAm2EbjQXLKnQ=
|
||||
github.com/miekg/dns v1.1.64/go.mod h1:Dzw9769uoKVaLuODMDZz9M6ynFU6Em65csPuoi8G0ck=
|
||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
|
||||
@@ -102,101 +121,174 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/nrdcg/porkbun v0.4.0 h1:rWweKlwo1PToQ3H+tEO9gPRW0wzzgmI/Ob3n2Guticw=
|
||||
github.com/nrdcg/porkbun v0.4.0/go.mod h1:/QMskrHEIM0IhC/wY7iTCUgINsxdT2WcOphktJ9+Q54=
|
||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
|
||||
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
|
||||
github.com/ovh/go-ovh v1.6.0 h1:ixLOwxQdzYDx296sXcgS35TOPEahJkpjMGtzPadCjQI=
|
||||
github.com/ovh/go-ovh v1.6.0/go.mod h1:cTVDnl94z4tl8pP1uZ/8jlVxntjSIf09bNcQ5TJSC7c=
|
||||
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
||||
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
|
||||
github.com/ovh/go-ovh v1.7.0 h1:V14nF7FwDjQrZt9g7jzcvAAQ3HN6DNShRFRMC3jLoPw=
|
||||
github.com/ovh/go-ovh v1.7.0/go.mod h1:cTVDnl94z4tl8pP1uZ/8jlVxntjSIf09bNcQ5TJSC7c=
|
||||
github.com/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/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y=
|
||||
github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
|
||||
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/prometheus/client_golang v1.21.1 h1:DOvXXTqVzvkIewV/CDPFdejpMCGeMcbGCQ8YOmu+Ibk=
|
||||
github.com/prometheus/client_golang v1.21.1/go.mod h1:U9NM32ykUErtVBxdvD3zfi+EuFkkaBvMb09mIfe0Zgg=
|
||||
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
|
||||
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
|
||||
github.com/prometheus/common v0.61.0 h1:3gv/GThfX0cV2lpO7gkTUwZru38mxevy90Bj8YFSRQQ=
|
||||
github.com/prometheus/common v0.61.0/go.mod h1:zr29OCN/2BsJRaFwG8QOBr41D6kkchKbpeNH7pAjb/s=
|
||||
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
|
||||
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
|
||||
github.com/puzpuzpuz/xsync/v3 v3.4.0 h1:DuVBAdXuGFHv8adVXjWWZ63pJq+NRXOWVXlKDBZ+mJ4=
|
||||
github.com/puzpuzpuz/xsync/v3 v3.4.0/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA=
|
||||
github.com/prometheus/common v0.63.0 h1:YR/EIY1o3mEFP/kZCD7iDMnLPlGyuU2Gb3HIcXnA98k=
|
||||
github.com/prometheus/common v0.63.0/go.mod h1:VVFF/fBIoToEnWRVkYoXEkq3R3paCoxG9PXP74SnV18=
|
||||
github.com/prometheus/procfs v0.16.0 h1:xh6oHhKwnOJKMYiYBDWmkHqQPyiY40sny36Cmx2bbsM=
|
||||
github.com/prometheus/procfs v0.16.0/go.mod h1:8veyXUu3nGP7oaCxhX6yeaM5u4stL2FeMXnCqhDthZg=
|
||||
github.com/puzpuzpuz/xsync/v3 v3.5.1 h1:GJYJZwO6IdxN/IKbneznS6yPkVC+c3zyY/j19c++5Fg=
|
||||
github.com/puzpuzpuz/xsync/v3 v3.5.1/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA=
|
||||
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
|
||||
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
|
||||
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
|
||||
github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8=
|
||||
github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
|
||||
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/shirou/gopsutil/v4 v4.25.2 h1:NMscG3l2CqtWFS86kj3vP7soOczqrQYIEhO/pMvvQkk=
|
||||
github.com/shirou/gopsutil/v4 v4.25.2/go.mod h1:34gBYJzyqCDT11b6bMHP0XCvWeU3J61XRT7a2EmCRTA=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/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/contrib/instrumentation/net/http/otelhttp v0.58.0 h1:yd02MEjBdJkG3uabWP9apV+OuWRIXGDuJEUJbOHmCFU=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0/go.mod h1:umTcuxiv1n/s/S6/c2AT/g2CQ7u5C59sHDNmfSwgz7Q=
|
||||
go.opentelemetry.io/otel v1.33.0 h1:/FerN9bax5LoK51X/sI0SVYrjSE0/yUL7DpxW4K3FWw=
|
||||
go.opentelemetry.io/otel v1.33.0/go.mod h1:SUUkR6csvUQl+yjReHu5uM3EtVV7MBm5FHKRlNx4I8I=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ=
|
||||
go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ=
|
||||
go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.30.0 h1:lsInsfvhVIfOI6qHVyysXMNDnjO9Npvl7tlDPJFBVd4=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.30.0/go.mod h1:KQsVNh4OjgjTG0G6EiNi1jVpnaeeKsKMRwbLN+f1+8M=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.30.0 h1:umZgi92IyxfXd/l4kaDhnKgY8rnN/cZcF1LKc6I8OQ8=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.30.0/go.mod h1:4lVs6obhSVRb1EW5FhOuBTyiQhtRtAnnva9vD3yRfq8=
|
||||
go.opentelemetry.io/otel/metric v1.33.0 h1:r+JOocAyeRVXD8lZpjdQjzMadVZp2M4WmQ+5WtEnklQ=
|
||||
go.opentelemetry.io/otel/metric v1.33.0/go.mod h1:L9+Fyctbp6HFTddIxClbQkjtubW6O9QS3Ann/M82u6M=
|
||||
go.opentelemetry.io/otel/sdk v1.30.0 h1:cHdik6irO49R5IysVhdn8oaiR9m8XluDaJAs4DfOrYE=
|
||||
go.opentelemetry.io/otel/sdk v1.30.0/go.mod h1:p14X4Ok8S+sygzblytT1nqG98QG2KYKv++HE0LY/mhg=
|
||||
go.opentelemetry.io/otel/trace v1.33.0 h1:cCJuF7LRjUFso9LPnEAHJDB2pqzp+hbO8eu1qqW2d/s=
|
||||
go.opentelemetry.io/otel/trace v1.33.0/go.mod h1:uIcdVUZMpTAmz0tI1z04GoVSezK37CbGV4fr1f2nBck=
|
||||
go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M=
|
||||
go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE=
|
||||
go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY=
|
||||
go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w=
|
||||
go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs=
|
||||
go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc=
|
||||
go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0=
|
||||
go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8=
|
||||
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.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
||||
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.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
|
||||
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
|
||||
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.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4=
|
||||
golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
|
||||
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
|
||||
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/oauth2 v0.25.0 h1:CY4y7XT9v0cRI9oupztF8AgiIu99L/ksR/Xp/6jrZ70=
|
||||
golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
||||
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
|
||||
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
|
||||
golang.org/x/oauth2 v0.28.0 h1:CrgCKl8PPAVtLnU3c+EDw6x11699EWlsDeWNWKdIOkc=
|
||||
golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
|
||||
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.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
|
||||
golang.org/x/sync v0.12.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.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
|
||||
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
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.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
||||
golang.org/x/sys v0.31.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.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||
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/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
|
||||
golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
||||
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
||||
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
|
||||
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.28.0 h1:WuB6qZ4RPCQo5aP3WdKZS7i595EdWqWR8vqJTlwTVK8=
|
||||
golang.org/x/tools v0.28.0/go.mod h1:dcIOrVd3mfQKTgrDVQHqCPMWy6lnhfhtX3hLXYVLfRw=
|
||||
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.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU=
|
||||
golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ=
|
||||
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=
|
||||
@@ -208,8 +300,8 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576 h1:
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576/go.mod h1:5uTbfoYQed2U9p3KIj2/Zzm02PYhndfdmML0qC3q3FU=
|
||||
google.golang.org/grpc v1.67.1 h1:zWnc1Vrcno+lHZCOofnIMvycFcc0QRGIzm9dhnDX68E=
|
||||
google.golang.org/grpc v1.67.1/go.mod h1:1gLDyUQU7CTLJI90u3nXZ9ekeghjeM7pTDZlqFNg2AA=
|
||||
google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk=
|
||||
google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
||||
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-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
|
||||
@@ -1,73 +1,118 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net"
|
||||
"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/utils"
|
||||
"github.com/yusing/go-proxy/internal/api/v1/certapi"
|
||||
"github.com/yusing/go-proxy/internal/api/v1/dockerapi"
|
||||
"github.com/yusing/go-proxy/internal/api/v1/favicon"
|
||||
"github.com/yusing/go-proxy/internal/common"
|
||||
config "github.com/yusing/go-proxy/internal/config/types"
|
||||
"github.com/yusing/go-proxy/internal/logging"
|
||||
"github.com/yusing/go-proxy/internal/net/http/middleware"
|
||||
"github.com/yusing/go-proxy/internal/logging/memlogger"
|
||||
"github.com/yusing/go-proxy/internal/metrics/uptime"
|
||||
"github.com/yusing/go-proxy/internal/net/gphttp/httpheaders"
|
||||
"github.com/yusing/go-proxy/internal/utils/strutils"
|
||||
)
|
||||
|
||||
type ServeMux struct{ *http.ServeMux }
|
||||
type (
|
||||
ServeMux struct {
|
||||
*http.ServeMux
|
||||
cfg config.ConfigInstance
|
||||
}
|
||||
WithCfgHandler = func(config.ConfigInstance, http.ResponseWriter, *http.Request)
|
||||
)
|
||||
|
||||
func NewServeMux() ServeMux {
|
||||
return ServeMux{http.NewServeMux()}
|
||||
func (mux ServeMux) HandleFunc(methods, endpoint string, h any, requireAuth ...bool) {
|
||||
var handler http.HandlerFunc
|
||||
switch h := h.(type) {
|
||||
case func(http.ResponseWriter, *http.Request):
|
||||
handler = h
|
||||
case http.Handler:
|
||||
handler = h.ServeHTTP
|
||||
case WithCfgHandler:
|
||||
handler = func(w http.ResponseWriter, r *http.Request) {
|
||||
h(mux.cfg, w, r)
|
||||
}
|
||||
default:
|
||||
panic(fmt.Errorf("unsupported handler type: %T", h))
|
||||
}
|
||||
|
||||
matchDomains := mux.cfg.Value().MatchDomains
|
||||
if len(matchDomains) > 0 {
|
||||
origHandler := handler
|
||||
handler = func(w http.ResponseWriter, r *http.Request) {
|
||||
if httpheaders.IsWebsocket(r.Header) {
|
||||
httpheaders.SetWebsocketAllowedDomains(r.Header, matchDomains)
|
||||
}
|
||||
origHandler(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
if len(requireAuth) > 0 && requireAuth[0] {
|
||||
handler = auth.RequireAuth(handler)
|
||||
}
|
||||
if methods == "" {
|
||||
mux.ServeMux.HandleFunc(endpoint, handler)
|
||||
} else {
|
||||
for _, m := range strutils.CommaSeperatedList(methods) {
|
||||
mux.ServeMux.HandleFunc(m+" "+endpoint, handler)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (mux ServeMux) HandleFunc(method, endpoint string, handler http.HandlerFunc) {
|
||||
mux.ServeMux.HandleFunc(method+" "+endpoint, checkHost(rateLimited(handler)))
|
||||
}
|
||||
|
||||
func NewHandler() http.Handler {
|
||||
mux := NewServeMux()
|
||||
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("POST", "/v1/login", auth.LoginHandler)
|
||||
mux.HandleFunc("GET", "/v1/logout", auth.LogoutHandler)
|
||||
mux.HandleFunc("POST", "/v1/logout", auth.LogoutHandler)
|
||||
mux.HandleFunc("POST", "/v1/reload", v1.Reload)
|
||||
mux.HandleFunc("GET", "/v1/list", auth.RequireAuth(v1.List))
|
||||
mux.HandleFunc("GET", "/v1/list/{what}", auth.RequireAuth(v1.List))
|
||||
mux.HandleFunc("GET", "/v1/list/{what}/{which}", auth.RequireAuth(v1.List))
|
||||
mux.HandleFunc("GET", "/v1/file", auth.RequireAuth(v1.GetFileContent))
|
||||
mux.HandleFunc("GET", "/v1/file/{filename...}", auth.RequireAuth(v1.GetFileContent))
|
||||
mux.HandleFunc("POST", "/v1/file/{filename...}", auth.RequireAuth(v1.SetFileContent))
|
||||
mux.HandleFunc("PUT", "/v1/file/{filename...}", auth.RequireAuth(v1.SetFileContent))
|
||||
mux.HandleFunc("GET", "/v1/stats", v1.Stats)
|
||||
mux.HandleFunc("GET", "/v1/stats/ws", v1.StatsWS)
|
||||
|
||||
mux.HandleFunc("GET", "/v1/stats", v1.Stats, true)
|
||||
mux.HandleFunc("POST", "/v1/reload", v1.Reload, true)
|
||||
mux.HandleFunc("GET", "/v1/list", v1.List, true)
|
||||
mux.HandleFunc("GET", "/v1/list/{what}", v1.List, true)
|
||||
mux.HandleFunc("GET", "/v1/list/{what}/{which}", v1.List, true)
|
||||
mux.HandleFunc("GET", "/v1/file/{type}/{filename}", v1.GetFileContent, true)
|
||||
mux.HandleFunc("POST,PUT", "/v1/file/{type}/{filename}", v1.SetFileContent, true)
|
||||
mux.HandleFunc("POST", "/v1/file/validate/{type}", v1.ValidateFile, true)
|
||||
mux.HandleFunc("GET", "/v1/health", v1.Health, true)
|
||||
mux.HandleFunc("GET", "/v1/logs", memlogger.Handler(), true)
|
||||
mux.HandleFunc("GET", "/v1/favicon", favicon.GetFavIcon, true)
|
||||
mux.HandleFunc("POST", "/v1/homepage/set", v1.SetHomePageOverrides, true)
|
||||
mux.HandleFunc("GET", "/v1/agents", v1.ListAgents, true)
|
||||
mux.HandleFunc("GET", "/v1/agents/new", v1.NewAgent, true)
|
||||
mux.HandleFunc("POST", "/v1/agents/verify", v1.VerifyNewAgent, true)
|
||||
mux.HandleFunc("GET", "/v1/metrics/system_info", v1.SystemInfo, true)
|
||||
mux.HandleFunc("GET", "/v1/metrics/uptime", uptime.Poller.ServeHTTP, true)
|
||||
mux.HandleFunc("GET", "/v1/cert/info", certapi.GetCertInfo, true)
|
||||
mux.HandleFunc("", "/v1/cert/renew", certapi.RenewCert, true)
|
||||
mux.HandleFunc("GET", "/v1/docker/info", dockerapi.DockerInfo, true)
|
||||
mux.HandleFunc("GET", "/v1/docker/logs/{server}/{container}", dockerapi.Logs, true)
|
||||
mux.HandleFunc("GET", "/v1/docker/containers", dockerapi.Containers, true)
|
||||
|
||||
if common.PrometheusEnabled {
|
||||
mux.Handle("GET /v1/metrics", promhttp.Handler())
|
||||
logging.Info().Msg("prometheus metrics enabled")
|
||||
}
|
||||
|
||||
defaultAuth := auth.GetDefaultAuth()
|
||||
if defaultAuth != nil {
|
||||
mux.HandleFunc("GET", "/v1/auth/redirect", defaultAuth.RedirectLoginPage)
|
||||
mux.HandleFunc("GET", "/v1/auth/check", func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := defaultAuth.CheckToken(r); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
})
|
||||
mux.HandleFunc("GET,POST", "/v1/auth/callback", defaultAuth.LoginCallbackHandler)
|
||||
mux.HandleFunc("GET,POST", "/v1/auth/logout", defaultAuth.LogoutCallbackHandler)
|
||||
} else {
|
||||
mux.HandleFunc("GET", "/v1/auth/check", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
}
|
||||
return mux
|
||||
}
|
||||
|
||||
// allow only requests to API server with localhost.
|
||||
func checkHost(f http.HandlerFunc) http.HandlerFunc {
|
||||
if common.IsDebug {
|
||||
return f
|
||||
}
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
host, _, _ := net.SplitHostPort(r.RemoteAddr)
|
||||
if host != "127.0.0.1" && host != "localhost" && host != "[::1]" {
|
||||
LogWarn(r).Msgf("blocked API request from %s", host)
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
LogDebug(r).Interface("headers", r.Header).Msg("API request")
|
||||
f(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func rateLimited(f http.HandlerFunc) http.HandlerFunc {
|
||||
m, err := middleware.RateLimiter.New(middleware.OptionsRaw{
|
||||
"average": 10,
|
||||
"burst": 10,
|
||||
})
|
||||
if err != nil {
|
||||
logging.Fatal().Err(err).Msg("unable to create API rate limiter")
|
||||
}
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
m.ModifyRequest(f, w, r)
|
||||
}
|
||||
}
|
||||
|
||||
24
internal/api/v1/agents.go
Normal file
24
internal/api/v1/agents.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
"github.com/coder/websocket/wsjson"
|
||||
config "github.com/yusing/go-proxy/internal/config/types"
|
||||
"github.com/yusing/go-proxy/internal/net/gphttp"
|
||||
"github.com/yusing/go-proxy/internal/net/gphttp/gpwebsocket"
|
||||
"github.com/yusing/go-proxy/internal/net/gphttp/httpheaders"
|
||||
)
|
||||
|
||||
func ListAgents(cfg config.ConfigInstance, w http.ResponseWriter, r *http.Request) {
|
||||
if httpheaders.IsWebsocket(r.Header) {
|
||||
gpwebsocket.Periodic(w, r, 10*time.Second, func(conn *websocket.Conn) error {
|
||||
wsjson.Write(r.Context(), conn, cfg.ListAgents())
|
||||
return nil
|
||||
})
|
||||
} else {
|
||||
gphttp.RespondJSON(w, r, cfg.ListAgents())
|
||||
}
|
||||
}
|
||||
@@ -1,135 +1,52 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
U "github.com/yusing/go-proxy/internal/api/v1/utils"
|
||||
"github.com/yusing/go-proxy/internal/common"
|
||||
E "github.com/yusing/go-proxy/internal/error"
|
||||
"github.com/yusing/go-proxy/internal/utils/strutils"
|
||||
"github.com/yusing/go-proxy/internal/net/gphttp"
|
||||
)
|
||||
|
||||
type (
|
||||
Credentials struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
Claims struct {
|
||||
Username string `json:"username"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
)
|
||||
var defaultAuth Provider
|
||||
|
||||
var (
|
||||
ErrInvalidUsername = E.New("invalid username")
|
||||
ErrInvalidPassword = E.New("invalid password")
|
||||
)
|
||||
// Initialize sets up authentication providers.
|
||||
func Initialize() error {
|
||||
if !IsEnabled() {
|
||||
return nil
|
||||
}
|
||||
|
||||
func validatePassword(cred *Credentials) error {
|
||||
if cred.Username != common.APIUser {
|
||||
return ErrInvalidUsername.Subject(cred.Username)
|
||||
var err error
|
||||
// Initialize OIDC if configured.
|
||||
if common.OIDCIssuerURL != "" {
|
||||
defaultAuth, err = NewOIDCProviderFromEnv()
|
||||
} else {
|
||||
defaultAuth, err = NewUserPassAuthFromEnv()
|
||||
}
|
||||
if !bytes.Equal(common.HashPassword(cred.Password), common.APIPasswordHash) {
|
||||
return ErrInvalidPassword.Subject(cred.Password)
|
||||
}
|
||||
return nil
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func LoginHandler(w http.ResponseWriter, r *http.Request) {
|
||||
var creds Credentials
|
||||
err := json.NewDecoder(r.Body).Decode(&creds)
|
||||
if err != nil {
|
||||
U.HandleErr(w, r, err, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := validatePassword(&creds); err != nil {
|
||||
U.HandleErr(w, r, err, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
expiresAt := time.Now().Add(common.APIJWTTokenTTL)
|
||||
claim := &Claims{
|
||||
Username: creds.Username,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(expiresAt),
|
||||
},
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS512, claim)
|
||||
tokenStr, err := token.SignedString(common.APIJWTSecret)
|
||||
if err != nil {
|
||||
U.HandleErr(w, r, err)
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "token",
|
||||
Value: tokenStr,
|
||||
Expires: expiresAt,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
Path: "/",
|
||||
})
|
||||
w.WriteHeader(http.StatusOK)
|
||||
func GetDefaultAuth() Provider {
|
||||
return defaultAuth
|
||||
}
|
||||
|
||||
func LogoutHandler(w http.ResponseWriter, r *http.Request) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "token",
|
||||
Value: "",
|
||||
Expires: time.Unix(0, 0),
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
Path: "/",
|
||||
})
|
||||
w.Header().Set("location", "/login")
|
||||
w.WriteHeader(http.StatusTemporaryRedirect)
|
||||
func IsEnabled() bool {
|
||||
return !common.DebugDisableAuth && (common.APIJWTSecret != nil || IsOIDCEnabled())
|
||||
}
|
||||
|
||||
func IsOIDCEnabled() bool {
|
||||
return common.OIDCIssuerURL != ""
|
||||
}
|
||||
|
||||
func RequireAuth(next http.HandlerFunc) http.HandlerFunc {
|
||||
if common.IsDebugSkipAuth || common.APIJWTSecret == nil {
|
||||
return next
|
||||
}
|
||||
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if checkToken(w, r) {
|
||||
next(w, r)
|
||||
if IsEnabled() {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := defaultAuth.CheckToken(r); err != nil {
|
||||
gphttp.ClientError(w, err, http.StatusUnauthorized)
|
||||
} else {
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func checkToken(w http.ResponseWriter, r *http.Request) (ok bool) {
|
||||
tokenCookie, err := r.Cookie("token")
|
||||
if err != nil {
|
||||
U.RespondError(w, E.New("missing token"), http.StatusUnauthorized)
|
||||
return false
|
||||
}
|
||||
var claims Claims
|
||||
token, err := jwt.ParseWithClaims(tokenCookie.Value, &claims, func(t *jwt.Token) (interface{}, error) {
|
||||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
|
||||
}
|
||||
return common.APIJWTSecret, nil
|
||||
})
|
||||
|
||||
switch {
|
||||
case err != nil:
|
||||
break
|
||||
case !token.Valid:
|
||||
err = E.New("invalid token")
|
||||
case claims.Username != common.APIUser:
|
||||
err = E.New("username mismatch").Subject(claims.Username)
|
||||
case claims.ExpiresAt.Before(time.Now()):
|
||||
err = E.Errorf("token expired on %s", strutils.FormatTime(claims.ExpiresAt.Time))
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
U.RespondError(w, err, http.StatusForbidden)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
return next
|
||||
}
|
||||
|
||||
22
internal/api/v1/auth/block_page.go
Normal file
22
internal/api/v1/auth/block_page.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"net/http"
|
||||
|
||||
_ "embed"
|
||||
)
|
||||
|
||||
//go:embed block_page.html
|
||||
var blockPageHTML string
|
||||
|
||||
var blockPageTemplate = template.Must(template.New("block_page").Parse(blockPageHTML))
|
||||
|
||||
func WriteBlockPage(w http.ResponseWriter, status int, error string, logoutURL string) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
blockPageTemplate.Execute(w, map[string]string{
|
||||
"StatusText": http.StatusText(status),
|
||||
"Error": error,
|
||||
"LogoutURL": logoutURL,
|
||||
})
|
||||
}
|
||||
14
internal/api/v1/auth/block_page.html
Normal file
14
internal/api/v1/auth/block_page.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
|
||||
<title>Access Denied</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>{{.StatusText}}</h1>
|
||||
<p>{{.Error}}</p>
|
||||
<a href="{{.LogoutURL}}">Logout</a>
|
||||
</body>
|
||||
</html>
|
||||
308
internal/api/v1/auth/oidc.go
Normal file
308
internal/api/v1/auth/oidc.go
Normal file
@@ -0,0 +1,308 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
"github.com/yusing/go-proxy/internal/common"
|
||||
"github.com/yusing/go-proxy/internal/net/gphttp"
|
||||
"github.com/yusing/go-proxy/internal/utils"
|
||||
"github.com/yusing/go-proxy/internal/utils/strutils"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
type (
|
||||
OIDCProvider struct {
|
||||
oauthConfig *oauth2.Config
|
||||
oidcProvider *oidc.Provider
|
||||
oidcVerifier *oidc.IDTokenVerifier
|
||||
oidcEndSessionURL *url.URL
|
||||
allowedUsers []string
|
||||
allowedGroups []string
|
||||
isMiddleware bool
|
||||
}
|
||||
|
||||
providerJSON struct {
|
||||
oidc.ProviderConfig
|
||||
EndSessionURL string `json:"end_session_endpoint"`
|
||||
}
|
||||
)
|
||||
|
||||
const CookieOauthState = "godoxy_oidc_state"
|
||||
|
||||
const (
|
||||
OIDCMiddlewareCallbackPath = "/auth/callback"
|
||||
OIDCLogoutPath = "/auth/logout"
|
||||
)
|
||||
|
||||
func NewOIDCProvider(issuerURL, clientID, clientSecret, redirectURL string, allowedUsers, allowedGroups []string) (*OIDCProvider, error) {
|
||||
if len(allowedUsers)+len(allowedGroups) == 0 {
|
||||
return nil, errors.New("OIDC users, groups, or both must not be empty")
|
||||
}
|
||||
|
||||
wellKnown := strings.TrimSuffix(issuerURL, "/") + "/.well-known/openid-configuration"
|
||||
resp, err := gphttp.Get(wellKnown)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("oidc: unable to read response body: %v", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("oidc: %s: %s", resp.Status, body)
|
||||
}
|
||||
|
||||
var p providerJSON
|
||||
err = json.Unmarshal(body, &p)
|
||||
if err != nil {
|
||||
mimeType, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type"))
|
||||
if err == nil && mimeType != "application/json" {
|
||||
return nil, fmt.Errorf("oidc: unexpected content type: %q from OIDC provider discovery, have you configured the correct issuer URL?", mimeType)
|
||||
}
|
||||
return nil, fmt.Errorf("oidc: failed to decode provider discovery object: %v", err)
|
||||
}
|
||||
|
||||
if p.IssuerURL != issuerURL {
|
||||
return nil, fmt.Errorf("oidc: issuer did not match the issuer returned by provider, expected %q got %q", issuerURL, p.IssuerURL)
|
||||
}
|
||||
|
||||
var endSessionURL *url.URL
|
||||
if p.EndSessionURL != "" {
|
||||
endSessionURL, err = url.Parse(p.EndSessionURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("oidc: failed to parse end session URL: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
provider := p.NewProvider(context.Background())
|
||||
return &OIDCProvider{
|
||||
oauthConfig: &oauth2.Config{
|
||||
ClientID: clientID,
|
||||
ClientSecret: clientSecret,
|
||||
RedirectURL: redirectURL,
|
||||
Endpoint: provider.Endpoint(),
|
||||
Scopes: strutils.CommaSeperatedList(common.OIDCScopes),
|
||||
},
|
||||
oidcProvider: provider,
|
||||
oidcVerifier: provider.Verifier(&oidc.Config{
|
||||
ClientID: clientID,
|
||||
}),
|
||||
oidcEndSessionURL: endSessionURL,
|
||||
allowedUsers: allowedUsers,
|
||||
allowedGroups: allowedGroups,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// NewOIDCProviderFromEnv creates a new OIDCProvider from environment variables.
|
||||
func NewOIDCProviderFromEnv() (*OIDCProvider, error) {
|
||||
return NewOIDCProvider(
|
||||
common.OIDCIssuerURL,
|
||||
common.OIDCClientID,
|
||||
common.OIDCClientSecret,
|
||||
common.OIDCRedirectURL,
|
||||
common.OIDCAllowedUsers,
|
||||
common.OIDCAllowedGroups,
|
||||
)
|
||||
}
|
||||
|
||||
func (auth *OIDCProvider) TokenCookieName() string {
|
||||
return "godoxy_oidc_token"
|
||||
}
|
||||
|
||||
func (auth *OIDCProvider) SetIsMiddleware(enabled bool) {
|
||||
auth.isMiddleware = enabled
|
||||
auth.oauthConfig.RedirectURL = ""
|
||||
}
|
||||
|
||||
func (auth *OIDCProvider) SetAllowedUsers(users []string) {
|
||||
auth.allowedUsers = users
|
||||
}
|
||||
|
||||
func (auth *OIDCProvider) SetAllowedGroups(groups []string) {
|
||||
auth.allowedGroups = groups
|
||||
}
|
||||
|
||||
func (auth *OIDCProvider) CheckToken(r *http.Request) error {
|
||||
token, err := r.Cookie(auth.TokenCookieName())
|
||||
if err != nil {
|
||||
return ErrMissingToken
|
||||
}
|
||||
|
||||
// checks for Expiry, Audience == ClientID, Issuer, etc.
|
||||
idToken, err := auth.oidcVerifier.Verify(r.Context(), token.Value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to verify ID token: %w: %w", ErrInvalidToken, err)
|
||||
}
|
||||
|
||||
if len(idToken.Audience) == 0 {
|
||||
return ErrInvalidToken
|
||||
}
|
||||
|
||||
var claims struct {
|
||||
Email string `json:"email"`
|
||||
Username string `json:"preferred_username"`
|
||||
Groups []string `json:"groups"`
|
||||
}
|
||||
if err := idToken.Claims(&claims); err != nil {
|
||||
return fmt.Errorf("failed to parse claims: %w", err)
|
||||
}
|
||||
|
||||
// Logical AND between allowed users and groups.
|
||||
allowedUser := slices.Contains(auth.allowedUsers, claims.Username)
|
||||
allowedGroup := len(utils.Intersect(claims.Groups, auth.allowedGroups)) > 0
|
||||
if !allowedUser && !allowedGroup {
|
||||
return ErrUserNotAllowed
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateState generates a random string for OIDC state.
|
||||
const oidcStateLength = 32
|
||||
|
||||
func generateState() (string, error) {
|
||||
b := make([]byte, oidcStateLength)
|
||||
_, err := rand.Read(b)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.URLEncoding.EncodeToString(b)[:oidcStateLength], nil
|
||||
}
|
||||
|
||||
// RedirectOIDC initiates the OIDC login flow.
|
||||
func (auth *OIDCProvider) RedirectLoginPage(w http.ResponseWriter, r *http.Request) {
|
||||
state, err := generateState()
|
||||
if err != nil {
|
||||
gphttp.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: CookieOauthState,
|
||||
Value: state,
|
||||
MaxAge: 300,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Secure: common.APIJWTSecure,
|
||||
Path: "/",
|
||||
})
|
||||
|
||||
redirURL := auth.oauthConfig.AuthCodeURL(state)
|
||||
if auth.isMiddleware {
|
||||
u, err := r.URL.Parse(redirURL)
|
||||
if err != nil {
|
||||
gphttp.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
q := u.Query()
|
||||
q.Set("redirect_uri", "https://"+r.Host+OIDCMiddlewareCallbackPath+q.Get("redirect_uri"))
|
||||
u.RawQuery = q.Encode()
|
||||
redirURL = u.String()
|
||||
}
|
||||
http.Redirect(w, r, redirURL, http.StatusTemporaryRedirect)
|
||||
}
|
||||
|
||||
func (auth *OIDCProvider) exchange(r *http.Request) (*oauth2.Token, error) {
|
||||
if auth.isMiddleware {
|
||||
cfg := *auth.oauthConfig
|
||||
cfg.RedirectURL = "https://" + r.Host + OIDCMiddlewareCallbackPath
|
||||
return cfg.Exchange(r.Context(), r.URL.Query().Get("code"))
|
||||
}
|
||||
return auth.oauthConfig.Exchange(r.Context(), r.URL.Query().Get("code"))
|
||||
}
|
||||
|
||||
// OIDCCallbackHandler handles the OIDC callback.
|
||||
func (auth *OIDCProvider) LoginCallbackHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// For testing purposes, skip provider verification
|
||||
if common.IsTest {
|
||||
auth.handleTestCallback(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
state, err := r.Cookie(CookieOauthState)
|
||||
if err != nil {
|
||||
gphttp.BadRequest(w, "missing state cookie")
|
||||
return
|
||||
}
|
||||
|
||||
query := r.URL.Query()
|
||||
if query.Get("state") != state.Value {
|
||||
gphttp.BadRequest(w, "invalid oauth state")
|
||||
return
|
||||
}
|
||||
|
||||
oauth2Token, err := auth.exchange(r)
|
||||
if err != nil {
|
||||
gphttp.ServerError(w, r, fmt.Errorf("failed to exchange token: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
rawIDToken, ok := oauth2Token.Extra("id_token").(string)
|
||||
if !ok {
|
||||
gphttp.BadRequest(w, "missing id_token")
|
||||
return
|
||||
}
|
||||
|
||||
idToken, err := auth.oidcVerifier.Verify(r.Context(), rawIDToken)
|
||||
if err != nil {
|
||||
gphttp.ServerError(w, r, fmt.Errorf("failed to verify ID token: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
setTokenCookie(w, r, auth.TokenCookieName(), rawIDToken, time.Until(idToken.Expiry))
|
||||
|
||||
// Redirect to home page
|
||||
http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
|
||||
}
|
||||
|
||||
func (auth *OIDCProvider) LogoutCallbackHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if auth.oidcEndSessionURL == nil {
|
||||
DefaultLogoutCallbackHandler(auth, w, r)
|
||||
return
|
||||
}
|
||||
|
||||
token, err := r.Cookie(auth.TokenCookieName())
|
||||
if err != nil {
|
||||
gphttp.BadRequest(w, "missing token cookie")
|
||||
return
|
||||
}
|
||||
clearTokenCookie(w, r, auth.TokenCookieName())
|
||||
|
||||
logoutURL := *auth.oidcEndSessionURL
|
||||
logoutURL.Query().Add("id_token_hint", token.Value)
|
||||
|
||||
http.Redirect(w, r, logoutURL.String(), http.StatusFound)
|
||||
}
|
||||
|
||||
// handleTestCallback handles OIDC callback in test environment.
|
||||
func (auth *OIDCProvider) handleTestCallback(w http.ResponseWriter, r *http.Request) {
|
||||
state, err := r.Cookie(CookieOauthState)
|
||||
if err != nil {
|
||||
gphttp.BadRequest(w, "missing state cookie")
|
||||
return
|
||||
}
|
||||
|
||||
if r.URL.Query().Get("state") != state.Value {
|
||||
gphttp.BadRequest(w, "invalid oauth state")
|
||||
return
|
||||
}
|
||||
|
||||
// Create test JWT token
|
||||
setTokenCookie(w, r, auth.TokenCookieName(), "test", time.Hour)
|
||||
|
||||
http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
|
||||
}
|
||||
454
internal/api/v1/auth/oidc_test.go
Normal file
454
internal/api/v1/auth/oidc_test.go
Normal file
@@ -0,0 +1,454 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/yusing/go-proxy/internal/common"
|
||||
"golang.org/x/oauth2"
|
||||
|
||||
. "github.com/yusing/go-proxy/internal/utils/testing"
|
||||
)
|
||||
|
||||
// setupMockOIDC configures mock OIDC provider for testing.
|
||||
func setupMockOIDC(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
provider := (&oidc.ProviderConfig{}).NewProvider(context.TODO())
|
||||
defaultAuth = &OIDCProvider{
|
||||
oauthConfig: &oauth2.Config{
|
||||
ClientID: "test-client",
|
||||
ClientSecret: "test-secret",
|
||||
RedirectURL: "http://localhost/callback",
|
||||
Endpoint: oauth2.Endpoint{
|
||||
AuthURL: "http://mock-provider/auth",
|
||||
TokenURL: "http://mock-provider/token",
|
||||
},
|
||||
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
|
||||
},
|
||||
oidcProvider: provider,
|
||||
oidcVerifier: provider.Verifier(&oidc.Config{
|
||||
ClientID: "test-client",
|
||||
}),
|
||||
allowedUsers: []string{"test-user"},
|
||||
allowedGroups: []string{"test-group1", "test-group2"},
|
||||
}
|
||||
}
|
||||
|
||||
// discoveryDocument returns a mock OIDC discovery document.
|
||||
func discoveryDocument(t *testing.T, server *httptest.Server) map[string]any {
|
||||
t.Helper()
|
||||
|
||||
discovery := map[string]any{
|
||||
"issuer": server.URL,
|
||||
"authorization_endpoint": server.URL + "/auth",
|
||||
"token_endpoint": server.URL + "/token",
|
||||
}
|
||||
|
||||
return discovery
|
||||
}
|
||||
|
||||
const (
|
||||
keyID = "test-key-id"
|
||||
clientID = "test-client-id"
|
||||
)
|
||||
|
||||
type provider struct {
|
||||
ts *httptest.Server
|
||||
key *rsa.PrivateKey
|
||||
verifier *oidc.IDTokenVerifier
|
||||
}
|
||||
|
||||
func (j *provider) SignClaims(t *testing.T, claims jwt.Claims) string {
|
||||
t.Helper()
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
|
||||
token.Header["kid"] = keyID
|
||||
signed, err := token.SignedString(j.key)
|
||||
ExpectNoError(t, err)
|
||||
return signed
|
||||
}
|
||||
|
||||
func setupProvider(t *testing.T) *provider {
|
||||
t.Helper()
|
||||
|
||||
// Generate an RSA key pair for the test.
|
||||
privKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
ExpectNoError(t, err)
|
||||
|
||||
// Build the matching public JWK that will be served by the endpoint.
|
||||
jwk := buildRSAJWK(t, &privKey.PublicKey, keyID)
|
||||
|
||||
// Start a test server that serves the JWKS endpoint.
|
||||
ts := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/.well-known/jwks.json":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"keys": []any{jwk},
|
||||
})
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(ts.Close)
|
||||
|
||||
// Create a test OIDCProvider.
|
||||
providerCtx := oidc.ClientContext(context.Background(), ts.Client())
|
||||
keySet := oidc.NewRemoteKeySet(providerCtx, ts.URL+"/.well-known/jwks.json")
|
||||
|
||||
return &provider{
|
||||
ts: ts,
|
||||
key: privKey,
|
||||
verifier: oidc.NewVerifier(ts.URL, keySet, &oidc.Config{
|
||||
ClientID: clientID, // matches audience in the token
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
// buildRSAJWK is a helper to construct a minimal JWK for the JWKS endpoint.
|
||||
func buildRSAJWK(t *testing.T, pub *rsa.PublicKey, kid string) map[string]any {
|
||||
t.Helper()
|
||||
|
||||
nBytes := pub.N.Bytes()
|
||||
eBytes := []byte{0x01, 0x00, 0x01} // Usually 65537
|
||||
|
||||
return map[string]any{
|
||||
"kty": "RSA",
|
||||
"alg": "RS256",
|
||||
"use": "sig",
|
||||
"kid": kid,
|
||||
"n": base64.RawURLEncoding.EncodeToString(nBytes),
|
||||
"e": base64.RawURLEncoding.EncodeToString(eBytes),
|
||||
}
|
||||
}
|
||||
|
||||
func cleanup() {
|
||||
defaultAuth = nil
|
||||
}
|
||||
|
||||
func TestOIDCLoginHandler(t *testing.T) {
|
||||
// Setup
|
||||
common.APIJWTSecret = []byte("test-secret")
|
||||
t.Cleanup(cleanup)
|
||||
setupMockOIDC(t)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
wantStatus int
|
||||
wantRedirect bool
|
||||
}{
|
||||
{
|
||||
name: "Success - Redirects to provider",
|
||||
wantStatus: http.StatusTemporaryRedirect,
|
||||
wantRedirect: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/auth/redirect", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
defaultAuth.RedirectLoginPage(w, req)
|
||||
|
||||
if got := w.Code; got != tt.wantStatus {
|
||||
t.Errorf("OIDCLoginHandler() status = %v, want %v", got, tt.wantStatus)
|
||||
}
|
||||
|
||||
if tt.wantRedirect {
|
||||
if loc := w.Header().Get("Location"); loc == "" {
|
||||
t.Error("OIDCLoginHandler() missing redirect location")
|
||||
}
|
||||
|
||||
cookie := w.Header().Get("Set-Cookie")
|
||||
if cookie == "" {
|
||||
t.Error("OIDCLoginHandler() missing state cookie")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOIDCCallbackHandler(t *testing.T) {
|
||||
// Setup
|
||||
common.APIJWTSecret = []byte("test-secret")
|
||||
t.Cleanup(cleanup)
|
||||
tests := []struct {
|
||||
name string
|
||||
state string
|
||||
code string
|
||||
setupMocks bool
|
||||
wantStatus int
|
||||
}{
|
||||
{
|
||||
name: "Success - Valid callback",
|
||||
state: "valid-state",
|
||||
code: "valid-code",
|
||||
setupMocks: true,
|
||||
wantStatus: http.StatusTemporaryRedirect,
|
||||
},
|
||||
{
|
||||
name: "Failure - Missing state",
|
||||
code: "valid-code",
|
||||
setupMocks: true,
|
||||
wantStatus: http.StatusBadRequest,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.setupMocks {
|
||||
setupMockOIDC(t)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/auth/callback?code="+tt.code+"&state="+tt.state, nil)
|
||||
if tt.state != "" {
|
||||
req.AddCookie(&http.Cookie{
|
||||
Name: CookieOauthState,
|
||||
Value: tt.state,
|
||||
})
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
defaultAuth.LoginCallbackHandler(w, req)
|
||||
|
||||
if got := w.Code; got != tt.wantStatus {
|
||||
t.Errorf("OIDCCallbackHandler() status = %v, want %v", got, tt.wantStatus)
|
||||
}
|
||||
|
||||
if tt.wantStatus == http.StatusTemporaryRedirect {
|
||||
setCookie := Must(http.ParseSetCookie(w.Header().Get("Set-Cookie")))
|
||||
ExpectEqual(t, setCookie.Name, defaultAuth.TokenCookieName())
|
||||
ExpectTrue(t, setCookie.Value != "")
|
||||
ExpectEqual(t, setCookie.Path, "/")
|
||||
ExpectEqual(t, setCookie.SameSite, http.SameSiteLaxMode)
|
||||
ExpectEqual(t, setCookie.HttpOnly, true)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitOIDC(t *testing.T) {
|
||||
setupMockOIDC(t)
|
||||
// Create a test server that serves the discovery document
|
||||
var server *httptest.Server
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
ExpectNoError(t, json.NewEncoder(w).Encode(discoveryDocument(t, server)))
|
||||
})
|
||||
server = httptest.NewServer(mux)
|
||||
t.Cleanup(server.Close)
|
||||
t.Cleanup(cleanup)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
issuerURL string
|
||||
clientID string
|
||||
clientSecret string
|
||||
redirectURL string
|
||||
logoutURL string
|
||||
allowedUsers []string
|
||||
allowedGroups []string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "Fail - Empty configuration",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "Success - Valid configuration with users",
|
||||
issuerURL: server.URL,
|
||||
clientID: "client_id",
|
||||
clientSecret: "client_secret",
|
||||
redirectURL: "https://example.com/callback",
|
||||
allowedUsers: []string{"user1", "user2"},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "Success - Valid configuration with groups",
|
||||
issuerURL: server.URL,
|
||||
clientID: "client_id",
|
||||
clientSecret: "client_secret",
|
||||
redirectURL: "https://example.com/callback",
|
||||
allowedGroups: []string{"group1", "group2"},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "Success - Valid configuration with users, groups and logout URL",
|
||||
issuerURL: server.URL,
|
||||
clientID: "client_id",
|
||||
clientSecret: "client_secret",
|
||||
redirectURL: "https://example.com/callback",
|
||||
logoutURL: "https://example.com/logout",
|
||||
allowedUsers: []string{"user1", "user2"},
|
||||
allowedGroups: []string{"group1", "group2"},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "Fail - No allowed users or allowed groups",
|
||||
issuerURL: "https://example.com",
|
||||
clientID: "client_id",
|
||||
clientSecret: "client_secret",
|
||||
redirectURL: "https://example.com/callback",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := NewOIDCProvider(tt.issuerURL, tt.clientID, tt.clientSecret, tt.redirectURL, tt.allowedUsers, tt.allowedGroups)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("InitOIDC() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckToken(t *testing.T) {
|
||||
provider := setupProvider(t)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
allowedUsers []string
|
||||
allowedGroups []string
|
||||
claims jwt.Claims
|
||||
wantErr error
|
||||
}{
|
||||
{
|
||||
name: "Success - Valid token with allowed user",
|
||||
allowedUsers: []string{"user1"},
|
||||
claims: jwt.MapClaims{
|
||||
"iss": provider.ts.URL,
|
||||
"aud": clientID,
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
"preferred_username": "user1",
|
||||
"groups": []string{"group1"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Success - Valid token with allowed group",
|
||||
allowedGroups: []string{"group1"},
|
||||
claims: jwt.MapClaims{
|
||||
"iss": provider.ts.URL,
|
||||
"aud": clientID,
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
"preferred_username": "user1",
|
||||
"groups": []string{"group1"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Success - Server omits groups, but user is allowed",
|
||||
allowedUsers: []string{"user1"},
|
||||
claims: jwt.MapClaims{
|
||||
"iss": provider.ts.URL,
|
||||
"aud": clientID,
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
"preferred_username": "user1",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Success - Server omits preferred_username, but group is allowed",
|
||||
allowedGroups: []string{"group1"},
|
||||
claims: jwt.MapClaims{
|
||||
"iss": provider.ts.URL,
|
||||
"aud": clientID,
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
"groups": []string{"group1"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Success - Valid token with allowed user and group",
|
||||
allowedUsers: []string{"user1"},
|
||||
allowedGroups: []string{"group1"},
|
||||
claims: jwt.MapClaims{
|
||||
"iss": provider.ts.URL,
|
||||
"aud": clientID,
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
"preferred_username": "user1",
|
||||
"groups": []string{"group1"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Error - User not allowed",
|
||||
allowedUsers: []string{"user2", "user3"},
|
||||
allowedGroups: []string{"group2", "group3"},
|
||||
claims: jwt.MapClaims{
|
||||
"iss": provider.ts.URL,
|
||||
"aud": clientID,
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
"preferred_username": "user1",
|
||||
"groups": []string{"group1"},
|
||||
},
|
||||
wantErr: ErrUserNotAllowed,
|
||||
},
|
||||
{
|
||||
name: "Error - Server returns incorrect issuer",
|
||||
claims: jwt.MapClaims{
|
||||
"iss": "https://example.com",
|
||||
"aud": clientID,
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
"preferred_username": "user1",
|
||||
"groups": []string{"group1"},
|
||||
},
|
||||
wantErr: ErrInvalidToken,
|
||||
},
|
||||
{
|
||||
name: "Error - Server returns incorrect audience",
|
||||
claims: jwt.MapClaims{
|
||||
"iss": provider.ts.URL,
|
||||
"aud": "some-other-audience",
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
"preferred_username": "user1",
|
||||
"groups": []string{"group1"},
|
||||
},
|
||||
wantErr: ErrInvalidToken,
|
||||
},
|
||||
{
|
||||
name: "Error - Server returns expired token",
|
||||
claims: jwt.MapClaims{
|
||||
"iss": provider.ts.URL,
|
||||
"aud": clientID,
|
||||
"exp": time.Now().Add(-time.Hour).Unix(),
|
||||
"preferred_username": "user1",
|
||||
"groups": []string{"group1"},
|
||||
},
|
||||
wantErr: ErrInvalidToken,
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// Create the Auth Provider.
|
||||
auth := &OIDCProvider{
|
||||
oidcVerifier: provider.verifier,
|
||||
allowedUsers: tc.allowedUsers,
|
||||
allowedGroups: tc.allowedGroups,
|
||||
}
|
||||
// Sign the claims to create a token.
|
||||
signedToken := provider.SignClaims(t, tc.claims)
|
||||
// Craft a test HTTP request that includes the token as a cookie.
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.AddCookie(&http.Cookie{
|
||||
Name: auth.TokenCookieName(),
|
||||
Value: signedToken,
|
||||
})
|
||||
|
||||
// Call CheckToken and verify the result.
|
||||
err := auth.CheckToken(req)
|
||||
if tc.wantErr == nil {
|
||||
ExpectNoError(t, err)
|
||||
} else {
|
||||
ExpectError(t, tc.wantErr, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
13
internal/api/v1/auth/provider.go
Normal file
13
internal/api/v1/auth/provider.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type Provider interface {
|
||||
TokenCookieName() string
|
||||
CheckToken(r *http.Request) error
|
||||
RedirectLoginPage(w http.ResponseWriter, r *http.Request)
|
||||
LoginCallbackHandler(w http.ResponseWriter, r *http.Request)
|
||||
LogoutCallbackHandler(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
142
internal/api/v1/auth/userpass.go
Normal file
142
internal/api/v1/auth/userpass.go
Normal file
@@ -0,0 +1,142 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/yusing/go-proxy/internal/common"
|
||||
"github.com/yusing/go-proxy/internal/gperr"
|
||||
"github.com/yusing/go-proxy/internal/net/gphttp"
|
||||
"github.com/yusing/go-proxy/internal/utils/strutils"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidUsername = gperr.New("invalid username")
|
||||
ErrInvalidPassword = gperr.New("invalid password")
|
||||
)
|
||||
|
||||
type (
|
||||
UserPassAuth struct {
|
||||
username string
|
||||
pwdHash []byte
|
||||
secret []byte
|
||||
tokenTTL time.Duration
|
||||
}
|
||||
UserPassClaims struct {
|
||||
Username string `json:"username"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
)
|
||||
|
||||
func NewUserPassAuth(username, password string, secret []byte, tokenTTL time.Duration) (*UserPassAuth, error) {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &UserPassAuth{
|
||||
username: username,
|
||||
pwdHash: hash,
|
||||
secret: secret,
|
||||
tokenTTL: tokenTTL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func NewUserPassAuthFromEnv() (*UserPassAuth, error) {
|
||||
return NewUserPassAuth(
|
||||
common.APIUser,
|
||||
common.APIPassword,
|
||||
common.APIJWTSecret,
|
||||
common.APIJWTTokenTTL,
|
||||
)
|
||||
}
|
||||
|
||||
func (auth *UserPassAuth) TokenCookieName() string {
|
||||
return "godoxy_token"
|
||||
}
|
||||
|
||||
func (auth *UserPassAuth) NewToken() (token string, err error) {
|
||||
claim := &UserPassClaims{
|
||||
Username: auth.username,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(auth.tokenTTL)),
|
||||
},
|
||||
}
|
||||
tok := jwt.NewWithClaims(jwt.SigningMethodHS512, claim)
|
||||
token, err = tok.SignedString(auth.secret)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func (auth *UserPassAuth) CheckToken(r *http.Request) error {
|
||||
jwtCookie, err := r.Cookie(auth.TokenCookieName())
|
||||
if err != nil {
|
||||
return ErrMissingToken
|
||||
}
|
||||
var claims UserPassClaims
|
||||
token, err := jwt.ParseWithClaims(jwtCookie.Value, &claims, func(t *jwt.Token) (interface{}, error) {
|
||||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
|
||||
}
|
||||
return auth.secret, nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch {
|
||||
case !token.Valid:
|
||||
return ErrInvalidToken
|
||||
case claims.Username != auth.username:
|
||||
return ErrUserNotAllowed.Subject(claims.Username)
|
||||
case claims.ExpiresAt.Before(time.Now()):
|
||||
return gperr.Errorf("token expired on %s", strutils.FormatTime(claims.ExpiresAt.Time))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (auth *UserPassAuth) RedirectLoginPage(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/login", http.StatusTemporaryRedirect)
|
||||
}
|
||||
|
||||
func (auth *UserPassAuth) LoginCallbackHandler(w http.ResponseWriter, r *http.Request) {
|
||||
var creds struct {
|
||||
User string `json:"username"`
|
||||
Pass string `json:"password"`
|
||||
}
|
||||
err := json.NewDecoder(r.Body).Decode(&creds)
|
||||
if err != nil {
|
||||
gphttp.Unauthorized(w, "invalid credentials")
|
||||
return
|
||||
}
|
||||
if err := auth.validatePassword(creds.User, creds.Pass); err != nil {
|
||||
gphttp.Unauthorized(w, "invalid credentials")
|
||||
return
|
||||
}
|
||||
token, err := auth.NewToken()
|
||||
if err != nil {
|
||||
gphttp.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
setTokenCookie(w, r, auth.TokenCookieName(), token, auth.tokenTTL)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func (auth *UserPassAuth) LogoutCallbackHandler(w http.ResponseWriter, r *http.Request) {
|
||||
DefaultLogoutCallbackHandler(auth, w, r)
|
||||
}
|
||||
|
||||
func (auth *UserPassAuth) validatePassword(user, pass string) error {
|
||||
if user != auth.username {
|
||||
return ErrInvalidUsername.Subject(user)
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword(auth.pwdHash, []byte(pass)); err != nil {
|
||||
return ErrInvalidPassword.With(err).Subject(pass)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
115
internal/api/v1/auth/userpass_test.go
Normal file
115
internal/api/v1/auth/userpass_test.go
Normal file
@@ -0,0 +1,115 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
. "github.com/yusing/go-proxy/internal/utils/testing"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func newMockUserPassAuth() *UserPassAuth {
|
||||
return &UserPassAuth{
|
||||
username: "username",
|
||||
pwdHash: Must(bcrypt.GenerateFromPassword([]byte("password"), bcrypt.DefaultCost)),
|
||||
secret: []byte("abcdefghijklmnopqrstuvwxyz"),
|
||||
tokenTTL: time.Hour,
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserPassValidateCredentials(t *testing.T) {
|
||||
auth := newMockUserPassAuth()
|
||||
err := auth.validatePassword("username", "password")
|
||||
ExpectNoError(t, err)
|
||||
err = auth.validatePassword("username", "wrong-password")
|
||||
ExpectError(t, ErrInvalidPassword, err)
|
||||
err = auth.validatePassword("wrong-username", "password")
|
||||
ExpectError(t, ErrInvalidUsername, err)
|
||||
}
|
||||
|
||||
func TestUserPassCheckToken(t *testing.T) {
|
||||
auth := newMockUserPassAuth()
|
||||
token, err := auth.NewToken()
|
||||
ExpectNoError(t, err)
|
||||
tests := []struct {
|
||||
token string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
token: token,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
token: "invalid-token",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
token: "",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
req := &http.Request{Header: http.Header{}}
|
||||
if tt.token != "" {
|
||||
req.Header.Set("Cookie", auth.TokenCookieName()+"="+tt.token)
|
||||
}
|
||||
err = auth.CheckToken(req)
|
||||
if tt.wantErr {
|
||||
ExpectTrue(t, err != nil)
|
||||
} else {
|
||||
ExpectNoError(t, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserPassLoginCallbackHandler(t *testing.T) {
|
||||
type cred struct {
|
||||
User string `json:"username"`
|
||||
Pass string `json:"password"`
|
||||
}
|
||||
auth := newMockUserPassAuth()
|
||||
tests := []struct {
|
||||
creds cred
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
creds: cred{
|
||||
User: "username",
|
||||
Pass: "password",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
creds: cred{
|
||||
User: "username",
|
||||
Pass: "wrong-password",
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
w := httptest.NewRecorder()
|
||||
req := &http.Request{
|
||||
Host: "app.example.com",
|
||||
Body: io.NopCloser(bytes.NewReader(Must(json.Marshal(tt.creds)))),
|
||||
}
|
||||
auth.LoginCallbackHandler(w, req)
|
||||
if tt.wantErr {
|
||||
ExpectEqual(t, w.Code, http.StatusUnauthorized)
|
||||
} else {
|
||||
setCookie := Must(http.ParseSetCookie(w.Header().Get("Set-Cookie")))
|
||||
ExpectTrue(t, setCookie.Name == auth.TokenCookieName())
|
||||
ExpectTrue(t, setCookie.Value != "")
|
||||
ExpectEqual(t, setCookie.Domain, "example.com")
|
||||
ExpectEqual(t, setCookie.Path, "/")
|
||||
ExpectEqual(t, setCookie.SameSite, http.SameSiteLaxMode)
|
||||
ExpectEqual(t, setCookie.HttpOnly, true)
|
||||
ExpectEqual(t, w.Code, http.StatusOK)
|
||||
}
|
||||
}
|
||||
}
|
||||
70
internal/api/v1/auth/utils.go
Normal file
70
internal/api/v1/auth/utils.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/yusing/go-proxy/internal/common"
|
||||
"github.com/yusing/go-proxy/internal/gperr"
|
||||
"github.com/yusing/go-proxy/internal/utils/strutils"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrMissingToken = gperr.New("missing token")
|
||||
ErrInvalidToken = gperr.New("invalid token")
|
||||
ErrUserNotAllowed = gperr.New("user not allowed")
|
||||
)
|
||||
|
||||
// cookieFQDN returns the fully qualified domain name of the request host
|
||||
// with subdomain stripped.
|
||||
//
|
||||
// If the request host does not have a subdomain,
|
||||
// an empty string is returned
|
||||
//
|
||||
// "abc.example.com" -> "example.com"
|
||||
// "example.com" -> ""
|
||||
func cookieFQDN(r *http.Request) string {
|
||||
host, _, err := net.SplitHostPort(r.Host)
|
||||
if err != nil {
|
||||
host = r.Host
|
||||
}
|
||||
parts := strutils.SplitRune(host, '.')
|
||||
if len(parts) < 2 {
|
||||
return ""
|
||||
}
|
||||
parts[0] = ""
|
||||
return strutils.JoinRune(parts, '.')
|
||||
}
|
||||
|
||||
func setTokenCookie(w http.ResponseWriter, r *http.Request, name, value string, ttl time.Duration) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: name,
|
||||
Value: value,
|
||||
MaxAge: int(ttl.Seconds()),
|
||||
Domain: cookieFQDN(r),
|
||||
HttpOnly: true,
|
||||
Secure: common.APIJWTSecure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Path: "/",
|
||||
})
|
||||
}
|
||||
|
||||
func clearTokenCookie(w http.ResponseWriter, r *http.Request, name string) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: name,
|
||||
Value: "",
|
||||
MaxAge: -1,
|
||||
Domain: cookieFQDN(r),
|
||||
HttpOnly: true,
|
||||
Secure: common.APIJWTSecure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Path: "/",
|
||||
})
|
||||
}
|
||||
|
||||
// DefaultLogoutCallbackHandler clears the token cookie and redirects to the login page..
|
||||
func DefaultLogoutCallbackHandler(auth Provider, w http.ResponseWriter, r *http.Request) {
|
||||
clearTokenCookie(w, r, auth.TokenCookieName())
|
||||
auth.RedirectLoginPage(w, r)
|
||||
}
|
||||
41
internal/api/v1/certapi/cert_info.go
Normal file
41
internal/api/v1/certapi/cert_info.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package certapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
config "github.com/yusing/go-proxy/internal/config/types"
|
||||
)
|
||||
|
||||
type CertInfo struct {
|
||||
Subject string `json:"subject"`
|
||||
Issuer string `json:"issuer"`
|
||||
NotBefore int64 `json:"not_before"`
|
||||
NotAfter int64 `json:"not_after"`
|
||||
DNSNames []string `json:"dns_names"`
|
||||
EmailAddresses []string `json:"email_addresses"`
|
||||
}
|
||||
|
||||
func GetCertInfo(w http.ResponseWriter, r *http.Request) {
|
||||
autocert := config.GetInstance().AutoCertProvider()
|
||||
if autocert == nil {
|
||||
http.Error(w, "autocert is not enabled", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
cert, err := autocert.GetCert(nil)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
certInfo := CertInfo{
|
||||
Subject: cert.Leaf.Subject.CommonName,
|
||||
Issuer: cert.Leaf.Issuer.CommonName,
|
||||
NotBefore: cert.Leaf.NotBefore.Unix(),
|
||||
NotAfter: cert.Leaf.NotAfter.Unix(),
|
||||
DNSNames: cert.Leaf.DNSNames,
|
||||
EmailAddresses: cert.Leaf.EmailAddresses,
|
||||
}
|
||||
json.NewEncoder(w).Encode(&certInfo)
|
||||
}
|
||||
56
internal/api/v1/certapi/renew.go
Normal file
56
internal/api/v1/certapi/renew.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package certapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
config "github.com/yusing/go-proxy/internal/config/types"
|
||||
"github.com/yusing/go-proxy/internal/gperr"
|
||||
"github.com/yusing/go-proxy/internal/logging"
|
||||
"github.com/yusing/go-proxy/internal/logging/memlogger"
|
||||
"github.com/yusing/go-proxy/internal/net/gphttp/gpwebsocket"
|
||||
)
|
||||
|
||||
func RenewCert(w http.ResponseWriter, r *http.Request) {
|
||||
autocert := config.GetInstance().AutoCertProvider()
|
||||
if autocert == nil {
|
||||
http.Error(w, "autocert is not enabled", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
conn, err := gpwebsocket.Initiate(w, r)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
//nolint:errcheck
|
||||
defer conn.CloseNow()
|
||||
|
||||
logs, cancel := memlogger.Events()
|
||||
defer cancel()
|
||||
|
||||
done := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
defer close(done)
|
||||
err = autocert.ObtainCert()
|
||||
if err != nil {
|
||||
gperr.LogError("failed to obtain cert", err)
|
||||
gpwebsocket.WriteText(r, conn, err.Error())
|
||||
} else {
|
||||
logging.Info().Msg("cert obtained successfully")
|
||||
}
|
||||
}()
|
||||
for {
|
||||
select {
|
||||
case l := <-logs:
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if !gpwebsocket.WriteText(r, conn, string(l)) {
|
||||
return
|
||||
}
|
||||
case <-done:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
132
internal/api/v1/config_file.go
Normal file
132
internal/api/v1/config_file.go
Normal file
@@ -0,0 +1,132 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/yusing/go-proxy/internal/common"
|
||||
config "github.com/yusing/go-proxy/internal/config/types"
|
||||
"github.com/yusing/go-proxy/internal/gperr"
|
||||
"github.com/yusing/go-proxy/internal/net/gphttp"
|
||||
"github.com/yusing/go-proxy/internal/net/gphttp/middleware"
|
||||
"github.com/yusing/go-proxy/internal/route/provider"
|
||||
)
|
||||
|
||||
type FileType string
|
||||
|
||||
const (
|
||||
FileTypeConfig FileType = "config"
|
||||
FileTypeProvider FileType = "provider"
|
||||
FileTypeMiddleware FileType = "middleware"
|
||||
)
|
||||
|
||||
func fileType(file string) FileType {
|
||||
switch {
|
||||
case strings.HasPrefix(path.Base(file), "config."):
|
||||
return FileTypeConfig
|
||||
case strings.HasPrefix(file, common.MiddlewareComposeBasePath):
|
||||
return FileTypeMiddleware
|
||||
}
|
||||
return FileTypeProvider
|
||||
}
|
||||
|
||||
func (t FileType) IsValid() bool {
|
||||
switch t {
|
||||
case FileTypeConfig, FileTypeProvider, FileTypeMiddleware:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (t FileType) GetPath(filename string) string {
|
||||
if t == FileTypeMiddleware {
|
||||
return path.Join(common.MiddlewareComposeBasePath, filename)
|
||||
}
|
||||
return path.Join(common.ConfigBasePath, filename)
|
||||
}
|
||||
|
||||
func getArgs(r *http.Request) (fileType FileType, filename string, err error) {
|
||||
fileType = FileType(r.PathValue("type"))
|
||||
if !fileType.IsValid() {
|
||||
err = gphttp.ErrInvalidKey("type")
|
||||
return
|
||||
}
|
||||
filename = r.PathValue("filename")
|
||||
if filename == "" {
|
||||
err = gphttp.ErrMissingKey("filename")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func GetFileContent(w http.ResponseWriter, r *http.Request) {
|
||||
fileType, filename, err := getArgs(r)
|
||||
if err != nil {
|
||||
gphttp.BadRequest(w, err.Error())
|
||||
return
|
||||
}
|
||||
content, err := os.ReadFile(fileType.GetPath(filename))
|
||||
if err != nil {
|
||||
gphttp.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
gphttp.WriteBody(w, content)
|
||||
}
|
||||
|
||||
func validateFile(fileType FileType, content []byte) gperr.Error {
|
||||
switch fileType {
|
||||
case FileTypeConfig:
|
||||
return config.Validate(content)
|
||||
case FileTypeMiddleware:
|
||||
errs := gperr.NewBuilder("middleware errors")
|
||||
middleware.BuildMiddlewaresFromYAML("", content, errs)
|
||||
return errs.Error()
|
||||
}
|
||||
return provider.Validate(content)
|
||||
}
|
||||
|
||||
func ValidateFile(w http.ResponseWriter, r *http.Request) {
|
||||
fileType := FileType(r.PathValue("type"))
|
||||
if !fileType.IsValid() {
|
||||
gphttp.BadRequest(w, "invalid file type")
|
||||
return
|
||||
}
|
||||
content, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
gphttp.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
r.Body.Close()
|
||||
if valErr := validateFile(fileType, content); valErr != nil {
|
||||
gphttp.JSONError(w, valErr, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func SetFileContent(w http.ResponseWriter, r *http.Request) {
|
||||
fileType, filename, err := getArgs(r)
|
||||
if err != nil {
|
||||
gphttp.BadRequest(w, err.Error())
|
||||
return
|
||||
}
|
||||
content, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
gphttp.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if valErr := validateFile(fileType, content); valErr != nil {
|
||||
gphttp.JSONError(w, valErr, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
err = os.WriteFile(fileType.GetPath(filename), content, 0o644)
|
||||
if err != nil {
|
||||
gphttp.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
5
internal/api/v1/dockerapi/common.go
Normal file
5
internal/api/v1/dockerapi/common.go
Normal file
@@ -0,0 +1,5 @@
|
||||
package dockerapi
|
||||
|
||||
import "time"
|
||||
|
||||
const reqTimeout = 10 * time.Second
|
||||
54
internal/api/v1/dockerapi/containers.go
Normal file
54
internal/api/v1/dockerapi/containers.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package dockerapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"sort"
|
||||
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/yusing/go-proxy/internal/gperr"
|
||||
)
|
||||
|
||||
type Container struct {
|
||||
Server string `json:"server"`
|
||||
Name string `json:"name"`
|
||||
ID string `json:"id"`
|
||||
Image string `json:"image"`
|
||||
State string `json:"state"`
|
||||
}
|
||||
|
||||
func Containers(w http.ResponseWriter, r *http.Request) {
|
||||
serveHTTP[Container, []Container](w, r, GetContainers)
|
||||
}
|
||||
|
||||
func GetContainers(ctx context.Context, dockerClients DockerClients) ([]Container, gperr.Error) {
|
||||
errs := gperr.NewBuilder("failed to get containers")
|
||||
containers := make([]Container, 0)
|
||||
for server, dockerClient := range dockerClients {
|
||||
conts, err := dockerClient.ContainerList(ctx, container.ListOptions{All: true})
|
||||
if err != nil {
|
||||
errs.Add(err)
|
||||
continue
|
||||
}
|
||||
for _, cont := range conts {
|
||||
containers = append(containers, Container{
|
||||
Server: server,
|
||||
Name: cont.Names[0],
|
||||
ID: cont.ID,
|
||||
Image: cont.Image,
|
||||
State: cont.State,
|
||||
})
|
||||
}
|
||||
}
|
||||
sort.Slice(containers, func(i, j int) bool {
|
||||
return containers[i].Name < containers[j].Name
|
||||
})
|
||||
if err := errs.Error(); err != nil {
|
||||
gperr.LogError("failed to get containers", err)
|
||||
if len(containers) == 0 {
|
||||
return nil, err
|
||||
}
|
||||
return containers, nil
|
||||
}
|
||||
return containers, nil
|
||||
}
|
||||
56
internal/api/v1/dockerapi/info.go
Normal file
56
internal/api/v1/dockerapi/info.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package dockerapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"sort"
|
||||
|
||||
dockerSystem "github.com/docker/docker/api/types/system"
|
||||
"github.com/yusing/go-proxy/internal/gperr"
|
||||
"github.com/yusing/go-proxy/internal/utils/strutils"
|
||||
)
|
||||
|
||||
type dockerInfo dockerSystem.Info
|
||||
|
||||
func (d *dockerInfo) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(map[string]any{
|
||||
"name": d.Name,
|
||||
"version": d.ServerVersion,
|
||||
"containers": map[string]int{
|
||||
"total": d.Containers,
|
||||
"running": d.ContainersRunning,
|
||||
"paused": d.ContainersPaused,
|
||||
"stopped": d.ContainersStopped,
|
||||
},
|
||||
"images": d.Images,
|
||||
"n_cpu": d.NCPU,
|
||||
"memory": strutils.FormatByteSizeWithUnit(d.MemTotal),
|
||||
})
|
||||
}
|
||||
|
||||
func DockerInfo(w http.ResponseWriter, r *http.Request) {
|
||||
serveHTTP[dockerInfo](w, r, GetDockerInfo)
|
||||
}
|
||||
|
||||
func GetDockerInfo(ctx context.Context, dockerClients DockerClients) ([]dockerInfo, gperr.Error) {
|
||||
errs := gperr.NewBuilder("failed to get docker info")
|
||||
dockerInfos := make([]dockerInfo, len(dockerClients))
|
||||
|
||||
i := 0
|
||||
for name, dockerClient := range dockerClients {
|
||||
info, err := dockerClient.Info(ctx)
|
||||
if err != nil {
|
||||
errs.Add(err)
|
||||
continue
|
||||
}
|
||||
info.Name = name
|
||||
dockerInfos[i] = dockerInfo(info)
|
||||
i++
|
||||
}
|
||||
|
||||
sort.Slice(dockerInfos, func(i, j int) bool {
|
||||
return dockerInfos[i].Name < dockerInfos[j].Name
|
||||
})
|
||||
return dockerInfos, errs.Error()
|
||||
}
|
||||
69
internal/api/v1/dockerapi/logs.go
Normal file
69
internal/api/v1/dockerapi/logs.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package dockerapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/pkg/stdcopy"
|
||||
"github.com/yusing/go-proxy/internal/logging"
|
||||
"github.com/yusing/go-proxy/internal/net/gphttp"
|
||||
"github.com/yusing/go-proxy/internal/net/gphttp/gpwebsocket"
|
||||
"github.com/yusing/go-proxy/internal/utils/strutils"
|
||||
)
|
||||
|
||||
func Logs(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query()
|
||||
server := r.PathValue("server")
|
||||
containerID := r.PathValue("container")
|
||||
stdout := strutils.ParseBool(query.Get("stdout"))
|
||||
stderr := strutils.ParseBool(query.Get("stderr"))
|
||||
since := query.Get("from")
|
||||
until := query.Get("to")
|
||||
levels := query.Get("levels") // TODO: implement levels
|
||||
|
||||
dockerClient, found, err := getDockerClient(w, server)
|
||||
if err != nil {
|
||||
gphttp.BadRequest(w, err.Error())
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
gphttp.NotFound(w, "server not found")
|
||||
return
|
||||
}
|
||||
|
||||
opts := container.LogsOptions{
|
||||
ShowStdout: stdout,
|
||||
ShowStderr: stderr,
|
||||
Since: since,
|
||||
Until: until,
|
||||
Timestamps: true,
|
||||
Follow: true,
|
||||
Tail: "100",
|
||||
}
|
||||
if levels != "" {
|
||||
opts.Details = true
|
||||
}
|
||||
|
||||
logs, err := dockerClient.ContainerLogs(r.Context(), containerID, opts)
|
||||
if err != nil {
|
||||
gphttp.BadRequest(w, err.Error())
|
||||
return
|
||||
}
|
||||
defer logs.Close()
|
||||
|
||||
conn, err := gpwebsocket.Initiate(w, r)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.CloseNow()
|
||||
|
||||
writer := gpwebsocket.NewWriter(r.Context(), conn, websocket.MessageText)
|
||||
_, err = stdcopy.StdCopy(writer, writer, logs) // de-multiplex logs
|
||||
if err != nil {
|
||||
logging.Err(err).
|
||||
Str("server", server).
|
||||
Str("container", containerID).
|
||||
Msg("failed to de-multiplex logs")
|
||||
}
|
||||
}
|
||||
124
internal/api/v1/dockerapi/utils.go
Normal file
124
internal/api/v1/dockerapi/utils.go
Normal file
@@ -0,0 +1,124 @@
|
||||
package dockerapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
"github.com/coder/websocket/wsjson"
|
||||
config "github.com/yusing/go-proxy/internal/config/types"
|
||||
"github.com/yusing/go-proxy/internal/docker"
|
||||
"github.com/yusing/go-proxy/internal/gperr"
|
||||
"github.com/yusing/go-proxy/internal/net/gphttp/gpwebsocket"
|
||||
"github.com/yusing/go-proxy/internal/net/gphttp/httpheaders"
|
||||
)
|
||||
|
||||
type (
|
||||
DockerClients map[string]*docker.SharedClient
|
||||
ResultType[T any] interface {
|
||||
map[string]T | []T
|
||||
}
|
||||
)
|
||||
|
||||
// getDockerClients returns a map of docker clients for the current config.
|
||||
//
|
||||
// Returns a map of docker clients by server name and an error if any.
|
||||
//
|
||||
// Even if there are errors, the map of docker clients might not be empty.
|
||||
func getDockerClients() (DockerClients, gperr.Error) {
|
||||
cfg := config.GetInstance()
|
||||
|
||||
dockerHosts := cfg.Value().Providers.Docker
|
||||
dockerClients := make(DockerClients)
|
||||
|
||||
connErrs := gperr.NewBuilder("failed to connect to docker")
|
||||
|
||||
for name, host := range dockerHosts {
|
||||
dockerClient, err := docker.NewClient(host)
|
||||
if err != nil {
|
||||
connErrs.Add(err)
|
||||
continue
|
||||
}
|
||||
dockerClients[name] = dockerClient
|
||||
}
|
||||
|
||||
for _, agent := range cfg.ListAgents() {
|
||||
dockerClient, err := docker.NewClient(agent.FakeDockerHost())
|
||||
if err != nil {
|
||||
connErrs.Add(err)
|
||||
continue
|
||||
}
|
||||
dockerClients[agent.Name()] = dockerClient
|
||||
}
|
||||
|
||||
return dockerClients, connErrs.Error()
|
||||
}
|
||||
|
||||
func getDockerClient(w http.ResponseWriter, server string) (*docker.SharedClient, bool, error) {
|
||||
cfg := config.GetInstance()
|
||||
var host string
|
||||
for name, h := range cfg.Value().Providers.Docker {
|
||||
if name == server {
|
||||
host = h
|
||||
break
|
||||
}
|
||||
}
|
||||
for _, agent := range cfg.ListAgents() {
|
||||
if agent.Name() == server {
|
||||
host = agent.FakeDockerHost()
|
||||
break
|
||||
}
|
||||
}
|
||||
if host == "" {
|
||||
return nil, false, nil
|
||||
}
|
||||
dockerClient, err := docker.NewClient(host)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return dockerClient, true, nil
|
||||
}
|
||||
|
||||
// closeAllClients closes all docker clients after a delay.
|
||||
//
|
||||
// This is used to ensure that all docker clients are closed after the http handler returns.
|
||||
func closeAllClients(dockerClients DockerClients) {
|
||||
for _, dockerClient := range dockerClients {
|
||||
dockerClient.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func handleResult[V any, T ResultType[V]](w http.ResponseWriter, errs error, result T) {
|
||||
if errs != nil {
|
||||
gperr.LogError("docker errors", errs)
|
||||
if len(result) == 0 {
|
||||
http.Error(w, "docker errors", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
json.NewEncoder(w).Encode(result)
|
||||
}
|
||||
|
||||
func serveHTTP[V any, T ResultType[V]](w http.ResponseWriter, r *http.Request, getResult func(ctx context.Context, dockerClients DockerClients) (T, gperr.Error)) {
|
||||
dockerClients, err := getDockerClients()
|
||||
if err != nil {
|
||||
handleResult[V, T](w, err, nil)
|
||||
return
|
||||
}
|
||||
defer closeAllClients(dockerClients)
|
||||
|
||||
if httpheaders.IsWebsocket(r.Header) {
|
||||
gpwebsocket.Periodic(w, r, 5*time.Second, func(conn *websocket.Conn) error {
|
||||
result, err := getResult(r.Context(), dockerClients)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return wsjson.Write(r.Context(), conn, result)
|
||||
})
|
||||
} else {
|
||||
result, err := getResult(r.Context(), dockerClients)
|
||||
handleResult[V, T](w, err, result)
|
||||
}
|
||||
}
|
||||
138
internal/api/v1/favicon/cache.go
Normal file
138
internal/api/v1/favicon/cache.go
Normal file
@@ -0,0 +1,138 @@
|
||||
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
|
||||
}
|
||||
37
internal/api/v1/favicon/content.go
Normal file
37
internal/api/v1/favicon/content.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package favicon
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type content struct {
|
||||
header http.Header
|
||||
data []byte
|
||||
status int
|
||||
}
|
||||
|
||||
func newContent() *content {
|
||||
return &content{
|
||||
header: make(http.Header),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *content) Header() http.Header {
|
||||
return c.header
|
||||
}
|
||||
|
||||
func (c *content) Write(data []byte) (int, error) {
|
||||
c.data = append(c.data, data...)
|
||||
return len(data), nil
|
||||
}
|
||||
|
||||
func (c *content) WriteHeader(statusCode int) {
|
||||
c.status = statusCode
|
||||
}
|
||||
|
||||
func (c *content) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
||||
return nil, nil, errors.New("not supported")
|
||||
}
|
||||
284
internal/api/v1/favicon/favicon.go
Normal file
284
internal/api/v1/favicon/favicon.go
Normal file
@@ -0,0 +1,284 @@
|
||||
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/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
|
||||
//
|
||||
// Returns:
|
||||
// - 200 OK: if icon found
|
||||
// - 400 Bad Request: if alias is empty or route is not HTTPRoute
|
||||
// - 404 Not Found: if route or icon not found
|
||||
// - 500 Internal Server Error: if internal error
|
||||
// - others: depends on route handler response
|
||||
func GetFavIcon(w http.ResponseWriter, req *http.Request) {
|
||||
url, alias := req.FormValue("url"), req.FormValue("alias")
|
||||
if url == "" && alias == "" {
|
||||
gphttp.ClientError(w, gphttp.ErrMissingKey("url or alias"), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if url != "" && alias != "" {
|
||||
gphttp.ClientError(w, gperr.New("url and alias are mutually exclusive"), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// try with url
|
||||
if url != "" {
|
||||
var iconURL homepage.IconURL
|
||||
if err := iconURL.Parse(url); err != nil {
|
||||
gphttp.ClientError(w, err, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
fetchResult := getFavIconFromURL(&iconURL)
|
||||
if !fetchResult.OK() {
|
||||
http.Error(w, fetchResult.errMsg, fetchResult.statusCode)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", fetchResult.ContentType())
|
||||
gphttp.WriteBody(w, fetchResult.icon)
|
||||
return
|
||||
}
|
||||
|
||||
// try with route.Homepage.Icon
|
||||
r, ok := routes.GetHTTPRoute(alias)
|
||||
if !ok {
|
||||
gphttp.ClientError(w, errors.New("no such route"), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
var result *fetchResult
|
||||
hp := r.HomepageItem()
|
||||
if hp.Icon != nil {
|
||||
if hp.Icon.IconSource == homepage.IconSourceRelative {
|
||||
result = findIcon(r, req, hp.Icon.Value)
|
||||
} else {
|
||||
result = getFavIconFromURL(hp.Icon)
|
||||
}
|
||||
} else {
|
||||
// try extract from "link[rel=icon]"
|
||||
result = findIcon(r, req, "/")
|
||||
}
|
||||
if result.statusCode == 0 {
|
||||
result.statusCode = http.StatusOK
|
||||
}
|
||||
if !result.OK() {
|
||||
http.Error(w, result.errMsg, result.statusCode)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", result.ContentType())
|
||||
gphttp.WriteBody(w, result.icon)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
U "github.com/yusing/go-proxy/internal/api/v1/utils"
|
||||
"github.com/yusing/go-proxy/internal/common"
|
||||
"github.com/yusing/go-proxy/internal/config"
|
||||
E "github.com/yusing/go-proxy/internal/error"
|
||||
"github.com/yusing/go-proxy/internal/net/http/middleware"
|
||||
"github.com/yusing/go-proxy/internal/route/provider"
|
||||
)
|
||||
|
||||
func GetFileContent(w http.ResponseWriter, r *http.Request) {
|
||||
filename := r.PathValue("filename")
|
||||
if filename == "" {
|
||||
filename = common.ConfigFileName
|
||||
}
|
||||
content, err := os.ReadFile(path.Join(common.ConfigBasePath, filename))
|
||||
if err != nil {
|
||||
U.HandleErr(w, r, err)
|
||||
return
|
||||
}
|
||||
U.WriteBody(w, content)
|
||||
}
|
||||
|
||||
func SetFileContent(w http.ResponseWriter, r *http.Request) {
|
||||
filename := r.PathValue("filename")
|
||||
if filename == "" {
|
||||
U.HandleErr(w, r, U.ErrMissingKey("filename"), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
content, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
U.HandleErr(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
var valErr E.Error
|
||||
switch {
|
||||
case filename == common.ConfigFileName:
|
||||
valErr = config.Validate(content)
|
||||
case strings.HasPrefix(filename, path.Base(common.MiddlewareComposeBasePath)):
|
||||
errs := E.NewBuilder("middleware errors")
|
||||
middleware.BuildMiddlewaresFromYAML(filename, content, errs)
|
||||
valErr = errs.Error()
|
||||
default:
|
||||
valErr = provider.Validate(content)
|
||||
}
|
||||
|
||||
if valErr != nil {
|
||||
U.RespondError(w, valErr, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
err = os.WriteFile(path.Join(common.ConfigBasePath, filename), content, 0o644)
|
||||
if err != nil {
|
||||
U.HandleErr(w, r, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
23
internal/api/v1/health.go
Normal file
23
internal/api/v1/health.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
"github.com/coder/websocket/wsjson"
|
||||
"github.com/yusing/go-proxy/internal/net/gphttp"
|
||||
"github.com/yusing/go-proxy/internal/net/gphttp/gpwebsocket"
|
||||
"github.com/yusing/go-proxy/internal/net/gphttp/httpheaders"
|
||||
"github.com/yusing/go-proxy/internal/route/routes/routequery"
|
||||
)
|
||||
|
||||
func Health(w http.ResponseWriter, r *http.Request) {
|
||||
if httpheaders.IsWebsocket(r.Header) {
|
||||
gpwebsocket.Periodic(w, r, 1*time.Second, func(conn *websocket.Conn) error {
|
||||
return wsjson.Write(r.Context(), conn, routequery.HealthMap())
|
||||
})
|
||||
} else {
|
||||
gphttp.RespondJSON(w, r, routequery.HealthMap())
|
||||
}
|
||||
}
|
||||
90
internal/api/v1/homepage_overrides.go
Normal file
90
internal/api/v1/homepage_overrides.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/yusing/go-proxy/internal/homepage"
|
||||
"github.com/yusing/go-proxy/internal/net/gphttp"
|
||||
)
|
||||
|
||||
const (
|
||||
HomepageOverrideItem = "item"
|
||||
HomepageOverrideItemsBatch = "items_batch"
|
||||
HomepageOverrideCategoryOrder = "category_order"
|
||||
HomepageOverrideItemVisible = "item_visible"
|
||||
)
|
||||
|
||||
type (
|
||||
HomepageOverrideItemParams struct {
|
||||
Which string `json:"which"`
|
||||
Value homepage.ItemConfig `json:"value"`
|
||||
}
|
||||
HomepageOverrideItemsBatchParams struct {
|
||||
Value map[string]*homepage.ItemConfig `json:"value"`
|
||||
}
|
||||
HomepageOverrideCategoryOrderParams struct {
|
||||
Which string `json:"which"`
|
||||
Value int `json:"value"`
|
||||
}
|
||||
HomepageOverrideItemVisibleParams struct {
|
||||
Which []string `json:"which"`
|
||||
Value bool `json:"value"`
|
||||
}
|
||||
)
|
||||
|
||||
func SetHomePageOverrides(w http.ResponseWriter, r *http.Request) {
|
||||
what := r.FormValue("what")
|
||||
if what == "" {
|
||||
gphttp.BadRequest(w, "missing what or which")
|
||||
return
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
gphttp.ClientError(w, err, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
r.Body.Close()
|
||||
|
||||
overrides := homepage.GetOverrideConfig()
|
||||
switch what {
|
||||
case HomepageOverrideItem:
|
||||
var params HomepageOverrideItemParams
|
||||
if err := json.Unmarshal(data, ¶ms); err != nil {
|
||||
gphttp.ClientError(w, 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)
|
||||
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)
|
||||
return
|
||||
}
|
||||
if params.Value {
|
||||
overrides.UnhideItems(params.Which)
|
||||
} else {
|
||||
overrides.HideItems(params.Which)
|
||||
}
|
||||
case HomepageOverrideCategoryOrder:
|
||||
var params HomepageOverrideCategoryOrderParams
|
||||
if err := json.Unmarshal(data, ¶ms); err != nil {
|
||||
gphttp.ClientError(w, err, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
overrides.SetCategoryOrder(params.Which, params.Value)
|
||||
default:
|
||||
http.Error(w, "invalid what", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
@@ -3,9 +3,9 @@ package v1
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
. "github.com/yusing/go-proxy/internal/api/v1/utils"
|
||||
"github.com/yusing/go-proxy/internal/net/gphttp"
|
||||
)
|
||||
|
||||
func Index(w http.ResponseWriter, r *http.Request) {
|
||||
WriteBody(w, []byte("API ready"))
|
||||
gphttp.WriteBody(w, []byte("API ready"))
|
||||
}
|
||||
|
||||
@@ -1,30 +1,37 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
U "github.com/yusing/go-proxy/internal/api/v1/utils"
|
||||
"github.com/yusing/go-proxy/internal"
|
||||
"github.com/yusing/go-proxy/internal/common"
|
||||
"github.com/yusing/go-proxy/internal/config"
|
||||
"github.com/yusing/go-proxy/internal/net/http/middleware"
|
||||
"github.com/yusing/go-proxy/internal/route"
|
||||
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"
|
||||
ListConfigFiles = "config_files"
|
||||
ListMiddlewares = "middlewares"
|
||||
ListMiddlewareTraces = "middleware_trace"
|
||||
ListMatchDomains = "match_domains"
|
||||
ListHomepageConfig = "homepage_config"
|
||||
ListTasks = "tasks"
|
||||
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(w http.ResponseWriter, r *http.Request) {
|
||||
func List(cfg config.ConfigInstance, w http.ResponseWriter, r *http.Request) {
|
||||
what := r.PathValue("what")
|
||||
if what == "" {
|
||||
what = ListRoutes
|
||||
@@ -33,36 +40,56 @@ func List(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
switch what {
|
||||
case ListRoute:
|
||||
if route := listRoute(which); route == nil {
|
||||
route := listRoute(which)
|
||||
if route == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
} else {
|
||||
U.RespondJSON(w, r, route)
|
||||
gphttp.RespondJSON(w, r, route)
|
||||
}
|
||||
case ListRoutes:
|
||||
U.RespondJSON(w, r, config.RoutesByAlias(route.RouteType(r.FormValue("type"))))
|
||||
case ListConfigFiles:
|
||||
listConfigFiles(w, r)
|
||||
gphttp.RespondJSON(w, r, routequery.RoutesByAlias(route.RouteType(r.FormValue("type"))))
|
||||
case ListFiles:
|
||||
listFiles(w, r)
|
||||
case ListMiddlewares:
|
||||
U.RespondJSON(w, r, middleware.All())
|
||||
gphttp.RespondJSON(w, r, middleware.All())
|
||||
case ListMiddlewareTraces:
|
||||
U.RespondJSON(w, r, middleware.GetAllTrace())
|
||||
gphttp.RespondJSON(w, r, middleware.GetAllTrace())
|
||||
case ListMatchDomains:
|
||||
U.RespondJSON(w, r, config.Value().MatchDomains)
|
||||
gphttp.RespondJSON(w, r, cfg.Value().MatchDomains)
|
||||
case ListHomepageConfig:
|
||||
U.RespondJSON(w, r, config.HomepageConfig())
|
||||
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:
|
||||
U.RespondJSON(w, r, task.DebugTaskList())
|
||||
gphttp.RespondJSON(w, r, task.DebugTaskList())
|
||||
default:
|
||||
U.HandleErr(w, r, U.ErrInvalidKey("what"), http.StatusBadRequest)
|
||||
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 config.RoutesByAlias()
|
||||
return routequery.RoutesByAlias()
|
||||
}
|
||||
routes := config.RoutesByAlias()
|
||||
routes := routequery.RoutesByAlias()
|
||||
route, ok := routes[which]
|
||||
if !ok {
|
||||
return nil
|
||||
@@ -70,14 +97,32 @@ func listRoute(which string) any {
|
||||
return route
|
||||
}
|
||||
|
||||
func listConfigFiles(w http.ResponseWriter, r *http.Request) {
|
||||
files, err := utils.ListFiles(common.ConfigBasePath, 1)
|
||||
func listFiles(w http.ResponseWriter, r *http.Request) {
|
||||
files, err := utils.ListFiles(common.ConfigBasePath, 0, true)
|
||||
if err != nil {
|
||||
U.HandleErr(w, r, err)
|
||||
gphttp.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
for i := range files {
|
||||
files[i] = strings.TrimPrefix(files[i], common.ConfigBasePath+"/")
|
||||
resp := map[FileType][]string{
|
||||
FileTypeConfig: make([]string, 0),
|
||||
FileTypeProvider: make([]string, 0),
|
||||
FileTypeMiddleware: make([]string, 0),
|
||||
}
|
||||
U.RespondJSON(w, r, files)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
142
internal/api/v1/new_agent.go
Normal file
142
internal/api/v1/new_agent.go
Normal file
@@ -0,0 +1,142 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
_ "embed"
|
||||
|
||||
"github.com/yusing/go-proxy/agent/pkg/agent"
|
||||
"github.com/yusing/go-proxy/agent/pkg/certs"
|
||||
config "github.com/yusing/go-proxy/internal/config/types"
|
||||
"github.com/yusing/go-proxy/internal/net/gphttp"
|
||||
"github.com/yusing/go-proxy/internal/utils/strutils"
|
||||
)
|
||||
|
||||
func NewAgent(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
name := q.Get("name")
|
||||
if name == "" {
|
||||
gphttp.ClientError(w, gphttp.ErrMissingKey("name"))
|
||||
return
|
||||
}
|
||||
host := q.Get("host")
|
||||
if host == "" {
|
||||
gphttp.ClientError(w, gphttp.ErrMissingKey("host"))
|
||||
return
|
||||
}
|
||||
portStr := q.Get("port")
|
||||
if portStr == "" {
|
||||
gphttp.ClientError(w, gphttp.ErrMissingKey("port"))
|
||||
return
|
||||
}
|
||||
port, err := strconv.Atoi(portStr)
|
||||
if err != nil || port < 1 || port > 65535 {
|
||||
gphttp.ClientError(w, gphttp.ErrInvalidKey("port"))
|
||||
return
|
||||
}
|
||||
hostport := fmt.Sprintf("%s:%d", host, port)
|
||||
if _, ok := config.GetInstance().GetAgent(hostport); ok {
|
||||
gphttp.ClientError(w, gphttp.ErrAlreadyExists("agent", hostport), http.StatusConflict)
|
||||
return
|
||||
}
|
||||
t := q.Get("type")
|
||||
switch t {
|
||||
case "docker", "system":
|
||||
break
|
||||
case "":
|
||||
gphttp.ClientError(w, gphttp.ErrMissingKey("type"))
|
||||
return
|
||||
default:
|
||||
gphttp.ClientError(w, gphttp.ErrInvalidKey("type"))
|
||||
return
|
||||
}
|
||||
|
||||
nightly := strutils.ParseBool(q.Get("nightly"))
|
||||
var image string
|
||||
if nightly {
|
||||
image = agent.DockerImageNightly
|
||||
} else {
|
||||
image = agent.DockerImageProduction
|
||||
}
|
||||
|
||||
ca, srv, client, err := agent.NewAgent()
|
||||
if err != nil {
|
||||
gphttp.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
var cfg agent.Generator = &agent.AgentEnvConfig{
|
||||
Name: name,
|
||||
Port: port,
|
||||
CACert: ca.String(),
|
||||
SSLCert: srv.String(),
|
||||
}
|
||||
if t == "docker" {
|
||||
cfg = &agent.AgentComposeConfig{
|
||||
Image: image,
|
||||
AgentEnvConfig: cfg.(*agent.AgentEnvConfig),
|
||||
}
|
||||
}
|
||||
template, err := cfg.Generate()
|
||||
if err != nil {
|
||||
gphttp.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
gphttp.RespondJSON(w, r, map[string]any{
|
||||
"compose": template,
|
||||
"ca": ca,
|
||||
"client": client,
|
||||
})
|
||||
}
|
||||
|
||||
func VerifyNewAgent(w http.ResponseWriter, r *http.Request) {
|
||||
defer r.Body.Close()
|
||||
clientPEMData, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
gphttp.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
var data struct {
|
||||
Host string `json:"host"`
|
||||
CA agent.PEMPair `json:"ca"`
|
||||
Client agent.PEMPair `json:"client"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(clientPEMData, &data); err != nil {
|
||||
gphttp.ClientError(w, err, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
nRoutesAdded, err := config.GetInstance().VerifyNewAgent(data.Host, data.CA, data.Client)
|
||||
if err != nil {
|
||||
gphttp.ClientError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
zip, err := certs.ZipCert(data.CA.Cert, data.Client.Cert, data.Client.Key)
|
||||
if err != nil {
|
||||
gphttp.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
filename, ok := certs.AgentCertsFilepath(data.Host)
|
||||
if !ok {
|
||||
gphttp.ClientError(w, gphttp.ErrInvalidKey("host"))
|
||||
return
|
||||
}
|
||||
|
||||
if err := os.WriteFile(filename, zip, 0600); err != nil {
|
||||
gphttp.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write(fmt.Appendf(nil, "Added %d routes", nRoutesAdded))
|
||||
}
|
||||
@@ -7,20 +7,20 @@ import (
|
||||
"net/http"
|
||||
|
||||
v1 "github.com/yusing/go-proxy/internal/api/v1"
|
||||
U "github.com/yusing/go-proxy/internal/api/v1/utils"
|
||||
"github.com/yusing/go-proxy/internal/common"
|
||||
E "github.com/yusing/go-proxy/internal/error"
|
||||
"github.com/yusing/go-proxy/internal/net/http/middleware"
|
||||
"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() E.Error {
|
||||
resp, err := U.Post(common.APIHTTPURL+"/v1/reload", "", nil)
|
||||
func ReloadServer() gperr.Error {
|
||||
resp, err := gphttp.Post(common.APIHTTPURL+"/v1/reload", "", nil)
|
||||
if err != nil {
|
||||
return E.From(err)
|
||||
return gperr.Wrap(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
failure := E.Errorf("server reload status %v", resp.StatusCode)
|
||||
failure := gperr.Errorf("server reload status %v", resp.StatusCode)
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return failure.With(err)
|
||||
@@ -31,34 +31,34 @@ func ReloadServer() E.Error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func List[T any](what string) (_ T, outErr E.Error) {
|
||||
resp, err := U.Get(fmt.Sprintf("%s/v1/list/%s", common.APIHTTPURL, what))
|
||||
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 = E.From(err)
|
||||
outErr = gperr.Wrap(err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
outErr = E.Errorf("list %s: failed, status %v", what, resp.StatusCode)
|
||||
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 = E.From(err)
|
||||
outErr = gperr.Wrap(err)
|
||||
return
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func ListRoutes() (map[string]map[string]any, E.Error) {
|
||||
func ListRoutes() (map[string]map[string]any, gperr.Error) {
|
||||
return List[map[string]map[string]any](v1.ListRoutes)
|
||||
}
|
||||
|
||||
func ListMiddlewareTraces() (middleware.Traces, E.Error) {
|
||||
func ListMiddlewareTraces() (middleware.Traces, gperr.Error) {
|
||||
return List[middleware.Traces](v1.ListMiddlewareTraces)
|
||||
}
|
||||
|
||||
func DebugListTasks() (map[string]any, E.Error) {
|
||||
func DebugListTasks() (map[string]any, gperr.Error) {
|
||||
return List[map[string]any](v1.ListTasks)
|
||||
}
|
||||
|
||||
@@ -3,14 +3,14 @@ package v1
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
U "github.com/yusing/go-proxy/internal/api/v1/utils"
|
||||
"github.com/yusing/go-proxy/internal/config"
|
||||
config "github.com/yusing/go-proxy/internal/config/types"
|
||||
"github.com/yusing/go-proxy/internal/net/gphttp"
|
||||
)
|
||||
|
||||
func Reload(w http.ResponseWriter, r *http.Request) {
|
||||
if err := config.Reload(); err != nil {
|
||||
U.HandleErr(w, r, err)
|
||||
func Reload(cfg config.ConfigInstance, w http.ResponseWriter, r *http.Request) {
|
||||
if err := cfg.Reload(); err != nil {
|
||||
gphttp.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
U.WriteBody(w, []byte("OK"))
|
||||
gphttp.WriteBody(w, []byte("OK"))
|
||||
}
|
||||
|
||||
@@ -1,70 +1,33 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
"github.com/coder/websocket/wsjson"
|
||||
U "github.com/yusing/go-proxy/internal/api/v1/utils"
|
||||
"github.com/yusing/go-proxy/internal/common"
|
||||
"github.com/yusing/go-proxy/internal/config"
|
||||
config "github.com/yusing/go-proxy/internal/config/types"
|
||||
"github.com/yusing/go-proxy/internal/net/gphttp"
|
||||
"github.com/yusing/go-proxy/internal/net/gphttp/gpwebsocket"
|
||||
"github.com/yusing/go-proxy/internal/net/gphttp/httpheaders"
|
||||
"github.com/yusing/go-proxy/internal/utils/strutils"
|
||||
)
|
||||
|
||||
func Stats(w http.ResponseWriter, r *http.Request) {
|
||||
U.RespondJSON(w, r, getStats())
|
||||
}
|
||||
|
||||
func StatsWS(w http.ResponseWriter, r *http.Request) {
|
||||
var originPats []string
|
||||
|
||||
localAddresses := []string{"127.0.0.1", "10.0.*.*", "172.16.*.*", "192.168.*.*"}
|
||||
|
||||
if len(config.Value().MatchDomains) == 0 {
|
||||
U.LogWarn(r).Msg("no match domains configured, accepting websocket API request from all origins")
|
||||
originPats = []string{"*"}
|
||||
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))
|
||||
})
|
||||
} else {
|
||||
originPats = make([]string, len(config.Value().MatchDomains))
|
||||
for i, domain := range config.Value().MatchDomains {
|
||||
originPats[i] = "*" + domain
|
||||
}
|
||||
originPats = append(originPats, localAddresses...)
|
||||
}
|
||||
if common.IsDebug {
|
||||
originPats = []string{"*"}
|
||||
}
|
||||
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
|
||||
OriginPatterns: originPats,
|
||||
})
|
||||
if err != nil {
|
||||
U.LogError(r).Err(err).Msg("failed to upgrade websocket")
|
||||
return
|
||||
}
|
||||
/* trunk-ignore(golangci-lint/errcheck) */
|
||||
defer conn.CloseNow()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
ticker := time.NewTicker(1 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
stats := getStats()
|
||||
if err := wsjson.Write(ctx, conn, stats); err != nil {
|
||||
U.LogError(r).Msg("failed to write JSON")
|
||||
return
|
||||
}
|
||||
gphttp.RespondJSON(w, r, getStats(cfg))
|
||||
}
|
||||
}
|
||||
|
||||
var startTime = time.Now()
|
||||
|
||||
func getStats() map[string]any {
|
||||
func getStats(cfg config.ConfigInstance) map[string]any {
|
||||
return map[string]any{
|
||||
"proxies": config.Statistics(),
|
||||
"proxies": cfg.Statistics(),
|
||||
"uptime": strutils.FormatDuration(time.Since(startTime)),
|
||||
}
|
||||
}
|
||||
|
||||
53
internal/api/v1/system_info.go
Normal file
53
internal/api/v1/system_info.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
agentPkg "github.com/yusing/go-proxy/agent/pkg/agent"
|
||||
config "github.com/yusing/go-proxy/internal/config/types"
|
||||
"github.com/yusing/go-proxy/internal/gperr"
|
||||
"github.com/yusing/go-proxy/internal/metrics/systeminfo"
|
||||
"github.com/yusing/go-proxy/internal/net/gphttp"
|
||||
"github.com/yusing/go-proxy/internal/net/gphttp/httpheaders"
|
||||
"github.com/yusing/go-proxy/internal/net/gphttp/reverseproxy"
|
||||
)
|
||||
|
||||
func SystemInfo(cfg config.ConfigInstance, w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query()
|
||||
agentAddr := query.Get("agent_addr")
|
||||
query.Del("agent_addr")
|
||||
if agentAddr == "" {
|
||||
systeminfo.Poller.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
agent, ok := cfg.GetAgent(agentAddr)
|
||||
if !ok {
|
||||
gphttp.NotFound(w, "agent_addr")
|
||||
return
|
||||
}
|
||||
|
||||
isWS := httpheaders.IsWebsocket(r.Header)
|
||||
if !isWS {
|
||||
respData, status, err := agent.Forward(r, agentPkg.EndpointSystemInfo)
|
||||
if err != nil {
|
||||
gphttp.ServerError(w, r, gperr.Wrap(err, "failed to forward request to agent"))
|
||||
return
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
http.Error(w, string(respData), status)
|
||||
return
|
||||
}
|
||||
gphttp.WriteBody(w, respData)
|
||||
} else {
|
||||
rp := reverseproxy.NewReverseProxy("agent", agentPkg.AgentURL, agent.Transport())
|
||||
header := r.Header.Clone()
|
||||
r, err := http.NewRequestWithContext(r.Context(), r.Method, agentPkg.EndpointSystemInfo+"?"+query.Encode(), nil)
|
||||
if err != nil {
|
||||
gphttp.ServerError(w, r, gperr.Wrap(err, "failed to create request"))
|
||||
return
|
||||
}
|
||||
r.Header = header
|
||||
rp.ServeHTTP(w, r)
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
E "github.com/yusing/go-proxy/internal/error"
|
||||
"github.com/yusing/go-proxy/internal/utils/strutils/ansi"
|
||||
)
|
||||
|
||||
// HandleErr logs the error and returns an HTTP error response to the client.
|
||||
// If code is specified, it will be used as the HTTP status code; otherwise,
|
||||
// http.StatusInternalServerError is used.
|
||||
//
|
||||
// The error is only logged but not returned to the client.
|
||||
func HandleErr(w http.ResponseWriter, r *http.Request, err error, code ...int) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
LogError(r).Msg(err.Error())
|
||||
if len(code) == 0 {
|
||||
code = []int{http.StatusInternalServerError}
|
||||
}
|
||||
http.Error(w, http.StatusText(code[0]), code[0])
|
||||
}
|
||||
|
||||
func RespondError(w http.ResponseWriter, err error, code ...int) {
|
||||
if len(code) == 0 {
|
||||
code = []int{http.StatusBadRequest}
|
||||
}
|
||||
http.Error(w, ansi.StripANSI(err.Error()), code[0])
|
||||
}
|
||||
|
||||
func ErrMissingKey(k string) error {
|
||||
return E.New("missing key '" + k + "' in query or request body")
|
||||
}
|
||||
|
||||
func ErrInvalidKey(k string) error {
|
||||
return E.New("invalid key '" + k + "' in query or request body")
|
||||
}
|
||||
|
||||
func ErrNotFound(k, v string) error {
|
||||
return E.Errorf("key %q with value %q not found", k, v)
|
||||
}
|
||||
@@ -3,10 +3,10 @@ package v1
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
. "github.com/yusing/go-proxy/internal/api/v1/utils"
|
||||
"github.com/yusing/go-proxy/internal/net/gphttp"
|
||||
"github.com/yusing/go-proxy/pkg"
|
||||
)
|
||||
|
||||
func GetVersion(w http.ResponseWriter, r *http.Request) {
|
||||
WriteBody(w, []byte(pkg.GetVersion()))
|
||||
gphttp.WriteBody(w, []byte(pkg.GetVersion()))
|
||||
}
|
||||
|
||||
@@ -6,84 +6,111 @@ import (
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"os"
|
||||
"regexp"
|
||||
|
||||
"github.com/go-acme/lego/v4/certcrypto"
|
||||
"github.com/go-acme/lego/v4/lego"
|
||||
E "github.com/yusing/go-proxy/internal/error"
|
||||
"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"
|
||||
|
||||
"github.com/yusing/go-proxy/internal/config/types"
|
||||
)
|
||||
|
||||
type Config types.AutoCertConfig
|
||||
type (
|
||||
AutocertConfig struct {
|
||||
Email string `json:"email,omitempty"`
|
||||
Domains []string `json:"domains,omitempty"`
|
||||
CertPath string `json:"cert_path,omitempty"`
|
||||
KeyPath string `json:"key_path,omitempty"`
|
||||
ACMEKeyPath string `json:"acme_key_path,omitempty"`
|
||||
Provider string `json:"provider,omitempty"`
|
||||
Options ProviderOpt `json:"options,omitempty"`
|
||||
}
|
||||
ProviderOpt map[string]any
|
||||
)
|
||||
|
||||
var (
|
||||
ErrMissingDomain = E.New("missing field 'domains'")
|
||||
ErrMissingEmail = E.New("missing field 'email'")
|
||||
ErrMissingProvider = E.New("missing field 'provider'")
|
||||
ErrUnknownProvider = E.New("unknown provider")
|
||||
ErrMissingDomain = gperr.New("missing field 'domains'")
|
||||
ErrMissingEmail = gperr.New("missing field 'email'")
|
||||
ErrMissingProvider = gperr.New("missing field 'provider'")
|
||||
ErrInvalidDomain = gperr.New("invalid domain")
|
||||
ErrUnknownProvider = gperr.New("unknown provider")
|
||||
)
|
||||
|
||||
func NewConfig(cfg *types.AutoCertConfig) *Config {
|
||||
var domainOrWildcardRE = regexp.MustCompile(`^\*?([^.]+\.)+[^.]+$`)
|
||||
|
||||
// Validate implements the utils.CustomValidator interface.
|
||||
func (cfg *AutocertConfig) Validate() gperr.Error {
|
||||
if cfg == nil {
|
||||
cfg = new(types.AutoCertConfig)
|
||||
return nil
|
||||
}
|
||||
|
||||
if cfg.Provider == "" {
|
||||
cfg.Provider = ProviderLocal
|
||||
return nil
|
||||
}
|
||||
|
||||
b := gperr.NewBuilder("autocert errors")
|
||||
if cfg.Provider != ProviderLocal && cfg.Provider != ProviderPseudo {
|
||||
if len(cfg.Domains) == 0 {
|
||||
b.Add(ErrMissingDomain)
|
||||
}
|
||||
if cfg.Email == "" {
|
||||
b.Add(ErrMissingEmail)
|
||||
}
|
||||
for i, d := range cfg.Domains {
|
||||
if !domainOrWildcardRE.MatchString(d) {
|
||||
b.Add(ErrInvalidDomain.Subjectf("domains[%d]", i))
|
||||
}
|
||||
}
|
||||
// check if provider is implemented
|
||||
providerConstructor, ok := providersGenMap[cfg.Provider]
|
||||
if !ok {
|
||||
b.Add(ErrUnknownProvider.
|
||||
Subject(cfg.Provider).
|
||||
Withf(strutils.DoYouMean(utils.NearestField(cfg.Provider, providersGenMap))))
|
||||
} else {
|
||||
_, err := providerConstructor(cfg.Options)
|
||||
if err != nil {
|
||||
b.Add(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return b.Error()
|
||||
}
|
||||
|
||||
func (cfg *AutocertConfig) GetProvider() (*Provider, gperr.Error) {
|
||||
if cfg == nil {
|
||||
cfg = new(AutocertConfig)
|
||||
}
|
||||
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if cfg.CertPath == "" {
|
||||
cfg.CertPath = CertFileDefault
|
||||
}
|
||||
if cfg.KeyPath == "" {
|
||||
cfg.KeyPath = KeyFileDefault
|
||||
}
|
||||
if cfg.Provider == "" {
|
||||
cfg.Provider = ProviderLocal
|
||||
}
|
||||
if cfg.ACMEKeyPath == "" {
|
||||
cfg.ACMEKeyPath = ACMEKeyFileDefault
|
||||
}
|
||||
return (*Config)(cfg)
|
||||
}
|
||||
|
||||
func (cfg *Config) GetProvider() (*Provider, E.Error) {
|
||||
b := E.NewBuilder("autocert errors")
|
||||
|
||||
if cfg.Provider != ProviderLocal {
|
||||
if len(cfg.Domains) == 0 {
|
||||
b.Add(ErrMissingDomain)
|
||||
}
|
||||
if cfg.Provider == "" {
|
||||
b.Add(ErrMissingProvider)
|
||||
}
|
||||
if cfg.Email == "" {
|
||||
b.Add(ErrMissingEmail)
|
||||
}
|
||||
// check if provider is implemented
|
||||
_, ok := providersGenMap[cfg.Provider]
|
||||
if !ok {
|
||||
b.Add(ErrUnknownProvider.
|
||||
Subject(cfg.Provider).
|
||||
Withf(strutils.DoYouMean(utils.NearestField(cfg.Provider, providersGenMap))))
|
||||
}
|
||||
}
|
||||
|
||||
if b.HasError() {
|
||||
return nil, b.Error()
|
||||
}
|
||||
|
||||
var privKey *ecdsa.PrivateKey
|
||||
var err error
|
||||
|
||||
if cfg.Provider != ProviderLocal {
|
||||
if cfg.Provider != ProviderLocal && cfg.Provider != ProviderPseudo {
|
||||
if privKey, err = cfg.loadACMEKey(); err != nil {
|
||||
logging.Info().Err(err).Msg("load ACME private key failed")
|
||||
logging.Info().Msg("generate new ACME private key")
|
||||
privKey, err = ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
return nil, E.New("generate ACME private key").With(err)
|
||||
return nil, gperr.New("generate ACME private key").With(err)
|
||||
}
|
||||
if err = cfg.saveACMEKey(privKey); err != nil {
|
||||
return nil, E.New("save ACME private key").With(err)
|
||||
return nil, gperr.New("save ACME private key").With(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -103,7 +130,7 @@ func (cfg *Config) GetProvider() (*Provider, E.Error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (cfg *Config) loadACMEKey() (*ecdsa.PrivateKey, error) {
|
||||
func (cfg *AutocertConfig) loadACMEKey() (*ecdsa.PrivateKey, error) {
|
||||
data, err := os.ReadFile(cfg.ACMEKeyPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -111,7 +138,7 @@ func (cfg *Config) loadACMEKey() (*ecdsa.PrivateKey, error) {
|
||||
return x509.ParseECPrivateKey(data)
|
||||
}
|
||||
|
||||
func (cfg *Config) saveACMEKey(key *ecdsa.PrivateKey) error {
|
||||
func (cfg *AutocertConfig) saveACMEKey(key *ecdsa.PrivateKey) error {
|
||||
data, err := x509.MarshalECPrivateKey(key)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"github.com/go-acme/lego/v4/providers/dns/cloudflare"
|
||||
"github.com/go-acme/lego/v4/providers/dns/duckdns"
|
||||
"github.com/go-acme/lego/v4/providers/dns/ovh"
|
||||
"github.com/go-acme/lego/v4/providers/dns/porkbun"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -20,6 +21,8 @@ const (
|
||||
ProviderClouddns = "clouddns"
|
||||
ProviderDuckdns = "duckdns"
|
||||
ProviderOVH = "ovh"
|
||||
ProviderPseudo = "pseudo" // for testing
|
||||
ProviderPorkbun = "porkbun"
|
||||
)
|
||||
|
||||
var providersGenMap = map[string]ProviderGenerator{
|
||||
@@ -28,4 +31,6 @@ var providersGenMap = map[string]ProviderGenerator{
|
||||
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),
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
package autocert
|
||||
|
||||
import "github.com/yusing/go-proxy/internal/logging"
|
||||
|
||||
var logger = logging.With().Str("module", "autocert").Logger()
|
||||
@@ -4,18 +4,20 @@ import (
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"reflect"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/go-acme/lego/v4/certificate"
|
||||
"github.com/go-acme/lego/v4/challenge"
|
||||
"github.com/go-acme/lego/v4/lego"
|
||||
"github.com/go-acme/lego/v4/registration"
|
||||
"github.com/yusing/go-proxy/internal/config/types"
|
||||
E "github.com/yusing/go-proxy/internal/error"
|
||||
"github.com/yusing/go-proxy/internal/gperr"
|
||||
"github.com/yusing/go-proxy/internal/logging"
|
||||
"github.com/yusing/go-proxy/internal/task"
|
||||
U "github.com/yusing/go-proxy/internal/utils"
|
||||
"github.com/yusing/go-proxy/internal/utils/strutils"
|
||||
@@ -23,7 +25,7 @@ import (
|
||||
|
||||
type (
|
||||
Provider struct {
|
||||
cfg *Config
|
||||
cfg *AutocertConfig
|
||||
user *User
|
||||
legoCfg *lego.Config
|
||||
client *lego.Client
|
||||
@@ -31,8 +33,10 @@ type (
|
||||
legoCert *certificate.Resource
|
||||
tlsCert *tls.Certificate
|
||||
certExpiries CertExpiries
|
||||
|
||||
obtainMu sync.Mutex
|
||||
}
|
||||
ProviderGenerator func(types.AutocertProviderOpt) (challenge.Provider, E.Error)
|
||||
ProviderGenerator func(ProviderOpt) (challenge.Provider, gperr.Error)
|
||||
|
||||
CertExpiries map[string]time.Time
|
||||
)
|
||||
@@ -62,11 +66,22 @@ func (p *Provider) GetExpiries() CertExpiries {
|
||||
return p.certExpiries
|
||||
}
|
||||
|
||||
func (p *Provider) ObtainCert() E.Error {
|
||||
func (p *Provider) ObtainCert() error {
|
||||
if p.cfg.Provider == ProviderLocal {
|
||||
return nil
|
||||
}
|
||||
|
||||
if p.cfg.Provider == ProviderPseudo {
|
||||
t := time.NewTicker(1000 * time.Millisecond)
|
||||
defer t.Stop()
|
||||
logging.Info().Msg("init client for pseudo provider")
|
||||
<-t.C
|
||||
logging.Info().Msg("registering acme for pseudo provider")
|
||||
<-t.C
|
||||
logging.Info().Msg("obtained cert for pseudo provider")
|
||||
return nil
|
||||
}
|
||||
|
||||
if p.client == nil {
|
||||
if err := p.initClient(); err != nil {
|
||||
return err
|
||||
@@ -75,7 +90,7 @@ func (p *Provider) ObtainCert() E.Error {
|
||||
|
||||
if p.user.Registration == nil {
|
||||
if err := p.registerACME(); err != nil {
|
||||
return E.From(err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,7 +103,7 @@ func (p *Provider) ObtainCert() E.Error {
|
||||
})
|
||||
if err != nil {
|
||||
p.legoCert = nil
|
||||
logger.Err(err).Msg("cert renew failed, fallback to obtain")
|
||||
logging.Err(err).Msg("cert renew failed, fallback to obtain")
|
||||
} else {
|
||||
p.legoCert = cert
|
||||
}
|
||||
@@ -100,22 +115,22 @@ func (p *Provider) ObtainCert() E.Error {
|
||||
Bundle: true,
|
||||
})
|
||||
if err != nil {
|
||||
return E.From(err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err = p.saveCert(cert); err != nil {
|
||||
return E.From(err)
|
||||
return err
|
||||
}
|
||||
|
||||
tlsCert, err := tls.X509KeyPair(cert.Certificate, cert.PrivateKey)
|
||||
if err != nil {
|
||||
return E.From(err)
|
||||
return err
|
||||
}
|
||||
|
||||
expiries, err := getCertExpiries(&tlsCert)
|
||||
if err != nil {
|
||||
return E.From(err)
|
||||
return err
|
||||
}
|
||||
p.tlsCert = &tlsCert
|
||||
p.certExpiries = expiries
|
||||
@@ -123,19 +138,19 @@ func (p *Provider) ObtainCert() E.Error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Provider) LoadCert() E.Error {
|
||||
func (p *Provider) LoadCert() error {
|
||||
cert, err := tls.LoadX509KeyPair(p.cfg.CertPath, p.cfg.KeyPath)
|
||||
if err != nil {
|
||||
return E.Errorf("load SSL certificate: %w", err)
|
||||
return fmt.Errorf("load SSL certificate: %w", err)
|
||||
}
|
||||
expiries, err := getCertExpiries(&cert)
|
||||
if err != nil {
|
||||
return E.Errorf("parse SSL certificate: %w", err)
|
||||
return fmt.Errorf("parse SSL certificate: %w", err)
|
||||
}
|
||||
p.tlsCert = &cert
|
||||
p.certExpiries = expiries
|
||||
|
||||
logger.Info().Msgf("next renewal in %v", strutils.FormatDuration(time.Until(p.ShouldRenewOn())))
|
||||
logging.Info().Msgf("next renewal in %v", strutils.FormatDuration(time.Until(p.ShouldRenewOn())))
|
||||
return p.renewIfNeeded()
|
||||
}
|
||||
|
||||
@@ -148,37 +163,46 @@ func (p *Provider) ShouldRenewOn() time.Time {
|
||||
panic("no certificate available")
|
||||
}
|
||||
|
||||
func (p *Provider) ScheduleRenewal() {
|
||||
if p.GetName() == ProviderLocal {
|
||||
func (p *Provider) ScheduleRenewal(parent task.Parent) {
|
||||
if p.GetName() == ProviderLocal || p.GetName() == ProviderPseudo {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
task := task.RootTask("cert-renew-scheduler", true)
|
||||
lastErrOn := time.Time{}
|
||||
renewalTime := p.ShouldRenewOn()
|
||||
timer := time.NewTimer(time.Until(renewalTime))
|
||||
defer timer.Stop()
|
||||
|
||||
task := parent.Subtask("cert-renew-scheduler")
|
||||
defer task.Finish(nil)
|
||||
|
||||
for {
|
||||
renewalTime := p.ShouldRenewOn()
|
||||
timer := time.NewTimer(time.Until(renewalTime))
|
||||
|
||||
select {
|
||||
case <-task.Context().Done():
|
||||
timer.Stop()
|
||||
return
|
||||
case <-timer.C:
|
||||
if err := p.renewIfNeeded(); err != nil {
|
||||
E.LogWarn("cert renew failed", err, &logger)
|
||||
// Retry after 1 hour on failure
|
||||
time.Sleep(time.Hour)
|
||||
// Retry after 1 hour on failure
|
||||
if !lastErrOn.IsZero() && time.Now().Before(lastErrOn.Add(time.Hour)) {
|
||||
continue
|
||||
}
|
||||
if err := p.renewIfNeeded(); err != nil {
|
||||
gperr.LogWarn("cert renew failed", err)
|
||||
lastErrOn = time.Now()
|
||||
continue
|
||||
}
|
||||
// Reset on success
|
||||
lastErrOn = time.Time{}
|
||||
renewalTime = p.ShouldRenewOn()
|
||||
timer.Reset(time.Until(renewalTime))
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (p *Provider) initClient() E.Error {
|
||||
func (p *Provider) initClient() error {
|
||||
legoClient, err := lego.NewClient(p.legoCfg)
|
||||
if err != nil {
|
||||
return E.From(err)
|
||||
return err
|
||||
}
|
||||
|
||||
generator := providersGenMap[p.cfg.Provider]
|
||||
@@ -189,7 +213,7 @@ func (p *Provider) initClient() E.Error {
|
||||
|
||||
err = legoClient.Challenge.SetDNS01Provider(legoProvider)
|
||||
if err != nil {
|
||||
return E.From(err)
|
||||
return err
|
||||
}
|
||||
|
||||
p.client = legoClient
|
||||
@@ -202,7 +226,7 @@ func (p *Provider) registerACME() error {
|
||||
}
|
||||
if reg, err := p.client.Registration.ResolveAccountByKey(); err == nil {
|
||||
p.user.Registration = reg
|
||||
logger.Info().Msg("reused acme registration from private key")
|
||||
logging.Info().Msg("reused acme registration from private key")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -211,7 +235,7 @@ func (p *Provider) registerACME() error {
|
||||
return err
|
||||
}
|
||||
p.user.Registration = reg
|
||||
logger.Info().Interface("reg", reg).Msg("acme registered")
|
||||
logging.Info().Interface("reg", reg).Msg("acme registered")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -257,23 +281,23 @@ func (p *Provider) certState() CertState {
|
||||
sort.Strings(certDomains)
|
||||
|
||||
if !reflect.DeepEqual(certDomains, wantedDomains) {
|
||||
logger.Info().Msgf("cert domains mismatch: %v != %v", certDomains, p.cfg.Domains)
|
||||
logging.Info().Msgf("cert domains mismatch: %v != %v", certDomains, p.cfg.Domains)
|
||||
return CertStateMismatch
|
||||
}
|
||||
|
||||
return CertStateValid
|
||||
}
|
||||
|
||||
func (p *Provider) renewIfNeeded() E.Error {
|
||||
func (p *Provider) renewIfNeeded() error {
|
||||
if p.cfg.Provider == ProviderLocal {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch p.certState() {
|
||||
case CertStateExpired:
|
||||
logger.Info().Msg("certs expired, renewing")
|
||||
logging.Info().Msg("certs expired, renewing")
|
||||
case CertStateMismatch:
|
||||
logger.Info().Msg("cert domains mismatch with config, renewing")
|
||||
logging.Info().Msg("cert domains mismatch with config, renewing")
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
@@ -303,13 +327,13 @@ func providerGenerator[CT any, PT challenge.Provider](
|
||||
defaultCfg func() *CT,
|
||||
newProvider func(*CT) (PT, error),
|
||||
) ProviderGenerator {
|
||||
return func(opt types.AutocertProviderOpt) (challenge.Provider, E.Error) {
|
||||
return func(opt ProviderOpt) (challenge.Provider, gperr.Error) {
|
||||
cfg := defaultCfg()
|
||||
err := U.Deserialize(opt, cfg)
|
||||
err := U.Deserialize(opt, &cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p, pErr := newProvider(cfg)
|
||||
return p, E.From(pErr)
|
||||
return p, gperr.Wrap(pErr)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,5 +46,5 @@ oauth2_config:
|
||||
opt := make(map[string]any)
|
||||
ExpectNoError(t, yaml.Unmarshal([]byte(testYaml), opt))
|
||||
ExpectNoError(t, U.Deserialize(opt, cfg))
|
||||
ExpectDeepEqual(t, cfg, cfgExpected)
|
||||
ExpectEqual(t, cfg, cfgExpected)
|
||||
}
|
||||
|
||||
@@ -1,27 +1,26 @@
|
||||
package autocert
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
|
||||
E "github.com/yusing/go-proxy/internal/error"
|
||||
"github.com/yusing/go-proxy/internal/logging"
|
||||
"github.com/yusing/go-proxy/internal/utils/strutils"
|
||||
)
|
||||
|
||||
func (p *Provider) Setup() (err E.Error) {
|
||||
func (p *Provider) Setup() (err error) {
|
||||
if err = p.LoadCert(); err != nil {
|
||||
if !err.Is(os.ErrNotExist) { // ignore if cert doesn't exist
|
||||
if !errors.Is(err, os.ErrNotExist) { // ignore if cert doesn't exist
|
||||
return err
|
||||
}
|
||||
logger.Debug().Msg("obtaining cert due to error loading cert")
|
||||
logging.Debug().Msg("obtaining cert due to error loading cert")
|
||||
if err = p.ObtainCert(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
p.ScheduleRenewal()
|
||||
|
||||
for _, expiry := range p.GetExpiries() {
|
||||
logger.Info().Msg("certificate expire on " + strutils.FormatTime(expiry))
|
||||
logging.Info().Msg("certificate expire on " + strutils.FormatTime(expiry))
|
||||
break
|
||||
}
|
||||
|
||||
|
||||
@@ -15,9 +15,11 @@ type User struct {
|
||||
func (u *User) GetEmail() string {
|
||||
return u.Email
|
||||
}
|
||||
|
||||
func (u *User) GetRegistration() *registration.Resource {
|
||||
return u.Registration
|
||||
}
|
||||
|
||||
func (u *User) GetPrivateKey() crypto.PrivateKey {
|
||||
return u.key
|
||||
}
|
||||
|
||||
@@ -1,18 +1,7 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
)
|
||||
|
||||
type Args struct {
|
||||
Command string
|
||||
}
|
||||
|
||||
const (
|
||||
CommandStart = ""
|
||||
CommandSetup = "setup"
|
||||
CommandValidate = "validate"
|
||||
CommandListConfigs = "ls-config"
|
||||
CommandListRoutes = "ls-routes"
|
||||
@@ -23,34 +12,20 @@ const (
|
||||
CommandDebugListMTrace = "debug-ls-mtrace"
|
||||
)
|
||||
|
||||
var ValidCommands = []string{
|
||||
CommandStart,
|
||||
CommandSetup,
|
||||
CommandValidate,
|
||||
CommandListConfigs,
|
||||
CommandListRoutes,
|
||||
CommandListIcons,
|
||||
CommandReload,
|
||||
CommandDebugListEntries,
|
||||
CommandDebugListProviders,
|
||||
CommandDebugListMTrace,
|
||||
}
|
||||
type MainServerCommandValidator struct{}
|
||||
|
||||
func GetArgs() Args {
|
||||
var args Args
|
||||
flag.Parse()
|
||||
args.Command = flag.Arg(0)
|
||||
if err := validateArg(args.Command); err != nil {
|
||||
log.Fatalf("invalid command: %s", err)
|
||||
func (v MainServerCommandValidator) IsCommandValid(cmd string) bool {
|
||||
switch cmd {
|
||||
case CommandStart,
|
||||
CommandValidate,
|
||||
CommandListConfigs,
|
||||
CommandListRoutes,
|
||||
CommandListIcons,
|
||||
CommandReload,
|
||||
CommandDebugListEntries,
|
||||
CommandDebugListProviders,
|
||||
CommandDebugListMTrace:
|
||||
return true
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
func validateArg(arg string) error {
|
||||
for _, v := range ValidCommands {
|
||||
if arg == v {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("invalid command %q", arg)
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -4,40 +4,32 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
ConnectionTimeout = 5 * time.Second
|
||||
DialTimeout = 3 * time.Second
|
||||
KeepAlive = 60 * time.Second
|
||||
)
|
||||
|
||||
// file, folder structure
|
||||
|
||||
const (
|
||||
DotEnvPath = ".env"
|
||||
DotEnvExamplePath = ".env.example"
|
||||
|
||||
ConfigBasePath = "config"
|
||||
ConfigFileName = "config.yml"
|
||||
ConfigExampleFileName = "config.example.yml"
|
||||
ConfigPath = ConfigBasePath + "/" + ConfigFileName
|
||||
|
||||
JWTKeyPath = ConfigBasePath + "/jwt.key"
|
||||
ConfigBasePath = "config"
|
||||
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"
|
||||
|
||||
MiddlewareComposeBasePath = ConfigBasePath + "/middlewares"
|
||||
|
||||
SchemaBasePath = "schema"
|
||||
ConfigSchemaPath = SchemaBasePath + "/config.schema.json"
|
||||
FileProviderSchemaPath = SchemaBasePath + "/providers.schema.json"
|
||||
|
||||
ComposeFileName = "compose.yml"
|
||||
ComposeExampleFileName = "compose.example.yml"
|
||||
|
||||
ErrorPagesBasePath = "error_pages"
|
||||
|
||||
AgentCertsBasePath = "certs"
|
||||
)
|
||||
|
||||
var RequiredDirectories = []string{
|
||||
ConfigBasePath,
|
||||
SchemaBasePath,
|
||||
ErrorPagesBasePath,
|
||||
MiddlewareComposeBasePath,
|
||||
}
|
||||
@@ -49,8 +41,6 @@ const (
|
||||
HealthCheckTimeoutDefault = 5 * time.Second
|
||||
|
||||
WakeTimeoutDefault = "30s"
|
||||
StopTimeoutDefault = "10s"
|
||||
StopTimeoutDefault = "30s"
|
||||
StopMethodDefault = "stop"
|
||||
)
|
||||
|
||||
const HeaderCheckRedirect = "X-Goproxy-Check-Redirect"
|
||||
|
||||
@@ -1,18 +1,12 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"crypto/sha512"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
func HashPassword(pwd string) []byte {
|
||||
h := sha512.New()
|
||||
h.Write([]byte(pwd))
|
||||
return h.Sum(nil)
|
||||
}
|
||||
|
||||
func decodeJWTKey(key string) []byte {
|
||||
if key == "" {
|
||||
return nil
|
||||
@@ -23,3 +17,12 @@ func decodeJWTKey(key string) []byte {
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
|
||||
func RandomJWTKey() []byte {
|
||||
key := make([]byte, 32)
|
||||
_, err := rand.Read(key)
|
||||
if err != nil {
|
||||
log.Panic().Err(err).Msg("failed to generate random jwt key")
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
@@ -9,16 +9,15 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/yusing/go-proxy/internal/utils/strutils"
|
||||
)
|
||||
|
||||
var (
|
||||
prefixes = []string{"GODOXY_", "GOPROXY_", ""}
|
||||
|
||||
IsTest = GetEnvBool("TEST", false) || strings.HasSuffix(os.Args[0], ".test")
|
||||
IsDebug = GetEnvBool("DEBUG", IsTest)
|
||||
IsDebugSkipAuth = GetEnvBool("DEBUG_SKIP_AUTH", false)
|
||||
IsTrace = GetEnvBool("TRACE", false) && IsDebug
|
||||
IsProduction = !IsTest && !IsDebug
|
||||
IsTest = GetEnvBool("TEST", false) || strings.HasSuffix(os.Args[0], ".test")
|
||||
IsDebug = GetEnvBool("DEBUG", IsTest)
|
||||
IsTrace = GetEnvBool("TRACE", false) && IsDebug
|
||||
|
||||
ProxyHTTPAddr,
|
||||
ProxyHTTPHost,
|
||||
@@ -35,16 +34,31 @@ var (
|
||||
APIHTTPPort,
|
||||
APIHTTPURL = GetAddrEnv("API_ADDR", "127.0.0.1:8888", "http")
|
||||
|
||||
MetricsHTTPAddr,
|
||||
MetricsHTTPHost,
|
||||
MetricsHTTPPort,
|
||||
MetricsHTTPURL = GetAddrEnv("PROMETHEUS_ADDR", "", "http")
|
||||
PrometheusEnabled = MetricsHTTPURL != ""
|
||||
PrometheusEnabled = GetEnvBool("PROMETHEUS_ENABLED", false)
|
||||
|
||||
APIJWTSecret = decodeJWTKey(GetEnvString("API_JWT_SECRET", ""))
|
||||
APIJWTTokenTTL = GetDurationEnv("API_JWT_TOKEN_TTL", time.Hour)
|
||||
APIUser = GetEnvString("API_USER", "admin")
|
||||
APIPasswordHash = HashPassword(GetEnvString("API_PASSWORD", "password"))
|
||||
APIJWTSecure = GetEnvBool("API_JWT_SECURE", true)
|
||||
APIJWTSecret = decodeJWTKey(GetEnvString("API_JWT_SECRET", ""))
|
||||
APIJWTTokenTTL = GetDurationEnv("API_JWT_TOKEN_TTL", time.Hour)
|
||||
APIUser = GetEnvString("API_USER", "admin")
|
||||
APIPassword = GetEnvString("API_PASSWORD", "password")
|
||||
|
||||
DebugDisableAuth = GetEnvBool("DEBUG_DISABLE_AUTH", false)
|
||||
|
||||
// OIDC Configuration.
|
||||
OIDCIssuerURL = GetEnvString("OIDC_ISSUER_URL", "")
|
||||
OIDCClientID = GetEnvString("OIDC_CLIENT_ID", "")
|
||||
OIDCClientSecret = GetEnvString("OIDC_CLIENT_SECRET", "")
|
||||
OIDCRedirectURL = GetEnvString("OIDC_REDIRECT_URL", "")
|
||||
OIDCScopes = GetEnvString("OIDC_SCOPES", "openid, profile, email")
|
||||
OIDCAllowedUsers = GetCommaSepEnv("OIDC_ALLOWED_USERS", "")
|
||||
OIDCAllowedGroups = GetCommaSepEnv("OIDC_ALLOWED_GROUPS", "")
|
||||
|
||||
// metrics configuration
|
||||
MetricsDisableCPU = GetEnvBool("METRICS_DISABLE_CPU", false)
|
||||
MetricsDisableMemory = GetEnvBool("METRICS_DISABLE_MEMORY", false)
|
||||
MetricsDisableDisk = GetEnvBool("METRICS_DISABLE_DISK", false)
|
||||
MetricsDisableNetwork = GetEnvBool("METRICS_DISABLE_NETWORK", false)
|
||||
MetricsDisableSensors = GetEnvBool("METRICS_DISABLE_SENSORS", false)
|
||||
)
|
||||
|
||||
func GetEnv[T any](key string, defaultValue T, parser func(string) (T, error)) T {
|
||||
@@ -77,7 +91,11 @@ func GetEnvBool(key string, defaultValue bool) bool {
|
||||
return GetEnv(key, defaultValue, strconv.ParseBool)
|
||||
}
|
||||
|
||||
func GetAddrEnv(key, defaultValue, scheme string) (addr, host, port, fullURL string) {
|
||||
func GetEnvInt(key string, defaultValue int) int {
|
||||
return GetEnv(key, defaultValue, strconv.Atoi)
|
||||
}
|
||||
|
||||
func GetAddrEnv(key, defaultValue, scheme string) (addr, host string, portInt int, fullURL string) {
|
||||
addr = GetEnvString(key, defaultValue)
|
||||
if addr == "" {
|
||||
return
|
||||
@@ -90,9 +108,17 @@ func GetAddrEnv(key, defaultValue, scheme string) (addr, host, port, fullURL str
|
||||
host = "localhost"
|
||||
}
|
||||
fullURL = fmt.Sprintf("%s://%s:%s", scheme, host, port)
|
||||
portInt, err = strconv.Atoi(port)
|
||||
if err != nil {
|
||||
log.Fatal().Msgf("env %s: invalid port: %s", key, port)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func GetDurationEnv(key string, defaultValue time.Duration) time.Duration {
|
||||
return GetEnv(key, defaultValue, time.ParseDuration)
|
||||
}
|
||||
|
||||
func GetCommaSepEnv(key string, defaultValue string) []string {
|
||||
return strutils.CommaSeperatedList(GetEnvString(key, defaultValue))
|
||||
}
|
||||
|
||||
66
internal/config/agent_pool.go
Normal file
66
internal/config/agent_pool.go
Normal file
@@ -0,0 +1,66 @@
|
||||
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
|
||||
}
|
||||
@@ -1,18 +1,22 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/yusing/go-proxy/internal/api"
|
||||
"github.com/yusing/go-proxy/internal/autocert"
|
||||
"github.com/yusing/go-proxy/internal/common"
|
||||
"github.com/yusing/go-proxy/internal/config/types"
|
||||
config "github.com/yusing/go-proxy/internal/config/types"
|
||||
"github.com/yusing/go-proxy/internal/entrypoint"
|
||||
E "github.com/yusing/go-proxy/internal/error"
|
||||
"github.com/yusing/go-proxy/internal/gperr"
|
||||
"github.com/yusing/go-proxy/internal/logging"
|
||||
"github.com/yusing/go-proxy/internal/net/gphttp/server"
|
||||
"github.com/yusing/go-proxy/internal/notif"
|
||||
proxy "github.com/yusing/go-proxy/internal/route/provider"
|
||||
"github.com/yusing/go-proxy/internal/task"
|
||||
@@ -23,16 +27,16 @@ import (
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
value *types.Config
|
||||
value *config.Config
|
||||
providers F.Map[string, *proxy.Provider]
|
||||
autocertProvider *autocert.Provider
|
||||
task *task.Task
|
||||
entrypoint *entrypoint.Entrypoint
|
||||
|
||||
task *task.Task
|
||||
}
|
||||
|
||||
var (
|
||||
instance *Config
|
||||
cfgWatcher watcher.Watcher
|
||||
logger = logging.With().Str("module", "config").Logger()
|
||||
reloadMu sync.Mutex
|
||||
)
|
||||
|
||||
@@ -45,34 +49,29 @@ Make sure you rename it back before next time you start.`
|
||||
You may run "ls-config" to show or dump the current config.`
|
||||
)
|
||||
|
||||
func GetInstance() *Config {
|
||||
return instance
|
||||
}
|
||||
var Validate = config.Validate
|
||||
|
||||
func newConfig() *Config {
|
||||
return &Config{
|
||||
value: types.DefaultConfig(),
|
||||
providers: F.NewMapOf[string, *proxy.Provider](),
|
||||
task: task.RootTask("config", false),
|
||||
value: config.DefaultConfig(),
|
||||
providers: F.NewMapOf[string, *proxy.Provider](),
|
||||
entrypoint: entrypoint.NewEntrypoint(),
|
||||
task: task.RootTask("config", false),
|
||||
}
|
||||
}
|
||||
|
||||
func Load() (*Config, E.Error) {
|
||||
if instance != nil {
|
||||
return instance, nil
|
||||
func Load() (*Config, gperr.Error) {
|
||||
if config.HasInstance() {
|
||||
panic(errors.New("config already loaded"))
|
||||
}
|
||||
instance = newConfig()
|
||||
cfg := newConfig()
|
||||
config.SetInstance(cfg)
|
||||
cfgWatcher = watcher.NewConfigFileWatcher(common.ConfigFileName)
|
||||
return instance, instance.load()
|
||||
}
|
||||
|
||||
func Validate(data []byte) E.Error {
|
||||
var model types.Config
|
||||
return utils.DeserializeYAML(data, &model)
|
||||
return cfg, cfg.load()
|
||||
}
|
||||
|
||||
func MatchDomains() []string {
|
||||
return instance.value.MatchDomains
|
||||
return config.GetInstance().Value().MatchDomains
|
||||
}
|
||||
|
||||
func WatchChanges() {
|
||||
@@ -81,8 +80,8 @@ func WatchChanges() {
|
||||
t,
|
||||
configEventFlushInterval,
|
||||
OnConfigChange,
|
||||
func(err E.Error) {
|
||||
E.LogError("config reload error", err, &logger)
|
||||
func(err gperr.Error) {
|
||||
gperr.LogError("config reload error", err)
|
||||
},
|
||||
)
|
||||
eventQueue.Start(cfgWatcher.Events(t.Context()))
|
||||
@@ -93,10 +92,10 @@ func OnConfigChange(ev []events.Event) {
|
||||
// just reload once and check the last event
|
||||
switch ev[len(ev)-1].Action {
|
||||
case events.ActionFileRenamed:
|
||||
logger.Warn().Msg(cfgRenameWarn)
|
||||
logging.Warn().Msg(cfgRenameWarn)
|
||||
return
|
||||
case events.ActionFileDeleted:
|
||||
logger.Warn().Msg(cfgDeleteWarn)
|
||||
logging.Warn().Msg(cfgDeleteWarn)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -106,7 +105,7 @@ func OnConfigChange(ev []events.Event) {
|
||||
}
|
||||
}
|
||||
|
||||
func Reload() E.Error {
|
||||
func Reload() gperr.Error {
|
||||
// avoid race between config change and API reload request
|
||||
reloadMu.Lock()
|
||||
defer reloadMu.Unlock()
|
||||
@@ -115,58 +114,119 @@ func Reload() E.Error {
|
||||
err := newCfg.load()
|
||||
if err != nil {
|
||||
newCfg.task.Finish(err)
|
||||
return err
|
||||
return gperr.New("using last config").With(err)
|
||||
}
|
||||
|
||||
// cancel all current subtasks -> wait
|
||||
// -> replace config -> start new subtasks
|
||||
instance.task.Finish("config changed")
|
||||
instance = newCfg
|
||||
instance.StartProxyProviders()
|
||||
config.GetInstance().(*Config).Task().Finish("config changed")
|
||||
newCfg.Start(StartAllServers)
|
||||
config.SetInstance(newCfg)
|
||||
return nil
|
||||
}
|
||||
|
||||
func Value() types.Config {
|
||||
return *instance.value
|
||||
func (cfg *Config) Value() *config.Config {
|
||||
return cfg.value
|
||||
}
|
||||
|
||||
func GetAutoCertProvider() *autocert.Provider {
|
||||
return instance.autocertProvider
|
||||
func (cfg *Config) Reload() gperr.Error {
|
||||
return Reload()
|
||||
}
|
||||
|
||||
// AutoCertProvider returns the autocert provider.
|
||||
//
|
||||
// If the autocert provider is not configured, it returns nil.
|
||||
func (cfg *Config) AutoCertProvider() *autocert.Provider {
|
||||
return cfg.autocertProvider
|
||||
}
|
||||
|
||||
func (cfg *Config) Task() *task.Task {
|
||||
return cfg.task
|
||||
}
|
||||
|
||||
func (cfg *Config) Context() context.Context {
|
||||
return cfg.task.Context()
|
||||
}
|
||||
|
||||
func (cfg *Config) Start(opts ...*StartServersOptions) {
|
||||
cfg.StartAutoCert()
|
||||
cfg.StartProxyProviders()
|
||||
cfg.StartServers(opts...)
|
||||
}
|
||||
|
||||
func (cfg *Config) StartAutoCert() {
|
||||
autocert := cfg.autocertProvider
|
||||
if autocert == nil {
|
||||
logging.Info().Msg("autocert not configured")
|
||||
return
|
||||
}
|
||||
|
||||
if err := autocert.Setup(); err != nil {
|
||||
gperr.LogFatal("autocert setup error", err)
|
||||
} else {
|
||||
autocert.ScheduleRenewal(cfg.task)
|
||||
}
|
||||
}
|
||||
|
||||
func (cfg *Config) StartProxyProviders() {
|
||||
errs := cfg.providers.CollectErrorsParallel(
|
||||
errs := cfg.providers.CollectErrors(
|
||||
func(_ string, p *proxy.Provider) error {
|
||||
return p.Start(cfg.task)
|
||||
})
|
||||
|
||||
if err := E.Join(errs...); err != nil {
|
||||
E.LogError("route provider errors", err, &logger)
|
||||
if err := gperr.Join(errs...); err != nil {
|
||||
gperr.LogError("route provider errors", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (cfg *Config) load() E.Error {
|
||||
type StartServersOptions struct {
|
||||
Proxy, API bool
|
||||
}
|
||||
|
||||
var StartAllServers = &StartServersOptions{true, true}
|
||||
|
||||
func (cfg *Config) StartServers(opts ...*StartServersOptions) {
|
||||
if len(opts) == 0 {
|
||||
opts = append(opts, &StartServersOptions{})
|
||||
}
|
||||
opt := opts[0]
|
||||
if opt.Proxy {
|
||||
server.StartServer(cfg.task, server.Options{
|
||||
Name: "proxy",
|
||||
CertProvider: cfg.AutoCertProvider(),
|
||||
HTTPAddr: common.ProxyHTTPAddr,
|
||||
HTTPSAddr: common.ProxyHTTPSAddr,
|
||||
Handler: cfg.entrypoint,
|
||||
})
|
||||
}
|
||||
if opt.API {
|
||||
server.StartServer(cfg.task, server.Options{
|
||||
Name: "api",
|
||||
CertProvider: cfg.AutoCertProvider(),
|
||||
HTTPAddr: common.APIHTTPAddr,
|
||||
Handler: api.NewHandler(cfg),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (cfg *Config) load() gperr.Error {
|
||||
const errMsg = "config load error"
|
||||
|
||||
data, err := os.ReadFile(common.ConfigPath)
|
||||
if err != nil {
|
||||
E.LogFatal(errMsg, err, &logger)
|
||||
gperr.LogFatal(errMsg, err)
|
||||
}
|
||||
|
||||
model := types.DefaultConfig()
|
||||
model := config.DefaultConfig()
|
||||
if err := utils.DeserializeYAML(data, model); err != nil {
|
||||
E.LogFatal(errMsg, err, &logger)
|
||||
gperr.LogFatal(errMsg, err)
|
||||
}
|
||||
|
||||
// errors are non fatal below
|
||||
errs := E.NewBuilder(errMsg)
|
||||
errs.Add(entrypoint.SetMiddlewares(model.Entrypoint.Middlewares))
|
||||
errs.Add(entrypoint.SetAccessLogger(cfg.task, model.Entrypoint.AccessLog))
|
||||
errs.Add(cfg.initNotification(model.Providers.Notification))
|
||||
errs := gperr.NewBuilder(errMsg)
|
||||
errs.Add(cfg.entrypoint.SetMiddlewares(model.Entrypoint.Middlewares))
|
||||
errs.Add(cfg.entrypoint.SetAccessLogger(cfg.task, model.Entrypoint.AccessLog))
|
||||
cfg.initNotification(model.Providers.Notification)
|
||||
errs.Add(cfg.initAutoCert(model.AutoCert))
|
||||
errs.Add(cfg.loadRouteProviders(&model.Providers))
|
||||
|
||||
@@ -176,68 +236,95 @@ func (cfg *Config) load() E.Error {
|
||||
model.MatchDomains[i] = "." + domain
|
||||
}
|
||||
}
|
||||
entrypoint.SetFindRouteDomains(model.MatchDomains)
|
||||
cfg.entrypoint.SetFindRouteDomains(model.MatchDomains)
|
||||
|
||||
return errs.Error()
|
||||
}
|
||||
|
||||
func (cfg *Config) initNotification(notifCfg []types.NotificationConfig) (err E.Error) {
|
||||
func (cfg *Config) initNotification(notifCfg []notif.NotificationConfig) {
|
||||
if len(notifCfg) == 0 {
|
||||
return
|
||||
}
|
||||
dispatcher := notif.StartNotifDispatcher(cfg.task)
|
||||
errs := E.NewBuilder("notification providers load errors")
|
||||
for i, notifier := range notifCfg {
|
||||
_, err := dispatcher.RegisterProvider(notifier)
|
||||
if err == nil {
|
||||
continue
|
||||
}
|
||||
errs.Add(err.Subjectf("[%d]", i))
|
||||
for _, notifier := range notifCfg {
|
||||
dispatcher.RegisterProvider(¬ifier)
|
||||
}
|
||||
return errs.Error()
|
||||
}
|
||||
|
||||
func (cfg *Config) initAutoCert(autocertCfg *types.AutoCertConfig) (err E.Error) {
|
||||
func (cfg *Config) initAutoCert(autocertCfg *autocert.AutocertConfig) (err gperr.Error) {
|
||||
if cfg.autocertProvider != nil {
|
||||
return
|
||||
}
|
||||
|
||||
cfg.autocertProvider, err = autocert.NewConfig(autocertCfg).GetProvider()
|
||||
cfg.autocertProvider, err = autocertCfg.GetProvider()
|
||||
return
|
||||
}
|
||||
|
||||
func (cfg *Config) loadRouteProviders(providers *types.Providers) E.Error {
|
||||
errs := E.NewBuilder("route provider errors")
|
||||
results := E.NewBuilder("loaded route providers")
|
||||
func (cfg *Config) errIfExists(p *proxy.Provider) gperr.Error {
|
||||
if _, ok := cfg.providers.Load(p.String()); ok {
|
||||
return gperr.Errorf("provider %s already exists", p.String())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
lenLongestName := 0
|
||||
func (cfg *Config) storeProvider(p *proxy.Provider) {
|
||||
cfg.providers.Store(p.String(), p)
|
||||
}
|
||||
|
||||
func (cfg *Config) loadRouteProviders(providers *config.Providers) gperr.Error {
|
||||
errs := gperr.NewBuilder("route provider errors")
|
||||
results := gperr.NewBuilder("loaded route providers")
|
||||
|
||||
removeAllAgents()
|
||||
|
||||
for _, agent := range providers.Agents {
|
||||
if err := agent.Start(cfg.task); err != nil {
|
||||
errs.Add(err.Subject(agent.String()))
|
||||
continue
|
||||
}
|
||||
addAgent(agent)
|
||||
p := proxy.NewAgentProvider(agent)
|
||||
if err := cfg.errIfExists(p); err != nil {
|
||||
errs.Add(err.Subject(p.String()))
|
||||
continue
|
||||
}
|
||||
cfg.storeProvider(p)
|
||||
}
|
||||
for _, filename := range providers.Files {
|
||||
p, err := proxy.NewFileProvider(filename)
|
||||
if err == nil {
|
||||
err = cfg.errIfExists(p)
|
||||
}
|
||||
if err != nil {
|
||||
errs.Add(E.PrependSubject(filename, err))
|
||||
errs.Add(gperr.PrependSubject(filename, err))
|
||||
continue
|
||||
}
|
||||
cfg.providers.Store(p.GetName(), p)
|
||||
if len(p.GetName()) > lenLongestName {
|
||||
lenLongestName = len(p.GetName())
|
||||
}
|
||||
cfg.storeProvider(p)
|
||||
}
|
||||
for name, dockerHost := range providers.Docker {
|
||||
p, err := proxy.NewDockerProvider(name, dockerHost)
|
||||
if err != nil {
|
||||
errs.Add(E.PrependSubject(name, err))
|
||||
p := proxy.NewDockerProvider(name, dockerHost)
|
||||
if err := cfg.errIfExists(p); err != nil {
|
||||
errs.Add(err.Subject(p.String()))
|
||||
continue
|
||||
}
|
||||
cfg.providers.Store(p.GetName(), p)
|
||||
if len(p.GetName()) > lenLongestName {
|
||||
lenLongestName = len(p.GetName())
|
||||
}
|
||||
cfg.storeProvider(p)
|
||||
}
|
||||
if cfg.providers.Size() == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
lenLongestName := 0
|
||||
cfg.providers.RangeAll(func(k string, _ *proxy.Provider) {
|
||||
if len(k) > lenLongestName {
|
||||
lenLongestName = len(k)
|
||||
}
|
||||
})
|
||||
cfg.providers.RangeAllParallel(func(_ string, p *proxy.Provider) {
|
||||
if err := p.LoadRoutes(); err != nil {
|
||||
errs.Add(err.Subject(p.String()))
|
||||
}
|
||||
results.Addf("%-"+strconv.Itoa(lenLongestName)+"s %d routes", p.GetName(), p.NumRoutes())
|
||||
results.Addf("%-"+strconv.Itoa(lenLongestName)+"s %d routes", p.String(), p.NumRoutes())
|
||||
})
|
||||
logger.Info().Msg(results.String())
|
||||
logging.Info().Msg(results.String())
|
||||
return errs.Error()
|
||||
}
|
||||
|
||||
@@ -1,138 +1,53 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/yusing/go-proxy/internal/homepage"
|
||||
route "github.com/yusing/go-proxy/internal/route"
|
||||
"github.com/yusing/go-proxy/internal/route/entry"
|
||||
proxy "github.com/yusing/go-proxy/internal/route/provider"
|
||||
"github.com/yusing/go-proxy/internal/route/routes"
|
||||
"github.com/yusing/go-proxy/internal/route/types"
|
||||
"github.com/yusing/go-proxy/internal/utils/strutils"
|
||||
"github.com/yusing/go-proxy/internal/route"
|
||||
"github.com/yusing/go-proxy/internal/route/provider"
|
||||
)
|
||||
|
||||
func DumpEntries() map[string]*types.RawEntry {
|
||||
entries := make(map[string]*types.RawEntry)
|
||||
instance.providers.RangeAll(func(_ string, p *proxy.Provider) {
|
||||
func (cfg *Config) DumpRoutes() map[string]*route.Route {
|
||||
entries := make(map[string]*route.Route)
|
||||
cfg.providers.RangeAll(func(_ string, p *provider.Provider) {
|
||||
p.RangeRoutes(func(alias string, r *route.Route) {
|
||||
entries[alias] = r.Entry
|
||||
entries[alias] = r
|
||||
})
|
||||
})
|
||||
return entries
|
||||
}
|
||||
|
||||
func DumpProviders() map[string]*proxy.Provider {
|
||||
entries := make(map[string]*proxy.Provider)
|
||||
instance.providers.RangeAll(func(name string, p *proxy.Provider) {
|
||||
entries[name] = p
|
||||
func (cfg *Config) DumpRouteProviders() map[string]*provider.Provider {
|
||||
entries := make(map[string]*provider.Provider)
|
||||
cfg.providers.RangeAll(func(_ string, p *provider.Provider) {
|
||||
entries[p.ShortName()] = p
|
||||
})
|
||||
return entries
|
||||
}
|
||||
|
||||
func HomepageConfig() homepage.Config {
|
||||
hpCfg := homepage.NewHomePageConfig()
|
||||
routes.GetHTTPRoutes().RangeAll(func(alias string, r types.HTTPRoute) {
|
||||
en := r.RawEntry()
|
||||
item := en.Homepage
|
||||
if item == nil {
|
||||
item = new(homepage.Item)
|
||||
item.Show = true
|
||||
}
|
||||
|
||||
if !item.IsEmpty() {
|
||||
item.Show = true
|
||||
}
|
||||
|
||||
if !item.Show {
|
||||
return
|
||||
}
|
||||
|
||||
item.Alias = alias
|
||||
|
||||
if item.Name == "" {
|
||||
item.Name = strutils.Title(
|
||||
strings.ReplaceAll(
|
||||
strings.ReplaceAll(alias, "-", " "),
|
||||
"_", " ",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
if instance.value.Homepage.UseDefaultCategories {
|
||||
if en.Container != nil && item.Category == "" {
|
||||
if category, ok := homepage.PredefinedCategories[en.Container.ImageName]; ok {
|
||||
item.Category = category
|
||||
}
|
||||
}
|
||||
|
||||
if item.Category == "" {
|
||||
if category, ok := homepage.PredefinedCategories[strings.ToLower(alias)]; ok {
|
||||
item.Category = category
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
switch {
|
||||
case entry.IsDocker(r):
|
||||
if item.Category == "" {
|
||||
item.Category = "Docker"
|
||||
}
|
||||
item.SourceType = string(proxy.ProviderTypeDocker)
|
||||
case entry.UseLoadBalance(r):
|
||||
if item.Category == "" {
|
||||
item.Category = "Load-balanced"
|
||||
}
|
||||
item.SourceType = "loadbalancer"
|
||||
default:
|
||||
if item.Category == "" {
|
||||
item.Category = "Others"
|
||||
}
|
||||
item.SourceType = string(proxy.ProviderTypeFile)
|
||||
}
|
||||
|
||||
item.AltURL = r.TargetURL().String()
|
||||
hpCfg.Add(item)
|
||||
func (cfg *Config) RouteProviderList() []string {
|
||||
var list []string
|
||||
cfg.providers.RangeAll(func(_ string, p *provider.Provider) {
|
||||
list = append(list, p.ShortName())
|
||||
})
|
||||
return hpCfg
|
||||
return list
|
||||
}
|
||||
|
||||
func RoutesByAlias(typeFilter ...route.RouteType) map[string]any {
|
||||
rts := make(map[string]any)
|
||||
if len(typeFilter) == 0 || typeFilter[0] == "" {
|
||||
typeFilter = []route.RouteType{route.RouteTypeReverseProxy, route.RouteTypeStream}
|
||||
}
|
||||
for _, t := range typeFilter {
|
||||
switch t {
|
||||
case route.RouteTypeReverseProxy:
|
||||
routes.GetHTTPRoutes().RangeAll(func(alias string, r types.HTTPRoute) {
|
||||
rts[alias] = r
|
||||
})
|
||||
case route.RouteTypeStream:
|
||||
routes.GetStreamRoutes().RangeAll(func(alias string, r types.StreamRoute) {
|
||||
rts[alias] = r
|
||||
})
|
||||
}
|
||||
}
|
||||
return rts
|
||||
}
|
||||
func (cfg *Config) Statistics() map[string]any {
|
||||
var rps, streams provider.RouteStats
|
||||
var total uint16
|
||||
providerStats := make(map[string]provider.ProviderStats)
|
||||
|
||||
func Statistics() map[string]any {
|
||||
nTotalStreams := 0
|
||||
nTotalRPs := 0
|
||||
providerStats := make(map[string]proxy.ProviderStats)
|
||||
|
||||
instance.providers.RangeAll(func(name string, p *proxy.Provider) {
|
||||
cfg.providers.RangeAll(func(_ string, p *provider.Provider) {
|
||||
stats := p.Statistics()
|
||||
providerStats[name] = stats
|
||||
|
||||
nTotalRPs += stats.NumRPs
|
||||
nTotalStreams += stats.NumStreams
|
||||
providerStats[p.ShortName()] = stats
|
||||
rps.AddOther(stats.RPs)
|
||||
streams.AddOther(stats.Streams)
|
||||
total += stats.RPs.Total + stats.Streams.Total
|
||||
})
|
||||
|
||||
return map[string]any{
|
||||
"num_total_streams": nTotalStreams,
|
||||
"num_total_reverse_proxies": nTotalRPs,
|
||||
"providers": providerStats,
|
||||
"total": total,
|
||||
"reverse_proxies": rps,
|
||||
"streams": streams,
|
||||
"providers": providerStats,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
package types
|
||||
|
||||
type (
|
||||
AutoCertConfig struct {
|
||||
Email string `json:"email,omitempty" validate:"email"`
|
||||
Domains []string `json:"domains,omitempty"`
|
||||
CertPath string `json:"cert_path,omitempty" validate:"omitempty,filepath"`
|
||||
KeyPath string `json:"key_path,omitempty" validate:"omitempty,filepath"`
|
||||
ACMEKeyPath string `json:"acme_key_path,omitempty" validate:"omitempty,filepath"`
|
||||
Provider string `json:"provider,omitempty"`
|
||||
Options AutocertProviderOpt `json:"options,omitempty"`
|
||||
}
|
||||
AutocertProviderOpt map[string]any
|
||||
)
|
||||
@@ -1,29 +1,55 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"github.com/yusing/go-proxy/internal/net/http/accesslog"
|
||||
"context"
|
||||
"regexp"
|
||||
"sync"
|
||||
|
||||
"github.com/go-playground/validator/v10"
|
||||
"github.com/yusing/go-proxy/agent/pkg/agent"
|
||||
"github.com/yusing/go-proxy/internal/autocert"
|
||||
"github.com/yusing/go-proxy/internal/gperr"
|
||||
"github.com/yusing/go-proxy/internal/net/gphttp/accesslog"
|
||||
"github.com/yusing/go-proxy/internal/notif"
|
||||
"github.com/yusing/go-proxy/internal/utils"
|
||||
)
|
||||
|
||||
type (
|
||||
Config struct {
|
||||
AutoCert *AutoCertConfig `json:"autocert" validate:"omitempty"`
|
||||
Entrypoint Entrypoint `json:"entrypoint"`
|
||||
Providers Providers `json:"providers"`
|
||||
MatchDomains []string `json:"match_domains" validate:"dive,fqdn"`
|
||||
Homepage HomepageConfig `json:"homepage"`
|
||||
TimeoutShutdown int `json:"timeout_shutdown" validate:"gte=0"`
|
||||
AutoCert *autocert.AutocertConfig `json:"autocert"`
|
||||
Entrypoint Entrypoint `json:"entrypoint"`
|
||||
Providers Providers `json:"providers"`
|
||||
MatchDomains []string `json:"match_domains" validate:"domain_name"`
|
||||
Homepage HomepageConfig `json:"homepage"`
|
||||
TimeoutShutdown int `json:"timeout_shutdown" validate:"gte=0"`
|
||||
}
|
||||
Providers struct {
|
||||
Files []string `json:"include" validate:"dive,filepath"`
|
||||
Docker map[string]string `json:"docker" validate:"dive,unix_addr|url"`
|
||||
Notification []NotificationConfig `json:"notification"`
|
||||
Files []string `json:"include" yaml:"include,omitempty" validate:"dive,filepath"`
|
||||
Docker map[string]string `json:"docker" yaml:"docker,omitempty" validate:"non_empty_docker_keys,dive,unix_addr|url"`
|
||||
Agents []*agent.AgentConfig `json:"agents" yaml:"agents,omitempty"`
|
||||
Notification []notif.NotificationConfig `json:"notification" yaml:"notification,omitempty"`
|
||||
}
|
||||
Entrypoint struct {
|
||||
Middlewares []map[string]any `json:"middlewares"`
|
||||
AccessLog *accesslog.Config `json:"access_log" validate:"omitempty"`
|
||||
}
|
||||
NotificationConfig map[string]any
|
||||
|
||||
ConfigInstance interface {
|
||||
Value() *Config
|
||||
Reload() gperr.Error
|
||||
Statistics() map[string]any
|
||||
RouteProviderList() []string
|
||||
Context() context.Context
|
||||
GetAgent(agentAddrOrDockerHost string) (*agent.AgentConfig, bool)
|
||||
VerifyNewAgent(host string, ca agent.PEMPair, client agent.PEMPair) (int, gperr.Error)
|
||||
ListAgents() []*agent.AgentConfig
|
||||
AutoCertProvider() *autocert.Provider
|
||||
}
|
||||
)
|
||||
|
||||
var (
|
||||
instance ConfigInstance
|
||||
instanceMu sync.RWMutex
|
||||
)
|
||||
|
||||
func DefaultConfig() *Config {
|
||||
@@ -35,6 +61,49 @@ func DefaultConfig() *Config {
|
||||
}
|
||||
}
|
||||
|
||||
func GetInstance() ConfigInstance {
|
||||
instanceMu.RLock()
|
||||
defer instanceMu.RUnlock()
|
||||
return instance
|
||||
}
|
||||
|
||||
func SetInstance(cfg ConfigInstance) {
|
||||
instanceMu.Lock()
|
||||
defer instanceMu.Unlock()
|
||||
instance = cfg
|
||||
}
|
||||
|
||||
func HasInstance() bool {
|
||||
instanceMu.RLock()
|
||||
defer instanceMu.RUnlock()
|
||||
return instance != nil
|
||||
}
|
||||
|
||||
func Validate(data []byte) gperr.Error {
|
||||
var model Config
|
||||
return utils.DeserializeYAML(data, &model)
|
||||
}
|
||||
|
||||
var matchDomainsRegex = regexp.MustCompile(`^[^\.]?([\w\d\-_]\.?)+[^\.]?$`)
|
||||
|
||||
func init() {
|
||||
utils.RegisterDefaultValueFactory(DefaultConfig)
|
||||
utils.MustRegisterValidation("domain_name", func(fl validator.FieldLevel) bool {
|
||||
domains := fl.Field().Interface().([]string)
|
||||
for _, domain := range domains {
|
||||
if !matchDomainsRegex.MatchString(domain) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
utils.MustRegisterValidation("non_empty_docker_keys", func(fl validator.FieldLevel) bool {
|
||||
m := fl.Field().Interface().(map[string]string)
|
||||
for k := range m {
|
||||
if k == "" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
package docker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/docker/cli/cli/connhelper"
|
||||
"github.com/docker/docker/client"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/yusing/go-proxy/agent/pkg/agent"
|
||||
"github.com/yusing/go-proxy/internal/common"
|
||||
config "github.com/yusing/go-proxy/internal/config/types"
|
||||
"github.com/yusing/go-proxy/internal/logging"
|
||||
"github.com/yusing/go-proxy/internal/task"
|
||||
U "github.com/yusing/go-proxy/internal/utils"
|
||||
F "github.com/yusing/go-proxy/internal/utils/functional"
|
||||
)
|
||||
|
||||
type (
|
||||
@@ -20,44 +24,89 @@ type (
|
||||
*client.Client
|
||||
|
||||
key string
|
||||
refCount *U.RefCount
|
||||
refCount uint32
|
||||
closedOn int64
|
||||
|
||||
l zerolog.Logger
|
||||
addr string
|
||||
dial func(ctx context.Context) (net.Conn, error)
|
||||
}
|
||||
)
|
||||
|
||||
var (
|
||||
clientMap F.Map[string, *SharedClient] = F.NewMapOf[string, *SharedClient]()
|
||||
clientMapMu sync.Mutex
|
||||
|
||||
clientOptEnvHost = []client.Opt{
|
||||
client.WithHostFromEnv(),
|
||||
client.WithAPIVersionNegotiation(),
|
||||
}
|
||||
clientMap = make(map[string]*SharedClient, 10)
|
||||
clientMapMu sync.RWMutex
|
||||
)
|
||||
|
||||
func init() {
|
||||
task.OnProgramExit("docker_clients_cleanup", func() {
|
||||
clientMap.RangeAllParallel(func(_ string, c *SharedClient) {
|
||||
if c.Connected() {
|
||||
c.Client.Close()
|
||||
var initClientCleanerOnce sync.Once
|
||||
|
||||
const (
|
||||
cleanInterval = 10 * time.Second
|
||||
clientTTLSecs = int64(10)
|
||||
)
|
||||
|
||||
func initClientCleaner() {
|
||||
cleaner := task.RootTask("docker_clients_cleaner")
|
||||
go func() {
|
||||
ticker := time.NewTicker(cleanInterval)
|
||||
defer ticker.Stop()
|
||||
defer cleaner.Finish("program exit")
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
closeTimedOutClients()
|
||||
case <-cleaner.Context().Done():
|
||||
return
|
||||
}
|
||||
})
|
||||
}
|
||||
}()
|
||||
|
||||
task.OnProgramExit("docker_clients_cleanup", func() {
|
||||
clientMapMu.Lock()
|
||||
defer clientMapMu.Unlock()
|
||||
|
||||
for _, c := range clientMap {
|
||||
delete(clientMap, c.key)
|
||||
c.Client.Close()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (c *SharedClient) Connected() bool {
|
||||
return c != nil && c.Client != nil
|
||||
func closeTimedOutClients() {
|
||||
clientMapMu.Lock()
|
||||
defer clientMapMu.Unlock()
|
||||
|
||||
now := time.Now().Unix()
|
||||
|
||||
for _, c := range clientMap {
|
||||
if atomic.LoadUint32(&c.refCount) == 0 && now-atomic.LoadInt64(&c.closedOn) > clientTTLSecs {
|
||||
delete(clientMap, c.key)
|
||||
c.Client.Close()
|
||||
logging.Debug().Str("host", c.key).Msg("docker client closed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *SharedClient) Address() string {
|
||||
return c.addr
|
||||
}
|
||||
|
||||
func (c *SharedClient) CheckConnection(ctx context.Context) error {
|
||||
conn, err := c.dial(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
conn.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
// if the client is still referenced, this is no-op.
|
||||
func (c *SharedClient) Close() {
|
||||
if c.Connected() {
|
||||
c.refCount.Sub()
|
||||
}
|
||||
atomic.StoreInt64(&c.closedOn, time.Now().Unix())
|
||||
atomic.AddUint32(&c.refCount, ^uint32(0))
|
||||
}
|
||||
|
||||
// ConnectClient creates a new Docker client connection to the specified host.
|
||||
// NewClient creates a new Docker client connection to the specified host.
|
||||
//
|
||||
// Returns existing client if available.
|
||||
//
|
||||
@@ -67,45 +116,66 @@ func (c *SharedClient) Close() {
|
||||
// Returns:
|
||||
// - Client: the Docker client connection.
|
||||
// - error: an error if the connection failed.
|
||||
func ConnectClient(host string) (*SharedClient, error) {
|
||||
func NewClient(host string) (*SharedClient, error) {
|
||||
initClientCleanerOnce.Do(initClientCleaner)
|
||||
|
||||
clientMapMu.Lock()
|
||||
defer clientMapMu.Unlock()
|
||||
|
||||
// check if client exists
|
||||
if client, ok := clientMap.Load(host); ok {
|
||||
client.refCount.Add()
|
||||
if client, ok := clientMap[host]; ok {
|
||||
atomic.StoreInt64(&client.closedOn, 0)
|
||||
atomic.AddUint32(&client.refCount, 1)
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// create client
|
||||
var opt []client.Opt
|
||||
var addr string
|
||||
var dial func(ctx context.Context) (net.Conn, error)
|
||||
|
||||
switch host {
|
||||
case "":
|
||||
return nil, errors.New("empty docker host")
|
||||
case common.DockerHostFromEnv:
|
||||
opt = clientOptEnvHost
|
||||
default:
|
||||
helper, err := connhelper.GetConnectionHelper(host)
|
||||
if err != nil {
|
||||
logging.Panic().Err(err).Msg("failed to get connection helper")
|
||||
if agent.IsDockerHostAgent(host) {
|
||||
cfg, ok := config.GetInstance().GetAgent(host)
|
||||
if !ok {
|
||||
panic(fmt.Errorf("agent %q not found", host))
|
||||
}
|
||||
if helper != nil {
|
||||
httpClient := &http.Client{
|
||||
Transport: &http.Transport{
|
||||
DialContext: helper.Dialer,
|
||||
},
|
||||
}
|
||||
opt = []client.Opt{
|
||||
client.WithHost(agent.DockerHost),
|
||||
client.WithHTTPClient(cfg.NewHTTPClient()),
|
||||
client.WithAPIVersionNegotiation(),
|
||||
}
|
||||
addr = "tcp://" + cfg.Addr
|
||||
dial = cfg.DialContext
|
||||
} else {
|
||||
switch host {
|
||||
case "":
|
||||
return nil, errors.New("empty docker host")
|
||||
case common.DockerHostFromEnv:
|
||||
opt = []client.Opt{
|
||||
client.WithHTTPClient(httpClient),
|
||||
client.WithHost(helper.Host),
|
||||
client.WithHostFromEnv(),
|
||||
client.WithAPIVersionNegotiation(),
|
||||
client.WithDialContext(helper.Dialer),
|
||||
}
|
||||
} else {
|
||||
opt = []client.Opt{
|
||||
client.WithHost(host),
|
||||
client.WithAPIVersionNegotiation(),
|
||||
default:
|
||||
helper, err := connhelper.GetConnectionHelper(host)
|
||||
if err != nil {
|
||||
logging.Panic().Err(err).Msg("failed to get connection helper")
|
||||
}
|
||||
if helper != nil {
|
||||
httpClient := &http.Client{
|
||||
Transport: &http.Transport{
|
||||
DialContext: helper.Dialer,
|
||||
},
|
||||
}
|
||||
opt = []client.Opt{
|
||||
client.WithHTTPClient(httpClient),
|
||||
client.WithHost(helper.Host),
|
||||
client.WithAPIVersionNegotiation(),
|
||||
client.WithDialContext(helper.Dialer),
|
||||
}
|
||||
} else {
|
||||
opt = []client.Opt{
|
||||
client.WithHost(host),
|
||||
client.WithAPIVersionNegotiation(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -118,21 +188,18 @@ func ConnectClient(host string) (*SharedClient, error) {
|
||||
c := &SharedClient{
|
||||
Client: client,
|
||||
key: host,
|
||||
refCount: U.NewRefCounter(),
|
||||
l: logger.With().Str("address", client.DaemonHost()).Logger(),
|
||||
refCount: 1,
|
||||
addr: addr,
|
||||
dial: dial,
|
||||
}
|
||||
c.l.Trace().Msg("client connected")
|
||||
|
||||
clientMap.Store(host, c)
|
||||
// non-agent client
|
||||
if c.dial == nil {
|
||||
c.dial = client.Dialer()
|
||||
}
|
||||
|
||||
go func() {
|
||||
<-c.refCount.Zero()
|
||||
clientMap.Delete(c.key)
|
||||
defer logging.Debug().Str("host", host).Msg("docker client initialized")
|
||||
|
||||
if c.Connected() {
|
||||
c.Client.Close()
|
||||
c.l.Trace().Msg("client closed")
|
||||
}
|
||||
}()
|
||||
clientMap[c.key] = c
|
||||
return c, nil
|
||||
}
|
||||
|
||||
@@ -5,87 +5,116 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/go-connections/nat"
|
||||
"github.com/yusing/go-proxy/agent/pkg/agent"
|
||||
config "github.com/yusing/go-proxy/internal/config/types"
|
||||
"github.com/yusing/go-proxy/internal/logging"
|
||||
U "github.com/yusing/go-proxy/internal/utils"
|
||||
"github.com/yusing/go-proxy/internal/utils/strutils"
|
||||
)
|
||||
|
||||
type (
|
||||
PortMapping = map[string]types.Port
|
||||
PortMapping = map[int]container.Port
|
||||
Container struct {
|
||||
_ U.NoCopy
|
||||
|
||||
DockerHost string `json:"docker_host"`
|
||||
ContainerName string `json:"container_name"`
|
||||
ContainerID string `json:"container_id"`
|
||||
ImageName string `json:"image_name"`
|
||||
DockerHost string `json:"docker_host"`
|
||||
Image *ContainerImage `json:"image"`
|
||||
ContainerName string `json:"container_name"`
|
||||
ContainerID string `json:"container_id"`
|
||||
|
||||
Agent *agent.AgentConfig `json:"agent"`
|
||||
|
||||
Labels map[string]string `json:"-"`
|
||||
|
||||
Mounts []string `json:"mounts"`
|
||||
|
||||
PublicPortMapping PortMapping `json:"public_ports"` // non-zero publicPort:types.Port
|
||||
PrivatePortMapping PortMapping `json:"private_ports"` // privatePort:types.Port
|
||||
PublicIP string `json:"public_ip"`
|
||||
PrivateIP string `json:"private_ip"`
|
||||
NetworkMode string `json:"network_mode"`
|
||||
PublicHostname string `json:"public_hostname"`
|
||||
PrivateHostname string `json:"private_hostname"`
|
||||
|
||||
Aliases []string `json:"aliases"`
|
||||
IsExcluded bool `json:"is_excluded"`
|
||||
IsExplicit bool `json:"is_explicit"`
|
||||
IsDatabase bool `json:"is_database"`
|
||||
IdleTimeout string `json:"idle_timeout,omitempty"`
|
||||
WakeTimeout string `json:"wake_timeout,omitempty"`
|
||||
StopMethod string `json:"stop_method,omitempty"`
|
||||
StopTimeout string `json:"stop_timeout,omitempty"` // stop_method = "stop" only
|
||||
StopSignal string `json:"stop_signal,omitempty"` // stop_method = "stop" | "kill" only
|
||||
Running bool `json:"running"`
|
||||
Aliases []string `json:"aliases"`
|
||||
IsExcluded bool `json:"is_excluded"`
|
||||
IsExplicit bool `json:"is_explicit"`
|
||||
IdleTimeout string `json:"idle_timeout,omitempty"`
|
||||
WakeTimeout string `json:"wake_timeout,omitempty"`
|
||||
StopMethod string `json:"stop_method,omitempty"`
|
||||
StopTimeout string `json:"stop_timeout,omitempty"` // stop_method = "stop" only
|
||||
StopSignal string `json:"stop_signal,omitempty"` // stop_method = "stop" | "kill" only
|
||||
StartEndpoint string `json:"start_endpoint,omitempty"`
|
||||
Running bool `json:"running"`
|
||||
}
|
||||
ContainerImage struct {
|
||||
Author string `json:"author,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Tag string `json:"tag,omitempty"`
|
||||
}
|
||||
)
|
||||
|
||||
var DummyContainer = new(Container)
|
||||
|
||||
func FromDocker(c *types.Container, dockerHost string) (res *Container) {
|
||||
isExplicit := c.Labels[LabelAliases] != ""
|
||||
func FromDocker(c *container.Summary, dockerHost string) (res *Container) {
|
||||
isExplicit := false
|
||||
helper := containerHelper{c}
|
||||
for lbl := range c.Labels {
|
||||
if strings.HasPrefix(lbl, NSProxy+".") {
|
||||
isExplicit = true
|
||||
} else {
|
||||
delete(c.Labels, lbl)
|
||||
}
|
||||
}
|
||||
res = &Container{
|
||||
DockerHost: dockerHost,
|
||||
Image: helper.parseImage(),
|
||||
ContainerName: helper.getName(),
|
||||
ContainerID: c.ID,
|
||||
ImageName: helper.getImageName(),
|
||||
|
||||
Labels: c.Labels,
|
||||
|
||||
Mounts: helper.getMounts(),
|
||||
|
||||
PublicPortMapping: helper.getPublicPortMapping(),
|
||||
PrivatePortMapping: helper.getPrivatePortMapping(),
|
||||
NetworkMode: c.HostConfig.NetworkMode,
|
||||
|
||||
Aliases: helper.getAliases(),
|
||||
IsExcluded: strutils.ParseBool(helper.getDeleteLabel(LabelExclude)),
|
||||
IsExplicit: isExplicit,
|
||||
IsDatabase: helper.isDatabase(),
|
||||
IdleTimeout: helper.getDeleteLabel(LabelIdleTimeout),
|
||||
WakeTimeout: helper.getDeleteLabel(LabelWakeTimeout),
|
||||
StopMethod: helper.getDeleteLabel(LabelStopMethod),
|
||||
StopTimeout: helper.getDeleteLabel(LabelStopTimeout),
|
||||
StopSignal: helper.getDeleteLabel(LabelStopSignal),
|
||||
Running: c.Status == "running" || c.State == "running",
|
||||
Aliases: helper.getAliases(),
|
||||
IsExcluded: strutils.ParseBool(helper.getDeleteLabel(LabelExclude)),
|
||||
IsExplicit: isExplicit,
|
||||
IdleTimeout: helper.getDeleteLabel(LabelIdleTimeout),
|
||||
WakeTimeout: helper.getDeleteLabel(LabelWakeTimeout),
|
||||
StopMethod: helper.getDeleteLabel(LabelStopMethod),
|
||||
StopTimeout: helper.getDeleteLabel(LabelStopTimeout),
|
||||
StopSignal: helper.getDeleteLabel(LabelStopSignal),
|
||||
StartEndpoint: helper.getDeleteLabel(LabelStartEndpoint),
|
||||
Running: c.Status == "running" || c.State == "running",
|
||||
}
|
||||
res.setPrivateIP(helper)
|
||||
res.setPublicIP()
|
||||
|
||||
if agent.IsDockerHostAgent(dockerHost) {
|
||||
var ok bool
|
||||
res.Agent, ok = config.GetInstance().GetAgent(dockerHost)
|
||||
if !ok {
|
||||
logging.Error().Msgf("agent %q not found", dockerHost)
|
||||
}
|
||||
}
|
||||
|
||||
res.setPrivateHostname(helper)
|
||||
res.setPublicHostname()
|
||||
return
|
||||
}
|
||||
|
||||
func FromJSON(json types.ContainerJSON, dockerHost string) *Container {
|
||||
ports := make([]types.Port, 0)
|
||||
func FromInspectResponse(json container.InspectResponse, dockerHost string) *Container {
|
||||
ports := make([]container.Port, 0)
|
||||
for k, bindings := range json.NetworkSettings.Ports {
|
||||
privPortStr, proto := k.Port(), k.Proto()
|
||||
proto, privPortStr := nat.SplitProtoPort(string(k))
|
||||
privPort, _ := strconv.ParseUint(privPortStr, 10, 16)
|
||||
ports = append(ports, types.Port{
|
||||
ports = append(ports, container.Port{
|
||||
PrivatePort: uint16(privPort),
|
||||
Type: proto,
|
||||
})
|
||||
for _, v := range bindings {
|
||||
pubPort, _ := strconv.ParseUint(v.HostPort, 10, 16)
|
||||
ports = append(ports, types.Port{
|
||||
ports = append(ports, container.Port{
|
||||
IP: v.HostIP,
|
||||
PublicPort: uint16(pubPort),
|
||||
PrivatePort: uint16(privPort),
|
||||
@@ -93,7 +122,7 @@ func FromJSON(json types.ContainerJSON, dockerHost string) *Container {
|
||||
})
|
||||
}
|
||||
}
|
||||
cont := FromDocker(&types.Container{
|
||||
cont := FromDocker(&container.Summary{
|
||||
ID: json.ID,
|
||||
Names: []string{strings.TrimPrefix(json.Name, "/")},
|
||||
Image: json.Image,
|
||||
@@ -102,33 +131,62 @@ func FromJSON(json types.ContainerJSON, dockerHost string) *Container {
|
||||
State: json.State.Status,
|
||||
Status: json.State.Status,
|
||||
Mounts: json.Mounts,
|
||||
NetworkSettings: &types.SummaryNetworkSettings{
|
||||
NetworkSettings: &container.NetworkSettingsSummary{
|
||||
Networks: json.NetworkSettings.Networks,
|
||||
},
|
||||
}, dockerHost)
|
||||
cont.NetworkMode = string(json.HostConfig.NetworkMode)
|
||||
return cont
|
||||
}
|
||||
|
||||
func (c *Container) setPublicIP() {
|
||||
func (c *Container) IsBlacklisted() bool {
|
||||
return c.Image.IsBlacklisted() || c.isDatabase()
|
||||
}
|
||||
|
||||
var databaseMPs = map[string]struct{}{
|
||||
"/var/lib/postgresql/data": {},
|
||||
"/var/lib/mysql": {},
|
||||
"/var/lib/mongodb": {},
|
||||
"/var/lib/mariadb": {},
|
||||
"/var/lib/memcached": {},
|
||||
"/var/lib/rabbitmq": {},
|
||||
}
|
||||
|
||||
func (c *Container) isDatabase() bool {
|
||||
for _, m := range c.Mounts {
|
||||
if _, ok := databaseMPs[m]; ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
for _, v := range c.PrivatePortMapping {
|
||||
switch v.PrivatePort {
|
||||
// postgres, mysql or mariadb, redis, memcached, mongodb
|
||||
case 5432, 3306, 6379, 11211, 27017:
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (c *Container) setPublicHostname() {
|
||||
if !c.Running {
|
||||
return
|
||||
}
|
||||
if strings.HasPrefix(c.DockerHost, "unix://") {
|
||||
c.PublicIP = "127.0.0.1"
|
||||
c.PublicHostname = "127.0.0.1"
|
||||
return
|
||||
}
|
||||
url, err := url.Parse(c.DockerHost)
|
||||
if err != nil {
|
||||
logger.Err(err).Msgf("invalid docker host %q, falling back to 127.0.0.1", c.DockerHost)
|
||||
c.PublicIP = "127.0.0.1"
|
||||
logging.Err(err).Msgf("invalid docker host %q, falling back to 127.0.0.1", c.DockerHost)
|
||||
c.PublicHostname = "127.0.0.1"
|
||||
return
|
||||
}
|
||||
c.PublicIP = url.Hostname()
|
||||
c.PublicHostname = url.Hostname()
|
||||
}
|
||||
|
||||
func (c *Container) setPrivateIP(helper containerHelper) {
|
||||
if !strings.HasPrefix(c.DockerHost, "unix://") {
|
||||
func (c *Container) setPrivateHostname(helper containerHelper) {
|
||||
if !strings.HasPrefix(c.DockerHost, "unix://") && c.Agent == nil {
|
||||
return
|
||||
}
|
||||
if helper.NetworkSettings == nil {
|
||||
@@ -138,7 +196,7 @@ func (c *Container) setPrivateIP(helper containerHelper) {
|
||||
if v.IPAddress == "" {
|
||||
continue
|
||||
}
|
||||
c.PrivateIP = v.IPAddress
|
||||
c.PrivateHostname = v.IPAddress
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,12 +3,12 @@ package docker
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/yusing/go-proxy/internal/utils/strutils"
|
||||
)
|
||||
|
||||
type containerHelper struct {
|
||||
*types.Container
|
||||
*container.Summary
|
||||
}
|
||||
|
||||
// getDeleteLabel gets the value of a label and then deletes it from the container.
|
||||
@@ -32,10 +32,28 @@ func (c containerHelper) getName() string {
|
||||
return strings.TrimPrefix(c.Names[0], "/")
|
||||
}
|
||||
|
||||
func (c containerHelper) getImageName() string {
|
||||
func (c containerHelper) getMounts() []string {
|
||||
m := make([]string, len(c.Mounts))
|
||||
for i, v := range c.Mounts {
|
||||
m[i] = v.Destination
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (c containerHelper) parseImage() *ContainerImage {
|
||||
colonSep := strutils.SplitRune(c.Image, ':')
|
||||
slashSep := strutils.SplitRune(colonSep[0], '/')
|
||||
return slashSep[len(slashSep)-1]
|
||||
im := new(ContainerImage)
|
||||
if len(slashSep) > 1 {
|
||||
im.Author = strings.Join(slashSep[:len(slashSep)-1], "/")
|
||||
im.Name = slashSep[len(slashSep)-1]
|
||||
} else {
|
||||
im.Name = slashSep[0]
|
||||
}
|
||||
if len(colonSep) > 1 {
|
||||
im.Tag = colonSep[1]
|
||||
}
|
||||
return im
|
||||
}
|
||||
|
||||
func (c containerHelper) getPublicPortMapping() PortMapping {
|
||||
@@ -44,7 +62,7 @@ func (c containerHelper) getPublicPortMapping() PortMapping {
|
||||
if v.PublicPort == 0 {
|
||||
continue
|
||||
}
|
||||
res[strutils.PortString(v.PublicPort)] = v
|
||||
res[int(v.PublicPort)] = v
|
||||
}
|
||||
return res
|
||||
}
|
||||
@@ -52,39 +70,7 @@ func (c containerHelper) getPublicPortMapping() PortMapping {
|
||||
func (c containerHelper) getPrivatePortMapping() PortMapping {
|
||||
res := make(PortMapping)
|
||||
for _, v := range c.Ports {
|
||||
res[strutils.PortString(v.PrivatePort)] = v
|
||||
res[int(v.PrivatePort)] = v
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
var databaseMPs = map[string]struct{}{
|
||||
"/var/lib/postgresql/data": {},
|
||||
"/var/lib/mysql": {},
|
||||
"/var/lib/mongodb": {},
|
||||
"/var/lib/mariadb": {},
|
||||
"/var/lib/memcached": {},
|
||||
"/var/lib/rabbitmq": {},
|
||||
}
|
||||
|
||||
var databasePrivPorts = map[uint16]struct{}{
|
||||
5432: {}, // postgres
|
||||
3306: {}, // mysql, mariadb
|
||||
6379: {}, // redis
|
||||
11211: {}, // memcached
|
||||
27017: {}, // mongodb
|
||||
}
|
||||
|
||||
func (c containerHelper) isDatabase() bool {
|
||||
for _, m := range c.Mounts {
|
||||
if _, ok := databaseMPs[m.Destination]; ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
for _, v := range c.Ports {
|
||||
if _, ok := databasePrivPorts[v.PrivatePort]; ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
43
internal/docker/container_test.go
Normal file
43
internal/docker/container_test.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package docker
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/docker/docker/api/types/container"
|
||||
. "github.com/yusing/go-proxy/internal/utils/testing"
|
||||
)
|
||||
|
||||
func TestContainerExplicit(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
labels map[string]string
|
||||
isExplicit bool
|
||||
}{
|
||||
{
|
||||
name: "explicit",
|
||||
labels: map[string]string{
|
||||
"proxy.aliases": "foo",
|
||||
},
|
||||
isExplicit: true,
|
||||
},
|
||||
{
|
||||
name: "explicit2",
|
||||
labels: map[string]string{
|
||||
"proxy.idle_timeout": "1s",
|
||||
},
|
||||
isExplicit: true,
|
||||
},
|
||||
{
|
||||
name: "not explicit",
|
||||
labels: map[string]string{},
|
||||
isExplicit: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
c := FromDocker(&container.Summary{Names: []string{"test"}, State: "test", Labels: tt.labels}, "")
|
||||
ExpectEqual(t, c.IsExplicit, tt.isExplicit)
|
||||
})
|
||||
}
|
||||
}
|
||||
60
internal/docker/idlewatcher/container.go
Normal file
60
internal/docker/idlewatcher/container.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package idlewatcher
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/docker/docker/api/types/container"
|
||||
)
|
||||
|
||||
type (
|
||||
containerMeta struct {
|
||||
ContainerID, ContainerName string
|
||||
}
|
||||
containerState struct {
|
||||
running bool
|
||||
ready bool
|
||||
err error
|
||||
}
|
||||
)
|
||||
|
||||
func (w *Watcher) ContainerID() string {
|
||||
return w.route.ContainerInfo().ContainerID
|
||||
}
|
||||
|
||||
func (w *Watcher) ContainerName() string {
|
||||
return w.route.ContainerInfo().ContainerName
|
||||
}
|
||||
|
||||
func (w *Watcher) containerStop(ctx context.Context) error {
|
||||
return w.client.ContainerStop(ctx, w.ContainerID(), container.StopOptions{
|
||||
Signal: string(w.Config().StopSignal),
|
||||
Timeout: &w.Config().StopTimeout,
|
||||
})
|
||||
}
|
||||
|
||||
func (w *Watcher) containerPause(ctx context.Context) error {
|
||||
return w.client.ContainerPause(ctx, w.ContainerID())
|
||||
}
|
||||
|
||||
func (w *Watcher) containerKill(ctx context.Context) error {
|
||||
return w.client.ContainerKill(ctx, w.ContainerID(), string(w.Config().StopSignal))
|
||||
}
|
||||
|
||||
func (w *Watcher) containerUnpause(ctx context.Context) error {
|
||||
return w.client.ContainerUnpause(ctx, w.ContainerID())
|
||||
}
|
||||
|
||||
func (w *Watcher) containerStart(ctx context.Context) error {
|
||||
return w.client.ContainerStart(ctx, w.ContainerID(), container.StartOptions{})
|
||||
}
|
||||
|
||||
func (w *Watcher) containerStatus() (string, error) {
|
||||
ctx, cancel := context.WithTimeoutCause(w.task.Context(), dockerReqTimeout, errors.New("docker request timeout"))
|
||||
defer cancel()
|
||||
json, err := w.client.ContainerInspect(ctx, w.ContainerID())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return json.State.Status, nil
|
||||
}
|
||||
@@ -1,88 +1,138 @@
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>{{.Title}}</title>
|
||||
<style>
|
||||
/* Global Styles */
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
body {
|
||||
font-family: Inter, Arial, sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
color: #fff;
|
||||
background-color: #212121;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
}
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>{{.Title}}</title>
|
||||
<style>
|
||||
/* size variables */
|
||||
:root {
|
||||
--dot-size: 12px;
|
||||
--logo-size: 100px;
|
||||
}
|
||||
/* Global Styles */
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
body {
|
||||
font-family:
|
||||
"Inter",
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
Roboto,
|
||||
Oxygen,
|
||||
Ubuntu,
|
||||
Cantarell,
|
||||
"Open Sans",
|
||||
"Helvetica Neue",
|
||||
sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
color: #f8f9fa;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
gap: 32px;
|
||||
background: linear-gradient(135deg, #121212 0%, #1e1e1e 100%);
|
||||
}
|
||||
|
||||
/* Spinner Styles */
|
||||
.spinner {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border: 16px solid #333;
|
||||
border-radius: 50%;
|
||||
border-top: 16px solid #66d9ef;
|
||||
animation: spin 2s linear infinite;
|
||||
}
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
/* Container */
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px;
|
||||
border-radius: 16px;
|
||||
background-color: rgba(30, 30, 30, 0.6);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
|
||||
backdrop-filter: blur(8px);
|
||||
max-width: 90%;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
/* Error Styles */
|
||||
.error {
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.error::before {
|
||||
content: "\26A0"; /* Unicode for warning symbol */
|
||||
font-size: 40px;
|
||||
color: #ff9900;
|
||||
}
|
||||
/* Spinner Styles */
|
||||
.loading-dots {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding-top: 20px;
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
.dot {
|
||||
width: var(--dot-size);
|
||||
height: var(--dot-size);
|
||||
background-color: #66d9ef;
|
||||
border-radius: 50%;
|
||||
animation: bounce 1.3s infinite ease-in-out;
|
||||
}
|
||||
.dot:nth-child(1) {
|
||||
animation-delay: -0.32s;
|
||||
}
|
||||
.dot:nth-child(2) {
|
||||
animation-delay: -0.16s;
|
||||
}
|
||||
@keyframes bounce {
|
||||
0%,
|
||||
80%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
40% {
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
}
|
||||
|
||||
/* Message Styles */
|
||||
.message {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
padding-left: 32px;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<script>
|
||||
window.onload = async function () {
|
||||
let resp = await fetch(window.location.href, {
|
||||
headers: {
|
||||
"{{.CheckRedirectHeader}}": "1",
|
||||
},
|
||||
});
|
||||
if (resp.ok) {
|
||||
window.location.href = resp.url;
|
||||
} else {
|
||||
document.getElementById("message").innerText =
|
||||
await resp.text();
|
||||
document
|
||||
.getElementById("spinner")
|
||||
.classList.replace("spinner", "error");
|
||||
}
|
||||
};
|
||||
</script>
|
||||
<div id="spinner" class="spinner"></div>
|
||||
<div id="message" class="message">{{.Message}}</div>
|
||||
</body>
|
||||
/* Message Styles */
|
||||
.message {
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
color: #f8f9fa;
|
||||
max-width: 500px;
|
||||
letter-spacing: 0.3px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Logo */
|
||||
.logo {
|
||||
width: var(--logo-size);
|
||||
height: var(--logo-size);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<!-- icon handled by waker_http -->
|
||||
<img class="logo" src="/favicon.ico" />
|
||||
<div id="loading-dots" class="loading-dots">
|
||||
<div class="dot"></div>
|
||||
<div class="dot"></div>
|
||||
<div class="dot"></div>
|
||||
</div>
|
||||
<div id="message" class="message">{{.Message}}</div>
|
||||
</div>
|
||||
<script>
|
||||
window.onload = async function () {
|
||||
let resp = await fetch(window.location.href, {
|
||||
headers: {
|
||||
"{{.CheckRedirectHeader}}": "1",
|
||||
},
|
||||
});
|
||||
if (resp.ok) {
|
||||
window.location.href = resp.url;
|
||||
} else {
|
||||
document.getElementById("message").innerText = await resp.text();
|
||||
document.getElementById("loading-dots").remove();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -3,10 +3,9 @@ package idlewatcher
|
||||
import (
|
||||
"bytes"
|
||||
_ "embed"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
"github.com/yusing/go-proxy/internal/common"
|
||||
"github.com/yusing/go-proxy/internal/net/gphttp/httpheaders"
|
||||
)
|
||||
|
||||
type templateData struct {
|
||||
@@ -20,14 +19,14 @@ var loadingPage []byte
|
||||
var loadingPageTmpl = template.Must(template.New("loading_page").Parse(string(loadingPage)))
|
||||
|
||||
func (w *Watcher) makeLoadingPageBody() []byte {
|
||||
msg := w.ContainerName + " is starting..."
|
||||
msg := w.ContainerName() + " is starting..."
|
||||
|
||||
data := new(templateData)
|
||||
data.CheckRedirectHeader = common.HeaderCheckRedirect
|
||||
data.Title = w.ContainerName
|
||||
data.Message = strings.ReplaceAll(msg, " ", " ")
|
||||
data.CheckRedirectHeader = httpheaders.HeaderGoDoxyCheckRedirect
|
||||
data.Title = w.route.HomepageItem().Name
|
||||
data.Message = msg
|
||||
|
||||
buf := bytes.NewBuffer(make([]byte, len(loadingPage)+len(data.Title)+len(data.Message)+len(common.HeaderCheckRedirect)))
|
||||
buf := bytes.NewBuffer(make([]byte, len(loadingPage)+len(data.Title)+len(data.Message)+len(httpheaders.HeaderGoDoxyCheckRedirect)))
|
||||
err := loadingPageTmpl.Execute(buf, data)
|
||||
if err != nil { // should never happen in production
|
||||
panic(err)
|
||||
|
||||
39
internal/docker/idlewatcher/state.go
Normal file
39
internal/docker/idlewatcher/state.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package idlewatcher
|
||||
|
||||
func (w *Watcher) running() bool {
|
||||
return w.state.Load().running
|
||||
}
|
||||
|
||||
func (w *Watcher) ready() bool {
|
||||
return w.state.Load().ready
|
||||
}
|
||||
|
||||
func (w *Watcher) error() error {
|
||||
return w.state.Load().err
|
||||
}
|
||||
|
||||
func (w *Watcher) setReady() {
|
||||
w.state.Store(&containerState{
|
||||
running: true,
|
||||
ready: true,
|
||||
})
|
||||
}
|
||||
|
||||
func (w *Watcher) setStarting() {
|
||||
w.state.Store(&containerState{
|
||||
running: true,
|
||||
ready: false,
|
||||
})
|
||||
}
|
||||
|
||||
func (w *Watcher) setNapping() {
|
||||
w.setError(nil)
|
||||
}
|
||||
|
||||
func (w *Watcher) setError(err error) {
|
||||
w.state.Store(&containerState{
|
||||
running: false,
|
||||
ready: false,
|
||||
err: err,
|
||||
})
|
||||
}
|
||||
@@ -2,24 +2,22 @@ package types
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/yusing/go-proxy/internal/docker"
|
||||
E "github.com/yusing/go-proxy/internal/error"
|
||||
"github.com/yusing/go-proxy/internal/gperr"
|
||||
)
|
||||
|
||||
type (
|
||||
Config struct {
|
||||
IdleTimeout time.Duration `json:"idle_timeout,omitempty"`
|
||||
WakeTimeout time.Duration `json:"wake_timeout,omitempty"`
|
||||
StopTimeout int `json:"stop_timeout,omitempty"` // docker api takes integer seconds for timeout argument
|
||||
StopMethod StopMethod `json:"stop_method,omitempty"`
|
||||
StopSignal Signal `json:"stop_signal,omitempty"`
|
||||
|
||||
DockerHost string `json:"docker_host,omitempty"`
|
||||
ContainerName string `json:"container_name,omitempty"`
|
||||
ContainerID string `json:"container_id,omitempty"`
|
||||
ContainerRunning bool `json:"container_running,omitempty"`
|
||||
IdleTimeout time.Duration `json:"idle_timeout,omitempty"`
|
||||
WakeTimeout time.Duration `json:"wake_timeout,omitempty"`
|
||||
StopTimeout int `json:"stop_timeout,omitempty"` // docker api takes integer seconds for timeout argument
|
||||
StopMethod StopMethod `json:"stop_method,omitempty"`
|
||||
StopSignal Signal `json:"stop_signal,omitempty"`
|
||||
StartEndpoint string `json:"start_endpoint,omitempty"` // Optional path that must be hit to start container
|
||||
}
|
||||
StopMethod string
|
||||
Signal string
|
||||
@@ -37,43 +35,31 @@ var validSignals = map[string]struct{}{
|
||||
"INT": {}, "TERM": {}, "HUP": {}, "QUIT": {},
|
||||
}
|
||||
|
||||
func ValidateConfig(cont *docker.Container) (*Config, E.Error) {
|
||||
if cont == nil {
|
||||
func ValidateConfig(cont *docker.Container) (*Config, gperr.Error) {
|
||||
if cont == nil || cont.IdleTimeout == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if cont.IdleTimeout == "" {
|
||||
return &Config{
|
||||
DockerHost: cont.DockerHost,
|
||||
ContainerName: cont.ContainerName,
|
||||
ContainerID: cont.ContainerID,
|
||||
ContainerRunning: cont.Running,
|
||||
}, nil
|
||||
}
|
||||
errs := gperr.NewBuilder("invalid idlewatcher config")
|
||||
|
||||
errs := E.NewBuilder("invalid idlewatcher config")
|
||||
|
||||
idleTimeout := E.Collect(errs, validateDurationPostitive, cont.IdleTimeout)
|
||||
wakeTimeout := E.Collect(errs, validateDurationPostitive, cont.WakeTimeout)
|
||||
stopTimeout := E.Collect(errs, validateDurationPostitive, cont.StopTimeout)
|
||||
stopMethod := E.Collect(errs, validateStopMethod, cont.StopMethod)
|
||||
signal := E.Collect(errs, validateSignal, cont.StopSignal)
|
||||
idleTimeout := gperr.Collect(errs, validateDurationPostitive, cont.IdleTimeout)
|
||||
wakeTimeout := gperr.Collect(errs, validateDurationPostitive, cont.WakeTimeout)
|
||||
stopTimeout := gperr.Collect(errs, validateDurationPostitive, cont.StopTimeout)
|
||||
stopMethod := gperr.Collect(errs, validateStopMethod, cont.StopMethod)
|
||||
signal := gperr.Collect(errs, validateSignal, cont.StopSignal)
|
||||
startEndpoint := gperr.Collect(errs, validateStartEndpoint, cont.StartEndpoint)
|
||||
|
||||
if errs.HasError() {
|
||||
return nil, errs.Error()
|
||||
}
|
||||
|
||||
return &Config{
|
||||
IdleTimeout: idleTimeout,
|
||||
WakeTimeout: wakeTimeout,
|
||||
StopTimeout: int(stopTimeout.Seconds()),
|
||||
StopMethod: stopMethod,
|
||||
StopSignal: signal,
|
||||
|
||||
DockerHost: cont.DockerHost,
|
||||
ContainerName: cont.ContainerName,
|
||||
ContainerID: cont.ContainerID,
|
||||
ContainerRunning: cont.Running,
|
||||
IdleTimeout: idleTimeout,
|
||||
WakeTimeout: wakeTimeout,
|
||||
StopTimeout: int(stopTimeout.Seconds()),
|
||||
StopMethod: stopMethod,
|
||||
StopSignal: signal,
|
||||
StartEndpoint: startEndpoint,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -104,3 +90,21 @@ func validateStopMethod(s string) (StopMethod, error) {
|
||||
return "", errors.New("invalid stop method " + s)
|
||||
}
|
||||
}
|
||||
|
||||
func validateStartEndpoint(s string) (string, error) {
|
||||
if s == "" {
|
||||
return "", nil
|
||||
}
|
||||
// checks needed as of Go 1.6 because of change https://github.com/golang/go/commit/617c93ce740c3c3cc28cdd1a0d712be183d0b328#diff-6c2d018290e298803c0c9419d8739885L195
|
||||
// emulate browser and strip the '#' suffix prior to validation. see issue-#237
|
||||
if i := strings.Index(s, "#"); i > -1 {
|
||||
s = s[:i]
|
||||
}
|
||||
if len(s) == 0 {
|
||||
return "", errors.New("start endpoint must not be empty if defined")
|
||||
}
|
||||
if _, err := url.ParseRequestURI(s); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user