Compare commits

...

321 Commits

Author SHA1 Message Date
yusing
759995972d docs: update README and config example for v0.11.0 2025-04-25 14:24:28 +08:00
yusing
03401488f6 chore(idlesleep): increase wake/sleep timeout to 3 minutes 2025-04-25 13:36:04 +08:00
yusing
1e790be70c chore: change ACL iso field to country 2025-04-25 13:25:45 +08:00
yusing
4410637f8b feat(autocert): added all available lego supported dns providers 2025-04-25 12:32:02 +08:00
yusing
3947152336 fix: uptime metrics 2025-04-25 11:26:24 +08:00
yusing
af8d2c74f6 revert(oidc): api breaking changes 2025-04-25 11:10:21 +08:00
yusing
e107f8d476 doc: checkout README from main 2025-04-25 10:49:05 +08:00
yusing
b427ff1f88 feat(acl): connection level ip/geo blocking
- fixed access log logic
- implement acl at connection level
- acl logging
- ip/cidr blocking
- geoblocking with MaxMind database
2025-04-25 10:47:52 +08:00
yusing
e513db62b0 refactor: move accesslog to logging/accesslog 2025-04-25 08:37:39 +08:00
yusing
2f33ee02d9 chore: replace gopkg.in/yaml.v3 with goccy/go-yaml 2025-04-25 08:36:54 +08:00
yusing
59490dcac0 refactor: move mock time to utils 2025-04-25 08:26:00 +08:00
yusing
5afa93a8f1 fix: json data not being loaded from disk correctly, logging 2025-04-25 08:26:00 +08:00
yusing
c8e9ed8440 fix: fatal and panic does not terminate the program 2025-04-25 08:26:00 +08:00
yusing
8363dfe257 fix: json marshal/unmarshal 2025-04-25 08:25:37 +08:00
yusing
080bbc18eb chore: completely drop prometheus support 2025-04-24 20:02:07 +08:00
yusing
1a0edc8bfe chore: deps upgrade 2025-04-24 16:39:05 +08:00
yusing
e8d1d524b9 fix: missing route handler iniitialization 2025-04-24 16:11:55 +08:00
yusing
edada22ac0 fix: tests 2025-04-24 15:45:34 +08:00
Yuzerion
76fb0cfdbb Feat/http3 (#84)
* chore(deps): update go-playground/validator to v10.26.0

* chore(deps): update Go version to 1.24.2 and dependencies, reorganize dependencies into categorized sections

* chore(deps): update Go version to 1.24.2 in Dockerfile

* refactor(agent): replace deprecated context import with standard context package

* feat(http3): add HTTP/3 support and refactor server handling code into utility functions

---------

Co-authored-by: yusing <yusing@6uo.me>
2025-04-24 15:37:12 +08:00
yusing
5df2553774 merge: better favicon handling 2025-04-24 15:34:47 +08:00
yusing
31812430f1 merge: access log rotation and enhancements 2025-04-24 15:29:18 +08:00
yusing
d668b03175 fix: tests 2025-04-24 15:09:46 +08:00
yusing
663a107c06 merge: main branch 2025-04-24 15:02:31 +08:00
yusing
806184e98b fix: redirectHTTP middleware redirect loop when behind another proxy 2025-04-24 09:27:10 +08:00
yusing
08ee82d7b0 fix(docker): docker clients not being cached correctly 2025-04-24 06:29:19 +08:00
yusing
bcc19167d4 feat: enhanced string utilities
- relative time formatting
- better relative duration formatting
2025-04-24 06:27:32 +08:00
yusing
858f65ee5a fix: update code for error handling changes, remove unused code 2025-04-24 06:24:28 +08:00
yusing
43566bbcfd feat: enhanced error handling library 2025-04-24 06:19:22 +08:00
yusing
ec8cca1245 feat: trie implementation 2025-04-24 05:56:03 +08:00
yusing
4a65de99a8 refactor: unify json load/saving with jsonstore 2025-04-24 05:49:32 +08:00
yusing
7461344004 fix: json store marshaling, api handler
- code clean up
- uncomment and simplify api auth handler
- fix redirect url for frontend
- proper redirect
2025-04-24 04:47:42 +08:00
yusing
b815c6fd69 feat(oidc): support token refreshing via offline_access scope
- refactored code
- moved api/v1/auth to auth/
- security enhancement
- env example update
- default jwt ttl changed to 24 hours
2025-04-23 17:50:22 +08:00
yusing
28c9a2e9d0 chore(oidc): remove debug logging 2025-04-23 02:02:17 +08:00
yusing
9e0bdd964c fix(oidc): rewrite login flow, fixed end_session_url retrieval and redirect issue 2025-04-22 19:29:19 +08:00
yusing
077641beaa refactor(oidc): simplify exchange method 2025-04-22 19:29:19 +08:00
yusing
ef483403da security: drop service headers 2025-04-22 19:28:58 +08:00
yusing
0a8aa2b215 fix(oidc): use XFH header from backend for cookie domain 2025-04-22 09:57:44 +08:00
yusing
5a984f5c0c chore: remove unused debugging printing 2025-04-22 09:54:19 +08:00
yusing
d60688c66f fix(route): error not being returned 2025-04-22 09:18:25 +08:00
yusing
23482da259 fix(route): panic on middleware error 2025-04-22 07:18:51 +08:00
yusing
62776229cb refactor(oidc): simplify initiialization flow, replace go-oidc with own forked version 2025-04-22 04:14:40 +08:00
yusing
36fab0cd50 fix(oidc): simplify and fix oidc middleware url handling 2025-04-22 03:55:43 +08:00
yusing
8f03662982 chore: upgrade go to 1.24.2 and dependencies 2025-04-22 03:21:42 +08:00
yusing
aad44031c4 chore: add rule files and debug dir to .gitignore 2025-04-22 03:19:05 +08:00
yusing
51813e6030 refactor(agent): replace deprecated context import with standard context package 2025-04-02 15:31:50 +08:00
yusing
f661907268 refactor(agent): streamline certificate and server handling in StartAgentServer function 2025-03-29 16:44:23 +08:00
yusing
be85633c32 fix(agent): fix agent host validatation and improve file path handling 2025-03-29 16:44:16 +08:00
yusing
392946fe33 fix(agent): fix generating incorrect cert values for shell command 2025-03-29 16:27:25 +08:00
yusing
671024965f fix(agent): initialize logger and start system info polling in main.go 2025-03-29 16:26:37 +08:00
yusing
8d9aef3cd5 fix: improve install-agent.sh script with better error handling, support for arm64 architecture 2025-03-29 16:26:08 +08:00
yusing
b5b4f0453a chore: remove unused AgentRegistrationPort environment variable from env.go 2025-03-29 16:10:33 +08:00
yusing
8ca6ac2752 fix: correct service section header in install-agent.sh 2025-03-29 15:47:29 +08:00
yusing
27f7e08e18 build trigger 2025-03-29 15:38:33 +08:00
yusing
f3e08dc9ea chore: remove outdated note about ongoing feature branch from README 2025-03-29 10:07:16 +08:00
yusing
e3797ea96b fix: update permissions in agent-binary workflow to allow write access for contents 2025-03-29 09:36:56 +08:00
yusing
146e7781be fix: limit redirect count when parsing html for favicon, fix url sanitize method 2025-03-29 09:35:12 +08:00
yusing
d2e2086540 fix: loading_page html 2025-03-29 08:21:26 +08:00
yusing
d105f866ff feat: enhance idlewaker loading page design and add favicon handling in waker_http, removed unnecessary checkings 2025-03-29 08:18:58 +08:00
yusing
1c001ed9df refactor: clean up logger and metric initialization flow 2025-03-29 02:59:40 +08:00
yusing
366c89164f chore: remove prometheus router metrics and related initialization code 2025-03-29 02:59:40 +08:00
yusing
36f13c61bb test: add tests for route validation scenarios 2025-03-29 02:59:40 +08:00
yusing
c8935102c3 feat: add validation for localhost routes to prevent usage of godoxy port causing self recursion 2025-03-29 02:59:40 +08:00
yusing
a9e4f82e30 refactor: move typescript stuff to 'schemas' directory 2025-03-28 09:28:02 +08:00
yusing
f966ca8b83 feat: update cookie security settings to use API_JWT_SECURE environment variable 2025-03-28 08:51:45 +08:00
yusing
2da7ea56d5 deps upgrade 2025-03-28 08:45:59 +08:00
yusing
232f720e77 refactor: use stretchr/testify, replace ExpectBytesEqual and ExpectDeepEqual with ExpectEqual in tests 2025-03-28 08:45:06 +08:00
yusing
2f476603d3 feat: implement experimental BackScanner for reading files backward and add tests for various scenarios 2025-03-28 08:14:06 +08:00
yusing
366fede517 feat: add websocket writer and error handling utilities 2025-03-28 08:14:06 +08:00
yusing
7ef8354eb0 feat: enhance route handling with agent support and refactor port selection mapping 2025-03-28 08:14:06 +08:00
yusing
fbb07011f1 refactor: update homepage item handling and improve JSON marshaling 2025-03-28 08:14:06 +08:00
yusing
a7da8ffb90 refactor: clean up code and fix race condition in idlewatcher 2025-03-28 08:14:06 +08:00
yusing
95fe294f7d feat: add AgentProvider implementation and integrate with provider types 2025-03-28 08:14:06 +08:00
yusing
cdb3ffe439 refactor: clean up code and enhance utilities with new functions 2025-03-28 08:14:06 +08:00
yusing
7707fc6f36 api: docker endpoints 2025-03-28 08:14:06 +08:00
yusing
765328affb api: system metrics endpoint 2025-03-28 08:14:06 +08:00
yusing
3c515b0258 feat: predefined docker image blacklist, avoid proxing service backends, refactor 2025-03-28 08:14:06 +08:00
yusing
c6f65ba69f feat: agent as docker provider, drop / reload routes when docker connection state changed, refactor 2025-03-28 08:14:06 +08:00
yusing
8c9a2b022b feat: agent health monitor 2025-03-28 08:14:06 +08:00
yusing
2e8248cd5b fix: race condition in health monitor 2025-03-28 08:14:06 +08:00
yusing
2b91d99ec6 refactor: remove unused old code 2025-03-28 08:14:06 +08:00
yusing
f7688a942a misc: schemas update 2025-03-28 08:14:06 +08:00
yusing
574056a7e3 metrics: metric utils 2025-03-28 07:47:58 +08:00
yusing
84e8dc0e06 refactor: improved config initialization flow, add agent config 2025-03-28 07:47:28 +08:00
yusing
fb8ce6c878 metrics: start polling uptime metrics 2025-03-28 07:42:31 +08:00
yusing
d961c11eb7 api: health endpoint support plain or ws based on request 2025-03-28 07:39:26 +08:00
yusing
90f8e82f14 refactor: error http handling 2025-03-28 07:39:26 +08:00
yusing
14bb66d12f env: remove LOG_STREAMING and DEBUG_MEM_LOGGER 2025-03-28 07:39:26 +08:00
yusing
7093985b57 refactor: clean up api handler 2025-03-28 07:39:26 +08:00
yusing
a557684542 api: manual cert renewal support, new api endpoint 2025-03-28 07:39:26 +08:00
yusing
b0876331e6 refactor: rename api/v1/file.go to config_file.go, updated error handling 2025-03-28 07:39:26 +08:00
yusing
cba7338d8d auth: support for end_session_endpoint discovery, remove OIDC_LOGOUT_URL 2025-03-28 07:39:26 +08:00
yusing
f72d9aee80 auth: implement block page on invalid credentials 2025-03-28 07:39:26 +08:00
yusing
480fb4818c api: allow authentication when on http 2025-03-28 07:39:26 +08:00
yusing
78a3c8a8e4 api: add DEBUG_DISABLE_AUTH for debugging 2025-03-28 07:39:26 +08:00
yusing
9cb7cc84ee refactor: move profiling code to pprof_*.go 2025-03-28 07:39:26 +08:00
yusing
2f24a1db41 api: generate random jwt secret if not present, remove unused imports 2025-03-28 07:39:26 +08:00
yusing
4a2cc70b52 refactor: rename module 'err' to 'gperr' and use gphttp error handling 2025-03-28 07:39:26 +08:00
yusing
3021672de5 refactor: move atomic.Value to value.go, improved handling for zero values 2025-03-28 07:39:26 +08:00
yusing
5d2df3550b refactor: remove forward auth, move module net/http to net/gphttp 2025-03-28 07:39:26 +08:00
yusing
c0c6e21a16 refactor: improved json loading flow and log messages 2025-03-28 07:39:26 +08:00
yusing
8c03c5e82e refactor: improved memlogger and remove html log formatting 2025-03-28 07:39:26 +08:00
yusing
dfd2f3962c refactor: move api/v1/utils to net/gphttp 2025-03-28 07:39:26 +08:00
yusing
d315710310 refactor: improved reverse proxy performance, reduce memory allocation calls 2025-03-28 07:39:26 +08:00
yusing
3424cc4e51 refactor: simplfy and move net/http/server to net/gphttp/server 2025-03-28 07:39:26 +08:00
yusing
361931ed96 refactor: rename module 'err' to 'gperr' in references 2025-03-28 07:39:26 +08:00
yusing
e4f6994dfc autocert: refactor and add pseudo provider for testing 2025-03-28 07:39:26 +08:00
yusing
827a27911c metrics: implement uptime and system metrics 2025-03-28 07:39:22 +08:00
yusing
1e39d0b186 refactor: improved init flow in main 2025-03-28 07:38:12 +08:00
yusing
fd223c7542 refactor: utils.WaitExit 2025-03-28 05:59:04 +08:00
yusing
40aa937f54 refactor: rename module 'err' to 'gperr' 2025-03-28 05:57:43 +08:00
yusing
47ab6b8a92 feat: godoxy agent 2025-03-28 03:36:35 +08:00
yusing
7420abf175 misc: update gitignore and trunk, remove next-release.md 2025-03-28 03:28:17 +08:00
yusing
e9a8194cf8 refactor: cleanup setup script 2025-03-28 03:26:04 +08:00
yusing
9006049d33 cicd: simplify and optimize Dockerfile, bump Go version to 1.24.1 2025-03-28 03:25:17 +08:00
yusing
39381a17de cicd: update github workflow 2025-03-28 03:24:02 +08:00
yusing
9460549eff docs: README update 2025-03-28 03:23:06 +08:00
yusing
5ea82645ef examples: add GODOXY_FRONTEND_PORT environment variable 2025-03-28 03:21:34 +08:00
yusing
597abc5b06 deps upgrade 2025-03-28 03:20:46 +08:00
yusing
350265e31f merge feat/godoxy-agent and update README 2025-03-28 03:13:59 +08:00
yusing
5680a306ff refactor: fix logout logic in oidc middleware 2025-03-28 02:19:46 +08:00
yusing
16cb09bda5 types: schema update 2025-03-28 02:11:00 +08:00
yusing
9a3c40f6a6 refactor: fix tests 2025-03-28 01:58:26 +08:00
yusing
821e4a225a middleware: use status 308 instead of 301 for redirectHTTP 2025-03-28 01:51:50 +08:00
yusing
939c99b0cf api: close connection on return 2025-03-28 01:44:11 +08:00
yusing
79b9c7011d deps upgrade 2025-03-28 01:33:06 +08:00
yusing
e7ff7402b4 fix Makefile 2025-03-23 00:05:10 +08:00
yusing
91f6369ba9 deps upgrade 2025-03-22 23:58:58 +08:00
yusing
17ef5cb9a5 security: sanitize uri 2025-03-22 23:58:37 +08:00
yusing
e8109f1b78 deps upgrade 2025-03-22 23:53:47 +08:00
yusing
f3840d56af security: sanitize path and uri 2025-03-22 23:53:33 +08:00
yusing
4a5e0b8d81 remove NEXT_PUBLIC_APP_BASE_DOMAIN 2025-03-21 05:55:11 +08:00
yusing
4ef29f027e deps upgrade 2025-03-17 05:40:42 +08:00
yusing
d4d2efe925 idlewatcher/waker: refactor 2025-03-08 07:59:10 +08:00
yusing
1078731f2d docker: refactor container related code 2025-03-08 07:10:53 +08:00
yusing
1739afae24 idlewatcher/waker: refactor, cleanup and fix 2025-03-08 07:06:57 +08:00
yusing
9f0c29c009 remove unused params 2025-03-08 04:47:24 +08:00
yusing
6220d02f32 api: log api error 2025-03-07 02:11:52 +08:00
yusing
c166b12515 upgrade go to 1.24.1, deps upgrade 2025-03-05 08:26:22 +08:00
yusing
189c870630 fix docker client panic introduced in last patch 2025-03-02 21:59:32 +08:00
yusing
cdead9ba8a fix makefile 2025-03-02 21:56:22 +08:00
yusing
21616f4d42 update agent docs 2025-03-01 17:06:30 +08:00
yusing
0a348278ca deps upgrade 2025-03-01 16:31:11 +08:00
yusing
98d0c9a4f6 update Makefile, removed old stuff 2025-03-01 16:31:03 +08:00
yusing
34a3739545 docker: fix docker client data race on Close() 2025-03-01 16:04:39 +08:00
yusing
7bb34b8788 fix redirectHTTP middleware test 2025-03-01 15:53:33 +08:00
yusing
f6dc432419 refactor: fix code formatting and return flow 2025-03-01 15:50:50 +08:00
yusing
9b2ee628aa fix docker client data race on Close(), remove SharedClient.IsConnected 2025-03-01 15:47:08 +08:00
yusing
357ad26a0e reduce docker client initiation 2025-03-01 15:39:25 +08:00
yusing
a3e705373c deps upgrade 2025-03-01 15:34:53 +08:00
yusing
71ad13256e fix redirectHTTP middleware, add bypass.user_agents option 2025-03-01 15:29:33 +08:00
yusing
68929631f2 fix redirectHTTP middleware, add bypass.user_agents option 2025-03-01 04:55:29 +08:00
yusing
9c04065c33 utils.io: revert last change 2025-03-01 04:32:24 +08:00
yusing
09db57db8f remove unused env 'IsProduction' 2025-02-27 05:29:54 +08:00
yusing
f9b7e64d53 oidc: use 'end_session_endpoint' from discovery, remove 'OIDC_LOGOUT_URL' 2025-02-27 05:27:38 +08:00
yusing
50262f2acc deps upgrade 2025-02-27 04:55:58 +08:00
yusing
a4d99b54af add a block page to oidc on invallid credentials, fix inifinite login redirect 2025-02-27 01:18:47 +08:00
yusing
485aa0f52b fix server initialization 2025-02-27 00:58:24 +08:00
yusing
f8b732c9b8 fix app url when using fqdn alias 2025-02-26 01:46:46 +08:00
yusing
ac72f77a74 homepage: refactor and fix overrides not being applied 2025-02-25 11:31:06 +08:00
yusing
626d48d151 fix dependabot messing with the nightly image 2025-02-25 10:26:45 +08:00
dependabot[bot]
07511281b8 Bump github.com/go-jose/go-jose/v4 from 4.0.4 to 4.0.5 (#67)
Bumps [github.com/go-jose/go-jose/v4](https://github.com/go-jose/go-jose) from 4.0.4 to 4.0.5.
- [Release notes](https://github.com/go-jose/go-jose/releases)
- [Changelog](https://github.com/go-jose/go-jose/blob/main/CHANGELOG.md)
- [Commits](https://github.com/go-jose/go-jose/compare/v4.0.4...v4.0.5)

---
updated-dependencies:
- dependency-name: github.com/go-jose/go-jose/v4
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-25 10:23:42 +08:00
yusing
7c11c9c91a update webui screenshot 2025-02-25 06:06:06 +08:00
yusing
2cabe4c416 update README and screenshots 2025-02-25 06:01:44 +08:00
yusing
dc88a037eb small memory usage optimization 2025-02-25 05:39:21 +08:00
yusing
2fe8531e51 deps upgrade 2025-02-25 05:39:07 +08:00
Yuzerion
fddd2651fc Update README.md 2025-02-25 05:38:18 +08:00
yusing
deb0781871 README and screenshots update 2025-02-25 05:34:38 +08:00
yusing
8114b04ab6 fix data race for system info 2025-02-25 04:29:17 +08:00
yusing
767560804d idlewatcher: refactor and fix data race 2025-02-25 04:29:07 +08:00
yusing
8074b93992 clarify setup script message 2025-02-25 03:44:44 +08:00
Yuzerion
588dd41244 Update README.md 2025-02-25 00:03:20 +08:00
yusing
61b0147a7c fix cloudflare real ip middleware data race 2025-02-24 19:50:04 +08:00
yusing
0d388a396c fix data race 2025-02-24 19:24:46 +08:00
yusing
135c79d2ad fix makefile for pprof 2025-02-24 18:54:16 +08:00
yusing
9925b042d8 bring back database check 2025-02-24 08:46:59 +08:00
yusing
1d16d514c7 fix empty homepage name, incorrect image parsing, refactor 2025-02-24 08:42:10 +08:00
yusing
bda547198e improved docker reconnect mechanism, removed redundant checkings, refactor 2025-02-24 07:50:23 +08:00
yusing
5f1b78ec84 allow agent without docker connected 2025-02-24 07:35:28 +08:00
yusing
b7e9a85be0 implement docker image blacklist 2025-02-24 06:47:07 +08:00
yusing
080c1cee4f replace deprecated docker types 2025-02-24 06:10:46 +08:00
yusing
baebede816 fix loggers 2025-02-24 05:44:48 +08:00
yusing
f455251645 metrics: fix metrics collection 2025-02-24 05:36:28 +08:00
yusing
8d06f7cf02 fix github action caching 2025-02-24 04:31:13 +08:00
yusing
4af2eaa6a3 fixed http server context handling 2025-02-24 04:20:00 +08:00
yusing
f5b8879b87 increase task timeout 2025-02-24 03:44:20 +08:00
yusing
7501fee448 cicd: fix workflow 2025-02-24 03:32:15 +08:00
yusing
b7b5090673 metrics: fix not using context 2025-02-24 03:28:47 +08:00
yusing
4f94a0f08a improved add agent mechanism 2025-02-24 03:28:23 +08:00
yusing
2281c8ac39 skip version check for dev versions 2025-02-24 03:27:50 +08:00
yusing
2cc152d0ab cicd: switch to use registry cache instead of gha cache 2025-02-24 02:52:15 +08:00
yusing
7b86bb262c fix setup script 2025-02-24 00:02:50 +08:00
yusing
ed2a4251f1 fix setup script 2025-02-24 00:00:05 +08:00
yusing
847811a52c rename go-proxy to godoxy 2025-02-23 14:27:25 +08:00
yusing
d25d5b734c fix agent install script 2025-02-23 14:24:41 +08:00
yusing
bc4792b7fd add quotes to agent install script 2025-02-23 13:54:47 +08:00
yusing
7850cbc4bf fix setup script 2025-02-23 13:48:40 +08:00
yusing
97fa648b2f fix setup script 2025-02-23 13:47:30 +08:00
yusing
c5cf867cd9 update default repo to main 2025-02-23 13:31:27 +08:00
yusing
03ea9bb760 update default image name 2025-02-23 13:28:35 +08:00
yusing
a1a5bf921e workflow update 2025-02-23 13:27:47 +08:00
yusing
3e1a7a0dc5 docker: clear routes on docker disconnect, reload routes on connection restore 2025-02-23 13:11:21 +08:00
yusing
2c21387ad9 implement system mode agent 2025-02-23 11:26:38 +08:00
yusing
5e8e4fa4a1 remove old code 2025-02-23 07:22:15 +08:00
yusing
a41107d021 fix markdown formatting 2025-02-23 06:40:02 +08:00
yusing
281523ee06 update default repo to main 2025-02-23 06:37:48 +08:00
yusing
2504510c61 update default image name 2025-02-23 06:36:20 +08:00
yusing
7153fc8bb5 workflow update 2025-02-23 06:28:34 +08:00
yusing
3af094d788 remove url from homepage item override 2025-02-23 01:38:32 +08:00
yusing
785ea71a20 add example to override app base domain in dashboard 2025-02-23 00:08:24 +08:00
yusing
05d2f77c0c refactor docker api code, deps upgrade 2025-02-22 04:51:07 +08:00
yusing
e22366e524 api: implement several docker apis 2025-02-20 18:03:54 +08:00
yusing
2b51c47846 reduce docker client initiation 2025-02-20 18:02:34 +08:00
yusing
dd6af9b8e0 debug: add option to disable auth 2025-02-20 17:45:47 +08:00
yusing
c66b17583f small refactor 2025-02-20 17:45:03 +08:00
yusing
3ce3520c45 middleware trace: fix incorrect log level 2025-02-20 17:44:47 +08:00
yusing
8d1e7f4331 fix sensor data not being returned to api 2025-02-19 15:30:22 +08:00
yusing
f0b04afa11 refactor and fix homepage override not correctly loaded 2025-02-19 14:58:52 +08:00
yusing
f1bfd13da3 fix cloudflare real ip middleware resolving local addresses 2025-02-19 00:36:44 +08:00
yusing
161cd84150 fix cloudflare real ip middleware resolving local addresses 2025-02-19 00:32:13 +08:00
yusing
da39593c15 add agent to schema 2025-02-18 21:11:24 +08:00
yusing
571f36e405 go.modL add comments explaining dependencies usage 2025-02-18 21:11:24 +08:00
yusing
a4b1200475 deps upgrade 2025-02-18 21:11:24 +08:00
yusing
43807dcba9 autocert: add porkbun cert provider 2025-02-18 21:11:24 +08:00
yusing
99a72451d9 fix access log rotation attempt 2025-02-18 21:11:20 +08:00
yusing
b8900999a4 deps upgrade 2025-02-18 16:39:25 +08:00
yusing
e6f77376b9 fix args.go affected from cherry-pick 2025-02-18 16:35:23 +08:00
yusing
b2a6a20f10 simplify setup with script 2025-02-18 05:43:33 +08:00
yusing
265b52dccb simplify setup with script 2025-02-18 05:39:15 +08:00
yusing
0c112e1db1 allow using auth without https 2025-02-18 04:15:47 +08:00
yusing
8eef7db1c6 trim and convert alias and host to lowercase 2025-02-18 04:07:21 +08:00
yusing
05cbf99237 trim and convert alias and host to lowercase 2025-02-18 02:32:31 +08:00
yusing
651a7cf83e enable auth by default with temporary random JWT 2025-02-18 02:27:45 +08:00
yusing
ee27237083 simplify access logger with bufio.Writer 2025-02-18 01:12:42 +08:00
yusing
72306e91a2 fix loadbalancing for agents 2025-02-17 17:39:38 +08:00
yusing
75d272be14 fix loadbalancing when two container have the same alias 2025-02-17 11:16:34 +08:00
yusing
a8a209f0b0 simplify some code and implement metrics storage 2025-02-17 07:18:59 +08:00
yusing
1b7b6196c5 reduce memory usage and improve performance 2025-02-17 05:05:53 +08:00
yusing
ed7937a026 system metrics aggregation 2025-02-16 23:38:19 +08:00
yusing
f2de4692ea fix system info timestamp 2025-02-16 04:18:19 +08:00
yusing
16b046bd44 add cert info and renewal api 2025-02-15 21:50:34 +08:00
yusing
7129e2cc9d remove unused code 2025-02-15 11:35:36 +08:00
yusing
01432fa778 improve initialization flow 2025-02-15 11:21:29 +08:00
yusing
9731d28ec3 fix server responding incorrect status code 2025-02-15 09:18:17 +08:00
yusing
99fbb31554 fix routes not started after adding agent 2025-02-15 09:10:42 +08:00
yusing
18d258aaa2 refactor and organize code 2025-02-15 05:44:47 +08:00
yusing
1af6dd9cf8 uses display name in uptime metrics, small refactor 2025-02-15 00:29:17 +08:00
yusing
0da183f084 update screenshots 2025-02-14 22:20:51 +08:00
yusing
205726b045 refactor 2025-02-14 22:04:45 +08:00
yusing
9cd5237bb8 remove add_agent command 2025-02-14 21:56:12 +08:00
yusing
964e94b3ba fix incorrect uptime history data 2025-02-14 21:30:49 +08:00
yusing
9f54f40f5a simplify setup process with WebUI 2025-02-14 20:14:16 +08:00
yusing
7047d37f70 fix system info metric crash on error 2025-02-14 12:36:45 +08:00
yusing
5b1d45a8fe fix http superfluous response.WriteHeader 2025-02-14 12:36:45 +08:00
yusing
a319957f3e fix duplicated routes not being shown 2025-02-14 12:36:45 +08:00
yusing
816166a30a fix feature not supported errors 2025-02-14 12:36:45 +08:00
yusing
5dd2ea776a remove unused code 2025-02-14 05:17:02 +08:00
yusing
3b94c7bb43 add buffering to docker watcher 2025-02-14 05:16:56 +08:00
yusing
f0198616ad improve error handling for system info metrics 2025-02-14 03:49:56 +08:00
yusing
267fd403da fix tcp/udp listening url 2025-02-14 03:28:58 +08:00
yusing
0a8bb7eae5 improved default port selection 2025-02-14 03:26:25 +08:00
yusing
409048c206 simplify code and fix metrics response 2025-02-14 02:19:58 +08:00
yusing
f84bd6a1e8 fix 5m period, fix websocket not responding on no data 2025-02-14 01:57:26 +08:00
yusing
d5c0e62be1 autocert: add porkbun cert provider 2025-02-13 23:48:35 +08:00
yusing
40c4344f73 poller error formatting 2025-02-13 23:31:00 +08:00
yusing
3bd8aca2d2 period: change "1m" to "1mo" to avoid confusion 2025-02-13 21:18:30 +08:00
yusing
a21bdedbc1 go.modL add comments explaining dependencies usage 2025-02-13 19:40:15 +08:00
yusing
797ebd7771 update next release md 2025-02-13 19:30:23 +08:00
yusing
04e9ecbc76 README.md: Update README.md 2025-02-13 19:26:23 +08:00
yusing
41d37579dc small refactor 2025-02-13 18:52:00 +08:00
yusing
10d23828a7 metrics: fix 5m period 2025-02-13 18:47:17 +08:00
yusing
19e3392825 improve reverse proxy and serverhandling
- buffer pool for IO copy
  - flush response after read, now works with event stream
  - fixed error handling for server
2025-02-13 18:39:35 +08:00
yusing
6bf4846ae8 poller: clear errors after logging 2025-02-13 17:23:00 +08:00
yusing
afcd37dac6 remove unnecessary transport.Clone 2025-02-13 17:14:57 +08:00
yusing
c2ff497cc9 revert readme 2025-02-13 17:07:17 +08:00
yusing
decd2c2ded fix various endpoints 2025-02-13 15:05:16 +08:00
yusing
02d1c9ce98 refactor header utils to httpheader package, cleanup api endpoints 2025-02-13 07:32:59 +08:00
yusing
5c9083a5df remove forwardAuth middleware 2025-02-13 07:19:40 +08:00
yusing
3c7fafa91f improved metrics implementation 2025-02-13 05:58:30 +08:00
yusing
fd50f8fcab fix check health for tcp/udp, refactor 2025-02-13 05:58:15 +08:00
yusing
1a93df5886 fix route port udp selection and healthcheck interval 2025-02-13 05:50:49 +08:00
yusing
bdc086c285 increase icon cache ttl to 3 days, remove pruned message when no icon pruned 2025-02-13 03:06:18 +08:00
yusing
82042e0b99 refactor, fix metrics and upgrade go to 1.24.0 2025-02-12 11:15:45 +08:00
yusing
c807b30c8f api: remove service health from prometheus, implement godoxy metrics 2025-02-12 05:30:34 +08:00
yusing
72dc76ec74 api: add system_info endpoint 2025-02-11 12:52:16 +08:00
yusing
71619042fd fix agent hot-reload issue and added list agents endpoint 2025-02-11 12:45:34 +08:00
yusing
429a77de8e refactor, fix reload error when using agents, and other small improvements 2025-02-11 12:15:51 +08:00
yusing
b1f72620dc refactor and properly set idlewaker error in JSON output 2025-02-11 10:14:32 +08:00
yusing
2a54aed135 fix incorrect RequestURI 2025-02-11 09:49:49 +08:00
yusing
040c1f6f78 fix query being duplicated 2025-02-11 09:44:23 +08:00
yusing
07bce90521 fixed some issues 2025-02-11 09:16:21 +08:00
yusing
508b093278 fix health monitor panic 2025-02-11 07:12:08 +08:00
yusing
9bed5bf872 fix agent json marshal 2025-02-11 06:27:27 +08:00
yusing
6d0a2cd301 fix serving wrong cert 2025-02-11 06:20:09 +08:00
yusing
e1ee08361d api: added network and sensors system info 2025-02-11 05:26:37 +08:00
yusing
3332ce34c5 simplify setup process 2025-02-11 05:05:56 +08:00
yusing
2c57e439d5 fixed a few stuff 2025-02-11 01:10:09 +08:00
yusing
73e2660e59 agent: add system-info endpoint 2025-02-11 01:10:09 +08:00
yusing
9120bbea34 change default agent name to hostname 2025-02-11 01:10:09 +08:00
yusing
58ea9750d7 Update next-release 2025-02-11 01:10:09 +08:00
yusing
a59ad97e5e Fix dockerfile and makefile 2025-02-11 01:10:09 +08:00
yusing
0a7b28caf5 refactor and remove unused code 2025-02-11 01:10:09 +08:00
yusing
eaf191e350 implement godoxy-agent 2025-02-11 01:10:09 +08:00
yusing
ecb89f80a0 update files for agent, deps upgrade 2025-02-11 01:10:07 +08:00
yusing
9626b65593 deps upgrade 2025-02-11 00:56:17 +08:00
yusing
c9b5516330 fix wildcard alias and some tests 2025-02-11 00:47:43 +08:00
yusing
4363ca88aa fix file server validation 2025-02-11 00:47:43 +08:00
Yuzerion
3353060ad4 Update README.md 2025-02-07 15:58:51 +08:00
yusing
ddc3b8575e fix startup panic when no notification provider is set 2025-02-07 03:07:21 +08:00
yusing
136a2ec89f remove some debug logging 2025-02-07 01:08:42 +08:00
yusing
021c68f2a7 update README 2025-02-06 18:31:49 +08:00
yusing
989a09274f restore notification 2025-02-06 18:25:39 +08:00
yusing
39c5886d7a make rules.name optional 2025-02-06 18:25:39 +08:00
Yuzerion
1a5f3735cf Feat/fileserver (#60)
* cleanup code for URL type

* fix makefile for trace mode

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

* Implement fileserver.

* refactor: rename HTTPRoute to ReverseProxyRoute to avoid confusion

* refactor: move metrics logger to middleware package

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

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

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

* rename rule.on.bypass to pass

* refactor and fixed map-to-map  deserialization

* updated route loading logic

* schemas: add "add_prefix" option to modify_request middleware


* updated route JSONMarshalling

---------

Co-authored-by: yusing <yusing@6uo.me>
2025-02-06 18:23:10 +08:00
yusing
4d47eb0e91 update compose example 2025-02-06 05:59:21 +08:00
yusing
af7c59b5c2 add tests for rules.on 2025-02-06 05:50:03 +08:00
yusing
693bf68864 rules: updated help message, make values optional, fixes tests 2025-02-06 05:13:47 +08:00
Yuzerion
c9ddf3d165 Create FUNDING.yml 2025-02-06 04:44:19 +08:00
yusing
1549b56866 README: move auth docs to wiki 2025-02-06 03:12:34 +08:00
yusing
2cd1f22e68 add test for the previous commit 2025-02-06 02:33:30 +08:00
yusing
688f38943d fix single line yaml list treated as comma seperated list 2025-02-06 01:58:45 +08:00
yusing
043bbd7a11 readme and docker compose example amendment 2025-02-06 00:56:11 +08:00
440 changed files with 21897 additions and 8722 deletions

View File

@@ -1,23 +1,26 @@
# set timezone to get correct log timestamp
TZ=ETC/UTC
# API JWT Configuration (common)
# generate secret with `openssl rand -base64 32`
GODOXY_API_JWT_SECRET=
# the JWT token time-to-live
# leave empty to use default (24 hours)
# format: https://pkg.go.dev/time#Duration
GODOXY_API_JWT_TOKEN_TTL=
# API/WebUI user password login credentials (optional)
# These fields are not required for OIDC authentication
GODOXY_API_USER=admin
GODOXY_API_PASSWORD=password
# generate secret with `openssl rand -base64 32`
GODOXY_API_JWT_SECRET=
# the JWT token time-to-live
GODOXY_API_JWT_TOKEN_TTL=1h
# OIDC Configuration (optional)
# Uncomment and configure these values to enable OIDC authentication.
# For `GODOXY_OIDC_SCOPES` you may also include `offline_access` if your Idp supports it (e.g. Authentik)
#
# 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.
@@ -42,6 +45,9 @@ GODOXY_HTTPS_ADDR=:443
# API listening address
GODOXY_API_ADDR=127.0.0.1:8888
# Frontend listening port
GODOXY_FRONTEND_PORT=3000
# Prometheus Metrics
GODOXY_PROMETHEUS_ENABLED=true

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

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

51
.github/workflows/agent-binary.yml vendored Normal file
View 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 }}

View 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
View File

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

View File

@@ -1,128 +1,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 }}

10
.gitignore vendored
View File

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

0
.gitmodules vendored
View File

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
# Stage 1: Builder
FROM golang:1.23.5-alpine AS builder
# Stage 1: deps
FROM golang:1.24.2-alpine AS deps
HEALTHCHECK NONE
# package version does not matter
@@ -11,30 +11,33 @@ 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
COPY Makefile /src/
COPY cmd /src/cmd
COPY internal /src/internal
COPY pkg /src/pkg
WORKDIR /src
COPY Makefile ./
COPY cmd ./cmd
COPY internal ./internal
COPY pkg ./pkg
COPY agent ./agent
ARG VERSION
ENV VERSION=${VERSION}
ARG BUILD_FLAGS
ENV BUILD_FLAGS=${BUILD_FLAGS}
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"
@@ -53,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"]

106
Makefile
View File

@@ -4,37 +4,59 @@ export GOOS = linux
LDFLAGS = -X github.com/yusing/go-proxy/pkg.version=${VERSION}
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 = -tags production
BUILD_FLAGS += -gcflags=all='-N -l' -tags debug
else ifeq ($(pprof), 1)
CGO_ENABLED = 1
GODEBUG = gctrace=1 inittrace=1 schedtrace=3000
GORACE = log_path=logs/pprof strip_path_prefix=$(shell pwd)/
BUILD_FLAGS = -race -gcflags=all='-N -l' -tags pprof
DOCKER_TAG = pprof
VERSION += -pprof
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
DOCKER_TAG = latest
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
export DOCKER_TAG
ifeq ($(shell id -u), 0)
SETCAP_CMD = setcap
else
SETCAP_CMD = sudo setcap
endif
.PHONY: debug
test:
GODOXY_TEST=1 go test ./internal/...
@@ -44,14 +66,17 @@ get:
build:
mkdir -p bin
go build ${BUILD_FLAGS} -o bin/godoxy ./cmd
if [ $(shell id -u) -eq 0 ]; \
then setcap CAP_NET_BIND_SERVICE=+eip bin/godoxy; \
else sudo setcap CAP_NET_BIND_SERVICE=+eip bin/godoxy; \
fi
go build ${BUILD_FLAGS} -o bin/${NAME} ${CMD_PATH}
# CAP_NET_BIND_SERVICE: permission for binding to :80 and :443
$(SETCAP_CMD) CAP_NET_BIND_SERVICE=+ep bin/${NAME}
run:
[ -f .env ] && godotenv -f .env go run ${BUILD_FLAGS} ./cmd
[ -f .env ] && godotenv -f .env go run ${BUILD_FLAGS} ${CMD_PATH}
debug:
make NAME="godoxy-test" debug=1 build
sh -c 'HTTP_ADDR=:81 HTTPS_ADDR=:8443 API_ADDR=:8899 DEBUG=1 bin/godoxy-test'
mtrace:
bin/godoxy debug-ls-mtrace > mtrace.json
@@ -71,55 +96,8 @@ 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:${DOCKER_TAG} \
-t docker.io/yusing/godoxy-nightly:${VERSION}-${BUILD_DATE} \
--build-arg VERSION="${VERSION}-nightly-${BUILD_DATE}" \
--build-arg BUILD_FLAGS="${BUILD_FLAGS}" \
--push .
build-docker:
docker build -t godoxy-nightly \
--build-arg VERSION="${VERSION}-nightly-${BUILD_DATE}" \
--build-arg BUILD_FLAGS="${BUILD_FLAGS}" \
.
# To generate schema
# comment out this part from typescript-json-schema.js#L884
#
# if (indexType.flags !== ts.TypeFlags.Number && !isIndexedObject) {
# throw new Error("Not supported: IndexSignatureDeclaration with index symbol other than a number or a string");
# }
gen-schema-single:
bun --bun run typescript-json-schema --noExtraProps --required --skipLibCheck --tsNodeRegister=true -o schemas/${OUT} schemas/${IN} ${CLASS}
# minify
python3 -c "import json; f=open('schemas/${OUT}', 'r'); j=json.load(f); f.close(); f=open('schemas/${OUT}', 'w'); json.dump(j, f, separators=(',', ':'));"
gen-schema:
bun --bun tsc
make IN=config/config.ts \
CLASS=Config \
OUT=config.schema.json \
gen-schema-single
make IN=providers/routes.ts \
CLASS=Routes \
OUT=routes.schema.json \
gen-schema-single
make IN=middlewares/middleware_compose.ts \
CLASS=MiddlewareCompose \
OUT=middleware_compose.schema.json \
gen-schema-single
make IN=docker.ts \
CLASS=DockerRoutes \
OUT=docker_routes.schema.json \
gen-schema-single
update-schema-generator:
pnpm up -g typescript-json-schema
link-binary:
ln -s /app/${NAME} bin/run
push-github:
git push origin $(shell git rev-parse --abbrev-ref HEAD)

202
README.md
View File

@@ -1,19 +1,22 @@
<div align="center">
# GoDoxy
[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=yusing_go-proxy&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy)
[![Lines of Code](https://sonarcloud.io/api/project_badges/measure?project=yusing_go-proxy&metric=ncloc)](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy)
[![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=yusing_go-proxy&metric=security_rating)](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy)
[![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=yusing_go-proxy&metric=sqale_rating)](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy)
[![Vulnerabilities](https://sonarcloud.io/api/project_badges/measure?project=yusing_go-proxy&metric=vulnerabilities)](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy)
[![](https://dcbadge.limes.pink/api/server/umReR62nRd)](https://discord.gg/umReR62nRd)
[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=yusing_go-proxy&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=yusing_godoxy)
![GitHub last commit](https://img.shields.io/github/last-commit/yusing/godoxy)
[![Lines of Code](https://sonarcloud.io/api/project_badges/measure?project=yusing_go-proxy&metric=ncloc)](https://sonarcloud.io/summary/new_code?id=yusing_godoxy)
![Demo](https://img.shields.io/website?url=https%3A%2F%2Fgodoxy.demo.6uo.me&label=Demo&link=https%3A%2F%2Fgodoxy.demo.6uo.me)
[![Discord](https://dcbadge.limes.pink/api/server/umReR62nRd?style=flat)](https://discord.gg/umReR62nRd)
[繁體中文文檔請看此](README_CHT.md)
A lightweight, simple, and [performant](https://github.com/yusing/godoxy/wiki/Benchmarks) reverse proxy with WebUI.
A lightweight, easy-to-use, and [performant](https://github.com/yusing/go-proxy/wiki/Benchmarks) reverse proxy with a Web UI and dashboard.
For full documentation, check out **[Wiki](https://github.com/yusing/godoxy/wiki)**
![Screenshot](https://github.com/user-attachments/assets/4bb371f4-6e4c-425c-89b2-b9e962bdd46f)
**EN** | <a href="README_CHT.md">中文</a>
_Join our [Discord](https://discord.gg/umReR62nRd) for help and discussions_
<img src="screenshots/webui.jpg" style="max-width: 650">
</div>
## Table of content
@@ -21,100 +24,139 @@ _Join our [Discord](https://discord.gg/umReR62nRd) for help and discussions_
- [GoDoxy](#godoxy)
- [Table of content](#table-of-content)
- [Running demo](#running-demo)
- [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)
- [Setup](#setup)
- [How does GoDoxy work](#how-does-godoxy-work)
- [Screenshots](#screenshots)
- [idlesleeper](#idlesleeper)
- [Metrics and Logs](#metrics-and-logs)
- [Manual Setup](#manual-setup)
- [Folder structrue](#folder-structrue)
- [Build it yourself](#build-it-yourself)
## Running demo
<https://godoxy.demo.6uo.me>
[![Deployed on Zeabur](https://zeabur.com/deployed-on-zeabur-dark.svg)](https://zeabur.com/referral?referralCode=yusing&utm_source=yusing&utm_campaign=oss)
## Key Features
- Easy to use
- Effortless configuration
- Simple multi-node setup
- Error messages is clear and detailed, easy troubleshooting
- Auto SSL cert management (See [Supported DNS-01 Challenge Providers](https://github.com/yusing/go-proxy/wiki/Supported-DNS%E2%80%9001-Providers))
- Auto configuration for docker containers
- Auto hot-reload on container state / config file changes
- **idlesleeper**: stop containers on idle, wake it up on traffic _(optional, see [screenshots](#idlesleeper))_
- HTTP(s) reserve proxy
- OpenID Connect support
- [HTTP middleware support](https://github.com/yusing/go-proxy/wiki/Middlewares)
- [Custom error pages support](https://github.com/yusing/go-proxy/wiki/Middlewares#custom-error-pages)
- TCP and UDP port forwarding
- **Web UI with App dashboard and config editor**
- Supports linux/amd64, linux/arm64
- Written in **[Go](https://go.dev)**
- **Simple**
- Effortless configuration with [simple labels](https://github.com/yusing/godoxy/wiki/Docker-labels-and-Route-Files) or WebUI
- [Simple multi-node setup](https://github.com/yusing/godoxy/wiki/Configurations#multi-docker-nodes-setup)
- Detailed error messages for easy troubleshooting.
- **ACL**: connection / request level access control
- IP/CIDR
- Country **(Maxmind account required)**
- Timezone **(Maxmind account required)**
- **Access logging**
- **Advanced Automation**
- Automatic SSL certificate management with Let's Encrypt ([using DNS-01 Challenge](https://github.com/yusing/go-proxy/wiki/Supported-DNS%E2%80%9001-Providers))
- Auto-configuration for Docker containers
- Hot-reloading of configurations and container state changes
- **Idle-sleep**: stop and wake containers based on traffic _(see [screenshots](#idlesleeper))_
- Docker containers
- Proxmox LXCs
- **Traffic Management**
- HTTP reserve proxy
- TCP/UDP port forwarding
- **OpenID Connect support**: SSO and secure your apps easily
- **Customization**
- [HTTP middlewares](https://github.com/yusing/go-proxy/wiki/Middlewares)
- [Custom error pages support](https://github.com/yusing/go-proxy/wiki/Middlewares#custom-error-pages)
- **Web UI**
- App Dashboard
- Config Editor
- Uptime and System Metrics
- Docker Logs Viewer
- **Cross-Platform support**
- Supports **linux/amd64** and **linux/arm64**
- **Efficient and Performant**
- Written in **[Go](https://go.dev)**
[🔼Back to top](#table-of-content)
## Prerequisites
## Getting Started
Configure Wildcard DNS Record(s) to point to machine running `GoDoxy`, e.g.
For full documentation, **[See Wiki](https://github.com/yusing/go-proxy/wiki)**
- A Record: `*.domain.com` -> `10.0.10.1`
- AAAA Record (if you use IPv6): `*.domain.com` -> `::ffff:a00:a01`
### Prerequisites
## Setup
Setup DNS Records point to machine which runs `GoDoxy`, e.g.
> [!NOTE]
> GoDoxy is designed to be running in `host` network mode, do not change it.
>
> To change listening ports, modify `.env`.
- A Record: `*.y.z` -> `10.0.10.1`
- AAAA Record: `*.y.z` -> `::ffff:a00:a01`
1. Prepare a new directory for docker compose and config files.
### Setup
2. Run setup script inside the directory, or [set up manually](#manual-setup)
1. Pull the latest docker images
```shell
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/yusing/godoxy/main/scripts/setup.sh)"
```
```shell
docker pull ghcr.io/yusing/go-proxy:latest
```
3. You may now do some extra configuration on WebUI `https://godoxy.yourdomain.com`
2. Create new directory, `cd` into it, then run setup, or [set up manually](#manual-setup)
## How does GoDoxy work
```shell
docker run --rm -v .:/setup ghcr.io/yusing/go-proxy /app/godoxy setup
```
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
3. _(Optional)_ setup WebUI login (skip if you use OIDC)
> [!NOTE]
> GoDoxy uses the label `proxy.aliases` as the subdomain(s), if unset it defaults to the `container_name` field in docker compose.
>
> For example, with the label `proxy.aliases: qbt` you can access your app via `qbt.domain.com`.
- set random JWT secret
## Screenshots
```shell
sed -i "s|API_JWT_SECRET=.*|API_JWT_SECRET=$(openssl rand -base64 32)|g" .env
```
### idlesleeper
- change username and password for WebUI authentication
```shell
USERNAME=admin
PASSWORD=some-password
sed -i "s|API_USERNAME=.*|API_USERNAME=${USERNAME}|g" .env
sed -i "s|API_PASSWORD=.*|API_PASSWORD=${PASSWORD}|g" .env
```
![idlesleeper](screenshots/idlesleeper.webp)
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`
### Metrics and Logs
5. Start the container `docker compose up -d`
<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>
6. You may now do some extra configuration on WebUI `https://gp.y.z`
[🔼Back to top](#table-of-content)
### Manual Setup
## Manual Setup
1. Make `config` directory then grab `config.example.yml` into `config/config.yml`
`mkdir -p config && wget https://raw.githubusercontent.com/yusing/go-proxy/v0.9/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.9/.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.9/compose.example.yml -O compose.yml`
`wget https://raw.githubusercontent.com/yusing/godoxy/main/compose.example.yml -O compose.yml`
### Folder structrue
@@ -130,26 +172,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
![idlesleeper](screenshots/idlesleeper.webp)
[🔼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

View File

@@ -1,19 +1,22 @@
<div align="center">
# GoDoxy
[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=yusing_go-proxy&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy)
![GitHub last commit](https://img.shields.io/github/last-commit/yusing/godoxy)
[![Lines of Code](https://sonarcloud.io/api/project_badges/measure?project=yusing_go-proxy&metric=ncloc)](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy)
[![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=yusing_go-proxy&metric=security_rating)](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy)
[![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=yusing_go-proxy&metric=sqale_rating)](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy)
[![Vulnerabilities](https://sonarcloud.io/api/project_badges/measure?project=yusing_go-proxy&metric=vulnerabilities)](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy)
[![](https://dcbadge.limes.pink/api/server/umReR62nRd)](https://discord.gg/umReR62nRd)
![Demo](https://img.shields.io/website?url=https%3A%2F%2Fgodoxy.demo.6uo.me&label=Demo&link=https%3A%2F%2Fgodoxy.demo.6uo.me)
[![Discord](https://dcbadge.limes.pink/api/server/umReR62nRd?style=flat)](https://discord.gg/umReR62nRd)
[English Documentation](README.md)
輕量、易用、 [高效能](https://github.com/yusing/godoxy/wiki/Benchmarks),且帶有主頁和配置面板的反向代理
一個輕量級、易於使用且[高效能](https://github.com/yusing/go-proxy/wiki/Benchmarks)的反向代理,具有網頁介面和儀表板。
完整文檔請查閱 **[Wiki](https://github.com/yusing/godoxy/wiki)**(暫未有中文翻譯)
![截圖](screenshots/webui.png)
<a href="README.md">EN</a> | **中文**
_加入我們的 [Discord](https://discord.gg/umReR62nRd) 獲取幫助和討論_
<img src="https://github.com/user-attachments/assets/4bb371f4-6e4c-425c-89b2-b9e962bdd46f" style="max-width: 650">
</div>
## 目錄
@@ -21,85 +24,65 @@ _加入我們的 [Discord](https://discord.gg/umReR62nRd) 獲取幫助和討論_
- [GoDoxy](#godoxy)
- [目錄](#目錄)
- [運行示例](#運行示例)
- [主要特點](#主要特點)
- [入門指南](#入門指南)
- [前置需求](#前置需求)
- [安裝](#安裝)
- [前置需求](#前置需求)
- [安裝](#安裝)
- [手動安裝](#手動安裝)
- [資料夾結構](#資料夾結構)
- [在 VSCode 中使用 JSON Schema](#在-vscode-中使用-json-schema)
- [截圖](#截圖)
- [閒置休眠](#閒置休眠)
- [監控](#監控)
- [自行編譯](#自行編譯)
## 運行示例
<https://godoxy.demo.6uo.me>
[![Deployed on Zeabur](https://zeabur.com/deployed-on-zeabur-dark.svg)](https://zeabur.com/referral?referralCode=yusing&utm_source=yusing&utm_campaign=oss)
## 主要特點
- 容易使用
- 輕鬆配置
- 簡單的多節點設置
- 錯誤訊息清晰詳細,易於排除故障
- 自動 SSL 憑證管理(參見 [支援的 DNS-01 驗證提供商](https://github.com/yusing/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 映像
> [!NOTE]
> 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` 進入後運行安裝,或[手動安裝](#手動安裝)
```shell
docker run --rm -v .:/setup ghcr.io/yusing/go-proxy /app/godoxy setup
```
3. _可選_ 設置網頁介面登入
- 設置隨機 JWT 密鑰
```shell
sed -i "s|API_JWT_SECRET=.*|API_JWT_SECRET=$(openssl rand -base64 32)|g" .env
```
- 更改網頁介面認證的使用者名稱和密碼
```shell
USERNAME=admin
PASSWORD=some-password
sed -i "s|API_USERNAME=.*|API_USERNAME=${USERNAME}|g" .env
sed -i "s|API_PASSWORD=.*|API_PASSWORD=${PASSWORD}|g" .env
```
4. _可選_ 設置其他 Docker 節點的 `docker-socket-proxy`(參見 [多 Docker 節點設置](https://github.com/yusing/go-proxy/wiki/Configurations#multi-docker-nodes-setup)),然後在 `config.yml` 中添加它們
5. 啟動容器 `docker compose up -d`
6. 現在您可以進行額外的配置
- 使用文字編輯器(如 Visual Studio Code
- 通過網頁介面 `https://gp.y.z`
3. 現在可以在 WebUI `https://godoxy.yourdomain.com` 進行額外配置
[🔼回到頂部](#目錄)
@@ -107,15 +90,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.9/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.9/.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.9/compose.example.yml -O compose.yml`
`wget https://raw.githubusercontent.com/yusing/godoxy/main/compose.example.yml -O compose.yml`
### 資料夾結構
@@ -131,15 +114,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` 並根據需要修改
[🔼回到頂部](#目錄)
## 截圖
### 閒置休眠
@@ -148,9 +129,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
View 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)
}

View 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
View File

@@ -0,0 +1,190 @@
package agent
import (
"context"
"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"
)
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,
})
}

View 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
View 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)
}
)

View 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
}

View 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)
}

View File

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

View File

@@ -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

View 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
View 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
}

View 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
View 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", "")
)

View File

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

View File

@@ -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)
})
}
}

View 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
}

View 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
}

View File

@@ -0,0 +1,62 @@
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"
)
func ProxyHTTP(w http.ResponseWriter, r *http.Request) {
host := r.Header.Get(agentproxy.HeaderXProxyHost)
isHTTPS, _ := strconv.ParseBool(r.Header.Get(agentproxy.HeaderXProxyHTTPS))
skipTLSVerify, _ := strconv.ParseBool(r.Header.Get(agentproxy.HeaderXProxySkipTLSVerify))
responseHeaderTimeout, err := strconv.Atoi(r.Header.Get(agentproxy.HeaderXProxyResponseHeaderTimeout))
if err != nil {
responseHeaderTimeout = 0
}
if host == "" {
http.Error(w, "missing required headers", http.StatusBadRequest)
return
}
scheme := "http"
if isHTTPS {
scheme = "https"
}
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)
}

View 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, nil, logger)
}

View File

@@ -2,55 +2,48 @@ package main
import (
"encoding/json"
"io"
"log"
"os"
"os/signal"
"syscall"
"time"
"sync"
"github.com/rs/zerolog"
"github.com/yusing/go-proxy/internal"
v1 "github.com/yusing/go-proxy/internal/api/v1"
"github.com/yusing/go-proxy/internal/api/v1/auth"
"github.com/yusing/go-proxy/internal/api/v1/favicon"
"github.com/yusing/go-proxy/internal/api/v1/query"
"github.com/yusing/go-proxy/internal/auth"
"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/homepage"
"github.com/yusing/go-proxy/internal/gperr"
"github.com/yusing/go-proxy/internal/logging"
"github.com/yusing/go-proxy/internal/net/http/middleware"
"github.com/yusing/go-proxy/internal/route/routes/routequery"
"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"
"github.com/yusing/go-proxy/internal/task"
"github.com/yusing/go-proxy/pkg"
)
var rawLogger = log.New(os.Stdout, "", 0)
func init() {
var out io.Writer = os.Stderr
if common.EnableLogStreaming {
out = zerolog.MultiLevelWriter(out, v1.GetMemLogger())
func parallel(fns ...func()) {
var wg sync.WaitGroup
for _, fn := range fns {
wg.Add(1)
go func() {
defer wg.Done()
fn()
}()
}
logging.InitLogger(out)
// logging.AddHook(v1.GetMemLogger())
internal.InitIconListCache()
homepage.InitOverridesConfig()
favicon.InitIconCache()
wg.Wait()
}
func main() {
initProfiling()
args := common.GetArgs()
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)
}
rawLogger.Println("ok")
return
@@ -80,9 +73,18 @@ 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,
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()
}
@@ -106,21 +108,22 @@ 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(routequery.RoutesByAlias())
printJSON(routes.ByAlias())
return
case common.CommandListConfigs:
printJSON(cfg.Value())
return
case common.CommandDebugListEntries:
printJSON(cfg.DumpEntries())
printJSON(cfg.DumpRoutes())
return
case common.CommandDebugListProviders:
printJSON(cfg.DumpRouteProviders())
@@ -138,19 +141,10 @@ func main() {
API: true,
})
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)
// wait for signal
<-sig
// gracefully shutdown
logging.Info().Msg("shutting down")
_ = task.GracefulShutdown(time.Second * time.Duration(cfg.Value().TimeoutShutdown))
task.WaitExit(cfg.Value().TimeoutShutdown)
}
func prepareDirectory(dir string) {

View File

@@ -1,4 +1,4 @@
//go:build production
//go:build !pprof
package main

View File

@@ -1,35 +1,40 @@
---
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
# To use autocert, certs will be stored in "./certs".
# You can also use a docker volume to store it

View File

@@ -15,7 +15,26 @@
# options:
# auth_token: c1234565789-abcdefghijklmnopqrst # your zone API token
# 3. other providers, see https://github.com/yusing/go-proxy/wiki/Supported-DNS%E2%80%9001-Providers#supported-dns-01-providers
# 3. other providers, see https://github.com/yusing/godoxy/wiki/Supported-DNS%E2%80%9001-Providers#supported-dns-01-providers
# acl:
# default: allow # or deny (default: allow)
# allow_local: true # or false (default: true)
# allow:
# - ip:1.2.3.4
# - cidr:1.2.3.4/32
# - country:US
# - timezone:Asia/Shanghai
# deny:
# - ip:1.2.3.4
# - cidr:1.2.3.4/32
# - country:US
# - timezone:Asia/Shanghai
# log: # warning: logging ACL can be slow based on the number of incoming connections and configured rules
# buffer_size: 65536 # (default: 64KB)
# path: /app/logs/acl.log # (default: none)
# stdout: false # (default: false)
# keep: last 10 # (default: none)
entrypoint:
# Below define an example of middleware config
@@ -73,7 +92,15 @@ providers:
# url: https://discord.com/api/webhooks/...
# template: discord # this means use payload template from internal/notif/templates/discord.json
# Check https://github.com/yusing/go-proxy/wiki/Certificates-and-domain-matching#domain-matching
# Proxmox providers (for idlesleep support for proxmox LXCs)
#
# proxmox:
# - url: https://pve.domain.com:8006/api2/json
# token_id: root@pam!abcdef
# secret: aaaa-bbbb-cccc-dddd
# no_tls_verify: true
# Check https://github.com/yusing/godoxy/wiki/Certificates-and-domain-matching#domain-matching
# for explaination of `match_domains`
#
# match_domains:

268
go.mod
View File

@@ -1,82 +1,248 @@
module github.com/yusing/go-proxy
go 1.23.5
go 1.24.2
require (
github.com/PuerkitoBio/goquery v1.10.1
github.com/coder/websocket v1.8.12
github.com/coreos/go-oidc/v3 v3.12.0
github.com/docker/cli v27.5.1+incompatible
github.com/docker/docker v27.5.1+incompatible
github.com/fsnotify/fsnotify v1.8.0
github.com/go-acme/lego/v4 v4.21.0
github.com/go-playground/validator/v10 v10.24.0
github.com/gobwas/glob v0.2.3
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/gotify/server/v2 v2.6.1
github.com/lithammer/fuzzysearch v1.1.8
github.com/prometheus/client_golang v1.20.5
github.com/puzpuzpuz/xsync/v3 v3.5.0
github.com/rs/zerolog v1.33.0
github.com/vincent-petithory/dataurl v1.0.0
golang.org/x/crypto v0.32.0
golang.org/x/net v0.34.0
golang.org/x/oauth2 v0.25.0
golang.org/x/text v0.21.0
golang.org/x/time v0.9.0
gopkg.in/yaml.v3 v3.0.1
github.com/PuerkitoBio/goquery v1.10.3 // parsing HTML for extract fav icon
github.com/coder/websocket v1.8.13 // websocket for API and agent
github.com/coreos/go-oidc/v3 v3.14.1 // oidc authentication
github.com/docker/docker v28.1.1+incompatible // docker daemon
github.com/fsnotify/fsnotify v1.9.0 // file watcher
github.com/go-acme/lego/v4 v4.23.1 // acme client
github.com/go-playground/validator/v10 v10.26.0 // validator
github.com/gobwas/glob v0.2.3 // glob matcher for route rules
github.com/gotify/server/v2 v2.6.1 // reference the Message struct for json response
github.com/lithammer/fuzzysearch v1.1.8 // fuzzy search for searching icons and filtering metrics
github.com/puzpuzpuz/xsync/v3 v3.5.1 // lock free map for concurrent operations
github.com/rs/zerolog v1.34.0 // logging
github.com/shirou/gopsutil/v4 v4.25.3 // system info metrics
github.com/vincent-petithory/dataurl v1.0.0 // data url for fav icon
golang.org/x/crypto v0.37.0 // encrypting password with bcrypt
golang.org/x/net v0.39.0 // HTTP header utilities
golang.org/x/oauth2 v0.29.0 // oauth2 authentication
golang.org/x/text v0.24.0 // string utilities
golang.org/x/time v0.11.0 // time utilities
gopkg.in/yaml.v3 v3.0.1 // indirect; yaml parsing for different config files
)
replace github.com/coreos/go-oidc/v3 => github.com/godoxy-app/go-oidc/v3 v3.14.2
require (
github.com/bytedance/sonic v1.13.2
github.com/docker/cli v28.1.1+incompatible
github.com/goccy/go-yaml v1.17.1
github.com/golang-jwt/jwt/v5 v5.2.2
github.com/luthermonson/go-proxmox v0.2.2
github.com/oschwald/maxminddb-golang v1.13.1
github.com/quic-go/quic-go v0.51.0
github.com/samber/slog-zerolog/v2 v2.7.3
github.com/spf13/afero v1.14.0
github.com/stretchr/testify v1.10.0
go.uber.org/atomic v1.11.0
)
replace github.com/docker/docker => github.com/godoxy-app/docker v0.0.0-20250418000134-7af8fd7b079e
require (
cloud.google.com/go/auth v0.16.1 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/compute/metadata v0.6.0 // indirect
github.com/AdamSLevy/jsonrpc2/v14 v14.1.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.9.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 // indirect
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns v1.2.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/privatedns/armprivatedns v1.3.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph v0.9.0 // indirect
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/OpenDNS/vegadns2client v0.0.0-20180418235048-a3fa4a771d87 // indirect
github.com/akamai/AkamaiOPEN-edgegrid-golang v1.2.2 // indirect
github.com/aliyun/alibaba-cloud-sdk-go v1.63.106 // indirect
github.com/andybalholm/cascadia v1.3.3 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/aws/aws-sdk-go-v2 v1.36.3 // indirect
github.com/aws/aws-sdk-go-v2/config v1.29.14 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.17.67 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 // indirect
github.com/aws/aws-sdk-go-v2/service/lightsail v1.43.2 // indirect
github.com/aws/aws-sdk-go-v2/service/route53 v1.51.1 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.25.3 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.33.19 // indirect
github.com/aws/smithy-go v1.22.3 // indirect
github.com/baidubce/bce-sdk-go v0.9.224 // indirect
github.com/benbjohnson/clock v1.3.5 // indirect
github.com/boombuler/barcode v1.0.2 // indirect
github.com/buger/goterm v1.0.4 // indirect
github.com/bytedance/sonic/loader v0.2.4 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/civo/civogo v0.3.98 // indirect
github.com/cloudflare/cloudflare-go v0.115.0 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/cloudwego/base64x v0.1.5 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/diskfs/go-diskfs v1.6.0 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/djherbis/times v1.6.0 // indirect
github.com/dnsimple/dnsimple-go v1.7.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/exoscale/egoscale/v3 v3.1.14 // indirect
github.com/fatih/structs v1.1.0 // 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/fxamacker/cbor/v2 v2.8.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
github.com/go-errors/errors v1.5.1 // indirect
github.com/go-jose/go-jose/v4 v4.1.0 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-resty/resty/v2 v2.16.5 // indirect
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/gofrs/flock v0.12.1 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/klauspost/compress v1.17.11 // indirect
github.com/google/pprof v0.0.0-20250423184734-337e5dd93bb4 // indirect
github.com/google/s2a-go v0.1.9 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect
github.com/googleapis/gax-go/v2 v2.14.1 // indirect
github.com/gophercloud/gophercloud v1.14.1 // indirect
github.com/gophercloud/utils v0.0.0-20231010081019-80377eca5d56 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-retryablehttp v0.7.7 // indirect
github.com/hashicorp/go-uuid v1.0.3 // indirect
github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.146 // indirect
github.com/iij/doapi v0.0.0-20190504054126-0bbf12d6d7df // indirect
github.com/infobloxopen/infoblox-go-client/v2 v2.9.0 // indirect
github.com/jinzhu/copier v0.4.0 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/kolo/xmlrpc v0.0.0-20220921171641-a4b6fa1dd06b // indirect
github.com/kylelemons/godebug v1.1.0 // indirect
github.com/labbsr0x/bindman-dns-webhook v1.0.2 // indirect
github.com/labbsr0x/goh v1.0.1 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/linode/linodego v1.49.0 // indirect
github.com/liquidweb/liquidweb-cli v0.7.0 // indirect
github.com/liquidweb/liquidweb-go v1.6.4 // indirect
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 // indirect
github.com/magefile/mage v1.15.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/miekg/dns v1.1.63 // indirect
github.com/miekg/dns v1.1.65 // indirect
github.com/mimuret/golang-iij-dpf v0.9.1 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/term v0.5.0 // indirect
github.com/morikuni/aec v1.0.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/namedotcom/go v0.0.0-20180403034216-08470befbe04 // indirect
github.com/nrdcg/auroradns v1.1.0 // indirect
github.com/nrdcg/bunny-go v0.0.0-20250327222614-988a091fc7ea // indirect
github.com/nrdcg/desec v0.11.0 // indirect
github.com/nrdcg/freemyip v0.3.0 // indirect
github.com/nrdcg/goacmedns v0.2.0 // indirect
github.com/nrdcg/goinwx v0.11.0 // indirect
github.com/nrdcg/mailinabox v0.2.0 // indirect
github.com/nrdcg/namesilo v0.2.1 // indirect
github.com/nrdcg/nodion v0.1.0 // indirect
github.com/nrdcg/porkbun v0.4.0 // indirect
github.com/nzdjb/go-metaname v1.0.0 // indirect
github.com/onsi/ginkgo/v2 v2.23.4 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.0 // indirect
github.com/ovh/go-ovh v1.6.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b // indirect
github.com/oracle/oci-go-sdk/v65 v65.89.2 // indirect
github.com/ovh/go-ovh v1.7.0 // indirect
github.com/patrickmn/go-cache v2.1.0+incompatible // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/peterhellberg/link v1.2.0 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.62.0 // indirect
github.com/prometheus/procfs v0.15.1 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/pquerna/otp v1.4.0 // indirect
github.com/quic-go/qpack v0.5.1 // indirect
github.com/regfish/regfish-dnsapi-go v0.1.1 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/sacloud/api-client-go v0.2.10 // indirect
github.com/sacloud/go-http v0.1.9 // indirect
github.com/sacloud/iaas-api-go v1.14.0 // indirect
github.com/sacloud/packages-go v0.0.11 // indirect
github.com/sagikazarmark/locafero v0.9.0 // indirect
github.com/samber/lo v1.49.1 // indirect
github.com/samber/slog-common v0.18.1 // indirect
github.com/scaleway/scaleway-sdk-go v1.0.0-beta.33 // indirect
github.com/selectel/domains-go v1.1.0 // indirect
github.com/selectel/go-selvpcclient/v3 v3.2.1 // indirect
github.com/shopspring/decimal v1.4.0 // indirect
github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af // indirect
github.com/smartystreets/go-aws-auth v0.0.0-20180515143844-0c1422d1fdb9 // indirect
github.com/softlayer/softlayer-go v1.1.7 // indirect
github.com/softlayer/xmlrpc v0.0.0-20200409220501-5f089df7cb7e // indirect
github.com/sony/gobreaker v1.0.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/cast v1.7.1 // indirect
github.com/spf13/pflag v1.0.6 // indirect
github.com/spf13/viper v1.20.1 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.1150 // indirect
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dnspod v1.0.1136 // indirect
github.com/tjfoc/gmsm v1.4.1 // indirect
github.com/tklauser/go-sysconf v0.3.15 // indirect
github.com/tklauser/numcpus v0.10.0 // indirect
github.com/transip/gotransip/v6 v6.26.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ultradns/ultradns-go-sdk v1.8.0-20241010134910-243eeec // indirect
github.com/vinyldns/go-vinyldns v0.9.16 // indirect
github.com/volcengine/volc-sdk-golang v1.0.205 // indirect
github.com/vultr/govultr/v3 v3.19.1 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.mongodb.org/mongo-driver v1.17.3 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 // indirect
go.opentelemetry.io/otel v1.34.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.30.0 // indirect
go.opentelemetry.io/otel/metric v1.34.0 // indirect
go.opentelemetry.io/otel/sdk v1.30.0 // indirect
go.opentelemetry.io/otel/trace v1.34.0 // indirect
golang.org/x/mod v0.22.0 // indirect
golang.org/x/sync v0.10.0 // indirect
golang.org/x/sys v0.29.0 // indirect
golang.org/x/tools v0.29.0 // indirect
google.golang.org/protobuf v1.36.4 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect
go.opentelemetry.io/otel v1.35.0 // indirect
go.opentelemetry.io/otel/metric v1.35.0 // indirect
go.opentelemetry.io/otel/trace v1.35.0 // indirect
go.uber.org/automaxprocs v1.6.0 // indirect
go.uber.org/mock v0.5.1 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/ratelimit v0.3.1 // indirect
golang.org/x/arch v0.16.0 // indirect
golang.org/x/mod v0.24.0 // indirect
golang.org/x/sync v0.13.0 // indirect
golang.org/x/sys v0.32.0 // indirect
golang.org/x/tools v0.32.0 // indirect
google.golang.org/api v0.230.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250422160041-2d3770c4ea7f // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250422160041-2d3770c4ea7f // indirect
google.golang.org/grpc v1.72.0 // indirect
google.golang.org/protobuf v1.36.6 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gotest.tools/v3 v3.5.1 // indirect
gopkg.in/ns1/ns1-go.v2 v2.14.2 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
k8s.io/api v0.33.0 // indirect
k8s.io/apimachinery v0.33.0 // indirect
k8s.io/klog/v2 v2.130.1 // indirect
k8s.io/utils v0.0.0-20250321185631-1f6e0b77f77e // indirect
sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect
sigs.k8s.io/randfill v1.0.0 // indirect
sigs.k8s.io/structured-merge-diff/v4 v4.7.0 // indirect
sigs.k8s.io/yaml v1.4.0 // indirect
)

2540
go.sum

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,39 @@
package acl
import (
"github.com/puzpuzpuz/xsync/v3"
acl "github.com/yusing/go-proxy/internal/acl/types"
"go.uber.org/atomic"
)
var cityCache = xsync.NewMapOf[string, *acl.City]()
var numCachedLookup atomic.Uint64
func (cfg *MaxMindConfig) lookupCity(ip *acl.IPInfo) (*acl.City, bool) {
if ip.City != nil {
return ip.City, true
}
if cfg.db.Reader == nil {
return nil, false
}
city, ok := cityCache.Load(ip.Str)
if ok {
numCachedLookup.Inc()
return city, true
}
cfg.db.RLock()
defer cfg.db.RUnlock()
city = new(acl.City)
err := cfg.db.Lookup(ip.IP, city)
if err != nil {
return nil, false
}
cityCache.Store(ip.Str, city)
ip.City = city
return city, true
}

215
internal/acl/config.go Normal file
View File

@@ -0,0 +1,215 @@
package acl
import (
"net"
"sync"
"time"
"github.com/oschwald/maxminddb-golang"
"github.com/puzpuzpuz/xsync/v3"
"github.com/rs/zerolog"
acl "github.com/yusing/go-proxy/internal/acl/types"
"github.com/yusing/go-proxy/internal/gperr"
"github.com/yusing/go-proxy/internal/logging"
"github.com/yusing/go-proxy/internal/logging/accesslog"
"github.com/yusing/go-proxy/internal/task"
"github.com/yusing/go-proxy/internal/utils"
)
type Config struct {
Default string `json:"default" validate:"omitempty,oneof=allow deny"` // default: allow
AllowLocal *bool `json:"allow_local"` // default: true
Allow []string `json:"allow"`
Deny []string `json:"deny"`
Log *accesslog.ACLLoggerConfig `json:"log"`
MaxMind *MaxMindConfig `json:"maxmind" validate:"omitempty"`
config
}
type (
MaxMindDatabaseType string
MaxMindConfig struct {
AccountID string `json:"account_id" validate:"required"`
LicenseKey string `json:"license_key" validate:"required"`
Database MaxMindDatabaseType `json:"database" validate:"required,oneof=geolite geoip2"`
logger zerolog.Logger
lastUpdate time.Time
db struct {
*maxminddb.Reader
sync.RWMutex
}
}
)
type config struct {
defaultAllow bool
allowLocal bool
allow []matcher
deny []matcher
ipCache *xsync.MapOf[string, *checkCache]
logAllowed bool
logger *accesslog.AccessLogger
}
type checkCache struct {
*acl.IPInfo
allow bool
created time.Time
}
const cacheTTL = 1 * time.Minute
func (c *checkCache) Expired() bool {
return c.created.Add(cacheTTL).After(utils.TimeNow())
}
//TODO: add stats
const (
ACLAllow = "allow"
ACLDeny = "deny"
)
const (
MaxMindGeoLite MaxMindDatabaseType = "geolite"
MaxMindGeoIP2 MaxMindDatabaseType = "geoip2"
)
func (c *Config) Validate() gperr.Error {
switch c.Default {
case "", ACLAllow:
c.defaultAllow = true
case ACLDeny:
c.defaultAllow = false
default:
return gperr.New("invalid default value").Subject(c.Default)
}
if c.AllowLocal != nil {
c.allowLocal = *c.AllowLocal
} else {
c.allowLocal = true
}
if c.MaxMind != nil {
c.MaxMind.logger = logging.With().Str("type", string(c.MaxMind.Database)).Logger()
}
if c.Log != nil {
c.logAllowed = c.Log.LogAllowed
}
errs := gperr.NewBuilder("syntax error")
c.allow = make([]matcher, 0, len(c.Allow))
c.deny = make([]matcher, 0, len(c.Deny))
for _, s := range c.Allow {
m, err := c.parseMatcher(s)
if err != nil {
errs.Add(err.Subject(s))
continue
}
c.allow = append(c.allow, m)
}
for _, s := range c.Deny {
m, err := c.parseMatcher(s)
if err != nil {
errs.Add(err.Subject(s))
continue
}
c.deny = append(c.deny, m)
}
if errs.HasError() {
c.allow = nil
c.deny = nil
return errMatcherFormat.With(errs.Error())
}
c.ipCache = xsync.NewMapOf[string, *checkCache]()
return nil
}
func (c *Config) Valid() bool {
return c != nil && (len(c.allow) > 0 || len(c.deny) > 0 || c.allowLocal)
}
func (c *Config) Start(parent *task.Task) gperr.Error {
if c.MaxMind != nil {
if err := c.MaxMind.LoadMaxMindDB(parent); err != nil {
return err
}
}
if c.Log != nil {
logger, err := accesslog.NewAccessLogger(parent, c.Log)
if err != nil {
return gperr.New("failed to start access logger").With(err)
}
c.logger = logger
}
return nil
}
func (c *config) cacheRecord(info *acl.IPInfo, allow bool) {
c.ipCache.Store(info.Str, &checkCache{
IPInfo: info,
allow: allow,
created: utils.TimeNow(),
})
}
func (c *config) log(info *acl.IPInfo, allowed bool) {
if c.logger == nil {
return
}
if !allowed || c.logAllowed {
c.logger.LogACL(info, !allowed)
}
}
func (c *Config) IPAllowed(ip net.IP) bool {
if ip == nil {
return false
}
// always allow private and loopback
// loopback is not logged
if ip.IsLoopback() {
return true
}
if c.allowLocal && ip.IsPrivate() {
c.log(&acl.IPInfo{IP: ip, Str: ip.String()}, true)
return true
}
ipStr := ip.String()
record, ok := c.ipCache.Load(ipStr)
if ok && !record.Expired() {
c.log(record.IPInfo, record.allow)
return record.allow
}
ipAndStr := &acl.IPInfo{IP: ip, Str: ipStr}
for _, m := range c.allow {
if m(ipAndStr) {
c.log(ipAndStr, true)
c.cacheRecord(ipAndStr, true)
return true
}
}
for _, m := range c.deny {
if m(ipAndStr) {
c.log(ipAndStr, false)
c.cacheRecord(ipAndStr, false)
return false
}
}
c.log(ipAndStr, c.defaultAllow)
c.cacheRecord(ipAndStr, c.defaultAllow)
return c.defaultAllow
}

99
internal/acl/matcher.go Normal file
View File

@@ -0,0 +1,99 @@
package acl
import (
"net"
"strings"
acl "github.com/yusing/go-proxy/internal/acl/types"
"github.com/yusing/go-proxy/internal/gperr"
)
type matcher func(*acl.IPInfo) bool
const (
MatcherTypeIP = "ip"
MatcherTypeCIDR = "cidr"
MatcherTypeTimeZone = "tz"
MatcherTypeCountry = "country"
)
var errMatcherFormat = gperr.Multiline().AddLines(
"invalid matcher format, expect {type}:{value}",
"Available types: ip|cidr|tz|country",
"ip:127.0.0.1",
"cidr:127.0.0.0/8",
"tz:Asia/Shanghai",
"country:GB",
)
var (
errSyntax = gperr.New("syntax error")
errInvalidIP = gperr.New("invalid IP")
errInvalidCIDR = gperr.New("invalid CIDR")
errMaxMindNotConfigured = gperr.New("MaxMind not configured")
)
func (cfg *Config) parseMatcher(s string) (matcher, gperr.Error) {
parts := strings.Split(s, ":")
if len(parts) != 2 {
return nil, errSyntax
}
switch parts[0] {
case MatcherTypeIP:
ip := net.ParseIP(parts[1])
if ip == nil {
return nil, errInvalidIP
}
return matchIP(ip), nil
case MatcherTypeCIDR:
_, net, err := net.ParseCIDR(parts[1])
if err != nil {
return nil, errInvalidCIDR
}
return matchCIDR(net), nil
case MatcherTypeTimeZone:
if cfg.MaxMind == nil {
return nil, errMaxMindNotConfigured
}
return cfg.MaxMind.matchTimeZone(parts[1]), nil
case MatcherTypeCountry:
if cfg.MaxMind == nil {
return nil, errMaxMindNotConfigured
}
return cfg.MaxMind.matchISOCode(parts[1]), nil
default:
return nil, errSyntax
}
}
func matchIP(ip net.IP) matcher {
return func(ip2 *acl.IPInfo) bool {
return ip.Equal(ip2.IP)
}
}
func matchCIDR(n *net.IPNet) matcher {
return func(ip *acl.IPInfo) bool {
return n.Contains(ip.IP)
}
}
func (cfg *MaxMindConfig) matchTimeZone(tz string) matcher {
return func(ip *acl.IPInfo) bool {
city, ok := cfg.lookupCity(ip)
if !ok {
return false
}
return city.Location.TimeZone == tz
}
}
func (cfg *MaxMindConfig) matchISOCode(iso string) matcher {
return func(ip *acl.IPInfo) bool {
city, ok := cfg.lookupCity(ip)
if !ok {
return false
}
return city.Country.IsoCode == iso
}
}

281
internal/acl/maxmind.go Normal file
View File

@@ -0,0 +1,281 @@
package acl
import (
"archive/tar"
"compress/gzip"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"time"
"github.com/oschwald/maxminddb-golang"
"github.com/yusing/go-proxy/internal/common"
"github.com/yusing/go-proxy/internal/gperr"
"github.com/yusing/go-proxy/internal/task"
)
var (
updateInterval = 24 * time.Hour
httpClient = &http.Client{
Timeout: 10 * time.Second,
}
ErrResponseNotOK = gperr.New("response not OK")
ErrDownloadFailure = gperr.New("download failure")
)
func dbPathImpl(dbType MaxMindDatabaseType) string {
if dbType == MaxMindGeoLite {
return filepath.Join(dataDir, "GeoLite2-City.mmdb")
}
return filepath.Join(dataDir, "GeoIP2-City.mmdb")
}
func dbURLimpl(dbType MaxMindDatabaseType) string {
if dbType == MaxMindGeoLite {
return "https://download.maxmind.com/geoip/databases/GeoLite2-City/download?suffix=tar.gz"
}
return "https://download.maxmind.com/geoip/databases/GeoIP2-City/download?suffix=tar.gz"
}
func dbFilename(dbType MaxMindDatabaseType) string {
if dbType == MaxMindGeoLite {
return "GeoLite2-City.mmdb"
}
return "GeoIP2-City.mmdb"
}
func (cfg *MaxMindConfig) LoadMaxMindDB(parent task.Parent) gperr.Error {
if cfg.Database == "" {
return nil
}
path := dbPath(cfg.Database)
reader, err := maxmindDBOpen(path)
exists := true
if err != nil {
switch {
case errors.Is(err, os.ErrNotExist):
default:
// ignore invalid error, just download it again
var invalidErr maxminddb.InvalidDatabaseError
if !errors.As(err, &invalidErr) {
return gperr.Wrap(err)
}
}
exists = false
}
if !exists {
cfg.logger.Info().Msg("MaxMind DB not found/invalid, downloading...")
reader, err = cfg.download()
if err != nil {
return ErrDownloadFailure.With(err)
}
}
cfg.logger.Info().Msg("MaxMind DB loaded")
cfg.db.Reader = reader
go cfg.scheduleUpdate(parent)
return nil
}
func (cfg *MaxMindConfig) loadLastUpdate() {
f, err := os.Stat(dbPath(cfg.Database))
if err != nil {
return
}
cfg.lastUpdate = f.ModTime()
}
func (cfg *MaxMindConfig) setLastUpdate(t time.Time) {
cfg.lastUpdate = t
_ = os.Chtimes(dbPath(cfg.Database), t, t)
}
func (cfg *MaxMindConfig) scheduleUpdate(parent task.Parent) {
task := parent.Subtask("schedule_update", true)
ticker := time.NewTicker(updateInterval)
cfg.loadLastUpdate()
cfg.update()
defer func() {
ticker.Stop()
if cfg.db.Reader != nil {
cfg.db.Reader.Close()
}
task.Finish(nil)
}()
for {
select {
case <-task.Context().Done():
return
case <-ticker.C:
cfg.update()
}
}
}
func (cfg *MaxMindConfig) update() {
// check for update
cfg.logger.Info().Msg("checking for MaxMind DB update...")
remoteLastModified, err := cfg.checkLastest()
if err != nil {
cfg.logger.Err(err).Msg("failed to check MaxMind DB update")
return
}
if remoteLastModified.Equal(cfg.lastUpdate) {
cfg.logger.Info().Msg("MaxMind DB is up to date")
return
}
cfg.logger.Info().
Time("latest", remoteLastModified.Local()).
Time("current", cfg.lastUpdate).
Msg("MaxMind DB update available")
reader, err := cfg.download()
if err != nil {
cfg.logger.Err(err).Msg("failed to update MaxMind DB")
return
}
cfg.db.Lock()
cfg.db.Close()
cfg.db.Reader = reader
cfg.setLastUpdate(*remoteLastModified)
cfg.db.Unlock()
cfg.logger.Info().Msg("MaxMind DB updated")
}
func (cfg *MaxMindConfig) newReq(method string) (*http.Response, error) {
req, err := http.NewRequest(method, dbURL(cfg.Database), nil)
if err != nil {
return nil, err
}
req.SetBasicAuth(cfg.AccountID, cfg.LicenseKey)
resp, err := httpClient.Do(req)
if err != nil {
return nil, err
}
return resp, nil
}
func (cfg *MaxMindConfig) checkLastest() (lastModifiedT *time.Time, err error) {
resp, err := newReq(cfg, http.MethodHead)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("%w: %d", ErrResponseNotOK, resp.StatusCode)
}
lastModified := resp.Header.Get("Last-Modified")
if lastModified == "" {
cfg.logger.Warn().Msg("MaxMind responded no last modified time, update skipped")
return nil, nil
}
lastModifiedTime, err := time.Parse(http.TimeFormat, lastModified)
if err != nil {
cfg.logger.Warn().Err(err).Msg("MaxMind responded invalid last modified time, update skipped")
return nil, err
}
return &lastModifiedTime, nil
}
func (cfg *MaxMindConfig) download() (*maxminddb.Reader, error) {
resp, err := newReq(cfg, http.MethodGet)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("%w: %d", ErrResponseNotOK, resp.StatusCode)
}
path := dbPath(cfg.Database)
tmpPath := path + "-tmp.tar.gz"
file, err := os.OpenFile(tmpPath, os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
return nil, err
}
cfg.logger.Info().Msg("MaxMind DB downloading...")
_, err = io.Copy(file, resp.Body)
if err != nil {
file.Close()
return nil, err
}
file.Close()
// extract .tar.gz and move only the dbFilename to path
err = extractFileFromTarGz(tmpPath, dbFilename(cfg.Database), path)
if err != nil {
return nil, gperr.New("failed to extract database from archive").With(err)
}
// cleanup the tar.gz file
_ = os.Remove(tmpPath)
db, err := maxmindDBOpen(path)
if err != nil {
return nil, err
}
return db, nil
}
func extractFileFromTarGz(tarGzPath, targetFilename, destPath string) error {
f, err := os.Open(tarGzPath)
if err != nil {
return err
}
defer f.Close()
gzr, err := gzip.NewReader(f)
if err != nil {
return err
}
defer gzr.Close()
tr := tar.NewReader(gzr)
for {
hdr, err := tr.Next()
if err == io.EOF {
break // End of archive
}
if err != nil {
return err
}
// Only extract the file that matches targetFilename (basename match)
if filepath.Base(hdr.Name) == targetFilename {
outFile, err := os.OpenFile(destPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, hdr.FileInfo().Mode())
if err != nil {
return err
}
defer outFile.Close()
_, err = io.Copy(outFile, tr)
if err != nil {
return err
}
return nil // Done
}
}
return fmt.Errorf("file %s not found in archive", targetFilename)
}
var (
dataDir = common.DataDir
dbURL = dbURLimpl
dbPath = dbPathImpl
maxmindDBOpen = maxminddb.Open
newReq = (*MaxMindConfig).newReq
)

View File

@@ -0,0 +1,213 @@
package acl
import (
"io"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
"github.com/oschwald/maxminddb-golang"
"github.com/rs/zerolog"
"github.com/yusing/go-proxy/internal/task"
)
func Test_dbPath(t *testing.T) {
tmpDataDir := "/tmp/testdata"
oldDataDir := dataDir
dataDir = tmpDataDir
defer func() { dataDir = oldDataDir }()
tests := []struct {
name string
dbType MaxMindDatabaseType
want string
}{
{"GeoLite", MaxMindGeoLite, filepath.Join(tmpDataDir, "GeoLite2-City.mmdb")},
{"GeoIP2", MaxMindGeoIP2, filepath.Join(tmpDataDir, "GeoIP2-City.mmdb")},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := dbPath(tt.dbType); got != tt.want {
t.Errorf("dbPath() = %v, want %v", got, tt.want)
}
})
}
}
func Test_dbURL(t *testing.T) {
tests := []struct {
name string
dbType MaxMindDatabaseType
want string
}{
{"GeoLite", MaxMindGeoLite, "https://download.maxmind.com/geoip/databases/GeoLite2-City/download?suffix=tar.gz"},
{"GeoIP2", MaxMindGeoIP2, "https://download.maxmind.com/geoip/databases/GeoIP2-City/download?suffix=tar.gz"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := dbURL(tt.dbType); got != tt.want {
t.Errorf("dbURL() = %v, want %v", got, tt.want)
}
})
}
}
// --- Helper for MaxMindConfig ---
type testLogger struct{ zerolog.Logger }
func (testLogger) Info() *zerolog.Event { return &zerolog.Event{} }
func (testLogger) Warn() *zerolog.Event { return &zerolog.Event{} }
func (testLogger) Err(_ error) *zerolog.Event { return &zerolog.Event{} }
func Test_MaxMindConfig_newReq(t *testing.T) {
cfg := &MaxMindConfig{
AccountID: "testid",
LicenseKey: "testkey",
Database: MaxMindGeoLite,
logger: zerolog.Nop(),
}
// Patch httpClient to use httptest
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if u, p, ok := r.BasicAuth(); !ok || u != "testid" || p != "testkey" {
t.Errorf("basic auth not set correctly")
}
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
oldURL := dbURL
dbURL = func(MaxMindDatabaseType) string { return server.URL }
defer func() { dbURL = oldURL }()
resp, err := cfg.newReq(http.MethodGet)
if err != nil {
t.Fatalf("newReq() error = %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Errorf("unexpected status: %v", resp.StatusCode)
}
}
func Test_MaxMindConfig_checkUpdate(t *testing.T) {
cfg := &MaxMindConfig{
AccountID: "id",
LicenseKey: "key",
Database: MaxMindGeoLite,
logger: zerolog.Nop(),
}
lastMod := time.Now().UTC().Format(http.TimeFormat)
buildTime := time.Now().Add(-time.Hour)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Last-Modified", lastMod)
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
oldURL := dbURL
dbURL = func(MaxMindDatabaseType) string { return server.URL }
defer func() { dbURL = oldURL }()
latest, err := cfg.checkLastest()
if err != nil {
t.Fatalf("checkUpdate() error = %v", err)
}
if latest.Equal(buildTime) {
t.Errorf("expected update needed")
}
}
type fakeReadCloser struct {
firstRead bool
closed bool
}
func (c *fakeReadCloser) Read(p []byte) (int, error) {
if !c.firstRead {
c.firstRead = true
return strings.NewReader("FAKEMMDB").Read(p)
}
return 0, io.EOF
}
func (c *fakeReadCloser) Close() error {
c.closed = true
return nil
}
func Test_MaxMindConfig_download(t *testing.T) {
cfg := &MaxMindConfig{
AccountID: "id",
LicenseKey: "key",
Database: MaxMindGeoLite,
logger: zerolog.Nop(),
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
io.Copy(w, strings.NewReader("FAKEMMDB"))
}))
defer server.Close()
oldURL := dbURL
dbURL = func(MaxMindDatabaseType) string { return server.URL }
defer func() { dbURL = oldURL }()
tmpDir := t.TempDir()
oldDataDir := dataDir
dataDir = tmpDir
defer func() { dataDir = oldDataDir }()
// Patch maxminddb.Open to always succeed
origOpen := maxmindDBOpen
maxmindDBOpen = func(path string) (*maxminddb.Reader, error) {
return &maxminddb.Reader{}, nil
}
defer func() { maxmindDBOpen = origOpen }()
rw := &fakeReadCloser{}
oldNewReq := newReq
newReq = func(cfg *MaxMindConfig, method string) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Body: rw,
}, nil
}
defer func() { newReq = oldNewReq }()
db, err := cfg.download()
if err != nil {
t.Fatalf("download() error = %v", err)
}
if db == nil {
t.Error("expected db instance")
}
if !rw.closed {
t.Error("expected rw to be closed")
}
}
func Test_MaxMindConfig_loadMaxMindDB(t *testing.T) {
// This test should cover both the path where DB exists and where it does not
// For brevity, only the non-existing path is tested here
cfg := &MaxMindConfig{
AccountID: "id",
LicenseKey: "key",
Database: MaxMindGeoLite,
logger: zerolog.Nop(),
}
oldOpen := maxmindDBOpen
maxmindDBOpen = func(path string) (*maxminddb.Reader, error) {
return &maxminddb.Reader{}, nil
}
defer func() { maxmindDBOpen = oldOpen }()
oldDBPath := dbPath
dbPath = func(MaxMindDatabaseType) string { return filepath.Join(t.TempDir(), "maxmind.mmdb") }
defer func() { dbPath = oldDBPath }()
task := task.RootTask("test")
defer task.Finish(nil)
err := cfg.LoadMaxMindDB(task)
if err != nil {
t.Errorf("loadMaxMindDB() error = %v", err)
}
}

View File

@@ -0,0 +1,46 @@
package acl
import (
"net"
)
type TCPListener struct {
acl *Config
lis net.Listener
}
func (cfg *Config) WrapTCP(lis net.Listener) net.Listener {
if cfg == nil {
return lis
}
return &TCPListener{
acl: cfg,
lis: lis,
}
}
func (s *TCPListener) Addr() net.Addr {
return s.lis.Addr()
}
func (s *TCPListener) Accept() (net.Conn, error) {
c, err := s.lis.Accept()
if err != nil {
return nil, err
}
addr, ok := c.RemoteAddr().(*net.TCPAddr)
if !ok {
// Not a TCPAddr, drop
c.Close()
return nil, nil
}
if !s.acl.IPAllowed(addr.IP) {
c.Close()
return nil, nil
}
return c, nil
}
func (s *TCPListener) Close() error {
return s.lis.Close()
}

View File

@@ -0,0 +1,10 @@
package acl
type City struct {
Location struct {
TimeZone string `maxminddb:"time_zone"`
} `maxminddb:"location"`
Country struct {
IsoCode string `maxminddb:"iso_code"`
} `maxminddb:"country"`
}

View File

@@ -0,0 +1,9 @@
package acl
import "net"
type IPInfo struct {
IP net.IP
Str string
City *City
}

View File

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

View File

@@ -1,70 +1,103 @@
package api
import (
"fmt"
"net/http"
"github.com/prometheus/client_golang/prometheus/promhttp"
v1 "github.com/yusing/go-proxy/internal/api/v1"
"github.com/yusing/go-proxy/internal/api/v1/auth"
"github.com/yusing/go-proxy/internal/api/v1/certapi"
"github.com/yusing/go-proxy/internal/api/v1/dockerapi"
"github.com/yusing/go-proxy/internal/api/v1/favicon"
"github.com/yusing/go-proxy/internal/common"
"github.com/yusing/go-proxy/internal/auth"
config "github.com/yusing/go-proxy/internal/config/types"
"github.com/yusing/go-proxy/internal/logging"
"github.com/yusing/go-proxy/internal/logging/memlogger"
"github.com/yusing/go-proxy/internal/metrics/uptime"
"github.com/yusing/go-proxy/internal/net/gphttp/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 (mux ServeMux) HandleFunc(methods, endpoint string, handler http.HandlerFunc) {
for _, m := range strutils.CommaSeperatedList(methods) {
mux.ServeMux.HandleFunc(m+" "+endpoint, handler)
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 NewHandler(cfg config.ConfigInstance) http.Handler {
mux := ServeMux{http.NewServeMux()}
mux := ServeMux{http.NewServeMux(), cfg}
mux.HandleFunc("GET", "/v1", v1.Index)
mux.HandleFunc("GET", "/v1/version", v1.GetVersion)
mux.HandleFunc("POST", "/v1/reload", useCfg(cfg, v1.Reload))
mux.HandleFunc("GET", "/v1/list", auth.RequireAuth(useCfg(cfg, v1.List)))
mux.HandleFunc("GET", "/v1/list/{what}", auth.RequireAuth(useCfg(cfg, v1.List)))
mux.HandleFunc("GET", "/v1/list/{what}/{which}", auth.RequireAuth(useCfg(cfg, v1.List)))
mux.HandleFunc("GET", "/v1/file/{type}/{filename}", auth.RequireAuth(v1.GetFileContent))
mux.HandleFunc("POST,PUT", "/v1/file/{type}/{filename}", auth.RequireAuth(v1.SetFileContent))
mux.HandleFunc("POST", "/v1/file/validate/{type}", auth.RequireAuth(v1.ValidateFile))
mux.HandleFunc("GET", "/v1/stats", useCfg(cfg, v1.Stats))
mux.HandleFunc("GET", "/v1/stats/ws", useCfg(cfg, v1.StatsWS))
mux.HandleFunc("GET", "/v1/health/ws", auth.RequireAuth(useCfg(cfg, v1.HealthWS)))
mux.HandleFunc("GET", "/v1/logs/ws", auth.RequireAuth(useCfg(cfg, v1.LogsWS())))
mux.HandleFunc("GET", "/v1/favicon", auth.RequireAuth(favicon.GetFavIcon))
mux.HandleFunc("POST", "/v1/homepage/set", auth.RequireAuth(v1.SetHomePageOverrides))
if common.PrometheusEnabled {
mux.Handle("GET /v1/metrics", promhttp.Handler())
logging.Info().Msg("prometheus metrics enabled")
}
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)
defaultAuth := auth.GetDefaultAuth()
if defaultAuth != nil {
mux.HandleFunc("GET", "/v1/auth/redirect", defaultAuth.RedirectLoginPage)
mux.HandleFunc("GET", "/v1/auth/check", func(w http.ResponseWriter, r *http.Request) {
if err := defaultAuth.CheckToken(r); err != nil {
http.Error(w, err.Error(), http.StatusUnauthorized)
return
}
})
mux.HandleFunc("GET,POST", "/v1/auth/callback", defaultAuth.LoginCallbackHandler)
mux.HandleFunc("GET,POST", "/v1/auth/logout", defaultAuth.LogoutCallbackHandler)
} else {
mux.HandleFunc("GET", "/v1/auth/check", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
if defaultAuth == nil {
return mux
}
mux.HandleFunc("GET", "/v1/auth/check", auth.AuthCheckHandler)
mux.HandleFunc("GET", "/v1/auth/redirect", defaultAuth.LoginHandler)
mux.HandleFunc("GET", "/v1/auth/callback", defaultAuth.PostAuthCallbackHandler)
mux.HandleFunc("GET,POST", "/v1/auth/logout", defaultAuth.LogoutHandler)
return mux
}
func useCfg(cfg config.ConfigInstance, handler func(cfg config.ConfigInstance, w http.ResponseWriter, r *http.Request)) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
handler(cfg, w, r)
}
}

24
internal/api/v1/agents.go Normal file
View 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())
}
}

View File

@@ -1,274 +0,0 @@
package auth
import (
"context"
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"net/http"
"net/url"
"slices"
"time"
"github.com/coreos/go-oidc/v3/oidc"
U "github.com/yusing/go-proxy/internal/api/v1/utils"
"github.com/yusing/go-proxy/internal/common"
E "github.com/yusing/go-proxy/internal/error"
CE "github.com/yusing/go-proxy/internal/utils"
"github.com/yusing/go-proxy/internal/utils/strutils"
"golang.org/x/oauth2"
)
type OIDCProvider struct {
oauthConfig *oauth2.Config
oidcProvider *oidc.Provider
oidcVerifier *oidc.IDTokenVerifier
oidcLogoutURL *url.URL
allowedUsers []string
allowedGroups []string
isMiddleware bool
}
const CookieOauthState = "godoxy_oidc_state"
const (
OIDCMiddlewareCallbackPath = "/auth/callback"
OIDCLogoutPath = "/auth/logout"
)
func NewOIDCProvider(issuerURL, clientID, clientSecret, redirectURL, logoutURL string, allowedUsers, allowedGroups []string) (*OIDCProvider, error) {
if len(allowedUsers)+len(allowedGroups) == 0 {
return nil, errors.New("OIDC users, groups, or both must not be empty")
}
var logout *url.URL
var err error
if logoutURL != "" {
logout, err = url.Parse(logoutURL)
if err != nil {
return nil, fmt.Errorf("failed to parse logout URL: %w", err)
}
}
provider, err := oidc.NewProvider(context.Background(), issuerURL)
if err != nil {
return nil, fmt.Errorf("failed to initialize OIDC provider: %w", err)
}
return &OIDCProvider{
oauthConfig: &oauth2.Config{
ClientID: clientID,
ClientSecret: clientSecret,
RedirectURL: redirectURL,
Endpoint: provider.Endpoint(),
Scopes: strutils.CommaSeperatedList(common.OIDCScopes),
},
oidcProvider: provider,
oidcVerifier: provider.Verifier(&oidc.Config{
ClientID: clientID,
}),
oidcLogoutURL: logout,
allowedUsers: allowedUsers,
allowedGroups: allowedGroups,
}, nil
}
// NewOIDCProviderFromEnv creates a new OIDCProvider from environment variables.
func NewOIDCProviderFromEnv() (*OIDCProvider, error) {
return NewOIDCProvider(
common.OIDCIssuerURL,
common.OIDCClientID,
common.OIDCClientSecret,
common.OIDCRedirectURL,
common.OIDCLogoutURL,
common.OIDCAllowedUsers,
common.OIDCAllowedGroups,
)
}
func (auth *OIDCProvider) TokenCookieName() string {
return "godoxy_oidc_token"
}
func (auth *OIDCProvider) SetIsMiddleware(enabled bool) {
auth.isMiddleware = enabled
auth.oauthConfig.RedirectURL = ""
}
func (auth *OIDCProvider) SetAllowedUsers(users []string) {
auth.allowedUsers = users
}
func (auth *OIDCProvider) SetAllowedGroups(groups []string) {
auth.allowedGroups = groups
}
func (auth *OIDCProvider) CheckToken(r *http.Request) error {
token, err := r.Cookie(auth.TokenCookieName())
if err != nil {
return ErrMissingToken
}
// checks for Expiry, Audience == ClientID, Issuer, etc.
idToken, err := auth.oidcVerifier.Verify(r.Context(), token.Value)
if err != nil {
return fmt.Errorf("failed to verify ID token: %w: %w", ErrInvalidToken, err)
}
if len(idToken.Audience) == 0 {
return ErrInvalidToken
}
var claims struct {
Email string `json:"email"`
Username string `json:"preferred_username"`
Groups []string `json:"groups"`
}
if err := idToken.Claims(&claims); err != nil {
return fmt.Errorf("failed to parse claims: %w", err)
}
// Logical AND between allowed users and groups.
allowedUser := slices.Contains(auth.allowedUsers, claims.Username)
allowedGroup := len(CE.Intersect(claims.Groups, auth.allowedGroups)) > 0
if !allowedUser && !allowedGroup {
return ErrUserNotAllowed.Subject(claims.Username)
}
return nil
}
// generateState generates a random string for OIDC state.
const oidcStateLength = 32
func generateState() (string, error) {
b := make([]byte, oidcStateLength)
_, err := rand.Read(b)
if err != nil {
return "", err
}
return base64.URLEncoding.EncodeToString(b)[:oidcStateLength], nil
}
// RedirectOIDC initiates the OIDC login flow.
func (auth *OIDCProvider) RedirectLoginPage(w http.ResponseWriter, r *http.Request) {
state, err := generateState()
if err != nil {
U.HandleErr(w, r, err, http.StatusInternalServerError)
return
}
http.SetCookie(w, &http.Cookie{
Name: CookieOauthState,
Value: state,
MaxAge: 300,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: true,
Path: "/",
})
redirURL := auth.oauthConfig.AuthCodeURL(state)
if auth.isMiddleware {
u, err := r.URL.Parse(redirURL)
if err != nil {
U.HandleErr(w, r, err, http.StatusInternalServerError)
return
}
q := u.Query()
q.Set("redirect_uri", "https://"+r.Host+OIDCMiddlewareCallbackPath+q.Get("redirect_uri"))
u.RawQuery = q.Encode()
redirURL = u.String()
}
http.Redirect(w, r, redirURL, http.StatusTemporaryRedirect)
}
func (auth *OIDCProvider) exchange(r *http.Request) (*oauth2.Token, error) {
if auth.isMiddleware {
cfg := *auth.oauthConfig
cfg.RedirectURL = "https://" + r.Host + OIDCMiddlewareCallbackPath
return cfg.Exchange(r.Context(), r.URL.Query().Get("code"))
}
return auth.oauthConfig.Exchange(r.Context(), r.URL.Query().Get("code"))
}
// OIDCCallbackHandler handles the OIDC callback.
func (auth *OIDCProvider) LoginCallbackHandler(w http.ResponseWriter, r *http.Request) {
// For testing purposes, skip provider verification
if common.IsTest {
auth.handleTestCallback(w, r)
return
}
state, err := r.Cookie(CookieOauthState)
if err != nil {
U.HandleErr(w, r, E.New("missing state cookie"), http.StatusBadRequest)
return
}
query := r.URL.Query()
if query.Get("state") != state.Value {
U.HandleErr(w, r, E.New("invalid oauth state"), http.StatusBadRequest)
return
}
oauth2Token, err := auth.exchange(r)
if err != nil {
U.HandleErr(w, r, fmt.Errorf("failed to exchange token: %w", err), http.StatusInternalServerError)
return
}
rawIDToken, ok := oauth2Token.Extra("id_token").(string)
if !ok {
U.HandleErr(w, r, E.New("missing id_token"), http.StatusInternalServerError)
return
}
idToken, err := auth.oidcVerifier.Verify(r.Context(), rawIDToken)
if err != nil {
U.HandleErr(w, r, fmt.Errorf("failed to verify ID token: %w", err), http.StatusInternalServerError)
return
}
setTokenCookie(w, r, auth.TokenCookieName(), rawIDToken, time.Until(idToken.Expiry))
// Redirect to home page
http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
}
func (auth *OIDCProvider) LogoutCallbackHandler(w http.ResponseWriter, r *http.Request) {
if auth.oidcLogoutURL == nil {
DefaultLogoutCallbackHandler(auth, w, r)
return
}
token, err := r.Cookie(auth.TokenCookieName())
if err != nil {
U.HandleErr(w, r, E.New("missing token cookie"), http.StatusBadRequest)
return
}
clearTokenCookie(w, r, auth.TokenCookieName())
logoutURL := *auth.oidcLogoutURL
logoutURL.Query().Add("id_token_hint", token.Value)
http.Redirect(w, r, logoutURL.String(), http.StatusFound)
}
// handleTestCallback handles OIDC callback in test environment.
func (auth *OIDCProvider) handleTestCallback(w http.ResponseWriter, r *http.Request) {
state, err := r.Cookie(CookieOauthState)
if err != nil {
U.HandleErr(w, r, E.New("missing state cookie"), http.StatusBadRequest)
return
}
if r.URL.Query().Get("state") != state.Value {
U.HandleErr(w, r, E.New("invalid oauth state"), http.StatusBadRequest)
return
}
// Create test JWT token
setTokenCookie(w, r, auth.TokenCookieName(), "test", time.Hour)
http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
}

View File

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

View File

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

View File

@@ -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)
}

View 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
}
}
}

View File

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

View File

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

View 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
}

View 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.FormatByteSize(d.MemTotal),
})
}
func DockerInfo(w http.ResponseWriter, r *http.Request) {
serveHTTP[dockerInfo](w, r, GetDockerInfo)
}
func GetDockerInfo(ctx context.Context, dockerClients DockerClients) ([]dockerInfo, gperr.Error) {
errs := gperr.NewBuilder("failed to get docker info")
dockerInfos := make([]dockerInfo, len(dockerClients))
i := 0
for name, dockerClient := range dockerClients {
info, err := dockerClient.Info(ctx)
if err != nil {
errs.Add(err)
continue
}
info.Name = name
dockerInfos[i] = dockerInfo(info)
i++
}
sort.Slice(dockerInfos, func(i, j int) bool {
return dockerInfos[i].Name < dockerInfos[j].Name
})
return dockerInfos, errs.Error()
}

View File

@@ -0,0 +1,69 @@
package dockerapi
import (
"net/http"
"strconv"
"github.com/coder/websocket"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/pkg/stdcopy"
"github.com/yusing/go-proxy/internal/logging"
"github.com/yusing/go-proxy/internal/net/gphttp"
"github.com/yusing/go-proxy/internal/net/gphttp/gpwebsocket"
)
func Logs(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query()
server := r.PathValue("server")
containerID := r.PathValue("container")
stdout, _ := strconv.ParseBool(query.Get("stdout"))
stderr, _ := strconv.ParseBool(query.Get("stderr"))
since := query.Get("from")
until := query.Get("to")
levels := query.Get("levels") // TODO: implement levels
dockerClient, found, err := getDockerClient(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")
}
}

View 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)
}
}

View File

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

View File

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

View File

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

View File

@@ -5,8 +5,8 @@ import (
"io"
"net/http"
"github.com/yusing/go-proxy/internal/api/v1/utils"
"github.com/yusing/go-proxy/internal/homepage"
"github.com/yusing/go-proxy/internal/net/gphttp"
)
const (
@@ -37,13 +37,13 @@ type (
func SetHomePageOverrides(w http.ResponseWriter, r *http.Request) {
what := r.FormValue("what")
if what == "" {
http.Error(w, "missing what or which", http.StatusBadRequest)
gphttp.BadRequest(w, "missing what or which")
return
}
data, err := io.ReadAll(r.Body)
if err != nil {
utils.RespondError(w, err, http.StatusBadRequest)
gphttp.ClientError(w, err, http.StatusBadRequest)
return
}
r.Body.Close()
@@ -53,32 +53,32 @@ func SetHomePageOverrides(w http.ResponseWriter, r *http.Request) {
case HomepageOverrideItem:
var params HomepageOverrideItemParams
if err := json.Unmarshal(data, &params); err != nil {
utils.RespondError(w, err, http.StatusBadRequest)
gphttp.ClientError(w, err, http.StatusBadRequest)
return
}
overrides.OverrideItem(params.Which, &params.Value)
case HomepageOverrideItemsBatch:
var params HomepageOverrideItemsBatchParams
if err := json.Unmarshal(data, &params); err != nil {
utils.RespondError(w, err, http.StatusBadRequest)
gphttp.ClientError(w, err, http.StatusBadRequest)
return
}
overrides.OverrideItems(params.Value)
case HomepageOverrideItemVisible: // POST /v1/item_visible [a,b,c], false => hide a, b, c
var params HomepageOverrideItemVisibleParams
if err := json.Unmarshal(data, &params); err != nil {
utils.RespondError(w, err, http.StatusBadRequest)
gphttp.ClientError(w, err, http.StatusBadRequest)
return
}
if params.Value {
overrides.UnhideItems(params.Which...)
overrides.UnhideItems(params.Which)
} else {
overrides.HideItems(params.Which...)
overrides.HideItems(params.Which)
}
case HomepageOverrideCategoryOrder:
var params HomepageOverrideCategoryOrderParams
if err := json.Unmarshal(data, &params); err != nil {
utils.RespondError(w, err, http.StatusBadRequest)
gphttp.ClientError(w, err, http.StatusBadRequest)
return
}
overrides.SetCategoryOrder(params.Which, params.Value)

View File

@@ -3,9 +3,9 @@ package v1
import (
"net/http"
. "github.com/yusing/go-proxy/internal/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"))
}

View File

@@ -1,16 +1,17 @@
package v1
import (
"fmt"
"net/http"
"strconv"
"strings"
"github.com/yusing/go-proxy/internal"
U "github.com/yusing/go-proxy/internal/api/v1/utils"
"github.com/yusing/go-proxy/internal/common"
config "github.com/yusing/go-proxy/internal/config/types"
"github.com/yusing/go-proxy/internal/net/http/middleware"
"github.com/yusing/go-proxy/internal/route/routes/routequery"
"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"
route "github.com/yusing/go-proxy/internal/route/types"
"github.com/yusing/go-proxy/internal/task"
"github.com/yusing/go-proxy/internal/utils"
@@ -39,28 +40,28 @@ func List(cfg config.ConfigInstance, 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, routequery.RoutesByAlias(route.RouteType(r.FormValue("type"))))
gphttp.RespondJSON(w, r, routes.ByAlias(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, cfg.Value().MatchDomains)
gphttp.RespondJSON(w, r, cfg.Value().MatchDomains)
case ListHomepageConfig:
U.RespondJSON(w, r, routequery.HomepageConfig(cfg.Value().Homepage.UseDefaultCategories, r.FormValue("category"), r.FormValue("provider")))
gphttp.RespondJSON(w, r, routes.HomepageConfig(r.FormValue("category"), r.FormValue("provider")))
case ListRouteProviders:
U.RespondJSON(w, r, cfg.RouteProviderList())
gphttp.RespondJSON(w, r, cfg.RouteProviderList())
case ListHomepageCategories:
U.RespondJSON(w, r, routequery.HomepageCategories())
gphttp.RespondJSON(w, r, routes.HomepageCategories())
case ListIcons:
limit, err := strconv.Atoi(r.FormValue("limit"))
if err != nil {
@@ -68,17 +69,17 @@ func List(cfg config.ConfigInstance, w http.ResponseWriter, r *http.Request) {
}
icons, err := internal.SearchIcons(r.FormValue("keyword"), limit)
if err != nil {
U.RespondError(w, err)
gphttp.ClientError(w, err)
return
}
if icons == nil {
icons = []string{}
}
U.RespondJSON(w, r, icons)
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))
}
}
@@ -86,9 +87,9 @@ func List(cfg config.ConfigInstance, w http.ResponseWriter, r *http.Request) {
// otherwise, return a single Route with alias which or nil if not found.
func listRoute(which string) any {
if which == "" || which == "all" {
return routequery.RoutesByAlias()
return routes.ByAlias()
}
routes := routequery.RoutesByAlias()
routes := routes.ByAlias()
route, ok := routes[which]
if !ok {
return nil
@@ -99,7 +100,7 @@ func listRoute(which string) any {
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
}
resp := map[FileType][]string{
@@ -116,12 +117,12 @@ func listFiles(w http.ResponseWriter, r *http.Request) {
mids, err := utils.ListFiles(common.MiddlewareComposeBasePath, 0, true)
if err != nil {
U.HandleErr(w, r, err)
gphttp.ServerError(w, r, err)
return
}
for _, mid := range mids {
mid = strings.TrimPrefix(mid, common.MiddlewareComposeBasePath+"/")
resp[FileTypeMiddleware] = append(resp[FileTypeMiddleware], mid)
}
U.RespondJSON(w, r, resp)
gphttp.RespondJSON(w, r, resp)
}

View File

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

View File

@@ -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)
}

View File

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

View File

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

View 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)
}
}

View File

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

View File

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

View File

@@ -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()))
}

View File

@@ -3,9 +3,8 @@ package auth
import (
"net/http"
U "github.com/yusing/go-proxy/internal/api/v1/utils"
"github.com/yusing/go-proxy/internal/common"
"github.com/yusing/go-proxy/internal/logging"
"github.com/yusing/go-proxy/internal/net/gphttp"
)
var defaultAuth Provider
@@ -13,7 +12,6 @@ var defaultAuth Provider
// Initialize sets up authentication providers.
func Initialize() error {
if !IsEnabled() {
logging.Warn().Msg("authentication is disabled, please set API_JWT_SECRET or OIDC_* to enable authentication")
return nil
}
@@ -33,7 +31,7 @@ func GetDefaultAuth() Provider {
}
func IsEnabled() bool {
return common.APIJWTSecret != nil || IsOIDCEnabled()
return !common.DebugDisableAuth && (common.APIJWTSecret != nil || IsOIDCEnabled())
}
func IsOIDCEnabled() bool {
@@ -44,7 +42,7 @@ func RequireAuth(next http.HandlerFunc) http.HandlerFunc {
if IsEnabled() {
return func(w http.ResponseWriter, r *http.Request) {
if err := defaultAuth.CheckToken(r); err != nil {
U.RespondError(w, err, http.StatusUnauthorized)
gphttp.ClientError(w, err, http.StatusUnauthorized)
} else {
next(w, r)
}
@@ -52,3 +50,11 @@ func RequireAuth(next http.HandlerFunc) http.HandlerFunc {
}
return next
}
func AuthCheckHandler(w http.ResponseWriter, r *http.Request) {
if err := defaultAuth.CheckToken(r); err != nil {
http.Redirect(w, r, "/v1/auth/login", http.StatusFound)
} else {
w.WriteHeader(http.StatusOK)
}
}

View 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,
})
}

View 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>

View File

@@ -0,0 +1,185 @@
package auth
import (
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"net/http"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/yusing/go-proxy/internal/common"
"github.com/yusing/go-proxy/internal/jsonstore"
"github.com/yusing/go-proxy/internal/logging"
"golang.org/x/oauth2"
)
type oauthRefreshToken struct {
Username string `json:"username"`
RefreshToken string `json:"refresh_token"`
Expiry time.Time `json:"expiry"`
}
type Session struct {
SessionID sessionID `json:"session_id"`
Username string `json:"username"`
Groups []string `json:"groups"`
}
type sessionClaims struct {
Session
jwt.RegisteredClaims
}
type sessionID string
var oauthRefreshTokens jsonstore.MapStore[oauthRefreshToken]
var (
defaultRefreshTokenExpiry = 30 * 24 * time.Hour // 1 month
refreshBefore = 30 * time.Second
)
var (
errNoRefreshToken = errors.New("no refresh token")
ErrRefreshTokenFailure = errors.New("failed to refresh token")
)
const sessionTokenIssuer = "GoDoxy"
func init() {
if IsOIDCEnabled() {
oauthRefreshTokens = jsonstore.Store[oauthRefreshToken]("oauth_refresh_tokens")
}
}
func (token *oauthRefreshToken) expired() bool {
return time.Now().After(token.Expiry)
}
func newSessionID() sessionID {
b := make([]byte, 32)
_, _ = rand.Read(b)
return sessionID(base64.StdEncoding.EncodeToString(b))
}
func newSession(username string, groups []string) Session {
return Session{
SessionID: newSessionID(),
Username: username,
Groups: groups,
}
}
// getOnceOAuthRefreshToken returns the refresh token for the given session.
//
// The token is removed from the store after retrieval.
func getOnceOAuthRefreshToken(claims *Session) (*oauthRefreshToken, bool) {
token, ok := oauthRefreshTokens.Load(string(claims.SessionID))
if !ok {
return nil, false
}
invalidateOAuthRefreshToken(claims.SessionID)
if token.expired() {
return nil, false
}
if claims.Username != token.Username {
return nil, false
}
return &token, true
}
func storeOAuthRefreshToken(sessionID sessionID, username, token string) {
oauthRefreshTokens.Store(string(sessionID), oauthRefreshToken{
Username: username,
RefreshToken: token,
Expiry: time.Now().Add(defaultRefreshTokenExpiry),
})
logging.Debug().Str("username", username).Msg("stored oauth refresh token")
}
func invalidateOAuthRefreshToken(sessionID sessionID) {
logging.Debug().Str("session_id", string(sessionID)).Msg("invalidating oauth refresh token")
oauthRefreshTokens.Delete(string(sessionID))
}
func (auth *OIDCProvider) setSessionTokenCookie(w http.ResponseWriter, r *http.Request, session Session) {
claims := &sessionClaims{
Session: session,
RegisteredClaims: jwt.RegisteredClaims{
Issuer: sessionTokenIssuer,
ExpiresAt: jwt.NewNumericDate(time.Now().Add(common.APIJWTTokenTTL)),
},
}
jwtToken := jwt.NewWithClaims(jwt.SigningMethodHS512, claims)
signed, err := jwtToken.SignedString(common.APIJWTSecret)
if err != nil {
logging.Err(err).Msg("failed to sign session token")
return
}
setTokenCookie(w, r, CookieOauthSessionToken, signed, common.APIJWTTokenTTL)
}
func (auth *OIDCProvider) parseSessionJWT(sessionJWT string) (claims *sessionClaims, valid bool, err error) {
claims = &sessionClaims{}
sessionToken, err := jwt.ParseWithClaims(sessionJWT, claims, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
}
return common.APIJWTSecret, nil
})
if err != nil {
return nil, false, err
}
return claims, sessionToken.Valid && claims.Issuer == sessionTokenIssuer, nil
}
func (auth *OIDCProvider) TryRefreshToken(w http.ResponseWriter, r *http.Request, sessionJWT string) error {
// verify the session cookie
claims, valid, err := auth.parseSessionJWT(sessionJWT)
if err != nil {
return fmt.Errorf("%w: %w", ErrInvalidSessionToken, err)
}
if !valid {
return ErrInvalidSessionToken
}
// check if refresh is possible
refreshToken, ok := getOnceOAuthRefreshToken(&claims.Session)
if !ok {
return errNoRefreshToken
}
if !auth.checkAllowed(claims.Username, claims.Groups) {
return ErrUserNotAllowed
}
// this step refreshes the token
// see https://cs.opensource.google/go/x/oauth2/+/refs/tags/v0.29.0:oauth2.go;l=313
newToken, err := auth.oauthConfig.TokenSource(r.Context(), &oauth2.Token{
RefreshToken: refreshToken.RefreshToken,
}).Token()
if err != nil {
return fmt.Errorf("%w: %w", ErrRefreshTokenFailure, err)
}
idTokenJWT, idToken, err := auth.getIdToken(r.Context(), newToken)
if err != nil {
return err
}
sessionID := newSessionID()
logging.Debug().Str("username", claims.Username).Time("expiry", newToken.Expiry).Msg("refreshed token")
storeOAuthRefreshToken(sessionID, claims.Username, newToken.RefreshToken)
// set new idToken and new sessionToken
auth.setIDTokenCookie(w, r, idTokenJWT, time.Until(idToken.Expiry))
auth.setSessionTokenCookie(w, r, Session{
SessionID: sessionID,
Username: claims.Username,
Groups: claims.Groups,
})
return nil
}

314
internal/auth/oidc.go Normal file
View File

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

View File

@@ -8,13 +8,13 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"time"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/golang-jwt/jwt/v5"
"github.com/yusing/go-proxy/internal/common"
E "github.com/yusing/go-proxy/internal/error"
"golang.org/x/oauth2"
. "github.com/yusing/go-proxy/internal/utils/testing"
@@ -36,7 +36,8 @@ func setupMockOIDC(t *testing.T) {
},
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
},
oidcProvider: provider,
endSessionURL: Must(url.Parse("http://mock-provider/logout")),
oidcProvider: provider,
oidcVerifier: provider.Verifier(&oidc.Config{
ClientID: "test-client",
}),
@@ -149,17 +150,17 @@ func TestOIDCLoginHandler(t *testing.T) {
}{
{
name: "Success - Redirects to provider",
wantStatus: http.StatusTemporaryRedirect,
wantStatus: http.StatusFound,
wantRedirect: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/auth/redirect", nil)
req := httptest.NewRequest(http.MethodGet, OIDCAuthInitPath, nil)
w := httptest.NewRecorder()
defaultAuth.RedirectLoginPage(w, req)
defaultAuth.(*OIDCProvider).HandleAuth(w, req)
if got := w.Code; got != tt.wantStatus {
t.Errorf("OIDCLoginHandler() status = %v, want %v", got, tt.wantStatus)
@@ -195,7 +196,7 @@ func TestOIDCCallbackHandler(t *testing.T) {
state: "valid-state",
code: "valid-code",
setupMocks: true,
wantStatus: http.StatusTemporaryRedirect,
wantStatus: http.StatusFound,
},
{
name: "Failure - Missing state",
@@ -220,15 +221,15 @@ func TestOIDCCallbackHandler(t *testing.T) {
}
w := httptest.NewRecorder()
defaultAuth.LoginCallbackHandler(w, req)
defaultAuth.(*OIDCProvider).PostAuthCallbackHandler(w, req)
if got := w.Code; got != tt.wantStatus {
t.Errorf("OIDCCallbackHandler() status = %v, want %v", got, tt.wantStatus)
}
if tt.wantStatus == http.StatusTemporaryRedirect {
setCookie := E.Must(http.ParseSetCookie(w.Header().Get("Set-Cookie")))
ExpectEqual(t, setCookie.Name, defaultAuth.TokenCookieName())
setCookie := Must(http.ParseSetCookie(w.Header().Get("Set-Cookie")))
ExpectEqual(t, setCookie.Name, CookieOauthToken)
ExpectTrue(t, setCookie.Value != "")
ExpectEqual(t, setCookie.Path, "/")
ExpectEqual(t, setCookie.SameSite, http.SameSiteLaxMode)
@@ -271,7 +272,6 @@ func TestInitOIDC(t *testing.T) {
issuerURL: server.URL,
clientID: "client_id",
clientSecret: "client_secret",
redirectURL: "https://example.com/callback",
allowedUsers: []string{"user1", "user2"},
wantErr: false,
},
@@ -280,7 +280,6 @@ func TestInitOIDC(t *testing.T) {
issuerURL: server.URL,
clientID: "client_id",
clientSecret: "client_secret",
redirectURL: "https://example.com/callback",
allowedGroups: []string{"group1", "group2"},
wantErr: false,
},
@@ -289,7 +288,6 @@ func TestInitOIDC(t *testing.T) {
issuerURL: server.URL,
clientID: "client_id",
clientSecret: "client_secret",
redirectURL: "https://example.com/callback",
logoutURL: "https://example.com/logout",
allowedUsers: []string{"user1", "user2"},
allowedGroups: []string{"group1", "group2"},
@@ -300,14 +298,13 @@ func TestInitOIDC(t *testing.T) {
issuerURL: "https://example.com",
clientID: "client_id",
clientSecret: "client_secret",
redirectURL: "https://example.com/callback",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := NewOIDCProvider(tt.issuerURL, tt.clientID, tt.clientSecret, tt.redirectURL, tt.logoutURL, tt.allowedUsers, tt.allowedGroups)
_, err := NewOIDCProvider(tt.issuerURL, tt.clientID, tt.clientSecret, tt.allowedUsers, tt.allowedGroups)
if (err != nil) != tt.wantErr {
t.Errorf("InitOIDC() error = %v, wantErr %v", err, tt.wantErr)
}
@@ -401,7 +398,7 @@ func TestCheckToken(t *testing.T) {
"preferred_username": "user1",
"groups": []string{"group1"},
},
wantErr: ErrInvalidToken,
wantErr: ErrInvalidOAuthToken,
},
{
name: "Error - Server returns incorrect audience",
@@ -412,7 +409,7 @@ func TestCheckToken(t *testing.T) {
"preferred_username": "user1",
"groups": []string{"group1"},
},
wantErr: ErrInvalidToken,
wantErr: ErrInvalidOAuthToken,
},
{
name: "Error - Server returns expired token",
@@ -423,7 +420,7 @@ func TestCheckToken(t *testing.T) {
"preferred_username": "user1",
"groups": []string{"group1"},
},
wantErr: ErrInvalidToken,
wantErr: ErrInvalidOAuthToken,
},
}
for _, tc := range tests {
@@ -439,7 +436,7 @@ func TestCheckToken(t *testing.T) {
// Craft a test HTTP request that includes the token as a cookie.
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.AddCookie(&http.Cookie{
Name: auth.TokenCookieName(),
Name: CookieOauthToken,
Value: signedToken,
})
@@ -453,3 +450,35 @@ func TestCheckToken(t *testing.T) {
})
}
}
func TestLogoutHandler(t *testing.T) {
t.Helper()
setupMockOIDC(t)
req := httptest.NewRequest(http.MethodGet, OIDCLogoutPath, nil)
w := httptest.NewRecorder()
req.AddCookie(&http.Cookie{
Name: CookieOauthToken,
Value: "test-token",
})
req.AddCookie(&http.Cookie{
Name: CookieOauthSessionToken,
Value: "test-session-token",
})
defaultAuth.(*OIDCProvider).LogoutHandler(w, req)
if got := w.Code; got != http.StatusFound {
t.Errorf("LogoutHandler() status = %v, want %v", got, http.StatusFound)
}
if got := w.Header().Get("Location"); got == "" {
t.Error("LogoutHandler() missing redirect location")
}
if len(w.Header().Values("Set-Cookie")) != 2 {
t.Error("LogoutHandler() did not clear all cookies")
}
}

10
internal/auth/provider.go Normal file
View File

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

View File

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

View File

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

72
internal/auth/utils.go Normal file
View File

@@ -0,0 +1,72 @@
package auth
import (
"net/http"
"time"
"github.com/yusing/go-proxy/internal/common"
"github.com/yusing/go-proxy/internal/gperr"
"github.com/yusing/go-proxy/internal/utils/strutils"
)
var (
ErrMissingOAuthToken = gperr.New("missing oauth token")
ErrMissingSessionToken = gperr.New("missing session token")
ErrInvalidOAuthToken = gperr.New("invalid oauth token")
ErrInvalidSessionToken = gperr.New("invalid session token")
ErrUserNotAllowed = gperr.New("user not allowed")
)
func requestHost(r *http.Request) string {
// check if it's from backend
switch r.Host {
case common.APIHTTPAddr:
// use XFH
return r.Header.Get("X-Forwarded-Host")
default:
return r.Host
}
}
// cookieDomain returns the fully qualified domain name of the request host
// with subdomain stripped.
//
// If the request host does not have a subdomain,
// an empty string is returned
//
// "abc.example.com" -> ".example.com" (cross subdomain)
// "example.com" -> "" (same domain only)
func cookieDomain(r *http.Request) string {
parts := strutils.SplitRune(requestHost(r), '.')
if len(parts) < 2 {
return ""
}
parts[0] = ""
return strutils.JoinRune(parts, '.')
}
func setTokenCookie(w http.ResponseWriter, r *http.Request, name, value string, ttl time.Duration) {
http.SetCookie(w, &http.Cookie{
Name: name,
Value: value,
MaxAge: int(ttl.Seconds()),
Domain: cookieDomain(r),
HttpOnly: true,
Secure: common.APIJWTSecure,
SameSite: http.SameSiteLaxMode,
Path: "/",
})
}
func clearTokenCookie(w http.ResponseWriter, r *http.Request, name string) {
http.SetCookie(w, &http.Cookie{
Name: name,
Value: "",
MaxAge: -1,
Domain: cookieDomain(r),
HttpOnly: true,
Secure: common.APIJWTSecure,
SameSite: http.SameSiteLaxMode,
Path: "/",
})
}

View File

@@ -10,7 +10,7 @@ import (
"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"
@@ -30,17 +30,17 @@ type (
)
var (
ErrMissingDomain = E.New("missing field 'domains'")
ErrMissingEmail = E.New("missing field 'email'")
ErrMissingProvider = E.New("missing field 'provider'")
ErrInvalidDomain = E.New("invalid domain")
ErrUnknownProvider = E.New("unknown provider")
ErrMissingDomain = gperr.New("missing field 'domains'")
ErrMissingEmail = gperr.New("missing field 'email'")
ErrMissingProvider = gperr.New("missing field 'provider'")
ErrInvalidDomain = gperr.New("invalid domain")
ErrUnknownProvider = gperr.New("unknown provider")
)
var domainOrWildcardRE = regexp.MustCompile(`^\*?([^.]+\.)+[^.]+$`)
// Validate implements the utils.CustomValidator interface.
func (cfg *AutocertConfig) Validate() E.Error {
func (cfg *AutocertConfig) Validate() gperr.Error {
if cfg == nil {
return nil
}
@@ -50,8 +50,8 @@ func (cfg *AutocertConfig) Validate() E.Error {
return nil
}
b := E.NewBuilder("autocert errors")
if cfg.Provider != ProviderLocal {
b := gperr.NewBuilder("autocert errors")
if cfg.Provider != ProviderLocal && cfg.Provider != ProviderPseudo {
if len(cfg.Domains) == 0 {
b.Add(ErrMissingDomain)
}
@@ -64,11 +64,11 @@ func (cfg *AutocertConfig) Validate() E.Error {
}
}
// check if provider is implemented
providerConstructor, ok := providersGenMap[cfg.Provider]
providerConstructor, ok := providers[cfg.Provider]
if !ok {
b.Add(ErrUnknownProvider.
Subject(cfg.Provider).
Withf(strutils.DoYouMean(utils.NearestField(cfg.Provider, providersGenMap))))
Withf(strutils.DoYouMean(utils.NearestField(cfg.Provider, providers))))
} else {
_, err := providerConstructor(cfg.Options)
if err != nil {
@@ -79,7 +79,7 @@ func (cfg *AutocertConfig) Validate() E.Error {
return b.Error()
}
func (cfg *AutocertConfig) GetProvider() (*Provider, E.Error) {
func (cfg *AutocertConfig) GetProvider() (*Provider, gperr.Error) {
if cfg == nil {
cfg = new(AutocertConfig)
}
@@ -101,16 +101,16 @@ func (cfg *AutocertConfig) GetProvider() (*Provider, E.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)
}
}
}

View File

@@ -1,31 +0,0 @@
package autocert
import (
"github.com/go-acme/lego/v4/providers/dns/clouddns"
"github.com/go-acme/lego/v4/providers/dns/cloudflare"
"github.com/go-acme/lego/v4/providers/dns/duckdns"
"github.com/go-acme/lego/v4/providers/dns/ovh"
)
const (
certBasePath = "certs/"
CertFileDefault = certBasePath + "cert.crt"
KeyFileDefault = certBasePath + "priv.key"
ACMEKeyFileDefault = certBasePath + "acme.key"
)
const (
ProviderLocal = "local"
ProviderCloudflare = "cloudflare"
ProviderClouddns = "clouddns"
ProviderDuckdns = "duckdns"
ProviderOVH = "ovh"
)
var providersGenMap = map[string]ProviderGenerator{
ProviderLocal: providerGenerator(NewDummyDefaultConfig, NewDummyDNSProviderConfig),
ProviderCloudflare: providerGenerator(cloudflare.NewDefaultConfig, cloudflare.NewDNSProviderConfig),
ProviderClouddns: providerGenerator(clouddns.NewDefaultConfig, clouddns.NewDNSProviderConfig),
ProviderDuckdns: providerGenerator(duckdns.NewDefaultConfig, duckdns.NewDNSProviderConfig),
ProviderOVH: providerGenerator(ovh.NewDefaultConfig, ovh.NewDNSProviderConfig),
}

53
internal/autocert/gen.py Normal file
View File

@@ -0,0 +1,53 @@
import requests
import os
class Entry:
def __init__(self, name: str, type: str, **kwargs) -> None:
self.name = name
self.type = type
url = "https://api.github.com/repos/go-acme/lego/contents/providers/dns"
response = requests.get(url)
data: list[Entry] = [Entry(**i) for i in response.json()]
header = "//go:generate /usr/bin/python3 gen.py\n\npackage autocert\n\n"
names: list[str] = [
"ProviderLocal = \"local\"",
"ProviderPseudo = \"pseudo\"",
]
imports: list[str] = []
genMap: list[str] = [
"ProviderLocal: providerGenerator(NewDummyDefaultConfig, NewDummyDNSProviderConfig),",
"ProviderPseudo: providerGenerator(NewDummyDefaultConfig, NewDummyDNSProviderConfig),",
]
blacklists = [
"internal",
# deprecated
"azure",
"brandit",
"cloudxns",
"dnspod",
"mythicbeasts",
"yandexcloud"
]
for item in data:
if item.type != "dir" or item.name in blacklists:
continue
imports.append(f"import \"github.com/go-acme/lego/v4/providers/dns/{item.name}\"")
names.append(f"Provider{item.name} = \"{item.name}\"")
genMap.append(f"Provider{item.name}: providerGenerator({item.name}.NewDefaultConfig, {item.name}.NewDNSProviderConfig),")
with open("providers.go", "w") as f:
f.write(header)
f.write("\n".join(imports))
f.write("\n\n")
f.write("const (\n")
f.write("\n".join(names))
f.write("\n)\n\n")
f.write("var providers = map[string]ProviderGenerator{\n")
f.write("\n".join(genMap))
f.write("\n}\n\n")
os.execvp("go", ["go", "fmt", "providers.go"])

View File

@@ -0,0 +1,8 @@
package autocert
const (
certBasePath = "certs/"
CertFileDefault = certBasePath + "cert.crt"
KeyFileDefault = certBasePath + "priv.key"
ACMEKeyFileDefault = certBasePath + "acme.key"
)

View File

@@ -4,17 +4,19 @@ 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"
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"
@@ -31,8 +33,10 @@ type (
legoCert *certificate.Resource
tlsCert *tls.Certificate
certExpiries CertExpiries
obtainMu sync.Mutex
}
ProviderGenerator func(ProviderOpt) (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
}
}
@@ -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,14 +138,14 @@ 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
@@ -149,7 +164,7 @@ func (p *Provider) ShouldRenewOn() time.Time {
}
func (p *Provider) ScheduleRenewal(parent task.Parent) {
if p.GetName() == ProviderLocal {
if p.GetName() == ProviderLocal || p.GetName() == ProviderPseudo {
return
}
go func() {
@@ -171,7 +186,7 @@ func (p *Provider) ScheduleRenewal(parent task.Parent) {
continue
}
if err := p.renewIfNeeded(); err != nil {
E.LogWarn("cert renew failed", err)
gperr.LogWarn("cert renew failed", err)
lastErrOn = time.Now()
continue
}
@@ -184,13 +199,13 @@ func (p *Provider) ScheduleRenewal(parent task.Parent) {
}()
}
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]
generator := providers[p.cfg.Provider]
legoProvider, pErr := generator(p.cfg.Options)
if pErr != nil {
return pErr
@@ -198,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
@@ -273,7 +288,7 @@ func (p *Provider) certState() CertState {
return CertStateValid
}
func (p *Provider) renewIfNeeded() E.Error {
func (p *Provider) renewIfNeeded() error {
if p.cfg.Provider == ProviderLocal {
return nil
}
@@ -312,13 +327,13 @@ func providerGenerator[CT any, PT challenge.Provider](
defaultCfg func() *CT,
newProvider func(*CT) (PT, error),
) ProviderGenerator {
return func(opt ProviderOpt) (challenge.Provider, E.Error) {
return func(opt ProviderOpt) (challenge.Provider, gperr.Error) {
cfg := defaultCfg()
err := U.Deserialize(opt, &cfg)
if err != nil {
return nil, err
}
p, pErr := newProvider(cfg)
return p, E.From(pErr)
return p, gperr.Wrap(pErr)
}
}

View File

@@ -4,9 +4,9 @@ import (
"testing"
"github.com/go-acme/lego/v4/providers/dns/ovh"
"github.com/goccy/go-yaml"
U "github.com/yusing/go-proxy/internal/utils"
. "github.com/yusing/go-proxy/internal/utils/testing"
"gopkg.in/yaml.v3"
)
// type Config struct {
@@ -44,7 +44,7 @@ oauth2_config:
}
testYaml = testYaml[1:] // remove first \n
opt := make(map[string]any)
ExpectNoError(t, yaml.Unmarshal([]byte(testYaml), opt))
ExpectNoError(t, yaml.Unmarshal([]byte(testYaml), &opt))
ExpectNoError(t, U.Deserialize(opt, cfg))
ExpectDeepEqual(t, cfg, cfgExpected)
ExpectEqual(t, cfg, cfgExpected)
}

View File

@@ -0,0 +1,452 @@
//go:generate /usr/bin/python3 gen.py
package autocert
import "github.com/go-acme/lego/v4/providers/dns/acmedns"
import "github.com/go-acme/lego/v4/providers/dns/active24"
import "github.com/go-acme/lego/v4/providers/dns/alidns"
import "github.com/go-acme/lego/v4/providers/dns/allinkl"
import "github.com/go-acme/lego/v4/providers/dns/arvancloud"
import "github.com/go-acme/lego/v4/providers/dns/auroradns"
import "github.com/go-acme/lego/v4/providers/dns/autodns"
import "github.com/go-acme/lego/v4/providers/dns/axelname"
import "github.com/go-acme/lego/v4/providers/dns/azuredns"
import "github.com/go-acme/lego/v4/providers/dns/baiducloud"
import "github.com/go-acme/lego/v4/providers/dns/bindman"
import "github.com/go-acme/lego/v4/providers/dns/bluecat"
import "github.com/go-acme/lego/v4/providers/dns/bookmyname"
import "github.com/go-acme/lego/v4/providers/dns/bunny"
import "github.com/go-acme/lego/v4/providers/dns/checkdomain"
import "github.com/go-acme/lego/v4/providers/dns/civo"
import "github.com/go-acme/lego/v4/providers/dns/clouddns"
import "github.com/go-acme/lego/v4/providers/dns/cloudflare"
import "github.com/go-acme/lego/v4/providers/dns/cloudns"
import "github.com/go-acme/lego/v4/providers/dns/cloudru"
import "github.com/go-acme/lego/v4/providers/dns/conoha"
import "github.com/go-acme/lego/v4/providers/dns/constellix"
import "github.com/go-acme/lego/v4/providers/dns/corenetworks"
import "github.com/go-acme/lego/v4/providers/dns/cpanel"
import "github.com/go-acme/lego/v4/providers/dns/derak"
import "github.com/go-acme/lego/v4/providers/dns/desec"
import "github.com/go-acme/lego/v4/providers/dns/designate"
import "github.com/go-acme/lego/v4/providers/dns/digitalocean"
import "github.com/go-acme/lego/v4/providers/dns/directadmin"
import "github.com/go-acme/lego/v4/providers/dns/dnshomede"
import "github.com/go-acme/lego/v4/providers/dns/dnsimple"
import "github.com/go-acme/lego/v4/providers/dns/dnsmadeeasy"
import "github.com/go-acme/lego/v4/providers/dns/dode"
import "github.com/go-acme/lego/v4/providers/dns/domeneshop"
import "github.com/go-acme/lego/v4/providers/dns/dreamhost"
import "github.com/go-acme/lego/v4/providers/dns/duckdns"
import "github.com/go-acme/lego/v4/providers/dns/dyn"
import "github.com/go-acme/lego/v4/providers/dns/dynu"
import "github.com/go-acme/lego/v4/providers/dns/easydns"
import "github.com/go-acme/lego/v4/providers/dns/edgedns"
import "github.com/go-acme/lego/v4/providers/dns/efficientip"
import "github.com/go-acme/lego/v4/providers/dns/epik"
import "github.com/go-acme/lego/v4/providers/dns/exec"
import "github.com/go-acme/lego/v4/providers/dns/exoscale"
import "github.com/go-acme/lego/v4/providers/dns/f5xc"
import "github.com/go-acme/lego/v4/providers/dns/freemyip"
import "github.com/go-acme/lego/v4/providers/dns/gandi"
import "github.com/go-acme/lego/v4/providers/dns/gandiv5"
import "github.com/go-acme/lego/v4/providers/dns/gcloud"
import "github.com/go-acme/lego/v4/providers/dns/gcore"
import "github.com/go-acme/lego/v4/providers/dns/glesys"
import "github.com/go-acme/lego/v4/providers/dns/godaddy"
import "github.com/go-acme/lego/v4/providers/dns/googledomains"
import "github.com/go-acme/lego/v4/providers/dns/hetzner"
import "github.com/go-acme/lego/v4/providers/dns/hostingde"
import "github.com/go-acme/lego/v4/providers/dns/hosttech"
import "github.com/go-acme/lego/v4/providers/dns/httpnet"
import "github.com/go-acme/lego/v4/providers/dns/httpreq"
import "github.com/go-acme/lego/v4/providers/dns/huaweicloud"
import "github.com/go-acme/lego/v4/providers/dns/hurricane"
import "github.com/go-acme/lego/v4/providers/dns/hyperone"
import "github.com/go-acme/lego/v4/providers/dns/ibmcloud"
import "github.com/go-acme/lego/v4/providers/dns/iij"
import "github.com/go-acme/lego/v4/providers/dns/iijdpf"
import "github.com/go-acme/lego/v4/providers/dns/infoblox"
import "github.com/go-acme/lego/v4/providers/dns/infomaniak"
import "github.com/go-acme/lego/v4/providers/dns/internetbs"
import "github.com/go-acme/lego/v4/providers/dns/inwx"
import "github.com/go-acme/lego/v4/providers/dns/ionos"
import "github.com/go-acme/lego/v4/providers/dns/ipv64"
import "github.com/go-acme/lego/v4/providers/dns/iwantmyname"
import "github.com/go-acme/lego/v4/providers/dns/joker"
import "github.com/go-acme/lego/v4/providers/dns/liara"
import "github.com/go-acme/lego/v4/providers/dns/lightsail"
import "github.com/go-acme/lego/v4/providers/dns/limacity"
import "github.com/go-acme/lego/v4/providers/dns/linode"
import "github.com/go-acme/lego/v4/providers/dns/liquidweb"
import "github.com/go-acme/lego/v4/providers/dns/loopia"
import "github.com/go-acme/lego/v4/providers/dns/luadns"
import "github.com/go-acme/lego/v4/providers/dns/mailinabox"
import "github.com/go-acme/lego/v4/providers/dns/manageengine"
import "github.com/go-acme/lego/v4/providers/dns/metaname"
import "github.com/go-acme/lego/v4/providers/dns/metaregistrar"
import "github.com/go-acme/lego/v4/providers/dns/mijnhost"
import "github.com/go-acme/lego/v4/providers/dns/mittwald"
import "github.com/go-acme/lego/v4/providers/dns/myaddr"
import "github.com/go-acme/lego/v4/providers/dns/mydnsjp"
import "github.com/go-acme/lego/v4/providers/dns/namecheap"
import "github.com/go-acme/lego/v4/providers/dns/namedotcom"
import "github.com/go-acme/lego/v4/providers/dns/namesilo"
import "github.com/go-acme/lego/v4/providers/dns/nearlyfreespeech"
import "github.com/go-acme/lego/v4/providers/dns/netcup"
import "github.com/go-acme/lego/v4/providers/dns/netlify"
import "github.com/go-acme/lego/v4/providers/dns/nicmanager"
import "github.com/go-acme/lego/v4/providers/dns/nifcloud"
import "github.com/go-acme/lego/v4/providers/dns/njalla"
import "github.com/go-acme/lego/v4/providers/dns/nodion"
import "github.com/go-acme/lego/v4/providers/dns/ns1"
import "github.com/go-acme/lego/v4/providers/dns/oraclecloud"
import "github.com/go-acme/lego/v4/providers/dns/otc"
import "github.com/go-acme/lego/v4/providers/dns/ovh"
import "github.com/go-acme/lego/v4/providers/dns/pdns"
import "github.com/go-acme/lego/v4/providers/dns/plesk"
import "github.com/go-acme/lego/v4/providers/dns/porkbun"
import "github.com/go-acme/lego/v4/providers/dns/rackspace"
import "github.com/go-acme/lego/v4/providers/dns/rainyun"
import "github.com/go-acme/lego/v4/providers/dns/rcodezero"
import "github.com/go-acme/lego/v4/providers/dns/regfish"
import "github.com/go-acme/lego/v4/providers/dns/regru"
import "github.com/go-acme/lego/v4/providers/dns/rfc2136"
import "github.com/go-acme/lego/v4/providers/dns/rimuhosting"
import "github.com/go-acme/lego/v4/providers/dns/route53"
import "github.com/go-acme/lego/v4/providers/dns/safedns"
import "github.com/go-acme/lego/v4/providers/dns/sakuracloud"
import "github.com/go-acme/lego/v4/providers/dns/scaleway"
import "github.com/go-acme/lego/v4/providers/dns/selectel"
import "github.com/go-acme/lego/v4/providers/dns/selectelv2"
import "github.com/go-acme/lego/v4/providers/dns/selfhostde"
import "github.com/go-acme/lego/v4/providers/dns/servercow"
import "github.com/go-acme/lego/v4/providers/dns/shellrent"
import "github.com/go-acme/lego/v4/providers/dns/simply"
import "github.com/go-acme/lego/v4/providers/dns/sonic"
import "github.com/go-acme/lego/v4/providers/dns/spaceship"
import "github.com/go-acme/lego/v4/providers/dns/stackpath"
import "github.com/go-acme/lego/v4/providers/dns/technitium"
import "github.com/go-acme/lego/v4/providers/dns/tencentcloud"
import "github.com/go-acme/lego/v4/providers/dns/timewebcloud"
import "github.com/go-acme/lego/v4/providers/dns/transip"
import "github.com/go-acme/lego/v4/providers/dns/ultradns"
import "github.com/go-acme/lego/v4/providers/dns/variomedia"
import "github.com/go-acme/lego/v4/providers/dns/vegadns"
import "github.com/go-acme/lego/v4/providers/dns/vercel"
import "github.com/go-acme/lego/v4/providers/dns/versio"
import "github.com/go-acme/lego/v4/providers/dns/vinyldns"
import "github.com/go-acme/lego/v4/providers/dns/vkcloud"
import "github.com/go-acme/lego/v4/providers/dns/volcengine"
import "github.com/go-acme/lego/v4/providers/dns/vscale"
import "github.com/go-acme/lego/v4/providers/dns/vultr"
import "github.com/go-acme/lego/v4/providers/dns/webnames"
import "github.com/go-acme/lego/v4/providers/dns/websupport"
import "github.com/go-acme/lego/v4/providers/dns/wedos"
import "github.com/go-acme/lego/v4/providers/dns/westcn"
import "github.com/go-acme/lego/v4/providers/dns/yandex"
import "github.com/go-acme/lego/v4/providers/dns/yandex360"
import "github.com/go-acme/lego/v4/providers/dns/zoneee"
import "github.com/go-acme/lego/v4/providers/dns/zonomi"
const (
ProviderLocal = "local"
ProviderPseudo = "pseudo"
Provideracmedns = "acmedns"
Provideractive24 = "active24"
Provideralidns = "alidns"
Providerallinkl = "allinkl"
Providerarvancloud = "arvancloud"
Providerauroradns = "auroradns"
Providerautodns = "autodns"
Provideraxelname = "axelname"
Providerazuredns = "azuredns"
Providerbaiducloud = "baiducloud"
Providerbindman = "bindman"
Providerbluecat = "bluecat"
Providerbookmyname = "bookmyname"
Providerbunny = "bunny"
Providercheckdomain = "checkdomain"
Providercivo = "civo"
Providerclouddns = "clouddns"
Providercloudflare = "cloudflare"
Providercloudns = "cloudns"
Providercloudru = "cloudru"
Providerconoha = "conoha"
Providerconstellix = "constellix"
Providercorenetworks = "corenetworks"
Providercpanel = "cpanel"
Providerderak = "derak"
Providerdesec = "desec"
Providerdesignate = "designate"
Providerdigitalocean = "digitalocean"
Providerdirectadmin = "directadmin"
Providerdnshomede = "dnshomede"
Providerdnsimple = "dnsimple"
Providerdnsmadeeasy = "dnsmadeeasy"
Providerdode = "dode"
Providerdomeneshop = "domeneshop"
Providerdreamhost = "dreamhost"
Providerduckdns = "duckdns"
Providerdyn = "dyn"
Providerdynu = "dynu"
Providereasydns = "easydns"
Provideredgedns = "edgedns"
Providerefficientip = "efficientip"
Providerepik = "epik"
Providerexec = "exec"
Providerexoscale = "exoscale"
Providerf5xc = "f5xc"
Providerfreemyip = "freemyip"
Providergandi = "gandi"
Providergandiv5 = "gandiv5"
Providergcloud = "gcloud"
Providergcore = "gcore"
Providerglesys = "glesys"
Providergodaddy = "godaddy"
Providergoogledomains = "googledomains"
Providerhetzner = "hetzner"
Providerhostingde = "hostingde"
Providerhosttech = "hosttech"
Providerhttpnet = "httpnet"
Providerhttpreq = "httpreq"
Providerhuaweicloud = "huaweicloud"
Providerhurricane = "hurricane"
Providerhyperone = "hyperone"
Provideribmcloud = "ibmcloud"
Provideriij = "iij"
Provideriijdpf = "iijdpf"
Providerinfoblox = "infoblox"
Providerinfomaniak = "infomaniak"
Providerinternetbs = "internetbs"
Providerinwx = "inwx"
Providerionos = "ionos"
Provideripv64 = "ipv64"
Provideriwantmyname = "iwantmyname"
Providerjoker = "joker"
Providerliara = "liara"
Providerlightsail = "lightsail"
Providerlimacity = "limacity"
Providerlinode = "linode"
Providerliquidweb = "liquidweb"
Providerloopia = "loopia"
Providerluadns = "luadns"
Providermailinabox = "mailinabox"
Providermanageengine = "manageengine"
Providermetaname = "metaname"
Providermetaregistrar = "metaregistrar"
Providermijnhost = "mijnhost"
Providermittwald = "mittwald"
Providermyaddr = "myaddr"
Providermydnsjp = "mydnsjp"
Providernamecheap = "namecheap"
Providernamedotcom = "namedotcom"
Providernamesilo = "namesilo"
Providernearlyfreespeech = "nearlyfreespeech"
Providernetcup = "netcup"
Providernetlify = "netlify"
Providernicmanager = "nicmanager"
Providernifcloud = "nifcloud"
Providernjalla = "njalla"
Providernodion = "nodion"
Providerns1 = "ns1"
Provideroraclecloud = "oraclecloud"
Providerotc = "otc"
Providerovh = "ovh"
Providerpdns = "pdns"
Providerplesk = "plesk"
Providerporkbun = "porkbun"
Providerrackspace = "rackspace"
Providerrainyun = "rainyun"
Providerrcodezero = "rcodezero"
Providerregfish = "regfish"
Providerregru = "regru"
Providerrfc2136 = "rfc2136"
Providerrimuhosting = "rimuhosting"
Providerroute53 = "route53"
Providersafedns = "safedns"
Providersakuracloud = "sakuracloud"
Providerscaleway = "scaleway"
Providerselectel = "selectel"
Providerselectelv2 = "selectelv2"
Providerselfhostde = "selfhostde"
Providerservercow = "servercow"
Providershellrent = "shellrent"
Providersimply = "simply"
Providersonic = "sonic"
Providerspaceship = "spaceship"
Providerstackpath = "stackpath"
Providertechnitium = "technitium"
Providertencentcloud = "tencentcloud"
Providertimewebcloud = "timewebcloud"
Providertransip = "transip"
Providerultradns = "ultradns"
Providervariomedia = "variomedia"
Providervegadns = "vegadns"
Providervercel = "vercel"
Providerversio = "versio"
Providervinyldns = "vinyldns"
Providervkcloud = "vkcloud"
Providervolcengine = "volcengine"
Providervscale = "vscale"
Providervultr = "vultr"
Providerwebnames = "webnames"
Providerwebsupport = "websupport"
Providerwedos = "wedos"
Providerwestcn = "westcn"
Provideryandex = "yandex"
Provideryandex360 = "yandex360"
Providerzoneee = "zoneee"
Providerzonomi = "zonomi"
)
var providers = map[string]ProviderGenerator{
ProviderLocal: providerGenerator(NewDummyDefaultConfig, NewDummyDNSProviderConfig),
ProviderPseudo: providerGenerator(NewDummyDefaultConfig, NewDummyDNSProviderConfig),
Provideracmedns: providerGenerator(acmedns.NewDefaultConfig, acmedns.NewDNSProviderConfig),
Provideractive24: providerGenerator(active24.NewDefaultConfig, active24.NewDNSProviderConfig),
Provideralidns: providerGenerator(alidns.NewDefaultConfig, alidns.NewDNSProviderConfig),
Providerallinkl: providerGenerator(allinkl.NewDefaultConfig, allinkl.NewDNSProviderConfig),
Providerarvancloud: providerGenerator(arvancloud.NewDefaultConfig, arvancloud.NewDNSProviderConfig),
Providerauroradns: providerGenerator(auroradns.NewDefaultConfig, auroradns.NewDNSProviderConfig),
Providerautodns: providerGenerator(autodns.NewDefaultConfig, autodns.NewDNSProviderConfig),
Provideraxelname: providerGenerator(axelname.NewDefaultConfig, axelname.NewDNSProviderConfig),
Providerazuredns: providerGenerator(azuredns.NewDefaultConfig, azuredns.NewDNSProviderConfig),
Providerbaiducloud: providerGenerator(baiducloud.NewDefaultConfig, baiducloud.NewDNSProviderConfig),
Providerbindman: providerGenerator(bindman.NewDefaultConfig, bindman.NewDNSProviderConfig),
Providerbluecat: providerGenerator(bluecat.NewDefaultConfig, bluecat.NewDNSProviderConfig),
Providerbookmyname: providerGenerator(bookmyname.NewDefaultConfig, bookmyname.NewDNSProviderConfig),
Providerbunny: providerGenerator(bunny.NewDefaultConfig, bunny.NewDNSProviderConfig),
Providercheckdomain: providerGenerator(checkdomain.NewDefaultConfig, checkdomain.NewDNSProviderConfig),
Providercivo: providerGenerator(civo.NewDefaultConfig, civo.NewDNSProviderConfig),
Providerclouddns: providerGenerator(clouddns.NewDefaultConfig, clouddns.NewDNSProviderConfig),
Providercloudflare: providerGenerator(cloudflare.NewDefaultConfig, cloudflare.NewDNSProviderConfig),
Providercloudns: providerGenerator(cloudns.NewDefaultConfig, cloudns.NewDNSProviderConfig),
Providercloudru: providerGenerator(cloudru.NewDefaultConfig, cloudru.NewDNSProviderConfig),
Providerconoha: providerGenerator(conoha.NewDefaultConfig, conoha.NewDNSProviderConfig),
Providerconstellix: providerGenerator(constellix.NewDefaultConfig, constellix.NewDNSProviderConfig),
Providercorenetworks: providerGenerator(corenetworks.NewDefaultConfig, corenetworks.NewDNSProviderConfig),
Providercpanel: providerGenerator(cpanel.NewDefaultConfig, cpanel.NewDNSProviderConfig),
Providerderak: providerGenerator(derak.NewDefaultConfig, derak.NewDNSProviderConfig),
Providerdesec: providerGenerator(desec.NewDefaultConfig, desec.NewDNSProviderConfig),
Providerdesignate: providerGenerator(designate.NewDefaultConfig, designate.NewDNSProviderConfig),
Providerdigitalocean: providerGenerator(digitalocean.NewDefaultConfig, digitalocean.NewDNSProviderConfig),
Providerdirectadmin: providerGenerator(directadmin.NewDefaultConfig, directadmin.NewDNSProviderConfig),
Providerdnshomede: providerGenerator(dnshomede.NewDefaultConfig, dnshomede.NewDNSProviderConfig),
Providerdnsimple: providerGenerator(dnsimple.NewDefaultConfig, dnsimple.NewDNSProviderConfig),
Providerdnsmadeeasy: providerGenerator(dnsmadeeasy.NewDefaultConfig, dnsmadeeasy.NewDNSProviderConfig),
Providerdode: providerGenerator(dode.NewDefaultConfig, dode.NewDNSProviderConfig),
Providerdomeneshop: providerGenerator(domeneshop.NewDefaultConfig, domeneshop.NewDNSProviderConfig),
Providerdreamhost: providerGenerator(dreamhost.NewDefaultConfig, dreamhost.NewDNSProviderConfig),
Providerduckdns: providerGenerator(duckdns.NewDefaultConfig, duckdns.NewDNSProviderConfig),
Providerdyn: providerGenerator(dyn.NewDefaultConfig, dyn.NewDNSProviderConfig),
Providerdynu: providerGenerator(dynu.NewDefaultConfig, dynu.NewDNSProviderConfig),
Providereasydns: providerGenerator(easydns.NewDefaultConfig, easydns.NewDNSProviderConfig),
Provideredgedns: providerGenerator(edgedns.NewDefaultConfig, edgedns.NewDNSProviderConfig),
Providerefficientip: providerGenerator(efficientip.NewDefaultConfig, efficientip.NewDNSProviderConfig),
Providerepik: providerGenerator(epik.NewDefaultConfig, epik.NewDNSProviderConfig),
Providerexec: providerGenerator(exec.NewDefaultConfig, exec.NewDNSProviderConfig),
Providerexoscale: providerGenerator(exoscale.NewDefaultConfig, exoscale.NewDNSProviderConfig),
Providerf5xc: providerGenerator(f5xc.NewDefaultConfig, f5xc.NewDNSProviderConfig),
Providerfreemyip: providerGenerator(freemyip.NewDefaultConfig, freemyip.NewDNSProviderConfig),
Providergandi: providerGenerator(gandi.NewDefaultConfig, gandi.NewDNSProviderConfig),
Providergandiv5: providerGenerator(gandiv5.NewDefaultConfig, gandiv5.NewDNSProviderConfig),
Providergcloud: providerGenerator(gcloud.NewDefaultConfig, gcloud.NewDNSProviderConfig),
Providergcore: providerGenerator(gcore.NewDefaultConfig, gcore.NewDNSProviderConfig),
Providerglesys: providerGenerator(glesys.NewDefaultConfig, glesys.NewDNSProviderConfig),
Providergodaddy: providerGenerator(godaddy.NewDefaultConfig, godaddy.NewDNSProviderConfig),
Providergoogledomains: providerGenerator(googledomains.NewDefaultConfig, googledomains.NewDNSProviderConfig),
Providerhetzner: providerGenerator(hetzner.NewDefaultConfig, hetzner.NewDNSProviderConfig),
Providerhostingde: providerGenerator(hostingde.NewDefaultConfig, hostingde.NewDNSProviderConfig),
Providerhosttech: providerGenerator(hosttech.NewDefaultConfig, hosttech.NewDNSProviderConfig),
Providerhttpnet: providerGenerator(httpnet.NewDefaultConfig, httpnet.NewDNSProviderConfig),
Providerhttpreq: providerGenerator(httpreq.NewDefaultConfig, httpreq.NewDNSProviderConfig),
Providerhuaweicloud: providerGenerator(huaweicloud.NewDefaultConfig, huaweicloud.NewDNSProviderConfig),
Providerhurricane: providerGenerator(hurricane.NewDefaultConfig, hurricane.NewDNSProviderConfig),
Providerhyperone: providerGenerator(hyperone.NewDefaultConfig, hyperone.NewDNSProviderConfig),
Provideribmcloud: providerGenerator(ibmcloud.NewDefaultConfig, ibmcloud.NewDNSProviderConfig),
Provideriij: providerGenerator(iij.NewDefaultConfig, iij.NewDNSProviderConfig),
Provideriijdpf: providerGenerator(iijdpf.NewDefaultConfig, iijdpf.NewDNSProviderConfig),
Providerinfoblox: providerGenerator(infoblox.NewDefaultConfig, infoblox.NewDNSProviderConfig),
Providerinfomaniak: providerGenerator(infomaniak.NewDefaultConfig, infomaniak.NewDNSProviderConfig),
Providerinternetbs: providerGenerator(internetbs.NewDefaultConfig, internetbs.NewDNSProviderConfig),
Providerinwx: providerGenerator(inwx.NewDefaultConfig, inwx.NewDNSProviderConfig),
Providerionos: providerGenerator(ionos.NewDefaultConfig, ionos.NewDNSProviderConfig),
Provideripv64: providerGenerator(ipv64.NewDefaultConfig, ipv64.NewDNSProviderConfig),
Provideriwantmyname: providerGenerator(iwantmyname.NewDefaultConfig, iwantmyname.NewDNSProviderConfig),
Providerjoker: providerGenerator(joker.NewDefaultConfig, joker.NewDNSProviderConfig),
Providerliara: providerGenerator(liara.NewDefaultConfig, liara.NewDNSProviderConfig),
Providerlightsail: providerGenerator(lightsail.NewDefaultConfig, lightsail.NewDNSProviderConfig),
Providerlimacity: providerGenerator(limacity.NewDefaultConfig, limacity.NewDNSProviderConfig),
Providerlinode: providerGenerator(linode.NewDefaultConfig, linode.NewDNSProviderConfig),
Providerliquidweb: providerGenerator(liquidweb.NewDefaultConfig, liquidweb.NewDNSProviderConfig),
Providerloopia: providerGenerator(loopia.NewDefaultConfig, loopia.NewDNSProviderConfig),
Providerluadns: providerGenerator(luadns.NewDefaultConfig, luadns.NewDNSProviderConfig),
Providermailinabox: providerGenerator(mailinabox.NewDefaultConfig, mailinabox.NewDNSProviderConfig),
Providermanageengine: providerGenerator(manageengine.NewDefaultConfig, manageengine.NewDNSProviderConfig),
Providermetaname: providerGenerator(metaname.NewDefaultConfig, metaname.NewDNSProviderConfig),
Providermetaregistrar: providerGenerator(metaregistrar.NewDefaultConfig, metaregistrar.NewDNSProviderConfig),
Providermijnhost: providerGenerator(mijnhost.NewDefaultConfig, mijnhost.NewDNSProviderConfig),
Providermittwald: providerGenerator(mittwald.NewDefaultConfig, mittwald.NewDNSProviderConfig),
Providermyaddr: providerGenerator(myaddr.NewDefaultConfig, myaddr.NewDNSProviderConfig),
Providermydnsjp: providerGenerator(mydnsjp.NewDefaultConfig, mydnsjp.NewDNSProviderConfig),
Providernamecheap: providerGenerator(namecheap.NewDefaultConfig, namecheap.NewDNSProviderConfig),
Providernamedotcom: providerGenerator(namedotcom.NewDefaultConfig, namedotcom.NewDNSProviderConfig),
Providernamesilo: providerGenerator(namesilo.NewDefaultConfig, namesilo.NewDNSProviderConfig),
Providernearlyfreespeech: providerGenerator(nearlyfreespeech.NewDefaultConfig, nearlyfreespeech.NewDNSProviderConfig),
Providernetcup: providerGenerator(netcup.NewDefaultConfig, netcup.NewDNSProviderConfig),
Providernetlify: providerGenerator(netlify.NewDefaultConfig, netlify.NewDNSProviderConfig),
Providernicmanager: providerGenerator(nicmanager.NewDefaultConfig, nicmanager.NewDNSProviderConfig),
Providernifcloud: providerGenerator(nifcloud.NewDefaultConfig, nifcloud.NewDNSProviderConfig),
Providernjalla: providerGenerator(njalla.NewDefaultConfig, njalla.NewDNSProviderConfig),
Providernodion: providerGenerator(nodion.NewDefaultConfig, nodion.NewDNSProviderConfig),
Providerns1: providerGenerator(ns1.NewDefaultConfig, ns1.NewDNSProviderConfig),
Provideroraclecloud: providerGenerator(oraclecloud.NewDefaultConfig, oraclecloud.NewDNSProviderConfig),
Providerotc: providerGenerator(otc.NewDefaultConfig, otc.NewDNSProviderConfig),
Providerovh: providerGenerator(ovh.NewDefaultConfig, ovh.NewDNSProviderConfig),
Providerpdns: providerGenerator(pdns.NewDefaultConfig, pdns.NewDNSProviderConfig),
Providerplesk: providerGenerator(plesk.NewDefaultConfig, plesk.NewDNSProviderConfig),
Providerporkbun: providerGenerator(porkbun.NewDefaultConfig, porkbun.NewDNSProviderConfig),
Providerrackspace: providerGenerator(rackspace.NewDefaultConfig, rackspace.NewDNSProviderConfig),
Providerrainyun: providerGenerator(rainyun.NewDefaultConfig, rainyun.NewDNSProviderConfig),
Providerrcodezero: providerGenerator(rcodezero.NewDefaultConfig, rcodezero.NewDNSProviderConfig),
Providerregfish: providerGenerator(regfish.NewDefaultConfig, regfish.NewDNSProviderConfig),
Providerregru: providerGenerator(regru.NewDefaultConfig, regru.NewDNSProviderConfig),
Providerrfc2136: providerGenerator(rfc2136.NewDefaultConfig, rfc2136.NewDNSProviderConfig),
Providerrimuhosting: providerGenerator(rimuhosting.NewDefaultConfig, rimuhosting.NewDNSProviderConfig),
Providerroute53: providerGenerator(route53.NewDefaultConfig, route53.NewDNSProviderConfig),
Providersafedns: providerGenerator(safedns.NewDefaultConfig, safedns.NewDNSProviderConfig),
Providersakuracloud: providerGenerator(sakuracloud.NewDefaultConfig, sakuracloud.NewDNSProviderConfig),
Providerscaleway: providerGenerator(scaleway.NewDefaultConfig, scaleway.NewDNSProviderConfig),
Providerselectel: providerGenerator(selectel.NewDefaultConfig, selectel.NewDNSProviderConfig),
Providerselectelv2: providerGenerator(selectelv2.NewDefaultConfig, selectelv2.NewDNSProviderConfig),
Providerselfhostde: providerGenerator(selfhostde.NewDefaultConfig, selfhostde.NewDNSProviderConfig),
Providerservercow: providerGenerator(servercow.NewDefaultConfig, servercow.NewDNSProviderConfig),
Providershellrent: providerGenerator(shellrent.NewDefaultConfig, shellrent.NewDNSProviderConfig),
Providersimply: providerGenerator(simply.NewDefaultConfig, simply.NewDNSProviderConfig),
Providersonic: providerGenerator(sonic.NewDefaultConfig, sonic.NewDNSProviderConfig),
Providerspaceship: providerGenerator(spaceship.NewDefaultConfig, spaceship.NewDNSProviderConfig),
Providerstackpath: providerGenerator(stackpath.NewDefaultConfig, stackpath.NewDNSProviderConfig),
Providertechnitium: providerGenerator(technitium.NewDefaultConfig, technitium.NewDNSProviderConfig),
Providertencentcloud: providerGenerator(tencentcloud.NewDefaultConfig, tencentcloud.NewDNSProviderConfig),
Providertimewebcloud: providerGenerator(timewebcloud.NewDefaultConfig, timewebcloud.NewDNSProviderConfig),
Providertransip: providerGenerator(transip.NewDefaultConfig, transip.NewDNSProviderConfig),
Providerultradns: providerGenerator(ultradns.NewDefaultConfig, ultradns.NewDNSProviderConfig),
Providervariomedia: providerGenerator(variomedia.NewDefaultConfig, variomedia.NewDNSProviderConfig),
Providervegadns: providerGenerator(vegadns.NewDefaultConfig, vegadns.NewDNSProviderConfig),
Providervercel: providerGenerator(vercel.NewDefaultConfig, vercel.NewDNSProviderConfig),
Providerversio: providerGenerator(versio.NewDefaultConfig, versio.NewDNSProviderConfig),
Providervinyldns: providerGenerator(vinyldns.NewDefaultConfig, vinyldns.NewDNSProviderConfig),
Providervkcloud: providerGenerator(vkcloud.NewDefaultConfig, vkcloud.NewDNSProviderConfig),
Providervolcengine: providerGenerator(volcengine.NewDefaultConfig, volcengine.NewDNSProviderConfig),
Providervscale: providerGenerator(vscale.NewDefaultConfig, vscale.NewDNSProviderConfig),
Providervultr: providerGenerator(vultr.NewDefaultConfig, vultr.NewDNSProviderConfig),
Providerwebnames: providerGenerator(webnames.NewDefaultConfig, webnames.NewDNSProviderConfig),
Providerwebsupport: providerGenerator(websupport.NewDefaultConfig, websupport.NewDNSProviderConfig),
Providerwedos: providerGenerator(wedos.NewDefaultConfig, wedos.NewDNSProviderConfig),
Providerwestcn: providerGenerator(westcn.NewDefaultConfig, westcn.NewDNSProviderConfig),
Provideryandex: providerGenerator(yandex.NewDefaultConfig, yandex.NewDNSProviderConfig),
Provideryandex360: providerGenerator(yandex360.NewDefaultConfig, yandex360.NewDNSProviderConfig),
Providerzoneee: providerGenerator(zoneee.NewDefaultConfig, zoneee.NewDNSProviderConfig),
Providerzonomi: providerGenerator(zonomi.NewDefaultConfig, zonomi.NewDNSProviderConfig),
}

View File

@@ -1,16 +1,16 @@
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
}
logging.Debug().Msg("obtaining cert due to error loading cert")

View File

@@ -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
}

View File

@@ -4,32 +4,33 @@ 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
HomepageJSONConfigPath = ConfigBasePath + "/.homepage.json"
IconListCachePath = ConfigBasePath + "/.icon_list_cache.json"
IconCachePath = ConfigBasePath + "/.icon_cache.json"
ConfigBasePath = "config"
ConfigFileName = "config.yml"
ConfigExampleFileName = "config.example.yml"
ConfigPath = ConfigBasePath + "/" + ConfigFileName
IconListCachePath = ConfigBasePath + "/.icon_list_cache.json"
IconCachePath = ConfigBasePath + "/.icon_cache.json"
NamespaceHomepageOverrides = ".homepage"
NamespaceIconCache = ".icon_cache"
MiddlewareComposeBasePath = ConfigBasePath + "/middlewares"
ComposeFileName = "compose.yml"
ComposeExampleFileName = "compose.example.yml"
DataDir = "data"
ErrorPagesBasePath = "error_pages"
AgentCertsBasePath = "certs"
)
var RequiredDirectories = []string{
@@ -44,9 +45,7 @@ const (
HealthCheckIntervalDefault = 5 * time.Second
HealthCheckTimeoutDefault = 5 * time.Second
WakeTimeoutDefault = "30s"
StopTimeoutDefault = "30s"
WakeTimeoutDefault = "3m"
StopTimeoutDefault = "3m"
StopMethodDefault = "stop"
)
const HeaderCheckRedirect = "X-Goproxy-Check-Redirect"

View File

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

View File

@@ -15,13 +15,11 @@ import (
var (
prefixes = []string{"GODOXY_", "GOPROXY_", ""}
IsTest = GetEnvBool("TEST", false) || strings.HasSuffix(os.Args[0], ".test")
IsDebug = GetEnvBool("DEBUG", IsTest)
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
EnableLogStreaming = GetEnvBool("LOG_STREAMING", true)
DebugMemLogger = GetEnvBool("DEBUG_MEM_LOGGER", false) && EnableLogStreaming
HTTP3Enabled = GetEnvBool("HTTP3_ENABLED", true)
ProxyHTTPAddr,
ProxyHTTPHost,
@@ -38,22 +36,28 @@ var (
APIHTTPPort,
APIHTTPURL = GetAddrEnv("API_ADDR", "127.0.0.1:8888", "http")
PrometheusEnabled = GetEnvBool("PROMETHEUS_ENABLED", false)
APIJWTSecure = GetEnvBool("API_JWT_SECURE", true)
APIJWTSecret = decodeJWTKey(GetEnvString("API_JWT_SECRET", ""))
APIJWTTokenTTL = GetDurationEnv("API_JWT_TOKEN_TTL", time.Hour)
APIJWTTokenTTL = GetDurationEnv("API_JWT_TOKEN_TTL", 24*time.Hour)
APIUser = GetEnvString("API_USER", "admin")
APIPassword = GetEnvString("API_PASSWORD", "password")
DebugDisableAuth = GetEnvBool("DEBUG_DISABLE_AUTH", false)
// OIDC Configuration.
OIDCIssuerURL = GetEnvString("OIDC_ISSUER_URL", "")
OIDCLogoutURL = GetEnvString("OIDC_LOGOUT_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 {
@@ -86,7 +90,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
@@ -99,6 +107,10 @@ 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
}

View 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
}

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