merge: main branch

This commit is contained in:
yusing 2025-04-24 15:02:31 +08:00
parent 806184e98b
commit 663a107c06
107 changed files with 3047 additions and 2034 deletions

View file

@ -27,18 +27,16 @@ endif
ifeq ($(debug), 1)
CGO_ENABLED = 0
GODOXY_DEBUG = 1
BUILD_FLAGS += -gcflags=all='-N -l'
endif
ifeq ($(pprof), 1)
BUILD_FLAGS += -gcflags=all='-N -l' -tags debug
else ifeq ($(pprof), 1)
CGO_ENABLED = 1
GORACE = log_path=logs/pprof strip_path_prefix=$(shell pwd)/ halt_on_error=1
BUILD_FLAGS = -tags pprof
BUILD_FLAGS += -tags pprof
VERSION := ${VERSION}-pprof
else
CGO_ENABLED = 0
LDFLAGS += -s -w
BUILD_FLAGS = -pgo=auto -tags production
BUILD_FLAGS += -pgo=auto -tags production
endif
BUILD_FLAGS += -ldflags='$(LDFLAGS)'
@ -52,6 +50,14 @@ export GODEBUG
export GORACE
export BUILD_FLAGS
ifeq ($(shell id -u), 0)
SETCAP_CMD = setcap
else
SETCAP_CMD = sudo setcap
endif
.PHONY: debug
test:
GODOXY_TEST=1 go test ./internal/...
@ -61,14 +67,17 @@ get:
build:
mkdir -p bin
go build ${BUILD_FLAGS} -o bin/${NAME} ${CMD_PATH}
if [ $(shell id -u) -eq 0 ]; \
then setcap CAP_NET_BIND_SERVICE=+eip bin/${NAME}; \
else sudo setcap CAP_NET_BIND_SERVICE=+eip bin/${NAME}; \
fi
# CAP_NET_BIND_SERVICE: permission for binding to :80 and :443
$(SETCAP_CMD) CAP_NET_BIND_SERVICE=+ep bin/${NAME}
run:
[ -f .env ] && godotenv -f .env go run ${BUILD_FLAGS} ${CMD_PATH}
debug:
make NAME="godoxy-test" debug=1 build
sh -c 'HTTP_ADDR=:81 HTTPS_ADDR=:8443 API_ADDR=:8899 DEBUG=1 bin/godoxy-test'
mtrace:
bin/godoxy debug-ls-mtrace > mtrace.json
@ -90,43 +99,5 @@ cloc:
link-binary:
ln -s /app/${NAME} bin/run
# To generate schema
# comment out this part from typescript-json-schema.js#L884
#
# if (indexType.flags !== ts.TypeFlags.Number && !isIndexedObject) {
# throw new Error("Not supported: IndexSignatureDeclaration with index symbol other than a number or a string");
# }
gen-schema-single:
bun --bun run typescript-json-schema --noExtraProps --required --skipLibCheck --tsNodeRegister=true -o schemas/${OUT} schemas/${IN} ${CLASS}
# minify
python3 -c "import json; f=open('schemas/${OUT}', 'r'); j=json.load(f); f.close(); f=open('schemas/${OUT}', 'w'); json.dump(j, f, separators=(',', ':'));"
gen-schema:
cd schemas && bun --bun tsc
make IN=config/config.ts \
CLASS=Config \
OUT=config.schema.json \
gen-schema-single
make IN=providers/routes.ts \
CLASS=Routes \
OUT=routes.schema.json \
gen-schema-single
make IN=middlewares/middleware_compose.ts \
CLASS=MiddlewareCompose \
OUT=middleware_compose.schema.json \
gen-schema-single
make IN=docker.ts \
CLASS=DockerRoutes \
OUT=docker_routes.schema.json \
gen-schema-single
cd ..
publish-schema:
cd schemas && bun publish && cd ..
update-schema-generator:
pnpm up -g typescript-json-schema
push-github:
git push origin $(shell git rev-parse --abbrev-ref HEAD)

View file

