mirror of
https://github.com/yusing/godoxy.git
synced 2026-01-11 22:30:47 +01:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61c8ac04e8 | ||
|
|
cc27942c4d | ||
|
|
1c2515cb29 | ||
|
|
45720db754 | ||
|
|
1b9cfa6540 | ||
|
|
f1d906ac11 | ||
|
|
2835fd5fb0 |
2
Makefile
2
Makefile
@@ -75,7 +75,7 @@ endif
|
||||
.PHONY: debug
|
||||
|
||||
test:
|
||||
go test -v -race ./internal/...
|
||||
CGO_ENABLED=1 go test -v -race ${BUILD_FLAGS} ./internal/...
|
||||
|
||||
docker-build-test:
|
||||
docker build -t godoxy .
|
||||
|
||||
12
agent/go.mod
12
agent/go.mod
@@ -25,8 +25,8 @@ require (
|
||||
github.com/yusing/godoxy v0.0.0-00010101000000-000000000000
|
||||
github.com/yusing/godoxy/socketproxy v0.0.0-00010101000000-000000000000
|
||||
github.com/yusing/goutils v0.7.0
|
||||
github.com/yusing/goutils/http/reverseproxy v0.0.0-20251217162119-cb0f79b51ce2
|
||||
github.com/yusing/goutils/server v0.0.0-20251217162119-cb0f79b51ce2
|
||||
github.com/yusing/goutils/http/reverseproxy v0.0.0-20260103043911-785deb23bd64
|
||||
github.com/yusing/goutils/server v0.0.0-20260103043911-785deb23bd64
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -65,7 +65,7 @@ require (
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/goccy/go-yaml v1.19.1 // indirect
|
||||
github.com/gorilla/mux v1.8.1 // indirect
|
||||
github.com/gotify/server/v2 v2.7.3 // indirect
|
||||
github.com/gotify/server/v2 v2.8.0 // indirect
|
||||
github.com/jinzhu/copier v0.4.0 // indirect
|
||||
github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12 // indirect
|
||||
github.com/klauspost/compress v1.18.2 // indirect
|
||||
@@ -73,7 +73,7 @@ require (
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/lithammer/fuzzysearch v1.1.8 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 // indirect
|
||||
github.com/luthermonson/go-proxmox v0.2.4 // indirect
|
||||
github.com/luthermonson/go-proxmox v0.3.1 // 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
|
||||
@@ -95,7 +95,7 @@ require (
|
||||
github.com/samber/lo v1.52.0 // indirect
|
||||
github.com/samber/slog-common v0.19.0 // indirect
|
||||
github.com/samber/slog-zerolog/v2 v2.9.0 // indirect
|
||||
github.com/shirou/gopsutil/v4 v4.25.11 // indirect
|
||||
github.com/shirou/gopsutil/v4 v4.25.12 // indirect
|
||||
github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af // indirect
|
||||
github.com/spf13/afero v1.15.0 // indirect
|
||||
github.com/tklauser/go-sysconf v0.3.16 // indirect
|
||||
@@ -106,7 +106,7 @@ require (
|
||||
github.com/vincent-petithory/dataurl v1.0.0 // indirect
|
||||
github.com/yusing/ds v0.3.1 // indirect
|
||||
github.com/yusing/gointernals v0.1.16 // indirect
|
||||
github.com/yusing/goutils/http/websocket v0.0.0-20251217162119-cb0f79b51ce2 // indirect
|
||||
github.com/yusing/goutils/http/websocket v0.0.0-20260103043911-785deb23bd64 // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 // indirect
|
||||
|
||||
93
agent/go.sum
93
agent/go.sum
@@ -1,13 +1,38 @@
|
||||
cloud.google.com/go/auth v0.18.0 h1:wnqy5hrv7p3k7cShwAU/Br3nzod7fxoqG+k0VZ+/Pk0=
|
||||
cloud.google.com/go/auth v0.18.0/go.mod h1:wwkPM1AgE1f2u6dG443MiWoD8C3BtOywNsUMcUTVDRo=
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
|
||||
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
|
||||
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
|
||||
github.com/Azure/azure-sdk-for-go v68.0.0+incompatible h1:fcYLmCpyNYRnvJbPerq7U0hS+6+I79yEDJBqVNcqUzU=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0 h1:JXg2dwJUmPB9JmtVmdEB16APJ7jurfbY5jnfXpJoRMc=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0/go.mod h1:YD5h/ldMsG0XiIw7PdyNhLxaM317eFh5yNLccNfGdyw=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 h1:9iefClla7iYpfYWdzPCRDozdmndjTm8DXdpCzPajMgA=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2/go.mod h1:XtLgD3ZD34DAaVIIAyG3objl5DynM3CQ/vMcbBNJZGI=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns v1.2.0 h1:lpOxwrQ919lCZoNCd69rVt8u1eLZuMORrGXqy8sNf3c=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns v1.2.0/go.mod h1:fSvRkb8d26z9dbL40Uf/OO6Vo9iExtZK3D0ulRV+8M0=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/privatedns/armprivatedns v1.3.0 h1:yzrctSl9GMIQ5lHu7jc8olOsGjWDCsBpJhWqfGa/YIM=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/privatedns/armprivatedns v1.3.0/go.mod h1:GE4m0rnnfwLGX0Y9A9A25Zx5N/90jneT5ABevqzhuFQ=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph v0.9.0 h1:zLzoX5+W2l95UJoVwiyNS4dX8vHyQ6x2xRLoBBL9wMk=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph v0.9.0/go.mod h1:wVEOJfGTj0oPAUGA1JuRAvz/lxXQsWW16axmHPP47Bk=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43xxfqw=
|
||||
github.com/PuerkitoBio/goquery v1.11.0/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ=
|
||||
github.com/akamai/AkamaiOPEN-edgegrid-golang/v11 v11.1.0 h1:h/33OxYLqBk0BYmEbSUy7MlvgQR/m1w1/7OJFKoPL1I=
|
||||
github.com/akamai/AkamaiOPEN-edgegrid-golang/v11 v11.1.0/go.mod h1:rvh3imDA6EaQi+oM/GQHkQAOHbXPKJ7EWJvfjuw141Q=
|
||||
github.com/anchore/go-lzo v0.1.0 h1:NgAacnzqPeGH49Ky19QKLBZEuFRqtTG9cdaucc3Vncs=
|
||||
github.com/anchore/go-lzo v0.1.0/go.mod h1:3kLx0bve2oN1iDwgM1U5zGku1Tfbdb0No5qp1eL1fIk=
|
||||
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
|
||||
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
|
||||
github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o=
|
||||
github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
|
||||
github.com/buger/goterm v1.0.4 h1:Z9YvGmOih81P0FbVtEYTFF6YsSgxSUKEhf/f9bTMXbY=
|
||||
github.com/buger/goterm v1.0.4/go.mod h1:HiFWV3xnkolgrBV3mY8m0X0Pumt4zg4QhbdOzQtB8tE=
|
||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
@@ -71,6 +96,8 @@ github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre
|
||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 h1:byhDUpfEwjsVQb1vBunvIjh2BHQ9ead57VkAEY4V+Es=
|
||||
github.com/go-ozzo/ozzo-validation/v4 v4.3.0/go.mod h1:2NKgrcHl3z6cJs+3Oo940FPRiTzuqKbvfrL2RxCj6Ew=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
@@ -79,6 +106,8 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||
github.com/go-resty/resty/v2 v2.17.1 h1:x3aMpHK1YM9e4va/TMDRlusDDoZiQ+ViDu/WpA6xTM4=
|
||||
github.com/go-resty/resty/v2 v2.17.1/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2mLtQrOyQlVA=
|
||||
github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
|
||||
github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
||||
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
|
||||
@@ -88,24 +117,38 @@ github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PU
|
||||
github.com/goccy/go-yaml v1.19.1 h1:3rG3+v8pkhRqoQ/88NYNMHYVGYztCOCIZ7UQhu7H+NE=
|
||||
github.com/goccy/go-yaml v1.19.1/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw=
|
||||
github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0=
|
||||
github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
|
||||
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.7 h1:zrn2Ee/nWmHulBx5sAVrGgAa0f2/R35S4DJwfFaUPFQ=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.7/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
|
||||
github.com/googleapis/gax-go/v2 v2.16.0 h1:iHbQmKLLZrexmb0OSsNGTeSTS0HO4YvFOG8g5E4Zd0Y=
|
||||
github.com/googleapis/gax-go/v2 v2.16.0/go.mod h1:o1vfQjjNZn4+dPnRdl/4ZD7S9414Y4xA+a/6Icj6l14=
|
||||
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/gotify/server/v2 v2.7.3 h1:nro/ZnxdlZFvxFcw9LREGA8zdk6CK744azwhuhX/A4g=
|
||||
github.com/gotify/server/v2 v2.7.3/go.mod h1:VAtE1RIc/2j886PYs9WPQbMjqbFsoyQ0G8IdFtnAxU0=
|
||||
github.com/gotify/server/v2 v2.8.0 h1:E3UDDn/3rFZi1sjZfbuhXNnxJP3ACZhdcw/iySegPRA=
|
||||
github.com/gotify/server/v2 v2.8.0/go.mod h1:6ci5adxcE2hf1v+2oowKiQmixOxXV8vU+CRLKP6sqZA=
|
||||
github.com/h2non/gock v1.2.0 h1:K6ol8rfrRkUOefooBC8elXoaNGYkpp7y2qcxGG6BzUE=
|
||||
github.com/h2non/gock v1.2.0/go.mod h1:tNhoxHYW2W42cYkYb1WqzdbYIieALC99kpYr7rH/BQk=
|
||||
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw=
|
||||
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI=
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
|
||||
github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48=
|
||||
github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw=
|
||||
github.com/jinzhu/copier v0.4.0 h1:w3ciUoD19shMCRargcpm0cm91ytaBhDvuRpz1ODO/U8=
|
||||
github.com/jinzhu/copier v0.4.0/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg=
|
||||
github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12 h1:9Nu54bhS/H/Kgo2/7xNSUuC5G28VR8ljfrLKU2G4IjU=
|
||||
@@ -118,14 +161,18 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/linode/linodego v1.63.0 h1:MdjizfXNJDVJU6ggoJmMO5O9h4KGPGivNX0fzrAnstk=
|
||||
github.com/linode/linodego v1.63.0/go.mod h1:GoiwLVuLdBQcAebxAVKVL3mMYUgJZR/puOUSla04xBE=
|
||||
github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4=
|
||||
github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4=
|
||||
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 h1:PwQumkgq4/acIiZhtifTV5OUqqiP82UAl0h87xj/l9k=
|
||||
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
||||
github.com/luthermonson/go-proxmox v0.2.4 h1:XQ6YNUTVvHS7N4EJxWpuqWLW2s1VPtsIblxLV/rGHLw=
|
||||
github.com/luthermonson/go-proxmox v0.2.4/go.mod h1:oyFgg2WwTEIF0rP6ppjiixOHa5ebK1p8OaRiFhvICBQ=
|
||||
github.com/luthermonson/go-proxmox v0.3.1 h1:h64s4/zIEQ06TBo0phFKcckV441YpvUPgLfRAptYsjY=
|
||||
github.com/luthermonson/go-proxmox v0.3.1/go.mod h1:oyFgg2WwTEIF0rP6ppjiixOHa5ebK1p8OaRiFhvICBQ=
|
||||
github.com/magefile/mage v1.15.0 h1:BvGheCMAsG3bWUDbZ8AyXXpCNwU9u5CB6sM+HNb9HYg=
|
||||
github.com/magefile/mage v1.15.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
@@ -137,6 +184,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/miekg/dns v1.1.69 h1:Kb7Y/1Jo+SG+a2GtfoFUfDkG//csdRPwRLkCsxDG9Sc=
|
||||
github.com/miekg/dns v1.1.69/go.mod h1:7OyjD9nEba5OkqQ/hB4fy3PIoxafSZJtducccIelz3g=
|
||||
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
|
||||
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
|
||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||
github.com/moby/moby/api v1.52.0 h1:00BtlJY4MXkkt84WhUZPRqt5TvPbgig2FZvTbe3igYg=
|
||||
@@ -148,18 +197,30 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/nrdcg/goacmedns v0.2.0 h1:ADMbThobzEMnr6kg2ohs4KGa3LFqmgiBA22/6jUWJR0=
|
||||
github.com/nrdcg/goacmedns v0.2.0/go.mod h1:T5o6+xvSLrQpugmwHvrSNkzWht0UGAwj2ACBMhh73Cg=
|
||||
github.com/nrdcg/oci-go-sdk/common/v1065 v1065.105.2 h1:l0tH15ACQADZAzC+LZ+mo2tIX4H6uZu0ulrVmG5Tqz0=
|
||||
github.com/nrdcg/oci-go-sdk/common/v1065 v1065.105.2/go.mod h1:Gcs8GCaZXL3FdiDWgdnMxlOLEdRprJJnPYB22TX1jw8=
|
||||
github.com/nrdcg/oci-go-sdk/dns/v1065 v1065.105.2 h1:gzB4c6ztb38C/jYiqEaFC+mCGcWFHDji9e6jwymY9d4=
|
||||
github.com/nrdcg/oci-go-sdk/dns/v1065 v1065.105.2/go.mod h1:l1qIPIq2uRV5WTSvkbhbl/ndbeOu7OCb3UZ+0+2ZSb8=
|
||||
github.com/nrdcg/porkbun v0.4.0 h1:rWweKlwo1PToQ3H+tEO9gPRW0wzzgmI/Ob3n2Guticw=
|
||||
github.com/nrdcg/porkbun v0.4.0/go.mod h1:/QMskrHEIM0IhC/wY7iTCUgINsxdT2WcOphktJ9+Q54=
|
||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
||||
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
|
||||
github.com/oschwald/maxminddb-golang v1.13.1 h1:G3wwjdN9JmIK2o/ermkHM+98oX5fS+k5MbwsmL4MRQE=
|
||||
github.com/oschwald/maxminddb-golang v1.13.1/go.mod h1:K4pgV9N/GcK694KSTmVSDTODk4IsCNThNdTmnaBZ/F8=
|
||||
github.com/ovh/go-ovh v1.9.0 h1:6K8VoL3BYjVV3In9tPJUdT7qMx9h0GExN9EXx1r2kKE=
|
||||
github.com/ovh/go-ovh v1.9.0/go.mod h1:cTVDnl94z4tl8pP1uZ/8jlVxntjSIf09bNcQ5TJSC7c=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ=
|
||||
github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
||||
github.com/pires/go-proxyproto v0.8.1 h1:9KEixbdJfhrbtjpz/ZwCdWDD2Xem0NZ38qMYaASJgp0=
|
||||
github.com/pires/go-proxyproto v0.8.1/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/xattr v0.4.9 h1:5883YPCtkSd8LFbs13nXplj9g9tlrwoJRjgpgMu1/fE=
|
||||
github.com/pkg/xattr v0.4.9/go.mod h1:di8WF84zAKk8jzR1UBTEWh9AUlIZZ7M/JNt8e9B6ktU=
|
||||
@@ -185,14 +246,20 @@ github.com/samber/slog-common v0.19.0 h1:fNcZb8B2uOLooeYwFpAlKjkQTUafdjfqKcwcC89
|
||||
github.com/samber/slog-common v0.19.0/go.mod h1:dTz+YOU76aH007YUU0DffsXNsGFQRQllPQh9XyNoA3M=
|
||||
github.com/samber/slog-zerolog/v2 v2.9.0 h1:6LkOabJmZdNLaUWkTC3IVVA+dq7b/V0FM6lz6/7+THI=
|
||||
github.com/samber/slog-zerolog/v2 v2.9.0/go.mod h1:gnQW9VnCfM34v2pRMUIGMsZOVbYLqY/v0Wxu6atSVGc=
|
||||
github.com/scaleway/scaleway-sdk-go v1.0.0-beta.36 h1:ObX9hZmK+VmijreZO/8x9pQ8/P/ToHD/bdSb4Eg4tUo=
|
||||
github.com/scaleway/scaleway-sdk-go v1.0.0-beta.36/go.mod h1:LEsDu4BubxK7/cWhtlQWfuxwL4rf/2UEpxXz1o1EMtM=
|
||||
github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af h1:Sp5TG9f7K39yfB+If0vjp97vuT74F72r8hfRpP8jLU0=
|
||||
github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/sony/gobreaker v1.0.0 h1:feX5fGGXSl3dYd4aHZItw+FpHLvvoaqkawKjVNiFMNQ=
|
||||
github.com/sony/gobreaker v1.0.0/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY=
|
||||
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
|
||||
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
@@ -216,11 +283,17 @@ github.com/valyala/fasthttp v1.68.0 h1:v12Nx16iepr8r9ySOwqI+5RBJ/DqTxhOy1HrHoDFn
|
||||
github.com/valyala/fasthttp v1.68.0/go.mod h1:5EXiRfYQAoiO/khu4oU9VISC/eVY6JqmSpPJoHCKsz4=
|
||||
github.com/vincent-petithory/dataurl v1.0.0 h1:cXw+kPto8NLuJtlMsI152irrVw9fRDX8AbShPRpg2CI=
|
||||
github.com/vincent-petithory/dataurl v1.0.0/go.mod h1:FHafX5vmDzyP+1CQATJn7WFKc9CvnvxyvZy6I1MrG/U=
|
||||
github.com/vultr/govultr/v3 v3.26.1 h1:G/M0rMQKwVSmL+gb0UgETbW5mcQi0Vf/o/ZSGdBCxJw=
|
||||
github.com/vultr/govultr/v3 v3.26.1/go.mod h1:9WwnWGCKnwDlNjHjtt+j+nP+0QWq6hQXzaHgddqrLWY=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/yusing/ds v0.3.1 h1:mCqTgTQD8RhiBpcysvii5kZ7ZBmqcknVsFubNALGLbY=
|
||||
github.com/yusing/ds v0.3.1/go.mod h1:XhKV4l7cZwBbbl7lRzNC9zX27zvCM0frIwiuD40ULRk=
|
||||
github.com/yusing/godoxy/internal/dnsproviders v0.0.0-20260104140148-1c2515cb298d h1:O6umnEZyKot6IqyOCuLMUuCT8/K8n+lKiQJ+UjmSfVc=
|
||||
github.com/yusing/godoxy/internal/dnsproviders v0.0.0-20260104140148-1c2515cb298d/go.mod h1:84uz4o4GfD4FhXv3v7620Vj7LtXL0gnxDgL9LA+KmEI=
|
||||
github.com/yusing/gointernals v0.1.16 h1:GrhZZdxzA+jojLEqankctJrOuAYDb7kY1C93S1pVR34=
|
||||
github.com/yusing/gointernals v0.1.16/go.mod h1:B/0FVXt4WPmgzVy3ynzkqKi+BSGaJVmwCJBRXYapo34=
|
||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||
@@ -243,6 +316,8 @@ go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko=
|
||||
go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=
|
||||
go.uber.org/ratelimit v0.3.1 h1:K4qVE+byfv/B3tC+4nYWP7v/6SimcO7HzHekoMNBma0=
|
||||
go.uber.org/ratelimit v0.3.1/go.mod h1:6euWsTB6U/Nb3X++xEUXA8ciPJvr19Q/0h1+oDcJhRk=
|
||||
golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg=
|
||||
golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
@@ -334,11 +409,21 @@ golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxb
|
||||
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
|
||||
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/api v0.258.0 h1:IKo1j5FBlN74fe5isA2PVozN3Y5pwNKriEgAXPOkDAc=
|
||||
google.golang.org/api v0.258.0/go.mod h1:qhOMTQEZ6lUps63ZNq9jhODswwjkjYYguA7fA3TBFww=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
|
||||
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
|
||||
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
22
go.mod
22
go.mod
@@ -22,7 +22,7 @@ require (
|
||||
github.com/go-playground/validator/v10 v10.30.1 // validator
|
||||
github.com/gobwas/glob v0.2.3 // glob matcher for route rules
|
||||
github.com/gorilla/websocket v1.5.3 // websocket for API and agent
|
||||
github.com/gotify/server/v2 v2.7.3 // reference the Message struct for json response
|
||||
github.com/gotify/server/v2 v2.8.0 // reference the Message struct for json response
|
||||
github.com/lithammer/fuzzysearch v1.1.8 // fuzzy search for searching icons and filtering metrics
|
||||
github.com/pires/go-proxyproto v0.8.1 // proxy protocol support
|
||||
github.com/puzpuzpuz/xsync/v4 v4.2.0 // lock free map for concurrent operations
|
||||
@@ -41,23 +41,23 @@ require (
|
||||
github.com/docker/cli v29.1.3+incompatible // needs docker/cli/cli/connhelper connection helper for docker client
|
||||
github.com/goccy/go-yaml v1.19.1 // yaml parsing for different config files
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 // jwt authentication
|
||||
github.com/luthermonson/go-proxmox v0.2.4 // proxmox API client
|
||||
github.com/luthermonson/go-proxmox v0.3.1 // proxmox API client
|
||||
github.com/moby/moby/api v1.52.0 // docker API
|
||||
github.com/moby/moby/client v0.2.1 // docker client
|
||||
github.com/oschwald/maxminddb-golang v1.13.1 // maxminddb for geoip database
|
||||
github.com/quic-go/quic-go v0.58.0 // http3 support
|
||||
github.com/shirou/gopsutil/v4 v4.25.11 // system information
|
||||
github.com/shirou/gopsutil/v4 v4.25.12 // system information
|
||||
github.com/spf13/afero v1.15.0 // afero for file system operations
|
||||
github.com/stretchr/testify v1.11.1 // testing framework
|
||||
github.com/valyala/fasthttp v1.68.0 // fast http for health check
|
||||
github.com/yusing/ds v0.3.1 // data structures and algorithms
|
||||
github.com/yusing/godoxy/agent v0.0.0-20251230135310-5087800fd763
|
||||
github.com/yusing/godoxy/internal/dnsproviders v0.0.0-20251230043958-dba8441e8a5d
|
||||
github.com/yusing/godoxy/agent v0.0.0-20260104140148-1c2515cb298d
|
||||
github.com/yusing/godoxy/internal/dnsproviders v0.0.0-20260104140148-1c2515cb298d
|
||||
github.com/yusing/gointernals v0.1.16
|
||||
github.com/yusing/goutils v0.7.0
|
||||
github.com/yusing/goutils/http/reverseproxy v0.0.0-20251217162119-cb0f79b51ce2
|
||||
github.com/yusing/goutils/http/websocket v0.0.0-20251217162119-cb0f79b51ce2
|
||||
github.com/yusing/goutils/server v0.0.0-20251217162119-cb0f79b51ce2
|
||||
github.com/yusing/goutils/http/reverseproxy v0.0.0-20260103043911-785deb23bd64
|
||||
github.com/yusing/goutils/http/websocket v0.0.0-20260103043911-785deb23bd64
|
||||
github.com/yusing/goutils/server v0.0.0-20260103043911-785deb23bd64
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -147,6 +147,7 @@ require (
|
||||
require (
|
||||
github.com/akamai/AkamaiOPEN-edgegrid-golang/v11 v11.1.0 // indirect
|
||||
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||
github.com/boombuler/barcode v1.1.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.4.0 // indirect
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
@@ -154,20 +155,25 @@ require (
|
||||
github.com/containerd/errdefs v1.0.0 // indirect
|
||||
github.com/containerd/errdefs/pkg v0.3.0 // indirect
|
||||
github.com/fatih/color v1.18.0 // indirect
|
||||
github.com/fatih/structs v1.1.0 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 // indirect
|
||||
github.com/go-resty/resty/v2 v2.17.1 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/google/go-querystring v1.2.0 // indirect
|
||||
github.com/klauspost/compress v1.18.2 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/kolo/xmlrpc v0.0.0-20220921171641-a4b6fa1dd06b // indirect
|
||||
github.com/linode/linodego v1.63.0 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 // indirect
|
||||
github.com/nrdcg/goinwx v0.12.0 // indirect
|
||||
github.com/nrdcg/oci-go-sdk/common/v1065 v1065.105.2 // indirect
|
||||
github.com/nrdcg/oci-go-sdk/dns/v1065 v1065.105.2 // indirect
|
||||
github.com/pierrec/lz4/v4 v4.1.21 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
||||
github.com/pquerna/otp v1.5.0 // indirect
|
||||
github.com/stretchr/objx v0.5.3 // indirect
|
||||
github.com/tklauser/go-sysconf v0.3.16 // indirect
|
||||
github.com/tklauser/numcpus v0.11.0 // indirect
|
||||
|
||||
21
go.sum
21
go.sum
@@ -44,6 +44,9 @@ github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3d
|
||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
|
||||
github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o=
|
||||
github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
github.com/boombuler/barcode v1.1.0 h1:ChaYjBR63fr4LFyGn8E8nt7dBSt3MiU3zMOZqFvVkHo=
|
||||
github.com/boombuler/barcode v1.1.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
github.com/buger/goterm v1.0.4 h1:Z9YvGmOih81P0FbVtEYTFF6YsSgxSUKEhf/f9bTMXbY=
|
||||
github.com/buger/goterm v1.0.4/go.mod h1:HiFWV3xnkolgrBV3mY8m0X0Pumt4zg4QhbdOzQtB8tE=
|
||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
@@ -85,6 +88,8 @@ github.com/elliotwutingfeng/asciiset v0.0.0-20230602022725-51bbb787efab h1:h1Ugj
|
||||
github.com/elliotwutingfeng/asciiset v0.0.0-20230602022725-51bbb787efab/go.mod h1:GLo/8fDswSAniFG+BFIaiSPcK610jyzgEhWYPQwuQdw=
|
||||
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
||||
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
||||
github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
|
||||
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
@@ -121,6 +126,8 @@ github.com/go-resty/resty/v2 v2.17.1 h1:x3aMpHK1YM9e4va/TMDRlusDDoZiQ+ViDu/WpA6x
|
||||
github.com/go-resty/resty/v2 v2.17.1/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2mLtQrOyQlVA=
|
||||
github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
|
||||
github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
|
||||
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
@@ -150,8 +157,8 @@ github.com/googleapis/gax-go/v2 v2.16.0 h1:iHbQmKLLZrexmb0OSsNGTeSTS0HO4YvFOG8g5
|
||||
github.com/googleapis/gax-go/v2 v2.16.0/go.mod h1:o1vfQjjNZn4+dPnRdl/4ZD7S9414Y4xA+a/6Icj6l14=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/gotify/server/v2 v2.7.3 h1:nro/ZnxdlZFvxFcw9LREGA8zdk6CK744azwhuhX/A4g=
|
||||
github.com/gotify/server/v2 v2.7.3/go.mod h1:VAtE1RIc/2j886PYs9WPQbMjqbFsoyQ0G8IdFtnAxU0=
|
||||
github.com/gotify/server/v2 v2.8.0 h1:E3UDDn/3rFZi1sjZfbuhXNnxJP3ACZhdcw/iySegPRA=
|
||||
github.com/gotify/server/v2 v2.8.0/go.mod h1:6ci5adxcE2hf1v+2oowKiQmixOxXV8vU+CRLKP6sqZA=
|
||||
github.com/h2non/gock v1.2.0 h1:K6ol8rfrRkUOefooBC8elXoaNGYkpp7y2qcxGG6BzUE=
|
||||
github.com/h2non/gock v1.2.0/go.mod h1:tNhoxHYW2W42cYkYb1WqzdbYIieALC99kpYr7rH/BQk=
|
||||
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw=
|
||||
@@ -174,6 +181,8 @@ github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uq
|
||||
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/kolo/xmlrpc v0.0.0-20220921171641-a4b6fa1dd06b h1:udzkj9S/zlT5X367kqJis0QP7YMxobob6zhzq6Yre00=
|
||||
github.com/kolo/xmlrpc v0.0.0-20220921171641-a4b6fa1dd06b/go.mod h1:pcaDhQK0/NJZEvtCO0qQPPropqV0sJOJ6YW7X+9kRwM=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
@@ -188,8 +197,8 @@ github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8
|
||||
github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4=
|
||||
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 h1:PwQumkgq4/acIiZhtifTV5OUqqiP82UAl0h87xj/l9k=
|
||||
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
||||
github.com/luthermonson/go-proxmox v0.2.4 h1:XQ6YNUTVvHS7N4EJxWpuqWLW2s1VPtsIblxLV/rGHLw=
|
||||
github.com/luthermonson/go-proxmox v0.2.4/go.mod h1:oyFgg2WwTEIF0rP6ppjiixOHa5ebK1p8OaRiFhvICBQ=
|
||||
github.com/luthermonson/go-proxmox v0.3.1 h1:h64s4/zIEQ06TBo0phFKcckV441YpvUPgLfRAptYsjY=
|
||||
github.com/luthermonson/go-proxmox v0.3.1/go.mod h1:oyFgg2WwTEIF0rP6ppjiixOHa5ebK1p8OaRiFhvICBQ=
|
||||
github.com/magefile/mage v1.15.0 h1:BvGheCMAsG3bWUDbZ8AyXXpCNwU9u5CB6sM+HNb9HYg=
|
||||
github.com/magefile/mage v1.15.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
@@ -218,6 +227,8 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/nrdcg/goacmedns v0.2.0 h1:ADMbThobzEMnr6kg2ohs4KGa3LFqmgiBA22/6jUWJR0=
|
||||
github.com/nrdcg/goacmedns v0.2.0/go.mod h1:T5o6+xvSLrQpugmwHvrSNkzWht0UGAwj2ACBMhh73Cg=
|
||||
github.com/nrdcg/goinwx v0.12.0 h1:ujdUqDBnaRSFwzVnImvPHYw3w3m9XgmGImNUw1GyMb4=
|
||||
github.com/nrdcg/goinwx v0.12.0/go.mod h1:IrVKd3ZDbFiMjdPgML4CSxZAY9wOoqLvH44zv3NodJ0=
|
||||
github.com/nrdcg/oci-go-sdk/common/v1065 v1065.105.2 h1:l0tH15ACQADZAzC+LZ+mo2tIX4H6uZu0ulrVmG5Tqz0=
|
||||
github.com/nrdcg/oci-go-sdk/common/v1065 v1065.105.2/go.mod h1:Gcs8GCaZXL3FdiDWgdnMxlOLEdRprJJnPYB22TX1jw8=
|
||||
github.com/nrdcg/oci-go-sdk/dns/v1065 v1065.105.2 h1:gzB4c6ztb38C/jYiqEaFC+mCGcWFHDji9e6jwymY9d4=
|
||||
@@ -248,6 +259,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
|
||||
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
|
||||
github.com/puzpuzpuz/xsync/v4 v4.2.0 h1:dlxm77dZj2c3rxq0/XNvvUKISAmovoXF4a4qM6Wvkr0=
|
||||
github.com/puzpuzpuz/xsync/v4 v4.2.0/go.mod h1:VJDmTCJMBt8igNxnkQd86r+8KUeN1quSfNKu5bLYFQo=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"github.com/yusing/godoxy/internal/autocert"
|
||||
"github.com/yusing/godoxy/internal/logging/memlogger"
|
||||
apitypes "github.com/yusing/goutils/apitypes"
|
||||
gperr "github.com/yusing/goutils/errs"
|
||||
"github.com/yusing/goutils/http/websocket"
|
||||
)
|
||||
|
||||
@@ -40,33 +39,33 @@ func Renew(c *gin.Context) {
|
||||
logs, cancel := memlogger.Events()
|
||||
defer cancel()
|
||||
|
||||
done := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
defer close(done)
|
||||
// Stream logs until WebSocket connection closes (renewal runs in background)
|
||||
for {
|
||||
select {
|
||||
case <-manager.Context().Done():
|
||||
return
|
||||
case l := <-logs:
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
err = autocert.ObtainCert()
|
||||
if err != nil {
|
||||
gperr.LogError("failed to obtain cert", err)
|
||||
_ = manager.WriteData(websocket.TextMessage, []byte(err.Error()), 10*time.Second)
|
||||
} else {
|
||||
log.Info().Msg("cert obtained successfully")
|
||||
err = manager.WriteData(websocket.TextMessage, l, 10*time.Second)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case l := <-logs:
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
err = manager.WriteData(websocket.TextMessage, l, 10*time.Second)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
case <-done:
|
||||
return
|
||||
}
|
||||
// renewal happens in background
|
||||
ok := autocert.ForceExpiryAll()
|
||||
if !ok {
|
||||
log.Error().Msg("cert renewal already in progress")
|
||||
time.Sleep(1 * time.Second) // wait for the log above to be sent
|
||||
return
|
||||
}
|
||||
log.Info().Msg("cert force renewal requested")
|
||||
|
||||
autocert.WaitRenewalDone(manager.Context())
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/yusing/godoxy/internal/common"
|
||||
"golang.org/x/oauth2"
|
||||
"golang.org/x/time/rate"
|
||||
|
||||
expect "github.com/yusing/goutils/testing"
|
||||
)
|
||||
@@ -42,6 +43,7 @@ func setupMockOIDC(t *testing.T) {
|
||||
}),
|
||||
allowedUsers: []string{"test-user"},
|
||||
allowedGroups: []string{"test-group1", "test-group2"},
|
||||
rateLimit: rate.NewLimiter(rate.Every(common.OIDCRateLimitPeriod), common.OIDCRateLimit),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
560
internal/autocert/README.md
Normal file
560
internal/autocert/README.md
Normal file
@@ -0,0 +1,560 @@
|
||||
# Autocert Package
|
||||
|
||||
Automated SSL certificate management using the ACME protocol (Let's Encrypt and compatible CAs).
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────────────────┐
|
||||
│ GoDoxy Proxy │
|
||||
├────────────────────────────────────────────────────────────────────────────┤
|
||||
│ ┌──────────────────────┐ ┌─────────────────────────────────────────┐ │
|
||||
│ │ Config.State │────▶│ autocert.Provider │ │
|
||||
│ │ (config loading) │ │ ┌───────────────────────────────────┐ │ │
|
||||
│ └──────────────────────┘ │ │ main Provider │ │ │
|
||||
│ │ │ - Primary certificate │ │ │
|
||||
│ │ │ - SNI matcher │ │ │
|
||||
│ │ │ - Renewal scheduler │ │ │
|
||||
│ │ └───────────────────────────────────┘ │ │
|
||||
│ │ ┌───────────────────────────────────┐ │ │
|
||||
│ │ │ extraProviders[] │ │ │
|
||||
│ │ │ - Additional certifictes │ │ │
|
||||
│ │ │ - Different domains/A │ │ │
|
||||
│ │ └───────────────────────────────────┘ │ │
|
||||
│ └─────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌────────────────────────────────┐ │
|
||||
│ │ TLS Handshake │ │
|
||||
│ │ GetCert(ClientHelloInf) │ │
|
||||
│ └────────────────────────────────┘ │
|
||||
└────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Certificate Lifecycle
|
||||
|
||||
```mermaid
|
||||
---
|
||||
config:
|
||||
theme: redux-dark-color
|
||||
---
|
||||
flowchart TD
|
||||
A[Start] --> B[Load Existing Cert]
|
||||
B --> C{Cert Exists?}
|
||||
C -->|Yes| D[Load Cert from Disk]
|
||||
C -->|No| E[Obtain New Cert]
|
||||
|
||||
D --> F{Valid & Not Expired?}
|
||||
F -->|Yes| G[Schedule Renewal]
|
||||
F -->|No| H{Renewal Time?}
|
||||
H -->|Yes| I[Renew Certificate]
|
||||
H -->|No| G
|
||||
|
||||
E --> J[Init ACME Client]
|
||||
J --> K[Register Account]
|
||||
K --> L[DNS-01 Challenge]
|
||||
L --> M[Complete Challenge]
|
||||
M --> N[Download Certificate]
|
||||
N --> O[Save to Disk]
|
||||
O --> G
|
||||
|
||||
G --> P[Wait Until Renewal Time]
|
||||
P --> Q[Trigger Renewal]
|
||||
Q --> I
|
||||
|
||||
I --> R[Renew via ACME]
|
||||
R --> S{Same Domains?}
|
||||
S -->|Yes| T[Bundle & Save]
|
||||
S -->|No| U[Re-obtain Certificate]
|
||||
U --> T
|
||||
|
||||
T --> V[Update SNI Matcher]
|
||||
V --> G
|
||||
|
||||
style E fill:#90EE90
|
||||
style I fill:#FFD700
|
||||
style N fill:#90EE90
|
||||
style U fill:#FFA07A
|
||||
```
|
||||
|
||||
## SNI Matching Flow
|
||||
|
||||
When a TLS client connects with Server Name Indication (SNI), the proxy needs to select the correct certificate.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Client["TLS Client"] -->|ClientHello SNI| Proxy["GoDoxy Proxy"]
|
||||
Proxy -->|Certificate| Client
|
||||
|
||||
subgraph "SNI Matching Process"
|
||||
direction TB
|
||||
A[Extract SNI from ClientHello] --> B{Normalize SNI}
|
||||
B --> C{Exact Match?}
|
||||
C -->|Yes| D[Return cert]
|
||||
C -->|No| E[Wildcard Suffix Tree]
|
||||
E --> F{Match Found?}
|
||||
F -->|Yes| D
|
||||
F -->|No| G[Return default cert]
|
||||
end
|
||||
|
||||
style C fill:#90EE90
|
||||
style E fill:#87CEEB
|
||||
style F fill:#FFD700
|
||||
```
|
||||
|
||||
### Suffix Tree Structure
|
||||
|
||||
The `sniMatcher` uses an optimized suffix tree for efficient wildcard matching:
|
||||
|
||||
```
|
||||
Certificate: *.example.com, example.com, *.api.example.com
|
||||
|
||||
exact:
|
||||
"example.com" → Provider_A
|
||||
|
||||
root:
|
||||
└── "com"
|
||||
└── "example"
|
||||
├── "*" → Provider_A [wildcard at *.example.com]
|
||||
└── "api"
|
||||
└── "*" → Provider_B [wildcard at *.api.example.com]
|
||||
```
|
||||
|
||||
## Key Components
|
||||
|
||||
### Config
|
||||
|
||||
Configuration for certificate management, loaded from `config/autocert.yml`.
|
||||
|
||||
```go
|
||||
type Config struct {
|
||||
Email string // ACME account email
|
||||
Domains []string // Domains to certifiy
|
||||
CertPath string // Output cert path
|
||||
KeyPath string // Output key path
|
||||
Extra []ConfigExtra // Additional cert configs
|
||||
ACMEKeyPath string // ACME account private key (shared by all extras)
|
||||
Provider string // DNS provider name
|
||||
Options map[string]strutils.Redacted // Provider-specific options
|
||||
Resolvers []string // DNS resolvers for DNS-01
|
||||
CADirURL string // Custom ACME CA directory
|
||||
CACerts []string // Custom CA certificates
|
||||
EABKid string // External Account Binding Key ID
|
||||
EABHmac string // External Account Binding HMAC
|
||||
|
||||
idx int // 0: main, 1+: extra[i]
|
||||
}
|
||||
|
||||
type ConfigExtra Config
|
||||
```
|
||||
|
||||
**Extra Provider Merging:** Extra configurations are merged with the main config using `MergeExtraConfig()`, inheriting most settings from the main provider while allowing per-certificate overrides for `Provider`, `Email`, `Domains`, `Options`, `Resolvers`, `CADirURL`, `CACerts`, `EABKid`, `EABHmac`, and `HTTPClient`. The `ACMEKeyPath` is shared across all providers.
|
||||
|
||||
**Validation:**
|
||||
|
||||
- Extra configs must have unique `cert_path` and `key_path` values (no duplicates across main or any extra provider)
|
||||
|
||||
### ConfigExtra
|
||||
|
||||
Extra certificate configuration type. Uses `MergeExtraConfig()` to inherit settings from the main provider:
|
||||
|
||||
```go
|
||||
func MergeExtraConfig(mainCfg *Config, extraCfg *ConfigExtra) ConfigExtra
|
||||
```
|
||||
|
||||
Fields that can be overridden per extra provider:
|
||||
|
||||
- `Provider` - DNS provider name
|
||||
- `Email` - ACME account email
|
||||
- `Domains` - Certificate domains
|
||||
- `Options` - Provider-specific options
|
||||
- `Resolvers` - DNS resolvers
|
||||
- `CADirURL` - Custom ACME CA directory
|
||||
- `CACerts` - Custom CA certificates
|
||||
- `EABKid` / `EABHmac` - External Account Binding credentials
|
||||
- `HTTPClient` - Custom HTTP client
|
||||
|
||||
Fields inherited from main config (shared):
|
||||
|
||||
- `ACMEKeyPath` - ACME account private key (same for all)
|
||||
|
||||
**Provider Types:**
|
||||
|
||||
- `local` - No ACME, use existing certificate (default)
|
||||
- `pseudo` - Mock provider for testing
|
||||
- `custom` - Custom ACME CA with `CADirURL`
|
||||
|
||||
### Provider
|
||||
|
||||
Main certificate management struct that handles:
|
||||
|
||||
- Certificate issuance and renewal
|
||||
- Loading certificates from disk
|
||||
- SNI-based certificate selection
|
||||
- Renewal scheduling
|
||||
|
||||
```go
|
||||
type Provider struct {
|
||||
logger zerolog.Logger // Provider-scoped logger
|
||||
|
||||
cfg *Config // Configuration
|
||||
user *User // ACME account
|
||||
legoCfg *lego.Config // LEGO client config
|
||||
client *lego.Client // ACME client
|
||||
lastFailure time.Time // Last renewal failure
|
||||
legoCert *certificate.Resource // Cached cert resource
|
||||
tlsCert *tls.Certificate // Parsed TLS certificate
|
||||
certExpiries CertExpiries // Domain → expiry map
|
||||
extraProviders []*Provider // Additional certificates
|
||||
sniMatcher sniMatcher // SNI → Provider mapping
|
||||
forceRenewalCh chan struct{} // Force renewal trigger channel
|
||||
scheduleRenewalOnce sync.Once // Prevents duplicate renewal scheduling
|
||||
}
|
||||
```
|
||||
|
||||
**Logging:** Each provider has a scoped logger with provider name ("main" or "extra[N]") for consistent log context.
|
||||
|
||||
**Key Methods:**
|
||||
|
||||
- `NewProvider(cfg *Config, user *User, legoCfg *lego.Config) (*Provider, error)` - Creates provider and initializes extra providers atomically
|
||||
- `GetCert(hello *tls.ClientHelloInfo)` - Returns certificate for TLS handshake
|
||||
- `GetName()` - Returns provider name ("main" or "extra[N]")
|
||||
- `ObtainCert()` - Obtains new certificate via ACME
|
||||
- `ObtainCertAll()` - Renews/obtains certificates for main and all extra providers
|
||||
- `ObtainCertIfNotExistsAll()` - Obtains certificates only if they don't exist on disk
|
||||
- `ForceExpiryAll()` - Triggers forced certificate renewal for main and all extra providers
|
||||
- `ScheduleRenewalAll(parent task.Parent)` - Schedules automatic renewal for all providers
|
||||
- `PrintCertExpiriesAll()` - Logs certificate expiry dates for all providers
|
||||
|
||||
### User
|
||||
|
||||
ACME account representation implementing lego's `acme.User` interface.
|
||||
|
||||
```go
|
||||
type User struct {
|
||||
Email string // Account email
|
||||
Registration *registration.Resource // ACME registration
|
||||
Key crypto.PrivateKey // Account key
|
||||
}
|
||||
```
|
||||
|
||||
### sniMatcher
|
||||
|
||||
Efficient SNI-to-Provider lookup with exact and wildcard matching.
|
||||
|
||||
```go
|
||||
type sniMatcher struct {
|
||||
exact map[string]*Provider // Exact domain matches
|
||||
root sniTreeNode // Wildcard suffix tree
|
||||
}
|
||||
|
||||
type sniTreeNode struct {
|
||||
children map[string]*sniTreeNode // DNS label → child node
|
||||
wildcard *Provider // Wildcard match at this level
|
||||
}
|
||||
```
|
||||
|
||||
## DNS Providers
|
||||
|
||||
Supported DNS providers for DNS-01 challenge validation:
|
||||
|
||||
| Provider | Name | Description |
|
||||
| ------------ | -------------- | ---------------------------------------- |
|
||||
| Cloudflare | `cloudflare` | Cloudflare DNS |
|
||||
| Route 53 | `route53` | AWS Route 53 |
|
||||
| DigitalOcean | `digitalocean` | DigitalOcean DNS |
|
||||
| GoDaddy | `godaddy` | GoDaddy DNS |
|
||||
| OVH | `ovh` | OVHcloud DNS |
|
||||
| CloudDNS | `clouddns` | Google Cloud DNS |
|
||||
| AzureDNS | `azuredns` | Azure DNS |
|
||||
| DuckDNS | `duckdns` | DuckDNS |
|
||||
| and more... | | See `internal/dnsproviders/providers.go` |
|
||||
|
||||
### Provider Configuration
|
||||
|
||||
Each provider accepts configuration via the `options` map:
|
||||
|
||||
```yaml
|
||||
autocert:
|
||||
provider: cloudflare
|
||||
email: admin@example.com
|
||||
domains:
|
||||
- example.com
|
||||
- "*.example.com"
|
||||
options:
|
||||
CF_API_TOKEN: your-api-token
|
||||
CF_ZONE_API_TOKEN: your-zone-token
|
||||
resolvers:
|
||||
- 1.1.1.1:53
|
||||
```
|
||||
|
||||
## ACME Integration
|
||||
|
||||
### Account Registration
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Load or Generate ACME Key] --> B[Init LEGO Client]
|
||||
B --> C[Resolve Account by Key]
|
||||
C --> D{Account Exists?}
|
||||
D -->|Yes| E[Continue with existing]
|
||||
D -->|No| F{Has EAB?}
|
||||
F -->|Yes| G[Register with EAB]
|
||||
F -->|No| H[Register with TOS Agreement]
|
||||
G --> I[Save Registration]
|
||||
H --> I
|
||||
```
|
||||
|
||||
### DNS-01 Challenge
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C as ACME CA
|
||||
participant P as GoDoxy
|
||||
participant D as DNS Provider
|
||||
|
||||
P->>C: Request certificate for domain
|
||||
C->>P: Present DNS-01 challenge
|
||||
P->>D: Create TXT record _acme-challenge.domain
|
||||
D-->>P: Record created
|
||||
P->>C: Challenge ready
|
||||
C->>D: Verify DNS TXT record
|
||||
D-->>C: Verification success
|
||||
C->>P: Issue certificate
|
||||
P->>D: Clean up TXT record
|
||||
```
|
||||
|
||||
## Multi-Certificate Support
|
||||
|
||||
The package supports multiple certificates through the `extra` configuration:
|
||||
|
||||
```yaml
|
||||
autocert:
|
||||
provider: cloudflare
|
||||
email: admin@example.com
|
||||
domains:
|
||||
- example.com
|
||||
- "*.example.com"
|
||||
cert_path: certs/example.com.crt
|
||||
key_path: certs/example.com.key
|
||||
extra:
|
||||
- domains:
|
||||
- api.example.com
|
||||
- "*.api.example.com"
|
||||
cert_path: certs/api.example.com.crt
|
||||
key_path: certs/api.example.com.key
|
||||
provider: cloudflare
|
||||
email: admin@api.example.com
|
||||
```
|
||||
|
||||
### Extra Provider Setup
|
||||
|
||||
Extra providers are initialized atomically within `NewProvider()`:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[NewProvider] --> B{Merge Config with Extra}
|
||||
B --> C[Create Provider per Extra]
|
||||
C --> D[Build SNI Matcher]
|
||||
D --> E[Register in SNI Tree]
|
||||
|
||||
style B fill:#87CEEB
|
||||
style C fill:#FFD700
|
||||
```
|
||||
|
||||
## Renewal Scheduling
|
||||
|
||||
### Renewal Timing
|
||||
|
||||
- **Initial Check**: Certificate expiry is checked at startup
|
||||
- **Renewal Window**: Renewal scheduled for 1 month before expiry
|
||||
- **Cooldown on Failure**: 1-hour cooldown after failed renewal
|
||||
- **Request Cooldown**: 15-second cooldown after startup (prevents rate limiting)
|
||||
- **Force Renewal**: `forceRenewalCh` channel allows triggering immediate renewal
|
||||
|
||||
### Force Renewal
|
||||
|
||||
The `forceRenewalCh` channel (buffered size 1) enables immediate certificate renewal on demand:
|
||||
|
||||
```go
|
||||
// Trigger forced renewal for main and all extra providers
|
||||
provider.ForceExpiryAll()
|
||||
```
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Start] --> B[Calculate Renewal Time]
|
||||
B --> C[expiry - 30 days]
|
||||
C --> D[Start Timer]
|
||||
|
||||
D --> E{Event?}
|
||||
E -->|forceRenewalCh| F[Force Renewal]
|
||||
E -->|Timer| G[Check Failure Cooldown]
|
||||
E -->|Context Done| H[Exit]
|
||||
|
||||
G --> H1{Recently Failed?}
|
||||
H1 -->|Yes| I[Skip, Wait Next Event]
|
||||
H1 -->|No| J[Attempt Renewal]
|
||||
|
||||
J --> K{Renewal Success?}
|
||||
K -->|Yes| L[Reset Failure, Notify Success]
|
||||
K -->|No| M[Update Failure Time, Notify Failure]
|
||||
|
||||
L --> N[Reset Timer]
|
||||
I --> N
|
||||
M --> D
|
||||
|
||||
N --> D
|
||||
|
||||
style F fill:#FFD700
|
||||
style J fill:#FFD700
|
||||
style K fill:#90EE90
|
||||
style M fill:#FFA07A
|
||||
```
|
||||
|
||||
**Notifications:** Renewal success/failure triggers system notifications with provider name.
|
||||
|
||||
### CertState
|
||||
|
||||
Certificate state tracking:
|
||||
|
||||
```go
|
||||
const (
|
||||
CertStateValid // Certificate is valid and up-to-date
|
||||
CertStateExpired // Certificate has expired or needs renewal
|
||||
CertStateMismatch // Certificate domains don't match config
|
||||
)
|
||||
```
|
||||
|
||||
### RenewMode
|
||||
|
||||
Controls renewal behavior:
|
||||
|
||||
```go
|
||||
const (
|
||||
renewModeForce // Force renewal, bypass cooldown and state check
|
||||
renewModeIfNeeded // Renew only if expired or domain mismatch
|
||||
)
|
||||
```
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
internal/autocert/
|
||||
├── README.md # This file
|
||||
├── config.go # Config struct and validation
|
||||
├── provider.go # Provider implementation
|
||||
├── setup.go # Extra provider setup
|
||||
├── sni_matcher.go # SNI matching logic
|
||||
├── providers.go # DNS provider registration
|
||||
├── state.go # Certificate state enum
|
||||
├── user.go # ACME user/account
|
||||
├── paths.go # Default paths
|
||||
└── types/
|
||||
└── provider.go # Provider interface
|
||||
```
|
||||
|
||||
## Default Paths
|
||||
|
||||
| Constant | Default Value | Description |
|
||||
| -------------------- | ---------------- | ------------------------ |
|
||||
| `CertFileDefault` | `certs/cert.crt` | Default certificate path |
|
||||
| `KeyFileDefault` | `certs/priv.key` | Default private key path |
|
||||
| `ACMEKeyFileDefault` | `certs/acme.key` | Default ACME account key |
|
||||
|
||||
Failure tracking file is generated per-certificate: `<cert_dir>/.last_failure-<hash>`
|
||||
|
||||
## Error Handling
|
||||
|
||||
The package uses structured error handling with `gperr`:
|
||||
|
||||
- **ErrMissingField** - Required configuration field missing
|
||||
- **ErrDuplicatedPath** - Duplicate certificate/key paths in extras
|
||||
- **ErrInvalidDomain** - Invalid domain format
|
||||
- **ErrUnknownProvider** - Unknown DNS provider
|
||||
- **ErrGetCertFailure** - Certificate retrieval failed
|
||||
|
||||
**Error Context:** All errors are prefixed with provider name ("main" or "extra[N]") via `fmtError()` for clear attribution.
|
||||
|
||||
### Failure Tracking
|
||||
|
||||
Last failure is persisted per-certificate to prevent rate limiting:
|
||||
|
||||
```go
|
||||
// File: <cert_dir>/.last_failure-<hash> where hash is SHA256(certPath|keyPath)[:6]
|
||||
```
|
||||
|
||||
**Cooldown Checks:** Last failure is checked in `obtainCertIfNotExists()` (15-second startup cooldown) and `renew()` (1-hour failure cooldown). The `renewModeForce` bypasses cooldown checks entirely.
|
||||
|
||||
## Integration with GoDoxy
|
||||
|
||||
The autocert package integrates with GoDoxy's configuration system:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph Config
|
||||
direction TB
|
||||
A[config.yml] --> B[Parse Config]
|
||||
B --> C[AutoCert Config]
|
||||
end
|
||||
|
||||
subgraph State
|
||||
C --> D[NewProvider]
|
||||
D --> E[Schedule Renewal]
|
||||
E --> F[Set Active Provider]
|
||||
end
|
||||
|
||||
subgraph Server
|
||||
F --> G[TLS Handshake]
|
||||
G --> H[GetCert via SNI]
|
||||
H --> I[Return Certificate]
|
||||
end
|
||||
```
|
||||
|
||||
### REST API
|
||||
|
||||
Force certificate renewal via WebSocket endpoint:
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
| -------------------- | ------ | ----------------------------------------- |
|
||||
| `/api/v1/cert/renew` | GET | Triggers `ForceExpiryAll()` via WebSocket |
|
||||
|
||||
The endpoint streams live logs during the renewal process.
|
||||
|
||||
## Usage Example
|
||||
|
||||
```yaml
|
||||
# config/config.yml
|
||||
autocert:
|
||||
provider: cloudflare
|
||||
email: admin@example.com
|
||||
domains:
|
||||
- example.com
|
||||
- "*.example.com"
|
||||
options:
|
||||
CF_API_TOKEN: ${CF_API_TOKEN}
|
||||
resolvers:
|
||||
- 1.1.1.1:53
|
||||
- 8.8.8.8:53
|
||||
```
|
||||
|
||||
```go
|
||||
// In config initialization
|
||||
autocertCfg := state.AutoCert
|
||||
user, legoCfg, err := autocertCfg.GetLegoConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
provider, err := autocert.NewProvider(autocertCfg, user, legoCfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("autocert error: %w", err)
|
||||
}
|
||||
|
||||
if err := provider.ObtainCertIfNotExistsAll(); err != nil {
|
||||
return fmt.Errorf("failed to obtain certificates: %w", err)
|
||||
}
|
||||
|
||||
provider.ScheduleRenewalAll(state.Task())
|
||||
provider.PrintCertExpiriesAll()
|
||||
```
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
@@ -19,13 +20,14 @@ import (
|
||||
strutils "github.com/yusing/goutils/strings"
|
||||
)
|
||||
|
||||
type ConfigExtra Config
|
||||
type Config struct {
|
||||
Email string `json:"email,omitempty"`
|
||||
Domains []string `json:"domains,omitempty"`
|
||||
CertPath string `json:"cert_path,omitempty"`
|
||||
KeyPath string `json:"key_path,omitempty"`
|
||||
Extra []Config `json:"extra,omitempty"`
|
||||
ACMEKeyPath string `json:"acme_key_path,omitempty"`
|
||||
Extra []ConfigExtra `json:"extra,omitempty"`
|
||||
ACMEKeyPath string `json:"acme_key_path,omitempty"` // shared by all extra providers
|
||||
Provider string `json:"provider,omitempty"`
|
||||
Options map[string]strutils.Redacted `json:"options,omitempty"`
|
||||
|
||||
@@ -42,15 +44,12 @@ type Config struct {
|
||||
HTTPClient *http.Client `json:"-"` // for tests only
|
||||
|
||||
challengeProvider challenge.Provider
|
||||
|
||||
idx int // 0: main, 1+: extra[i]
|
||||
}
|
||||
|
||||
var (
|
||||
ErrMissingDomain = gperr.New("missing field 'domains'")
|
||||
ErrMissingEmail = gperr.New("missing field 'email'")
|
||||
ErrMissingProvider = gperr.New("missing field 'provider'")
|
||||
ErrMissingCADirURL = gperr.New("missing field 'ca_dir_url'")
|
||||
ErrMissingCertPath = gperr.New("missing field 'cert_path'")
|
||||
ErrMissingKeyPath = gperr.New("missing field 'key_path'")
|
||||
ErrMissingField = gperr.New("missing field")
|
||||
ErrDuplicatedPath = gperr.New("duplicated path")
|
||||
ErrInvalidDomain = gperr.New("invalid domain")
|
||||
ErrUnknownProvider = gperr.New("unknown provider")
|
||||
@@ -66,95 +65,22 @@ var domainOrWildcardRE = regexp.MustCompile(`^\*?([^.]+\.)+[^.]+$`)
|
||||
|
||||
// Validate implements the utils.CustomValidator interface.
|
||||
func (cfg *Config) Validate() gperr.Error {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
seenPaths := make(map[string]int) // path -> provider idx (0 for main, 1+ for extras)
|
||||
return cfg.validate(seenPaths)
|
||||
}
|
||||
|
||||
func (cfg *ConfigExtra) Validate() gperr.Error {
|
||||
return nil // done by main config's validate
|
||||
}
|
||||
|
||||
func (cfg *ConfigExtra) AsConfig() *Config {
|
||||
return (*Config)(cfg)
|
||||
}
|
||||
|
||||
func (cfg *Config) validate(seenPaths map[string]int) gperr.Error {
|
||||
if cfg.Provider == "" {
|
||||
cfg.Provider = ProviderLocal
|
||||
}
|
||||
|
||||
b := gperr.NewBuilder("autocert errors")
|
||||
if len(cfg.Extra) > 0 {
|
||||
seenCertPaths := make(map[string]int, len(cfg.Extra))
|
||||
seenKeyPaths := make(map[string]int, len(cfg.Extra))
|
||||
for i := range cfg.Extra {
|
||||
if cfg.Extra[i].CertPath == "" {
|
||||
b.Add(ErrMissingCertPath.Subjectf("extra[%d].cert_path", i))
|
||||
}
|
||||
if cfg.Extra[i].KeyPath == "" {
|
||||
b.Add(ErrMissingKeyPath.Subjectf("extra[%d].key_path", i))
|
||||
}
|
||||
if cfg.Extra[i].CertPath != "" {
|
||||
if first, ok := seenCertPaths[cfg.Extra[i].CertPath]; ok {
|
||||
b.Add(ErrDuplicatedPath.Subjectf("extra[%d].cert_path", i).Withf("first: %d", first))
|
||||
} else {
|
||||
seenCertPaths[cfg.Extra[i].CertPath] = i
|
||||
}
|
||||
}
|
||||
if cfg.Extra[i].KeyPath != "" {
|
||||
if first, ok := seenKeyPaths[cfg.Extra[i].KeyPath]; ok {
|
||||
b.Add(ErrDuplicatedPath.Subjectf("extra[%d].key_path", i).Withf("first: %d", first))
|
||||
} else {
|
||||
seenKeyPaths[cfg.Extra[i].KeyPath] = i
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.Provider == ProviderCustom && cfg.CADirURL == "" {
|
||||
b.Add(ErrMissingCADirURL)
|
||||
}
|
||||
|
||||
if cfg.Provider != ProviderLocal && cfg.Provider != ProviderPseudo {
|
||||
if len(cfg.Domains) == 0 {
|
||||
b.Add(ErrMissingDomain)
|
||||
}
|
||||
if cfg.Email == "" {
|
||||
b.Add(ErrMissingEmail)
|
||||
}
|
||||
if cfg.Provider != ProviderCustom {
|
||||
for i, d := range cfg.Domains {
|
||||
if !domainOrWildcardRE.MatchString(d) {
|
||||
b.Add(ErrInvalidDomain.Subjectf("domains[%d]", i))
|
||||
}
|
||||
}
|
||||
}
|
||||
// check if provider is implemented
|
||||
providerConstructor, ok := Providers[cfg.Provider]
|
||||
if !ok {
|
||||
if cfg.Provider != ProviderCustom {
|
||||
b.Add(ErrUnknownProvider.
|
||||
Subject(cfg.Provider).
|
||||
With(gperr.DoYouMeanField(cfg.Provider, Providers)))
|
||||
}
|
||||
} else {
|
||||
provider, err := providerConstructor(cfg.Options)
|
||||
if err != nil {
|
||||
b.Add(err)
|
||||
} else {
|
||||
cfg.challengeProvider = provider
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.challengeProvider == nil {
|
||||
cfg.challengeProvider, _ = Providers[ProviderLocal](nil)
|
||||
}
|
||||
return b.Error()
|
||||
}
|
||||
|
||||
func (cfg *Config) dns01Options() []dns01.ChallengeOption {
|
||||
return []dns01.ChallengeOption{
|
||||
dns01.CondOption(len(cfg.Resolvers) > 0, dns01.AddRecursiveNameservers(cfg.Resolvers)),
|
||||
}
|
||||
}
|
||||
|
||||
func (cfg *Config) GetLegoConfig() (*User, *lego.Config, gperr.Error) {
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
if cfg.CertPath == "" {
|
||||
cfg.CertPath = CertFileDefault
|
||||
}
|
||||
@@ -165,6 +91,83 @@ func (cfg *Config) GetLegoConfig() (*User, *lego.Config, gperr.Error) {
|
||||
cfg.ACMEKeyPath = ACMEKeyFileDefault
|
||||
}
|
||||
|
||||
b := gperr.NewBuilder("certificate error")
|
||||
|
||||
// check if cert_path is unique
|
||||
if first, ok := seenPaths[cfg.CertPath]; ok {
|
||||
b.Add(ErrDuplicatedPath.Subjectf("cert_path %s", cfg.CertPath).Withf("first seen in %s", fmt.Sprintf("extra[%d]", first)))
|
||||
} else {
|
||||
seenPaths[cfg.CertPath] = cfg.idx
|
||||
}
|
||||
|
||||
// check if key_path is unique
|
||||
if first, ok := seenPaths[cfg.KeyPath]; ok {
|
||||
b.Add(ErrDuplicatedPath.Subjectf("key_path %s", cfg.KeyPath).Withf("first seen in %s", fmt.Sprintf("extra[%d]", first)))
|
||||
} else {
|
||||
seenPaths[cfg.KeyPath] = cfg.idx
|
||||
}
|
||||
|
||||
if cfg.Provider == ProviderCustom && cfg.CADirURL == "" {
|
||||
b.Add(ErrMissingField.Subject("ca_dir_url"))
|
||||
}
|
||||
|
||||
if cfg.Provider != ProviderLocal && cfg.Provider != ProviderPseudo {
|
||||
if len(cfg.Domains) == 0 {
|
||||
b.Add(ErrMissingField.Subject("domains"))
|
||||
}
|
||||
if cfg.Email == "" {
|
||||
b.Add(ErrMissingField.Subject("email"))
|
||||
}
|
||||
if cfg.Provider != ProviderCustom {
|
||||
for i, d := range cfg.Domains {
|
||||
if !domainOrWildcardRE.MatchString(d) {
|
||||
b.Add(ErrInvalidDomain.Subjectf("domains[%d]", i))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// check if provider is implemented
|
||||
providerConstructor, ok := Providers[cfg.Provider]
|
||||
if !ok {
|
||||
if cfg.Provider != ProviderCustom {
|
||||
b.Add(ErrUnknownProvider.
|
||||
Subject(cfg.Provider).
|
||||
With(gperr.DoYouMeanField(cfg.Provider, Providers)))
|
||||
}
|
||||
} else {
|
||||
provider, err := providerConstructor(cfg.Options)
|
||||
if err != nil {
|
||||
b.Add(err)
|
||||
} else {
|
||||
cfg.challengeProvider = provider
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.challengeProvider == nil {
|
||||
cfg.challengeProvider, _ = Providers[ProviderLocal](nil)
|
||||
}
|
||||
|
||||
if len(cfg.Extra) > 0 {
|
||||
for i := range cfg.Extra {
|
||||
cfg.Extra[i] = MergeExtraConfig(cfg, &cfg.Extra[i])
|
||||
cfg.Extra[i].AsConfig().idx = i + 1
|
||||
err := cfg.Extra[i].AsConfig().validate(seenPaths)
|
||||
if err != nil {
|
||||
b.Add(err.Subjectf("extra[%d]", i))
|
||||
}
|
||||
}
|
||||
}
|
||||
return b.Error()
|
||||
}
|
||||
|
||||
func (cfg *Config) dns01Options() []dns01.ChallengeOption {
|
||||
return []dns01.ChallengeOption{
|
||||
dns01.CondOption(len(cfg.Resolvers) > 0, dns01.AddRecursiveNameservers(cfg.Resolvers)),
|
||||
}
|
||||
}
|
||||
|
||||
func (cfg *Config) GetLegoConfig() (*User, *lego.Config, error) {
|
||||
var privKey *ecdsa.PrivateKey
|
||||
var err error
|
||||
|
||||
@@ -208,6 +211,46 @@ func (cfg *Config) GetLegoConfig() (*User, *lego.Config, gperr.Error) {
|
||||
return user, legoCfg, nil
|
||||
}
|
||||
|
||||
func MergeExtraConfig(mainCfg *Config, extraCfg *ConfigExtra) ConfigExtra {
|
||||
merged := ConfigExtra(*mainCfg)
|
||||
merged.Extra = nil
|
||||
merged.CertPath = extraCfg.CertPath
|
||||
merged.KeyPath = extraCfg.KeyPath
|
||||
// NOTE: Using same ACME key as main provider
|
||||
|
||||
if extraCfg.Provider != "" {
|
||||
merged.Provider = extraCfg.Provider
|
||||
}
|
||||
if extraCfg.Email != "" {
|
||||
merged.Email = extraCfg.Email
|
||||
}
|
||||
if len(extraCfg.Domains) > 0 {
|
||||
merged.Domains = extraCfg.Domains
|
||||
}
|
||||
if len(extraCfg.Options) > 0 {
|
||||
merged.Options = extraCfg.Options
|
||||
}
|
||||
if len(extraCfg.Resolvers) > 0 {
|
||||
merged.Resolvers = extraCfg.Resolvers
|
||||
}
|
||||
if extraCfg.CADirURL != "" {
|
||||
merged.CADirURL = extraCfg.CADirURL
|
||||
}
|
||||
if len(extraCfg.CACerts) > 0 {
|
||||
merged.CACerts = extraCfg.CACerts
|
||||
}
|
||||
if extraCfg.EABKid != "" {
|
||||
merged.EABKid = extraCfg.EABKid
|
||||
}
|
||||
if extraCfg.EABHmac != "" {
|
||||
merged.EABHmac = extraCfg.EABHmac
|
||||
}
|
||||
if extraCfg.HTTPClient != nil {
|
||||
merged.HTTPClient = extraCfg.HTTPClient
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
func (cfg *Config) LoadACMEKey() (*ecdsa.PrivateKey, error) {
|
||||
if common.IsTest {
|
||||
return nil, os.ErrNotExist
|
||||
|
||||
@@ -1,27 +1,32 @@
|
||||
package autocert
|
||||
package autocert_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/yusing/godoxy/internal/autocert"
|
||||
"github.com/yusing/godoxy/internal/dnsproviders"
|
||||
"github.com/yusing/godoxy/internal/serialization"
|
||||
)
|
||||
|
||||
func TestEABConfigRequired(t *testing.T) {
|
||||
dnsproviders.InitProviders()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg *Config
|
||||
cfg *autocert.Config
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "Missing EABKid", cfg: &Config{EABHmac: "1234567890"}, wantErr: true},
|
||||
{name: "Missing EABHmac", cfg: &Config{EABKid: "1234567890"}, wantErr: true},
|
||||
{name: "Valid EAB", cfg: &Config{EABKid: "1234567890", EABHmac: "1234567890"}, wantErr: false},
|
||||
{name: "Missing EABKid", cfg: &autocert.Config{EABHmac: "1234567890"}, wantErr: true},
|
||||
{name: "Missing EABHmac", cfg: &autocert.Config{EABKid: "1234567890"}, wantErr: true},
|
||||
{name: "Valid EAB", cfg: &autocert.Config{EABKid: "1234567890", EABHmac: "1234567890"}, wantErr: false},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
yaml := fmt.Appendf(nil, "eab_kid: %s\neab_hmac: %s", test.cfg.EABKid, test.cfg.EABHmac)
|
||||
cfg := Config{}
|
||||
cfg := autocert.Config{}
|
||||
err := serialization.UnmarshalValidateYAML(yaml, &cfg)
|
||||
if (err != nil) != test.wantErr {
|
||||
t.Errorf("Validate() error = %v, wantErr %v", err, test.wantErr)
|
||||
@@ -29,3 +34,27 @@ func TestEABConfigRequired(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtraCertKeyPathsUnique(t *testing.T) {
|
||||
t.Run("duplicate cert_path rejected", func(t *testing.T) {
|
||||
cfg := &autocert.Config{
|
||||
Provider: autocert.ProviderLocal,
|
||||
Extra: []autocert.ConfigExtra{
|
||||
{CertPath: "a.crt", KeyPath: "a.key"},
|
||||
{CertPath: "a.crt", KeyPath: "b.key"},
|
||||
},
|
||||
}
|
||||
require.Error(t, cfg.Validate())
|
||||
})
|
||||
|
||||
t.Run("duplicate key_path rejected", func(t *testing.T) {
|
||||
cfg := &autocert.Config{
|
||||
Provider: autocert.ProviderLocal,
|
||||
Extra: []autocert.ConfigExtra{
|
||||
{CertPath: "a.crt", KeyPath: "a.key"},
|
||||
{CertPath: "b.crt", KeyPath: "a.key"},
|
||||
},
|
||||
}
|
||||
require.Error(t, cfg.Validate())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -5,5 +5,4 @@ const (
|
||||
CertFileDefault = certBasePath + "cert.crt"
|
||||
KeyFileDefault = certBasePath + "priv.key"
|
||||
ACMEKeyFileDefault = certBasePath + "acme.key"
|
||||
LastFailureFile = certBasePath + ".last_failure"
|
||||
)
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
package autocert
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"maps"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
@@ -28,6 +31,8 @@ import (
|
||||
|
||||
type (
|
||||
Provider struct {
|
||||
logger zerolog.Logger
|
||||
|
||||
cfg *Config
|
||||
user *User
|
||||
legoCfg *lego.Config
|
||||
@@ -42,12 +47,18 @@ type (
|
||||
|
||||
extraProviders []*Provider
|
||||
sniMatcher sniMatcher
|
||||
|
||||
forceRenewalCh chan struct{}
|
||||
forceRenewalDoneCh atomic.Value // chan struct{}
|
||||
|
||||
scheduleRenewalOnce sync.Once
|
||||
}
|
||||
|
||||
CertExpiries map[string]time.Time
|
||||
RenewMode uint8
|
||||
)
|
||||
|
||||
var ErrGetCertFailure = errors.New("get certificate failed")
|
||||
var ErrNoCertificate = errors.New("no certificate found")
|
||||
|
||||
const (
|
||||
// renew failed for whatever reason, 1 hour cooldown
|
||||
@@ -56,21 +67,38 @@ const (
|
||||
requestCooldownDuration = 15 * time.Second
|
||||
)
|
||||
|
||||
const (
|
||||
renewModeForce = iota
|
||||
renewModeIfNeeded
|
||||
)
|
||||
|
||||
// could be nil
|
||||
var ActiveProvider atomic.Pointer[Provider]
|
||||
|
||||
func NewProvider(cfg *Config, user *User, legoCfg *lego.Config) *Provider {
|
||||
return &Provider{
|
||||
func NewProvider(cfg *Config, user *User, legoCfg *lego.Config) (*Provider, error) {
|
||||
p := &Provider{
|
||||
cfg: cfg,
|
||||
user: user,
|
||||
legoCfg: legoCfg,
|
||||
lastFailureFile: lastFailureFileFor(cfg.CertPath, cfg.KeyPath),
|
||||
forceRenewalCh: make(chan struct{}, 1),
|
||||
}
|
||||
p.forceRenewalDoneCh.Store(emptyForceRenewalDoneCh)
|
||||
|
||||
if cfg.idx == 0 {
|
||||
p.logger = log.With().Str("provider", "main").Logger()
|
||||
} else {
|
||||
p.logger = log.With().Str("provider", fmt.Sprintf("extra[%d]", cfg.idx)).Logger()
|
||||
}
|
||||
if err := p.setupExtraProviders(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (p *Provider) GetCert(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||
if p.tlsCert == nil {
|
||||
return nil, ErrGetCertFailure
|
||||
return nil, ErrNoCertificate
|
||||
}
|
||||
if hello == nil || hello.ServerName == "" {
|
||||
return p.tlsCert, nil
|
||||
@@ -82,7 +110,14 @@ func (p *Provider) GetCert(hello *tls.ClientHelloInfo) (*tls.Certificate, error)
|
||||
}
|
||||
|
||||
func (p *Provider) GetName() string {
|
||||
return p.cfg.Provider
|
||||
if p.cfg.idx == 0 {
|
||||
return "main"
|
||||
}
|
||||
return fmt.Sprintf("extra[%d]", p.cfg.idx)
|
||||
}
|
||||
|
||||
func (p *Provider) fmtError(err error) error {
|
||||
return gperr.PrependSubject(fmt.Sprintf("provider: %s", p.GetName()), err)
|
||||
}
|
||||
|
||||
func (p *Provider) GetCertPath() string {
|
||||
@@ -129,45 +164,88 @@ func (p *Provider) ClearLastFailure() error {
|
||||
return nil
|
||||
}
|
||||
p.lastFailure = time.Time{}
|
||||
return os.Remove(p.lastFailureFile)
|
||||
err := os.Remove(p.lastFailureFile)
|
||||
if err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Provider) ObtainCert() error {
|
||||
if len(p.extraProviders) > 0 {
|
||||
errs := gperr.NewGroup("autocert errors")
|
||||
errs.Go(p.obtainCertSelf)
|
||||
for _, ep := range p.extraProviders {
|
||||
errs.Go(ep.obtainCertSelf)
|
||||
}
|
||||
if err := errs.Wait().Error(); err != nil {
|
||||
return err
|
||||
}
|
||||
p.rebuildSNIMatcher()
|
||||
// allProviders returns all providers including this provider and all extra providers.
|
||||
func (p *Provider) allProviders() []*Provider {
|
||||
return append([]*Provider{p}, p.extraProviders...)
|
||||
}
|
||||
|
||||
// ObtainCertIfNotExistsAll obtains a new certificate for this provider and all extra providers if they do not exist.
|
||||
func (p *Provider) ObtainCertIfNotExistsAll() error {
|
||||
errs := gperr.NewGroup("obtain cert error")
|
||||
|
||||
for _, provider := range p.allProviders() {
|
||||
errs.Go(func() error {
|
||||
if err := provider.obtainCertIfNotExists(); err != nil {
|
||||
return fmt.Errorf("failed to obtain cert for %s: %w", provider.GetName(), err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
p.rebuildSNIMatcher()
|
||||
return errs.Wait().Error()
|
||||
}
|
||||
|
||||
// obtainCertIfNotExists obtains a new certificate for this provider if it does not exist.
|
||||
func (p *Provider) obtainCertIfNotExists() error {
|
||||
err := p.LoadCert()
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
return p.obtainCertSelf()
|
||||
|
||||
if !errors.Is(err, fs.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
|
||||
// check last failure
|
||||
lastFailure, err := p.GetLastFailure()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get last failure: %w", err)
|
||||
}
|
||||
if !lastFailure.IsZero() && time.Since(lastFailure) < requestCooldownDuration {
|
||||
return fmt.Errorf("still in cooldown until %s", strutils.FormatTime(lastFailure.Add(requestCooldownDuration).Local()))
|
||||
}
|
||||
|
||||
p.logger.Info().Msg("cert not found, obtaining new cert")
|
||||
return p.ObtainCert()
|
||||
}
|
||||
|
||||
func (p *Provider) obtainCertSelf() error {
|
||||
// ObtainCertAll renews existing certificates or obtains new certificates for this provider and all extra providers.
|
||||
func (p *Provider) ObtainCertAll() error {
|
||||
errs := gperr.NewGroup("obtain cert error")
|
||||
for _, provider := range p.allProviders() {
|
||||
errs.Go(func() error {
|
||||
if err := provider.obtainCertIfNotExists(); err != nil {
|
||||
return fmt.Errorf("failed to obtain cert for %s: %w", provider.GetName(), err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
return errs.Wait().Error()
|
||||
}
|
||||
|
||||
// ObtainCert renews existing certificate or obtains a new certificate for this provider.
|
||||
func (p *Provider) ObtainCert() error {
|
||||
if p.cfg.Provider == ProviderLocal {
|
||||
return nil
|
||||
}
|
||||
|
||||
if p.cfg.Provider == ProviderPseudo {
|
||||
log.Info().Msg("init client for pseudo provider")
|
||||
p.logger.Info().Msg("init client for pseudo provider")
|
||||
<-time.After(time.Second)
|
||||
log.Info().Msg("registering acme for pseudo provider")
|
||||
p.logger.Info().Msg("registering acme for pseudo provider")
|
||||
<-time.After(time.Second)
|
||||
log.Info().Msg("obtained cert for pseudo provider")
|
||||
p.logger.Info().Msg("obtained cert for pseudo provider")
|
||||
return nil
|
||||
}
|
||||
|
||||
if lastFailure, err := p.GetLastFailure(); err != nil {
|
||||
return err
|
||||
} else if time.Since(lastFailure) < requestCooldownDuration {
|
||||
return fmt.Errorf("%w: still in cooldown until %s", ErrGetCertFailure, strutils.FormatTime(lastFailure.Add(requestCooldownDuration).Local()))
|
||||
}
|
||||
|
||||
if p.client == nil {
|
||||
if err := p.initClient(); err != nil {
|
||||
return err
|
||||
@@ -227,6 +305,7 @@ func (p *Provider) obtainCertSelf() error {
|
||||
}
|
||||
p.tlsCert = &tlsCert
|
||||
p.certExpiries = expiries
|
||||
p.rebuildSNIMatcher()
|
||||
|
||||
if err := p.ClearLastFailure(); err != nil {
|
||||
return fmt.Errorf("failed to clear last failure: %w", err)
|
||||
@@ -235,19 +314,37 @@ func (p *Provider) obtainCertSelf() error {
|
||||
}
|
||||
|
||||
func (p *Provider) LoadCert() error {
|
||||
var errs gperr.Builder
|
||||
cert, err := tls.LoadX509KeyPair(p.cfg.CertPath, p.cfg.KeyPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load SSL certificate: %w", err)
|
||||
errs.Addf("load SSL certificate: %w", p.fmtError(err))
|
||||
}
|
||||
|
||||
expiries, err := getCertExpiries(&cert)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse SSL certificate: %w", err)
|
||||
errs.Addf("parse SSL certificate: %w", p.fmtError(err))
|
||||
}
|
||||
|
||||
p.tlsCert = &cert
|
||||
p.certExpiries = expiries
|
||||
|
||||
log.Info().Msgf("next cert renewal in %s", strutils.FormatDuration(time.Until(p.ShouldRenewOn())))
|
||||
return p.renewIfNeeded()
|
||||
for _, ep := range p.extraProviders {
|
||||
if err := ep.LoadCert(); err != nil {
|
||||
errs.Add(err)
|
||||
}
|
||||
}
|
||||
|
||||
p.rebuildSNIMatcher()
|
||||
return errs.Error()
|
||||
}
|
||||
|
||||
// PrintCertExpiriesAll prints the certificate expiries for this provider and all extra providers.
|
||||
func (p *Provider) PrintCertExpiriesAll() {
|
||||
for _, provider := range p.allProviders() {
|
||||
for domain, expiry := range provider.certExpiries {
|
||||
p.logger.Info().Str("domain", domain).Msgf("certificate expire on %s", strutils.FormatTime(expiry))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ShouldRenewOn returns the time at which the certificate should be renewed.
|
||||
@@ -255,65 +352,129 @@ func (p *Provider) ShouldRenewOn() time.Time {
|
||||
for _, expiry := range p.certExpiries {
|
||||
return expiry.AddDate(0, -1, 0) // 1 month before
|
||||
}
|
||||
// this line should never be reached
|
||||
panic("no certificate available")
|
||||
// this line should never be reached in production, but will be useful for testing
|
||||
return time.Now().AddDate(0, 1, 0) // 1 month after
|
||||
}
|
||||
|
||||
func (p *Provider) ScheduleRenewal(parent task.Parent) {
|
||||
// ForceExpiryAll triggers immediate certificate renewal for this provider and all extra providers.
|
||||
// Returns true if the renewal was triggered, false if the renewal was dropped.
|
||||
//
|
||||
// If at least one renewal is triggered, returns true.
|
||||
func (p *Provider) ForceExpiryAll() (ok bool) {
|
||||
doneCh := make(chan struct{})
|
||||
if swapped := p.forceRenewalDoneCh.CompareAndSwap(emptyForceRenewalDoneCh, doneCh); !swapped { // already in progress
|
||||
close(doneCh)
|
||||
return false
|
||||
}
|
||||
|
||||
select {
|
||||
case p.forceRenewalCh <- struct{}{}:
|
||||
ok = true
|
||||
default:
|
||||
}
|
||||
|
||||
for _, ep := range p.extraProviders {
|
||||
if ep.ForceExpiryAll() {
|
||||
ok = true
|
||||
}
|
||||
}
|
||||
|
||||
return ok
|
||||
}
|
||||
|
||||
// WaitRenewalDone waits for the renewal to complete.
|
||||
// Returns false if the renewal was dropped.
|
||||
func (p *Provider) WaitRenewalDone(ctx context.Context) bool {
|
||||
done, ok := p.forceRenewalDoneCh.Load().(chan struct{})
|
||||
if !ok || done == nil {
|
||||
return false
|
||||
}
|
||||
select {
|
||||
case <-done:
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
}
|
||||
|
||||
for _, ep := range p.extraProviders {
|
||||
if !ep.WaitRenewalDone(ctx) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ScheduleRenewalAll schedules the renewal of the certificate for this provider and all extra providers.
|
||||
func (p *Provider) ScheduleRenewalAll(parent task.Parent) {
|
||||
p.scheduleRenewalOnce.Do(func() {
|
||||
p.scheduleRenewal(parent)
|
||||
})
|
||||
for _, ep := range p.extraProviders {
|
||||
ep.scheduleRenewalOnce.Do(func() {
|
||||
ep.scheduleRenewal(parent)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
var emptyForceRenewalDoneCh any = chan struct{}(nil)
|
||||
|
||||
// scheduleRenewal schedules the renewal of the certificate for this provider.
|
||||
func (p *Provider) scheduleRenewal(parent task.Parent) {
|
||||
if p.GetName() == ProviderLocal || p.GetName() == ProviderPseudo {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
renewalTime := p.ShouldRenewOn()
|
||||
timer := time.NewTimer(time.Until(renewalTime))
|
||||
defer timer.Stop()
|
||||
|
||||
task := parent.Subtask("cert-renew-scheduler:"+filepath.Base(p.cfg.CertPath), true)
|
||||
timer := time.NewTimer(time.Until(p.ShouldRenewOn()))
|
||||
task := parent.Subtask("cert-renew-scheduler:"+filepath.Base(p.cfg.CertPath), true)
|
||||
|
||||
renew := func(renewMode RenewMode) {
|
||||
defer func() {
|
||||
if done, ok := p.forceRenewalDoneCh.Swap(emptyForceRenewalDoneCh).(chan struct{}); ok && done != nil {
|
||||
close(done)
|
||||
}
|
||||
}()
|
||||
|
||||
renewed, err := p.renew(renewMode)
|
||||
if err != nil {
|
||||
gperr.LogWarn("autocert: cert renew failed", p.fmtError(err))
|
||||
notif.Notify(¬if.LogMessage{
|
||||
Level: zerolog.ErrorLevel,
|
||||
Title: fmt.Sprintf("SSL certificate renewal failed for %s", p.GetName()),
|
||||
Body: notif.MessageBody(err.Error()),
|
||||
})
|
||||
return
|
||||
}
|
||||
if renewed {
|
||||
p.rebuildSNIMatcher()
|
||||
|
||||
notif.Notify(¬if.LogMessage{
|
||||
Level: zerolog.InfoLevel,
|
||||
Title: fmt.Sprintf("SSL certificate renewed for %s", p.GetName()),
|
||||
Body: notif.ListBody(p.cfg.Domains),
|
||||
})
|
||||
|
||||
// Reset on success
|
||||
if err := p.ClearLastFailure(); err != nil {
|
||||
gperr.LogWarn("autocert: failed to clear last failure", p.fmtError(err))
|
||||
}
|
||||
timer.Reset(time.Until(p.ShouldRenewOn()))
|
||||
}
|
||||
}
|
||||
|
||||
go func() {
|
||||
defer timer.Stop()
|
||||
defer task.Finish(nil)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-task.Context().Done():
|
||||
return
|
||||
case <-p.forceRenewalCh:
|
||||
renew(renewModeForce)
|
||||
case <-timer.C:
|
||||
// Retry after 1 hour on failure
|
||||
lastFailure, err := p.GetLastFailure()
|
||||
if err != nil {
|
||||
gperr.LogWarn("autocert: failed to get last failure", err)
|
||||
continue
|
||||
}
|
||||
if !lastFailure.IsZero() && time.Since(lastFailure) < renewalCooldownDuration {
|
||||
continue
|
||||
}
|
||||
if err := p.renewIfNeeded(); err != nil {
|
||||
gperr.LogWarn("autocert: cert renew failed", err)
|
||||
if err := p.UpdateLastFailure(); err != nil {
|
||||
gperr.LogWarn("autocert: failed to update last failure", err)
|
||||
}
|
||||
notif.Notify(¬if.LogMessage{
|
||||
Level: zerolog.ErrorLevel,
|
||||
Title: "SSL certificate renewal failed",
|
||||
Body: notif.MessageBody(err.Error()),
|
||||
})
|
||||
continue
|
||||
}
|
||||
notif.Notify(¬if.LogMessage{
|
||||
Level: zerolog.InfoLevel,
|
||||
Title: "SSL certificate renewed",
|
||||
Body: notif.ListBody(p.cfg.Domains),
|
||||
})
|
||||
// Reset on success
|
||||
if err := p.ClearLastFailure(); err != nil {
|
||||
gperr.LogWarn("autocert: failed to clear last failure", err)
|
||||
}
|
||||
renewalTime = p.ShouldRenewOn()
|
||||
timer.Reset(time.Until(renewalTime))
|
||||
renew(renewModeIfNeeded)
|
||||
}
|
||||
}
|
||||
}()
|
||||
for _, ep := range p.extraProviders {
|
||||
ep.ScheduleRenewal(parent)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Provider) initClient() error {
|
||||
@@ -409,21 +570,42 @@ func (p *Provider) certState() CertState {
|
||||
return CertStateValid
|
||||
}
|
||||
|
||||
func (p *Provider) renewIfNeeded() error {
|
||||
func (p *Provider) renew(mode RenewMode) (renewed bool, err error) {
|
||||
if p.cfg.Provider == ProviderLocal {
|
||||
return nil
|
||||
return false, nil
|
||||
}
|
||||
|
||||
switch p.certState() {
|
||||
case CertStateExpired:
|
||||
log.Info().Msg("certs expired, renewing")
|
||||
case CertStateMismatch:
|
||||
log.Info().Msg("cert domains mismatch with config, renewing")
|
||||
default:
|
||||
return nil
|
||||
if mode != renewModeForce {
|
||||
// Retry after 1 hour on failure
|
||||
lastFailure, err := p.GetLastFailure()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to get last failure: %w", err)
|
||||
}
|
||||
if !lastFailure.IsZero() && time.Since(lastFailure) < renewalCooldownDuration {
|
||||
until := lastFailure.Add(renewalCooldownDuration).Local()
|
||||
return false, fmt.Errorf("still in cooldown until %s", strutils.FormatTime(until))
|
||||
}
|
||||
}
|
||||
|
||||
return p.obtainCertSelf()
|
||||
if mode == renewModeIfNeeded {
|
||||
switch p.certState() {
|
||||
case CertStateExpired:
|
||||
log.Info().Msg("certs expired, renewing")
|
||||
case CertStateMismatch:
|
||||
log.Info().Msg("cert domains mismatch with config, renewing")
|
||||
default:
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
|
||||
if mode == renewModeForce {
|
||||
log.Info().Msg("force renewing cert by user request")
|
||||
}
|
||||
|
||||
if err := p.ObtainCert(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func getCertExpiries(cert *tls.Certificate) (CertExpiries, error) {
|
||||
@@ -445,15 +627,16 @@ func getCertExpiries(cert *tls.Certificate) (CertExpiries, error) {
|
||||
}
|
||||
|
||||
func lastFailureFileFor(certPath, keyPath string) string {
|
||||
if certPath == "" && keyPath == "" {
|
||||
return LastFailureFile
|
||||
}
|
||||
dir := filepath.Dir(certPath)
|
||||
sum := sha256.Sum256([]byte(certPath + "|" + keyPath))
|
||||
return filepath.Join(dir, fmt.Sprintf(".last_failure-%x", sum[:6]))
|
||||
}
|
||||
|
||||
func (p *Provider) rebuildSNIMatcher() {
|
||||
if p.cfg.idx != 0 { // only main provider has extra providers
|
||||
return
|
||||
}
|
||||
|
||||
p.sniMatcher = sniMatcher{}
|
||||
p.sniMatcher.addProvider(p)
|
||||
for _, ep := range p.extraProviders {
|
||||
|
||||
@@ -10,12 +10,15 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -24,6 +27,368 @@ import (
|
||||
"github.com/yusing/godoxy/internal/dnsproviders"
|
||||
)
|
||||
|
||||
// TestACMEServer implements a minimal ACME server for testing with request tracking.
|
||||
type TestACMEServer struct {
|
||||
server *httptest.Server
|
||||
caCert *x509.Certificate
|
||||
caKey *rsa.PrivateKey
|
||||
clientCSRs map[string]*x509.CertificateRequest
|
||||
orderDomains map[string][]string
|
||||
authzDomains map[string]string
|
||||
orderSeq int
|
||||
certRequestCount map[string]int
|
||||
renewalRequestCount map[string]int
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func newTestACMEServer(t *testing.T) *TestACMEServer {
|
||||
t.Helper()
|
||||
|
||||
// Generate CA certificate and key
|
||||
caKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
require.NoError(t, err)
|
||||
|
||||
caTemplate := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{
|
||||
Organization: []string{"Test CA"},
|
||||
Country: []string{"US"},
|
||||
Province: []string{""},
|
||||
Locality: []string{"Test"},
|
||||
StreetAddress: []string{""},
|
||||
PostalCode: []string{""},
|
||||
},
|
||||
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
|
||||
NotBefore: time.Now(),
|
||||
NotAfter: time.Now().Add(365 * 24 * time.Hour),
|
||||
IsCA: true,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth},
|
||||
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign,
|
||||
BasicConstraintsValid: true,
|
||||
}
|
||||
|
||||
caCertDER, err := x509.CreateCertificate(rand.Reader, caTemplate, caTemplate, &caKey.PublicKey, caKey)
|
||||
require.NoError(t, err)
|
||||
|
||||
caCert, err := x509.ParseCertificate(caCertDER)
|
||||
require.NoError(t, err)
|
||||
|
||||
acme := &TestACMEServer{
|
||||
caCert: caCert,
|
||||
caKey: caKey,
|
||||
clientCSRs: make(map[string]*x509.CertificateRequest),
|
||||
orderDomains: make(map[string][]string),
|
||||
authzDomains: make(map[string]string),
|
||||
orderSeq: 0,
|
||||
certRequestCount: make(map[string]int),
|
||||
renewalRequestCount: make(map[string]int),
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
acme.setupRoutes(mux)
|
||||
|
||||
acme.server = httptest.NewUnstartedServer(mux)
|
||||
acme.server.TLS = &tls.Config{
|
||||
Certificates: []tls.Certificate{
|
||||
{
|
||||
Certificate: [][]byte{caCert.Raw},
|
||||
PrivateKey: caKey,
|
||||
},
|
||||
},
|
||||
MinVersion: tls.VersionTLS12,
|
||||
}
|
||||
acme.server.StartTLS()
|
||||
return acme
|
||||
}
|
||||
|
||||
func (s *TestACMEServer) Close() {
|
||||
s.server.Close()
|
||||
}
|
||||
|
||||
func (s *TestACMEServer) URL() string {
|
||||
return s.server.URL
|
||||
}
|
||||
|
||||
func (s *TestACMEServer) httpClient() *http.Client {
|
||||
certPool := x509.NewCertPool()
|
||||
certPool.AddCert(s.caCert)
|
||||
|
||||
return &http.Client{
|
||||
Transport: &http.Transport{
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: 30 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
}).DialContext,
|
||||
TLSHandshakeTimeout: 30 * time.Second,
|
||||
ResponseHeaderTimeout: 30 * time.Second,
|
||||
TLSClientConfig: &tls.Config{
|
||||
RootCAs: certPool,
|
||||
MinVersion: tls.VersionTLS12,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *TestACMEServer) setupRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/acme/acme/directory", s.handleDirectory)
|
||||
mux.HandleFunc("/acme/new-nonce", s.handleNewNonce)
|
||||
mux.HandleFunc("/acme/new-account", s.handleNewAccount)
|
||||
mux.HandleFunc("/acme/new-order", s.handleNewOrder)
|
||||
mux.HandleFunc("/acme/authz/", s.handleAuthorization)
|
||||
mux.HandleFunc("/acme/chall/", s.handleChallenge)
|
||||
mux.HandleFunc("/acme/order/", s.handleOrder)
|
||||
mux.HandleFunc("/acme/cert/", s.handleCertificate)
|
||||
}
|
||||
|
||||
func (s *TestACMEServer) handleDirectory(w http.ResponseWriter, r *http.Request) {
|
||||
directory := map[string]any{
|
||||
"newNonce": s.server.URL + "/acme/new-nonce",
|
||||
"newAccount": s.server.URL + "/acme/new-account",
|
||||
"newOrder": s.server.URL + "/acme/new-order",
|
||||
"keyChange": s.server.URL + "/acme/key-change",
|
||||
"meta": map[string]any{
|
||||
"termsOfService": s.server.URL + "/terms",
|
||||
},
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(directory)
|
||||
}
|
||||
|
||||
func (s *TestACMEServer) handleNewNonce(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Replay-Nonce", "test-nonce-12345")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func (s *TestACMEServer) handleNewAccount(w http.ResponseWriter, r *http.Request) {
|
||||
account := map[string]any{
|
||||
"status": "valid",
|
||||
"contact": []string{"mailto:test@example.com"},
|
||||
"orders": s.server.URL + "/acme/orders",
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Location", s.server.URL+"/acme/account/1")
|
||||
w.Header().Set("Replay-Nonce", "test-nonce-67890")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(account)
|
||||
}
|
||||
|
||||
func (s *TestACMEServer) handleNewOrder(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var jws struct {
|
||||
Payload string `json:"payload"`
|
||||
}
|
||||
json.Unmarshal(body, &jws)
|
||||
payloadBytes, _ := base64.RawURLEncoding.DecodeString(jws.Payload)
|
||||
var orderReq struct {
|
||||
Identifiers []map[string]string `json:"identifiers"`
|
||||
}
|
||||
json.Unmarshal(payloadBytes, &orderReq)
|
||||
|
||||
domains := []string{}
|
||||
for _, id := range orderReq.Identifiers {
|
||||
domains = append(domains, id["value"])
|
||||
}
|
||||
sort.Strings(domains)
|
||||
domainKey := strings.Join(domains, ",")
|
||||
|
||||
s.mu.Lock()
|
||||
s.orderSeq++
|
||||
orderID := fmt.Sprintf("test-order-%d", s.orderSeq)
|
||||
authzID := fmt.Sprintf("test-authz-%d", s.orderSeq)
|
||||
s.orderDomains[orderID] = domains
|
||||
if len(domains) > 0 {
|
||||
s.authzDomains[authzID] = domains[0]
|
||||
}
|
||||
s.certRequestCount[domainKey]++
|
||||
s.mu.Unlock()
|
||||
|
||||
order := map[string]any{
|
||||
"status": "ready",
|
||||
"expires": time.Now().Add(24 * time.Hour).Format(time.RFC3339),
|
||||
"identifiers": orderReq.Identifiers,
|
||||
"authorizations": []string{s.server.URL + "/acme/authz/" + authzID},
|
||||
"finalize": s.server.URL + "/acme/order/" + orderID + "/finalize",
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Location", s.server.URL+"/acme/order/"+orderID)
|
||||
w.Header().Set("Replay-Nonce", "test-nonce-order")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(order)
|
||||
}
|
||||
|
||||
func (s *TestACMEServer) handleAuthorization(w http.ResponseWriter, r *http.Request) {
|
||||
authzID := strings.TrimPrefix(r.URL.Path, "/acme/authz/")
|
||||
domain := s.authzDomains[authzID]
|
||||
if domain == "" {
|
||||
domain = "test.example.com"
|
||||
}
|
||||
authz := map[string]any{
|
||||
"status": "valid",
|
||||
"expires": time.Now().Add(24 * time.Hour).Format(time.RFC3339),
|
||||
"identifier": map[string]string{"type": "dns", "value": domain},
|
||||
"challenges": []map[string]any{
|
||||
{
|
||||
"type": "dns-01",
|
||||
"status": "valid",
|
||||
"url": s.server.URL + "/acme/chall/test-chall-789",
|
||||
"token": "test-token-abc123",
|
||||
},
|
||||
},
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Replay-Nonce", "test-nonce-authz")
|
||||
json.NewEncoder(w).Encode(authz)
|
||||
}
|
||||
|
||||
func (s *TestACMEServer) handleChallenge(w http.ResponseWriter, r *http.Request) {
|
||||
challenge := map[string]any{
|
||||
"type": "dns-01",
|
||||
"status": "valid",
|
||||
"url": r.URL.String(),
|
||||
"token": "test-token-abc123",
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Replay-Nonce", "test-nonce-chall")
|
||||
json.NewEncoder(w).Encode(challenge)
|
||||
}
|
||||
|
||||
func (s *TestACMEServer) handleOrder(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasSuffix(r.URL.Path, "/finalize") {
|
||||
s.handleFinalize(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
orderID := strings.TrimPrefix(r.URL.Path, "/acme/order/")
|
||||
domains := s.orderDomains[orderID]
|
||||
if len(domains) == 0 {
|
||||
domains = []string{"test.example.com"}
|
||||
}
|
||||
certURL := s.server.URL + "/acme/cert/" + orderID
|
||||
order := map[string]any{
|
||||
"status": "valid",
|
||||
"expires": time.Now().Add(24 * time.Hour).Format(time.RFC3339),
|
||||
"identifiers": func() []map[string]string {
|
||||
out := make([]map[string]string, 0, len(domains))
|
||||
for _, d := range domains {
|
||||
out = append(out, map[string]string{"type": "dns", "value": d})
|
||||
}
|
||||
return out
|
||||
}(),
|
||||
"certificate": certURL,
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Replay-Nonce", "test-nonce-order-get")
|
||||
json.NewEncoder(w).Encode(order)
|
||||
}
|
||||
|
||||
func (s *TestACMEServer) handleFinalize(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to read request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
csr, err := s.extractCSRFromJWS(body)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid CSR: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
orderID := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/acme/order/"), "/finalize")
|
||||
s.mu.Lock()
|
||||
s.clientCSRs[orderID] = csr
|
||||
|
||||
// Detect renewal: if we already have a certificate for these domains, it's a renewal
|
||||
domains := csr.DNSNames
|
||||
sort.Strings(domains)
|
||||
domainKey := strings.Join(domains, ",")
|
||||
|
||||
if s.certRequestCount[domainKey] > 1 {
|
||||
s.renewalRequestCount[domainKey]++
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
certURL := s.server.URL + "/acme/cert/" + orderID
|
||||
order := map[string]any{
|
||||
"status": "valid",
|
||||
"expires": time.Now().Add(24 * time.Hour).Format(time.RFC3339),
|
||||
"identifiers": func() []map[string]string {
|
||||
out := make([]map[string]string, 0, len(domains))
|
||||
for _, d := range domains {
|
||||
out = append(out, map[string]string{"type": "dns", "value": d})
|
||||
}
|
||||
return out
|
||||
}(),
|
||||
"certificate": certURL,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Location", strings.TrimSuffix(r.URL.String(), "/finalize"))
|
||||
w.Header().Set("Replay-Nonce", "test-nonce-finalize")
|
||||
json.NewEncoder(w).Encode(order)
|
||||
}
|
||||
|
||||
func (s *TestACMEServer) extractCSRFromJWS(jwsData []byte) (*x509.CertificateRequest, error) {
|
||||
var jws struct {
|
||||
Payload string `json:"payload"`
|
||||
}
|
||||
if err := json.Unmarshal(jwsData, &jws); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
payloadBytes, err := base64.RawURLEncoding.DecodeString(jws.Payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var finalizeReq struct {
|
||||
CSR string `json:"csr"`
|
||||
}
|
||||
if err := json.Unmarshal(payloadBytes, &finalizeReq); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
csrBytes, err := base64.RawURLEncoding.DecodeString(finalizeReq.CSR)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return x509.ParseCertificateRequest(csrBytes)
|
||||
}
|
||||
|
||||
func (s *TestACMEServer) handleCertificate(w http.ResponseWriter, r *http.Request) {
|
||||
orderID := strings.TrimPrefix(r.URL.Path, "/acme/cert/")
|
||||
csr, exists := s.clientCSRs[orderID]
|
||||
if !exists {
|
||||
http.Error(w, "No CSR found for order", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(2),
|
||||
Subject: pkix.Name{
|
||||
Organization: []string{"Test Cert"},
|
||||
Country: []string{"US"},
|
||||
},
|
||||
DNSNames: csr.DNSNames,
|
||||
NotBefore: time.Now(),
|
||||
NotAfter: time.Now().Add(90 * 24 * time.Hour),
|
||||
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
BasicConstraintsValid: true,
|
||||
}
|
||||
|
||||
certDER, err := x509.CreateCertificate(rand.Reader, template, s.caCert, csr.PublicKey, s.caKey)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
|
||||
caPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: s.caCert.Raw})
|
||||
|
||||
w.Header().Set("Content-Type", "application/pem-certificate-chain")
|
||||
w.Header().Set("Replay-Nonce", "test-nonce-cert")
|
||||
w.Write(append(certPEM, caPEM...))
|
||||
}
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
dnsproviders.InitProviders()
|
||||
m.Run()
|
||||
@@ -41,7 +406,7 @@ func TestCustomProvider(t *testing.T) {
|
||||
ACMEKeyPath: "certs/custom-acme.key",
|
||||
}
|
||||
|
||||
err := cfg.Validate()
|
||||
err := error(cfg.Validate())
|
||||
require.NoError(t, err)
|
||||
|
||||
user, legoCfg, err := cfg.GetLegoConfig()
|
||||
@@ -62,7 +427,8 @@ func TestCustomProvider(t *testing.T) {
|
||||
|
||||
err := cfg.Validate()
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "missing field 'ca_dir_url'")
|
||||
require.Contains(t, err.Error(), "missing field")
|
||||
require.Contains(t, err.Error(), "ca_dir_url")
|
||||
})
|
||||
|
||||
t.Run("custom provider with step-ca internal CA", func(t *testing.T) {
|
||||
@@ -76,7 +442,7 @@ func TestCustomProvider(t *testing.T) {
|
||||
ACMEKeyPath: "certs/internal-acme.key",
|
||||
}
|
||||
|
||||
err := cfg.Validate()
|
||||
err := error(cfg.Validate())
|
||||
require.NoError(t, err)
|
||||
|
||||
user, legoCfg, err := cfg.GetLegoConfig()
|
||||
@@ -86,9 +452,10 @@ func TestCustomProvider(t *testing.T) {
|
||||
require.Equal(t, "https://step-ca.internal:443/acme/acme/directory", legoCfg.CADirURL)
|
||||
require.Equal(t, "admin@internal.com", user.Email)
|
||||
|
||||
provider := autocert.NewProvider(cfg, user, legoCfg)
|
||||
provider, err := autocert.NewProvider(cfg, user, legoCfg)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, provider)
|
||||
require.Equal(t, autocert.ProviderCustom, provider.GetName())
|
||||
require.Equal(t, "main", provider.GetName())
|
||||
require.Equal(t, "certs/internal.crt", provider.GetCertPath())
|
||||
require.Equal(t, "certs/internal.key", provider.GetKeyPath())
|
||||
})
|
||||
@@ -119,7 +486,8 @@ func TestObtainCertFromCustomProvider(t *testing.T) {
|
||||
require.NotNil(t, user)
|
||||
require.NotNil(t, legoCfg)
|
||||
|
||||
provider := autocert.NewProvider(cfg, user, legoCfg)
|
||||
provider, err := autocert.NewProvider(cfg, user, legoCfg)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, provider)
|
||||
|
||||
// Test obtaining certificate
|
||||
@@ -161,7 +529,8 @@ func TestObtainCertFromCustomProvider(t *testing.T) {
|
||||
require.NotNil(t, user)
|
||||
require.NotNil(t, legoCfg)
|
||||
|
||||
provider := autocert.NewProvider(cfg, user, legoCfg)
|
||||
provider, err := autocert.NewProvider(cfg, user, legoCfg)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, provider)
|
||||
|
||||
err = provider.ObtainCert()
|
||||
@@ -178,330 +547,3 @@ func TestObtainCertFromCustomProvider(t *testing.T) {
|
||||
require.True(t, time.Now().After(x509Cert.NotBefore))
|
||||
})
|
||||
}
|
||||
|
||||
// testACMEServer implements a minimal ACME server for testing.
|
||||
type testACMEServer struct {
|
||||
server *httptest.Server
|
||||
caCert *x509.Certificate
|
||||
caKey *rsa.PrivateKey
|
||||
clientCSRs map[string]*x509.CertificateRequest
|
||||
orderID string
|
||||
}
|
||||
|
||||
func newTestACMEServer(t *testing.T) *testACMEServer {
|
||||
t.Helper()
|
||||
|
||||
// Generate CA certificate and key
|
||||
caKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
require.NoError(t, err)
|
||||
|
||||
caTemplate := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{
|
||||
Organization: []string{"Test CA"},
|
||||
Country: []string{"US"},
|
||||
Province: []string{""},
|
||||
Locality: []string{"Test"},
|
||||
StreetAddress: []string{""},
|
||||
PostalCode: []string{""},
|
||||
},
|
||||
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
|
||||
NotBefore: time.Now(),
|
||||
NotAfter: time.Now().Add(365 * 24 * time.Hour),
|
||||
IsCA: true,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth},
|
||||
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign,
|
||||
BasicConstraintsValid: true,
|
||||
}
|
||||
|
||||
caCertDER, err := x509.CreateCertificate(rand.Reader, caTemplate, caTemplate, &caKey.PublicKey, caKey)
|
||||
require.NoError(t, err)
|
||||
|
||||
caCert, err := x509.ParseCertificate(caCertDER)
|
||||
require.NoError(t, err)
|
||||
|
||||
acme := &testACMEServer{
|
||||
caCert: caCert,
|
||||
caKey: caKey,
|
||||
clientCSRs: make(map[string]*x509.CertificateRequest),
|
||||
orderID: "test-order-123",
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
acme.setupRoutes(mux)
|
||||
|
||||
acme.server = httptest.NewUnstartedServer(mux)
|
||||
acme.server.TLS = &tls.Config{
|
||||
Certificates: []tls.Certificate{
|
||||
{
|
||||
Certificate: [][]byte{caCert.Raw},
|
||||
PrivateKey: caKey,
|
||||
},
|
||||
},
|
||||
MinVersion: tls.VersionTLS12,
|
||||
}
|
||||
acme.server.StartTLS()
|
||||
return acme
|
||||
}
|
||||
|
||||
func (s *testACMEServer) Close() {
|
||||
s.server.Close()
|
||||
}
|
||||
|
||||
func (s *testACMEServer) URL() string {
|
||||
return s.server.URL
|
||||
}
|
||||
|
||||
func (s *testACMEServer) httpClient() *http.Client {
|
||||
certPool := x509.NewCertPool()
|
||||
certPool.AddCert(s.caCert)
|
||||
|
||||
return &http.Client{
|
||||
Transport: &http.Transport{
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: 30 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
}).DialContext,
|
||||
TLSHandshakeTimeout: 30 * time.Second,
|
||||
ResponseHeaderTimeout: 30 * time.Second,
|
||||
TLSClientConfig: &tls.Config{
|
||||
RootCAs: certPool,
|
||||
MinVersion: tls.VersionTLS12,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *testACMEServer) setupRoutes(mux *http.ServeMux) {
|
||||
// ACME directory endpoint
|
||||
mux.HandleFunc("/acme/acme/directory", s.handleDirectory)
|
||||
|
||||
// ACME endpoints
|
||||
mux.HandleFunc("/acme/new-nonce", s.handleNewNonce)
|
||||
mux.HandleFunc("/acme/new-account", s.handleNewAccount)
|
||||
mux.HandleFunc("/acme/new-order", s.handleNewOrder)
|
||||
mux.HandleFunc("/acme/authz/", s.handleAuthorization)
|
||||
mux.HandleFunc("/acme/chall/", s.handleChallenge)
|
||||
mux.HandleFunc("/acme/order/", s.handleOrder)
|
||||
mux.HandleFunc("/acme/cert/", s.handleCertificate)
|
||||
}
|
||||
|
||||
func (s *testACMEServer) handleDirectory(w http.ResponseWriter, r *http.Request) {
|
||||
directory := map[string]interface{}{
|
||||
"newNonce": s.server.URL + "/acme/new-nonce",
|
||||
"newAccount": s.server.URL + "/acme/new-account",
|
||||
"newOrder": s.server.URL + "/acme/new-order",
|
||||
"keyChange": s.server.URL + "/acme/key-change",
|
||||
"meta": map[string]interface{}{
|
||||
"termsOfService": s.server.URL + "/terms",
|
||||
},
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(directory)
|
||||
}
|
||||
|
||||
func (s *testACMEServer) handleNewNonce(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Replay-Nonce", "test-nonce-12345")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func (s *testACMEServer) handleNewAccount(w http.ResponseWriter, r *http.Request) {
|
||||
account := map[string]interface{}{
|
||||
"status": "valid",
|
||||
"contact": []string{"mailto:test@example.com"},
|
||||
"orders": s.server.URL + "/acme/orders",
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Location", s.server.URL+"/acme/account/1")
|
||||
w.Header().Set("Replay-Nonce", "test-nonce-67890")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(account)
|
||||
}
|
||||
|
||||
func (s *testACMEServer) handleNewOrder(w http.ResponseWriter, r *http.Request) {
|
||||
authzID := "test-authz-456"
|
||||
|
||||
order := map[string]interface{}{
|
||||
"status": "ready", // Skip pending state for simplicity
|
||||
"expires": time.Now().Add(24 * time.Hour).Format(time.RFC3339),
|
||||
"identifiers": []map[string]string{{"type": "dns", "value": "test.example.com"}},
|
||||
"authorizations": []string{s.server.URL + "/acme/authz/" + authzID},
|
||||
"finalize": s.server.URL + "/acme/order/" + s.orderID + "/finalize",
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Location", s.server.URL+"/acme/order/"+s.orderID)
|
||||
w.Header().Set("Replay-Nonce", "test-nonce-order")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(order)
|
||||
}
|
||||
|
||||
func (s *testACMEServer) handleAuthorization(w http.ResponseWriter, r *http.Request) {
|
||||
authz := map[string]interface{}{
|
||||
"status": "valid", // Skip challenge validation for simplicity
|
||||
"expires": time.Now().Add(24 * time.Hour).Format(time.RFC3339),
|
||||
"identifier": map[string]string{"type": "dns", "value": "test.example.com"},
|
||||
"challenges": []map[string]interface{}{
|
||||
{
|
||||
"type": "dns-01",
|
||||
"status": "valid",
|
||||
"url": s.server.URL + "/acme/chall/test-chall-789",
|
||||
"token": "test-token-abc123",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Replay-Nonce", "test-nonce-authz")
|
||||
json.NewEncoder(w).Encode(authz)
|
||||
}
|
||||
|
||||
func (s *testACMEServer) handleChallenge(w http.ResponseWriter, r *http.Request) {
|
||||
challenge := map[string]interface{}{
|
||||
"type": "dns-01",
|
||||
"status": "valid",
|
||||
"url": r.URL.String(),
|
||||
"token": "test-token-abc123",
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Replay-Nonce", "test-nonce-chall")
|
||||
json.NewEncoder(w).Encode(challenge)
|
||||
}
|
||||
|
||||
func (s *testACMEServer) handleOrder(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasSuffix(r.URL.Path, "/finalize") {
|
||||
s.handleFinalize(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
certURL := s.server.URL + "/acme/cert/" + s.orderID
|
||||
order := map[string]interface{}{
|
||||
"status": "valid",
|
||||
"expires": time.Now().Add(24 * time.Hour).Format(time.RFC3339),
|
||||
"identifiers": []map[string]string{{"type": "dns", "value": "test.example.com"}},
|
||||
"certificate": certURL,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Replay-Nonce", "test-nonce-order-get")
|
||||
json.NewEncoder(w).Encode(order)
|
||||
}
|
||||
|
||||
func (s *testACMEServer) handleFinalize(w http.ResponseWriter, r *http.Request) {
|
||||
// Read the JWS payload
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to read request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Extract CSR from JWS payload
|
||||
csr, err := s.extractCSRFromJWS(body)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid CSR: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Store the CSR for certificate generation
|
||||
s.clientCSRs[s.orderID] = csr
|
||||
|
||||
certURL := s.server.URL + "/acme/cert/" + s.orderID
|
||||
order := map[string]interface{}{
|
||||
"status": "valid",
|
||||
"expires": time.Now().Add(24 * time.Hour).Format(time.RFC3339),
|
||||
"identifiers": []map[string]string{{"type": "dns", "value": "test.example.com"}},
|
||||
"certificate": certURL,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Location", strings.TrimSuffix(r.URL.String(), "/finalize"))
|
||||
w.Header().Set("Replay-Nonce", "test-nonce-finalize")
|
||||
json.NewEncoder(w).Encode(order)
|
||||
}
|
||||
|
||||
func (s *testACMEServer) extractCSRFromJWS(jwsData []byte) (*x509.CertificateRequest, error) {
|
||||
// Parse the JWS structure
|
||||
var jws struct {
|
||||
Protected string `json:"protected"`
|
||||
Payload string `json:"payload"`
|
||||
Signature string `json:"signature"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(jwsData, &jws); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Decode the payload
|
||||
payloadBytes, err := base64.RawURLEncoding.DecodeString(jws.Payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Parse the finalize request
|
||||
var finalizeReq struct {
|
||||
CSR string `json:"csr"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(payloadBytes, &finalizeReq); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Decode the CSR
|
||||
csrBytes, err := base64.RawURLEncoding.DecodeString(finalizeReq.CSR)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Parse the CSR
|
||||
csr, err := x509.ParseCertificateRequest(csrBytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return csr, nil
|
||||
}
|
||||
|
||||
func (s *testACMEServer) handleCertificate(w http.ResponseWriter, r *http.Request) {
|
||||
// Extract order ID from URL
|
||||
orderID := strings.TrimPrefix(r.URL.Path, "/acme/cert/")
|
||||
|
||||
// Get the CSR for this order
|
||||
csr, exists := s.clientCSRs[orderID]
|
||||
if !exists {
|
||||
http.Error(w, "No CSR found for order", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Create certificate using the public key from the client's CSR
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(2),
|
||||
Subject: pkix.Name{
|
||||
Organization: []string{"Test Cert"},
|
||||
Country: []string{"US"},
|
||||
},
|
||||
DNSNames: csr.DNSNames,
|
||||
NotBefore: time.Now(),
|
||||
NotAfter: time.Now().Add(90 * 24 * time.Hour),
|
||||
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
BasicConstraintsValid: true,
|
||||
}
|
||||
|
||||
// Use the public key from the CSR and sign with CA key
|
||||
certDER, err := x509.CreateCertificate(rand.Reader, template, s.caCert, csr.PublicKey, s.caKey)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Return certificate chain
|
||||
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
|
||||
caPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: s.caCert.Raw})
|
||||
|
||||
w.Header().Set("Content-Type", "application/pem-certificate-chain")
|
||||
w.Header().Set("Replay-Nonce", "test-nonce-cert")
|
||||
w.Write(append(certPEM, caPEM...))
|
||||
}
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
package provider_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/yusing/godoxy/internal/autocert"
|
||||
)
|
||||
|
||||
func TestExtraCertKeyPathsUnique(t *testing.T) {
|
||||
t.Run("duplicate cert_path rejected", func(t *testing.T) {
|
||||
cfg := &autocert.Config{
|
||||
Provider: autocert.ProviderLocal,
|
||||
Extra: []autocert.Config{
|
||||
{CertPath: "a.crt", KeyPath: "a.key"},
|
||||
{CertPath: "a.crt", KeyPath: "b.key"},
|
||||
},
|
||||
}
|
||||
require.Error(t, cfg.Validate())
|
||||
})
|
||||
|
||||
t.Run("duplicate key_path rejected", func(t *testing.T) {
|
||||
cfg := &autocert.Config{
|
||||
Provider: autocert.ProviderLocal,
|
||||
Extra: []autocert.Config{
|
||||
{CertPath: "a.crt", KeyPath: "a.key"},
|
||||
{CertPath: "b.crt", KeyPath: "a.key"},
|
||||
},
|
||||
}
|
||||
require.Error(t, cfg.Validate())
|
||||
})
|
||||
}
|
||||
90
internal/autocert/provider_test/multi_cert_test.go
Normal file
90
internal/autocert/provider_test/multi_cert_test.go
Normal file
@@ -0,0 +1,90 @@
|
||||
//nolint:errchkjson,errcheck
|
||||
package provider_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/yusing/godoxy/internal/autocert"
|
||||
"github.com/yusing/godoxy/internal/serialization"
|
||||
"github.com/yusing/goutils/task"
|
||||
)
|
||||
|
||||
func buildMultiCertYAML(serverURL string) []byte {
|
||||
return fmt.Appendf(nil, `
|
||||
email: main@example.com
|
||||
domains: [main.example.com]
|
||||
provider: custom
|
||||
ca_dir_url: %s/acme/acme/directory
|
||||
cert_path: certs/main.crt
|
||||
key_path: certs/main.key
|
||||
extra:
|
||||
- email: extra1@example.com
|
||||
domains: [extra1.example.com]
|
||||
cert_path: certs/extra1.crt
|
||||
key_path: certs/extra1.key
|
||||
- email: extra2@example.com
|
||||
domains: [extra2.example.com]
|
||||
cert_path: certs/extra2.crt
|
||||
key_path: certs/extra2.key
|
||||
`, serverURL)
|
||||
}
|
||||
|
||||
func TestMultipleCertificatesLifecycle(t *testing.T) {
|
||||
acmeServer := newTestACMEServer(t)
|
||||
defer acmeServer.Close()
|
||||
|
||||
yamlConfig := buildMultiCertYAML(acmeServer.URL())
|
||||
var cfg autocert.Config
|
||||
cfg.HTTPClient = acmeServer.httpClient()
|
||||
|
||||
/* unmarshal yaml config with multiple certs */
|
||||
err := error(serialization.UnmarshalValidateYAML(yamlConfig, &cfg))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{"main.example.com"}, cfg.Domains)
|
||||
require.Len(t, cfg.Extra, 2)
|
||||
require.Equal(t, []string{"extra1.example.com"}, cfg.Extra[0].Domains)
|
||||
require.Equal(t, []string{"extra2.example.com"}, cfg.Extra[1].Domains)
|
||||
|
||||
var provider *autocert.Provider
|
||||
|
||||
/* initialize autocert with multi-cert config */
|
||||
user, legoCfg, gerr := cfg.GetLegoConfig()
|
||||
require.NoError(t, gerr)
|
||||
provider, err = autocert.NewProvider(&cfg, user, legoCfg)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, provider)
|
||||
|
||||
// Start renewal scheduler
|
||||
root := task.RootTask("test", false)
|
||||
defer root.Finish(nil)
|
||||
provider.ScheduleRenewalAll(root)
|
||||
|
||||
require.Equal(t, "custom", cfg.Provider)
|
||||
require.Equal(t, "custom", cfg.Extra[0].Provider)
|
||||
require.Equal(t, "custom", cfg.Extra[1].Provider)
|
||||
|
||||
/* track cert requests for all configs */
|
||||
os.MkdirAll("certs", 0755)
|
||||
defer os.RemoveAll("certs")
|
||||
|
||||
err = provider.ObtainCertIfNotExistsAll()
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, 1, acmeServer.certRequestCount["main.example.com"])
|
||||
require.Equal(t, 1, acmeServer.certRequestCount["extra1.example.com"])
|
||||
require.Equal(t, 1, acmeServer.certRequestCount["extra2.example.com"])
|
||||
|
||||
/* track renewal scheduling and requests */
|
||||
|
||||
// force renewal for all providers and wait for completion
|
||||
ok := provider.ForceExpiryAll()
|
||||
require.True(t, ok)
|
||||
provider.WaitRenewalDone(t.Context())
|
||||
|
||||
require.Equal(t, 1, acmeServer.renewalRequestCount["main.example.com"])
|
||||
require.Equal(t, 1, acmeServer.renewalRequestCount["extra1.example.com"])
|
||||
require.Equal(t, 1, acmeServer.renewalRequestCount["extra2.example.com"])
|
||||
}
|
||||
@@ -71,15 +71,18 @@ func TestGetCertBySNI(t *testing.T) {
|
||||
Provider: autocert.ProviderLocal,
|
||||
CertPath: mainCert,
|
||||
KeyPath: mainKey,
|
||||
Extra: []autocert.Config{
|
||||
Extra: []autocert.ConfigExtra{
|
||||
{CertPath: extraCert, KeyPath: extraKey},
|
||||
},
|
||||
}
|
||||
|
||||
require.NoError(t, cfg.Validate())
|
||||
|
||||
p := autocert.NewProvider(cfg, nil, nil)
|
||||
require.NoError(t, p.Setup())
|
||||
p, err := autocert.NewProvider(cfg, nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = p.LoadCert()
|
||||
require.NoError(t, err)
|
||||
|
||||
cert, err := p.GetCert(&tls.ClientHelloInfo{ServerName: "a.internal.example.com"})
|
||||
require.NoError(t, err)
|
||||
@@ -100,15 +103,18 @@ func TestGetCertBySNI(t *testing.T) {
|
||||
Provider: autocert.ProviderLocal,
|
||||
CertPath: mainCert,
|
||||
KeyPath: mainKey,
|
||||
Extra: []autocert.Config{
|
||||
Extra: []autocert.ConfigExtra{
|
||||
{CertPath: extraCert, KeyPath: extraKey},
|
||||
},
|
||||
}
|
||||
|
||||
require.NoError(t, cfg.Validate())
|
||||
|
||||
p := autocert.NewProvider(cfg, nil, nil)
|
||||
require.NoError(t, p.Setup())
|
||||
p, err := autocert.NewProvider(cfg, nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = p.LoadCert()
|
||||
require.NoError(t, err)
|
||||
|
||||
cert, err := p.GetCert(&tls.ClientHelloInfo{ServerName: "foo.example.com"})
|
||||
require.NoError(t, err)
|
||||
@@ -129,15 +135,18 @@ func TestGetCertBySNI(t *testing.T) {
|
||||
Provider: autocert.ProviderLocal,
|
||||
CertPath: mainCert,
|
||||
KeyPath: mainKey,
|
||||
Extra: []autocert.Config{
|
||||
Extra: []autocert.ConfigExtra{
|
||||
{CertPath: extraCert, KeyPath: extraKey},
|
||||
},
|
||||
}
|
||||
|
||||
require.NoError(t, cfg.Validate())
|
||||
|
||||
p := autocert.NewProvider(cfg, nil, nil)
|
||||
require.NoError(t, p.Setup())
|
||||
p, err := autocert.NewProvider(cfg, nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = p.LoadCert()
|
||||
require.NoError(t, err)
|
||||
|
||||
cert, err := p.GetCert(&tls.ClientHelloInfo{ServerName: "unknown.domain.com"})
|
||||
require.NoError(t, err)
|
||||
@@ -159,8 +168,11 @@ func TestGetCertBySNI(t *testing.T) {
|
||||
|
||||
require.NoError(t, cfg.Validate())
|
||||
|
||||
p := autocert.NewProvider(cfg, nil, nil)
|
||||
require.NoError(t, p.Setup())
|
||||
p, err := autocert.NewProvider(cfg, nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = p.LoadCert()
|
||||
require.NoError(t, err)
|
||||
|
||||
cert, err := p.GetCert(nil)
|
||||
require.NoError(t, err)
|
||||
@@ -182,8 +194,11 @@ func TestGetCertBySNI(t *testing.T) {
|
||||
|
||||
require.NoError(t, cfg.Validate())
|
||||
|
||||
p := autocert.NewProvider(cfg, nil, nil)
|
||||
require.NoError(t, p.Setup())
|
||||
p, err := autocert.NewProvider(cfg, nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = p.LoadCert()
|
||||
require.NoError(t, err)
|
||||
|
||||
cert, err := p.GetCert(&tls.ClientHelloInfo{ServerName: ""})
|
||||
require.NoError(t, err)
|
||||
@@ -204,15 +219,18 @@ func TestGetCertBySNI(t *testing.T) {
|
||||
Provider: autocert.ProviderLocal,
|
||||
CertPath: mainCert,
|
||||
KeyPath: mainKey,
|
||||
Extra: []autocert.Config{
|
||||
Extra: []autocert.ConfigExtra{
|
||||
{CertPath: extraCert, KeyPath: extraKey},
|
||||
},
|
||||
}
|
||||
|
||||
require.NoError(t, cfg.Validate())
|
||||
|
||||
p := autocert.NewProvider(cfg, nil, nil)
|
||||
require.NoError(t, p.Setup())
|
||||
p, err := autocert.NewProvider(cfg, nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = p.LoadCert()
|
||||
require.NoError(t, err)
|
||||
|
||||
cert, err := p.GetCert(&tls.ClientHelloInfo{ServerName: "FOO.EXAMPLE.COM"})
|
||||
require.NoError(t, err)
|
||||
@@ -233,15 +251,18 @@ func TestGetCertBySNI(t *testing.T) {
|
||||
Provider: autocert.ProviderLocal,
|
||||
CertPath: mainCert,
|
||||
KeyPath: mainKey,
|
||||
Extra: []autocert.Config{
|
||||
Extra: []autocert.ConfigExtra{
|
||||
{CertPath: extraCert, KeyPath: extraKey},
|
||||
},
|
||||
}
|
||||
|
||||
require.NoError(t, cfg.Validate())
|
||||
|
||||
p := autocert.NewProvider(cfg, nil, nil)
|
||||
require.NoError(t, p.Setup())
|
||||
p, err := autocert.NewProvider(cfg, nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = p.LoadCert()
|
||||
require.NoError(t, err)
|
||||
|
||||
cert, err := p.GetCert(&tls.ClientHelloInfo{ServerName: " foo.example.com. "})
|
||||
require.NoError(t, err)
|
||||
@@ -262,15 +283,18 @@ func TestGetCertBySNI(t *testing.T) {
|
||||
Provider: autocert.ProviderLocal,
|
||||
CertPath: mainCert,
|
||||
KeyPath: mainKey,
|
||||
Extra: []autocert.Config{
|
||||
Extra: []autocert.ConfigExtra{
|
||||
{CertPath: extraCert1, KeyPath: extraKey1},
|
||||
},
|
||||
}
|
||||
|
||||
require.NoError(t, cfg.Validate())
|
||||
|
||||
p := autocert.NewProvider(cfg, nil, nil)
|
||||
require.NoError(t, p.Setup())
|
||||
p, err := autocert.NewProvider(cfg, nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = p.LoadCert()
|
||||
require.NoError(t, err)
|
||||
|
||||
cert, err := p.GetCert(&tls.ClientHelloInfo{ServerName: "foo.a.example.com"})
|
||||
require.NoError(t, err)
|
||||
@@ -292,8 +316,11 @@ func TestGetCertBySNI(t *testing.T) {
|
||||
|
||||
require.NoError(t, cfg.Validate())
|
||||
|
||||
p := autocert.NewProvider(cfg, nil, nil)
|
||||
require.NoError(t, p.Setup())
|
||||
p, err := autocert.NewProvider(cfg, nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = p.LoadCert()
|
||||
require.NoError(t, err)
|
||||
|
||||
cert, err := p.GetCert(&tls.ClientHelloInfo{ServerName: "bar.example.com"})
|
||||
require.NoError(t, err)
|
||||
@@ -317,7 +344,7 @@ func TestGetCertBySNI(t *testing.T) {
|
||||
Provider: autocert.ProviderLocal,
|
||||
CertPath: mainCert,
|
||||
KeyPath: mainKey,
|
||||
Extra: []autocert.Config{
|
||||
Extra: []autocert.ConfigExtra{
|
||||
{CertPath: extraCert1, KeyPath: extraKey1},
|
||||
{CertPath: extraCert2, KeyPath: extraKey2},
|
||||
},
|
||||
@@ -325,8 +352,11 @@ func TestGetCertBySNI(t *testing.T) {
|
||||
|
||||
require.NoError(t, cfg.Validate())
|
||||
|
||||
p := autocert.NewProvider(cfg, nil, nil)
|
||||
require.NoError(t, p.Setup())
|
||||
p, err := autocert.NewProvider(cfg, nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = p.LoadCert()
|
||||
require.NoError(t, err)
|
||||
|
||||
cert1, err := p.GetCert(&tls.ClientHelloInfo{ServerName: "foo.test.com"})
|
||||
require.NoError(t, err)
|
||||
@@ -352,15 +382,18 @@ func TestGetCertBySNI(t *testing.T) {
|
||||
Provider: autocert.ProviderLocal,
|
||||
CertPath: mainCert,
|
||||
KeyPath: mainKey,
|
||||
Extra: []autocert.Config{
|
||||
Extra: []autocert.ConfigExtra{
|
||||
{CertPath: extraCert, KeyPath: extraKey},
|
||||
},
|
||||
}
|
||||
|
||||
require.NoError(t, cfg.Validate())
|
||||
|
||||
p := autocert.NewProvider(cfg, nil, nil)
|
||||
require.NoError(t, p.Setup())
|
||||
p, err := autocert.NewProvider(cfg, nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = p.LoadCert()
|
||||
require.NoError(t, err)
|
||||
|
||||
cert1, err := p.GetCert(&tls.ClientHelloInfo{ServerName: "foo.example.com"})
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -1,101 +1,30 @@
|
||||
package autocert
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
gperr "github.com/yusing/goutils/errs"
|
||||
strutils "github.com/yusing/goutils/strings"
|
||||
)
|
||||
|
||||
func (p *Provider) Setup() (err error) {
|
||||
if err = p.LoadCert(); err != nil {
|
||||
if !errors.Is(err, os.ErrNotExist) { // ignore if cert doesn't exist
|
||||
return err
|
||||
}
|
||||
log.Debug().Msg("obtaining cert due to error loading cert")
|
||||
if err = p.ObtainCert(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err = p.setupExtraProviders(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, expiry := range p.GetExpiries() {
|
||||
log.Info().Msg("certificate expire on " + strutils.FormatTime(expiry))
|
||||
break
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Provider) setupExtraProviders() error {
|
||||
p.extraProviders = nil
|
||||
func (p *Provider) setupExtraProviders() gperr.Error {
|
||||
p.sniMatcher = sniMatcher{}
|
||||
if len(p.cfg.Extra) == 0 {
|
||||
p.rebuildSNIMatcher()
|
||||
return nil
|
||||
}
|
||||
|
||||
for i := range p.cfg.Extra {
|
||||
merged := mergeExtraConfig(p.cfg, &p.cfg.Extra[i])
|
||||
user, legoCfg, err := merged.GetLegoConfig()
|
||||
p.extraProviders = make([]*Provider, 0, len(p.cfg.Extra))
|
||||
|
||||
errs := gperr.NewBuilder("setup extra providers error")
|
||||
for _, extra := range p.cfg.Extra {
|
||||
user, legoCfg, err := extra.AsConfig().GetLegoConfig()
|
||||
if err != nil {
|
||||
return err.Subjectf("extra[%d]", i)
|
||||
errs.Add(p.fmtError(err))
|
||||
continue
|
||||
}
|
||||
ep := NewProvider(&merged, user, legoCfg)
|
||||
if err := ep.Setup(); err != nil {
|
||||
return gperr.PrependSubject(fmt.Sprintf("extra[%d]", i), err)
|
||||
ep, err := NewProvider(extra.AsConfig(), user, legoCfg)
|
||||
if err != nil {
|
||||
errs.Add(p.fmtError(err))
|
||||
continue
|
||||
}
|
||||
p.extraProviders = append(p.extraProviders, ep)
|
||||
}
|
||||
p.rebuildSNIMatcher()
|
||||
return nil
|
||||
}
|
||||
|
||||
func mergeExtraConfig(mainCfg *Config, extraCfg *Config) Config {
|
||||
merged := *mainCfg
|
||||
merged.Extra = nil
|
||||
merged.CertPath = extraCfg.CertPath
|
||||
merged.KeyPath = extraCfg.KeyPath
|
||||
|
||||
if merged.Email == "" {
|
||||
merged.Email = mainCfg.Email
|
||||
}
|
||||
|
||||
if len(extraCfg.Domains) > 0 {
|
||||
merged.Domains = extraCfg.Domains
|
||||
}
|
||||
if extraCfg.ACMEKeyPath != "" {
|
||||
merged.ACMEKeyPath = extraCfg.ACMEKeyPath
|
||||
}
|
||||
if extraCfg.Provider != "" {
|
||||
merged.Provider = extraCfg.Provider
|
||||
}
|
||||
if len(extraCfg.Options) > 0 {
|
||||
merged.Options = extraCfg.Options
|
||||
}
|
||||
if len(extraCfg.Resolvers) > 0 {
|
||||
merged.Resolvers = extraCfg.Resolvers
|
||||
}
|
||||
if extraCfg.CADirURL != "" {
|
||||
merged.CADirURL = extraCfg.CADirURL
|
||||
}
|
||||
if len(extraCfg.CACerts) > 0 {
|
||||
merged.CACerts = extraCfg.CACerts
|
||||
}
|
||||
if extraCfg.EABKid != "" {
|
||||
merged.EABKid = extraCfg.EABKid
|
||||
}
|
||||
if extraCfg.EABHmac != "" {
|
||||
merged.EABHmac = extraCfg.EABHmac
|
||||
}
|
||||
if extraCfg.HTTPClient != nil {
|
||||
merged.HTTPClient = extraCfg.HTTPClient
|
||||
}
|
||||
return merged
|
||||
return errs.Error()
|
||||
}
|
||||
|
||||
82
internal/autocert/setup_test.go
Normal file
82
internal/autocert/setup_test.go
Normal file
@@ -0,0 +1,82 @@
|
||||
package autocert_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/yusing/godoxy/internal/autocert"
|
||||
"github.com/yusing/godoxy/internal/dnsproviders"
|
||||
"github.com/yusing/godoxy/internal/serialization"
|
||||
strutils "github.com/yusing/goutils/strings"
|
||||
)
|
||||
|
||||
func TestSetupExtraProviders(t *testing.T) {
|
||||
dnsproviders.InitProviders()
|
||||
|
||||
cfgYAML := `
|
||||
email: test@example.com
|
||||
domains: [example.com]
|
||||
provider: custom
|
||||
ca_dir_url: https://ca.example.com:9000/acme/acme/directory
|
||||
cert_path: certs/test.crt
|
||||
key_path: certs/test.key
|
||||
options: {key: value}
|
||||
resolvers: [8.8.8.8]
|
||||
ca_certs: [ca.crt]
|
||||
eab_kid: eabKid
|
||||
eab_hmac: eabHmac
|
||||
extra:
|
||||
- cert_path: certs/extra.crt
|
||||
key_path: certs/extra.key
|
||||
- cert_path: certs/extra2.crt
|
||||
key_path: certs/extra2.key
|
||||
email: override@example.com
|
||||
provider: pseudo
|
||||
domains: [override.com]
|
||||
ca_dir_url: https://ca2.example.com/directory
|
||||
options: {opt2: val2}
|
||||
resolvers: [1.1.1.1]
|
||||
ca_certs: [ca2.crt]
|
||||
eab_kid: eabKid2
|
||||
eab_hmac: eabHmac2
|
||||
`
|
||||
|
||||
var cfg autocert.Config
|
||||
err := error(serialization.UnmarshalValidateYAML([]byte(cfgYAML), &cfg))
|
||||
require.NoError(t, err)
|
||||
|
||||
// Test: extra[0] inherits all fields from main except CertPath and KeyPath.
|
||||
merged0 := cfg.Extra[0]
|
||||
require.Equal(t, "certs/extra.crt", merged0.CertPath)
|
||||
require.Equal(t, "certs/extra.key", merged0.KeyPath)
|
||||
// Inherited fields from main config:
|
||||
require.Equal(t, "test@example.com", merged0.Email) // inherited
|
||||
require.Equal(t, "custom", merged0.Provider) // inherited
|
||||
require.Equal(t, []string{"example.com"}, merged0.Domains) // inherited
|
||||
require.Equal(t, "https://ca.example.com:9000/acme/acme/directory", merged0.CADirURL) // inherited
|
||||
require.Equal(t, map[string]strutils.Redacted{"key": "value"}, merged0.Options) // inherited
|
||||
require.Equal(t, []string{"8.8.8.8"}, merged0.Resolvers) // inherited
|
||||
require.Equal(t, []string{"ca.crt"}, merged0.CACerts) // inherited
|
||||
require.Equal(t, "eabKid", merged0.EABKid) // inherited
|
||||
require.Equal(t, "eabHmac", merged0.EABHmac) // inherited
|
||||
require.Equal(t, cfg.HTTPClient, merged0.HTTPClient) // inherited
|
||||
require.Nil(t, merged0.Extra)
|
||||
|
||||
// Test: extra[1] overrides some fields, and inherits others.
|
||||
merged1 := cfg.Extra[1]
|
||||
require.Equal(t, "certs/extra2.crt", merged1.CertPath)
|
||||
require.Equal(t, "certs/extra2.key", merged1.KeyPath)
|
||||
// Overridden fields:
|
||||
require.Equal(t, "override@example.com", merged1.Email) // overridden
|
||||
require.Equal(t, "pseudo", merged1.Provider) // overridden
|
||||
require.Equal(t, []string{"override.com"}, merged1.Domains) // overridden
|
||||
require.Equal(t, "https://ca2.example.com/directory", merged1.CADirURL) // overridden
|
||||
require.Equal(t, map[string]strutils.Redacted{"opt2": "val2"}, merged1.Options) // overridden
|
||||
require.Equal(t, []string{"1.1.1.1"}, merged1.Resolvers) // overridden
|
||||
require.Equal(t, []string{"ca2.crt"}, merged1.CACerts) // overridden
|
||||
require.Equal(t, "eabKid2", merged1.EABKid) // overridden
|
||||
require.Equal(t, "eabHmac2", merged1.EABHmac) // overridden
|
||||
// Inherited field:
|
||||
require.Equal(t, cfg.HTTPClient, merged1.HTTPClient) // inherited
|
||||
require.Nil(t, merged1.Extra)
|
||||
}
|
||||
@@ -9,6 +9,6 @@ import (
|
||||
type Provider interface {
|
||||
Setup() error
|
||||
GetCert(*tls.ClientHelloInfo) (*tls.Certificate, error)
|
||||
ScheduleRenewal(task.Parent)
|
||||
ObtainCert() error
|
||||
ScheduleRenewalAll(task.Parent)
|
||||
ObtainCertAll() error
|
||||
}
|
||||
|
||||
@@ -272,6 +272,7 @@ func (state *state) initAutoCert() error {
|
||||
autocertCfg := state.AutoCert
|
||||
if autocertCfg == nil {
|
||||
autocertCfg = new(autocert.Config)
|
||||
_ = autocertCfg.Validate()
|
||||
}
|
||||
|
||||
user, legoCfg, err := autocertCfg.GetLegoConfig()
|
||||
@@ -279,12 +280,19 @@ func (state *state) initAutoCert() error {
|
||||
return err
|
||||
}
|
||||
|
||||
state.autocertProvider = autocert.NewProvider(autocertCfg, user, legoCfg)
|
||||
if err := state.autocertProvider.Setup(); err != nil {
|
||||
return fmt.Errorf("autocert error: %w", err)
|
||||
} else {
|
||||
state.autocertProvider.ScheduleRenewal(state.task)
|
||||
p, err := autocert.NewProvider(autocertCfg, user, legoCfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := p.ObtainCertIfNotExistsAll(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
p.ScheduleRenewalAll(state.task)
|
||||
p.PrintCertExpiriesAll()
|
||||
|
||||
state.autocertProvider = p
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package dnsproviders
|
||||
|
||||
type (
|
||||
DummyConfig struct{}
|
||||
DummyConfig map[string]any
|
||||
DummyProvider struct{}
|
||||
)
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ allowlist = [
|
||||
"hostinger",
|
||||
"httpreq",
|
||||
"ionos",
|
||||
"inwx",
|
||||
"linode",
|
||||
"namecheap",
|
||||
"netcup",
|
||||
@@ -49,6 +50,7 @@ allowlist = [
|
||||
"ovh",
|
||||
"porkbun",
|
||||
"rfc2136",
|
||||
# "route53",
|
||||
"scaleway",
|
||||
"spaceship",
|
||||
"vercel",
|
||||
|
||||
@@ -6,7 +6,7 @@ replace github.com/yusing/godoxy => ../..
|
||||
|
||||
require (
|
||||
github.com/go-acme/lego/v4 v4.30.1
|
||||
github.com/yusing/godoxy v0.21.3
|
||||
github.com/yusing/godoxy v0.23.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -22,6 +22,7 @@ require (
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 // indirect
|
||||
github.com/akamai/AkamaiOPEN-edgegrid-golang/v11 v11.1.0 // indirect
|
||||
github.com/benbjohnson/clock v1.3.5 // indirect
|
||||
github.com/boombuler/barcode v1.1.0 // indirect
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic v1.14.2 // indirect
|
||||
github.com/bytedance/sonic/loader v0.4.0 // indirect
|
||||
@@ -29,6 +30,7 @@ require (
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/fatih/structs v1.1.0 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
|
||||
@@ -39,6 +41,7 @@ require (
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||
github.com/go-resty/resty/v2 v2.17.1 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
github.com/goccy/go-yaml v1.19.1 // indirect
|
||||
github.com/gofrs/flock v0.13.0 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
|
||||
@@ -47,10 +50,11 @@ require (
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.7 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.16.0 // indirect
|
||||
github.com/gotify/server/v2 v2.7.3 // indirect
|
||||
github.com/gotify/server/v2 v2.8.0 // indirect
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
|
||||
github.com/hashicorp/go-retryablehttp v0.7.8 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/kolo/xmlrpc v0.0.0-20220921171641-a4b6fa1dd06b // indirect
|
||||
github.com/kylelemons/godebug v1.1.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/linode/linodego v1.63.0 // indirect
|
||||
@@ -60,12 +64,14 @@ require (
|
||||
github.com/miekg/dns v1.1.69 // indirect
|
||||
github.com/mitchellh/go-homedir v1.1.0 // indirect
|
||||
github.com/nrdcg/goacmedns v0.2.0 // indirect
|
||||
github.com/nrdcg/goinwx v0.12.0 // indirect
|
||||
github.com/nrdcg/oci-go-sdk/common/v1065 v1065.105.2 // indirect
|
||||
github.com/nrdcg/oci-go-sdk/dns/v1065 v1065.105.2 // indirect
|
||||
github.com/nrdcg/porkbun v0.4.0 // indirect
|
||||
github.com/ovh/go-ovh v1.9.0 // indirect
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/pquerna/otp v1.5.0 // indirect
|
||||
github.com/puzpuzpuz/xsync/v4 v4.2.0 // indirect
|
||||
github.com/rs/zerolog v1.34.0 // indirect
|
||||
github.com/scaleway/scaleway-sdk-go v1.0.0-beta.36 // indirect
|
||||
|
||||
@@ -34,6 +34,9 @@ github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3d
|
||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
|
||||
github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o=
|
||||
github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
github.com/boombuler/barcode v1.1.0 h1:ChaYjBR63fr4LFyGn8E8nt7dBSt3MiU3zMOZqFvVkHo=
|
||||
github.com/boombuler/barcode v1.1.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/sonic v1.14.2 h1:k1twIoe97C1DtYUo+fZQy865IuHia4PR5RPiuGPPIIE=
|
||||
@@ -53,6 +56,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
||||
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
||||
github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
|
||||
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||
@@ -78,6 +83,8 @@ github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy0
|
||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||
github.com/go-resty/resty/v2 v2.17.1 h1:x3aMpHK1YM9e4va/TMDRlusDDoZiQ+ViDu/WpA6xTM4=
|
||||
github.com/go-resty/resty/v2 v2.17.1/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2mLtQrOyQlVA=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/goccy/go-yaml v1.19.1 h1:3rG3+v8pkhRqoQ/88NYNMHYVGYztCOCIZ7UQhu7H+NE=
|
||||
github.com/goccy/go-yaml v1.19.1/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
@@ -100,8 +107,8 @@ github.com/googleapis/enterprise-certificate-proxy v0.3.7 h1:zrn2Ee/nWmHulBx5sAV
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.7/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
|
||||
github.com/googleapis/gax-go/v2 v2.16.0 h1:iHbQmKLLZrexmb0OSsNGTeSTS0HO4YvFOG8g5E4Zd0Y=
|
||||
github.com/googleapis/gax-go/v2 v2.16.0/go.mod h1:o1vfQjjNZn4+dPnRdl/4ZD7S9414Y4xA+a/6Icj6l14=
|
||||
github.com/gotify/server/v2 v2.7.3 h1:nro/ZnxdlZFvxFcw9LREGA8zdk6CK744azwhuhX/A4g=
|
||||
github.com/gotify/server/v2 v2.7.3/go.mod h1:VAtE1RIc/2j886PYs9WPQbMjqbFsoyQ0G8IdFtnAxU0=
|
||||
github.com/gotify/server/v2 v2.8.0 h1:E3UDDn/3rFZi1sjZfbuhXNnxJP3ACZhdcw/iySegPRA=
|
||||
github.com/gotify/server/v2 v2.8.0/go.mod h1:6ci5adxcE2hf1v+2oowKiQmixOxXV8vU+CRLKP6sqZA=
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
|
||||
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
|
||||
@@ -114,6 +121,8 @@ github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRt
|
||||
github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/kolo/xmlrpc v0.0.0-20220921171641-a4b6fa1dd06b h1:udzkj9S/zlT5X367kqJis0QP7YMxobob6zhzq6Yre00=
|
||||
github.com/kolo/xmlrpc v0.0.0-20220921171641-a4b6fa1dd06b/go.mod h1:pcaDhQK0/NJZEvtCO0qQPPropqV0sJOJ6YW7X+9kRwM=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
@@ -139,6 +148,8 @@ github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG
|
||||
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
|
||||
github.com/nrdcg/goacmedns v0.2.0 h1:ADMbThobzEMnr6kg2ohs4KGa3LFqmgiBA22/6jUWJR0=
|
||||
github.com/nrdcg/goacmedns v0.2.0/go.mod h1:T5o6+xvSLrQpugmwHvrSNkzWht0UGAwj2ACBMhh73Cg=
|
||||
github.com/nrdcg/goinwx v0.12.0 h1:ujdUqDBnaRSFwzVnImvPHYw3w3m9XgmGImNUw1GyMb4=
|
||||
github.com/nrdcg/goinwx v0.12.0/go.mod h1:IrVKd3ZDbFiMjdPgML4CSxZAY9wOoqLvH44zv3NodJ0=
|
||||
github.com/nrdcg/oci-go-sdk/common/v1065 v1065.105.2 h1:l0tH15ACQADZAzC+LZ+mo2tIX4H6uZu0ulrVmG5Tqz0=
|
||||
github.com/nrdcg/oci-go-sdk/common/v1065 v1065.105.2/go.mod h1:Gcs8GCaZXL3FdiDWgdnMxlOLEdRprJJnPYB22TX1jw8=
|
||||
github.com/nrdcg/oci-go-sdk/dns/v1065 v1065.105.2 h1:gzB4c6ztb38C/jYiqEaFC+mCGcWFHDji9e6jwymY9d4=
|
||||
@@ -153,6 +164,8 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
|
||||
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
|
||||
github.com/puzpuzpuz/xsync/v4 v4.2.0 h1:dlxm77dZj2c3rxq0/XNvvUKISAmovoXF4a4qM6Wvkr0=
|
||||
github.com/puzpuzpuz/xsync/v4 v4.2.0/go.mod h1:VJDmTCJMBt8igNxnkQd86r+8KUeN1quSfNKu5bLYFQo=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
@@ -226,10 +239,12 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
|
||||
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"github.com/go-acme/lego/v4/providers/dns/hetzner"
|
||||
"github.com/go-acme/lego/v4/providers/dns/hostinger"
|
||||
"github.com/go-acme/lego/v4/providers/dns/httpreq"
|
||||
"github.com/go-acme/lego/v4/providers/dns/inwx"
|
||||
"github.com/go-acme/lego/v4/providers/dns/ionos"
|
||||
"github.com/go-acme/lego/v4/providers/dns/linode"
|
||||
"github.com/go-acme/lego/v4/providers/dns/namecheap"
|
||||
@@ -57,6 +58,7 @@ func InitProviders() {
|
||||
autocert.Providers["hostinger"] = autocert.DNSProvider(hostinger.NewDefaultConfig, hostinger.NewDNSProviderConfig)
|
||||
autocert.Providers["httpreq"] = autocert.DNSProvider(httpreq.NewDefaultConfig, httpreq.NewDNSProviderConfig)
|
||||
autocert.Providers["ionos"] = autocert.DNSProvider(ionos.NewDefaultConfig, ionos.NewDNSProviderConfig)
|
||||
autocert.Providers["inwx"] = autocert.DNSProvider(inwx.NewDefaultConfig, inwx.NewDNSProviderConfig)
|
||||
autocert.Providers["linode"] = autocert.DNSProvider(linode.NewDefaultConfig, linode.NewDNSProviderConfig)
|
||||
autocert.Providers["namecheap"] = autocert.DNSProvider(namecheap.NewDefaultConfig, namecheap.NewDNSProviderConfig)
|
||||
autocert.Providers["netcup"] = autocert.DNSProvider(netcup.NewDefaultConfig, netcup.NewDNSProviderConfig)
|
||||
|
||||
Submodule internal/gopsutil updated: 2dec30129b...9532b08add
355
internal/idlewatcher/README.md
Normal file
355
internal/idlewatcher/README.md
Normal file
@@ -0,0 +1,355 @@
|
||||
# Idlewatcher
|
||||
|
||||
Idlewatcher manages container lifecycle based on idle timeout. When a container is idle for a configured duration, it can be automatically stopped, paused, or killed. When a request comes in, the container is woken up automatically.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph Request Flow
|
||||
HTTP[HTTP Request] -->|Intercept| W[Watcher]
|
||||
Stream[Stream Request] -->|Intercept| W
|
||||
end
|
||||
|
||||
subgraph Wake Process
|
||||
W -->|Wake| Wake[Wake Container]
|
||||
Wake -->|Check Status| State[Container State]
|
||||
Wake -->|Wait Ready| Health[Health Check]
|
||||
Wake -->|Events| SSE[SSE Events]
|
||||
end
|
||||
|
||||
subgraph Idle Management
|
||||
Timer[Idle Timer] -->|Timeout| Stop[Stop Container]
|
||||
State -->|Running| Timer
|
||||
State -->|Stopped| Timer
|
||||
end
|
||||
|
||||
subgraph Providers
|
||||
Docker[DockerProvider] --> DockerAPI[Docker API]
|
||||
Proxmox[ProxmoxProvider] --> ProxmoxAPI[Proxmox API]
|
||||
end
|
||||
|
||||
W -->|Uses| Providers
|
||||
```
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
idlewatcher/
|
||||
├── cmd # Command execution utilities
|
||||
├── debug.go # Debug utilities for watcher inspection
|
||||
├── errors.go # Error types and conversion
|
||||
├── events.go # Wake event types and broadcasting
|
||||
├── handle_http.go # HTTP request handling and loading page
|
||||
├── handle_http_debug.go # Debug HTTP handler (dev only)
|
||||
├── handle_stream.go # Stream connection handling
|
||||
├── health.go # Health monitoring interface
|
||||
├── loading_page.go # Loading page HTML/CSS/JS templates
|
||||
├── state.go # Container state management
|
||||
├── watcher.go # Core Watcher implementation
|
||||
├── provider/ # Container provider implementations
|
||||
│ ├── docker.go # Docker container management
|
||||
│ └── proxmox.go # Proxmox LXC management
|
||||
├── types/
|
||||
│ └── provider.go # Provider interface definition
|
||||
└── html/
|
||||
├── loading_page.html # Loading page template
|
||||
├── style.css # Loading page styles
|
||||
└── loading.js # Loading page JavaScript
|
||||
```
|
||||
|
||||
## Core Components
|
||||
|
||||
### Watcher
|
||||
|
||||
The main component that manages a single container's lifecycle:
|
||||
|
||||
```mermaid
|
||||
classDiagram
|
||||
class Watcher {
|
||||
+string Key() string
|
||||
+Wake(ctx context.Context) error
|
||||
+Start(parent task.Parent) gperr.Error
|
||||
+ServeHTTP(rw ResponseWriter, r *Request)
|
||||
+ListenAndServe(ctx context.Context, predial, onRead HookFunc)
|
||||
-idleTicker: *time.Ticker
|
||||
-healthTicker: *time.Ticker
|
||||
-state: synk.Value~*containerState~
|
||||
-provider: synk.Value~Provider~
|
||||
-dependsOn: []*dependency
|
||||
}
|
||||
|
||||
class containerState {
|
||||
+status: ContainerStatus
|
||||
+ready: bool
|
||||
+err: error
|
||||
+startedAt: time.Time
|
||||
+healthTries: int
|
||||
}
|
||||
|
||||
class dependency {
|
||||
+*Watcher
|
||||
+waitHealthy: bool
|
||||
}
|
||||
|
||||
Watcher --> containerState : manages
|
||||
Watcher --> dependency : depends on
|
||||
```
|
||||
|
||||
### Provider Interface
|
||||
|
||||
Abstraction for different container backends:
|
||||
|
||||
```mermaid
|
||||
classDiagram
|
||||
class Provider {
|
||||
<<interface>>
|
||||
+ContainerPause(ctx) error
|
||||
+ContainerUnpause(ctx) error
|
||||
+ContainerStart(ctx) error
|
||||
+ContainerStop(ctx, signal, timeout) error
|
||||
+ContainerKill(ctx, signal) error
|
||||
+ContainerStatus(ctx) (ContainerStatus, error)
|
||||
+Watch(ctx) (eventCh, errCh)
|
||||
+Close()
|
||||
}
|
||||
|
||||
class DockerProvider {
|
||||
+client: *docker.SharedClient
|
||||
+watcher: watcher.DockerWatcher
|
||||
+containerID: string
|
||||
}
|
||||
|
||||
class ProxmoxProvider {
|
||||
+*proxmox.Node
|
||||
+vmid: int
|
||||
+lxcName: string
|
||||
+running: bool
|
||||
}
|
||||
|
||||
Provider <|-- DockerProvider
|
||||
Provider <|-- ProxmoxProvider
|
||||
```
|
||||
|
||||
### Container Status
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> Napping: Container stopped/paused
|
||||
Napping --> Waking: Wake request
|
||||
Waking --> Running: Container started
|
||||
Running --> Starting: Container is running but not healthy
|
||||
Starting --> Ready: Health check passes
|
||||
Ready --> Napping: Idle timeout
|
||||
Ready --> Error check fails: Health
|
||||
Error --> Waking: Retry wake
|
||||
```
|
||||
|
||||
## Lifecycle Flow
|
||||
|
||||
### Wake Flow (HTTP)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C as Client
|
||||
participant W as Watcher
|
||||
participant P as Provider
|
||||
participant H as HealthChecker
|
||||
participant SSE as SSE Events
|
||||
|
||||
C->>W: HTTP Request
|
||||
W->>W: resetIdleTimer()
|
||||
alt Container already ready
|
||||
W->>W: return true (proceed)
|
||||
else
|
||||
alt No loading page configured
|
||||
W->>P: ContainerStart()
|
||||
W->>H: Wait for healthy
|
||||
H-->>W: Healthy
|
||||
W->>C: Continue request
|
||||
else Loading page enabled
|
||||
W->>P: ContainerStart()
|
||||
W->>SSE: Send WakeEventStarting
|
||||
W->>C: Serve loading page
|
||||
loop Health checks
|
||||
H->>H: Check health
|
||||
H-->>W: Not healthy yet
|
||||
W->>SSE: Send progress
|
||||
end
|
||||
H-->>W: Healthy
|
||||
W->>SSE: Send WakeEventReady
|
||||
C->>W: SSE connection
|
||||
W->>SSE: Events streamed
|
||||
C->>W: Poll/retry request
|
||||
W->>W: return true (proceed)
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
### Stream Wake Flow
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C as Client
|
||||
participant W as Watcher
|
||||
participant P as Provider
|
||||
participant H as HealthChecker
|
||||
|
||||
C->>W: Connect to stream
|
||||
W->>W: preDial hook
|
||||
W->>W: wakeFromStream()
|
||||
alt Container ready
|
||||
W->>W: Pass through
|
||||
else
|
||||
W->>P: ContainerStart()
|
||||
W->>W: waitStarted()
|
||||
W->>H: Wait for healthy
|
||||
H-->>W: Healthy
|
||||
W->>C: Stream connected
|
||||
end
|
||||
```
|
||||
|
||||
### Idle Timeout Flow
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Client as Client
|
||||
participant T as Idle Timer
|
||||
participant W as Watcher
|
||||
participant P as Provider
|
||||
participant D as Dependencies
|
||||
|
||||
loop Every request
|
||||
Client->>W: HTTP/Stream
|
||||
W->>W: resetIdleTimer()
|
||||
end
|
||||
|
||||
T->>W: Timeout
|
||||
W->>W: stopByMethod()
|
||||
alt stop method = pause
|
||||
W->>P: ContainerPause()
|
||||
else stop method = stop
|
||||
W->>P: ContainerStop(signal, timeout)
|
||||
else kill method = kill
|
||||
W->>P: ContainerKill(signal)
|
||||
end
|
||||
P-->>W: Result
|
||||
W->>D: Stop dependencies
|
||||
D-->>W: Done
|
||||
```
|
||||
|
||||
## Dependency Management
|
||||
|
||||
Watchers can depend on other containers being started first:
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
A[App] -->|depends on| B[Database]
|
||||
A -->|depends on| C[Redis]
|
||||
B -->|depends on| D[Cache]
|
||||
```
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant A as App Watcher
|
||||
participant B as DB Watcher
|
||||
participant P as Provider
|
||||
|
||||
A->>B: Wake()
|
||||
Note over B: SingleFlight prevents<br/>duplicate wake
|
||||
B->>P: ContainerStart()
|
||||
P-->>B: Started
|
||||
B->>B: Wait healthy
|
||||
B-->>A: Ready
|
||||
A->>P: ContainerStart()
|
||||
P-->>A: Started
|
||||
```
|
||||
|
||||
## Event System
|
||||
|
||||
Wake events are broadcast via Server-Sent Events (SSE):
|
||||
|
||||
```mermaid
|
||||
classDiagram
|
||||
class WakeEvent {
|
||||
+Type: WakeEventType
|
||||
+Message: string
|
||||
+Timestamp: time.Time
|
||||
+Error: string
|
||||
+WriteSSE(w io.Writer) error
|
||||
}
|
||||
|
||||
class WakeEventType {
|
||||
<<enumeration>>
|
||||
WakeEventStarting
|
||||
WakeEventWakingDep
|
||||
WakeEventDepReady
|
||||
WakeEventContainerWoke
|
||||
WakeEventWaitingReady
|
||||
WakeEventReady
|
||||
WakeEventError
|
||||
}
|
||||
|
||||
WakeEvent --> WakeEventType
|
||||
```
|
||||
|
||||
## State Machine
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
note right of Napping
|
||||
Container is stopped or paused
|
||||
Idle timer stopped
|
||||
end note
|
||||
|
||||
note right of Waking
|
||||
Container is starting
|
||||
Health checking active
|
||||
Events broadcasted
|
||||
end note
|
||||
|
||||
note right of Ready
|
||||
Container healthy
|
||||
Idle timer running
|
||||
end note
|
||||
|
||||
Napping --> Waking: Wake()
|
||||
Waking --> Ready: Health check passes
|
||||
Waking --> Error: Health check fails
|
||||
Error --> Waking: Retry
|
||||
Ready --> Napping: Idle timeout
|
||||
Ready --> Napping: Manual stop
|
||||
```
|
||||
|
||||
## Key Files
|
||||
|
||||
| File | Purpose |
|
||||
| --------------------- | ----------------------------------------------------- |
|
||||
| `watcher.go` | Core Watcher implementation with lifecycle management |
|
||||
| `handle_http.go` | HTTP interception and loading page serving |
|
||||
| `handle_stream.go` | Stream connection wake handling |
|
||||
| `provider/docker.go` | Docker container operations |
|
||||
| `provider/proxmox.go` | Proxmox LXC container operations |
|
||||
| `state.go` | Container state transitions |
|
||||
| `events.go` | Event broadcasting via SSE |
|
||||
| `health.go` | Health monitor interface implementation |
|
||||
|
||||
## Configuration
|
||||
|
||||
See `types.IdlewatcherConfig` for configuration options:
|
||||
|
||||
- `IdleTimeout`: Duration before container is put to sleep
|
||||
- `StopMethod`: pause, stop, or kill
|
||||
- `StopSignal`: Signal to send when stopping
|
||||
- `StopTimeout`: Timeout for stop operation
|
||||
- `WakeTimeout`: Timeout for wake operation
|
||||
- `DependsOn`: List of dependent containers
|
||||
- `StartEndpoint`: Optional endpoint restriction for wake requests
|
||||
- `NoLoadingPage`: Skip loading page, wait directly
|
||||
|
||||
## Thread Safety
|
||||
|
||||
- Uses `synk.Value` for atomic state updates
|
||||
- Uses `xsync.Map` for SSE subscriber management
|
||||
- Uses `sync.RWMutex` for watcher map access
|
||||
- Uses `singleflight.Group` to prevent duplicate wake calls
|
||||
263
internal/logging/README.md
Normal file
263
internal/logging/README.md
Normal file
@@ -0,0 +1,263 @@
|
||||
# Logging Package
|
||||
|
||||
This package provides structured logging capabilities for GoDoxy, including application logging, HTTP access logging, and in-memory log streaming.
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
internal/logging/
|
||||
├── logging.go # Main logger initialization using zerolog
|
||||
├── accesslog/ # HTTP access logging with rotation and filtering
|
||||
│ ├── access_logger.go # Core logging logic and buffering
|
||||
│ ├── multi_access_logger.go # Fan-out to multiple writers
|
||||
│ ├── config.go # Configuration types and defaults
|
||||
│ ├── formatter.go # Log format implementations
|
||||
│ ├── file_logger.go # File I/O with reference counting
|
||||
│ ├── rotate.go # Log rotation based on retention policy
|
||||
│ ├── writer.go # Buffered/unbuffered writer abstractions
|
||||
│ ├── back_scanner.go # Backward line scanning for rotation
|
||||
│ ├── filter.go # Request filtering by status/method/header
|
||||
│ ├── retention.go # Retention policy definitions
|
||||
│ ├── response_recorder.go # HTTP response recording middleware
|
||||
│ └── ... # Tests and utilities
|
||||
└── memlogger/ # In-memory circular buffer with WebSocket streaming
|
||||
└── mem_logger.go # Ring buffer with WebSocket event notifications
|
||||
```
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "Application Logger"
|
||||
L[logging.go] --> Z[zerolog.Logger]
|
||||
Z --> CW[ConsoleWriter]
|
||||
end
|
||||
|
||||
subgraph "Access Log Pipeline"
|
||||
R[HTTP Request] --> M[Middleware]
|
||||
M --> RR[ResponseRecorder]
|
||||
RR --> F[Formatter]
|
||||
F --> B[BufferedWriter]
|
||||
B --> W[Writer]
|
||||
W --> F1[File]
|
||||
W --> S[Stdout]
|
||||
end
|
||||
|
||||
subgraph "Log Rotation"
|
||||
B --> RT[Rotate Timer]
|
||||
RT --> BS[BackScanner]
|
||||
BS --> T[Truncate/Move]
|
||||
T --> F1
|
||||
end
|
||||
|
||||
subgraph "In-Memory Logger"
|
||||
WB[Write Buffer]
|
||||
WB --> RB[Circular Buffer<br/>16KB max]
|
||||
RB --> WS[WebSocket]
|
||||
WS --> C[Client]
|
||||
end
|
||||
```
|
||||
|
||||
## Components
|
||||
|
||||
### 1. Application Logger (`logging.go`)
|
||||
|
||||
Initializes a zerolog-based console logger with level-aware formatting:
|
||||
|
||||
- **Levels**: Trace → Debug → Info (determined by `common.IsTrace`/`common.IsDebug`)
|
||||
- **Time Format**: 04:05 (trace) or 01-02 15:04 (debug/info)
|
||||
- **Multi-line Handling**: Automatically indents continuation lines
|
||||
|
||||
```go
|
||||
// Auto-initialized on import
|
||||
func InitLogger(out ...io.Writer)
|
||||
|
||||
// Create logger with fixed level
|
||||
NewLoggerWithFixedLevel(level zerolog.Level, out ...io.Writer)
|
||||
```
|
||||
|
||||
### 2. Access Logging (`accesslog/`)
|
||||
|
||||
Logs HTTP requests/responses with configurable formats, filters, and destinations.
|
||||
|
||||
#### Core Interface
|
||||
|
||||
```go
|
||||
type AccessLogger interface {
|
||||
Log(req *http.Request, res *http.Response)
|
||||
LogError(req *http.Request, err error)
|
||||
LogACL(info *maxmind.IPInfo, blocked bool)
|
||||
Config() *Config
|
||||
Flush()
|
||||
Close() error
|
||||
}
|
||||
```
|
||||
|
||||
#### Log Formats
|
||||
|
||||
| Format | Description |
|
||||
| ---------- | --------------------------------- |
|
||||
| `common` | Basic Apache Common format |
|
||||
| `combined` | Common + Referer + User-Agent |
|
||||
| `json` | Structured JSON with full details |
|
||||
|
||||
#### Example Output
|
||||
|
||||
```
|
||||
common: localhost 127.0.0.1 - - [01-04 10:30:45] "GET /api HTTP/1.1" 200 1234
|
||||
combined: localhost 127.0.0.1 - - [01-04 10:30:45] "GET /api HTTP/1.1" 200 1234 "https://example.com" "Mozilla/5.0"
|
||||
json: {"time":"04/Jan/2025:10:30:45 +0000","ip":"127.0.0.1","method":"GET",...}
|
||||
```
|
||||
|
||||
#### Filters
|
||||
|
||||
Filter incoming requests before logging:
|
||||
|
||||
- **StatusCodes**: Keep/drop by HTTP status code range
|
||||
- **Method**: Keep/drop by HTTP method
|
||||
- **Headers**: Match header existence or value
|
||||
- **CIDR**: Match client IP against CIDR ranges
|
||||
|
||||
#### Multi-Destination Support
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
A[Request] --> B[MultiAccessLogger]
|
||||
B --> C[AccessLogger 1] --> F[File]
|
||||
B --> D[AccessLogger 2] --> S[Stdout]
|
||||
```
|
||||
|
||||
### 3. File Management (`file_logger.go`)
|
||||
|
||||
- **Reference Counting**: Multiple loggers can share the same file
|
||||
- **Auto-Close**: File closes when ref count reaches zero
|
||||
- **Thread-Safe**: Shared mutex per file path
|
||||
|
||||
### 4. Log Rotation (`rotate.go`)
|
||||
|
||||
Rotates logs based on retention policy:
|
||||
|
||||
| Policy | Description |
|
||||
| ---------- | ----------------------------------- |
|
||||
| `Days` | Keep logs within last N days |
|
||||
| `Last` | Keep last N log lines |
|
||||
| `KeepSize` | Keep last N bytes (simple truncate) |
|
||||
|
||||
**Algorithm** (for Days/Last):
|
||||
|
||||
1. Scan file backward line-by-line using `BackScanner`
|
||||
2. Parse timestamps to find cutoff point
|
||||
3. Move retained lines to file front
|
||||
4. Truncate excess
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[File End] --> B[BackScanner]
|
||||
B --> C{Valid timestamp?}
|
||||
C -->|No| D[Skip line]
|
||||
C -->|Yes| E{Within retention?}
|
||||
E -->|No| F[Keep line]
|
||||
E -->|Yes| G[Stop scanning]
|
||||
F --> H[Move to front]
|
||||
G --> I[Truncate rest]
|
||||
```
|
||||
|
||||
### 5. Buffering (`access_logger.go`)
|
||||
|
||||
- **Dynamic Sizing**: Adjusts buffer size based on write throughput
|
||||
- **Initial**: 4KB → **Max**: 8MB
|
||||
- **Adjustment**: Every 5 seconds based on writes-per-second
|
||||
|
||||
### 6. In-Memory Logger (`memlogger/`)
|
||||
|
||||
Circular buffer for real-time log streaming via WebSocket:
|
||||
|
||||
- **Size**: 16KB maximum, auto-truncates old entries
|
||||
- **Streaming**: WebSocket connection receives live updates
|
||||
- **Events API**: Subscribe to log events
|
||||
|
||||
```go
|
||||
// HTTP handler for WebSocket streaming
|
||||
HandlerFunc() gin.HandlerFunc
|
||||
|
||||
// Subscribe to log events
|
||||
Events() (<-chan []byte, func())
|
||||
|
||||
// Write to buffer (implements io.Writer)
|
||||
Write(p []byte) (n int, err error)
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
```yaml
|
||||
access_log:
|
||||
path: /var/log/godoxy/access.log # File path (optional)
|
||||
stdout: true # Also log to stdout (optional)
|
||||
format: combined # common | combined | json
|
||||
rotate_interval: 1h # How often to check rotation
|
||||
retention:
|
||||
days: 30 # Keep last 30 days
|
||||
# OR
|
||||
last: 10000 # Keep last 10000 lines
|
||||
# OR
|
||||
keep_size: 100MB # Keep last 100MB
|
||||
filters:
|
||||
status_codes: [400-599] # Only log errors
|
||||
method: [GET, POST]
|
||||
headers:
|
||||
- name: X-Internal
|
||||
value: "true"
|
||||
cidr:
|
||||
- 10.0.0.0/8
|
||||
fields:
|
||||
headers: drop # keep | drop | redacted
|
||||
query: keep # keep | drop | redacted
|
||||
cookies: drop # keep | drop | redacted
|
||||
```
|
||||
|
||||
## Data Flow
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C as Client
|
||||
participant M as Middleware
|
||||
participant R as ResponseRecorder
|
||||
participant F as Formatter
|
||||
participant B as BufferedWriter
|
||||
participant W as Writer
|
||||
|
||||
C->>M: HTTP Request
|
||||
M->>R: Capture request
|
||||
R-->>M: Continue
|
||||
|
||||
M->>M: Process request
|
||||
|
||||
C->>M: HTTP Response
|
||||
M->>R: Capture response
|
||||
R->>F: Format log line
|
||||
F->>B: Write formatted line
|
||||
B->>W: Flush when needed
|
||||
|
||||
par File Writer
|
||||
W->>File: Append line
|
||||
and Stdout Writer
|
||||
W->>Stdout: Print line
|
||||
end
|
||||
|
||||
Note over B,W: Periodic rotation check
|
||||
W->>File: Rotate if needed
|
||||
```
|
||||
|
||||
## Key Design Patterns
|
||||
|
||||
1. **Interface Segregation**: Small, focused interfaces (`AccessLogger`, `Writer`, `BufferedWriter`)
|
||||
|
||||
2. **Dependency Injection**: Writers injected at creation for flexibility
|
||||
|
||||
3. **Reference Counting**: Shared file handles prevent too-many-open-files
|
||||
|
||||
4. **Dynamic Buffering**: Adapts to write throughput automatically
|
||||
|
||||
5. **Backward Scanning**: Efficient rotation without loading entire file
|
||||
|
||||
6. **Zero-Allocation Formatting**: Build log lines in pre-allocated buffers
|
||||
285
internal/metrics/README.md
Normal file
285
internal/metrics/README.md
Normal file
@@ -0,0 +1,285 @@
|
||||
# Metrics Package
|
||||
|
||||
System monitoring and metrics collection for GoDoxy.
|
||||
|
||||
## Overview
|
||||
|
||||
This package provides a unified metrics collection system that polls system and route data at regular intervals, stores historical data across multiple time periods, and exposes both REST and WebSocket APIs for consumption.
|
||||
|
||||
## Architecture
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "Core Framework"
|
||||
P[Period<T> Generic]
|
||||
E[Entries<T> Ring Buffer]
|
||||
PL[Poller<T, A> Orchestrator]
|
||||
end
|
||||
|
||||
subgraph "Data Sources"
|
||||
SI[SystemInfo Poller]
|
||||
UP[Uptime Poller]
|
||||
end
|
||||
|
||||
subgraph "Utilities"
|
||||
UT[Utils]
|
||||
end
|
||||
|
||||
P --> E
|
||||
PL --> P
|
||||
PL --> SI
|
||||
PL --> UP
|
||||
UT -.-> PL
|
||||
UT -.-> SI
|
||||
UT -.-> UP
|
||||
```
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
internal/metrics/
|
||||
├── period/ # Core polling and storage framework
|
||||
│ ├── period.go # Period[T] - multi-timeframe container
|
||||
│ ├── entries.go # Entries[T] - ring buffer implementation
|
||||
│ ├── poller.go # Poller[T, A] - orchestration and lifecycle
|
||||
│ └── handler.go # HTTP handler for data access
|
||||
├── systeminfo/ # System metrics (CPU, memory, disk, network, sensors)
|
||||
├── uptime/ # Route health and uptime monitoring
|
||||
└── utils/ # Shared utilities (query parsing, pagination)
|
||||
```
|
||||
|
||||
## Core Components
|
||||
|
||||
### 1. Period[T] (`period/period.go`)
|
||||
|
||||
A generic container that manages multiple time periods for the same data type.
|
||||
|
||||
```go
|
||||
type Period[T any] struct {
|
||||
Entries map[Filter]*Entries[T] // 5m, 15m, 1h, 1d, 1mo
|
||||
mu sync.RWMutex
|
||||
}
|
||||
```
|
||||
|
||||
**Time Periods:**
|
||||
|
||||
| Filter | Duration | Entries | Interval |
|
||||
| ------ | -------- | ------- | -------- |
|
||||
| `5m` | 5 min | 100 | 3s |
|
||||
| `15m` | 15 min | 100 | 9s |
|
||||
| `1h` | 1 hour | 100 | 36s |
|
||||
| `1d` | 1 day | 100 | 14.4m |
|
||||
| `1mo` | 30 days | 100 | 7.2h |
|
||||
|
||||
### 2. Entries[T] (`period/entries.go`)
|
||||
|
||||
A fixed-size ring buffer (100 entries) with time-aware sampling.
|
||||
|
||||
```go
|
||||
type Entries[T any] struct {
|
||||
entries [100]T // Fixed-size array
|
||||
index int // Current position
|
||||
count int // Number of entries
|
||||
interval time.Duration // Sampling interval
|
||||
lastAdd time.Time // Last write timestamp
|
||||
}
|
||||
```
|
||||
|
||||
**Features:**
|
||||
|
||||
- Circular buffer for efficient memory usage
|
||||
- Rate-limited adds (respects configured interval)
|
||||
- JSON serialization/deserialization with temporal spacing
|
||||
|
||||
### 3. Poller[T, A] (`period/poller.go`)
|
||||
|
||||
The orchestrator that ties together polling, storage, and HTTP serving.
|
||||
|
||||
```go
|
||||
type Poller[T any, A any] struct {
|
||||
name string
|
||||
poll PollFunc[T] // Data collection
|
||||
aggregate AggregateFunc[T, A] // Data aggregation
|
||||
resultFilter FilterFunc[T] // Query filtering
|
||||
period *Period[T] // Data storage
|
||||
lastResult synk.Value[T] // Latest snapshot
|
||||
}
|
||||
```
|
||||
|
||||
**Poll Cycle (1 second interval):**
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant T as Task
|
||||
participant P as Poller
|
||||
participant D as Data Source
|
||||
participant S as Storage (Period)
|
||||
participant F as File
|
||||
|
||||
T->>P: Start()
|
||||
P->>F: Load historical data
|
||||
F-->>P: Period[T] state
|
||||
|
||||
loop Every 1 second
|
||||
P->>D: Poll(ctx, lastResult)
|
||||
D-->>P: New data point
|
||||
P->>S: Add to all periods
|
||||
P->>P: Update lastResult
|
||||
|
||||
alt Every 30 seconds
|
||||
P->>P: Gather & log errors
|
||||
end
|
||||
|
||||
alt Every 5 minutes
|
||||
P->>F: Persist to JSON
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
### 4. HTTP Handler (`period/handler.go`)
|
||||
|
||||
Provides REST and WebSocket endpoints for data access.
|
||||
|
||||
**Endpoints:**
|
||||
|
||||
- `GET /metrics?period=5m&aggregate=cpu_average` - Historical data
|
||||
- `WS /metrics?period=5m&interval=5s` - Streaming updates
|
||||
|
||||
**Query Parameters:**
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `period` | Filter | (none) | Time range (5m, 15m, 1h, 1d, 1mo) |
|
||||
| `aggregate` | string | (varies) | Aggregation mode |
|
||||
| `interval` | duration | 1s | WebSocket update interval |
|
||||
| `limit` | int | 0 | Max results (0 = all) |
|
||||
| `offset` | int | 0 | Pagination offset |
|
||||
| `keyword` | string | "" | Fuzzy search filter |
|
||||
|
||||
## Implementations
|
||||
|
||||
### SystemInfo Poller
|
||||
|
||||
Collects system metrics using `gopsutil`:
|
||||
|
||||
```go
|
||||
type SystemInfo struct {
|
||||
Timestamp int64
|
||||
CPUAverage *float64
|
||||
Memory mem.VirtualMemoryStat
|
||||
Disks map[string]disk.UsageStat
|
||||
DisksIO map[string]*disk.IOCountersStat
|
||||
Network net.IOCountersStat
|
||||
Sensors Sensors
|
||||
}
|
||||
```
|
||||
|
||||
**Aggregation Modes:**
|
||||
|
||||
- `cpu_average` - CPU usage percentage
|
||||
- `memory_usage` - Memory used in bytes
|
||||
- `memory_usage_percent` - Memory usage percentage
|
||||
- `disks_read_speed` - Disk read speed (bytes/s)
|
||||
- `disks_write_speed` - Disk write speed (bytes/s)
|
||||
- `disks_iops` - Disk I/O operations per second
|
||||
- `disk_usage` - Disk usage in bytes
|
||||
- `network_speed` - Upload/download speed (bytes/s)
|
||||
- `network_transfer` - Total bytes transferred
|
||||
- `sensor_temperature` - Temperature sensor readings
|
||||
|
||||
### Uptime Poller
|
||||
|
||||
Monitors route health and calculates uptime statistics:
|
||||
|
||||
```go
|
||||
type RouteAggregate struct {
|
||||
Alias string
|
||||
DisplayName string
|
||||
Uptime float32 // Percentage healthy
|
||||
Downtime float32 // Percentage unhealthy
|
||||
Idle float32 // Percentage napping/starting
|
||||
AvgLatency float32 // Average latency in ms
|
||||
CurrentStatus HealthStatus
|
||||
Statuses []Status // Historical statuses
|
||||
}
|
||||
```
|
||||
|
||||
## Data Flow
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Data Source] -->|PollFunc| B[Poller]
|
||||
B -->|Add| C[Period.Entries]
|
||||
C -->|Ring Buffer| D[(Memory)]
|
||||
D -->|Every 5min| E[(data/metrics/*.json)]
|
||||
|
||||
B -->|HTTP Request| F[ServeHTTP]
|
||||
F -->|Filter| G[Get]
|
||||
G -->|Aggregate| H[Response]
|
||||
|
||||
F -->|WebSocket| I[PeriodicWrite]
|
||||
I -->|interval| J[Push Updates]
|
||||
```
|
||||
|
||||
## Persistence
|
||||
|
||||
Data is persisted to `data/metrics/` as JSON files:
|
||||
|
||||
```json
|
||||
{
|
||||
"entries": {
|
||||
"5m": {
|
||||
"entries": [...],
|
||||
"interval": "3s"
|
||||
},
|
||||
"15m": {...},
|
||||
"1h": {...},
|
||||
"1d": {...},
|
||||
"1mo": {...}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**On Load:**
|
||||
|
||||
- Validates and fixes interval mismatches
|
||||
- Reconstructs temporal spacing for historical entries
|
||||
|
||||
## Thread Safety
|
||||
|
||||
- `Period[T]` uses `sync.RWMutex` for concurrent access
|
||||
- `Entries[T]` is append-only (safe for single writer)
|
||||
- `Poller` uses `synk.Value[T]` for atomic last result storage
|
||||
|
||||
## Creating a New Poller
|
||||
|
||||
```go
|
||||
type MyData struct {
|
||||
Value int
|
||||
}
|
||||
|
||||
type MyAggregate struct {
|
||||
Values []int
|
||||
}
|
||||
|
||||
var MyPoller = period.NewPoller(
|
||||
"my_poll_name",
|
||||
func(ctx context.Context, last *MyData) (*MyData, error) {
|
||||
// Fetch data
|
||||
return &MyData{Value: 42}, nil
|
||||
},
|
||||
func(entries []*MyData, query url.Values) (int, MyAggregate) {
|
||||
// Aggregate for API response
|
||||
return len(entries), MyAggregate{Values: [...]}
|
||||
},
|
||||
)
|
||||
|
||||
func init() {
|
||||
MyPoller.Start()
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
- Poll errors are aggregated over 30-second windows
|
||||
- Errors are logged with frequency counts
|
||||
- Individual sensor warnings (e.g., ENODATA) are suppressed gracefully
|
||||
@@ -106,7 +106,7 @@ func TestReverseProxyBypass(t *testing.T) {
|
||||
rp := reverseproxy.NewReverseProxy("test", url, fakeRoundTripper{})
|
||||
err = PatchReverseProxy(rp, map[string]OptionsRaw{
|
||||
"response": {
|
||||
"bypass": "path glob(/test/*) | path /api",
|
||||
"bypass": []string{"path glob(/test/*)", "path /api"},
|
||||
"set_headers": map[string]string{
|
||||
"Test-Header": "test-value",
|
||||
},
|
||||
|
||||
@@ -788,6 +788,15 @@ func (r *Route) Finalize() {
|
||||
}
|
||||
|
||||
r.Port.Listening, r.Port.Proxy = lp, pp
|
||||
|
||||
workingState := config.WorkingState.Load()
|
||||
if workingState == nil {
|
||||
if common.IsTest { // in tests, working state might be nil
|
||||
return
|
||||
}
|
||||
panic("bug: working state is nil")
|
||||
}
|
||||
|
||||
r.HealthCheck.ApplyDefaults(config.WorkingState.Load().Value().Defaults.HealthCheck)
|
||||
}
|
||||
|
||||
|
||||
@@ -270,7 +270,7 @@ func TestLogCommand_ConditionalLogging(t *testing.T) {
|
||||
errorContent, err := os.ReadFile(errorFile.Name())
|
||||
require.NoError(t, err)
|
||||
errorLines := strings.Split(strings.TrimSpace(string(errorContent)), "\n")
|
||||
assert.Len(t, errorLines, 2)
|
||||
require.Len(t, errorLines, 2)
|
||||
assert.Equal(t, "ERROR: GET /notfound 404", errorLines[0])
|
||||
assert.Equal(t, "ERROR: POST /error 500", errorLines[1])
|
||||
}
|
||||
@@ -368,7 +368,7 @@ func TestLogCommand_FilePermissions(t *testing.T) {
|
||||
logContent := strings.TrimSpace(string(content))
|
||||
lines := strings.Split(logContent, "\n")
|
||||
|
||||
assert.Len(t, lines, 2)
|
||||
require.Len(t, lines, 2)
|
||||
assert.Equal(t, "GET 200", lines[0])
|
||||
assert.Equal(t, "POST 200", lines[1])
|
||||
}
|
||||
|
||||
303
internal/serialization/README.md
Normal file
303
internal/serialization/README.md
Normal file
@@ -0,0 +1,303 @@
|
||||
# Serialization Package
|
||||
|
||||
A Go package for flexible, type-safe serialization/deserialization with validation support. It provides robust handling of YAML/JSON input, environment variable substitution, and field-level validation with case-insensitive matching.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```mermaid
|
||||
---
|
||||
config:
|
||||
theme: redux-dark-color
|
||||
---
|
||||
flowchart TB
|
||||
subgraph Input Processing
|
||||
YAML[YAML Bytes] --> EnvSub[Env Substitution]
|
||||
EnvSub --> YAMLParse[YAML Parse]
|
||||
YAMLParse --> Map[map<string,any>]
|
||||
end
|
||||
|
||||
subgraph Type Inspection
|
||||
Map --> TypeInfo[Type Info Cache]
|
||||
TypeInfo -.-> FieldLookup[Field Lookup]
|
||||
end
|
||||
|
||||
subgraph Conversion
|
||||
FieldLookup --> Convert[Convert Function]
|
||||
Convert --> StringConvert[String Conversion]
|
||||
Convert --> NumericConvert[Numeric Conversion]
|
||||
Convert --> MapConvert[Map/Struct Conversion]
|
||||
Convert --> SliceConvert[Slice Conversion]
|
||||
end
|
||||
|
||||
subgraph Validation
|
||||
Convert --> Validate[ValidateWithFieldTags]
|
||||
Convert --> CustomValidate[Custom Validator]
|
||||
CustomValidate --> CustomValidator[CustomValidator Interface]
|
||||
end
|
||||
|
||||
subgraph Output
|
||||
Validate --> Result[Typed Struct/Map]
|
||||
end
|
||||
```
|
||||
|
||||
## File Structure
|
||||
|
||||
| File | Purpose |
|
||||
| ----------------------- | ------------------------------------------------- |
|
||||
| `serialization.go` | Core serialization/deserialization logic |
|
||||
| `validation.go` | Field tag validation and custom validator support |
|
||||
| `time.go` | Duration unit extensions (d, w, M) |
|
||||
| `serialization_test.go` | Core functionality tests |
|
||||
| `validation_*_test.go` | Validation-specific tests |
|
||||
|
||||
## Core Types
|
||||
|
||||
```go
|
||||
type SerializedObject = map[string]any
|
||||
```
|
||||
|
||||
The `SerializedObject` is the intermediate representation used throughout deserialization.
|
||||
|
||||
### Interfaces
|
||||
|
||||
```go
|
||||
// For custom map unmarshaling logic
|
||||
type MapUnmarshaller interface {
|
||||
UnmarshalMap(m map[string]any) gperr.Error
|
||||
}
|
||||
|
||||
// For custom validation logic
|
||||
type CustomValidator interface {
|
||||
Validate() gperr.Error
|
||||
}
|
||||
```
|
||||
|
||||
## Key Features
|
||||
|
||||
### 1. Case-Insensitive Field Matching
|
||||
|
||||
Fields are matched using FNV-1a hash with case-insensitive comparison:
|
||||
|
||||
```go
|
||||
type Config struct {
|
||||
AuthToken string `json:"auth_token"`
|
||||
}
|
||||
|
||||
// Matches: "auth_token", "AUTH_TOKEN", "AuthToken", "Auth_Token"
|
||||
```
|
||||
|
||||
### 2. Field Tags
|
||||
|
||||
```go
|
||||
type Config struct {
|
||||
Name string `json:"name"` // JSON/deserialize field name
|
||||
Port int `validate:"required"` // Validation tag
|
||||
Secret string `json:"-"` // Exclude from deserialization
|
||||
Token string `aliases:"key,api_key"` // Aliases for matching
|
||||
}
|
||||
```
|
||||
|
||||
| Tag | Purpose |
|
||||
| ------------- | -------------------------------------------- |
|
||||
| `json` | Field name for serialization; `-` to exclude |
|
||||
| `deserialize` | Explicit deserialize name; `-` to exclude |
|
||||
| `validate` | go-playground/validator tags |
|
||||
| `aliases` | Comma-separated alternative field names |
|
||||
|
||||
### 3. Environment Variable Substitution
|
||||
|
||||
Supports `${VAR}` syntax with prefix-aware lookup:
|
||||
|
||||
```yaml
|
||||
autocert:
|
||||
auth_token: ${CLOUDFLARE_AUTH_TOKEN}
|
||||
```
|
||||
|
||||
Prefix resolution order: `GODOXY_VAR`, `GOPROXY_VAR`, `VAR`
|
||||
|
||||
### 4. String Conversions
|
||||
|
||||
Converts strings to various types:
|
||||
|
||||
```go
|
||||
// Duration: "1h30m", "2d" (d=day, w=week, M=month)
|
||||
ConvertString("2d", reflect.ValueOf(&duration))
|
||||
|
||||
// Numeric: "123", "0xFF"
|
||||
ConvertString("123", reflect.ValueOf(&intVal))
|
||||
|
||||
// Slice: "a,b,c" or YAML list format
|
||||
ConvertString("a,b,c", reflect.ValueOf(&slice))
|
||||
|
||||
// Map/Struct: YAML format
|
||||
ConvertString("key: value", reflect.ValueOf(&mapVal))
|
||||
```
|
||||
|
||||
### 5. Custom Convertor Pattern
|
||||
|
||||
Types can implement a `Parse` method for custom string conversion:
|
||||
|
||||
```go
|
||||
type Duration struct {
|
||||
Value int
|
||||
Unit string
|
||||
}
|
||||
|
||||
func (d *Duration) Parse(v string) error {
|
||||
// custom parsing logic
|
||||
}
|
||||
```
|
||||
|
||||
## Main Functions
|
||||
|
||||
### Deserialization
|
||||
|
||||
```go
|
||||
// YAML with validation
|
||||
func UnmarshalValidateYAML[T any](data []byte, target *T) gperr.Error
|
||||
|
||||
// YAML with interceptor
|
||||
func UnmarshalValidateYAMLIntercept[T any](
|
||||
data []byte,
|
||||
target *T,
|
||||
intercept func(m map[string]any) gperr.Error,
|
||||
) gperr.Error
|
||||
|
||||
// Direct map deserialization
|
||||
func MapUnmarshalValidate(src SerializedObject, dst any) gperr.Error
|
||||
|
||||
// To xsync.Map
|
||||
func UnmarshalValidateYAMLXSync[V any](data []byte) (*xsync.Map[string, V], gperr.Error)
|
||||
```
|
||||
|
||||
### Conversion
|
||||
|
||||
```go
|
||||
// Convert any value to target reflect.Value
|
||||
func Convert(src reflect.Value, dst reflect.Value, checkValidateTag bool) gperr.Error
|
||||
|
||||
// String to target type
|
||||
func ConvertString(src string, dst reflect.Value) (convertible bool, convErr gperr.Error)
|
||||
```
|
||||
|
||||
### Validation
|
||||
|
||||
```go
|
||||
// Validate using struct tags
|
||||
func ValidateWithFieldTags(s any) gperr.Error
|
||||
|
||||
// Register custom validator
|
||||
func MustRegisterValidation(tag string, fn validator.Func)
|
||||
|
||||
// Validate using CustomValidator interface
|
||||
func ValidateWithCustomValidator(v reflect.Value) gperr.Error
|
||||
```
|
||||
|
||||
### Default Values
|
||||
|
||||
```go
|
||||
// Register factory for default values
|
||||
func RegisterDefaultValueFactory[T any](factory func() *T)
|
||||
```
|
||||
|
||||
## Usage Example
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"github.com/yusing/godoxy/internal/serialization"
|
||||
)
|
||||
|
||||
type ServerConfig struct {
|
||||
Host string `json:"host" validate:"required,hostname_port"`
|
||||
Port int `json:"port" validate:"required,min=1,max=65535"`
|
||||
MaxConns int `json:"max_conns"`
|
||||
TLSEnabled bool `json:"tls_enabled"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
yamlData := []byte(`
|
||||
host: localhost
|
||||
port: 8080
|
||||
max_conns: 100
|
||||
tls_enabled: true
|
||||
`)
|
||||
|
||||
var config ServerConfig
|
||||
if err := serialization.UnmarshalValidateYAML(yamlData, &config); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
// config is now populated and validated
|
||||
}
|
||||
```
|
||||
|
||||
## Deserialization Flow
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C as Caller
|
||||
participant U as UnmarshalValidateYAML
|
||||
participant E as Env Substitution
|
||||
participant Y as YAML Parser
|
||||
participant M as MapUnmarshalValidate
|
||||
participant T as Type Info Cache
|
||||
participant CV as Convert
|
||||
participant V as Validator
|
||||
|
||||
C->>U: YAML bytes + target struct
|
||||
U->>E: Substitute ${ENV} vars
|
||||
E-->>U: Substituted bytes
|
||||
U->>Y: Parse YAML
|
||||
Y-->>U: map[string]any
|
||||
U->>M: Map + target
|
||||
M->>T: Get type info
|
||||
loop For each field in map
|
||||
M->>T: Lookup field by name (case-insensitive)
|
||||
T-->>M: Field reflect.Value
|
||||
M->>CV: Convert value to field type
|
||||
CV-->>M: Converted value or error
|
||||
end
|
||||
M->>V: Validate struct tags
|
||||
V-->>M: Validation errors
|
||||
M-->>U: Combined errors
|
||||
U-->>C: Result
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
Errors use `gperr` (goutils error package) with structured error subjects:
|
||||
|
||||
```go
|
||||
// Unknown field
|
||||
ErrUnknownField.Subject("field_name").With(gperr.DoYouMeanField("field_name", ["fieldName"]))
|
||||
|
||||
// Validation error
|
||||
ErrValidationError.Subject("Namespace").Withf("required")
|
||||
|
||||
// Unsupported conversion
|
||||
ErrUnsupportedConversion.Subjectf("string to int")
|
||||
```
|
||||
|
||||
## Performance Optimizations
|
||||
|
||||
1. **Type Info Caching**: Uses `xsync.Map` to cache field metadata per type
|
||||
2. **Hash-based Lookup**: FNV-1a hash for O(1) field matching
|
||||
3. **Lazy Pointer Init**: Pointers initialized only when first set
|
||||
4. **Presized Collections**: Initial capacity hints for maps/slices
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
go test ./internal/serialization/... -v
|
||||
```
|
||||
|
||||
Test categories:
|
||||
|
||||
- Basic deserialization
|
||||
- Anonymous struct handling
|
||||
- Pointer primitives
|
||||
- String conversions
|
||||
- Environment substitution
|
||||
- Custom validators
|
||||
@@ -1,7 +1,6 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -27,7 +26,7 @@ test:
|
||||
ca_file: /etc/ssl/ca.crt
|
||||
cert_file: /etc/ssl/cert.crt
|
||||
key_file: /etc/ssl/key.crt`), &cfg)
|
||||
assert.Error(t, err, os.ErrNotExist)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, &DockerProviderConfig{URL: "http://localhost:2375", TLS: &DockerTLSConfig{CAFile: "/etc/ssl/ca.crt", CertFile: "/etc/ssl/cert.crt", KeyFile: "/etc/ssl/key.crt"}}, cfg["test"])
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user