From d5f46138d59fa60e7694b75804c27d7ac5d340c5 Mon Sep 17 00:00:00 2001 From: ryan Date: Mon, 1 Jun 2026 08:47:09 +0800 Subject: [PATCH] =?UTF-8?q?[=E4=BC=98=E5=8C=96]=20Phase3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- openflare_relay/Dockerfile | 28 ++ openflare_relay/cmd/relay/main.go | 73 +++++ openflare_relay/go.mod | 90 ++++++ openflare_relay/go.sum | 261 ++++++++++++++++++ openflare_relay/internal/config/config.go | 173 ++++++++++++ openflare_relay/internal/frps/manager.go | 148 ++++++++++ openflare_relay/internal/heartbeat/service.go | 65 +++++ openflare_relay/internal/httpclient/client.go | 111 ++++++++ openflare_relay/internal/relay/runner.go | 94 +++++++ openflare_relay/internal/state/store.go | 55 ++++ openflare_relay/internal/wsclient/client.go | 143 ++++++++++ 11 files changed, 1241 insertions(+) create mode 100644 openflare_relay/Dockerfile create mode 100644 openflare_relay/cmd/relay/main.go create mode 100644 openflare_relay/go.mod create mode 100644 openflare_relay/go.sum create mode 100644 openflare_relay/internal/config/config.go create mode 100644 openflare_relay/internal/frps/manager.go create mode 100644 openflare_relay/internal/heartbeat/service.go create mode 100644 openflare_relay/internal/httpclient/client.go create mode 100644 openflare_relay/internal/relay/runner.go create mode 100644 openflare_relay/internal/state/store.go create mode 100644 openflare_relay/internal/wsclient/client.go diff --git a/openflare_relay/Dockerfile b/openflare_relay/Dockerfile new file mode 100644 index 00000000..fe71af27 --- /dev/null +++ b/openflare_relay/Dockerfile @@ -0,0 +1,28 @@ +FROM golang:1.25-alpine AS builder + +WORKDIR /build + +COPY go.mod go.sum ./ +# Note: In a real monorepo build, openflare and openflare_server need to be available. +# For now, we assume this is built from the root of the project where both are present. +COPY openflare_server /openflare_server +COPY openflare_relay /openflare_relay + +WORKDIR /openflare_relay +RUN go build -o openflare-relay ./cmd/relay + +FROM alpine:latest + +# Install frps +RUN apk add --no-cache curl \ + && curl -fSL https://github.com/fatedier/frp/releases/download/v0.61.1/frp_0.61.1_linux_amd64.tar.gz -o frp.tar.gz \ + && tar -zxvf frp.tar.gz \ + && mv frp_0.61.1_linux_amd64/frps /usr/local/bin/ \ + && rm -rf frp_0.61.1_linux_amd64 frp.tar.gz \ + && chmod +x /usr/local/bin/frps + +COPY --from=builder /openflare_relay/openflare-relay /usr/local/bin/openflare-relay + +VOLUME ["/var/lib/openflare-relay"] + +CMD ["openflare-relay", "-config", "/etc/openflare-relay/relay.json"] diff --git a/openflare_relay/cmd/relay/main.go b/openflare_relay/cmd/relay/main.go new file mode 100644 index 00000000..8f57abfd --- /dev/null +++ b/openflare_relay/cmd/relay/main.go @@ -0,0 +1,73 @@ +package main + +import ( + "context" + "flag" + "log/slog" + "os" + "os/signal" + "syscall" + + "openflare-relay/internal/config" + "openflare-relay/internal/frps" + "openflare-relay/internal/heartbeat" + "openflare-relay/internal/httpclient" + "openflare-relay/internal/relay" + "openflare-relay/internal/state" + "openflare-relay/internal/wsclient" +) + +func main() { + // Setup simple structured logging + slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ + Level: slog.LevelDebug, + }))) + + configPath := flag.String("config", "./relay.json", "relay config path") + flag.Parse() + + cfg, err := config.Load(*configPath) + if err != nil { + slog.Error("load relay config failed", "error", err) + os.Exit(1) + } + + slog.Info("relay config loaded", + "server", cfg.ServerURL, + "node", cfg.NodeName, + "ip", cfg.NodeIP, + "frps_path", cfg.FrpsPath, + "data_dir", cfg.DataDir, + "heartbeat_interval", cfg.HeartbeatInterval, + ) + + stateStore := state.NewStore(cfg.StatePath) + _ = stateStore // In the future we may use stateStore for auth caching + + frpsManager := frps.NewManager(cfg.FrpsPath, cfg.DataDir) + + slog.Info("detected frps version", "version", frpsManager.GetVersion()) + + httpClient := httpclient.New(cfg.ServerURL, cfg.InitialAuthToken(), cfg.RequestTimeout.Duration()) + wsClient := wsclient.New(cfg.ServerURL, cfg.InitialAuthToken(), cfg.RequestTimeout.Duration()) + + runner := &relay.Runner{ + Config: cfg, + StateStore: stateStore, + FrpsManager: frpsManager, + HttpClient: httpClient, + WebSocketService: wsClient, + HeartbeatService: heartbeat.New(httpClient, frpsManager, cfg), + } + + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + slog.Info("relay process started") + + if err := runner.Run(ctx); err != nil && err != context.Canceled { + slog.Error("relay process exited with error", "error", err) + os.Exit(1) + } + slog.Info("relay process stopped") +} diff --git a/openflare_relay/go.mod b/openflare_relay/go.mod new file mode 100644 index 00000000..521b4890 --- /dev/null +++ b/openflare_relay/go.mod @@ -0,0 +1,90 @@ +module openflare-relay + +go 1.25.0 + +replace openflare => ../openflare_server + +require ( + github.com/KyleBanks/depth v1.2.1 // indirect + github.com/PuerkitoBio/purell v1.1.1 // indirect + github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect + github.com/boj/redistore v0.0.0-20180917114910-cd5dcc76aeff // indirect + github.com/bwmarrin/snowflake v0.3.0 // indirect + github.com/bytedance/sonic v1.11.2 // indirect + github.com/cenkalti/backoff/v5 v5.0.3 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect + github.com/chenzhuoyu/iasm v0.9.1 // indirect + github.com/dgraph-io/ristretto/v2 v2.2.0 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/gabriel-vasile/mimetype v1.4.13 // indirect + github.com/gin-contrib/cors v1.6.0 // indirect + github.com/gin-contrib/sessions v0.0.5 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/gin-contrib/static v0.0.1 // indirect + github.com/gin-gonic/gin v1.9.1 // indirect + github.com/glebarez/go-sqlite v1.21.2 // indirect + github.com/glebarez/sqlite v1.11.0 // indirect + github.com/go-acme/lego/v4 v4.35.2 // indirect + github.com/go-jose/go-jose/v4 v4.1.4 // indirect + github.com/go-openapi/jsonpointer v0.19.5 // indirect + github.com/go-openapi/jsonreference v0.19.6 // indirect + github.com/go-openapi/spec v0.20.4 // indirect + github.com/go-openapi/swag v0.19.15 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.23.0 // indirect + github.com/go-redis/redis/v8 v8.11.5 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/gomodule/redigo v2.0.0+incompatible // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/context v1.1.1 // indirect + github.com/gorilla/securecookie v1.1.1 // indirect + github.com/gorilla/sessions v1.2.1 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.6.0 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12 // indirect + github.com/klauspost/cpuid/v2 v2.2.7 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/longbridgeapp/sqlparser v0.3.1 // indirect + github.com/mailru/easyjson v0.7.6 // indirect + github.com/mattn/go-isatty v0.0.21 // indirect + github.com/miekg/dns v1.1.72 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/oschwald/maxminddb-golang v1.13.1 // indirect + github.com/pelletier/go-toml/v2 v2.1.1 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/robfig/cron/v3 v3.0.1 // indirect + github.com/swaggo/files v1.0.1 // indirect + github.com/swaggo/gin-swagger v1.6.1 // indirect + github.com/swaggo/swag v1.16.4 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.12 // indirect + golang.org/x/arch v0.7.0 // indirect + golang.org/x/crypto v0.51.0 // indirect + golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect + golang.org/x/mod v0.35.0 // indirect + golang.org/x/net v0.55.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.45.0 // indirect + golang.org/x/text v0.37.0 // indirect + golang.org/x/tools v0.44.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + gorm.io/driver/postgres v1.6.0 // indirect + gorm.io/gorm v1.25.10 // indirect + gorm.io/sharding v0.6.2 // indirect + modernc.org/libc v1.22.5 // indirect + modernc.org/mathutil v1.5.0 // indirect + modernc.org/memory v1.5.0 // indirect + modernc.org/sqlite v1.23.1 // indirect + openflare v0.0.0-00010101000000-000000000000 // indirect +) diff --git a/openflare_relay/go.sum b/openflare_relay/go.sum new file mode 100644 index 00000000..2897ea1e --- /dev/null +++ b/openflare_relay/go.sum @@ -0,0 +1,261 @@ +github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= +github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= +github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI= +github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= +github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M= +github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= +github.com/boj/redistore v0.0.0-20180917114910-cd5dcc76aeff h1:RmdPFa+slIr4SCBg4st/l/vZWVe9QJKMXGO60Bxbe04= +github.com/boj/redistore v0.0.0-20180917114910-cd5dcc76aeff/go.mod h1:+RTT1BOk5P97fT2CiHkbFQwkK3mjsFAP6zCYV2aXtjw= +github.com/bwmarrin/snowflake v0.3.0 h1:xm67bEhkKh6ij1790JB83OujPR5CzNe8QuQqAgISZN0= +github.com/bwmarrin/snowflake v0.3.0/go.mod h1:NdZxfVWX+oR6y2K0o6qAYv6gIOP9rjG0/E9WsDpxqwE= +github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM= +github.com/bytedance/sonic v1.10.0-rc/go.mod h1:ElCzW+ufi8qKqNW0FY314xriJhyJhuoJ3gFZdAHF7NM= +github.com/bytedance/sonic v1.11.2 h1:ywfwo0a/3j9HR8wsYGWsIWl2mvRsI950HyoxiBERw5A= +github.com/bytedance/sonic v1.11.2/go.mod h1:iZcSUejdk5aukTND/Eu/ivjQuEL0Cu9/rf50Hi0u/g4= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY= +github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk= +github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d h1:77cEq6EriyTZ0g/qfRdp61a3Uu/AWrgIq2s0ClJV1g0= +github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d/go.mod h1:8EPpVsBuRksnlj1mLy4AWzRNQYxauNi62uWcE3to6eA= +github.com/chenzhuoyu/iasm v0.9.0/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog= +github.com/chenzhuoyu/iasm v0.9.1 h1:tUHQJXo3NhBqw6s33wkGn9SP3bvrWLdlVIJ3hQBL7P0= +github.com/chenzhuoyu/iasm v0.9.1/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgraph-io/ristretto/v2 v2.2.0 h1:bkY3XzJcXoMuELV8F+vS8kzNgicwQFAaGINAEJdWGOM= +github.com/dgraph-io/ristretto/v2 v2.2.0/go.mod h1:RZrm63UmcBAaYWC1DotLYBmTvgkrs0+XhBd7Npn7/zI= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= +github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gin-contrib/cors v1.6.0 h1:0Z7D/bVhE6ja07lI8CTjTonp6SB07o8bNuFyRbsBUQg= +github.com/gin-contrib/cors v1.6.0/go.mod h1:cI+h6iOAyxKRtUtC6iF/Si1KSFvGm/gK+kshxlCi8ro= +github.com/gin-contrib/sessions v0.0.5 h1:CATtfHmLMQrMNpJRgzjWXD7worTh7g7ritsQfmF+0jE= +github.com/gin-contrib/sessions v0.0.5/go.mod h1:vYAuaUPqie3WUSsft6HUlCjlwwoJQs97miaG2+7neKY= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-contrib/static v0.0.1 h1:JVxuvHPuUfkoul12N7dtQw7KRn/pSMq7Ue1Va9Swm1U= +github.com/gin-contrib/static v0.0.1/go.mod h1:CSxeF+wep05e0kCOsqWdAWbSszmc31zTIbD8TvWl7Hs= +github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M= +github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg= +github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU= +github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo= +github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k= +github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= +github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= +github.com/go-acme/lego/v4 v4.35.2 h1:uVQg+KC/yj9R2g7Q9W5wDqhvQvxV5SMu5eqFVoN5xZU= +github.com/go-acme/lego/v4 v4.35.2/go.mod h1:pX2jN5n8OphMGY1IaMjYm5DAEzguBaKRt8AvJAgJXpc= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY= +github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/jsonreference v0.19.6 h1:UBIxjkht+AWIgYzCDSv2GN+E/togfwXUJFRTWhl2Jjs= +github.com/go-openapi/jsonreference v0.19.6/go.mod h1:diGHMEHg2IqXZGKxqyvWdfWU/aim5Dprw5bqpKkTvns= +github.com/go-openapi/spec v0.20.4 h1:O8hJrt0UMnhHcluhIdUgCLRWyM2x7QkBXRvOs7m+O1M= +github.com/go-openapi/spec v0.20.4/go.mod h1:faYFR1CvsJZ0mNsmsphTMSoRrNV3TEDoAM7FOEWeq8I= +github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= +github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM= +github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= +github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GOhaH6EGOAJShg8Id5JGkI= +github.com/go-playground/validator/v10 v10.23.0 h1:/PwmTwZhS0dPkav3cdK9kV1FsAmrL8sThn8IHr/sO+o= +github.com/go-playground/validator/v10 v10.23.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI= +github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo= +github.com/go-test/deep v1.0.7/go.mod h1:QV8Hv/iy04NyLBxAdO9njL0iVPN1S4d/A3NVv1V36o8= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/gomodule/redigo v2.0.0+incompatible h1:K/R+8tc58AaqLkqG2Ol3Qk+DR/TlNuhuh457pBFPtt0= +github.com/gomodule/redigo v2.0.0+incompatible/go.mod h1:B4C85qUVwatsJoIUNIfCRsp7qO0iAmpGFZ4EELWSbC4= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +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/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8= +github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= +github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ= +github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= +github.com/gorilla/sessions v1.1.1/go.mod h1:8KCfur6+4Mqcc6S0FEfKuN15Vl5MgXW92AE8ovaJD0w= +github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI= +github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY= +github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12 h1:9Nu54bhS/H/Kgo2/7xNSUuC5G28VR8ljfrLKU2G4IjU= +github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12/go.mod h1:TBzl5BIHNXfS9+C35ZyJaklL7mLDbgUkcgXzSLa8Tk0= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= +github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/longbridgeapp/sqlparser v0.3.1 h1:iWOZWGIFgQrJRgobLXUNJdvqGRpbVXkyKUKUA5CNJBE= +github.com/longbridgeapp/sqlparser v0.3.1/go.mod h1:GIHaUq8zvYyHLCLMJJykx1CdM6LHtkUih/QaJXySSx4= +github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA= +github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs= +github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= +github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI= +github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/oschwald/maxminddb-golang v1.13.1 h1:G3wwjdN9JmIK2o/ermkHM+98oX5fS+k5MbwsmL4MRQE= +github.com/oschwald/maxminddb-golang v1.13.1/go.mod h1:K4pgV9N/GcK694KSTmVSDTODk4IsCNThNdTmnaBZ/F8= +github.com/pelletier/go-toml/v2 v2.1.1 h1:LWAJwfNvjQZCFIDKWYQaM62NcYeYViCmWIwmOStowAI= +github.com/pelletier/go-toml/v2 v2.1.1/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE= +github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg= +github.com/swaggo/gin-swagger v1.6.1 h1:Ri06G4gc9N4t4k8hekMigJ9zKTFSlqj/9paAQCQs7cY= +github.com/swaggo/gin-swagger v1.6.1/go.mod h1:LQ+hJStHakCWRiK/YNYtJOu4mR2FP+pxLnILT/qNiTw= +github.com/swaggo/swag v1.16.4 h1:clWJtd9LStiG3VeijiCfOVODP6VpHtKdQy9ELFG3s1A= +github.com/swaggo/swag v1.16.4/go.mod h1:VBsHJRsDvfYvqoiMKnsdwhNV9LEMHgEDZcyVYX0sxPg= +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/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw= +github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY= +github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= +github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/arch v0.7.0 h1:pskyeJh/3AmoQ8CPE95vxHLqp1G1GfGNXTmcl9NEKTc= +golang.org/x/arch v0.7.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= +golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= +golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/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-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= +golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/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= +gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= +gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= +gorm.io/gorm v1.25.10 h1:dQpO+33KalOA+aFYGlK+EfxcI5MbO7EP2yYygwh9h+s= +gorm.io/gorm v1.25.10/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= +gorm.io/sharding v0.6.2 h1:V9inmbdhN+RfWPEKTvbKKKv7qxLz1CneBDQvuL5P7jg= +gorm.io/sharding v0.6.2/go.mod h1:dXaAZv0qyUmLkLAciQ+NH2O1D1A4/ttrrZ/XK4xW9HU= +modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE= +modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY= +modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ= +modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= +modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds= +modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= +modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM= +modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk= +nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/openflare_relay/internal/config/config.go b/openflare_relay/internal/config/config.go new file mode 100644 index 00000000..195a9bc4 --- /dev/null +++ b/openflare_relay/internal/config/config.go @@ -0,0 +1,173 @@ +package config + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "time" +) + +type MillisecondDuration time.Duration + +func (d *MillisecondDuration) UnmarshalJSON(b []byte) error { + var v interface{} + if err := json.Unmarshal(b, &v); err != nil { + return err + } + switch value := v.(type) { + case float64: + *d = MillisecondDuration(time.Duration(value) * time.Millisecond) + return nil + case string: + duration, err := time.ParseDuration(value) + if err != nil { + return err + } + *d = MillisecondDuration(duration) + return nil + default: + return errors.New("invalid duration format") + } +} + +func (d MillisecondDuration) Duration() time.Duration { + return time.Duration(d) +} + +func (d MillisecondDuration) String() string { + return time.Duration(d).String() +} + +type Config struct { + ServerURL string `json:"server_url"` + AgentToken string `json:"agent_token"` + DiscoveryToken string `json:"discovery_token"` + NodeName string `json:"node_name"` + NodeIP string `json:"node_ip"` + FrpsPath string `json:"frps_path"` + DataDir string `json:"data_dir"` + StatePath string `json:"state_path"` + HeartbeatInterval MillisecondDuration `json:"heartbeat_interval"` + RequestTimeout MillisecondDuration `json:"request_timeout"` + configPath string +} + +func Load(path string) (*Config, error) { + data, err := os.ReadFile(path) + if err != nil && !os.IsNotExist(err) { + return nil, err + } + cfg := &Config{} + if err == nil { + if err = json.Unmarshal(data, cfg); err != nil { + return nil, err + } + } + if err != nil && !hasEnvConfig() { + return nil, err + } + cfg.configPath = path + applyEnvOverrides(cfg) + applyDefaults(cfg, filepath.Dir(path)) + if err = validate(cfg); err != nil { + return nil, err + } + return cfg, nil +} + +func hasEnvConfig() bool { + for _, key := range []string{ + "OPENFLARE_SERVER_URL", + "OPENFLARE_AGENT_TOKEN", + "OPENFLARE_DISCOVERY_TOKEN", + "OPENFLARE_NODE_NAME", + "OPENFLARE_NODE_IP", + "OPENFLARE_DATA_DIR", + "OPENFLARE_FRPS_PATH", + } { + if strings.TrimSpace(os.Getenv(key)) != "" { + return true + } + } + return false +} + +func applyEnvOverrides(cfg *Config) { + if cfg == nil { + return + } + overrideString := func(key string, target *string) { + if value := strings.TrimSpace(os.Getenv(key)); value != "" { + *target = value + } + } + overrideString("OPENFLARE_SERVER_URL", &cfg.ServerURL) + overrideString("OPENFLARE_AGENT_TOKEN", &cfg.AgentToken) + overrideString("OPENFLARE_DISCOVERY_TOKEN", &cfg.DiscoveryToken) + overrideString("OPENFLARE_NODE_NAME", &cfg.NodeName) + overrideString("OPENFLARE_NODE_IP", &cfg.NodeIP) + overrideString("OPENFLARE_DATA_DIR", &cfg.DataDir) + overrideString("OPENFLARE_FRPS_PATH", &cfg.FrpsPath) +} + +func applyDefaults(cfg *Config, baseDir string) { + baseDir = filepath.Clean(baseDir) + if cfg.FrpsPath == "" { + cfg.FrpsPath = "frps" // rely on PATH + } + if cfg.DataDir == "" { + cfg.DataDir = filepath.Join(baseDir, "data") + } + if cfg.NodeName == "" { + host, _ := os.Hostname() + cfg.NodeName = strings.TrimSpace(host) + } + if cfg.StatePath == "" { + cfg.StatePath = filepath.Join(cfg.DataDir, "relay-state.json") + } + if cfg.HeartbeatInterval <= 0 { + cfg.HeartbeatInterval = MillisecondDuration(10 * time.Second) + } + if cfg.RequestTimeout <= 0 { + cfg.RequestTimeout = MillisecondDuration(10 * time.Second) + } +} + +func validate(cfg *Config) error { + if cfg.ServerURL == "" { + return errors.New("server_url 不能为空") + } + if strings.TrimSpace(cfg.AgentToken) == "" && strings.TrimSpace(cfg.DiscoveryToken) == "" { + return errors.New("agent_token 和 discovery_token 不能同时为空") + } + if cfg.NodeName == "" { + return errors.New("node_name 不能为空") + } + return nil +} + +func (cfg *Config) InitialAuthToken() string { + if cfg == nil { + return "" + } + if token := strings.TrimSpace(cfg.AgentToken); token != "" { + return token + } + return strings.TrimSpace(cfg.DiscoveryToken) +} + +func (cfg *Config) Save() error { + if cfg == nil { + return errors.New("config 不能为空") + } + if cfg.configPath == "" { + return errors.New("config path 未初始化") + } + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return err + } + return os.WriteFile(cfg.configPath, data, 0o644) +} diff --git a/openflare_relay/internal/frps/manager.go b/openflare_relay/internal/frps/manager.go new file mode 100644 index 00000000..8af5f44f --- /dev/null +++ b/openflare_relay/internal/frps/manager.go @@ -0,0 +1,148 @@ +package frps + +import ( + "bytes" + "context" + "fmt" + "log/slog" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "time" + + "openflare/service" +) + +type Manager struct { + frpsPath string + dataDir string + configPath string + + mu sync.RWMutex + activeConfig *service.RelayConfig + cmd *exec.Cmd + status string +} + +func NewManager(frpsPath string, dataDir string) *Manager { + return &Manager{ + frpsPath: frpsPath, + dataDir: dataDir, + configPath: filepath.Join(dataDir, "frps.toml"), + status: "unhealthy", + } +} + +func (m *Manager) GetVersion() string { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, m.frpsPath, "-v") + out, err := cmd.CombinedOutput() + if err != nil { + slog.Error("failed to get frps version", "error", err) + return "" + } + return strings.TrimSpace(string(out)) +} + +func (m *Manager) GetStatus() string { + m.mu.RLock() + defer m.mu.RUnlock() + return m.status +} + +func (m *Manager) UpdateConfig(cfg *service.RelayConfig) { + if cfg == nil { + return + } + m.mu.Lock() + defer m.mu.Unlock() + + // Check if config changed + if m.activeConfig != nil && + m.activeConfig.BindPort == cfg.BindPort && + m.activeConfig.VhostHTTPPort == cfg.VhostHTTPPort && + m.activeConfig.AuthToken == cfg.AuthToken { + return // No change + } + + m.activeConfig = cfg + slog.Info("relay config updated, reloading frps") + + if err := m.renderConfig(cfg); err != nil { + slog.Error("failed to render frps config", "error", err) + m.status = "unhealthy" + return + } + + if err := m.restartProcess(); err != nil { + slog.Error("failed to restart frps", "error", err) + m.status = "unhealthy" + } else { + m.status = "healthy" + } +} + +func (m *Manager) renderConfig(cfg *service.RelayConfig) error { + if err := os.MkdirAll(m.dataDir, 0755); err != nil { + return err + } + var buf bytes.Buffer + buf.WriteString(fmt.Sprintf("bindPort = %d\n", cfg.BindPort)) + if cfg.VhostHTTPPort > 0 { + buf.WriteString(fmt.Sprintf("vhostHTTPPort = %d\n", cfg.VhostHTTPPort)) + } + if cfg.AuthToken != "" { + buf.WriteString("[auth]\n") + buf.WriteString("method = \"token\"\n") + buf.WriteString(fmt.Sprintf("token = \"%s\"\n", cfg.AuthToken)) + } + + return os.WriteFile(m.configPath, buf.Bytes(), 0644) +} + +func (m *Manager) restartProcess() error { + if m.cmd != nil && m.cmd.Process != nil { + slog.Debug("stopping existing frps process") + _ = m.cmd.Process.Kill() + _ = m.cmd.Wait() + m.cmd = nil + } + + cmd := exec.Command(m.frpsPath, "-c", m.configPath) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if err := cmd.Start(); err != nil { + return err + } + + m.cmd = cmd + + // Start a goroutine to monitor process exit + go func(c *exec.Cmd) { + err := c.Wait() + slog.Warn("frps process exited", "error", err) + m.mu.Lock() + if m.cmd == c { + m.cmd = nil + m.status = "unhealthy" + } + m.mu.Unlock() + }(cmd) + + return nil +} + +func (m *Manager) Stop() { + m.mu.Lock() + defer m.mu.Unlock() + if m.cmd != nil && m.cmd.Process != nil { + _ = m.cmd.Process.Kill() + _ = m.cmd.Wait() + m.cmd = nil + } +} diff --git a/openflare_relay/internal/heartbeat/service.go b/openflare_relay/internal/heartbeat/service.go new file mode 100644 index 00000000..df05be8d --- /dev/null +++ b/openflare_relay/internal/heartbeat/service.go @@ -0,0 +1,65 @@ +package heartbeat + +import ( + "context" + "log/slog" + "time" + + "openflare-relay/internal/config" + "openflare-relay/internal/frps" + "openflare-relay/internal/httpclient" + "openflare/service" +) + +type Service struct { + client *httpclient.Client + frpsManager *frps.Manager + config *config.Config +} + +func New(client *httpclient.Client, manager *frps.Manager, cfg *config.Config) *Service { + return &Service{ + client: client, + frpsManager: manager, + config: cfg, + } +} + +func (s *Service) Run(ctx context.Context) { + ticker := time.NewTicker(s.config.HeartbeatInterval.Duration()) + defer ticker.Stop() + + // initial heartbeat + s.doHeartbeat(ctx) + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + s.doHeartbeat(ctx) + } + } +} + +func (s *Service) doHeartbeat(ctx context.Context) { + slog.Debug("sending heartbeat") + + payload := service.RelayHeartbeatPayload{ + RelayVersion: "0.1.0", // TODO dynamically inject build version + FrpVersion: s.frpsManager.GetVersion(), + RelayStatus: s.frpsManager.GetStatus(), + FrpsConnCount: 0, + FrpsProxyCount: 0, + } + + resp, err := s.client.Heartbeat(ctx, payload) + if err != nil { + slog.Error("heartbeat failed", "error", err) + return + } + slog.Debug("heartbeat succeeded") + + // Update configs if changed + s.frpsManager.UpdateConfig(resp.RelayConfig) +} diff --git a/openflare_relay/internal/httpclient/client.go b/openflare_relay/internal/httpclient/client.go new file mode 100644 index 00000000..30a7aa7b --- /dev/null +++ b/openflare_relay/internal/httpclient/client.go @@ -0,0 +1,111 @@ +package httpclient + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "log/slog" + "net/http" + "strings" + "time" + + "openflare/service" +) + +type APIResponse[T any] struct { + Success bool `json:"success"` + Message string `json:"message"` + Data T `json:"data"` +} + +type Client struct { + baseURL string + token string + httpClient *http.Client +} + +func New(baseURL string, token string, timeout time.Duration) *Client { + return &Client{ + baseURL: strings.TrimRight(baseURL, "/"), + token: token, + httpClient: &http.Client{ + Timeout: timeout, + }, + } +} + +func (c *Client) Heartbeat(ctx context.Context, payload service.RelayHeartbeatPayload) (*service.RelayHeartbeatResponse, error) { + resp := APIResponse[service.RelayHeartbeatResponse]{} + if err := c.postJSON(ctx, "/api/relay/heartbeat", payload, &resp); err != nil { + return nil, err + } + if !resp.Success { + return nil, errors.New(resp.Message) + } + return &resp.Data, nil +} + +func (c *Client) SetToken(token string) { + c.token = strings.TrimSpace(token) + slog.Debug("http client token updated") +} + +func (c *Client) getJSON(ctx context.Context, path string, target any) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil) + if err != nil { + return err + } + req.Header.Set("X-Agent-Token", c.token) + return c.do(req, target) +} + +func (c *Client) postJSON(ctx context.Context, path string, body any, target any) error { + data, err := json.Marshal(body) + if err != nil { + return err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, bytes.NewReader(data)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Agent-Token", c.token) + return c.do(req, target) +} + +func (c *Client) do(req *http.Request, target any) error { + res, err := c.httpClient.Do(req) + if err != nil { + slog.Error("http request failed", "method", req.Method, "path", req.URL.Path, "error", err) + return err + } + defer func(Body io.ReadCloser) { + err := Body.Close() + if err != nil { + slog.Error("failed to close response body", "error", err) + } + }(res.Body) + if res.StatusCode != http.StatusOK { + slog.Warn("http request returned non-200", "method", req.Method, "path", req.URL.Path, "status", res.Status) + return errors.New(res.Status) + } + if target == nil { + var wrapper APIResponse[json.RawMessage] + if err = json.NewDecoder(res.Body).Decode(&wrapper); err != nil { + slog.Error("http response decode failed", "method", req.Method, "path", req.URL.Path, "error", err) + return err + } + if !wrapper.Success { + slog.Warn("http api response failed", "method", req.Method, "path", req.URL.Path, "message", wrapper.Message) + return errors.New(wrapper.Message) + } + return nil + } + if err = json.NewDecoder(res.Body).Decode(target); err != nil { + slog.Error("http response decode failed", "method", req.Method, "path", req.URL.Path, "error", err) + return err + } + return nil +} diff --git a/openflare_relay/internal/relay/runner.go b/openflare_relay/internal/relay/runner.go new file mode 100644 index 00000000..26995a70 --- /dev/null +++ b/openflare_relay/internal/relay/runner.go @@ -0,0 +1,94 @@ +package relay + +import ( + "context" + "encoding/json" + "log/slog" + "time" + + "openflare-relay/internal/config" + "openflare-relay/internal/frps" + "openflare-relay/internal/heartbeat" + "openflare-relay/internal/httpclient" + "openflare-relay/internal/state" + "openflare-relay/internal/wsclient" + "openflare/service" +) + +type Runner struct { + Config *config.Config + StateStore *state.Store + HeartbeatService *heartbeat.Service + FrpsManager *frps.Manager + WebSocketService *wsclient.Client + HttpClient *httpclient.Client +} + +func (r *Runner) Run(ctx context.Context) error { + // Start heartbeat loop in background + go r.HeartbeatService.Run(ctx) + + // WebSocket reconnection loop + for { + select { + case <-ctx.Done(): + r.FrpsManager.Stop() + return ctx.Err() + default: + } + + conn, err := r.WebSocketService.Connect(ctx) + if err != nil { + slog.Error("relay ws connect failed, will retry", "error", err) + r.sleepContext(ctx, 5*time.Second) + continue + } + + r.handleConnection(ctx, conn) + _ = conn.Close() + slog.Info("relay ws connection closed, reconnecting...") + r.sleepContext(ctx, 2*time.Second) + } +} + +func (r *Runner) handleConnection(ctx context.Context, conn *wsclient.Connection) { + for { + select { + case <-ctx.Done(): + return + default: + } + + msg, err := conn.Receive() + if err != nil { + slog.Error("relay ws receive failed", "error", err) + return + } + + switch msg.Type { + case "ping": + _ = conn.SendPong() + case "relay_config": + payloadBytes, ok := msg.Payload.(json.RawMessage) + if !ok { + slog.Error("invalid relay_config payload type") + continue + } + var cfg service.RelayConfig + if err := json.Unmarshal(payloadBytes, &cfg); err != nil { + slog.Error("failed to unmarshal relay_config", "error", err) + continue + } + r.FrpsManager.UpdateConfig(&cfg) + default: + slog.Debug("ignored unknown ws message type", "type", msg.Type) + } + } +} + +func (r *Runner) sleepContext(ctx context.Context, d time.Duration) { + select { + case <-ctx.Done(): + case <-time.After(d): + } +} diff --git a/openflare_relay/internal/state/store.go b/openflare_relay/internal/state/store.go new file mode 100644 index 00000000..39c63699 --- /dev/null +++ b/openflare_relay/internal/state/store.go @@ -0,0 +1,55 @@ +package state + +import ( + "encoding/json" + "log/slog" + "os" + "sync" +) + +type Store struct { + path string + mu sync.RWMutex +} + +type State struct { + LastAuthToken string `json:"last_auth_token"` +} + +func NewStore(path string) *Store { + return &Store{ + path: path, + } +} + +func (s *Store) Load() (*State, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + data, err := os.ReadFile(s.path) + if err != nil { + if os.IsNotExist(err) { + return &State{}, nil + } + return nil, err + } + + var state State + if err := json.Unmarshal(data, &state); err != nil { + return &State{}, nil // Return empty state on corrupted file + } + return &state, nil +} + +func (s *Store) Save(state *State) error { + s.mu.Lock() + defer s.mu.Unlock() + + data, err := json.MarshalIndent(state, "", " ") + if err != nil { + return err + } + + slog.Debug("saving relay state") + return os.WriteFile(s.path, data, 0644) +} diff --git a/openflare_relay/internal/wsclient/client.go b/openflare_relay/internal/wsclient/client.go new file mode 100644 index 00000000..cd882508 --- /dev/null +++ b/openflare_relay/internal/wsclient/client.go @@ -0,0 +1,143 @@ +package wsclient + +import ( + "context" + "encoding/json" + "errors" + "log/slog" + "net" + "net/http" + "net/url" + "strings" + "time" + + "golang.org/x/net/websocket" + "openflare/service" +) + +type Client struct { + baseURL string + token string + timeout time.Duration +} + +type Connection struct { + conn *websocket.Conn + url string + readTimeout time.Duration +} + +func New(baseURL string, token string, timeout time.Duration) *Client { + return &Client{ + baseURL: strings.TrimRight(baseURL, "/"), + token: strings.TrimSpace(token), + timeout: timeout, + } +} + +func (c *Client) SetToken(token string) { + c.token = strings.TrimSpace(token) + slog.Debug("relay ws client token updated") +} + +func (c *Client) Connect(ctx context.Context) (*Connection, error) { + wsURL, err := buildWebsocketURL(c.baseURL) + if err != nil { + return nil, err + } + if strings.TrimSpace(c.token) == "" { + return nil, errors.New("relay ws token is empty") + } + origin := strings.TrimSpace(c.baseURL) + if origin == "" { + origin = "http://localhost" + } + config, err := websocket.NewConfig(wsURL, origin) + if err != nil { + return nil, err + } + config.Header = http.Header{} + config.Header.Set("X-Agent-Token", c.token) + if c.timeout > 0 { + config.Dialer = &net.Dialer{Timeout: c.timeout} + } + slog.Debug("relay ws dialing server", "url", wsURL) + conn, err := config.DialContext(ctx) + if err != nil { + return nil, err + } + slog.Debug("relay ws dial succeeded", "url", wsURL) + return &Connection{conn: conn, url: wsURL, readTimeout: websocketReadTimeout(c.timeout)}, nil +} + +func buildWebsocketURL(baseURL string) (string, error) { + parsed, err := url.Parse(strings.TrimRight(baseURL, "/")) + if err != nil { + return "", err + } + switch parsed.Scheme { + case "http": + parsed.Scheme = "ws" + case "https": + parsed.Scheme = "wss" + case "ws", "wss": + default: + return "", errors.New("server_url scheme must be http, https, ws, or wss") + } + parsed.Path = strings.TrimRight(parsed.Path, "/") + "/api/relay/ws" + parsed.RawQuery = "" + parsed.Fragment = "" + return parsed.String(), nil +} + +func (conn *Connection) SendPong() error { + if conn == nil || conn.conn == nil { + return errors.New("relay ws connection is nil") + } + slog.Debug("relay ws sending pong") + return websocket.JSON.Send(conn.conn, service.WSMessage{ + Type: "pong", + }) +} + +func (conn *Connection) Receive() (service.WSMessage, error) { + var message service.WSMessage + if conn == nil || conn.conn == nil { + return message, errors.New("relay ws connection is nil") + } + if conn.readTimeout > 0 { + _ = conn.conn.SetReadDeadline(time.Now().Add(conn.readTimeout)) + } + // Use custom json unmarshaling to handle any type + var raw struct { + Type string `json:"type"` + Payload json.RawMessage `json:"payload,omitempty"` + } + err := websocket.JSON.Receive(conn.conn, &raw) + if err != nil { + var netErr net.Error + if errors.As(err, &netErr) && netErr.Timeout() { + slog.Debug("relay ws receive timeout waiting for server message", "timeout", conn.readTimeout) + } + return message, err + } + message.Type = raw.Type + message.Payload = raw.Payload + slog.Debug("relay ws received message", "type", message.Type) + return message, nil +} + +func websocketReadTimeout(requestTimeout time.Duration) time.Duration { + timeout := requestTimeout * 6 + if timeout < 75*time.Second { + return 75 * time.Second + } + return timeout +} + +func (conn *Connection) Close() error { + if conn == nil || conn.conn == nil { + return nil + } + return conn.conn.Close() +}