This commit is contained in:
ryan
2026-06-18 16:08:48 +08:00
parent 3366edb3a1
commit 772962c2e9
156 changed files with 23460 additions and 183 deletions
+48 -40
View File
@@ -1,44 +1,52 @@
module github.com/Rain-kl/Wavelet
go 1.25.5
go 1.25.7
require (
github.com/ClickHouse/clickhouse-go/v2 v2.37.2
github.com/ClickHouse/clickhouse-go/v2 v2.45.0
github.com/alicebob/miniredis/v2 v2.38.0
github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.5.1
github.com/aws/aws-sdk-go-v2 v1.41.5
github.com/aws/aws-sdk-go-v2/config v1.32.14
github.com/aws/aws-sdk-go-v2/credentials v1.19.14
github.com/aws/aws-sdk-go-v2/service/s3 v1.99.0
github.com/bwmarrin/snowflake v0.3.0
github.com/coreos/go-oidc/v3 v3.17.0
github.com/deepteams/webp v1.2.3
github.com/gin-contrib/sessions v1.0.4
github.com/gin-gonic/gin v1.11.0
github.com/glebarez/sqlite v1.11.0
github.com/go-jose/go-jose/v4 v4.1.3
github.com/go-jose/go-jose/v4 v4.1.4
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/hibiken/asynq v0.25.1
github.com/pressly/goose/v3 v3.15.1
github.com/maypok86/otter/v2 v2.3.0
github.com/peterbourgon/diskv/v3 v3.0.1
github.com/pressly/goose/v3 v3.27.1
github.com/rain-kl/openflare v0.0.0
github.com/redis/go-redis/extra/redisotel/v9 v9.16.0
github.com/redis/go-redis/v9 v9.16.0
github.com/robfig/cron/v3 v3.0.1
github.com/shopspring/decimal v1.4.0
github.com/spf13/cobra v1.10.1
github.com/spf13/viper v1.21.0
github.com/stretchr/testify v1.11.1
github.com/studio-b12/gowebdav v0.12.0
github.com/swaggo/files v1.0.1
github.com/swaggo/gin-swagger v1.6.1
github.com/swaggo/swag v1.16.6
github.com/uptrace/opentelemetry-go-extra/otelzap v0.3.2
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.61.0
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0
go.opentelemetry.io/otel v1.36.0
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0
go.opentelemetry.io/otel v1.43.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.36.0
go.opentelemetry.io/otel/sdk v1.36.0
go.opentelemetry.io/otel/trace v1.36.0
go.uber.org/zap v1.27.0
go.opentelemetry.io/otel/sdk v1.43.0
go.opentelemetry.io/otel/trace v1.43.0
go.uber.org/zap v1.27.1
golang.org/x/crypto v0.51.0
golang.org/x/image v0.42.0
golang.org/x/mod v0.36.0
golang.org/x/oauth2 v0.32.0
golang.org/x/oauth2 v0.34.0
golang.org/x/sync v0.21.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1
gorm.io/driver/postgres v1.6.0
@@ -48,12 +56,13 @@ require (
gorm.io/plugin/opentelemetry v0.1.14
)
replace github.com/rain-kl/openflare => ../
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/ClickHouse/ch-go v0.66.1 // indirect
filippo.io/edwards25519 v1.2.0 // indirect
github.com/ClickHouse/ch-go v0.71.0 // indirect
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.5.1 // indirect
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/andybalholm/brotli v1.2.1 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect
@@ -77,12 +86,12 @@ require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/deepteams/webp v1.2.3 // 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/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.11 // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/glebarez/go-sqlite v1.21.2 // indirect
github.com/go-faster/city v1.0.1 // indirect
@@ -106,74 +115,73 @@ require (
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.18.0 // indirect
github.com/gomodule/redigo v1.9.3 // indirect
github.com/gomodule/redigo v2.0.0+incompatible // indirect
github.com/google/btree v1.0.0 // indirect
github.com/gorilla/context v1.1.2 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect
github.com/gorilla/sessions v1.4.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 // indirect
github.com/hashicorp/go-version v1.7.0 // indirect
github.com/hashicorp/go-version v1.8.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // 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.7.6 // indirect
github.com/jackc/pgx/v5 v5.9.2 // 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/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.18.1 // indirect
github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12 // indirect
github.com/klauspost/compress v1.18.5 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-isatty v0.0.21 // indirect
github.com/mattn/go-sqlite3 v1.14.22 // indirect
github.com/maypok86/otter/v2 v2.3.0 // indirect
github.com/mfridman/interpolate v0.0.2 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/paulmach/orb v0.12.0 // indirect
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/oschwald/maxminddb-golang v1.13.1 // indirect
github.com/paulmach/orb v0.13.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/peterbourgon/diskv/v3 v3.0.1 // indirect
github.com/pierrec/lz4/v4 v4.1.22 // indirect
github.com/pierrec/lz4/v4 v4.1.26 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/quic-go/qpack v0.5.1 // indirect
github.com/quic-go/quic-go v0.55.0 // indirect
github.com/redis/go-redis/extra/rediscmd/v9 v9.16.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/sagikazarmark/locafero v0.12.0 // indirect
github.com/segmentio/asm v1.2.1 // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/studio-b12/gowebdav v0.12.0 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
github.com/uptrace/opentelemetry-go-extra/otelutil v0.3.2 // indirect
github.com/yuin/gopher-lua v1.1.1 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.36.0 // indirect
go.opentelemetry.io/otel/log v0.12.2 // indirect
go.opentelemetry.io/otel/metric v1.36.0 // indirect
go.opentelemetry.io/otel/metric v1.43.0 // indirect
go.opentelemetry.io/proto/otlp v1.6.0 // indirect
go.uber.org/mock v0.6.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/arch v0.22.0 // indirect
golang.org/x/image v0.42.0 // indirect
golang.org/x/net v0.54.0 // indirect
golang.org/x/sys v0.44.0 // indirect
golang.org/x/text v0.38.0 // indirect
golang.org/x/time v0.14.0 // indirect
golang.org/x/tools v0.45.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20251103181224-f26f9409b101 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251103181224-f26f9409b101 // indirect
google.golang.org/grpc v1.72.1 // indirect
google.golang.org/protobuf v1.36.10 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260120221211-b8f7ae30c516 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529 // indirect
google.golang.org/grpc v1.80.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
gorm.io/driver/clickhouse v0.7.0 // indirect
gorm.io/driver/mysql v1.6.0 // indirect
modernc.org/libc v1.24.1 // indirect
modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.7.2 // indirect
modernc.org/sqlite v1.26.0 // indirect
modernc.org/libc v1.72.1 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.49.1 // indirect
)
+104 -143
View File
@@ -1,17 +1,17 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/ClickHouse/ch-go v0.66.1 h1:LQHFslfVYZsISOY0dnOYOXGkOUvpv376CCm8g7W74A4=
github.com/ClickHouse/ch-go v0.66.1/go.mod h1:NEYcg3aOFv2EmTJfo4m2WF7sHB/YFbLUuIWv9iq76xY=
github.com/ClickHouse/clickhouse-go/v2 v2.37.2 h1:wRLNKoynvHQEN4znnVHNLaYnrqVc9sGJmGYg+GGCfto=
github.com/ClickHouse/clickhouse-go/v2 v2.37.2/go.mod h1:pH2zrBGp5Y438DMwAxXMm1neSXPPjSI7tD4MURVULw8=
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
github.com/ClickHouse/ch-go v0.71.0 h1:bUdZ/EZj/LcVHsMqaRUP2holqygrPWQKeMjc6nZoyRM=
github.com/ClickHouse/ch-go v0.71.0/go.mod h1:NwbNc+7jaqfY58dmdDUbG4Jl22vThgx1cYjBw0vtgXw=
github.com/ClickHouse/clickhouse-go/v2 v2.45.0 h1:iHt15nA4iYhfde5bDQAcLAat9BAh7B5ksPRNRa4UI7s=
github.com/ClickHouse/clickhouse-go/v2 v2.45.0/go.mod h1:giJfUVlMkcfUEPVfRpt51zZaGEx9i17gCos8gBl392c=
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/alicebob/miniredis/v2 v2.38.0 h1:nZAzCR+Lj+Vxk4ZXzm2NuKq2O33RXj1XxJ2e2uP9jiw=
github.com/alicebob/miniredis/v2 v2.38.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.5.1 h1:vtiFd0hhPAbyYJjztl0wYUq/PqEGkIlDmVuTIy6zw8Y=
github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.5.1/go.mod h1:FTzydeQVmR24FI0D6XWUOMKckjXehM/jgMn1xC+DA9M=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY=
github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o=
@@ -78,6 +78,10 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/deepteams/webp v1.2.3 h1:TedmP3+U8/xBrC0dTkbd3+wbmnM4EIaMowQJpLz6UqU=
github.com/deepteams/webp v1.2.3/go.mod h1:J8Ap+HAixxpKKRN9IpEeSKlfvhsef1v43jKTO7m3f4c=
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-farm v0.0.0-20240924180020-3414d57e47da h1:aIftn67I1fkbMa512G+w+Pxci9hJPB8oMnkcP3iZF38=
github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
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=
@@ -88,8 +92,8 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
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.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik=
github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
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/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4=
github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk=
github.com/gin-contrib/sessions v1.0.4 h1:ha6CNdpYiTOK/hTp05miJLbpTSNfOnFg5Jm2kbcqy8U=
@@ -106,8 +110,8 @@ github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw=
github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw=
github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg=
github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo=
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
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-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
@@ -152,24 +156,19 @@ 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/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/gomodule/redigo v1.9.3 h1:dNPSXeXv6HCq2jdyWfjgmhBdqnR6PRO3m/G05nvpPC8=
github.com/gomodule/redigo v1.9.3/go.mod h1:KsU3hiK/Ay8U42qpaJk+kuNa3C+spxapWpM+ywhcgtw=
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/btree v1.0.0 h1:0udJVsspx3VBr5FwtLhQQtuAsVc79tTq0ocGIPAU6qo=
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
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.2 h1:WRkNAv2uoa03QNIc1A6u4O7DAGMUVoopZhkiXWA2V1o=
@@ -182,8 +181,10 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI=
github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY=
github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4=
github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/hibiken/asynq v0.25.1 h1:phj028N0nm15n8O2ims+IvJ2gz4k2auvermngh9JhTw=
github.com/hibiken/asynq v0.25.1/go.mod h1:pazWNOLBu0FEynQRBvHA26qdIKRSmfdIfUm4HdsLmXg=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
@@ -192,60 +193,56 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
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.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
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/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
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.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co=
github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0=
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/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
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 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/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/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/maypok86/otter/v2 v2.3.0 h1:8H8AVVFUSzJwIegKwv1uF5aGitTY+AIrtktg7OcLs8w=
github.com/maypok86/otter/v2 v2.3.0/go.mod h1:XgIdlpmL6jYz882/CAx1E4C1ukfgDKSaw4mWq59+7l8=
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
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 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
github.com/paulmach/orb v0.12.0 h1:z+zOwjmG3MyEEqzv92UN49Lg1JFYx0L9GpGKNVDKk1s=
github.com/paulmach/orb v0.12.0/go.mod h1:5mULz1xQfs3bmQm63QEJA6lNGujuRafwA5S/EnuLaLU=
github.com/paulmach/protoscan v0.2.1/go.mod h1:SpcSwydNLrxUGSDvXvO0P7g7AuhJ7lcKfDlhJCDw2gY=
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/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
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/paulmach/orb v0.13.0 h1:r7n7mQGGF+cj/CbcivEj9J3HGK+XR+yXnvzRdq9saIw=
github.com/paulmach/orb v0.13.0/go.mod h1:6scRWINywA2Jf05dcjOfLfxrUIMECvTSG2MVbRLxu/k=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/peterbourgon/diskv/v3 v3.0.1 h1:x06SQA46+PKIUftmEujdwSEpIx8kR+M9eLYsUxeYveU=
github.com/peterbourgon/diskv/v3 v3.0.1/go.mod h1:kJ5Ny7vLdARGU3WUuy6uzO6T0nb/2gWcT1JiBvRmb5o=
github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU=
github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY=
github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pressly/goose/v3 v3.15.1 h1:dKaJ1SdLvS/+HtS8PzFT0KBEtICC1jewLXM+b3emlv8=
github.com/pressly/goose/v3 v3.15.1/go.mod h1:0E3Yg/+EwYzO6Rz2P98MlClFgIcoujbVRs575yi3iIM=
github.com/pressly/goose/v3 v3.27.1 h1:6uEvcprBybDmW4hcz3gYujhARhye+GoWKhEWyzD5sh4=
github.com/pressly/goose/v3 v3.27.1/go.mod h1:maruOxsPnIG2yHHyo8UqKWXYKFcH7Q76csUV7+7KYoM=
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
github.com/quic-go/quic-go v0.55.0 h1:zccPQIqYCXDt5NmcEabyYvOnomjs8Tlwl7tISjJh9Mk=
@@ -260,13 +257,15 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94
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/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
@@ -285,7 +284,6 @@ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSS
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
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=
@@ -303,7 +301,6 @@ github.com/swaggo/gin-swagger v1.6.1 h1:Ri06G4gc9N4t4k8hekMigJ9zKTFSlqj/9paAQCQs
github.com/swaggo/gin-swagger v1.6.1/go.mod h1:LQ+hJStHakCWRiK/YNYtJOu4mR2FP+pxLnILT/qNiTw=
github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=
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/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
@@ -312,26 +309,19 @@ github.com/uptrace/opentelemetry-go-extra/otelutil v0.3.2 h1:3/aHKUq7qaFMWxyQV0W
github.com/uptrace/opentelemetry-go-extra/otelutil v0.3.2/go.mod h1:Zit4b8AQXaXvA68+nzmbyDzqiyFRISyw1JiD5JqUBjw=
github.com/uptrace/opentelemetry-go-extra/otelzap v0.3.2 h1:cj/Z6FKTTYBnstI0Lni9PA+k2foounKIPUmj1LBwNiQ=
github.com/uptrace/opentelemetry-go-extra/otelzap v0.3.2/go.mod h1:LDaXk90gKEC2nC7JH3Lpnhfu+2V7o/TsqomJJmqA39o=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
github.com/xdg-go/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g=
github.com/xdg-go/stringprep v1.0.3/go.mod h1:W3f5j4i+9rC0kuIEJL0ky1VpHXQU3ocBgklLGvcBnW8=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
go.mongodb.org/mongo-driver v1.11.4/go.mod h1:PTSz5yu21bkT/wXpkS7WR5f0ddqw5quethTUn9WM+2g=
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/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.61.0 h1:VkrF0D14uQrCmPqBkYlwWnhgcwzXvIRAjX8eXO7vy6M=
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.61.0/go.mod h1:p/mVr/Hs7gQnguNPXUyuiMRNtisyc9y/Oo7Kqr/6wbU=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg=
go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.36.0 h1:dNzwXjZKpMpE2JhmO+9HsPl42NIXFIFSUSSs0fiqra0=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.36.0/go.mod h1:90PoxvaEB5n6AOdZvi+yWJQoE95U8Dhhw2bSyRqnTD0=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.36.0 h1:JgtbA0xkWHnTmYk7YusopJFX6uleBmAuZ8n05NEh8nQ=
@@ -340,14 +330,14 @@ go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.36.0 h1:G8Xec/SgZQricwW
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.36.0/go.mod h1:PD57idA/AiFD5aqoxGxCvT/ILJPeHy3MjqU/NS7KogY=
go.opentelemetry.io/otel/log v0.12.2 h1:yob9JVHn2ZY24byZeaXpTVoPS6l+UrrxmxmPKohXTwc=
go.opentelemetry.io/otel/log v0.12.2/go.mod h1:ShIItIxSYxufUMt+1H5a2wbckGli3/iCfuEbVZi/98E=
go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE=
go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs=
go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs=
go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY=
go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis=
go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4=
go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w=
go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
go.opentelemetry.io/proto/otlp v1.6.0 h1:jQjP+AQyTf+Fe7OKj/MfkDrmK4MNVtw2NpXsf9fefDI=
go.opentelemetry.io/proto/otlp v1.6.0/go.mod h1:cicgGehlFuNdgZkcALOCh3VE6K/u2tAjzlRhDwmVpZc=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
@@ -356,65 +346,39 @@ go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/image v0.42.0 h1:1gSs6ehNWXLbkHBIPcWztk3D/6aIA/8hauiAYtlodVY=
golang.org/x/image v0.42.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
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.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY=
golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/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.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.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-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
@@ -422,40 +386,29 @@ golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuX
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.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.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/genproto/googleapis/api v0.0.0-20251103181224-f26f9409b101 h1:vk5TfqZHNn0obhPIYeS+cxIFKFQgser/M2jnI+9c6MM=
google.golang.org/genproto/googleapis/api v0.0.0-20251103181224-f26f9409b101/go.mod h1:E17fc4PDhkr22dE3RgnH2hEubUaky6ZwW4VhANxyspg=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251103181224-f26f9409b101 h1:tRPGkdGHuewF4UisLzzHHr1spKw92qLM98nIzxbC0wY=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251103181224-f26f9409b101/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.72.1 h1:HR03wO6eyZ7lknl75XlxABNVLLFc2PAb6mHlYh756mA=
google.golang.org/grpc v1.72.1/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/api v0.0.0-20260120221211-b8f7ae30c516 h1:vmC/ws+pLzWjj/gzApyoZuSVrDtF1aod4u/+bbj8hgM=
google.golang.org/genproto/googleapis/api v0.0.0-20260120221211-b8f7ae30c516/go.mod h1:p3MLuOwURrGBRoEyFHBT3GjUwaCQVKeNqqWxlcISGdw=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529 h1:XF8+t6QQiS0o9ArVan/HW8Q7cycNPGsJf6GA2nXxYAg=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
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-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
@@ -477,23 +430,31 @@ gorm.io/plugin/dbresolver v1.6.2 h1:F4b85TenghUeITqe3+epPSUtHH7RIk3fXr5l83DF8Pc=
gorm.io/plugin/dbresolver v1.6.2/go.mod h1:tctw63jdrOezFR9HmrKnPkmig3m5Edem9fdxk9bQSzM=
gorm.io/plugin/opentelemetry v0.1.14 h1:xivP39t/0JgcceDl+BLwVAJHihjFEUj0ZocMSBwZ7ZY=
gorm.io/plugin/opentelemetry v0.1.14/go.mod h1:ZAp4v5vU1CCcK9Oo8/va5rl6NStrzpSU+a70evd+W/g=
lukechampine.com/uint128 v1.3.0 h1:cDdUVfRwDUDovz610ABgFD17nXD4/uDgVHl2sC3+sbo=
lukechampine.com/uint128 v1.3.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
modernc.org/cc/v3 v3.41.0 h1:QoR1Sn3YWlmA1T4vLaKZfawdVtSiGx8H+cEojbC7v1Q=
modernc.org/cc/v3 v3.41.0/go.mod h1:Ni4zjJYJ04CDOhG7dn640WGfwBzfE0ecX8TyMB0Fv0Y=
modernc.org/ccgo/v3 v3.16.15 h1:KbDR3ZAVU+wiLyMESPtbtE/Add4elztFyfsWoNTgxS0=
modernc.org/ccgo/v3 v3.16.15/go.mod h1:yT7B+/E2m43tmMOT51GMoM98/MtHIcQQSleGnddkUNI=
modernc.org/libc v1.24.1 h1:uvJSeCKL/AgzBo2yYIPPTy82v21KgGnizcGYfBHaNuM=
modernc.org/libc v1.24.1/go.mod h1:FmfO1RLrU3MHJfyi9eYYmZBfi/R+tqZ6+hQ3yQQUkak=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E=
modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E=
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/sqlite v1.26.0 h1:SocQdLRSYlA8W99V8YH0NES75thx19d9sB/aFc4R8Lw=
modernc.org/sqlite v1.26.0/go.mod h1:FL3pVXie73rg3Rii6V/u5BoHlSoyeZeIgKZEgHARyCU=
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
modernc.org/cc/v4 v4.28.1 h1:XpLbkYVQ24E8tX5u8+yWGvaxerxkR/S4zqxI8ZoSBuc=
modernc.org/cc/v4 v4.28.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.33.0 h1:dspBCm75jsj8Y/ufwAMVfe375L2iYdMyQ2QG/v3hL54=
modernc.org/ccgo/v4 v4.33.0/go.mod h1:+RhXBoRYzRwaH21mV/aj6XvQRDtfjcZfAlPMsQo8CR0=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.72.1 h1:db1xwJ6u1kE3KHTFTTbe2GCrczHPKzlURP0aDC4NGD0=
modernc.org/libc v1.72.1/go.mod h1:HRMiC/PhPGLIPM7GzAFCbI+oSgE3dhZ8FWftmRrHVlY=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.49.1 h1:dYGHTKcX1sJ+EQDnUzvz4TJ5GbuvhNJa8Fg6ElGx73U=
modernc.org/sqlite v1.49.1/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
@@ -0,0 +1,40 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package updater
import "context"
// GetStatus returns the current build and the newest compatible upstream release.
func GetStatus(ctx context.Context) (Status, error) {
status, _, err := defaultManager.status(ctx)
return status, err
}
// PrepareUpgrade downloads and stages the upgrade binary for the current platform.
func PrepareUpgrade(ctx context.Context) (executable string, stagedBinary string, status Status, err error) {
status, _, err = defaultManager.status(ctx)
if err != nil {
return "", "", Status{}, err
}
executable, stagedBinary, err = defaultManager.prepareUpgrade(ctx)
return executable, stagedBinary, status, err
}
// ApplyPreparedUpgrade replaces the running binary and restarts the process.
func ApplyPreparedUpgrade(executable, stagedBinary string) error {
return replaceAndRestart(executable, stagedBinary)
}
// FinishUpgrade clears the in-progress upgrade flag after a failed restart.
func FinishUpgrade() {
defaultManager.finishUpgrade()
}
// IsUpgrading reports whether an upgrade task is currently running.
func IsUpgrading() bool {
defaultManager.mu.Lock()
defer defaultManager.mu.Unlock()
return defaultManager.upgrading
}
@@ -0,0 +1,143 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package agent
import (
"context"
"errors"
"strings"
"sync"
"time"
"github.com/Rain-kl/Wavelet/internal/model"
"gorm.io/gorm"
)
const (
agentTokenPositiveCacheTTL = 2 * time.Minute
agentTokenNegativeCacheTTL = 10 * time.Minute
)
type cachedAgentNode struct {
node *model.OpenFlareNode
expiresAt time.Time
}
type accessTokenAuthCache struct {
mu sync.RWMutex
positive map[string]cachedAgentNode
negative map[string]time.Time
now func() time.Time
loadNodeByToken func(context.Context, string) (*model.OpenFlareNode, error)
}
var tokenCache = newAccessTokenAuthCache()
func newAccessTokenAuthCache() *accessTokenAuthCache {
return &accessTokenAuthCache{
positive: make(map[string]cachedAgentNode),
negative: make(map[string]time.Time),
now: time.Now,
loadNodeByToken: func(ctx context.Context, token string) (*model.OpenFlareNode, error) {
return model.GetOpenFlareNodeByAccessToken(ctx, token)
},
}
}
func (c *accessTokenAuthCache) authenticate(ctx context.Context, token string) (*model.OpenFlareNode, error) {
now := c.now()
if node, ok := c.getNode(token, now); ok {
return node, nil
}
if c.isMissing(token, now) {
return nil, gorm.ErrRecordNotFound
}
node, err := c.loadNodeByToken(ctx, token)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
c.storeMissing(token, now.Add(agentTokenNegativeCacheTTL))
}
return nil, err
}
c.storeNode(token, node)
return cloneNode(node), nil
}
func (c *accessTokenAuthCache) getNode(token string, now time.Time) (*model.OpenFlareNode, bool) {
c.mu.RLock()
entry, ok := c.positive[token]
c.mu.RUnlock()
if !ok {
return nil, false
}
if now.After(entry.expiresAt) {
c.mu.Lock()
delete(c.positive, token)
c.mu.Unlock()
return nil, false
}
return cloneNode(entry.node), true
}
func (c *accessTokenAuthCache) isMissing(token string, now time.Time) bool {
c.mu.RLock()
expiresAt, ok := c.negative[token]
c.mu.RUnlock()
if !ok {
return false
}
if now.After(expiresAt) {
c.mu.Lock()
delete(c.negative, token)
c.mu.Unlock()
return false
}
return true
}
func (c *accessTokenAuthCache) storeNode(token string, node *model.OpenFlareNode) {
if token == "" || node == nil {
return
}
c.mu.Lock()
defer c.mu.Unlock()
delete(c.negative, token)
c.positive[token] = cachedAgentNode{
node: cloneNode(node),
expiresAt: c.now().Add(agentTokenPositiveCacheTTL),
}
}
func (c *accessTokenAuthCache) storeMissing(token string, expiresAt time.Time) {
if token == "" {
return
}
c.mu.Lock()
defer c.mu.Unlock()
delete(c.positive, token)
c.negative[token] = expiresAt
}
func (c *accessTokenAuthCache) reset() {
c.mu.Lock()
defer c.mu.Unlock()
c.positive = make(map[string]cachedAgentNode)
c.negative = make(map[string]time.Time)
}
// ResetAuthCacheForTest clears the in-memory access token cache for integration tests.
func ResetAuthCacheForTest() {
tokenCache.reset()
}
// AuthenticateAccessToken validates X-Agent-Token against of_nodes.access_token.
func AuthenticateAccessToken(ctx context.Context, token string) (*model.OpenFlareNode, error) {
token = strings.TrimSpace(token)
if token == "" {
return nil, errors.New(errMissingAgentToken)
}
return tokenCache.authenticate(ctx, token)
}
@@ -0,0 +1,98 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package agent
import (
"context"
"encoding/json"
"errors"
"strings"
"time"
"github.com/Rain-kl/Wavelet/internal/db"
"gorm.io/gorm"
)
type configVersionRecord struct {
ID uint `gorm:"primaryKey"`
Version string `gorm:"column:version"`
SnapshotJSON string `gorm:"column:snapshot_json"`
SupportFilesJSON string `gorm:"column:support_files_json"`
Checksum string `gorm:"column:checksum"`
IsActive bool `gorm:"column:is_active"`
CreatedAt time.Time `gorm:"column:created_at"`
}
func (configVersionRecord) TableName() string {
return "of_config_versions"
}
func getActiveConfigMeta(ctx context.Context) (*ActiveConfigMeta, error) {
version, err := loadActiveConfigVersion(ctx)
if err != nil {
return nil, err
}
return &ActiveConfigMeta{
Version: version.Version,
Checksum: version.Checksum,
}, nil
}
func getActiveConfigForAgent(ctx context.Context) (*ConfigResponse, error) {
version, err := loadActiveConfigVersion(ctx)
if err != nil {
return nil, err
}
var supportFiles []SupportFile
if strings.TrimSpace(version.SupportFilesJSON) != "" {
if err = json.Unmarshal([]byte(version.SupportFilesJSON), &supportFiles); err != nil {
return nil, err
}
}
return &ConfigResponse{
Version: version.Version,
Checksum: version.Checksum,
SourceConfigJSON: version.SnapshotJSON,
SupportFiles: sourceSupportFiles(supportFiles),
CreatedAt: version.CreatedAt,
}, nil
}
func loadActiveConfigVersion(ctx context.Context) (*configVersionRecord, error) {
conn := db.DB(ctx)
if conn == nil {
return nil, errors.New("database not initialized")
}
version := &configVersionRecord{}
err := conn.Where("is_active = ?", true).Order("id desc").First(version).Error
if err != nil {
return nil, err
}
return version, nil
}
func sourceSupportFiles(files []SupportFile) []SupportFile {
if len(files) == 0 {
return nil
}
result := make([]SupportFile, 0, len(files))
for _, file := range files {
if isRuntimeGeneratedSupportFile(file.Path) {
continue
}
result = append(result, file)
}
return result
}
func isRuntimeGeneratedSupportFile(path string) bool {
path = strings.TrimSpace(path)
return strings.HasPrefix(path, "runtime/")
}
func isActiveConfigNotFound(err error) bool {
return errors.Is(err, gorm.ErrRecordNotFound)
}
@@ -0,0 +1,21 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package agent
const (
errMissingAgentToken = "缺少 Agent Token"
errInvalidAgentToken = "无权进行此操作,Agent Token 无效"
errInvalidDiscoveryToken = "无权进行此操作,注册 Token 无效"
errNodeMissingFromContext = "Node object missing from context"
errNoActiveConfig = "当前没有激活版本"
errNodeNotFound = "节点不存在"
errNodeIDRequired = "node_id 不能为空"
errVersionRequired = "version 不能为空"
errInvalidApplyResult = "result 仅支持 success、warning 或 failed"
errIPRequired = "ip 不能为空"
errIPInvalid = "ip 格式无效"
errAgentVersionRequired = "version 不能为空"
errNodeIDConflict = "节点标识生成冲突,请重试"
errPagesPackageNotFound = "Pages 部署包尚未实现"
)
@@ -0,0 +1,257 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package agent
import (
"context"
"crypto/rand"
"encoding/hex"
"net"
"strings"
"time"
"github.com/Rain-kl/Wavelet/internal/model"
)
const (
openrestyStatusHealthy = "healthy"
openrestyStatusUnhealthy = "unhealthy"
openrestyStatusUnknown = "unknown"
releaseChannelStable = "stable"
)
func newRandomToken() (string, error) {
buf := make([]byte, 16)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return hex.EncodeToString(buf), nil
}
func newServerNodeID() (string, error) {
token, err := newRandomToken()
if err != nil {
return "", err
}
return "node-" + token, nil
}
func normalizeOpenrestyStatus(status string) string {
switch strings.ToLower(strings.TrimSpace(status)) {
case openrestyStatusHealthy:
return openrestyStatusHealthy
case openrestyStatusUnhealthy:
return openrestyStatusUnhealthy
default:
return openrestyStatusUnknown
}
}
func normalizeNodePayload(payload NodePayload) NodePayload {
payload.Name = strings.TrimSpace(payload.Name)
payload.IP = strings.TrimSpace(payload.IP)
payload.Version = strings.TrimSpace(payload.Version)
payload.ExtVersion = strings.TrimSpace(payload.ExtVersion)
payload.CurrentVersion = strings.TrimSpace(payload.CurrentVersion)
payload.LastError = truncateForDatabase(payload.LastError, 16000)
payload.OpenrestyStatus = normalizeOpenrestyStatus(payload.OpenrestyStatus)
payload.OpenrestyMessage = truncateForDatabase(payload.OpenrestyMessage, 16000)
return payload
}
func validateNodePayload(payload NodePayload) error {
if payload.IP == "" {
return errPayload(errIPRequired)
}
if net.ParseIP(payload.IP) == nil {
return errPayload(errIPInvalid)
}
if payload.Version == "" {
return errPayload(errAgentVersionRequired)
}
return nil
}
type payloadError string
func (e payloadError) Error() string { return string(e) }
func errPayload(message string) error { return payloadError(message) }
func applyNodeRuntime(node *model.OpenFlareNode, payload NodePayload, preserveName bool) {
if !preserveName || strings.TrimSpace(node.Name) == "" {
if strings.TrimSpace(payload.Name) != "" {
node.Name = strings.TrimSpace(payload.Name)
}
}
if !node.IPManualOverride {
node.IP = strings.TrimSpace(payload.IP)
}
node.Version = strings.TrimSpace(payload.Version)
node.ExtVersion = strings.TrimSpace(payload.ExtVersion)
node.OpenrestyStatus = normalizeOpenrestyStatus(payload.OpenrestyStatus)
node.OpenrestyMessage = truncateForDatabase(payload.OpenrestyMessage, 16000)
node.Status = nodeStatusOnline
node.CurrentVersion = strings.TrimSpace(payload.CurrentVersion)
now := time.Now()
node.LastSeenAt = &now
node.LastError = truncateForDatabase(payload.LastError, 16000)
}
func truncateForDatabase(value string, max int) string {
if max <= 0 {
return ""
}
runes := []rune(strings.TrimSpace(value))
if len(runes) <= max {
return string(runes)
}
return string(runes[:max])
}
func resolveReportedNodeIP(reportedIP string, remoteAddr string) string {
reported := normalizeIP(reportedIP)
remote := normalizeRemoteAddr(remoteAddr)
if reported == "" {
return remote
}
if isPublicNodeIP(reported) {
return reported
}
if isPublicNodeIP(remote) {
return remote
}
return reported
}
func normalizeIP(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
host := raw
if strings.Contains(raw, ":") {
if h, _, err := net.SplitHostPort(raw); err == nil {
host = h
}
}
host = strings.TrimPrefix(host, "[")
host = strings.TrimSuffix(host, "]")
if ip := net.ParseIP(host); ip != nil {
return ip.String()
}
return ""
}
func normalizeRemoteAddr(remoteAddr string) string {
remoteAddr = strings.TrimSpace(remoteAddr)
if remoteAddr == "" {
return ""
}
host, _, err := net.SplitHostPort(remoteAddr)
if err != nil {
return normalizeIP(remoteAddr)
}
return normalizeIP(host)
}
func isPublicNodeIP(raw string) bool {
ip := net.ParseIP(strings.TrimSpace(raw))
if ip == nil {
return false
}
if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsUnspecified() {
return false
}
return true
}
func buildAgentSettings(node *model.OpenFlareNode, updateNow bool, updateChannel string, updateTag string, restartOpenrestyNow bool) *Settings {
autoUpdate := false
if node != nil {
autoUpdate = node.AutoUpdateEnabled
}
if strings.TrimSpace(updateChannel) == "" {
updateChannel = releaseChannelStable
}
return &Settings{
HeartbeatInterval: model.AgentHeartbeatInterval,
WebsocketUpgradeEnabled: model.AgentWebsocketUpgradeEnabled,
AutoUpdate: autoUpdate,
UpdateRepo: model.AgentUpdateRepo,
UpdateNow: updateNow,
UpdateChannel: updateChannel,
UpdateTag: strings.TrimSpace(updateTag),
RestartOpenrestyNow: restartOpenrestyNow,
}
}
func collectHeartbeatChanges(previous *model.OpenFlareNode, current *model.OpenFlareNode) map[string]any {
if previous == nil || current == nil {
return map[string]any{}
}
changes := make(map[string]any)
appendIfChanged := func(key string, before any, after any) {
if before != after {
changes[key] = after
}
}
appendIfChanged("name", previous.Name, current.Name)
appendIfChanged("ip", previous.IP, current.IP)
appendIfChanged("version", previous.Version, current.Version)
appendIfChanged("ext_version", previous.ExtVersion, current.ExtVersion)
appendIfChanged("openresty_status", previous.OpenrestyStatus, current.OpenrestyStatus)
appendIfChanged("openresty_message", previous.OpenrestyMessage, current.OpenrestyMessage)
appendIfChanged("status", previous.Status, current.Status)
appendIfChanged("current_version", previous.CurrentVersion, current.CurrentVersion)
appendIfChanged("last_error", previous.LastError, current.LastError)
appendIfChanged("update_requested", previous.UpdateRequested, current.UpdateRequested)
appendIfChanged("update_channel", previous.UpdateChannel, current.UpdateChannel)
appendIfChanged("update_tag", previous.UpdateTag, current.UpdateTag)
appendIfChanged("restart_openresty_requested", previous.RestartOpenrestyRequested, current.RestartOpenrestyRequested)
if !lastSeenAtEqual(previous.LastSeenAt, current.LastSeenAt) {
changes["last_seen_at"] = current.LastSeenAt
}
return changes
}
func lastSeenAtEqual(before *time.Time, after *time.Time) bool {
if before == nil || after == nil {
return before == after
}
return before.Equal(*after)
}
func normalizeApplyLogPayload(payload ApplyLogPayload) ApplyLogPayload {
payload.NodeID = strings.TrimSpace(payload.NodeID)
payload.Version = strings.TrimSpace(payload.Version)
payload.Result = strings.ToLower(strings.TrimSpace(payload.Result))
payload.Message = truncateForDatabase(strings.TrimSpace(payload.Message), 16000)
payload.Checksum = strings.TrimSpace(payload.Checksum)
payload.MainConfigChecksum = strings.TrimSpace(payload.MainConfigChecksum)
payload.RouteConfigChecksum = strings.TrimSpace(payload.RouteConfigChecksum)
return payload
}
func isUniqueConstraintError(err error) bool {
if err == nil {
return false
}
return strings.Contains(strings.ToLower(err.Error()), "unique")
}
func refreshAccessTokenCache(ctx context.Context, node *model.OpenFlareNode) {
if node == nil {
return
}
tokenCache.storeNode(node.AccessToken, cloneNode(node))
}
func cloneNode(node *model.OpenFlareNode) *model.OpenFlareNode {
if node == nil {
return nil
}
cloned := *node
return &cloned
}
@@ -0,0 +1,209 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package agent
import (
"context"
"errors"
"strings"
"time"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/node"
"github.com/Rain-kl/Wavelet/internal/db"
"github.com/Rain-kl/Wavelet/internal/model"
"gorm.io/gorm"
)
// RegisterWithAccessToken registers an agent on a reserved node token.
func RegisterWithAccessToken(ctx context.Context, authNode *model.OpenFlareNode, payload NodePayload) (*RegistrationResponse, error) {
payload = normalizeNodePayload(payload)
if authNode == nil {
return nil, errors.New(errNodeNotFound)
}
if err := validateNodePayload(payload); err != nil {
return nil, err
}
applyNodeRuntime(authNode, payload, true)
if err := model.SaveOpenFlareNode(ctx, authNode); err != nil {
return nil, err
}
refreshAccessTokenCache(ctx, authNode)
return &RegistrationResponse{
NodeID: authNode.NodeID,
AccessToken: authNode.AccessToken,
Name: authNode.Name,
}, nil
}
// RegisterWithDiscovery registers a new node using the global discovery token.
func RegisterWithDiscovery(ctx context.Context, payload NodePayload) (*RegistrationResponse, error) {
payload = normalizeNodePayload(payload)
if err := validateNodePayload(payload); err != nil {
return nil, err
}
nodeID, err := newServerNodeID()
if err != nil {
return nil, err
}
accessToken, err := newRandomToken()
if err != nil {
return nil, err
}
nodeName := payload.Name
if nodeName == "" {
nodeName = nodeID
}
record := &model.OpenFlareNode{
NodeID: nodeID,
Name: nodeName,
AccessToken: accessToken,
Status: nodeStatusOnline,
NodeType: "edge_node",
CapabilitiesJSON: "[]",
UpdateChannel: releaseChannelStable,
}
applyNodeRuntime(record, payload, false)
if err = model.CreateOpenFlareNode(ctx, record); err != nil {
if isUniqueConstraintError(err) {
return nil, errors.New(errNodeIDConflict)
}
return nil, err
}
refreshAccessTokenCache(ctx, record)
return &RegistrationResponse{
NodeID: record.NodeID,
AccessToken: record.AccessToken,
Name: record.Name,
}, nil
}
// HeartbeatNode updates runtime state and returns agent settings.
func HeartbeatNode(ctx context.Context, authNode *model.OpenFlareNode, payload NodePayload) (*HeartbeatResponse, error) {
if authNode == nil {
return nil, errors.New(errNodeNotFound)
}
payload.NodeID = authNode.NodeID
payload = normalizeNodePayload(payload)
if err := validateNodePayload(payload); err != nil {
return nil, err
}
previous := *authNode
updateNow := authNode.UpdateRequested
restartOpenrestyNow := authNode.RestartOpenrestyRequested
updateChannel := strings.TrimSpace(authNode.UpdateChannel)
updateTag := strings.TrimSpace(authNode.UpdateTag)
applyNodeRuntime(authNode, payload, true)
authNode.UpdateRequested = false
authNode.UpdateChannel = releaseChannelStable
authNode.UpdateTag = ""
authNode.RestartOpenrestyRequested = false
changes := collectHeartbeatChanges(&previous, authNode)
if len(changes) > 0 {
fields := make([]string, 0, len(changes))
for field := range changes {
fields = append(fields, field)
}
if err := model.UpdateOpenFlareNodeFields(ctx, authNode, fields...); err != nil {
return nil, err
}
}
refreshAccessTokenCache(ctx, authNode)
activeConfig, err := getActiveConfigMeta(ctx)
if err != nil && !isActiveConfigNotFound(err) {
return nil, err
}
return &HeartbeatResponse{
Node: authNode,
AgentSettings: buildAgentSettings(authNode, updateNow, updateChannel, updateTag, restartOpenrestyNow),
ActiveConfig: activeConfig,
WAFIPGroups: nil,
}, nil
}
// GetActiveConfig returns the active configuration for an agent.
func GetActiveConfig(ctx context.Context) (*ConfigResponse, error) {
config, err := getActiveConfigForAgent(ctx)
if err != nil {
if isActiveConfigNotFound(err) {
return nil, errors.New(errNoActiveConfig)
}
return nil, err
}
return config, nil
}
// SyncWAFIPGroups is a stub until full WAF agent sync is migrated.
func SyncWAFIPGroups(_ context.Context, _ WAFIPGroupSyncInput) (*WAFIPGroupSyncResult, error) {
return &WAFIPGroupSyncResult{Groups: []WAFIPGroup{}}, nil
}
// ReportApplyLog records an agent apply result.
func ReportApplyLog(ctx context.Context, payload ApplyLogPayload) (*model.OpenFlareApplyLog, error) {
now := time.Now()
payload = normalizeApplyLogPayload(payload)
if payload.NodeID == "" {
return nil, errors.New(errNodeIDRequired)
}
if payload.Version == "" {
return nil, errors.New(errVersionRequired)
}
if payload.Result != applyResultOK && payload.Result != applyResultWarn && payload.Result != applyResultFailed {
return nil, errors.New(errInvalidApplyResult)
}
log := &model.OpenFlareApplyLog{
NodeID: payload.NodeID,
Version: payload.Version,
Result: payload.Result,
Message: payload.Message,
Checksum: payload.Checksum,
MainConfigChecksum: payload.MainConfigChecksum,
RouteConfigChecksum: payload.RouteConfigChecksum,
SupportFileCount: payload.SupportFileCount,
CreatedAt: now,
}
conn := db.DB(ctx)
if conn == nil {
return nil, errors.New("database not initialized")
}
err := conn.Transaction(func(tx *gorm.DB) error {
record := &model.OpenFlareNode{}
if err := tx.Where("node_id = ?", payload.NodeID).First(record).Error; err != nil {
return err
}
record.Status = nodeStatusOnline
record.LastSeenAt = &now
if payload.Result == applyResultOK {
record.CurrentVersion = payload.Version
record.LastError = ""
} else {
record.LastError = payload.Message
}
if err := tx.Create(log).Error; err != nil {
return err
}
return tx.Model(record).Select("status", "last_seen_at", "current_version", "last_error").Updates(record).Error
})
if err != nil {
return nil, err
}
return log, nil
}
// ValidateDiscoveryToken delegates to the node package discovery token helper.
func ValidateDiscoveryToken(ctx context.Context, token string) error {
return node.ValidateDiscoveryToken(ctx, token)
}
@@ -0,0 +1,61 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package agent
import (
"strings"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/compat"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/gin-gonic/gin"
)
const (
agentTokenHeader = "X-Agent-Token"
agentNodeContextKey = "agent_node"
)
// AgentAuth validates X-Agent-Token against of_nodes.access_token.
func AgentAuth() gin.HandlerFunc {
return func(c *gin.Context) {
token := strings.TrimSpace(c.GetHeader(agentTokenHeader))
node, err := AuthenticateAccessToken(c.Request.Context(), token)
if err != nil {
compat.Unauthorized(c, errInvalidAgentToken)
c.Abort()
return
}
c.Set(agentNodeContextKey, node)
c.Next()
}
}
// AgentRegisterAuth accepts either a node access token or the global discovery token.
func AgentRegisterAuth() gin.HandlerFunc {
return func(c *gin.Context) {
token := strings.TrimSpace(c.GetHeader(agentTokenHeader))
if node, err := AuthenticateAccessToken(c.Request.Context(), token); err == nil {
c.Set(agentNodeContextKey, node)
c.Next()
return
}
if err := ValidateDiscoveryToken(c.Request.Context(), token); err != nil {
compat.Unauthorized(c, errInvalidDiscoveryToken)
c.Abort()
return
}
c.Set("discovery_enabled", true)
c.Next()
}
}
// AgentNodeFromContext returns the authenticated agent node.
func AgentNodeFromContext(c *gin.Context) (*model.OpenFlareNode, bool) {
value, ok := c.Get(agentNodeContextKey)
if !ok {
return nil, false
}
node, ok := value.(*model.OpenFlareNode)
return node, ok
}
@@ -0,0 +1,209 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package agent
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/compat"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/option"
"github.com/Rain-kl/Wavelet/internal/db"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func setupAgentAuthTestDB(t *testing.T) func() {
t.Helper()
sqliteDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
DisableForeignKeyConstraintWhenMigrating: true,
})
require.NoError(t, err)
require.NoError(t, sqliteDB.AutoMigrate(
&model.OpenFlareNode{},
&model.OpenFlareOption{},
))
db.SetDB(sqliteDB)
option.ResetInitializationForTest()
tokenCache.reset()
return func() {
db.SetDB(nil)
option.ResetInitializationForTest()
tokenCache.reset()
}
}
func TestAuthenticateAccessToken(t *testing.T) {
cleanup := setupAgentAuthTestDB(t)
defer cleanup()
ctx := context.Background()
now := time.Now()
require.NoError(t, db.DB(ctx).Create(&model.OpenFlareNode{
NodeID: "node-auth-1",
Name: "edge",
AccessToken: "valid-agent-token",
Status: nodeStatusOnline,
LastSeenAt: &now,
NodeType: "edge_node",
}).Error)
t.Run("valid token", func(t *testing.T) {
node, err := AuthenticateAccessToken(ctx, "valid-agent-token")
require.NoError(t, err)
assert.Equal(t, "node-auth-1", node.NodeID)
})
t.Run("cached token", func(t *testing.T) {
originalLoader := tokenCache.loadNodeByToken
t.Cleanup(func() {
tokenCache.loadNodeByToken = originalLoader
})
tokenCache.loadNodeByToken = func(context.Context, string) (*model.OpenFlareNode, error) {
t.Fatal("db should not be queried for cached token")
return nil, nil
}
node, err := AuthenticateAccessToken(ctx, "valid-agent-token")
require.NoError(t, err)
assert.Equal(t, "node-auth-1", node.NodeID)
})
t.Run("missing token", func(t *testing.T) {
_, err := AuthenticateAccessToken(ctx, "")
require.Error(t, err)
assert.Contains(t, err.Error(), errMissingAgentToken)
})
t.Run("invalid token", func(t *testing.T) {
_, err := AuthenticateAccessToken(ctx, "invalid-token")
require.Error(t, err)
})
}
func TestAgentAuthMiddleware(t *testing.T) {
cleanup := setupAgentAuthTestDB(t)
defer cleanup()
ctx := context.Background()
now := time.Now()
require.NoError(t, db.DB(ctx).Create(&model.OpenFlareNode{
NodeID: "node-mw-1",
Name: "edge",
AccessToken: "middleware-token",
Status: nodeStatusOnline,
LastSeenAt: &now,
NodeType: "edge_node",
}).Error)
gin.SetMode(gin.TestMode)
router := gin.New()
router.GET("/protected", AgentAuth(), func(c *gin.Context) {
node, ok := AgentNodeFromContext(c)
if !ok {
c.Status(http.StatusInternalServerError)
return
}
compat.OK(c, gin.H{"node_id": node.NodeID})
})
t.Run("authorized request", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
req.Header.Set(agentTokenHeader, "middleware-token")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
assert.Equal(t, http.StatusOK, resp.Code)
var envelope compat.Envelope
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &envelope))
assert.True(t, envelope.Success)
})
t.Run("unauthorized request", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
req.Header.Set(agentTokenHeader, "bad-token")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
assert.Equal(t, http.StatusUnauthorized, resp.Code)
})
}
func TestAgentRegisterAuthMiddleware(t *testing.T) {
cleanup := setupAgentAuthTestDB(t)
defer cleanup()
ctx := context.Background()
now := time.Now()
require.NoError(t, db.DB(ctx).Create(&model.OpenFlareNode{
NodeID: "node-register-1",
Name: "edge",
AccessToken: "existing-node-token",
Status: nodeStatusOnline,
LastSeenAt: &now,
NodeType: "edge_node",
}).Error)
require.NoError(t, model.UpdateOpenFlareOption(ctx, "AgentDiscoveryToken", "discovery-token"))
gin.SetMode(gin.TestMode)
router := gin.New()
router.POST("/register", AgentRegisterAuth(), func(c *gin.Context) {
if node, ok := AgentNodeFromContext(c); ok {
compat.OK(c, gin.H{"mode": "node", "node_id": node.NodeID})
return
}
if _, ok := c.Get("discovery_enabled"); ok {
compat.OK(c, gin.H{"mode": "discovery"})
return
}
c.Status(http.StatusInternalServerError)
})
t.Run("existing node token", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/register", nil)
req.Header.Set(agentTokenHeader, "existing-node-token")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
assert.Equal(t, http.StatusOK, resp.Code)
var envelope compat.Envelope
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &envelope))
data, ok := envelope.Data.(map[string]any)
require.True(t, ok)
assert.Equal(t, "node", data["mode"])
})
t.Run("discovery token", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/register", nil)
req.Header.Set(agentTokenHeader, "discovery-token")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
assert.Equal(t, http.StatusOK, resp.Code)
var envelope compat.Envelope
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &envelope))
data, ok := envelope.Data.(map[string]any)
require.True(t, ok)
assert.Equal(t, "discovery", data["mode"])
})
t.Run("invalid token", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/register", nil)
req.Header.Set(agentTokenHeader, "invalid-token")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
assert.Equal(t, http.StatusUnauthorized, resp.Code)
})
}
@@ -0,0 +1,161 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package agent
import (
"net/http"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/compat"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/websocket"
"github.com/gin-gonic/gin"
)
// RegisterRoutes mounts agent API routes under /agent.
func RegisterRoutes(apiGroup *gin.RouterGroup) {
agentRoute := apiGroup.Group("/agent")
{
discoveryRoute := agentRoute.Group("/")
discoveryRoute.Use(AgentRegisterAuth())
{
discoveryRoute.POST("/nodes/register", RegisterHandler)
}
authorizedRoute := agentRoute.Group("/")
authorizedRoute.Use(AgentAuth())
{
authorizedRoute.GET("/ws", AgentWebSocketHandler)
authorizedRoute.POST("/nodes/heartbeat", HeartbeatHandler)
authorizedRoute.GET("/config-versions/active", GetActiveConfigHandler)
authorizedRoute.GET("/pages/deployments/:deployment_id/package", DownloadPagesPackageHandler)
authorizedRoute.POST("/waf/ip-groups/sync", SyncWAFIPGroupsHandler)
authorizedRoute.POST("/apply-logs", ReportApplyLogHandler)
}
}
}
// RegisterHandler registers or discovers an agent node.
func RegisterHandler(c *gin.Context) {
var payload NodePayload
if !compat.BindJSON(c, &payload) {
return
}
payload.IP = resolveReportedNodeIP(payload.IP, c.Request.RemoteAddr)
var (
result *RegistrationResponse
err error
)
if authNode, ok := AgentNodeFromContext(c); ok {
result, err = RegisterWithAccessToken(c.Request.Context(), authNode, payload)
} else {
result, err = RegisterWithDiscovery(c.Request.Context(), payload)
}
if err != nil {
compat.Fail(c, err.Error())
return
}
compat.OK(c, result)
}
// HeartbeatHandler records agent heartbeat state.
func HeartbeatHandler(c *gin.Context) {
var payload NodePayload
if !compat.BindJSON(c, &payload) {
return
}
payload.IP = resolveReportedNodeIP(payload.IP, c.Request.RemoteAddr)
authNode, ok := AgentNodeFromContext(c)
if !ok {
compat.Unauthorized(c, errInvalidAgentToken)
return
}
response, err := HeartbeatNode(c.Request.Context(), authNode, payload)
if err != nil {
compat.Fail(c, err.Error())
return
}
okWithExtras(c, response.Node, gin.H{
"agent_settings": response.AgentSettings,
"active_config": response.ActiveConfig,
"waf_ip_groups": response.WAFIPGroups,
})
}
// GetActiveConfigHandler returns the active configuration version.
func GetActiveConfigHandler(c *gin.Context) {
if _, ok := AgentNodeFromContext(c); !ok {
compat.Unauthorized(c, errNodeMissingFromContext)
return
}
config, err := GetActiveConfig(c.Request.Context())
if err != nil {
compat.Fail(c, err.Error())
return
}
compat.OK(c, config)
}
// SyncWAFIPGroupsHandler syncs WAF IP groups for an agent (stub).
func SyncWAFIPGroupsHandler(c *gin.Context) {
var input WAFIPGroupSyncInput
if !compat.BindJSON(c, &input) {
return
}
result, err := SyncWAFIPGroups(c.Request.Context(), input)
if err != nil {
compat.Fail(c, err.Error())
return
}
compat.OK(c, result)
}
// ReportApplyLogHandler records an agent apply log entry.
func ReportApplyLogHandler(c *gin.Context) {
var payload ApplyLogPayload
if !compat.BindJSON(c, &payload) {
return
}
if authNode, ok := AgentNodeFromContext(c); ok {
payload.NodeID = authNode.NodeID
}
log, err := ReportApplyLog(c.Request.Context(), payload)
if err != nil {
compat.Fail(c, err.Error())
return
}
compat.OK(c, log)
}
// DownloadPagesPackageHandler is a stub until Pages agent packaging is migrated.
func DownloadPagesPackageHandler(c *gin.Context) {
c.JSON(http.StatusNotFound, compat.Envelope{
Success: false,
Message: errPagesPackageNotFound,
Data: nil,
})
}
// AgentWebSocketHandler upgrades an authenticated agent websocket connection.
func AgentWebSocketHandler(c *gin.Context) {
authNode, ok := AgentNodeFromContext(c)
if !ok {
compat.Unauthorized(c, errInvalidAgentToken)
return
}
websocket.ServeAgent(c, authNode.NodeID)
}
func okWithExtras(c *gin.Context, data any, extras gin.H) {
payload := gin.H{
"success": true,
"message": "",
"data": data,
}
for key, value := range extras {
payload[key] = value
}
c.JSON(http.StatusOK, payload)
}
@@ -0,0 +1,112 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package agent
import (
"time"
"github.com/Rain-kl/Wavelet/internal/model"
)
const (
nodeStatusOnline = "online"
applyResultOK = "success"
applyResultWarn = "warning"
applyResultFailed = "failed"
)
// NodePayload is the agent register/heartbeat payload.
type NodePayload struct {
NodeID string `json:"node_id"`
Name string `json:"name"`
IP string `json:"ip"`
Version string `json:"version"`
ExtVersion string `json:"ext_version"`
CurrentVersion string `json:"current_version"`
LastError string `json:"last_error"`
OpenrestyStatus string `json:"openresty_status"`
OpenrestyMessage string `json:"openresty_message"`
WAFIPGroupChecksums map[string]string `json:"waf_ip_group_checksums,omitempty"`
}
// ApplyLogPayload is the agent apply log report payload.
type ApplyLogPayload struct {
NodeID string `json:"node_id"`
Version string `json:"version"`
Result string `json:"result"`
Message string `json:"message"`
Checksum string `json:"checksum"`
MainConfigChecksum string `json:"main_config_checksum"`
RouteConfigChecksum string `json:"route_config_checksum"`
SupportFileCount int `json:"support_file_count"`
}
// RegistrationResponse is returned after agent registration.
type RegistrationResponse struct {
NodeID string `json:"node_id"`
AccessToken string `json:"access_token"`
Name string `json:"name"`
}
// Settings carries remote agent control flags.
type Settings struct {
HeartbeatInterval int `json:"heartbeat_interval"`
WebsocketUpgradeEnabled bool `json:"websocket_upgrade_enabled"`
AutoUpdate bool `json:"auto_update"`
UpdateRepo string `json:"update_repo"`
UpdateNow bool `json:"update_now"`
UpdateChannel string `json:"update_channel"`
UpdateTag string `json:"update_tag"`
RestartOpenrestyNow bool `json:"restart_openresty_now"`
}
// ActiveConfigMeta summarizes the active configuration version.
type ActiveConfigMeta struct {
Version string `json:"version"`
Checksum string `json:"checksum"`
}
// SupportFile is a configuration support artifact shipped to agents.
type SupportFile struct {
Path string `json:"path"`
Content string `json:"content"`
}
// ConfigResponse is the full active config payload for agents.
type ConfigResponse struct {
Version string `json:"version"`
Checksum string `json:"checksum"`
SourceConfigJSON string `json:"source_config_json"`
SupportFiles []SupportFile `json:"support_files"`
CreatedAt time.Time `json:"created_at"`
}
// WAFIPGroup is a WAF IP group snapshot for agents.
type WAFIPGroup struct {
ID uint `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Enabled bool `json:"enabled"`
IPList []string `json:"ip_list"`
Checksum string `json:"checksum"`
}
// WAFIPGroupSyncInput requests changed WAF IP groups.
type WAFIPGroupSyncInput struct {
IDs []uint `json:"ids"`
Checksums map[string]string `json:"checksums"`
}
// WAFIPGroupSyncResult returns synced WAF IP groups.
type WAFIPGroupSyncResult struct {
Groups []WAFIPGroup `json:"groups"`
}
// HeartbeatResponse is the heartbeat handler result.
type HeartbeatResponse struct {
Node *model.OpenFlareNode `json:"node"`
AgentSettings *Settings `json:"agent_settings"`
ActiveConfig *ActiveConfigMeta `json:"active_config"`
WAFIPGroups []WAFIPGroup `json:"waf_ip_groups,omitempty"`
}
@@ -0,0 +1,8 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package apply_log
const (
errRetentionDaysOutOfRange = "retention_days 必须在 1 到 3650 之间"
)
@@ -0,0 +1,128 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package apply_log
import (
"context"
"errors"
"strings"
"time"
"github.com/Rain-kl/Wavelet/internal/model"
)
const (
defaultApplyLogPageSize = 20
maxApplyLogPageSize = 200
maxApplyLogRetentionDays = 3650
)
// ListQuery filters apply logs for paginated listing.
type ListQuery struct {
NodeID string `json:"node_id"`
PageNo int `json:"pageNo"`
PageSize int `json:"pageSize"`
}
// ListResult is the paginated apply log list response.
type ListResult struct {
Rows []*model.OpenFlareApplyLog `json:"rows"`
Current int `json:"current"`
Total int `json:"total"`
TotalPage int `json:"totalPage"`
}
// CleanupInput controls apply log cleanup behavior.
type CleanupInput struct {
DeleteAll bool `json:"delete_all"`
RetentionDays int `json:"retention_days"`
}
// CleanupResult reports apply log cleanup outcome.
type CleanupResult struct {
DeleteAll bool `json:"delete_all"`
RetentionDays int `json:"retention_days"`
DeletedCount int64 `json:"deleted_count"`
Cutoff *time.Time `json:"cutoff,omitempty"`
}
// ListPage returns paginated apply logs with optional node_id filter.
func ListPage(ctx context.Context, input ListQuery) (*ListResult, error) {
pageNo := normalizePageNo(input.PageNo)
pageSize := normalizePageSize(input.PageSize)
nodeID := strings.TrimSpace(input.NodeID)
rows, err := model.ListOpenFlareApplyLogs(ctx, model.OpenFlareApplyLogQuery{
NodeID: nodeID,
PageNo: pageNo,
PageSize: pageSize,
})
if err != nil {
return nil, err
}
total, err := model.CountOpenFlareApplyLogs(ctx, nodeID)
if err != nil {
return nil, err
}
totalPage := 0
if total > 0 {
totalPage = int((total + int64(pageSize) - 1) / int64(pageSize))
}
return &ListResult{
Rows: rows,
Current: pageNo,
Total: int(total),
TotalPage: totalPage,
}, nil
}
// Cleanup removes old apply logs or deletes all records.
func Cleanup(ctx context.Context, input CleanupInput) (*CleanupResult, error) {
if input.DeleteAll {
deleted, err := model.DeleteAllOpenFlareApplyLogs(ctx)
if err != nil {
return nil, err
}
return &CleanupResult{
DeleteAll: true,
DeletedCount: deleted,
}, nil
}
if input.RetentionDays <= 0 || input.RetentionDays > maxApplyLogRetentionDays {
return nil, errors.New(errRetentionDaysOutOfRange)
}
cutoff := time.Now().UTC().Add(-time.Duration(input.RetentionDays) * 24 * time.Hour)
deleted, err := model.DeleteOpenFlareApplyLogsBefore(ctx, cutoff)
if err != nil {
return nil, err
}
return &CleanupResult{
RetentionDays: input.RetentionDays,
DeletedCount: deleted,
Cutoff: &cutoff,
}, nil
}
func normalizePageNo(pageNo int) int {
if pageNo <= 0 {
return 1
}
return pageNo
}
func normalizePageSize(pageSize int) int {
if pageSize <= 0 {
return defaultApplyLogPageSize
}
if pageSize > maxApplyLogPageSize {
return maxApplyLogPageSize
}
return pageSize
}
@@ -0,0 +1,105 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package apply_log
import (
"context"
"testing"
"time"
"github.com/Rain-kl/Wavelet/internal/db"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func setupApplyLogTestDB(t *testing.T) func() {
sqliteDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
DisableForeignKeyConstraintWhenMigrating: true,
})
require.NoError(t, err)
err = sqliteDB.AutoMigrate(&model.OpenFlareApplyLog{})
require.NoError(t, err)
db.SetDB(sqliteDB)
return func() {
db.SetDB(nil)
}
}
func TestListPageAndCleanup(t *testing.T) {
cleanup := setupApplyLogTestDB(t)
defer cleanup()
ctx := context.Background()
now := time.Now().UTC()
logs := []model.OpenFlareApplyLog{
{NodeID: "node-logs", Version: "v1", Result: "success", Message: "1", CreatedAt: now.Add(-10 * 24 * time.Hour)},
{NodeID: "node-logs", Version: "v2", Result: "success", Message: "2", CreatedAt: now.Add(-5 * 24 * time.Hour)},
{NodeID: "node-logs", Version: "v3", Result: "success", Message: "3", CreatedAt: now},
}
for i := range logs {
require.NoError(t, db.DB(ctx).Create(&logs[i]).Error)
}
pageResult, err := ListPage(ctx, ListQuery{
NodeID: "node-logs",
PageNo: 1,
PageSize: 2,
})
require.NoError(t, err)
assert.Equal(t, 3, pageResult.Total)
assert.Len(t, pageResult.Rows, 2)
assert.Equal(t, 2, pageResult.TotalPage)
assert.Equal(t, 1, pageResult.Current)
cleanupResult, err := Cleanup(ctx, CleanupInput{
DeleteAll: false,
RetentionDays: 7,
})
require.NoError(t, err)
assert.Equal(t, int64(1), cleanupResult.DeletedCount)
assert.NotNil(t, cleanupResult.Cutoff)
remaining, err := model.ListOpenFlareApplyLogs(ctx, model.OpenFlareApplyLogQuery{
NodeID: "node-logs",
PageNo: 1,
PageSize: 10,
})
require.NoError(t, err)
assert.Len(t, remaining, 2)
cleanupAll, err := Cleanup(ctx, CleanupInput{DeleteAll: true})
require.NoError(t, err)
assert.Equal(t, int64(2), cleanupAll.DeletedCount)
assert.True(t, cleanupAll.DeleteAll)
finalLogs, err := model.ListOpenFlareApplyLogs(ctx, model.OpenFlareApplyLogQuery{
NodeID: "node-logs",
PageNo: 1,
PageSize: 10,
})
require.NoError(t, err)
assert.Empty(t, finalLogs)
}
func TestCleanupInvalidRetentionDays(t *testing.T) {
cleanup := setupApplyLogTestDB(t)
defer cleanup()
ctx := context.Background()
_, err := Cleanup(ctx, CleanupInput{RetentionDays: 0})
require.Error(t, err)
assert.Equal(t, errRetentionDaysOutOfRange, err.Error())
_, err = Cleanup(ctx, CleanupInput{RetentionDays: 4000})
require.Error(t, err)
assert.Equal(t, errRetentionDaysOutOfRange, err.Error())
}
@@ -0,0 +1,49 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package apply_log
import (
"strconv"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/compat"
"github.com/gin-gonic/gin"
)
// GetApplyLogs lists apply logs with pagination and optional node_id filter.
func GetApplyLogs(c *gin.Context) {
result, err := ListPage(c.Request.Context(), ListQuery{
NodeID: c.Query("node_id"),
PageNo: readIntQuery(c, "pageNo", "page_no"),
PageSize: readIntQuery(c, "pageSize", "page_size"),
})
if err != nil {
compat.Fail(c, err.Error())
return
}
compat.OK(c, result)
}
// CleanupApplyLogs removes old apply logs or deletes all records.
func CleanupApplyLogs(c *gin.Context) {
var input CleanupInput
if !compat.BindJSON(c, &input) {
return
}
result, err := Cleanup(c.Request.Context(), input)
if err != nil {
compat.Fail(c, err.Error())
return
}
compat.OK(c, result)
}
func readIntQuery(c *gin.Context, primary, secondary string) int {
value := c.Query(primary)
if value == "" {
value = c.Query(secondary)
}
parsed, _ := strconv.Atoi(value)
return parsed
}
@@ -0,0 +1,33 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package auth
const (
errInvalidParams = "无效的参数"
errPasswordLoginDisabled = "管理员关闭了密码登录"
errUsernameOrPasswordWrong = "用户名或密码错误"
errBannedAccount = "用户已被封禁"
errSaveSessionFailed = "无法保存会话信息,请重试"
errRegistrationDisabled = "管理员关闭了注册"
errPasswordTooShort = "密码长度不能少于 8 位"
errEmailRequired = "邮箱地址不能为空"
errEmailAlreadyRegistered = "邮箱地址已被占用"
errEmailNotRegistered = "该邮箱地址未注册"
errEmailCodeInvalid = "验证码错误或已过期"
errResetLinkInvalid = "重置链接非法或已过期"
errUserNotFound = "用户不存在"
errGenerateTokenFailed = "生成 Token 失败"
errInsufficientPermission = "无权进行此操作,权限不足"
errCannotDisableRoot = "无法禁用超级管理员用户"
errCannotDeleteRoot = "无法删除超级管理员用户"
errCannotPromoteAdmin = "普通管理员用户无法提升其他用户为管理员"
errAlreadyAdmin = "该用户已经是管理员"
errAlreadyCommonUser = "该用户已经是普通用户"
errAuthSourceDisabled = "认证源未启用"
errInvalidAuthSourceID = "认证源 ID 无效"
errPendingOAuthExpired = "待绑定第三方账号已失效,请重新登录"
errPendingOAuthInvalid = "待绑定第三方账号无效,请重新登录"
errCapTokenMissing = "缺少人机验证凭证"
errCapTokenInvalid = "人机验证凭证无效或已过期"
)
@@ -0,0 +1,82 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package auth provides OpenFlare legacy auth business logic.
package auth
import (
"github.com/Rain-kl/Wavelet/internal/apps/openflare/compat"
"github.com/Rain-kl/Wavelet/internal/model"
)
// LegacyUser mirrors the old OpenFlare frontend user shape.
type LegacyUser struct {
ID int `json:"id"`
Username string `json:"username"`
DisplayName string `json:"display_name"`
Role int `json:"role"`
Status int `json:"status"`
Token string `json:"token,omitempty"`
Email string `json:"email,omitempty"`
}
const (
legacyUserStatusEnabled = 1
legacyUserStatusDisabled = 2
)
// RoleFromUser maps a Wavelet user to the legacy role value.
func RoleFromUser(user *model.User) int {
if user == nil {
return 0
}
if user.IsAdmin {
return compat.RoleRootUser
}
return compat.RoleCommonUser
}
// StatusFromUser maps is_active to legacy status.
func StatusFromUser(user *model.User) int {
if user == nil || !user.IsActive {
return legacyUserStatusDisabled
}
return legacyUserStatusEnabled
}
// ToLegacyUser converts a Wavelet user to the legacy response shape.
func ToLegacyUser(user *model.User, token string) LegacyUser {
if user == nil {
return LegacyUser{}
}
return LegacyUser{
ID: int(user.ID),
Username: user.Username,
DisplayName: displayName(user),
Role: RoleFromUser(user),
Status: StatusFromUser(user),
Token: token,
Email: user.Email,
}
}
// ToLegacyUsers converts a slice of users.
func ToLegacyUsers(users []model.User) []LegacyUser {
result := make([]LegacyUser, 0, len(users))
for i := range users {
result = append(result, ToLegacyUser(&users[i], ""))
}
return result
}
func displayName(user *model.User) string {
if user.Nickname != "" {
return user.Nickname
}
return user.Username
}
// IsAdminRole reports whether a legacy role has admin privileges.
func IsAdminRole(role int) bool {
return role >= compat.RoleAdminUser
}
@@ -0,0 +1,801 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package auth
import (
"context"
"crypto/rand"
"encoding/json"
"errors"
"fmt"
"math/big"
"strings"
"time"
"github.com/Rain-kl/Wavelet/internal/apps/oauth"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/compat"
"github.com/Rain-kl/Wavelet/internal/buildinfo"
"github.com/Rain-kl/Wavelet/internal/config"
"github.com/Rain-kl/Wavelet/internal/db"
"github.com/Rain-kl/Wavelet/internal/db/idgen"
"github.com/Rain-kl/Wavelet/internal/listener"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/internal/repository"
"github.com/Rain-kl/Wavelet/internal/task"
"github.com/Rain-kl/Wavelet/pkg/logger"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
const (
legacyTokenName = "openflare-legacy"
minPasswordLength = 8
legacyItemsPerPage = 10
passwordResetKeyFmt = "of_password_reset:%s"
passwordResetExpiry = 15 * time.Minute
verificationCodeRange = 900000
verificationOffset = 100000
)
// LoginInput holds legacy login credentials.
type LoginInput struct {
Username string
Password string
Code string
}
// RegisterInput holds legacy registration fields.
type RegisterInput struct {
Username string
Password string
Nickname string
DisplayName string
Email string
Code string
}
// ManageUserInput holds legacy user management actions.
type ManageUserInput struct {
Username string
Action string
}
// UpdateUserInput holds legacy admin user update fields.
type UpdateUserInput struct {
ID int
Username string
Password string
DisplayName string
Role int
Email string
}
// UpdateSelfInput holds legacy self-update fields.
type UpdateSelfInput struct {
Username string
Password string
DisplayName string
Email string
}
// CreateUserInput holds legacy admin create-user fields.
type CreateUserInput struct {
Username string
Password string
DisplayName string
Role int
Email string
}
// PasswordResetInput holds password reset confirmation.
type PasswordResetInput struct {
Email string
Token string
}
// LinkExistingInput binds a pending OAuth account to an existing user.
type LinkExistingInput struct {
Username string
Password string
}
// Login authenticates a user and returns the legacy user shape with an access token.
func Login(ctx context.Context, c *gin.Context, input LoginInput) (LegacyUser, error) {
if !isPasswordLoginEnabled(ctx) {
return LegacyUser{}, errors.New(errPasswordLoginDisabled)
}
input.Username = strings.TrimSpace(input.Username)
if input.Username == "" || input.Password == "" {
return LegacyUser{}, errors.New(errInvalidParams)
}
var user model.User
if err := db.DB(ctx).Where("username = ? OR email = ?", input.Username, input.Username).First(&user).Error; err != nil {
logger.WarnF(ctx, "[LoginAudit] failed login attempt (username not found) for input: %s, IP: %s", input.Username, c.ClientIP())
return LegacyUser{}, errors.New(errUsernameOrPasswordWrong)
}
if !user.IsActive {
return LegacyUser{}, errors.New(errBannedAccount)
}
if !user.CheckPassword(input.Password) {
logger.WarnF(ctx, "[LoginAudit] failed login attempt (incorrect password) for username: %s, ID: %d, IP: %s", user.Username, user.ID, c.ClientIP())
return LegacyUser{}, errors.New(errUsernameOrPasswordWrong)
}
if isEmailLoginVerificationEnabled(ctx) {
if err := verifyLoginEmailCode(ctx, user.Email, input.Code); err != nil {
return LegacyUser{}, err
}
}
user.LastLoginAt = time.Now()
if err := db.DB(ctx).Model(&user).Update("last_login_at", user.LastLoginAt).Error; err != nil {
return LegacyUser{}, err
}
if err := setLoginSession(ctx, c, &user); err != nil {
return LegacyUser{}, errors.New(errSaveSessionFailed)
}
token, err := issueLegacyAccessToken(ctx, &user)
if err != nil {
return LegacyUser{}, err
}
logger.InfoF(ctx, "[LoginAudit] successful legacy login for user: %s, ID: %d, IP: %s", user.Username, user.ID, c.ClientIP())
listener.EmitAdminLoggedIn(ctx, &user, c.ClientIP())
return ToLegacyUser(&user, token), nil
}
// Register creates a new user and logs them in.
func Register(ctx context.Context, c *gin.Context, input RegisterInput) (LegacyUser, error) {
if !isRegistrationEnabled(ctx) || !isPasswordRegisterEnabled(ctx) {
return LegacyUser{}, errors.New(errRegistrationDisabled)
}
input.Username = strings.TrimSpace(input.Username)
input.Password = strings.TrimSpace(input.Password)
input.Nickname = strings.TrimSpace(input.Nickname)
input.DisplayName = strings.TrimSpace(input.DisplayName)
input.Email = strings.TrimSpace(input.Email)
input.Code = strings.TrimSpace(input.Code)
if input.Username == "" || input.Password == "" {
return LegacyUser{}, errors.New(errInvalidParams)
}
if len(input.Password) < minPasswordLength {
return LegacyUser{}, errors.New(errPasswordTooShort)
}
if input.Email == "" {
return LegacyUser{}, errors.New(errEmailRequired)
}
if isEmailRegisterVerificationEnabled(ctx) {
if input.Code == "" || !verifyEmailCode(ctx, input.Email, "register", input.Code) {
return LegacyUser{}, errors.New(errEmailCodeInvalid)
}
}
user := model.User{
ID: idgen.NextUint64ID(),
Username: input.Username,
Nickname: input.Nickname,
Email: input.Email,
IsActive: true,
IsAdmin: false,
LastLoginAt: time.Now(),
}
if user.Nickname == "" {
user.Nickname = input.DisplayName
}
if user.Nickname == "" {
user.Nickname = input.Username
}
if err := user.SetEncryptedPassword(input.Password); err != nil {
return LegacyUser{}, err
}
if err := user.RegisterUser(ctx, db.DB(ctx)); err != nil {
return LegacyUser{}, err
}
if err := setLoginSession(ctx, c, &user); err != nil {
return LegacyUser{}, errors.New(errSaveSessionFailed)
}
token, err := issueLegacyAccessToken(ctx, &user)
if err != nil {
return LegacyUser{}, err
}
return ToLegacyUser(&user, token), nil
}
// Logout clears session and revokes the legacy access token when provided.
func Logout(ctx context.Context, c *gin.Context) error {
token := strings.TrimSpace(c.GetHeader(compat.OpenFlareTokenHeader()))
if token == "" {
token = strings.TrimSpace(c.GetHeader("X-Access-Token"))
}
if token != "" {
tokenHash := model.HashToken(token)
_ = db.DB(ctx).Where("token_hash = ? AND name = ?", tokenHash, legacyTokenName).Delete(&model.AccessToken{}).Error
}
session := sessions.Default(c)
session.Options(oauth.GetSessionOptions(-1))
session.Clear()
return session.Save()
}
// GetSelf returns the current user's legacy profile.
func GetSelf(ctx context.Context, userID uint64) (LegacyUser, error) {
user, err := repository.GetUserByID(ctx, userID)
if err != nil {
return LegacyUser{}, errors.New(errUserNotFound)
}
return ToLegacyUser(&user, ""), nil
}
// GenerateUserToken issues a fresh legacy access token for the user.
func GenerateUserToken(ctx context.Context, userID uint64) (string, error) {
user, err := repository.GetUserByID(ctx, userID)
if err != nil {
return "", errors.New(errUserNotFound)
}
return issueLegacyAccessToken(ctx, &user)
}
// UpdateSelf updates the logged-in user's profile.
func UpdateSelf(ctx context.Context, userID uint64, input UpdateSelfInput) error {
user, err := repository.GetUserByID(ctx, userID)
if err != nil {
return errors.New(errUserNotFound)
}
if input.DisplayName != "" {
user.Nickname = strings.TrimSpace(input.DisplayName)
}
if input.Username != "" {
user.Username = strings.TrimSpace(input.Username)
}
if input.Email != "" {
user.Email = strings.TrimSpace(input.Email)
}
if input.Password != "" {
if len(input.Password) < minPasswordLength {
return errors.New(errPasswordTooShort)
}
if err := user.SetEncryptedPassword(input.Password); err != nil {
return err
}
}
return db.DB(ctx).Save(&user).Error
}
// DeleteSelf removes the logged-in user.
func DeleteSelf(ctx context.Context, userID uint64) error {
return repository.DeleteUserWithRelations(ctx, userID)
}
// ListUsers returns a paginated legacy user list.
func ListUsers(ctx context.Context, page int) ([]LegacyUser, error) {
if page < 0 {
page = 0
}
_, users, err := repository.ListAdminUsers(ctx, repository.AdminUserListFilter{
Page: page + 1,
PageSize: legacyItemsPerPage,
})
if err != nil {
return nil, err
}
return ToLegacyUsers(users), nil
}
// SearchUsers searches users by keyword.
func SearchUsers(ctx context.Context, keyword string) ([]LegacyUser, error) {
keyword = strings.TrimSpace(keyword)
var users []model.User
query := db.DB(ctx).Model(&model.User{}).
Select("id, username, nickname, email, is_active, is_admin")
if keyword != "" {
like := keyword + "%"
query = query.Where(
"CAST(id AS TEXT) = ? OR username LIKE ? OR email LIKE ? OR nickname LIKE ?",
keyword, like, like, like,
)
}
if err := query.Order("id DESC").Find(&users).Error; err != nil {
return nil, err
}
return ToLegacyUsers(users), nil
}
// GetUserByID returns a legacy user if the caller has sufficient role.
func GetUserByID(ctx context.Context, callerRole int, id uint64) (LegacyUser, error) {
user, err := repository.GetAdminUserDetail(ctx, id)
if err != nil {
return LegacyUser{}, errors.New(errUserNotFound)
}
targetRole := RoleFromUser(&user)
if callerRole <= targetRole {
return LegacyUser{}, errors.New(errInsufficientPermission)
}
return ToLegacyUser(&user, ""), nil
}
// CreateUser creates a user from legacy admin input.
func CreateUser(ctx context.Context, callerRole int, input CreateUserInput) error {
input.Username = strings.TrimSpace(input.Username)
input.Password = strings.TrimSpace(input.Password)
input.DisplayName = strings.TrimSpace(input.DisplayName)
input.Email = strings.TrimSpace(input.Email)
if input.Username == "" || input.Password == "" {
return errors.New(errInvalidParams)
}
if input.Role >= callerRole {
return errors.New(errInsufficientPermission)
}
if input.Email == "" {
input.Email = input.Username + "@openflare.local"
}
newUser := model.User{
ID: idgen.NextUint64ID(),
Username: input.Username,
Nickname: input.DisplayName,
Email: input.Email,
IsActive: true,
IsAdmin: input.Role >= compat.RoleAdminUser,
}
if newUser.Nickname == "" {
newUser.Nickname = input.Username
}
if err := newUser.SetEncryptedPassword(input.Password); err != nil {
return err
}
return repository.CreateUser(ctx, &newUser)
}
// UpdateUser updates another user with legacy role checks.
func UpdateUser(ctx context.Context, callerRole int, input UpdateUserInput) error {
if input.ID == 0 {
return errors.New(errInvalidParams)
}
origin, err := repository.GetAdminUserDetail(ctx, uint64(input.ID))
if err != nil {
return errors.New(errUserNotFound)
}
originRole := RoleFromUser(&origin)
if callerRole <= originRole {
return errors.New(errInsufficientPermission)
}
if input.Role > 0 && callerRole <= input.Role {
return errors.New(errInsufficientPermission)
}
if trimmed := strings.TrimSpace(input.Username); trimmed != "" {
origin.Username = trimmed
}
if input.DisplayName != "" {
origin.Nickname = strings.TrimSpace(input.DisplayName)
}
if input.Email != "" {
origin.Email = strings.TrimSpace(input.Email)
}
if input.Role > 0 {
origin.IsAdmin = input.Role >= compat.RoleAdminUser
}
if input.Password != "" {
if len(input.Password) < minPasswordLength {
return errors.New(errPasswordTooShort)
}
if err := origin.SetEncryptedPassword(input.Password); err != nil {
return err
}
}
return db.DB(ctx).Save(&origin).Error
}
// DeleteUserByID deletes a user when the caller has sufficient role.
func DeleteUserByID(ctx context.Context, callerRole int, id uint64) error {
origin, err := repository.GetAdminUserDetail(ctx, id)
if err != nil {
return errors.New(errUserNotFound)
}
if callerRole <= RoleFromUser(&origin) {
return errors.New(errInsufficientPermission)
}
if RoleFromUser(&origin) >= compat.RoleRootUser {
return errors.New(errCannotDeleteRoot)
}
return repository.DeleteUserWithRelations(ctx, id)
}
// ManageUser performs enable/disable/delete/promote/demote actions.
func ManageUser(ctx context.Context, callerRole int, input ManageUserInput) (LegacyUser, error) {
input.Username = strings.TrimSpace(input.Username)
input.Action = strings.TrimSpace(input.Action)
if input.Username == "" || input.Action == "" {
return LegacyUser{}, errors.New(errInvalidParams)
}
user, err := repository.GetUserByUsername(ctx, input.Username)
if err != nil {
return LegacyUser{}, errors.New(errUserNotFound)
}
targetRole := RoleFromUser(&user)
if callerRole <= targetRole && callerRole != compat.RoleRootUser {
return LegacyUser{}, errors.New(errInsufficientPermission)
}
switch input.Action {
case "disable":
if targetRole >= compat.RoleRootUser {
return LegacyUser{}, errors.New(errCannotDisableRoot)
}
user.IsActive = false
case "enable":
user.IsActive = true
case "delete":
if targetRole >= compat.RoleRootUser {
return LegacyUser{}, errors.New(errCannotDeleteRoot)
}
if err := repository.DeleteUserWithRelations(ctx, user.ID); err != nil {
return LegacyUser{}, err
}
return LegacyUser{Role: compat.RoleCommonUser, Status: legacyUserStatusDisabled}, nil
case "promote":
if callerRole != compat.RoleRootUser {
return LegacyUser{}, errors.New(errCannotPromoteAdmin)
}
if user.IsAdmin {
return LegacyUser{}, errors.New(errAlreadyAdmin)
}
user.IsAdmin = true
case "demote":
if targetRole >= compat.RoleRootUser {
return LegacyUser{}, errors.New(errCannotDisableRoot)
}
if !user.IsAdmin {
return LegacyUser{}, errors.New(errAlreadyCommonUser)
}
user.IsAdmin = false
default:
return LegacyUser{}, errors.New(errInvalidParams)
}
if err := db.DB(ctx).Save(&user).Error; err != nil {
return LegacyUser{}, err
}
return LegacyUser{
Role: RoleFromUser(&user),
Status: StatusFromUser(&user),
}, nil
}
// SendRegisterVerificationEmail sends a registration verification code.
func SendRegisterVerificationEmail(ctx context.Context, email string) error {
email = strings.TrimSpace(email)
if email == "" || !strings.Contains(email, "@") {
return errors.New(errInvalidParams)
}
var count int64
if err := db.DB(ctx).Model(&model.User{}).Where("email = ?", email).Count(&count).Error; err != nil {
return err
}
if count > 0 {
return errors.New(errEmailAlreadyRegistered)
}
return sendEmailVerificationCode(ctx, email, "register", "register_email")
}
// SendPasswordResetEmail stores a reset token and emails the user.
func SendPasswordResetEmail(ctx context.Context, email string) error {
email = strings.TrimSpace(email)
if email == "" || !strings.Contains(email, "@") {
return errors.New(errInvalidParams)
}
var user model.User
if err := db.DB(ctx).Where("email = ?", email).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New(errEmailNotRegistered)
}
return err
}
token, err := generateResetToken()
if err != nil {
return err
}
key := fmt.Sprintf(passwordResetKeyFmt, email)
if err := db.SetJSON(ctx, key, token, passwordResetExpiry); err != nil {
return err
}
serverAddr, _ := repository.GetSystemConfigByKey(ctx, model.ConfigKeyServerAddress)
base := strings.TrimRight(serverAddr.Value, "/")
link := fmt.Sprintf("%s/user/reset?email=%s&token=%s", base, email, token)
body := fmt.Sprintf("<p>您好,你正在进行密码重置。</p><p>点击<a href='%s'>此处</a>进行密码重置。</p>", link)
return dispatchEmail(ctx, email, "密码重置", body)
}
// ResetPassword validates a reset token and returns a new random password.
func ResetPassword(ctx context.Context, input PasswordResetInput) (string, error) {
input.Email = strings.TrimSpace(input.Email)
input.Token = strings.TrimSpace(input.Token)
if input.Email == "" || input.Token == "" {
return "", errors.New(errInvalidParams)
}
key := fmt.Sprintf(passwordResetKeyFmt, input.Email)
var stored string
if err := db.GetJSON(ctx, key, &stored); err != nil || stored != input.Token {
return "", errors.New(errResetLinkInvalid)
}
password, err := generateResetToken()
if err != nil {
return "", err
}
if len(password) < minPasswordLength {
password = password + "Aa1!"
}
var user model.User
if err := db.DB(ctx).Where("email = ?", input.Email).First(&user).Error; err != nil {
return "", errors.New(errEmailNotRegistered)
}
if err := user.SetEncryptedPassword(password); err != nil {
return "", err
}
if err := db.DB(ctx).Model(&user).Update("password", user.Password).Error; err != nil {
return "", err
}
_ = db.Redis.Del(ctx, db.PrefixedKey(key)).Err()
return password, nil
}
// BuildPublicStatus assembles the legacy /api/status payload.
func BuildPublicStatus(ctx context.Context) (map[string]any, error) {
authSources, err := publicAuthSources(ctx, "/api")
if err != nil {
authSources = []map[string]any{}
}
siteName, _ := repository.GetSystemConfigByKey(ctx, model.ConfigKeySiteName)
serverAddr, _ := repository.GetSystemConfigByKey(ctx, model.ConfigKeyServerAddress)
return map[string]any{
"version": buildVersion(),
"start_time": appStartUnix(),
"email_verification": isEmailRegisterVerificationEnabled(ctx),
"github_oauth": false,
"github_client_id": "",
"system_name": siteName.Value,
"home_page_link": "",
"footer_html": "",
"wechat_qrcode": "",
"wechat_login": false,
"server_address": serverAddr.Value,
"password_register_enabled": isPasswordRegisterEnabled(ctx),
"cap_login_enabled": capLoginEnabled(ctx),
"auth_sources": authSources,
}, nil
}
// GetNotice returns the legacy notice option value.
func GetNotice(ctx context.Context) string {
return getOptionValue(ctx, "notice")
}
// GetAbout returns the legacy about option value.
func GetAbout(ctx context.Context) string {
return getOptionValue(ctx, "about")
}
func issueLegacyAccessToken(ctx context.Context, user *model.User) (string, error) {
tokenStr, err := model.GenerateTokenString()
if err != nil {
return "", errors.New(errGenerateTokenFailed)
}
record := model.AccessToken{
UserID: user.ID,
Name: legacyTokenName,
TokenHash: model.HashToken(tokenStr),
MaskedToken: model.MaskTokenString(tokenStr),
IsAdmin: user.IsAdmin,
}
if err := db.DB(ctx).Create(&record).Error; err != nil {
return "", err
}
return tokenStr, nil
}
func setLoginSession(ctx context.Context, c *gin.Context, user *model.User) error {
session := sessions.Default(c)
session.Set(oauth.UserIDKey, user.ID)
session.Set(oauth.UserNameKey, user.Username)
session.Set(oauth.PasswordHashKey, user.Password)
maxAge := config.Config.App.SessionAge
isSessionCookie := false
ttlHours, err := repository.GetIntByKey(ctx, model.ConfigKeyLoginSessionTTLHours)
if err == nil {
switch {
case ttlHours == -1:
maxAge = 10 * 365 * 24 * 3600
case ttlHours > 0:
maxAge = ttlHours * 3600
case ttlHours == 0:
isSessionCookie = true
}
}
session.Options(oauth.GetSessionOptions(maxAge))
if err := session.Save(); err != nil {
return err
}
if isSessionCookie {
oauth.StripCookieMaxAgeAndExpires(c.Writer.Header(), config.Config.App.SessionCookieName)
}
return nil
}
func isPasswordLoginEnabled(ctx context.Context) bool {
enabled, err := repository.GetBoolByKey(ctx, model.ConfigKeyPasswordLoginEnabled)
return err != nil || enabled
}
func isPasswordRegisterEnabled(ctx context.Context) bool {
enabled, err := repository.GetBoolByKey(ctx, model.ConfigKeyPasswordRegisterEnabled)
return err != nil || enabled
}
func isRegistrationEnabled(ctx context.Context) bool {
enabled, err := repository.GetBoolByKey(ctx, model.ConfigKeyRegistrationEnabled)
return err != nil || enabled
}
func isEmailLoginVerificationEnabled(ctx context.Context) bool {
enabled, err := repository.GetBoolByKey(ctx, model.ConfigKeyEmailLoginVerificationEnabled)
return err == nil && enabled
}
func isEmailRegisterVerificationEnabled(ctx context.Context) bool {
enabled, err := repository.GetBoolByKey(ctx, model.ConfigKeyEmailRegisterVerificationEnabled)
return err == nil && enabled
}
func capLoginEnabled(ctx context.Context) bool {
enabled, err := repository.GetBoolByKey(ctx, model.ConfigKeyCapLoginEnabled)
return err == nil && enabled
}
func verifyLoginEmailCode(ctx context.Context, email, code string) error {
if code == "" {
return errors.New("need_email_code:" + email)
}
if !verifyEmailCode(ctx, email, "login", code) {
return errors.New(errEmailCodeInvalid)
}
return nil
}
func verifyEmailCode(ctx context.Context, email, scene, code string) bool {
key := fmt.Sprintf("email_code:%s:%s", scene, email)
var stored string
if err := db.GetJSON(ctx, key, &stored); err != nil {
return false
}
if stored != code {
return false
}
_ = db.Redis.Del(ctx, db.PrefixedKey(key)).Err()
return true
}
func sendEmailVerificationCode(ctx context.Context, email, scene, templateName string) error {
scHost, errHost := repository.GetSystemConfigByKey(ctx, model.ConfigKeySMTPHost)
scPort, errPort := repository.GetSystemConfigByKey(ctx, model.ConfigKeySMTPPort)
scUser, errUser := repository.GetSystemConfigByKey(ctx, model.ConfigKeySMTPUsername)
scPass, errPass := repository.GetSystemConfigByKey(ctx, model.ConfigKeySMTPPassword)
if errHost != nil || errPort != nil || errUser != nil || errPass != nil ||
scHost.Value == "" || scPort.Value == "" || scUser.Value == "" || scPass.Value == "" {
return errors.New("系统 SMTP 邮件服务配置不完整")
}
code, err := generateVerificationCode()
if err != nil {
return err
}
codeKey := fmt.Sprintf("email_code:%s:%s", scene, email)
if err := db.SetJSON(ctx, codeKey, code, 5*time.Minute); err != nil {
return err
}
tmpl, err := repository.GetTemplateByKey(ctx, templateName)
if err != nil {
body := fmt.Sprintf("<p>您的验证码为: <strong>%s</strong></p>", code)
return dispatchEmail(ctx, email, "邮箱验证", body)
}
subject, body, err := tmpl.Render(map[string]any{"Code": code})
if err != nil {
return err
}
return dispatchEmail(ctx, email, subject, body)
}
type sendEmailPayload struct {
To string `json:"to"`
Subject string `json:"subject"`
Body string `json:"body"`
}
func dispatchEmail(ctx context.Context, to, subject, body string) error {
payload := sendEmailPayload{To: to, Subject: subject, Body: body}
payloadBytes, err := json.Marshal(payload)
if err != nil {
return err
}
_, err = task.DispatchTask(ctx, "mail:send", payloadBytes, "system")
return err
}
func generateVerificationCode() (string, error) {
n, err := rand.Int(rand.Reader, big.NewInt(verificationCodeRange))
if err != nil {
return "", err
}
return fmt.Sprintf("%06d", n.Int64()+verificationOffset), nil
}
func generateResetToken() (string, error) {
n, err := rand.Int(rand.Reader, big.NewInt(1<<62))
if err != nil {
return "", err
}
return fmt.Sprintf("%x", n.Int64()), nil
}
func publicAuthSources(ctx context.Context, baseAPIPath string) ([]map[string]any, error) {
sources, err := model.GetActiveAuthSources(ctx)
if err != nil {
return nil, err
}
result := make([]map[string]any, 0, len(sources))
base := strings.TrimRight(baseAPIPath, "/")
for _, source := range sources {
result = append(result, map[string]any{
"id": source.ID,
"name": source.Name,
"type": source.Type,
"display_name": source.DisplayName,
"authorize_url": fmt.Sprintf("%s/oauth/%s/authorize", base, source.Name),
"icon_url": source.IconURL,
})
}
return result, nil
}
func getOptionValue(ctx context.Context, key string) string {
sc, err := repository.GetSystemConfigByKey(ctx, key)
if err != nil {
return ""
}
return sc.Value
}
var appStart = time.Now()
func appStartUnix() int64 {
return appStart.Unix()
}
func buildVersion() string {
if buildinfo.Version != "" {
return buildinfo.Version
}
return "dev"
}
@@ -0,0 +1,509 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package auth
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/url"
"strings"
"time"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/compat"
"github.com/Rain-kl/Wavelet/internal/db"
"github.com/Rain-kl/Wavelet/internal/listener"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/internal/repository"
"github.com/Rain-kl/Wavelet/pkg/logger"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"gorm.io/gorm"
)
const pendingExternalAccountSessionKey = "pending_external_account"
// OAuthCallbackResult is the legacy OAuth callback payload.
type OAuthCallbackResult struct {
Status string `json:"status"`
User *LegacyUser `json:"user,omitempty"`
}
// PendingExternalAccount stores OAuth bind-pending state in session.
type PendingExternalAccount struct {
AuthSourceID uint64 `json:"auth_source_id"`
ExternalID string `json:"external_id"`
ExternalUsername string `json:"external_username"`
DisplayName string `json:"display_name"`
Email string `json:"email"`
}
// OAuthAuthorize builds an authorize URL for a legacy auth source route param.
func OAuthAuthorize(ctx context.Context, c *gin.Context, sourceKey string) (string, error) {
source, err := resolveAuthSourceByRoute(ctx, sourceKey)
if err != nil {
return "", err
}
if !source.IsActive {
return "", errors.New(errAuthSourceDisabled)
}
if err := source.Validate(); err != nil {
return "", err
}
state := uuid.NewString()
session := sessions.Default(c)
session.Set(oauthStateSessionKey(source.ID), state)
if err := session.Save(); err != nil {
return "", errors.New(errSaveSessionFailed)
}
redirectURL := legacyOAuthCallbackURL(c, source)
return buildLegacyAuthorizeURL(ctx, source, redirectURL, state)
}
// OAuthCallback handles GET /oauth/:source/callback for the legacy frontend.
func OAuthCallback(ctx context.Context, c *gin.Context, sourceKey string) (OAuthCallbackResult, error) {
source, err := resolveAuthSourceByRoute(ctx, sourceKey)
if err != nil {
return OAuthCallbackResult{}, err
}
if !source.IsActive {
return OAuthCallbackResult{}, errors.New(errAuthSourceDisabled)
}
session := sessions.Default(c)
expectedState, _ := session.Get(oauthStateSessionKey(source.ID)).(string)
state := c.Query("state")
if expectedState == "" || state == "" || state != expectedState {
return OAuthCallbackResult{}, errors.New("授权状态无效,请重新登录")
}
session.Delete(oauthStateSessionKey(source.ID))
if err := session.Save(); err != nil {
return OAuthCallbackResult{}, errors.New(errSaveSessionFailed)
}
if oauthError := c.Query("error"); oauthError != "" {
description := c.Query("error_description")
if description == "" {
description = oauthError
}
return OAuthCallbackResult{}, errors.New(description)
}
redirectURL := legacyOAuthCallbackURL(c, source)
userInfo, err := exchangeLegacyOAuthProfile(ctx, source, c.Query("code"), state, redirectURL)
if err != nil {
return OAuthCallbackResult{}, err
}
var currentUserID *uint64
if current := currentUserFromLegacyToken(ctx, c); current != nil {
currentUserID = &current.ID
}
result, pending, err := completeLegacyOAuthLogin(ctx, source, userInfo, currentUserID)
if err != nil {
return OAuthCallbackResult{}, err
}
if pending != nil {
raw, marshalErr := json.Marshal(pending)
if marshalErr != nil {
return OAuthCallbackResult{}, marshalErr
}
session.Set(pendingExternalAccountSessionKey, string(raw))
if err := session.Save(); err != nil {
return OAuthCallbackResult{}, errors.New(errSaveSessionFailed)
}
return result, nil
}
if result.User != nil {
var dbUser model.User
if err := db.DB(ctx).Where("id = ?", result.User.ID).First(&dbUser).Error; err != nil {
return OAuthCallbackResult{}, err
}
if err := setLoginSession(ctx, c, &dbUser); err != nil {
return OAuthCallbackResult{}, errors.New(errSaveSessionFailed)
}
token, tokenErr := issueLegacyAccessToken(ctx, &dbUser)
if tokenErr != nil {
return OAuthCallbackResult{}, tokenErr
}
legacy := ToLegacyUser(&dbUser, token)
result.User = &legacy
listener.EmitAdminLoggedIn(ctx, &dbUser, c.ClientIP())
}
return result, nil
}
// LinkExistingOAuthAccount binds a pending external account to an existing user.
func LinkExistingOAuthAccount(ctx context.Context, c *gin.Context, input LinkExistingInput) (OAuthCallbackResult, error) {
session := sessions.Default(c)
raw, _ := session.Get(pendingExternalAccountSessionKey).(string)
if raw == "" {
return OAuthCallbackResult{}, errors.New(errPendingOAuthExpired)
}
var pending PendingExternalAccount
if err := json.Unmarshal([]byte(raw), &pending); err != nil {
return OAuthCallbackResult{}, errors.New(errPendingOAuthInvalid)
}
user, err := linkPendingExternalAccount(ctx, &pending, input)
if err != nil {
return OAuthCallbackResult{}, err
}
session.Delete(pendingExternalAccountSessionKey)
if err := session.Save(); err != nil {
return OAuthCallbackResult{}, errors.New(errSaveSessionFailed)
}
if err := setLoginSession(ctx, c, user); err != nil {
return OAuthCallbackResult{}, errors.New(errSaveSessionFailed)
}
token, err := issueLegacyAccessToken(ctx, user)
if err != nil {
return OAuthCallbackResult{}, err
}
legacy := ToLegacyUser(user, token)
return OAuthCallbackResult{Status: "linked", User: &legacy}, nil
}
func resolveAuthSourceByRoute(ctx context.Context, raw string) (*model.AuthSource, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, errors.New("认证源不能为空")
}
if parsed, err := parseUint64(raw); err == nil && parsed > 0 {
return model.GetAuthSourceByID(ctx, parsed)
}
return model.GetAuthSourceByName(ctx, raw)
}
func oauthStateSessionKey(sourceID uint64) string {
return fmt.Sprintf("oauth_state_%d", sourceID)
}
func legacyOAuthCallbackURL(c *gin.Context, source *model.AuthSource) string {
ctx := c.Request.Context()
base := ""
if sc, err := repository.GetSystemConfigByKey(ctx, model.ConfigKeyServerAddress); err == nil {
base = strings.TrimRight(sc.Value, "/")
}
if base == "" {
scheme := "http"
if c.Request.TLS != nil || c.GetHeader("X-Forwarded-Proto") == "https" {
scheme = "https"
}
host := c.Request.Host
if forwardedHost := c.GetHeader("X-Forwarded-Host"); forwardedHost != "" {
host = forwardedHost
}
base = scheme + "://" + host
}
sourceName := source.Name
if sourceName == "" {
sourceName = fmt.Sprintf("%d", source.ID)
}
callback, _ := url.JoinPath(base, "oauth", sourceName)
return callback
}
func buildLegacyAuthorizeURL(ctx context.Context, source *model.AuthSource, redirectURL, state string) (string, error) {
payloadValue, err := encodeLegacyOAuthState(source.Name, state)
if err != nil {
return "", err
}
stateKey := fmt.Sprintf("of_oauth_state:%s", state)
if err := db.Redis.Set(ctx, db.PrefixedKey(stateKey), payloadValue, 10*time.Minute).Err(); err != nil {
return "", err
}
return oauthBuildAuthorizeURL(ctx, source, redirectURL, state)
}
func encodeLegacyOAuthState(sourceName, state string) (string, error) {
payload := map[string]string{
"source_name": sourceName,
"state": state,
}
raw, err := json.Marshal(payload)
if err != nil {
return "", err
}
return string(raw), nil
}
func legacyFrontendLoginRedirectURL(ctx context.Context, source *model.AuthSource) (string, error) {
sc, err := repository.GetSystemConfigByKey(ctx, model.ConfigKeyServerAddress)
if err != nil || strings.TrimSpace(sc.Value) == "" {
return "", errors.New("server_address 未配置")
}
base := strings.TrimRight(sc.Value, "/")
name := source.Name
if name == "" {
name = fmt.Sprintf("%d", source.ID)
}
return base + "/oauth/" + url.PathEscape(name), nil
}
func exchangeLegacyOAuthProfile(ctx context.Context, source *model.AuthSource, code, state, redirectURL string) (*model.OAuthUserInfo, error) {
if strings.TrimSpace(code) == "" {
return nil, errors.New("授权 code 不能为空")
}
// Validate state from Redis cache written during authorize.
stateKey := fmt.Sprintf("of_oauth_state:%s", state)
payloadRaw, err := db.Redis.Get(ctx, db.PrefixedKey(stateKey)).Result()
if err != nil {
return nil, errors.New("授权状态无效,请重新登录")
}
_ = db.Redis.Del(ctx, db.PrefixedKey(stateKey)).Err()
var payload map[string]string
if err := json.Unmarshal([]byte(payloadRaw), &payload); err != nil {
return nil, err
}
if payload["source_name"] != source.Name {
return nil, errors.New("授权状态无效,请重新登录")
}
userInfo, err := buildOAuthUserInfo(ctx, source, code, state, redirectURL)
if err != nil {
return nil, err
}
if err := normalizeOAuthUserInfo(userInfo); err != nil {
return nil, err
}
if userInfo.Sub == "" {
userInfo.Sub = userInfo.Username
}
return userInfo, nil
}
func completeLegacyOAuthLogin(ctx context.Context, source *model.AuthSource, profile *model.OAuthUserInfo, currentUserID *uint64) (OAuthCallbackResult, *PendingExternalAccount, error) {
if source == nil || profile == nil || strings.TrimSpace(profile.Sub) == "" {
return OAuthCallbackResult{}, nil, errors.New("第三方账号资料不完整")
}
account, err := model.FindExternalAccount(ctx, source.ID, profile.Sub)
if err == nil {
var user model.User
if err := db.DB(ctx).Where("id = ?", account.UserID).First(&user).Error; err != nil {
return OAuthCallbackResult{}, nil, err
}
if !user.IsActive {
return OAuthCallbackResult{}, nil, errors.New(errBannedAccount)
}
legacy := ToLegacyUser(&user, "")
return OAuthCallbackResult{Status: "logged_in", User: &legacy}, nil, nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return OAuthCallbackResult{}, nil, err
}
if currentUserID != nil && *currentUserID > 0 {
var user model.User
if err := db.DB(ctx).Where("id = ?", *currentUserID).First(&user).Error; err != nil {
return OAuthCallbackResult{}, nil, err
}
if !user.IsActive {
return OAuthCallbackResult{}, nil, errors.New(errBannedAccount)
}
if err := model.BindExternalAccount(ctx, &model.ExternalAccount{
AuthSourceID: source.ID,
UserID: user.ID,
ExternalID: profile.Sub,
ExternalUsername: profile.Username,
Email: profile.Email,
}); err != nil {
return OAuthCallbackResult{}, nil, err
}
legacy := ToLegacyUser(&user, "")
return OAuthCallbackResult{Status: "linked", User: &legacy}, nil, nil
}
registrationEnabled, regErr := repository.GetBoolByKey(ctx, model.ConfigKeyRegistrationEnabled)
if regErr != nil {
registrationEnabled = true
}
if !registrationEnabled {
pending := &PendingExternalAccount{
AuthSourceID: source.ID,
ExternalID: profile.Sub,
ExternalUsername: profile.Username,
DisplayName: profile.Name,
Email: profile.Email,
}
return OAuthCallbackResult{Status: "link_required"}, pending, nil
}
user, err := createUserFromOAuthProfile(ctx, source, profile)
if err != nil {
return OAuthCallbackResult{}, nil, err
}
legacy := ToLegacyUser(&user, "")
return OAuthCallbackResult{Status: "logged_in", User: &legacy}, nil, nil
}
func createUserFromOAuthProfile(ctx context.Context, source *model.AuthSource, profile *model.OAuthUserInfo) (model.User, error) {
username, err := uniqueLegacyUsername(ctx, profile.Username)
if err != nil {
return model.User{}, err
}
profile.Username = username
var user model.User
if err := user.CreateUser(ctx, db.DB(ctx), profile); err != nil {
return model.User{}, err
}
if err := model.BindExternalAccount(ctx, &model.ExternalAccount{
AuthSourceID: source.ID,
UserID: user.ID,
ExternalID: profile.Sub,
ExternalUsername: profile.Username,
Email: profile.Email,
}); err != nil {
return model.User{}, err
}
logger.InfoF(ctx, "[LoginAudit] successful legacy OAuth registration via source: %s, user: %s, ID: %d", source.Name, user.Username, user.ID)
return user, nil
}
func linkPendingExternalAccount(ctx context.Context, pending *PendingExternalAccount, input LinkExistingInput) (*model.User, error) {
if pending == nil || pending.AuthSourceID == 0 || pending.ExternalID == "" {
return nil, errors.New(errPendingOAuthExpired)
}
input.Username = strings.TrimSpace(input.Username)
if input.Username == "" || input.Password == "" {
return nil, errors.New(errInvalidParams)
}
var user model.User
if err := db.DB(ctx).Where("username = ? OR email = ?", input.Username, input.Username).First(&user).Error; err != nil {
return nil, errors.New(errUsernameOrPasswordWrong)
}
if !user.IsActive {
return nil, errors.New(errBannedAccount)
}
if !user.CheckPassword(input.Password) {
return nil, errors.New(errUsernameOrPasswordWrong)
}
if existing, err := model.FindExternalAccount(ctx, pending.AuthSourceID, pending.ExternalID); err == nil {
if existing.UserID != user.ID {
return nil, errors.New("该第三方账号已绑定其他用户")
}
return &user, nil
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
if err := model.BindExternalAccount(ctx, &model.ExternalAccount{
AuthSourceID: pending.AuthSourceID,
UserID: user.ID,
ExternalID: pending.ExternalID,
ExternalUsername: pending.ExternalUsername,
Email: pending.Email,
}); err != nil {
return nil, err
}
return &user, nil
}
func currentUserFromLegacyToken(ctx context.Context, c *gin.Context) *model.User {
token := strings.TrimSpace(c.GetHeader(compat.OpenFlareTokenHeader()))
if token == "" {
return nil
}
tokenHash := model.HashToken(token)
var record model.AccessToken
if err := db.DB(ctx).Where("token_hash = ?", tokenHash).First(&record).Error; err != nil {
return nil
}
var user model.User
if err := db.DB(ctx).Where("id = ? AND is_active = ?", record.UserID, true).First(&user).Error; err != nil {
return nil
}
return &user
}
func uniqueLegacyUsername(ctx context.Context, base string) (string, error) {
base = strings.TrimSpace(base)
if base == "" {
base = "user"
}
candidate := base
for i := 0; i <= 1000; i++ {
if i > 0 {
candidate = fmt.Sprintf("%s-%d", base, i)
}
count, err := repository.CountUsersByUsername(ctx, candidate)
if err != nil {
return "", err
}
if count == 0 {
return candidate, nil
}
}
return "", errors.New("无法生成唯一用户名")
}
func parseUint64(raw string) (uint64, error) {
var id uint64
_, err := fmt.Sscanf(raw, "%d", &id)
return id, err
}
func isOIDCLoginEnabled(ctx context.Context) bool {
enabled, err := repository.GetBoolByKey(ctx, model.ConfigKeyOIDCLoginEnabled)
return err != nil || enabled
}
// The following functions mirror oauth package internals for legacy GET callback support.
// They intentionally duplicate minimal logic to avoid modifying the core oauth module.
func buildOAuthUserInfo(ctx context.Context, source *model.AuthSource, code, nonce, redirectURL string) (*model.OAuthUserInfo, error) {
authConfig, verifier, err := buildOAuthConfig(ctx, source, redirectURL)
if err != nil {
return nil, err
}
token, err := authConfig.Exchange(ctx, code)
if err != nil {
return nil, err
}
userInfo := &model.OAuthUserInfo{Active: true}
if verifier != nil {
if verifyErr := verifyIDToken(ctx, verifier, token, nonce, userInfo); verifyErr != nil {
return nil, verifyErr
}
}
if userInfo.Username == "" && userInfo.PreferredUsername != "" {
userInfo.Username = userInfo.PreferredUsername
}
if userInfo.Username == "" && userInfo.Email != "" {
userInfo.Username = strings.Split(userInfo.Email, "@")[0]
}
if userInfo.Username == "" && userInfo.Sub != "" {
userInfo.Username = userInfo.Sub
}
if userInfo.Name == "" {
userInfo.Name = userInfo.Username
}
return userInfo, nil
}
func normalizeOAuthUserInfo(userInfo *model.OAuthUserInfo) error {
userInfo.Username = strings.TrimSpace(userInfo.Username)
userInfo.Email = strings.TrimSpace(userInfo.Email)
userInfo.Name = strings.TrimSpace(userInfo.Name)
if userInfo.Username == "" {
return errors.New("无法从认证源获取用户名")
}
if userInfo.Name == "" {
userInfo.Name = userInfo.Username
}
if !userInfo.Active {
userInfo.Active = true
}
return nil
}
@@ -0,0 +1,134 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package auth
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
"sync"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
"golang.org/x/sync/singleflight"
)
type legacyOIDCProviderCacheType struct {
mu sync.RWMutex
entries map[string]*oidc.Provider
sfGroup singleflight.Group
}
var legacyOIDCProviderCache = &legacyOIDCProviderCacheType{
entries: make(map[string]*oidc.Provider),
}
func (c *legacyOIDCProviderCacheType) get(ctx context.Context, issuer string) (*oidc.Provider, error) {
c.mu.RLock()
if p, ok := c.entries[issuer]; ok {
c.mu.RUnlock()
return p, nil
}
c.mu.RUnlock()
bg := context.Background()
if client, ok := ctx.Value(oauth2.HTTPClient).(*http.Client); ok && client != nil {
bg = oidc.ClientContext(bg, client)
}
v, err, _ := c.sfGroup.Do(issuer, func() (any, error) {
c.mu.RLock()
if p, ok := c.entries[issuer]; ok {
c.mu.RUnlock()
return p, nil
}
c.mu.RUnlock()
p, err := oidc.NewProvider(bg, issuer)
if err != nil {
return nil, err
}
c.mu.Lock()
c.entries[issuer] = p
c.mu.Unlock()
return p, nil
})
if err != nil {
return nil, err
}
return v.(*oidc.Provider), nil //nolint:forcetypeassert // singleflight value type is fixed
}
// oauthBuildAuthorizeURL builds an OAuth authorize URL using the same rules as apps/oauth.
func oauthBuildAuthorizeURL(ctx context.Context, source *model.AuthSource, redirectURL, state string) (string, error) {
authConfig, verifier, err := buildOAuthConfig(ctx, source, redirectURL)
if err != nil {
return "", err
}
if verifier != nil {
return authConfig.AuthCodeURL(state, oidc.Nonce(state)), nil
}
return authConfig.AuthCodeURL(state), nil
}
func buildOAuthConfig(ctx context.Context, source *model.AuthSource, redirectURL string) (*oauth2.Config, *oidc.IDTokenVerifier, error) {
if source == nil {
return nil, nil, errors.New("认证源不能为空")
}
if source.OpenIDDiscoveryURL == "" {
return nil, nil, errors.New("认证源未配置 OpenID Discovery URL")
}
issuer := strings.TrimSuffix(strings.TrimSpace(source.OpenIDDiscoveryURL), "/")
issuer = strings.TrimSuffix(issuer, "/.well-known/openid-configuration")
issuer = strings.TrimSuffix(issuer, "/.well-known/oauth-authorization-server")
provider, err := legacyOIDCProviderCache.get(ctx, issuer)
if err != nil {
return nil, nil, err
}
verifier := provider.Verifier(&oidc.Config{ClientID: source.ClientID})
scopes := strings.Fields(source.Scopes)
if len(scopes) == 0 {
scopes = []string{oidc.ScopeOpenID, "profile", "email"}
}
if !containsScope(scopes, oidc.ScopeOpenID) {
scopes = append([]string{oidc.ScopeOpenID}, scopes...)
}
return &oauth2.Config{
ClientID: source.ClientID,
ClientSecret: source.ClientSecret,
RedirectURL: redirectURL,
Scopes: scopes,
Endpoint: provider.Endpoint(),
}, verifier, nil
}
func containsScope(scopes []string, scope string) bool {
for _, item := range scopes {
if item == scope {
return true
}
}
return false
}
func verifyIDToken(ctx context.Context, verifier *oidc.IDTokenVerifier, token *oauth2.Token, nonce string, userInfo *model.OAuthUserInfo) error {
rawIDToken, ok := token.Extra("id_token").(string)
if !ok {
return nil
}
idToken, verifyErr := verifier.Verify(ctx, rawIDToken)
if verifyErr != nil {
return fmt.Errorf("ID Token 验证失败: %w", verifyErr)
}
if nonce != "" && idToken.Nonce != nonce {
return errors.New("nonce 不匹配")
}
return idToken.Claims(userInfo)
}
@@ -0,0 +1,78 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package compat
import (
"github.com/Rain-kl/Wavelet/internal/apps/oauth"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/gin-gonic/gin"
)
const openFlareTokenHeader = "OpenFlare-Token"
// Role constants mirror legacy OpenFlare role values.
const (
RoleCommonUser = 1
RoleAdminUser = 10
RoleRootUser = 100
)
// RequireRole ensures the caller is authenticated with at least minRole.
// Phase 1: supports Wavelet session/access-token via oauth.GetUserFromRequest.
func RequireRole(minRole int) gin.HandlerFunc {
return func(c *gin.Context) {
user, err := oauth.GetUserFromRequest(c)
if err != nil || user == nil {
Unauthorized(c, "无权进行此操作,未登录或 token 无效")
c.Abort()
return
}
role := resolveRole(user)
if role < minRole {
Fail(c, "无权进行此操作,权限不足")
c.Abort()
return
}
c.Set("of_user_id", user.ID)
c.Set("of_role", role)
c.Set("of_is_admin", user.IsAdmin)
c.Next()
}
}
// UserAuth requires role >= CommonUser.
func UserAuth() gin.HandlerFunc { return RequireRole(RoleCommonUser) }
// AdminAuth requires role >= AdminUser.
func AdminAuth() gin.HandlerFunc { return RequireRole(RoleAdminUser) }
// RootAuth requires role >= RootUser.
func RootAuth() gin.HandlerFunc { return RequireRole(RoleRootUser) }
func resolveRole(user *model.User) int {
if user == nil {
return 0
}
if user.IsAdmin {
return RoleRootUser
}
return RoleCommonUser
}
// OpenFlareTokenHeader returns the legacy auth header name.
func OpenFlareTokenHeader() string {
return openFlareTokenHeader
}
// BridgeOpenFlareToken maps OpenFlare-Token to X-Access-Token for legacy clients.
func BridgeOpenFlareToken() gin.HandlerFunc {
return func(c *gin.Context) {
if c.GetHeader("X-Access-Token") == "" {
if token := c.GetHeader(openFlareTokenHeader); token != "" {
c.Request.Header.Set("X-Access-Token", token)
}
}
c.Next()
}
}
@@ -0,0 +1,34 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package compat
import (
"strconv"
"github.com/gin-gonic/gin"
)
// IDParam parses :id from the URL path.
func IDParam(c *gin.Context) (uint, bool) {
raw := c.Param("id")
if raw == "" {
Fail(c, "无效的 ID")
return 0, false
}
id64, err := strconv.ParseUint(raw, 10, 64)
if err != nil || id64 == 0 {
Fail(c, "无效的 ID")
return 0, false
}
return uint(id64), true
}
// BindJSON binds JSON body; returns false after writing a failure response.
func BindJSON(c *gin.Context, dst any) bool {
if err := c.ShouldBindJSON(dst); err != nil {
Fail(c, "参数错误")
return false
}
return true
}
@@ -0,0 +1,43 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package compat provides OpenFlare legacy API compatibility helpers.
package compat
import (
"net/http"
"github.com/gin-gonic/gin"
)
// Envelope is the legacy OpenFlare frontend response format.
type Envelope struct {
Success bool `json:"success"`
Message string `json:"message"`
Data any `json:"data"`
}
// OK sends a successful legacy response.
func OK(c *gin.Context, data any) {
c.JSON(http.StatusOK, Envelope{Success: true, Message: "", Data: data})
}
// OKMessage sends a successful legacy response with a message.
func OKMessage(c *gin.Context, message string) {
c.JSON(http.StatusOK, Envelope{Success: true, Message: message, Data: nil})
}
// Fail sends a failed legacy response with HTTP 200 (OpenFlare convention).
func Fail(c *gin.Context, message string) {
c.JSON(http.StatusOK, Envelope{Success: false, Message: message, Data: nil})
}
// Unauthorized sends a 401 legacy response.
func Unauthorized(c *gin.Context, message string) {
c.JSON(http.StatusUnauthorized, Envelope{Success: false, Message: message, Data: nil})
}
// Forbidden sends a 403 legacy response.
func Forbidden(c *gin.Context, message string) {
c.JSON(http.StatusForbidden, Envelope{Success: false, Message: message, Data: nil})
}
@@ -0,0 +1,12 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package config_version
const (
errNoActiveVersion = "当前没有激活版本"
errNoEnabledRoutes = "没有可发布的启用规则"
errNoChangesToPublish = "当前规则没有变更,不能重复发布"
errVersionConflict = "版本号生成冲突,请重试"
errInvalidSnapshotFormat = "历史版本快照格式不合法"
)
@@ -0,0 +1,352 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package config_version
import (
"context"
"encoding/json"
"fmt"
"net"
"strconv"
"strings"
"github.com/Rain-kl/Wavelet/internal/model"
)
type customHeaderInput struct {
Key string `json:"key"`
Value string `json:"value"`
}
func isUniqueConstraintError(err error) bool {
if err == nil {
return false
}
return strings.Contains(strings.ToLower(err.Error()), "unique")
}
func normalizeProxyRouteSiteName(route *model.ProxyRoute, raw, primaryDomain string) string {
siteName := strings.TrimSpace(raw)
if siteName != "" {
return siteName
}
if route != nil && strings.TrimSpace(route.SiteName) != "" {
return strings.TrimSpace(route.SiteName)
}
return primaryDomain
}
func normalizeProxyRouteDomains(rawDomains []string) ([]string, error) {
normalized := make([]string, 0, len(rawDomains))
seen := make(map[string]struct{}, len(rawDomains))
for _, rawDomain := range rawDomains {
domain := strings.ToLower(strings.TrimSpace(rawDomain))
if domain == "" {
continue
}
if strings.Contains(domain, "://") || strings.Contains(domain, "/") {
return nil, fmt.Errorf("domain %q is invalid", rawDomain)
}
if _, ok := seen[domain]; ok {
continue
}
seen[domain] = struct{}{}
normalized = append(normalized, domain)
}
if len(normalized) == 0 {
return nil, fmt.Errorf("domain is required")
}
return normalized, nil
}
func decodeStoredDomains(raw string, fallbackDomain string) ([]string, error) {
text := strings.TrimSpace(raw)
if text == "" {
return normalizeProxyRouteDomains([]string{fallbackDomain})
}
var domains []string
if err := json.Unmarshal([]byte(text), &domains); err != nil {
return nil, fmt.Errorf("domains payload is invalid")
}
return normalizeProxyRouteDomains(domains)
}
func decodeStoredUpstreams(raw string, fallbackOriginURL string) ([]string, error) {
text := strings.TrimSpace(raw)
if text == "" {
return normalizeUpstreams(fallbackOriginURL, nil)
}
var upstreams []string
if err := json.Unmarshal([]byte(text), &upstreams); err != nil {
return nil, fmt.Errorf("upstreams payload is invalid")
}
return normalizeUpstreams(fallbackOriginURL, upstreams)
}
func normalizeUpstreams(originURL string, upstreams []string) ([]string, error) {
candidates := upstreams
if len(candidates) == 0 {
candidates = []string{originURL}
}
normalized := make([]string, 0, len(candidates))
seen := make(map[string]struct{}, len(candidates))
for _, item := range candidates {
value := strings.TrimSpace(item)
if value == "" {
continue
}
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
normalized = append(normalized, value)
}
if len(normalized) == 0 {
return nil, fmt.Errorf("upstream is required")
}
return normalized, nil
}
func decodeStoredCustomHeaders(raw string) ([]customHeaderInput, error) {
text := strings.TrimSpace(raw)
if text == "" {
return []customHeaderInput{}, nil
}
var headers []customHeaderInput
if err := json.Unmarshal([]byte(text), &headers); err != nil {
return nil, fmt.Errorf("custom_headers payload is invalid")
}
return headers, nil
}
func decodeStoredCacheRules(raw string) ([]string, error) {
text := strings.TrimSpace(raw)
if text == "" {
return []string{}, nil
}
var rules []string
if err := json.Unmarshal([]byte(text), &rules); err != nil {
return nil, fmt.Errorf("cache_rules payload is invalid")
}
normalized := make([]string, 0, len(rules))
for _, rule := range rules {
item := strings.TrimSpace(rule)
if item == "" {
continue
}
normalized = append(normalized, item)
}
return normalized, nil
}
func decodeStoredCertIDs(raw string, fallbackCertID *uint) ([]uint, error) {
text := strings.TrimSpace(raw)
if text == "" {
if fallbackCertID == nil || *fallbackCertID == 0 {
return []uint{}, nil
}
return []uint{*fallbackCertID}, nil
}
var certIDs []uint
if err := json.Unmarshal([]byte(text), &certIDs); err != nil {
return nil, fmt.Errorf("cert_ids payload is invalid")
}
normalized := make([]uint, 0, len(certIDs))
seen := make(map[uint]struct{}, len(certIDs))
for _, certID := range certIDs {
if certID == 0 {
continue
}
if _, ok := seen[certID]; ok {
continue
}
seen[certID] = struct{}{}
normalized = append(normalized, certID)
}
if len(normalized) == 0 && fallbackCertID != nil && *fallbackCertID != 0 {
return []uint{*fallbackCertID}, nil
}
return normalized, nil
}
func resolveDomainCertIDs(domains []string, certIDs []uint, rawDomainCertIDs string) ([]uint, error) {
text := strings.TrimSpace(rawDomainCertIDs)
if text != "" {
var domainCertIDs []uint
if err := json.Unmarshal([]byte(text), &domainCertIDs); err != nil {
return nil, fmt.Errorf("domain_cert_ids payload is invalid")
}
if len(domains) > 0 && len(domainCertIDs) != len(domains) {
return nil, fmt.Errorf("domain_cert_ids length is invalid")
}
return domainCertIDs, nil
}
if len(certIDs) == 0 {
return []uint{}, nil
}
if len(certIDs) == 1 {
result := make([]uint, len(domains))
for index := range result {
result[index] = certIDs[0]
}
return result, nil
}
if len(certIDs) == len(domains) {
result := make([]uint, len(certIDs))
copy(result, certIDs)
return result, nil
}
return []uint{}, nil
}
func mustDecodeCertIDs(route *model.ProxyRoute) []uint {
if route == nil {
return []uint{}
}
certIDs, err := decodeStoredCertIDs(route.CertIDs, route.CertID)
if err != nil {
return []uint{}
}
return certIDs
}
func mustDecodeDomainCertIDs(route *model.ProxyRoute, domains []string) []uint {
if route == nil {
return []uint{}
}
certIDs, err := decodeStoredCertIDs(route.CertIDs, route.CertID)
if err != nil {
return []uint{}
}
domainCertIDs, err := resolveDomainCertIDs(domains, certIDs, route.DomainCertIDs)
if err != nil {
return []uint{}
}
return domainCertIDs
}
func normalizeUpstreamType(raw string) string {
value := strings.ToLower(strings.TrimSpace(raw))
switch value {
case "tunnel", "pages":
return value
default:
return "direct"
}
}
func normalizeTunnelTargetProtocol(raw string) string {
value := strings.ToLower(strings.TrimSpace(raw))
switch value {
case "http", "https", "tcp":
return value
default:
return "http"
}
}
func normalizePEM(content string) string {
return strings.TrimSpace(content) + "\n"
}
func certificateCertFileName(id uint) string {
return fmt.Sprintf("%d.crt", id)
}
func certificateKeyFileName(id uint) string {
return fmt.Sprintf("%d.key", id)
}
func dedupeSupportFiles(files []SupportFile) []SupportFile {
if len(files) == 0 {
return nil
}
unique := make(map[string]SupportFile, len(files))
for _, file := range files {
unique[file.Path] = file
}
result := make([]SupportFile, 0, len(unique))
for _, file := range unique {
result = append(result, file)
}
return result
}
func uintPtrEqual(left *uint, right *uint) bool {
if left == nil || right == nil {
return left == nil && right == nil
}
return *left == *right
}
func uintSliceEqual(left []uint, right []uint) bool {
if len(left) != len(right) {
return false
}
for index := range left {
if left[index] != right[index] {
return false
}
}
return true
}
func relayAgentAddress(node *model.OpenFlareNode) string {
if node == nil {
return ""
}
port := node.RelayVhostHTTPPort
if port <= 0 {
port = 8080
}
addr := strings.TrimSpace(node.RelayAgentAccessAddr)
if addr == "" {
addr = strings.TrimSpace(node.RelayClientAccessAddr)
}
if addr == "" {
addr = strings.TrimSpace(node.IP)
}
if addr == "" {
return fmt.Sprintf("127.0.0.1:%d", port)
}
if _, _, err := net.SplitHostPort(addr); err == nil {
return addr
}
if strings.Contains(addr, ":") && strings.Count(addr, ":") > 1 {
return net.JoinHostPort(addr, strconv.Itoa(port))
}
return fmt.Sprintf("%s:%d", addr, port)
}
func resolveTunnelOpenRestyUpstreamURL(ctx context.Context) string {
nodes, err := model.ListOpenFlareNodes(ctx)
if err == nil {
for index := range nodes {
node := &nodes[index]
if node.NodeType != "tunnel_relay" {
continue
}
addr := relayAgentAddress(node)
if addr != "" {
return "http://" + addr
}
}
}
return "http://127.0.0.1:8080"
}
func listWAFIPGroupsByIDs(ctx context.Context, ids []uint) ([]*model.OpenFlareWAFIPGroup, error) {
if len(ids) == 0 {
return []*model.OpenFlareWAFIPGroup{}, nil
}
groups := make([]*model.OpenFlareWAFIPGroup, 0, len(ids))
for _, id := range ids {
group, err := model.GetOpenFlareWAFIPGroupByID(ctx, id)
if err != nil {
return nil, err
}
groups = append(groups, group)
}
return groups, nil
}
@@ -0,0 +1,559 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package config_version
import (
"context"
"encoding/json"
"errors"
"fmt"
"sort"
"strconv"
"strings"
"time"
"github.com/Rain-kl/Wavelet/internal/model"
"gorm.io/gorm"
)
// ConfigPreviewResult is the preview response payload.
type ConfigPreviewResult struct {
SnapshotJSON string `json:"snapshot_json"`
MainConfig string `json:"main_config"`
RouteConfig string `json:"route_config"`
RenderedConfig string `json:"rendered_config"`
SupportFiles []SupportFile `json:"support_files"`
Checksum string `json:"checksum"`
RouteCount int `json:"route_count"`
WebsiteCount int `json:"website_count"`
}
// ConfigDiffResult is the diff response payload.
type ConfigDiffResult struct {
ActiveVersion string `json:"active_version,omitempty"`
AddedSites []string `json:"added_sites"`
RemovedSites []string `json:"removed_sites"`
ModifiedSites []string `json:"modified_sites"`
AddedDomains []string `json:"added_domains"`
RemovedDomains []string `json:"removed_domains"`
ModifiedDomains []string `json:"modified_domains"`
MainConfigChanged bool `json:"main_config_changed"`
WAFConfigChanged bool `json:"waf_config_changed"`
ChangedOptionKeys []string `json:"changed_option_keys"`
ChangedOptionDetails []ConfigOptionDiffItem `json:"changed_option_details"`
CurrentWebsiteCount int `json:"current_website_count"`
ActiveWebsiteCount int `json:"active_website_count"`
}
// ConfigOptionDiffItem describes a changed OpenResty option.
type ConfigOptionDiffItem struct {
Key string `json:"key"`
PreviousValue string `json:"previous_value"`
CurrentValue string `json:"current_value"`
}
// CleanupInput is the cleanup request payload.
type CleanupInput struct {
KeepCount int `json:"keep_count"`
}
// CleanupResult is the cleanup response payload.
type CleanupResult struct {
DeletedCount int64 `json:"deleted_count"`
Message string `json:"message"`
}
// ListConfigVersions returns all config version summaries.
func ListConfigVersions(ctx context.Context) ([]*model.ConfigVersionSummary, error) {
return model.ListConfigVersionSummaries(ctx)
}
// GetConfigVersionDetail returns a config version by id.
func GetConfigVersionDetail(ctx context.Context, id uint) (*model.ConfigVersion, error) {
return model.GetConfigVersionByID(ctx, id)
}
// GetActiveConfigVersion returns the active config version.
func GetActiveConfigVersion(ctx context.Context) (*model.ConfigVersion, error) {
return model.GetActiveConfigVersion(ctx)
}
// PreviewConfigVersion renders the current draft configuration.
func PreviewConfigVersion(ctx context.Context) (*ConfigPreviewResult, error) {
bundle, err := buildCurrentConfigBundle(ctx, false)
if err != nil {
return nil, err
}
return &ConfigPreviewResult{
SnapshotJSON: bundle.SnapshotJSON,
MainConfig: bundle.MainConfig,
RouteConfig: bundle.RouteConfig,
RenderedConfig: bundle.RouteConfig,
SupportFiles: bundle.SupportFiles,
Checksum: bundle.Checksum,
RouteCount: len(bundle.Routes),
WebsiteCount: len(bundle.SnapshotRoutes),
}, nil
}
// DiffConfigVersion compares the current draft against the active version.
func DiffConfigVersion(ctx context.Context) (*ConfigDiffResult, error) {
bundle, err := buildCurrentConfigBundle(ctx, false)
if err != nil {
return nil, err
}
result := &ConfigDiffResult{
AddedSites: []string{},
RemovedSites: []string{},
ModifiedSites: []string{},
AddedDomains: []string{},
RemovedDomains: []string{},
ModifiedDomains: []string{},
ChangedOptionKeys: []string{},
ChangedOptionDetails: []ConfigOptionDiffItem{},
CurrentWebsiteCount: len(bundle.SnapshotRoutes),
}
activeVersion, err := model.GetActiveConfigVersion(ctx)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
for _, route := range bundle.SnapshotRoutes {
result.AddedSites = append(result.AddedSites, route.SiteName)
result.AddedDomains = append(result.AddedDomains, route.Domains...)
}
result.MainConfigChanged = true
result.ChangedOptionKeys = openRestyOptionKeys()
result.ChangedOptionDetails = buildInitialOpenRestyOptionDiffs(bundle.OpenRestyConfig)
sort.Strings(result.AddedSites)
sort.Strings(result.AddedDomains)
sort.Strings(result.ChangedOptionKeys)
return result, nil
}
return nil, err
}
result.ActiveVersion = activeVersion.Version
activeSnapshot, err := parseSnapshotDocument(activeVersion.SnapshotJSON)
if err != nil {
return nil, err
}
result.ActiveWebsiteCount = len(activeSnapshot.Routes)
currentSiteMap := flattenSnapshotRoutesBySite(bundle.SnapshotRoutes)
activeSiteMap := flattenSnapshotRoutesBySite(activeSnapshot.Routes)
for siteName, currentRoute := range currentSiteMap {
activeRoute, ok := activeSiteMap[siteName]
if !ok {
result.AddedSites = append(result.AddedSites, siteName)
continue
}
if !snapshotRouteConfigEqual(activeRoute, currentRoute) {
result.ModifiedSites = append(result.ModifiedSites, siteName)
}
}
for siteName := range activeSiteMap {
if _, ok := currentSiteMap[siteName]; !ok {
result.RemovedSites = append(result.RemovedSites, siteName)
}
}
currentMap := flattenSnapshotRoutesByDomain(bundle.SnapshotRoutes)
activeMap := flattenSnapshotRoutesByDomain(activeSnapshot.Routes)
for domain, currentRoute := range currentMap {
activeRoute, ok := activeMap[domain]
if !ok {
result.AddedDomains = append(result.AddedDomains, domain)
continue
}
if !snapshotRouteConfigEqual(activeRoute, currentRoute) {
result.ModifiedDomains = append(result.ModifiedDomains, domain)
}
}
for domain := range activeMap {
if _, ok := currentMap[domain]; !ok {
result.RemovedDomains = append(result.RemovedDomains, domain)
}
}
result.MainConfigChanged = activeVersion.MainConfig != bundle.MainConfig
result.WAFConfigChanged = !snapshotWAFConfigEqual(activeSnapshot.WAF, bundle.WAFSnapshot)
result.ChangedOptionDetails = diffOpenRestyOptionDetails(activeSnapshot.OpenRestyConfig, bundle.OpenRestyConfig)
result.ChangedOptionKeys = extractOptionDiffKeys(result.ChangedOptionDetails)
sort.Strings(result.AddedSites)
sort.Strings(result.RemovedSites)
sort.Strings(result.ModifiedSites)
sort.Strings(result.AddedDomains)
sort.Strings(result.RemovedDomains)
sort.Strings(result.ModifiedDomains)
sort.Strings(result.ChangedOptionKeys)
return result, nil
}
// PublishConfigVersion publishes the current draft as a new active version.
func PublishConfigVersion(ctx context.Context, createdBy string, force bool) (*model.ConfigVersion, error) {
bundle, err := buildCurrentConfigBundle(ctx, true)
if err != nil {
return nil, err
}
if len(bundle.Routes) == 0 {
return nil, errors.New(errNoEnabledRoutes)
}
activeVersion, err := model.GetActiveConfigVersion(ctx)
if !force && err == nil && activeVersion.Checksum == bundle.Checksum {
return nil, errors.New(errNoChangesToPublish)
}
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
supportFilesJSON, err := json.Marshal(bundle.SupportFiles)
if err != nil {
return nil, err
}
version, err := nextVersionNumber(ctx, time.Now())
if err != nil {
return nil, err
}
record := &model.ConfigVersion{
Version: version,
SnapshotJSON: bundle.SnapshotJSON,
MainConfig: bundle.MainConfig,
RenderedConfig: bundle.RouteConfig,
SupportFilesJSON: string(supportFilesJSON),
Checksum: bundle.Checksum,
IsActive: true,
CreatedBy: createdBy,
}
if err = model.PublishConfigVersionTx(ctx, record); err != nil {
if isUniqueConstraintError(err) {
return nil, errors.New(errVersionConflict)
}
return nil, err
}
return record, nil
}
// ActivateConfigVersion activates an existing config version.
func ActivateConfigVersion(ctx context.Context, id uint) (*model.ConfigVersion, error) {
version, err := model.GetConfigVersionByID(ctx, id)
if err != nil {
return nil, err
}
if err = model.ActivateConfigVersionTx(ctx, id); err != nil {
return nil, err
}
version.IsActive = true
return version, nil
}
// CleanupConfigVersions removes old inactive config versions.
func CleanupConfigVersions(ctx context.Context, keepCount int) (*CleanupResult, error) {
if keepCount < 3 {
keepCount = 3
}
versions, err := model.ListConfigVersionSummaries(ctx)
if err != nil {
return nil, err
}
if len(versions) <= keepCount {
return &CleanupResult{DeletedCount: 0, Message: "清理成功"}, nil
}
var deleteIDs []uint
for index, version := range versions {
if index < keepCount {
continue
}
if version.IsActive {
continue
}
deleteIDs = append(deleteIDs, version.ID)
}
if len(deleteIDs) == 0 {
return &CleanupResult{DeletedCount: 0, Message: "清理成功"}, nil
}
deletedCount, err := model.DeleteConfigVersionsByIDs(ctx, deleteIDs)
if err != nil {
return nil, err
}
return &CleanupResult{DeletedCount: deletedCount, Message: "清理成功"}, nil
}
func nextVersionNumber(ctx context.Context, now time.Time) (string, error) {
prefix := now.Format("20060102")
latest, err := model.GetLatestConfigVersionByPrefix(ctx, prefix)
if errors.Is(err, gorm.ErrRecordNotFound) {
return fmt.Sprintf("%s-%03d", prefix, 1), nil
}
if err != nil {
return "", err
}
suffix := strings.TrimPrefix(latest, prefix+"-")
sequence, err := strconv.Atoi(suffix)
if err != nil {
return "", fmt.Errorf("invalid config version sequence %q: %w", latest, err)
}
return fmt.Sprintf("%s-%03d", prefix, sequence+1), nil
}
func parseSnapshotDocument(snapshotJSON string) (*snapshotDocument, error) {
text := strings.TrimSpace(snapshotJSON)
if text == "" {
return &snapshotDocument{Routes: []snapshotRoute{}}, nil
}
if strings.HasPrefix(text, "[") {
var routes []snapshotRoute
if err := json.Unmarshal([]byte(text), &routes); err != nil {
return nil, errors.New(errInvalidSnapshotFormat)
}
return &snapshotDocument{Routes: normalizeSnapshotRoutes(routes)}, nil
}
var snapshot snapshotDocument
if err := json.Unmarshal([]byte(text), &snapshot); err != nil {
return nil, errors.New(errInvalidSnapshotFormat)
}
snapshot.Routes = normalizeSnapshotRoutes(snapshot.Routes)
return &snapshot, nil
}
func normalizeSnapshotRoutes(routes []snapshotRoute) []snapshotRoute {
if len(routes) == 0 {
return []snapshotRoute{}
}
for index := range routes {
normalizedDomains, err := decodeStoredDomains("", routes[index].Domain)
if len(routes[index].Domains) > 0 {
normalizedDomains, err = normalizeProxyRouteDomains(routes[index].Domains)
}
if err == nil && len(normalizedDomains) > 0 {
routes[index].Domains = normalizedDomains
routes[index].Domain = normalizedDomains[0]
routes[index].SiteName = normalizeProxyRouteSiteName(nil, routes[index].SiteName, normalizedDomains[0])
}
normalizedCertIDs, primaryCertID, certErr := normalizeSnapshotCertificateIDs(routes[index].CertID, routes[index].CertIDs)
if certErr == nil {
routes[index].CertID = primaryCertID
routes[index].CertIDs = normalizedCertIDs
}
normalizedDomainCertIDs, domainCertErr := resolveDomainCertIDs(routes[index].Domains, routes[index].CertIDs, "")
if domainCertErr == nil && len(routes[index].DomainCertIDs) == 0 {
routes[index].DomainCertIDs = normalizedDomainCertIDs
}
normalizedUpstreams, upstreamErr := normalizeUpstreams(routes[index].OriginURL, routes[index].Upstreams)
if upstreamErr == nil {
routes[index].OriginURL = normalizedUpstreams[0]
routes[index].Upstreams = normalizedUpstreams
}
if !routes[index].BasicAuthEnabled {
routes[index].BasicAuthUsername = ""
routes[index].BasicAuthPassword = ""
}
routes[index].UpstreamType = normalizeUpstreamType(routes[index].UpstreamType)
}
return routes
}
func flattenSnapshotRoutesBySite(routes []snapshotRoute) map[string]snapshotRoute {
siteMap := make(map[string]snapshotRoute)
for _, route := range normalizeSnapshotRoutes(routes) {
siteMap[route.SiteName] = route
}
return siteMap
}
func flattenSnapshotRoutesByDomain(routes []snapshotRoute) map[string]snapshotRoute {
domainMap := make(map[string]snapshotRoute)
for _, route := range normalizeSnapshotRoutes(routes) {
for _, domain := range route.Domains {
item := route
item.Domain = domain
domainMap[domain] = item
}
}
return domainMap
}
func snapshotRouteConfigEqual(left snapshotRoute, right snapshotRoute) bool {
if left.SiteName != right.SiteName || left.Domain != right.Domain || left.OriginURL != right.OriginURL ||
left.OriginHost != right.OriginHost || left.EnableHTTPS != right.EnableHTTPS || left.RedirectHTTP != right.RedirectHTTP ||
left.LimitConnPerServer != right.LimitConnPerServer || left.LimitConnPerIP != right.LimitConnPerIP ||
left.LimitRate != right.LimitRate || left.CacheEnabled != right.CacheEnabled || left.CachePolicy != right.CachePolicy ||
left.BasicAuthEnabled != right.BasicAuthEnabled || left.BasicAuthUsername != right.BasicAuthUsername ||
left.BasicAuthPassword != right.BasicAuthPassword || left.UpstreamType != right.UpstreamType ||
!uintPtrEqual(left.TunnelNodeID, right.TunnelNodeID) || left.TunnelTargetAddr != right.TunnelTargetAddr ||
left.TunnelTargetProto != right.TunnelTargetProto || !uintPtrEqual(left.PagesProjectID, right.PagesProjectID) ||
!uintSliceEqual(left.CertIDs, right.CertIDs) || !uintSliceEqual(left.DomainCertIDs, right.DomainCertIDs) {
return false
}
if len(left.Domains) != len(right.Domains) {
return false
}
for index := range left.Domains {
if left.Domains[index] != right.Domains[index] {
return false
}
}
if len(left.Upstreams) != len(right.Upstreams) {
return false
}
for index := range left.Upstreams {
if left.Upstreams[index] != right.Upstreams[index] {
return false
}
}
if len(left.CacheRules) != len(right.CacheRules) {
return false
}
for index := range left.CacheRules {
if left.CacheRules[index] != right.CacheRules[index] {
return false
}
}
if len(left.CustomHeaders) != len(right.CustomHeaders) {
return false
}
for index := range left.CustomHeaders {
if left.CustomHeaders[index] != right.CustomHeaders[index] {
return false
}
}
return true
}
func snapshotWAFConfigEqual(left snapshotWAFDocument, right snapshotWAFDocument) bool {
leftJSON, err := json.Marshal(left)
if err != nil {
return false
}
rightJSON, err := json.Marshal(right)
if err != nil {
return false
}
return string(leftJSON) == string(rightJSON)
}
func normalizeSnapshotCertificateIDs(primaryCertID *uint, certIDs []uint) ([]uint, *uint, error) {
candidates := make([]uint, 0, len(certIDs)+1)
if primaryCertID != nil && *primaryCertID != 0 {
candidates = append(candidates, *primaryCertID)
}
candidates = append(candidates, certIDs...)
normalized := make([]uint, 0, len(candidates))
seen := make(map[uint]struct{}, len(candidates))
for _, certID := range candidates {
if certID == 0 {
continue
}
if _, ok := seen[certID]; ok {
continue
}
seen[certID] = struct{}{}
normalized = append(normalized, certID)
}
var normalizedPrimary *uint
if len(normalized) > 0 {
normalizedPrimary = &normalized[0]
}
return normalized, normalizedPrimary, nil
}
func buildInitialOpenRestyOptionDiffs(current openRestyConfigSnapshot) []ConfigOptionDiffItem {
details := diffOpenRestyOptionDetails(openRestyConfigSnapshot{}, current)
for index := range details {
details[index].PreviousValue = ""
}
return details
}
func diffOpenRestyOptionDetails(left openRestyConfigSnapshot, right openRestyConfigSnapshot) []ConfigOptionDiffItem {
changes := make([]ConfigOptionDiffItem, 0)
appendIfChanged := func(key string, previous string, current string) {
if previous == current {
return
}
changes = append(changes, ConfigOptionDiffItem{
Key: key,
PreviousValue: previous,
CurrentValue: current,
})
}
appendIfChanged("OpenRestyDefaultServerReturnStatus", fmt.Sprintf("%d", left.DefaultServerReturnStatus), fmt.Sprintf("%d", right.DefaultServerReturnStatus))
appendIfChanged("OpenRestyWorkerProcesses", left.WorkerProcesses, right.WorkerProcesses)
appendIfChanged("OpenRestyWorkerConnections", fmt.Sprintf("%d", left.WorkerConnections), fmt.Sprintf("%d", right.WorkerConnections))
appendIfChanged("OpenRestyWorkerRlimitNofile", fmt.Sprintf("%d", left.WorkerRlimitNofile), fmt.Sprintf("%d", right.WorkerRlimitNofile))
appendIfChanged("OpenRestyEventsUse", left.EventsUse, right.EventsUse)
appendIfChanged("OpenRestyEventsMultiAcceptEnabled", fmt.Sprintf("%t", left.EventsMultiAcceptEnabled), fmt.Sprintf("%t", right.EventsMultiAcceptEnabled))
appendIfChanged("OpenRestyKeepaliveTimeout", fmt.Sprintf("%d", left.KeepaliveTimeout), fmt.Sprintf("%d", right.KeepaliveTimeout))
appendIfChanged("OpenRestyKeepaliveRequests", fmt.Sprintf("%d", left.KeepaliveRequests), fmt.Sprintf("%d", right.KeepaliveRequests))
appendIfChanged("OpenRestyClientHeaderTimeout", fmt.Sprintf("%d", left.ClientHeaderTimeout), fmt.Sprintf("%d", right.ClientHeaderTimeout))
appendIfChanged("OpenRestyClientBodyTimeout", fmt.Sprintf("%d", left.ClientBodyTimeout), fmt.Sprintf("%d", right.ClientBodyTimeout))
appendIfChanged("OpenRestyClientMaxBodySize", left.ClientMaxBodySize, right.ClientMaxBodySize)
appendIfChanged("OpenRestyLargeClientHeaderBuffers", left.LargeClientHeaderBuffers, right.LargeClientHeaderBuffers)
appendIfChanged("OpenRestySendTimeout", fmt.Sprintf("%d", left.SendTimeout), fmt.Sprintf("%d", right.SendTimeout))
appendIfChanged("OpenRestyProxyConnectTimeout", fmt.Sprintf("%d", left.ProxyConnectTimeout), fmt.Sprintf("%d", right.ProxyConnectTimeout))
appendIfChanged("OpenRestyProxySendTimeout", fmt.Sprintf("%d", left.ProxySendTimeout), fmt.Sprintf("%d", right.ProxySendTimeout))
appendIfChanged("OpenRestyProxyReadTimeout", fmt.Sprintf("%d", left.ProxyReadTimeout), fmt.Sprintf("%d", right.ProxyReadTimeout))
appendIfChanged("OpenRestyWebsocketEnabled", fmt.Sprintf("%t", left.WebsocketEnabled), fmt.Sprintf("%t", right.WebsocketEnabled))
appendIfChanged("OpenRestyHTTP3Enabled", fmt.Sprintf("%t", left.HTTP3Enabled), fmt.Sprintf("%t", right.HTTP3Enabled))
appendIfChanged("OpenRestyProxyRequestBufferingEnabled", fmt.Sprintf("%t", left.ProxyRequestBuffering), fmt.Sprintf("%t", right.ProxyRequestBuffering))
appendIfChanged("OpenRestyProxyBufferingEnabled", fmt.Sprintf("%t", left.ProxyBufferingEnabled), fmt.Sprintf("%t", right.ProxyBufferingEnabled))
appendIfChanged("OpenRestyProxyBuffers", left.ProxyBuffers, right.ProxyBuffers)
appendIfChanged("OpenRestyProxyBufferSize", left.ProxyBufferSize, right.ProxyBufferSize)
appendIfChanged("OpenRestyProxyBusyBuffersSize", left.ProxyBusyBuffersSize, right.ProxyBusyBuffersSize)
appendIfChanged("OpenRestyGzipEnabled", fmt.Sprintf("%t", left.GzipEnabled), fmt.Sprintf("%t", right.GzipEnabled))
appendIfChanged("OpenRestyGzipMinLength", fmt.Sprintf("%d", left.GzipMinLength), fmt.Sprintf("%d", right.GzipMinLength))
appendIfChanged("OpenRestyGzipCompLevel", fmt.Sprintf("%d", left.GzipCompLevel), fmt.Sprintf("%d", right.GzipCompLevel))
appendIfChanged("OpenRestyResolvers", left.Resolvers, right.Resolvers)
appendIfChanged("OpenRestyCacheEnabled", fmt.Sprintf("%t", left.CacheEnabled), fmt.Sprintf("%t", right.CacheEnabled))
appendIfChanged("OpenRestyCachePath", left.CachePath, right.CachePath)
appendIfChanged("OpenRestyCacheLevels", left.CacheLevels, right.CacheLevels)
appendIfChanged("OpenRestyCacheInactive", left.CacheInactive, right.CacheInactive)
appendIfChanged("OpenRestyCacheMaxSize", left.CacheMaxSize, right.CacheMaxSize)
appendIfChanged("OpenRestyCacheKeyTemplate", left.CacheKeyTemplate, right.CacheKeyTemplate)
appendIfChanged("OpenRestyCacheLockEnabled", fmt.Sprintf("%t", left.CacheLockEnabled), fmt.Sprintf("%t", right.CacheLockEnabled))
appendIfChanged("OpenRestyCacheLockTimeout", left.CacheLockTimeout, right.CacheLockTimeout)
appendIfChanged("OpenRestyCacheUseStale", left.CacheUseStale, right.CacheUseStale)
return changes
}
func extractOptionDiffKeys(details []ConfigOptionDiffItem) []string {
keys := make([]string, 0, len(details))
for _, item := range details {
keys = append(keys, item.Key)
}
return keys
}
func openRestyOptionKeys() []string {
return []string{
"OpenRestyDefaultServerReturnStatus",
"OpenRestyWorkerProcesses",
"OpenRestyWorkerConnections",
"OpenRestyWorkerRlimitNofile",
"OpenRestyEventsUse",
"OpenRestyEventsMultiAcceptEnabled",
"OpenRestyKeepaliveTimeout",
"OpenRestyKeepaliveRequests",
"OpenRestyClientHeaderTimeout",
"OpenRestyClientBodyTimeout",
"OpenRestyClientMaxBodySize",
"OpenRestyLargeClientHeaderBuffers",
"OpenRestySendTimeout",
"OpenRestyProxyConnectTimeout",
"OpenRestyProxySendTimeout",
"OpenRestyProxyReadTimeout",
"OpenRestyWebsocketEnabled",
"OpenRestyHTTP3Enabled",
"OpenRestyProxyRequestBufferingEnabled",
"OpenRestyProxyBufferingEnabled",
"OpenRestyProxyBuffers",
"OpenRestyProxyBufferSize",
"OpenRestyProxyBusyBuffersSize",
"OpenRestyGzipEnabled",
"OpenRestyGzipMinLength",
"OpenRestyGzipCompLevel",
"OpenRestyCacheEnabled",
"OpenRestyCachePath",
"OpenRestyCacheLevels",
"OpenRestyCacheInactive",
"OpenRestyCacheMaxSize",
"OpenRestyCacheKeyTemplate",
"OpenRestyCacheLockEnabled",
"OpenRestyCacheLockTimeout",
"OpenRestyCacheUseStale",
}
}
@@ -0,0 +1,83 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package config_version
import (
"context"
"encoding/json"
"testing"
"github.com/Rain-kl/Wavelet/internal/db"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func setupConfigVersionTestDB(t *testing.T) func() {
t.Helper()
sqliteDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
DisableForeignKeyConstraintWhenMigrating: true,
})
require.NoError(t, err)
require.NoError(t, sqliteDB.AutoMigrate(
&model.ProxyRoute{},
&model.ConfigVersion{},
&model.OpenFlareWAFRuleGroup{},
&model.OpenFlareWAFRuleGroupBinding{},
&model.OpenFlareWAFIPGroup{},
))
db.SetDB(sqliteDB)
return func() {
db.SetDB(nil)
}
}
func TestPublishConfigVersionCreatesVersion(t *testing.T) {
cleanup := setupConfigVersionTestDB(t)
defer cleanup()
ctx := context.Background()
route := &model.ProxyRoute{
SiteName: "publish-site",
Domain: "publish.example.com",
Domains: `["publish.example.com"]`,
OriginURL: "http://origin.publish.example.com:8080",
Upstreams: `["http://origin.publish.example.com:8080"]`,
Enabled: true,
}
require.NoError(t, model.CreateProxyRouteRecord(ctx, route))
version, err := PublishConfigVersion(ctx, "tester", false)
require.NoError(t, err)
require.NotNil(t, version)
assert.NotZero(t, version.ID)
assert.True(t, version.IsActive)
assert.Equal(t, "tester", version.CreatedBy)
assert.NotEmpty(t, version.Version)
assert.NotEmpty(t, version.Checksum)
assert.NotEmpty(t, version.SnapshotJSON)
assert.NotEmpty(t, version.RenderedConfig)
var snapshot snapshotDocument
require.NoError(t, json.Unmarshal([]byte(version.SnapshotJSON), &snapshot))
require.Len(t, snapshot.Routes, 1)
assert.Equal(t, "publish-site", snapshot.Routes[0].SiteName)
assert.Equal(t, "publish.example.com", snapshot.Routes[0].Domain)
active, err := GetActiveConfigVersion(ctx)
require.NoError(t, err)
assert.Equal(t, version.ID, active.ID)
_, err = PublishConfigVersion(ctx, "tester", false)
require.Error(t, err)
assert.Contains(t, err.Error(), errNoChangesToPublish)
forced, err := PublishConfigVersion(ctx, "tester", true)
require.NoError(t, err)
assert.NotEqual(t, version.ID, forced.ID)
}
@@ -0,0 +1,53 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package config_version
import (
openrestyrender "github.com/rain-kl/openflare/pkg/render/openresty"
)
// SupportFile is a rendered configuration support artifact.
type SupportFile struct {
Path string `json:"path"`
Content string `json:"content"`
}
func renderSnapshotConfig(sourceJSON string, certificateFiles []SupportFile) (*openrestyrender.Result, error) {
return openrestyrender.RenderJSON(sourceJSON, toOpenRestySupportFiles(certificateFiles))
}
func toOpenRestySupportFiles(files []SupportFile) []openrestyrender.SupportFile {
if len(files) == 0 {
return nil
}
result := make([]openrestyrender.SupportFile, 0, len(files))
for _, file := range files {
result = append(result, openrestyrender.SupportFile{
Path: file.Path,
Content: file.Content,
})
}
return result
}
func fromOpenRestySupportFiles(files []openrestyrender.SupportFile) []SupportFile {
if len(files) == 0 {
return nil
}
result := make([]SupportFile, 0, len(files))
for _, file := range files {
result = append(result, SupportFile{
Path: file.Path,
Content: file.Content,
})
}
return result
}
func renderPlaceholderConfig(snapshotJSON string) (mainConfig, routeConfig, checksum string) {
mainConfig = `{"placeholder":"main_config"}`
routeConfig = snapshotJSON
checksum = openrestyrender.ChecksumBundle(mainConfig, routeConfig, nil)
return mainConfig, routeConfig, checksum
}
@@ -0,0 +1,115 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package config_version
import (
"errors"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/compat"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
func handleLogicError(c *gin.Context, err error) bool {
if err == nil {
return false
}
if errors.Is(err, gorm.ErrRecordNotFound) {
compat.Fail(c, "记录不存在")
return true
}
compat.Fail(c, err.Error())
return true
}
// ListConfigVersionsHandler lists config versions.
func ListConfigVersionsHandler(c *gin.Context) {
versions, err := ListConfigVersions(c.Request.Context())
if handleLogicError(c, err) {
return
}
compat.OK(c, versions)
}
// GetConfigVersionHandler returns a config version by id.
func GetConfigVersionHandler(c *gin.Context) {
id, ok := compat.IDParam(c)
if !ok {
return
}
version, err := GetConfigVersionDetail(c.Request.Context(), id)
if handleLogicError(c, err) {
return
}
compat.OK(c, version)
}
// GetActiveConfigVersionHandler returns the active config version.
func GetActiveConfigVersionHandler(c *gin.Context) {
version, err := GetActiveConfigVersion(c.Request.Context())
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
compat.Fail(c, errNoActiveVersion)
return
}
handleLogicError(c, err)
return
}
compat.OK(c, version)
}
// PreviewConfigVersionHandler previews the current draft configuration.
func PreviewConfigVersionHandler(c *gin.Context) {
preview, err := PreviewConfigVersion(c.Request.Context())
if handleLogicError(c, err) {
return
}
compat.OK(c, preview)
}
// DiffConfigVersionHandler diffs the current draft against the active version.
func DiffConfigVersionHandler(c *gin.Context) {
diff, err := DiffConfigVersion(c.Request.Context())
if handleLogicError(c, err) {
return
}
compat.OK(c, diff)
}
// PublishConfigVersionHandler publishes a new config version.
func PublishConfigVersionHandler(c *gin.Context) {
username := c.GetString("username")
force := c.Query("force") == "true"
version, err := PublishConfigVersion(c.Request.Context(), username, force)
if handleLogicError(c, err) {
return
}
compat.OK(c, version)
}
// ActivateConfigVersionHandler activates an existing config version.
func ActivateConfigVersionHandler(c *gin.Context) {
id, ok := compat.IDParam(c)
if !ok {
return
}
version, err := ActivateConfigVersion(c.Request.Context(), id)
if handleLogicError(c, err) {
return
}
compat.OK(c, version)
}
// CleanupConfigVersionsHandler removes old inactive config versions.
func CleanupConfigVersionsHandler(c *gin.Context) {
var input CleanupInput
if !compat.BindJSON(c, &input) {
return
}
result, err := CleanupConfigVersions(c.Request.Context(), input.KeepCount)
if handleLogicError(c, err) {
return
}
compat.OK(c, result)
}
@@ -0,0 +1,510 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package config_version
import (
"context"
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/waf"
"github.com/Rain-kl/Wavelet/internal/model"
openrestyrender "github.com/rain-kl/openflare/pkg/render/openresty"
)
type snapshotRoute struct {
ID uint `json:"id,omitempty"`
SiteName string `json:"site_name,omitempty"`
Domain string `json:"domain"`
Domains []string `json:"domains,omitempty"`
OriginURL string `json:"origin_url"`
OriginHost string `json:"origin_host,omitempty"`
Upstreams []string `json:"upstreams,omitempty"`
Enabled bool `json:"enabled"`
EnableHTTPS bool `json:"enable_https"`
CertID *uint `json:"cert_id,omitempty"`
CertIDs []uint `json:"cert_ids,omitempty"`
DomainCertIDs []uint `json:"domain_cert_ids,omitempty"`
RedirectHTTP bool `json:"redirect_http"`
LimitConnPerServer int `json:"limit_conn_per_server,omitempty"`
LimitConnPerIP int `json:"limit_conn_per_ip,omitempty"`
LimitRate string `json:"limit_rate,omitempty"`
CacheEnabled bool `json:"cache_enabled"`
CachePolicy string `json:"cache_policy,omitempty"`
CacheRules []string `json:"cache_rules,omitempty"`
CustomHeaders []customHeaderInput `json:"custom_headers,omitempty"`
BasicAuthEnabled bool `json:"basic_auth_enabled,omitempty"`
BasicAuthUsername string `json:"basic_auth_username,omitempty"`
BasicAuthPassword string `json:"basic_auth_password,omitempty"`
Remark string `json:"remark,omitempty"`
UpstreamType string `json:"upstream_type,omitempty"`
TunnelNodeID *uint `json:"tunnel_node_id,omitempty"`
TunnelTargetAddr string `json:"tunnel_target_addr,omitempty"`
TunnelTargetProto string `json:"tunnel_target_protocol,omitempty"`
PagesProjectID *uint `json:"pages_project_id,omitempty"`
}
type snapshotWAFRuleGroup struct {
ID uint `json:"id"`
Name string `json:"name"`
Enabled bool `json:"enabled"`
IsGlobal bool `json:"is_global"`
BlockStatusCode int `json:"block_status_code"`
BlockResponseBody string `json:"block_response_body,omitempty"`
IPWhitelist []string `json:"ip_whitelist,omitempty"`
IPBlacklist []string `json:"ip_blacklist,omitempty"`
IPWhitelistGroups []uint `json:"ip_whitelist_group_ids,omitempty"`
IPBlacklistGroups []uint `json:"ip_blacklist_group_ids,omitempty"`
CountryWhitelist []string `json:"country_whitelist,omitempty"`
CountryBlacklist []string `json:"country_blacklist,omitempty"`
RegionWhitelist []string `json:"region_whitelist,omitempty"`
RegionBlacklist []string `json:"region_blacklist,omitempty"`
PoWEnabled bool `json:"pow_enabled,omitempty"`
PoWConfig *openrestyrender.PoWConfig `json:"pow_config,omitempty"`
}
type snapshotWAFIPGroup struct {
ID uint `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Enabled bool `json:"enabled"`
IPList []string `json:"ip_list,omitempty"`
}
type snapshotWAFBinding struct {
RouteID uint `json:"route_id"`
SiteName string `json:"site_name"`
RuleGroupIDs []uint `json:"rule_group_ids"`
}
type snapshotWAFDocument struct {
RuleGroups []snapshotWAFRuleGroup `json:"rule_groups"`
IPGroups []snapshotWAFIPGroup `json:"ip_groups,omitempty"`
Bindings []snapshotWAFBinding `json:"bindings"`
}
type openRestyConfigSnapshot struct {
DefaultServerReturnStatus int `json:"default_server_return_status"`
WorkerProcesses string `json:"worker_processes"`
WorkerConnections int `json:"worker_connections"`
WorkerRlimitNofile int `json:"worker_rlimit_nofile"`
EventsUse string `json:"events_use,omitempty"`
EventsMultiAcceptEnabled bool `json:"events_multi_accept_enabled"`
KeepaliveTimeout int `json:"keepalive_timeout"`
KeepaliveRequests int `json:"keepalive_requests"`
ClientHeaderTimeout int `json:"client_header_timeout"`
ClientBodyTimeout int `json:"client_body_timeout"`
ClientMaxBodySize string `json:"client_max_body_size"`
LargeClientHeaderBuffers string `json:"large_client_header_buffers"`
SendTimeout int `json:"send_timeout"`
ProxyConnectTimeout int `json:"proxy_connect_timeout"`
ProxySendTimeout int `json:"proxy_send_timeout"`
ProxyReadTimeout int `json:"proxy_read_timeout"`
WebsocketEnabled bool `json:"websocket_enabled"`
HTTP3Enabled bool `json:"http3_enabled"`
ProxyRequestBuffering bool `json:"proxy_request_buffering"`
ProxyBufferingEnabled bool `json:"proxy_buffering_enabled"`
ProxyBuffers string `json:"proxy_buffers"`
ProxyBufferSize string `json:"proxy_buffer_size"`
ProxyBusyBuffersSize string `json:"proxy_busy_buffers_size"`
GzipEnabled bool `json:"gzip_enabled"`
GzipMinLength int `json:"gzip_min_length"`
GzipCompLevel int `json:"gzip_comp_level"`
Resolvers string `json:"resolvers,omitempty"`
CacheEnabled bool `json:"cache_enabled"`
CachePath string `json:"cache_path,omitempty"`
CacheLevels string `json:"cache_levels"`
CacheInactive string `json:"cache_inactive"`
CacheMaxSize string `json:"cache_max_size"`
CacheKeyTemplate string `json:"cache_key_template"`
CacheLockEnabled bool `json:"cache_lock_enabled"`
CacheLockTimeout string `json:"cache_lock_timeout"`
CacheUseStale string `json:"cache_use_stale"`
MainConfigTemplate string `json:"main_config_template,omitempty"`
}
type snapshotDocument struct {
Routes []snapshotRoute `json:"routes"`
OpenRestyConfig openRestyConfigSnapshot `json:"openresty_config"`
WAF snapshotWAFDocument `json:"waf"`
}
type configBundle struct {
Routes []*model.ProxyRoute
SnapshotRoutes []snapshotRoute
WAFSnapshot snapshotWAFDocument
OpenRestyConfig openRestyConfigSnapshot
SnapshotJSON string
MainConfig string
RouteConfig string
SupportFiles []SupportFile
Checksum string
ChangedOptionKeys []string
}
func buildCurrentConfigBundle(ctx context.Context, requireRoutes bool) (*configBundle, error) {
routes, err := model.ListEnabledProxyRoutes(ctx)
if err != nil {
return nil, err
}
if requireRoutes && len(routes) == 0 {
return nil, errors.New(errNoEnabledRoutes)
}
snapshotRoutes, err := buildSnapshotRoutes(ctx, routes)
if err != nil {
return nil, err
}
wafSnapshot, err := buildSnapshotWAFDocument(ctx, routes)
if err != nil {
return nil, err
}
openRestyConfig := buildOpenRestyConfigSnapshot()
snapshotDoc := snapshotDocument{
Routes: snapshotRoutes,
OpenRestyConfig: openRestyConfig,
WAF: wafSnapshot,
}
snapshotJSON, err := json.Marshal(snapshotDoc)
if err != nil {
return nil, err
}
certificateFiles, err := buildCertificateSupportFiles(ctx, snapshotRoutes)
if err != nil {
return nil, err
}
mainConfig := ""
routeConfig := ""
checksum := ""
supportFiles := []SupportFile(nil)
rendered, renderErr := renderSnapshotConfig(string(snapshotJSON), certificateFiles)
if renderErr == nil {
mainConfig = rendered.MainConfig
routeConfig = rendered.RouteConfig
checksum = rendered.Checksum
supportFiles = fromOpenRestySupportFiles(rendered.SupportFiles)
} else {
mainConfig, routeConfig, checksum = renderPlaceholderConfig(string(snapshotJSON))
}
return &configBundle{
Routes: routes,
SnapshotRoutes: snapshotRoutes,
WAFSnapshot: wafSnapshot,
OpenRestyConfig: openRestyConfig,
SnapshotJSON: string(snapshotJSON),
MainConfig: mainConfig,
RouteConfig: routeConfig,
SupportFiles: supportFiles,
Checksum: checksum,
ChangedOptionKeys: openRestyOptionKeys(),
}, nil
}
func buildSnapshotRoutes(ctx context.Context, routes []*model.ProxyRoute) ([]snapshotRoute, error) {
items := make([]snapshotRoute, 0, len(routes))
for _, route := range routes {
domains, err := decodeStoredDomains(route.Domains, route.Domain)
if err != nil {
return nil, fmt.Errorf("route %s domains are invalid", route.Domain)
}
customHeaders, err := decodeStoredCustomHeaders(route.CustomHeaders)
if err != nil {
return nil, fmt.Errorf("路由 %s 自定义请求头无效", route.Domain)
}
upstreamType := normalizeUpstreamType(route.UpstreamType)
originURL := route.OriginURL
upstreams, err := decodeStoredUpstreams(route.Upstreams, route.OriginURL)
if err != nil {
return nil, fmt.Errorf("路由 %s 上游配置无效", route.Domain)
}
var tunnelNodeID *uint
var tunnelTargetAddr string
var tunnelTargetProtocol string
var pagesProjectID *uint
if upstreamType == "tunnel" {
originURL = resolveTunnelOpenRestyUpstreamURL(ctx)
upstreams = []string{originURL}
tunnelNodeID = route.TunnelNodeID
tunnelTargetAddr = strings.TrimSpace(route.TunnelTargetAddr)
tunnelTargetProtocol = normalizeTunnelTargetProtocol(route.TunnelTargetProtocol)
} else if upstreamType == "pages" {
return nil, fmt.Errorf("路由 %s Pages 配置无效: pages module is not available", route.Domain)
}
cacheRules, err := decodeStoredCacheRules(route.CacheRules)
if err != nil {
return nil, fmt.Errorf("路由 %s 缓存规则无效", route.Domain)
}
items = append(items, snapshotRoute{
ID: route.ID,
SiteName: normalizeProxyRouteSiteName(route, route.SiteName, domains[0]),
Domain: domains[0],
Domains: domains,
OriginURL: originURL,
OriginHost: route.OriginHost,
Upstreams: upstreams,
Enabled: route.Enabled,
EnableHTTPS: route.EnableHTTPS,
CertID: route.CertID,
CertIDs: mustDecodeCertIDs(route),
DomainCertIDs: mustDecodeDomainCertIDs(route, domains),
RedirectHTTP: route.RedirectHTTP,
LimitConnPerServer: route.LimitConnPerServer,
LimitConnPerIP: route.LimitConnPerIP,
LimitRate: route.LimitRate,
CacheEnabled: route.CacheEnabled,
CachePolicy: route.CachePolicy,
CacheRules: cacheRules,
CustomHeaders: customHeaders,
BasicAuthEnabled: route.BasicAuthEnabled,
BasicAuthUsername: route.BasicAuthUsername,
BasicAuthPassword: route.BasicAuthPassword,
Remark: route.Remark,
UpstreamType: upstreamType,
TunnelNodeID: tunnelNodeID,
TunnelTargetAddr: tunnelTargetAddr,
TunnelTargetProto: tunnelTargetProtocol,
PagesProjectID: pagesProjectID,
})
}
return items, nil
}
func buildSnapshotWAFDocument(ctx context.Context, routes []*model.ProxyRoute) (snapshotWAFDocument, error) {
if err := waf.EnsureDefaultRuleGroup(ctx); err != nil {
return snapshotWAFDocument{}, err
}
views, err := waf.ListRuleGroups(ctx)
if err != nil {
return snapshotWAFDocument{}, err
}
ruleGroups := make([]snapshotWAFRuleGroup, 0, len(views))
for _, view := range views {
if !view.Enabled {
continue
}
ruleGroups = append(ruleGroups, snapshotWAFRuleGroup{
ID: view.ID,
Name: view.Name,
Enabled: view.Enabled,
IsGlobal: view.IsGlobal,
BlockStatusCode: view.BlockStatusCode,
BlockResponseBody: view.BlockResponseBody,
IPWhitelist: view.IPWhitelist,
IPBlacklist: view.IPBlacklist,
IPWhitelistGroups: view.IPWhitelistGroups,
IPBlacklistGroups: view.IPBlacklistGroups,
CountryWhitelist: view.CountryWhitelist,
CountryBlacklist: view.CountryBlacklist,
RegionWhitelist: view.RegionWhitelist,
RegionBlacklist: view.RegionBlacklist,
PoWEnabled: view.PoWEnabled,
PoWConfig: convertPoWConfig(view.PoWConfig),
})
}
ipGroups, err := buildSnapshotWAFIPGroups(ctx, ruleGroups)
if err != nil {
return snapshotWAFDocument{}, err
}
enabledRouteIDs := make(map[uint]string, len(routes))
for _, route := range routes {
if route == nil {
continue
}
siteName := strings.TrimSpace(route.SiteName)
if siteName == "" {
siteName = route.Domain
}
enabledRouteIDs[route.ID] = siteName
}
rawBindings, err := model.ListOpenFlareWAFRuleGroupBindings(ctx)
if err != nil {
return snapshotWAFDocument{}, err
}
groupIDsByRoute := make(map[uint][]uint, len(rawBindings))
for _, binding := range rawBindings {
if _, ok := enabledRouteIDs[binding.ProxyRouteID]; !ok {
continue
}
groupIDsByRoute[binding.ProxyRouteID] = append(groupIDsByRoute[binding.ProxyRouteID], binding.RuleGroupID)
}
bindings := make([]snapshotWAFBinding, 0, len(groupIDsByRoute))
for routeID, groupIDs := range groupIDsByRoute {
sort.Slice(groupIDs, func(i, j int) bool { return groupIDs[i] < groupIDs[j] })
bindings = append(bindings, snapshotWAFBinding{
RouteID: routeID,
SiteName: enabledRouteIDs[routeID],
RuleGroupIDs: groupIDs,
})
}
sort.Slice(bindings, func(i, j int) bool {
if bindings[i].SiteName == bindings[j].SiteName {
return bindings[i].RouteID < bindings[j].RouteID
}
return bindings[i].SiteName < bindings[j].SiteName
})
return snapshotWAFDocument{RuleGroups: ruleGroups, IPGroups: ipGroups, Bindings: bindings}, nil
}
func buildSnapshotWAFIPGroups(ctx context.Context, ruleGroups []snapshotWAFRuleGroup) ([]snapshotWAFIPGroup, error) {
idSet := make(map[uint]struct{})
for _, group := range ruleGroups {
for _, id := range group.IPWhitelistGroups {
idSet[id] = struct{}{}
}
for _, id := range group.IPBlacklistGroups {
idSet[id] = struct{}{}
}
}
if len(idSet) == 0 {
return []snapshotWAFIPGroup{}, nil
}
ids := make([]uint, 0, len(idSet))
for id := range idSet {
ids = append(ids, id)
}
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
groups, err := listWAFIPGroupsByIDs(ctx, ids)
if err != nil {
return nil, err
}
groupByID := make(map[uint]*model.OpenFlareWAFIPGroup, len(groups))
for _, group := range groups {
groupByID[group.ID] = group
}
snapshots := make([]snapshotWAFIPGroup, 0, len(ids))
for _, id := range ids {
group := groupByID[id]
if group == nil {
return nil, fmt.Errorf("IP 组 %d 不存在", id)
}
ipList, decodeErr := decodeIPList(group.IPList)
if decodeErr != nil {
return nil, decodeErr
}
snapshots = append(snapshots, snapshotWAFIPGroup{
ID: group.ID,
Name: group.Name,
Type: group.Type,
Enabled: group.Enabled,
IPList: ipList,
})
}
return snapshots, nil
}
func decodeIPList(raw string) ([]string, error) {
text := strings.TrimSpace(raw)
if text == "" {
return []string{}, nil
}
var items []string
if err := json.Unmarshal([]byte(text), &items); err != nil {
return nil, fmt.Errorf("ip_list payload is invalid")
}
return items, nil
}
func convertPoWConfig(config *waf.PoWConfig) *openrestyrender.PoWConfig {
if config == nil {
return nil
}
return &openrestyrender.PoWConfig{
Difficulty: config.Difficulty,
Algorithm: config.Algorithm,
SessionTTL: config.SessionTTL,
ChallengeTTL: config.ChallengeTTL,
Whitelist: openrestyrender.PoWListConfig{
IPs: config.Whitelist.IPs,
IPCidrs: config.Whitelist.IPCidrs,
Paths: config.Whitelist.Paths,
PathRegexes: config.Whitelist.PathRegexes,
UserAgents: config.Whitelist.UserAgents,
},
Blacklist: openrestyrender.PoWListConfig{
IPs: config.Blacklist.IPs,
IPCidrs: config.Blacklist.IPCidrs,
Paths: config.Blacklist.Paths,
PathRegexes: config.Blacklist.PathRegexes,
UserAgents: config.Blacklist.UserAgents,
},
}
}
func buildOpenRestyConfigSnapshot() openRestyConfigSnapshot {
return openRestyConfigSnapshot{
DefaultServerReturnStatus: model.OpenRestyDefaultServerReturnStatus,
WorkerProcesses: model.OpenRestyWorkerProcesses,
WorkerConnections: model.OpenRestyWorkerConnections,
WorkerRlimitNofile: model.OpenRestyWorkerRlimitNofile,
EventsUse: model.OpenRestyEventsUse,
EventsMultiAcceptEnabled: model.OpenRestyEventsMultiAcceptEnabled,
KeepaliveTimeout: model.OpenRestyKeepaliveTimeout,
KeepaliveRequests: model.OpenRestyKeepaliveRequests,
ClientHeaderTimeout: model.OpenRestyClientHeaderTimeout,
ClientBodyTimeout: model.OpenRestyClientBodyTimeout,
ClientMaxBodySize: model.OpenRestyClientMaxBodySize,
LargeClientHeaderBuffers: model.OpenRestyLargeClientHeaderBuffers,
SendTimeout: model.OpenRestySendTimeout,
ProxyConnectTimeout: model.OpenRestyProxyConnectTimeout,
ProxySendTimeout: model.OpenRestyProxySendTimeout,
ProxyReadTimeout: model.OpenRestyProxyReadTimeout,
WebsocketEnabled: model.OpenRestyWebsocketEnabled,
HTTP3Enabled: model.OpenRestyHTTP3Enabled,
ProxyRequestBuffering: model.OpenRestyProxyRequestBufferingEnabled,
ProxyBufferingEnabled: model.OpenRestyProxyBufferingEnabled,
ProxyBuffers: model.OpenRestyProxyBuffers,
ProxyBufferSize: model.OpenRestyProxyBufferSize,
ProxyBusyBuffersSize: model.OpenRestyProxyBusyBuffersSize,
GzipEnabled: model.OpenRestyGzipEnabled,
GzipMinLength: model.OpenRestyGzipMinLength,
GzipCompLevel: model.OpenRestyGzipCompLevel,
Resolvers: model.OpenRestyResolvers,
CacheEnabled: model.OpenRestyCacheEnabled,
CachePath: model.OpenRestyCachePath,
CacheLevels: model.OpenRestyCacheLevels,
CacheInactive: model.OpenRestyCacheInactive,
CacheMaxSize: model.OpenRestyCacheMaxSize,
CacheKeyTemplate: model.OpenRestyCacheKeyTemplate,
CacheLockEnabled: model.OpenRestyCacheLockEnabled,
CacheLockTimeout: model.OpenRestyCacheLockTimeout,
CacheUseStale: model.OpenRestyCacheUseStale,
MainConfigTemplate: model.OpenRestyMainConfigTemplate,
}
}
func buildCertificateSupportFiles(ctx context.Context, routes []snapshotRoute) ([]SupportFile, error) {
certIDSet := make(map[uint]struct{})
for _, route := range routes {
for _, certID := range route.CertIDs {
if certID != 0 {
certIDSet[certID] = struct{}{}
}
}
}
if len(certIDSet) == 0 {
return nil, nil
}
certIDs := make([]uint, 0, len(certIDSet))
for certID := range certIDSet {
certIDs = append(certIDs, certID)
}
sort.Slice(certIDs, func(i, j int) bool { return certIDs[i] < certIDs[j] })
files := make([]SupportFile, 0, len(certIDs)*2)
for _, certID := range certIDs {
certificate, err := model.GetTLSCertificateByID(ctx, certID)
if err != nil {
return nil, err
}
files = append(files,
SupportFile{Path: certificateCertFileName(certificate.ID), Content: normalizePEM(certificate.CertPEM)},
SupportFile{Path: certificateKeyFileName(certificate.ID), Content: normalizePEM(certificate.KeyPEM)},
)
}
return dedupeSupportFiles(files), nil
}
@@ -0,0 +1,36 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package dashboard
import (
"time"
"github.com/Rain-kl/Wavelet/internal/model"
)
const (
nodeStatusOnline = "online"
nodeStatusOffline = "offline"
nodeStatusPending = "pending"
)
func computeNodeStatus(node *model.OpenFlareNode) string {
if node == nil {
return nodeStatusOffline
}
if node.LastSeenAt == nil || node.LastSeenAt.IsZero() {
return nodeStatusPending
}
if time.Since(*node.LastSeenAt) > model.NodeOfflineThreshold {
return nodeStatusOffline
}
return nodeStatusOnline
}
func nodeViewLastSeenAt(node *model.OpenFlareNode) any {
if node == nil || node.LastSeenAt == nil {
return time.Time{}
}
return *node.LastSeenAt
}
@@ -0,0 +1,366 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package dashboard
import (
"context"
"sort"
"time"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/observability"
"github.com/Rain-kl/Wavelet/internal/model"
)
// Summary is the dashboard node summary section.
type Summary struct {
TotalNodes int `json:"total_nodes"`
OnlineNodes int `json:"online_nodes"`
OfflineNodes int `json:"offline_nodes"`
PendingNodes int `json:"pending_nodes"`
UnhealthyNodes int `json:"unhealthy_nodes"`
}
// Traffic is the dashboard traffic section.
type Traffic struct {
RequestCount int64 `json:"request_count"`
UniqueVisitors int64 `json:"unique_visitors"`
ErrorCount int64 `json:"error_count"`
EstimatedQPS float64 `json:"estimated_qps"`
ReportedNodes int `json:"reported_nodes"`
}
// Capacity is the dashboard capacity section.
type Capacity struct {
AverageCPUUsagePercent float64 `json:"average_cpu_usage_percent"`
AverageMemoryUsagePercent float64 `json:"average_memory_usage_percent"`
HighCPUNodes int `json:"high_cpu_nodes"`
HighMemoryNodes int `json:"high_memory_nodes"`
HighStorageNodes int `json:"high_storage_nodes"`
}
// NodeHealth is a dashboard node health row.
type NodeHealth struct {
ID uint `json:"id"`
NodeID string `json:"node_id"`
Name string `json:"name"`
GeoName string `json:"geo_name"`
GeoLatitude *float64 `json:"geo_latitude"`
GeoLongitude *float64 `json:"geo_longitude"`
Status string `json:"status"`
OpenrestyStatus string `json:"openresty_status"`
CurrentVersion string `json:"current_version"`
LastSeenAt any `json:"last_seen_at"`
ActiveEventCount int `json:"active_event_count"`
CPUUsagePercent float64 `json:"cpu_usage_percent"`
MemoryUsagePercent float64 `json:"memory_usage_percent"`
StorageUsagePercent float64 `json:"storage_usage_percent"`
RequestCount int64 `json:"request_count"`
ErrorCount int64 `json:"error_count"`
UniqueVisitorCount int64 `json:"unique_visitor_count"`
}
// OverviewView is the expanded dashboard overview payload.
type OverviewView struct {
GeneratedAt time.Time `json:"generated_at"`
Summary Summary `json:"summary"`
Traffic Traffic `json:"traffic"`
Capacity Capacity `json:"capacity"`
Distributions observability.TrafficDistributions `json:"distributions"`
Trends observability.NodeTrends `json:"trends"`
Nodes []NodeHealth `json:"nodes"`
}
// OverviewPayload is the compact legacy dashboard overview response.
type OverviewPayload struct {
GeneratedAt any `json:"generated_at"`
Summary Summary `json:"summary"`
Traffic Traffic `json:"traffic"`
Capacity Capacity `json:"capacity"`
Distributions distributionsPayload `json:"distributions"`
Trends trendsPayload `json:"trends"`
Nodes [][]any `json:"nodes"`
}
type distributionsPayload struct {
StatusCodes [][]any `json:"status_codes"`
TopDomains [][]any `json:"top_domains"`
SourceCountries [][]any `json:"source_countries"`
}
type trendsPayload struct {
Traffic24h [][]any `json:"traffic_24h"`
Capacity24h [][]any `json:"capacity_24h"`
Network24h [][]any `json:"network_24h"`
DiskIO24h [][]any `json:"disk_io_24h"`
}
// GetOverview aggregates dashboard overview data from nodes and observability tables.
func GetOverview(ctx context.Context) (*OverviewPayload, error) {
view, err := buildOverviewView(ctx)
if err != nil {
return nil, err
}
return compressOverview(view), nil
}
func buildOverviewView(ctx context.Context) (*OverviewView, error) {
now := time.Now()
since := now.Add(-24 * time.Hour)
nodes, err := model.ListOpenFlareNodes(ctx)
if err != nil {
return nil, err
}
snapshots, err := model.ListOpenFlareMetricSnapshotsSince(ctx, "", since, 0)
if err != nil {
return nil, err
}
reports, err := model.ListOpenFlareRequestReportsSince(ctx, "", since, 0)
if err != nil {
return nil, err
}
accessLogRegions, err := model.ListOpenFlareAccessLogRegionCounts(ctx, "", since, 8)
if err != nil {
return nil, err
}
activeEvents, err := model.ListOpenFlareActiveHealthEvents(ctx)
if err != nil {
return nil, err
}
openrestySnapshots, err := model.ListOpenFlareNodeObservationOpenresty(ctx, "", since, 0)
if err != nil {
return nil, err
}
view := &OverviewView{
GeneratedAt: now,
Nodes: make([]NodeHealth, 0, len(nodes)),
Distributions: observability.BuildTrafficDistributions(reports, accessLogRegions, 8),
Trends: observability.NodeTrends{
Traffic24h: observability.BuildTrafficTrendPoints(now, reports),
Capacity24h: observability.BuildCapacityTrendPoints(now, snapshots),
Network24h: observability.BuildNetworkTrendPoints(now, snapshots, openrestySnapshots),
DiskIO24h: observability.BuildDiskIOTrendPoints(now, snapshots),
},
}
var cpuNodeCount int
var memoryNodeCount int
latestSnapshots := observability.LatestMetricSnapshotsByNode(snapshots)
latestTrafficReports := observability.LatestTrafficReportsByNode(reports)
activeEventsByNode := observability.ActiveHealthEventsByNode(activeEvents)
for _, node := range nodes {
computedStatus := computeNodeStatus(&node)
switch computedStatus {
case nodeStatusOnline:
view.Summary.OnlineNodes++
case nodeStatusOffline:
view.Summary.OfflineNodes++
case nodeStatusPending:
view.Summary.PendingNodes++
}
if node.OpenrestyStatus == "unhealthy" {
view.Summary.UnhealthyNodes++
}
latestSnapshot := latestSnapshots[node.NodeID]
latestTraffic := latestTrafficReports[node.NodeID]
nodeActiveEvents := activeEventsByNode[node.NodeID]
nodeHealth := NodeHealth{
ID: node.ID,
NodeID: node.NodeID,
Name: node.Name,
GeoName: node.GeoName,
GeoLatitude: node.GeoLatitude,
GeoLongitude: node.GeoLongitude,
Status: computedStatus,
OpenrestyStatus: node.OpenrestyStatus,
CurrentVersion: node.CurrentVersion,
LastSeenAt: nodeViewLastSeenAt(&node),
ActiveEventCount: len(nodeActiveEvents),
}
if latestSnapshot != nil {
nodeHealth.CPUUsagePercent = latestSnapshot.CPUUsagePercent
nodeHealth.MemoryUsagePercent = observability.Percentage(latestSnapshot.MemoryUsedBytes, latestSnapshot.MemoryTotalBytes)
nodeHealth.StorageUsagePercent = observability.Percentage(latestSnapshot.StorageUsedBytes, latestSnapshot.StorageTotalBytes)
if latestSnapshot.CPUUsagePercent > 0 {
view.Capacity.AverageCPUUsagePercent += latestSnapshot.CPUUsagePercent
cpuNodeCount++
}
if nodeHealth.MemoryUsagePercent > 0 {
view.Capacity.AverageMemoryUsagePercent += nodeHealth.MemoryUsagePercent
memoryNodeCount++
}
if latestSnapshot.CPUUsagePercent >= 80 {
view.Capacity.HighCPUNodes++
}
if nodeHealth.MemoryUsagePercent >= 85 {
view.Capacity.HighMemoryNodes++
}
if nodeHealth.StorageUsagePercent >= 85 {
view.Capacity.HighStorageNodes++
}
}
if latestTraffic != nil {
nodeHealth.RequestCount = latestTraffic.RequestCount
nodeHealth.ErrorCount = latestTraffic.ErrorCount
nodeHealth.UniqueVisitorCount = latestTraffic.UniqueVisitorCount
view.Traffic.RequestCount += latestTraffic.RequestCount
view.Traffic.UniqueVisitors += latestTraffic.UniqueVisitorCount
view.Traffic.ErrorCount += latestTraffic.ErrorCount
if duration := latestTraffic.WindowEndedAt.Sub(latestTraffic.WindowStartedAt).Seconds(); duration > 0 {
view.Traffic.EstimatedQPS += float64(latestTraffic.RequestCount) / duration
}
view.Traffic.ReportedNodes++
}
view.Nodes = append(view.Nodes, nodeHealth)
}
view.Summary.TotalNodes = len(nodes)
if cpuNodeCount > 0 {
view.Capacity.AverageCPUUsagePercent /= float64(cpuNodeCount)
}
if memoryNodeCount > 0 {
view.Capacity.AverageMemoryUsagePercent /= float64(memoryNodeCount)
}
sort.Slice(view.Nodes, func(i int, j int) bool {
if view.Nodes[i].ActiveEventCount == view.Nodes[j].ActiveEventCount {
return view.Nodes[i].CPUUsagePercent > view.Nodes[j].CPUUsagePercent
}
return view.Nodes[i].ActiveEventCount > view.Nodes[j].ActiveEventCount
})
return view, nil
}
func compressOverview(view *OverviewView) *OverviewPayload {
if view == nil {
return &OverviewPayload{
Distributions: distributionsPayload{
StatusCodes: [][]any{},
TopDomains: [][]any{},
SourceCountries: [][]any{},
},
Trends: trendsPayload{
Traffic24h: [][]any{},
Capacity24h: [][]any{},
Network24h: [][]any{},
DiskIO24h: [][]any{},
},
Nodes: [][]any{},
}
}
return &OverviewPayload{
GeneratedAt: view.GeneratedAt,
Summary: view.Summary,
Traffic: view.Traffic,
Capacity: view.Capacity,
Distributions: distributionsPayload{
StatusCodes: compressDistributionItems(view.Distributions.StatusCodes),
TopDomains: compressDistributionItems(view.Distributions.TopDomains),
SourceCountries: compressDistributionItems(view.Distributions.SourceCountries),
},
Trends: trendsPayload{
Traffic24h: compressTrafficTrendPoints(view.Trends.Traffic24h),
Capacity24h: compressCapacityTrendPoints(view.Trends.Capacity24h),
Network24h: compressNetworkTrendPoints(view.Trends.Network24h),
DiskIO24h: compressDiskIOTrendPoints(view.Trends.DiskIO24h),
},
Nodes: compressDashboardNodes(view.Nodes),
}
}
func compressDistributionItems(items []observability.DistributionItem) [][]any {
rows := make([][]any, 0, len(items))
for _, item := range items {
rows = append(rows, []any{item.Key, item.Value})
}
return rows
}
func compressTrafficTrendPoints(points []observability.TrafficTrendPoint) [][]any {
rows := make([][]any, 0, len(points))
for _, point := range points {
rows = append(rows, []any{
point.BucketStartedAt,
point.RequestCount,
point.ErrorCount,
point.UniqueVisitorCount,
})
}
return rows
}
func compressCapacityTrendPoints(points []observability.CapacityTrendPoint) [][]any {
rows := make([][]any, 0, len(points))
for _, point := range points {
rows = append(rows, []any{
point.BucketStartedAt,
point.AverageCPUUsagePercent,
point.AverageMemoryUsagePercent,
point.ReportedNodes,
})
}
return rows
}
func compressNetworkTrendPoints(points []observability.NetworkTrendPoint) [][]any {
rows := make([][]any, 0, len(points))
for _, point := range points {
rows = append(rows, []any{
point.BucketStartedAt,
point.NetworkRxBytes,
point.NetworkTxBytes,
point.OpenrestyRxBytes,
point.OpenrestyTxBytes,
point.ReportedNodes,
})
}
return rows
}
func compressDiskIOTrendPoints(points []observability.DiskIOTrendPoint) [][]any {
rows := make([][]any, 0, len(points))
for _, point := range points {
rows = append(rows, []any{
point.BucketStartedAt,
point.DiskReadBytes,
point.DiskWriteBytes,
point.ReportedNodes,
})
}
return rows
}
func compressDashboardNodes(nodes []NodeHealth) [][]any {
rows := make([][]any, 0, len(nodes))
for _, node := range nodes {
rows = append(rows, []any{
node.ID,
node.NodeID,
node.Name,
node.GeoName,
node.GeoLatitude,
node.GeoLongitude,
node.Status,
node.OpenrestyStatus,
node.CurrentVersion,
node.LastSeenAt,
node.ActiveEventCount,
node.CPUUsagePercent,
node.MemoryUsagePercent,
node.StorageUsagePercent,
node.RequestCount,
node.ErrorCount,
node.UniqueVisitorCount,
})
}
return rows
}
@@ -0,0 +1,125 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package dashboard
import (
"context"
"testing"
"time"
"github.com/Rain-kl/Wavelet/internal/db"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func setupDashboardTestDB(t *testing.T) func() {
sqliteDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
DisableForeignKeyConstraintWhenMigrating: true,
})
require.NoError(t, err)
require.NoError(t, sqliteDB.AutoMigrate(&model.OpenFlareNode{}))
db.SetDB(sqliteDB)
return func() {
db.SetDB(nil)
}
}
func TestGetOverviewStructure(t *testing.T) {
cleanup := setupDashboardTestDB(t)
defer cleanup()
ctx := context.Background()
now := time.Now().UTC()
lastSeen := now.Add(-time.Minute)
require.NoError(t, db.DB(ctx).Create(&model.OpenFlareNode{
NodeID: "node-dashboard-1",
Name: "Edge 1",
IP: "10.0.0.1",
Status: "online",
OpenrestyStatus: "healthy",
CurrentVersion: "v1.0.0",
LastSeenAt: &lastSeen,
}).Error)
require.NoError(t, db.DB(ctx).Create(&model.OpenFlareNode{
NodeID: "node-dashboard-2",
Name: "Edge 2",
IP: "10.0.0.2",
Status: "pending",
OpenrestyStatus: "unknown",
}).Error)
overview, err := GetOverview(ctx)
require.NoError(t, err)
require.NotNil(t, overview)
assert.False(t, overview.GeneratedAt.(time.Time).IsZero())
assert.Equal(t, 2, overview.Summary.TotalNodes)
assert.Equal(t, 1, overview.Summary.OnlineNodes)
assert.Equal(t, 1, overview.Summary.PendingNodes)
assert.Equal(t, 0, overview.Summary.OfflineNodes)
assert.Equal(t, 0, overview.Summary.UnhealthyNodes)
assert.Equal(t, int64(0), overview.Traffic.RequestCount)
assert.Equal(t, int64(0), overview.Traffic.UniqueVisitors)
assert.Equal(t, int64(0), overview.Traffic.ErrorCount)
assert.Equal(t, float64(0), overview.Traffic.EstimatedQPS)
assert.Equal(t, 0, overview.Traffic.ReportedNodes)
assert.Equal(t, float64(0), overview.Capacity.AverageCPUUsagePercent)
assert.Equal(t, float64(0), overview.Capacity.AverageMemoryUsagePercent)
assert.Equal(t, 0, overview.Capacity.HighCPUNodes)
assert.Equal(t, 0, overview.Capacity.HighMemoryNodes)
assert.Equal(t, 0, overview.Capacity.HighStorageNodes)
require.NotNil(t, overview.Distributions.StatusCodes)
require.NotNil(t, overview.Distributions.TopDomains)
require.NotNil(t, overview.Distributions.SourceCountries)
assert.Empty(t, overview.Distributions.StatusCodes)
assert.Empty(t, overview.Distributions.TopDomains)
assert.Empty(t, overview.Distributions.SourceCountries)
require.Len(t, overview.Trends.Traffic24h, 24)
require.Len(t, overview.Trends.Capacity24h, 24)
require.Len(t, overview.Trends.Network24h, 24)
require.Len(t, overview.Trends.DiskIO24h, 24)
for _, row := range overview.Trends.Traffic24h {
require.Len(t, row, 4)
}
for _, row := range overview.Trends.Capacity24h {
require.Len(t, row, 4)
}
for _, row := range overview.Trends.Network24h {
require.Len(t, row, 6)
}
for _, row := range overview.Trends.DiskIO24h {
require.Len(t, row, 4)
}
require.Len(t, overview.Nodes, 2)
for _, row := range overview.Nodes {
require.Len(t, row, 17)
}
nodeByID := make(map[string][]any, len(overview.Nodes))
for _, row := range overview.Nodes {
nodeByID[row[1].(string)] = row
}
onlineNode := nodeByID["node-dashboard-1"]
require.NotNil(t, onlineNode)
assert.Equal(t, "Edge 1", onlineNode[2])
assert.Equal(t, "online", onlineNode[6])
assert.Equal(t, "healthy", onlineNode[7])
pendingNode := nodeByID["node-dashboard-2"]
require.NotNil(t, pendingNode)
assert.Equal(t, "Edge 2", pendingNode[2])
assert.Equal(t, "pending", pendingNode[6])
assert.Equal(t, "unknown", pendingNode[7])
}
@@ -0,0 +1,27 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package dashboard
import (
"github.com/Rain-kl/Wavelet/internal/apps/openflare/compat"
"github.com/gin-gonic/gin"
)
// RegisterRoutes mounts legacy OpenFlare dashboard routes.
func RegisterRoutes(apiGroup *gin.RouterGroup) {
dashboardRoute := apiGroup.Group("/dashboard")
dashboardRoute.Use(compat.AdminAuth())
{
dashboardRoute.GET("/overview", getOverviewHandler)
}
}
func getOverviewHandler(c *gin.Context) {
overview, err := GetOverview(c.Request.Context())
if err != nil {
compat.Fail(c, err.Error())
return
}
compat.OK(c, overview)
}
@@ -0,0 +1,9 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package flared
const (
errTunnelTokenInvalid = "无权进行此操作,Tunnel Token 无效"
errTunnelNodeTypeMismatch = "此节点不是 TunnelClient 类型"
)
@@ -0,0 +1,176 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package flared
import (
"context"
"encoding/json"
"errors"
"fmt"
"net"
"strconv"
"strings"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/relay"
"github.com/Rain-kl/Wavelet/internal/db"
"github.com/Rain-kl/Wavelet/internal/model"
"gorm.io/gorm"
)
type configVersionRow struct {
Version string `gorm:"column:version"`
Checksum string `gorm:"column:checksum"`
}
func (configVersionRow) TableName() string {
return "of_config_versions"
}
func normalizeReleaseChannel(channel string) string {
if strings.ToLower(strings.TrimSpace(channel)) == "preview" {
return "preview"
}
return "stable"
}
func normalizeFlaredHeartbeatPayload(payload HeartbeatPayload) HeartbeatPayload {
payload.ClientVersion = strings.TrimSpace(payload.ClientVersion)
payload.FrpVersion = strings.TrimSpace(payload.FrpVersion)
payload.IP = strings.TrimSpace(payload.IP)
payload.TunnelStatus = strings.ToLower(strings.TrimSpace(payload.TunnelStatus))
payload.CurrentVersion = strings.TrimSpace(payload.CurrentVersion)
payload.CurrentChecksum = strings.TrimSpace(payload.CurrentChecksum)
cleaned := make([]ConnectedRelay, 0, len(payload.ConnectedRelays))
for _, item := range payload.ConnectedRelays {
item.RelayNodeID = strings.TrimSpace(item.RelayNodeID)
item.Status = strings.ToLower(strings.TrimSpace(item.Status))
if item.RelayNodeID == "" {
continue
}
if item.Status == "" {
item.Status = "unknown"
}
cleaned = append(cleaned, item)
}
payload.ConnectedRelays = cleaned
return payload
}
func getActiveConfigMeta(ctx context.Context) (*ActiveConfigMeta, error) {
conn := db.DB(ctx)
if conn == nil {
return nil, errors.New("database not initialized")
}
if !conn.Migrator().HasTable(&configVersionRow{}) {
return nil, gorm.ErrRecordNotFound
}
var version configVersionRow
err := conn.Where("is_active = ?", true).Order("id desc").First(&version).Error
if err != nil {
return nil, err
}
return &ActiveConfigMeta{
Version: version.Version,
Checksum: version.Checksum,
}, nil
}
func listTunnelRelayNodes(ctx context.Context) ([]model.OpenFlareNode, error) {
nodes, err := model.ListOpenFlareNodes(ctx)
if err != nil {
return nil, err
}
relays := make([]model.OpenFlareNode, 0)
for _, node := range nodes {
if node.NodeType == "tunnel_relay" {
relays = append(relays, node)
}
}
return relays, nil
}
func relayClientAddress(node *model.OpenFlareNode) string {
if node == nil {
return ""
}
port := node.RelayBindPort
if port <= 0 {
port = 7000
}
addr := strings.TrimSpace(node.RelayClientAccessAddr)
if addr == "" {
addr = strings.TrimSpace(node.IP)
}
if addr == "" {
return fmt.Sprintf("127.0.0.1:%d", port)
}
if _, _, err := net.SplitHostPort(addr); err == nil {
return addr
}
if strings.Contains(addr, ":") && strings.Count(addr, ":") > 1 {
return net.JoinHostPort(addr, strconv.Itoa(port))
}
return fmt.Sprintf("%s:%d", addr, port)
}
func decodeStoredDomains(raw string, fallbackDomain string) ([]string, error) {
text := strings.TrimSpace(raw)
if text == "" {
domain := strings.ToLower(strings.TrimSpace(fallbackDomain))
if domain == "" {
return nil, errors.New("domain is required")
}
return []string{domain}, nil
}
var domains []string
if err := json.Unmarshal([]byte(text), &domains); err != nil {
return nil, errors.New("domains payload is invalid")
}
normalized := make([]string, 0, len(domains))
for _, item := range domains {
domain := strings.ToLower(strings.TrimSpace(item))
if domain == "" {
continue
}
normalized = append(normalized, domain)
}
if len(normalized) == 0 {
return nil, errors.New("domain is required")
}
return normalized, nil
}
func parseTunnelTargetAddr(addr string) (string, int) {
addr = strings.TrimSpace(addr)
if addr == "" {
return "127.0.0.1", 80
}
host, portStr, err := net.SplitHostPort(addr)
if err != nil {
lastColon := strings.LastIndex(addr, ":")
if lastColon < 0 {
return addr, 80
}
host = addr[:lastColon]
portStr = addr[lastColon+1:]
}
port := 80
if _, scanErr := fmt.Sscanf(portStr, "%d", &port); scanErr != nil {
port = 80
}
if host == "" {
host = "127.0.0.1"
}
return host, port
}
func sanitizeProxyName(domain string) string {
return strings.ReplaceAll(strings.ReplaceAll(domain, ".", "-"), "*", "wildcard")
}
func buildTunnelSettings(node *model.OpenFlareNode, updateNow bool, updateChannel, updateTag string) *relay.Settings {
return relay.BuildSettings(node, updateNow, updateChannel, updateTag)
}
@@ -0,0 +1,300 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package flared
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/relay"
"github.com/Rain-kl/Wavelet/internal/db"
"github.com/Rain-kl/Wavelet/internal/model"
"gorm.io/gorm"
)
const (
nodeStatusOnline = "online"
applyResultOK = "success"
applyResultWarn = "warning"
applyResultFail = "failed"
)
// HeartbeatPayload is sent by OpenFlared on each heartbeat.
type HeartbeatPayload struct {
ClientVersion string `json:"client_version"`
FrpVersion string `json:"frp_version"`
IP string `json:"ip"`
TunnelStatus string `json:"tunnel_status"`
ConnectedRelays []ConnectedRelay `json:"connected_relays"`
CurrentVersion string `json:"current_version"`
CurrentChecksum string `json:"current_checksum"`
}
// ConnectedRelay describes relay connection status from the client.
type ConnectedRelay struct {
RelayNodeID string `json:"relay_node_id"`
Status string `json:"status"`
ProxyCount int `json:"proxy_count"`
}
// ActiveConfigMeta summarizes the active config version.
type ActiveConfigMeta struct {
Version string `json:"version"`
Checksum string `json:"checksum"`
}
// HeartbeatResponse is returned to the OpenFlared client.
type HeartbeatResponse struct {
ActiveConfig *ActiveConfigMeta `json:"active_config"`
TunnelSettings *relay.Settings `json:"tunnel_settings"`
}
// TunnelConfigResponse is the full tunnel routing config sent to the client.
type TunnelConfigResponse struct {
Version string `json:"version"`
Checksum string `json:"checksum"`
Relays []RelayInfo `json:"relays"`
Proxies []ProxyEntry `json:"proxies"`
}
// RelayInfo describes a relay the client should connect to.
type RelayInfo struct {
RelayNodeID string `json:"relay_node_id"`
Address string `json:"address"`
AuthToken string `json:"auth_token"`
ProxyURL string `json:"proxy_url"`
}
// ProxyEntry describes one frpc proxy definition.
type ProxyEntry struct {
Name string `json:"name"`
Type string `json:"type"`
LocalAddr string `json:"local_addr"`
LocalPort int `json:"local_port"`
CustomDomains []string `json:"custom_domains"`
}
// ApplyLogPayload is the apply result reported by OpenFlared.
type ApplyLogPayload struct {
NodeID string `json:"node_id"`
Version string `json:"version"`
Result string `json:"result"`
Message string `json:"message"`
Checksum string `json:"checksum"`
MainConfigChecksum string `json:"main_config_checksum"`
RouteConfigChecksum string `json:"route_config_checksum"`
SupportFileCount int `json:"support_file_count"`
}
// Heartbeat processes an OpenFlared heartbeat and returns runtime settings.
func Heartbeat(ctx context.Context, node *model.OpenFlareNode, payload HeartbeatPayload) (*HeartbeatResponse, error) {
if node == nil {
return nil, fmt.Errorf("tunnel client node is nil")
}
if node.NodeType != "tunnel_client" {
return nil, fmt.Errorf("node %s is not a tunnel_client", node.NodeID)
}
payload = normalizeFlaredHeartbeatPayload(payload)
previous := *node
updateNow := node.UpdateRequested
updateChannel := normalizeReleaseChannel(node.UpdateChannel)
updateTag := strings.TrimSpace(node.UpdateTag)
now := time.Now().UTC()
changes := map[string]any{
"version": payload.ClientVersion,
"ext_version": payload.FrpVersion,
"current_version": payload.CurrentVersion,
"last_seen_at": now,
"status": nodeStatusOnline,
"update_requested": false,
"update_channel": "stable",
"update_tag": "",
}
if !previous.UpdateRequested {
delete(changes, "update_requested")
}
if previous.UpdateChannel == "stable" {
delete(changes, "update_channel")
}
if previous.UpdateTag == "" {
delete(changes, "update_tag")
}
if !node.IPManualOverride && payload.IP != "" && previous.IP != payload.IP {
changes["ip"] = payload.IP
node.IP = payload.IP
}
node.Version = payload.ClientVersion
node.ExtVersion = payload.FrpVersion
node.CurrentVersion = payload.CurrentVersion
node.UpdateRequested = false
node.UpdateChannel = "stable"
node.UpdateTag = ""
lastSeen := now
node.LastSeenAt = &lastSeen
node.Status = nodeStatusOnline
if err := db.DB(ctx).Model(node).Updates(changes).Error; err != nil {
return nil, fmt.Errorf("update flared heartbeat: %w", err)
}
activeConfig, err := getActiveConfigMeta(ctx)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
return &HeartbeatResponse{
ActiveConfig: activeConfig,
TunnelSettings: buildTunnelSettings(node, updateNow, updateChannel, updateTag),
}, nil
}
// GetTunnelConfig builds the full tunnel routing config for an OpenFlared client.
func GetTunnelConfig(ctx context.Context, node *model.OpenFlareNode) (*TunnelConfigResponse, error) {
if node == nil {
return nil, fmt.Errorf("node is nil")
}
activeVersion, err := getActiveConfigVersion(ctx)
if err != nil {
return nil, fmt.Errorf("no active config version: %w", err)
}
routes, err := model.ListProxyRoutes(ctx)
if err != nil {
return nil, fmt.Errorf("get proxy routes: %w", err)
}
relayNodes, err := listTunnelRelayNodes(ctx)
if err != nil {
return nil, fmt.Errorf("get relay nodes: %w", err)
}
relays := make([]RelayInfo, 0, len(relayNodes))
for i := range relayNodes {
relayNode := relayNodes[i]
if relayNode.RelayStatus == "healthy" || relayNode.Status == nodeStatusOnline {
relays = append(relays, RelayInfo{
RelayNodeID: relayNode.NodeID,
Address: relayClientAddress(&relayNode),
AuthToken: relayNode.RelayAuthToken,
ProxyURL: strings.TrimSpace(relayNode.RelayClientProxyURL),
})
}
}
proxies := make([]ProxyEntry, 0)
for _, route := range routes {
if route == nil || route.UpstreamType != "tunnel" || route.TunnelNodeID == nil || *route.TunnelNodeID != node.ID {
continue
}
if !route.Enabled {
continue
}
domains, decodeErr := decodeStoredDomains(route.Domains, route.Domain)
if decodeErr != nil {
continue
}
localAddr, localPort := parseTunnelTargetAddr(route.TunnelTargetAddr)
proxies = append(proxies, ProxyEntry{
Name: fmt.Sprintf("%s-%s", node.NodeID, sanitizeProxyName(domains[0])),
Type: "http",
LocalAddr: localAddr,
LocalPort: localPort,
CustomDomains: domains,
})
}
return &TunnelConfigResponse{
Version: activeVersion.Version,
Checksum: activeVersion.Checksum,
Relays: relays,
Proxies: proxies,
}, nil
}
// ReportApplyLog records an apply result from OpenFlared.
func ReportApplyLog(ctx context.Context, payload ApplyLogPayload) (*model.OpenFlareApplyLog, error) {
now := time.Now().UTC()
payload = normalizeApplyLogPayload(payload)
if payload.NodeID == "" {
return nil, errors.New("node_id 不能为空")
}
if payload.Version == "" {
return nil, errors.New("version 不能为空")
}
if payload.Result != applyResultOK && payload.Result != applyResultWarn && payload.Result != applyResultFail {
return nil, errors.New("result 仅支持 success、warning 或 failed")
}
log := &model.OpenFlareApplyLog{
NodeID: payload.NodeID,
Version: payload.Version,
Result: payload.Result,
Message: payload.Message,
Checksum: payload.Checksum,
MainConfigChecksum: payload.MainConfigChecksum,
RouteConfigChecksum: payload.RouteConfigChecksum,
SupportFileCount: payload.SupportFileCount,
CreatedAt: now,
}
err := db.DB(ctx).Transaction(func(tx *gorm.DB) error {
var node model.OpenFlareNode
if err := tx.Where("node_id = ?", payload.NodeID).First(&node).Error; err != nil {
return err
}
node.Status = nodeStatusOnline
lastSeen := now
node.LastSeenAt = &lastSeen
if payload.Result == applyResultOK {
node.CurrentVersion = payload.Version
node.LastError = ""
} else {
node.LastError = payload.Message
}
if err := tx.Create(log).Error; err != nil {
return err
}
return tx.Model(&node).Select("status", "last_seen_at", "current_version", "last_error").Updates(&node).Error
})
if err != nil {
return nil, err
}
return log, nil
}
func getActiveConfigVersion(ctx context.Context) (*configVersionRow, error) {
conn := db.DB(ctx)
if conn == nil {
return nil, errors.New("database not initialized")
}
if !conn.Migrator().HasTable(&configVersionRow{}) {
return nil, gorm.ErrRecordNotFound
}
var version configVersionRow
if err := conn.Where("is_active = ?", true).Order("id desc").First(&version).Error; err != nil {
return nil, err
}
return &version, nil
}
func normalizeApplyLogPayload(payload ApplyLogPayload) ApplyLogPayload {
payload.NodeID = strings.TrimSpace(payload.NodeID)
payload.Version = strings.TrimSpace(payload.Version)
payload.Result = strings.ToLower(strings.TrimSpace(payload.Result))
payload.Message = strings.TrimSpace(payload.Message)
payload.Checksum = strings.TrimSpace(payload.Checksum)
payload.MainConfigChecksum = strings.TrimSpace(payload.MainConfigChecksum)
payload.RouteConfigChecksum = strings.TrimSpace(payload.RouteConfigChecksum)
if len(payload.Message) > 16000 {
payload.Message = payload.Message[:16000]
}
return payload
}
@@ -0,0 +1,51 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package flared
import (
"context"
"errors"
"strings"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/compat"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
const ctxFlaredNodeKey = "flared_node"
// TunnelAuth authenticates flared requests using X-Tunnel-Token and verifies tunnel_client type.
func TunnelAuth() gin.HandlerFunc {
return func(c *gin.Context) {
token := strings.TrimSpace(c.GetHeader("X-Tunnel-Token"))
node, err := authenticateAccessToken(c.Request.Context(), token)
if err != nil {
compat.Unauthorized(c, errTunnelTokenInvalid)
c.Abort()
return
}
if node.NodeType != "tunnel_client" {
compat.Forbidden(c, errTunnelNodeTypeMismatch)
c.Abort()
return
}
c.Set(ctxFlaredNodeKey, node)
c.Next()
}
}
func authenticateAccessToken(ctx context.Context, token string) (*model.OpenFlareNode, error) {
if token == "" {
return nil, errors.New("missing tunnel token")
}
node, err := model.GetOpenFlareNodeByAccessToken(ctx, token)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("invalid tunnel token")
}
return nil, err
}
return node, nil
}
@@ -0,0 +1,106 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package flared
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/Rain-kl/Wavelet/internal/db"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func setupFlaredMiddlewareTestDB(t *testing.T) func() {
t.Helper()
sqliteDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
DisableForeignKeyConstraintWhenMigrating: true,
})
require.NoError(t, err)
require.NoError(t, sqliteDB.AutoMigrate(&model.OpenFlareNode{}))
db.SetDB(sqliteDB)
return func() {
db.SetDB(nil)
}
}
func seedFlaredNode(t *testing.T, nodeType, accessToken string) *model.OpenFlareNode {
t.Helper()
ctx := context.Background()
node := &model.OpenFlareNode{
NodeID: "flared-test-node",
Name: "flared-test",
Status: "pending",
NodeType: nodeType,
AccessToken: accessToken,
}
require.NoError(t, model.CreateOpenFlareNode(ctx, node))
return node
}
func TestTunnelAuthMissingToken(t *testing.T) {
cleanup := setupFlaredMiddlewareTestDB(t)
defer cleanup()
gin.SetMode(gin.TestMode)
engine := gin.New()
engine.GET("/flared/test", TunnelAuth(), func(c *gin.Context) {
c.Status(http.StatusOK)
})
req := httptest.NewRequest(http.MethodGet, "/flared/test", nil)
rec := httptest.NewRecorder()
engine.ServeHTTP(rec, req)
assert.Equal(t, http.StatusUnauthorized, rec.Code)
}
func TestTunnelAuthRejectsWrongNodeType(t *testing.T) {
cleanup := setupFlaredMiddlewareTestDB(t)
defer cleanup()
seedFlaredNode(t, "edge_node", "edge-token-flared")
gin.SetMode(gin.TestMode)
engine := gin.New()
engine.GET("/flared/test", TunnelAuth(), func(c *gin.Context) {
c.Status(http.StatusOK)
})
req := httptest.NewRequest(http.MethodGet, "/flared/test", nil)
req.Header.Set("X-Tunnel-Token", "edge-token-flared")
rec := httptest.NewRecorder()
engine.ServeHTTP(rec, req)
assert.Equal(t, http.StatusForbidden, rec.Code)
}
func TestTunnelAuthAcceptsTunnelClient(t *testing.T) {
cleanup := setupFlaredMiddlewareTestDB(t)
defer cleanup()
node := seedFlaredNode(t, "tunnel_client", "tunnel-token-valid")
gin.SetMode(gin.TestMode)
engine := gin.New()
engine.GET("/flared/test", TunnelAuth(), func(c *gin.Context) {
authNode, ok := c.Get(ctxFlaredNodeKey)
require.True(t, ok)
assert.Equal(t, node.NodeID, authNode.(*model.OpenFlareNode).NodeID)
c.Status(http.StatusOK)
})
req := httptest.NewRequest(http.MethodGet, "/flared/test", nil)
req.Header.Set("X-Tunnel-Token", "tunnel-token-valid")
rec := httptest.NewRecorder()
engine.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
}
@@ -0,0 +1,79 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package flared
import (
"github.com/Rain-kl/Wavelet/internal/apps/openflare/compat"
ofws "github.com/Rain-kl/Wavelet/internal/apps/openflare/websocket"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/gin-gonic/gin"
)
// PostHeartbeat handles POST /flared/heartbeat.
func PostHeartbeat(c *gin.Context) {
var payload HeartbeatPayload
if !compat.BindJSON(c, &payload) {
return
}
authNode, ok := c.Get(ctxFlaredNodeKey)
if !ok {
compat.Unauthorized(c, errTunnelTokenInvalid)
return
}
node := authNode.(*model.OpenFlareNode)
result, err := Heartbeat(c.Request.Context(), node, payload)
if err != nil {
compat.Fail(c, err.Error())
return
}
compat.OK(c, result)
}
// GetActiveConfig handles GET /flared/config/active.
func GetActiveConfig(c *gin.Context) {
authNode, ok := c.Get(ctxFlaredNodeKey)
if !ok {
compat.Unauthorized(c, errTunnelTokenInvalid)
return
}
node := authNode.(*model.OpenFlareNode)
config, err := GetTunnelConfig(c.Request.Context(), node)
if err != nil {
compat.Fail(c, "无法生成隧道配置: "+err.Error())
return
}
compat.OK(c, config)
}
// PostApplyLog handles POST /flared/apply-log.
func PostApplyLog(c *gin.Context) {
var payload ApplyLogPayload
if !compat.BindJSON(c, &payload) {
return
}
if authNode, ok := c.Get(ctxFlaredNodeKey); ok {
payload.NodeID = authNode.(*model.OpenFlareNode).NodeID
}
log, err := ReportApplyLog(c.Request.Context(), payload)
if err != nil {
compat.Fail(c, err.Error())
return
}
compat.OK(c, log)
}
// GetWebSocket handles GET /flared/ws.
func GetWebSocket(c *gin.Context) {
authNode, ok := c.Get(ctxFlaredNodeKey)
if !ok {
compat.Unauthorized(c, errTunnelTokenInvalid)
return
}
node := authNode.(*model.OpenFlareNode)
ofws.ServeFlared(c, node.NodeID)
}
@@ -0,0 +1,76 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package geoip provides OpenFlare-compatible GeoIP lookup helpers.
package geoip
import (
"errors"
"net"
"strings"
pkggeoip "github.com/rain-kl/openflare/pkg/geoip"
)
// LookupView is the legacy OpenFlare GeoIP lookup response shape.
type LookupView struct {
Provider string `json:"provider"`
IP string `json:"ip"`
ISOCode string `json:"iso_code"`
Name string `json:"name"`
Latitude *float64 `json:"latitude,omitempty"`
Longitude *float64 `json:"longitude,omitempty"`
}
const (
errProviderInvalid = "归属方式仅支持 disabled、mmdb、ip-api、geojs、ipinfo"
errIPEmpty = "IP 不能为空"
errIPInvalid = "IP 格式无效"
errLookupEmpty = "未获取到 IP 归属结果"
)
// IsValidProvider reports whether provider is a supported GeoIP backend.
func IsValidProvider(provider string) bool {
return pkggeoip.IsValidProvider(provider)
}
// Lookup resolves geographic information for rawIP using the given provider.
func Lookup(provider, rawIP string) (*LookupView, error) {
trimmedProvider := strings.TrimSpace(provider)
if !pkggeoip.IsValidProvider(trimmedProvider) {
return nil, errors.New(errProviderInvalid)
}
trimmedIP := strings.TrimSpace(rawIP)
if trimmedIP == "" {
return nil, errors.New(errIPEmpty)
}
parsedIP := net.ParseIP(trimmedIP)
if parsedIP == nil {
return nil, errors.New(errIPInvalid)
}
if trimmedProvider == pkggeoip.ProviderDisabled {
return &LookupView{
Provider: trimmedProvider,
IP: parsedIP.String(),
}, nil
}
info, err := pkggeoip.LookupGeoInfoWithProvider(trimmedProvider, parsedIP)
if err != nil {
return nil, err
}
if info == nil {
return nil, errors.New(errLookupEmpty)
}
return &LookupView{
Provider: trimmedProvider,
IP: parsedIP.String(),
ISOCode: info.ISOCode,
Name: info.Name,
Latitude: info.Latitude,
Longitude: info.Longitude,
}, nil
}
@@ -0,0 +1,73 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package geoip
import (
"net"
"testing"
pkggeoip "github.com/rain-kl/openflare/pkg/geoip"
)
type fakeLookupProvider struct{}
func (f *fakeLookupProvider) Name() string { return "fake-lookup" }
func (f *fakeLookupProvider) GetGeoInfo(_ net.IP) (*pkggeoip.GeoInfo, error) {
lat := 37.7749
lon := -122.4194
return &pkggeoip.GeoInfo{
ISOCode: "US",
Name: "United States",
Latitude: &lat,
Longitude: &lon,
}, nil
}
func (f *fakeLookupProvider) UpdateDatabase() error { return nil }
func (f *fakeLookupProvider) Close() error { return nil }
func TestLookupWithProvider(t *testing.T) {
previousFactory := pkggeoip.ProviderFactoryForTest()
pkggeoip.SetProviderFactoryForTest(func(provider string) (pkggeoip.GeoIPService, error) {
return &fakeLookupProvider{}, nil
})
t.Cleanup(func() {
pkggeoip.SetProviderFactoryForTest(previousFactory)
})
view, err := Lookup("ipinfo", "8.8.8.8")
if err != nil {
t.Fatalf("Lookup() error = %v", err)
}
if view.Provider != "ipinfo" || view.IP != "8.8.8.8" {
t.Fatalf("unexpected lookup view: %+v", view)
}
if view.ISOCode != "US" || view.Name != "United States" {
t.Fatalf("unexpected geo fields: %+v", view)
}
if view.Latitude == nil || view.Longitude == nil {
t.Fatalf("expected coordinates, got %+v", view)
}
}
func TestLookupRejectsInvalidInput(t *testing.T) {
if _, err := Lookup("invalid", "8.8.8.8"); err == nil {
t.Fatal("expected invalid provider to fail")
}
if _, err := Lookup("ipinfo", "not-an-ip"); err == nil {
t.Fatal("expected invalid IP to fail")
}
}
func TestLookupDisabledProvider(t *testing.T) {
view, err := Lookup("disabled", "8.8.8.8")
if err != nil {
t.Fatalf("Lookup() error = %v", err)
}
if view.Provider != "disabled" || view.IP != "8.8.8.8" {
t.Fatalf("unexpected disabled view: %+v", view)
}
}
@@ -0,0 +1,232 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package integration
import (
"context"
"encoding/json"
"net/http"
"testing"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/agent"
oflegacy "github.com/Rain-kl/Wavelet/internal/apps/openflare/legacy"
ofnode "github.com/Rain-kl/Wavelet/internal/apps/openflare/node"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/option"
"github.com/Rain-kl/Wavelet/internal/db"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
type configVersionRecord struct {
ID uint `gorm:"primaryKey"`
Version string `gorm:"column:version"`
SnapshotJSON string `gorm:"column:snapshot_json"`
SupportFilesJSON string `gorm:"column:support_files_json"`
Checksum string `gorm:"column:checksum"`
IsActive bool `gorm:"column:is_active"`
}
func (configVersionRecord) TableName() string {
return "of_config_versions"
}
func setupProtocolTestEnv(t *testing.T) (*gin.Engine, func()) {
t.Helper()
sqliteDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
DisableForeignKeyConstraintWhenMigrating: true,
})
require.NoError(t, err)
require.NoError(t, sqliteDB.AutoMigrate(
&model.OpenFlareNode{},
&model.OpenFlareOption{},
&model.OpenFlareApplyLog{},
&configVersionRecord{},
))
db.SetDB(sqliteDB)
option.ResetInitializationForTest()
agent.ResetAuthCacheForTest()
gin.SetMode(gin.TestMode)
engine := gin.New()
apiGroup := engine.Group("/api")
oflegacy.RegisterRoutes(apiGroup)
cleanup := func() {
db.SetDB(nil)
option.ResetInitializationForTest()
agent.ResetAuthCacheForTest()
}
return engine, cleanup
}
func TestAgentRelayFlaredProtocol(t *testing.T) {
engine, cleanup := setupProtocolTestEnv(t)
defer cleanup()
ctx := context.Background()
t.Run("create edge node and heartbeat with X-Agent-Token", func(t *testing.T) {
edge, err := ofnode.CreateNode(ctx, ofnode.Input{
Name: "edge-1",
IP: "10.0.0.1",
})
require.NoError(t, err)
require.NotEmpty(t, edge.AccessToken)
assert.Equal(t, "edge_node", edge.NodeType)
rec := performJSONRequest(t, engine, http.MethodPost, "/api/agent/nodes/heartbeat", map[string]any{
"name": "edge-1",
"ip": "203.0.113.10",
"version": "0.1.0",
}, map[string]string{
"X-Agent-Token": edge.AccessToken,
})
assert.Equal(t, http.StatusOK, rec.Code)
envelope := decodeEnvelope(t, rec)
assert.True(t, envelope.Success)
var heartbeatBody struct {
Success bool `json:"success"`
Data any `json:"data"`
AgentSettings any `json:"agent_settings"`
}
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &heartbeatBody))
assert.True(t, heartbeatBody.Success)
assert.NotNil(t, heartbeatBody.AgentSettings)
stored, err := model.GetOpenFlareNodeByNodeID(ctx, edge.NodeID)
require.NoError(t, err)
assert.Equal(t, "online", stored.Status)
assert.Equal(t, "0.1.0", stored.Version)
})
t.Run("create tunnel_relay node and relay heartbeat", func(t *testing.T) {
relayNode, err := ofnode.CreateNode(ctx, ofnode.Input{
Name: "relay-1",
NodeType: "tunnel_relay",
})
require.NoError(t, err)
require.NotEmpty(t, relayNode.AccessToken)
rec := performJSONRequest(t, engine, http.MethodPost, "/api/relay/heartbeat", map[string]any{
"version": "v0.1.0",
"frp_version": "0.61.0",
"relay_status": "healthy",
"name": "relay-1",
"ip": "203.0.113.20",
}, map[string]string{
"X-Agent-Token": relayNode.AccessToken,
})
assert.Equal(t, http.StatusOK, rec.Code)
envelope := decodeEnvelope(t, rec)
assert.True(t, envelope.Success)
var heartbeatData struct {
RelayConfig map[string]any `json:"relay_config"`
RelaySettings map[string]any `json:"relay_settings"`
}
unmarshalEnvelopeData(t, envelope.Data, &heartbeatData)
assert.NotNil(t, heartbeatData.RelayConfig)
assert.NotNil(t, heartbeatData.RelaySettings)
stored, err := model.GetOpenFlareNodeByNodeID(ctx, relayNode.NodeID)
require.NoError(t, err)
assert.Equal(t, "online", stored.Status)
assert.Equal(t, "healthy", stored.RelayStatus)
})
t.Run("create tunnel_client node and flared heartbeat with X-Tunnel-Token", func(t *testing.T) {
clientNode, err := ofnode.CreateNode(ctx, ofnode.Input{
Name: "client-1",
NodeType: "tunnel_client",
})
require.NoError(t, err)
require.NotEmpty(t, clientNode.AccessToken)
rec := performJSONRequest(t, engine, http.MethodPost, "/api/flared/heartbeat", map[string]any{
"client_version": "v0.2.0",
"frp_version": "0.61.0",
"tunnel_status": "running",
}, map[string]string{
"X-Tunnel-Token": clientNode.AccessToken,
})
assert.Equal(t, http.StatusOK, rec.Code)
envelope := decodeEnvelope(t, rec)
assert.True(t, envelope.Success)
stored, err := model.GetOpenFlareNodeByNodeID(ctx, clientNode.NodeID)
require.NoError(t, err)
assert.Equal(t, "online", stored.Status)
assert.Equal(t, "v0.2.0", stored.Version)
})
t.Run("agent register with discovery token from options", func(t *testing.T) {
bootstrap, err := ofnode.GetBootstrapToken(ctx)
require.NoError(t, err)
require.NotEmpty(t, bootstrap.DiscoveryToken)
rec := performJSONRequest(t, engine, http.MethodPost, "/api/agent/nodes/register", map[string]any{
"name": "discovered-edge",
"ip": "203.0.113.30",
"version": "0.2.0",
}, map[string]string{
"X-Agent-Token": bootstrap.DiscoveryToken,
})
assert.Equal(t, http.StatusOK, rec.Code)
envelope := decodeEnvelope(t, rec)
assert.True(t, envelope.Success)
var registration agent.RegistrationResponse
unmarshalEnvelopeData(t, envelope.Data, &registration)
assert.NotEmpty(t, registration.NodeID)
assert.NotEmpty(t, registration.AccessToken)
assert.Equal(t, "discovered-edge", registration.Name)
stored, err := model.GetOpenFlareNodeByNodeID(ctx, registration.NodeID)
require.NoError(t, err)
assert.Equal(t, "online", stored.Status)
assert.Equal(t, registration.AccessToken, stored.AccessToken)
})
t.Run("POST agent apply-logs", func(t *testing.T) {
edge, err := ofnode.CreateNode(ctx, ofnode.Input{
Name: "edge-apply",
IP: "10.0.0.2",
})
require.NoError(t, err)
rec := performJSONRequest(t, engine, http.MethodPost, "/api/agent/apply-logs", map[string]any{
"version": "20260618-001",
"result": "success",
"message": "apply ok",
}, map[string]string{
"X-Agent-Token": edge.AccessToken,
})
assert.Equal(t, http.StatusOK, rec.Code)
envelope := decodeEnvelope(t, rec)
assert.True(t, envelope.Success)
var applyLog model.OpenFlareApplyLog
unmarshalEnvelopeData(t, envelope.Data, &applyLog)
assert.Equal(t, edge.NodeID, applyLog.NodeID)
assert.Equal(t, "success", applyLog.Result)
assert.Equal(t, "20260618-001", applyLog.Version)
stored, err := model.GetOpenFlareNodeByNodeID(ctx, edge.NodeID)
require.NoError(t, err)
assert.Equal(t, "online", stored.Status)
assert.Equal(t, "20260618-001", stored.CurrentVersion)
})
}
@@ -0,0 +1,241 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package integration
import (
"context"
"net/http"
"testing"
"github.com/Rain-kl/Wavelet/internal/apps/oauth"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/compat"
oflegacy "github.com/Rain-kl/Wavelet/internal/apps/openflare/legacy"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/option"
"github.com/Rain-kl/Wavelet/internal/config"
"github.com/Rain-kl/Wavelet/internal/db/idgen"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/internal/testhelper"
"github.com/gin-contrib/sessions"
"github.com/gin-contrib/sessions/cookie"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
type statusPayload struct {
SystemName string `json:"system_name"`
}
type legacyUserPayload struct {
Username string `json:"username"`
Token string `json:"token"`
}
func setupAuthOptionIntegration(t *testing.T) (*gorm.DB, *gin.Engine) {
t.Helper()
dbConn, _, cleanup := testhelper.SetupTestEnvironment(t)
t.Cleanup(cleanup)
require.NoError(t, dbConn.AutoMigrate(&model.OpenFlareOption{}))
option.ResetInitializationForTest()
t.Cleanup(option.ResetInitializationForTest)
oldCookieName := config.Config.App.SessionCookieName
oldSecret := config.Config.App.SessionSecret
oldDomain := config.Config.App.SessionDomain
oldSecure := config.Config.App.SessionSecure
oldHTTPOnly := config.Config.App.SessionHTTPOnly
t.Cleanup(func() {
config.Config.App.SessionCookieName = oldCookieName
config.Config.App.SessionSecret = oldSecret
config.Config.App.SessionDomain = oldDomain
config.Config.App.SessionSecure = oldSecure
config.Config.App.SessionHTTPOnly = oldHTTPOnly
})
config.Config.App.SessionCookieName = "test_openflare_session"
config.Config.App.SessionSecret = "test_openflare_session_secret"
config.Config.App.SessionDomain = ""
config.Config.App.SessionSecure = false
config.Config.App.SessionHTTPOnly = true
store := cookie.NewStore([]byte(config.Config.App.SessionSecret))
store.Options(oauth.GetSessionOptions(3600))
r := testhelper.NewTestGinEngine(sessions.Sessions(config.Config.App.SessionCookieName, store))
api := r.Group("/api")
oflegacy.RegisterRoutes(api)
return dbConn, r
}
func seedUser(t *testing.T, dbConn *gorm.DB, username, password string, isAdmin bool) *model.User {
t.Helper()
user := &model.User{
ID: idgen.NextUint64ID(),
Username: username,
Nickname: username,
Email: username + "@openflare.test",
IsActive: true,
IsAdmin: isAdmin,
}
require.NoError(t, user.SetEncryptedPassword(password))
require.NoError(t, dbConn.Create(user).Error)
return user
}
func TestGETStatusReturnsSuccessEnvelope(t *testing.T) {
_, r := setupAuthOptionIntegration(t)
w := performJSONRequest(t, r, http.MethodGet, "/api/status", nil, nil)
assert.Equal(t, http.StatusOK, w.Code)
env := decodeEnvelope(t, w)
assert.True(t, env.Success, "message=%s", env.Message)
var status statusPayload
unmarshalEnvelopeData(t, env.Data, &status)
assert.NotEmpty(t, status.SystemName)
}
func TestPOSTUserLoginWithSeededUser(t *testing.T) {
dbConn, r := setupAuthOptionIntegration(t)
seedUser(t, dbConn, "testuser", "password123", false)
w := performJSONRequest(t, r, http.MethodPost, "/api/user/login", map[string]string{
"username": "testuser",
"password": "password123",
}, nil)
assert.Equal(t, http.StatusOK, w.Code)
env := decodeEnvelope(t, w)
assert.True(t, env.Success, "message=%s", env.Message)
var user legacyUserPayload
unmarshalEnvelopeData(t, env.Data, &user)
assert.Equal(t, "testuser", user.Username)
assert.NotEmpty(t, user.Token)
}
func TestGETUserSelfWithToken(t *testing.T) {
dbConn, r := setupAuthOptionIntegration(t)
seedUser(t, dbConn, "selfuser", "password123", false)
loginResp := performJSONRequest(t, r, http.MethodPost, "/api/user/login", map[string]string{
"username": "selfuser",
"password": "password123",
}, nil)
loginEnv := decodeEnvelope(t, loginResp)
require.True(t, loginEnv.Success, "login failed: %s", loginEnv.Message)
var loginUser legacyUserPayload
unmarshalEnvelopeData(t, loginEnv.Data, &loginUser)
require.NotEmpty(t, loginUser.Token)
w := performJSONRequest(t, r, http.MethodGet, "/api/user/self", nil, map[string]string{
compat.OpenFlareTokenHeader(): loginUser.Token,
})
assert.Equal(t, http.StatusOK, w.Code)
env := decodeEnvelope(t, w)
assert.True(t, env.Success, "message=%s", env.Message)
var self legacyUserPayload
unmarshalEnvelopeData(t, env.Data, &self)
assert.Equal(t, "selfuser", self.Username)
}
func TestGETOptionRequiresRootAuth(t *testing.T) {
dbConn, r := setupAuthOptionIntegration(t)
seedUser(t, dbConn, "commonuser", "password123", false)
seedUser(t, dbConn, "rootuser", "password123", true)
commonToken := loginAndGetToken(t, r, "commonuser", "password123")
rootToken := loginAndGetToken(t, r, "rootuser", "password123")
t.Run("unauthenticated", func(t *testing.T) {
w := performJSONRequest(t, r, http.MethodGet, "/api/option/", nil, nil)
assert.Equal(t, http.StatusUnauthorized, w.Code)
env := decodeEnvelope(t, w)
assert.False(t, env.Success)
})
t.Run("common user forbidden", func(t *testing.T) {
w := performJSONRequest(t, r, http.MethodGet, "/api/option/", nil, map[string]string{
compat.OpenFlareTokenHeader(): commonToken,
})
assert.Equal(t, http.StatusOK, w.Code)
env := decodeEnvelope(t, w)
assert.False(t, env.Success)
assert.Contains(t, env.Message, "权限不足")
})
t.Run("root user allowed", func(t *testing.T) {
w := performJSONRequest(t, r, http.MethodGet, "/api/option/", nil, map[string]string{
compat.OpenFlareTokenHeader(): rootToken,
})
assert.Equal(t, http.StatusOK, w.Code)
env := decodeEnvelope(t, w)
assert.True(t, env.Success, "message=%s", env.Message)
})
}
func TestOptionHotReloadAfterUpdate(t *testing.T) {
dbConn, r := setupAuthOptionIntegration(t)
seedUser(t, dbConn, "admin", "password123", true)
rootToken := loginAndGetToken(t, r, "admin", "password123")
statusBefore := getStatusSystemName(t, r, nil)
assert.NotEmpty(t, statusBefore)
updateResp := performJSONRequest(t, r, http.MethodPost, "/api/option/update", map[string]string{
"key": "SystemName",
"value": "HotReloadIntegration",
}, map[string]string{
compat.OpenFlareTokenHeader(): rootToken,
})
assert.Equal(t, http.StatusOK, updateResp.Code)
updateEnv := decodeEnvelope(t, updateResp)
assert.True(t, updateEnv.Success, "message=%s", updateEnv.Message)
statusAfter := getStatusSystemName(t, r, nil)
assert.Equal(t, "HotReloadIntegration", statusAfter)
assert.Equal(t, "HotReloadIntegration", model.SystemName)
ctx := context.Background()
require.NoError(t, option.EnsureInitialized(ctx))
assert.Equal(t, "HotReloadIntegration", model.OptionValue("SystemName"))
}
func loginAndGetToken(t *testing.T, r http.Handler, username, password string) string {
t.Helper()
w := performJSONRequest(t, r, http.MethodPost, "/api/user/login", map[string]string{
"username": username,
"password": password,
}, nil)
env := decodeEnvelope(t, w)
require.True(t, env.Success, "login failed: %s", env.Message)
var user legacyUserPayload
unmarshalEnvelopeData(t, env.Data, &user)
require.NotEmpty(t, user.Token)
return user.Token
}
func getStatusSystemName(t *testing.T, r http.Handler, headers map[string]string) string {
t.Helper()
w := performJSONRequest(t, r, http.MethodGet, "/api/status", nil, headers)
require.Equal(t, http.StatusOK, w.Code)
env := decodeEnvelope(t, w)
require.True(t, env.Success, "message=%s", env.Message)
var status statusPayload
unmarshalEnvelopeData(t, env.Data, &status)
return status.SystemName
}
@@ -0,0 +1,291 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package integration
import (
"net/http"
"testing"
"time"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/agent"
oflegacy "github.com/Rain-kl/Wavelet/internal/apps/openflare/legacy"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/option"
"github.com/Rain-kl/Wavelet/internal/db"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
const (
adminUserID = uint64(1001)
adminUsername = "openflare-admin"
)
type adminSeed struct {
User model.User
Token string
TokenHash string
}
func setupCoreChainTest(t *testing.T) (*gin.Engine, adminSeed, func()) {
t.Helper()
sqliteDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
DisableForeignKeyConstraintWhenMigrating: true,
})
require.NoError(t, err)
require.NoError(t, sqliteDB.AutoMigrate(
&model.User{},
&model.AccessToken{},
&model.Origin{},
&model.ProxyRoute{},
&model.ConfigVersion{},
&model.OpenFlareWAFRuleGroup{},
&model.OpenFlareWAFRuleGroupBinding{},
&model.OpenFlareWAFIPGroup{},
&model.OpenFlareNode{},
&model.OpenFlareOption{},
&model.OpenFlareApplyLog{},
))
db.SetDB(sqliteDB)
option.ResetInitializationForTest()
agent.ResetAuthCacheForTest()
seed, err := seedAdminWithAccessToken(sqliteDB)
require.NoError(t, err)
gin.SetMode(gin.TestMode)
engine := gin.New()
apiGroup := engine.Group("/api")
oflegacy.RegisterRoutes(apiGroup)
cleanup := func() {
db.SetDB(nil)
option.ResetInitializationForTest()
agent.ResetAuthCacheForTest()
}
return engine, seed, cleanup
}
func seedAdminWithAccessToken(conn *gorm.DB) (adminSeed, error) {
now := time.Now().UTC()
admin := model.User{
ID: adminUserID,
Username: adminUsername,
Nickname: "OpenFlare Admin",
IsActive: true,
IsAdmin: true,
LastLoginAt: now,
}
if err := conn.Create(&admin).Error; err != nil {
return adminSeed{}, err
}
token, err := model.GenerateTokenString()
if err != nil {
return adminSeed{}, err
}
tokenHash := model.HashToken(token)
tokenRecord := model.AccessToken{
UserID: adminUserID,
Name: "integration-admin-token",
TokenHash: tokenHash,
MaskedToken: model.MaskTokenString(token),
IsAdmin: true,
}
if err := conn.Create(&tokenRecord).Error; err != nil {
return adminSeed{}, err
}
return adminSeed{
User: admin,
Token: token,
TokenHash: tokenHash,
}, nil
}
func TestCoreChainMigrationFlow(t *testing.T) {
engine, seed, cleanup := setupCoreChainTest(t)
defer cleanup()
var (
originID uint
proxyRouteID uint
configVersion string
configChecksum string
nodeID uint
nodePublicID string
agentToken string
)
t.Run("create origin", func(t *testing.T) {
rec := performJSONRequest(t, engine, http.MethodPost, "/api/origins/", map[string]any{
"name": "Primary Origin",
"address": "origin.core-chain.internal",
"remark": "integration upstream",
}, map[string]string{
"X-Access-Token": seed.Token,
})
require.Equal(t, http.StatusOK, rec.Code)
envelope := decodeEnvelope(t, rec)
require.True(t, envelope.Success, envelope.Message)
data := unmarshalEnvelopeMap(t, envelope.Data)
originID = uint(data["id"].(float64))
assert.NotZero(t, originID)
assert.Equal(t, "Primary Origin", data["name"])
assert.Equal(t, "origin.core-chain.internal", data["address"])
})
t.Run("create proxy route linked to origin", func(t *testing.T) {
rec := performJSONRequest(t, engine, http.MethodPost, "/api/proxy-routes/", map[string]any{
"site_name": "core-chain-site",
"domain": "core-chain.example.com",
"origin_id": originID,
"origin_scheme": "http",
"origin_port": "8080",
"enabled": true,
}, map[string]string{
"X-Access-Token": seed.Token,
})
require.Equal(t, http.StatusOK, rec.Code)
envelope := decodeEnvelope(t, rec)
require.True(t, envelope.Success, envelope.Message)
data := unmarshalEnvelopeMap(t, envelope.Data)
proxyRouteID = uint(data["id"].(float64))
assert.NotZero(t, proxyRouteID)
assert.Equal(t, "core-chain-site", data["site_name"])
assert.Equal(t, "core-chain.example.com", data["domain"])
assert.Equal(t, float64(originID), data["origin_id"])
assert.Equal(t, "http://origin.core-chain.internal:8080", data["origin_url"])
})
t.Run("publish config version", func(t *testing.T) {
rec := performJSONRequest(t, engine, http.MethodPost, "/api/config-versions/publish", nil, map[string]string{
"X-Access-Token": seed.Token,
})
require.Equal(t, http.StatusOK, rec.Code)
envelope := decodeEnvelope(t, rec)
require.True(t, envelope.Success, envelope.Message)
data := unmarshalEnvelopeMap(t, envelope.Data)
configVersion, _ = data["version"].(string)
configChecksum, _ = data["checksum"].(string)
assert.NotEmpty(t, configVersion)
assert.NotEmpty(t, configChecksum)
assert.Equal(t, true, data["is_active"])
activeRec := performJSONRequest(t, engine, http.MethodGet, "/api/config-versions/active", nil, map[string]string{
"X-Access-Token": seed.Token,
})
require.Equal(t, http.StatusOK, activeRec.Code)
activeEnvelope := decodeEnvelope(t, activeRec)
require.True(t, activeEnvelope.Success, activeEnvelope.Message)
activeData := unmarshalEnvelopeMap(t, activeEnvelope.Data)
assert.Equal(t, configVersion, activeData["version"])
assert.Equal(t, configChecksum, activeData["checksum"])
})
t.Run("create node", func(t *testing.T) {
rec := performJSONRequest(t, engine, http.MethodPost, "/api/nodes/", map[string]any{
"name": "edge-core-chain",
"ip": "10.10.0.1",
"auto_update_enabled": true,
}, map[string]string{
"X-Access-Token": seed.Token,
})
require.Equal(t, http.StatusOK, rec.Code)
envelope := decodeEnvelope(t, rec)
require.True(t, envelope.Success, envelope.Message)
data := unmarshalEnvelopeMap(t, envelope.Data)
nodeID = uint(data["id"].(float64))
nodePublicID, _ = data["node_id"].(string)
agentToken, _ = data["access_token"].(string)
assert.NotZero(t, nodeID)
assert.NotEmpty(t, nodePublicID)
assert.Len(t, agentToken, 32)
})
t.Run("create apply log for node", func(t *testing.T) {
rec := performJSONRequest(t, engine, http.MethodPost, "/api/agent/apply-logs", map[string]any{
"version": configVersion,
"result": "success",
"message": "config applied",
"checksum": configChecksum,
"main_config_checksum": "main-checksum",
"route_config_checksum": "route-checksum",
"support_file_count": 2,
}, map[string]string{
"X-Agent-Token": agentToken,
})
require.Equal(t, http.StatusOK, rec.Code)
envelope := decodeEnvelope(t, rec)
require.True(t, envelope.Success, envelope.Message)
data := unmarshalEnvelopeMap(t, envelope.Data)
assert.Equal(t, nodePublicID, data["node_id"])
assert.Equal(t, configVersion, data["version"])
assert.Equal(t, "success", data["result"])
assert.Equal(t, configChecksum, data["checksum"])
})
t.Run("verify apply log listing and node metadata", func(t *testing.T) {
listRec := performJSONRequest(
t,
engine,
http.MethodGet,
"/api/apply-logs/?node_id="+nodePublicID+"&pageNo=1&pageSize=10",
nil,
map[string]string{
"X-Access-Token": seed.Token,
},
)
require.Equal(t, http.StatusOK, listRec.Code)
listEnvelope := decodeEnvelope(t, listRec)
require.True(t, listEnvelope.Success, listEnvelope.Message)
listData := unmarshalEnvelopeMap(t, listEnvelope.Data)
assert.Equal(t, float64(1), listData["total"])
rows, ok := listData["rows"].([]any)
require.True(t, ok)
require.Len(t, rows, 1)
row, ok := rows[0].(map[string]any)
require.True(t, ok)
assert.Equal(t, nodePublicID, row["node_id"])
assert.Equal(t, configVersion, row["version"])
assert.Equal(t, "success", row["result"])
nodeRec := performJSONRequest(t, engine, http.MethodGet, "/api/nodes/", nil, map[string]string{
"X-Access-Token": seed.Token,
})
require.Equal(t, http.StatusOK, nodeRec.Code)
nodeEnvelope := decodeEnvelope(t, nodeRec)
require.True(t, nodeEnvelope.Success, nodeEnvelope.Message)
nodes := unmarshalEnvelopeSlice(t, nodeEnvelope.Data)
require.Len(t, nodes, 1)
nodeView, ok := nodes[0].(map[string]any)
require.True(t, ok)
assert.Equal(t, float64(nodeID), nodeView["id"])
assert.Equal(t, nodePublicID, nodeView["node_id"])
assert.Equal(t, "success", nodeView["latest_apply_result"])
assert.Equal(t, configChecksum, nodeView["latest_apply_checksum"])
assert.Equal(t, float64(2), nodeView["latest_support_file_count"])
})
}
@@ -0,0 +1,93 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package integration
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/compat"
"github.com/stretchr/testify/require"
)
func decodeEnvelope(t *testing.T, rec *httptest.ResponseRecorder) compat.Envelope {
t.Helper()
var envelope compat.Envelope
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &envelope))
return envelope
}
func unmarshalEnvelopeData(t *testing.T, data any, target any) {
t.Helper()
payload, err := json.Marshal(data)
require.NoError(t, err)
require.NoError(t, json.Unmarshal(payload, target))
}
func unmarshalEnvelopeMap(t *testing.T, data any) map[string]any {
t.Helper()
var result map[string]any
unmarshalEnvelopeData(t, data, &result)
return result
}
func unmarshalEnvelopeSlice(t *testing.T, data any) []any {
t.Helper()
var result []any
unmarshalEnvelopeData(t, data, &result)
return result
}
func performJSONRequest(
t *testing.T,
engine http.Handler,
method, path string,
body any,
headers map[string]string,
) *httptest.ResponseRecorder {
t.Helper()
var payload []byte
if body != nil {
var err error
payload, err = json.Marshal(body)
require.NoError(t, err)
}
req := httptest.NewRequest(method, path, bytes.NewReader(payload))
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
for key, value := range headers {
req.Header.Set(key, value)
}
rec := httptest.NewRecorder()
engine.ServeHTTP(rec, req)
return rec
}
func performLegacyRequest(
t *testing.T,
engine http.Handler,
method, path string,
body any,
headers map[string]string,
) *httptest.ResponseRecorder {
t.Helper()
return performJSONRequest(t, engine, method, path, body, headers)
}
func adminAuthHeaders(token string) map[string]string {
return map[string]string{
"X-Access-Token": token,
}
}
@@ -0,0 +1,386 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package integration
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"fmt"
"math/big"
"net/http"
"testing"
"time"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/legacy"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/option"
"github.com/Rain-kl/Wavelet/internal/config"
"github.com/Rain-kl/Wavelet/internal/db"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/internal/testhelper"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func setupSecurityTest(t *testing.T) (*gin.Engine, adminSeed, func()) {
t.Helper()
sqliteDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
DisableForeignKeyConstraintWhenMigrating: true,
})
require.NoError(t, err)
require.NoError(t, sqliteDB.AutoMigrate(
&model.User{},
&model.AccessToken{},
&model.Origin{},
&model.ProxyRoute{},
&model.OpenFlareWAFRuleGroup{},
&model.OpenFlareWAFRuleGroupBinding{},
&model.OpenFlareWAFIPGroup{},
&model.TLSCertificate{},
&model.ManagedDomain{},
&model.DNSAccount{},
&model.AcmeAccount{},
))
db.SetDB(sqliteDB)
option.ResetInitializationForTest()
seed, err := seedAdminWithAccessToken(sqliteDB)
require.NoError(t, err)
oldSecret := config.Config.App.SessionSecret
config.Config.App.SessionSecret = "test_session_secret_for_security_integration"
engine := testhelper.NewTestGinEngine()
apiGroup := engine.Group("/api")
legacy.RegisterRoutes(apiGroup)
cleanup := func() {
config.Config.App.SessionSecret = oldSecret
db.SetDB(nil)
option.ResetInitializationForTest()
}
return engine, seed, cleanup
}
func generateSelfSignedCertificatePair(t *testing.T, dnsNames []string) (string, string) {
t.Helper()
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)
template := &x509.Certificate{
SerialNumber: big.NewInt(time.Now().UnixNano()),
Subject: pkix.Name{
CommonName: dnsNames[0],
},
DNSNames: dnsNames,
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(24 * time.Hour),
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
}
certDER, err := x509.CreateCertificate(rand.Reader, template, template, &privateKey.PublicKey, privateKey)
require.NoError(t, err)
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(privateKey)})
return string(certPEM), string(keyPEM)
}
func TestSecurityWAFTLSMigrationFlow(t *testing.T) {
engine, seed, cleanup := setupSecurityTest(t)
defer cleanup()
var (
ruleGroupID uint
ipGroupID uint
proxyRouteID uint
certID uint
domainID uint
dnsAccountID uint
)
t.Run("WAF rule group create", func(t *testing.T) {
rec := performLegacyRequest(t, engine, http.MethodPost, "/api/waf/rule-groups", map[string]any{
"name": "edge-security",
"enabled": true,
"block_status_code": 403,
"ip_whitelist": []string{"192.0.2.1"},
"ip_blacklist": []string{"203.0.113.10"},
"country_blacklist": []string{"CN"},
"remark": "integration rule group",
}, adminAuthHeaders(seed.Token))
require.Equal(t, http.StatusOK, rec.Code)
envelope := decodeEnvelope(t, rec)
require.True(t, envelope.Success, envelope.Message)
data := unmarshalEnvelopeMap(t, envelope.Data)
ruleGroupID = uint(data["id"].(float64))
assert.NotZero(t, ruleGroupID)
assert.Equal(t, "edge-security", data["name"])
assert.Equal(t, false, data["is_global"])
assert.Equal(t, float64(403), data["block_status_code"])
})
t.Run("WAF rule group list includes global and custom groups", func(t *testing.T) {
rec := performLegacyRequest(t, engine, http.MethodGet, "/api/waf/rule-groups", nil, adminAuthHeaders(seed.Token))
require.Equal(t, http.StatusOK, rec.Code)
envelope := decodeEnvelope(t, rec)
require.True(t, envelope.Success, envelope.Message)
groups := unmarshalEnvelopeSlice(t, envelope.Data)
require.GreaterOrEqual(t, len(groups), 2)
foundCustom := false
foundGlobal := false
for _, item := range groups {
group, ok := item.(map[string]any)
require.True(t, ok)
if group["is_global"] == true {
foundGlobal = true
}
if uint(group["id"].(float64)) == ruleGroupID {
foundCustom = true
assert.Equal(t, "edge-security", group["name"])
}
}
assert.True(t, foundGlobal)
assert.True(t, foundCustom)
})
t.Run("WAF rule group get detail", func(t *testing.T) {
rec := performLegacyRequest(
t,
engine,
http.MethodGet,
fmt.Sprintf("/api/waf/rule-groups/%d", ruleGroupID),
nil,
adminAuthHeaders(seed.Token),
)
require.Equal(t, http.StatusOK, rec.Code)
envelope := decodeEnvelope(t, rec)
require.True(t, envelope.Success, envelope.Message)
data := unmarshalEnvelopeMap(t, envelope.Data)
assert.Equal(t, float64(ruleGroupID), data["id"])
assert.Equal(t, "edge-security", data["name"])
})
t.Run("WAF rule group update", func(t *testing.T) {
rec := performLegacyRequest(
t,
engine,
http.MethodPost,
fmt.Sprintf("/api/waf/rule-groups/%d/update", ruleGroupID),
map[string]any{
"name": "edge-security-updated",
"enabled": true,
"block_status_code": 451,
"remark": "updated by integration test",
},
adminAuthHeaders(seed.Token),
)
require.Equal(t, http.StatusOK, rec.Code)
envelope := decodeEnvelope(t, rec)
require.True(t, envelope.Success, envelope.Message)
data := unmarshalEnvelopeMap(t, envelope.Data)
assert.Equal(t, "edge-security-updated", data["name"])
assert.Equal(t, float64(451), data["block_status_code"])
})
t.Run("WAF IP group create", func(t *testing.T) {
rec := performLegacyRequest(t, engine, http.MethodPost, "/api/waf/ip-groups", map[string]any{
"name": "blocked-ips",
"type": "manual",
"enabled": true,
"ip_list": []string{"203.0.113.0/24", "198.51.100.10"},
"remark": "manual deny list",
}, adminAuthHeaders(seed.Token))
require.Equal(t, http.StatusOK, rec.Code)
envelope := decodeEnvelope(t, rec)
require.True(t, envelope.Success, envelope.Message)
data := unmarshalEnvelopeMap(t, envelope.Data)
ipGroupID = uint(data["id"].(float64))
assert.NotZero(t, ipGroupID)
assert.Equal(t, "blocked-ips", data["name"])
assert.Equal(t, "manual", data["type"])
})
t.Run("create proxy route for WAF binding", func(t *testing.T) {
rec := performLegacyRequest(t, engine, http.MethodPost, "/api/proxy-routes/", map[string]any{
"site_name": "security-site",
"domain": "security.example.com",
"origin_url": "http://origin.security.internal:8080",
"enabled": true,
}, adminAuthHeaders(seed.Token))
require.Equal(t, http.StatusOK, rec.Code)
envelope := decodeEnvelope(t, rec)
require.True(t, envelope.Success, envelope.Message)
data := unmarshalEnvelopeMap(t, envelope.Data)
proxyRouteID = uint(data["id"].(float64))
assert.NotZero(t, proxyRouteID)
assert.Equal(t, "security.example.com", data["domain"])
})
t.Run("bind WAF rule group to proxy route", func(t *testing.T) {
rec := performLegacyRequest(
t,
engine,
http.MethodPost,
fmt.Sprintf("/api/waf/sites/%d/rule-groups", proxyRouteID),
map[string]any{
"ids": []uint{ruleGroupID},
},
adminAuthHeaders(seed.Token),
)
require.Equal(t, http.StatusOK, rec.Code)
envelope := decodeEnvelope(t, rec)
require.True(t, envelope.Success, envelope.Message)
data := unmarshalEnvelopeMap(t, envelope.Data)
assert.Equal(t, float64(proxyRouteID), data["route_id"])
appliedIDs, ok := data["applied_ids"].([]any)
require.True(t, ok)
require.Len(t, appliedIDs, 1)
assert.Equal(t, float64(ruleGroupID), appliedIDs[0])
})
t.Run("verify site rule groups binding", func(t *testing.T) {
rec := performLegacyRequest(
t,
engine,
http.MethodGet,
fmt.Sprintf("/api/waf/sites/%d/rule-groups", proxyRouteID),
nil,
adminAuthHeaders(seed.Token),
)
require.Equal(t, http.StatusOK, rec.Code)
envelope := decodeEnvelope(t, rec)
require.True(t, envelope.Success, envelope.Message)
data := unmarshalEnvelopeMap(t, envelope.Data)
assert.NotNil(t, data["global_rule_group"])
appliedGroups, ok := data["applied_rule_groups"].([]any)
require.True(t, ok)
require.Len(t, appliedGroups, 1)
group, ok := appliedGroups[0].(map[string]any)
require.True(t, ok)
assert.Equal(t, float64(ruleGroupID), group["id"])
})
t.Run("create TLS certificate with PEM", func(t *testing.T) {
certPEM, keyPEM := generateSelfSignedCertificatePair(t, []string{"security.example.com"})
rec := performLegacyRequest(t, engine, http.MethodPost, "/api/tls-certificates/", map[string]any{
"name": "security-cert",
"cert_pem": certPEM,
"key_pem": keyPEM,
"remark": "self-signed integration cert",
}, adminAuthHeaders(seed.Token))
require.Equal(t, http.StatusOK, rec.Code)
envelope := decodeEnvelope(t, rec)
require.True(t, envelope.Success, envelope.Message)
data := unmarshalEnvelopeMap(t, envelope.Data)
certID = uint(data["id"].(float64))
assert.NotZero(t, certID)
assert.Equal(t, "security-cert", data["name"])
assert.Equal(t, "upload", data["provider"])
})
t.Run("create managed domain", func(t *testing.T) {
rec := performLegacyRequest(t, engine, http.MethodPost, "/api/managed-domains/", map[string]any{
"domain": "security.example.com",
"cert_id": certID,
"enabled": true,
"remark": "primary security domain",
}, adminAuthHeaders(seed.Token))
require.Equal(t, http.StatusOK, rec.Code)
envelope := decodeEnvelope(t, rec)
require.True(t, envelope.Success, envelope.Message)
data := unmarshalEnvelopeMap(t, envelope.Data)
domainID = uint(data["id"].(float64))
assert.NotZero(t, domainID)
assert.Equal(t, "security.example.com", data["domain"])
assert.Equal(t, float64(certID), data["cert_id"])
assert.Equal(t, true, data["enabled"])
})
t.Run("create DNS account", func(t *testing.T) {
rec := performLegacyRequest(t, engine, http.MethodPost, "/api/dns-accounts/", map[string]any{
"name": "cloudflare-dns",
"type": "cloudflare",
"authorization": "test-api-token-value",
}, adminAuthHeaders(seed.Token))
require.Equal(t, http.StatusOK, rec.Code)
envelope := decodeEnvelope(t, rec)
require.True(t, envelope.Success, envelope.Message)
data := unmarshalEnvelopeMap(t, envelope.Data)
dnsAccountID = uint(data["id"].(float64))
assert.NotZero(t, dnsAccountID)
assert.Equal(t, "cloudflare-dns", data["name"])
assert.Equal(t, "cloudflare", data["type"])
// API 响应会脱敏 authorization,不应回显明文凭证。
if auth, ok := data["authorization"]; ok {
assert.NotEqual(t, "test-api-token-value", auth)
}
})
t.Run("WAF rule group delete", func(t *testing.T) {
rec := performLegacyRequest(
t,
engine,
http.MethodPost,
fmt.Sprintf("/api/waf/rule-groups/%d/delete", ruleGroupID),
nil,
adminAuthHeaders(seed.Token),
)
require.Equal(t, http.StatusOK, rec.Code)
envelope := decodeEnvelope(t, rec)
require.True(t, envelope.Success, envelope.Message)
detailRec := performLegacyRequest(
t,
engine,
http.MethodGet,
fmt.Sprintf("/api/waf/rule-groups/%d", ruleGroupID),
nil,
adminAuthHeaders(seed.Token),
)
require.Equal(t, http.StatusOK, detailRec.Code)
detailEnvelope := decodeEnvelope(t, detailRec)
assert.False(t, detailEnvelope.Success)
})
_ = ipGroupID
_ = domainID
_ = dnsAccountID
}
@@ -0,0 +1,251 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package legacy
import (
"fmt"
"strconv"
"strings"
ofauth "github.com/Rain-kl/Wavelet/internal/apps/openflare/auth"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/compat"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/gin-gonic/gin"
)
type legacyUserPayload struct {
ID int `json:"id"`
Username string `json:"username"`
Password string `json:"password"`
DisplayName string `json:"display_name"`
Role int `json:"role"`
Email string `json:"email"`
}
type manageUserRequest struct {
Username string `json:"username"`
Action string `json:"action"`
}
type authSourcePayload struct {
Name string `json:"name"`
Type string `json:"type"`
DisplayName string `json:"display_name"`
IsActive bool `json:"is_active"`
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret"`
OpenIDDiscoveryURL string `json:"openid_discovery_url"`
Scopes string `json:"scopes"`
IconURL string `json:"icon_url"`
}
type authSourceTogglePayload struct {
IsActive bool `json:"is_active"`
}
func GetAllUsers(c *gin.Context) {
page, _ := strconv.Atoi(c.Query("p"))
users, err := ofauth.ListUsers(c.Request.Context(), page)
if err != nil {
compat.Fail(c, err.Error())
return
}
compat.OK(c, users)
}
func SearchUsers(c *gin.Context) {
users, err := ofauth.SearchUsers(c.Request.Context(), c.Query("keyword"))
if err != nil {
compat.Fail(c, err.Error())
return
}
compat.OK(c, users)
}
func GetUser(c *gin.Context) {
id, ok := compat.IDParam(c)
if !ok {
return
}
user, err := ofauth.GetUserByID(c.Request.Context(), callerRole(c), uint64(id))
if err != nil {
compat.Fail(c, err.Error())
return
}
compat.OK(c, user)
}
func CreateUser(c *gin.Context) {
var req legacyUserPayload
if !compat.BindJSON(c, &req) {
return
}
if err := ofauth.CreateUser(c.Request.Context(), callerRole(c), ofauth.CreateUserInput{
Username: req.Username,
Password: req.Password,
DisplayName: req.DisplayName,
Role: req.Role,
Email: req.Email,
}); err != nil {
compat.Fail(c, err.Error())
return
}
compat.OKMessage(c, "")
}
func UpdateUser(c *gin.Context) {
var req legacyUserPayload
if !compat.BindJSON(c, &req) {
return
}
if err := ofauth.UpdateUser(c.Request.Context(), callerRole(c), ofauth.UpdateUserInput{
ID: req.ID,
Username: req.Username,
Password: req.Password,
DisplayName: req.DisplayName,
Role: req.Role,
Email: req.Email,
}); err != nil {
compat.Fail(c, err.Error())
return
}
compat.OKMessage(c, "")
}
func DeleteUser(c *gin.Context) {
id, ok := compat.IDParam(c)
if !ok {
return
}
if err := ofauth.DeleteUserByID(c.Request.Context(), callerRole(c), uint64(id)); err != nil {
compat.Fail(c, err.Error())
return
}
compat.OKMessage(c, "")
}
func ManageUser(c *gin.Context) {
var req manageUserRequest
if !compat.BindJSON(c, &req) {
return
}
user, err := ofauth.ManageUser(c.Request.Context(), callerRole(c), ofauth.ManageUserInput{
Username: req.Username,
Action: req.Action,
})
if err != nil {
compat.Fail(c, err.Error())
return
}
compat.OK(c, user)
}
func ListAuthSources(c *gin.Context) {
sources, err := model.GetAuthSources(c.Request.Context())
if err != nil {
compat.Fail(c, err.Error())
return
}
compat.OK(c, sources)
}
func CreateAuthSource(c *gin.Context) {
var payload authSourcePayload
if !compat.BindJSON(c, &payload) {
return
}
source := payload.toModel()
if err := model.CreateAuthSource(c.Request.Context(), &source); err != nil {
compat.Fail(c, err.Error())
return
}
source.Sanitize()
compat.OK(c, source)
}
func UpdateAuthSource(c *gin.Context) {
id, err := parseAuthSourceID(c)
if err != nil {
compat.Fail(c, err.Error())
return
}
var payload authSourcePayload
if !compat.BindJSON(c, &payload) {
return
}
source := payload.toModel()
source.ID = id
keepSecret := strings.TrimSpace(source.ClientSecret) == ""
if err := model.UpdateAuthSource(c.Request.Context(), &source, keepSecret); err != nil {
compat.Fail(c, err.Error())
return
}
updated, err := model.GetAuthSourceByID(c.Request.Context(), id)
if err != nil {
compat.Fail(c, err.Error())
return
}
updated.Sanitize()
compat.OK(c, updated)
}
func DeleteAuthSource(c *gin.Context) {
id, err := parseAuthSourceID(c)
if err != nil {
compat.Fail(c, err.Error())
return
}
if err := model.DeleteAuthSource(c.Request.Context(), id); err != nil {
compat.Fail(c, err.Error())
return
}
compat.OKMessage(c, "")
}
func ToggleAuthSource(c *gin.Context) {
id, err := parseAuthSourceID(c)
if err != nil {
compat.Fail(c, err.Error())
return
}
var payload authSourceTogglePayload
if !compat.BindJSON(c, &payload) {
return
}
if err := model.ToggleAuthSource(c.Request.Context(), id, payload.IsActive); err != nil {
compat.Fail(c, err.Error())
return
}
compat.OKMessage(c, "")
}
func (payload authSourcePayload) toModel() model.AuthSource {
return model.AuthSource{
Name: payload.Name,
Type: payload.Type,
DisplayName: payload.DisplayName,
IsActive: payload.IsActive,
ClientID: payload.ClientID,
ClientSecret: payload.ClientSecret,
OpenIDDiscoveryURL: payload.OpenIDDiscoveryURL,
Scopes: payload.Scopes,
IconURL: payload.IconURL,
}
}
func parseAuthSourceID(c *gin.Context) (uint64, error) {
raw := strings.TrimSpace(c.Param("id"))
if raw == "" {
return 0, fmt.Errorf("认证源 ID 无效")
}
source, err := model.GetAuthSourceByName(c.Request.Context(), raw)
if err == nil {
return source.ID, nil
}
parsed, err := strconv.ParseUint(raw, 10, 64)
if err != nil || parsed == 0 {
return 0, fmt.Errorf("认证源 ID 无效")
}
return parsed, nil
}
@@ -0,0 +1,87 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package legacy
import (
"strconv"
"strings"
ofauth "github.com/Rain-kl/Wavelet/internal/apps/openflare/auth"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/compat"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/gin-gonic/gin"
)
type linkExistingRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
// OAuthAuthorize starts OAuth authorization for a legacy auth source.
func OAuthAuthorize(c *gin.Context) {
url, err := ofauth.OAuthAuthorize(c.Request.Context(), c, c.Param("source"))
if err != nil {
compat.Fail(c, err.Error())
return
}
compat.OK(c, gin.H{"authorize_url": url})
}
// OAuthCallback handles the legacy GET OAuth callback.
func OAuthCallback(c *gin.Context) {
result, err := ofauth.OAuthCallback(c.Request.Context(), c, c.Param("source"))
if err != nil {
compat.Fail(c, err.Error())
return
}
compat.OK(c, result)
}
// LinkExistingOAuthAccount binds a pending OAuth account to an existing user.
func LinkExistingOAuthAccount(c *gin.Context) {
var req linkExistingRequest
if !compat.BindJSON(c, &req) {
return
}
result, err := ofauth.LinkExistingOAuthAccount(c.Request.Context(), c, ofauth.LinkExistingInput{
Username: req.Username,
Password: req.Password,
})
if err != nil {
compat.Fail(c, err.Error())
return
}
compat.OK(c, result)
}
// ListExternalAccounts returns external account bindings for the current user.
func ListExternalAccounts(c *gin.Context) {
userID := callerUserID(c)
accounts, err := model.ListExternalAccountsByUserID(c.Request.Context(), userID)
if err != nil {
compat.Fail(c, err.Error())
return
}
compat.OK(c, accounts)
}
// DeleteExternalAccount removes an external account binding.
func DeleteExternalAccount(c *gin.Context) {
userID := callerUserID(c)
if userID == 0 {
compat.Unauthorized(c, "无权进行此操作,未登录或 token 无效")
return
}
rawID := strings.TrimSpace(c.Param("id"))
id, err := strconv.ParseUint(rawID, 10, 64)
if err != nil || id == 0 {
compat.Fail(c, "绑定记录 ID 无效")
return
}
if err := model.DeleteExternalAccountForUser(c.Request.Context(), id, userID); err != nil {
compat.Fail(c, err.Error())
return
}
compat.OKMessage(c, "")
}
@@ -0,0 +1,150 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package legacy
import (
ofauth "github.com/Rain-kl/Wavelet/internal/apps/openflare/auth"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/compat"
"github.com/gin-gonic/gin"
)
type loginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
Code string `json:"code"`
}
type registerRequest struct {
Username string `json:"username"`
Password string `json:"password"`
Nickname string `json:"nickname"`
DisplayName string `json:"display_name"`
Email string `json:"email"`
Code string `json:"code"`
}
type updateSelfRequest struct {
Username string `json:"username"`
Password string `json:"password"`
DisplayName string `json:"display_name"`
Email string `json:"email"`
}
type passwordResetRequest struct {
Email string `json:"email"`
Token string `json:"token"`
}
func Login(c *gin.Context) {
var req loginRequest
if !compat.BindJSON(c, &req) {
return
}
user, err := ofauth.Login(c.Request.Context(), c, ofauth.LoginInput{
Username: req.Username,
Password: req.Password,
Code: req.Code,
})
if err != nil {
compat.Fail(c, err.Error())
return
}
compat.OK(c, user)
}
func Logout(c *gin.Context) {
if err := ofauth.Logout(c.Request.Context(), c); err != nil {
compat.Fail(c, err.Error())
return
}
compat.OKMessage(c, "")
}
func GetSelf(c *gin.Context) {
user, err := ofauth.GetSelf(c.Request.Context(), callerUserID(c))
if err != nil {
compat.Fail(c, err.Error())
return
}
compat.OK(c, user)
}
func UpdateSelf(c *gin.Context) {
var req updateSelfRequest
if !compat.BindJSON(c, &req) {
return
}
if err := ofauth.UpdateSelf(c.Request.Context(), callerUserID(c), ofauth.UpdateSelfInput{
Username: req.Username,
Password: req.Password,
DisplayName: req.DisplayName,
Email: req.Email,
}); err != nil {
compat.Fail(c, err.Error())
return
}
compat.OKMessage(c, "")
}
func DeleteSelf(c *gin.Context) {
if err := ofauth.DeleteSelf(c.Request.Context(), callerUserID(c)); err != nil {
compat.Fail(c, err.Error())
return
}
compat.OKMessage(c, "")
}
func GenerateToken(c *gin.Context) {
token, err := ofauth.GenerateUserToken(c.Request.Context(), callerUserID(c))
if err != nil {
compat.Fail(c, err.Error())
return
}
compat.OK(c, token)
}
func Register(c *gin.Context) {
var req registerRequest
if !compat.BindJSON(c, &req) {
return
}
user, err := ofauth.Register(c.Request.Context(), c, ofauth.RegisterInput{
Username: req.Username,
Password: req.Password,
Nickname: req.Nickname,
DisplayName: req.DisplayName,
Email: req.Email,
Code: req.Code,
})
if err != nil {
compat.Fail(c, err.Error())
return
}
compat.OK(c, user)
}
func SendEmailVerification(c *gin.Context) {
email := c.Query("email")
if err := ofauth.SendRegisterVerificationEmail(c.Request.Context(), email); err != nil {
compat.Fail(c, err.Error())
return
}
compat.OKMessage(c, "")
}
func ResetPassword(c *gin.Context) {
var req passwordResetRequest
if !compat.BindJSON(c, &req) {
return
}
password, err := ofauth.ResetPassword(c.Request.Context(), ofauth.PasswordResetInput{
Email: req.Email,
Token: req.Token,
})
if err != nil {
compat.Fail(c, err.Error())
return
}
compat.OK(c, password)
}
@@ -0,0 +1,73 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package legacy
import (
"net/http"
"github.com/Rain-kl/Wavelet/internal/apps/cap"
"github.com/gin-gonic/gin"
)
type capRedeemRequest struct {
Token string `json:"token" binding:"required"`
Solutions []int `json:"solutions" binding:"required"`
}
// GetCapChallenge generates a CAP challenge for the legacy frontend.
func GetCapChallenge(c *gin.Context) {
scope := c.Param("scope")
if scope == "" {
scope = c.Query("scope")
}
if scope == "" {
scope = "login"
}
mgr := cap.GetDefaultManager()
resp, err := mgr.Generate(c.Request.Context(), scope)
if err != nil {
c.JSON(http.StatusInternalServerError, cap.RedeemResponse{
Success: false,
Error: err.Error(),
})
return
}
c.JSON(http.StatusOK, resp)
}
// RedeemCapChallenge redeems a CAP challenge for the legacy frontend.
func RedeemCapChallenge(c *gin.Context) {
scope := c.Param("scope")
if scope == "" {
scope = c.Query("scope")
}
if scope == "" {
scope = "login"
}
var req capRedeemRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, cap.RedeemResponse{
Success: false,
Error: "无效的参数",
})
return
}
mgr := cap.GetDefaultManager()
resp, err := mgr.Redeem(c.Request.Context(), req.Token, req.Solutions, scope)
if err != nil {
c.JSON(http.StatusInternalServerError, cap.RedeemResponse{
Success: false,
Error: err.Error(),
})
return
}
if !resp.Success {
c.JSON(http.StatusBadRequest, resp)
return
}
c.JSON(http.StatusOK, resp)
}
@@ -0,0 +1,57 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package legacy
import (
"github.com/Rain-kl/Wavelet/internal/apps/cap"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/compat"
"github.com/gin-gonic/gin"
)
// bridgeOpenFlareToken maps OpenFlare-Token to X-Access-Token for compat auth middleware.
func bridgeOpenFlareToken() gin.HandlerFunc {
return compat.BridgeOpenFlareToken()
}
// legacyCapAuth verifies PoW CAPTCHA for legacy login using OpenFlare response format.
func legacyCapAuth(scope string) gin.HandlerFunc {
mgr := cap.GetDefaultManager()
return func(c *gin.Context) {
if !cap.ProtectionEnabled(c.Request.Context()) {
c.Next()
return
}
token := c.GetHeader("X-Cap-Token")
if token == "" {
compat.Fail(c, "缺少人机验证凭证")
c.Abort()
return
}
valid, err := mgr.VerifyToken(c.Request.Context(), token, scope)
if err != nil || !valid {
compat.Fail(c, "人机验证凭证无效或已过期")
c.Abort()
return
}
c.Next()
}
}
func callerRole(c *gin.Context) int {
if role, ok := c.Get("of_role"); ok {
if v, ok := role.(int); ok {
return v
}
}
return 0
}
func callerUserID(c *gin.Context) uint64 {
if id, ok := c.Get("of_user_id"); ok {
if v, ok := id.(uint64); ok {
return v
}
}
return 0
}
@@ -0,0 +1,20 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package legacy
import (
ofauth "github.com/Rain-kl/Wavelet/internal/apps/openflare/auth"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/compat"
"github.com/gin-gonic/gin"
)
// SendPasswordResetEmail sends a password reset email for the legacy frontend.
func SendPasswordResetEmail(c *gin.Context) {
email := c.Query("email")
if err := ofauth.SendPasswordResetEmail(c.Request.Context(), email); err != nil {
compat.Fail(c, err.Error())
return
}
compat.OKMessage(c, "")
}
@@ -0,0 +1,25 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package legacy registers OpenFlare /api/* compatibility routes for the old frontend.
package legacy
import "github.com/gin-gonic/gin"
// RegisterRoutes mounts all OpenFlare legacy API routes under the /api group.
func RegisterRoutes(apiGroup *gin.RouterGroup) {
registerAuthRoutes(apiGroup)
registerOptionRoutes(apiGroup)
registerOriginRoutes(apiGroup)
registerApplyLogRoutes(apiGroup)
registerProxyRouteRoutes(apiGroup)
registerNodeRoutes(apiGroup)
registerWAFRoutes(apiGroup)
registerTLSRoutes(apiGroup)
registerConfigVersionRoutes(apiGroup)
registerAgentRoutes(apiGroup)
registerPagesRoutes(apiGroup)
registerRelayFlaredRoutes(apiGroup)
registerDashboardObsRoutes(apiGroup)
registerMiscRoutes(apiGroup)
}
@@ -0,0 +1,13 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package legacy
import (
"github.com/Rain-kl/Wavelet/internal/apps/openflare/agent"
"github.com/gin-gonic/gin"
)
func registerAgentRoutes(apiGroup *gin.RouterGroup) {
agent.RegisterRoutes(apiGroup)
}
@@ -0,0 +1,19 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package legacy
import (
"github.com/Rain-kl/Wavelet/internal/apps/openflare/apply_log"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/compat"
"github.com/gin-gonic/gin"
)
func registerApplyLogRoutes(apiGroup *gin.RouterGroup) {
applyLogRoute := apiGroup.Group("/apply-logs")
applyLogRoute.Use(compat.AdminAuth())
{
applyLogRoute.GET("/", apply_log.GetApplyLogs)
applyLogRoute.POST("/cleanup", apply_log.CleanupApplyLogs)
}
}
@@ -0,0 +1,74 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package legacy
import (
"github.com/Rain-kl/Wavelet/internal/apps/openflare/compat"
"github.com/gin-gonic/gin"
)
func registerAuthRoutes(apiGroup *gin.RouterGroup) {
// /status, /notice, /about are registered by T-OPTION (option.RegisterRoutes).
apiGroup.GET("/verification", SendEmailVerification)
apiGroup.GET("/reset_password", SendPasswordResetEmail)
apiGroup.POST("/user/reset", ResetPassword)
oauthGroup := apiGroup.Group("/oauth")
{
oauthGroup.GET("/:source/authorize", OAuthAuthorize)
oauthGroup.GET("/:source/callback", OAuthCallback)
oauthGroup.POST("/link-existing", LinkExistingOAuthAccount)
externalAccounts := oauthGroup.Group("/external-accounts")
externalAccounts.Use(bridgeOpenFlareToken(), compat.UserAuth())
{
externalAccounts.GET("/", ListExternalAccounts)
externalAccounts.POST("/:id/delete", DeleteExternalAccount)
}
}
capGroup := apiGroup.Group("/cap")
{
capGroup.POST("/:scope/challenge", GetCapChallenge)
capGroup.POST("/:scope/redeem", RedeemCapChallenge)
}
userGroup := apiGroup.Group("/user")
{
userGroup.POST("/register", Register)
userGroup.POST("/login", legacyCapAuth("login"), Login)
userGroup.GET("/logout", Logout)
selfGroup := userGroup.Group("/")
selfGroup.Use(bridgeOpenFlareToken(), compat.UserAuth())
{
selfGroup.GET("/self", GetSelf)
selfGroup.POST("/self/update", UpdateSelf)
selfGroup.POST("/self/delete", DeleteSelf)
selfGroup.GET("/token", GenerateToken)
}
adminGroup := userGroup.Group("/")
adminGroup.Use(bridgeOpenFlareToken(), compat.AdminAuth())
{
adminGroup.GET("/", GetAllUsers)
adminGroup.GET("/search", SearchUsers)
adminGroup.GET("/:id", GetUser)
adminGroup.POST("/", CreateUser)
adminGroup.POST("/manage", ManageUser)
adminGroup.POST("/update", UpdateUser)
adminGroup.POST("/:id/delete", DeleteUser)
}
}
authSourceGroup := apiGroup.Group("/auth-sources")
authSourceGroup.Use(bridgeOpenFlareToken(), compat.RootAuth())
{
authSourceGroup.GET("/", ListAuthSources)
authSourceGroup.POST("/", CreateAuthSource)
authSourceGroup.POST("/:id/update", UpdateAuthSource)
authSourceGroup.POST("/:id/delete", DeleteAuthSource)
authSourceGroup.POST("/:id/toggle", ToggleAuthSource)
}
}
@@ -0,0 +1,25 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package legacy
import (
"github.com/Rain-kl/Wavelet/internal/apps/openflare/compat"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/config_version"
"github.com/gin-gonic/gin"
)
func registerConfigVersionRoutes(apiGroup *gin.RouterGroup) {
configVersionGroup := apiGroup.Group("/config-versions")
configVersionGroup.Use(compat.AdminAuth())
{
configVersionGroup.GET("/", config_version.ListConfigVersionsHandler)
configVersionGroup.GET("/active", config_version.GetActiveConfigVersionHandler)
configVersionGroup.GET("/preview", config_version.PreviewConfigVersionHandler)
configVersionGroup.GET("/diff", config_version.DiffConfigVersionHandler)
configVersionGroup.GET("/:id", config_version.GetConfigVersionHandler)
configVersionGroup.POST("/publish", config_version.PublishConfigVersionHandler)
configVersionGroup.POST("/:id/activate", config_version.ActivateConfigVersionHandler)
configVersionGroup.POST("/cleanup", config_version.CleanupConfigVersionsHandler)
}
}
@@ -0,0 +1,15 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package legacy
import (
"github.com/Rain-kl/Wavelet/internal/apps/openflare/dashboard"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/observability"
"github.com/gin-gonic/gin"
)
func registerDashboardObsRoutes(apiGroup *gin.RouterGroup) {
dashboard.RegisterRoutes(apiGroup)
observability.RegisterRoutes(apiGroup)
}
@@ -0,0 +1,22 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package legacy
import (
"github.com/Rain-kl/Wavelet/internal/apps/openflare/compat"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/update"
"github.com/gin-gonic/gin"
)
func registerMiscRoutes(apiGroup *gin.RouterGroup) {
updateRoute := apiGroup.Group("/update")
updateRoute.Use(compat.RootAuth())
{
updateRoute.GET("/latest-release", update.GetLatestReleaseHandler)
updateRoute.GET("/logs/ws", update.StreamServerUpgradeLogsHandler)
updateRoute.POST("/manual-upload", update.UploadManualServerBinaryHandler)
updateRoute.POST("/manual-upgrade", update.ConfirmManualServerUpgradeHandler)
updateRoute.POST("/upgrade", update.UpgradeServerHandler)
}
}
@@ -0,0 +1,29 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package legacy
import (
"github.com/Rain-kl/Wavelet/internal/apps/openflare/compat"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/node"
"github.com/gin-gonic/gin"
)
func registerNodeRoutes(apiGroup *gin.RouterGroup) {
nodeRoute := apiGroup.Group("/nodes")
nodeRoute.Use(compat.AdminAuth())
{
nodeRoute.GET("/bootstrap-token", node.GetBootstrapTokenHandler)
nodeRoute.POST("/bootstrap-token/rotate", node.RotateBootstrapTokenHandler)
nodeRoute.GET("/", node.ListNodesHandler)
nodeRoute.POST("/", node.CreateNodeHandler)
nodeRoute.GET("/:id/agent-release", node.GetAgentReleaseHandler)
nodeRoute.POST("/:id/update", node.UpdateNodeHandler)
nodeRoute.POST("/:id/delete", node.DeleteNodeHandler)
nodeRoute.POST("/:id/agent-update", node.RequestAgentUpdateHandler)
nodeRoute.POST("/:id/openresty-restart", node.RequestOpenrestyRestartHandler)
nodeRoute.POST("/:id/force-sync", node.RequestForceSyncHandler)
nodeRoute.GET("/:id/observability", node.GetObservabilityHandler)
nodeRoute.POST("/:id/observability/cleanup", node.CleanupHealthEventsHandler)
}
}
@@ -0,0 +1,13 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package legacy
import (
"github.com/Rain-kl/Wavelet/internal/apps/openflare/option"
"github.com/gin-gonic/gin"
)
func registerOptionRoutes(apiGroup *gin.RouterGroup) {
option.RegisterRoutes(apiGroup)
}
@@ -0,0 +1,22 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package legacy
import (
"github.com/Rain-kl/Wavelet/internal/apps/openflare/compat"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/origin"
"github.com/gin-gonic/gin"
)
func registerOriginRoutes(apiGroup *gin.RouterGroup) {
originRoute := apiGroup.Group("/origins")
originRoute.Use(compat.AdminAuth())
{
originRoute.GET("/", origin.GetOrigins)
originRoute.GET("/:id", origin.GetOrigin)
originRoute.POST("/", origin.CreateOriginHandler)
originRoute.POST("/:id/update", origin.UpdateOriginHandler)
originRoute.POST("/:id/delete", origin.DeleteOriginHandler)
}
}
@@ -0,0 +1,27 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package legacy
import (
"github.com/Rain-kl/Wavelet/internal/apps/openflare/compat"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/pages"
"github.com/gin-gonic/gin"
)
func registerPagesRoutes(apiGroup *gin.RouterGroup) {
pagesRoute := apiGroup.Group("/pages")
pagesRoute.Use(compat.AdminAuth())
{
pagesRoute.GET("/", pages.ListProjectsHandler)
pagesRoute.GET("/:id", pages.GetProjectHandler)
pagesRoute.POST("/", pages.CreateProjectHandler)
pagesRoute.POST("/:id/update", pages.UpdateProjectHandler)
pagesRoute.POST("/:id/delete", pages.DeleteProjectHandler)
pagesRoute.GET("/:id/deployments", pages.ListDeploymentsHandler)
pagesRoute.POST("/:id/deployments/upload", pages.UploadDeploymentHandler)
pagesRoute.POST("/:id/deployments/:deployment_id/activate", pages.ActivateDeploymentHandler)
pagesRoute.POST("/:id/deployments/:deployment_id/delete", pages.DeleteDeploymentHandler)
pagesRoute.GET("/deployments/:deployment_id/files", pages.ListDeploymentFilesHandler)
}
}
@@ -0,0 +1,22 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package legacy
import (
"github.com/Rain-kl/Wavelet/internal/apps/openflare/compat"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/proxy_route"
"github.com/gin-gonic/gin"
)
func registerProxyRouteRoutes(apiGroup *gin.RouterGroup) {
proxyRouteGroup := apiGroup.Group("/proxy-routes")
proxyRouteGroup.Use(compat.AdminAuth())
{
proxyRouteGroup.GET("/", proxy_route.GetProxyRoutes)
proxyRouteGroup.GET("/:id", proxy_route.GetProxyRouteHandler)
proxyRouteGroup.POST("/", proxy_route.CreateProxyRouteHandler)
proxyRouteGroup.POST("/:id/update", proxy_route.UpdateProxyRouteHandler)
proxyRouteGroup.POST("/:id/delete", proxy_route.DeleteProxyRouteHandler)
}
}
@@ -0,0 +1,28 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package legacy
import (
"github.com/Rain-kl/Wavelet/internal/apps/openflare/flared"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/relay"
"github.com/gin-gonic/gin"
)
func registerRelayFlaredRoutes(apiGroup *gin.RouterGroup) {
relayRoute := apiGroup.Group("/relay")
relayRoute.Use(relay.RelayAuth())
{
relayRoute.POST("/heartbeat", relay.PostHeartbeat)
relayRoute.GET("/ws", relay.GetWebSocket)
}
flaredRoute := apiGroup.Group("/flared")
flaredRoute.Use(flared.TunnelAuth())
{
flaredRoute.POST("/heartbeat", flared.PostHeartbeat)
flaredRoute.GET("/config/active", flared.GetActiveConfig)
flaredRoute.POST("/apply-log", flared.PostApplyLog)
flaredRoute.GET("/ws", flared.GetWebSocket)
}
}
@@ -0,0 +1,53 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package legacy
import (
"github.com/Rain-kl/Wavelet/internal/apps/openflare/compat"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/tls"
"github.com/gin-gonic/gin"
)
func registerTLSRoutes(apiGroup *gin.RouterGroup) {
managedDomainRoute := apiGroup.Group("/managed-domains")
managedDomainRoute.Use(compat.AdminAuth())
{
managedDomainRoute.GET("/", tls.GetManagedDomains)
managedDomainRoute.GET("/match", tls.MatchManagedDomainCertificateHandler)
managedDomainRoute.POST("/", tls.CreateManagedDomainHandler)
managedDomainRoute.POST("/:id/update", tls.UpdateManagedDomainHandler)
managedDomainRoute.POST("/:id/delete", tls.DeleteManagedDomainHandler)
}
tlsCertificateRoute := apiGroup.Group("/tls-certificates")
tlsCertificateRoute.Use(compat.AdminAuth())
{
tlsCertificateRoute.GET("/", tls.GetCertificates)
tlsCertificateRoute.GET("/:id", tls.GetCertificateDetail)
tlsCertificateRoute.GET("/:id/content", tls.GetCertificateContentHandler)
tlsCertificateRoute.POST("/", tls.CreateCertificateHandler)
tlsCertificateRoute.POST("/:id/update", tls.UpdateCertificateHandler)
tlsCertificateRoute.POST("/:id/update-acme", tls.UpdateACMECertificateHandler)
tlsCertificateRoute.POST("/:id/convert-acme", tls.ConvertCertificateToACMEHandler)
tlsCertificateRoute.POST("/import-file", tls.ImportCertificateFile)
tlsCertificateRoute.POST("/:id/delete", tls.DeleteCertificateHandler)
tlsCertificateRoute.POST("/apply", tls.ApplyCertificateHandler)
tlsCertificateRoute.POST("/:id/renew", tls.RenewCertificateHandler)
}
acmeAccountRoute := apiGroup.Group("/acme-accounts")
acmeAccountRoute.Use(compat.AdminAuth())
{
acmeAccountRoute.GET("/default", tls.GetDefaultAcmeAccountHandler)
}
dnsAccountRoute := apiGroup.Group("/dns-accounts")
dnsAccountRoute.Use(compat.AdminAuth())
{
dnsAccountRoute.GET("/", tls.GetDNSAccounts)
dnsAccountRoute.POST("/", tls.CreateDNSAccountHandler)
dnsAccountRoute.POST("/:id/update", tls.UpdateDNSAccountHandler)
dnsAccountRoute.POST("/:id/delete", tls.DeleteDNSAccountHandler)
}
}
@@ -0,0 +1,34 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package legacy
import (
"github.com/Rain-kl/Wavelet/internal/apps/openflare/compat"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/waf"
"github.com/gin-gonic/gin"
)
func registerWAFRoutes(apiGroup *gin.RouterGroup) {
wafRoute := apiGroup.Group("/waf")
wafRoute.Use(compat.AdminAuth())
{
wafRoute.GET("/ip-groups", waf.ListIPGroupsHandler)
wafRoute.GET("/ip-groups/:id", waf.GetIPGroupHandler)
wafRoute.POST("/ip-groups", waf.CreateIPGroupHandler)
wafRoute.POST("/ip-groups/test", waf.TestIPGroupAutoConfigHandler)
wafRoute.POST("/ip-groups/:id/update", waf.UpdateIPGroupHandler)
wafRoute.POST("/ip-groups/:id/delete", waf.DeleteIPGroupHandler)
wafRoute.POST("/ip-groups/:id/sync", waf.SyncIPGroupHandler)
wafRoute.GET("/rule-groups", waf.ListRuleGroupsHandler)
wafRoute.GET("/rule-groups/:id", waf.GetRuleGroupHandler)
wafRoute.POST("/rule-groups", waf.CreateRuleGroupHandler)
wafRoute.POST("/rule-groups/:id/update", waf.UpdateRuleGroupHandler)
wafRoute.POST("/rule-groups/:id/delete", waf.DeleteRuleGroupHandler)
wafRoute.POST("/rule-groups/:id/sites", waf.ReplaceRuleGroupSitesHandler)
wafRoute.GET("/sites/:route_id/rule-groups", waf.GetSiteRuleGroupsHandler)
wafRoute.POST("/sites/:route_id/rule-groups", waf.ReplaceSiteRuleGroupsHandler)
}
}
@@ -0,0 +1,30 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package legacy
import (
ofauth "github.com/Rain-kl/Wavelet/internal/apps/openflare/auth"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/compat"
"github.com/gin-gonic/gin"
)
// GetStatus returns public server status for the legacy frontend.
func GetStatus(c *gin.Context) {
data, err := ofauth.BuildPublicStatus(c.Request.Context())
if err != nil {
compat.Fail(c, err.Error())
return
}
compat.OK(c, data)
}
// GetNotice returns the legacy notice content.
func GetNotice(c *gin.Context) {
compat.OK(c, ofauth.GetNotice(c.Request.Context()))
}
// GetAbout returns the legacy about content.
func GetAbout(c *gin.Context) {
compat.OK(c, ofauth.GetAbout(c.Request.Context()))
}
@@ -0,0 +1,20 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package node
const (
errNodeNameRequired = "节点名不能为空"
errNodeIPTooLong = "节点 IP 不能超过 64 个字符"
errNodeIPInvalid = "节点 IP 格式无效"
errNodeIPManualRequired = "锁定节点 IP 时必须填写节点 IP"
errNodeGeoNameTooLong = "节点位置名不能超过 128 个字符"
errNodeGeoCoordinateMismatch = "地图坐标必须同时填写纬度和经度"
errNodeGeoLatitudeInvalid = "纬度必须在 -90 到 90 之间"
errNodeGeoLongitudeInvalid = "经度必须在 -180 到 180 之间"
errNodeIDConflict = "节点标识生成冲突,请重试"
errNodeNotFound = "节点不存在"
errNodeForceSyncFailed = "节点不在线或通过 WebSocket 发送同步指令失败"
errAgentPreviewTagInvalid = "指定版本不是 preview 发布"
errAgentStableTagInvalid = "正式版更新不能选择 preview 发布"
)
@@ -0,0 +1,456 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package node
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"strconv"
"strings"
"time"
"github.com/Rain-kl/Wavelet/internal/model"
)
const (
nodeStatusOnline = "online"
nodeStatusOffline = "offline"
nodeStatusPending = "pending"
openrestyStatusHealthy = "healthy"
openrestyStatusUnhealthy = "unhealthy"
openrestyStatusUnknown = "unknown"
githubReleasesAPIBase = "https://api.github.com/repos/%s/releases"
)
type releaseChannel string
const (
releaseChannelStable releaseChannel = "stable"
releaseChannelPreview releaseChannel = "preview"
)
var releaseHTTPClient = &http.Client{Timeout: 30 * time.Second}
type githubReleaseResponse struct {
TagName string `json:"tag_name"`
Body string `json:"body"`
HTMLURL string `json:"html_url"`
PublishedAt string `json:"published_at"`
Prerelease bool `json:"prerelease"`
Draft bool `json:"draft"`
}
func newRandomToken() (string, error) {
buf := make([]byte, 16)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return hex.EncodeToString(buf), nil
}
func newServerNodeID() (string, error) {
token, err := newRandomToken()
if err != nil {
return "", err
}
return "node-" + token, nil
}
func normalizeNodeType(raw string) string {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "tunnel_relay":
return "tunnel_relay"
case "tunnel_client":
return "tunnel_client"
default:
return "edge_node"
}
}
func normalizeRelayPort(port int, defaultPort int) int {
if port <= 0 || port > 65535 {
return defaultPort
}
return port
}
func normalizeReleaseChannel(channel string) releaseChannel {
switch strings.ToLower(strings.TrimSpace(channel)) {
case string(releaseChannelPreview):
return releaseChannelPreview
default:
return releaseChannelStable
}
}
func (channel releaseChannel) String() string {
if channel == releaseChannelPreview {
return string(releaseChannelPreview)
}
return string(releaseChannelStable)
}
func normalizeOpenrestyStatus(status string) string {
switch strings.ToLower(strings.TrimSpace(status)) {
case openrestyStatusHealthy:
return openrestyStatusHealthy
case openrestyStatusUnhealthy:
return openrestyStatusUnhealthy
default:
return openrestyStatusUnknown
}
}
func cloneCoordinate(value *float64) *float64 {
if value == nil {
return nil
}
cloned := *value
return &cloned
}
func resolveNodeIPManualOverride(input Input, existing *model.OpenFlareNode, normalizedIP string) bool {
if input.IPManualOverride != nil {
return *input.IPManualOverride
}
if existing == nil {
return strings.TrimSpace(normalizedIP) != ""
}
if existing.IPManualOverride {
return true
}
return strings.TrimSpace(normalizedIP) != "" && strings.TrimSpace(normalizedIP) != strings.TrimSpace(existing.IP)
}
func normalizeNodeInput(input Input) (string, string, string, *float64, *float64, bool, error) {
name := strings.TrimSpace(input.Name)
ip := strings.TrimSpace(input.IP)
geoName := strings.TrimSpace(input.GeoName)
manualOverride := input.GeoManualOverride || geoName != "" || input.GeoLatitude != nil || input.GeoLongitude != nil
if len(ip) > 64 {
return "", "", "", nil, nil, false, fmt.Errorf("%s", errNodeIPTooLong)
}
if ip != "" && net.ParseIP(ip) == nil {
return "", "", "", nil, nil, false, fmt.Errorf("%s", errNodeIPInvalid)
}
if input.IPManualOverride != nil && *input.IPManualOverride && ip == "" {
return "", "", "", nil, nil, false, fmt.Errorf("%s", errNodeIPManualRequired)
}
if len(geoName) > 128 {
return "", "", "", nil, nil, false, fmt.Errorf("%s", errNodeGeoNameTooLong)
}
geoLatitude := cloneCoordinate(input.GeoLatitude)
geoLongitude := cloneCoordinate(input.GeoLongitude)
if (geoLatitude == nil) != (geoLongitude == nil) {
return "", "", "", nil, nil, false, fmt.Errorf("%s", errNodeGeoCoordinateMismatch)
}
if geoLatitude != nil && (*geoLatitude < -90 || *geoLatitude > 90) {
return "", "", "", nil, nil, false, fmt.Errorf("%s", errNodeGeoLatitudeInvalid)
}
if geoLongitude != nil && (*geoLongitude < -180 || *geoLongitude > 180) {
return "", "", "", nil, nil, false, fmt.Errorf("%s", errNodeGeoLongitudeInvalid)
}
if !manualOverride {
return name, ip, "", nil, nil, false, nil
}
if geoLatitude == nil && geoLongitude == nil && geoName == "" {
return name, ip, "", nil, nil, false, nil
}
return name, ip, geoName, geoLatitude, geoLongitude, true, nil
}
func computeNodeStatus(node *model.OpenFlareNode) string {
if node == nil {
return nodeStatusOffline
}
if node.LastSeenAt == nil || node.LastSeenAt.IsZero() {
return nodeStatusPending
}
if time.Since(*node.LastSeenAt) > model.NodeOfflineThreshold {
return nodeStatusOffline
}
return nodeStatusOnline
}
func nodeViewLastSeenAt(node *model.OpenFlareNode) any {
if node == nil || node.LastSeenAt == nil {
return time.Time{}
}
return *node.LastSeenAt
}
func buildNodeView(node *model.OpenFlareNode) *View {
if node == nil {
return nil
}
status := computeNodeStatus(node)
view := &View{
ID: node.ID,
NodeID: node.NodeID,
Name: node.Name,
IP: node.IP,
IPManualOverride: node.IPManualOverride,
GeoName: strings.TrimSpace(node.GeoName),
GeoLatitude: node.GeoLatitude,
GeoLongitude: node.GeoLongitude,
GeoManualOverride: node.GeoManualOverride,
AccessToken: node.AccessToken,
UpdateChannel: strings.TrimSpace(node.UpdateChannel),
UpdateTag: strings.TrimSpace(node.UpdateTag),
RestartOpenrestyRequested: node.RestartOpenrestyRequested,
Version: node.Version,
ExtVersion: node.ExtVersion,
OpenrestyStatus: normalizeOpenrestyStatus(node.OpenrestyStatus),
OpenrestyMessage: strings.TrimSpace(node.OpenrestyMessage),
Status: status,
CurrentVersion: node.CurrentVersion,
LastSeenAt: nodeViewLastSeenAt(node),
LastError: node.LastError,
CreatedAt: node.CreatedAt,
UpdatedAt: node.UpdatedAt,
AutoUpdateEnabled: node.AutoUpdateEnabled,
UpdateRequested: node.UpdateRequested,
NodeType: node.NodeType,
RelayBindPort: node.RelayBindPort,
RelayVhostHTTPPort: node.RelayVhostHTTPPort,
RelayAgentAccessAddr: node.RelayAgentAccessAddr,
RelayClientAccessAddr: node.RelayClientAccessAddr,
RelayClientProxyURL: node.RelayClientProxyURL,
RelayStatus: node.RelayStatus,
RelayWebServerEnabled: node.RelayWebServerEnabled,
}
if view.UpdateChannel == "" {
view.UpdateChannel = releaseChannelStable.String()
}
if view.NodeType == "" {
view.NodeType = "edge_node"
}
return view
}
func buildNodeAgentReleaseView(node *model.OpenFlareNode, release *githubReleaseResponse, channel releaseChannel) *AgentReleaseInfo {
currentVersion := strings.TrimSpace(node.Version)
view := &AgentReleaseInfo{
CurrentVersion: currentVersion,
Channel: channel.String(),
UpdateRequested: node.UpdateRequested,
RequestedChannel: normalizeReleaseChannel(node.UpdateChannel).String(),
RequestedTag: strings.TrimSpace(node.UpdateTag),
}
if release == nil {
return view
}
view.TagName = release.TagName
view.Body = release.Body
view.HTMLURL = release.HTMLURL
view.PublishedAt = release.PublishedAt
view.Prerelease = release.Prerelease
view.HasUpdate = isVersionNewer(currentVersion, release.TagName)
return view
}
func isVersionNewer(current string, latest string) bool {
return compareVersions(current, latest) < 0
}
func compareVersions(local, remote string) int {
left := parseVersionInfo(local)
right := parseVersionInfo(remote)
if left.isDev {
if right.valid {
return -1
}
return 0
}
if !left.valid || !right.valid {
return 0
}
maxLen := len(left.numbers)
if len(right.numbers) > maxLen {
maxLen = len(right.numbers)
}
for index := 0; index < maxLen; index++ {
leftValue := 0
rightValue := 0
if index < len(left.numbers) {
leftValue = left.numbers[index]
}
if index < len(right.numbers) {
rightValue = right.numbers[index]
}
if leftValue < rightValue {
return -1
}
if leftValue > rightValue {
return 1
}
}
return 0
}
type versionInfo struct {
valid bool
isDev bool
numbers []int
}
func parseVersionInfo(version string) versionInfo {
normalized := strings.TrimSpace(strings.TrimPrefix(version, "v"))
if normalized == "" || normalized == "dev" {
return versionInfo{isDev: strings.EqualFold(normalized, "dev")}
}
base := normalized
if separator := strings.IndexRune(normalized, '-'); separator >= 0 {
base = normalized[:separator]
}
segments := strings.Split(base, ".")
parts := make([]int, 0, len(segments))
for _, segment := range segments {
segment = strings.TrimSpace(segment)
if segment == "" {
parts = append(parts, 0)
continue
}
numeric := strings.Builder{}
for _, r := range segment {
if r < '0' || r > '9' {
break
}
numeric.WriteRune(r)
}
if numeric.Len() == 0 {
parts = append(parts, 0)
continue
}
value, err := strconv.Atoi(numeric.String())
if err != nil {
return versionInfo{}
}
parts = append(parts, value)
}
return versionInfo{valid: len(parts) > 0, numbers: parts}
}
func fetchLatestGitHubRelease(ctx context.Context, repo string, channel releaseChannel) (*githubReleaseResponse, error) {
switch normalizeReleaseChannel(string(channel)) {
case releaseChannelPreview:
return fetchLatestPreviewGitHubRelease(ctx, repo)
default:
return fetchLatestStableGitHubRelease(ctx, repo)
}
}
func fetchLatestStableGitHubRelease(ctx context.Context, repo string) (*githubReleaseResponse, error) {
url := fmt.Sprintf(githubReleasesAPIBase+"/latest", strings.TrimSpace(repo))
req, err := newGitHubReleaseRequest(ctx, url)
if err != nil {
return nil, fmt.Errorf("创建更新请求失败")
}
resp, err := releaseHTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("获取最新版本失败: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("GitHub 返回异常状态: %s", resp.Status)
}
return decodeGitHubRelease(resp.Body)
}
func fetchLatestPreviewGitHubRelease(ctx context.Context, repo string) (*githubReleaseResponse, error) {
url := fmt.Sprintf(githubReleasesAPIBase+"?per_page=20", strings.TrimSpace(repo))
req, err := newGitHubReleaseRequest(ctx, url)
if err != nil {
return nil, fmt.Errorf("创建更新请求失败")
}
resp, err := releaseHTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("获取 preview 版本失败: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("GitHub 返回异常状态: %s", resp.Status)
}
var releases []githubReleaseResponse
if err = json.NewDecoder(resp.Body).Decode(&releases); err != nil {
return nil, fmt.Errorf("解析 preview 版本信息失败")
}
for _, release := range releases {
if release.Draft || !release.Prerelease {
continue
}
releaseCopy := release
return &releaseCopy, nil
}
return nil, fmt.Errorf("当前没有可用的 preview 发布")
}
func fetchGitHubReleaseByTag(ctx context.Context, repo string, tag string) (*githubReleaseResponse, error) {
tag = strings.TrimSpace(tag)
if tag == "" {
return nil, fmt.Errorf("缺少发布版本号")
}
url := fmt.Sprintf(githubReleasesAPIBase+"/tags/%s", strings.TrimSpace(repo), tag)
req, err := newGitHubReleaseRequest(ctx, url)
if err != nil {
return nil, fmt.Errorf("创建更新请求失败")
}
resp, err := releaseHTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("获取指定版本失败: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return nil, fmt.Errorf("未找到指定版本: %s", tag)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("GitHub 返回异常状态: %s", resp.Status)
}
return decodeGitHubRelease(resp.Body)
}
func newGitHubReleaseRequest(ctx context.Context, url string) (*http.Request, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("User-Agent", "OpenFlare-Server")
return req, nil
}
func decodeGitHubRelease(reader io.Reader) (*githubReleaseResponse, error) {
var release githubReleaseResponse
if err := json.NewDecoder(reader).Decode(&release); err != nil {
return nil, fmt.Errorf("解析版本信息失败")
}
return &release, nil
}
func isUniqueConstraintError(err error) bool {
if err == nil {
return false
}
return strings.Contains(strings.ToLower(err.Error()), "unique")
}
func setReleaseHTTPClientForTest(client *http.Client) *http.Client {
previous := releaseHTTPClient
if client == nil {
releaseHTTPClient = &http.Client{Timeout: 30 * time.Second}
} else {
releaseHTTPClient = client
}
return previous
}
@@ -0,0 +1,393 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package node
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/observability"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/option"
"github.com/Rain-kl/Wavelet/internal/model"
)
// Input is the create/update node payload.
type Input struct {
Name string `json:"name"`
IP string `json:"ip"`
IPManualOverride *bool `json:"ip_manual_override"`
AutoUpdateEnabled bool `json:"auto_update_enabled"`
GeoName string `json:"geo_name"`
GeoLatitude *float64 `json:"geo_latitude"`
GeoLongitude *float64 `json:"geo_longitude"`
GeoManualOverride bool `json:"geo_manual_override"`
NodeType string `json:"node_type"`
RelayBindPort int `json:"relay_bind_port"`
RelayVhostHTTPPort int `json:"relay_vhost_http_port"`
RelayAgentAccessAddr string `json:"relay_agent_access_addr"`
RelayClientAccessAddr string `json:"relay_client_access_addr"`
RelayClientProxyURL string `json:"relay_client_proxy_url"`
RelayWebServerEnabled bool `json:"relay_web_server_enabled"`
}
// AgentUpdateInput requests an agent self-update on a node.
type AgentUpdateInput struct {
Channel string `json:"channel"`
TagName string `json:"tag_name"`
}
// AgentReleaseInfo describes the latest agent release for a node.
type AgentReleaseInfo struct {
TagName string `json:"tag_name"`
Body string `json:"body"`
HTMLURL string `json:"html_url"`
PublishedAt string `json:"published_at"`
CurrentVersion string `json:"current_version"`
HasUpdate bool `json:"has_update"`
Channel string `json:"channel"`
Prerelease bool `json:"prerelease"`
UpdateRequested bool `json:"update_requested"`
RequestedChannel string `json:"requested_channel"`
RequestedTag string `json:"requested_tag"`
}
// BootstrapView exposes the global discovery token.
type BootstrapView struct {
DiscoveryToken string `json:"discovery_token"`
}
// View is the admin-facing node representation.
type View struct {
ID uint `json:"id"`
NodeID string `json:"node_id"`
Name string `json:"name"`
IP string `json:"ip"`
IPManualOverride bool `json:"ip_manual_override"`
GeoName string `json:"geo_name"`
GeoLatitude *float64 `json:"geo_latitude"`
GeoLongitude *float64 `json:"geo_longitude"`
GeoManualOverride bool `json:"geo_manual_override"`
AccessToken string `json:"access_token"`
AutoUpdateEnabled bool `json:"auto_update_enabled"`
UpdateRequested bool `json:"update_requested"`
UpdateChannel string `json:"update_channel"`
UpdateTag string `json:"update_tag"`
RestartOpenrestyRequested bool `json:"restart_openresty_requested"`
Version string `json:"version"`
ExtVersion string `json:"ext_version"`
OpenrestyStatus string `json:"openresty_status"`
OpenrestyMessage string `json:"openresty_message"`
Status string `json:"status"`
CurrentVersion string `json:"current_version"`
LastSeenAt any `json:"last_seen_at"`
LastError string `json:"last_error"`
LatestApplyResult string `json:"latest_apply_result"`
LatestApplyMessage string `json:"latest_apply_message"`
LatestApplyChecksum string `json:"latest_apply_checksum"`
LatestMainConfigChecksum string `json:"latest_main_config_checksum"`
LatestRouteConfigChecksum string `json:"latest_route_config_checksum"`
LatestSupportFileCount int `json:"latest_support_file_count"`
LatestApplyAt *time.Time `json:"latest_apply_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
NodeType string `json:"node_type"`
RelayBindPort int `json:"relay_bind_port"`
RelayVhostHTTPPort int `json:"relay_vhost_http_port"`
RelayAgentAccessAddr string `json:"relay_agent_access_addr"`
RelayClientAccessAddr string `json:"relay_client_access_addr"`
RelayClientProxyURL string `json:"relay_client_proxy_url"`
RelayStatus string `json:"relay_status"`
RelayWebServerEnabled bool `json:"relay_web_server_enabled"`
}
// ObservabilityQuery filters node observability data.
type ObservabilityQuery struct {
Hours int `json:"hours"`
Limit int `json:"limit"`
}
// ObservabilityView is the node observability API response.
type ObservabilityView = observability.NodeView
// HealthEventCleanupResult reports health event cleanup outcome.
type HealthEventCleanupResult = observability.HealthEventCleanupResult
// ListNodes returns all node views with latest apply log metadata.
func ListNodes(ctx context.Context) ([]*View, error) {
nodes, err := model.ListOpenFlareNodes(ctx)
if err != nil {
return nil, err
}
nodeIDs := make([]string, 0, len(nodes))
for _, node := range nodes {
nodeIDs = append(nodeIDs, node.NodeID)
}
latestLogs, err := model.GetLatestOpenFlareApplyLogsByNodeIDs(ctx, nodeIDs)
if err != nil {
return nil, err
}
views := make([]*View, 0, len(nodes))
for _, node := range nodes {
view := buildNodeView(&node)
view.Status = computeNodeStatus(&node)
if log, ok := latestLogs[node.NodeID]; ok {
view.LatestApplyResult = log.Result
view.LatestApplyMessage = log.Message
view.LatestApplyChecksum = log.Checksum
view.LatestMainConfigChecksum = log.MainConfigChecksum
view.LatestRouteConfigChecksum = log.RouteConfigChecksum
view.LatestSupportFileCount = log.SupportFileCount
view.LatestApplyAt = &log.CreatedAt
}
views = append(views, view)
}
return views, nil
}
// CreateNode creates a reserved node with generated node_id and access_token.
func CreateNode(ctx context.Context, input Input) (*View, error) {
name, ip, geoName, geoLatitude, geoLongitude, geoManualOverride, err := normalizeNodeInput(input)
if err != nil {
return nil, err
}
if name == "" {
return nil, errors.New(errNodeNameRequired)
}
ipManualOverride := resolveNodeIPManualOverride(input, nil, ip)
node := &model.OpenFlareNode{
Name: name,
IP: ip,
IPManualOverride: ipManualOverride,
GeoName: geoName,
GeoLatitude: geoLatitude,
GeoLongitude: geoLongitude,
GeoManualOverride: geoManualOverride,
Version: "",
ExtVersion: "",
Status: nodeStatusPending,
AutoUpdateEnabled: input.AutoUpdateEnabled,
NodeType: normalizeNodeType(input.NodeType),
CapabilitiesJSON: "[]",
}
node.NodeID, err = newServerNodeID()
if err != nil {
return nil, err
}
node.AccessToken, err = newRandomToken()
if err != nil {
return nil, err
}
if node.NodeType == "tunnel_relay" {
node.RelayBindPort = normalizeRelayPort(input.RelayBindPort, 7000)
node.RelayVhostHTTPPort = normalizeRelayPort(input.RelayVhostHTTPPort, 8080)
node.RelayAuthToken, err = newRandomToken()
if err != nil {
return nil, err
}
node.RelayAgentAccessAddr = strings.TrimSpace(input.RelayAgentAccessAddr)
node.RelayClientAccessAddr = strings.TrimSpace(input.RelayClientAccessAddr)
node.RelayClientProxyURL = strings.TrimSpace(input.RelayClientProxyURL)
node.RelayWebServerEnabled = input.RelayWebServerEnabled
}
if err = model.CreateOpenFlareNode(ctx, node); err != nil {
if isUniqueConstraintError(err) {
return nil, errors.New(errNodeIDConflict)
}
return nil, err
}
return buildNodeView(node), nil
}
// UpdateNode updates an existing node.
func UpdateNode(ctx context.Context, id uint, input Input) (*View, error) {
name, ip, geoName, geoLatitude, geoLongitude, geoManualOverride, err := normalizeNodeInput(input)
if err != nil {
return nil, err
}
if name == "" {
return nil, errors.New(errNodeNameRequired)
}
node, err := model.GetOpenFlareNodeByID(ctx, id)
if err != nil {
return nil, err
}
ipManualOverride := resolveNodeIPManualOverride(input, node, ip)
node.Name = name
node.IP = ip
node.IPManualOverride = ipManualOverride
node.GeoName = geoName
node.GeoLatitude = geoLatitude
node.GeoLongitude = geoLongitude
node.GeoManualOverride = geoManualOverride
node.AutoUpdateEnabled = input.AutoUpdateEnabled
if node.NodeType == "tunnel_relay" {
node.RelayAgentAccessAddr = strings.TrimSpace(input.RelayAgentAccessAddr)
node.RelayClientAccessAddr = strings.TrimSpace(input.RelayClientAccessAddr)
node.RelayClientProxyURL = strings.TrimSpace(input.RelayClientProxyURL)
node.RelayWebServerEnabled = input.RelayWebServerEnabled
if input.RelayBindPort > 0 {
node.RelayBindPort = input.RelayBindPort
}
if input.RelayVhostHTTPPort > 0 {
node.RelayVhostHTTPPort = input.RelayVhostHTTPPort
}
}
if err = model.SaveOpenFlareNode(ctx, node); err != nil {
return nil, err
}
return buildNodeView(node), nil
}
// DeleteNode removes a node by id.
func DeleteNode(ctx context.Context, id uint) error {
if _, err := model.GetOpenFlareNodeByID(ctx, id); err != nil {
return err
}
return model.DeleteOpenFlareNode(ctx, id)
}
// GetBootstrapToken returns the global discovery token, creating one if missing.
func GetBootstrapToken(ctx context.Context) (*BootstrapView, error) {
token, err := ensureGlobalDiscoveryToken(ctx)
if err != nil {
return nil, err
}
return &BootstrapView{DiscoveryToken: token}, nil
}
// RotateBootstrapToken rotates the global discovery token.
func RotateBootstrapToken(ctx context.Context) (*BootstrapView, error) {
token, err := newRandomToken()
if err != nil {
return nil, err
}
if err = model.UpdateOpenFlareOption(ctx, "AgentDiscoveryToken", token); err != nil {
return nil, err
}
return &BootstrapView{DiscoveryToken: token}, nil
}
// GetAgentRelease checks the latest agent release for a node.
func GetAgentRelease(ctx context.Context, id uint, channel string) (*AgentReleaseInfo, error) {
node, err := model.GetOpenFlareNodeByID(ctx, id)
if err != nil {
return nil, err
}
release, err := fetchLatestGitHubRelease(ctx, model.AgentUpdateRepo, normalizeReleaseChannel(channel))
if err != nil {
return nil, err
}
return buildNodeAgentReleaseView(node, release, normalizeReleaseChannel(channel)), nil
}
// RequestAgentUpdate marks a node for manual agent update.
func RequestAgentUpdate(ctx context.Context, id uint, input AgentUpdateInput) (*View, error) {
node, err := model.GetOpenFlareNodeByID(ctx, id)
if err != nil {
return nil, err
}
channel := normalizeReleaseChannel(input.Channel)
tagName := strings.TrimSpace(input.TagName)
if tagName != "" {
release, releaseErr := fetchGitHubReleaseByTag(ctx, model.AgentUpdateRepo, tagName)
if releaseErr != nil {
return nil, releaseErr
}
if channel == releaseChannelPreview && !release.Prerelease {
return nil, errors.New(errAgentPreviewTagInvalid)
}
if channel == releaseChannelStable && release.Prerelease {
return nil, errors.New(errAgentStableTagInvalid)
}
}
node.UpdateRequested = true
node.UpdateChannel = channel.String()
node.UpdateTag = tagName
if err = model.UpdateOpenFlareNodeFields(ctx, node, "update_requested", "update_channel", "update_tag"); err != nil {
return nil, err
}
return buildNodeView(node), nil
}
// RequestOpenrestyRestart marks a node for openresty restart.
func RequestOpenrestyRestart(ctx context.Context, id uint) (*View, error) {
node, err := model.GetOpenFlareNodeByID(ctx, id)
if err != nil {
return nil, err
}
node.RestartOpenrestyRequested = true
if err = model.UpdateOpenFlareNodeFields(ctx, node, "restart_openresty_requested"); err != nil {
return nil, err
}
return buildNodeView(node), nil
}
// RequestForceSync requests a force sync via websocket (stub until T-AGENT).
func RequestForceSync(ctx context.Context, id uint) (*View, error) {
node, err := model.GetOpenFlareNodeByID(ctx, id)
if err != nil {
return nil, err
}
if !sendAgentWSForceSyncConfig(node.NodeID) {
return nil, errors.New(errNodeForceSyncFailed)
}
return buildNodeView(node), nil
}
// GetObservability returns observability details for a node.
func GetObservability(ctx context.Context, id uint, query ObservabilityQuery) (*ObservabilityView, error) {
return observability.GetNodeObservability(ctx, id, observability.NodeQuery{
Hours: query.Hours,
Limit: query.Limit,
})
}
// CleanupHealthEvents removes all health events for a node.
func CleanupHealthEvents(ctx context.Context, id uint) (*HealthEventCleanupResult, error) {
return observability.CleanupHealthEvents(ctx, id)
}
func ensureGlobalDiscoveryToken(ctx context.Context) (string, error) {
if err := option.EnsureInitialized(ctx); err != nil {
return "", err
}
model.OptionMapRWMutex.RLock()
token := strings.TrimSpace(model.AgentDiscoveryToken)
model.OptionMapRWMutex.RUnlock()
if token != "" {
return token, nil
}
token, err := newRandomToken()
if err != nil {
return "", err
}
if err = model.UpdateOpenFlareOption(ctx, "AgentDiscoveryToken", token); err != nil {
return "", err
}
return token, nil
}
func sendAgentWSForceSyncConfig(nodeID string) bool {
_ = nodeID
return false
}
// ValidateDiscoveryToken validates the global discovery token.
func ValidateDiscoveryToken(ctx context.Context, token string) error {
token = strings.TrimSpace(token)
if token == "" {
return fmt.Errorf("缺少 Discovery Token")
}
discoveryToken, err := ensureGlobalDiscoveryToken(ctx)
if err != nil {
return err
}
if token != discoveryToken {
return fmt.Errorf("Discovery Token 无效")
}
return nil
}
@@ -0,0 +1,291 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package node
import (
"context"
"io"
"net/http"
"strings"
"testing"
"time"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/option"
"github.com/Rain-kl/Wavelet/internal/db"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func setupNodeTestDB(t *testing.T) func() {
t.Helper()
sqliteDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
DisableForeignKeyConstraintWhenMigrating: true,
})
require.NoError(t, err)
require.NoError(t, sqliteDB.AutoMigrate(
&model.OpenFlareNode{},
&model.OpenFlareOption{},
&model.OpenFlareApplyLog{},
))
db.SetDB(sqliteDB)
option.ResetInitializationForTest()
return func() {
db.SetDB(nil)
option.ResetInitializationForTest()
}
}
func TestCreateEdgeNode(t *testing.T) {
cleanup := setupNodeTestDB(t)
defer cleanup()
ctx := context.Background()
view, err := CreateNode(ctx, Input{
Name: "edge-1",
IP: "10.0.0.1",
AutoUpdateEnabled: true,
})
require.NoError(t, err)
assert.NotZero(t, view.ID)
assert.True(t, strings.HasPrefix(view.NodeID, "node-"))
assert.Len(t, view.AccessToken, 32)
assert.Equal(t, "edge_node", view.NodeType)
assert.Equal(t, nodeStatusPending, view.Status)
assert.True(t, view.AutoUpdateEnabled)
}
func TestCreateTunnelRelayNode(t *testing.T) {
cleanup := setupNodeTestDB(t)
defer cleanup()
ctx := context.Background()
view, err := CreateNode(ctx, Input{
Name: "relay-1",
NodeType: "tunnel_relay",
})
require.NoError(t, err)
assert.Equal(t, "tunnel_relay", view.NodeType)
assert.Equal(t, 7000, view.RelayBindPort)
assert.Equal(t, 8080, view.RelayVhostHTTPPort)
stored, err := model.GetOpenFlareNodeByID(ctx, view.ID)
require.NoError(t, err)
assert.NotEmpty(t, stored.RelayAuthToken)
}
func TestCreateTunnelClientNode(t *testing.T) {
cleanup := setupNodeTestDB(t)
defer cleanup()
ctx := context.Background()
view, err := CreateNode(ctx, Input{
Name: "client-1",
NodeType: "tunnel_client",
})
require.NoError(t, err)
assert.Equal(t, "tunnel_client", view.NodeType)
}
func TestCreateNodeRequiresName(t *testing.T) {
cleanup := setupNodeTestDB(t)
defer cleanup()
ctx := context.Background()
_, err := CreateNode(ctx, Input{IP: "10.0.0.2"})
require.Error(t, err)
assert.Equal(t, errNodeNameRequired, err.Error())
}
func TestUpdateNode(t *testing.T) {
cleanup := setupNodeTestDB(t)
defer cleanup()
ctx := context.Background()
created, err := CreateNode(ctx, Input{Name: "edge-update"})
require.NoError(t, err)
updated, err := UpdateNode(ctx, created.ID, Input{
Name: "edge-updated",
IP: "192.168.1.10",
AutoUpdateEnabled: true,
})
require.NoError(t, err)
assert.Equal(t, "edge-updated", updated.Name)
assert.Equal(t, "192.168.1.10", updated.IP)
assert.True(t, updated.AutoUpdateEnabled)
}
func TestDeleteNode(t *testing.T) {
cleanup := setupNodeTestDB(t)
defer cleanup()
ctx := context.Background()
created, err := CreateNode(ctx, Input{Name: "edge-delete"})
require.NoError(t, err)
require.NoError(t, DeleteNode(ctx, created.ID))
_, err = model.GetOpenFlareNodeByID(ctx, created.ID)
require.Error(t, err)
assert.ErrorIs(t, err, gorm.ErrRecordNotFound)
}
func TestListNodesWithApplyLogMetadata(t *testing.T) {
cleanup := setupNodeTestDB(t)
defer cleanup()
ctx := context.Background()
created, err := CreateNode(ctx, Input{Name: "edge-list"})
require.NoError(t, err)
applyAt := time.Now().UTC().Truncate(time.Second)
require.NoError(t, db.DB(ctx).Create(&model.OpenFlareApplyLog{
NodeID: created.NodeID,
Version: "20260618-001",
Result: "success",
Message: "ok",
Checksum: "checksum-1",
MainConfigChecksum: "main-1",
RouteConfigChecksum: "route-1",
SupportFileCount: 3,
CreatedAt: applyAt,
}).Error)
views, err := ListNodes(ctx)
require.NoError(t, err)
require.Len(t, views, 1)
assert.Equal(t, "success", views[0].LatestApplyResult)
assert.Equal(t, "checksum-1", views[0].LatestApplyChecksum)
assert.Equal(t, 3, views[0].LatestSupportFileCount)
require.NotNil(t, views[0].LatestApplyAt)
assert.Equal(t, applyAt, views[0].LatestApplyAt.UTC())
}
func TestBootstrapTokenLifecycle(t *testing.T) {
cleanup := setupNodeTestDB(t)
defer cleanup()
ctx := context.Background()
first, err := GetBootstrapToken(ctx)
require.NoError(t, err)
assert.Len(t, first.DiscoveryToken, 32)
second, err := GetBootstrapToken(ctx)
require.NoError(t, err)
assert.Equal(t, first.DiscoveryToken, second.DiscoveryToken)
rotated, err := RotateBootstrapToken(ctx)
require.NoError(t, err)
assert.NotEqual(t, first.DiscoveryToken, rotated.DiscoveryToken)
assert.Equal(t, rotated.DiscoveryToken, model.OptionValue("AgentDiscoveryToken"))
}
func TestValidateDiscoveryToken(t *testing.T) {
cleanup := setupNodeTestDB(t)
defer cleanup()
ctx := context.Background()
bootstrap, err := GetBootstrapToken(ctx)
require.NoError(t, err)
require.NoError(t, ValidateDiscoveryToken(ctx, bootstrap.DiscoveryToken))
require.Error(t, ValidateDiscoveryToken(ctx, "invalid-token"))
}
func TestRequestAgentUpdateWithPreviewTag(t *testing.T) {
cleanup := setupNodeTestDB(t)
defer cleanup()
ctx := context.Background()
created, err := CreateNode(ctx, Input{Name: "edge-update-agent"})
require.NoError(t, err)
originalClient := setReleaseHTTPClientForTest(&http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
expected := "https://api.github.com/repos/" + model.AgentUpdateRepo + "/releases/tags/v0.5.0-rc.1"
require.Equal(t, expected, req.URL.String())
return &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(`{"tag_name":"v0.5.0-rc.1","prerelease":true}`)),
}, nil
}),
})
t.Cleanup(func() {
setReleaseHTTPClientForTest(originalClient)
})
updated, err := RequestAgentUpdate(ctx, created.ID, AgentUpdateInput{
Channel: "preview",
TagName: "v0.5.0-rc.1",
})
require.NoError(t, err)
assert.True(t, updated.UpdateRequested)
assert.Equal(t, "preview", updated.UpdateChannel)
assert.Equal(t, "v0.5.0-rc.1", updated.UpdateTag)
}
func TestRequestOpenrestyRestart(t *testing.T) {
cleanup := setupNodeTestDB(t)
defer cleanup()
ctx := context.Background()
created, err := CreateNode(ctx, Input{Name: "edge-restart"})
require.NoError(t, err)
updated, err := RequestOpenrestyRestart(ctx, created.ID)
require.NoError(t, err)
assert.True(t, updated.RestartOpenrestyRequested)
}
func TestRequestForceSyncStub(t *testing.T) {
cleanup := setupNodeTestDB(t)
defer cleanup()
ctx := context.Background()
created, err := CreateNode(ctx, Input{Name: "edge-sync"})
require.NoError(t, err)
_, err = RequestForceSync(ctx, created.ID)
require.Error(t, err)
assert.Equal(t, errNodeForceSyncFailed, err.Error())
}
func TestGetObservabilityStub(t *testing.T) {
cleanup := setupNodeTestDB(t)
defer cleanup()
ctx := context.Background()
created, err := CreateNode(ctx, Input{Name: "edge-obs"})
require.NoError(t, err)
view, err := GetObservability(ctx, created.ID, ObservabilityQuery{Hours: 24, Limit: 50})
require.NoError(t, err)
assert.Equal(t, created.NodeID, view.NodeID)
assert.Empty(t, view.MetricSnapshots)
}
func TestComputeNodeStatus(t *testing.T) {
now := time.Now()
pending := &model.OpenFlareNode{}
assert.Equal(t, nodeStatusPending, computeNodeStatus(pending))
online := &model.OpenFlareNode{LastSeenAt: &now}
assert.Equal(t, nodeStatusOnline, computeNodeStatus(online))
offlineAt := now.Add(-model.NodeOfflineThreshold - time.Minute)
offline := &model.OpenFlareNode{LastSeenAt: &offlineAt}
assert.Equal(t, nodeStatusOffline, computeNodeStatus(offline))
}
type roundTripFunc func(req *http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
@@ -0,0 +1,192 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package node
import (
"encoding/json"
"errors"
"io"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/compat"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
func handleLogicError(c *gin.Context, err error) bool {
if err == nil {
return false
}
if errors.Is(err, gorm.ErrRecordNotFound) {
compat.Fail(c, errNodeNotFound)
return true
}
compat.Fail(c, err.Error())
return true
}
// ListNodesHandler lists all nodes.
func ListNodesHandler(c *gin.Context) {
nodes, err := ListNodes(c.Request.Context())
if handleLogicError(c, err) {
return
}
compat.OK(c, nodes)
}
// CreateNodeHandler creates a node.
func CreateNodeHandler(c *gin.Context) {
var input Input
if !compat.BindJSON(c, &input) {
return
}
view, err := CreateNode(c.Request.Context(), input)
if handleLogicError(c, err) {
return
}
compat.OK(c, view)
}
// UpdateNodeHandler updates a node.
func UpdateNodeHandler(c *gin.Context) {
id, ok := compat.IDParam(c)
if !ok {
return
}
var input Input
if !compat.BindJSON(c, &input) {
return
}
view, err := UpdateNode(c.Request.Context(), id, input)
if handleLogicError(c, err) {
return
}
compat.OK(c, view)
}
// DeleteNodeHandler deletes a node.
func DeleteNodeHandler(c *gin.Context) {
id, ok := compat.IDParam(c)
if !ok {
return
}
if err := DeleteNode(c.Request.Context(), id); handleLogicError(c, err) {
return
}
compat.OKMessage(c, "")
}
// GetBootstrapTokenHandler returns the global discovery token.
func GetBootstrapTokenHandler(c *gin.Context) {
view, err := GetBootstrapToken(c.Request.Context())
if handleLogicError(c, err) {
return
}
compat.OK(c, view)
}
// RotateBootstrapTokenHandler rotates the global discovery token.
func RotateBootstrapTokenHandler(c *gin.Context) {
view, err := RotateBootstrapToken(c.Request.Context())
if handleLogicError(c, err) {
return
}
compat.OK(c, view)
}
// GetAgentReleaseHandler returns the latest agent release for a node.
func GetAgentReleaseHandler(c *gin.Context) {
id, ok := compat.IDParam(c)
if !ok {
return
}
release, err := GetAgentRelease(c.Request.Context(), id, c.Query("channel"))
if handleLogicError(c, err) {
return
}
compat.OK(c, release)
}
// RequestAgentUpdateHandler requests agent self-update on a node.
func RequestAgentUpdateHandler(c *gin.Context) {
id, ok := compat.IDParam(c)
if !ok {
return
}
var request AgentUpdateInput
if c.Request.ContentLength > 0 {
if err := bindOptionalJSON(c.Request.Body, &request); err != nil {
compat.Fail(c, "参数错误")
return
}
}
view, err := RequestAgentUpdate(c.Request.Context(), id, request)
if handleLogicError(c, err) {
return
}
compat.OK(c, view)
}
// RequestOpenrestyRestartHandler requests openresty restart on a node.
func RequestOpenrestyRestartHandler(c *gin.Context) {
id, ok := compat.IDParam(c)
if !ok {
return
}
view, err := RequestOpenrestyRestart(c.Request.Context(), id)
if handleLogicError(c, err) {
return
}
compat.OK(c, view)
}
// RequestForceSyncHandler requests force sync on a node.
func RequestForceSyncHandler(c *gin.Context) {
id, ok := compat.IDParam(c)
if !ok {
return
}
view, err := RequestForceSync(c.Request.Context(), id)
if handleLogicError(c, err) {
return
}
compat.OK(c, view)
}
// GetObservabilityHandler returns node observability details.
func GetObservabilityHandler(c *gin.Context) {
id, ok := compat.IDParam(c)
if !ok {
return
}
var query ObservabilityQuery
if err := c.ShouldBindQuery(&query); err != nil {
compat.Fail(c, "参数错误")
return
}
view, err := GetObservability(c.Request.Context(), id, query)
if handleLogicError(c, err) {
return
}
compat.OK(c, view)
}
// CleanupHealthEventsHandler cleans up node health events.
func CleanupHealthEventsHandler(c *gin.Context) {
id, ok := compat.IDParam(c)
if !ok {
return
}
result, err := CleanupHealthEvents(c.Request.Context(), id)
if handleLogicError(c, err) {
return
}
compat.OK(c, result)
}
func bindOptionalJSON(body io.Reader, target any) error {
if err := json.NewDecoder(body).Decode(target); err != nil && !errors.Is(err, io.EOF) {
return err
}
return nil
}
@@ -0,0 +1,636 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package observability
import (
"context"
"errors"
"strings"
"time"
"github.com/Rain-kl/Wavelet/internal/model"
)
const (
defaultAccessLogPageSize = 20
maxAccessLogPageSize = 200
defaultAccessLogSortBy = "logged_at"
defaultAccessLogSortOrder = "desc"
defaultAccessLogFoldMinute = 3
defaultIPTrendHours = 24
defaultIPTrendBucketMinute = 30
maxIPTrendHours = 168
nodeAccessLogRetentionDays = 90
)
var nodeAccessLogRetentionWindow = nodeAccessLogRetentionDays * 24 * time.Hour
// AccessLogQuery filters access log list queries.
type AccessLogQuery struct {
NodeID string `json:"node_id"`
RemoteAddr string `json:"remote_addr"`
Host string `json:"host"`
Path string `json:"path"`
Page int `json:"page"`
PageSize int `json:"page_size"`
SortBy string `json:"sort_by"`
SortOrder string `json:"sort_order"`
FoldMinutes int `json:"fold_minutes"`
}
// AccessLogView is a single access log row.
type AccessLogView struct {
ID uint `json:"id"`
NodeID string `json:"node_id"`
NodeName string `json:"node_name"`
LoggedAt time.Time `json:"logged_at"`
RemoteAddr string `json:"remote_addr"`
Region string `json:"region"`
Host string `json:"host"`
Path string `json:"path"`
StatusCode int `json:"status_code"`
}
// AccessLogList is a paginated access log response.
type AccessLogList struct {
Items []AccessLogView `json:"items"`
Page int `json:"page"`
PageSize int `json:"page_size"`
HasMore bool `json:"has_more"`
TotalRecord int64 `json:"total_record"`
TotalIP int64 `json:"total_ip"`
}
// FoldedAccessLogView is a folded access log bucket.
type FoldedAccessLogView struct {
BucketStartedAt time.Time `json:"bucket_started_at"`
RequestCount int64 `json:"request_count"`
UniqueIPCount int64 `json:"unique_ip_count"`
UniqueHostCount int64 `json:"unique_host_count"`
SuccessCount int64 `json:"success_count"`
ClientErrorCount int64 `json:"client_error_count"`
ServerErrorCount int64 `json:"server_error_count"`
}
// FoldedAccessLogList is a paginated folded access log response.
type FoldedAccessLogList struct {
Items []FoldedAccessLogView `json:"items"`
Page int `json:"page"`
PageSize int `json:"page_size"`
HasMore bool `json:"has_more"`
TotalBucket int64 `json:"total_bucket"`
TotalRecord int64 `json:"total_record"`
TotalIP int64 `json:"total_ip"`
FoldMinutes int `json:"fold_minutes"`
}
// FoldedAccessLogIPQuery filters folded IP summary queries.
type FoldedAccessLogIPQuery struct {
NodeID string `json:"node_id"`
RemoteAddr string `json:"remote_addr"`
Host string `json:"host"`
Path string `json:"path"`
BucketStartedAt string `json:"bucket_started_at"`
FoldMinutes int `json:"fold_minutes"`
Page int `json:"page"`
PageSize int `json:"page_size"`
SortBy string `json:"sort_by"`
SortOrder string `json:"sort_order"`
}
// FoldedAccessLogIPView is a folded IP row.
type FoldedAccessLogIPView struct {
RemoteAddr string `json:"remote_addr"`
RequestCount int64 `json:"request_count"`
SuccessCount int64 `json:"success_count"`
ClientErrorCount int64 `json:"client_error_count"`
ServerErrorCount int64 `json:"server_error_count"`
LastSeenAt time.Time `json:"last_seen_at"`
}
// FoldedAccessLogIPList is a paginated folded IP response.
type FoldedAccessLogIPList struct {
Items []FoldedAccessLogIPView `json:"items"`
Page int `json:"page"`
PageSize int `json:"page_size"`
HasMore bool `json:"has_more"`
TotalIP int64 `json:"total_ip"`
BucketStartedAt time.Time `json:"bucket_started_at"`
FoldMinutes int `json:"fold_minutes"`
SortBy string `json:"sort_by"`
SortOrder string `json:"sort_order"`
}
// AccessLogIPSummaryQuery filters IP summary list queries.
type AccessLogIPSummaryQuery struct {
NodeID string `json:"node_id"`
RemoteAddr string `json:"remote_addr"`
Host string `json:"host"`
Page int `json:"page"`
PageSize int `json:"page_size"`
SortBy string `json:"sort_by"`
SortOrder string `json:"sort_order"`
}
// AccessLogIPSummaryView is an IP summary row.
type AccessLogIPSummaryView struct {
RemoteAddr string `json:"remote_addr"`
TotalRequests int64 `json:"total_requests"`
RecentRequests int64 `json:"recent_requests"`
LastSeenAt time.Time `json:"last_seen_at"`
}
// AccessLogIPSummaryList is a paginated IP summary response.
type AccessLogIPSummaryList struct {
Items []AccessLogIPSummaryView `json:"items"`
Page int `json:"page"`
PageSize int `json:"page_size"`
HasMore bool `json:"has_more"`
TotalIP int64 `json:"total_ip"`
SortBy string `json:"sort_by"`
SortOrder string `json:"sort_order"`
}
// AccessLogIPTrendQuery filters IP trend queries.
type AccessLogIPTrendQuery struct {
NodeID string `json:"node_id"`
RemoteAddr string `json:"remote_addr"`
Host string `json:"host"`
Hours int `json:"hours"`
BucketMinutes int `json:"bucket_minutes"`
}
// AccessLogIPTrendPoint is an IP trend bucket.
type AccessLogIPTrendPoint struct {
BucketStartedAt time.Time `json:"bucket_started_at"`
RequestCount int64 `json:"request_count"`
}
// AccessLogIPTrendView is the IP trend response.
type AccessLogIPTrendView struct {
RemoteAddr string `json:"remote_addr"`
Hours int `json:"hours"`
BucketMinutes int `json:"bucket_minutes"`
Points []AccessLogIPTrendPoint `json:"points"`
}
// AccessLogCleanupInput is the cleanup request payload.
type AccessLogCleanupInput struct {
RetentionDays int `json:"retention_days"`
}
// AccessLogCleanupResult is the cleanup response payload.
type AccessLogCleanupResult struct {
RetentionDays int `json:"retention_days"`
DeletedCount int64 `json:"deleted_count"`
Cutoff time.Time `json:"cutoff"`
}
// ListAccessLogs returns paginated access logs.
func ListAccessLogs(ctx context.Context, input AccessLogQuery) (*AccessLogList, error) {
normalized := normalizeAccessLogQuery(input)
modelQuery := buildModelAccessLogQuery(normalized)
logs, err := model.ListOpenFlareAccessLogs(ctx, modelQuery)
if err != nil {
return nil, err
}
totalRecords, totalIPs, err := model.CountOpenFlareAccessLogs(ctx, modelQuery)
if err != nil {
return nil, err
}
nodeNames, err := listNodeNameMap(ctx, logs)
if err != nil {
return nil, err
}
views := make([]AccessLogView, 0, len(logs))
for _, item := range logs {
if item == nil {
continue
}
views = append(views, AccessLogView{
ID: item.ID,
NodeID: item.NodeID,
NodeName: nodeNames[item.NodeID],
LoggedAt: item.LoggedAt,
RemoteAddr: item.RemoteAddr,
Region: item.Region,
Host: item.Host,
Path: item.Path,
StatusCode: item.StatusCode,
})
}
return &AccessLogList{
Items: views,
Page: normalized.Page,
PageSize: normalized.PageSize,
HasMore: int64((normalized.Page+1)*normalized.PageSize) < totalRecords,
TotalRecord: totalRecords,
TotalIP: totalIPs,
}, nil
}
// ListFoldedAccessLogs returns paginated folded access logs.
func ListFoldedAccessLogs(ctx context.Context, input AccessLogQuery) (*FoldedAccessLogList, error) {
normalized := normalizeAccessLogQuery(input)
foldMinutes, err := normalizeFoldMinutes(normalized.FoldMinutes)
if err != nil {
return nil, err
}
modelQuery := buildModelAccessLogQuery(normalized)
bucketQuery := model.OpenFlareAccessLogBucketQuery{
NodeID: modelQuery.NodeID,
RemoteAddr: modelQuery.RemoteAddr,
Host: modelQuery.Host,
Path: modelQuery.Path,
Since: modelQuery.Since,
Page: normalized.Page,
PageSize: normalized.PageSize,
SortBy: normalizeFoldSortBy(input.SortBy),
SortOrder: normalized.SortOrder,
FoldMinutes: foldMinutes,
}
items, err := model.ListOpenFlareAccessLogBuckets(ctx, bucketQuery)
if err != nil {
return nil, err
}
totalBuckets, err := model.CountOpenFlareAccessLogBuckets(ctx, bucketQuery)
if err != nil {
return nil, err
}
totalRecords, totalIPs, err := model.CountOpenFlareAccessLogs(ctx, modelQuery)
if err != nil {
return nil, err
}
views := make([]FoldedAccessLogView, 0, len(items))
for _, item := range items {
if item == nil {
continue
}
views = append(views, FoldedAccessLogView{
BucketStartedAt: time.Unix(item.BucketEpoch, 0).UTC(),
RequestCount: item.RequestCount,
UniqueIPCount: item.UniqueIPCount,
UniqueHostCount: item.UniqueHostCount,
SuccessCount: item.SuccessCount,
ClientErrorCount: item.ClientErrorCount,
ServerErrorCount: item.ServerErrorCount,
})
}
return &FoldedAccessLogList{
Items: views,
Page: normalized.Page,
PageSize: normalized.PageSize,
HasMore: int64((normalized.Page+1)*normalized.PageSize) < totalBuckets,
TotalBucket: totalBuckets,
TotalRecord: totalRecords,
TotalIP: totalIPs,
FoldMinutes: foldMinutes,
}, nil
}
// ListFoldedAccessLogIPs returns paginated folded IP summaries.
func ListFoldedAccessLogIPs(ctx context.Context, input FoldedAccessLogIPQuery) (*FoldedAccessLogIPList, error) {
normalized, bucketStartedAt, err := normalizeFoldedAccessLogIPQuery(input)
if err != nil {
return nil, err
}
modelQuery := model.OpenFlareAccessLogBucketIPQuery{
NodeID: normalized.NodeID,
RemoteAddr: normalized.RemoteAddr,
Host: normalized.Host,
Path: normalized.Path,
BucketStartedAt: bucketStartedAt,
FoldMinutes: normalized.FoldMinutes,
Page: normalized.Page,
PageSize: normalized.PageSize,
SortBy: normalized.SortBy,
SortOrder: normalized.SortOrder,
}
items, err := model.ListOpenFlareAccessLogBucketIPs(ctx, modelQuery)
if err != nil {
return nil, err
}
totalIP, err := model.CountOpenFlareAccessLogBucketIPs(ctx, modelQuery)
if err != nil {
return nil, err
}
views := make([]FoldedAccessLogIPView, 0, len(items))
for _, item := range items {
if item == nil {
continue
}
views = append(views, FoldedAccessLogIPView{
RemoteAddr: item.RemoteAddr,
RequestCount: item.RequestCount,
SuccessCount: item.SuccessCount,
ClientErrorCount: item.ClientErrorCount,
ServerErrorCount: item.ServerErrorCount,
LastSeenAt: time.Unix(item.LastSeenEpoch, 0).UTC(),
})
}
return &FoldedAccessLogIPList{
Items: views,
Page: normalized.Page,
PageSize: normalized.PageSize,
HasMore: int64((normalized.Page+1)*normalized.PageSize) < totalIP,
TotalIP: totalIP,
BucketStartedAt: bucketStartedAt,
FoldMinutes: normalized.FoldMinutes,
SortBy: normalized.SortBy,
SortOrder: normalized.SortOrder,
}, nil
}
// ListAccessLogIPSummaries returns paginated IP summaries.
func ListAccessLogIPSummaries(ctx context.Context, input AccessLogIPSummaryQuery) (*AccessLogIPSummaryList, error) {
normalized := normalizeAccessLogIPSummaryQuery(input)
since := time.Now().UTC().Add(-nodeAccessLogRetentionWindow)
recentSince := time.Now().UTC().Add(-3 * time.Hour)
query := model.OpenFlareAccessLogIPSummaryQuery{
NodeID: strings.TrimSpace(normalized.NodeID),
RemoteAddr: strings.TrimSpace(normalized.RemoteAddr),
Host: strings.TrimSpace(normalized.Host),
Since: since,
Page: normalized.Page,
PageSize: normalized.PageSize,
SortBy: normalized.SortBy,
SortOrder: normalized.SortOrder,
}
items, err := model.ListOpenFlareAccessLogIPSummaries(ctx, query, recentSince)
if err != nil {
return nil, err
}
totalIP, err := model.CountOpenFlareAccessLogIPSummaries(ctx, query)
if err != nil {
return nil, err
}
views := make([]AccessLogIPSummaryView, 0, len(items))
for _, item := range items {
if item == nil {
continue
}
views = append(views, AccessLogIPSummaryView{
RemoteAddr: item.RemoteAddr,
TotalRequests: item.TotalRequests,
RecentRequests: item.RecentRequests,
LastSeenAt: time.Unix(item.LastSeenEpoch, 0).UTC(),
})
}
return &AccessLogIPSummaryList{
Items: views,
Page: normalized.Page,
PageSize: normalized.PageSize,
HasMore: int64((normalized.Page+1)*normalized.PageSize) < totalIP,
TotalIP: totalIP,
SortBy: normalized.SortBy,
SortOrder: normalized.SortOrder,
}, nil
}
// GetAccessLogIPTrend returns IP request trend points.
func GetAccessLogIPTrend(ctx context.Context, input AccessLogIPTrendQuery) (*AccessLogIPTrendView, error) {
normalized, err := normalizeAccessLogIPTrendQuery(input)
if err != nil {
return nil, err
}
points, err := model.ListOpenFlareAccessLogIPTrend(ctx, model.OpenFlareAccessLogIPTrendQuery{
NodeID: strings.TrimSpace(normalized.NodeID),
RemoteAddr: strings.TrimSpace(normalized.RemoteAddr),
Host: strings.TrimSpace(normalized.Host),
Since: time.Now().UTC().Add(-time.Duration(normalized.Hours) * time.Hour),
BucketMinutes: normalized.BucketMinutes,
})
if err != nil {
return nil, err
}
pointMap := make(map[int64]int64, len(points))
for _, item := range points {
if item == nil {
continue
}
pointMap[item.BucketEpoch] = item.RequestCount
}
bucketDuration := time.Duration(normalized.BucketMinutes) * time.Minute
start := time.Now().UTC().Add(-time.Duration(normalized.Hours) * time.Hour).Truncate(bucketDuration)
end := time.Now().UTC().Truncate(bucketDuration)
views := make([]AccessLogIPTrendPoint, 0, int(end.Sub(start)/bucketDuration)+1)
for cursor := start; !cursor.After(end); cursor = cursor.Add(bucketDuration) {
views = append(views, AccessLogIPTrendPoint{
BucketStartedAt: cursor,
RequestCount: pointMap[cursor.Unix()],
})
}
return &AccessLogIPTrendView{
RemoteAddr: normalized.RemoteAddr,
Hours: normalized.Hours,
BucketMinutes: normalized.BucketMinutes,
Points: views,
}, nil
}
// CleanupAccessLogs removes access logs older than retention days.
func CleanupAccessLogs(ctx context.Context, input AccessLogCleanupInput) (*AccessLogCleanupResult, error) {
if input.RetentionDays <= 0 || input.RetentionDays > nodeAccessLogRetentionDays {
return nil, errors.New("retention_days 必须在 1 到 90 之间")
}
cutoff := time.Now().UTC().Add(-time.Duration(input.RetentionDays) * 24 * time.Hour)
deleted, err := model.DeleteOpenFlareAccessLogsBefore(ctx, cutoff)
if err != nil {
return nil, err
}
return &AccessLogCleanupResult{
RetentionDays: input.RetentionDays,
DeletedCount: deleted,
Cutoff: cutoff,
}, nil
}
func buildModelAccessLogQuery(input AccessLogQuery) model.OpenFlareAccessLogQuery {
return model.OpenFlareAccessLogQuery{
NodeID: strings.TrimSpace(input.NodeID),
RemoteAddr: strings.TrimSpace(input.RemoteAddr),
Host: strings.TrimSpace(input.Host),
Path: strings.TrimSpace(input.Path),
Since: time.Now().UTC().Add(-nodeAccessLogRetentionWindow),
Page: input.Page,
PageSize: input.PageSize,
SortBy: input.SortBy,
SortOrder: input.SortOrder,
}
}
func listNodeNameMap(ctx context.Context, logs []*model.OpenFlareAccessLog) (map[string]string, error) {
nodeIDs := make([]string, 0, len(logs))
seen := make(map[string]struct{}, len(logs))
for _, item := range logs {
if item == nil || item.NodeID == "" {
continue
}
if _, exists := seen[item.NodeID]; exists {
continue
}
seen[item.NodeID] = struct{}{}
nodeIDs = append(nodeIDs, item.NodeID)
}
if len(nodeIDs) == 0 {
return map[string]string{}, nil
}
nodes, err := model.ListOpenFlareNodesByNodeIDs(ctx, nodeIDs)
if err != nil {
return nil, err
}
result := make(map[string]string, len(nodes))
for _, node := range nodes {
result[node.NodeID] = node.Name
}
return result, nil
}
func normalizeAccessLogQuery(input AccessLogQuery) AccessLogQuery {
return AccessLogQuery{
NodeID: strings.TrimSpace(input.NodeID),
RemoteAddr: strings.TrimSpace(input.RemoteAddr),
Host: strings.TrimSpace(input.Host),
Path: strings.TrimSpace(input.Path),
Page: normalizeAccessLogPage(input.Page),
PageSize: normalizeAccessLogPageSize(input.PageSize),
SortBy: normalizeAccessLogSortBy(input.SortBy),
SortOrder: normalizeAccessLogSortOrder(input.SortOrder),
FoldMinutes: input.FoldMinutes,
}
}
func normalizeAccessLogIPSummaryQuery(input AccessLogIPSummaryQuery) AccessLogIPSummaryQuery {
return AccessLogIPSummaryQuery{
NodeID: strings.TrimSpace(input.NodeID),
RemoteAddr: strings.TrimSpace(input.RemoteAddr),
Host: strings.TrimSpace(input.Host),
Page: normalizeAccessLogPage(input.Page),
PageSize: normalizeAccessLogPageSize(input.PageSize),
SortBy: normalizeIPSummarySortBy(input.SortBy),
SortOrder: normalizeAccessLogSortOrder(input.SortOrder),
}
}
func normalizeFoldedAccessLogIPQuery(input FoldedAccessLogIPQuery) (FoldedAccessLogIPQuery, time.Time, error) {
foldMinutes, err := normalizeFoldMinutes(input.FoldMinutes)
if err != nil {
return FoldedAccessLogIPQuery{}, time.Time{}, err
}
bucketStartedAt, err := time.Parse(time.RFC3339, strings.TrimSpace(input.BucketStartedAt))
if err != nil {
return FoldedAccessLogIPQuery{}, time.Time{}, errors.New("bucket_started_at 必须为 RFC3339 时间")
}
normalizedSortBy := strings.TrimSpace(input.SortBy)
switch normalizedSortBy {
case "last_seen_at", "remote_addr":
default:
normalizedSortBy = "request_count"
}
return FoldedAccessLogIPQuery{
NodeID: strings.TrimSpace(input.NodeID),
RemoteAddr: strings.TrimSpace(input.RemoteAddr),
Host: strings.TrimSpace(input.Host),
Path: strings.TrimSpace(input.Path),
BucketStartedAt: strings.TrimSpace(input.BucketStartedAt),
FoldMinutes: foldMinutes,
Page: normalizeAccessLogPage(input.Page),
PageSize: normalizeAccessLogPageSize(input.PageSize),
SortBy: normalizedSortBy,
SortOrder: normalizeAccessLogSortOrder(input.SortOrder),
}, bucketStartedAt.UTC(), nil
}
func normalizeAccessLogIPTrendQuery(input AccessLogIPTrendQuery) (AccessLogIPTrendQuery, error) {
remoteAddr := strings.TrimSpace(input.RemoteAddr)
if remoteAddr == "" {
return AccessLogIPTrendQuery{}, errors.New("remote_addr 不能为空")
}
hours := input.Hours
if hours <= 0 {
hours = defaultIPTrendHours
}
if hours > maxIPTrendHours {
hours = maxIPTrendHours
}
bucketMinutes := input.BucketMinutes
if bucketMinutes <= 0 {
bucketMinutes = defaultIPTrendBucketMinute
}
switch bucketMinutes {
case 5, 10, 15, 30, 60:
default:
return AccessLogIPTrendQuery{}, errors.New("bucket_minutes 仅支持 5、10、15、30、60")
}
return AccessLogIPTrendQuery{
NodeID: strings.TrimSpace(input.NodeID),
RemoteAddr: remoteAddr,
Host: strings.TrimSpace(input.Host),
Hours: hours,
BucketMinutes: bucketMinutes,
}, nil
}
func normalizeAccessLogPage(page int) int {
if page < 0 {
return 0
}
return page
}
func normalizeAccessLogPageSize(pageSize int) int {
if pageSize <= 0 {
return defaultAccessLogPageSize
}
if pageSize > maxAccessLogPageSize {
return maxAccessLogPageSize
}
return pageSize
}
func normalizeAccessLogSortBy(sortBy string) string {
switch strings.TrimSpace(sortBy) {
case "status_code", "remote_addr", "host", "path":
return strings.TrimSpace(sortBy)
default:
return defaultAccessLogSortBy
}
}
func normalizeAccessLogSortOrder(sortOrder string) string {
if strings.EqualFold(strings.TrimSpace(sortOrder), "asc") {
return "asc"
}
return defaultAccessLogSortOrder
}
func normalizeFoldSortBy(sortBy string) string {
switch strings.TrimSpace(sortBy) {
case "request_count":
return "request_count"
default:
return "bucket_started_at"
}
}
func normalizeIPSummarySortBy(sortBy string) string {
switch strings.TrimSpace(sortBy) {
case "recent_requests", "last_seen_at", "remote_addr":
return strings.TrimSpace(sortBy)
default:
return "total_requests"
}
}
func normalizeFoldMinutes(value int) (int, error) {
if value <= 0 {
return defaultAccessLogFoldMinute, nil
}
switch value {
case 3, 5:
return value, nil
default:
return 0, errors.New("fold_minutes 仅支持 3 或 5")
}
}
@@ -0,0 +1,482 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package observability
import (
"encoding/json"
"sort"
"strings"
"time"
"github.com/Rain-kl/Wavelet/internal/model"
)
const observabilityTrendBuckets = 24
const (
healthEventStatusActive = "active"
healthEventStatusResolved = "resolved"
healthSeverityCritical = "critical"
healthSeverityWarning = "warning"
)
// DistributionItem is a key/value distribution entry.
type DistributionItem struct {
Key string `json:"key"`
Value int64 `json:"value"`
}
// TrafficDistributions groups traffic distribution charts.
type TrafficDistributions struct {
StatusCodes []DistributionItem `json:"status_codes"`
TopDomains []DistributionItem `json:"top_domains"`
SourceCountries []DistributionItem `json:"source_countries"`
}
// TrafficWindowSummary summarizes a traffic reporting window.
type TrafficWindowSummary struct {
WindowStartedAt time.Time `json:"window_started_at"`
WindowEndedAt time.Time `json:"window_ended_at"`
RequestCount int64 `json:"request_count"`
UniqueVisitorCount int64 `json:"unique_visitor_count"`
ErrorCount int64 `json:"error_count"`
EstimatedQPS float64 `json:"estimated_qps"`
ErrorRatePercent float64 `json:"error_rate_percent"`
}
// HealthSummary summarizes node health alerts and risks.
type HealthSummary struct {
ActiveAlerts int `json:"active_alerts"`
CriticalAlerts int `json:"critical_alerts"`
WarningAlerts int `json:"warning_alerts"`
InfoAlerts int `json:"info_alerts"`
ResolvedAlerts int `json:"resolved_alerts"`
HasCapacityRisk bool `json:"has_capacity_risk"`
HasTrafficRisk bool `json:"has_traffic_risk"`
HasRuntimeRisk bool `json:"has_runtime_risk"`
}
// TrafficTrendPoint is a traffic trend bucket.
type TrafficTrendPoint struct {
BucketStartedAt time.Time `json:"bucket_started_at"`
RequestCount int64 `json:"request_count"`
ErrorCount int64 `json:"error_count"`
UniqueVisitorCount int64 `json:"unique_visitor_count"`
}
// CapacityTrendPoint is a capacity trend bucket.
type CapacityTrendPoint struct {
BucketStartedAt time.Time `json:"bucket_started_at"`
AverageCPUUsagePercent float64 `json:"average_cpu_usage_percent"`
AverageMemoryUsagePercent float64 `json:"average_memory_usage_percent"`
ReportedNodes int `json:"reported_nodes"`
}
// NetworkTrendPoint is a network trend bucket.
type NetworkTrendPoint struct {
BucketStartedAt time.Time `json:"bucket_started_at"`
NetworkRxBytes int64 `json:"network_rx_bytes"`
NetworkTxBytes int64 `json:"network_tx_bytes"`
OpenrestyRxBytes int64 `json:"openresty_rx_bytes"`
OpenrestyTxBytes int64 `json:"openresty_tx_bytes"`
ReportedNodes int `json:"reported_nodes"`
}
// DiskIOTrendPoint is a disk IO trend bucket.
type DiskIOTrendPoint struct {
BucketStartedAt time.Time `json:"bucket_started_at"`
DiskReadBytes int64 `json:"disk_read_bytes"`
DiskWriteBytes int64 `json:"disk_write_bytes"`
ReportedNodes int `json:"reported_nodes"`
}
type distributionAccumulator map[string]int64
type capacityTrendAccumulator struct {
cpuSum float64
cpuCount int
memSum float64
memCount int
nodes map[string]struct{}
}
type snapshotTrendAccumulator struct {
nodes map[string]struct{}
}
type diskCounterState struct {
read int64
write int64
seen bool
}
func buildTrafficWindowSummary(report *model.OpenFlareRequestReport) TrafficWindowSummary {
if report == nil {
return TrafficWindowSummary{}
}
summary := TrafficWindowSummary{
WindowStartedAt: report.WindowStartedAt,
WindowEndedAt: report.WindowEndedAt,
RequestCount: report.RequestCount,
UniqueVisitorCount: report.UniqueVisitorCount,
ErrorCount: report.ErrorCount,
}
if duration := report.WindowEndedAt.Sub(report.WindowStartedAt).Seconds(); duration > 0 {
summary.EstimatedQPS = float64(report.RequestCount) / duration
}
if report.RequestCount > 0 {
summary.ErrorRatePercent = (float64(report.ErrorCount) / float64(report.RequestCount)) * 100
}
return summary
}
// BuildTrafficDistributions aggregates traffic distribution charts.
func BuildTrafficDistributions(
reports []*model.OpenFlareRequestReport,
accessLogRegions []*model.OpenFlareAccessLogRegionCount,
limit int,
) TrafficDistributions {
statusCodes := make(distributionAccumulator)
topDomains := make(distributionAccumulator)
reportSourceCountries := make(distributionAccumulator)
for _, report := range reports {
mergeJSONCounts(statusCodes, report.StatusCodesJSON)
mergeJSONCounts(topDomains, report.TopDomainsJSON)
mergeJSONCounts(reportSourceCountries, report.SourceCountriesJSON)
}
sourceCountries := reportSourceCountries
if len(accessLogRegions) > 0 {
sourceCountries = make(distributionAccumulator, len(accessLogRegions))
for _, item := range accessLogRegions {
if item == nil || strings.TrimSpace(item.Region) == "" || item.Count <= 0 {
continue
}
sourceCountries[item.Region] = item.Count
}
}
return TrafficDistributions{
StatusCodes: toDistributionItems(statusCodes, limit),
TopDomains: toDistributionItems(topDomains, limit),
SourceCountries: toDistributionItems(sourceCountries, limit),
}
}
func buildHealthSummary(
snapshot *model.OpenFlareMetricSnapshot,
report *model.OpenFlareRequestReport,
events []*model.OpenFlareHealthEvent,
) HealthSummary {
summary := HealthSummary{}
for _, event := range events {
if event == nil {
continue
}
if event.Status == healthEventStatusResolved {
summary.ResolvedAlerts++
continue
}
summary.ActiveAlerts++
switch event.Severity {
case healthSeverityCritical:
summary.CriticalAlerts++
case healthSeverityWarning:
summary.WarningAlerts++
default:
summary.InfoAlerts++
}
}
if snapshot != nil {
memoryUsage := Percentage(snapshot.MemoryUsedBytes, snapshot.MemoryTotalBytes)
storageUsage := Percentage(snapshot.StorageUsedBytes, snapshot.StorageTotalBytes)
summary.HasCapacityRisk = snapshot.CPUUsagePercent >= 80 || memoryUsage >= 85 || storageUsage >= 85
}
if report != nil && report.RequestCount >= 100 {
summary.HasTrafficRisk = (float64(report.ErrorCount) / float64(report.RequestCount)) >= 0.05
}
summary.HasRuntimeRisk = summary.ActiveAlerts > 0 || summary.HasCapacityRisk || summary.HasTrafficRisk
return summary
}
// BuildTrafficTrendPoints builds 24h traffic trend buckets.
func BuildTrafficTrendPoints(now time.Time, reports []*model.OpenFlareRequestReport) []TrafficTrendPoint {
start := trendWindowStart(now)
points := make([]TrafficTrendPoint, observabilityTrendBuckets)
for index := range points {
points[index].BucketStartedAt = start.Add(time.Duration(index) * time.Hour)
}
for _, report := range reports {
index, ok := trendBucketIndex(report.WindowEndedAt, start)
if !ok {
continue
}
points[index].RequestCount += report.RequestCount
points[index].ErrorCount += report.ErrorCount
points[index].UniqueVisitorCount += report.UniqueVisitorCount
}
return points
}
// BuildCapacityTrendPoints builds 24h capacity trend buckets.
func BuildCapacityTrendPoints(now time.Time, snapshots []*model.OpenFlareMetricSnapshot) []CapacityTrendPoint {
start := trendWindowStart(now)
points := make([]CapacityTrendPoint, observabilityTrendBuckets)
accumulators := make([]capacityTrendAccumulator, observabilityTrendBuckets)
for index := range points {
points[index].BucketStartedAt = start.Add(time.Duration(index) * time.Hour)
accumulators[index].nodes = make(map[string]struct{})
}
for _, snapshot := range snapshots {
index, ok := trendBucketIndex(snapshot.CapturedAt, start)
if !ok {
continue
}
if snapshot.CPUUsagePercent > 0 {
accumulators[index].cpuSum += snapshot.CPUUsagePercent
accumulators[index].cpuCount++
}
if memoryUsage := Percentage(snapshot.MemoryUsedBytes, snapshot.MemoryTotalBytes); memoryUsage > 0 {
accumulators[index].memSum += memoryUsage
accumulators[index].memCount++
}
if snapshot.NodeID != "" {
accumulators[index].nodes[snapshot.NodeID] = struct{}{}
}
}
for index := range points {
if accumulators[index].cpuCount > 0 {
points[index].AverageCPUUsagePercent = accumulators[index].cpuSum / float64(accumulators[index].cpuCount)
}
if accumulators[index].memCount > 0 {
points[index].AverageMemoryUsagePercent = accumulators[index].memSum / float64(accumulators[index].memCount)
}
points[index].ReportedNodes = len(accumulators[index].nodes)
}
return points
}
// BuildNetworkTrendPoints builds 24h network trend buckets.
func BuildNetworkTrendPoints(
now time.Time,
snapshots []*model.OpenFlareMetricSnapshot,
openrestyObs []*model.OpenFlareNodeObservationOpenresty,
) []NetworkTrendPoint {
start := trendWindowStart(now)
points := make([]NetworkTrendPoint, observabilityTrendBuckets)
accumulators := make([]snapshotTrendAccumulator, observabilityTrendBuckets)
for index := range points {
points[index].BucketStartedAt = start.Add(time.Duration(index) * time.Hour)
accumulators[index].nodes = make(map[string]struct{})
}
for _, snapshot := range snapshots {
index, ok := trendBucketIndex(snapshot.CapturedAt, start)
if !ok {
continue
}
points[index].NetworkRxBytes += snapshot.NetworkRxBytes
points[index].NetworkTxBytes += snapshot.NetworkTxBytes
if snapshot.NodeID != "" {
accumulators[index].nodes[snapshot.NodeID] = struct{}{}
}
}
for _, obs := range openrestyObs {
index, ok := trendBucketIndex(obs.CapturedAt, start)
if !ok {
continue
}
points[index].OpenrestyRxBytes += obs.OpenrestyRxBytes
points[index].OpenrestyTxBytes += obs.OpenrestyTxBytes
if obs.NodeID != "" {
accumulators[index].nodes[obs.NodeID] = struct{}{}
}
}
for index := range points {
points[index].ReportedNodes = len(accumulators[index].nodes)
}
return points
}
// BuildDiskIOTrendPoints builds 24h disk IO trend buckets.
func BuildDiskIOTrendPoints(now time.Time, snapshots []*model.OpenFlareMetricSnapshot) []DiskIOTrendPoint {
start := trendWindowStart(now)
points := make([]DiskIOTrendPoint, observabilityTrendBuckets)
accumulators := make([]snapshotTrendAccumulator, observabilityTrendBuckets)
for index := range points {
points[index].BucketStartedAt = start.Add(time.Duration(index) * time.Hour)
accumulators[index].nodes = make(map[string]struct{})
}
sort.Slice(snapshots, func(i int, j int) bool {
if snapshots[i].CapturedAt.Equal(snapshots[j].CapturedAt) {
return snapshots[i].NodeID < snapshots[j].NodeID
}
return snapshots[i].CapturedAt.Before(snapshots[j].CapturedAt)
})
previousByNode := make(map[string]diskCounterState, len(snapshots))
for _, snapshot := range snapshots {
nodeKey := snapshot.NodeID
if nodeKey == "" {
nodeKey = "__unknown__"
}
previous := previousByNode[nodeKey]
previousByNode[nodeKey] = diskCounterState{
read: snapshot.DiskReadBytes,
write: snapshot.DiskWriteBytes,
seen: true,
}
if !previous.seen {
continue
}
index, ok := trendBucketIndex(snapshot.CapturedAt, start)
if !ok {
continue
}
readDelta := snapshot.DiskReadBytes - previous.read
writeDelta := snapshot.DiskWriteBytes - previous.write
if readDelta < 0 {
readDelta = 0
}
if writeDelta < 0 {
writeDelta = 0
}
points[index].DiskReadBytes += readDelta
points[index].DiskWriteBytes += writeDelta
if snapshot.NodeID != "" {
accumulators[index].nodes[snapshot.NodeID] = struct{}{}
}
}
for index := range points {
points[index].ReportedNodes = len(accumulators[index].nodes)
}
return points
}
func latestMetricSnapshot(snapshots []*model.OpenFlareMetricSnapshot) *model.OpenFlareMetricSnapshot {
for _, snapshot := range snapshots {
if snapshot != nil {
return snapshot
}
}
return nil
}
func latestTrafficReport(reports []*model.OpenFlareRequestReport) *model.OpenFlareRequestReport {
for _, report := range reports {
if report != nil {
return report
}
}
return nil
}
// LatestMetricSnapshotsByNode returns the latest snapshot per node.
func LatestMetricSnapshotsByNode(snapshots []*model.OpenFlareMetricSnapshot) map[string]*model.OpenFlareMetricSnapshot {
result := make(map[string]*model.OpenFlareMetricSnapshot, len(snapshots))
for _, snapshot := range snapshots {
if snapshot == nil || snapshot.NodeID == "" {
continue
}
if existing, ok := result[snapshot.NodeID]; ok && !snapshot.CapturedAt.After(existing.CapturedAt) {
continue
}
result[snapshot.NodeID] = snapshot
}
return result
}
// LatestTrafficReportsByNode returns the latest traffic report per node.
func LatestTrafficReportsByNode(reports []*model.OpenFlareRequestReport) map[string]*model.OpenFlareRequestReport {
result := make(map[string]*model.OpenFlareRequestReport, len(reports))
for _, report := range reports {
if report == nil || report.NodeID == "" {
continue
}
if existing, ok := result[report.NodeID]; ok && !report.WindowEndedAt.After(existing.WindowEndedAt) {
continue
}
result[report.NodeID] = report
}
return result
}
// ActiveHealthEventsByNode groups active health events by node id.
func ActiveHealthEventsByNode(events []*model.OpenFlareHealthEvent) map[string][]*model.OpenFlareHealthEvent {
result := make(map[string][]*model.OpenFlareHealthEvent)
for _, event := range events {
if event == nil || event.NodeID == "" {
continue
}
result[event.NodeID] = append(result[event.NodeID], event)
}
return result
}
// Percentage returns used/total as a percentage.
func Percentage(used int64, total int64) float64 {
if used <= 0 || total <= 0 {
return 0
}
return (float64(used) / float64(total)) * 100
}
func mergeJSONCounts(target distributionAccumulator, raw string) {
if len(target) == 0 && strings.TrimSpace(raw) == "" {
return
}
values := parseJSONCounts(raw)
for key, value := range values {
if strings.TrimSpace(key) == "" || value <= 0 {
continue
}
target[key] += value
}
}
func parseJSONCounts(raw string) map[string]int64 {
if strings.TrimSpace(raw) == "" {
return nil
}
values := make(map[string]int64)
if err := json.Unmarshal([]byte(raw), &values); err != nil {
return nil
}
return values
}
func toDistributionItems(values distributionAccumulator, limit int) []DistributionItem {
if len(values) == 0 {
return []DistributionItem{}
}
items := make([]DistributionItem, 0, len(values))
for key, value := range values {
if strings.TrimSpace(key) == "" || value <= 0 {
continue
}
items = append(items, DistributionItem{Key: key, Value: value})
}
sort.Slice(items, func(i int, j int) bool {
if items[i].Value == items[j].Value {
return items[i].Key < items[j].Key
}
return items[i].Value > items[j].Value
})
if limit > 0 && len(items) > limit {
items = items[:limit]
}
return items
}
func trendWindowStart(now time.Time) time.Time {
return now.Truncate(time.Hour).Add(-(observabilityTrendBuckets - 1) * time.Hour)
}
func trendBucketIndex(timestamp time.Time, start time.Time) (int, bool) {
if timestamp.Before(start) {
return 0, false
}
delta := timestamp.Sub(start)
index := int(delta / time.Hour)
if index < 0 || index >= observabilityTrendBuckets {
return 0, false
}
return index, true
}
@@ -0,0 +1,246 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package observability
import (
"context"
"encoding/json"
"errors"
"time"
"github.com/Rain-kl/Wavelet/internal/model"
"gorm.io/gorm"
)
const (
defaultObservabilityWindow = 24 * time.Hour
defaultObservabilityLimit = 120
maxObservabilityLimit = 500
)
// NodeQuery filters node observability data.
type NodeQuery struct {
Hours int `json:"hours"`
Limit int `json:"limit"`
}
// NodeAnalytics groups node observability analytics.
type NodeAnalytics struct {
Traffic TrafficWindowSummary `json:"traffic"`
Distributions TrafficDistributions `json:"distributions"`
Health HealthSummary `json:"health"`
}
// NodeTrends groups node observability trend series.
type NodeTrends struct {
Traffic24h []TrafficTrendPoint `json:"traffic_24h"`
Capacity24h []CapacityTrendPoint `json:"capacity_24h"`
Network24h []NetworkTrendPoint `json:"network_24h"`
DiskIO24h []DiskIOTrendPoint `json:"disk_io_24h"`
}
// RelayDashboardSnapshot summarizes tunnel relay status.
type RelayDashboardSnapshot struct {
TotalProxies int `json:"total_proxies"`
OnlineProxies int `json:"online_proxies"`
OfflineProxies int `json:"offline_proxies"`
Proxies []RelayProxyStat `json:"proxies"`
TotalConnections int `json:"total_connections"`
ClientCounts int `json:"client_counts"`
}
// RelayProxyStat is a single relay proxy entry.
type RelayProxyStat struct {
Name string `json:"name"`
Type string `json:"type"`
Status string `json:"status"`
ClientVersion string `json:"client_version"`
LastStartTime string `json:"last_start_time"`
LastCloseTime string `json:"last_close_time"`
ClientAddr string `json:"client_addr"`
}
// NodeView is the node observability API response.
type NodeView struct {
NodeID string `json:"node_id"`
Profile *model.OpenFlareNodeSystemProfile `json:"profile"`
MetricSnapshots []*model.OpenFlareMetricSnapshot `json:"metric_snapshots"`
TrafficReports []*model.OpenFlareRequestReport `json:"traffic_reports"`
HealthEvents []*model.OpenFlareHealthEvent `json:"health_events"`
Analytics NodeAnalytics `json:"analytics"`
Trends NodeTrends `json:"trends"`
RelayDashboard *RelayDashboardSnapshot `json:"relay_dashboard,omitempty"`
}
// HealthEventCleanupResult reports health event cleanup outcome.
type HealthEventCleanupResult struct {
NodeID string `json:"node_id"`
DeletedCount int64 `json:"deleted_count"`
}
// GetNodeObservability returns observability details for a node.
func GetNodeObservability(ctx context.Context, id uint, query NodeQuery) (*NodeView, error) {
now := time.Now()
node, err := model.GetOpenFlareNodeByID(ctx, id)
if err != nil {
return nil, err
}
limit := normalizeObservabilityLimit(query.Limit)
since := now.Add(-normalizeObservabilityWindow(query.Hours))
profile, err := model.GetOpenFlareNodeSystemProfile(ctx, node.NodeID)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
if errors.Is(err, gorm.ErrRecordNotFound) {
profile = nil
}
snapshots, err := model.ListOpenFlareMetricSnapshotsSince(ctx, node.NodeID, since, limit)
if err != nil {
return nil, err
}
reports, err := model.ListOpenFlareRequestReportsSince(ctx, node.NodeID, since, limit)
if err != nil {
return nil, err
}
accessLogRegions, err := model.ListOpenFlareAccessLogRegionCounts(ctx, node.NodeID, since, 8)
if err != nil {
return nil, err
}
trendSnapshots, err := model.ListOpenFlareMetricSnapshotsSince(ctx, node.NodeID, now.Add(-24*time.Hour), 0)
if err != nil {
return nil, err
}
trendOpenresty, err := model.ListOpenFlareNodeObservationOpenresty(ctx, node.NodeID, now.Add(-24*time.Hour), 0)
if err != nil {
return nil, err
}
trendReports, err := model.ListOpenFlareRequestReportsSince(ctx, node.NodeID, now.Add(-24*time.Hour), 0)
if err != nil {
return nil, err
}
events, err := model.ListOpenFlareHealthEvents(ctx, node.NodeID, false, limit)
if err != nil {
return nil, err
}
view := &NodeView{
NodeID: node.NodeID,
Profile: profile,
MetricSnapshots: snapshots,
TrafficReports: reports,
HealthEvents: events,
Analytics: NodeAnalytics{
Traffic: buildTrafficWindowSummary(latestTrafficReport(reports)),
Distributions: BuildTrafficDistributions(reports, accessLogRegions, 8),
Health: buildHealthSummary(latestMetricSnapshot(snapshots), latestTrafficReport(reports), events),
},
Trends: NodeTrends{
Traffic24h: BuildTrafficTrendPoints(now, trendReports),
Capacity24h: BuildCapacityTrendPoints(now, trendSnapshots),
Network24h: BuildNetworkTrendPoints(now, trendSnapshots, trendOpenresty),
DiskIO24h: BuildDiskIOTrendPoints(now, trendSnapshots),
},
}
if node.NodeType == "tunnel_relay" {
frpsObs, frpsErr := model.ListOpenFlareNodeObservationFrps(ctx, node.NodeID, time.Time{}, 1)
if frpsErr != nil {
return nil, frpsErr
}
var latestFrps *model.OpenFlareNodeObservationFrps
if len(frpsObs) > 0 {
latestFrps = frpsObs[0]
}
view.RelayDashboard = buildRelayDashboardSnapshot(node, latestFrps)
}
return view, nil
}
// CleanupHealthEvents removes all health events for a node.
func CleanupHealthEvents(ctx context.Context, id uint) (*HealthEventCleanupResult, error) {
node, err := model.GetOpenFlareNodeByID(ctx, id)
if err != nil {
return nil, err
}
deletedCount, err := model.DeleteOpenFlareHealthEventsByNodeID(ctx, node.NodeID)
if err != nil {
return nil, err
}
return &HealthEventCleanupResult{
NodeID: node.NodeID,
DeletedCount: deletedCount,
}, nil
}
func buildRelayDashboardSnapshot(node *model.OpenFlareNode, obs *model.OpenFlareNodeObservationFrps) *RelayDashboardSnapshot {
if node == nil {
return nil
}
totalProxies := 0
totalConnections := 0
clientCounts := 0
proxies := []RelayProxyStat{}
if obs != nil {
totalProxies = obs.FrpsProxyCount
totalConnections = obs.FrpsConnections
clientCounts = obs.FrpsClientCount
if obs.FrpsProxies != "" {
var decoded []RelayProxyStat
if err := json.Unmarshal([]byte(obs.FrpsProxies), &decoded); err == nil {
proxies = decoded
}
}
}
if totalProxies < 0 {
totalProxies = 0
}
onlineProxies := 0
for _, proxy := range proxies {
if proxy.Status == "online" {
onlineProxies++
}
}
if len(proxies) == 0 {
onlineProxies = totalProxies
if node.RelayStatus != "healthy" {
onlineProxies = 0
}
}
return &RelayDashboardSnapshot{
TotalProxies: totalProxies,
OnlineProxies: onlineProxies,
OfflineProxies: totalProxies - onlineProxies,
Proxies: proxies,
TotalConnections: maxInt(totalConnections, 0),
ClientCounts: maxInt(clientCounts, 0),
}
}
func maxInt(a int, b int) int {
if a > b {
return a
}
return b
}
func normalizeObservabilityLimit(limit int) int {
if limit <= 0 {
return defaultObservabilityLimit
}
if limit > maxObservabilityLimit {
return maxObservabilityLimit
}
return limit
}
func normalizeObservabilityWindow(hours int) time.Duration {
if hours <= 0 {
return defaultObservabilityWindow
}
return time.Duration(hours) * time.Hour
}
@@ -0,0 +1,128 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package observability
import (
"strconv"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/compat"
"github.com/gin-gonic/gin"
)
// RegisterRoutes mounts legacy OpenFlare access log routes.
func RegisterRoutes(apiGroup *gin.RouterGroup) {
accessLogRoute := apiGroup.Group("/access-logs")
accessLogRoute.Use(compat.AdminAuth())
{
accessLogRoute.GET("/", getAccessLogsHandler)
accessLogRoute.GET("/folds", getFoldedAccessLogsHandler)
accessLogRoute.GET("/folds/ip-summary", getFoldedAccessLogIPsHandler)
accessLogRoute.GET("/ip-summary", getAccessLogIPSummariesHandler)
accessLogRoute.GET("/ip-summary/trend", getAccessLogIPTrendHandler)
accessLogRoute.POST("/cleanup", cleanupAccessLogsHandler)
}
}
func getAccessLogsHandler(c *gin.Context) {
logs, err := ListAccessLogs(c.Request.Context(), readAccessLogQuery(c))
if err != nil {
compat.Fail(c, err.Error())
return
}
compat.OK(c, logs)
}
func getFoldedAccessLogsHandler(c *gin.Context) {
query := readAccessLogQuery(c)
query.FoldMinutes = readQueryInt(c, "fold_minutes")
logs, err := ListFoldedAccessLogs(c.Request.Context(), query)
if err != nil {
compat.Fail(c, err.Error())
return
}
compat.OK(c, logs)
}
func getFoldedAccessLogIPsHandler(c *gin.Context) {
result, err := ListFoldedAccessLogIPs(c.Request.Context(), FoldedAccessLogIPQuery{
NodeID: c.Query("node_id"),
RemoteAddr: c.Query("remote_addr"),
Host: c.Query("host"),
Path: c.Query("path"),
BucketStartedAt: c.Query("bucket_started_at"),
FoldMinutes: readQueryInt(c, "fold_minutes"),
Page: readQueryInt(c, "p"),
PageSize: readQueryInt(c, "page_size"),
SortBy: c.Query("sort_by"),
SortOrder: c.Query("sort_order"),
})
if err != nil {
compat.Fail(c, err.Error())
return
}
compat.OK(c, result)
}
func getAccessLogIPSummariesHandler(c *gin.Context) {
result, err := ListAccessLogIPSummaries(c.Request.Context(), AccessLogIPSummaryQuery{
NodeID: c.Query("node_id"),
RemoteAddr: c.Query("remote_addr"),
Host: c.Query("host"),
Page: readQueryInt(c, "p"),
PageSize: readQueryInt(c, "page_size"),
SortBy: c.Query("sort_by"),
SortOrder: c.Query("sort_order"),
})
if err != nil {
compat.Fail(c, err.Error())
return
}
compat.OK(c, result)
}
func getAccessLogIPTrendHandler(c *gin.Context) {
result, err := GetAccessLogIPTrend(c.Request.Context(), AccessLogIPTrendQuery{
NodeID: c.Query("node_id"),
RemoteAddr: c.Query("remote_addr"),
Host: c.Query("host"),
Hours: readQueryInt(c, "hours"),
BucketMinutes: readQueryInt(c, "bucket_minutes"),
})
if err != nil {
compat.Fail(c, err.Error())
return
}
compat.OK(c, result)
}
func cleanupAccessLogsHandler(c *gin.Context) {
var input AccessLogCleanupInput
if !compat.BindJSON(c, &input) {
return
}
result, err := CleanupAccessLogs(c.Request.Context(), input)
if err != nil {
compat.Fail(c, err.Error())
return
}
compat.OK(c, result)
}
func readAccessLogQuery(c *gin.Context) AccessLogQuery {
return AccessLogQuery{
NodeID: c.Query("node_id"),
RemoteAddr: c.Query("remote_addr"),
Host: c.Query("host"),
Path: c.Query("path"),
Page: readQueryInt(c, "p"),
PageSize: readQueryInt(c, "page_size"),
SortBy: c.Query("sort_by"),
SortOrder: c.Query("sort_order"),
}
}
func readQueryInt(c *gin.Context, key string) int {
value, _ := strconv.Atoi(c.DefaultQuery(key, "0"))
return value
}
@@ -0,0 +1,13 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package option
const (
errInvalidParams = "无效的参数"
errOptionInitFailed = "系统选项初始化失败"
errGeoIPProvider = "归属方式仅支持 disabled、mmdb、ip-api、geojs、ipinfo"
errGeoIPIPEmpty = "IP 不能为空"
errGeoIPIPInvalid = "IP 格式无效"
errGeoIPLookupDisabled = "GeoIP 查询已禁用"
)
@@ -0,0 +1,244 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package option
import (
"context"
"errors"
"fmt"
"strings"
"sync"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/geoip"
"github.com/Rain-kl/Wavelet/internal/buildinfo"
"github.com/Rain-kl/Wavelet/internal/model"
)
var (
initOnce sync.Once
initErr error
)
// EnsureInitialized loads OptionMap from defaults and database once per process.
func EnsureInitialized(ctx context.Context) error {
initOnce.Do(func() {
initErr = model.InitOptionMap(ctx)
})
return initErr
}
// ResetInitializationForTest clears lazy-init state for unit tests.
func ResetInitializationForTest() {
initOnce = sync.Once{}
initErr = nil
model.ResetOptionMapForTest()
}
type publicAuthSourceView struct {
ID uint64 `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
DisplayName string `json:"display_name"`
AuthorizeURL string `json:"authorize_url"`
IconURL string `json:"icon_url"`
}
type statusView struct {
Version string `json:"version"`
StartTime int64 `json:"start_time"`
EmailVerification bool `json:"email_verification"`
GitHubOAuth bool `json:"github_oauth"`
GitHubClientID string `json:"github_client_id"`
SystemName string `json:"system_name"`
HomePageLink string `json:"home_page_link"`
FooterHTML string `json:"footer_html"`
WeChatQRCode string `json:"wechat_qrcode"`
WeChatLogin bool `json:"wechat_login"`
ServerAddress string `json:"server_address"`
PasswordRegisterEnabled bool `json:"password_register_enabled"`
CapLoginEnabled bool `json:"cap_login_enabled"`
AuthSources []publicAuthSourceView `json:"auth_sources"`
}
type geoIPLookupRequest struct {
Provider string `json:"provider"`
IP string `json:"ip"`
}
type geoIPLookupView struct {
Provider string `json:"provider"`
IP string `json:"ip"`
ISOCode string `json:"iso_code"`
Name string `json:"name"`
Latitude *float64 `json:"latitude,omitempty"`
Longitude *float64 `json:"longitude,omitempty"`
}
type databaseCleanupInput struct {
Target string `json:"target"`
RetentionDays *int `json:"retention_days"`
}
type databaseCleanupResult struct {
Target string `json:"target"`
TargetLabel string `json:"target_label"`
DeletedCount int64 `json:"deleted_count"`
DeleteAll bool `json:"delete_all"`
RetentionDays *int `json:"retention_days,omitempty"`
}
type optionBatchPayload struct {
Options []model.OpenFlareOption `json:"options"`
}
func listOptions(ctx context.Context) ([]model.OpenFlareOption, error) {
if err := EnsureInitialized(ctx); err != nil {
return nil, err
}
model.OptionMapRWMutex.RLock()
defer model.OptionMapRWMutex.RUnlock()
options := make([]model.OpenFlareOption, 0, len(model.OptionMap))
for key, value := range model.OptionMap {
if isSecretOptionKey(key) {
continue
}
options = append(options, model.OpenFlareOption{
Key: key,
Value: value,
})
}
return options, nil
}
func updateOption(ctx context.Context, option model.OpenFlareOption) error {
if err := EnsureInitialized(ctx); err != nil {
return err
}
return updateOptions(ctx, []model.OpenFlareOption{option})
}
func updateOptionsBatch(ctx context.Context, payload optionBatchPayload) error {
if err := EnsureInitialized(ctx); err != nil {
return err
}
if len(payload.Options) == 0 {
return errors.New(errInvalidParams)
}
return updateOptions(ctx, payload.Options)
}
func updateOptions(ctx context.Context, options []model.OpenFlareOption) error {
if err := validateOptions(options); err != nil {
return err
}
return model.UpdateOpenFlareOptions(ctx, options)
}
func getNotice(ctx context.Context) (string, error) {
if err := EnsureInitialized(ctx); err != nil {
return "", err
}
return model.OptionValue("Notice"), nil
}
func getAbout(ctx context.Context) (string, error) {
if err := EnsureInitialized(ctx); err != nil {
return "", err
}
return model.OptionValue("About"), nil
}
func getStatus(ctx context.Context, baseAPIPath string) (*statusView, error) {
if err := EnsureInitialized(ctx); err != nil {
return nil, err
}
authSources, err := publicAuthSources(ctx, baseAPIPath)
if err != nil {
authSources = []publicAuthSourceView{}
}
return &statusView{
Version: buildinfo.Version,
StartTime: model.StartTime,
EmailVerification: model.EmailVerificationEnabled,
GitHubOAuth: model.GitHubOAuthEnabled,
GitHubClientID: model.GitHubClientId,
SystemName: model.SystemName,
HomePageLink: model.HomePageLink,
FooterHTML: model.Footer,
WeChatQRCode: model.WeChatAccountQRCodeImageURL,
WeChatLogin: model.WeChatAuthEnabled,
ServerAddress: model.ServerAddress,
PasswordRegisterEnabled: model.PasswordRegisterEnabled,
CapLoginEnabled: model.CapLoginEnabled,
AuthSources: authSources,
}, nil
}
func publicAuthSources(ctx context.Context, baseAPIPath string) ([]publicAuthSourceView, error) {
sources, err := model.GetActiveAuthSources(ctx)
if err != nil {
return nil, err
}
result := make([]publicAuthSourceView, 0, len(sources))
base := strings.TrimRight(baseAPIPath, "/")
for _, source := range sources {
result = append(result, publicAuthSourceView{
ID: source.ID,
Name: source.Name,
Type: source.Type,
DisplayName: source.DisplayName,
AuthorizeURL: fmt.Sprintf("%s/oauth/%s/authorize", base, source.Name),
IconURL: source.IconURL,
})
}
return result, nil
}
func lookupGeoIP(_ context.Context, provider, rawIP string) (*geoIPLookupView, error) {
view, err := geoip.Lookup(provider, rawIP)
if err != nil {
return nil, err
}
return &geoIPLookupView{
Provider: view.Provider,
IP: view.IP,
ISOCode: view.ISOCode,
Name: view.Name,
Latitude: view.Latitude,
Longitude: view.Longitude,
}, nil
}
func cleanupDatabaseObservability(_ context.Context, input databaseCleanupInput) (*databaseCleanupResult, error) {
target := strings.TrimSpace(input.Target)
if target == "" {
return nil, errors.New(errInvalidParams)
}
if input.RetentionDays != nil && *input.RetentionDays <= 0 {
return nil, errors.New("retention_days 必须为大于 0 的整数")
}
return &databaseCleanupResult{
Target: target,
TargetLabel: target,
DeletedCount: 0,
DeleteAll: input.RetentionDays == nil,
RetentionDays: input.RetentionDays,
}, nil
}
func syncUptimeKuma(_ context.Context) error {
// Stub: full Uptime Kuma sync is implemented in T-MISC.
return nil
}
func isSecretOptionKey(key string) bool {
return strings.Contains(key, "Token") ||
strings.Contains(key, "Secret") ||
strings.Contains(key, "Password")
}
@@ -0,0 +1,119 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package option
import (
"context"
"testing"
"github.com/Rain-kl/Wavelet/internal/db"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func setupOptionTestDB(t *testing.T) func() {
t.Helper()
sqliteDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
DisableForeignKeyConstraintWhenMigrating: true,
})
require.NoError(t, err)
require.NoError(t, sqliteDB.AutoMigrate(&model.OpenFlareOption{}))
db.SetDB(sqliteDB)
ResetInitializationForTest()
return func() {
db.SetDB(nil)
ResetInitializationForTest()
}
}
func TestListOptionsFiltersSecretKeys(t *testing.T) {
cleanup := setupOptionTestDB(t)
defer cleanup()
ctx := context.Background()
require.NoError(t, model.UpdateOpenFlareOptions(ctx, []model.OpenFlareOption{
{Key: "SystemName", Value: "TestFlare"},
{Key: "SMTPToken", Value: "secret-token"},
{Key: "GitHubClientSecret", Value: "secret-id"},
}))
options, err := listOptions(ctx)
require.NoError(t, err)
keys := make(map[string]string, len(options))
for _, option := range options {
keys[option.Key] = option.Value
}
assert.Equal(t, "TestFlare", keys["SystemName"])
assert.NotContains(t, keys, "SMTPToken")
assert.NotContains(t, keys, "GitHubClientSecret")
}
func TestUpdateOptionHotReloadsOptionMap(t *testing.T) {
cleanup := setupOptionTestDB(t)
defer cleanup()
ctx := context.Background()
err := updateOption(ctx, model.OpenFlareOption{
Key: "SystemName",
Value: "HotReloaded",
})
require.NoError(t, err)
assert.Equal(t, "HotReloaded", model.OptionValue("SystemName"))
assert.Equal(t, "HotReloaded", model.SystemName)
}
func TestGetNoticeAndAbout(t *testing.T) {
cleanup := setupOptionTestDB(t)
defer cleanup()
ctx := context.Background()
require.NoError(t, updateOption(ctx, model.OpenFlareOption{Key: "Notice", Value: "hello"}))
require.NoError(t, updateOption(ctx, model.OpenFlareOption{Key: "About", Value: "about-us"}))
notice, err := getNotice(ctx)
require.NoError(t, err)
assert.Equal(t, "hello", notice)
about, err := getAbout(ctx)
require.NoError(t, err)
assert.Equal(t, "about-us", about)
}
func TestLookupGeoIPDisabledProvider(t *testing.T) {
cleanup := setupOptionTestDB(t)
defer cleanup()
ctx := context.Background()
view, err := lookupGeoIP(ctx, "disabled", "8.8.8.8")
require.NoError(t, err)
assert.Equal(t, "disabled", view.Provider)
assert.Equal(t, "8.8.8.8", view.IP)
}
func TestCleanupDatabaseObservabilityStub(t *testing.T) {
cleanup := setupOptionTestDB(t)
defer cleanup()
ctx := context.Background()
retention := 7
result, err := cleanupDatabaseObservability(ctx, databaseCleanupInput{
Target: "node_access_logs",
RetentionDays: &retention,
})
require.NoError(t, err)
assert.Equal(t, "node_access_logs", result.Target)
assert.Equal(t, int64(0), result.DeletedCount)
assert.False(t, result.DeleteAll)
require.NotNil(t, result.RetentionDays)
assert.Equal(t, 7, *result.RetentionDays)
}
@@ -0,0 +1,139 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package option
import (
"encoding/json"
"errors"
"io"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/compat"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/gin-gonic/gin"
)
// RegisterRoutes mounts legacy OpenFlare option and public status routes.
func RegisterRoutes(apiGroup *gin.RouterGroup) {
apiGroup.GET("/status", getStatusHandler)
apiGroup.GET("/notice", getNoticeHandler)
apiGroup.GET("/about", getAboutHandler)
optionRoute := apiGroup.Group("/option")
optionRoute.Use(compat.BridgeOpenFlareToken(), compat.RootAuth())
{
optionRoute.GET("/", listOptionsHandler)
optionRoute.POST("/update", updateOptionHandler)
optionRoute.POST("/update-batch", updateOptionsBatchHandler)
optionRoute.POST("/geoip/lookup", lookupGeoIPHandler)
optionRoute.POST("/database/cleanup", cleanupDatabaseHandler)
}
uptimeKumaRoute := apiGroup.Group("/uptimekuma")
uptimeKumaRoute.Use(compat.BridgeOpenFlareToken(), compat.RootAuth())
{
uptimeKumaRoute.POST("/sync", syncUptimeKumaHandler)
}
}
func getStatusHandler(c *gin.Context) {
view, err := getStatus(c.Request.Context(), "/api")
if err != nil {
compat.Fail(c, errOptionInitFailed)
return
}
compat.OK(c, view)
}
func getNoticeHandler(c *gin.Context) {
notice, err := getNotice(c.Request.Context())
if err != nil {
compat.Fail(c, errOptionInitFailed)
return
}
compat.OK(c, notice)
}
func getAboutHandler(c *gin.Context) {
about, err := getAbout(c.Request.Context())
if err != nil {
compat.Fail(c, errOptionInitFailed)
return
}
compat.OK(c, about)
}
func listOptionsHandler(c *gin.Context) {
options, err := listOptions(c.Request.Context())
if err != nil {
compat.Fail(c, errOptionInitFailed)
return
}
compat.OK(c, options)
}
func updateOptionHandler(c *gin.Context) {
var option model.OpenFlareOption
if !compat.BindJSON(c, &option) {
return
}
if err := updateOption(c.Request.Context(), option); err != nil {
compat.Fail(c, err.Error())
return
}
compat.OKMessage(c, "")
}
func updateOptionsBatchHandler(c *gin.Context) {
var payload optionBatchPayload
if !compat.BindJSON(c, &payload) {
return
}
if err := updateOptionsBatch(c.Request.Context(), payload); err != nil {
compat.Fail(c, err.Error())
return
}
compat.OKMessage(c, "")
}
func lookupGeoIPHandler(c *gin.Context) {
var request geoIPLookupRequest
if !compat.BindJSON(c, &request) {
return
}
view, err := lookupGeoIP(c.Request.Context(), request.Provider, request.IP)
if err != nil {
compat.Fail(c, err.Error())
return
}
compat.OK(c, view)
}
func cleanupDatabaseHandler(c *gin.Context) {
var input databaseCleanupInput
if err := bindOptionalJSON(c.Request.Body, &input); err != nil {
compat.Fail(c, errInvalidParams)
return
}
result, err := cleanupDatabaseObservability(c.Request.Context(), input)
if err != nil {
compat.Fail(c, err.Error())
return
}
compat.OK(c, result)
}
func syncUptimeKumaHandler(c *gin.Context) {
if err := syncUptimeKuma(c.Request.Context()); err != nil {
compat.Fail(c, err.Error())
return
}
compat.OKMessage(c, "同步成功")
}
func bindOptionalJSON(body io.Reader, target any) error {
if err := json.NewDecoder(body).Decode(target); err != nil && !errors.Is(err, io.EOF) {
return err
}
return nil
}
@@ -0,0 +1,310 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package option
import (
"fmt"
"regexp"
"strconv"
"strings"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/geoip"
"github.com/Rain-kl/Wavelet/internal/model"
)
const rateLimitKeyExpirationSeconds = 1200 // 20 minutes
var (
openRestySizePattern = regexp.MustCompile(`^\d+[kKmMgG]?$`)
openRestyProxyBuffersPattern = regexp.MustCompile(`^\d+\s+\d+[kKmMgG]?$`)
openRestyCacheLevelsPattern = regexp.MustCompile(`^\d{1,2}(?::\d{1,2}){0,2}$`)
openRestyDurationTokenPattern = regexp.MustCompile(`^\d+[smhdwSMHDW]$`)
)
func buildOptionValidationState(options []model.OpenFlareOption) map[string]string {
model.OptionMapRWMutex.RLock()
state := make(map[string]string, len(model.OptionMap)+len(options))
for key, value := range model.OptionMap {
state[key] = value
}
model.OptionMapRWMutex.RUnlock()
for _, option := range options {
state[option.Key] = option.Value
}
return state
}
func validateOptionWithState(option model.OpenFlareOption, state map[string]string) error {
switch option.Key {
case "GitHubOAuthEnabled":
if option.Value == "true" && strings.TrimSpace(state["GitHubClientId"]) == "" {
return fmt.Errorf("无法启用 GitHub OAuth,请先填入 GitHub Client ID 以及 GitHub Client Secret!")
}
case "WeChatAuthEnabled":
if option.Value == "true" && strings.TrimSpace(state["WeChatServerAddress"]) == "" {
return fmt.Errorf("无法启用微信登录,请先填入微信登录相关配置信息!")
}
}
if err := validateRateLimitOption(option.Key, option.Value); err != nil {
return err
}
if err := validateOpenRestyOption(option.Key, option.Value); err != nil {
return err
}
if err := validateGeoIPOption(option.Key, option.Value); err != nil {
return err
}
if err := validateDatabaseCleanupOption(option.Key, option.Value); err != nil {
return err
}
if err := validateAgentOption(option.Key, option.Value); err != nil {
return err
}
return validateUptimeKumaOption(option.Key, option.Value, state)
}
func validateRateLimitOption(key, value string) error {
switch key {
case "GlobalApiRateLimitNum", "GlobalWebRateLimitNum", "CriticalRateLimitNum":
intValue, err := strconv.Atoi(value)
if err != nil || intValue <= 0 {
return fmt.Errorf("%s 必须为大于 0 的整数", key)
}
case "GlobalApiRateLimitDuration", "GlobalWebRateLimitDuration", "CriticalRateLimitDuration":
intValue, err := strconv.Atoi(value)
if err != nil || intValue <= 0 {
return fmt.Errorf("%s 必须为大于 0 的整数秒", key)
}
if intValue > rateLimitKeyExpirationSeconds {
return fmt.Errorf("%s 不能大于 %d 秒", key, rateLimitKeyExpirationSeconds)
}
}
return nil
}
func validatePositiveIntegerOption(key, value string) error {
intValue, err := strconv.Atoi(value)
if err != nil || intValue <= 0 {
return fmt.Errorf("%s 必须为大于 0 的整数", key)
}
return nil
}
func validateBooleanOption(key, value string) error {
switch value {
case "true", "false":
return nil
default:
return fmt.Errorf("%s 必须为 true 或 false", key)
}
}
func validateGeoIPOption(key, value string) error {
if key != "GeoIPProvider" {
return nil
}
if geoip.IsValidProvider(value) {
return nil
}
return fmt.Errorf("%s 仅支持 disabled、mmdb、ip-api、geojs、ipinfo", key)
}
func validateDatabaseCleanupOption(key, value string) error {
switch key {
case "DatabaseAutoCleanupEnabled":
return validateBooleanOption(key, value)
case "DatabaseAutoCleanupRetentionDays":
intValue, err := strconv.Atoi(value)
if err != nil || intValue < 1 {
return fmt.Errorf("%s 必须为大于等于 1 的整数天", key)
}
}
return nil
}
func validateAgentOption(key, value string) error {
if key == "AgentWebsocketUpgradeEnabled" {
return validateBooleanOption(key, strings.TrimSpace(value))
}
return nil
}
func validateUptimeKumaOption(key, value string, state map[string]string) error {
trimmed := strings.TrimSpace(value)
switch key {
case "UptimeKumaEnabled":
if err := validateBooleanOption(key, trimmed); err != nil {
return err
}
if trimmed == "true" {
url := strings.TrimSpace(state["UptimeKumaUrl"])
username := strings.TrimSpace(state["UptimeKumaUsername"])
password := strings.TrimSpace(state["UptimeKumaPassword"])
if url == "" {
return fmt.Errorf("启用 Uptime Kuma 时地址不能为空")
}
if username == "" {
return fmt.Errorf("启用 Uptime Kuma 时用户名不能为空")
}
if password == "" && model.UptimeKumaPassword == "" {
return fmt.Errorf("启用 Uptime Kuma 时密码不能为空")
}
}
case "UptimeKumaUsername":
if trimmed == "" && state["UptimeKumaEnabled"] == "true" {
return fmt.Errorf("启用 Uptime Kuma 时用户名不能为空")
}
case "UptimeKumaUrl":
if trimmed != "" && !strings.HasPrefix(trimmed, "http://") && !strings.HasPrefix(trimmed, "https://") {
return fmt.Errorf("Uptime Kuma 地址必须以 http:// 或 https:// 开头")
}
case "UptimeKumaMonitorScope":
if trimmed != "all" && trimmed != "selected" {
return fmt.Errorf("监控范围必须为全部站点 (all) 或选择站点 (selected)")
}
case "UptimeKumaSyncInterval", "UptimeKumaInterval", "UptimeKumaRetryInterval", "UptimeKumaTimeout":
return validatePositiveIntegerOption(key, trimmed)
case "UptimeKumaRetry":
intValue, err := strconv.Atoi(trimmed)
if err != nil || intValue < 0 {
return fmt.Errorf("%s 必须为大于等于 0 的整数", key)
}
}
return nil
}
func validateOpenRestyOption(key, value string) error {
trimmed := strings.TrimSpace(value)
switch key {
case "OpenRestyDefaultServerReturnStatus":
if err := validatePositiveIntegerOption(key, trimmed); err != nil {
return err
}
statusCode, _ := strconv.Atoi(trimmed)
if statusCode < 100 || statusCode > 999 {
return fmt.Errorf("%s 必须在 100 到 999 之间", key)
}
case "OpenRestyWorkerProcesses":
if trimmed == "auto" {
return nil
}
return validatePositiveIntegerOption(key, trimmed)
case "OpenRestyWorkerConnections",
"OpenRestyWorkerRlimitNofile",
"OpenRestyKeepaliveTimeout",
"OpenRestyKeepaliveRequests",
"OpenRestyClientHeaderTimeout",
"OpenRestyClientBodyTimeout",
"OpenRestySendTimeout",
"OpenRestyProxyConnectTimeout",
"OpenRestyProxySendTimeout",
"OpenRestyProxyReadTimeout",
"OpenRestyGzipMinLength":
return validatePositiveIntegerOption(key, trimmed)
case "OpenRestyGzipCompLevel":
if err := validatePositiveIntegerOption(key, trimmed); err != nil {
return err
}
level, _ := strconv.Atoi(trimmed)
if level > 9 {
return fmt.Errorf("%s 不能大于 9", key)
}
case "OpenRestyEventsUse":
if trimmed == "" {
return nil
}
switch trimmed {
case "epoll", "kqueue", "poll", "select", "rtsig", "/dev/poll", "eventport":
return nil
default:
return fmt.Errorf("%s 仅支持 epoll、kqueue、poll、select、rtsig、/dev/poll、eventport 或留空", key)
}
case "OpenRestyResolvers":
if trimmed == "" {
return nil
}
if !regexp.MustCompile(`^[a-zA-Z0-9.:\-\s]+$`).MatchString(trimmed) {
return fmt.Errorf("%s 包含非法字符,请填入有效的 IP 地址或域名,以空格分隔", key)
}
case "OpenRestyEventsMultiAcceptEnabled",
"OpenRestyWebsocketEnabled",
"OpenRestyHTTP3Enabled",
"OpenRestyProxyRequestBufferingEnabled",
"OpenRestyProxyBufferingEnabled",
"OpenRestyGzipEnabled",
"OpenRestyCacheEnabled",
"OpenRestyCacheLockEnabled":
return validateBooleanOption(key, trimmed)
case "OpenRestyProxyBuffers", "OpenRestyLargeClientHeaderBuffers":
if openRestyProxyBuffersPattern.MatchString(trimmed) {
return nil
}
return fmt.Errorf("%s 格式必须类似 \"16 16k\"", key)
case "OpenRestyProxyBufferSize", "OpenRestyProxyBusyBuffersSize", "OpenRestyCacheMaxSize", "OpenRestyClientMaxBodySize":
if openRestySizePattern.MatchString(trimmed) {
return nil
}
return fmt.Errorf("%s 格式必须为整数或带 k/m/g 单位的大小值", key)
case "OpenRestyCachePath":
if strings.ContainsAny(trimmed, "\r\n\t") {
return fmt.Errorf("%s 不能包含换行或制表符", key)
}
case "OpenRestyCacheLevels":
if openRestyCacheLevelsPattern.MatchString(trimmed) {
return nil
}
return fmt.Errorf("%s 格式必须类似 \"1:2\" 或 \"1:2:2\"", key)
case "OpenRestyCacheInactive", "OpenRestyCacheLockTimeout":
if openRestyDurationTokenPattern.MatchString(trimmed) {
return nil
}
return fmt.Errorf("%s 格式必须为带单位的时长,例如 30m 或 5s", key)
case "OpenRestyCacheKeyTemplate":
if trimmed == "" {
return fmt.Errorf("%s 不能为空", key)
}
if strings.ContainsAny(trimmed, "\r\n") {
return fmt.Errorf("%s 不能包含换行", key)
}
case "OpenRestyCacheUseStale":
if trimmed == "" {
return fmt.Errorf("%s 不能为空", key)
}
allowedTokens := map[string]struct{}{
"error": {}, "timeout": {}, "invalid_header": {}, "updating": {},
"http_500": {}, "http_502": {}, "http_503": {}, "http_504": {},
"http_403": {}, "http_404": {}, "http_429": {}, "off": {},
}
for _, token := range strings.Fields(trimmed) {
if _, ok := allowedTokens[token]; !ok {
return fmt.Errorf("%s 包含不支持的值 %q", key, token)
}
}
case "OpenRestyMainConfigTemplate":
if strings.TrimSpace(value) == "" {
return fmt.Errorf("%s 不能为空", key)
}
}
return nil
}
func validateOptions(options []model.OpenFlareOption) error {
if len(options) == 0 {
return fmt.Errorf(errInvalidParams)
}
state := buildOptionValidationState(options)
for _, option := range options {
if strings.TrimSpace(option.Key) == "" {
return fmt.Errorf(errInvalidParams)
}
if err := validateOptionWithState(option, state); err != nil {
return err
}
}
return nil
}
@@ -0,0 +1,13 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package origin
const (
errOriginAddressRequired = "源站地址不能为空"
errOriginAddressInvalid = "源站地址格式不合法"
errOriginAddressExists = "源站地址已存在"
errOriginDeleteReferenced = "该源站仍被规则引用,无法删除"
errOriginMissingPort = "源站地址缺少端口"
errOriginNotFound = "源站不存在"
)
@@ -0,0 +1,87 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package origin
import (
"errors"
"fmt"
"net"
"net/url"
"strings"
"unicode"
)
func normalizeOriginAddress(raw string) string {
return strings.ToLower(strings.TrimSpace(raw))
}
func validateOriginAddress(address string) error {
if address == "" {
return errors.New(errOriginAddressRequired)
}
if strings.Contains(address, "://") || strings.ContainsAny(address, "/?#") {
return errors.New(errOriginAddressInvalid)
}
if strings.HasPrefix(address, "[") || strings.HasSuffix(address, "]") {
return errors.New(errOriginAddressInvalid)
}
if ip := net.ParseIP(address); ip != nil {
return nil
}
if len(address) > 253 {
return errors.New(errOriginAddressInvalid)
}
labels := strings.Split(address, ".")
for _, label := range labels {
if len(label) == 0 || len(label) > 63 {
return errors.New(errOriginAddressInvalid)
}
if label[0] == '-' || label[len(label)-1] == '-' {
return errors.New(errOriginAddressInvalid)
}
for _, r := range label {
if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '-' {
continue
}
return errors.New(errOriginAddressInvalid)
}
}
return nil
}
func normalizeOriginName(name string, address string) string {
normalized := strings.TrimSpace(name)
if normalized != "" {
return normalized
}
return address
}
func formatOriginHost(address string, port string) string {
return net.JoinHostPort(address, port)
}
func rewriteOriginURLAddress(rawURL string, newAddress string) (string, error) {
parsed, err := url.ParseRequestURI(strings.TrimSpace(rawURL))
if err != nil {
return "", fmt.Errorf("%s: %w", errOriginAddressInvalid, err)
}
address := normalizeOriginAddress(newAddress)
if err := validateOriginAddress(address); err != nil {
return "", err
}
port := parsed.Port()
if port == "" {
return "", errors.New(errOriginMissingPort)
}
parsed.Host = formatOriginHost(address, port)
return parsed.String(), nil
}
func isUniqueConstraintError(err error) bool {
if err == nil {
return false
}
return strings.Contains(strings.ToLower(err.Error()), "unique")
}
@@ -0,0 +1,229 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package origin
import (
"context"
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
"github.com/Rain-kl/Wavelet/internal/db"
"github.com/Rain-kl/Wavelet/internal/model"
"gorm.io/gorm"
)
// Input 源站创建/更新请求。
type Input struct {
Name string `json:"name"`
Address string `json:"address"`
Remark string `json:"remark"`
}
// RouteSummary 源站详情中的代理规则摘要。
type RouteSummary struct {
ID uint `json:"id"`
Domain string `json:"domain"`
OriginURL string `json:"origin_url"`
Enabled bool `json:"enabled"`
UpdatedAt string `json:"updated_at"`
}
// View 源站列表项。
type View struct {
ID uint `json:"id"`
Name string `json:"name"`
Address string `json:"address"`
Remark string `json:"remark"`
RouteCount int64 `json:"route_count"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// DetailView 源站详情。
type DetailView struct {
View
Routes []RouteSummary `json:"routes"`
}
// ListOrigins 列出全部源站。
func ListOrigins(ctx context.Context) ([]View, error) {
origins, err := model.ListOrigins(ctx)
if err != nil {
return nil, err
}
return buildOriginViews(ctx, origins)
}
// GetOriginDetail 获取源站详情。
func GetOriginDetail(ctx context.Context, id uint) (*DetailView, error) {
origin, err := model.GetOriginByID(ctx, id)
if err != nil {
return nil, err
}
views, err := buildOriginViews(ctx, []model.Origin{*origin})
if err != nil {
return nil, err
}
routes, err := model.ListProxyRoutesByOriginID(ctx, id)
if err != nil {
return nil, err
}
items := make([]RouteSummary, 0, len(routes))
for _, route := range routes {
items = append(items, RouteSummary{
ID: route.ID,
Domain: route.Domain,
OriginURL: route.OriginURL,
Enabled: route.Enabled,
UpdatedAt: route.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
})
}
sort.Slice(items, func(i, j int) bool {
return items[i].Domain < items[j].Domain
})
return &DetailView{
View: views[0],
Routes: items,
}, nil
}
// CreateOrigin 创建源站。
func CreateOrigin(ctx context.Context, input Input) (*model.Origin, error) {
origin, err := buildOrigin(nil, input)
if err != nil {
return nil, err
}
if err = model.CreateOriginRecord(ctx, origin); err != nil {
if isUniqueConstraintError(err) {
return nil, errors.New(errOriginAddressExists)
}
return nil, err
}
return origin, nil
}
// UpdateOrigin 更新源站。
func UpdateOrigin(ctx context.Context, id uint, input Input) (*model.Origin, error) {
origin, err := model.GetOriginByID(ctx, id)
if err != nil {
return nil, err
}
previousAddress := origin.Address
nextOrigin, err := buildOrigin(origin, input)
if err != nil {
return nil, err
}
err = db.DB(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Save(nextOrigin).Error; err != nil {
if isUniqueConstraintError(err) {
return errors.New(errOriginAddressExists)
}
return err
}
if previousAddress == nextOrigin.Address {
return nil
}
return updateRoutesForOriginAddress(ctx, tx, nextOrigin.ID, nextOrigin.Address)
})
if err != nil {
return nil, err
}
return nextOrigin, nil
}
// DeleteOrigin 删除源站。
func DeleteOrigin(ctx context.Context, id uint) error {
count, err := model.CountProxyRoutesByOriginID(ctx, id)
if err != nil {
return err
}
if count > 0 {
return errors.New(errOriginDeleteReferenced)
}
if _, err = model.GetOriginByID(ctx, id); err != nil {
return err
}
return model.DeleteOriginRecord(ctx, id)
}
func buildOrigin(existing *model.Origin, input Input) (*model.Origin, error) {
address := normalizeOriginAddress(input.Address)
if err := validateOriginAddress(address); err != nil {
return nil, err
}
if existing == nil {
existing = &model.Origin{}
}
existing.Address = address
existing.Name = normalizeOriginName(input.Name, address)
existing.Remark = strings.TrimSpace(input.Remark)
return existing, nil
}
func buildOriginViews(ctx context.Context, origins []model.Origin) ([]View, error) {
countRows, err := model.ListOriginRouteCounts(ctx)
if err != nil {
return nil, err
}
countMap := make(map[uint]int64, len(countRows))
for _, row := range countRows {
countMap[row.OriginID] = row.RouteCount
}
views := make([]View, 0, len(origins))
for _, origin := range origins {
views = append(views, View{
ID: origin.ID,
Name: origin.Name,
Address: origin.Address,
Remark: origin.Remark,
RouteCount: countMap[origin.ID],
CreatedAt: origin.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
UpdatedAt: origin.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
})
}
return views, nil
}
func updateRoutesForOriginAddress(ctx context.Context, tx *gorm.DB, originID uint, address string) error {
if !model.HasProxyRoutesTable(ctx) {
return nil
}
var routes []model.OriginProxyRoute
if err := tx.Where("origin_id = ?", originID).Order("id asc").Find(&routes).Error; err != nil {
return fmt.Errorf("query routes for origin update failed: %w", err)
}
for _, route := range routes {
rewrittenOriginURL, err := rewriteOriginURLAddress(route.OriginURL, address)
if err != nil {
return fmt.Errorf("rewrite route %d origin failed: %w", route.ID, err)
}
upstreams := make([]string, 0)
if strings.TrimSpace(route.Upstreams) != "" {
if err := json.Unmarshal([]byte(route.Upstreams), &upstreams); err != nil {
return fmt.Errorf("decode route %d upstreams failed: %w", route.ID, err)
}
}
if len(upstreams) == 0 {
upstreams = append(upstreams, rewrittenOriginURL)
} else {
upstreams[0] = rewrittenOriginURL
}
upstreamsJSON, err := json.Marshal(upstreams)
if err != nil {
return fmt.Errorf("encode route %d upstreams failed: %w", route.ID, err)
}
if err := tx.Model(&model.OriginProxyRoute{}).
Where("id = ?", route.ID).
Updates(map[string]any{
"origin_url": rewrittenOriginURL,
"upstreams": string(upstreamsJSON),
}).Error; err != nil {
return fmt.Errorf("update route %d origin address failed: %w", route.ID, err)
}
}
return nil
}
@@ -0,0 +1,80 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package origin
import (
"context"
"testing"
"github.com/Rain-kl/Wavelet/internal/db"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func setupOriginTestDB(t *testing.T) func() {
t.Helper()
sqliteDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
DisableForeignKeyConstraintWhenMigrating: true,
})
require.NoError(t, err)
require.NoError(t, sqliteDB.AutoMigrate(&model.Origin{}))
db.SetDB(sqliteDB)
return func() {
db.SetDB(nil)
}
}
func TestCreateOrigin(t *testing.T) {
cleanup := setupOriginTestDB(t)
defer cleanup()
ctx := context.Background()
origin, err := CreateOrigin(ctx, Input{
Name: "Primary Origin",
Address: "origin-a.internal",
Remark: "main upstream",
})
require.NoError(t, err)
assert.NotZero(t, origin.ID)
assert.Equal(t, "Primary Origin", origin.Name)
assert.Equal(t, "origin-a.internal", origin.Address)
assert.Equal(t, "main upstream", origin.Remark)
_, err = CreateOrigin(ctx, Input{
Address: "origin-a.internal",
})
require.Error(t, err)
assert.Equal(t, errOriginAddressExists, err.Error())
}
func TestListOrigins(t *testing.T) {
cleanup := setupOriginTestDB(t)
defer cleanup()
ctx := context.Background()
first, err := CreateOrigin(ctx, Input{
Name: "first-origin",
Address: "origin-a.internal",
})
require.NoError(t, err)
second, err := CreateOrigin(ctx, Input{
Name: "second-origin",
Address: "origin-b.internal",
})
require.NoError(t, err)
origins, err := ListOrigins(ctx)
require.NoError(t, err)
require.Len(t, origins, 2)
assert.Equal(t, second.ID, origins[0].ID)
assert.Equal(t, first.ID, origins[1].ID)
assert.Equal(t, int64(0), origins[0].RouteCount)
assert.Equal(t, int64(0), origins[1].RouteCount)
}
@@ -0,0 +1,88 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package origin
import (
"errors"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/compat"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
func handleLogicError(c *gin.Context, err error) bool {
if err == nil {
return false
}
if errors.Is(err, gorm.ErrRecordNotFound) {
compat.Fail(c, errOriginNotFound)
return true
}
compat.Fail(c, err.Error())
return true
}
// GetOrigins 列出全部源站。
func GetOrigins(c *gin.Context) {
origins, err := ListOrigins(c.Request.Context())
if handleLogicError(c, err) {
return
}
compat.OK(c, origins)
}
// GetOrigin 获取源站详情。
func GetOrigin(c *gin.Context) {
id, ok := compat.IDParam(c)
if !ok {
return
}
detail, err := GetOriginDetail(c.Request.Context(), id)
if handleLogicError(c, err) {
return
}
compat.OK(c, detail)
}
// CreateOriginHandler 创建源站。
func CreateOriginHandler(c *gin.Context) {
var input Input
if !compat.BindJSON(c, &input) {
return
}
origin, err := CreateOrigin(c.Request.Context(), input)
if handleLogicError(c, err) {
return
}
compat.OK(c, origin)
}
// UpdateOriginHandler 更新源站。
func UpdateOriginHandler(c *gin.Context) {
id, ok := compat.IDParam(c)
if !ok {
return
}
var input Input
if !compat.BindJSON(c, &input) {
return
}
origin, err := UpdateOrigin(c.Request.Context(), id, input)
if handleLogicError(c, err) {
return
}
compat.OK(c, origin)
}
// DeleteOriginHandler 删除源站。
func DeleteOriginHandler(c *gin.Context) {
id, ok := compat.IDParam(c)
if !ok {
return
}
if err := DeleteOrigin(c.Request.Context(), id); handleLogicError(c, err) {
return
}
compat.OK(c, nil)
}
@@ -0,0 +1,23 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package pages
const (
errPagesProjectNotFound = "Pages 项目不存在"
errPagesSlugExists = "Pages 项目标识已存在"
errPagesNameRequired = "Pages 项目名称不能为空"
errPagesSlugInvalid = "Pages 项目标识只能包含小写字母、数字和连字符"
errPagesDeleteReferenced = "Pages 项目已被规则引用,不能删除"
errPagesDeploymentNotFound = "Pages 部署不存在"
errPagesDeploymentMismatch = "Pages 部署不属于该项目"
errPagesDeleteActiveDeploy = "不能删除当前激活的 Pages 部署"
errPagesPackageMissing = "缺少 Pages 部署包"
errPagesPackageNotZip = "Pages 部署包必须是 .zip 文件"
errPagesPackageInvalidZip = "Pages 部署包不是有效 zip 文件"
errPagesPackageEmpty = "Pages 部署包不能为空"
errPagesAPIProxyPathRequired = "启用 API 反代时,匹配路径不能为空"
errPagesAPIProxyPathPrefix = "API 反代匹配路径必须以 '/' 开头"
errPagesAPIProxyPassRequired = "启用 API 反代时,后端服务地址不能为空"
errPagesAPIProxyPassInvalid = "API 反代后端服务地址必须是有效的 HTTP/HTTPS URL"
)
@@ -0,0 +1,355 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package pages
import (
"archive/zip"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"mime/multipart"
"os"
"path"
"path/filepath"
"regexp"
"strings"
"github.com/Rain-kl/Wavelet/internal/config"
"github.com/Rain-kl/Wavelet/internal/model"
)
const (
pagesMaxDeploymentFiles = 1000
pagesMaxDeploymentBytes = 100 * 1024 * 1024
defaultPagesEntryFile = "index.html"
defaultPagesFallbackPath = "/index.html"
)
var pagesSlugPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,126}[a-z0-9]$|^[a-z0-9]$`)
type deploymentManifest struct {
Files []model.PagesDeploymentFile
FileCount int
TotalSize int64
EntryFile string
}
func isUniqueConstraintError(err error) bool {
if err == nil {
return false
}
return strings.Contains(strings.ToLower(err.Error()), "unique")
}
func normalizePagesSlug(raw string) string {
value := strings.ToLower(strings.TrimSpace(raw))
var builder strings.Builder
lastDash := false
for _, r := range value {
valid := (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9')
if valid {
builder.WriteRune(r)
lastDash = false
continue
}
if !lastDash {
builder.WriteByte('-')
lastDash = true
}
}
return strings.Trim(builder.String(), "-")
}
func validateAndNormalizePagesRootDir(raw string) (string, error) {
value := strings.TrimSpace(raw)
if value == "" {
return "", nil
}
if len(value) > 512 {
return "", errors.New("Pages 根目录长度不能超过 512")
}
if strings.Contains(value, "\\") || strings.ContainsAny(value, "\"';") {
return "", errors.New("Pages 根目录包含不支持的字符")
}
for _, r := range value {
if r <= 0x20 || r == 0x7f {
return "", errors.New("Pages 根目录不能包含空白或控制字符")
}
}
cleaned := path.Clean(filepath.ToSlash(value))
if cleaned == "." || cleaned == "/" {
return "", nil
}
for _, segment := range strings.Split(cleaned, "/") {
if segment == "." || segment == ".." {
return "", errors.New("Pages 根目录不能包含 . 或 .. 路径段")
}
}
return strings.TrimPrefix(cleaned, "/"), nil
}
func normalizePagesFallbackPath(raw string) (string, error) {
value := strings.TrimSpace(raw)
if value == "" {
value = defaultPagesFallbackPath
}
if len(value) > 512 {
return "", errors.New("SPA fallback 回退路径长度不能超过 512")
}
if !strings.HasPrefix(value, "/") {
return "", errors.New("SPA fallback 回退路径必须以 / 开头")
}
if value == "/" || strings.HasSuffix(value, "/") {
return "", errors.New("SPA fallback 回退路径必须指向具体文件")
}
if strings.Contains(value, "\\") || strings.ContainsAny(value, "\"';") {
return "", errors.New("SPA fallback 回退路径包含不支持的字符")
}
for _, r := range value {
if r <= 0x20 || r == 0x7f {
return "", errors.New("SPA fallback 回退路径不能包含空白或控制字符")
}
}
for _, segment := range strings.Split(value, "/") {
if segment == "." || segment == ".." {
return "", errors.New("SPA fallback 回退路径不能包含 . 或 .. 路径段")
}
}
cleaned := path.Clean(value)
if cleaned == "." || !strings.HasPrefix(cleaned, "/") {
return "", errors.New("SPA fallback 回退路径不合法")
}
if cleaned == "/" || strings.HasSuffix(cleaned, "/") {
return "", errors.New("SPA fallback 回退路径必须指向具体文件")
}
return cleaned, nil
}
func normalizeStoredPagesFallbackPath(value string) string {
normalized, err := normalizePagesFallbackPath(value)
if err != nil {
return defaultPagesFallbackPath
}
return normalized
}
func normalizePagesEntryFile(raw string) string {
value := path.Clean(strings.TrimSpace(filepath.ToSlash(raw)))
if value == "." || value == "/" {
return defaultPagesEntryFile
}
return strings.TrimPrefix(value, "/")
}
func persistPagesUploadTemp(fileHeader *multipart.FileHeader) (string, string, error) {
file, err := fileHeader.Open()
if err != nil {
return "", "", err
}
defer file.Close()
temp, err := os.CreateTemp("", "openflare-pages-*.zip")
if err != nil {
return "", "", err
}
defer temp.Close()
hash := sha256.New()
limited := io.LimitReader(file, pagesMaxDeploymentBytes+1)
written, err := io.Copy(io.MultiWriter(temp, hash), limited)
if err != nil {
_ = os.Remove(temp.Name())
return "", "", err
}
if written > pagesMaxDeploymentBytes {
_ = os.Remove(temp.Name())
return "", "", fmt.Errorf("Pages 部署包不能超过 %d MiB", pagesMaxDeploymentBytes/1024/1024)
}
return temp.Name(), hex.EncodeToString(hash.Sum(nil)), nil
}
func findCommonRootPrefix(files []*zip.File) (string, error) {
var firstFilePath string
hasMultipleFiles := false
for _, item := range files {
normalizedPath, skip, err := normalizePagesZipPath(item.Name)
if err != nil {
return "", err
}
if skip {
continue
}
if firstFilePath == "" {
firstFilePath = normalizedPath
} else {
hasMultipleFiles = true
}
}
if firstFilePath == "" {
return "", nil
}
parts := strings.Split(firstFilePath, "/")
if len(parts) <= 1 {
return "", nil
}
commonPrefix := parts[0] + "/"
if hasMultipleFiles {
for _, item := range files {
normalizedPath, skip, err := normalizePagesZipPath(item.Name)
if err != nil {
return "", err
}
if skip {
continue
}
if !strings.HasPrefix(normalizedPath, commonPrefix) {
return "", nil
}
}
}
return commonPrefix, nil
}
func inspectPagesZip(zipPath string, rootDir string, entryFile string) (*deploymentManifest, error) {
reader, err := zip.OpenReader(zipPath)
if err != nil {
return nil, errors.New(errPagesPackageInvalidZip)
}
defer reader.Close()
commonPrefix, err := findCommonRootPrefix(reader.File)
if err != nil {
return nil, err
}
manifest := &deploymentManifest{
Files: []model.PagesDeploymentFile{},
EntryFile: entryFile,
}
targetEntryPath := entryFile
if rootDir != "" {
targetEntryPath = path.Join(rootDir, entryFile)
}
entrySeen := false
for _, item := range reader.File {
normalizedPath, skip, err := normalizePagesZipPath(item.Name)
if err != nil {
return nil, err
}
if skip {
continue
}
if commonPrefix != "" {
normalizedPath = strings.TrimPrefix(normalizedPath, commonPrefix)
}
if item.FileInfo().Mode()&os.ModeSymlink != 0 {
return nil, fmt.Errorf("Pages 部署包不支持符号链接: %s", normalizedPath)
}
if item.UncompressedSize64 > pagesMaxDeploymentBytes {
return nil, fmt.Errorf("Pages 文件过大: %s", normalizedPath)
}
manifest.FileCount++
if manifest.FileCount > pagesMaxDeploymentFiles {
return nil, fmt.Errorf("Pages 部署文件数不能超过 %d", pagesMaxDeploymentFiles)
}
manifest.TotalSize += int64(item.UncompressedSize64)
if manifest.TotalSize > pagesMaxDeploymentBytes {
return nil, fmt.Errorf("Pages 部署展开后不能超过 %d MiB", pagesMaxDeploymentBytes/1024/1024)
}
checksum, err := checksumZipFile(item)
if err != nil {
return nil, err
}
if normalizedPath == targetEntryPath {
entrySeen = true
}
manifest.Files = append(manifest.Files, model.PagesDeploymentFile{
Path: normalizedPath,
Size: int64(item.UncompressedSize64),
Checksum: checksum,
})
}
if manifest.FileCount == 0 {
return nil, errors.New(errPagesPackageEmpty)
}
if !entrySeen {
return nil, fmt.Errorf("Pages 部署包缺少入口文件 %s", targetEntryPath)
}
return manifest, nil
}
func normalizePagesZipPath(raw string) (string, bool, error) {
name := strings.TrimSpace(filepath.ToSlash(raw))
if name == "" {
return "", true, nil
}
if strings.HasSuffix(name, "/") {
return "", true, nil
}
if strings.HasPrefix(name, "/") || path.IsAbs(name) {
return "", false, fmt.Errorf("Pages 部署包不能包含绝对路径: %s", raw)
}
cleaned := path.Clean(name)
if cleaned == "." {
return "", true, nil
}
if cleaned == ".." || strings.HasPrefix(cleaned, "../") || strings.Contains(cleaned, "/../") {
return "", false, fmt.Errorf("Pages 部署包路径不能逃逸目录: %s", raw)
}
return cleaned, false, nil
}
func checksumZipFile(item *zip.File) (string, error) {
file, err := item.Open()
if err != nil {
return "", err
}
defer file.Close()
hash := sha256.New()
if _, err = io.Copy(hash, file); err != nil {
return "", err
}
return hex.EncodeToString(hash.Sum(nil)), nil
}
func pagesArtifactPath(projectSlug string, checksum string) (string, error) {
root, err := pagesStorageRoot()
if err != nil {
return "", err
}
return filepath.Join(root, "artifacts", projectSlug, checksum+".zip"), nil
}
func pagesStorageRoot() (string, error) {
cfg := config.Config.Database
if cfg.Enabled {
return filepath.Abs(filepath.Join("data", "pages"))
}
dbPath := strings.TrimSpace(cfg.SQLitePath)
if dbPath == "" || dbPath == ":memory:" {
return filepath.Abs(filepath.Join("data", "pages"))
}
dir := filepath.Dir(dbPath)
if dir == "." || dir == "" {
dir = "data"
}
return filepath.Abs(filepath.Join(dir, "pages"))
}
func copyFile(src string, dst string) error {
input, err := os.Open(src)
if err != nil {
return err
}
defer input.Close()
output, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
if err != nil {
return err
}
defer output.Close()
if _, err = io.Copy(output, input); err != nil {
return err
}
return output.Sync()
}
@@ -0,0 +1,485 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package pages
import (
"context"
"errors"
"fmt"
"mime/multipart"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"github.com/Rain-kl/Wavelet/internal/db"
"github.com/Rain-kl/Wavelet/internal/model"
"gorm.io/gorm"
)
// Input Pages 项目创建/更新请求。
type Input struct {
Name string `json:"name"`
Slug string `json:"slug"`
Description string `json:"description"`
Enabled bool `json:"enabled"`
SPAFallbackEnabled bool `json:"spa_fallback_enabled"`
SPAFallbackPath string `json:"spa_fallback_path"`
APIProxyEnabled bool `json:"api_proxy_enabled"`
APIProxyPath string `json:"api_proxy_path"`
APIProxyPass string `json:"api_proxy_pass"`
APIProxyRewrite string `json:"api_proxy_rewrite"`
RootDir string `json:"root_dir"`
EntryFile string `json:"entry_file"`
}
// DeploymentView Pages 部署视图。
type DeploymentView struct {
ID uint `json:"id"`
ProjectID uint `json:"project_id"`
DeploymentNumber int `json:"deployment_number"`
Checksum string `json:"checksum"`
Status string `json:"status"`
FileCount int `json:"file_count"`
TotalSize int64 `json:"total_size"`
CreatedBy string `json:"created_by"`
CreatedAt time.Time `json:"created_at"`
ActivatedAt *time.Time `json:"activated_at"`
}
// DeploymentFileView Pages 部署文件视图。
type DeploymentFileView struct {
ID uint `json:"id"`
DeploymentID uint `json:"deployment_id"`
Path string `json:"path"`
Size int64 `json:"size"`
Checksum string `json:"checksum"`
CreatedAt time.Time `json:"created_at"`
}
// View Pages 项目视图。
type View struct {
ID uint `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
Description string `json:"description"`
Enabled bool `json:"enabled"`
SPAFallbackEnabled bool `json:"spa_fallback_enabled"`
SPAFallbackPath string `json:"spa_fallback_path"`
APIProxyEnabled bool `json:"api_proxy_enabled"`
APIProxyPath string `json:"api_proxy_path"`
APIProxyPass string `json:"api_proxy_pass"`
APIProxyRewrite string `json:"api_proxy_rewrite"`
RootDir string `json:"root_dir"`
EntryFile string `json:"entry_file"`
ActiveDeploymentID *uint `json:"active_deployment_id"`
ActiveDeployment *DeploymentView `json:"active_deployment,omitempty"`
DeploymentCount int64 `json:"deployment_count"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// ListProjects 列出全部 Pages 项目。
func ListProjects(ctx context.Context) ([]View, error) {
projects, err := model.ListPagesProjects(ctx)
if err != nil {
return nil, err
}
views := make([]View, 0, len(projects))
for _, project := range projects {
view, err := buildProjectView(ctx, &project)
if err != nil {
return nil, err
}
views = append(views, *view)
}
return views, nil
}
// GetProject 获取 Pages 项目详情。
func GetProject(ctx context.Context, id uint) (*View, error) {
project, err := model.GetPagesProjectByID(ctx, id)
if err != nil {
return nil, err
}
return buildProjectView(ctx, project)
}
// CreateProject 创建 Pages 项目。
func CreateProject(ctx context.Context, input Input) (*View, error) {
project, err := buildProject(nil, input)
if err != nil {
return nil, err
}
if err = model.CreatePagesProjectRecord(ctx, project); err != nil {
if isUniqueConstraintError(err) {
return nil, errors.New(errPagesSlugExists)
}
return nil, err
}
return buildProjectView(ctx, project)
}
// UpdateProject 更新 Pages 项目。
func UpdateProject(ctx context.Context, id uint, input Input) (*View, error) {
project, err := model.GetPagesProjectByID(ctx, id)
if err != nil {
return nil, err
}
project, err = buildProject(project, input)
if err != nil {
return nil, err
}
if err = db.DB(ctx).Model(project).Updates(map[string]any{
"name": project.Name,
"slug": project.Slug,
"description": project.Description,
"enabled": project.Enabled,
"spa_fallback_enabled": project.SPAFallbackEnabled,
"spa_fallback_path": project.SPAFallbackPath,
"api_proxy_enabled": project.APIProxyEnabled,
"api_proxy_path": project.APIProxyPath,
"api_proxy_pass": project.APIProxyPass,
"api_proxy_rewrite": project.APIProxyRewrite,
"root_dir": project.RootDir,
"entry_file": project.EntryFile,
}).Error; err != nil {
if isUniqueConstraintError(err) {
return nil, errors.New(errPagesSlugExists)
}
return nil, err
}
return buildProjectView(ctx, project)
}
// DeleteProject 删除 Pages 项目。
func DeleteProject(ctx context.Context, id uint) error {
project, err := model.GetPagesProjectByID(ctx, id)
if err != nil {
return err
}
routeCount, err := model.CountProxyRoutesByPagesProjectID(ctx, project.ID)
if err != nil {
return err
}
if routeCount > 0 {
return errors.New(errPagesDeleteReferenced)
}
deployments, err := model.ListPagesDeployments(ctx, project.ID)
if err != nil {
return err
}
return db.DB(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Where(
"deployment_id IN (?)",
tx.Model(&model.PagesDeployment{}).Select("id").Where("project_id = ?", project.ID),
).Delete(&model.PagesDeploymentFile{}).Error; err != nil {
return err
}
if err := tx.Where("project_id = ?", project.ID).Delete(&model.PagesDeployment{}).Error; err != nil {
return err
}
if err := tx.Delete(project).Error; err != nil {
return err
}
for _, deployment := range deployments {
_ = os.Remove(deployment.ArtifactPath)
}
return nil
})
}
// ListProjectDeployments 列出项目的全部部署。
func ListProjectDeployments(ctx context.Context, projectID uint) ([]DeploymentView, error) {
if _, err := model.GetPagesProjectByID(ctx, projectID); err != nil {
return nil, err
}
deployments, err := model.ListPagesDeployments(ctx, projectID)
if err != nil {
return nil, err
}
views := make([]DeploymentView, 0, len(deployments))
for _, deployment := range deployments {
views = append(views, buildDeploymentView(&deployment))
}
return views, nil
}
// ListDeploymentFiles 列出部署文件清单。
func ListDeploymentFiles(ctx context.Context, deploymentID uint) ([]DeploymentFileView, error) {
if _, err := model.GetPagesDeploymentByID(ctx, deploymentID); err != nil {
return nil, err
}
files, err := model.ListPagesDeploymentFiles(ctx, deploymentID)
if err != nil {
return nil, err
}
views := make([]DeploymentFileView, 0, len(files))
for _, file := range files {
views = append(views, DeploymentFileView{
ID: file.ID,
DeploymentID: file.DeploymentID,
Path: file.Path,
Size: file.Size,
Checksum: file.Checksum,
CreatedAt: file.CreatedAt,
})
}
return views, nil
}
// UploadDeployment 上传 Pages 部署包。
func UploadDeployment(ctx context.Context, projectID uint, fileHeader *multipart.FileHeader, createdBy string) (*DeploymentView, error) {
project, err := model.GetPagesProjectByID(ctx, projectID)
if err != nil {
return nil, err
}
if fileHeader == nil {
return nil, errors.New(errPagesPackageMissing)
}
if !strings.EqualFold(filepath.Ext(fileHeader.Filename), ".zip") {
return nil, errors.New(errPagesPackageNotZip)
}
rootDir, err := validateAndNormalizePagesRootDir(project.RootDir)
if err != nil {
return nil, err
}
entryFile := normalizePagesEntryFile(project.EntryFile)
tempPath, checksum, err := persistPagesUploadTemp(fileHeader)
if err != nil {
return nil, err
}
defer os.Remove(tempPath)
manifest, err := inspectPagesZip(tempPath, rootDir, entryFile)
if err != nil {
return nil, err
}
artifactPath, err := pagesArtifactPath(project.Slug, checksum)
if err != nil {
return nil, err
}
if err = os.MkdirAll(filepath.Dir(artifactPath), 0o755); err != nil {
return nil, fmt.Errorf("创建 Pages 存储目录失败: %w", err)
}
if err = copyFile(tempPath, artifactPath); err != nil {
return nil, err
}
deployment := &model.PagesDeployment{}
err = db.DB(ctx).Transaction(func(tx *gorm.DB) error {
var maxNumber int
if err := tx.Model(&model.PagesDeployment{}).
Where("project_id = ?", project.ID).
Select("COALESCE(MAX(deployment_number), 0)").
Scan(&maxNumber).Error; err != nil {
return err
}
deployment = &model.PagesDeployment{
ProjectID: project.ID,
DeploymentNumber: maxNumber + 1,
Checksum: checksum,
Status: model.PagesDeploymentStatusUploaded,
ArtifactPath: artifactPath,
FileCount: manifest.FileCount,
TotalSize: manifest.TotalSize,
CreatedBy: strings.TrimSpace(createdBy),
}
if err := tx.Create(deployment).Error; err != nil {
return err
}
for index := range manifest.Files {
manifest.Files[index].DeploymentID = deployment.ID
}
if len(manifest.Files) > 0 {
if err := tx.Create(&manifest.Files).Error; err != nil {
return err
}
}
return nil
})
if err != nil {
_ = os.Remove(artifactPath)
return nil, err
}
view := buildDeploymentView(deployment)
return &view, nil
}
// ActivateDeployment 激活 Pages 部署。
func ActivateDeployment(ctx context.Context, projectID uint, deploymentID uint) (*View, error) {
project, err := model.GetPagesProjectByID(ctx, projectID)
if err != nil {
return nil, err
}
deployment, err := model.GetPagesDeploymentByID(ctx, deploymentID)
if err != nil {
return nil, err
}
if deployment.ProjectID != project.ID {
return nil, errors.New(errPagesDeploymentMismatch)
}
now := time.Now()
if err = db.DB(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&model.PagesDeployment{}).
Where("project_id = ?", project.ID).
Update("status", model.PagesDeploymentStatusUploaded).Error; err != nil {
return err
}
if err := tx.Model(deployment).Updates(map[string]any{
"status": model.PagesDeploymentStatusActive,
"activated_at": &now,
}).Error; err != nil {
return err
}
return tx.Model(project).Updates(map[string]any{
"active_deployment_id": deployment.ID,
}).Error
}); err != nil {
return nil, err
}
return GetProject(ctx, project.ID)
}
// DeleteDeployment 删除 Pages 部署。
func DeleteDeployment(ctx context.Context, projectID uint, deploymentID uint) error {
project, err := model.GetPagesProjectByID(ctx, projectID)
if err != nil {
return err
}
deployment, err := model.GetPagesDeploymentByID(ctx, deploymentID)
if err != nil {
return err
}
if deployment.ProjectID != project.ID {
return errors.New(errPagesDeploymentMismatch)
}
if project.ActiveDeploymentID != nil && *project.ActiveDeploymentID == deployment.ID {
return errors.New(errPagesDeleteActiveDeploy)
}
return db.DB(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Where("deployment_id = ?", deployment.ID).Delete(&model.PagesDeploymentFile{}).Error; err != nil {
return err
}
if err := tx.Delete(deployment).Error; err != nil {
return err
}
_ = os.Remove(deployment.ArtifactPath)
return nil
})
}
func buildProject(existing *model.PagesProject, input Input) (*model.PagesProject, error) {
name := strings.TrimSpace(input.Name)
if name == "" {
return nil, errors.New(errPagesNameRequired)
}
slug := normalizePagesSlug(input.Slug)
if slug == "" {
slug = normalizePagesSlug(name)
}
if !pagesSlugPattern.MatchString(slug) {
return nil, errors.New(errPagesSlugInvalid)
}
if existing == nil {
existing = &model.PagesProject{}
}
existing.Name = name
existing.Slug = slug
existing.Description = strings.TrimSpace(input.Description)
existing.Enabled = input.Enabled
existing.SPAFallbackEnabled = input.SPAFallbackEnabled
fallbackPath, err := normalizePagesFallbackPath(input.SPAFallbackPath)
if err != nil {
return nil, err
}
existing.SPAFallbackPath = fallbackPath
existing.APIProxyEnabled = input.APIProxyEnabled
apiProxyPath := strings.TrimSpace(input.APIProxyPath)
apiProxyPass := strings.TrimSpace(input.APIProxyPass)
apiProxyRewrite := strings.TrimSpace(input.APIProxyRewrite)
if existing.APIProxyEnabled {
if apiProxyPath == "" {
return nil, errors.New(errPagesAPIProxyPathRequired)
}
if !strings.HasPrefix(apiProxyPath, "/") {
return nil, errors.New(errPagesAPIProxyPathPrefix)
}
if apiProxyPass == "" {
return nil, errors.New(errPagesAPIProxyPassRequired)
}
parsedURL, err := url.Parse(apiProxyPass)
if err != nil || (parsedURL.Scheme != "http" && parsedURL.Scheme != "https") || parsedURL.Host == "" {
return nil, errors.New(errPagesAPIProxyPassInvalid)
}
}
existing.APIProxyPath = apiProxyPath
existing.APIProxyPass = apiProxyPass
existing.APIProxyRewrite = apiProxyRewrite
rootDir, err := validateAndNormalizePagesRootDir(input.RootDir)
if err != nil {
return nil, err
}
existing.RootDir = rootDir
existing.EntryFile = normalizePagesEntryFile(input.EntryFile)
return existing, nil
}
func buildProjectView(ctx context.Context, project *model.PagesProject) (*View, error) {
if project == nil {
return nil, errors.New(errPagesProjectNotFound)
}
view := &View{
ID: project.ID,
Name: project.Name,
Slug: project.Slug,
Description: project.Description,
Enabled: project.Enabled,
SPAFallbackEnabled: project.SPAFallbackEnabled,
SPAFallbackPath: normalizeStoredPagesFallbackPath(project.SPAFallbackPath),
APIProxyEnabled: project.APIProxyEnabled,
APIProxyPath: project.APIProxyPath,
APIProxyPass: project.APIProxyPass,
APIProxyRewrite: project.APIProxyRewrite,
RootDir: project.RootDir,
EntryFile: project.EntryFile,
ActiveDeploymentID: project.ActiveDeploymentID,
CreatedAt: project.CreatedAt,
UpdatedAt: project.UpdatedAt,
}
count, err := model.CountPagesDeploymentsByProjectID(ctx, project.ID)
if err != nil {
return nil, err
}
view.DeploymentCount = count
if project.ActiveDeploymentID != nil && *project.ActiveDeploymentID != 0 {
deployment, err := model.GetPagesDeploymentByID(ctx, *project.ActiveDeploymentID)
if err == nil {
active := buildDeploymentView(deployment)
view.ActiveDeployment = &active
}
}
return view, nil
}
func buildDeploymentView(deployment *model.PagesDeployment) DeploymentView {
if deployment == nil {
return DeploymentView{}
}
return DeploymentView{
ID: deployment.ID,
ProjectID: deployment.ProjectID,
DeploymentNumber: deployment.DeploymentNumber,
Checksum: deployment.Checksum,
Status: deployment.Status,
FileCount: deployment.FileCount,
TotalSize: deployment.TotalSize,
CreatedBy: deployment.CreatedBy,
CreatedAt: deployment.CreatedAt,
ActivatedAt: deployment.ActivatedAt,
}
}
@@ -0,0 +1,84 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package pages
import (
"context"
"testing"
"github.com/Rain-kl/Wavelet/internal/db"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func setupPagesTestDB(t *testing.T) func() {
t.Helper()
sqliteDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
DisableForeignKeyConstraintWhenMigrating: true,
})
require.NoError(t, err)
require.NoError(t, sqliteDB.AutoMigrate(
&model.PagesProject{},
&model.PagesDeployment{},
&model.PagesDeploymentFile{},
))
db.SetDB(sqliteDB)
return func() {
db.SetDB(nil)
}
}
func TestCreateProject(t *testing.T) {
cleanup := setupPagesTestDB(t)
defer cleanup()
ctx := context.Background()
project, err := CreateProject(ctx, Input{
Name: "Marketing Site",
Slug: "marketing-site",
Description: "public site",
Enabled: true,
SPAFallbackEnabled: true,
SPAFallbackPath: "/index.html",
EntryFile: "index.html",
})
require.NoError(t, err)
assert.NotZero(t, project.ID)
assert.Equal(t, "Marketing Site", project.Name)
assert.Equal(t, "marketing-site", project.Slug)
assert.Equal(t, "public site", project.Description)
assert.True(t, project.Enabled)
assert.True(t, project.SPAFallbackEnabled)
assert.Equal(t, "/index.html", project.SPAFallbackPath)
assert.Equal(t, "index.html", project.EntryFile)
assert.Equal(t, int64(0), project.DeploymentCount)
_, err = CreateProject(ctx, Input{
Name: "Duplicate Slug",
Slug: "marketing-site",
})
require.Error(t, err)
assert.Equal(t, errPagesSlugExists, err.Error())
}
func TestCreateProjectRejectsUnsafeFallbackPath(t *testing.T) {
cleanup := setupPagesTestDB(t)
defer cleanup()
ctx := context.Background()
_, err := CreateProject(ctx, Input{
Name: "Unsafe Fallback",
Slug: "unsafe-fallback",
Enabled: true,
SPAFallbackEnabled: true,
SPAFallbackPath: "/index.html; proxy_pass http://evil",
})
require.Error(t, err)
assert.Contains(t, err.Error(), "回退路径")
}
@@ -0,0 +1,180 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package pages
import (
"errors"
"strconv"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/compat"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
func handleLogicError(c *gin.Context, err error) bool {
if err == nil {
return false
}
if errors.Is(err, gorm.ErrRecordNotFound) {
compat.Fail(c, errPagesProjectNotFound)
return true
}
compat.Fail(c, err.Error())
return true
}
func deploymentIDParam(c *gin.Context) (uint, bool) {
raw := c.Param("deployment_id")
if raw == "" {
compat.Fail(c, "无效的 ID")
return 0, false
}
id64, err := strconv.ParseUint(raw, 10, 64)
if err != nil || id64 == 0 {
compat.Fail(c, "无效的 ID")
return 0, false
}
return uint(id64), true
}
// ListProjectsHandler 列出全部 Pages 项目。
func ListProjectsHandler(c *gin.Context) {
projects, err := ListProjects(c.Request.Context())
if handleLogicError(c, err) {
return
}
compat.OK(c, projects)
}
// GetProjectHandler 获取 Pages 项目详情。
func GetProjectHandler(c *gin.Context) {
id, ok := compat.IDParam(c)
if !ok {
return
}
project, err := GetProject(c.Request.Context(), id)
if handleLogicError(c, err) {
return
}
compat.OK(c, project)
}
// CreateProjectHandler 创建 Pages 项目。
func CreateProjectHandler(c *gin.Context) {
var input Input
if !compat.BindJSON(c, &input) {
return
}
project, err := CreateProject(c.Request.Context(), input)
if handleLogicError(c, err) {
return
}
compat.OK(c, project)
}
// UpdateProjectHandler 更新 Pages 项目。
func UpdateProjectHandler(c *gin.Context) {
id, ok := compat.IDParam(c)
if !ok {
return
}
var input Input
if !compat.BindJSON(c, &input) {
return
}
project, err := UpdateProject(c.Request.Context(), id, input)
if handleLogicError(c, err) {
return
}
compat.OK(c, project)
}
// DeleteProjectHandler 删除 Pages 项目。
func DeleteProjectHandler(c *gin.Context) {
id, ok := compat.IDParam(c)
if !ok {
return
}
if err := DeleteProject(c.Request.Context(), id); handleLogicError(c, err) {
return
}
compat.OK(c, nil)
}
// ListDeploymentsHandler 列出项目的全部部署。
func ListDeploymentsHandler(c *gin.Context) {
id, ok := compat.IDParam(c)
if !ok {
return
}
deployments, err := ListProjectDeployments(c.Request.Context(), id)
if handleLogicError(c, err) {
return
}
compat.OK(c, deployments)
}
// UploadDeploymentHandler 上传 Pages 部署包。
func UploadDeploymentHandler(c *gin.Context) {
id, ok := compat.IDParam(c)
if !ok {
return
}
file, err := c.FormFile("package")
if err != nil {
compat.Fail(c, errPagesPackageMissing)
return
}
deployment, err := UploadDeployment(c.Request.Context(), id, file, "")
if handleLogicError(c, err) {
return
}
compat.OK(c, deployment)
}
// ActivateDeploymentHandler 激活 Pages 部署。
func ActivateDeploymentHandler(c *gin.Context) {
projectID, ok := compat.IDParam(c)
if !ok {
return
}
deploymentID, ok := deploymentIDParam(c)
if !ok {
return
}
project, err := ActivateDeployment(c.Request.Context(), projectID, deploymentID)
if handleLogicError(c, err) {
return
}
compat.OK(c, project)
}
// DeleteDeploymentHandler 删除 Pages 部署。
func DeleteDeploymentHandler(c *gin.Context) {
projectID, ok := compat.IDParam(c)
if !ok {
return
}
deploymentID, ok := deploymentIDParam(c)
if !ok {
return
}
if err := DeleteDeployment(c.Request.Context(), projectID, deploymentID); handleLogicError(c, err) {
return
}
compat.OK(c, nil)
}
// ListDeploymentFilesHandler 列出部署文件清单。
func ListDeploymentFilesHandler(c *gin.Context) {
deploymentID, ok := deploymentIDParam(c)
if !ok {
return
}
files, err := ListDeploymentFiles(c.Request.Context(), deploymentID)
if handleLogicError(c, err) {
return
}
compat.OK(c, files)
}
@@ -0,0 +1,53 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package proxy_route
const (
errProxyRouteNotFound = "proxy route not found"
errProxyRouteIdentityExists = "proxy route identity already exists"
errProxyRouteSiteNameExists = "site_name already exists"
errProxyRouteDomainExists = "domain %s already exists"
errProxyRouteSiteNameEmpty = "site_name cannot be empty"
errProxyRouteDomainRequired = "at least one domain is required"
errProxyRouteDomainInvalid = "domain format is invalid"
errProxyRouteDomainMismatch = "domain must match domains[0]"
errProxyRouteOriginEmpty = "origin_url cannot be empty"
errProxyRouteOriginInvalid = "origin URL format is invalid"
errProxyRouteOriginScheme = "origin URL must start with http:// or https://"
errProxyRouteOriginHostInvalid = "origin_host format is invalid"
errProxyRouteUpstreamRequired = "at least one upstream is required"
errProxyRouteUpstreamScheme = "all upstreams must use the same scheme"
errProxyRouteUpstreamPath = "multi-upstream mode does not support origin paths"
errProxyRouteUpstreamQuery = "multi-upstream mode does not support origin query strings"
errProxyRouteOriginNotFound = "selected origin does not exist"
errProxyRouteCertNotFound = "selected certificate does not exist"
errProxyRouteCertRequired = "must select a certificate when HTTPS is enabled"
errProxyRouteCertDomainLength = "domain_cert_ids must match domains length"
errProxyRouteRedirectHTTP = "redirect_http requires enable_https"
errProxyRouteBasicAuth = "basic_auth_username and basic_auth_password cannot be empty when basic auth is enabled"
errProxyRouteLimitRate = "limit_rate must be a number or use the 512k / 1m format"
errProxyRouteCachePolicy = "cache policy is not supported"
errProxyRouteCacheSuffix = "cache suffix format is invalid"
errProxyRouteCachePath = "cache path rule format is invalid"
errProxyRouteCacheSuffixReq = "at least one suffix is required"
errProxyRouteCachePrefixReq = "at least one path prefix is required"
errProxyRouteCacheExactReq = "at least one exact path is required"
errProxyRouteHeaderKeyEmpty = "custom header key cannot be empty"
errProxyRouteHeaderKeyInvalid = "custom header key format is invalid"
errProxyRouteHeaderNewline = "custom headers cannot contain newlines"
errProxyRouteTunnelNodeReq = "tunnel_node_id is required for tunnel upstream"
errProxyRouteTunnelNodeMissing = "tunnel client node does not exist"
errProxyRouteTunnelNodeType = "tunnel_node_id must reference a tunnel_client node"
errProxyRouteTunnelAddrReq = "tunnel_target_addr is required for tunnel upstream"
errProxyRouteTunnelProtocol = "tunnel_target_protocol must be http or https"
errProxyRoutePagesProjectReq = "pages_project_id is required for Pages upstream"
errProxyRoutePagesNotFound = "Pages 项目不存在"
errProxyRoutePagesDisabled = "Pages 项目未启用"
errProxyRoutePagesNoDeploy = "Pages 项目没有激活部署"
errProxyRouteOriginSchemeOnly = "源站协议仅支持 http 或 https"
errProxyRouteOriginPort = "端口格式不合法"
errProxyRouteOriginPortEmpty = "端口不能为空"
errProxyRouteOriginURI = "源站路径需以 / 或 ? 开头"
errProxyRouteOriginURIProto = "源站路径不能包含协议"
)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,430 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package proxy_route
import (
"context"
"encoding/json"
"errors"
"strings"
"time"
"github.com/Rain-kl/Wavelet/internal/model"
)
// CustomHeaderInput 自定义响应头。
type CustomHeaderInput struct {
Key string `json:"key"`
Value string `json:"value"`
}
// Input 代理规则创建/更新请求。
type Input struct {
SiteName string `json:"site_name"`
Domain string `json:"domain"`
Domains []string `json:"domains"`
OriginID *uint `json:"origin_id"`
OriginURL string `json:"origin_url"`
OriginScheme string `json:"origin_scheme"`
OriginAddress string `json:"origin_address"`
OriginPort string `json:"origin_port"`
OriginURI string `json:"origin_uri"`
OriginHost string `json:"origin_host"`
Upstreams []string `json:"upstreams"`
Enabled bool `json:"enabled"`
EnableHTTPS bool `json:"enable_https"`
CertID *uint `json:"cert_id"`
CertIDs []uint `json:"cert_ids"`
DomainCertIDs []uint `json:"domain_cert_ids"`
RedirectHTTP bool `json:"redirect_http"`
LimitConnPerServer int `json:"limit_conn_per_server"`
LimitConnPerIP int `json:"limit_conn_per_ip"`
LimitRate string `json:"limit_rate"`
CacheEnabled bool `json:"cache_enabled"`
CachePolicy string `json:"cache_policy"`
CacheRules []string `json:"cache_rules"`
CustomHeaders []CustomHeaderInput `json:"custom_headers"`
BasicAuthEnabled bool `json:"basic_auth_enabled"`
BasicAuthUsername string `json:"basic_auth_username"`
BasicAuthPassword string `json:"basic_auth_password"`
Remark string `json:"remark"`
UpstreamType string `json:"upstream_type"`
TunnelNodeID *uint `json:"tunnel_node_id"`
TunnelID *uint `json:"tunnel_id"`
TunnelTargetAddr string `json:"tunnel_target_addr"`
TunnelTargetProtocol string `json:"tunnel_target_protocol"`
PagesProjectID *uint `json:"pages_project_id"`
}
// View 代理规则视图。
type View struct {
ID uint `json:"id"`
SiteName string `json:"site_name"`
Domain string `json:"domain"`
Domains []string `json:"domains"`
PrimaryDomain string `json:"primary_domain"`
DomainCount int `json:"domain_count"`
OriginID *uint `json:"origin_id"`
OriginURL string `json:"origin_url"`
OriginHost string `json:"origin_host"`
Upstreams string `json:"upstreams"`
UpstreamList []string `json:"upstream_list"`
Enabled bool `json:"enabled"`
EnableHTTPS bool `json:"enable_https"`
CertID *uint `json:"cert_id"`
CertIDs []uint `json:"cert_ids"`
DomainCertIDs []uint `json:"domain_cert_ids"`
RedirectHTTP bool `json:"redirect_http"`
LimitConnPerServer int `json:"limit_conn_per_server"`
LimitConnPerIP int `json:"limit_conn_per_ip"`
LimitRate string `json:"limit_rate"`
CacheEnabled bool `json:"cache_enabled"`
CachePolicy string `json:"cache_policy"`
CacheRules string `json:"cache_rules"`
CacheRuleList []string `json:"cache_rule_list"`
CustomHeaders string `json:"custom_headers"`
CustomHeaderList []CustomHeaderInput `json:"custom_header_list"`
BasicAuthEnabled bool `json:"basic_auth_enabled"`
BasicAuthUsername string `json:"basic_auth_username"`
BasicAuthPassword string `json:"basic_auth_password"`
Remark string `json:"remark"`
UpstreamType string `json:"upstream_type"`
TunnelNodeID *uint `json:"tunnel_node_id"`
TunnelID *uint `json:"tunnel_id"`
TunnelTargetAddr string `json:"tunnel_target_addr"`
TunnelTargetProtocol string `json:"tunnel_target_protocol"`
PagesProjectID *uint `json:"pages_project_id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// ListProxyRoutes 列出全部代理规则。
func ListProxyRoutes(ctx context.Context) ([]*View, error) {
routes, err := model.ListProxyRoutes(ctx)
if err != nil {
return nil, err
}
return buildProxyRouteViews(ctx, routes)
}
// GetProxyRoute 获取代理规则详情。
func GetProxyRoute(ctx context.Context, id uint) (*View, error) {
route, err := model.GetProxyRouteByID(ctx, id)
if err != nil {
return nil, err
}
return buildProxyRouteView(ctx, route)
}
// CreateProxyRoute 创建代理规则。
func CreateProxyRoute(ctx context.Context, input Input) (*View, error) {
route, err := buildProxyRoute(ctx, nil, input)
if err != nil {
return nil, err
}
if err = model.CreateProxyRouteRecord(ctx, route); err != nil {
if isUniqueConstraintError(err) {
return nil, errors.New(errProxyRouteIdentityExists)
}
return nil, err
}
return buildProxyRouteView(ctx, route)
}
// UpdateProxyRoute 更新代理规则。
func UpdateProxyRoute(ctx context.Context, id uint, input Input) (*View, error) {
route, err := model.GetProxyRouteByID(ctx, id)
if err != nil {
return nil, err
}
route, err = buildProxyRoute(ctx, route, input)
if err != nil {
return nil, err
}
if err = model.UpdateProxyRouteRecord(ctx, route); err != nil {
if isUniqueConstraintError(err) {
return nil, errors.New(errProxyRouteIdentityExists)
}
return nil, err
}
return buildProxyRouteView(ctx, route)
}
// DeleteProxyRoute 删除代理规则。
func DeleteProxyRoute(ctx context.Context, id uint) error {
if _, err := model.GetProxyRouteByID(ctx, id); err != nil {
return err
}
return model.DeleteProxyRouteRecord(ctx, id)
}
func buildProxyRoute(ctx context.Context, route *model.ProxyRoute, input Input) (*model.ProxyRoute, error) {
domains, err := normalizeProxyRouteDomainsInput(route, input.Domain, input.Domains)
if err != nil {
return nil, err
}
domain := domains[0]
siteName := normalizeProxyRouteSiteNameInput(route, input.SiteName, domain)
upstreamType := normalizeUpstreamType(input.UpstreamType)
var originURL string
var originID *uint
var upstreams []string
if upstreamType == "tunnel" {
originURL = "http://127.0.0.1"
upstreams = []string{originURL}
} else if upstreamType == "pages" {
if err := validatePagesRouteInput(ctx, input.PagesProjectID); err != nil {
return nil, err
}
originURL = "http://127.0.0.1"
upstreams = []string{originURL}
} else {
originURL, originID, err = resolveProxyRoutePrimaryOrigin(ctx, input)
if err != nil {
return nil, err
}
upstreams, err = normalizeUpstreams(originURL, input.Upstreams)
if err != nil {
return nil, err
}
}
originHost := strings.TrimSpace(input.OriginHost)
remark := strings.TrimSpace(input.Remark)
cachePolicy := strings.TrimSpace(input.CachePolicy)
cacheRules, err := normalizeCacheRules(input.CacheEnabled, cachePolicy, input.CacheRules)
if err != nil {
return nil, err
}
customHeaders, err := normalizeCustomHeaders(input.CustomHeaders)
if err != nil {
return nil, err
}
limitConnPerServer, err := normalizeProxyRouteLimitConnValue(input.LimitConnPerServer, "limit_conn_per_server")
if err != nil {
return nil, err
}
limitConnPerIP, err := normalizeProxyRouteLimitConnValue(input.LimitConnPerIP, "limit_conn_per_ip")
if err != nil {
return nil, err
}
limitRate, err := normalizeProxyRouteLimitRate(input.LimitRate)
if err != nil {
return nil, err
}
cacheRulesJSON, err := json.Marshal(cacheRules)
if err != nil {
return nil, err
}
upstreamsJSON, err := json.Marshal(upstreams)
if err != nil {
return nil, err
}
customHeadersJSON, err := json.Marshal(customHeaders)
if err != nil {
return nil, err
}
if !input.EnableHTTPS {
input.RedirectHTTP = false
input.CertID = nil
input.CertIDs = nil
input.DomainCertIDs = nil
}
domainCertIDs, certIDs, primaryCertID, err := normalizeProxyRouteDomainCertificateIDs(
ctx,
domains,
input.EnableHTTPS,
input.DomainCertIDs,
input.CertID,
input.CertIDs,
)
if err != nil {
return nil, err
}
if err := validateProxyRouteDomainCertificateCoverage(ctx, domains, domainCertIDs); err != nil {
return nil, err
}
certIDsJSON, err := json.Marshal(certIDs)
if err != nil {
return nil, err
}
domainCertIDsJSON, err := json.Marshal(domainCertIDs)
if err != nil {
return nil, err
}
domainsJSON, err := json.Marshal(domains)
if err != nil {
return nil, err
}
if err := validateProxyRouteSiteName(siteName); err != nil {
return nil, err
}
if err := validateProxyRouteIdentityUniqueness(ctx, route, siteName, domains); err != nil {
return nil, err
}
if err := validateOriginHost(originHost); err != nil {
return nil, err
}
input.DomainCertIDs = domainCertIDs
input.CertIDs = certIDs
input.CertID = primaryCertID
if input.RedirectHTTP && !input.EnableHTTPS {
return nil, errors.New(errProxyRouteRedirectHTTP)
}
if input.BasicAuthEnabled {
input.BasicAuthUsername = strings.TrimSpace(input.BasicAuthUsername)
input.BasicAuthPassword = strings.TrimSpace(input.BasicAuthPassword)
if input.BasicAuthUsername == "" || input.BasicAuthPassword == "" {
return nil, errors.New(errProxyRouteBasicAuth)
}
} else {
input.BasicAuthUsername = ""
input.BasicAuthPassword = ""
}
if route == nil {
route = &model.ProxyRoute{}
}
route.SiteName = siteName
route.Domain = domain
route.Domains = string(domainsJSON)
route.OriginID = originID
route.OriginURL = upstreams[0]
route.OriginHost = originHost
route.Upstreams = string(upstreamsJSON)
route.Enabled = input.Enabled
route.EnableHTTPS = input.EnableHTTPS
route.CertID = input.CertID
route.CertIDs = string(certIDsJSON)
route.DomainCertIDs = string(domainCertIDsJSON)
route.RedirectHTTP = input.RedirectHTTP
route.LimitConnPerServer = limitConnPerServer
route.LimitConnPerIP = limitConnPerIP
route.LimitRate = limitRate
route.CacheEnabled = input.CacheEnabled
route.CachePolicy = normalizeCachePolicy(input.CacheEnabled, cachePolicy)
route.CacheRules = string(cacheRulesJSON)
route.CustomHeaders = string(customHeadersJSON)
route.BasicAuthEnabled = input.BasicAuthEnabled
route.BasicAuthUsername = input.BasicAuthUsername
route.BasicAuthPassword = input.BasicAuthPassword
route.Remark = remark
route.UpstreamType = upstreamType
if upstreamType == "tunnel" {
tunnelNodeID, err := normalizeTunnelNodeID(input.TunnelNodeID, input.TunnelID)
if err != nil {
return nil, err
}
if err := validateTunnelRouteInput(ctx, tunnelNodeID, input.TunnelTargetAddr, input.TunnelTargetProtocol); err != nil {
return nil, err
}
route.TunnelNodeID = tunnelNodeID
route.TunnelTargetAddr = strings.TrimSpace(input.TunnelTargetAddr)
route.TunnelTargetProtocol = normalizeTunnelTargetProtocol(input.TunnelTargetProtocol)
route.PagesProjectID = nil
} else if upstreamType == "pages" {
route.TunnelNodeID = nil
route.TunnelTargetAddr = ""
route.TunnelTargetProtocol = ""
route.PagesProjectID = input.PagesProjectID
} else {
route.TunnelNodeID = nil
route.TunnelTargetAddr = ""
route.TunnelTargetProtocol = ""
route.PagesProjectID = nil
}
return route, nil
}
func buildProxyRouteViews(ctx context.Context, routes []*model.ProxyRoute) ([]*View, error) {
views := make([]*View, 0, len(routes))
for _, route := range routes {
view, err := buildProxyRouteView(ctx, route)
if err != nil {
return nil, err
}
views = append(views, view)
}
return views, nil
}
func buildProxyRouteView(ctx context.Context, route *model.ProxyRoute) (*View, error) {
if route == nil {
return nil, errors.New("proxy route is nil")
}
domains, err := decodeStoredDomains(route.Domains, route.Domain)
if err != nil {
return nil, err
}
upstreams, err := decodeStoredUpstreams(route.Upstreams, route.OriginURL)
if err != nil {
return nil, err
}
cacheRules, err := decodeStoredCacheRules(route.CacheRules)
if err != nil {
return nil, err
}
customHeaders, err := decodeStoredCustomHeaders(route.CustomHeaders)
if err != nil {
return nil, err
}
certIDs, err := decodeStoredCertIDs(route.CertIDs, route.CertID)
if err != nil {
return nil, err
}
domainCertIDs, err := resolveProxyRouteDomainCertIDs(ctx, route, domains, certIDs)
if err != nil {
return nil, err
}
var certID *uint
if len(certIDs) > 0 {
certID = &certIDs[0]
}
primaryDomain := domains[0]
return &View{
ID: route.ID,
SiteName: normalizeProxyRouteSiteNameInput(route, route.SiteName, primaryDomain),
Domain: primaryDomain,
Domains: domains,
PrimaryDomain: primaryDomain,
DomainCount: len(domains),
OriginID: route.OriginID,
OriginURL: route.OriginURL,
OriginHost: route.OriginHost,
Upstreams: route.Upstreams,
UpstreamList: upstreams,
Enabled: route.Enabled,
EnableHTTPS: route.EnableHTTPS,
CertID: certID,
CertIDs: certIDs,
DomainCertIDs: domainCertIDs,
RedirectHTTP: route.RedirectHTTP,
LimitConnPerServer: route.LimitConnPerServer,
LimitConnPerIP: route.LimitConnPerIP,
LimitRate: route.LimitRate,
CacheEnabled: route.CacheEnabled,
CachePolicy: route.CachePolicy,
CacheRules: route.CacheRules,
CacheRuleList: cacheRules,
CustomHeaders: route.CustomHeaders,
CustomHeaderList: customHeaders,
BasicAuthEnabled: route.BasicAuthEnabled,
BasicAuthUsername: route.BasicAuthUsername,
BasicAuthPassword: route.BasicAuthPassword,
Remark: route.Remark,
UpstreamType: route.UpstreamType,
TunnelNodeID: route.TunnelNodeID,
TunnelID: route.TunnelNodeID,
TunnelTargetAddr: route.TunnelTargetAddr,
TunnelTargetProtocol: route.TunnelTargetProtocol,
PagesProjectID: route.PagesProjectID,
CreatedAt: route.CreatedAt,
UpdatedAt: route.UpdatedAt,
}, nil
}
@@ -0,0 +1,88 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package proxy_route
import (
"context"
"testing"
"github.com/Rain-kl/Wavelet/internal/db"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func setupProxyRouteTestDB(t *testing.T) func() {
t.Helper()
sqliteDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
DisableForeignKeyConstraintWhenMigrating: true,
})
require.NoError(t, err)
require.NoError(t, sqliteDB.AutoMigrate(&model.ProxyRoute{}, &model.Origin{}))
db.SetDB(sqliteDB)
return func() {
db.SetDB(nil)
}
}
func TestCreateProxyRoute(t *testing.T) {
cleanup := setupProxyRouteTestDB(t)
defer cleanup()
ctx := context.Background()
view, err := CreateProxyRoute(ctx, Input{
SiteName: "example-site",
Domain: "example.com",
OriginURL: "http://origin.example.com:8080",
Enabled: true,
})
require.NoError(t, err)
assert.NotZero(t, view.ID)
assert.Equal(t, "example-site", view.SiteName)
assert.Equal(t, "example.com", view.Domain)
assert.Equal(t, []string{"example.com"}, view.Domains)
assert.Equal(t, "http://origin.example.com:8080", view.OriginURL)
assert.Equal(t, []string{"http://origin.example.com:8080"}, view.UpstreamList)
assert.True(t, view.Enabled)
_, err = CreateProxyRoute(ctx, Input{
SiteName: "duplicate-site",
Domain: "example.com",
OriginURL: "http://origin.example.com:8080",
})
require.Error(t, err)
assert.Contains(t, err.Error(), "already exists")
}
func TestListProxyRoutes(t *testing.T) {
cleanup := setupProxyRouteTestDB(t)
defer cleanup()
ctx := context.Background()
first, err := CreateProxyRoute(ctx, Input{
SiteName: "first-site",
Domain: "first.example.com",
OriginURL: "http://origin-a.internal:80",
})
require.NoError(t, err)
second, err := CreateProxyRoute(ctx, Input{
SiteName: "second-site",
Domain: "second.example.com",
OriginURL: "http://origin-b.internal:80",
})
require.NoError(t, err)
routes, err := ListProxyRoutes(ctx)
require.NoError(t, err)
require.Len(t, routes, 2)
assert.Equal(t, second.ID, routes[0].ID)
assert.Equal(t, first.ID, routes[1].ID)
assert.Equal(t, "second.example.com", routes[0].Domain)
assert.Equal(t, "first.example.com", routes[1].Domain)
}
@@ -0,0 +1,88 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package proxy_route
import (
"errors"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/compat"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
func handleLogicError(c *gin.Context, err error) bool {
if err == nil {
return false
}
if errors.Is(err, gorm.ErrRecordNotFound) {
compat.Fail(c, errProxyRouteNotFound)
return true
}
compat.Fail(c, err.Error())
return true
}
// GetProxyRoutes 列出全部代理规则。
func GetProxyRoutes(c *gin.Context) {
routes, err := ListProxyRoutes(c.Request.Context())
if handleLogicError(c, err) {
return
}
compat.OK(c, routes)
}
// GetProxyRouteHandler 获取代理规则详情。
func GetProxyRouteHandler(c *gin.Context) {
id, ok := compat.IDParam(c)
if !ok {
return
}
route, err := GetProxyRoute(c.Request.Context(), id)
if handleLogicError(c, err) {
return
}
compat.OK(c, route)
}
// CreateProxyRouteHandler 创建代理规则。
func CreateProxyRouteHandler(c *gin.Context) {
var input Input
if !compat.BindJSON(c, &input) {
return
}
route, err := CreateProxyRoute(c.Request.Context(), input)
if handleLogicError(c, err) {
return
}
compat.OK(c, route)
}
// UpdateProxyRouteHandler 更新代理规则。
func UpdateProxyRouteHandler(c *gin.Context) {
id, ok := compat.IDParam(c)
if !ok {
return
}
var input Input
if !compat.BindJSON(c, &input) {
return
}
route, err := UpdateProxyRoute(c.Request.Context(), id, input)
if handleLogicError(c, err) {
return
}
compat.OK(c, route)
}
// DeleteProxyRouteHandler 删除代理规则。
func DeleteProxyRouteHandler(c *gin.Context) {
id, ok := compat.IDParam(c)
if !ok {
return
}
if err := DeleteProxyRoute(c.Request.Context(), id); handleLogicError(c, err) {
return
}
compat.OK(c, nil)
}
@@ -0,0 +1,9 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package relay
const (
errAgentTokenInvalid = "无权进行此操作,Agent Token 无效"
errRelayNodeTypeMismatch = "此节点不是 TunnelRelay 类型"
)

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