@ -8,7 +8,6 @@ import (
"strings"
"github.com/yusing/go-proxy/internal/net/gphttp"
"github.com/yusing/go-proxy/internal/net/types"
"github.com/yusing/go-proxy/internal/watcher/health"
"github.com/yusing/go-proxy/internal/watcher/health/monitor"
)
@ -44,11 +43,11 @@ func CheckHealth(w http.ResponseWriter, r *http.Request) {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
result, err = monitor.NewHTTPHealthChecker(types.NewURL(&url.URL{
result, err = monitor.NewHTTPHealthMonitor(&url.URL{
Scheme: scheme,
Host: host,
Path: path,
}), defaultHealthConfig).CheckHealth()
}, defaultHealthConfig).CheckHealth()
case "tcp", "udp":
host := query.Get("host")
if host == "" {
@ -63,10 +62,10 @@ func CheckHealth(w http.ResponseWriter, r *http.Request) {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
result, err = monitor.NewRawHealthChecker(types.NewURL(&url.URL{
result, err = monitor.NewRawHealthMonitor(&url.URL{
Scheme: scheme,
Host: host,
}), defaultHealthConfig).CheckHealth()
}, defaultHealthConfig).CheckHealth()
}
if err != nil {

View file

@ -13,13 +13,12 @@ import (
"github.com/yusing/go-proxy/internal/net/gphttp"
"github.com/yusing/go-proxy/internal/net/gphttp/reverseproxy"
"github.com/yusing/go-proxy/internal/net/types"
"github.com/yusing/go-proxy/internal/utils/strutils"
)
func ProxyHTTP(w http.ResponseWriter, r *http.Request) {
host := r.Header.Get(agentproxy.HeaderXProxyHost)
isHTTPS := strutils.ParseBool(r.Header.Get(agentproxy.HeaderXProxyHTTPS))
skipTLSVerify := strutils.ParseBool(r.Header.Get(agentproxy.HeaderXProxySkipTLSVerify))
isHTTPS, _ := strconv.ParseBool(r.Header.Get(agentproxy.HeaderXProxyHTTPS))
skipTLSVerify, _ := strconv.ParseBool(r.Header.Get(agentproxy.HeaderXProxySkipTLSVerify))
responseHeaderTimeout, err := strconv.Atoi(r.Header.Get(agentproxy.HeaderXProxyResponseHeaderTimeout))
if err != nil {
responseHeaderTimeout = 0

View file

@ -17,7 +17,7 @@ import (
"github.com/yusing/go-proxy/internal/metrics/systeminfo"
"github.com/yusing/go-proxy/internal/metrics/uptime"
"github.com/yusing/go-proxy/internal/net/gphttp/middleware"
"github.com/yusing/go-proxy/internal/route/routes/routequery"
"github.com/yusing/go-proxy/internal/route/routes"
"github.com/yusing/go-proxy/internal/task"
"github.com/yusing/go-proxy/pkg"
)
@ -117,7 +117,7 @@ func main() {
switch args.Command {
case common.CommandListRoutes:
cfg.StartProxyProviders()
printJSON(routequery.RoutesByAlias())
printJSON(routes.ByAlias())
return
case common.CommandListConfigs:
printJSON(cfg.Value())

27
go.mod
View file

@ -32,45 +32,47 @@ replace github.com/coreos/go-oidc/v3 => github.com/godoxy-app/go-oidc/v3 v3.14.2
require (
github.com/bytedance/sonic v1.13.2
github.com/docker/cli v28.1.1+incompatible
github.com/docker/go-connections v0.5.0
github.com/luthermonson/go-proxmox v0.2.2
github.com/stretchr/testify v1.10.0
)
replace github.com/docker/docker => github.com/godoxy-app/docker v0.0.0-20250418000134-7af8fd7b079e
require (
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/andybalholm/cascadia v1.3.3 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/buger/goterm v1.0.4 // indirect
github.com/bytedance/sonic/loader v0.2.4 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudflare/cloudflare-go v0.115.0 // indirect
github.com/cloudwego/base64x v0.1.5 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/diskfs/go-diskfs v1.5.0 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/djherbis/times v1.6.0 // indirect
github.com/docker/go-connections v0.5.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/ebitengine/purego v0.8.2 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
github.com/go-jose/go-jose/v4 v4.1.0 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/jinzhu/copier v0.3.4 // indirect
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 // indirect
github.com/magefile/mage v1.14.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/miekg/dns v1.1.65 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/sys/atomicwriter v0.1.0 // indirect
github.com/moby/term v0.5.0 // indirect
github.com/morikuni/aec v1.0.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/nrdcg/porkbun v0.4.0 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
@ -82,16 +84,14 @@ require (
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.63.0 // indirect
github.com/prometheus/procfs v0.16.1 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/rogpeppe/go-internal v1.13.1 // indirect
github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af // indirect
github.com/tklauser/go-sysconf v0.3.15 // indirect
github.com/tklauser/numcpus v0.10.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect
go.opentelemetry.io/otel v1.35.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.30.0 // indirect
go.opentelemetry.io/otel/metric v1.35.0 // indirect
go.opentelemetry.io/otel/sdk v1.35.0 // indirect
go.opentelemetry.io/otel/trace v1.35.0 // indirect
golang.org/x/arch v0.8.0 // indirect
golang.org/x/mod v0.24.0 // indirect
@ -100,5 +100,4 @@ require (
golang.org/x/tools v0.32.0 // indirect
google.golang.org/protobuf v1.36.6 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gotest.tools/v3 v3.5.1 // indirect
)

65
go.sum
View file

@ -1,5 +1,5 @@
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8=
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo=
@ -8,6 +8,8 @@ github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kk
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/buger/goterm v1.0.4 h1:Z9YvGmOih81P0FbVtEYTFF6YsSgxSUKEhf/f9bTMXbY=
github.com/buger/goterm v1.0.4/go.mod h1:HiFWV3xnkolgrBV3mY8m0X0Pumt4zg4QhbdOzQtB8tE=
github.com/bytedance/sonic v1.13.2 h1:8/H1FempDZqC4VqjptGo14QQlJx8VdZJegxs6wwfqpQ=
github.com/bytedance/sonic v1.13.2/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
@ -31,20 +33,22 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/diskfs/go-diskfs v1.5.0 h1:0SANkrab4ifiZBytk380gIesYh5Gc+3i40l7qsrYP4s=
github.com/diskfs/go-diskfs v1.5.0/go.mod h1:bRFumZeGFCO8C2KNswrQeuj2m1WCVr4Ms5IjWMczMDk=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c=
github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0=
github.com/docker/cli v28.1.1+incompatible h1:eyUemzeI45DY7eDPuwUcmDyDj1pM98oD5MdSpiItp8k=
github.com/docker/cli v28.1.1+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
github.com/docker/docker v28.1.1+incompatible h1:49M11BFLsVO1gxY9UX9p/zwkE/rswggs8AdFmXQw51I=
github.com/docker/docker v28.1.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/ebitengine/purego v0.8.2 h1:jPPGWs2sZ1UgOSgD2bClL0MJIqu58nOmIcBuXr62z1I=
github.com/ebitengine/purego v0.8.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/elliotwutingfeng/asciiset v0.0.0-20230602022725-51bbb787efab h1:h1UgjJdAAhj+uPL68n7XASS6bU+07ZX1WJvVS2eyoeY=
github.com/elliotwutingfeng/asciiset v0.0.0-20230602022725-51bbb787efab/go.mod h1:GLo/8fDswSAniFG+BFIaiSPcK610jyzgEhWYPQwuQdw=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
@ -53,7 +57,6 @@ github.com/go-acme/lego/v4 v4.23.1 h1:lZ5fGtGESA2L9FB8dNTvrQUq3/X4QOb8ExkKyY7LSV
github.com/go-acme/lego/v4 v4.23.1/go.mod h1:7UMVR7oQbIYw6V7mTgGwi4Er7B6Ww0c+c8feiBM0EgI=
github.com/go-jose/go-jose/v4 v4.1.0 h1:cYSYxd3pw5zd2FSXk2vGdn9igQU2PS8MuxrCOCl0FdY=
github.com/go-jose/go-jose/v4 v4.1.0/go.mod h1:GG/vqmYm3Von2nYiB2vGTXzdoNKE5tix5tuc6iAd+sw=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
@ -69,11 +72,15 @@ 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.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k=
github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
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=
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/godoxy-app/docker v0.0.0-20250418000134-7af8fd7b079e h1:LEbMtJ6loEubxetD+Aw8+1x0rShor5iMoy9WuFQ8hN8=
github.com/godoxy-app/docker v0.0.0-20250418000134-7af8fd7b079e/go.mod h1:3tMTnTkH7IN5smn7PX83XdmRnNj4Nw2/Pt8GgReqnKM=
github.com/godoxy-app/go-oidc/v3 v3.14.2 h1:y1sosR6N7IpMiREM8I8w68zrUhh5P0Hg+6wERmuhFAc=
github.com/godoxy-app/go-oidc/v3 v3.14.2/go.mod h1:ZRZLrEz7MmMe1kRzRsYqYmWKN2EHlPVGn71GMbrLLt4=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
@ -88,12 +95,20 @@ github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gotify/server/v2 v2.6.1 h1:Kf7v5fzBxzELzZa/jonWfwJMkqYqh1LBzBpCmt5QIAI=
github.com/gotify/server/v2 v2.6.1/go.mod h1:Dk8HLyTVDqmXM8YEg6tjROBen6mxyHZFRggJFHTwZLc=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0 h1:asbCHRVmodnJTuQ3qamDwqVOIjwqUPTYmYuemVOx+Ys=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0/go.mod h1:ggCgvZ2r7uOoQjOyu2Y1NhHmEPPzzuhWgcza5M1Ji1I=
github.com/h2non/gock v1.2.0 h1:K6ol8rfrRkUOefooBC8elXoaNGYkpp7y2qcxGG6BzUE=
github.com/h2non/gock v1.2.0/go.mod h1:tNhoxHYW2W42cYkYb1WqzdbYIieALC99kpYr7rH/BQk=
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw=
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI=
github.com/jarcoal/httpmock v1.3.0 h1:2RJ8GP0IIaWwcC9Fp2BmVi8Kog3v2Hn7VXM3fTd+nuc=
github.com/jarcoal/httpmock v1.3.0/go.mod h1:3yb8rc4BI7TCBhFY8ng0gjuLKJNquuDNiPaZjnENuYg=
github.com/jinzhu/copier v0.3.4 h1:mfU6jI9PtCeUjkjQ322dlff9ELjGDu975C2p/nrubVI=
github.com/jinzhu/copier v0.3.4/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
@ -114,6 +129,10 @@ 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-20250317134145-8bc96cf8fc35 h1:PpXWgLPs+Fqr325bN2FD2ISlRRztXibcX6e8f5FR5Dc=
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/luthermonson/go-proxmox v0.2.2 h1:BZ7VEj302wxw2i/EwTcyEiBzQib8teocB2SSkLHyySY=
github.com/luthermonson/go-proxmox v0.2.2/go.mod h1:oyFgg2WwTEIF0rP6ppjiixOHa5ebK1p8OaRiFhvICBQ=
github.com/magefile/mage v1.14.0 h1:6QDX3g6z1YvJ4olPhT1wksUcSa/V0a1B+pJb73fBjyo=
github.com/magefile/mage v1.14.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
@ -131,8 +150,8 @@ github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w
github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs=
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
@ -145,8 +164,12 @@ github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJw
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/ovh/go-ovh v1.7.0 h1:V14nF7FwDjQrZt9g7jzcvAAQ3HN6DNShRFRMC3jLoPw=
github.com/ovh/go-ovh v1.7.0/go.mod h1:cTVDnl94z4tl8pP1uZ/8jlVxntjSIf09bNcQ5TJSC7c=
github.com/pierrec/lz4/v4 v4.1.17 h1:kV4Ip+/hUBC+8T6+2EgburRtkE9ef4nbY3f4dFhGjMc=
github.com/pierrec/lz4/v4 v4.1.17/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/xattr v0.4.9 h1:5883YPCtkSd8LFbs13nXplj9g9tlrwoJRjgpgMu1/fE=
github.com/pkg/xattr v0.4.9/go.mod h1:di8WF84zAKk8jzR1UBTEWh9AUlIZZ7M/JNt8e9B6ktU=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
@ -169,8 +192,8 @@ github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
github.com/shirou/gopsutil/v4 v4.25.3 h1:SeA68lsu8gLggyMbmCn8cmp97V1TI9ld9sVzAUcKcKE=
github.com/shirou/gopsutil/v4 v4.25.3/go.mod h1:xbuxyoZj+UsgnZrENu3lQivsngRR5BdjbJwf2fv4szA=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
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/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=
@ -186,6 +209,8 @@ github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfj
github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ulikunitz/xz v0.5.11 h1:kpFauv27b6ynzBNT/Xy+1k+fK4WswhN/6PN5WhFAGw8=
github.com/ulikunitz/xz v0.5.11/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/vincent-petithory/dataurl v1.0.0 h1:cXw+kPto8NLuJtlMsI152irrVw9fRDX8AbShPRpg2CI=
github.com/vincent-petithory/dataurl v1.0.0/go.mod h1:FHafX5vmDzyP+1CQATJn7WFKc9CvnvxyvZy6I1MrG/U=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
@ -195,20 +220,16 @@ github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ=
go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ=
go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.30.0 h1:lsInsfvhVIfOI6qHVyysXMNDnjO9Npvl7tlDPJFBVd4=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.30.0/go.mod h1:KQsVNh4OjgjTG0G6EiNi1jVpnaeeKsKMRwbLN+f1+8M=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.30.0 h1:umZgi92IyxfXd/l4kaDhnKgY8rnN/cZcF1LKc6I8OQ8=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.30.0/go.mod h1:4lVs6obhSVRb1EW5FhOuBTyiQhtRtAnnva9vD3yRfq8=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.31.0 h1:K0XaT3DwHAcV4nKLzcQvwAgSyisUghWoY20I7huthMk=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.31.0/go.mod h1:B5Ki776z/MBnVha1Nzwp5arlzBbE3+1jk+pGmaP5HME=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.31.0 h1:lUsI2TYsQw2r1IASwoROaCnjdj2cvC2+Jbxvk6nHnWU=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.31.0/go.mod h1:2HpZxxQurfGxJlJDblybejHB6RX6pmExPNe517hREw4=
go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M=
go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE=
go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY=
go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg=
go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o=
go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w=
go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs=
go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc=
go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0=
@ -267,8 +288,10 @@ golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210331175145-43e1dd70ce54/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@ -335,6 +358,6 @@ gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU=
gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU=
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=

View file

@ -25,7 +25,7 @@ func (d *dockerInfo) MarshalJSON() ([]byte, error) {
},
"images": d.Images,
"n_cpu": d.NCPU,
"memory": strutils.FormatByteSizeWithUnit(d.MemTotal),
"memory": strutils.FormatByteSize(d.MemTotal),
})
}

View file

@ -2,6 +2,7 @@ package dockerapi
import (
"net/http"
"strconv"
"github.com/coder/websocket"
"github.com/docker/docker/api/types/container"
@ -9,15 +10,14 @@ import (
"github.com/yusing/go-proxy/internal/logging"
"github.com/yusing/go-proxy/internal/net/gphttp"
"github.com/yusing/go-proxy/internal/net/gphttp/gpwebsocket"
"github.com/yusing/go-proxy/internal/utils/strutils"
)
func Logs(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query()
server := r.PathValue("server")
containerID := r.PathValue("container")
stdout := strutils.ParseBool(query.Get("stdout"))
stderr := strutils.ParseBool(query.Get("stderr"))
stdout, _ := strconv.ParseBool(query.Get("stdout"))
stderr, _ := strconv.ParseBool(query.Get("stderr"))
since := query.Get("from")
until := query.Get("to")
levels := query.Get("levels") // TODO: implement levels

View file

@ -7,7 +7,7 @@ import (
"github.com/yusing/go-proxy/internal/common"
"github.com/yusing/go-proxy/internal/jsonstore"
"github.com/yusing/go-proxy/internal/logging"
route "github.com/yusing/go-proxy/internal/route/types"
"github.com/yusing/go-proxy/internal/route/routes"
"github.com/yusing/go-proxy/internal/task"
)
@ -52,11 +52,11 @@ func pruneExpiredIconCache() {
}
}
func routeKey(r route.HTTPRoute) string {
return r.ProviderName() + ":" + r.TargetName()
func routeKey(r routes.HTTPRoute) string {
return r.ProviderName() + ":" + r.Name()
}
func PruneRouteIconCache(route route.HTTPRoute) {
func PruneRouteIconCache(route routes.HTTPRoute) {
iconCache.Delete(routeKey(route))
}

View file

@ -17,7 +17,6 @@ import (
"github.com/yusing/go-proxy/internal/logging"
gphttp "github.com/yusing/go-proxy/internal/net/gphttp"
"github.com/yusing/go-proxy/internal/route/routes"
route "github.com/yusing/go-proxy/internal/route/types"
"github.com/yusing/go-proxy/internal/utils/strutils"
)
@ -83,7 +82,7 @@ func GetFavIcon(w http.ResponseWriter, req *http.Request) {
}
// try with route.Homepage.Icon
r, ok := routes.GetHTTPRoute(alias)
r, ok := routes.HTTP.Get(alias)
if !ok {
gphttp.ClientError(w, errors.New("no such route"), http.StatusNotFound)
return
@ -185,13 +184,13 @@ func fetchIcon(filetype, filename string) *fetchResult {
return fetchKnownIcon(homepage.NewWalkXCodeIconURL(filename, filetype))
}
func findIcon(r route.HTTPRoute, req *http.Request, uri string) *fetchResult {
func findIcon(r routes.HTTPRoute, req *http.Request, uri string) *fetchResult {
key := routeKey(r)
if result := loadIconCache(key); result != nil {
return result
}
result := fetchIcon("png", sanitizeName(r.TargetName()))
result := fetchIcon("png", sanitizeName(r.Name()))
cont := r.ContainerInfo()
if !result.OK() && cont != nil {
result = fetchIcon("png", sanitizeName(cont.Image.Name))
@ -206,7 +205,7 @@ func findIcon(r route.HTTPRoute, req *http.Request, uri string) *fetchResult {
return result
}
func findIconSlow(r route.HTTPRoute, req *http.Request, uri string, depth int) *fetchResult {
func findIconSlow(r routes.HTTPRoute, req *http.Request, uri string, depth int) *fetchResult {
ctx, cancel := context.WithTimeoutCause(req.Context(), 3*time.Second, errors.New("favicon request timeout"))
defer cancel()
newReq := req.WithContext(ctx)
@ -214,7 +213,7 @@ func findIconSlow(r route.HTTPRoute, req *http.Request, uri string, depth int) *
u, err := url.ParseRequestURI(strutils.SanitizeURI(uri))
if err != nil {
logging.Error().Err(err).
Str("route", r.TargetName()).
Str("route", r.Name()).
Str("path", uri).
Msg("failed to parse uri")
return &fetchResult{statusCode: http.StatusInternalServerError, errMsg: "cannot parse uri"}
@ -252,7 +251,7 @@ func findIconSlow(r route.HTTPRoute, req *http.Request, uri string, depth int) *
doc, err := goquery.NewDocumentFromReader(bytes.NewBuffer(c.data))
if err != nil {
logging.Error().Err(err).
Str("route", r.TargetName()).
Str("route", r.Name()).
Msg("failed to parse html")
return &fetchResult{statusCode: http.StatusInternalServerError, errMsg: "internal error"}
}
@ -269,7 +268,7 @@ func findIconSlow(r route.HTTPRoute, req *http.Request, uri string, depth int) *
dataURI, err := dataurl.DecodeString(href)
if err != nil {
logging.Error().Err(err).
Str("route", r.TargetName()).
Str("route", r.Name()).
Msg("failed to decode favicon")
return &fetchResult{statusCode: http.StatusInternalServerError, errMsg: "internal error"}
}

View file

@ -9,15 +9,15 @@ import (
"github.com/yusing/go-proxy/internal/net/gphttp"
"github.com/yusing/go-proxy/internal/net/gphttp/gpwebsocket"
"github.com/yusing/go-proxy/internal/net/gphttp/httpheaders"
"github.com/yusing/go-proxy/internal/route/routes/routequery"
"github.com/yusing/go-proxy/internal/route/routes"
)
func Health(w http.ResponseWriter, r *http.Request) {
if httpheaders.IsWebsocket(r.Header) {
gpwebsocket.Periodic(w, r, 1*time.Second, func(conn *websocket.Conn) error {
return wsjson.Write(r.Context(), conn, routequery.HealthMap())
return wsjson.Write(r.Context(), conn, routes.HealthMap())
})
} else {
gphttp.RespondJSON(w, r, routequery.HealthMap())
gphttp.RespondJSON(w, r, routes.HealthMap())
}
}

View file

@ -11,7 +11,7 @@ import (
config "github.com/yusing/go-proxy/internal/config/types"
"github.com/yusing/go-proxy/internal/net/gphttp"
"github.com/yusing/go-proxy/internal/net/gphttp/middleware"
"github.com/yusing/go-proxy/internal/route/routes/routequery"
"github.com/yusing/go-proxy/internal/route/routes"
route "github.com/yusing/go-proxy/internal/route/types"
"github.com/yusing/go-proxy/internal/task"
"github.com/yusing/go-proxy/internal/utils"
@ -47,7 +47,7 @@ func List(cfg config.ConfigInstance, w http.ResponseWriter, r *http.Request) {
gphttp.RespondJSON(w, r, route)
}
case ListRoutes:
gphttp.RespondJSON(w, r, routequery.RoutesByAlias(route.RouteType(r.FormValue("type"))))
gphttp.RespondJSON(w, r, routes.ByAlias(route.RouteType(r.FormValue("type"))))
case ListFiles:
listFiles(w, r)
case ListMiddlewares:
@ -57,11 +57,11 @@ func List(cfg config.ConfigInstance, w http.ResponseWriter, r *http.Request) {
case ListMatchDomains:
gphttp.RespondJSON(w, r, cfg.Value().MatchDomains)
case ListHomepageConfig:
gphttp.RespondJSON(w, r, routequery.HomepageConfig(r.FormValue("category"), r.FormValue("provider")))
gphttp.RespondJSON(w, r, routes.HomepageConfig(r.FormValue("category"), r.FormValue("provider")))
case ListRouteProviders:
gphttp.RespondJSON(w, r, cfg.RouteProviderList())
case ListHomepageCategories:
gphttp.RespondJSON(w, r, routequery.HomepageCategories())
gphttp.RespondJSON(w, r, routes.HomepageCategories())
case ListIcons:
limit, err := strconv.Atoi(r.FormValue("limit"))
if err != nil {
@ -87,9 +87,9 @@ func List(cfg config.ConfigInstance, w http.ResponseWriter, r *http.Request) {
// otherwise, return a single Route with alias which or nil if not found.
func listRoute(which string) any {
if which == "" || which == "all" {
return routequery.RoutesByAlias()
return routes.ByAlias()
}
routes := routequery.RoutesByAlias()
routes := routes.ByAlias()
route, ok := routes[which]
if !ok {
return nil

View file

@ -14,7 +14,6 @@ import (
"github.com/yusing/go-proxy/agent/pkg/certs"
config "github.com/yusing/go-proxy/internal/config/types"
"github.com/yusing/go-proxy/internal/net/gphttp"
"github.com/yusing/go-proxy/internal/utils/strutils"
)
func NewAgent(w http.ResponseWriter, r *http.Request) {
@ -56,7 +55,7 @@ func NewAgent(w http.ResponseWriter, r *http.Request) {
return
}
nightly := strutils.ParseBool(q.Get("nightly"))
nightly, _ := strconv.ParseBool(q.Get("nightly"))
var image string
if nightly {
image = agent.DockerImageNightly

View file

@ -6,12 +6,13 @@ import (
"strings"
"github.com/docker/docker/api/types/container"
"github.com/docker/go-connections/nat"
"github.com/yusing/go-proxy/agent/pkg/agent"
config "github.com/yusing/go-proxy/internal/config/types"
"github.com/yusing/go-proxy/internal/gperr"
idlewatcher "github.com/yusing/go-proxy/internal/idlewatcher/types"
"github.com/yusing/go-proxy/internal/logging"
"github.com/yusing/go-proxy/internal/utils"
U "github.com/yusing/go-proxy/internal/utils"
"github.com/yusing/go-proxy/internal/utils/strutils"
)
type (
@ -26,7 +27,8 @@ type (
Agent *agent.AgentConfig `json:"agent"`
Labels map[string]string `json:"-"`
Labels map[string]string `json:"-"`
IdlewatcherConfig *idlewatcher.Config `json:"idlewatcher_config"`
Mounts []string `json:"mounts"`
@ -35,16 +37,10 @@ type (
PublicHostname string `json:"public_hostname"`
PrivateHostname string `json:"private_hostname"`
Aliases []string `json:"aliases"`
IsExcluded bool `json:"is_excluded"`
IsExplicit bool `json:"is_explicit"`
IdleTimeout string `json:"idle_timeout,omitempty"`
WakeTimeout string `json:"wake_timeout,omitempty"`
StopMethod string `json:"stop_method,omitempty"`
StopTimeout string `json:"stop_timeout,omitempty"` // stop_method = "stop" only
StopSignal string `json:"stop_signal,omitempty"` // stop_method = "stop" | "kill" only
StartEndpoint string `json:"start_endpoint,omitempty"`
Running bool `json:"running"`
Aliases []string `json:"aliases"`
IsExcluded bool `json:"is_excluded"`
IsExplicit bool `json:"is_explicit"`
Running bool `json:"running"`
}
ContainerImage struct {
Author string `json:"author,omitempty"`
@ -55,7 +51,7 @@ type (
var DummyContainer = new(Container)
func FromDocker(c *container.Summary, dockerHost string) (res *Container) {
func FromDocker(c *container.SummaryTrimmed, dockerHost string) (res *Container) {
isExplicit := false
helper := containerHelper{c}
for lbl := range c.Labels {
@ -65,6 +61,8 @@ func FromDocker(c *container.Summary, dockerHost string) (res *Container) {
delete(c.Labels, lbl)
}
}
isExcluded, _ := strconv.ParseBool(helper.getDeleteLabel(LabelExclude))
res = &Container{
DockerHost: dockerHost,
Image: helper.parseImage(),
@ -78,16 +76,10 @@ func FromDocker(c *container.Summary, dockerHost string) (res *Container) {
PublicPortMapping: helper.getPublicPortMapping(),
PrivatePortMapping: helper.getPrivatePortMapping(),
Aliases: helper.getAliases(),
IsExcluded: strutils.ParseBool(helper.getDeleteLabel(LabelExclude)),
IsExplicit: isExplicit,
IdleTimeout: helper.getDeleteLabel(LabelIdleTimeout),
WakeTimeout: helper.getDeleteLabel(LabelWakeTimeout),
StopMethod: helper.getDeleteLabel(LabelStopMethod),
StopTimeout: helper.getDeleteLabel(LabelStopTimeout),
StopSignal: helper.getDeleteLabel(LabelStopSignal),
StartEndpoint: helper.getDeleteLabel(LabelStartEndpoint),
Running: c.Status == "running" || c.State == "running",
Aliases: helper.getAliases(),
IsExcluded: isExcluded,
IsExplicit: isExplicit,
Running: c.Status == "running" || c.State == "running",
}
if agent.IsDockerHostAgent(dockerHost) {
@ -100,44 +92,10 @@ func FromDocker(c *container.Summary, dockerHost string) (res *Container) {
res.setPrivateHostname(helper)
res.setPublicHostname()
res.loadDeleteIdlewatcherLabels(helper)
return
}
func FromInspectResponse(json container.InspectResponse, dockerHost string) *Container {
ports := make([]container.Port, 0)
for k, bindings := range json.NetworkSettings.Ports {
proto, privPortStr := nat.SplitProtoPort(string(k))
privPort, _ := strconv.ParseUint(privPortStr, 10, 16)
ports = append(ports, container.Port{
PrivatePort: uint16(privPort),
Type: proto,
})
for _, v := range bindings {
pubPort, _ := strconv.ParseUint(v.HostPort, 10, 16)
ports = append(ports, container.Port{
IP: v.HostIP,
PublicPort: uint16(pubPort),
PrivatePort: uint16(privPort),
Type: proto,
})
}
}
cont := FromDocker(&container.Summary{
ID: json.ID,
Names: []string{strings.TrimPrefix(json.Name, "/")},
Image: json.Image,
Ports: ports,
Labels: json.Config.Labels,
State: json.State.Status,
Status: json.State.Status,
Mounts: json.Mounts,
NetworkSettings: &container.NetworkSettingsSummary{
Networks: json.NetworkSettings.Networks,
},
}, dockerHost)
return cont
}
func (c *Container) IsBlacklisted() bool {
return c.Image.IsBlacklisted() || c.isDatabase()
}
@ -200,3 +158,31 @@ func (c *Container) setPrivateHostname(helper containerHelper) {
return
}
}
func (c *Container) loadDeleteIdlewatcherLabels(helper containerHelper) {
cfg := map[string]any{
"idle_timeout": helper.getDeleteLabel(LabelIdleTimeout),
"wake_timeout": helper.getDeleteLabel(LabelWakeTimeout),
"stop_method": helper.getDeleteLabel(LabelStopMethod),
"stop_timeout": helper.getDeleteLabel(LabelStopTimeout),
"stop_signal": helper.getDeleteLabel(LabelStopSignal),
"start_endpoint": helper.getDeleteLabel(LabelStartEndpoint),
}
// set only if idlewatcher is enabled
idleTimeout := cfg["idle_timeout"]
if idleTimeout != "" {
idwCfg := &idlewatcher.Config{
Docker: &idlewatcher.DockerConfig{
DockerHost: c.DockerHost,
ContainerID: c.ContainerID,
ContainerName: c.ContainerName,
},
}
err := utils.Deserialize(cfg, idwCfg)
if err != nil {
gperr.LogWarn("invalid idlewatcher config", gperr.PrependSubject(c.ContainerName, err))
} else {
c.IdlewatcherConfig = idwCfg
}
}
}

View file

@ -8,7 +8,7 @@ import (
)
type containerHelper struct {
*container.Summary
*container.SummaryTrimmed
}
// getDeleteLabel gets the value of a label and then deletes it from the container.

View file

@ -36,7 +36,7 @@ func TestContainerExplicit(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := FromDocker(&container.Summary{Names: []string{"test"}, State: "test", Labels: tt.labels}, "")
c := FromDocker(&container.SummaryTrimmed{Names: []string{"test"}, State: "test", Labels: tt.labels}, "")
ExpectEqual(t, c.IsExplicit, tt.isExplicit)
})
}

View file

@ -1,60 +0,0 @@
package idlewatcher
import (
"context"
"errors"
"github.com/docker/docker/api/types/container"
)
type (
containerMeta struct {
ContainerID, ContainerName string
}
containerState struct {
running bool
ready bool
err error
}
)
func (w *Watcher) ContainerID() string {
return w.route.ContainerInfo().ContainerID
}
func (w *Watcher) ContainerName() string {
return w.route.ContainerInfo().ContainerName
}
func (w *Watcher) containerStop(ctx context.Context) error {
return w.client.ContainerStop(ctx, w.ContainerID(), container.StopOptions{
Signal: string(w.Config().StopSignal),
Timeout: &w.Config().StopTimeout,
})
}
func (w *Watcher) containerPause(ctx context.Context) error {
return w.client.ContainerPause(ctx, w.ContainerID())
}
func (w *Watcher) containerKill(ctx context.Context) error {
return w.client.ContainerKill(ctx, w.ContainerID(), string(w.Config().StopSignal))
}
func (w *Watcher) containerUnpause(ctx context.Context) error {
return w.client.ContainerUnpause(ctx, w.ContainerID())
}
func (w *Watcher) containerStart(ctx context.Context) error {
return w.client.ContainerStart(ctx, w.ContainerID(), container.StartOptions{})
}
func (w *Watcher) containerStatus() (string, error) {
ctx, cancel := context.WithTimeoutCause(w.task.Context(), dockerReqTimeout, errors.New("docker request timeout"))
defer cancel()
json, err := w.client.ContainerInspect(ctx, w.ContainerID())
if err != nil {
return "", err
}
return json.State.Status, nil
}

View file

@ -1,39 +0,0 @@
package idlewatcher
func (w *Watcher) running() bool {
return w.state.Load().running
}
func (w *Watcher) ready() bool {
return w.state.Load().ready
}
func (w *Watcher) error() error {
return w.state.Load().err
}
func (w *Watcher) setReady() {
w.state.Store(&containerState{
running: true,
ready: true,
})
}
func (w *Watcher) setStarting() {
w.state.Store(&containerState{
running: true,
ready: false,
})
}
func (w *Watcher) setNapping() {
w.setError(nil)
}
func (w *Watcher) setError(err error) {
w.state.Store(&containerState{
running: false,
ready: false,
err: err,
})
}

View file

@ -1,110 +0,0 @@
package types
import (
"errors"
"net/url"
"strings"
"time"
"github.com/yusing/go-proxy/internal/docker"
"github.com/yusing/go-proxy/internal/gperr"
)
type (
Config struct {
IdleTimeout time.Duration `json:"idle_timeout,omitempty"`
WakeTimeout time.Duration `json:"wake_timeout,omitempty"`
StopTimeout int `json:"stop_timeout,omitempty"` // docker api takes integer seconds for timeout argument
StopMethod StopMethod `json:"stop_method,omitempty"`
StopSignal Signal `json:"stop_signal,omitempty"`
StartEndpoint string `json:"start_endpoint,omitempty"` // Optional path that must be hit to start container
}
StopMethod string
Signal string
)
const (
StopMethodPause StopMethod = "pause"
StopMethodStop StopMethod = "stop"
StopMethodKill StopMethod = "kill"
)
var validSignals = map[string]struct{}{
"": {},
"SIGINT": {}, "SIGTERM": {}, "SIGHUP": {}, "SIGQUIT": {},
"INT": {}, "TERM": {}, "HUP": {}, "QUIT": {},
}
func ValidateConfig(cont *docker.Container) (*Config, gperr.Error) {
if cont == nil || cont.IdleTimeout == "" {
return nil, nil
}
errs := gperr.NewBuilder("invalid idlewatcher config")
idleTimeout := gperr.Collect(errs, validateDurationPostitive, cont.IdleTimeout)
wakeTimeout := gperr.Collect(errs, validateDurationPostitive, cont.WakeTimeout)
stopTimeout := gperr.Collect(errs, validateDurationPostitive, cont.StopTimeout)
stopMethod := gperr.Collect(errs, validateStopMethod, cont.StopMethod)
signal := gperr.Collect(errs, validateSignal, cont.StopSignal)
startEndpoint := gperr.Collect(errs, validateStartEndpoint, cont.StartEndpoint)
if errs.HasError() {
return nil, errs.Error()
}
return &Config{
IdleTimeout: idleTimeout,
WakeTimeout: wakeTimeout,
StopTimeout: int(stopTimeout.Seconds()),
StopMethod: stopMethod,
StopSignal: signal,
StartEndpoint: startEndpoint,
}, nil
}
func validateDurationPostitive(value string) (time.Duration, error) {
d, err := time.ParseDuration(value)
if err != nil {
return 0, err
}
if d < 0 {
return 0, errors.New("duration must be positive")
}
return d, nil
}
func validateSignal(s string) (Signal, error) {
if _, ok := validSignals[s]; ok {
return Signal(s), nil
}
return "", errors.New("invalid signal " + s)
}
func validateStopMethod(s string) (StopMethod, error) {
sm := StopMethod(s)
switch sm {
case StopMethodPause, StopMethodStop, StopMethodKill:
return sm, nil
default:
return "", errors.New("invalid stop method " + s)
}
}
func validateStartEndpoint(s string) (string, error) {
if s == "" {
return "", nil
}
// checks needed as of Go 1.6 because of change https://github.com/golang/go/commit/617c93ce740c3c3cc28cdd1a0d712be183d0b328#diff-6c2d018290e298803c0c9419d8739885L195
// emulate browser and strip the '#' suffix prior to validation. see issue-#237
if i := strings.Index(s, "#"); i > -1 {
s = s[:i]
}
if len(s) == 0 {
return "", errors.New("start endpoint must not be empty if defined")
}
if _, err := url.ParseRequestURI(s); err != nil {
return "", err
}
return s, nil
}

View file

@ -1,181 +0,0 @@
package idlewatcher
import (
"time"
"github.com/yusing/go-proxy/internal/docker/idlewatcher/types"
"github.com/yusing/go-proxy/internal/gperr"
"github.com/yusing/go-proxy/internal/metrics"
"github.com/yusing/go-proxy/internal/net/gphttp/reverseproxy"
net "github.com/yusing/go-proxy/internal/net/types"
route "github.com/yusing/go-proxy/internal/route/types"
"github.com/yusing/go-proxy/internal/task"
U "github.com/yusing/go-proxy/internal/utils"
"github.com/yusing/go-proxy/internal/watcher/health"
"github.com/yusing/go-proxy/internal/watcher/health/monitor"
)
type (
Waker = types.Waker
waker struct {
_ U.NoCopy
rp *reverseproxy.ReverseProxy
stream net.Stream
hc health.HealthChecker
metric *metrics.Gauge
}
)
const (
idleWakerCheckInterval = 100 * time.Millisecond
idleWakerCheckTimeout = time.Second
)
// TODO: support stream
func newWaker(parent task.Parent, route route.Route, rp *reverseproxy.ReverseProxy, stream net.Stream) (Waker, gperr.Error) {
hcCfg := route.HealthCheckConfig()
hcCfg.Timeout = idleWakerCheckTimeout
waker := &waker{
rp: rp,
stream: stream,
}
watcher, err := registerWatcher(parent, route, waker)
if err != nil {
return nil, gperr.Errorf("register watcher: %w", err)
}
switch {
case route.IsAgent():
waker.hc = monitor.NewAgentProxiedMonitor(route.Agent(), hcCfg, monitor.AgentTargetFromURL(route.TargetURL()))
case rp != nil:
waker.hc = monitor.NewHTTPHealthChecker(route.TargetURL(), hcCfg)
case stream != nil:
waker.hc = monitor.NewRawHealthChecker(route.TargetURL(), hcCfg)
default:
panic("both nil")
}
return watcher, nil
}
// lifetime should follow route provider.
func NewHTTPWaker(parent task.Parent, route route.Route, rp *reverseproxy.ReverseProxy) (Waker, gperr.Error) {
return newWaker(parent, route, rp, nil)
}
func NewStreamWaker(parent task.Parent, route route.Route, stream net.Stream) (Waker, gperr.Error) {
return newWaker(parent, route, nil, stream)
}
// Start implements health.HealthMonitor.
func (w *Watcher) Start(parent task.Parent) gperr.Error {
w.task.OnCancel("route_cleanup", func() {
parent.Finish(w.task.FinishCause())
if w.metric != nil {
w.metric.Reset()
}
})
return nil
}
// Task implements health.HealthMonitor.
func (w *Watcher) Task() *task.Task {
return w.task
}
// Finish implements health.HealthMonitor.
func (w *Watcher) Finish(reason any) {
if w.stream != nil {
w.stream.Close()
}
}
// Name implements health.HealthMonitor.
func (w *Watcher) Name() string {
return w.String()
}
// String implements health.HealthMonitor.
func (w *Watcher) String() string {
return w.ContainerName()
}
// Uptime implements health.HealthMonitor.
func (w *Watcher) Uptime() time.Duration {
return 0
}
// Latency implements health.HealthMonitor.
func (w *Watcher) Latency() time.Duration {
return 0
}
// Status implements health.HealthMonitor.
func (w *Watcher) Status() health.Status {
state := w.state.Load()
if state.err != nil {
return health.StatusError
}
if state.ready {
return health.StatusHealthy
}
if state.running {
return health.StatusStarting
}
return health.StatusNapping
}
func (w *Watcher) checkUpdateState() (ready bool, err error) {
// already ready
if w.ready() {
return true, nil
}
if !w.running() {
return false, nil
}
if w.metric != nil {
defer w.metric.Set(float64(w.Status()))
}
// the new container info not yet updated
if w.hc.URL().Host == "" {
return false, nil
}
res, err := w.hc.CheckHealth()
if err != nil {
w.setError(err)
return false, err
}
if res.Healthy {
w.setReady()
return true, nil
}
w.setStarting()
return false, nil
}
// MarshalJSON implements health.HealthMonitor.
func (w *Watcher) MarshalJSON() ([]byte, error) {
var url *net.URL
if w.hc.URL().Port() != "0" {
url = w.hc.URL()
}
var detail string
if err := w.error(); err != nil {
detail = err.Error()
}
return (&monitor.JSONRepresentation{
Name: w.Name(),
Status: w.Status(),
Config: w.hc.Config(),
URL: url,
Detail: detail,
}).MarshalJSON()
}

View file

@ -1,279 +0,0 @@
package idlewatcher
import (
"context"
"errors"
"sync"
"time"
"github.com/rs/zerolog"
"github.com/yusing/go-proxy/internal/docker"
idlewatcher "github.com/yusing/go-proxy/internal/docker/idlewatcher/types"
"github.com/yusing/go-proxy/internal/gperr"
"github.com/yusing/go-proxy/internal/logging"
route "github.com/yusing/go-proxy/internal/route/types"
"github.com/yusing/go-proxy/internal/task"
U "github.com/yusing/go-proxy/internal/utils"
"github.com/yusing/go-proxy/internal/utils/atomic"
"github.com/yusing/go-proxy/internal/watcher"
"github.com/yusing/go-proxy/internal/watcher/events"
)
type (
Watcher struct {
_ U.NoCopy
zerolog.Logger
*waker
route route.Route
client *docker.SharedClient
state atomic.Value[*containerState]
stopByMethod StopCallback // send a docker command w.r.t. `stop_method`
ticker *time.Ticker
lastReset time.Time
task *task.Task
}
StopCallback func() error
)
var (
watcherMap = make(map[string]*Watcher)
watcherMapMu sync.RWMutex
errShouldNotReachHere = errors.New("should not reach here")
)
const dockerReqTimeout = 3 * time.Second
func registerWatcher(parent task.Parent, route route.Route, waker *waker) (*Watcher, error) {
cfg := route.IdlewatcherConfig()
cont := route.ContainerInfo()
key := cont.ContainerID
watcherMapMu.Lock()
defer watcherMapMu.Unlock()
w, ok := watcherMap[key]
if !ok {
client, err := docker.NewClient(cont.DockerHost)
if err != nil {
return nil, err
}
w = &Watcher{
Logger: logging.With().Str("name", cont.ContainerName).Logger(),
client: client,
task: parent.Subtask("idlewatcher." + cont.ContainerName),
ticker: time.NewTicker(cfg.IdleTimeout),
}
}
// FIXME: possible race condition here
w.waker = waker
w.route = route
w.ticker.Reset(cfg.IdleTimeout)
if cont.Running {
w.setStarting()
} else {
w.setNapping()
}
if !ok {
w.stopByMethod = w.getStopCallback()
watcherMap[key] = w
go func() {
cause := w.watchUntilDestroy()
watcherMapMu.Lock()
defer watcherMapMu.Unlock()
delete(watcherMap, key)
w.ticker.Stop()
w.client.Close()
w.task.Finish(cause)
}()
}
return w, nil
}
func (w *Watcher) Config() *idlewatcher.Config {
return w.route.IdlewatcherConfig()
}
func (w *Watcher) Wake() error {
return w.wakeIfStopped()
}
// WakeDebug logs a debug message related to waking the container.
func (w *Watcher) WakeDebug() *zerolog.Event {
//nolint:zerologlint
return w.Debug().Str("action", "wake")
}
func (w *Watcher) WakeTrace() *zerolog.Event {
//nolint:zerologlint
return w.Trace().Str("action", "wake")
}
func (w *Watcher) WakeError(err error) {
w.Err(err).Str("action", "wake").Msg("error")
}
func (w *Watcher) wakeIfStopped() error {
if w.running() {
return nil
}
status, err := w.containerStatus()
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(w.task.Context(), w.Config().WakeTimeout)
defer cancel()
// !Hard coded here since theres no constants from Docker API
switch status {
case "exited", "dead":
return w.containerStart(ctx)
case "paused":
return w.containerUnpause(ctx)
case "running":
return nil
default:
return gperr.Errorf("unexpected container status: %s", status)
}
}
func (w *Watcher) getStopCallback() StopCallback {
var cb func(context.Context) error
switch w.Config().StopMethod {
case idlewatcher.StopMethodPause:
cb = w.containerPause
case idlewatcher.StopMethodStop:
cb = w.containerStop
case idlewatcher.StopMethodKill:
cb = w.containerKill
default:
panic(errShouldNotReachHere)
}
return func() error {
ctx, cancel := context.WithTimeout(w.task.Context(), time.Duration(w.Config().StopTimeout)*time.Second)
defer cancel()
return cb(ctx)
}
}
func (w *Watcher) resetIdleTimer() {
w.Trace().Msg("reset idle timer")
w.ticker.Reset(w.Config().IdleTimeout)
w.lastReset = time.Now()
}
func (w *Watcher) expires() time.Time {
return w.lastReset.Add(w.Config().IdleTimeout)
}
func (w *Watcher) getEventCh(ctx context.Context, dockerWatcher *watcher.DockerWatcher) (eventCh <-chan events.Event, errCh <-chan gperr.Error) {
eventCh, errCh = dockerWatcher.EventsWithOptions(ctx, watcher.DockerListOptions{
Filters: watcher.NewDockerFilter(
watcher.DockerFilterContainer,
watcher.DockerFilterContainerNameID(w.route.ContainerInfo().ContainerID),
watcher.DockerFilterStart,
watcher.DockerFilterStop,
watcher.DockerFilterDie,
watcher.DockerFilterKill,
watcher.DockerFilterDestroy,
watcher.DockerFilterPause,
watcher.DockerFilterUnpause,
),
})
return
}
// watchUntilDestroy waits for the container to be created, started, or unpaused,
// and then reset the idle timer.
//
// When the container is stopped, paused,
// or killed, the idle timer is stopped and the ContainerRunning flag is set to false.
//
// When the idle timer fires, the container is stopped according to the
// stop method.
//
// it exits only if the context is canceled, the container is destroyed,
// errors occurred on docker client, or route provider died (mainly caused by config reload).
func (w *Watcher) watchUntilDestroy() (returnCause error) {
eventCtx, eventCancel := context.WithCancel(w.task.Context())
defer eventCancel()
dockerWatcher := watcher.NewDockerWatcher(w.client.DaemonHost())
dockerEventCh, dockerEventErrCh := w.getEventCh(eventCtx, dockerWatcher)
for {
select {
case <-w.task.Context().Done():
return w.task.FinishCause()
case err := <-dockerEventErrCh:
if !err.Is(context.Canceled) {
gperr.LogError("idlewatcher error", err, &w.Logger)
}
return err
case e := <-dockerEventCh:
switch {
case e.Action == events.ActionContainerDestroy:
w.setError(errors.New("container destroyed"))
w.Info().Str("reason", "container destroyed").Msg("watcher stopped")
return errors.New("container destroyed")
// create / start / unpause
case e.Action.IsContainerWake():
w.setStarting()
w.resetIdleTimer()
w.Info().Msg("awaken")
case e.Action.IsContainerSleep(): // stop / pause / kil
w.setNapping()
w.resetIdleTimer()
w.ticker.Stop()
default:
w.Error().Msg("unexpected docker event: " + e.String())
}
// container name changed should also change the container id
// if w.ContainerName != e.ActorName {
// w.Debug().Msgf("renamed %s -> %s", w.ContainerName, e.ActorName)
// w.ContainerName = e.ActorName
// }
// if w.ContainerID != e.ActorID {
// w.Debug().Msgf("id changed %s -> %s", w.ContainerID, e.ActorID)
// w.ContainerID = e.ActorID
// // recreate event stream
// eventCancel()
// eventCtx, eventCancel = context.WithCancel(w.task.Context())
// defer eventCancel()
// dockerEventCh, dockerEventErrCh = w.getEventCh(eventCtx, dockerWatcher)
// }
case <-w.ticker.C:
w.ticker.Stop()
if w.running() {
err := w.stopByMethod()
switch {
case errors.Is(err, context.Canceled):
continue
case err != nil:
if errors.Is(err, context.DeadlineExceeded) {
err = errors.New("timeout waiting for container to stop, please set a higher value for `stop_timeout`")
}
w.Err(err).Msgf("container stop with method %q failed", w.Config().StopMethod)
default:
w.Info().Str("reason", "idle timeout").Msg("container stopped")
}
}
}
}
}

View file

@ -1,28 +0,0 @@
package docker
import (
"context"
"errors"
"time"
)
func Inspect(dockerHost string, containerID string) (*Container, error) {
client, err := NewClient(dockerHost)
if err != nil {
return nil, err
}
defer client.Close()
return client.Inspect(containerID)
}
func (c *SharedClient) Inspect(containerID string) (*Container, error) {
ctx, cancel := context.WithTimeoutCause(context.Background(), 3*time.Second, errors.New("docker container inspect timeout"))
defer cancel()
json, err := c.ContainerInspect(ctx, containerID)
if err != nil {
return nil, err
}
return FromInspectResponse(json, c.key), nil
}

View file

@ -21,7 +21,7 @@ var listOptions = container.ListOptions{
All: true,
}
func ListContainers(clientHost string) ([]container.Summary, error) {
func ListContainers(clientHost string) ([]container.SummaryTrimmed, error) {
dockerClient, err := NewClient(clientHost)
if err != nil {
return nil, err

View file

@ -12,7 +12,6 @@ import (
"github.com/yusing/go-proxy/internal/net/gphttp/middleware"
"github.com/yusing/go-proxy/internal/net/gphttp/middleware/errorpage"
"github.com/yusing/go-proxy/internal/route/routes"
route "github.com/yusing/go-proxy/internal/route/types"
"github.com/yusing/go-proxy/internal/task"
"github.com/yusing/go-proxy/internal/utils/strutils"
)
@ -20,7 +19,7 @@ import (
type Entrypoint struct {
middleware *middleware.Middleware
accessLogger *accesslog.AccessLogger
findRouteFunc func(host string) (route.HTTPRoute, error)
findRouteFunc func(host string) (routes.HTTPRoute, error)
}
var ErrNoSuchRoute = errors.New("no such route")
@ -108,7 +107,7 @@ func (ep *Entrypoint) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
}
func findRouteAnyDomain(host string) (route.HTTPRoute, error) {
func findRouteAnyDomain(host string) (routes.HTTPRoute, error) {
hostSplit := strutils.SplitRune(host, '.')
target := hostSplit[0]
@ -118,19 +117,19 @@ func findRouteAnyDomain(host string) (route.HTTPRoute, error) {
return nil, fmt.Errorf("%w: %s", ErrNoSuchRoute, target)
}
func findRouteByDomains(domains []string) func(host string) (route.HTTPRoute, error) {
return func(host string) (route.HTTPRoute, error) {
func findRouteByDomains(domains []string) func(host string) (routes.HTTPRoute, error) {
return func(host string) (routes.HTTPRoute, error) {
for _, domain := range domains {
if strings.HasSuffix(host, domain) {
target := strings.TrimSuffix(host, domain)
if r, ok := routes.GetHTTPRoute(target); ok {
if r, ok := routes.HTTP.Get(target); ok {
return r, nil
}
}
}
// fallback to exact match
if r, ok := routes.GetHTTPRoute(host); ok {
if r, ok := routes.HTTP.Get(host); ok {
return r, nil
}
return nil, fmt.Errorf("%w: %s", ErrNoSuchRoute, host)

View file

@ -5,37 +5,43 @@ import (
"github.com/yusing/go-proxy/internal/route"
"github.com/yusing/go-proxy/internal/route/routes"
. "github.com/yusing/go-proxy/internal/utils/testing"
expect "github.com/yusing/go-proxy/internal/utils/testing"
)
var (
r route.ReveseProxyRoute
ep = NewEntrypoint()
)
var ep = NewEntrypoint()
func addRoute(alias string) {
routes.HTTP.Add(&route.ReveseProxyRoute{
Route: &route.Route{
Alias: alias,
},
})
}
func run(t *testing.T, match []string, noMatch []string) {
t.Helper()
t.Cleanup(routes.TestClear)
t.Cleanup(routes.Clear)
t.Cleanup(func() { ep.SetFindRouteDomains(nil) })
for _, test := range match {
t.Run(test, func(t *testing.T) {
found, err := ep.findRouteFunc(test)
ExpectNoError(t, err)
ExpectTrue(t, found == &r)
expect.NoError(t, err)
expect.NotNil(t, found)
})
}
for _, test := range noMatch {
t.Run(test, func(t *testing.T) {
_, err := ep.findRouteFunc(test)
ExpectError(t, ErrNoSuchRoute, err)
expect.ErrorIs(t, ErrNoSuchRoute, err)
})
}
}
func TestFindRouteAnyDomain(t *testing.T) {
routes.SetHTTPRoute("app1", &r)
addRoute("app1")
tests := []string{
"app1.com",
@ -66,7 +72,7 @@ func TestFindRouteExactHostMatch(t *testing.T) {
}
for _, test := range tests {
routes.SetHTTPRoute(test, &r)
addRoute(test)
}
run(t, tests, testsNoMatch)
@ -78,7 +84,7 @@ func TestFindRouteByDomains(t *testing.T) {
".sub.domain.com",
})
routes.SetHTTPRoute("app1", &r)
addRoute("app1")
tests := []string{
"app1.domain.com",
@ -103,7 +109,7 @@ func TestFindRouteByDomainsExactMatch(t *testing.T) {
".sub.domain.com",
})
routes.SetHTTPRoute("app1.foo.bar", &r)
addRoute("app1.foo.bar")
tests := []string{
"app1.foo.bar", // exact match

View file

@ -0,0 +1,13 @@
package idlewatcher
import "context"
func (w *Watcher) cancelled(reqCtx context.Context) bool {
select {
case <-reqCtx.Done():
w.l.Debug().AnErr("cause", context.Cause(reqCtx)).Msg("wake canceled")
return true
default:
return false
}
}

View file

@ -0,0 +1,40 @@
package idlewatcher
import (
"iter"
"strconv"
"github.com/yusing/go-proxy/internal/utils/strutils"
)
type watcherDebug struct {
*Watcher
}
func (w watcherDebug) MarshalMap() map[string]any {
state := w.state.Load()
return map[string]any{
"name": w.Name(),
"state": map[string]string{
"status": string(state.status),
"ready": strconv.FormatBool(state.ready),
"err": fmtErr(state.err),
},
"expires": strutils.FormatTime(w.expires()),
"last_reset": strutils.FormatTime(w.lastReset.Load()),
"config": w.cfg,
}
}
func Watchers() iter.Seq2[string, watcherDebug] {
return func(yield func(string, watcherDebug) bool) {
watcherMapMu.RLock()
defer watcherMapMu.RUnlock()
for k, w := range watcherMap {
if !yield(k, watcherDebug{w}) {
return
}
}
}
}

View file

@ -42,20 +42,6 @@ func (w *Watcher) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
}
}
func (w *Watcher) cancelled(reqCtx context.Context, rw http.ResponseWriter) bool {
select {
case <-reqCtx.Done():
w.WakeDebug().Str("cause", context.Cause(reqCtx).Error()).Msg("canceled")
return true
case <-w.task.Context().Done():
w.WakeDebug().Str("cause", w.task.FinishCause().Error()).Msg("canceled")
http.Error(rw, "Service unavailable", http.StatusServiceUnavailable)
return true
default:
return false
}
}
func isFaviconPath(path string) bool {
return path == "/favicon.ico"
}
@ -70,13 +56,13 @@ func (w *Watcher) wakeFromHTTP(rw http.ResponseWriter, r *http.Request) (shouldN
// handle favicon request
if isFaviconPath(r.URL.Path) {
r.URL.RawQuery = "alias=" + w.route.TargetName()
r.URL.RawQuery = "alias=" + w.rp.TargetName
favicon.GetFavIcon(rw, r)
return false
}
// Check if start endpoint is configured and request path matches
if w.Config().StartEndpoint != "" && r.URL.Path != w.Config().StartEndpoint {
if w.cfg.StartEndpoint != "" && r.URL.Path != w.cfg.StartEndpoint {
http.Error(rw, "Forbidden: Container can only be started via configured start endpoint", http.StatusForbidden)
return false
}
@ -95,44 +81,48 @@ func (w *Watcher) wakeFromHTTP(rw http.ResponseWriter, r *http.Request) (shouldN
rw.Header().Add("Cache-Control", "must-revalidate")
rw.Header().Add("Connection", "close")
if _, err := rw.Write(body); err != nil {
w.Err(err).Msg("error writing http response")
return false
}
return false
}
ctx, cancel := context.WithTimeoutCause(r.Context(), w.Config().WakeTimeout, errors.New("wake timeout"))
ctx, cancel := context.WithTimeoutCause(r.Context(), w.cfg.WakeTimeout, errors.New("wake timeout"))
defer cancel()
if w.cancelled(ctx, rw) {
if w.cancelled(ctx) {
gphttp.ServerError(rw, r, context.Cause(ctx), http.StatusServiceUnavailable)
return false
}
w.WakeTrace().Msg("signal received")
w.l.Trace().Msg("signal received")
err := w.wakeIfStopped()
if err != nil {
w.WakeError(err)
http.Error(rw, "Error waking container", http.StatusInternalServerError)
gphttp.ServerError(rw, r, err)
return false
}
var ready bool
for {
if w.cancelled(ctx, rw) {
w.resetIdleTimer()
if w.cancelled(ctx) {
gphttp.ServerError(rw, r, context.Cause(ctx), http.StatusServiceUnavailable)
return false
}
ready, err := w.checkUpdateState()
w, ready, err = checkUpdateState(w.Key())
if err != nil {
http.Error(rw, "Error waking container", http.StatusInternalServerError)
gphttp.ServerError(rw, r, err)
return false
}
if ready {
w.resetIdleTimer()
if isCheckRedirect {
w.Debug().Msgf("redirecting to %s ...", w.hc.URL())
w.l.Debug().Stringer("url", w.hc.URL()).Msg("container is ready, redirecting")
rw.WriteHeader(http.StatusOK)
return false
}
w.Debug().Msgf("passing through to %s ...", w.hc.URL())
w.l.Debug().Stringer("url", w.hc.URL()).Msg("container is ready, passing through")
return true
}

View file

@ -3,11 +3,10 @@ package idlewatcher
import (
"context"
"errors"
"fmt"
"net"
"time"
"github.com/yusing/go-proxy/internal/net/types"
gpnet "github.com/yusing/go-proxy/internal/net/types"
)
// Setup implements types.Stream.
@ -21,19 +20,19 @@ func (w *Watcher) Setup() error {
}
// Accept implements types.Stream.
func (w *Watcher) Accept() (conn types.StreamConn, err error) {
func (w *Watcher) Accept() (conn gpnet.StreamConn, err error) {
conn, err = w.stream.Accept()
if err != nil {
return
}
if wakeErr := w.wakeFromStream(); wakeErr != nil {
w.WakeError(wakeErr)
w.l.Err(wakeErr).Msg("error waking container")
}
return
}
// Handle implements types.Stream.
func (w *Watcher) Handle(conn types.StreamConn) error {
func (w *Watcher) Handle(conn gpnet.StreamConn) error {
if err := w.wakeFromStream(); err != nil {
return err
}
@ -53,35 +52,29 @@ func (w *Watcher) wakeFromStream() error {
return nil
}
w.WakeDebug().Msg("wake signal received")
wakeErr := w.wakeIfStopped()
if wakeErr != nil {
wakeErr = fmt.Errorf("%s failed: %w", w.String(), wakeErr)
w.WakeError(wakeErr)
return wakeErr
w.l.Debug().Msg("wake signal received")
err := w.wakeIfStopped()
if err != nil {
return err
}
ctx, cancel := context.WithTimeoutCause(w.task.Context(), w.Config().WakeTimeout, errors.New("wake timeout"))
ctx, cancel := context.WithTimeoutCause(w.task.Context(), w.cfg.WakeTimeout, errors.New("wake timeout"))
defer cancel()
var ready bool
for {
select {
case <-w.task.Context().Done():
cause := w.task.FinishCause()
w.WakeDebug().Str("cause", cause.Error()).Msg("canceled")
return cause
case <-ctx.Done():
cause := context.Cause(ctx)
w.WakeDebug().Str("cause", cause.Error()).Msg("timeout")
return cause
default:
if w.cancelled(ctx) {
return context.Cause(ctx)
}
if ready, err := w.checkUpdateState(); err != nil {
w, ready, err = checkUpdateState(w.Key())
if err != nil {
return err
} else if ready {
}
if ready {
w.resetIdleTimer()
w.Debug().Msg("container is ready, passing through to " + w.hc.URL().String())
w.l.Debug().Stringer("url", w.hc.URL()).Msg("container is ready, passing through")
return nil
}

View file

@ -0,0 +1,122 @@
package idlewatcher
import (
"errors"
"time"
"github.com/yusing/go-proxy/internal/gperr"
idlewatcher "github.com/yusing/go-proxy/internal/idlewatcher/types"
"github.com/yusing/go-proxy/internal/task"
"github.com/yusing/go-proxy/internal/watcher/health"
)
// Start implements health.HealthMonitor.
func (w *Watcher) Start(parent task.Parent) gperr.Error {
w.task.OnCancel("route_cleanup", func() {
parent.Finish(w.task.FinishCause())
})
return nil
}
// Task implements health.HealthMonitor.
func (w *Watcher) Task() *task.Task {
return w.task
}
// Finish implements health.HealthMonitor.
func (w *Watcher) Finish(reason any) {
if w.stream != nil {
w.stream.Close()
}
}
// Name implements health.HealthMonitor.
func (w *Watcher) Name() string {
return w.cfg.ContainerName()
}
// String implements health.HealthMonitor.
func (w *Watcher) String() string {
return w.Name()
}
// Uptime implements health.HealthMonitor.
func (w *Watcher) Uptime() time.Duration {
return 0
}
// Latency implements health.HealthMonitor.
func (w *Watcher) Latency() time.Duration {
return 0
}
// Status implements health.HealthMonitor.
func (w *Watcher) Status() health.Status {
state := w.state.Load()
if state.err != nil {
return health.StatusError
}
if state.ready {
return health.StatusHealthy
}
if state.status == idlewatcher.ContainerStatusRunning {
return health.StatusStarting
}
return health.StatusNapping
}
func checkUpdateState(key string) (w *Watcher, ready bool, err error) {
watcherMapMu.RLock()
w, ok := watcherMap[key]
if !ok {
watcherMapMu.RUnlock()
return nil, false, errors.New("watcher not found")
}
watcherMapMu.RUnlock()
// already ready
if w.ready() {
return w, true, nil
}
if !w.running() {
return w, false, nil
}
// the new container info not yet updated
if w.hc.URL().Host == "" {
return w, false, nil
}
res, err := w.hc.CheckHealth()
if err != nil {
w.setError(err)
return w, false, err
}
if res.Healthy {
w.setReady()
return w, true, nil
}
w.setStarting()
return w, false, nil
}
// MarshalMap implements health.HealthMonitor.
func (w *Watcher) MarshalMap() map[string]any {
url := w.hc.URL()
if url.Port() == "0" {
url = nil
}
var detail string
if err := w.error(); err != nil {
detail = err.Error()
}
return (&health.JSONRepresentation{
Name: w.Name(),
Status: w.Status(),
Config: dummyHealthCheckConfig,
URL: url,
Detail: detail,
}).MarshalMap()
}

View file

@ -19,11 +19,11 @@ var loadingPage []byte
var loadingPageTmpl = template.Must(template.New("loading_page").Parse(string(loadingPage)))
func (w *Watcher) makeLoadingPageBody() []byte {
msg := w.ContainerName() + " is starting..."
msg := w.cfg.ContainerName() + " is starting..."
data := new(templateData)
data.CheckRedirectHeader = httpheaders.HeaderGoDoxyCheckRedirect
data.Title = w.route.HomepageItem().Name
data.Title = w.cfg.ContainerName()
data.Message = msg
buf := bytes.NewBuffer(make([]byte, len(loadingPage)+len(data.Title)+len(data.Message)+len(httpheaders.HeaderGoDoxyCheckRedirect)))

View file

@ -0,0 +1,90 @@
package provider
import (
"context"
"github.com/docker/docker/api/types/container"
"github.com/yusing/go-proxy/internal/docker"
"github.com/yusing/go-proxy/internal/gperr"
idlewatcher "github.com/yusing/go-proxy/internal/idlewatcher/types"
"github.com/yusing/go-proxy/internal/watcher"
)
type DockerProvider struct {
client *docker.SharedClient
watcher *watcher.DockerWatcher
containerID string
}
var startOptions = container.StartOptions{}
func NewDockerProvider(dockerHost, containerID string) (idlewatcher.Provider, error) {
client, err := docker.NewClient(dockerHost)
if err != nil {
return nil, err
}
return &DockerProvider{
client: client,
watcher: watcher.NewDockerWatcher(dockerHost),
containerID: containerID,
}, nil
}
func (p *DockerProvider) ContainerPause(ctx context.Context) error {
return p.client.ContainerPause(ctx, p.containerID)
}
func (p *DockerProvider) ContainerUnpause(ctx context.Context) error {
return p.client.ContainerUnpause(ctx, p.containerID)
}
func (p *DockerProvider) ContainerStart(ctx context.Context) error {
return p.client.ContainerStart(ctx, p.containerID, startOptions)
}
func (p *DockerProvider) ContainerStop(ctx context.Context, signal idlewatcher.Signal, timeout int) error {
return p.client.ContainerStop(ctx, p.containerID, container.StopOptions{
Signal: string(signal),
Timeout: &timeout,
})
}
func (p *DockerProvider) ContainerKill(ctx context.Context, signal idlewatcher.Signal) error {
return p.client.ContainerKill(ctx, p.containerID, string(signal))
}
func (p *DockerProvider) ContainerStatus(ctx context.Context) (idlewatcher.ContainerStatus, error) {
status, err := p.client.ContainerInspect(ctx, p.containerID)
if err != nil {
return idlewatcher.ContainerStatusError, err
}
switch status.State.Status {
case "running":
return idlewatcher.ContainerStatusRunning, nil
case "exited", "dead", "restarting":
return idlewatcher.ContainerStatusStopped, nil
case "paused":
return idlewatcher.ContainerStatusPaused, nil
}
return idlewatcher.ContainerStatusError, idlewatcher.ErrUnexpectedContainerStatus.Subject(status.State.Status)
}
func (p *DockerProvider) Watch(ctx context.Context) (eventCh <-chan watcher.Event, errCh <-chan gperr.Error) {
return p.watcher.EventsWithOptions(ctx, watcher.DockerListOptions{
Filters: watcher.NewDockerFilter(
watcher.DockerFilterContainer,
watcher.DockerFilterContainerNameID(p.containerID),
watcher.DockerFilterStart,
watcher.DockerFilterStop,
watcher.DockerFilterDie,
watcher.DockerFilterKill,
watcher.DockerFilterDestroy,
watcher.DockerFilterPause,
watcher.DockerFilterUnpause,
),
})
}
func (p *DockerProvider) Close() {
p.client.Close()
}

View file

@ -0,0 +1,129 @@
package provider
import (
"context"
"strconv"
"time"
"github.com/yusing/go-proxy/internal/gperr"
idlewatcher "github.com/yusing/go-proxy/internal/idlewatcher/types"
"github.com/yusing/go-proxy/internal/proxmox"
"github.com/yusing/go-proxy/internal/watcher"
"github.com/yusing/go-proxy/internal/watcher/events"
)
type ProxmoxProvider struct {
*proxmox.Node
vmid int
lxcName string
running bool
}
const proxmoxStateCheckInterval = 1 * time.Second
var ErrNodeNotFound = gperr.New("node not found in pool")
func NewProxmoxProvider(nodeName string, vmid int) (idlewatcher.Provider, error) {
node, ok := proxmox.Nodes.Get(nodeName)
if !ok {
return nil, ErrNodeNotFound.Subject(nodeName).
Withf("available nodes: %s", proxmox.AvailableNodeNames())
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
lxcName, err := node.LXCName(ctx, vmid)
if err != nil {
return nil, err
}
return &ProxmoxProvider{Node: node, vmid: vmid, lxcName: lxcName}, nil
}
func (p *ProxmoxProvider) ContainerPause(ctx context.Context) error {
return p.LXCAction(ctx, p.vmid, proxmox.LXCSuspend)
}
func (p *ProxmoxProvider) ContainerUnpause(ctx context.Context) error {
return p.LXCAction(ctx, p.vmid, proxmox.LXCResume)
}
func (p *ProxmoxProvider) ContainerStart(ctx context.Context) error {
return p.LXCAction(ctx, p.vmid, proxmox.LXCStart)
}
func (p *ProxmoxProvider) ContainerStop(ctx context.Context, _ idlewatcher.Signal, _ int) error {
return p.LXCAction(ctx, p.vmid, proxmox.LXCShutdown)
}
func (p *ProxmoxProvider) ContainerKill(ctx context.Context, _ idlewatcher.Signal) error {
return p.LXCAction(ctx, p.vmid, proxmox.LXCShutdown)
}
func (p *ProxmoxProvider) ContainerStatus(ctx context.Context) (idlewatcher.ContainerStatus, error) {
status, err := p.LXCStatus(ctx, p.vmid)
if err != nil {
return idlewatcher.ContainerStatusError, err
}
switch status {
case proxmox.LXCStatusRunning:
return idlewatcher.ContainerStatusRunning, nil
case proxmox.LXCStatusStopped:
return idlewatcher.ContainerStatusStopped, nil
}
return idlewatcher.ContainerStatusError, idlewatcher.ErrUnexpectedContainerStatus.Subject(string(status))
}
func (p *ProxmoxProvider) Watch(ctx context.Context) (<-chan watcher.Event, <-chan gperr.Error) {
eventCh := make(chan watcher.Event)
errCh := make(chan gperr.Error)
go func() {
defer close(eventCh)
defer close(errCh)
var err error
p.running, err = p.LXCIsRunning(ctx, p.vmid)
if err != nil {
errCh <- gperr.Wrap(err)
return
}
ticker := time.NewTicker(proxmoxStateCheckInterval)
defer ticker.Stop()
event := watcher.Event{
Type: events.EventTypeDocker,
ActorID: strconv.Itoa(p.vmid),
ActorName: p.lxcName,
}
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
status, err := p.ContainerStatus(ctx)
if err != nil {
errCh <- gperr.Wrap(err)
return
}
running := status == idlewatcher.ContainerStatusRunning
if p.running != running {
p.running = running
if running {
event.Action = events.ActionContainerStart
} else {
event.Action = events.ActionContainerStop
}
eventCh <- event
}
}
}
}()
return eventCh, errCh
}
func (p *ProxmoxProvider) Close() {
// noop
}

View file

@ -0,0 +1,44 @@
package idlewatcher
import idlewatcher "github.com/yusing/go-proxy/internal/idlewatcher/types"
func (w *Watcher) running() bool {
return w.state.Load().status == idlewatcher.ContainerStatusRunning
}
func (w *Watcher) ready() bool {
return w.state.Load().ready
}
func (w *Watcher) error() error {
return w.state.Load().err
}
func (w *Watcher) setReady() {
w.state.Store(&containerState{
status: idlewatcher.ContainerStatusRunning,
ready: true,
})
}
func (w *Watcher) setStarting() {
w.state.Store(&containerState{
status: idlewatcher.ContainerStatusRunning,
ready: false,
})
}
func (w *Watcher) setNapping(status idlewatcher.ContainerStatus) {
w.state.Store(&containerState{
status: status,
ready: false,
})
}
func (w *Watcher) setError(err error) {
w.state.Store(&containerState{
status: idlewatcher.ContainerStatusError,
ready: false,
err: err,
})
}

View file

@ -0,0 +1,128 @@
package idlewatcher
import (
"net/url"
"strconv"
"strings"
"time"
"github.com/yusing/go-proxy/internal/gperr"
)
type (
Config struct {
Proxmox *ProxmoxConfig `json:"proxmox,omitempty"`
Docker *DockerConfig `json:"docker,omitempty"`
IdleTimeout time.Duration `json:"idle_timeout" json_ext:"duration"`
WakeTimeout time.Duration `json:"wake_timeout" json_ext:"duration"`
StopTimeout time.Duration `json:"stop_timeout" json_ext:"duration"`
StopMethod StopMethod `json:"stop_method"`
StopSignal Signal `json:"stop_signal,omitempty"`
StartEndpoint string `json:"start_endpoint,omitempty"` // Optional path that must be hit to start container
}
StopMethod string
Signal string
DockerConfig struct {
DockerHost string `json:"docker_host" validate:"required"`
ContainerID string `json:"container_id" validate:"required"`
ContainerName string `json:"container_name" validate:"required"`
}
ProxmoxConfig struct {
Node string `json:"node" validate:"required"`
VMID int `json:"vmid" validate:"required"`
}
)
const (
WakeTimeoutDefault = 30 * time.Second
StopTimeoutDefault = 1 * time.Minute
StopMethodPause StopMethod = "pause"
StopMethodStop StopMethod = "stop"
StopMethodKill StopMethod = "kill"
)
func (c *Config) Key() string {
if c.Docker != nil {
return c.Docker.ContainerID
}
return c.Proxmox.Node + ":" + strconv.Itoa(c.Proxmox.VMID)
}
func (c *Config) ContainerName() string {
if c.Docker != nil {
return c.Docker.ContainerName
}
return "lxc " + strconv.Itoa(c.Proxmox.VMID)
}
func (c *Config) Validate() gperr.Error {
if c.IdleTimeout == 0 { // no idle timeout means no idle watcher
return nil
}
errs := gperr.NewBuilder("idlewatcher config validation error")
errs.AddRange(
c.validateProvider(),
c.validateTimeouts(),
c.validateStopMethod(),
c.validateStopSignal(),
c.validateStartEndpoint(),
)
return errs.Error()
}
func (c *Config) validateProvider() error {
if c.Docker == nil && c.Proxmox == nil {
return gperr.New("missing idlewatcher provider config")
}
return nil
}
func (c *Config) validateTimeouts() error {
if c.WakeTimeout == 0 {
c.WakeTimeout = WakeTimeoutDefault
}
if c.StopTimeout == 0 {
c.StopTimeout = StopTimeoutDefault
}
return nil
}
func (c *Config) validateStopMethod() error {
switch c.StopMethod {
case "":
c.StopMethod = StopMethodStop
return nil
case StopMethodPause, StopMethodStop, StopMethodKill:
return nil
default:
return gperr.New("invalid stop method").Subject(string(c.StopMethod))
}
}
func (c *Config) validateStopSignal() error {
switch c.StopSignal {
case "", "SIGINT", "SIGTERM", "SIGQUIT", "SIGHUP", "INT", "TERM", "QUIT", "HUP":
return nil
default:
return gperr.New("invalid stop signal").Subject(string(c.StopSignal))
}
}
func (c *Config) validateStartEndpoint() error {
if c.StartEndpoint == "" {
return nil
}
// checks needed as of Go 1.6 because of change https://github.com/golang/go/commit/617c93ce740c3c3cc28cdd1a0d712be183d0b328#diff-6c2d018290e298803c0c9419d8739885L195
// emulate browser and strip the '#' suffix prior to validation. see issue-#237
if i := strings.Index(c.StartEndpoint, "#"); i > -1 {
c.StartEndpoint = c.StartEndpoint[:i]
}
if len(c.StartEndpoint) == 0 {
return gperr.New("start endpoint must not be empty if defined")
}
_, err := url.ParseRequestURI(c.StartEndpoint)
return err
}

View file

@ -1,9 +1,9 @@
package types
package idlewatcher
import (
"testing"
. "github.com/yusing/go-proxy/internal/utils/testing"
expect "github.com/yusing/go-proxy/internal/utils/testing"
)
func TestValidateStartEndpoint(t *testing.T) {
@ -35,9 +35,10 @@ func TestValidateStartEndpoint(t *testing.T) {
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
s, err := validateStartEndpoint(tc.input)
cfg := Config{StartEndpoint: tc.input}
err := cfg.validateStartEndpoint()
if err == nil {
ExpectEqual(t, s, tc.input)
expect.Equal(t, cfg.StartEndpoint, tc.input)
}
if (err != nil) != tc.wantErr {
t.Errorf("validateStartEndpoint() error = %v, wantErr %t", err, tc.wantErr)

View file

@ -0,0 +1,14 @@
package idlewatcher
import "github.com/yusing/go-proxy/internal/gperr"
type ContainerStatus string
const (
ContainerStatusError ContainerStatus = "error"
ContainerStatusRunning ContainerStatus = "running"
ContainerStatusPaused ContainerStatus = "paused"
ContainerStatusStopped ContainerStatus = "stopped"
)
var ErrUnexpectedContainerStatus = gperr.New("unexpected container status")

View file

@ -0,0 +1,19 @@
package idlewatcher
import (
"context"
"github.com/yusing/go-proxy/internal/gperr"
"github.com/yusing/go-proxy/internal/watcher/events"
)
type Provider interface {
ContainerPause(ctx context.Context) error
ContainerUnpause(ctx context.Context) error
ContainerStart(ctx context.Context) error
ContainerStop(ctx context.Context, signal Signal, timeout int) error
ContainerKill(ctx context.Context, signal Signal) error
ContainerStatus(ctx context.Context) (ContainerStatus, error)
Watch(ctx context.Context) (eventCh <-chan events.Event, errCh <-chan gperr.Error)
Close()
}

View file

@ -1,4 +1,4 @@
package types
package idlewatcher
import (
"net/http"

View file

@ -0,0 +1,310 @@
package idlewatcher
import (
"context"
"errors"
"sync"
"time"
"github.com/rs/zerolog"
"github.com/yusing/go-proxy/internal/gperr"
"github.com/yusing/go-proxy/internal/idlewatcher/provider"
idlewatcher "github.com/yusing/go-proxy/internal/idlewatcher/types"
"github.com/yusing/go-proxy/internal/logging"
"github.com/yusing/go-proxy/internal/net/gphttp/reverseproxy"
net "github.com/yusing/go-proxy/internal/net/types"
"github.com/yusing/go-proxy/internal/route/routes"
"github.com/yusing/go-proxy/internal/task"
U "github.com/yusing/go-proxy/internal/utils"
"github.com/yusing/go-proxy/internal/utils/atomic"
"github.com/yusing/go-proxy/internal/watcher/events"
"github.com/yusing/go-proxy/internal/watcher/health"
"github.com/yusing/go-proxy/internal/watcher/health/monitor"
)
type (
routeHelper struct {
rp *reverseproxy.ReverseProxy
stream net.Stream
hc health.HealthChecker
}
containerState struct {
status idlewatcher.ContainerStatus
ready bool
err error
}
Watcher struct {
_ U.NoCopy
routeHelper
l zerolog.Logger
cfg *idlewatcher.Config
provider idlewatcher.Provider
state atomic.Value[*containerState]
lastReset atomic.Value[time.Time]
ticker *time.Ticker
task *task.Task
}
StopCallback func() error
)
const ContextKey = "idlewatcher.watcher"
var (
watcherMap = make(map[string]*Watcher)
watcherMapMu sync.RWMutex
)
const (
idleWakerCheckInterval = 100 * time.Millisecond
idleWakerCheckTimeout = time.Second
)
var dummyHealthCheckConfig = &health.HealthCheckConfig{
Interval: idleWakerCheckInterval,
Timeout: idleWakerCheckTimeout,
}
var (
causeReload = gperr.New("reloaded")
causeContainerDestroy = gperr.New("container destroyed")
)
const reqTimeout = 3 * time.Second
// TODO: fix stream type
func NewWatcher(parent task.Parent, r routes.Route) (*Watcher, error) {
cfg := r.IdlewatcherConfig()
key := cfg.Key()
watcherMapMu.RLock()
// if the watcher already exists, finish it
w, exists := watcherMap[key]
if exists {
if w.cfg == cfg {
// same address, likely two routes from the same container
return w, nil
}
w.task.Finish(causeReload)
}
watcherMapMu.RUnlock()
w = &Watcher{
ticker: time.NewTicker(cfg.IdleTimeout),
cfg: cfg,
routeHelper: routeHelper{
hc: monitor.NewMonitor(r),
},
}
var p idlewatcher.Provider
var providerType string
var err error
switch {
case cfg.Docker != nil:
p, err = provider.NewDockerProvider(cfg.Docker.DockerHost, cfg.Docker.ContainerID)
providerType = "docker"
default:
p, err = provider.NewProxmoxProvider(cfg.Proxmox.Node, cfg.Proxmox.VMID)
providerType = "proxmox"
}
if err != nil {
return nil, err
}
w.provider = p
w.l = logging.With().
Str("provider", providerType).
Str("container", cfg.ContainerName()).
Logger()
switch r := r.(type) {
case routes.ReverseProxyRoute:
w.rp = r.ReverseProxy()
case routes.StreamRoute:
w.stream = r
default:
return nil, gperr.New("unexpected route type")
}
ctx, cancel := context.WithTimeout(parent.Context(), reqTimeout)
defer cancel()
status, err := w.provider.ContainerStatus(ctx)
if err != nil {
w.provider.Close()
return nil, gperr.Wrap(err, "failed to get container status")
}
switch p := w.provider.(type) {
case *provider.ProxmoxProvider:
shutdownTimeout := max(time.Second, cfg.StopTimeout-idleWakerCheckTimeout)
err = p.LXCSetShutdownTimeout(ctx, cfg.Proxmox.VMID, shutdownTimeout)
if err != nil {
w.l.Warn().Err(err).Msg("failed to set shutdown timeout")
}
}
w.state.Store(&containerState{status: status})
w.task = parent.Subtask("idlewatcher."+r.Name(), true)
watcherMapMu.Lock()
defer watcherMapMu.Unlock()
watcherMap[key] = w
go func() {
cause := w.watchUntilDestroy()
if cause.Is(causeContainerDestroy) || cause.Is(task.ErrProgramExiting) {
watcherMapMu.Lock()
defer watcherMapMu.Unlock()
delete(watcherMap, key)
w.l.Info().Msg("idlewatcher stopped")
} else if !cause.Is(causeReload) {
gperr.LogError("idlewatcher stopped unexpectedly", cause, &w.l)
}
w.ticker.Stop()
w.provider.Close()
w.task.Finish(cause)
}()
if exists {
w.l.Info().Msg("idlewatcher reloaded")
} else {
w.l.Info().Msg("idlewatcher started")
}
return w, nil
}
func (w *Watcher) Key() string {
return w.cfg.Key()
}
func (w *Watcher) Wake() error {
return w.wakeIfStopped()
}
func (w *Watcher) wakeIfStopped() error {
state := w.state.Load()
if state.status == idlewatcher.ContainerStatusRunning {
w.l.Debug().Msg("container is already running")
return nil
}
ctx, cancel := context.WithTimeout(w.task.Context(), w.cfg.WakeTimeout)
defer cancel()
switch state.status {
case idlewatcher.ContainerStatusStopped:
w.l.Info().Msg("starting container")
return w.provider.ContainerStart(ctx)
case idlewatcher.ContainerStatusPaused:
w.l.Info().Msg("unpausing container")
return w.provider.ContainerUnpause(ctx)
default:
return gperr.Errorf("unexpected container status: %s", state.status)
}
}
func (w *Watcher) stopByMethod() error {
if !w.running() {
return nil
}
cfg := w.cfg
ctx, cancel := context.WithTimeout(w.task.Context(), cfg.StopTimeout)
defer cancel()
switch cfg.StopMethod {
case idlewatcher.StopMethodPause:
return w.provider.ContainerPause(ctx)
case idlewatcher.StopMethodStop:
return w.provider.ContainerStop(ctx, cfg.StopSignal, int(cfg.StopTimeout.Seconds()))
case idlewatcher.StopMethodKill:
return w.provider.ContainerKill(ctx, cfg.StopSignal)
default:
return gperr.Errorf("unexpected stop method: %q", cfg.StopMethod)
}
}
func (w *Watcher) resetIdleTimer() {
w.ticker.Reset(w.cfg.IdleTimeout)
w.lastReset.Store(time.Now())
}
func (w *Watcher) expires() time.Time {
if !w.running() {
return time.Time{}
}
return w.lastReset.Load().Add(w.cfg.IdleTimeout)
}
// watchUntilDestroy waits for the container to be created, started, or unpaused,
// and then reset the idle timer.
//
// When the container is stopped, paused,
// or killed, the idle timer is stopped and the ContainerRunning flag is set to false.
//
// When the idle timer fires, the container is stopped according to the
// stop method.
//
// it exits only if the context is canceled, the container is destroyed,
// errors occurred on docker client, or route provider died (mainly caused by config reload).
func (w *Watcher) watchUntilDestroy() (returnCause gperr.Error) {
eventCh, errCh := w.provider.Watch(w.Task().Context())
for {
select {
case <-w.task.Context().Done():
return gperr.Wrap(w.task.FinishCause())
case err := <-errCh:
return err
case e := <-eventCh:
w.l.Debug().Stringer("action", e.Action).Msg("state changed")
if e.Action == events.ActionContainerDestroy {
return causeContainerDestroy
}
w.resetIdleTimer()
switch {
case e.Action.IsContainerStart(): // create / start / unpause
w.setStarting()
w.l.Info().Msg("awaken")
case e.Action.IsContainerStop(): // stop / kill / die
w.setNapping(idlewatcher.ContainerStatusStopped)
w.ticker.Stop()
case e.Action.IsContainerPause(): // pause
w.setNapping(idlewatcher.ContainerStatusPaused)
w.ticker.Stop()
default:
w.l.Error().Stringer("action", e.Action).Msg("unexpected container action")
}
case <-w.ticker.C:
w.ticker.Stop()
if w.running() {
err := w.stopByMethod()
switch {
case errors.Is(err, context.Canceled):
continue
case err != nil:
if errors.Is(err, context.DeadlineExceeded) {
err = errors.New("timeout waiting for container to stop, please set a higher value for `stop_timeout`")
}
w.l.Err(err).Msgf("container stop with method %q failed", w.cfg.StopMethod)
default:
w.l.Info().Str("reason", "idle timeout").Msg("container stopped")
}
}
}
}
}
func fmtErr(err error) string {
if err == nil {
return ""
}
return err.Error()
}

View file

@ -59,7 +59,7 @@ func save() error {
defer stores.Unlock()
errs := gperr.NewBuilder("failed to save data stores")
for ns, store := range stores.m {
if err := utils.SaveJSON(filepath.Join(common.DataDir, string(ns)+".json"), &store, 0o644); err != nil {
if err := utils.SaveJSON(filepath.Join(storesPath, string(ns)+".json"), &store, 0o644); err != nil {
errs.Add(err)
}
}

View file

@ -1,7 +1,6 @@
package jsonstore
import (
"path/filepath"
"testing"
)
@ -14,8 +13,7 @@ func TestNewJSON(t *testing.T) {
}
func TestSaveLoad(t *testing.T) {
tmpDir := t.TempDir()
storesPath = filepath.Join(tmpDir, "data.json")
storesPath = t.TempDir()
store := Store[string]("test")
store.Store("a", "1")
if err := save(); err != nil {

View file

@ -11,14 +11,13 @@ import (
"github.com/yusing/go-proxy/internal/metrics/period"
metricsutils "github.com/yusing/go-proxy/internal/metrics/utils"
"github.com/yusing/go-proxy/internal/route/routes"
"github.com/yusing/go-proxy/internal/route/routes/routequery"
"github.com/yusing/go-proxy/internal/watcher/health"
)
type (
StatusByAlias struct {
Map map[string]*routequery.HealthInfoRaw `json:"statuses"`
Timestamp int64 `json:"timestamp"`
Map map[string]*routes.HealthInfoRaw `json:"statuses"`
Timestamp int64 `json:"timestamp"`
}
Status struct {
Status health.Status `json:"status"`
@ -33,7 +32,7 @@ var Poller = period.NewPoller("uptime", getStatuses, aggregateStatuses)
func getStatuses(ctx context.Context, _ *StatusByAlias) (*StatusByAlias, error) {
return &StatusByAlias{
Map: routequery.HealthInfo(),
Map: routes.HealthInfo(),
Timestamp: time.Now().Unix(),
}, nil
}
@ -117,7 +116,7 @@ func (rs RouteStatuses) aggregate(limit int, offset int) Aggregated {
"avg_latency": latency,
"statuses": statuses,
}
r, ok := routes.GetRoute(alias)
r, ok := routes.Get(alias)
if ok {
result[i]["display_name"] = r.HomepageConfig().Name
}

View file

@ -1,6 +1,7 @@
package loadbalancer
import (
"fmt"
"net/http"
"sync"
"time"
@ -10,10 +11,9 @@ import (
"github.com/yusing/go-proxy/internal/logging"
"github.com/yusing/go-proxy/internal/net/gphttp/httpheaders"
"github.com/yusing/go-proxy/internal/net/gphttp/loadbalancer/types"
"github.com/yusing/go-proxy/internal/route/routes"
"github.com/yusing/go-proxy/internal/task"
"github.com/yusing/go-proxy/internal/utils/pool"
"github.com/yusing/go-proxy/internal/watcher/health"
"github.com/yusing/go-proxy/internal/watcher/health/monitor"
)
// TODO: stats of each server.
@ -31,7 +31,7 @@ type (
task *task.Task
pool Pool
pool pool.Pool[Server]
poolMu sync.Mutex
sumWeight Weight
@ -46,7 +46,7 @@ const maxWeight Weight = 100
func New(cfg *Config) *LoadBalancer {
lb := &LoadBalancer{
Config: new(Config),
pool: types.NewServerPool(),
pool: pool.New[Server]("loadbalancer." + cfg.Link),
l: logging.With().Str("name", cfg.Link).Logger(),
}
lb.UpdateConfigIfNeeded(cfg)
@ -56,16 +56,14 @@ func New(cfg *Config) *LoadBalancer {
// Start implements task.TaskStarter.
func (lb *LoadBalancer) Start(parent task.Parent) gperr.Error {
lb.startTime = time.Now()
lb.task = parent.Subtask("loadbalancer."+lb.Link, false)
parent.OnCancel("lb_remove_route", func() {
routes.DeleteHTTPRoute(lb.Link)
})
lb.task.OnFinished("cleanup", func() {
lb.task = parent.Subtask("loadbalancer."+lb.Link, true)
lb.task.OnCancel("cleanup", func() {
if lb.impl != nil {
lb.pool.RangeAll(func(k string, v Server) {
lb.impl.OnRemoveServer(v)
})
for _, srv := range lb.pool.Iter {
lb.impl.OnRemoveServer(srv)
}
}
lb.task.Finish(nil)
})
return nil
}
@ -91,9 +89,9 @@ func (lb *LoadBalancer) updateImpl() {
default: // should happen in test only
lb.impl = lb.newRoundRobin()
}
lb.pool.RangeAll(func(_ string, srv Server) {
for _, srv := range lb.pool.Iter {
lb.impl.OnAddServer(srv)
})
}
}
func (lb *LoadBalancer) UpdateConfigIfNeeded(cfg *Config) {
@ -125,12 +123,12 @@ func (lb *LoadBalancer) AddServer(srv Server) {
lb.poolMu.Lock()
defer lb.poolMu.Unlock()
if lb.pool.Has(srv.Key()) { // FIXME: this should be a warning
old, _ := lb.pool.Load(srv.Key())
if old, ok := lb.pool.Get(srv.Key()); ok { // FIXME: this should be a warning
lb.sumWeight -= old.Weight()
lb.impl.OnRemoveServer(old)
lb.pool.Del(old)
}
lb.pool.Store(srv.Key(), srv)
lb.pool.Add(srv)
lb.sumWeight += srv.Weight()
lb.rebalance()
@ -146,11 +144,11 @@ func (lb *LoadBalancer) RemoveServer(srv Server) {
lb.poolMu.Lock()
defer lb.poolMu.Unlock()
if !lb.pool.Has(srv.Key()) {
if _, ok := lb.pool.Get(srv.Key()); !ok {
return
}
lb.pool.Delete(srv.Key())
lb.pool.Del(srv)
lb.sumWeight -= srv.Weight()
lb.rebalance()
@ -179,15 +177,15 @@ func (lb *LoadBalancer) rebalance() {
if lb.sumWeight == 0 { // distribute evenly
weightEach := maxWeight / Weight(poolSize)
remainder := maxWeight % Weight(poolSize)
lb.pool.RangeAll(func(_ string, s Server) {
for _, srv := range lb.pool.Iter {
w := weightEach
lb.sumWeight += weightEach
if remainder > 0 {
w++
remainder--
}
s.SetWeight(w)
})
srv.SetWeight(w)
}
return
}
@ -195,30 +193,29 @@ func (lb *LoadBalancer) rebalance() {
scaleFactor := float64(maxWeight) / float64(lb.sumWeight)
lb.sumWeight = 0
lb.pool.RangeAll(func(_ string, s Server) {
s.SetWeight(Weight(float64(s.Weight()) * scaleFactor))
lb.sumWeight += s.Weight()
})
for _, srv := range lb.pool.Iter {
srv.SetWeight(Weight(float64(srv.Weight()) * scaleFactor))
lb.sumWeight += srv.Weight()
}
delta := maxWeight - lb.sumWeight
if delta == 0 {
return
}
lb.pool.Range(func(_ string, s Server) bool {
for _, srv := range lb.pool.Iter {
if delta == 0 {
return false
break
}
if delta > 0 {
s.SetWeight(s.Weight() + 1)
srv.SetWeight(srv.Weight() + 1)
lb.sumWeight++
delta--
} else {
s.SetWeight(s.Weight() - 1)
srv.SetWeight(srv.Weight() - 1)
lb.sumWeight--
delta++
}
return true
})
}
}
func (lb *LoadBalancer) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
@ -240,23 +237,26 @@ func (lb *LoadBalancer) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
lb.impl.ServeHTTP(srvs, rw, r)
}
// MarshalJSON implements health.HealthMonitor.
func (lb *LoadBalancer) MarshalJSON() ([]byte, error) {
// MarshalMap implements health.HealthMonitor.
func (lb *LoadBalancer) MarshalMap() map[string]any {
extra := make(map[string]any)
lb.pool.RangeAll(func(k string, v Server) {
extra[v.Key()] = v
})
for _, srv := range lb.pool.Iter {
extra[srv.Key()] = srv
}
return (&monitor.JSONRepresentation{
status, numHealthy := lb.status()
return (&health.JSONRepresentation{
Name: lb.Name(),
Status: lb.Status(),
Status: status,
Detail: fmt.Sprintf("%d/%d servers are healthy", numHealthy, lb.pool.Size()),
Started: lb.startTime,
Uptime: lb.Uptime(),
Extra: map[string]any{
"config": lb.Config,
"pool": extra,
},
}).MarshalJSON()
}).MarshalMap()
}
// Name implements health.HealthMonitor.
@ -266,22 +266,26 @@ func (lb *LoadBalancer) Name() string {
// Status implements health.HealthMonitor.
func (lb *LoadBalancer) Status() health.Status {
status, _ := lb.status()
return status
}
func (lb *LoadBalancer) status() (status health.Status, numHealthy int) {
if lb.pool.Size() == 0 {
return health.StatusUnknown
return health.StatusUnknown, 0
}
isHealthy := true
lb.pool.Range(func(_ string, srv Server) bool {
if srv.Status().Bad() {
isHealthy = false
return false
// should be healthy if at least one server is healthy
numHealthy = 0
for _, srv := range lb.pool.Iter {
if srv.Status().Good() {
numHealthy++
}
return true
})
if !isHealthy {
return health.StatusUnhealthy
}
return health.StatusHealthy
if numHealthy == 0 {
return health.StatusUnhealthy, numHealthy
}
return health.StatusHealthy, numHealthy
}
// Uptime implements health.HealthMonitor.
@ -292,9 +296,9 @@ func (lb *LoadBalancer) Uptime() time.Duration {
// Latency implements health.HealthMonitor.
func (lb *LoadBalancer) Latency() time.Duration {
var sum time.Duration
lb.pool.RangeAll(func(_ string, srv Server) {
for _, srv := range lb.pool.Iter {
sum += srv.Latency()
})
}
return sum
}
@ -305,10 +309,10 @@ func (lb *LoadBalancer) String() string {
func (lb *LoadBalancer) availServers() []Server {
avail := make([]Server, 0, lb.pool.Size())
lb.pool.RangeAll(func(_ string, srv Server) {
for _, srv := range lb.pool.Iter {
if srv.Status().Good() {
avail = append(avail, srv)
}
})
}
return avail
}

View file

@ -7,7 +7,6 @@ import (
type (
Server = types.Server
Servers = []types.Server
Pool = types.Pool
Weight = types.Weight
Config = types.Config
Mode = types.Mode

View file

@ -3,10 +3,9 @@ package types
import (
"net/http"
idlewatcher "github.com/yusing/go-proxy/internal/docker/idlewatcher/types"
idlewatcher "github.com/yusing/go-proxy/internal/idlewatcher/types"
net "github.com/yusing/go-proxy/internal/net/types"
U "github.com/yusing/go-proxy/internal/utils"
F "github.com/yusing/go-proxy/internal/utils/functional"
"github.com/yusing/go-proxy/internal/watcher/health"
)
@ -32,12 +31,8 @@ type (
SetWeight(weight Weight)
TryWake() error
}
Pool = F.Map[string, Server]
)
var NewServerPool = F.NewMap[Pool]
func NewServer(name string, url *net.URL, weight Weight, handler http.Handler, healthMon health.HealthMonitor) Server {
srv := &server{
name: name,

18
internal/net/tcp.go Normal file
View file

@ -0,0 +1,18 @@
package netutils
import (
"context"
"fmt"
"net"
)
// PingTCP "pings" the IP address using TCP.
func PingTCP(ctx context.Context, ip net.IP, port int) error {
var dialer net.Dialer
conn, err := dialer.DialContext(ctx, "tcp", fmt.Sprintf("%s:%d", ip, port))
if err != nil {
return err
}
conn.Close()
return nil
}

View file

@ -0,0 +1,68 @@
package proxmox
import (
"context"
"fmt"
"github.com/luthermonson/go-proxmox"
"github.com/yusing/go-proxy/internal/utils/pool"
)
type Client struct {
*proxmox.Client
proxmox.Cluster
Version *proxmox.Version
}
var Clients = pool.New[*Client]("proxmox_clients")
func NewClient(baseUrl string, opts ...proxmox.Option) *Client {
return &Client{Client: proxmox.NewClient(baseUrl, opts...)}
}
func (c *Client) UpdateClusterInfo(ctx context.Context) (err error) {
c.Version, err = c.Client.Version(ctx)
if err != nil {
return err
}
// requires (/, Sys.Audit)
if err := c.Get(ctx, "/cluster/status", &c.Cluster); err != nil {
return err
}
for _, node := range c.Cluster.Nodes {
Nodes.Add(&Node{name: node.Name, id: node.ID, client: c.Client})
}
return nil
}
// Key implements pool.Object
func (c *Client) Key() string {
return c.Cluster.ID
}
// Name implements pool.Object
func (c *Client) Name() string {
return c.Cluster.Name
}
// MarshalMap implements pool.Object
func (c *Client) MarshalMap() map[string]any {
return map[string]any{
"version": c.Version,
"cluster": map[string]any{
"name": c.Cluster.Name,
"id": c.Cluster.ID,
"version": c.Cluster.Version,
"nodes": c.Cluster.Nodes,
"quorate": c.Cluster.Quorate,
},
}
}
func (c *Client) NumNodes() int {
return len(c.Cluster.Nodes)
}
func (c *Client) String() string {
return fmt.Sprintf("%s (%s)", c.Cluster.Name, c.Cluster.ID)
}

View file

@ -0,0 +1,69 @@
package proxmox
import (
"context"
"crypto/tls"
"errors"
"net/http"
"strings"
"time"
"github.com/luthermonson/go-proxmox"
"github.com/yusing/go-proxy/internal/gperr"
"github.com/yusing/go-proxy/internal/net/gphttp"
)
type Config struct {
URL string `json:"url" validate:"required,url"`
TokenID string `json:"token_id" validate:"required"`
Secret string `json:"secret" validate:"required"`
NoTLSVerify bool `json:"no_tls_verify" yaml:"no_tls_verify,omitempty"`
client *Client
}
func (c *Config) Client() *Client {
if c.client == nil {
panic("proxmox client accessed before init")
}
return c.client
}
func (c *Config) Init() gperr.Error {
var tr *http.Transport
if c.NoTLSVerify {
tr = gphttp.NewTransportWithTLSConfig(&tls.Config{
InsecureSkipVerify: true,
})
} else {
tr = gphttp.NewTransport()
}
if strings.HasSuffix(c.URL, "/") {
c.URL = c.URL[:len(c.URL)-1]
}
if !strings.HasSuffix(c.URL, "/api2/json") {
c.URL += "/api2/json"
}
opts := []proxmox.Option{
proxmox.WithAPIToken(c.TokenID, c.Secret),
proxmox.WithHTTPClient(&http.Client{
Transport: tr,
}),
}
c.client = NewClient(c.URL, opts...)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if err := c.client.UpdateClusterInfo(ctx); err != nil {
if errors.Is(err, context.DeadlineExceeded) {
return gperr.New("timeout fetching proxmox cluster info")
}
return gperr.New("failed to fetch proxmox cluster info").With(err)
}
return nil
}

236
internal/proxmox/lxc.go Normal file
View file

@ -0,0 +1,236 @@
package proxmox
import (
"context"
"fmt"
"net"
"strings"
"time"
"github.com/luthermonson/go-proxmox"
)
type (
LXCAction string
LXCStatus string
statusOnly struct {
Status LXCStatus `json:"status"`
}
nameOnly struct {
Name string `json:"name"`
}
)
const (
LXCStart LXCAction = "start"
LXCShutdown LXCAction = "shutdown"
LXCSuspend LXCAction = "suspend"
LXCResume LXCAction = "resume"
LXCReboot LXCAction = "reboot"
)
const (
LXCStatusRunning LXCStatus = "running"
LXCStatusStopped LXCStatus = "stopped"
LXCStatusSuspended LXCStatus = "suspended" // placeholder, suspending lxc is experimental and the enum is undocumented
)
const (
proxmoxReqTimeout = 3 * time.Second
proxmoxTaskCheckInterval = 300 * time.Millisecond
)
func (n *Node) LXCAction(ctx context.Context, vmid int, action LXCAction) error {
var upid proxmox.UPID
if err := n.client.Post(ctx, fmt.Sprintf("/nodes/%s/lxc/%d/status/%s", n.name, vmid, action), nil, &upid); err != nil {
return err
}
task := proxmox.NewTask(upid, n.client)
checkTicker := time.NewTicker(proxmoxTaskCheckInterval)
defer checkTicker.Stop()
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-checkTicker.C:
if err := task.Ping(ctx); err != nil {
return err
}
if task.Status != proxmox.TaskRunning {
status, err := n.LXCStatus(ctx, vmid)
if err != nil {
return err
}
switch status {
case LXCStatusRunning:
if action == LXCStart {
return nil
}
case LXCStatusStopped:
if action == LXCShutdown {
return nil
}
case LXCStatusSuspended:
if action == LXCSuspend {
return nil
}
}
}
}
}
}
func (n *Node) LXCName(ctx context.Context, vmid int) (string, error) {
var name nameOnly
if err := n.client.Get(ctx, fmt.Sprintf("/nodes/%s/lxc/%d/status/current", n.name, vmid), &name); err != nil {
return "", err
}
return name.Name, nil
}
func (n *Node) LXCStatus(ctx context.Context, vmid int) (LXCStatus, error) {
var status statusOnly
if err := n.client.Get(ctx, fmt.Sprintf("/nodes/%s/lxc/%d/status/current", n.name, vmid), &status); err != nil {
return "", err
}
return status.Status, nil
}
func (n *Node) LXCIsRunning(ctx context.Context, vmid int) (bool, error) {
status, err := n.LXCStatus(ctx, vmid)
return status == LXCStatusRunning, err
}
func (n *Node) LXCIsStopped(ctx context.Context, vmid int) (bool, error) {
status, err := n.LXCStatus(ctx, vmid)
return status == LXCStatusStopped, err
}
func (n *Node) LXCSetShutdownTimeout(ctx context.Context, vmid int, timeout time.Duration) error {
return n.client.Put(ctx, fmt.Sprintf("/nodes/%s/lxc/%d/config", n.name, vmid), map[string]interface{}{
"startup": fmt.Sprintf("down=%.0f", timeout.Seconds()),
}, nil)
}
func parseCIDR(s string) net.IP {
if s == "" {
return nil
}
ip, _, err := net.ParseCIDR(s)
if err != nil {
return nil
}
return checkIPPrivate(ip)
}
func checkIPPrivate(ip net.IP) net.IP {
if ip == nil {
return nil
}
if ip.IsPrivate() {
return ip
}
return nil
}
func getIPFromNet(s string) (res []net.IP) { // name:...,bridge:...,gw=..,ip=...,ip6=...
if s == "" {
return nil
}
var i4, i6 net.IP
cidrIndex := strings.Index(s, "ip=")
if cidrIndex != -1 {
cidrIndex += 3
slash := strings.Index(s[cidrIndex:], "/")
if slash != -1 {
i4 = checkIPPrivate(net.ParseIP(s[cidrIndex : cidrIndex+slash]))
} else {
i4 = checkIPPrivate(net.ParseIP(s[cidrIndex:]))
}
}
cidr6Index := strings.Index(s, "ip6=")
if cidr6Index != -1 {
cidr6Index += 4
slash := strings.Index(s[cidr6Index:], "/")
if slash != -1 {
i6 = checkIPPrivate(net.ParseIP(s[cidr6Index : cidr6Index+slash]))
} else {
i6 = checkIPPrivate(net.ParseIP(s[cidr6Index:]))
}
}
if i4 != nil {
res = append(res, i4)
}
if i6 != nil {
res = append(res, i6)
}
return res
}
// LXCGetIPs returns the ip addresses of the container
// it first tries to get the ip addresses from the config
// if that fails, it gets the ip addresses from the interfaces
func (n *Node) LXCGetIPs(ctx context.Context, vmid int) (res []net.IP, err error) {
ips, err := n.LXCGetIPsFromConfig(ctx, vmid)
if err != nil {
return nil, err
}
if len(ips) > 0 {
return ips, nil
}
ips, err = n.LXCGetIPsFromInterfaces(ctx, vmid)
if err != nil {
return nil, err
}
return ips, nil
}
// LXCGetIPsFromConfig returns the ip addresses of the container from the config
func (n *Node) LXCGetIPsFromConfig(ctx context.Context, vmid int) (res []net.IP, err error) {
type Config struct {
Net0 string `json:"net0"`
Net1 string `json:"net1"`
Net2 string `json:"net2"`
}
var cfg Config
if err := n.client.Get(ctx, fmt.Sprintf("/nodes/%s/lxc/%d/config", n.name, vmid), &cfg); err != nil {
return nil, err
}
res = append(res, getIPFromNet(cfg.Net0)...)
res = append(res, getIPFromNet(cfg.Net1)...)
res = append(res, getIPFromNet(cfg.Net2)...)
return res, nil
}
// LXCGetIPsFromInterfaces returns the ip addresses of the container from the interfaces
// it will return nothing if the container is stopped
func (n *Node) LXCGetIPsFromInterfaces(ctx context.Context, vmid int) ([]net.IP, error) {
type Interface struct {
IPv4 string `json:"inet"`
IPv6 string `json:"inet6"`
Name string `json:"name"`
}
var res []Interface
if err := n.client.Get(ctx, fmt.Sprintf("/nodes/%s/lxc/%d/interfaces", n.name, vmid), &res); err != nil {
return nil, err
}
ips := make([]net.IP, 0)
for _, ip := range res {
if ip.Name == "lo" ||
strings.HasPrefix(ip.Name, "br-") ||
strings.HasPrefix(ip.Name, "veth") ||
strings.HasPrefix(ip.Name, "docker") {
continue
}
if ip := parseCIDR(ip.IPv4); ip != nil {
ips = append(ips, ip)
}
if ip := parseCIDR(ip.IPv6); ip != nil {
ips = append(ips, ip)
}
}
return ips, nil
}

View file

@ -0,0 +1,40 @@
package proxmox
import (
"net"
"reflect"
"testing"
)
func TestGetIPFromNet(t *testing.T) {
testCases := []struct {
name string
input string
want []net.IP
}{
{
name: "ipv4 only",
input: "name=eth0,bridge=vmbr0,gw=10.0.0.1,hwaddr=BC:24:11:10:88:97,ip=10.0.6.68/16,type=veth",
want: []net.IP{net.ParseIP("10.0.6.68")},
},
{
name: "ipv6 only, at the end",
input: "name=eth0,bridge=vmbr0,hwaddr=BC:24:11:10:88:97,gw=::ffff:a00:1,type=veth,ip6=::ffff:a00:644/48",
want: []net.IP{net.ParseIP("::ffff:a00:644")},
},
{
name: "both",
input: "name=eth0,bridge=vmbr0,hwaddr=BC:24:11:10:88:97,gw=::ffff:a00:1,type=veth,ip6=::ffff:a00:644/48,ip=10.0.6.68/16",
want: []net.IP{net.ParseIP("10.0.6.68"), net.ParseIP("::ffff:a00:644")},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
got := getIPFromNet(tc.input)
if !reflect.DeepEqual(got, tc.want) {
t.Errorf("getIPFromNet(%q) = %s, want %s", tc.name, got, tc.want)
}
})
}
}

50
internal/proxmox/node.go Normal file
View file

@ -0,0 +1,50 @@
package proxmox
import (
"context"
"fmt"
"strings"
"github.com/luthermonson/go-proxmox"
"github.com/yusing/go-proxy/internal/utils/pool"
)
type Node struct {
name string
id string // likely node/<name>
client *proxmox.Client
}
var Nodes = pool.New[*Node]("proxmox_nodes")
func AvailableNodeNames() string {
var sb strings.Builder
for _, node := range Nodes.Iter {
sb.WriteString(node.name)
sb.WriteString(", ")
}
return sb.String()[:sb.Len()-2]
}
func (n *Node) Key() string {
return n.name
}
func (n *Node) Name() string {
return n.name
}
func (n *Node) String() string {
return fmt.Sprintf("%s (%s)", n.name, n.id)
}
func (n *Node) MarshalMap() map[string]any {
return map[string]any{
"name": n.name,
"id": n.id,
}
}
func (n *Node) Get(ctx context.Context, path string, v any) error {
return n.client.Get(ctx, path, v)
}

View file

@ -57,7 +57,7 @@ func NewFileServer(base *Route) (*FileServer, gperr.Error) {
// Start implements task.TaskStarter.
func (s *FileServer) Start(parent task.Parent) gperr.Error {
s.task = parent.Subtask("fileserver."+s.TargetName(), false)
s.task = parent.Subtask("fileserver."+s.Name(), false)
pathPatterns := s.PathPatterns
switch {
@ -92,7 +92,7 @@ func (s *FileServer) Start(parent task.Parent) gperr.Error {
}
if common.PrometheusEnabled {
metricsLogger := metricslogger.NewMetricsLogger(s.TargetName())
metricsLogger := metricslogger.NewMetricsLogger(s.Name())
s.handler = metricsLogger.GetHandler(s.handler)
s.task.OnCancel("reset_metrics", metricsLogger.ResetMetrics)
}
@ -104,9 +104,9 @@ func (s *FileServer) Start(parent task.Parent) gperr.Error {
}
}
routes.SetHTTPRoute(s.TargetName(), s)
routes.HTTP.Add(s)
s.task.OnCancel("entrypoint_remove_route", func() {
routes.DeleteHTTPRoute(s.TargetName())
routes.HTTP.Del(s)
})
return nil
}

View file

@ -3,7 +3,7 @@ package provider
import (
"testing"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
"github.com/yusing/go-proxy/internal/docker"
. "github.com/yusing/go-proxy/internal/utils/testing"
"gopkg.in/yaml.v3"
@ -21,11 +21,11 @@ func TestParseDockerLabels(t *testing.T) {
ExpectNoError(t, yaml.Unmarshal(testDockerLabelsYAML, &labels))
routes, err := provider.routesFromContainerLabels(
docker.FromDocker(&types.Container{
docker.FromDocker(&container.SummaryTrimmed{
Names: []string{"container"},
Labels: labels,
State: "running",
Ports: []types.Port{
Ports: []container.Port{
{Type: "tcp", PrivatePort: 1234, PublicPort: 1234},
},
}, "/var/run/docker.sock"),

View file

@ -5,13 +5,13 @@ import (
"time"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/network"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/client"
"github.com/yusing/go-proxy/internal/common"
D "github.com/yusing/go-proxy/internal/docker"
"github.com/yusing/go-proxy/internal/route"
T "github.com/yusing/go-proxy/internal/route/types"
. "github.com/yusing/go-proxy/internal/utils/testing"
expect "github.com/yusing/go-proxy/internal/utils/testing"
)
var dummyNames = []string{"/a"}
@ -21,7 +21,7 @@ const (
testDockerIP = "172.17.0.123"
)
func makeRoutes(cont *types.Container, dockerHostIP ...string) route.Routes {
func makeRoutes(cont *container.SummaryTrimmed, dockerHostIP ...string) route.Routes {
var p DockerProvider
var host string
if len(dockerHostIP) > 0 {
@ -64,15 +64,15 @@ func TestApplyLabel(t *testing.T) {
},
},
}
entries := makeRoutes(&types.Container{
entries := makeRoutes(&container.SummaryTrimmed{
Names: dummyNames,
Labels: map[string]string{
D.LabelAliases: "a,b",
D.LabelIdleTimeout: "",
D.LabelStopMethod: common.StopMethodDefault,
D.LabelIdleTimeout: "10s",
D.LabelStopMethod: "stop",
D.LabelStopSignal: "SIGTERM",
D.LabelStopTimeout: common.StopTimeoutDefault,
D.LabelWakeTimeout: common.WakeTimeoutDefault,
D.LabelStopTimeout: "1h",
D.LabelWakeTimeout: "10s",
"proxy.*.no_tls_verify": "true",
"proxy.*.scheme": "https",
"proxy.*.host": "app",
@ -88,54 +88,55 @@ func TestApplyLabel(t *testing.T) {
})
a, ok := entries["a"]
ExpectTrue(t, ok)
expect.True(t, ok)
b, ok := entries["b"]
ExpectTrue(t, ok)
expect.True(t, ok)
ExpectEqual(t, a.Scheme, "https")
ExpectEqual(t, b.Scheme, "https")
expect.Equal(t, a.Scheme, "https")
expect.Equal(t, b.Scheme, "https")
ExpectEqual(t, a.Host, "app")
ExpectEqual(t, b.Host, "app")
expect.Equal(t, a.Host, "app")
expect.Equal(t, b.Host, "app")
ExpectEqual(t, a.Port.Proxy, 4567)
ExpectEqual(t, b.Port.Proxy, 4567)
expect.Equal(t, a.Port.Proxy, 4567)
expect.Equal(t, b.Port.Proxy, 4567)
ExpectTrue(t, a.NoTLSVerify)
ExpectTrue(t, b.NoTLSVerify)
expect.True(t, a.NoTLSVerify)
expect.True(t, b.NoTLSVerify)
ExpectEqual(t, a.PathPatterns, pathPatternsExpect)
ExpectEqual(t, len(b.PathPatterns), 0)
expect.Equal(t, a.PathPatterns, pathPatternsExpect)
expect.Equal(t, len(b.PathPatterns), 0)
ExpectEqual(t, a.Middlewares, middlewaresExpect)
ExpectEqual(t, len(b.Middlewares), 0)
expect.Equal(t, a.Middlewares, middlewaresExpect)
expect.Equal(t, len(b.Middlewares), 0)
ExpectEqual(t, a.Container.IdleTimeout, "")
ExpectEqual(t, b.Container.IdleTimeout, "")
expect.NotNil(t, a.Container)
expect.NotNil(t, b.Container)
expect.NotNil(t, a.Container.IdlewatcherConfig)
expect.NotNil(t, b.Container.IdlewatcherConfig)
ExpectEqual(t, a.Container.StopTimeout, common.StopTimeoutDefault)
ExpectEqual(t, b.Container.StopTimeout, common.StopTimeoutDefault)
expect.Equal(t, a.Container.IdlewatcherConfig.IdleTimeout, 10*time.Second)
expect.Equal(t, b.Container.IdlewatcherConfig.IdleTimeout, 10*time.Second)
expect.Equal(t, a.Container.IdlewatcherConfig.StopTimeout, time.Hour)
expect.Equal(t, b.Container.IdlewatcherConfig.StopTimeout, time.Hour)
expect.Equal(t, a.Container.IdlewatcherConfig.StopMethod, "stop")
expect.Equal(t, b.Container.IdlewatcherConfig.StopMethod, "stop")
expect.Equal(t, a.Container.IdlewatcherConfig.WakeTimeout, 10*time.Second)
expect.Equal(t, b.Container.IdlewatcherConfig.WakeTimeout, 10*time.Second)
expect.Equal(t, a.Container.IdlewatcherConfig.StopSignal, "SIGTERM")
expect.Equal(t, b.Container.IdlewatcherConfig.StopSignal, "SIGTERM")
ExpectEqual(t, a.Container.StopMethod, common.StopMethodDefault)
ExpectEqual(t, b.Container.StopMethod, common.StopMethodDefault)
expect.Equal(t, a.Homepage.Show, true)
expect.Equal(t, a.Homepage.Icon.Value, "png/adguard-home.png")
expect.Equal(t, a.Homepage.Icon.Extra.FileType, "png")
expect.Equal(t, a.Homepage.Icon.Extra.Name, "adguard-home")
ExpectEqual(t, a.Container.WakeTimeout, common.WakeTimeoutDefault)
ExpectEqual(t, b.Container.WakeTimeout, common.WakeTimeoutDefault)
ExpectEqual(t, a.Container.StopSignal, "SIGTERM")
ExpectEqual(t, b.Container.StopSignal, "SIGTERM")
ExpectEqual(t, a.Homepage.Show, true)
ExpectEqual(t, a.Homepage.Icon.Value, "png/adguard-home.png")
ExpectEqual(t, a.Homepage.Icon.Extra.FileType, "png")
ExpectEqual(t, a.Homepage.Icon.Extra.Name, "adguard-home")
ExpectEqual(t, a.HealthCheck.Path, "/ping")
ExpectEqual(t, a.HealthCheck.Interval, 10*time.Second)
expect.Equal(t, a.HealthCheck.Path, "/ping")
expect.Equal(t, a.HealthCheck.Interval, 10*time.Second)
}
func TestApplyLabelWithAlias(t *testing.T) {
entries := makeRoutes(&types.Container{
entries := makeRoutes(&container.SummaryTrimmed{
Names: dummyNames,
State: "running",
Labels: map[string]string{
@ -162,7 +163,7 @@ func TestApplyLabelWithAlias(t *testing.T) {
}
func TestApplyLabelWithRef(t *testing.T) {
entries := makeRoutes(&types.Container{
entries := makeRoutes(&container.SummaryTrimmed{
Names: dummyNames,
State: "running",
Labels: map[string]string{
@ -190,7 +191,7 @@ func TestApplyLabelWithRef(t *testing.T) {
}
func TestApplyLabelWithRefIndexError(t *testing.T) {
c := D.FromDocker(&types.Container{
c := D.FromDocker(&container.SummaryTrimmed{
Names: dummyNames,
State: "running",
Labels: map[string]string{
@ -204,7 +205,7 @@ func TestApplyLabelWithRefIndexError(t *testing.T) {
_, err := p.routesFromContainerLabels(c)
ExpectError(t, ErrAliasRefIndexOutOfRange, err)
c = D.FromDocker(&types.Container{
c = D.FromDocker(&container.SummaryTrimmed{
Names: dummyNames,
State: "running",
Labels: map[string]string{
@ -217,7 +218,7 @@ func TestApplyLabelWithRefIndexError(t *testing.T) {
}
func TestDynamicAliases(t *testing.T) {
c := &types.Container{
c := &container.SummaryTrimmed{
Names: []string{"app1"},
State: "running",
Labels: map[string]string{
@ -240,7 +241,7 @@ func TestDynamicAliases(t *testing.T) {
}
func TestDisableHealthCheck(t *testing.T) {
c := &types.Container{
c := &container.SummaryTrimmed{
Names: dummyNames,
State: "running",
Labels: map[string]string{
@ -254,7 +255,7 @@ func TestDisableHealthCheck(t *testing.T) {
}
func TestPublicIPLocalhost(t *testing.T) {
c := &types.Container{Names: dummyNames, State: "running"}
c := &container.SummaryTrimmed{Names: dummyNames, State: "running"}
r, ok := makeRoutes(c)["a"]
ExpectTrue(t, ok)
ExpectEqual(t, r.Container.PublicHostname, "127.0.0.1")
@ -262,7 +263,7 @@ func TestPublicIPLocalhost(t *testing.T) {
}
func TestPublicIPRemote(t *testing.T) {
c := &types.Container{Names: dummyNames, State: "running"}
c := &container.SummaryTrimmed{Names: dummyNames, State: "running"}
raw, ok := makeRoutes(c, testIP)["a"]
ExpectTrue(t, ok)
ExpectEqual(t, raw.Container.PublicHostname, testIP)
@ -270,10 +271,10 @@ func TestPublicIPRemote(t *testing.T) {
}
func TestPrivateIPLocalhost(t *testing.T) {
c := &types.Container{
c := &container.SummaryTrimmed{
Names: dummyNames,
NetworkSettings: &types.SummaryNetworkSettings{
Networks: map[string]*network.EndpointSettings{
NetworkSettings: &container.NetworkSettingsSummaryTrimmed{
Networks: map[string]*struct{ IPAddress string }{
"network": {
IPAddress: testDockerIP,
},
@ -287,11 +288,11 @@ func TestPrivateIPLocalhost(t *testing.T) {
}
func TestPrivateIPRemote(t *testing.T) {
c := &types.Container{
c := &container.SummaryTrimmed{
Names: dummyNames,
State: "running",
NetworkSettings: &types.SummaryNetworkSettings{
Networks: map[string]*network.EndpointSettings{
NetworkSettings: &container.NetworkSettingsSummaryTrimmed{
Networks: map[string]*struct{ IPAddress string }{
"network": {
IPAddress: testDockerIP,
},
@ -309,11 +310,11 @@ func TestStreamDefaultValues(t *testing.T) {
privPort := uint16(1234)
pubPort := uint16(4567)
privIP := "172.17.0.123"
cont := &types.Container{
cont := &container.SummaryTrimmed{
Names: []string{"a"},
State: "running",
NetworkSettings: &types.SummaryNetworkSettings{
Networks: map[string]*network.EndpointSettings{
NetworkSettings: &container.NetworkSettingsSummaryTrimmed{
Networks: map[string]*struct{ IPAddress string }{
"network": {
IPAddress: privIP,
},
@ -346,7 +347,7 @@ func TestStreamDefaultValues(t *testing.T) {
}
func TestExplicitExclude(t *testing.T) {
r, ok := makeRoutes(&types.Container{
r, ok := makeRoutes(&container.SummaryTrimmed{
Names: dummyNames,
Labels: map[string]string{
D.LabelAliases: "a",
@ -360,17 +361,17 @@ func TestExplicitExclude(t *testing.T) {
func TestImplicitExcludeDatabase(t *testing.T) {
t.Run("mount path detection", func(t *testing.T) {
r, ok := makeRoutes(&types.Container{
r, ok := makeRoutes(&container.SummaryTrimmed{
Names: dummyNames,
Mounts: []types.MountPoint{
{Source: "/data", Destination: "/var/lib/postgresql/data"},
Mounts: []container.MountPointTrimmed{
{Destination: "/var/lib/postgresql/data"},
},
})["a"]
ExpectTrue(t, ok)
ExpectTrue(t, r.ShouldExclude())
})
t.Run("exposed port detection", func(t *testing.T) {
r, ok := makeRoutes(&types.Container{
r, ok := makeRoutes(&container.SummaryTrimmed{
Names: dummyNames,
Ports: []types.Port{
{Type: "tcp", PrivatePort: 5432, PublicPort: 5432},

View file

@ -8,10 +8,8 @@ import (
"github.com/yusing/go-proxy/agent/pkg/agentproxy"
"github.com/yusing/go-proxy/internal/api/v1/favicon"
"github.com/yusing/go-proxy/internal/common"
"github.com/yusing/go-proxy/internal/docker"
"github.com/yusing/go-proxy/internal/docker/idlewatcher"
"github.com/yusing/go-proxy/internal/gperr"
"github.com/yusing/go-proxy/internal/logging"
"github.com/yusing/go-proxy/internal/idlewatcher"
gphttp "github.com/yusing/go-proxy/internal/net/gphttp"
"github.com/yusing/go-proxy/internal/net/gphttp/accesslog"
"github.com/yusing/go-proxy/internal/net/gphttp/loadbalancer"
@ -60,7 +58,7 @@ func NewReverseProxyRoute(base *Route) (*ReveseProxyRoute, gperr.Error) {
}
}
service := base.TargetName()
service := base.Name()
rp := reverseproxy.NewReverseProxy(service, proxyURL, trans)
if len(base.Middlewares) > 0 {
@ -91,38 +89,24 @@ func NewReverseProxyRoute(base *Route) (*ReveseProxyRoute, gperr.Error) {
return r, nil
}
func (r *ReveseProxyRoute) String() string {
return r.TargetName()
}
// Start implements task.TaskStarter.
func (r *ReveseProxyRoute) Start(parent task.Parent) gperr.Error {
if existing, ok := routes.GetHTTPRoute(r.TargetName()); ok && !r.UseLoadBalance() {
if existing, ok := routes.HTTP.Get(r.Key()); ok && !r.UseLoadBalance() {
return gperr.Errorf("route already exists: from provider %s and %s", existing.ProviderName(), r.ProviderName())
}
r.task = parent.Subtask("http."+r.TargetName(), false)
r.task = parent.Subtask("http."+r.Name(), false)
switch {
case r.UseIdleWatcher():
waker, err := idlewatcher.NewHTTPWaker(parent, r, r.rp)
waker, err := idlewatcher.NewWatcher(parent, r)
if err != nil {
r.task.Finish(err)
return err
return gperr.Wrap(err)
}
r.handler = waker
r.HealthMon = waker
case r.UseHealthCheck():
if r.IsDocker() {
client, err := docker.NewClient(r.Container.DockerHost)
if err == nil {
fallback := r.newHealthMonitor()
r.HealthMon = monitor.NewDockerHealthMonitor(client, r.Container.ContainerID, r.TargetName(), r.HealthCheck, fallback)
r.task.OnCancel("close_docker_client", client.Close)
}
}
if r.HealthMon == nil {
r.HealthMon = r.newHealthMonitor()
}
r.HealthMon = monitor.NewMonitor(r)
}
if r.UseAccessLog() {
@ -134,32 +118,8 @@ func (r *ReveseProxyRoute) Start(parent task.Parent) gperr.Error {
}
}
if r.handler == nil {
pathPatterns := r.PathPatterns
switch {
case len(pathPatterns) == 0:
r.handler = r.rp
case len(pathPatterns) == 1 && pathPatterns[0] == "/":
r.handler = r.rp
default:
logging.Warn().
Str("route", r.TargetName()).
Msg("`path_patterns` for reverse proxy is deprecated. Use `rules` instead.")
mux := gphttp.NewServeMux()
patErrs := gperr.NewBuilder("invalid path pattern(s)")
for _, p := range pathPatterns {
patErrs.Add(mux.HandleFunc(p, r.rp.HandlerFunc))
}
if err := patErrs.Error(); err != nil {
r.task.Finish(err)
return err
}
r.handler = mux
}
}
if len(r.Rules) > 0 {
r.handler = r.Rules.BuildHandler(r.TargetName(), r.handler)
r.handler = r.Rules.BuildHandler(r.Name(), r.handler)
}
if r.HealthMon != nil {
@ -169,7 +129,7 @@ func (r *ReveseProxyRoute) Start(parent task.Parent) gperr.Error {
}
if common.PrometheusEnabled {
metricsLogger := metricslogger.NewMetricsLogger(r.TargetName())
metricsLogger := metricslogger.NewMetricsLogger(r.Name())
r.handler = metricsLogger.GetHandler(r.handler)
r.task.OnCancel("reset_metrics", metricsLogger.ResetMetrics)
}
@ -177,9 +137,9 @@ func (r *ReveseProxyRoute) Start(parent task.Parent) gperr.Error {
if r.UseLoadBalance() {
r.addToLoadBalancer(parent)
} else {
routes.SetHTTPRoute(r.TargetName(), r)
r.task.OnCancel("entrypoint_remove_route", func() {
routes.DeleteHTTPRoute(r.TargetName())
routes.HTTP.Add(r)
r.task.OnFinished("entrypoint_remove_route", func() {
routes.HTTP.Del(r)
})
}
@ -205,21 +165,10 @@ func (r *ReveseProxyRoute) HealthMonitor() health.HealthMonitor {
return r.HealthMon
}
func (r *ReveseProxyRoute) newHealthMonitor() interface {
health.HealthMonitor
health.HealthChecker
} {
if a := r.Agent(); a != nil {
target := monitor.AgentTargetFromURL(r.ProxyURL)
return monitor.NewAgentProxiedMonitor(a, r.HealthCheck, target)
}
return monitor.NewHTTPHealthMonitor(r.ProxyURL, r.HealthCheck)
}
func (r *ReveseProxyRoute) addToLoadBalancer(parent task.Parent) {
var lb *loadbalancer.LoadBalancer
cfg := r.LoadBalance
l, ok := routes.GetHTTPRoute(cfg.Link)
l, ok := routes.HTTP.Get(cfg.Link)
var linked *ReveseProxyRoute
if ok {
linked = l.(*ReveseProxyRoute)
@ -240,7 +189,10 @@ func (r *ReveseProxyRoute) addToLoadBalancer(parent task.Parent) {
loadBalancer: lb,
handler: lb,
}
routes.SetHTTPRoute(cfg.Link, linked)
routes.HTTP.Add(linked)
r.task.OnFinished("entrypoint_remove_route", func() {
routes.HTTP.Del(linked)
})
}
r.loadBalancer = lb

View file

@ -1,17 +1,22 @@
package route
import (
"context"
"fmt"
"strings"
"time"
"github.com/docker/docker/api/types/container"
"github.com/yusing/go-proxy/agent/pkg/agent"
"github.com/yusing/go-proxy/internal"
"github.com/yusing/go-proxy/internal/docker"
idlewatcher "github.com/yusing/go-proxy/internal/docker/idlewatcher/types"
"github.com/yusing/go-proxy/internal/gperr"
"github.com/yusing/go-proxy/internal/homepage"
idlewatcher "github.com/yusing/go-proxy/internal/idlewatcher/types"
"github.com/yusing/go-proxy/internal/logging"
netutils "github.com/yusing/go-proxy/internal/net"
net "github.com/yusing/go-proxy/internal/net/types"
"github.com/yusing/go-proxy/internal/proxmox"
"github.com/yusing/go-proxy/internal/task"
"github.com/yusing/go-proxy/internal/utils/strutils"
"github.com/yusing/go-proxy/internal/watcher/health"
@ -20,8 +25,9 @@ import (
config "github.com/yusing/go-proxy/internal/config/types"
"github.com/yusing/go-proxy/internal/net/gphttp/accesslog"
loadbalance "github.com/yusing/go-proxy/internal/net/gphttp/loadbalancer/types"
"github.com/yusing/go-proxy/internal/route/routes"
"github.com/yusing/go-proxy/internal/route/rules"
"github.com/yusing/go-proxy/internal/route/types"
route "github.com/yusing/go-proxy/internal/route/types"
"github.com/yusing/go-proxy/internal/utils"
)
@ -30,12 +36,12 @@ type (
_ utils.NoCopy
Alias string `json:"alias"`
Scheme types.Scheme `json:"scheme,omitempty"`
Scheme route.Scheme `json:"scheme,omitempty"`
Host string `json:"host,omitempty"`
Port types.Port `json:"port,omitempty"`
Port route.Port `json:"port,omitempty"`
Root string `json:"root,omitempty"`
types.HTTPConfig
route.HTTPConfig
PathPatterns []string `json:"path_patterns,omitempty"`
Rules rules.Rules `json:"rules,omitempty" validate:"omitempty,unique=Name"`
HealthCheck *health.HealthCheckConfig `json:"healthcheck,omitempty"`
@ -44,6 +50,8 @@ type (
Homepage *homepage.ItemConfig `json:"homepage,omitempty"`
AccessLog *accesslog.Config `json:"access_log,omitempty"`
Idlewatcher *idlewatcher.Config `json:"idlewatcher,omitempty"`
Metadata `deserialize:"-"`
}
@ -53,17 +61,18 @@ type (
Provider string `json:"provider,omitempty"`
// private fields
LisURL *net.URL `json:"lurl,omitempty"`
ProxyURL *net.URL `json:"purl,omitempty"`
Idlewatcher *idlewatcher.Config `json:"idlewatcher,omitempty"`
LisURL *net.URL `json:"lurl,omitempty"`
ProxyURL *net.URL `json:"purl,omitempty"`
impl types.Route
impl routes.Route
isValidated bool
lastError gperr.Error
}
Routes map[string]*Route
)
const DefaultHost = "localhost"
func (r Routes) Contains(alias string) bool {
_, ok := r[alias]
return ok
@ -76,12 +85,79 @@ func (r *Route) Validate() gperr.Error {
r.isValidated = true
r.Finalize()
if r.Idlewatcher != nil && r.Idlewatcher.Proxmox != nil {
node := r.Idlewatcher.Proxmox.Node
vmid := r.Idlewatcher.Proxmox.VMID
if node == "" {
return gperr.Errorf("node (proxmox node name) is required")
}
if vmid <= 0 {
return gperr.Errorf("vmid (lxc id) is required")
}
if r.Host == DefaultHost {
containerName := r.Idlewatcher.ContainerName()
// get ip addresses of the vmid
node, ok := proxmox.Nodes.Get(node)
if !ok {
return gperr.Errorf("proxmox node %s not found in pool", node)
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
ips, err := node.LXCGetIPs(ctx, vmid)
if err != nil {
return gperr.Errorf("failed to get ip addresses of vmid %d: %w", vmid, err)
}
if len(ips) == 0 {
return gperr.Multiline().
Addf("no ip addresses found for %s", containerName).
Adds("make sure you have set static ip address for container instead of dhcp").
Subject(containerName)
}
l := logging.With().Str("container", containerName).Logger()
l.Info().Msg("checking if container is running")
running, err := node.LXCIsRunning(ctx, vmid)
if err != nil {
return gperr.New("failed to check container state").With(err)
}
if !running {
l.Info().Msg("starting container")
if err := node.LXCAction(ctx, vmid, proxmox.LXCStart); err != nil {
return gperr.New("failed to start container").With(err)
}
}
l.Info().Msgf("finding reachable ip addresses")
errs := gperr.NewBuilder("failed to find reachable ip addresses")
for _, ip := range ips {
if err := netutils.PingTCP(ctx, ip, r.Port.Proxy); err != nil {
errs.Add(gperr.Unwrap(err).Subjectf("%s:%d", ip, r.Port.Proxy))
} else {
r.Host = ip.String()
l.Info().Msgf("using ip %s", r.Host)
break
}
}
if r.Host == DefaultHost {
return gperr.Multiline().
Addf("no reachable ip addresses found, tried %d IPs", len(ips)).
With(errs.Error()).
Subject(containerName)
}
}
}
// return error if route is localhost:<godoxy_port>
switch r.Host {
case "localhost", "127.0.0.1":
switch r.Port.Proxy {
case common.ProxyHTTPPort, common.ProxyHTTPSPort, common.APIHTTPPort:
if r.Scheme.IsReverseProxy() || r.Scheme == types.SchemeTCP {
if r.Scheme.IsReverseProxy() || r.Scheme == route.SchemeTCP {
return gperr.Errorf("localhost:%d is reserved for godoxy", r.Port.Proxy)
}
}
@ -89,29 +165,27 @@ func (r *Route) Validate() gperr.Error {
errs := gperr.NewBuilder("entry validation failed")
var impl types.Route
var impl routes.Route
var err gperr.Error
switch r.Scheme {
case types.SchemeFileServer:
if r.Scheme == route.SchemeFileServer {
r.impl, err = NewFileServer(r)
if err != nil {
errs.Add(err)
}
case types.SchemeHTTP, types.SchemeHTTPS:
if r.Port.Listening != 0 {
errs.Addf("unexpected listening port for %s scheme", r.Scheme)
}
fallthrough
case types.SchemeTCP, types.SchemeUDP:
r.LisURL = gperr.Collect(errs, net.ParseURL, fmt.Sprintf("%s://:%d", r.Scheme, r.Port.Listening))
fallthrough
default:
if r.LoadBalance != nil && r.LoadBalance.Link == "" {
r.LoadBalance = nil
r.ProxyURL = gperr.Collect(errs, net.ParseURL, "file://"+r.Root)
r.Host = ""
r.Port.Proxy = 0
} else {
switch r.Scheme {
case route.SchemeHTTP, route.SchemeHTTPS:
if r.Port.Listening != 0 {
errs.Addf("unexpected listening port for %s scheme", r.Scheme)
}
case route.SchemeTCP, route.SchemeUDP:
r.LisURL = gperr.Collect(errs, net.ParseURL, fmt.Sprintf("%s://:%d", r.Scheme, r.Port.Listening))
}
r.ProxyURL = gperr.Collect(errs, net.ParseURL, fmt.Sprintf("%s://%s:%d", r.Scheme, r.Host, r.Port.Proxy))
r.Idlewatcher = gperr.Collect(errs, idlewatcher.ValidateConfig, r.Container)
}
if !r.UseHealthCheck() && (r.UseLoadBalance() || r.UseIdleWatcher()) {
@ -120,15 +194,15 @@ func (r *Route) Validate() gperr.Error {
if errs.HasError() {
r.lastError = errs.Error()
return r.lastError
return errs.Error()
}
switch r.Scheme {
case types.SchemeFileServer:
case route.SchemeFileServer:
impl, err = NewFileServer(r)
case types.SchemeHTTP, types.SchemeHTTPS:
case route.SchemeHTTP, route.SchemeHTTPS:
impl, err = NewReverseProxyRoute(r)
case types.SchemeTCP, types.SchemeUDP:
case route.SchemeTCP, route.SchemeUDP:
impl, err = NewStreamRoute(r)
default:
panic(fmt.Errorf("unexpected scheme %s for alias %s", r.Scheme, r.Alias))
@ -167,20 +241,33 @@ func (r *Route) ProviderName() string {
return r.Provider
}
func (r *Route) TargetName() string {
return r.Alias
}
func (r *Route) TargetURL() *net.URL {
return r.ProxyURL
}
func (r *Route) Type() types.RouteType {
func (r *Route) Reference() string {
if r.Container != nil {
return r.Container.Image.Name
}
return r.Alias
}
// Name implements pool.Object.
func (r *Route) Name() string {
return r.Alias
}
// Key implements pool.Object.
func (r *Route) Key() string {
return r.Alias
}
func (r *Route) Type() route.RouteType {
switch r.Scheme {
case types.SchemeHTTP, types.SchemeHTTPS, types.SchemeFileServer:
return types.RouteTypeHTTP
case types.SchemeTCP, types.SchemeUDP:
return types.RouteTypeStream
case route.SchemeHTTP, route.SchemeHTTPS, route.SchemeFileServer:
return route.RouteTypeHTTP
case route.SchemeTCP, route.SchemeUDP:
return route.RouteTypeStream
}
panic(fmt.Errorf("unexpected scheme %s for alias %s", r.Scheme, r.Alias))
}
@ -301,7 +388,7 @@ func (r *Route) Finalize() {
scheme, port, ok := getSchemePortByImageName(cont.Image.Name)
if ok {
if r.Scheme == "" {
r.Scheme = types.Scheme(scheme)
r.Scheme = route.Scheme(scheme)
}
if pp == 0 {
pp = port
@ -311,7 +398,7 @@ func (r *Route) Finalize() {
if scheme, port, ok := getSchemePortByAlias(r.Alias); ok {
if r.Scheme == "" {
r.Scheme = types.Scheme(scheme)
r.Scheme = route.Scheme(scheme)
}
if pp == 0 {
pp = port
@ -379,18 +466,6 @@ func (r *Route) Finalize() {
r.HealthCheck.Timeout = common.HealthCheckTimeoutDefault
}
}
if isDocker && cont.IdleTimeout != "" {
if cont.WakeTimeout == "" {
cont.WakeTimeout = common.WakeTimeoutDefault
}
if cont.StopTimeout == "" {
cont.StopTimeout = common.StopTimeoutDefault
}
if cont.StopMethod == "" {
cont.StopMethod = common.StopMethodDefault
}
}
}
func (r *Route) FinalizeHomepageConfig() {

View file

@ -3,59 +3,45 @@ package route
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/yusing/go-proxy/internal/common"
"github.com/yusing/go-proxy/internal/docker"
loadbalance "github.com/yusing/go-proxy/internal/net/gphttp/loadbalancer/types"
"github.com/yusing/go-proxy/internal/route/types"
route "github.com/yusing/go-proxy/internal/route/types"
expect "github.com/yusing/go-proxy/internal/utils/testing"
"github.com/yusing/go-proxy/internal/watcher/health"
)
func TestRouteValidate(t *testing.T) {
t.Run("AlreadyValidated", func(t *testing.T) {
r := &Route{
Alias: "test",
Scheme: types.SchemeHTTP,
Host: "example.com",
Port: types.Port{Proxy: 80},
Metadata: Metadata{
isValidated: true,
},
}
err := r.Validate()
require.NoError(t, err, "Validate should return nil for already validated route")
})
t.Run("ReservedPort", func(t *testing.T) {
r := &Route{
Alias: "test",
Scheme: types.SchemeHTTP,
Scheme: route.SchemeHTTP,
Host: "localhost",
Port: types.Port{Proxy: common.ProxyHTTPPort},
Port: route.Port{Proxy: common.ProxyHTTPPort},
}
err := r.Validate()
require.Error(t, err, "Validate should return error for localhost with reserved port")
require.Contains(t, err.Error(), "reserved for godoxy")
expect.HasError(t, err, "Validate should return error for localhost with reserved port")
expect.ErrorContains(t, err, "reserved for godoxy")
})
t.Run("ListeningPortWithHTTP", func(t *testing.T) {
r := &Route{
Alias: "test",
Scheme: types.SchemeHTTP,
Scheme: route.SchemeHTTP,
Host: "example.com",
Port: types.Port{Proxy: 80, Listening: 1234},
Port: route.Port{Proxy: 80, Listening: 1234},
}
err := r.Validate()
require.Error(t, err, "Validate should return error for HTTP scheme with listening port")
require.Contains(t, err.Error(), "unexpected listening port")
expect.HasError(t, err, "Validate should return error for HTTP scheme with listening port")
expect.ErrorContains(t, err, "unexpected listening port")
})
t.Run("DisabledHealthCheckWithLoadBalancer", func(t *testing.T) {
r := &Route{
Alias: "test",
Scheme: types.SchemeHTTP,
Scheme: route.SchemeHTTP,
Host: "example.com",
Port: types.Port{Proxy: 80},
Port: route.Port{Proxy: 80},
HealthCheck: &health.HealthCheckConfig{
Disable: true,
},
@ -64,53 +50,53 @@ func TestRouteValidate(t *testing.T) {
}, // Minimal LoadBalance config with non-empty Link will be checked by UseLoadBalance
}
err := r.Validate()
require.Error(t, err, "Validate should return error for disabled healthcheck with loadbalancer")
require.Contains(t, err.Error(), "cannot disable healthcheck")
expect.HasError(t, err, "Validate should return error for disabled healthcheck with loadbalancer")
expect.ErrorContains(t, err, "cannot disable healthcheck")
})
t.Run("FileServerScheme", func(t *testing.T) {
r := &Route{
Alias: "test",
Scheme: types.SchemeFileServer,
Scheme: route.SchemeFileServer,
Host: "example.com",
Port: types.Port{Proxy: 80},
Port: route.Port{Proxy: 80},
Root: "/tmp", // Root is required for file server
}
err := r.Validate()
require.NoError(t, err, "Validate should not return error for valid file server route")
require.NotNil(t, r.impl, "Impl should be initialized")
expect.NoError(t, err, "Validate should not return error for valid file server route")
expect.NotNil(t, r.impl, "Impl should be initialized")
})
t.Run("HTTPScheme", func(t *testing.T) {
r := &Route{
Alias: "test",
Scheme: types.SchemeHTTP,
Scheme: route.SchemeHTTP,
Host: "example.com",
Port: types.Port{Proxy: 80},
Port: route.Port{Proxy: 80},
}
err := r.Validate()
require.NoError(t, err, "Validate should not return error for valid HTTP route")
require.NotNil(t, r.impl, "Impl should be initialized")
expect.NoError(t, err, "Validate should not return error for valid HTTP route")
expect.NotNil(t, r.impl, "Impl should be initialized")
})
t.Run("TCPScheme", func(t *testing.T) {
r := &Route{
Alias: "test",
Scheme: types.SchemeTCP,
Scheme: route.SchemeTCP,
Host: "example.com",
Port: types.Port{Proxy: 80, Listening: 8080},
Port: route.Port{Proxy: 80, Listening: 8080},
}
err := r.Validate()
require.NoError(t, err, "Validate should not return error for valid TCP route")
require.NotNil(t, r.impl, "Impl should be initialized")
expect.NoError(t, err, "Validate should not return error for valid TCP route")
expect.NotNil(t, r.impl, "Impl should be initialized")
})
t.Run("DockerContainer", func(t *testing.T) {
r := &Route{
Alias: "test",
Scheme: types.SchemeHTTP,
Scheme: route.SchemeHTTP,
Host: "example.com",
Port: types.Port{Proxy: 80},
Port: route.Port{Proxy: 80},
Metadata: Metadata{
Container: &docker.Container{
ContainerID: "test-id",
@ -121,8 +107,8 @@ func TestRouteValidate(t *testing.T) {
},
}
err := r.Validate()
require.NoError(t, err, "Validate should not return error for valid docker container route")
require.NotNil(t, r.ProxyURL, "ProxyURL should be set")
expect.NoError(t, err, "Validate should not return error for valid docker container route")
expect.NotNil(t, r.ProxyURL, "ProxyURL should be set")
})
t.Run("InvalidScheme", func(t *testing.T) {
@ -130,9 +116,9 @@ func TestRouteValidate(t *testing.T) {
Alias: "test",
Scheme: "invalid",
Host: "example.com",
Port: types.Port{Proxy: 80},
Port: route.Port{Proxy: 80},
}
require.Panics(t, func() {
expect.Panics(t, func() {
_ = r.Validate()
}, "Validate should panic for invalid scheme")
})
@ -140,14 +126,13 @@ func TestRouteValidate(t *testing.T) {
t.Run("ModifiedFields", func(t *testing.T) {
r := &Route{
Alias: "test",
Scheme: types.SchemeHTTP,
Scheme: route.SchemeHTTP,
Host: "example.com",
Port: types.Port{Proxy: 80},
Port: route.Port{Proxy: 80},
}
err := r.Validate()
require.NoError(t, err)
require.True(t, r.isValidated)
require.NotNil(t, r.ProxyURL)
require.NotNil(t, r.HealthCheck)
expect.NoError(t, err)
expect.NotNil(t, r.ProxyURL)
expect.NotNil(t, r.HealthCheck)
})
}

View file

@ -1,15 +1,14 @@
package routequery
package routes
import (
"time"
"github.com/yusing/go-proxy/internal/homepage"
"github.com/yusing/go-proxy/internal/route/routes"
route "github.com/yusing/go-proxy/internal/route/types"
"github.com/yusing/go-proxy/internal/watcher/health"
)
func getHealthInfo(r route.Route) map[string]string {
func getHealthInfo(r Route) map[string]string {
mon := r.HealthMonitor()
if mon == nil {
return map[string]string{
@ -26,11 +25,11 @@ func getHealthInfo(r route.Route) map[string]string {
}
type HealthInfoRaw struct {
Status health.Status
Latency time.Duration
Status health.Status `json:"status,string"`
Latency time.Duration `json:"latency"`
}
func getHealthInfoRaw(r route.Route) *HealthInfoRaw {
func getHealthInfoRaw(r Route) *HealthInfoRaw {
mon := r.HealthMonitor()
if mon == nil {
return &HealthInfoRaw{
@ -45,69 +44,69 @@ func getHealthInfoRaw(r route.Route) *HealthInfoRaw {
}
func HealthMap() map[string]map[string]string {
healthMap := make(map[string]map[string]string, routes.NumRoutes())
routes.RangeRoutes(func(alias string, r route.Route) {
healthMap := make(map[string]map[string]string, NumRoutes())
for alias, r := range Iter {
healthMap[alias] = getHealthInfo(r)
})
}
return healthMap
}
func HealthInfo() map[string]*HealthInfoRaw {
healthMap := make(map[string]*HealthInfoRaw, routes.NumRoutes())
routes.RangeRoutes(func(alias string, r route.Route) {
healthMap := make(map[string]*HealthInfoRaw, NumRoutes())
for alias, r := range Iter {
healthMap[alias] = getHealthInfoRaw(r)
})
}
return healthMap
}
func HomepageCategories() []string {
check := make(map[string]struct{})
categories := make([]string, 0)
routes.GetHTTPRoutes().RangeAll(func(alias string, r route.HTTPRoute) {
for _, r := range HTTP.Iter {
item := r.HomepageConfig()
if item == nil || item.Category == "" {
return
continue
}
if _, ok := check[item.Category]; ok {
return
continue
}
check[item.Category] = struct{}{}
categories = append(categories, item.Category)
})
}
return categories
}
func HomepageConfig(categoryFilter, providerFilter string) homepage.Homepage {
hp := make(homepage.Homepage)
routes.GetHTTPRoutes().RangeAll(func(alias string, r route.HTTPRoute) {
for _, r := range HTTP.Iter {
if providerFilter != "" && r.ProviderName() != providerFilter {
return
continue
}
item := r.HomepageItem()
if categoryFilter != "" && item.Category != categoryFilter {
return
continue
}
hp.Add(item)
})
}
return hp
}
func RoutesByAlias(typeFilter ...route.RouteType) map[string]route.Route {
rts := make(map[string]route.Route)
func ByAlias(typeFilter ...route.RouteType) map[string]Route {
rts := make(map[string]Route)
if len(typeFilter) == 0 || typeFilter[0] == "" {
typeFilter = []route.RouteType{route.RouteTypeHTTP, route.RouteTypeStream}
}
for _, t := range typeFilter {
switch t {
case route.RouteTypeHTTP:
routes.GetHTTPRoutes().RangeAll(func(alias string, r route.HTTPRoute) {
for alias, r := range HTTP.Iter {
rts[alias] = r
})
}
case route.RouteTypeStream:
routes.GetStreamRoutes().RangeAll(func(alias string, r route.StreamRoute) {
for alias, r := range Stream.Iter {
rts[alias] = r
})
}
}
}
return rts

View file

@ -1,17 +1,19 @@
package types
package routes
import (
"net/http"
"github.com/yusing/go-proxy/agent/pkg/agent"
"github.com/yusing/go-proxy/internal/docker"
idlewatcher "github.com/yusing/go-proxy/internal/docker/idlewatcher/types"
"github.com/yusing/go-proxy/internal/homepage"
idlewatcher "github.com/yusing/go-proxy/internal/idlewatcher/types"
net "github.com/yusing/go-proxy/internal/net/types"
"github.com/yusing/go-proxy/internal/task"
"github.com/yusing/go-proxy/internal/utils/pool"
"github.com/yusing/go-proxy/internal/watcher/health"
loadbalance "github.com/yusing/go-proxy/internal/net/gphttp/loadbalancer/types"
"github.com/yusing/go-proxy/internal/net/gphttp/reverseproxy"
)
type (
@ -19,10 +21,11 @@ type (
Route interface {
task.TaskStarter
task.TaskFinisher
pool.Object
ProviderName() string
TargetName() string
TargetURL() *net.URL
HealthMonitor() health.HealthMonitor
Reference() string
Started() bool
@ -46,6 +49,10 @@ type (
Route
http.Handler
}
ReverseProxyRoute interface {
HTTPRoute
ReverseProxy() *reverseproxy.ReverseProxy
}
StreamRoute interface {
Route
net.Stream

View file

@ -1,78 +1,49 @@
package routes
import (
"github.com/yusing/go-proxy/internal/route/types"
F "github.com/yusing/go-proxy/internal/utils/functional"
"github.com/yusing/go-proxy/internal/utils/pool"
)
var (
httpRoutes = F.NewMapOf[string, types.HTTPRoute]()
streamRoutes = F.NewMapOf[string, types.StreamRoute]()
HTTP = pool.New[HTTPRoute]("http_routes")
Stream = pool.New[StreamRoute]("stream_routes")
)
func RangeRoutes(callback func(alias string, r types.Route)) {
httpRoutes.RangeAll(func(alias string, r types.HTTPRoute) {
callback(alias, r)
})
streamRoutes.RangeAll(func(alias string, r types.StreamRoute) {
callback(alias, r)
})
func Iter(yield func(alias string, r Route) bool) {
for k, r := range HTTP.Iter {
if !yield(k, r) {
break
}
}
for k, r := range Stream.Iter {
if !yield(k, r) {
break
}
}
}
func NumRoutes() int {
return httpRoutes.Size() + streamRoutes.Size()
return HTTP.Size() + Stream.Size()
}
func GetHTTPRoutes() F.Map[string, types.HTTPRoute] {
return httpRoutes
func Clear() {
HTTP.Clear()
Stream.Clear()
}
func GetStreamRoutes() F.Map[string, types.StreamRoute] {
return streamRoutes
}
func GetHTTPRouteOrExact(alias, host string) (types.HTTPRoute, bool) {
r, ok := httpRoutes.Load(alias)
func GetHTTPRouteOrExact(alias, host string) (HTTPRoute, bool) {
r, ok := HTTP.Get(alias)
if ok {
return r, true
}
// try find with exact match
return httpRoutes.Load(host)
return HTTP.Get(host)
}
func GetHTTPRoute(alias string) (types.HTTPRoute, bool) {
return httpRoutes.Load(alias)
}
func GetStreamRoute(alias string) (types.StreamRoute, bool) {
return streamRoutes.Load(alias)
}
func GetRoute(alias string) (types.Route, bool) {
r, ok := httpRoutes.Load(alias)
func Get(alias string) (Route, bool) {
r, ok := HTTP.Get(alias)
if ok {
return r, true
}
return streamRoutes.Load(alias)
}
func SetHTTPRoute(alias string, r types.HTTPRoute) {
httpRoutes.Store(alias, r)
}
func SetStreamRoute(alias string, r types.StreamRoute) {
streamRoutes.Store(alias, r)
}
func DeleteHTTPRoute(alias string) {
httpRoutes.Delete(alias)
}
func DeleteStreamRoute(alias string) {
streamRoutes.Delete(alias)
}
func TestClear() {
httpRoutes = F.NewMapOf[string, types.HTTPRoute]()
streamRoutes = F.NewMapOf[string, types.StreamRoute]()
return Stream.Get(alias)
}

View file

@ -5,13 +5,11 @@ import (
"errors"
"github.com/rs/zerolog"
"github.com/yusing/go-proxy/internal/docker"
"github.com/yusing/go-proxy/internal/docker/idlewatcher"
"github.com/yusing/go-proxy/internal/gperr"
"github.com/yusing/go-proxy/internal/idlewatcher"
"github.com/yusing/go-proxy/internal/logging"
net "github.com/yusing/go-proxy/internal/net/types"
"github.com/yusing/go-proxy/internal/route/routes"
route "github.com/yusing/go-proxy/internal/route/types"
"github.com/yusing/go-proxy/internal/task"
"github.com/yusing/go-proxy/internal/watcher/health"
"github.com/yusing/go-proxy/internal/watcher/health/monitor"
@ -30,53 +28,36 @@ type StreamRoute struct {
l zerolog.Logger
}
func NewStreamRoute(base *Route) (route.Route, gperr.Error) {
func NewStreamRoute(base *Route) (routes.Route, gperr.Error) {
// TODO: support non-coherent scheme
return &StreamRoute{
Route: base,
l: logging.With().
Str("type", string(base.Scheme)).
Str("name", base.TargetName()).
Str("name", base.Name()).
Logger(),
}, nil
}
func (r *StreamRoute) String() string {
return "stream " + r.TargetName()
}
// Start implements task.TaskStarter.
func (r *StreamRoute) Start(parent task.Parent) gperr.Error {
if existing, ok := routes.GetStreamRoute(r.TargetName()); ok {
if existing, ok := routes.Stream.Get(r.Key()); ok {
return gperr.Errorf("route already exists: from provider %s and %s", existing.ProviderName(), r.ProviderName())
}
r.task = parent.Subtask("stream." + r.TargetName())
r.task = parent.Subtask("stream." + r.Name())
r.Stream = NewStream(r)
parent.OnCancel("finish", func() {
r.task.Finish(nil)
})
switch {
case r.UseIdleWatcher():
waker, err := idlewatcher.NewStreamWaker(parent, r, r.Stream)
waker, err := idlewatcher.NewWatcher(parent, r)
if err != nil {
r.task.Finish(err)
return err
return gperr.Wrap(err, "idlewatcher error")
}
r.Stream = waker
r.HealthMon = waker
case r.UseHealthCheck():
if r.IsDocker() {
client, err := docker.NewClient(r.Container.DockerHost)
if err == nil {
fallback := monitor.NewRawHealthChecker(r.TargetURL(), r.HealthCheck)
r.HealthMon = monitor.NewDockerHealthMonitor(client, r.Container.ContainerID, r.TargetName(), r.HealthCheck, fallback)
r.task.OnCancel("close_docker_client", client.Close)
}
}
if r.HealthMon == nil {
r.HealthMon = monitor.NewRawHealthMonitor(r.TargetURL(), r.HealthCheck)
}
r.HealthMon = monitor.NewMonitor(r)
}
if err := r.Stream.Setup(); err != nil {
@ -94,9 +75,9 @@ func (r *StreamRoute) Start(parent task.Parent) gperr.Error {
go r.acceptConnections()
routes.SetStreamRoute(r.TargetName(), r)
r.task.OnCancel("entrypoint_remove_route", func() {
routes.DeleteStreamRoute(r.TargetName())
routes.Stream.Add(r)
r.task.OnFinished("entrypoint_remove_route", func() {
routes.Stream.Del(r)
})
return nil
}

View file

@ -1,4 +1,4 @@
package types
package route
import (
"time"

View file

@ -1,27 +1,27 @@
package types_test
package route_test
import (
"testing"
"time"
. "github.com/yusing/go-proxy/internal/route"
"github.com/yusing/go-proxy/internal/route/types"
route "github.com/yusing/go-proxy/internal/route/types"
"github.com/yusing/go-proxy/internal/utils"
. "github.com/yusing/go-proxy/internal/utils/testing"
expect "github.com/yusing/go-proxy/internal/utils/testing"
)
func TestHTTPConfigDeserialize(t *testing.T) {
tests := []struct {
name string
input map[string]any
expected types.HTTPConfig
expected route.HTTPConfig
}{
{
name: "no_tls_verify",
input: map[string]any{
"no_tls_verify": "true",
},
expected: types.HTTPConfig{
expected: route.HTTPConfig{
NoTLSVerify: true,
},
},
@ -30,7 +30,7 @@ func TestHTTPConfigDeserialize(t *testing.T) {
input: map[string]any{
"response_header_timeout": "1s",
},
expected: types.HTTPConfig{
expected: route.HTTPConfig{
ResponseHeaderTimeout: 1 * time.Second,
},
},
@ -39,11 +39,12 @@ func TestHTTPConfigDeserialize(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := Route{}
tt.input["host"] = "internal"
err := utils.Deserialize(tt.input, &cfg)
if err != nil {
ExpectNoError(t, err)
expect.NoError(t, err)
}
ExpectEqual(t, cfg.HTTPConfig, tt.expected)
expect.Equal(t, cfg.HTTPConfig, tt.expected)
})
}
}

View file

@ -1,4 +1,4 @@
package types
package route
import (
"strconv"

View file

@ -1,4 +1,4 @@
package types
package route
import (
"errors"

View file

@ -1,4 +1,4 @@
package types
package route
type RouteType string

View file

@ -1,4 +1,4 @@
package types
package route
import (
"github.com/yusing/go-proxy/internal/gperr"

View file

@ -0,0 +1,65 @@
package pool
import (
"sort"
"github.com/puzpuzpuz/xsync/v3"
"github.com/yusing/go-proxy/internal/logging"
)
type (
Pool[T Object] struct {
m *xsync.MapOf[string, T]
name string
}
Object interface {
Key() string
Name() string
}
)
func New[T Object](name string) Pool[T] {
return Pool[T]{xsync.NewMapOf[string, T](), name}
}
func (p Pool[T]) Name() string {
return p.name
}
func (p Pool[T]) Add(obj T) {
p.checkExists(obj.Key())
p.m.Store(obj.Key(), obj)
logging.Info().Msgf("%s: added %s", p.name, obj.Name())
}
func (p Pool[T]) Del(obj T) {
p.m.Delete(obj.Key())
logging.Info().Msgf("%s: removed %s", p.name, obj.Name())
}
func (p Pool[T]) Get(key string) (T, bool) {
return p.m.Load(key)
}
func (p Pool[T]) Size() int {
return p.m.Size()
}
func (p Pool[T]) Clear() {
p.m.Clear()
}
func (p Pool[T]) Iter(fn func(k string, v T) bool) {
p.m.Range(fn)
}
func (p Pool[T]) Slice() []T {
slice := make([]T, 0, p.m.Size())
for _, v := range p.m.Range {
slice = append(slice, v)
}
sort.Slice(slice, func(i, j int) bool {
return slice[i].Name() < slice[j].Name()
})
return slice
}

View file

@ -0,0 +1,15 @@
//go:build debug
package pool
import (
"runtime/debug"
"github.com/yusing/go-proxy/internal/logging"
)
func (p Pool[T]) checkExists(key string) {
if _, ok := p.m.Load(key); ok {
logging.Warn().Msgf("%s: key %s already exists\nstacktrace: %s", p.name, key, string(debug.Stack()))
}
}

View file

@ -0,0 +1,7 @@
//go:build !debug
package pool
func (p Pool[T]) checkExists(key string) {
// no-op in production
}

View file

@ -0,0 +1,71 @@
package expect
import (
"os"
"testing"
"github.com/stretchr/testify/require"
"github.com/yusing/go-proxy/internal/common"
)
func init() {
if common.IsTest {
// force verbose output
os.Args = append([]string{os.Args[0], "-test.v"}, os.Args[1:]...)
}
}
func Must[Result any](r Result, err error) Result {
if err != nil {
panic(err)
}
return r
}
var (
NoError = require.NoError
HasError = require.Error
True = require.True
False = require.False
Nil = require.Nil
NotNil = require.NotNil
ErrorContains = require.ErrorContains
Panics = require.Panics
Greater = require.Greater
Less = require.Less
GreaterOrEqual = require.GreaterOrEqual
LessOrEqual = require.LessOrEqual
)
func ErrorIs(t *testing.T, expected error, err error, msgAndArgs ...any) {
t.Helper()
require.ErrorIs(t, err, expected, msgAndArgs...)
}
func ErrorT[T error](t *testing.T, err error, msgAndArgs ...any) {
t.Helper()
var errAs T
require.ErrorAs(t, err, &errAs, msgAndArgs...)
}
func Equal[T any](t *testing.T, got T, want T, msgAndArgs ...any) {
t.Helper()
require.EqualValues(t, want, got, msgAndArgs...)
}
func NotEqual[T any](t *testing.T, got T, want T, msgAndArgs ...any) {
t.Helper()
require.NotEqual(t, want, got, msgAndArgs...)
}
func Contains[T any](t *testing.T, got T, wants []T, msgAndArgs ...any) {
t.Helper()
require.Contains(t, wants, got, msgAndArgs...)
}
func Type[T any](t *testing.T, got any, msgAndArgs ...any) (_ T) {
t.Helper()
_, ok := got.(T)
require.True(t, ok, msgAndArgs...)
return got.(T)
}

View file

@ -0,0 +1,14 @@
package expect
import (
"github.com/bytedance/sonic"
"github.com/yusing/go-proxy/internal/common"
)
func init() {
if common.IsTest {
sonic.ConfigDefault = sonic.Config{
SortMapKeys: true,
}.Froze()
}
}

View file

@ -1,4 +1,4 @@
package utils
package expect
import (
"os"
@ -14,13 +14,6 @@ func init() {
}
}
func Must[Result any](r Result, err error) Result {
if err != nil {
panic(err)
}
return r
}
func ExpectNoError(t *testing.T, err error) {
t.Helper()
require.NoError(t, err)

View file

@ -36,8 +36,8 @@ const (
ActionForceReload
actionContainerWakeMask = ActionContainerCreate | ActionContainerStart | ActionContainerUnpause
actionContainerSleepMask = ActionContainerKill | ActionContainerStop | ActionContainerPause | ActionContainerDie
actionContainerStartMask = ActionContainerCreate | ActionContainerStart | ActionContainerUnpause
actionContainerStopMask = ActionContainerKill | ActionContainerStop | ActionContainerDie
)
const (
@ -83,10 +83,14 @@ func (a Action) String() string {
return actionNameMap[a]
}
func (a Action) IsContainerWake() bool {
return a&actionContainerWakeMask != 0
func (a Action) IsContainerStart() bool {
return a&actionContainerStartMask != 0
}
func (a Action) IsContainerSleep() bool {
return a&actionContainerSleepMask != 0
func (a Action) IsContainerStop() bool {
return a&actionContainerStopMask != 0
}
func (a Action) IsContainerPause() bool {
return a == ActionContainerPause
}

View file

@ -1,34 +1,35 @@
package monitor
package health
import (
"encoding/json"
"net/url"
"strconv"
"time"
net "github.com/yusing/go-proxy/internal/net/types"
"github.com/yusing/go-proxy/internal/utils/strutils"
"github.com/yusing/go-proxy/internal/watcher/health"
)
type JSONRepresentation struct {
Name string
Config *health.HealthCheckConfig
Status health.Status
Config *HealthCheckConfig
Status Status
Started time.Time
Uptime time.Duration
Latency time.Duration
LastSeen time.Time
Detail string
URL *net.URL
URL *url.URL
Extra map[string]any
}
func (jsonRepr *JSONRepresentation) MarshalJSON() ([]byte, error) {
url := jsonRepr.URL.String()
func (jsonRepr *JSONRepresentation) MarshalMap() map[string]any {
var url string
if jsonRepr.URL != nil {
url = jsonRepr.URL.String()
}
if url == "http://:0" {
url = ""
}
return json.Marshal(map[string]any{
return map[string]any{
"name": jsonRepr.Name,
"config": jsonRepr.Config,
"started": jsonRepr.Started.Unix(),
@ -43,5 +44,5 @@ func (jsonRepr *JSONRepresentation) MarshalJSON() ([]byte, error) {
"detail": jsonRepr.Detail,
"url": url,
"extra": jsonRepr.Extra,
})
}
}

View file

@ -7,7 +7,6 @@ import (
"net/url"
agentPkg "github.com/yusing/go-proxy/agent/pkg/agent"
"github.com/yusing/go-proxy/internal/net/types"
"github.com/yusing/go-proxy/internal/watcher/health"
)
@ -24,7 +23,7 @@ type (
}
)
func AgentTargetFromURL(url *types.URL) *AgentCheckHealthTarget {
func AgentTargetFromURL(url *url.URL) *AgentCheckHealthTarget {
return &AgentCheckHealthTarget{
Scheme: url.Scheme,
Host: url.Host,
@ -40,12 +39,12 @@ func (target *AgentCheckHealthTarget) buildQuery() string {
return query.Encode()
}
func (target *AgentCheckHealthTarget) displayURL() *types.URL {
return types.NewURL(&url.URL{
func (target *AgentCheckHealthTarget) displayURL() *url.URL {
return &url.URL{
Scheme: target.Scheme,
Host: target.Host,
Path: target.Path,
})
}
}
func NewAgentProxiedMonitor(agent *agentPkg.AgentConfig, config *health.HealthCheckConfig, target *AgentCheckHealthTarget) *AgentProxiedMonitor {

View file

@ -1,9 +1,9 @@
package monitor
import (
"github.com/docker/docker/api/types/container"
"github.com/yusing/go-proxy/internal/docker"
dockerTypes "github.com/docker/docker/api/types"
"github.com/yusing/go-proxy/internal/watcher/health"
)
@ -25,7 +25,9 @@ func NewDockerHealthMonitor(client *docker.SharedClient, containerID, alias stri
}
func (mon *DockerHealthMonitor) CheckHealth() (result *health.HealthCheckResult, err error) {
cont, err := mon.client.ContainerInspect(mon.task.Context(), mon.containerID)
ctx, cancel := mon.ContextWithTimeout("docker health check timed out")
defer cancel()
cont, err := mon.client.ContainerInspect(ctx, mon.containerID)
if err != nil {
return mon.fallback.CheckHealth()
}
@ -46,7 +48,7 @@ func (mon *DockerHealthMonitor) CheckHealth() (result *health.HealthCheckResult,
return mon.fallback.CheckHealth()
}
result = new(health.HealthCheckResult)
result.Healthy = cont.State.Health.Status == dockerTypes.Healthy
result.Healthy = cont.State.Health.Status == container.Healthy
if len(cont.State.Health.Log) > 0 {
lastLog := cont.State.Health.Log[len(cont.State.Health.Log)-1]
result.Detail = lastLog.Output

View file

@ -4,9 +4,9 @@ import (
"crypto/tls"
"errors"
"net/http"
"net/url"
"time"
"github.com/yusing/go-proxy/internal/net/types"
"github.com/yusing/go-proxy/internal/watcher/health"
"github.com/yusing/go-proxy/pkg"
)
@ -26,7 +26,7 @@ var pinger = &http.Client{
},
}
func NewHTTPHealthMonitor(url *types.URL, config *health.HealthCheckConfig) *HTTPHealthMonitor {
func NewHTTPHealthMonitor(url *url.URL, config *health.HealthCheckConfig) *HTTPHealthMonitor {
mon := new(HTTPHealthMonitor)
mon.monitor = newMonitor(url, config, mon.CheckHealth)
if config.UseGet {
@ -37,10 +37,6 @@ func NewHTTPHealthMonitor(url *types.URL, config *health.HealthCheckConfig) *HTT
return mon
}
func NewHTTPHealthChecker(url *types.URL, config *health.HealthCheckConfig) health.HealthChecker {
return NewHTTPHealthMonitor(url, config)
}
func (mon *HTTPHealthMonitor) CheckHealth() (result *health.HealthCheckResult, err error) {
ctx, cancel := mon.ContextWithTimeout("ping request timed out")
defer cancel()

View file

@ -4,13 +4,14 @@ import (
"context"
"errors"
"fmt"
"net/url"
"time"
"github.com/yusing/go-proxy/internal/docker"
"github.com/yusing/go-proxy/internal/gperr"
"github.com/yusing/go-proxy/internal/logging"
"github.com/yusing/go-proxy/internal/metrics"
"github.com/yusing/go-proxy/internal/net/types"
"github.com/yusing/go-proxy/internal/notif"
"github.com/yusing/go-proxy/internal/route/routes"
"github.com/yusing/go-proxy/internal/task"
"github.com/yusing/go-proxy/internal/utils/atomic"
"github.com/yusing/go-proxy/internal/utils/strutils"
@ -22,7 +23,7 @@ type (
monitor struct {
service string
config *health.HealthCheckConfig
url atomic.Value[*types.URL]
url atomic.Value[*url.URL]
status atomic.Value[health.Status]
lastResult atomic.Value[*health.HealthCheckResult]
@ -30,15 +31,39 @@ type (
checkHealth HealthCheckFunc
startTime time.Time
metric *metrics.Gauge
task *task.Task
}
)
var ErrNegativeInterval = errors.New("negative interval")
func newMonitor(url *types.URL, config *health.HealthCheckConfig, healthCheckFunc HealthCheckFunc) *monitor {
func NewMonitor(r routes.Route) health.HealthMonCheck {
var mon health.HealthMonCheck
if r.IsAgent() {
mon = NewAgentProxiedMonitor(r.Agent(), r.HealthCheckConfig(), AgentTargetFromURL(&r.TargetURL().URL))
} else {
switch r := r.(type) {
case routes.HTTPRoute:
mon = NewHTTPHealthMonitor(&r.TargetURL().URL, r.HealthCheckConfig())
case routes.StreamRoute:
mon = NewRawHealthMonitor(&r.TargetURL().URL, r.HealthCheckConfig())
default:
logging.Panic().Msgf("unexpected route type: %T", r)
}
}
if r.IsDocker() {
cont := r.ContainerInfo()
client, err := docker.NewClient(cont.DockerHost)
if err != nil {
return mon
}
r.Task().OnCancel("close_docker_client", client.Close)
return NewDockerHealthMonitor(client, cont.ContainerID, r.Name(), r.HealthCheckConfig(), mon)
}
return mon
}
func newMonitor(url *url.URL, config *health.HealthCheckConfig, healthCheckFunc HealthCheckFunc) *monitor {
mon := &monitor{
config: config,
checkHealth: healthCheckFunc,
@ -63,7 +88,7 @@ func (mon *monitor) Start(parent task.Parent) gperr.Error {
}
mon.service = parent.Name()
mon.task = parent.Subtask("health_monitor")
mon.task = parent.Subtask("health_monitor", true)
go func() {
logger := logging.With().Str("name", mon.service).Logger()
@ -72,9 +97,6 @@ func (mon *monitor) Start(parent task.Parent) gperr.Error {
if mon.status.Load() != health.StatusError {
mon.status.Store(health.StatusUnknown)
}
if mon.metric != nil {
mon.metric.Reset()
}
mon.task.Finish(nil)
}()
@ -113,12 +135,12 @@ func (mon *monitor) Finish(reason any) {
}
// UpdateURL implements HealthChecker.
func (mon *monitor) UpdateURL(url *types.URL) {
func (mon *monitor) UpdateURL(url *url.URL) {
mon.url.Store(url)
}
// URL implements HealthChecker.
func (mon *monitor) URL() *types.URL {
func (mon *monitor) URL() *url.URL {
return mon.url.Load()
}
@ -157,8 +179,8 @@ func (mon *monitor) String() string {
return mon.Name()
}
// MarshalJSON implements json.Marshaler of HealthMonitor.
func (mon *monitor) MarshalJSON() ([]byte, error) {
// MarshalMap implements health.HealthMonitor.
func (mon *monitor) MarshalMap() map[string]any {
res := mon.lastResult.Load()
if res == nil {
res = &health.HealthCheckResult{
@ -166,7 +188,7 @@ func (mon *monitor) MarshalJSON() ([]byte, error) {
}
}
return (&JSONRepresentation{
return (&health.JSONRepresentation{
Name: mon.service,
Config: mon.config,
Status: mon.status.Load(),
@ -176,7 +198,7 @@ func (mon *monitor) MarshalJSON() ([]byte, error) {
LastSeen: GetLastSeen(mon.service),
Detail: res.Detail,
URL: mon.url.Load(),
}).MarshalJSON()
}).MarshalMap()
}
func (mon *monitor) checkUpdateHealth() error {
@ -230,9 +252,6 @@ func (mon *monitor) checkUpdateHealth() error {
})
}
}
if mon.metric != nil {
mon.metric.Set(float64(status))
}
return nil
}

View file

@ -2,9 +2,9 @@ package monitor
import (
"net"
"net/url"
"time"
"github.com/yusing/go-proxy/internal/net/types"
"github.com/yusing/go-proxy/internal/watcher/health"
)
@ -15,7 +15,7 @@ type (
}
)
func NewRawHealthMonitor(url *types.URL, config *health.HealthCheckConfig) *RawHealthMonitor {
func NewRawHealthMonitor(url *url.URL, config *health.HealthCheckConfig) *RawHealthMonitor {
mon := new(RawHealthMonitor)
mon.monitor = newMonitor(url, config, mon.CheckHealth)
mon.dialer = &net.Dialer{
@ -25,10 +25,6 @@ func NewRawHealthMonitor(url *types.URL, config *health.HealthCheckConfig) *RawH
return mon
}
func NewRawHealthChecker(url *types.URL, config *health.HealthCheckConfig) health.HealthChecker {
return NewRawHealthMonitor(url, config)
}
func (mon *RawHealthMonitor) CheckHealth() (result *health.HealthCheckResult, err error) {
ctx, cancel := mon.ContextWithTimeout("ping request timed out")
defer cancel()

View file

@ -1,7 +1,5 @@
package health
import "encoding/json"
type Status uint8
const (
@ -35,32 +33,6 @@ func (s Status) String() string {
}
}
func (s Status) MarshalJSON() ([]byte, error) {
return []byte(`"` + s.String() + `"`), nil
}
func (s *Status) UnmarshalJSON(data []byte) error {
var str string
if err := json.Unmarshal(data, &str); err != nil {
return err
}
switch str {
case "healthy":
*s = StatusHealthy
case "unhealthy":
*s = StatusUnhealthy
case "napping":
*s = StatusNapping
case "starting":
*s = StatusStarting
case "error":
*s = StatusError
default:
*s = StatusUnknown
}
return nil
}
func (s Status) Good() bool {
return s&HealthyMask != 0
}

View file

@ -1,11 +1,10 @@
package health
import (
"encoding/json"
"fmt"
"net/url"
"time"
"github.com/yusing/go-proxy/internal/net/types"
"github.com/yusing/go-proxy/internal/task"
)
@ -24,14 +23,17 @@ type (
task.TaskStarter
task.TaskFinisher
fmt.Stringer
json.Marshaler
WithHealthInfo
Name() string
}
HealthChecker interface {
CheckHealth() (result *HealthCheckResult, err error)
URL() *types.URL
URL() *url.URL
Config() *HealthCheckConfig
UpdateURL(url *types.URL)
UpdateURL(url *url.URL)
}
HealthMonCheck interface {
HealthMonitor
HealthChecker
}
)

33
schemas/Makefile Normal file
View file

@ -0,0 +1,33 @@
# To generate schema
# comment out this part from typescript-json-schema.js#L884
#
# if (indexType.flags !== ts.TypeFlags.Number && !isIndexedObject) {
# throw new Error("Not supported: IndexSignatureDeclaration with index symbol other than a number or a string");
# }
gen-schema-single:
bun -bun typescript-json-schema --noExtraProps --required --skipLibCheck --tsNodeRegister=true -o "${OUT}" "${IN}" ${CLASS}
# minify
python3 -c "import json; f=open('${OUT}', 'r'); j=json.load(f); f.close(); f=open('${OUT}', 'w'); json.dump(j, f, separators=(',', ':'));"
gen-schema:
bun -bun tsc
sed -i 's#"type": "module"#"type": "commonjs"#' package.json
make IN=config/config.ts \
CLASS=Config \
OUT=config.schema.json \
gen-schema-single
make IN=providers/routes.ts \
CLASS=Routes \
OUT=routes.schema.json \
gen-schema-single
make IN=middlewares/middleware_compose.ts \
CLASS=MiddlewareCompose \
OUT=middleware_compose.schema.json \
gen-schema-single
make IN=docker.ts \
CLASS=DockerRoutes \
OUT=docker_routes.schema.json \
gen-schema-single
sed -i 's#"type": "commonjs"#"type": "module"#' package.json
bun format:write

View file

@ -2,10 +2,10 @@
"lockfileVersion": 1,
"workspaces": {
"": {
"name": "godoxy-types",
"name": "godoxy-schemas",
"devDependencies": {
"prettier": "^3.4.2",
"typescript": "^5.7.3",
"prettier": "^3.5.3",
"typescript": "^5.8.3",
"typescript-json-schema": "^0.65.1",
},
},
@ -29,9 +29,9 @@
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
"@types/node": ["@types/node@18.19.74", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-HMwEkkifei3L605gFdV+/UwtpxP6JSzM+xFk2Ia6DNFSwSVBRh9qp5Tgf4lNFOMfPVuU0WnkcWpXZpgn5ufO4A=="],
"@types/node": ["@types/node@18.19.86", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-fifKayi175wLyKyc5qUfyENhQ1dCNI1UNjp653d8kuYcPQN5JhX3dGuP/XmvPTg/xRBn1VTLpbmi+H/Mr7tLfQ=="],
"acorn": ["acorn@8.14.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA=="],
"acorn": ["acorn@8.14.1", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg=="],
"acorn-walk": ["acorn-walk@8.3.4", "", { "dependencies": { "acorn": "^8.11.0" } }, "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g=="],
@ -83,7 +83,7 @@
"path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="],
"prettier": ["prettier@3.4.2", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-e9MewbtFo+Fevyuxn/4rrcDAaq0IYxPGLvObpQjiZBMAzB9IGmzlnG9RZy3FFas+eBMu2vA0CszMeduow5dIuQ=="],
"prettier": ["prettier@3.5.3", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw=="],
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
@ -95,7 +95,7 @@
"ts-node": ["ts-node@10.9.2", "", { "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", "@tsconfig/node12": "^1.0.7", "@tsconfig/node14": "^1.0.0", "@tsconfig/node16": "^1.0.2", "acorn": "^8.4.1", "acorn-walk": "^8.1.1", "arg": "^4.1.0", "create-require": "^1.1.0", "diff": "^4.0.1", "make-error": "^1.1.1", "v8-compile-cache-lib": "^3.0.1", "yn": "3.1.1" }, "peerDependencies": { "@swc/core": ">=1.2.50", "@swc/wasm": ">=1.2.50", "@types/node": "*", "typescript": ">=2.7" }, "optionalPeers": ["@swc/core", "@swc/wasm"], "bin": { "ts-node": "dist/bin.js", "ts-script": "dist/bin-script-deprecated.js", "ts-node-cwd": "dist/bin-cwd.js", "ts-node-esm": "dist/bin-esm.js", "ts-node-script": "dist/bin-script.js", "ts-node-transpile-only": "dist/bin-transpile.js" } }, "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ=="],
"typescript": ["typescript@5.7.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw=="],
"typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
"typescript-json-schema": ["typescript-json-schema@0.65.1", "", { "dependencies": { "@types/json-schema": "^7.0.9", "@types/node": "^18.11.9", "glob": "^7.1.7", "path-equal": "^1.2.5", "safe-stable-stringify": "^2.2.0", "ts-node": "^10.9.1", "typescript": "~5.5.0", "yargs": "^17.1.1" }, "bin": { "typescript-json-schema": "bin/typescript-json-schema" } }, "sha512-tuGH7ff2jPaUYi6as3lHyHcKpSmXIqN7/mu50x3HlYn0EHzLpmt3nplZ7EuhUkO0eqDRc9GqWNkfjgBPIS9kxg=="],

View file

@ -1,49 +1,57 @@
import { CIDR, HTTPHeader, HTTPMethod, StatusCodeRange, URI } from "../types";
export declare const ACCESS_LOG_FORMATS: readonly ["combined", "common", "json"];
export declare const ACCESS_LOG_FORMATS: readonly [
"combined",
"common",
"json",
];
export type AccessLogFormat = (typeof ACCESS_LOG_FORMATS)[number];
export type AccessLogConfig = {
/**
* The size of the buffer.
*
* @minimum 0
* @default 65536
* @TJS-type integer
*/
buffer_size?: number;
/** The format of the access log.
*
* @default "combined"
*/
format?: AccessLogFormat;
path: URI;
filters?: AccessLogFilters;
fields?: AccessLogFields;
/**
* The size of the buffer.
*
* @minimum 0
* @default 65536
* @TJS-type integer
*/
buffer_size?: number;
/** The format of the access log.
*
* @default "combined"
*/
format?: AccessLogFormat;
path: URI;
filters?: AccessLogFilters;
fields?: AccessLogFields;
};
export type AccessLogFilter<T> = {
/** Whether the filter is negative.
*
* @default false
*/
negative?: boolean;
values: T[];
/** Whether the filter is negative.
*
* @default false
*/
negative?: boolean;
values: T[];
};
export type AccessLogFilters = {
status_code?: AccessLogFilter<StatusCodeRange>;
method?: AccessLogFilter<HTTPMethod>;
host?: AccessLogFilter<string>;
headers?: AccessLogFilter<HTTPHeader>;
cidr?: AccessLogFilter<CIDR>;
status_code?: AccessLogFilter<StatusCodeRange>;
method?: AccessLogFilter<HTTPMethod>;
host?: AccessLogFilter<string>;
headers?: AccessLogFilter<HTTPHeader>;
cidr?: AccessLogFilter<CIDR>;
};
export declare const ACCESS_LOG_FIELD_MODES: readonly ["keep", "drop", "redact"];
export declare const ACCESS_LOG_FIELD_MODES: readonly [
"keep",
"drop",
"redact",
];
export type AccessLogFieldMode = (typeof ACCESS_LOG_FIELD_MODES)[number];
export type AccessLogField = {
default?: AccessLogFieldMode;
config: {
[key: string]: AccessLogFieldMode;
};
default?: AccessLogFieldMode;
config: {
[key: string]: AccessLogFieldMode;
};
};
export type AccessLogFields = {
header?: AccessLogField;
query?: AccessLogField;
cookie?: AccessLogField;
header?: AccessLogField;
query?: AccessLogField;
cookie?: AccessLogField;
};

View file

@ -1,66 +1,88 @@
import { DomainOrWildcard, Email } from "../types";
export declare const AUTOCERT_PROVIDERS: readonly ["local", "cloudflare", "clouddns", "duckdns", "ovh", "porkbun"];
export declare const AUTOCERT_PROVIDERS: readonly [
"local",
"cloudflare",
"clouddns",
"duckdns",
"ovh",
"porkbun",
];
export type AutocertProvider = (typeof AUTOCERT_PROVIDERS)[number];
export type AutocertConfig = LocalOptions | CloudflareOptions | CloudDNSOptions | DuckDNSOptions | OVHOptionsWithAppKey | OVHOptionsWithOAuth2Config | PorkbunOptions;
export type AutocertConfig =
| LocalOptions
| CloudflareOptions
| CloudDNSOptions
| DuckDNSOptions
| OVHOptionsWithAppKey
| OVHOptionsWithOAuth2Config
| PorkbunOptions;
export interface AutocertConfigBase {
email: Email;
domains: DomainOrWildcard[];
cert_path?: string;
key_path?: string;
email: Email;
domains: DomainOrWildcard[];
cert_path?: string;
key_path?: string;
}
export interface LocalOptions {
provider: "local";
cert_path?: string;
key_path?: string;
options?: {} | null;
provider: "local";
cert_path?: string;
key_path?: string;
options?: {} | null;
}
export interface CloudflareOptions extends AutocertConfigBase {
provider: "cloudflare";
options: {
auth_token: string;
};
provider: "cloudflare";
options: {
auth_token: string;
};
}
export interface CloudDNSOptions extends AutocertConfigBase {
provider: "clouddns";
options: {
client_id: string;
email: Email;
password: string;
};
provider: "clouddns";
options: {
client_id: string;
email: Email;
password: string;
};
}
export interface DuckDNSOptions extends AutocertConfigBase {
provider: "duckdns";
options: {
token: string;
};
provider: "duckdns";
options: {
token: string;
};
}
export interface PorkbunOptions extends AutocertConfigBase {
provider: "porkbun";
options: {
api_key: string;
secret_api_key: string;
};
provider: "porkbun";
options: {
api_key: string;
secret_api_key: string;
};
}
export declare const OVH_ENDPOINTS: readonly ["ovh-eu", "ovh-ca", "ovh-us", "kimsufi-eu", "kimsufi-ca", "soyoustart-eu", "soyoustart-ca"];
export declare const OVH_ENDPOINTS: readonly [
"ovh-eu",
"ovh-ca",
"ovh-us",
"kimsufi-eu",
"kimsufi-ca",
"soyoustart-eu",
"soyoustart-ca",
];
export type OVHEndpoint = (typeof OVH_ENDPOINTS)[number];
export interface OVHOptionsWithAppKey extends AutocertConfigBase {
provider: "ovh";
options: {
application_secret: string;
consumer_key: string;
api_endpoint?: OVHEndpoint;
application_key: string;
};
provider: "ovh";
options: {
application_secret: string;
consumer_key: string;
api_endpoint?: OVHEndpoint;
application_key: string;
};
}
export interface OVHOptionsWithOAuth2Config extends AutocertConfigBase {
provider: "ovh";
options: {
application_secret: string;
consumer_key: string;
api_endpoint?: OVHEndpoint;
oauth2_config: {
client_id: string;
client_secret: string;
};
provider: "ovh";
options: {
application_secret: string;
consumer_key: string;
api_endpoint?: OVHEndpoint;
oauth2_config: {
client_id: string;
client_secret: string;
};
};
}

View file

@ -4,51 +4,58 @@ import { EntrypointConfig } from "./entrypoint";
import { HomepageConfig } from "./homepage";
import { Providers } from "./providers";
export type Config = {
/** Optional autocert configuration
*
* @examples require(".").autocertExamples
*/
autocert?: AutocertConfig;
entrypoint?: EntrypointConfig;
providers: Providers;
/** Optional list of domains to match
*
* @minItems 1
* @examples require(".").matchDomainsExamples
*/
match_domains?: DomainName[];
homepage?: HomepageConfig;
/**
* Optional timeout before shutdown
* @default 3
* @minimum 1
*/
timeout_shutdown?: number;
/** Optional autocert configuration
*
* @examples require(".").autocertExamples
*/
autocert?: AutocertConfig;
entrypoint?: EntrypointConfig;
providers: Providers;
/** Optional list of domains to match
*
* @minItems 1
* @examples require(".").matchDomainsExamples
*/
match_domains?: DomainName[];
homepage?: HomepageConfig;
/**
* Optional timeout before shutdown
* @default 3
* @minimum 1
*/
timeout_shutdown?: number;
};
export declare const autocertExamples: ({
provider: string;
email?: undefined;
domains?: undefined;
options?: undefined;
} | {
provider: string;
email: string;
domains: string[];
options: {
export declare const autocertExamples: (
| {
provider: string;
email?: undefined;
domains?: undefined;
options?: undefined;
}
| {
provider: string;
email: string;
domains: string[];
options: {
auth_token: string;
client_id?: undefined;
email?: undefined;
password?: undefined;
};
} | {
provider: string;
email: string;
domains: string[];
options: {
};
}
| {
provider: string;
email: string;
domains: string[];
options: {
client_id: string;
email: string;
password: string;
auth_token?: undefined;
};
})[];
export declare const matchDomainsExamples: readonly ["example.com", "*.example.com"];
};
}
)[];
export declare const matchDomainsExamples: readonly [
"example.com",
"*.example.com",
];

View file

@ -1,39 +1,49 @@
import { MiddlewareCompose } from "../middlewares/middleware_compose";
import { AccessLogConfig } from "./access_log";
export type EntrypointConfig = {
/** Entrypoint middleware configuration
*
* @examples require(".").middlewaresExamples
*/
middlewares?: MiddlewareCompose;
/** Entrypoint access log configuration
*
* @examples require(".").accessLogExamples
*/
access_log?: AccessLogConfig;
/** Entrypoint middleware configuration
*
* @examples require(".").middlewaresExamples
*/
middlewares?: MiddlewareCompose;
/** Entrypoint access log configuration
*
* @examples require(".").accessLogExamples
*/
access_log?: AccessLogConfig;
};
export declare const accessLogExamples: readonly [{
export declare const accessLogExamples: readonly [
{
readonly path: "/var/log/access.log";
readonly format: "combined";
readonly filters: {
readonly status_codes: {
readonly values: readonly ["200-299"];
};
readonly status_codes: {
readonly values: readonly ["200-299"];
};
};
readonly fields: {
readonly headers: {
readonly default: "keep";
readonly config: {
readonly foo: "redact";
};
readonly headers: {
readonly default: "keep";
readonly config: {
readonly foo: "redact";
};
};
};
}];
export declare const middlewaresExamples: readonly [{
},
];
export declare const middlewaresExamples: readonly [
{
readonly use: "RedirectHTTP";
}, {
},
{
readonly use: "CIDRWhitelist";
readonly allow: readonly ["127.0.0.1", "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"];
readonly allow: readonly [
"127.0.0.1",
"10.0.0.0/8",
"172.16.0.0/12",
"192.168.0.0/16",
];
readonly status: 403;
readonly message: "Forbidden";
}];
},
];

View file

@ -1,7 +1,7 @@
export type HomepageConfig = {
/**
* Use default app categories (uses docker image name)
* @default true
*/
use_default_categories: boolean;
/**
* Use default app categories (uses docker image name)
* @default true
*/
use_default_categories: boolean;
};

View file

@ -1,60 +1,69 @@
import { URL } from "../types";
export declare const NOTIFICATION_PROVIDERS: readonly ["webhook", "gotify", "ntfy"];
export declare const NOTIFICATION_PROVIDERS: readonly [
"webhook",
"gotify",
"ntfy",
];
export type NotificationProvider = (typeof NOTIFICATION_PROVIDERS)[number];
export type NotificationConfig = {
name: string;
url: URL;
name: string;
url: URL;
};
export interface GotifyConfig extends NotificationConfig {
provider: "gotify";
token: string;
provider: "gotify";
token: string;
}
export declare const NTFY_MSG_STYLES: string[];
export type NtfyStyle = (typeof NTFY_MSG_STYLES)[number];
export interface NtfyConfig extends NotificationConfig {
provider: "ntfy";
topic: string;
token?: string;
style?: NtfyStyle;
provider: "ntfy";
topic: string;
token?: string;
style?: NtfyStyle;
}
export declare const WEBHOOK_TEMPLATES: readonly ["", "discord"];
export declare const WEBHOOK_METHODS: readonly ["POST", "GET", "PUT"];
export declare const WEBHOOK_MIME_TYPES: readonly ["application/json", "application/x-www-form-urlencoded", "text/plain", "text/markdown"];
export declare const WEBHOOK_MIME_TYPES: readonly [
"application/json",
"application/x-www-form-urlencoded",
"text/plain",
"text/markdown",
];
export declare const WEBHOOK_COLOR_MODES: readonly ["hex", "dec"];
export type WebhookTemplate = (typeof WEBHOOK_TEMPLATES)[number];
export type WebhookMethod = (typeof WEBHOOK_METHODS)[number];
export type WebhookMimeType = (typeof WEBHOOK_MIME_TYPES)[number];
export type WebhookColorMode = (typeof WEBHOOK_COLOR_MODES)[number];
export interface WebhookConfig extends NotificationConfig {
provider: "webhook";
/**
* Webhook template
*
* @default "discord"
*/
template?: WebhookTemplate;
token?: string;
/**
* Webhook message (usally JSON),
* required when template is not defined
*/
payload?: string;
/**
* Webhook method
*
* @default "POST"
*/
method?: WebhookMethod;
/**
* Webhook mime type
*
* @default "application/json"
*/
mime_type?: WebhookMimeType;
/**
* Webhook color mode
*
* @default "hex"
*/
color_mode?: WebhookColorMode;
provider: "webhook";
/**
* Webhook template
*
* @default "discord"
*/
template?: WebhookTemplate;
token?: string;
/**
* Webhook message (usally JSON),
* required when template is not defined
*/
payload?: string;
/**
* Webhook method
*
* @default "POST"
*/
method?: WebhookMethod;
/**
* Webhook mime type
*
* @default "application/json"
*/
mime_type?: WebhookMimeType;
/**
* Webhook color mode
*
* @default "hex"
*/
color_mode?: WebhookColorMode;
}

View file

@ -1,51 +1,58 @@
import { URI, URL } from "../types";
import { GotifyConfig, NtfyConfig, WebhookConfig } from "./notification";
export type Providers = {
/** List of route definition files to include
*
* @minItems 1
* @examples require(".").includeExamples
* @items.pattern ^[\w\d\-_]+\.(yaml|yml)$
*/
include?: URI[];
/** Name-value mapping of docker hosts to retrieve routes from
*
* @minProperties 1
* @examples require(".").dockerExamples
*/
docker?: {
[name: string]: URL | "$DOCKER_HOST";
};
/** List of GoDoxy agents
*
* @minItems 1
* @examples require(".").agentExamples
*/
agents?: `${string}:${number}`[];
/** List of notification providers
*
* @minItems 1
* @examples require(".").notificationExamples
*/
notification?: (WebhookConfig | GotifyConfig | NtfyConfig)[];
/** List of route definition files to include
*
* @minItems 1
* @examples require(".").includeExamples
* @items.pattern ^[\w\d\-_]+\.(yaml|yml)$
*/
include?: URI[];
/** Name-value mapping of docker hosts to retrieve routes from
*
* @minProperties 1
* @examples require(".").dockerExamples
*/
docker?: {
[name: string]: URL | "$DOCKER_HOST";
};
/** List of GoDoxy agents
*
* @minItems 1
* @examples require(".").agentExamples
*/
agents?: `${string}:${number}`[];
/** List of notification providers
*
* @minItems 1
* @examples require(".").notificationExamples
*/
notification?: (WebhookConfig | GotifyConfig | NtfyConfig)[];
};
export declare const includeExamples: readonly ["file1.yml", "file2.yml"];
export declare const dockerExamples: readonly [{
export declare const dockerExamples: readonly [
{
readonly local: "$DOCKER_HOST";
}, {
},
{
readonly remote: "tcp://10.0.2.1:2375";
}, {
},
{
readonly remote2: "ssh://root:1234@10.0.2.2";
}];
export declare const notificationExamples: readonly [{
},
];
export declare const notificationExamples: readonly [
{
readonly name: "gotify";
readonly provider: "gotify";
readonly url: "https://gotify.domain.tld";
readonly token: "abcd";
}, {
},
{
readonly name: "discord";
readonly provider: "webhook";
readonly template: "discord";
readonly url: "https://discord.com/api/webhooks/1234/abcd";
}];
},
];
export declare const agentExamples: readonly ["10.0.2.3:8890", "10.0.2.4:8890"];

2
schemas/docker.d.ts vendored
View file

@ -1,5 +1,5 @@
import { IdleWatcherConfig } from "./providers/idlewatcher";
import { Route } from "./providers/routes";
export type DockerRoutes = {
[key: string]: Route & IdleWatcherConfig;
[key: string]: Route & IdleWatcherConfig;
};

File diff suppressed because one or more lines are too long

21
schemas/index.d.ts vendored
View file

@ -16,4 +16,23 @@ import ConfigSchema from "./config.schema.json";
import DockerRoutesSchema from "./docker_routes.schema.json";
import MiddlewareComposeSchema from "./middleware_compose.schema.json";
import RoutesSchema from "./routes.schema.json";
export { AccessLog, Autocert, Config, ConfigSchema, DockerRoutesSchema, Entrypoint, GoDoxy, Healthcheck, Homepage, IdleWatcher, LoadBalance, MiddlewareCompose, MiddlewareComposeSchema, Middlewares, Notification, Providers, Routes, RoutesSchema, };
export {
AccessLog,
Autocert,
Config,
ConfigSchema,
DockerRoutesSchema,
Entrypoint,
GoDoxy,
Healthcheck,
Homepage,
IdleWatcher,
LoadBalance,
MiddlewareCompose,
MiddlewareComposeSchema,
Middlewares,
Notification,
Providers,
Routes,
RoutesSchema,
};

View file

@ -1,133 +1,186 @@
import * as types from "../types";
export type KeyOptMapping<T extends {
export type KeyOptMapping<
T extends {
use: string;
}> = {
[key in T["use"]]?: Omit<T, "use">;
},
> = {
[key in T["use"]]?: Omit<T, "use">;
};
export declare const ALL_MIDDLEWARES: readonly ["ErrorPage", "RedirectHTTP", "SetXForwarded", "HideXForwarded", "CIDRWhitelist", "CloudflareRealIP", "ModifyRequest", "ModifyResponse", "OIDC", "RateLimit", "RealIP"];
export declare const ALL_MIDDLEWARES: readonly [
"ErrorPage",
"RedirectHTTP",
"SetXForwarded",
"HideXForwarded",
"CIDRWhitelist",
"CloudflareRealIP",
"ModifyRequest",
"ModifyResponse",
"OIDC",
"RateLimit",
"RealIP",
];
/**
* @type object
* @patternProperties {"^.*@file$": {"type": "null"}}
*/
export type MiddlewareFileRef = {
[key: `${string}@file`]: null;
[key: `${string}@file`]: null;
};
export type MiddlewaresMap = (KeyOptMapping<CustomErrorPage> & KeyOptMapping<RedirectHTTP> & KeyOptMapping<SetXForwarded> & KeyOptMapping<HideXForwarded> & KeyOptMapping<CIDRWhitelist> & KeyOptMapping<CloudflareRealIP> & KeyOptMapping<ModifyRequest> & KeyOptMapping<ModifyResponse> & KeyOptMapping<OIDC> & KeyOptMapping<RateLimit> & KeyOptMapping<RealIP>) | MiddlewareFileRef;
export type MiddlewareComposeMap = CustomErrorPage | RedirectHTTP | SetXForwarded | HideXForwarded | CIDRWhitelist | CloudflareRealIP | ModifyRequest | ModifyResponse | OIDC | RateLimit | RealIP;
export type MiddlewaresMap =
| (KeyOptMapping<CustomErrorPage> &
KeyOptMapping<RedirectHTTP> &
KeyOptMapping<SetXForwarded> &
KeyOptMapping<HideXForwarded> &
KeyOptMapping<CIDRWhitelist> &
KeyOptMapping<CloudflareRealIP> &
KeyOptMapping<ModifyRequest> &
KeyOptMapping<ModifyResponse> &
KeyOptMapping<OIDC> &
KeyOptMapping<RateLimit> &
KeyOptMapping<RealIP>)
| MiddlewareFileRef;
export type MiddlewareComposeMap =
| CustomErrorPage
| RedirectHTTP
| SetXForwarded
| HideXForwarded
| CIDRWhitelist
| CloudflareRealIP
| ModifyRequest
| ModifyResponse
| OIDC
| RateLimit
| RealIP;
export type CustomErrorPage = {
use: "error_page" | "errorPage" | "ErrorPage" | "custom_error_page" | "customErrorPage" | "CustomErrorPage";
use:
| "error_page"
| "errorPage"
| "ErrorPage"
| "custom_error_page"
| "customErrorPage"
| "CustomErrorPage";
};
export type RedirectHTTP = {
use: "redirect_http" | "redirectHTTP" | "RedirectHTTP";
/** Bypass redirect */
bypass?: {
/** Bypass redirect for user agents */
user_agents?: string[];
};
use: "redirect_http" | "redirectHTTP" | "RedirectHTTP";
/** Bypass redirect */
bypass?: {
/** Bypass redirect for user agents */
user_agents?: string[];
};
};
export type SetXForwarded = {
use: "set_x_forwarded" | "setXForwarded" | "SetXForwarded";
use: "set_x_forwarded" | "setXForwarded" | "SetXForwarded";
};
export type HideXForwarded = {
use: "hide_x_forwarded" | "hideXForwarded" | "HideXForwarded";
use: "hide_x_forwarded" | "hideXForwarded" | "HideXForwarded";
};
export type CIDRWhitelist = {
use: "cidr_whitelist" | "cidrWhitelist" | "CIDRWhitelist";
allow: types.CIDR[];
/** HTTP status code when blocked
*
* @default 403
*/
status_code?: types.StatusCode;
/** HTTP status code when blocked (alias of status_code)
*
* @default 403
*/
status?: types.StatusCode;
/** Error message when blocked
*
* @default "IP not allowed"
*/
message?: string;
use: "cidr_whitelist" | "cidrWhitelist" | "CIDRWhitelist";
allow: types.CIDR[];
/** HTTP status code when blocked
*
* @default 403
*/
status_code?: types.StatusCode;
/** HTTP status code when blocked (alias of status_code)
*
* @default 403
*/
status?: types.StatusCode;
/** Error message when blocked
*
* @default "IP not allowed"
*/
message?: string;
};
export type CloudflareRealIP = {
use: "cloudflare_real_ip" | "cloudflareRealIp" | "CloudflareRealIP";
/** Recursively resolve the IP
*
* @default false
*/
recursive?: boolean;
use: "cloudflare_real_ip" | "cloudflareRealIp" | "CloudflareRealIP";
/** Recursively resolve the IP
*
* @default false
*/
recursive?: boolean;
};
export type ModifyRequest = {
use: "request" | "Request" | "modify_request" | "modifyRequest" | "ModifyRequest";
/** Set HTTP headers */
set_headers?: {
[key: types.HTTPHeader]: string;
};
/** Add HTTP headers */
add_headers?: {
[key: types.HTTPHeader]: string;
};
/** Hide HTTP headers */
hide_headers?: types.HTTPHeader[];
/** Add prefix to request URL */
add_prefix?: string;
use:
| "request"
| "Request"
| "modify_request"
| "modifyRequest"
| "ModifyRequest";
/** Set HTTP headers */
set_headers?: {
[key: types.HTTPHeader]: string;
};
/** Add HTTP headers */
add_headers?: {
[key: types.HTTPHeader]: string;
};
/** Hide HTTP headers */
hide_headers?: types.HTTPHeader[];
/** Add prefix to request URL */
add_prefix?: string;
};
export type ModifyResponse = {
use: "response" | "Response" | "modify_response" | "modifyResponse" | "ModifyResponse";
/** Set HTTP headers */
set_headers?: {
[key: types.HTTPHeader]: string;
};
/** Add HTTP headers */
add_headers?: {
[key: types.HTTPHeader]: string;
};
/** Hide HTTP headers */
hide_headers?: types.HTTPHeader[];
use:
| "response"
| "Response"
| "modify_response"
| "modifyResponse"
| "ModifyResponse";
/** Set HTTP headers */
set_headers?: {
[key: types.HTTPHeader]: string;
};
/** Add HTTP headers */
add_headers?: {
[key: types.HTTPHeader]: string;
};
/** Hide HTTP headers */
hide_headers?: types.HTTPHeader[];
};
export type OIDC = {
use: "oidc" | "OIDC";
/** Allowed users
*
* @minItems 1
*/
allowed_users?: string[];
/** Allowed groups
*
* @minItems 1
*/
allowed_groups?: string[];
use: "oidc" | "OIDC";
/** Allowed users
*
* @minItems 1
*/
allowed_users?: string[];
/** Allowed groups
*
* @minItems 1
*/
allowed_groups?: string[];
};
export type RateLimit = {
use: "rate_limit" | "rateLimit" | "RateLimit";
/** Average number of requests allowed in a period
*
* @min 1
*/
average: number;
/** Maximum number of requests allowed in a period
*
* @min 1
*/
burst: number;
/** Duration of the rate limit
*
* @default 1s
*/
period?: types.Duration;
use: "rate_limit" | "rateLimit" | "RateLimit";
/** Average number of requests allowed in a period
*
* @min 1
*/
average: number;
/** Maximum number of requests allowed in a period
*
* @min 1
*/
burst: number;
/** Duration of the rate limit
*
* @default 1s
*/
period?: types.Duration;
};
export type RealIP = {
use: "real_ip" | "realIP" | "RealIP";
/** Header to get the client IP from
*
* @default "X-Real-IP"
*/
header?: types.HTTPHeader;
from: types.CIDR[];
/** Recursive resolve the IP
*
* @default false
*/
recursive?: boolean;
use: "real_ip" | "realIP" | "RealIP";
/** Header to get the client IP from
*
* @default "X-Real-IP"
*/
header?: types.HTTPHeader;
from: types.CIDR[];
/** Recursive resolve the IP
*
* @default false
*/
recursive?: boolean;
};

View file

@ -1,6 +1,6 @@
{
"name": "godoxy-schemas",
"version": "0.10.0-3",
"version": "0.10.1-6",
"description": "JSON Schema and typescript types for GoDoxy configuration",
"license": "MIT",
"repository": {
@ -8,9 +8,11 @@
"url": "https://github.com/yusing/godoxy"
},
"files": [
"schemas/",
"README.md",
"LICENSE"
"**/*.ts",
"**/*.js",
"*.schema.json",
"../README.md",
"../LICENSE"
],
"type": "module",
"main": "./index.ts",
@ -22,15 +24,14 @@
}
},
"devDependencies": {
"prettier": "^3.4.2",
"typescript": "^5.7.3",
"prettier": "^3.5.3",
"typescript": "^5.8.3",
"typescript-json-schema": "^0.65.1"
},
"displayName": "GoDoxy Types",
"packageManager": "bun@1.2.0",
"packageManager": "bun@1.2.9",
"publisher": "yusing",
"scripts": {
"gen-schema": "make gen-schema",
"format:write": "prettier --write \"schemas/**/*.ts\" --cache"
"format:write": "prettier --write \"**/*.ts\" --cache"
}
}

View file

@ -3,30 +3,30 @@ import { Duration, URI } from "../types";
* @additionalProperties false
*/
export type HealthcheckConfig = {
/** Disable healthcheck
*
* @default false
*/
disable?: boolean;
/** Healthcheck path
*
* @default /
*/
path?: URI;
/**
* Use GET instead of HEAD
*
* @default false
*/
use_get?: boolean;
/** Healthcheck interval
*
* @default 5s
*/
interval?: Duration;
/** Healthcheck timeout
*
* @default 5s
*/
timeout?: Duration;
/** Disable healthcheck
*
* @default false
*/
disable?: boolean;
/** Healthcheck path
*
* @default /
*/
path?: URI;
/**
* Use GET instead of HEAD
*
* @default false
*/
use_get?: boolean;
/** Healthcheck interval
*
* @default 5s
*/
interval?: Duration;
/** Healthcheck timeout
*
* @default 5s
*/
timeout?: Duration;
};

View file

@ -3,19 +3,19 @@ import { URL } from "../types";
* @additionalProperties false
*/
export type HomepageConfig = {
/** Whether show in dashboard
*
* @default true
*/
show?: boolean;
name?: string;
icon?: URL | WalkxcodeIcon | ExternalIcon | TargetRelativeIconPath;
description?: string;
url?: URL;
category?: string;
widget_config?: {
[key: string]: any;
};
/** Whether show in dashboard
*
* @default true
*/
show?: boolean;
name?: string;
icon?: URL | WalkxcodeIcon | ExternalIcon | TargetRelativeIconPath;
description?: string;
url?: URL;
category?: string;
widget_config?: {
[key: string]: any;
};
};
/** Walkxcode icon
*

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