mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-04 23:16:37 +08:00
migrate
This commit is contained in:
+29
-27
@@ -6,16 +6,18 @@ require (
|
|||||||
github.com/ClickHouse/clickhouse-go/v2 v2.45.0
|
github.com/ClickHouse/clickhouse-go/v2 v2.45.0
|
||||||
github.com/alicebob/miniredis/v2 v2.38.0
|
github.com/alicebob/miniredis/v2 v2.38.0
|
||||||
github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.5.1
|
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 v1.41.6
|
||||||
github.com/aws/aws-sdk-go-v2/config v1.32.14
|
github.com/aws/aws-sdk-go-v2/config v1.32.16
|
||||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.14
|
github.com/aws/aws-sdk-go-v2/credentials v1.19.15
|
||||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.99.0
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.99.1
|
||||||
github.com/bwmarrin/snowflake v0.3.0
|
github.com/bwmarrin/snowflake v0.3.0
|
||||||
github.com/coreos/go-oidc/v3 v3.17.0
|
github.com/coreos/go-oidc/v3 v3.17.0
|
||||||
github.com/deepteams/webp v1.2.3
|
github.com/deepteams/webp v1.2.3
|
||||||
|
github.com/expr-lang/expr v1.17.8
|
||||||
github.com/gin-contrib/sessions v1.0.4
|
github.com/gin-contrib/sessions v1.0.4
|
||||||
github.com/gin-gonic/gin v1.11.0
|
github.com/gin-gonic/gin v1.11.0
|
||||||
github.com/glebarez/sqlite v1.11.0
|
github.com/glebarez/sqlite v1.11.0
|
||||||
|
github.com/go-acme/lego/v4 v4.35.2
|
||||||
github.com/go-jose/go-jose/v4 v4.1.4
|
github.com/go-jose/go-jose/v4 v4.1.4
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
@@ -23,7 +25,7 @@ require (
|
|||||||
github.com/maypok86/otter/v2 v2.3.0
|
github.com/maypok86/otter/v2 v2.3.0
|
||||||
github.com/peterbourgon/diskv/v3 v3.0.1
|
github.com/peterbourgon/diskv/v3 v3.0.1
|
||||||
github.com/pressly/goose/v3 v3.27.1
|
github.com/pressly/goose/v3 v3.27.1
|
||||||
github.com/rain-kl/openflare v0.0.0-00010101000000-000000000000
|
github.com/rain-kl/openflare v0.0.0
|
||||||
github.com/redis/go-redis/extra/redisotel/v9 v9.16.0
|
github.com/redis/go-redis/extra/redisotel/v9 v9.16.0
|
||||||
github.com/redis/go-redis/v9 v9.16.0
|
github.com/redis/go-redis/v9 v9.16.0
|
||||||
github.com/robfig/cron/v3 v3.0.1
|
github.com/robfig/cron/v3 v3.0.1
|
||||||
@@ -46,7 +48,7 @@ require (
|
|||||||
golang.org/x/crypto v0.51.0
|
golang.org/x/crypto v0.51.0
|
||||||
golang.org/x/image v0.42.0
|
golang.org/x/image v0.42.0
|
||||||
golang.org/x/mod v0.36.0
|
golang.org/x/mod v0.36.0
|
||||||
golang.org/x/oauth2 v0.35.0
|
golang.org/x/oauth2 v0.36.0
|
||||||
golang.org/x/sync v0.21.0
|
golang.org/x/sync v0.21.0
|
||||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||||
gorm.io/driver/postgres v1.6.0
|
gorm.io/driver/postgres v1.6.0
|
||||||
@@ -63,21 +65,20 @@ require (
|
|||||||
github.com/ClickHouse/ch-go v0.71.0 // indirect
|
github.com/ClickHouse/ch-go v0.71.0 // indirect
|
||||||
github.com/KyleBanks/depth v1.2.1 // indirect
|
github.com/KyleBanks/depth v1.2.1 // indirect
|
||||||
github.com/andybalholm/brotli v1.2.1 // 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/aws/protocol/eventstream v1.7.9 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 // indirect
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.22 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.22 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 // indirect
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.23 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 // indirect
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.8 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.14 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 // indirect
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.22 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 // indirect
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.22 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 // indirect
|
github.com/aws/aws-sdk-go-v2/service/signin v1.0.10 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 // indirect
|
github.com/aws/aws-sdk-go-v2/service/sso v1.30.16 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.15 // indirect
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.20 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19 // indirect
|
github.com/aws/aws-sdk-go-v2/service/sts v1.42.0 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 // indirect
|
github.com/aws/smithy-go v1.25.0 // indirect
|
||||||
github.com/aws/smithy-go v1.24.2 // indirect
|
|
||||||
github.com/boj/redistore v1.4.1 // indirect
|
github.com/boj/redistore v1.4.1 // indirect
|
||||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||||
github.com/bytedance/sonic v1.14.2 // indirect
|
github.com/bytedance/sonic v1.14.2 // indirect
|
||||||
@@ -85,7 +86,7 @@ require (
|
|||||||
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||||
github.com/dgraph-io/ristretto/v2 v2.2.0 // indirect
|
github.com/dgraph-io/ristretto/v2 v2.2.0 // indirect
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
@@ -112,16 +113,16 @@ require (
|
|||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
github.com/go-playground/validator/v10 v10.28.0 // indirect
|
github.com/go-playground/validator/v10 v10.28.0 // indirect
|
||||||
github.com/go-sql-driver/mysql v1.9.3 // indirect
|
github.com/go-sql-driver/mysql v1.9.3 // indirect
|
||||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||||
github.com/goccy/go-json v0.10.5 // indirect
|
github.com/goccy/go-json v0.10.5 // indirect
|
||||||
github.com/goccy/go-yaml v1.18.0 // indirect
|
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||||
github.com/gomodule/redigo v1.9.3 // indirect
|
github.com/gomodule/redigo v1.9.3 // indirect
|
||||||
github.com/google/btree v1.0.0 // indirect
|
github.com/google/btree v1.0.0 // indirect
|
||||||
github.com/gorilla/context v1.1.2 // indirect
|
github.com/gorilla/context v1.1.2 // indirect
|
||||||
github.com/gorilla/securecookie v1.1.2 // indirect
|
github.com/gorilla/securecookie v1.1.2 // indirect
|
||||||
github.com/gorilla/sessions v1.4.0 // indirect
|
github.com/gorilla/sessions v1.4.0 // indirect
|
||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
|
||||||
github.com/hashicorp/go-version v1.8.0 // indirect
|
github.com/hashicorp/go-version v1.9.0 // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
@@ -136,6 +137,7 @@ require (
|
|||||||
github.com/mattn/go-isatty v0.0.21 // indirect
|
github.com/mattn/go-isatty v0.0.21 // indirect
|
||||||
github.com/mattn/go-sqlite3 v1.14.22 // indirect
|
github.com/mattn/go-sqlite3 v1.14.22 // indirect
|
||||||
github.com/mfridman/interpolate v0.0.2 // indirect
|
github.com/mfridman/interpolate v0.0.2 // indirect
|
||||||
|
github.com/miekg/dns v1.1.72 // indirect
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
|
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
|
||||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
@@ -143,7 +145,7 @@ require (
|
|||||||
github.com/paulmach/orb v0.13.0 // indirect
|
github.com/paulmach/orb v0.13.0 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||||
github.com/pierrec/lz4/v4 v4.1.26 // indirect
|
github.com/pierrec/lz4/v4 v4.1.26 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||||
github.com/quic-go/qpack v0.5.1 // indirect
|
github.com/quic-go/qpack v0.5.1 // indirect
|
||||||
github.com/quic-go/quic-go v0.55.0 // 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/redis/go-redis/extra/rediscmd/v9 v9.16.0 // indirect
|
||||||
@@ -171,7 +173,7 @@ require (
|
|||||||
golang.org/x/net v0.54.0 // indirect
|
golang.org/x/net v0.54.0 // indirect
|
||||||
golang.org/x/sys v0.44.0 // indirect
|
golang.org/x/sys v0.44.0 // indirect
|
||||||
golang.org/x/text v0.38.0 // indirect
|
golang.org/x/text v0.38.0 // indirect
|
||||||
golang.org/x/time v0.14.0 // indirect
|
golang.org/x/time v0.15.0 // indirect
|
||||||
golang.org/x/tools v0.45.0 // indirect
|
golang.org/x/tools v0.45.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect
|
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529 // indirect
|
||||||
|
|||||||
+56
-50
@@ -12,44 +12,42 @@ github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.5.1 h1:vtiFd0hhPAbyYJjztl0wYUq/P
|
|||||||
github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.5.1/go.mod h1:FTzydeQVmR24FI0D6XWUOMKckjXehM/jgMn1xC+DA9M=
|
github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.5.1/go.mod h1:FTzydeQVmR24FI0D6XWUOMKckjXehM/jgMn1xC+DA9M=
|
||||||
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
|
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/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.6 h1:1AX0AthnBQzMx1vbmir3Y4WsnJgiydmnJjiLu+LvXOg=
|
||||||
github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
|
github.com/aws/aws-sdk-go-v2 v1.41.6/go.mod h1:dy0UzBIfwSeot4grGvY1AqFWN5zgziMmWGzysDnHFcQ=
|
||||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o=
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.9 h1:adBsCIIpLbLmYnkQU+nAChU5yhVTvu5PerROm+/Kq2A=
|
||||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.9/go.mod h1:uOYhgfgThm/ZyAuJGNQ5YgNyOlYfqnGpTHXvk3cpykg=
|
||||||
github.com/aws/aws-sdk-go-v2/config v1.32.14 h1:opVIRo/ZbbI8OIqSOKmpFaY7IwfFUOCCXBsUpJOwDdI=
|
github.com/aws/aws-sdk-go-v2/config v1.32.16 h1:Q0iQ7quUgJP0F/SCRTieScnaMdXr9h/2+wze1u3cNeM=
|
||||||
github.com/aws/aws-sdk-go-v2/config v1.32.14/go.mod h1:U4/V0uKxh0Tl5sxmCBZ3AecYny4UNlVmObYjKuuaiOo=
|
github.com/aws/aws-sdk-go-v2/config v1.32.16/go.mod h1:duCCnJEFqpt2RC6no1iK6q+8HpwOAkiUua0pY507dQc=
|
||||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.14 h1:n+UcGWAIZHkXzYt87uMFBv/l8THYELoX6gVcUvgl6fI=
|
github.com/aws/aws-sdk-go-v2/credentials v1.19.15 h1:fyvgWTszojq8hEnMi8PPBTvZdTtEVmAVyo+NFLHBhH4=
|
||||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.14/go.mod h1:cJKuyWB59Mqi0jM3nFYQRmnHVQIcgoxjEMAbLkpr62w=
|
github.com/aws/aws-sdk-go-v2/credentials v1.19.15/go.mod h1:gJiYyMOjNg8OEdRWOf3CrFQxM2a98qmrtjx1zuiQfB8=
|
||||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 h1:NUS3K4BTDArQqNu2ih7yeDLaS3bmHD0YndtA6UP884g=
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.22 h1:IOGsJ1xVWhsi+ZO7/NW8OuZZBtMJLZbk4P5HDjJO0jQ=
|
||||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21/go.mod h1:YWNWJQNjKigKY1RHVJCuupeWDrrHjRqHm0N9rdrWzYI=
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.22/go.mod h1:b+hYdbU+jGKfXE8kKM6g1+h+L/Go3vMvzlxBsiuGsxg=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4=
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.22 h1:GmLa5Kw1ESqtFpXsx5MmC84QWa/ZrLZvlJGa2y+4kcQ=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c=
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.22/go.mod h1:6sW9iWm9DK9YRpRGga/qzrzNLgKpT2cIxb7Vo2eNOp0=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A=
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22 h1:dY4kWZiSaXIzxnKlj17nHnBcXXBfac6UlsAx2qL6XrU=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps=
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22/go.mod h1:KIpEUx0JuRZLO7U6cbV204cWAEco2iC3l061IxlwLtI=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 h1:qYQ4pzQ2Oz6WpQ8T3HvGHnZydA72MnLuFK9tJwmrbHw=
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.23 h1:FPXsW9+gMuIeKmz7j6ENWcWtBGTe1kH8r9thNt5Uxx4=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY=
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.23/go.mod h1:7J8iGMdRKk6lw2C+cMIphgAnT8uTwBwNOsGkyOCm80U=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 h1:rWyie/PxDRIdhNf4DzRk0lvjVOqFJuNnO8WwaIRVxzQ=
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.8 h1:HtOTYcbVcGABLOVuPYaIihj6IlkqubBwFj10K5fxRek=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22/go.mod h1:zd/JsJ4P7oGfUhXn1VyLqaRZwPmZwg44Jf2dS84Dm3Y=
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.8/go.mod h1:VsK9abqQeGlzPgUr+isNWzPlK2vKe9INMLWnY65f5Xs=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY=
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.14 h1:xnvDEnw+pnj5mctWiYuFbigrEzSm35x7k4KS/ZkCANg=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.14/go.mod h1:yS5rNogD8e0Wu9+l3MUwr6eENBzEeGejvINpN5PAYfY=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 h1:JRaIgADQS/U6uXDqlPiefP32yXTda7Kqfx+LgspooZM=
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.22 h1:PUmZeJU6Y1Lbvt9WFuJ0ugUK2xn6hIWUBBbKuOWF30s=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13/go.mod h1:CEuVn5WqOMilYl+tbccq8+N2ieCy0gVn3OtRb0vBNNM=
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.22/go.mod h1:nO6egFBoAaoXze24a2C0NjQCvdpk8OueRoYimvEB9jo=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 h1:c31//R3xgIJMSC8S6hEVq+38DcvUlgFY0FM6mSI5oto=
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.22 h1:SE+aQ4DEqG53RRCAIHlCf//B2ycxGH7jFkpnAh/kKPM=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21/go.mod h1:r6+pf23ouCB718FUxaqzZdbpYFyDtehyZcmP5KL9FkA=
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.22/go.mod h1:ES3ynECd7fYeJIL6+oax+uIEljmfps0S70BaQzbMd/o=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 h1:ZlvrNcHSFFWURB8avufQq9gFsheUgjVD9536obIknfM=
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.99.1 h1:kU/eBN5+MWNo/LcbNa4hWDdN76hdcd7hocU5kvu7IsU=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21/go.mod h1:cv3TNhVrssKR0O/xxLJVRfd2oazSnZnkUeTf6ctUwfQ=
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.99.1/go.mod h1:Fw9aqhJicIVee1VytBBjH+l+5ov6/PhbtIK/u3rt/ls=
|
||||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.99.0 h1:hlSuz394kV0vhv9drL5lhuEFbEOEP1VyQpy15qWh1Pk=
|
github.com/aws/aws-sdk-go-v2/service/signin v1.0.10 h1:a1Fq/KXn75wSzoJaPQTgZO0wHGqE9mjFnylnqEPTchA=
|
||||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.99.0/go.mod h1:uoA43SdFwacedBfSgfFSjjCvYe8aYBS7EnU5GZ/YKMM=
|
github.com/aws/aws-sdk-go-v2/service/signin v1.0.10/go.mod h1:p6+MXNxW7IA6dMgHfTAzljuwSKD0NCm/4lbS4t6+7vI=
|
||||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 h1:QKZH0S178gCmFEgst8hN0mCX1KxLgHBKKY/CLqwP8lg=
|
github.com/aws/aws-sdk-go-v2/service/sso v1.30.16 h1:x6bKbmDhsgSZwv6q19wY/u3rLk/3FGjJWyqKcIRufpE=
|
||||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.9/go.mod h1:7yuQJoT+OoH8aqIxw9vwF+8KpvLZ8AWmvmUWHsGQZvI=
|
github.com/aws/aws-sdk-go-v2/service/sso v1.30.16/go.mod h1:CudnEVKRtLn0+3uMV0yEXZ+YZOKnAtUJ5DmDhilVnIw=
|
||||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.15 h1:lFd1+ZSEYJZYvv9d6kXzhkZu07si3f+GQ1AaYwa2LUM=
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.20 h1:oK/njaL8GtyEihkWMD4k3VgHCT64RQKkZwh0DG5j8ak=
|
||||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.15/go.mod h1:WSvS1NLr7JaPunCXqpJnWk1Bjo7IxzZXrZi1QQCkuqM=
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.20/go.mod h1:JHs8/y1f3zY7U5WcuzoJ/yAYGYtNIVPKLIbp61euvmg=
|
||||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19 h1:dzztQ1YmfPrxdrOiuZRMF6fuOwWlWpD2StNLTceKpys=
|
github.com/aws/aws-sdk-go-v2/service/sts v1.42.0 h1:ks8KBcZPh3PYISr5dAiXCM5/Thcuxk8l+PG4+A0exds=
|
||||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19/go.mod h1:YO8TrYtFdl5w/4vmjL8zaBSsiNp3w0L1FfKVKenZT7w=
|
github.com/aws/aws-sdk-go-v2/service/sts v1.42.0/go.mod h1:pFw33T0WLvXU3rw1WBkpMlkgIn54eCB5FYLhjDc9Foo=
|
||||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 h1:p8ogvvLugcR/zLBXTXrTkj0RYBUdErbMnAFFp12Lm/U=
|
github.com/aws/smithy-go v1.25.0 h1:Sz/XJ64rwuiKtB6j98nDIPyYrV1nVNJ4YU74gttcl5U=
|
||||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.10/go.mod h1:60dv0eZJfeVXfbT1tFJinbHrDfSJ2GZl4Q//OSSNAVw=
|
github.com/aws/smithy-go v1.25.0/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||||
github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
|
|
||||||
github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
|
||||||
github.com/boj/redistore v1.4.1 h1:lP9ZZWqKMq2RIqexlZX1w1ODSnegL+puxGIujkU5tIw=
|
github.com/boj/redistore v1.4.1 h1:lP9ZZWqKMq2RIqexlZX1w1ODSnegL+puxGIujkU5tIw=
|
||||||
github.com/boj/redistore v1.4.1/go.mod h1:c0Tvw6aMjslog4jHIAcNv6EtJM849YoOAhMY7JBbWpI=
|
github.com/boj/redistore v1.4.1/go.mod h1:c0Tvw6aMjslog4jHIAcNv6EtJM849YoOAhMY7JBbWpI=
|
||||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||||
@@ -74,8 +72,9 @@ github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNv
|
|||||||
github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
|
github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
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/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||||
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/deepteams/webp v1.2.3 h1:TedmP3+U8/xBrC0dTkbd3+wbmnM4EIaMowQJpLz6UqU=
|
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/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 h1:bkY3XzJcXoMuELV8F+vS8kzNgicwQFAaGINAEJdWGOM=
|
||||||
@@ -86,6 +85,8 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/r
|
|||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/expr-lang/expr v1.17.8 h1:W1loDTT+0PQf5YteHSTpju2qfUfNoBt4yw9+wOEU9VM=
|
||||||
|
github.com/expr-lang/expr v1.17.8/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4=
|
||||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||||
@@ -106,6 +107,8 @@ github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9g
|
|||||||
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
|
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
|
||||||
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
||||||
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
||||||
|
github.com/go-acme/lego/v4 v4.35.2 h1:uVQg+KC/yj9R2g7Q9W5wDqhvQvxV5SMu5eqFVoN5xZU=
|
||||||
|
github.com/go-acme/lego/v4 v4.35.2/go.mod h1:pX2jN5n8OphMGY1IaMjYm5DAEzguBaKRt8AvJAgJXpc=
|
||||||
github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw=
|
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/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 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg=
|
||||||
@@ -150,12 +153,12 @@ github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0
|
|||||||
github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU=
|
github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU=
|
||||||
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
|
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
|
||||||
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
||||||
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
|
||||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
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-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.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||||
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||||
github.com/gomodule/redigo v1.9.3 h1:dNPSXeXv6HCq2jdyWfjgmhBdqnR6PRO3m/G05nvpPC8=
|
github.com/gomodule/redigo v1.9.3 h1:dNPSXeXv6HCq2jdyWfjgmhBdqnR6PRO3m/G05nvpPC8=
|
||||||
@@ -181,8 +184,8 @@ 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/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
|
||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
|
||||||
github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4=
|
github.com/hashicorp/go-version v1.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaXPSCnA=
|
||||||
github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
|
github.com/hashicorp/go-version v1.9.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 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
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 h1:phj028N0nm15n8O2ims+IvJ2gz4k2auvermngh9JhTw=
|
||||||
@@ -221,6 +224,8 @@ github.com/maypok86/otter/v2 v2.3.0 h1:8H8AVVFUSzJwIegKwv1uF5aGitTY+AIrtktg7OcLs
|
|||||||
github.com/maypok86/otter/v2 v2.3.0/go.mod h1:XgIdlpmL6jYz882/CAx1E4C1ukfgDKSaw4mWq59+7l8=
|
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 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
|
||||||
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
|
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
|
||||||
|
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
|
||||||
|
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-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 h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
@@ -239,8 +244,9 @@ github.com/peterbourgon/diskv/v3 v3.0.1 h1:x06SQA46+PKIUftmEujdwSEpIx8kR+M9eLYsU
|
|||||||
github.com/peterbourgon/diskv/v3 v3.0.1/go.mod h1:kJ5Ny7vLdARGU3WUuy6uzO6T0nb/2gWcT1JiBvRmb5o=
|
github.com/peterbourgon/diskv/v3 v3.0.1/go.mod h1:kJ5Ny7vLdARGU3WUuy6uzO6T0nb/2gWcT1JiBvRmb5o=
|
||||||
github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY=
|
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/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/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||||
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/pressly/goose/v3 v3.27.1 h1:6uEvcprBybDmW4hcz3gYujhARhye+GoWKhEWyzD5sh4=
|
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/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 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
|
||||||
@@ -367,8 +373,8 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug
|
|||||||
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||||
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
|
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/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
|
||||||
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
|
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||||
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||||
@@ -390,8 +396,8 @@ 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.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
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/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
||||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
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-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
|
|||||||
@@ -29,6 +29,11 @@ type activeConfigSnapshot struct {
|
|||||||
WAF snapshotWAFSection `json:"waf"`
|
WAF snapshotWAFSection `json:"waf"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WAFIPGroupsForAgent builds agent-facing WAF IP group payloads for the given ids.
|
||||||
|
func WAFIPGroupsForAgent(ctx context.Context, ids []uint) ([]WAFIPGroup, error) {
|
||||||
|
return buildAgentWAFIPGroups(ctx, ids)
|
||||||
|
}
|
||||||
|
|
||||||
// ChangedWAFIPGroupsForAgent returns WAF IP groups whose checksums differ from the agent state.
|
// ChangedWAFIPGroupsForAgent returns WAF IP groups whose checksums differ from the agent state.
|
||||||
func ChangedWAFIPGroupsForAgent(ctx context.Context, ids []uint, checksums map[string]string) ([]WAFIPGroup, error) {
|
func ChangedWAFIPGroupsForAgent(ctx context.Context, ids []uint, checksums map[string]string) ([]WAFIPGroup, error) {
|
||||||
targetIDs := uniqueUintIDs(ids)
|
targetIDs := uniqueUintIDs(ids)
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/Rain-kl/Wavelet/internal/apps/openflare/geoip"
|
"github.com/Rain-kl/Wavelet/internal/apps/openflare/geoip"
|
||||||
|
oftasks "github.com/Rain-kl/Wavelet/internal/apps/openflare/tasks"
|
||||||
|
"github.com/Rain-kl/Wavelet/internal/apps/openflare/uptimekuma"
|
||||||
"github.com/Rain-kl/Wavelet/internal/buildinfo"
|
"github.com/Rain-kl/Wavelet/internal/buildinfo"
|
||||||
"github.com/Rain-kl/Wavelet/internal/model"
|
"github.com/Rain-kl/Wavelet/internal/model"
|
||||||
)
|
)
|
||||||
@@ -214,27 +216,31 @@ func lookupGeoIP(_ context.Context, provider, rawIP string) (*geoIPLookupView, e
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func cleanupDatabaseObservability(_ context.Context, input databaseCleanupInput) (*databaseCleanupResult, error) {
|
func cleanupDatabaseObservability(ctx context.Context, input databaseCleanupInput) (*databaseCleanupResult, error) {
|
||||||
target := strings.TrimSpace(input.Target)
|
target := strings.TrimSpace(input.Target)
|
||||||
if target == "" {
|
if target == "" {
|
||||||
return nil, errors.New(errInvalidParams)
|
return nil, errors.New(errInvalidParams)
|
||||||
}
|
}
|
||||||
if input.RetentionDays != nil && *input.RetentionDays <= 0 {
|
|
||||||
return nil, errors.New("retention_days 必须为大于 0 的整数")
|
result, err := oftasks.CleanupDatabaseObservability(ctx, oftasks.DatabaseCleanupInput{
|
||||||
|
Target: target,
|
||||||
|
RetentionDays: input.RetentionDays,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return &databaseCleanupResult{
|
return &databaseCleanupResult{
|
||||||
Target: target,
|
Target: result.Target,
|
||||||
TargetLabel: target,
|
TargetLabel: result.TargetLabel,
|
||||||
DeletedCount: 0,
|
DeletedCount: result.DeletedCount,
|
||||||
DeleteAll: input.RetentionDays == nil,
|
DeleteAll: result.DeleteAll,
|
||||||
RetentionDays: input.RetentionDays,
|
RetentionDays: result.RetentionDays,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func syncUptimeKuma(_ context.Context) error {
|
func syncUptimeKuma(ctx context.Context) error {
|
||||||
// Stub: full Uptime Kuma sync is implemented in T-MISC.
|
return uptimekuma.SyncToUptimeKuma(ctx)
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func isSecretOptionKey(key string) bool {
|
func isSecretOptionKey(key string) bool {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ package option
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/Rain-kl/Wavelet/internal/db"
|
"github.com/Rain-kl/Wavelet/internal/db"
|
||||||
"github.com/Rain-kl/Wavelet/internal/model"
|
"github.com/Rain-kl/Wavelet/internal/model"
|
||||||
@@ -100,11 +101,32 @@ func TestLookupGeoIPDisabledProvider(t *testing.T) {
|
|||||||
assert.Equal(t, "8.8.8.8", view.IP)
|
assert.Equal(t, "8.8.8.8", view.IP)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCleanupDatabaseObservabilityStub(t *testing.T) {
|
func TestCleanupDatabaseObservabilityDeletesRows(t *testing.T) {
|
||||||
cleanup := setupOptionTestDB(t)
|
cleanup := setupOptionTestDB(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
|
sqliteDB := db.DB(ctx)
|
||||||
|
require.NoError(t, sqliteDB.AutoMigrate(&model.OpenFlareAccessLog{}))
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
require.NoError(t, sqliteDB.Create(&model.OpenFlareAccessLog{
|
||||||
|
NodeID: "node-a",
|
||||||
|
LoggedAt: now.Add(-10 * 24 * time.Hour),
|
||||||
|
RemoteAddr: "203.0.113.1",
|
||||||
|
Host: "example.com",
|
||||||
|
Path: "/old",
|
||||||
|
StatusCode: 200,
|
||||||
|
}).Error)
|
||||||
|
require.NoError(t, sqliteDB.Create(&model.OpenFlareAccessLog{
|
||||||
|
NodeID: "node-a",
|
||||||
|
LoggedAt: now.Add(-2 * time.Hour),
|
||||||
|
RemoteAddr: "203.0.113.2",
|
||||||
|
Host: "example.com",
|
||||||
|
Path: "/recent",
|
||||||
|
StatusCode: 200,
|
||||||
|
}).Error)
|
||||||
|
|
||||||
retention := 7
|
retention := 7
|
||||||
result, err := cleanupDatabaseObservability(ctx, databaseCleanupInput{
|
result, err := cleanupDatabaseObservability(ctx, databaseCleanupInput{
|
||||||
Target: "node_access_logs",
|
Target: "node_access_logs",
|
||||||
@@ -112,8 +134,14 @@ func TestCleanupDatabaseObservabilityStub(t *testing.T) {
|
|||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, "node_access_logs", result.Target)
|
assert.Equal(t, "node_access_logs", result.Target)
|
||||||
assert.Equal(t, int64(0), result.DeletedCount)
|
assert.Equal(t, "访问日志", result.TargetLabel)
|
||||||
|
assert.Equal(t, int64(1), result.DeletedCount)
|
||||||
assert.False(t, result.DeleteAll)
|
assert.False(t, result.DeleteAll)
|
||||||
require.NotNil(t, result.RetentionDays)
|
require.NotNil(t, result.RetentionDays)
|
||||||
assert.Equal(t, 7, *result.RetentionDays)
|
assert.Equal(t, 7, *result.RetentionDays)
|
||||||
|
|
||||||
|
rows, err := model.ListOpenFlareAccessLogs(ctx, model.OpenFlareAccessLogQuery{Page: 0, PageSize: 10})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, rows, 1)
|
||||||
|
assert.Equal(t, "/recent", rows[0].Path)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
// Copyright 2026 Arctel.net
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
package tasks
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/Rain-kl/Wavelet/internal/model"
|
||||||
|
"github.com/Rain-kl/Wavelet/pkg/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// DatabaseCleanupTargetAccessLogs is the API cleanup target for access logs.
|
||||||
|
DatabaseCleanupTargetAccessLogs = "node_access_logs"
|
||||||
|
// DatabaseCleanupTargetMetricSnapshots is the API cleanup target for metric snapshots.
|
||||||
|
DatabaseCleanupTargetMetricSnapshots = "node_metric_snapshots"
|
||||||
|
// DatabaseCleanupTargetRequestReports is the API cleanup target for request reports.
|
||||||
|
DatabaseCleanupTargetRequestReports = "node_request_reports"
|
||||||
|
)
|
||||||
|
|
||||||
|
var databaseCleanupTargets = map[string]string{
|
||||||
|
DatabaseCleanupTargetAccessLogs: "访问日志",
|
||||||
|
DatabaseCleanupTargetMetricSnapshots: "性能快照",
|
||||||
|
DatabaseCleanupTargetRequestReports: "请求聚合",
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
registerJob("database_auto_cleanup", "0 3 * * *", runDatabaseAutoCleanupJob)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DatabaseCleanupInput describes a manual observability cleanup request.
|
||||||
|
type DatabaseCleanupInput struct {
|
||||||
|
Target string `json:"target"`
|
||||||
|
RetentionDays *int `json:"retention_days"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DatabaseCleanupResult summarizes a manual observability cleanup run.
|
||||||
|
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"`
|
||||||
|
Cutoff *time.Time `json:"cutoff,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DatabaseAutoCleanupSummary summarizes a scheduled auto-cleanup run.
|
||||||
|
type DatabaseAutoCleanupSummary struct {
|
||||||
|
RetentionDays int `json:"retention_days"`
|
||||||
|
ExecutedAt time.Time `json:"executed_at"`
|
||||||
|
Results []DatabaseCleanupResult `json:"results"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CleanupDatabaseObservability deletes observability rows for the given target.
|
||||||
|
func CleanupDatabaseObservability(ctx context.Context, input DatabaseCleanupInput) (*DatabaseCleanupResult, error) {
|
||||||
|
target := strings.TrimSpace(input.Target)
|
||||||
|
targetLabel, ok := databaseCleanupTargets[target]
|
||||||
|
if !ok {
|
||||||
|
return nil, errors.New("unsupported cleanup target")
|
||||||
|
}
|
||||||
|
if input.RetentionDays != nil && *input.RetentionDays <= 0 {
|
||||||
|
return nil, errors.New("retention_days 必须为大于 0 的整数")
|
||||||
|
}
|
||||||
|
|
||||||
|
result := &DatabaseCleanupResult{
|
||||||
|
Target: target,
|
||||||
|
TargetLabel: targetLabel,
|
||||||
|
DeleteAll: input.RetentionDays == nil,
|
||||||
|
}
|
||||||
|
|
||||||
|
if input.RetentionDays == nil {
|
||||||
|
deleted, err := deleteAllObservabilityRows(ctx, target)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result.DeletedCount = deleted
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
retentionDays := *input.RetentionDays
|
||||||
|
cutoff := time.Now().UTC().Add(-time.Duration(retentionDays) * 24 * time.Hour)
|
||||||
|
deleted, err := deleteObservabilityRowsBefore(ctx, target, cutoff)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result.DeletedCount = deleted
|
||||||
|
result.RetentionDays = &retentionDays
|
||||||
|
result.Cutoff = &cutoff
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunDatabaseAutoCleanupOnce runs retention-based cleanup for all observability targets.
|
||||||
|
func RunDatabaseAutoCleanupOnce(now time.Time) (*DatabaseAutoCleanupSummary, error) {
|
||||||
|
if !model.DatabaseAutoCleanupEnabled {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if model.DatabaseAutoCleanupRetentionDays < 1 {
|
||||||
|
return nil, fmt.Errorf("database auto cleanup retention_days must be at least 1")
|
||||||
|
}
|
||||||
|
|
||||||
|
retentionDays := model.DatabaseAutoCleanupRetentionDays
|
||||||
|
ctx := context.Background()
|
||||||
|
results := make([]DatabaseCleanupResult, 0, len(databaseCleanupTargets))
|
||||||
|
for _, target := range []string{
|
||||||
|
DatabaseCleanupTargetAccessLogs,
|
||||||
|
DatabaseCleanupTargetMetricSnapshots,
|
||||||
|
DatabaseCleanupTargetRequestReports,
|
||||||
|
} {
|
||||||
|
result, err := CleanupDatabaseObservability(ctx, DatabaseCleanupInput{
|
||||||
|
Target: target,
|
||||||
|
RetentionDays: &retentionDays,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
results = append(results, *result)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &DatabaseAutoCleanupSummary{
|
||||||
|
RetentionDays: retentionDays,
|
||||||
|
ExecutedAt: now.UTC(),
|
||||||
|
Results: results,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runDatabaseAutoCleanupJob(ctx context.Context) {
|
||||||
|
summary, err := RunDatabaseAutoCleanupOnce(time.Now())
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorF(ctx, "[OpenFlareTasks] database auto cleanup failed: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if summary == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
totalDeleted := int64(0)
|
||||||
|
for _, item := range summary.Results {
|
||||||
|
totalDeleted += item.DeletedCount
|
||||||
|
}
|
||||||
|
logger.InfoF(
|
||||||
|
ctx,
|
||||||
|
"[OpenFlareTasks] database auto cleanup completed retention_days=%d deleted_count=%d",
|
||||||
|
summary.RetentionDays,
|
||||||
|
totalDeleted,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteAllObservabilityRows(ctx context.Context, target string) (int64, error) {
|
||||||
|
switch target {
|
||||||
|
case DatabaseCleanupTargetAccessLogs:
|
||||||
|
return model.DeleteAllOpenFlareAccessLogs(ctx)
|
||||||
|
case DatabaseCleanupTargetMetricSnapshots:
|
||||||
|
return model.DeleteAllOpenFlareMetricSnapshots(ctx)
|
||||||
|
case DatabaseCleanupTargetRequestReports:
|
||||||
|
return model.DeleteAllOpenFlareRequestReports(ctx)
|
||||||
|
default:
|
||||||
|
return 0, errors.New("unsupported cleanup target")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteObservabilityRowsBefore(ctx context.Context, target string, cutoff time.Time) (int64, error) {
|
||||||
|
switch target {
|
||||||
|
case DatabaseCleanupTargetAccessLogs:
|
||||||
|
return model.DeleteOpenFlareAccessLogsBefore(ctx, cutoff)
|
||||||
|
case DatabaseCleanupTargetMetricSnapshots:
|
||||||
|
return model.DeleteOpenFlareMetricSnapshotsBefore(ctx, cutoff)
|
||||||
|
case DatabaseCleanupTargetRequestReports:
|
||||||
|
return model.DeleteOpenFlareRequestReportsBefore(ctx, cutoff)
|
||||||
|
default:
|
||||||
|
return 0, errors.New("unsupported cleanup target")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
// Copyright 2026 Arctel.net
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
package tasks
|
||||||
|
|
||||||
|
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 setupDatabaseCleanupTestDB(t *testing.T) context.Context {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
sqliteDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
|
||||||
|
DisableForeignKeyConstraintWhenMigrating: true,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, sqliteDB.AutoMigrate(
|
||||||
|
&model.OpenFlareAccessLog{},
|
||||||
|
&model.OpenFlareMetricSnapshot{},
|
||||||
|
&model.OpenFlareRequestReport{},
|
||||||
|
))
|
||||||
|
db.SetDB(sqliteDB)
|
||||||
|
t.Cleanup(func() {
|
||||||
|
db.SetDB(nil)
|
||||||
|
})
|
||||||
|
return context.Background()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCleanupDatabaseObservabilityDeletesTargetedRows(t *testing.T) {
|
||||||
|
ctx := setupDatabaseCleanupTestDB(t)
|
||||||
|
now := time.Now().UTC()
|
||||||
|
|
||||||
|
require.NoError(t, db.DB(ctx).Create(&model.OpenFlareMetricSnapshot{
|
||||||
|
NodeID: "node-a",
|
||||||
|
CapturedAt: now.Add(-10 * 24 * time.Hour),
|
||||||
|
CPUUsagePercent: 10,
|
||||||
|
}).Error)
|
||||||
|
require.NoError(t, db.DB(ctx).Create(&model.OpenFlareMetricSnapshot{
|
||||||
|
NodeID: "node-a",
|
||||||
|
CapturedAt: now.Add(-12 * time.Hour),
|
||||||
|
CPUUsagePercent: 20,
|
||||||
|
}).Error)
|
||||||
|
|
||||||
|
retentionDays := 7
|
||||||
|
result, err := CleanupDatabaseObservability(ctx, DatabaseCleanupInput{
|
||||||
|
Target: DatabaseCleanupTargetMetricSnapshots,
|
||||||
|
RetentionDays: &retentionDays,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, result.DeleteAll)
|
||||||
|
assert.Equal(t, int64(1), result.DeletedCount)
|
||||||
|
|
||||||
|
rows, err := model.ListOpenFlareMetricSnapshotsSince(ctx, "", time.Time{}, 0)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, rows, 1)
|
||||||
|
assert.Equal(t, float64(20), rows[0].CPUUsagePercent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCleanupDatabaseObservabilityDeletesAllRowsWhenRetentionMissing(t *testing.T) {
|
||||||
|
ctx := setupDatabaseCleanupTestDB(t)
|
||||||
|
now := time.Now().UTC()
|
||||||
|
|
||||||
|
require.NoError(t, db.DB(ctx).Create(&model.OpenFlareAccessLog{
|
||||||
|
NodeID: "node-a",
|
||||||
|
LoggedAt: now.Add(-3 * time.Hour),
|
||||||
|
RemoteAddr: "203.0.113.1",
|
||||||
|
Host: "example.com",
|
||||||
|
Path: "/one",
|
||||||
|
StatusCode: 200,
|
||||||
|
}).Error)
|
||||||
|
require.NoError(t, db.DB(ctx).Create(&model.OpenFlareAccessLog{
|
||||||
|
NodeID: "node-a",
|
||||||
|
LoggedAt: now.Add(-2 * time.Hour),
|
||||||
|
RemoteAddr: "203.0.113.2",
|
||||||
|
Host: "example.com",
|
||||||
|
Path: "/two",
|
||||||
|
StatusCode: 502,
|
||||||
|
}).Error)
|
||||||
|
|
||||||
|
result, err := CleanupDatabaseObservability(ctx, DatabaseCleanupInput{
|
||||||
|
Target: DatabaseCleanupTargetAccessLogs,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, result.DeleteAll)
|
||||||
|
assert.Equal(t, int64(2), result.DeletedCount)
|
||||||
|
|
||||||
|
rows, err := model.ListOpenFlareAccessLogs(ctx, model.OpenFlareAccessLogQuery{Page: 0, PageSize: 10})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunDatabaseAutoCleanupOnceDeletesAllObservabilityTargets(t *testing.T) {
|
||||||
|
ctx := setupDatabaseCleanupTestDB(t)
|
||||||
|
now := time.Now().UTC()
|
||||||
|
|
||||||
|
require.NoError(t, db.DB(ctx).Create(&model.OpenFlareAccessLog{
|
||||||
|
NodeID: "node-a",
|
||||||
|
LoggedAt: now.Add(-48 * time.Hour),
|
||||||
|
RemoteAddr: "203.0.113.10",
|
||||||
|
Host: "example.com",
|
||||||
|
Path: "/access",
|
||||||
|
StatusCode: 200,
|
||||||
|
}).Error)
|
||||||
|
require.NoError(t, db.DB(ctx).Create(&model.OpenFlareMetricSnapshot{
|
||||||
|
NodeID: "node-a",
|
||||||
|
CapturedAt: now.Add(-48 * time.Hour),
|
||||||
|
CPUUsagePercent: 10,
|
||||||
|
}).Error)
|
||||||
|
require.NoError(t, db.DB(ctx).Create(&model.OpenFlareRequestReport{
|
||||||
|
NodeID: "node-a",
|
||||||
|
WindowStartedAt: now.Add(-49 * time.Hour),
|
||||||
|
WindowEndedAt: now.Add(-48 * time.Hour),
|
||||||
|
RequestCount: 15,
|
||||||
|
}).Error)
|
||||||
|
|
||||||
|
previousEnabled := model.DatabaseAutoCleanupEnabled
|
||||||
|
previousRetentionDays := model.DatabaseAutoCleanupRetentionDays
|
||||||
|
model.DatabaseAutoCleanupEnabled = true
|
||||||
|
model.DatabaseAutoCleanupRetentionDays = 1
|
||||||
|
t.Cleanup(func() {
|
||||||
|
model.DatabaseAutoCleanupEnabled = previousEnabled
|
||||||
|
model.DatabaseAutoCleanupRetentionDays = previousRetentionDays
|
||||||
|
})
|
||||||
|
|
||||||
|
summary, err := RunDatabaseAutoCleanupOnce(now)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, summary)
|
||||||
|
require.Len(t, summary.Results, 3)
|
||||||
|
|
||||||
|
accessLogs, err := model.ListOpenFlareAccessLogs(ctx, model.OpenFlareAccessLogQuery{Page: 0, PageSize: 10})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, accessLogs)
|
||||||
|
|
||||||
|
metricSnapshots, err := model.ListOpenFlareMetricSnapshotsSince(ctx, "", time.Time{}, 0)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, metricSnapshots)
|
||||||
|
|
||||||
|
requestReports, err := model.ListOpenFlareRequestReportsSince(ctx, "", time.Time{}, 0)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, requestReports)
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
// Copyright 2026 Arctel.net
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
// Package tasks is the single home for OpenFlare scheduled and background work
|
||||||
|
// that runs inside the API process (goroutines plus robfig/cron), not the Asynq
|
||||||
|
// worker or scheduler.
|
||||||
|
//
|
||||||
|
// Each job lives in its own file and registers via registerJob in init(). This
|
||||||
|
// layout is intentional so jobs can migrate to a future task framework without
|
||||||
|
// changing call sites: swap the registry implementation while keeping per-job files.
|
||||||
|
//
|
||||||
|
// Wire-up: bootstrap.RegisterOpenFlareBackgroundTasks imports this package so
|
||||||
|
// init() registrations run; bootstrap.Init starts the cron scheduler when the
|
||||||
|
// process serves the HTTP API.
|
||||||
|
package tasks
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
// Copyright 2026 Arctel.net
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
package tasks
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/Rain-kl/Wavelet/pkg/logger"
|
||||||
|
"github.com/robfig/cron/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
type cronJob struct {
|
||||||
|
name string
|
||||||
|
spec string
|
||||||
|
run func(context.Context)
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
registryMu sync.Mutex
|
||||||
|
started bool
|
||||||
|
cronJobs []cronJob
|
||||||
|
cronRunner *cron.Cron
|
||||||
|
jobCtx context.Context
|
||||||
|
jobCancel context.CancelFunc
|
||||||
|
)
|
||||||
|
|
||||||
|
func registerJob(name, cronSpec string, fn func(context.Context)) {
|
||||||
|
registryMu.Lock()
|
||||||
|
defer registryMu.Unlock()
|
||||||
|
cronJobs = append(cronJobs, cronJob{name: name, spec: cronSpec, run: fn})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterCronJob registers a cron job from another OpenFlare package (for example waf).
|
||||||
|
// Prefer registerJob from init() inside this package when possible.
|
||||||
|
func RegisterCronJob(name, cronSpec string, fn func(context.Context)) {
|
||||||
|
registerJob(name, cronSpec, fn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogJobError records a failed OpenFlare cron job run.
|
||||||
|
func LogJobError(ctx context.Context, name string, err error) {
|
||||||
|
logger.ErrorF(ctx, "[OpenFlareTasks] %s failed: %v", name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start launches the cron scheduler in a background goroutine. Safe to call multiple times.
|
||||||
|
func Start(ctx context.Context) {
|
||||||
|
registryMu.Lock()
|
||||||
|
defer registryMu.Unlock()
|
||||||
|
if started {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
jobCtx, jobCancel = context.WithCancel(context.Background())
|
||||||
|
runner := cron.New()
|
||||||
|
for _, job := range cronJobs {
|
||||||
|
current := job
|
||||||
|
if _, err := runner.AddFunc(current.spec, func() {
|
||||||
|
current.run(jobCtx)
|
||||||
|
}); err != nil {
|
||||||
|
logger.ErrorF(ctx, "[OpenFlareTasks] register cron job %q failed: %v", current.name, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
logger.InfoF(ctx, "[OpenFlareTasks] registered cron job %q (%s)", current.name, current.spec)
|
||||||
|
}
|
||||||
|
runner.Start()
|
||||||
|
cronRunner = runner
|
||||||
|
started = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop shuts down the cron scheduler gracefully and cancels job contexts.
|
||||||
|
func Stop() {
|
||||||
|
registryMu.Lock()
|
||||||
|
defer registryMu.Unlock()
|
||||||
|
if !started || cronRunner == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
stopCtx := cronRunner.Stop()
|
||||||
|
<-stopCtx.Done()
|
||||||
|
if jobCancel != nil {
|
||||||
|
jobCancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
cronRunner = nil
|
||||||
|
started = false
|
||||||
|
jobCtx = nil
|
||||||
|
jobCancel = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResetRegistryForTest clears scheduler state so unit tests can call Start again.
|
||||||
|
func ResetRegistryForTest() {
|
||||||
|
Stop()
|
||||||
|
registryMu.Lock()
|
||||||
|
defer registryMu.Unlock()
|
||||||
|
cronJobs = nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
// Copyright 2026 Arctel.net
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
package tasks
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/Rain-kl/Wavelet/internal/apps/openflare/tls"
|
||||||
|
"github.com/Rain-kl/Wavelet/internal/model"
|
||||||
|
"github.com/Rain-kl/Wavelet/pkg/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
registerJob("ssl_renew", "0 0 * * *", func(ctx context.Context) {
|
||||||
|
if err := runSSLRenewJob(ctx); err != nil {
|
||||||
|
LogJobError(ctx, "ssl_renew", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func runSSLRenewJob(ctx context.Context) error {
|
||||||
|
logger.InfoF(ctx, "[OpenFlareTasks] SSL renew job started")
|
||||||
|
|
||||||
|
certificates, err := model.ListTLSCertificates(ctx)
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorF(ctx, "[OpenFlareTasks] list certificates failed: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
due := tls.CertificatesDueForRenewal(certificates, now)
|
||||||
|
if len(due) == 0 {
|
||||||
|
logger.InfoF(ctx, "[OpenFlareTasks] SSL renew job completed: no certificates due")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var triggered int
|
||||||
|
for _, cert := range due {
|
||||||
|
logger.InfoF(ctx, "[OpenFlareTasks] renewing certificate id=%d domain=%s", cert.ID, cert.PrimaryDomain)
|
||||||
|
if _, err := tls.RenewCertificate(ctx, cert.ID); err != nil {
|
||||||
|
logger.ErrorF(ctx, "[OpenFlareTasks] renew certificate id=%d domain=%s failed: %v", cert.ID, cert.PrimaryDomain, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
triggered++
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.InfoF(ctx, "[OpenFlareTasks] SSL renew job completed: triggered=%d eligible=%d", triggered, len(due))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
// Copyright 2026 Arctel.net
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
package tasks
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/Rain-kl/Wavelet/internal/config"
|
||||||
|
"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 setupSSLRenewTestDB(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.TLSCertificate{}))
|
||||||
|
|
||||||
|
db.SetDB(sqliteDB)
|
||||||
|
oldSecret := config.Config.App.SessionSecret
|
||||||
|
config.Config.App.SessionSecret = "test_session_secret_for_ssl_renew"
|
||||||
|
return func() {
|
||||||
|
db.SetDB(nil)
|
||||||
|
config.Config.App.SessionSecret = oldSecret
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunSSLRenewJobTriggersDueCertificates(t *testing.T) {
|
||||||
|
cleanup := setupSSLRenewTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
now := time.Date(2026, 6, 18, 12, 0, 0, 0, time.UTC)
|
||||||
|
due := &model.TLSCertificate{
|
||||||
|
Name: "due-cert",
|
||||||
|
Provider: "acme",
|
||||||
|
AutoRenew: true,
|
||||||
|
ApplyStatus: "ready",
|
||||||
|
PrimaryDomain: "due.example.com",
|
||||||
|
CertPEM: " ",
|
||||||
|
KeyPEM: " ",
|
||||||
|
NotAfter: now.Add(2 * 24 * time.Hour),
|
||||||
|
}
|
||||||
|
fresh := &model.TLSCertificate{
|
||||||
|
Name: "fresh-cert",
|
||||||
|
Provider: "acme",
|
||||||
|
AutoRenew: true,
|
||||||
|
ApplyStatus: "ready",
|
||||||
|
PrimaryDomain: "fresh.example.com",
|
||||||
|
CertPEM: " ",
|
||||||
|
KeyPEM: " ",
|
||||||
|
NotAfter: now.Add(30 * 24 * time.Hour),
|
||||||
|
}
|
||||||
|
require.NoError(t, model.CreateTLSCertificateRecord(ctx, due))
|
||||||
|
require.NoError(t, model.CreateTLSCertificateRecord(ctx, fresh))
|
||||||
|
|
||||||
|
require.NoError(t, runSSLRenewJob(ctx))
|
||||||
|
|
||||||
|
renewed, err := model.GetTLSCertificateByID(ctx, due.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "applying", renewed.ApplyStatus)
|
||||||
|
|
||||||
|
unchanged, err := model.GetTLSCertificateByID(ctx, fresh.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "ready", unchanged.ApplyStatus)
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
// Copyright 2026 Arctel.net
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
package tasks
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/Rain-kl/Wavelet/internal/apps/openflare/uptimekuma"
|
||||||
|
"github.com/Rain-kl/Wavelet/internal/model"
|
||||||
|
"github.com/Rain-kl/Wavelet/pkg/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
lastUptimeKumaSyncTime time.Time
|
||||||
|
uptimeKumaSyncMutex sync.Mutex
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
registerJob("uptime_kuma_sync", "* * * * *", runUptimeKumaSyncJob)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runUptimeKumaSyncJob(ctx context.Context) {
|
||||||
|
if !model.UptimeKumaEnabled {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
interval := model.UptimeKumaSyncInterval
|
||||||
|
if interval <= 0 {
|
||||||
|
interval = 5
|
||||||
|
}
|
||||||
|
|
||||||
|
if time.Since(lastUptimeKumaSyncTime) < time.Duration(interval)*time.Minute {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !uptimeKumaSyncMutex.TryLock() {
|
||||||
|
logger.WarnF(ctx, "[OpenFlareTasks] Uptime Kuma sync job is already running, skipping this scheduled run")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer uptimeKumaSyncMutex.Unlock()
|
||||||
|
|
||||||
|
logger.InfoF(ctx, "[OpenFlareTasks] Starting scheduled Uptime Kuma sync")
|
||||||
|
if err := uptimekuma.SyncToUptimeKuma(ctx); err != nil {
|
||||||
|
logger.ErrorF(ctx, "[OpenFlareTasks] Uptime Kuma sync failed: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
lastUptimeKumaSyncTime = time.Now()
|
||||||
|
logger.InfoF(ctx, "[OpenFlareTasks] Uptime Kuma sync completed successfully")
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
// Copyright 2026 Arctel.net
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
package tasks
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
// RegisterWAFIPGroupSync registers the WAF IP group sync cron job without importing waf.
|
||||||
|
func RegisterWAFIPGroupSync(syncFn func(context.Context) error) {
|
||||||
|
RegisterCronJob("waf_ip_group_sync", "@every 5m", func(ctx context.Context) {
|
||||||
|
if err := syncFn(ctx); err != nil {
|
||||||
|
LogJobError(ctx, "waf_ip_group_sync", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
// Copyright 2026 Arctel.net
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
package acme
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto"
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/elliptic"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/json"
|
||||||
|
"encoding/pem"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-acme/lego/v4/acme"
|
||||||
|
"github.com/go-acme/lego/v4/certcrypto"
|
||||||
|
"github.com/go-acme/lego/v4/certificate"
|
||||||
|
"github.com/go-acme/lego/v4/challenge/dns01"
|
||||||
|
"github.com/go-acme/lego/v4/lego"
|
||||||
|
"github.com/go-acme/lego/v4/providers/dns/cloudflare"
|
||||||
|
"github.com/go-acme/lego/v4/registration"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AcmeUser implements lego's user interface.
|
||||||
|
type AcmeUser struct {
|
||||||
|
Email string
|
||||||
|
Registration *registration.Resource
|
||||||
|
key crypto.PrivateKey
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *AcmeUser) GetEmail() string {
|
||||||
|
return u.Email
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *AcmeUser) GetRegistration() *registration.Resource {
|
||||||
|
return u.Registration
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *AcmeUser) GetPrivateKey() crypto.PrivateKey {
|
||||||
|
return u.key
|
||||||
|
}
|
||||||
|
|
||||||
|
// CertificateResult holds obtained certificate material.
|
||||||
|
type CertificateResult struct {
|
||||||
|
CertPEM string
|
||||||
|
KeyPEM string
|
||||||
|
NotBefore time.Time
|
||||||
|
NotAfter time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func parsePrivateKey(pemData string) (crypto.PrivateKey, error) {
|
||||||
|
block, _ := pem.Decode([]byte(pemData))
|
||||||
|
if block == nil {
|
||||||
|
return nil, errors.New("failed to parse PEM block containing the key")
|
||||||
|
}
|
||||||
|
|
||||||
|
if key, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil {
|
||||||
|
return key, nil
|
||||||
|
}
|
||||||
|
if key, err := x509.ParsePKCS8PrivateKey(block.Bytes); err == nil {
|
||||||
|
return key, nil
|
||||||
|
}
|
||||||
|
if key, err := x509.ParseECPrivateKey(block.Bytes); err == nil {
|
||||||
|
return key, nil
|
||||||
|
}
|
||||||
|
return nil, errors.New("failed to parse private key")
|
||||||
|
}
|
||||||
|
|
||||||
|
func encodePrivateKey(key crypto.PrivateKey) (string, error) {
|
||||||
|
var pemBlock *pem.Block
|
||||||
|
switch k := key.(type) {
|
||||||
|
case *rsa.PrivateKey:
|
||||||
|
pemBlock = &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(k)}
|
||||||
|
case *ecdsa.PrivateKey:
|
||||||
|
b, err := x509.MarshalECPrivateKey(k)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
pemBlock = &pem.Block{Type: "EC PRIVATE KEY", Bytes: b}
|
||||||
|
default:
|
||||||
|
return "", errors.New("unsupported key type")
|
||||||
|
}
|
||||||
|
return string(pem.EncodeToMemory(pemBlock)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOrCreateLegoClient returns a configured lego client and optional new account credentials.
|
||||||
|
func GetOrCreateLegoClient(acmeEmail, privateKeyPEM, accountURL string, keyAlgorithm string) (*lego.Client, *AcmeUser, string, string, error) {
|
||||||
|
var privateKey crypto.PrivateKey
|
||||||
|
var err error
|
||||||
|
var newPrivateKeyPEM string
|
||||||
|
var newAccountURL string
|
||||||
|
|
||||||
|
if privateKeyPEM == "" {
|
||||||
|
privateKey, err = ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, "", "", err
|
||||||
|
}
|
||||||
|
pemStr, err := encodePrivateKey(privateKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, "", "", err
|
||||||
|
}
|
||||||
|
newPrivateKeyPEM = pemStr
|
||||||
|
} else {
|
||||||
|
privateKey, err = parsePrivateKey(privateKeyPEM)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, "", "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
user := &AcmeUser{
|
||||||
|
Email: acmeEmail,
|
||||||
|
key: privateKey,
|
||||||
|
}
|
||||||
|
|
||||||
|
if accountURL != "" {
|
||||||
|
user.Registration = ®istration.Resource{
|
||||||
|
Body: acme.Account{
|
||||||
|
Status: "valid",
|
||||||
|
Contact: []string{"mailto:" + acmeEmail},
|
||||||
|
},
|
||||||
|
URI: accountURL,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
config := lego.NewConfig(user)
|
||||||
|
config.CADirURL = lego.LEDirectoryProduction
|
||||||
|
|
||||||
|
switch keyAlgorithm {
|
||||||
|
case "RSA2048":
|
||||||
|
config.Certificate.KeyType = certcrypto.RSA2048
|
||||||
|
case "RSA4096":
|
||||||
|
config.Certificate.KeyType = certcrypto.RSA4096
|
||||||
|
case "EC256":
|
||||||
|
config.Certificate.KeyType = certcrypto.EC256
|
||||||
|
case "EC384":
|
||||||
|
config.Certificate.KeyType = certcrypto.EC384
|
||||||
|
default:
|
||||||
|
config.Certificate.KeyType = certcrypto.RSA2048
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := lego.NewClient(config)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if accountURL == "" {
|
||||||
|
reg, err := client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true})
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, "", "", err
|
||||||
|
}
|
||||||
|
user.Registration = reg
|
||||||
|
newAccountURL = reg.URI
|
||||||
|
}
|
||||||
|
|
||||||
|
return client, user, newPrivateKeyPEM, newAccountURL, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetupDNSProvider configures DNS-01 challenge for the lego client.
|
||||||
|
func SetupDNSProvider(client *lego.Client, dnsType, dnsAuth string, dns1, dns2 string, disableCNAME, skipDNS bool) error {
|
||||||
|
var provider challengeProvider
|
||||||
|
|
||||||
|
switch dnsType {
|
||||||
|
case "cloudflare":
|
||||||
|
var creds map[string]string
|
||||||
|
if err := json.Unmarshal([]byte(dnsAuth), &creds); err != nil {
|
||||||
|
return fmt.Errorf("failed to parse cloudflare credentials: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
config := cloudflare.NewDefaultConfig()
|
||||||
|
config.AuthToken = creds["api_token"]
|
||||||
|
|
||||||
|
p, err := cloudflare.NewDNSProviderConfig(config)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
provider = p
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unsupported DNS provider: %s", dnsType)
|
||||||
|
}
|
||||||
|
|
||||||
|
var resolvers []string
|
||||||
|
if dns1 != "" {
|
||||||
|
resolvers = append(resolvers, dns1+":53")
|
||||||
|
}
|
||||||
|
if dns2 != "" {
|
||||||
|
resolvers = append(resolvers, dns2+":53")
|
||||||
|
}
|
||||||
|
|
||||||
|
var opts []dns01.ChallengeOption
|
||||||
|
|
||||||
|
if len(resolvers) > 0 {
|
||||||
|
opts = append(opts, dns01.AddRecursiveNameservers(resolvers))
|
||||||
|
}
|
||||||
|
|
||||||
|
if disableCNAME {
|
||||||
|
opts = append(opts, dns01.DisableCompletePropagationRequirement())
|
||||||
|
}
|
||||||
|
|
||||||
|
if skipDNS {
|
||||||
|
opts = append(opts, dns01.WrapPreCheck(func(domain, fqdn, value string, check dns01.PreCheckFunc) (bool, error) {
|
||||||
|
time.Sleep(20 * time.Second)
|
||||||
|
return true, nil
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
return client.Challenge.SetDNS01Provider(provider, opts...)
|
||||||
|
}
|
||||||
|
|
||||||
|
type challengeProvider interface {
|
||||||
|
Present(domain, token, keyAuth string) error
|
||||||
|
CleanUp(domain, token, keyAuth string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ObtainSSL obtains a certificate via ACME DNS-01 challenge.
|
||||||
|
func ObtainSSL(
|
||||||
|
acmeEmail, acmePrivateKeyPEM, acmeURL string,
|
||||||
|
dnsType, dnsAuth string,
|
||||||
|
dns1, dns2 string,
|
||||||
|
disableCNAME, skipDNS bool,
|
||||||
|
keyAlgorithm string,
|
||||||
|
domains []string,
|
||||||
|
) (string, string, *CertificateResult, error) {
|
||||||
|
client, _, newPrivateKeyPEM, newAccountURL, err := GetOrCreateLegoClient(acmeEmail, acmePrivateKeyPEM, acmeURL, keyAlgorithm)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", nil, fmt.Errorf("failed to create ACME client: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = SetupDNSProvider(client, dnsType, dnsAuth, dns1, dns2, disableCNAME, skipDNS)
|
||||||
|
if err != nil {
|
||||||
|
return newAccountURL, newPrivateKeyPEM, nil, fmt.Errorf("failed to setup DNS provider: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
request := certificate.ObtainRequest{
|
||||||
|
Domains: domains,
|
||||||
|
Bundle: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
certificates, err := client.Certificate.Obtain(request)
|
||||||
|
if err != nil {
|
||||||
|
return newAccountURL, newPrivateKeyPEM, nil, fmt.Errorf("failed to obtain certificate: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := &CertificateResult{
|
||||||
|
CertPEM: string(certificates.Certificate),
|
||||||
|
KeyPEM: string(certificates.PrivateKey),
|
||||||
|
}
|
||||||
|
|
||||||
|
certBlock, _ := pem.Decode(certificates.Certificate)
|
||||||
|
if certBlock != nil {
|
||||||
|
parsedCert, err := x509.ParseCertificate(certBlock.Bytes)
|
||||||
|
if err == nil {
|
||||||
|
result.NotBefore = parsedCert.NotBefore
|
||||||
|
result.NotAfter = parsedCert.NotAfter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return newAccountURL, newPrivateKeyPEM, result, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
// Copyright 2026 Arctel.net
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
package tls
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/Rain-kl/Wavelet/internal/model"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSplitAcmeDomains(t *testing.T) {
|
||||||
|
assert.Equal(t, []string{"example.com"}, splitAcmeDomains("example.com", ""))
|
||||||
|
assert.Equal(t, []string{"example.com", "*.example.com"}, splitAcmeDomains("example.com", "*.example.com"))
|
||||||
|
assert.Equal(t, []string{"example.com", "www.example.com", "api.example.com"}, splitAcmeDomains("example.com", "www.example.com\napi.example.com"))
|
||||||
|
assert.Equal(t, []string{"example.com", "www.example.com", "api.example.com"}, splitAcmeDomains("example.com", "www.example.com, api.example.com"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCertificatesDueForRenewal(t *testing.T) {
|
||||||
|
now := time.Date(2026, 6, 18, 12, 0, 0, 0, time.UTC)
|
||||||
|
certificates := []model.TLSCertificate{
|
||||||
|
{ID: 1, Provider: "acme", AutoRenew: true, ApplyStatus: "ready", PrimaryDomain: "due.example.com", NotAfter: now.Add(3 * 24 * time.Hour)},
|
||||||
|
{ID: 2, Provider: "acme", AutoRenew: true, ApplyStatus: "ready", PrimaryDomain: "fresh.example.com", NotAfter: now.Add(30 * 24 * time.Hour)},
|
||||||
|
{ID: 3, Provider: "upload", AutoRenew: true, ApplyStatus: "ready", PrimaryDomain: "upload.example.com", NotAfter: now.Add(24 * time.Hour)},
|
||||||
|
{ID: 4, Provider: "acme", AutoRenew: false, ApplyStatus: "ready", PrimaryDomain: "manual.example.com", NotAfter: now.Add(24 * time.Hour)},
|
||||||
|
{ID: 5, Provider: "acme", AutoRenew: true, ApplyStatus: "applying", PrimaryDomain: "busy.example.com", NotAfter: now.Add(24 * time.Hour)},
|
||||||
|
}
|
||||||
|
|
||||||
|
due := CertificatesDueForRenewal(certificates, now)
|
||||||
|
require.Len(t, due, 1)
|
||||||
|
assert.Equal(t, uint(1), due[0].ID)
|
||||||
|
assert.Equal(t, "due.example.com", due[0].PrimaryDomain)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyCertificateReturnsApplying(t *testing.T) {
|
||||||
|
cleanup := setupTLSTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
dnsAccount, err := CreateDNSAccount(ctx, DNSAccountInput{
|
||||||
|
Name: "Test Cloudflare",
|
||||||
|
Type: "cloudflare",
|
||||||
|
Authorization: `{"api_token": "dummy_token"}`,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
restore := SetObtainCertificateFuncForTest(func(ctx context.Context, cert *model.TLSCertificate) error {
|
||||||
|
return updateCertError(ctx, cert, "dns challenge failed")
|
||||||
|
})
|
||||||
|
defer restore()
|
||||||
|
|
||||||
|
cert, err := ApplyCertificate(ctx, ApplyInput{
|
||||||
|
Name: "Test ACME Cert",
|
||||||
|
PrimaryDomain: "example.com",
|
||||||
|
OtherDomains: "*.example.com",
|
||||||
|
DnsAccountID: dnsAccount.ID,
|
||||||
|
KeyAlgorithm: "RSA2048",
|
||||||
|
AutoRenew: true,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "applying", cert.ApplyStatus)
|
||||||
|
assert.Equal(t, "acme", cert.Provider)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenewCertificateSetsApplying(t *testing.T) {
|
||||||
|
cleanup := setupTLSTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
cert := &model.TLSCertificate{
|
||||||
|
Name: "renew-cert",
|
||||||
|
Provider: "acme",
|
||||||
|
AutoRenew: true,
|
||||||
|
ApplyStatus: "ready",
|
||||||
|
PrimaryDomain: "renew.example.com",
|
||||||
|
CertPEM: " ",
|
||||||
|
KeyPEM: " ",
|
||||||
|
}
|
||||||
|
require.NoError(t, model.CreateTLSCertificateRecord(ctx, cert))
|
||||||
|
|
||||||
|
restore := SetObtainCertificateFuncForTest(func(ctx context.Context, c *model.TLSCertificate) error {
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
defer restore()
|
||||||
|
|
||||||
|
renewed, err := RenewCertificate(ctx, cert.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "applying", renewed.ApplyStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertCertificateToACMEPreservesUploadOnFailure(t *testing.T) {
|
||||||
|
cleanup := setupTLSTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
originalCertPEM, originalKeyPEM := generateTestCertificatePair(t, []string{"manual.example.com"})
|
||||||
|
cert, err := CreateCertificate(ctx, CertificateInput{
|
||||||
|
Name: "manual-cert",
|
||||||
|
CertPEM: originalCertPEM,
|
||||||
|
KeyPEM: originalKeyPEM,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
stored, err := model.GetTLSCertificateByID(ctx, cert.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
originalStoredCertPEM := stored.CertPEM
|
||||||
|
originalStoredKeyPEM := stored.KeyPEM
|
||||||
|
|
||||||
|
stored.ApplyStatus = "applying"
|
||||||
|
stored.PrimaryDomain = "manual.example.com"
|
||||||
|
require.NoError(t, model.SaveTLSCertificate(ctx, stored))
|
||||||
|
|
||||||
|
err = updateCertError(ctx, stored, "dns challenge failed")
|
||||||
|
require.Error(t, err)
|
||||||
|
|
||||||
|
finalCert, err := model.GetTLSCertificateByID(ctx, cert.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "upload", finalCert.Provider)
|
||||||
|
assert.Equal(t, "error", finalCert.ApplyStatus)
|
||||||
|
assert.Equal(t, originalStoredCertPEM, finalCert.CertPEM)
|
||||||
|
assert.Equal(t, originalStoredKeyPEM, finalCert.KeyPEM)
|
||||||
|
assert.True(t, strings.Contains(finalCert.ApplyMessage, "dns challenge failed"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertCertificateToACMERejectsInvalidStates(t *testing.T) {
|
||||||
|
cleanup := setupTLSTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
certPEM, keyPEM := generateTestCertificatePair(t, []string{"manual.example.com"})
|
||||||
|
cert, err := CreateCertificate(ctx, CertificateInput{
|
||||||
|
Name: "manual-cert",
|
||||||
|
CertPEM: certPEM,
|
||||||
|
KeyPEM: keyPEM,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
cert.Provider = "acme"
|
||||||
|
require.NoError(t, model.SaveTLSCertificate(ctx, cert))
|
||||||
|
_, err = ConvertCertificateToACME(ctx, cert.ID, ApplyInput{Name: "manual-cert"})
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "only uploaded")
|
||||||
|
|
||||||
|
cert.Provider = "upload"
|
||||||
|
cert.ApplyStatus = "applying"
|
||||||
|
require.NoError(t, model.SaveTLSCertificate(ctx, cert))
|
||||||
|
_, err = ConvertCertificateToACME(ctx, cert.ID, ApplyInput{Name: "manual-cert"})
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "already applying")
|
||||||
|
}
|
||||||
@@ -23,6 +23,4 @@ const (
|
|||||||
errManagedDomainCertNotFound = "所选证书不存在"
|
errManagedDomainCertNotFound = "所选证书不存在"
|
||||||
|
|
||||||
errDNSAccountInUse = "该 DNS 账号已被证书使用,无法删除"
|
errDNSAccountInUse = "该 DNS 账号已被证书使用,无法删除"
|
||||||
|
|
||||||
errACMENotImplemented = "ACME certificate obtain is not implemented yet"
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ func DeleteCertificate(ctx context.Context, id uint) error {
|
|||||||
return model.DeleteTLSCertificateRecord(ctx, id)
|
return model.DeleteTLSCertificateRecord(ctx, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ApplyCertificate 申请 ACME 证书(当前为占位实现)。
|
// ApplyCertificate 申请 ACME 证书。
|
||||||
func ApplyCertificate(ctx context.Context, input ApplyInput) (*model.TLSCertificate, error) {
|
func ApplyCertificate(ctx context.Context, input ApplyInput) (*model.TLSCertificate, error) {
|
||||||
cert := &model.TLSCertificate{
|
cert := &model.TLSCertificate{
|
||||||
Provider: "acme",
|
Provider: "acme",
|
||||||
@@ -193,10 +193,15 @@ func ApplyCertificate(ctx context.Context, input ApplyInput) (*model.TLSCertific
|
|||||||
}
|
}
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return markACMEStubFailure(ctx, cert)
|
|
||||||
|
go func(c *model.TLSCertificate) {
|
||||||
|
_ = obtainTLSCertificate(context.Background(), c)
|
||||||
|
}(cert)
|
||||||
|
|
||||||
|
return sanitizeCertificateForResponse(cert), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateACMECertificate 更新 ACME 证书配置(当前为占位实现)。
|
// UpdateACMECertificate 更新 ACME 证书配置。
|
||||||
func UpdateACMECertificate(ctx context.Context, id uint, input ApplyInput) (*model.TLSCertificate, error) {
|
func UpdateACMECertificate(ctx context.Context, id uint, input ApplyInput) (*model.TLSCertificate, error) {
|
||||||
cert, err := model.GetTLSCertificateByID(ctx, id)
|
cert, err := model.GetTLSCertificateByID(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -215,10 +220,15 @@ func UpdateACMECertificate(ctx context.Context, id uint, input ApplyInput) (*mod
|
|||||||
}
|
}
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return markACMEStubFailure(ctx, cert)
|
|
||||||
|
go func(c *model.TLSCertificate) {
|
||||||
|
_ = obtainTLSCertificate(context.Background(), c)
|
||||||
|
}(cert)
|
||||||
|
|
||||||
|
return sanitizeCertificateForResponse(cert), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConvertCertificateToACME 将上传证书转为 ACME 管理(当前为占位实现)。
|
// ConvertCertificateToACME 将上传证书转为 ACME 管理。
|
||||||
func ConvertCertificateToACME(ctx context.Context, id uint, input ApplyInput) (*model.TLSCertificate, error) {
|
func ConvertCertificateToACME(ctx context.Context, id uint, input ApplyInput) (*model.TLSCertificate, error) {
|
||||||
cert, err := model.GetTLSCertificateByID(ctx, id)
|
cert, err := model.GetTLSCertificateByID(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -241,10 +251,25 @@ func ConvertCertificateToACME(ctx context.Context, id uint, input ApplyInput) (*
|
|||||||
}
|
}
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return markACMEStubFailure(ctx, cert)
|
|
||||||
|
go func(c *model.TLSCertificate) {
|
||||||
|
if err := obtainTLSCertificate(context.Background(), c); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
latest, err := model.GetTLSCertificateByID(context.Background(), c.ID)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
latest.Provider = "acme"
|
||||||
|
latest.ApplyStatus = "ready"
|
||||||
|
latest.ApplyMessage = ""
|
||||||
|
_ = model.SaveTLSCertificate(context.Background(), latest)
|
||||||
|
}(cert)
|
||||||
|
|
||||||
|
return sanitizeCertificateForResponse(cert), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RenewCertificate 续期 ACME 证书(当前为占位实现)。
|
// RenewCertificate 续期 ACME 证书。
|
||||||
func RenewCertificate(ctx context.Context, id uint) (*model.TLSCertificate, error) {
|
func RenewCertificate(ctx context.Context, id uint) (*model.TLSCertificate, error) {
|
||||||
cert, err := model.GetTLSCertificateByID(ctx, id)
|
cert, err := model.GetTLSCertificateByID(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -253,12 +278,17 @@ func RenewCertificate(ctx context.Context, id uint) (*model.TLSCertificate, erro
|
|||||||
if cert.Provider != "acme" {
|
if cert.Provider != "acme" {
|
||||||
return nil, errors.New(errCertificateOnlyACMERenew)
|
return nil, errors.New(errCertificateOnlyACMERenew)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
go func(c *model.TLSCertificate) {
|
||||||
|
_ = obtainTLSCertificate(context.Background(), c)
|
||||||
|
}(cert)
|
||||||
|
|
||||||
cert.ApplyStatus = "applying"
|
cert.ApplyStatus = "applying"
|
||||||
cert.ApplyMessage = ""
|
cert.ApplyMessage = ""
|
||||||
if err := model.SaveTLSCertificate(ctx, cert); err != nil {
|
if err := model.SaveTLSCertificate(ctx, cert); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return markACMEStubFailure(ctx, cert)
|
return sanitizeCertificateForResponse(cert), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListDNSAccounts 列出 DNS 账号。
|
// ListDNSAccounts 列出 DNS 账号。
|
||||||
@@ -386,19 +416,9 @@ func fillAcmeCertificateFields(cert *model.TLSCertificate, input ApplyInput) {
|
|||||||
cert.SkipDNS = input.SkipDNS
|
cert.SkipDNS = input.SkipDNS
|
||||||
cert.DNS1 = strings.TrimSpace(input.DNS1)
|
cert.DNS1 = strings.TrimSpace(input.DNS1)
|
||||||
cert.DNS2 = strings.TrimSpace(input.DNS2)
|
cert.DNS2 = strings.TrimSpace(input.DNS2)
|
||||||
cert.Provider = "acme"
|
|
||||||
cert.ApplyStatus = "applying"
|
cert.ApplyStatus = "applying"
|
||||||
}
|
}
|
||||||
|
|
||||||
func markACMEStubFailure(ctx context.Context, cert *model.TLSCertificate) (*model.TLSCertificate, error) {
|
|
||||||
cert.ApplyStatus = "failed"
|
|
||||||
cert.ApplyMessage = errACMENotImplemented
|
|
||||||
if err := model.SaveTLSCertificate(ctx, cert); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return sanitizeCertificateForResponse(cert), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func ensureCertificateNotReferenced(ctx context.Context, id uint) error {
|
func ensureCertificateNotReferenced(ctx context.Context, id uint) error {
|
||||||
routes, err := model.ListTLSProxyRouteRefs(ctx)
|
routes, err := model.ListTLSProxyRouteRefs(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"encoding/pem"
|
"encoding/pem"
|
||||||
"math/big"
|
"math/big"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -24,8 +25,11 @@ import (
|
|||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var tlsTestDBMu sync.Mutex
|
||||||
|
|
||||||
func setupTLSTestDB(t *testing.T) func() {
|
func setupTLSTestDB(t *testing.T) func() {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
tlsTestDBMu.Lock()
|
||||||
|
|
||||||
sqliteDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
|
sqliteDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
|
||||||
DisableForeignKeyConstraintWhenMigrating: true,
|
DisableForeignKeyConstraintWhenMigrating: true,
|
||||||
@@ -44,6 +48,7 @@ func setupTLSTestDB(t *testing.T) func() {
|
|||||||
return func() {
|
return func() {
|
||||||
db.SetDB(nil)
|
db.SetDB(nil)
|
||||||
config.Config.App.SessionSecret = oldSecret
|
config.Config.App.SessionSecret = oldSecret
|
||||||
|
tlsTestDBMu.Unlock()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
// Copyright 2026 Arctel.net
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
package tls
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/Rain-kl/Wavelet/internal/apps/openflare/tls/acme"
|
||||||
|
"github.com/Rain-kl/Wavelet/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
const acmeRenewLeadTime = 7 * 24 * time.Hour
|
||||||
|
|
||||||
|
var obtainTLSCertificate = obtainCertificate
|
||||||
|
|
||||||
|
// SetObtainCertificateFuncForTest swaps the async obtain implementation for tests.
|
||||||
|
func SetObtainCertificateFuncForTest(fn func(context.Context, *model.TLSCertificate) error) func() {
|
||||||
|
previous := obtainTLSCertificate
|
||||||
|
obtainTLSCertificate = fn
|
||||||
|
return func() {
|
||||||
|
obtainTLSCertificate = previous
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func obtainCertificate(ctx context.Context, cert *model.TLSCertificate) error {
|
||||||
|
cert.ApplyStatus = "applying"
|
||||||
|
if err := model.SaveTLSCertificate(ctx, cert); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
acmeAccount, err := model.GetAcmeAccountByID(ctx, cert.AcmeAccountID)
|
||||||
|
if err != nil {
|
||||||
|
acmeAccount, err = model.GetDefaultAcmeAccount(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return updateCertError(ctx, cert, fmt.Sprintf("Failed to get ACME account: %v", err))
|
||||||
|
}
|
||||||
|
cert.AcmeAccountID = acmeAccount.ID
|
||||||
|
if err := model.SaveTLSCertificate(ctx, cert); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dnsAccount, err := model.GetDNSAccountByID(ctx, cert.DnsAccountID)
|
||||||
|
if err != nil {
|
||||||
|
return updateCertError(ctx, cert, fmt.Sprintf("Failed to get DNS account: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
dnsAuth, err := openSensitive(dnsAccount.Authorization)
|
||||||
|
if err != nil {
|
||||||
|
return updateCertError(ctx, cert, fmt.Sprintf("Failed to decrypt DNS credentials: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
acmePrivateKey, err := openSensitive(acmeAccount.PrivateKey)
|
||||||
|
if err != nil {
|
||||||
|
return updateCertError(ctx, cert, fmt.Sprintf("Failed to decrypt ACME account key: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
domains := splitAcmeDomains(cert.PrimaryDomain, cert.OtherDomains)
|
||||||
|
|
||||||
|
newAccountURL, newPrivateKeyPEM, result, err := acme.ObtainSSL(
|
||||||
|
acmeAccount.Email,
|
||||||
|
acmePrivateKey,
|
||||||
|
acmeAccount.URL,
|
||||||
|
dnsAccount.Type,
|
||||||
|
dnsAuth,
|
||||||
|
cert.DNS1,
|
||||||
|
cert.DNS2,
|
||||||
|
cert.DisableCNAME,
|
||||||
|
cert.SkipDNS,
|
||||||
|
cert.KeyAlgorithm,
|
||||||
|
domains,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (newPrivateKeyPEM != "" && acmePrivateKey != newPrivateKeyPEM) || (newAccountURL != "" && acmeAccount.URL != newAccountURL) {
|
||||||
|
if newPrivateKeyPEM != "" {
|
||||||
|
sealedKey, sealErr := sealSensitive(newPrivateKeyPEM)
|
||||||
|
if sealErr != nil {
|
||||||
|
return updateCertError(ctx, cert, fmt.Sprintf("Failed to seal ACME account key: %v", sealErr))
|
||||||
|
}
|
||||||
|
acmeAccount.PrivateKey = sealedKey
|
||||||
|
}
|
||||||
|
if newAccountURL != "" {
|
||||||
|
acmeAccount.URL = newAccountURL
|
||||||
|
}
|
||||||
|
if acmeAccount.ID == 0 {
|
||||||
|
if dbErr := model.CreateAcmeAccountRecord(ctx, acmeAccount); dbErr != nil {
|
||||||
|
return updateCertError(ctx, cert, fmt.Sprintf("Failed to create ACME account: %v", dbErr))
|
||||||
|
}
|
||||||
|
} else if dbErr := model.SaveAcmeAccount(ctx, acmeAccount); dbErr != nil {
|
||||||
|
return updateCertError(ctx, cert, fmt.Sprintf("Failed to save ACME account: %v", dbErr))
|
||||||
|
}
|
||||||
|
cert.AcmeAccountID = acmeAccount.ID
|
||||||
|
if err := model.SaveTLSCertificate(ctx, cert); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return updateCertError(ctx, cert, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
sealedKey, err := sealSensitive(result.KeyPEM)
|
||||||
|
if err != nil {
|
||||||
|
return updateCertError(ctx, cert, fmt.Sprintf("Failed to seal certificate key: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
cert.CertPEM = result.CertPEM
|
||||||
|
cert.KeyPEM = sealedKey
|
||||||
|
cert.NotBefore = result.NotBefore
|
||||||
|
cert.NotAfter = result.NotAfter
|
||||||
|
cert.ApplyStatus = "ready"
|
||||||
|
cert.ApplyMessage = ""
|
||||||
|
|
||||||
|
return model.SaveTLSCertificate(ctx, cert)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateCertError(ctx context.Context, cert *model.TLSCertificate, message string) error {
|
||||||
|
cert.ApplyStatus = "error"
|
||||||
|
cert.ApplyMessage = message
|
||||||
|
if err := model.SaveTLSCertificate(ctx, cert); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%s", message)
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitAcmeDomains(primaryDomain, otherDomains string) []string {
|
||||||
|
primaryDomain = strings.TrimSpace(primaryDomain)
|
||||||
|
domains := []string{}
|
||||||
|
if primaryDomain != "" {
|
||||||
|
domains = append(domains, primaryDomain)
|
||||||
|
}
|
||||||
|
otherDomains = strings.TrimSpace(otherDomains)
|
||||||
|
if otherDomains == "" {
|
||||||
|
return domains
|
||||||
|
}
|
||||||
|
|
||||||
|
separator := "\n"
|
||||||
|
if !strings.Contains(otherDomains, "\n") && strings.Contains(otherDomains, ",") {
|
||||||
|
separator = ","
|
||||||
|
}
|
||||||
|
for _, domain := range strings.Split(otherDomains, separator) {
|
||||||
|
domain = strings.TrimSpace(domain)
|
||||||
|
if domain != "" {
|
||||||
|
domains = append(domains, domain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return domains
|
||||||
|
}
|
||||||
|
|
||||||
|
// CertificatesDueForRenewal returns ACME certificates that should be renewed at the given time.
|
||||||
|
func CertificatesDueForRenewal(certificates []model.TLSCertificate, now time.Time) []model.TLSCertificate {
|
||||||
|
due := make([]model.TLSCertificate, 0)
|
||||||
|
for _, cert := range certificates {
|
||||||
|
if !cert.AutoRenew || cert.Provider != "acme" || cert.ApplyStatus == "applying" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if cert.NotAfter.IsZero() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if cert.NotAfter.Sub(now) < acmeRenewLeadTime {
|
||||||
|
due = append(due, cert)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return due
|
||||||
|
}
|
||||||
@@ -0,0 +1,383 @@
|
|||||||
|
// Copyright 2026 Arctel.net
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
package uptimekuma
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UptimeKumaMonitor represents a monitor entry from Uptime Kuma.
|
||||||
|
type UptimeKumaMonitor struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Url string `json:"url"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Interval int `json:"interval"`
|
||||||
|
MaxRetries int `json:"maxretries"`
|
||||||
|
RetryInterval int `json:"retryInterval"`
|
||||||
|
Timeout int `json:"timeout"`
|
||||||
|
Tags []UptimeKumaTag `json:"tags"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UptimeKumaTag represents a tag attached to a monitor.
|
||||||
|
type UptimeKumaTag struct {
|
||||||
|
ID int `json:"tag_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Color string `json:"color"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UptimeKumaTagItem represents a tag returned by getTags.
|
||||||
|
type UptimeKumaTagItem struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Color string `json:"color"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SocketIOClient is a minimal Engine.IO/Socket.IO polling client for Uptime Kuma.
|
||||||
|
type SocketIOClient struct {
|
||||||
|
baseURL string
|
||||||
|
httpClient *http.Client
|
||||||
|
sid string
|
||||||
|
ackMutex sync.Mutex
|
||||||
|
ackID int
|
||||||
|
ackChanMap map[int]chan string
|
||||||
|
doneChan chan struct{}
|
||||||
|
closeOnce sync.Once
|
||||||
|
|
||||||
|
monitorListMutex sync.RWMutex
|
||||||
|
monitorList map[string]UptimeKumaMonitor
|
||||||
|
monitorListChan chan struct{}
|
||||||
|
monitorListOnce sync.Once
|
||||||
|
|
||||||
|
ctx context.Context
|
||||||
|
cancel context.CancelFunc
|
||||||
|
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSocketIOClient creates a Socket.IO polling client for the given base URL.
|
||||||
|
func NewSocketIOClient(baseURL string) *SocketIOClient {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
return &SocketIOClient{
|
||||||
|
baseURL: strings.TrimSuffix(baseURL, "/"),
|
||||||
|
httpClient: &http.Client{
|
||||||
|
Timeout: 60 * time.Second,
|
||||||
|
},
|
||||||
|
ackChanMap: make(map[int]chan string),
|
||||||
|
doneChan: make(chan struct{}),
|
||||||
|
monitorListChan: make(chan struct{}),
|
||||||
|
monitorList: make(map[string]UptimeKumaMonitor),
|
||||||
|
ctx: ctx,
|
||||||
|
cancel: cancel,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect performs the Engine.IO handshake and starts the polling loop.
|
||||||
|
func (c *SocketIOClient) Connect() error {
|
||||||
|
slog.Debug("Uptime Kuma client starting handshake", "baseURL", c.baseURL)
|
||||||
|
u := fmt.Sprintf("%s/socket.io/?EIO=4&transport=polling", c.baseURL)
|
||||||
|
reqHandshake, err := http.NewRequestWithContext(c.ctx, http.MethodGet, u, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create handshake request failed: %w", err)
|
||||||
|
}
|
||||||
|
resp, err := c.httpClient.Do(reqHandshake)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Uptime Kuma handshake connection failed", "url", u, "error", err)
|
||||||
|
return fmt.Errorf("handshake request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
bs, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed to read Uptime Kuma handshake response body", "error", err)
|
||||||
|
return fmt.Errorf("read handshake body failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyStr := string(bs)
|
||||||
|
slog.Debug("Received handshake response from Uptime Kuma", "body", bodyStr)
|
||||||
|
if len(bodyStr) == 0 || bodyStr[0] != '0' {
|
||||||
|
return fmt.Errorf("invalid handshake response format: %s", bodyStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
var hs struct {
|
||||||
|
Sid string `json:"sid"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(bodyStr[1:]), &hs); err != nil {
|
||||||
|
return fmt.Errorf("unmarshal handshake sid failed: %w", err)
|
||||||
|
}
|
||||||
|
c.sid = hs.Sid
|
||||||
|
slog.Debug("Uptime Kuma handshake success", "sid", c.sid)
|
||||||
|
|
||||||
|
slog.Debug("Sending namespace connect request to Uptime Kuma", "sid", c.sid)
|
||||||
|
connectURL := fmt.Sprintf("%s/socket.io/?EIO=4&transport=polling&sid=%s", c.baseURL, c.sid)
|
||||||
|
req, err := http.NewRequestWithContext(c.ctx, http.MethodPost, connectURL, strings.NewReader("40"))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create connect request failed: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "text/plain;charset=UTF-8")
|
||||||
|
respConnect, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Uptime Kuma namespace connect request failed", "sid", c.sid, "error", err)
|
||||||
|
return fmt.Errorf("namespace connect failed: %w", err)
|
||||||
|
}
|
||||||
|
respConnect.Body.Close()
|
||||||
|
slog.Debug("Namespace connected successfully to Uptime Kuma", "sid", c.sid)
|
||||||
|
|
||||||
|
go c.pollLoop()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *SocketIOClient) pollLoop() {
|
||||||
|
slog.Debug("Uptime Kuma polling loop started", "sid", c.sid)
|
||||||
|
defer c.Close()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-c.doneChan:
|
||||||
|
slog.Debug("Uptime Kuma polling loop stopped (doneChan closed)", "sid", c.sid)
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
u := fmt.Sprintf("%s/socket.io/?EIO=4&transport=polling&sid=%s", c.baseURL, c.sid)
|
||||||
|
reqPoll, err := http.NewRequestWithContext(c.ctx, http.MethodGet, u, nil)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed to create Uptime Kuma polling request", "sid", c.sid, "error", err)
|
||||||
|
c.err = err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp, err := c.httpClient.Do(reqPoll)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Uptime Kuma polling request failed", "sid", c.sid, "error", err)
|
||||||
|
c.err = err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
bs, err := io.ReadAll(resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed to read Uptime Kuma polling body", "sid", c.sid, "error", err)
|
||||||
|
c.err = err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyStr := string(bs)
|
||||||
|
if len(bodyStr) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Debug("Received polling payload from Uptime Kuma", "length", len(bodyStr))
|
||||||
|
packets := strings.Split(bodyStr, "\x1e")
|
||||||
|
for _, pkt := range packets {
|
||||||
|
if len(pkt) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
engineIOType := pkt[0]
|
||||||
|
payload := pkt[1:]
|
||||||
|
|
||||||
|
slog.Debug("Parsing engine.io packet", "type", string(engineIOType), "payload_len", len(payload))
|
||||||
|
switch engineIOType {
|
||||||
|
case '2':
|
||||||
|
slog.Debug("Received engine.io ping, responding with pong", "sid", c.sid)
|
||||||
|
c.sendPong()
|
||||||
|
case '4':
|
||||||
|
if len(payload) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
socketIOType := payload[0]
|
||||||
|
socketIOPayload := payload[1:]
|
||||||
|
|
||||||
|
slog.Debug("Parsing socket.io packet", "type", string(socketIOType), "payload", socketIOPayload)
|
||||||
|
switch socketIOType {
|
||||||
|
case '2':
|
||||||
|
c.handleEvent(socketIOPayload)
|
||||||
|
case '3':
|
||||||
|
c.handleAck(socketIOPayload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *SocketIOClient) sendPong() {
|
||||||
|
u := fmt.Sprintf("%s/socket.io/?EIO=4&transport=polling&sid=%s", c.baseURL, c.sid)
|
||||||
|
req, err := http.NewRequestWithContext(c.ctx, http.MethodPost, u, strings.NewReader("3"))
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "text/plain;charset=UTF-8")
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err == nil {
|
||||||
|
resp.Body.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *SocketIOClient) handleEvent(payload string) {
|
||||||
|
var arr []json.RawMessage
|
||||||
|
if err := json.Unmarshal([]byte(payload), &arr); err != nil || len(arr) < 2 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var eventName string
|
||||||
|
if err := json.Unmarshal(arr[0], &eventName); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if eventName == "monitorList" {
|
||||||
|
var list map[string]UptimeKumaMonitor
|
||||||
|
if err := json.Unmarshal(arr[1], &list); err == nil {
|
||||||
|
c.monitorListMutex.Lock()
|
||||||
|
c.monitorList = list
|
||||||
|
c.monitorListMutex.Unlock()
|
||||||
|
c.monitorListOnce.Do(func() {
|
||||||
|
close(c.monitorListChan)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *SocketIOClient) handleAck(payload string) {
|
||||||
|
idx := strings.IndexByte(payload, '[')
|
||||||
|
if idx == -1 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ackIDStr := payload[:idx]
|
||||||
|
ackID, err := strconv.Atoi(ackIDStr)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.ackMutex.Lock()
|
||||||
|
ch, ok := c.ackChanMap[ackID]
|
||||||
|
if ok {
|
||||||
|
delete(c.ackChanMap, ackID)
|
||||||
|
c.ackMutex.Unlock()
|
||||||
|
select {
|
||||||
|
case ch <- payload[idx:]:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
c.ackMutex.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit sends a Socket.IO event and waits for the corresponding ack.
|
||||||
|
func (c *SocketIOClient) Emit(event string, args ...any) (string, error) {
|
||||||
|
c.ackMutex.Lock()
|
||||||
|
id := c.ackID
|
||||||
|
c.ackID++
|
||||||
|
ch := make(chan string, 1)
|
||||||
|
c.ackChanMap[id] = ch
|
||||||
|
c.ackMutex.Unlock()
|
||||||
|
|
||||||
|
payloadArr := []any{event}
|
||||||
|
payloadArr = append(payloadArr, args...)
|
||||||
|
bs, err := json.Marshal(payloadArr)
|
||||||
|
if err != nil {
|
||||||
|
c.ackMutex.Lock()
|
||||||
|
delete(c.ackChanMap, id)
|
||||||
|
c.ackMutex.Unlock()
|
||||||
|
slog.Error("Failed to marshal event payload", "event", event, "error", err)
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
body := fmt.Sprintf("42%d%s", id, string(bs))
|
||||||
|
slog.Debug("Emitting Socket.IO event", "event", event, "ackID", id, "payload", string(bs))
|
||||||
|
|
||||||
|
u := fmt.Sprintf("%s/socket.io/?EIO=4&transport=polling&sid=%s", c.baseURL, c.sid)
|
||||||
|
req, err := http.NewRequestWithContext(c.ctx, http.MethodPost, u, strings.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
c.ackMutex.Lock()
|
||||||
|
delete(c.ackChanMap, id)
|
||||||
|
c.ackMutex.Unlock()
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "text/plain;charset=UTF-8")
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
c.ackMutex.Lock()
|
||||||
|
delete(c.ackChanMap, id)
|
||||||
|
c.ackMutex.Unlock()
|
||||||
|
slog.Error("Failed to send Emit request", "event", event, "ackID", id, "error", err)
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case result := <-ch:
|
||||||
|
slog.Debug("Received Ack for event", "event", event, "ackID", id, "response", result)
|
||||||
|
return result, nil
|
||||||
|
case <-time.After(10 * time.Second):
|
||||||
|
c.ackMutex.Lock()
|
||||||
|
delete(c.ackChanMap, id)
|
||||||
|
c.ackMutex.Unlock()
|
||||||
|
slog.Error("Timeout waiting for event Ack", "event", event, "ackID", id)
|
||||||
|
return "", fmt.Errorf("timeout waiting for ack for event: %s", event)
|
||||||
|
case <-c.doneChan:
|
||||||
|
c.ackMutex.Lock()
|
||||||
|
delete(c.ackChanMap, id)
|
||||||
|
c.ackMutex.Unlock()
|
||||||
|
slog.Error("Client closed while waiting for event Ack", "event", event, "ackID", id)
|
||||||
|
return "", fmt.Errorf("client closed while waiting for event ack: %s", event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close shuts down the polling loop.
|
||||||
|
func (c *SocketIOClient) Close() {
|
||||||
|
c.closeOnce.Do(func() {
|
||||||
|
c.cancel()
|
||||||
|
close(c.doneChan)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMonitorListChan returns a channel closed when the first monitorList event arrives.
|
||||||
|
func (c *SocketIOClient) GetMonitorListChan() <-chan struct{} {
|
||||||
|
return c.monitorListChan
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMonitorList returns a copy of the current monitor list.
|
||||||
|
func (c *SocketIOClient) GetMonitorList() map[string]UptimeKumaMonitor {
|
||||||
|
c.monitorListMutex.RLock()
|
||||||
|
defer c.monitorListMutex.RUnlock()
|
||||||
|
|
||||||
|
m := make(map[string]UptimeKumaMonitor, len(c.monitorList))
|
||||||
|
for k, v := range c.monitorList {
|
||||||
|
m[k] = v
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseAckResponse unmarshals an ack payload and validates the ok status when present.
|
||||||
|
func ParseAckResponse(response string, target any) error {
|
||||||
|
var arr []json.RawMessage
|
||||||
|
if err := json.Unmarshal([]byte(response), &arr); err != nil || len(arr) == 0 {
|
||||||
|
return fmt.Errorf("invalid ack response format: %s", response)
|
||||||
|
}
|
||||||
|
|
||||||
|
var status struct {
|
||||||
|
Ok bool `json:"ok"`
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(arr[0], &status); err == nil {
|
||||||
|
if !status.Ok {
|
||||||
|
errMsg := status.Msg
|
||||||
|
if errMsg == "" {
|
||||||
|
errMsg = "unknown error from Uptime Kuma"
|
||||||
|
}
|
||||||
|
return fmt.Errorf("Uptime Kuma error response: %s", errMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if target != nil {
|
||||||
|
return json.Unmarshal(arr[0], target)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,349 @@
|
|||||||
|
// Copyright 2026 Arctel.net
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
package uptimekuma
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/Rain-kl/Wavelet/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
var isSyncing atomic.Bool
|
||||||
|
|
||||||
|
// SyncToUptimeKuma synchronizes enabled proxy routes to Uptime Kuma monitors.
|
||||||
|
func SyncToUptimeKuma(ctx context.Context) error {
|
||||||
|
if !model.UptimeKumaEnabled {
|
||||||
|
return fmt.Errorf("Uptime Kuma integration is disabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isSyncing.CompareAndSwap(false, true) {
|
||||||
|
return fmt.Errorf("sync task is already in progress, please try again later")
|
||||||
|
}
|
||||||
|
defer isSyncing.Store(false)
|
||||||
|
|
||||||
|
kumaURL := strings.TrimSpace(model.UptimeKumaUrl)
|
||||||
|
kumaUsername := strings.TrimSpace(model.UptimeKumaUsername)
|
||||||
|
kumaPassword := strings.TrimSpace(model.UptimeKumaPassword)
|
||||||
|
if kumaURL == "" || kumaUsername == "" || kumaPassword == "" {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"Uptime Kuma URL, username, or password is not configured (URL: %q, Username: %q, PasswordLength: %d)",
|
||||||
|
kumaURL, kumaUsername, len(kumaPassword),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("Starting Uptime Kuma sync process",
|
||||||
|
"url", kumaURL,
|
||||||
|
"username", kumaUsername,
|
||||||
|
"scope", model.UptimeKumaMonitorScope,
|
||||||
|
)
|
||||||
|
|
||||||
|
allRoutes, err := model.ListProxyRoutes(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to list local proxy routes: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedRoutes, err := filterExpectedRoutes(allRoutes)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Debug("Connecting to Uptime Kuma socket endpoint", "url", kumaURL)
|
||||||
|
client := NewSocketIOClient(kumaURL)
|
||||||
|
if err := client.Connect(); err != nil {
|
||||||
|
slog.Error("Failed to connect to Uptime Kuma endpoint", "url", kumaURL, "error", err)
|
||||||
|
return fmt.Errorf("failed to connect to Uptime Kuma: %w", err)
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
slog.Debug("Sending login request to Uptime Kuma", "username", kumaUsername)
|
||||||
|
loginPayload := map[string]string{
|
||||||
|
"username": kumaUsername,
|
||||||
|
"password": kumaPassword,
|
||||||
|
}
|
||||||
|
loginAck, err := client.Emit("login", loginPayload)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed to send login request to Uptime Kuma", "username", kumaUsername, "error", err)
|
||||||
|
return fmt.Errorf("login request failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var loginResult struct {
|
||||||
|
Ok bool `json:"ok"`
|
||||||
|
}
|
||||||
|
if err := ParseAckResponse(loginAck, &loginResult); err != nil || !loginResult.Ok {
|
||||||
|
slog.Error("Uptime Kuma login verification failed", "username", kumaUsername, "error", err)
|
||||||
|
return fmt.Errorf("login failed: %w", err)
|
||||||
|
}
|
||||||
|
slog.Debug("Successfully logged into Uptime Kuma", "username", kumaUsername)
|
||||||
|
|
||||||
|
slog.Debug("Waiting for monitor list push from Uptime Kuma")
|
||||||
|
select {
|
||||||
|
case <-client.GetMonitorListChan():
|
||||||
|
slog.Debug("Received monitor list from Uptime Kuma")
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
slog.Error("Timeout waiting for Uptime Kuma monitorList push event")
|
||||||
|
return fmt.Errorf("timeout waiting for monitorList event from Uptime Kuma")
|
||||||
|
}
|
||||||
|
|
||||||
|
openFlareTagID, err := ensureOpenFlareTag(client)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
existingOpenFlareMonitors := filterOpenFlareMonitors(client.GetMonitorList(), openFlareTagID)
|
||||||
|
expectedSitesMap := make(map[string]bool)
|
||||||
|
|
||||||
|
for _, route := range expectedRoutes {
|
||||||
|
expectedSitesMap[route.SiteName] = true
|
||||||
|
targetURL, urlErr := routeMonitorURL(route)
|
||||||
|
if urlErr != nil {
|
||||||
|
slog.Error("Failed to resolve monitor URL", "name", route.SiteName, "error", urlErr)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
existing, exists := existingOpenFlareMonitors[route.SiteName]
|
||||||
|
if !exists {
|
||||||
|
if err := createMonitor(client, route.SiteName, targetURL, openFlareTagID); err != nil {
|
||||||
|
slog.Error("Failed to add monitor to Uptime Kuma", "name", route.SiteName, "error", err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if monitorNeedsUpdate(existing, targetURL) {
|
||||||
|
if err := updateMonitor(client, existing.ID, route.SiteName, targetURL); err != nil {
|
||||||
|
slog.Error("Failed to edit monitor in Uptime Kuma", "name", route.SiteName, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, monitor := range existingOpenFlareMonitors {
|
||||||
|
if expectedSitesMap[name] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
slog.Info("Deleting monitor in Uptime Kuma", "name", name, "monitorID", monitor.ID)
|
||||||
|
deleteAck, err := client.Emit("deleteMonitor", monitor.ID)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed to delete monitor in Uptime Kuma", "name", name, "monitorID", monitor.ID, "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := ParseAckResponse(deleteAck, nil); err != nil {
|
||||||
|
slog.Error("Failed to parse delete monitor result", "name", name, "monitorID", monitor.ID, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func filterExpectedRoutes(allRoutes []*model.ProxyRoute) ([]*model.ProxyRoute, error) {
|
||||||
|
scope := model.UptimeKumaMonitorScope
|
||||||
|
if scope == "selected" {
|
||||||
|
selectedList := strings.Split(model.UptimeKumaSelectedSites, ",")
|
||||||
|
selectedMap := make(map[string]bool)
|
||||||
|
for _, name := range selectedList {
|
||||||
|
trimmedName := strings.TrimSpace(name)
|
||||||
|
if trimmedName != "" {
|
||||||
|
selectedMap[trimmedName] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var expectedRoutes []*model.ProxyRoute
|
||||||
|
for _, route := range allRoutes {
|
||||||
|
if route.Enabled && selectedMap[route.SiteName] {
|
||||||
|
expectedRoutes = append(expectedRoutes, route)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return expectedRoutes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var expectedRoutes []*model.ProxyRoute
|
||||||
|
for _, route := range allRoutes {
|
||||||
|
if route.Enabled {
|
||||||
|
expectedRoutes = append(expectedRoutes, route)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return expectedRoutes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureOpenFlareTag(client *SocketIOClient) (int, error) {
|
||||||
|
slog.Debug("Fetching tags from Uptime Kuma")
|
||||||
|
tagsAck, err := client.Emit("getTags")
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed to request tags from Uptime Kuma", "error", err)
|
||||||
|
return 0, fmt.Errorf("failed to fetch tags: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var tagsResult struct {
|
||||||
|
Ok bool `json:"ok"`
|
||||||
|
Tags []UptimeKumaTagItem `json:"tags"`
|
||||||
|
}
|
||||||
|
if err := ParseAckResponse(tagsAck, &tagsResult); err != nil {
|
||||||
|
slog.Error("Failed to parse tags response from Uptime Kuma", "error", err)
|
||||||
|
return 0, fmt.Errorf("parse tags response failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tag := range tagsResult.Tags {
|
||||||
|
if tag.Name == "OpenFlare" {
|
||||||
|
slog.Debug("Found existing OpenFlare tag", "tag_id", tag.ID)
|
||||||
|
return tag.ID, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Debug("OpenFlare tag not found, creating new tag")
|
||||||
|
addTagAck, err := client.Emit("addTag", map[string]string{
|
||||||
|
"name": "OpenFlare",
|
||||||
|
"color": "#4f46e5",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed to create OpenFlare tag in Uptime Kuma", "error", err)
|
||||||
|
return 0, fmt.Errorf("failed to create tag: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var tagResult struct {
|
||||||
|
Ok bool `json:"ok"`
|
||||||
|
Tag struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
} `json:"tag"`
|
||||||
|
}
|
||||||
|
if err := ParseAckResponse(addTagAck, &tagResult); err != nil || tagResult.Tag.ID == 0 {
|
||||||
|
slog.Error("Failed to parse addTag response from Uptime Kuma", "error", err)
|
||||||
|
return 0, fmt.Errorf("parse addTag response failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Debug("Successfully created OpenFlare tag", "tag_id", tagResult.Tag.ID)
|
||||||
|
return tagResult.Tag.ID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func filterOpenFlareMonitors(monitors map[string]UptimeKumaMonitor, openFlareTagID int) map[string]UptimeKumaMonitor {
|
||||||
|
existingOpenFlareMonitors := make(map[string]UptimeKumaMonitor)
|
||||||
|
for _, monitor := range monitors {
|
||||||
|
hasOpenFlareTag := false
|
||||||
|
for _, tag := range monitor.Tags {
|
||||||
|
if tag.Name == "OpenFlare" || tag.ID == openFlareTagID {
|
||||||
|
hasOpenFlareTag = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if hasOpenFlareTag {
|
||||||
|
existingOpenFlareMonitors[monitor.Name] = monitor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return existingOpenFlareMonitors
|
||||||
|
}
|
||||||
|
|
||||||
|
func routeMonitorURL(route *model.ProxyRoute) (string, error) {
|
||||||
|
domains, err := decodeStoredDomains(route.Domains, route.Domain)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
domain := route.Domain
|
||||||
|
if len(domains) > 0 {
|
||||||
|
domain = domains[0]
|
||||||
|
}
|
||||||
|
if route.EnableHTTPS {
|
||||||
|
return "https://" + domain, nil
|
||||||
|
}
|
||||||
|
return "http://" + domain, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeStoredDomains(raw string, fallbackDomain string) ([]string, error) {
|
||||||
|
text := strings.TrimSpace(raw)
|
||||||
|
if text == "" {
|
||||||
|
if strings.TrimSpace(fallbackDomain) == "" {
|
||||||
|
return nil, fmt.Errorf("domain is empty")
|
||||||
|
}
|
||||||
|
return []string{fallbackDomain}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var domains []string
|
||||||
|
if err := json.Unmarshal([]byte(text), &domains); err != nil {
|
||||||
|
return nil, fmt.Errorf("domains payload is invalid: %w", err)
|
||||||
|
}
|
||||||
|
if len(domains) == 0 {
|
||||||
|
if strings.TrimSpace(fallbackDomain) == "" {
|
||||||
|
return nil, fmt.Errorf("domain is empty")
|
||||||
|
}
|
||||||
|
return []string{fallbackDomain}, nil
|
||||||
|
}
|
||||||
|
return domains, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func monitorPayload(id int, name, targetURL string) map[string]any {
|
||||||
|
payload := map[string]any{
|
||||||
|
"type": "http",
|
||||||
|
"name": name,
|
||||||
|
"url": targetURL,
|
||||||
|
"interval": model.UptimeKumaInterval,
|
||||||
|
"maxretries": model.UptimeKumaRetry,
|
||||||
|
"retryInterval": model.UptimeKumaRetryInterval,
|
||||||
|
"timeout": model.UptimeKumaTimeout,
|
||||||
|
"active": true,
|
||||||
|
"resendInterval": 0,
|
||||||
|
"expiryNotification": false,
|
||||||
|
"ignoreTls": false,
|
||||||
|
"accepted_statuscodes": []string{"200-299"},
|
||||||
|
"dns_resolve_type": "A",
|
||||||
|
"conditions": []any{},
|
||||||
|
}
|
||||||
|
if id > 0 {
|
||||||
|
payload["id"] = id
|
||||||
|
}
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
func monitorNeedsUpdate(existing UptimeKumaMonitor, targetURL string) bool {
|
||||||
|
return existing.Url != targetURL ||
|
||||||
|
existing.Interval != model.UptimeKumaInterval ||
|
||||||
|
existing.MaxRetries != model.UptimeKumaRetry ||
|
||||||
|
existing.RetryInterval != model.UptimeKumaRetryInterval ||
|
||||||
|
existing.Timeout != model.UptimeKumaTimeout
|
||||||
|
}
|
||||||
|
|
||||||
|
func createMonitor(client *SocketIOClient, siteName, targetURL string, openFlareTagID int) error {
|
||||||
|
slog.Info("Creating monitor in Uptime Kuma", "name", siteName, "url", targetURL)
|
||||||
|
addAck, err := client.Emit("add", monitorPayload(0, siteName, targetURL))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var addResult struct {
|
||||||
|
Ok bool `json:"ok"`
|
||||||
|
MonitorID int `json:"monitorID"`
|
||||||
|
}
|
||||||
|
if err := ParseAckResponse(addAck, &addResult); err != nil || addResult.MonitorID == 0 {
|
||||||
|
return fmt.Errorf("parse add monitor result failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Debug("Adding OpenFlare tag to the new monitor",
|
||||||
|
"name", siteName,
|
||||||
|
"monitor_id", addResult.MonitorID,
|
||||||
|
"tag_id", openFlareTagID,
|
||||||
|
)
|
||||||
|
tagAck, err := client.Emit("addMonitorTag", openFlareTagID, addResult.MonitorID, "")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := ParseAckResponse(tagAck, nil); err != nil {
|
||||||
|
return fmt.Errorf("parse add tag result failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Debug("OpenFlare tag successfully added to monitor", "name", siteName, "monitor_id", addResult.MonitorID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateMonitor(client *SocketIOClient, monitorID int, siteName, targetURL string) error {
|
||||||
|
slog.Info("Updating monitor in Uptime Kuma due to settings mismatch", "name", siteName)
|
||||||
|
editAck, err := client.Emit("editMonitor", monitorPayload(monitorID, siteName, targetURL))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := ParseAckResponse(editAck, nil); err != nil {
|
||||||
|
return fmt.Errorf("parse edit monitor result failed: %w", err)
|
||||||
|
}
|
||||||
|
slog.Info("Successfully updated monitor in Uptime Kuma", "name", siteName)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,347 @@
|
|||||||
|
// Copyright 2026 Arctel.net
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
package uptimekuma
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
type mockKumaServer struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
postsReceived []string
|
||||||
|
pendingPackets chan string
|
||||||
|
monitorList string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMockKumaServer(monitorList string) *mockKumaServer {
|
||||||
|
return &mockKumaServer{
|
||||||
|
pendingPackets: make(chan string, 100),
|
||||||
|
monitorList: monitorList,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *mockKumaServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
transport := r.URL.Query().Get("transport")
|
||||||
|
sid := r.URL.Query().Get("sid")
|
||||||
|
|
||||||
|
if r.Method == http.MethodGet {
|
||||||
|
if transport == "polling" && sid == "" {
|
||||||
|
w.Header().Set("Content-Type", "text/plain;charset=UTF-8")
|
||||||
|
_, _ = w.Write([]byte(`0{"sid":"mock-sid"}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if transport == "polling" && sid == "mock-sid" {
|
||||||
|
w.Header().Set("Content-Type", "text/plain;charset=UTF-8")
|
||||||
|
select {
|
||||||
|
case pkt := <-s.pendingPackets:
|
||||||
|
_, _ = w.Write([]byte(pkt))
|
||||||
|
case <-time.After(100 * time.Millisecond):
|
||||||
|
_, _ = w.Write([]byte(""))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else if r.Method == http.MethodPost {
|
||||||
|
bodyBytes, _ := io.ReadAll(r.Body)
|
||||||
|
bodyStr := string(bodyBytes)
|
||||||
|
s.postsReceived = append(s.postsReceived, bodyStr)
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/plain;charset=UTF-8")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
|
||||||
|
if bodyStr == "40" {
|
||||||
|
s.pendingPackets <- fmt.Sprintf(`42["monitorList",%s]`, s.monitorList)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(bodyStr, "42") {
|
||||||
|
payload := bodyStr[2:]
|
||||||
|
digitsEnd := 0
|
||||||
|
for digitsEnd < len(payload) && payload[digitsEnd] >= '0' && payload[digitsEnd] <= '9' {
|
||||||
|
digitsEnd++
|
||||||
|
}
|
||||||
|
if digitsEnd == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ackIDStr := payload[:digitsEnd]
|
||||||
|
jsonArrayStr := payload[digitsEnd:]
|
||||||
|
|
||||||
|
var arr []json.RawMessage
|
||||||
|
if err := json.Unmarshal([]byte(jsonArrayStr), &arr); err != nil || len(arr) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var eventName string
|
||||||
|
_ = json.Unmarshal(arr[0], &eventName)
|
||||||
|
|
||||||
|
switch eventName {
|
||||||
|
case "login", "loginByToken":
|
||||||
|
s.pendingPackets <- fmt.Sprintf("43%s[{\"ok\":true}]", ackIDStr)
|
||||||
|
case "getTags":
|
||||||
|
s.pendingPackets <- fmt.Sprintf("43%s[{\"ok\":true,\"tags\":[{\"id\":10,\"name\":\"OpenFlare\",\"color\":\"#4f46e5\"}]}]", ackIDStr)
|
||||||
|
case "addTag":
|
||||||
|
s.pendingPackets <- fmt.Sprintf("43%s[{\"ok\":true,\"tag\":{\"id\":10}}]", ackIDStr)
|
||||||
|
case "add":
|
||||||
|
s.pendingPackets <- fmt.Sprintf("43%s[{\"ok\":true,\"monitorID\":100}]", ackIDStr)
|
||||||
|
case "addMonitorTag":
|
||||||
|
s.pendingPackets <- fmt.Sprintf("43%s[{\"ok\":true}]", ackIDStr)
|
||||||
|
case "editMonitor":
|
||||||
|
s.pendingPackets <- fmt.Sprintf("43%s[{\"ok\":true}]", ackIDStr)
|
||||||
|
case "deleteMonitor":
|
||||||
|
s.pendingPackets <- fmt.Sprintf("43%s[{\"ok\":true}]", ackIDStr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupSyncTestDB(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{}))
|
||||||
|
|
||||||
|
db.SetDB(sqliteDB)
|
||||||
|
return func() {
|
||||||
|
db.SetDB(nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func backupUptimeKumaConfig() func() {
|
||||||
|
oldEnabled := model.UptimeKumaEnabled
|
||||||
|
oldURL := model.UptimeKumaUrl
|
||||||
|
oldUsername := model.UptimeKumaUsername
|
||||||
|
oldPassword := model.UptimeKumaPassword
|
||||||
|
oldScope := model.UptimeKumaMonitorScope
|
||||||
|
oldSelected := model.UptimeKumaSelectedSites
|
||||||
|
oldInterval := model.UptimeKumaInterval
|
||||||
|
oldRetry := model.UptimeKumaRetry
|
||||||
|
oldRetryInterval := model.UptimeKumaRetryInterval
|
||||||
|
oldTimeout := model.UptimeKumaTimeout
|
||||||
|
|
||||||
|
return func() {
|
||||||
|
model.UptimeKumaEnabled = oldEnabled
|
||||||
|
model.UptimeKumaUrl = oldURL
|
||||||
|
model.UptimeKumaUsername = oldUsername
|
||||||
|
model.UptimeKumaPassword = oldPassword
|
||||||
|
model.UptimeKumaMonitorScope = oldScope
|
||||||
|
model.UptimeKumaSelectedSites = oldSelected
|
||||||
|
model.UptimeKumaInterval = oldInterval
|
||||||
|
model.UptimeKumaRetry = oldRetry
|
||||||
|
model.UptimeKumaRetryInterval = oldRetryInterval
|
||||||
|
model.UptimeKumaTimeout = oldTimeout
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSyncToUptimeKumaDisabled(t *testing.T) {
|
||||||
|
cleanup := setupSyncTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
restore := backupUptimeKumaConfig()
|
||||||
|
defer restore()
|
||||||
|
|
||||||
|
model.UptimeKumaEnabled = false
|
||||||
|
|
||||||
|
err := SyncToUptimeKuma(context.Background())
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "disabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSyncToUptimeKumaSuccess(t *testing.T) {
|
||||||
|
cleanup := setupSyncTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
restore := backupUptimeKumaConfig()
|
||||||
|
defer restore()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
require.NoError(t, db.DB(ctx).Where("1 = 1").Delete(&model.ProxyRoute{}).Error)
|
||||||
|
|
||||||
|
routeA := &model.ProxyRoute{
|
||||||
|
SiteName: "site-a",
|
||||||
|
Domain: "site-a.com",
|
||||||
|
Domains: `["site-a.com"]`,
|
||||||
|
OriginURL: "http://10.0.0.1",
|
||||||
|
Enabled: true,
|
||||||
|
EnableHTTPS: false,
|
||||||
|
}
|
||||||
|
routeB := &model.ProxyRoute{
|
||||||
|
SiteName: "site-b",
|
||||||
|
Domain: "site-b.com",
|
||||||
|
Domains: `["site-b.com"]`,
|
||||||
|
OriginURL: "https://10.0.0.2",
|
||||||
|
Enabled: true,
|
||||||
|
EnableHTTPS: true,
|
||||||
|
}
|
||||||
|
routeC := &model.ProxyRoute{
|
||||||
|
SiteName: "site-c",
|
||||||
|
Domain: "site-c.com",
|
||||||
|
Domains: `["site-c.com"]`,
|
||||||
|
OriginURL: "http://10.0.0.3",
|
||||||
|
Enabled: false,
|
||||||
|
EnableHTTPS: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, model.CreateProxyRouteRecord(ctx, routeA))
|
||||||
|
require.NoError(t, model.CreateProxyRouteRecord(ctx, routeB))
|
||||||
|
require.NoError(t, model.CreateProxyRouteRecord(ctx, routeC))
|
||||||
|
|
||||||
|
monitorListJSON := `{
|
||||||
|
"99": {
|
||||||
|
"id": 99,
|
||||||
|
"name": "site-old",
|
||||||
|
"url": "http://site-old.com",
|
||||||
|
"interval": 60,
|
||||||
|
"tags": [{"tag_id": 10, "name": "OpenFlare"}]
|
||||||
|
},
|
||||||
|
"98": {
|
||||||
|
"id": 98,
|
||||||
|
"name": "site-a",
|
||||||
|
"url": "http://site-a.com",
|
||||||
|
"interval": 30,
|
||||||
|
"tags": [{"tag_id": 10, "name": "OpenFlare"}]
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
|
||||||
|
mockSrv := newMockKumaServer(monitorListJSON)
|
||||||
|
server := httptest.NewServer(mockSrv)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
model.UptimeKumaEnabled = true
|
||||||
|
model.UptimeKumaUrl = server.URL
|
||||||
|
model.UptimeKumaUsername = "admin"
|
||||||
|
model.UptimeKumaPassword = "password"
|
||||||
|
model.UptimeKumaMonitorScope = "all"
|
||||||
|
model.UptimeKumaInterval = 60
|
||||||
|
model.UptimeKumaRetry = 0
|
||||||
|
model.UptimeKumaRetryInterval = 60
|
||||||
|
model.UptimeKumaTimeout = 48
|
||||||
|
|
||||||
|
require.NoError(t, SyncToUptimeKuma(ctx))
|
||||||
|
|
||||||
|
mockSrv.mu.Lock()
|
||||||
|
posts := mockSrv.postsReceived
|
||||||
|
mockSrv.mu.Unlock()
|
||||||
|
|
||||||
|
hasLogin := false
|
||||||
|
hasGetTags := false
|
||||||
|
hasAddSiteB := false
|
||||||
|
hasTagSiteB := false
|
||||||
|
hasEditSiteA := false
|
||||||
|
hasDeleteOld := false
|
||||||
|
|
||||||
|
for _, body := range posts {
|
||||||
|
if strings.Contains(body, `"login"`) && strings.Contains(body, `"admin"`) && strings.Contains(body, `"password"`) {
|
||||||
|
hasLogin = true
|
||||||
|
}
|
||||||
|
if strings.Contains(body, `"getTags"`) {
|
||||||
|
hasGetTags = true
|
||||||
|
}
|
||||||
|
if strings.Contains(body, `"add"`) && strings.Contains(body, `"site-b"`) && strings.Contains(body, `"https://site-b.com"`) {
|
||||||
|
hasAddSiteB = true
|
||||||
|
}
|
||||||
|
if strings.Contains(body, `"addMonitorTag"`) && strings.Contains(body, `10`) && strings.Contains(body, `100`) {
|
||||||
|
hasTagSiteB = true
|
||||||
|
}
|
||||||
|
if strings.Contains(body, `"editMonitor"`) && strings.Contains(body, `98`) && strings.Contains(body, `"site-a"`) && strings.Contains(body, `"interval":60`) {
|
||||||
|
hasEditSiteA = true
|
||||||
|
}
|
||||||
|
if strings.Contains(body, `"deleteMonitor"`) && strings.Contains(body, `99`) {
|
||||||
|
hasDeleteOld = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.True(t, hasLogin, "expected login event to be called")
|
||||||
|
assert.True(t, hasGetTags, "expected getTags event to be called")
|
||||||
|
assert.True(t, hasAddSiteB, "expected site-b to be added")
|
||||||
|
assert.True(t, hasTagSiteB, "expected site-b to be tagged")
|
||||||
|
assert.True(t, hasEditSiteA, "expected site-a to be edited/updated")
|
||||||
|
assert.True(t, hasDeleteOld, "expected site-old to be deleted")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSyncToUptimeKumaSelectedScope(t *testing.T) {
|
||||||
|
cleanup := setupSyncTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
restore := backupUptimeKumaConfig()
|
||||||
|
defer restore()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
require.NoError(t, db.DB(ctx).Where("1 = 1").Delete(&model.ProxyRoute{}).Error)
|
||||||
|
|
||||||
|
routeA := &model.ProxyRoute{
|
||||||
|
SiteName: "site-a",
|
||||||
|
Domain: "site-a.com",
|
||||||
|
Domains: `["site-a.com"]`,
|
||||||
|
OriginURL: "http://10.0.0.1",
|
||||||
|
Enabled: true,
|
||||||
|
EnableHTTPS: false,
|
||||||
|
}
|
||||||
|
routeB := &model.ProxyRoute{
|
||||||
|
SiteName: "site-b",
|
||||||
|
Domain: "site-b.com",
|
||||||
|
Domains: `["site-b.com"]`,
|
||||||
|
OriginURL: "http://10.0.0.2",
|
||||||
|
Enabled: true,
|
||||||
|
EnableHTTPS: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, model.CreateProxyRouteRecord(ctx, routeA))
|
||||||
|
require.NoError(t, model.CreateProxyRouteRecord(ctx, routeB))
|
||||||
|
|
||||||
|
mockSrv := newMockKumaServer(`{}`)
|
||||||
|
server := httptest.NewServer(mockSrv)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
model.UptimeKumaEnabled = true
|
||||||
|
model.UptimeKumaUrl = server.URL
|
||||||
|
model.UptimeKumaUsername = "admin"
|
||||||
|
model.UptimeKumaPassword = "password"
|
||||||
|
model.UptimeKumaMonitorScope = "selected"
|
||||||
|
model.UptimeKumaSelectedSites = "site-a"
|
||||||
|
|
||||||
|
require.NoError(t, SyncToUptimeKuma(ctx))
|
||||||
|
|
||||||
|
mockSrv.mu.Lock()
|
||||||
|
posts := mockSrv.postsReceived
|
||||||
|
mockSrv.mu.Unlock()
|
||||||
|
|
||||||
|
hasLogin := false
|
||||||
|
hasAddSiteA := false
|
||||||
|
hasAddSiteB := false
|
||||||
|
|
||||||
|
for _, body := range posts {
|
||||||
|
if strings.Contains(body, `"login"`) && strings.Contains(body, `"admin"`) && strings.Contains(body, `"password"`) {
|
||||||
|
hasLogin = true
|
||||||
|
}
|
||||||
|
if strings.Contains(body, `"add"`) && strings.Contains(body, `"site-a"`) {
|
||||||
|
hasAddSiteA = true
|
||||||
|
}
|
||||||
|
if strings.Contains(body, `"add"`) && strings.Contains(body, `"site-b"`) {
|
||||||
|
hasAddSiteB = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.True(t, hasLogin, "expected login event to be called")
|
||||||
|
assert.True(t, hasAddSiteA, "expected site-a to be added")
|
||||||
|
assert.False(t, hasAddSiteB, "expected site-b NOT to be added (not in selected scope)")
|
||||||
|
}
|
||||||
@@ -0,0 +1,475 @@
|
|||||||
|
// Copyright 2026 Arctel.net
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
package waf
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/Rain-kl/Wavelet/internal/apps/openflare/agent"
|
||||||
|
"github.com/Rain-kl/Wavelet/internal/apps/openflare/websocket"
|
||||||
|
"github.com/Rain-kl/Wavelet/internal/model"
|
||||||
|
|
||||||
|
exprlang "github.com/expr-lang/expr"
|
||||||
|
"github.com/expr-lang/expr/vm"
|
||||||
|
)
|
||||||
|
|
||||||
|
const maxWAFIPGroupSubscriptionBytes = 2 * 1024 * 1024
|
||||||
|
|
||||||
|
type ipGroupAutoRuleEnv struct {
|
||||||
|
IP string `expr:"ip"`
|
||||||
|
RequestCount int `expr:"request_count"`
|
||||||
|
Status404Count int `expr:"status_404_count"`
|
||||||
|
Status404Ratio float64 `expr:"status_404_ratio"`
|
||||||
|
IPHostCount int `expr:"ip_host_count"`
|
||||||
|
IPHostRatio float64 `expr:"ip_host_ratio"`
|
||||||
|
ClientErrorCount int `expr:"client_error_count"`
|
||||||
|
ServerErrorCount int `expr:"server_error_count"`
|
||||||
|
LastSeenUnix int64 `expr:"last_seen_unix"`
|
||||||
|
statusCounts map[int]int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (env ipGroupAutoRuleEnv) StatusCount(code int) int {
|
||||||
|
if env.statusCounts == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return env.statusCounts[code]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (env ipGroupAutoRuleEnv) StatusRatio(code int) float64 {
|
||||||
|
if env.RequestCount <= 0 || env.statusCounts == nil {
|
||||||
|
return 0.0
|
||||||
|
}
|
||||||
|
return float64(env.statusCounts[code]) / float64(env.RequestCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ipGroupAutoAccumulator struct {
|
||||||
|
ip string
|
||||||
|
requestCount int
|
||||||
|
status404Count int
|
||||||
|
ipHostCount int
|
||||||
|
clientErrorCount int
|
||||||
|
serverErrorCount int
|
||||||
|
lastSeen time.Time
|
||||||
|
statusCounts map[int]int
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncDueWAFIPGroups syncs all enabled automatic/subscription IP groups that are due.
|
||||||
|
func SyncDueWAFIPGroups(ctx context.Context) error {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
groups, err := model.ListDueOpenFlareWAFIPGroups(ctx, now)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, group := range groups {
|
||||||
|
if _, err := syncOpenFlareWAFIPGroup(ctx, group, now); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func syncOpenFlareWAFIPGroup(ctx context.Context, group *model.OpenFlareWAFIPGroup, now time.Time) (*IPGroupSyncResult, error) {
|
||||||
|
if group == nil {
|
||||||
|
return nil, errors.New("IP 组不存在")
|
||||||
|
}
|
||||||
|
switch group.Type {
|
||||||
|
case wafIPGroupTypeSubscription:
|
||||||
|
return syncIPGroupSubscription(ctx, group, now)
|
||||||
|
case wafIPGroupTypeAutomatic:
|
||||||
|
return syncIPGroupAutomatic(ctx, group, now)
|
||||||
|
default:
|
||||||
|
return nil, errors.New("只有自动和订阅类型 IP 组支持同步")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func syncIPGroupSubscription(ctx context.Context, group *model.OpenFlareWAFIPGroup, now time.Time) (*IPGroupSyncResult, error) {
|
||||||
|
content, err := downloadIPGroupSubscription(group.SubscriptionURL)
|
||||||
|
if err != nil {
|
||||||
|
recordIPGroupSyncFailure(ctx, group, now, err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ips, err := parseIPGroupSubscription(content, group.SubscriptionFormat, group.SubscriptionMappingRule)
|
||||||
|
if err != nil {
|
||||||
|
recordIPGroupSyncFailure(ctx, group, now, err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ipListJSON, _ := json.Marshal(ips)
|
||||||
|
nextSyncAt := now.Add(time.Duration(group.SyncIntervalMinutes) * time.Minute)
|
||||||
|
group.IPList = string(ipListJSON)
|
||||||
|
group.LastSyncedAt = &now
|
||||||
|
group.NextSyncAt = &nextSyncAt
|
||||||
|
group.LastSyncStatus = "success"
|
||||||
|
group.LastSyncMessage = fmt.Sprintf("同步成功,共 %d 条 IP/IP 段", len(ips))
|
||||||
|
if err := model.UpdateOpenFlareWAFIPGroupSyncResult(ctx, group); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
broadcastIPGroupToAgents(ctx, group.ID)
|
||||||
|
view, err := GetIPGroup(ctx, group.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &IPGroupSyncResult{
|
||||||
|
Group: *view,
|
||||||
|
IPCount: len(ips),
|
||||||
|
SyncedAt: now.Format(time.RFC3339),
|
||||||
|
NextSyncAt: nextSyncAt.Format(time.RFC3339),
|
||||||
|
Status: group.LastSyncStatus,
|
||||||
|
Message: group.LastSyncMessage,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func syncIPGroupAutomatic(ctx context.Context, group *model.OpenFlareWAFIPGroup, now time.Time) (*IPGroupSyncResult, error) {
|
||||||
|
config, err := parseIPGroupAutoConfig(json.RawMessage(group.AutoConfig))
|
||||||
|
if err != nil {
|
||||||
|
recordIPGroupSyncFailure(ctx, group, now, err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var existingExtIPs []ipGroupExtIP
|
||||||
|
if group.ExtIPs != "" && group.ExtIPs != "[]" {
|
||||||
|
_ = json.Unmarshal([]byte(group.ExtIPs), &existingExtIPs)
|
||||||
|
}
|
||||||
|
|
||||||
|
activeExtIPs := make([]ipGroupExtIP, 0, len(existingExtIPs))
|
||||||
|
for _, extIP := range existingExtIPs {
|
||||||
|
if config.TTL > 0 {
|
||||||
|
expirationTime := extIP.CapturedAt.Add(time.Duration(config.TTL) * time.Second)
|
||||||
|
if expirationTime.Before(now) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
activeExtIPs = append(activeExtIPs, extIP)
|
||||||
|
}
|
||||||
|
|
||||||
|
ips, err := evaluateParsedIPGroupAutoConfig(ctx, config, now)
|
||||||
|
if err != nil {
|
||||||
|
recordIPGroupSyncFailure(ctx, group, now, err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
extIPMap := make(map[string]int)
|
||||||
|
for idx, extIP := range activeExtIPs {
|
||||||
|
extIPMap[extIP.IP] = idx
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, ip := range ips {
|
||||||
|
if idx, ok := extIPMap[ip]; ok {
|
||||||
|
activeExtIPs[idx].CapturedAt = now
|
||||||
|
} else {
|
||||||
|
activeExtIPs = append(activeExtIPs, ipGroupExtIP{
|
||||||
|
IP: ip,
|
||||||
|
CapturedAt: now,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
finalIPs := make([]string, 0, len(activeExtIPs))
|
||||||
|
for _, extIP := range activeExtIPs {
|
||||||
|
finalIPs = append(finalIPs, extIP.IP)
|
||||||
|
}
|
||||||
|
finalIPs, err = normalizeIPList(finalIPs)
|
||||||
|
if err != nil {
|
||||||
|
recordIPGroupSyncFailure(ctx, group, now, err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
extIPsJSON, _ := json.Marshal(activeExtIPs)
|
||||||
|
ipListJSON, _ := json.Marshal(finalIPs)
|
||||||
|
|
||||||
|
nextSyncAt := now.Add(time.Duration(normalizeIPGroupSyncInterval(group.SyncIntervalMinutes)) * time.Minute)
|
||||||
|
group.IPList = string(ipListJSON)
|
||||||
|
group.ExtIPs = string(extIPsJSON)
|
||||||
|
group.LastSyncedAt = &now
|
||||||
|
group.NextSyncAt = &nextSyncAt
|
||||||
|
group.LastSyncStatus = "success"
|
||||||
|
group.LastSyncMessage = fmt.Sprintf("自动规则执行成功,共命中 %d 个 IP,当前生效 %d 个 IP", len(ips), len(finalIPs))
|
||||||
|
if err := model.UpdateOpenFlareWAFIPGroupSyncResult(ctx, group); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
broadcastIPGroupToAgents(ctx, group.ID)
|
||||||
|
view, err := GetIPGroup(ctx, group.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &IPGroupSyncResult{
|
||||||
|
Group: *view,
|
||||||
|
IPCount: len(finalIPs),
|
||||||
|
SyncedAt: now.Format(time.RFC3339),
|
||||||
|
NextSyncAt: nextSyncAt.Format(time.RFC3339),
|
||||||
|
Status: group.LastSyncStatus,
|
||||||
|
Message: group.LastSyncMessage,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func recordIPGroupSyncFailure(ctx context.Context, group *model.OpenFlareWAFIPGroup, now time.Time, syncErr error) {
|
||||||
|
nextSyncAt := now.Add(time.Duration(normalizeIPGroupSyncInterval(group.SyncIntervalMinutes)) * time.Minute)
|
||||||
|
group.LastSyncedAt = &now
|
||||||
|
group.NextSyncAt = &nextSyncAt
|
||||||
|
group.LastSyncStatus = "failed"
|
||||||
|
group.LastSyncMessage = syncErr.Error()
|
||||||
|
_ = model.UpdateOpenFlareWAFIPGroupSyncResult(ctx, group)
|
||||||
|
}
|
||||||
|
|
||||||
|
func evaluateParsedIPGroupAutoConfig(ctx context.Context, config ipGroupAutoConfig, now time.Time) ([]string, error) {
|
||||||
|
if len(config.Rules) == 0 {
|
||||||
|
return []string{}, nil
|
||||||
|
}
|
||||||
|
programs := make([]*vm.Program, 0, len(config.Rules))
|
||||||
|
for i, rule := range config.Rules {
|
||||||
|
program, err := exprlang.Compile(rule.Expr, exprlang.Env(ipGroupAutoRuleEnv{}), exprlang.AsBool())
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("自动规则 %s Expr 无效: %w", displayIPGroupAutoRuleName(rule, i), err)
|
||||||
|
}
|
||||||
|
programs = append(programs, program)
|
||||||
|
}
|
||||||
|
logs, err := model.ListOpenFlareAccessLogsForWAFIPGroup(ctx, model.OpenFlareAccessLogQuery{
|
||||||
|
Since: now.Add(-time.Duration(config.LookbackMinutes) * time.Minute),
|
||||||
|
Until: now,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
accumulators := make(map[string]*ipGroupAutoAccumulator)
|
||||||
|
for _, item := range logs {
|
||||||
|
if item == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ip, ok := normalizeIPLiteral(item.RemoteAddr)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
acc := accumulators[ip]
|
||||||
|
if acc == nil {
|
||||||
|
acc = &ipGroupAutoAccumulator{
|
||||||
|
ip: ip,
|
||||||
|
statusCounts: make(map[int]int),
|
||||||
|
}
|
||||||
|
accumulators[ip] = acc
|
||||||
|
}
|
||||||
|
acc.requestCount++
|
||||||
|
acc.statusCounts[item.StatusCode]++
|
||||||
|
if item.StatusCode == http.StatusNotFound {
|
||||||
|
acc.status404Count++
|
||||||
|
}
|
||||||
|
if item.StatusCode >= 400 && item.StatusCode < 500 {
|
||||||
|
acc.clientErrorCount++
|
||||||
|
}
|
||||||
|
if item.StatusCode >= 500 {
|
||||||
|
acc.serverErrorCount++
|
||||||
|
}
|
||||||
|
if hostIsIPLiteral(item.Host) {
|
||||||
|
acc.ipHostCount++
|
||||||
|
}
|
||||||
|
if item.LoggedAt.After(acc.lastSeen) {
|
||||||
|
acc.lastSeen = item.LoggedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
matched := make([]string, 0)
|
||||||
|
for _, acc := range accumulators {
|
||||||
|
env := acc.toExprEnv()
|
||||||
|
for _, program := range programs {
|
||||||
|
output, err := exprlang.Run(program, env)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("执行自动规则失败: %w", err)
|
||||||
|
}
|
||||||
|
if matchedRule, ok := output.(bool); ok && matchedRule {
|
||||||
|
matched = append(matched, acc.ip)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return normalizeIPList(matched)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (acc *ipGroupAutoAccumulator) toExprEnv() ipGroupAutoRuleEnv {
|
||||||
|
env := ipGroupAutoRuleEnv{
|
||||||
|
IP: acc.ip,
|
||||||
|
RequestCount: acc.requestCount,
|
||||||
|
Status404Count: acc.status404Count,
|
||||||
|
IPHostCount: acc.ipHostCount,
|
||||||
|
ClientErrorCount: acc.clientErrorCount,
|
||||||
|
ServerErrorCount: acc.serverErrorCount,
|
||||||
|
statusCounts: acc.statusCounts,
|
||||||
|
}
|
||||||
|
if acc.requestCount > 0 {
|
||||||
|
env.Status404Ratio = float64(acc.status404Count) / float64(acc.requestCount)
|
||||||
|
env.IPHostRatio = float64(acc.ipHostCount) / float64(acc.requestCount)
|
||||||
|
}
|
||||||
|
if !acc.lastSeen.IsZero() {
|
||||||
|
env.LastSeenUnix = acc.lastSeen.Unix()
|
||||||
|
}
|
||||||
|
return env
|
||||||
|
}
|
||||||
|
|
||||||
|
func displayIPGroupAutoRuleName(rule ipGroupAutoRule, index int) string {
|
||||||
|
if rule.Name != "" {
|
||||||
|
return rule.Name
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("#%d", index+1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeIPLiteral(value string) (string, bool) {
|
||||||
|
host := strings.TrimSpace(value)
|
||||||
|
if host == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
if parsedHost, _, err := net.SplitHostPort(host); err == nil {
|
||||||
|
host = parsedHost
|
||||||
|
}
|
||||||
|
host = strings.Trim(host, "[]")
|
||||||
|
addr, err := netip.ParseAddr(host)
|
||||||
|
if err != nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return addr.String(), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func hostIsIPLiteral(value string) bool {
|
||||||
|
_, ok := normalizeIPLiteral(value)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func downloadIPGroupSubscription(rawURL string) ([]byte, error) {
|
||||||
|
if err := validateSubscriptionURL(rawURL); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
client := http.Client{Timeout: 15 * time.Second}
|
||||||
|
resp, err := client.Get(rawURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("下载订阅失败: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return nil, fmt.Errorf("订阅返回状态码 %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
var buffer bytes.Buffer
|
||||||
|
reader := io.LimitReader(resp.Body, maxWAFIPGroupSubscriptionBytes+1)
|
||||||
|
if _, err := buffer.ReadFrom(reader); err != nil {
|
||||||
|
return nil, fmt.Errorf("读取订阅内容失败: %w", err)
|
||||||
|
}
|
||||||
|
if buffer.Len() > maxWAFIPGroupSubscriptionBytes {
|
||||||
|
return nil, fmt.Errorf("订阅内容不能超过 %d 字节", maxWAFIPGroupSubscriptionBytes)
|
||||||
|
}
|
||||||
|
return buffer.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseIPGroupSubscription(content []byte, format string, mappingRule string) ([]string, error) {
|
||||||
|
switch normalizeIPGroupSubscriptionFormat(format) {
|
||||||
|
case wafIPGroupSubscriptionFormatJSON:
|
||||||
|
items, err := parseIPGroupJSONSubscription(content, mappingRule)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return normalizeIPList(items)
|
||||||
|
default:
|
||||||
|
return normalizeIPList(parseIPGroupTextSubscription(string(content)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseIPGroupTextSubscription(text string) []string {
|
||||||
|
lines := strings.Split(text, "\n")
|
||||||
|
items := make([]string, 0, len(lines))
|
||||||
|
for _, line := range lines {
|
||||||
|
item := strings.TrimSpace(line)
|
||||||
|
if item == "" || strings.HasPrefix(item, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
items = append(items, item)
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseIPGroupJSONSubscription(content []byte, mappingRule string) ([]string, error) {
|
||||||
|
var payload any
|
||||||
|
if err := json.Unmarshal(content, &payload); err != nil {
|
||||||
|
return nil, fmt.Errorf("JSON 订阅解析失败: %w", err)
|
||||||
|
}
|
||||||
|
nodes, err := selectJSONMappingNodes(payload, mappingRule)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items := make([]string, 0, len(nodes))
|
||||||
|
for _, node := range nodes {
|
||||||
|
collectJSONStrings(node, &items)
|
||||||
|
}
|
||||||
|
if len(items) == 0 {
|
||||||
|
return nil, errors.New("JSON 订阅没有解析到 IP/IP 段")
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectJSONMappingNodes(payload any, mappingRule string) ([]any, error) {
|
||||||
|
rule := strings.TrimSpace(mappingRule)
|
||||||
|
if rule == "" || rule == "$" {
|
||||||
|
return []any{payload}, nil
|
||||||
|
}
|
||||||
|
rule = strings.TrimPrefix(rule, "$.")
|
||||||
|
nodes := []any{payload}
|
||||||
|
for _, rawSegment := range strings.Split(rule, ".") {
|
||||||
|
segment := strings.TrimSpace(rawSegment)
|
||||||
|
if segment == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
expandArray := strings.HasSuffix(segment, "[]")
|
||||||
|
segment = strings.TrimSuffix(segment, "[]")
|
||||||
|
next := make([]any, 0)
|
||||||
|
for _, node := range nodes {
|
||||||
|
object, ok := node.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
value, ok := object[segment]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if expandArray {
|
||||||
|
array, ok := value.([]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
next = append(next, array...)
|
||||||
|
} else {
|
||||||
|
next = append(next, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
nodes = next
|
||||||
|
}
|
||||||
|
if len(nodes) == 0 {
|
||||||
|
return nil, fmt.Errorf("JSON 映射规则 %q 未匹配到内容", mappingRule)
|
||||||
|
}
|
||||||
|
return nodes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectJSONStrings(node any, items *[]string) {
|
||||||
|
switch value := node.(type) {
|
||||||
|
case string:
|
||||||
|
*items = append(*items, value)
|
||||||
|
case []any:
|
||||||
|
for _, item := range value {
|
||||||
|
collectJSONStrings(item, items)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func broadcastIPGroupToAgents(ctx context.Context, id uint) {
|
||||||
|
groups, err := agent.WAFIPGroupsForAgent(ctx, []uint{id})
|
||||||
|
if err != nil || len(groups) == 0 {
|
||||||
|
if err != nil {
|
||||||
|
slog.Debug("build waf ip group broadcast payload failed", "id", id, "error", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
websocket.BroadcastWAFIPGroups(groups)
|
||||||
|
}
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
// Copyright 2026 Arctel.net
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
package waf
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"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 setupIPGroupSyncTestDB(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.OpenFlareWAFRuleGroup{},
|
||||||
|
&model.OpenFlareWAFIPGroup{},
|
||||||
|
&model.OpenFlareAccessLog{},
|
||||||
|
))
|
||||||
|
|
||||||
|
db.SetDB(sqliteDB)
|
||||||
|
return func() {
|
||||||
|
db.SetDB(nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseIPGroupSubscriptionParsers(t *testing.T) {
|
||||||
|
textItems, err := parseIPGroupSubscription([]byte("# comment\n203.0.113.10\n\n198.51.100.0/24\n"), "text", "")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, textItems, 2)
|
||||||
|
assert.Equal(t, "198.51.100.0/24", textItems[0])
|
||||||
|
assert.Equal(t, "203.0.113.10", textItems[1])
|
||||||
|
|
||||||
|
jsonItems, err := parseIPGroupSubscription([]byte(`{"data":{"items":[{"ip":"203.0.113.11"},{"ip":"203.0.113.12"}]}}`), "json", "data.items[].ip")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, jsonItems, 2)
|
||||||
|
assert.Equal(t, "203.0.113.11", jsonItems[0])
|
||||||
|
assert.Equal(t, "203.0.113.12", jsonItems[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSyncIPGroupDownloadsSubscription(t *testing.T) {
|
||||||
|
cleanup := setupIPGroupSyncTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write([]byte("203.0.113.20\n"))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
group, err := CreateIPGroup(ctx, IPGroupInput{
|
||||||
|
Name: "subscription",
|
||||||
|
Type: wafIPGroupTypeSubscription,
|
||||||
|
Enabled: true,
|
||||||
|
SubscriptionURL: server.URL,
|
||||||
|
SubscriptionFormat: wafIPGroupSubscriptionFormatText,
|
||||||
|
SyncIntervalMinutes: 10,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
result, err := SyncIPGroup(ctx, group.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, 1, result.IPCount)
|
||||||
|
assert.Equal(t, "203.0.113.20", result.Group.IPList[0])
|
||||||
|
assert.Equal(t, "success", result.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSyncIPGroupAutomaticExprRules(t *testing.T) {
|
||||||
|
cleanup := setupIPGroupSyncTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
seedWAFAccessLogs(t, ctx, now, "203.0.113.10", "app.example.com", 101, 81)
|
||||||
|
seedWAFAccessLogs(t, ctx, now, "203.0.113.11", "198.51.100.10", 60, 0)
|
||||||
|
seedWAFAccessLogs(t, ctx, now, "203.0.113.12", "app.example.com", 120, 10)
|
||||||
|
|
||||||
|
group, err := CreateIPGroup(ctx, IPGroupInput{
|
||||||
|
Name: "auto blacklist",
|
||||||
|
Type: wafIPGroupTypeAutomatic,
|
||||||
|
Enabled: true,
|
||||||
|
AutoConfig: json.RawMessage(`{
|
||||||
|
"lookback_minutes": 60,
|
||||||
|
"rules": [
|
||||||
|
{"name":"单 IP 404 高频扫描","expr":"request_count > 100 && StatusRatio(404) >= 0.8"},
|
||||||
|
{"name":"单 IP 直连访问异常","expr":"ip_host_count > 50 && ip_host_ratio > 0.5"}
|
||||||
|
]
|
||||||
|
}`),
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
result, err := SyncIPGroup(ctx, group.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, 2, result.IPCount)
|
||||||
|
|
||||||
|
want := map[string]bool{"203.0.113.10": true, "203.0.113.11": true}
|
||||||
|
for _, item := range result.Group.IPList {
|
||||||
|
assert.True(t, want[item], "unexpected matched IP %s", item)
|
||||||
|
delete(want, item)
|
||||||
|
}
|
||||||
|
assert.Empty(t, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTestIPGroupAutoConfigReturnsMatchedIPs(t *testing.T) {
|
||||||
|
cleanup := setupIPGroupSyncTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
seedWAFAccessLogs(t, ctx, now, "203.0.113.10", "app.example.com", 101, 81)
|
||||||
|
seedWAFAccessLogs(t, ctx, now, "203.0.113.11", "198.51.100.10", 60, 0)
|
||||||
|
seedWAFAccessLogs(t, ctx, now, "203.0.113.12", "app.example.com", 120, 10)
|
||||||
|
|
||||||
|
result, err := TestIPGroupAutoConfig(ctx, IPGroupAutoTestInput{
|
||||||
|
AutoConfig: json.RawMessage(`{
|
||||||
|
"lookback_minutes": 60,
|
||||||
|
"rules": [
|
||||||
|
{"name":"单 IP 404 高频扫描","expr":"request_count > 100 && StatusRatio(404) >= 0.8"},
|
||||||
|
{"name":"单 IP 直连访问异常","expr":"ip_host_count > 50 && ip_host_ratio > 0.5"}
|
||||||
|
]
|
||||||
|
}`),
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 2, result.MatchedCount)
|
||||||
|
assert.Equal(t, 2, result.RuleCount)
|
||||||
|
assert.Equal(t, 60, result.LookbackMinutes)
|
||||||
|
|
||||||
|
want := map[string]bool{"203.0.113.10": true, "203.0.113.11": true}
|
||||||
|
for _, item := range result.MatchedIPs {
|
||||||
|
assert.True(t, want[item], "unexpected matched IP %s", item)
|
||||||
|
delete(want, item)
|
||||||
|
}
|
||||||
|
assert.Empty(t, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListDueOpenFlareWAFIPGroups(t *testing.T) {
|
||||||
|
cleanup := setupIPGroupSyncTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
past := time.Now().UTC().Add(-time.Hour)
|
||||||
|
future := time.Now().UTC().Add(time.Hour)
|
||||||
|
|
||||||
|
dueAuto := &model.OpenFlareWAFIPGroup{
|
||||||
|
Name: "due auto", Type: wafIPGroupTypeAutomatic, Enabled: true,
|
||||||
|
IPList: "[]", AutoConfig: "{}", ExtIPs: "[]", NextSyncAt: &past,
|
||||||
|
}
|
||||||
|
require.NoError(t, model.CreateOpenFlareWAFIPGroup(ctx, dueAuto))
|
||||||
|
|
||||||
|
futureAuto := &model.OpenFlareWAFIPGroup{
|
||||||
|
Name: "future auto", Type: wafIPGroupTypeAutomatic, Enabled: true,
|
||||||
|
IPList: "[]", AutoConfig: "{}", ExtIPs: "[]", NextSyncAt: &future,
|
||||||
|
}
|
||||||
|
require.NoError(t, model.CreateOpenFlareWAFIPGroup(ctx, futureAuto))
|
||||||
|
|
||||||
|
dueSub := &model.OpenFlareWAFIPGroup{
|
||||||
|
Name: "due sub", Type: wafIPGroupTypeSubscription, Enabled: true,
|
||||||
|
IPList: "[]", AutoConfig: "{}", ExtIPs: "[]",
|
||||||
|
SubscriptionURL: "https://example.com/list", NextSyncAt: &past,
|
||||||
|
}
|
||||||
|
require.NoError(t, model.CreateOpenFlareWAFIPGroup(ctx, dueSub))
|
||||||
|
|
||||||
|
manual := &model.OpenFlareWAFIPGroup{
|
||||||
|
Name: "manual", Type: wafIPGroupTypeManual, Enabled: true,
|
||||||
|
IPList: "[]", AutoConfig: "{}", ExtIPs: "[]", NextSyncAt: &past,
|
||||||
|
}
|
||||||
|
require.NoError(t, model.CreateOpenFlareWAFIPGroup(ctx, manual))
|
||||||
|
|
||||||
|
groups, err := model.ListDueOpenFlareWAFIPGroups(ctx, time.Now().UTC())
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, groups, 2)
|
||||||
|
ids := []uint{groups[0].ID, groups[1].ID}
|
||||||
|
assert.Contains(t, ids, dueAuto.ID)
|
||||||
|
assert.Contains(t, ids, dueSub.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedWAFAccessLogs(t *testing.T, ctx context.Context, loggedAt time.Time, remoteAddr string, host string, total int, notFound int) {
|
||||||
|
t.Helper()
|
||||||
|
for i := 0; i < total; i++ {
|
||||||
|
statusCode := http.StatusOK
|
||||||
|
if i < notFound {
|
||||||
|
statusCode = http.StatusNotFound
|
||||||
|
}
|
||||||
|
require.NoError(t, db.DB(ctx).Create(&model.OpenFlareAccessLog{
|
||||||
|
NodeID: "node-waf-auto",
|
||||||
|
LoggedAt: loggedAt.Add(-time.Duration(i%30) * time.Second),
|
||||||
|
RemoteAddr: remoteAddr,
|
||||||
|
Host: host,
|
||||||
|
Path: "/probe",
|
||||||
|
StatusCode: statusCode,
|
||||||
|
}).Error)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,8 @@ import (
|
|||||||
"unicode"
|
"unicode"
|
||||||
|
|
||||||
"github.com/Rain-kl/Wavelet/internal/model"
|
"github.com/Rain-kl/Wavelet/internal/model"
|
||||||
|
|
||||||
|
exprlang "github.com/expr-lang/expr"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -486,39 +488,29 @@ func DeleteIPGroup(ctx context.Context, id uint) error {
|
|||||||
return model.DeleteOpenFlareWAFIPGroup(ctx, group.ID)
|
return model.DeleteOpenFlareWAFIPGroup(ctx, group.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SyncIPGroup is a stub that returns a successful sync result.
|
// SyncIPGroup synchronizes a subscription or automatic WAF IP group.
|
||||||
func SyncIPGroup(ctx context.Context, id uint) (*IPGroupSyncResult, error) {
|
func SyncIPGroup(ctx context.Context, id uint) (*IPGroupSyncResult, error) {
|
||||||
group, err := model.GetOpenFlareWAFIPGroupByID(ctx, id)
|
group, err := model.GetOpenFlareWAFIPGroupByID(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
now := time.Now().UTC()
|
return syncOpenFlareWAFIPGroup(ctx, group, time.Now().UTC())
|
||||||
view, err := GetIPGroup(ctx, id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
nextSyncAt := now.Add(time.Duration(normalizeIPGroupSyncInterval(group.SyncIntervalMinutes)) * time.Minute)
|
|
||||||
return &IPGroupSyncResult{
|
|
||||||
Group: *view,
|
|
||||||
IPCount: len(view.IPList),
|
|
||||||
SyncedAt: now.Format(time.RFC3339),
|
|
||||||
NextSyncAt: nextSyncAt.Format(time.RFC3339),
|
|
||||||
Status: "success",
|
|
||||||
Message: "同步成功",
|
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestIPGroupAutoConfig is a stub that validates config and returns an empty match set.
|
// TestIPGroupAutoConfig evaluates automatic IP group rules against recent access logs.
|
||||||
func TestIPGroupAutoConfig(ctx context.Context, input IPGroupAutoTestInput) (*IPGroupAutoTestResult, error) {
|
func TestIPGroupAutoConfig(ctx context.Context, input IPGroupAutoTestInput) (*IPGroupAutoTestResult, error) {
|
||||||
_ = ctx
|
|
||||||
config, err := parseIPGroupAutoConfig(input.AutoConfig)
|
config, err := parseIPGroupAutoConfig(input.AutoConfig)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
|
ips, err := evaluateParsedIPGroupAutoConfig(ctx, config, now)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
return &IPGroupAutoTestResult{
|
return &IPGroupAutoTestResult{
|
||||||
MatchedIPs: []string{},
|
MatchedIPs: ips,
|
||||||
MatchedCount: 0,
|
MatchedCount: len(ips),
|
||||||
LookbackMinutes: config.LookbackMinutes,
|
LookbackMinutes: config.LookbackMinutes,
|
||||||
RuleCount: len(config.Rules),
|
RuleCount: len(config.Rules),
|
||||||
TestedAt: now.Format(time.RFC3339),
|
TestedAt: now.Format(time.RFC3339),
|
||||||
@@ -1104,6 +1096,9 @@ func parseIPGroupAutoConfig(raw json.RawMessage) (ipGroupAutoConfig, error) {
|
|||||||
if rule.Expr == "" {
|
if rule.Expr == "" {
|
||||||
return ipGroupAutoConfig{}, fmt.Errorf("自动规则 %d 的 Expr 表达式不能为空", i+1)
|
return ipGroupAutoConfig{}, fmt.Errorf("自动规则 %d 的 Expr 表达式不能为空", i+1)
|
||||||
}
|
}
|
||||||
|
if _, err := exprlang.Compile(rule.Expr, exprlang.Env(ipGroupAutoRuleEnv{}), exprlang.AsBool()); err != nil {
|
||||||
|
return ipGroupAutoConfig{}, fmt.Errorf("自动规则 %s Expr 无效: %w", displayIPGroupAutoRuleName(rule, i), err)
|
||||||
|
}
|
||||||
config.Rules[i] = rule
|
config.Rules[i] = rule
|
||||||
}
|
}
|
||||||
return config, nil
|
return config, nil
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
// Copyright 2026 Arctel.net
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
package waf
|
||||||
|
|
||||||
|
import (
|
||||||
|
oftasks "github.com/Rain-kl/Wavelet/internal/apps/openflare/tasks"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
oftasks.RegisterWAFIPGroupSync(SyncDueWAFIPGroups)
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ const (
|
|||||||
AgentWSConnectedLastSeenValue = "__OPENFLARE_AGENT_WS_CONNECTED__"
|
AgentWSConnectedLastSeenValue = "__OPENFLARE_AGENT_WS_CONNECTED__"
|
||||||
|
|
||||||
agentMessageTypeForceSyncConfig = "force_sync_config"
|
agentMessageTypeForceSyncConfig = "force_sync_config"
|
||||||
|
agentMessageTypeWAFIPGroups = "waf_ip_groups"
|
||||||
)
|
)
|
||||||
|
|
||||||
type agentClient struct {
|
type agentClient struct {
|
||||||
@@ -102,6 +103,28 @@ func IsAgentConnected(nodeID string) bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BroadcastWAFIPGroups pushes changed WAF IP groups to all connected agents.
|
||||||
|
func BroadcastWAFIPGroups(payload any) int {
|
||||||
|
if payload == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
message := Message{Type: agentMessageTypeWAFIPGroups, Payload: payload}
|
||||||
|
defaultAgentHub.mu.RLock()
|
||||||
|
clients := make([]*agentClient, 0, len(defaultAgentHub.clients))
|
||||||
|
for _, client := range defaultAgentHub.clients {
|
||||||
|
clients = append(clients, client)
|
||||||
|
}
|
||||||
|
defaultAgentHub.mu.RUnlock()
|
||||||
|
|
||||||
|
success := 0
|
||||||
|
for _, client := range clients {
|
||||||
|
if client.enqueue(message) {
|
||||||
|
success++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return success
|
||||||
|
}
|
||||||
|
|
||||||
// SendForceSyncConfig notifies an agent to force sync configuration.
|
// SendForceSyncConfig notifies an agent to force sync configuration.
|
||||||
func SendForceSyncConfig(nodeID string, payload any) bool {
|
func SendForceSyncConfig(nodeID string, payload any) bool {
|
||||||
defaultAgentHub.mu.RLock()
|
defaultAgentHub.mu.RLock()
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import (
|
|||||||
|
|
||||||
admin_push "github.com/Rain-kl/Wavelet/internal/apps/admin/push"
|
admin_push "github.com/Rain-kl/Wavelet/internal/apps/admin/push"
|
||||||
"github.com/Rain-kl/Wavelet/internal/apps/admin/push/custom_events"
|
"github.com/Rain-kl/Wavelet/internal/apps/admin/push/custom_events"
|
||||||
|
oftasks "github.com/Rain-kl/Wavelet/internal/apps/openflare/tasks"
|
||||||
|
_ "github.com/Rain-kl/Wavelet/internal/apps/openflare/waf"
|
||||||
"github.com/Rain-kl/Wavelet/internal/apps/risk_control"
|
"github.com/Rain-kl/Wavelet/internal/apps/risk_control"
|
||||||
taskhandlers "github.com/Rain-kl/Wavelet/internal/task/handlers"
|
taskhandlers "github.com/Rain-kl/Wavelet/internal/task/handlers"
|
||||||
"github.com/Rain-kl/Wavelet/pkg/logger"
|
"github.com/Rain-kl/Wavelet/pkg/logger"
|
||||||
@@ -23,10 +25,11 @@ type Options struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
registerTasksOnce sync.Once
|
registerTasksOnce sync.Once
|
||||||
registerPushDomainEventsOnce sync.Once
|
registerPushDomainEventsOnce sync.Once
|
||||||
registerTaskListenersOnce sync.Once
|
registerTaskListenersOnce sync.Once
|
||||||
initRuntimeOnce sync.Once
|
registerOpenFlareBackgroundTasksOnce sync.Once
|
||||||
|
initRuntimeOnce sync.Once
|
||||||
)
|
)
|
||||||
|
|
||||||
// RegisterTasks registers all built-in task handlers and metadata.
|
// RegisterTasks registers all built-in task handlers and metadata.
|
||||||
@@ -50,10 +53,16 @@ func RegisterTaskListeners() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RegisterOpenFlareBackgroundTasks links OpenFlare in-process cron jobs registered via init().
|
||||||
|
func RegisterOpenFlareBackgroundTasks() {
|
||||||
|
registerOpenFlareBackgroundTasksOnce.Do(func() {})
|
||||||
|
}
|
||||||
|
|
||||||
// RegisterAPI wires integrations required by the HTTP API process.
|
// RegisterAPI wires integrations required by the HTTP API process.
|
||||||
func RegisterAPI() {
|
func RegisterAPI() {
|
||||||
RegisterTasks()
|
RegisterTasks()
|
||||||
RegisterPushDomainEvents()
|
RegisterPushDomainEvents()
|
||||||
|
RegisterOpenFlareBackgroundTasks()
|
||||||
}
|
}
|
||||||
|
|
||||||
// RegisterWorker wires integrations required by the task worker process.
|
// RegisterWorker wires integrations required by the task worker process.
|
||||||
@@ -72,6 +81,7 @@ func RegisterAll() {
|
|||||||
RegisterTasks()
|
RegisterTasks()
|
||||||
RegisterPushDomainEvents()
|
RegisterPushDomainEvents()
|
||||||
RegisterTaskListeners()
|
RegisterTaskListeners()
|
||||||
|
RegisterOpenFlareBackgroundTasks()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Init runs shared runtime bootstrap exactly once per process.
|
// Init runs shared runtime bootstrap exactly once per process.
|
||||||
@@ -83,6 +93,7 @@ func Init(ctx context.Context, opts Options) {
|
|||||||
}
|
}
|
||||||
if opts.API {
|
if opts.API {
|
||||||
risk_control.InitLogWriter(ctx)
|
risk_control.InitLogWriter(ctx)
|
||||||
|
oftasks.Start(ctx)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,6 +51,11 @@ type openFlareAccessLogIPTrendRow struct {
|
|||||||
RequestCount int64 `gorm:"column:request_count"`
|
RequestCount int64 `gorm:"column:request_count"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListOpenFlareAccessLogsForWAFIPGroup lists access logs in a time window for automatic IP group rules.
|
||||||
|
func ListOpenFlareAccessLogsForWAFIPGroup(ctx context.Context, query OpenFlareAccessLogQuery) ([]*OpenFlareAccessLog, error) {
|
||||||
|
return ListOpenFlareAccessLogs(ctx, query)
|
||||||
|
}
|
||||||
|
|
||||||
// ListOpenFlareAccessLogs lists access logs matching the query.
|
// ListOpenFlareAccessLogs lists access logs matching the query.
|
||||||
func ListOpenFlareAccessLogs(ctx context.Context, query OpenFlareAccessLogQuery) ([]*OpenFlareAccessLog, error) {
|
func ListOpenFlareAccessLogs(ctx context.Context, query OpenFlareAccessLogQuery) ([]*OpenFlareAccessLog, error) {
|
||||||
conn := db.DB(ctx)
|
conn := db.DB(ctx)
|
||||||
@@ -236,6 +241,22 @@ ORDER BY bucket_epoch ASC`, bucketExpr, openFlareAccessLogTable, queryClause)
|
|||||||
return rows, nil
|
return rows, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeleteAllOpenFlareAccessLogs deletes all access logs.
|
||||||
|
func DeleteAllOpenFlareAccessLogs(ctx context.Context) (int64, error) {
|
||||||
|
conn := db.DB(ctx)
|
||||||
|
if conn == nil {
|
||||||
|
return 0, errors.New(errDatabaseNotInitialized)
|
||||||
|
}
|
||||||
|
result := conn.Where("1 = 1").Delete(&OpenFlareAccessLog{})
|
||||||
|
if result.Error != nil {
|
||||||
|
if isMissingTableError(result.Error) {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
return 0, result.Error
|
||||||
|
}
|
||||||
|
return result.RowsAffected, nil
|
||||||
|
}
|
||||||
|
|
||||||
// DeleteOpenFlareAccessLogsBefore deletes access logs older than cutoff.
|
// DeleteOpenFlareAccessLogsBefore deletes access logs older than cutoff.
|
||||||
func DeleteOpenFlareAccessLogsBefore(ctx context.Context, cutoff time.Time) (int64, error) {
|
func DeleteOpenFlareAccessLogsBefore(ctx context.Context, cutoff time.Time) (int64, error) {
|
||||||
conn := db.DB(ctx)
|
conn := db.DB(ctx)
|
||||||
|
|||||||
@@ -40,6 +40,24 @@ func GetAcmeAccountByID(ctx context.Context, id uint) (*AcmeAccount, error) {
|
|||||||
return &account, nil
|
return &account, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CreateAcmeAccountRecord 创建 ACME 账号。
|
||||||
|
func CreateAcmeAccountRecord(ctx context.Context, account *AcmeAccount) error {
|
||||||
|
conn := db.DB(ctx)
|
||||||
|
if conn == nil {
|
||||||
|
return errors.New(errDatabaseNotInitialized)
|
||||||
|
}
|
||||||
|
return conn.Create(account).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveAcmeAccount 保存 ACME 账号。
|
||||||
|
func SaveAcmeAccount(ctx context.Context, account *AcmeAccount) error {
|
||||||
|
conn := db.DB(ctx)
|
||||||
|
if conn == nil {
|
||||||
|
return errors.New(errDatabaseNotInitialized)
|
||||||
|
}
|
||||||
|
return conn.Save(account).Error
|
||||||
|
}
|
||||||
|
|
||||||
// GetDefaultAcmeAccount 获取默认 ACME 账号,不存在时创建占位记录。
|
// GetDefaultAcmeAccount 获取默认 ACME 账号,不存在时创建占位记录。
|
||||||
func GetDefaultAcmeAccount(ctx context.Context) (*AcmeAccount, error) {
|
func GetDefaultAcmeAccount(ctx context.Context) (*AcmeAccount, error) {
|
||||||
conn := db.DB(ctx)
|
conn := db.DB(ctx)
|
||||||
|
|||||||
@@ -375,6 +375,70 @@ func ListOpenFlareHealthEvents(ctx context.Context, nodeID string, activeOnly bo
|
|||||||
return rows, nil
|
return rows, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeleteOpenFlareMetricSnapshotsBefore deletes metric snapshots captured before cutoff.
|
||||||
|
func DeleteOpenFlareMetricSnapshotsBefore(ctx context.Context, cutoff time.Time) (int64, error) {
|
||||||
|
conn := db.DB(ctx)
|
||||||
|
if conn == nil {
|
||||||
|
return 0, errors.New(errDatabaseNotInitialized)
|
||||||
|
}
|
||||||
|
result := conn.Where("captured_at < ?", cutoff).Delete(&OpenFlareMetricSnapshot{})
|
||||||
|
if result.Error != nil {
|
||||||
|
if isMissingTableError(result.Error) {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
return 0, result.Error
|
||||||
|
}
|
||||||
|
return result.RowsAffected, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteAllOpenFlareMetricSnapshots deletes all metric snapshots.
|
||||||
|
func DeleteAllOpenFlareMetricSnapshots(ctx context.Context) (int64, error) {
|
||||||
|
conn := db.DB(ctx)
|
||||||
|
if conn == nil {
|
||||||
|
return 0, errors.New(errDatabaseNotInitialized)
|
||||||
|
}
|
||||||
|
result := conn.Where("1 = 1").Delete(&OpenFlareMetricSnapshot{})
|
||||||
|
if result.Error != nil {
|
||||||
|
if isMissingTableError(result.Error) {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
return 0, result.Error
|
||||||
|
}
|
||||||
|
return result.RowsAffected, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteOpenFlareRequestReportsBefore deletes request reports ending before cutoff.
|
||||||
|
func DeleteOpenFlareRequestReportsBefore(ctx context.Context, cutoff time.Time) (int64, error) {
|
||||||
|
conn := db.DB(ctx)
|
||||||
|
if conn == nil {
|
||||||
|
return 0, errors.New(errDatabaseNotInitialized)
|
||||||
|
}
|
||||||
|
result := conn.Where("window_ended_at < ?", cutoff).Delete(&OpenFlareRequestReport{})
|
||||||
|
if result.Error != nil {
|
||||||
|
if isMissingTableError(result.Error) {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
return 0, result.Error
|
||||||
|
}
|
||||||
|
return result.RowsAffected, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteAllOpenFlareRequestReports deletes all request reports.
|
||||||
|
func DeleteAllOpenFlareRequestReports(ctx context.Context) (int64, error) {
|
||||||
|
conn := db.DB(ctx)
|
||||||
|
if conn == nil {
|
||||||
|
return 0, errors.New(errDatabaseNotInitialized)
|
||||||
|
}
|
||||||
|
result := conn.Where("1 = 1").Delete(&OpenFlareRequestReport{})
|
||||||
|
if result.Error != nil {
|
||||||
|
if isMissingTableError(result.Error) {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
return 0, result.Error
|
||||||
|
}
|
||||||
|
return result.RowsAffected, nil
|
||||||
|
}
|
||||||
|
|
||||||
// DeleteOpenFlareHealthEventsByNodeID deletes all health events for a node.
|
// DeleteOpenFlareHealthEventsByNodeID deletes all health events for a node.
|
||||||
func DeleteOpenFlareHealthEventsByNodeID(ctx context.Context, nodeID string) (int64, error) {
|
func DeleteOpenFlareHealthEventsByNodeID(ctx context.Context, nodeID string) (int64, error) {
|
||||||
conn := db.DB(ctx)
|
conn := db.DB(ctx)
|
||||||
|
|||||||
@@ -246,6 +246,37 @@ func UpdateOpenFlareWAFIPGroup(ctx context.Context, group *OpenFlareWAFIPGroup)
|
|||||||
}).Error
|
}).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListDueOpenFlareWAFIPGroups returns enabled automatic/subscription groups due for sync.
|
||||||
|
func ListDueOpenFlareWAFIPGroups(ctx context.Context, now time.Time) ([]*OpenFlareWAFIPGroup, error) {
|
||||||
|
conn, err := wafDB(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var groups []*OpenFlareWAFIPGroup
|
||||||
|
err = conn.Where(
|
||||||
|
"enabled = ? AND (type = ? OR (type = ? AND subscription_url <> '')) AND (next_sync_at IS NULL OR next_sync_at <= ?)",
|
||||||
|
true, "automatic", "subscription", now,
|
||||||
|
).Order("id asc").Find(&groups).Error
|
||||||
|
return groups, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateOpenFlareWAFIPGroupSyncResult persists IP group sync outcome fields.
|
||||||
|
func UpdateOpenFlareWAFIPGroupSyncResult(ctx context.Context, group *OpenFlareWAFIPGroup) error {
|
||||||
|
conn, err := wafDB(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return conn.Model(&OpenFlareWAFIPGroup{}).Where("id = ?", group.ID).Updates(map[string]any{
|
||||||
|
"ip_list": group.IPList,
|
||||||
|
"ext_ips": group.ExtIPs,
|
||||||
|
"last_synced_at": group.LastSyncedAt,
|
||||||
|
"next_sync_at": group.NextSyncAt,
|
||||||
|
"last_sync_status": group.LastSyncStatus,
|
||||||
|
"last_sync_message": group.LastSyncMessage,
|
||||||
|
"subscription_format": group.SubscriptionFormat,
|
||||||
|
}).Error
|
||||||
|
}
|
||||||
|
|
||||||
// DeleteOpenFlareWAFIPGroup removes an IP group.
|
// DeleteOpenFlareWAFIPGroup removes an IP group.
|
||||||
func DeleteOpenFlareWAFIPGroup(ctx context.Context, id uint) error {
|
func DeleteOpenFlareWAFIPGroup(ctx context.Context, id uint) error {
|
||||||
conn, err := wafDB(ctx)
|
conn, err := wafDB(ctx)
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ sidebar: false
|
|||||||
- 扩展 Relay/Flared heartbeat 载荷与可观测性持久化(frps 观测、健康事件);新增 `of_node_obs_frpc` 单表。
|
- 扩展 Relay/Flared heartbeat 载荷与可观测性持久化(frps 观测、健康事件);新增 `of_node_obs_frpc` 单表。
|
||||||
- Agent heartbeat 恢复 Geo 自动更新、访问日志地域解析与 90 天保留清理;对齐 config `support_files` 过滤规则。
|
- Agent heartbeat 恢复 Geo 自动更新、访问日志地域解析与 90 天保留清理;对齐 config `support_files` 过滤规则。
|
||||||
- 补全 OAuth 快捷路由(`/api/oauth/github`、`/api/oauth/wechat`、`/api/oauth/wechat/bind`、`/api/oauth/email/bind`)。
|
- 补全 OAuth 快捷路由(`/api/oauth/github`、`/api/oauth/wechat`、`/api/oauth/wechat/bind`、`/api/oauth/email/bind`)。
|
||||||
|
- 新增 `internal/apps/openflare/tasks/` 集中承载 OpenFlare 定时/后台任务(主进程 cron,非 Asynq),含数据库可观测性自动清理、WAF IP 组周期同步、UptimeKuma 同步、ACME 证书自动续期。
|
||||||
|
- 实装数据库可观测性手动/自动清理、WAF IP 组订阅/自动同步与测试接口、UptimeKuma 监控同步、TLS ACME 申请/续期(lego DNS-01)。
|
||||||
|
|
||||||
## [v2.3.4] - 2026-06-17
|
## [v2.3.4] - 2026-06-17
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user