From dfc480c73aff9f3002bb406d262b8b3bdf8ccb4e Mon Sep 17 00:00:00 2001 From: ryan Date: Thu, 18 Jun 2026 16:59:02 +0800 Subject: [PATCH] migrate --- Wavelet/go.mod | 56 ++- Wavelet/go.sum | 106 ++-- .../apps/openflare/agent/waf_ip_group.go | 5 + .../internal/apps/openflare/option/logics.go | 28 +- .../apps/openflare/option/logics_test.go | 32 +- .../apps/openflare/tasks/database_cleanup.go | 177 +++++++ .../openflare/tasks/database_cleanup_test.go | 150 ++++++ Wavelet/internal/apps/openflare/tasks/doc.go | 15 + .../internal/apps/openflare/tasks/registry.go | 97 ++++ .../apps/openflare/tasks/ssl_renew.go | 51 ++ .../apps/openflare/tasks/ssl_renew_test.go | 76 +++ .../apps/openflare/tasks/uptimekuma_sync.go | 53 ++ .../apps/openflare/tasks/waf_ip_group_sync.go | 15 + .../apps/openflare/tls/acme/client.go | 262 ++++++++++ .../apps/openflare/tls/acme_obtain_test.go | 155 ++++++ Wavelet/internal/apps/openflare/tls/errs.go | 2 - Wavelet/internal/apps/openflare/tls/logics.go | 56 ++- .../apps/openflare/tls/logics_test.go | 5 + Wavelet/internal/apps/openflare/tls/obtain.go | 169 +++++++ .../apps/openflare/uptimekuma/client.go | 383 ++++++++++++++ .../apps/openflare/uptimekuma/sync.go | 349 +++++++++++++ .../apps/openflare/uptimekuma/sync_test.go | 347 +++++++++++++ .../apps/openflare/waf/ip_group_sync.go | 475 ++++++++++++++++++ .../apps/openflare/waf/ip_group_sync_test.go | 208 ++++++++ Wavelet/internal/apps/openflare/waf/logics.go | 33 +- .../apps/openflare/waf/register_tasks.go | 12 + .../apps/openflare/websocket/agent_hub.go | 23 + Wavelet/internal/bootstrap/bootstrap.go | 19 +- .../internal/model/openflare_access_log.go | 21 + .../internal/model/openflare_acme_account.go | 18 + .../internal/model/openflare_observability.go | 64 +++ Wavelet/internal/model/openflare_waf.go | 31 ++ docs/changelog/index.md | 2 + 33 files changed, 3362 insertions(+), 133 deletions(-) create mode 100644 Wavelet/internal/apps/openflare/tasks/database_cleanup.go create mode 100644 Wavelet/internal/apps/openflare/tasks/database_cleanup_test.go create mode 100644 Wavelet/internal/apps/openflare/tasks/doc.go create mode 100644 Wavelet/internal/apps/openflare/tasks/registry.go create mode 100644 Wavelet/internal/apps/openflare/tasks/ssl_renew.go create mode 100644 Wavelet/internal/apps/openflare/tasks/ssl_renew_test.go create mode 100644 Wavelet/internal/apps/openflare/tasks/uptimekuma_sync.go create mode 100644 Wavelet/internal/apps/openflare/tasks/waf_ip_group_sync.go create mode 100644 Wavelet/internal/apps/openflare/tls/acme/client.go create mode 100644 Wavelet/internal/apps/openflare/tls/acme_obtain_test.go create mode 100644 Wavelet/internal/apps/openflare/tls/obtain.go create mode 100644 Wavelet/internal/apps/openflare/uptimekuma/client.go create mode 100644 Wavelet/internal/apps/openflare/uptimekuma/sync.go create mode 100644 Wavelet/internal/apps/openflare/uptimekuma/sync_test.go create mode 100644 Wavelet/internal/apps/openflare/waf/ip_group_sync.go create mode 100644 Wavelet/internal/apps/openflare/waf/ip_group_sync_test.go create mode 100644 Wavelet/internal/apps/openflare/waf/register_tasks.go diff --git a/Wavelet/go.mod b/Wavelet/go.mod index adb90b2b..8444c939 100644 --- a/Wavelet/go.mod +++ b/Wavelet/go.mod @@ -6,16 +6,18 @@ require ( github.com/ClickHouse/clickhouse-go/v2 v2.45.0 github.com/alicebob/miniredis/v2 v2.38.0 github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.5.1 - github.com/aws/aws-sdk-go-v2 v1.41.5 - github.com/aws/aws-sdk-go-v2/config v1.32.14 - github.com/aws/aws-sdk-go-v2/credentials v1.19.14 - github.com/aws/aws-sdk-go-v2/service/s3 v1.99.0 + github.com/aws/aws-sdk-go-v2 v1.41.6 + github.com/aws/aws-sdk-go-v2/config v1.32.16 + github.com/aws/aws-sdk-go-v2/credentials v1.19.15 + github.com/aws/aws-sdk-go-v2/service/s3 v1.99.1 github.com/bwmarrin/snowflake v0.3.0 github.com/coreos/go-oidc/v3 v3.17.0 github.com/deepteams/webp v1.2.3 + github.com/expr-lang/expr v1.17.8 github.com/gin-contrib/sessions v1.0.4 github.com/gin-gonic/gin v1.11.0 github.com/glebarez/sqlite v1.11.0 + github.com/go-acme/lego/v4 v4.35.2 github.com/go-jose/go-jose/v4 v4.1.4 github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 @@ -23,7 +25,7 @@ require ( github.com/maypok86/otter/v2 v2.3.0 github.com/peterbourgon/diskv/v3 v3.0.1 github.com/pressly/goose/v3 v3.27.1 - github.com/rain-kl/openflare v0.0.0-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/v9 v9.16.0 github.com/robfig/cron/v3 v3.0.1 @@ -46,7 +48,7 @@ require ( golang.org/x/crypto v0.51.0 golang.org/x/image v0.42.0 golang.org/x/mod v0.36.0 - golang.org/x/oauth2 v0.35.0 + golang.org/x/oauth2 v0.36.0 golang.org/x/sync v0.21.0 gopkg.in/natefinch/lumberjack.v2 v2.2.1 gorm.io/driver/postgres v1.6.0 @@ -63,21 +65,20 @@ require ( github.com/ClickHouse/ch-go v0.71.0 // indirect github.com/KyleBanks/depth 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/feature/ec2/imds v1.18.21 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // 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.22 // 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.13 // 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.21 // 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.15 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 // indirect - github.com/aws/smithy-go v1.24.2 // 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.22 // 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.22 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.23 // 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/checksum v1.9.14 // 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/s3shared v1.19.22 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.0.10 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.30.16 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.20 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.42.0 // indirect + github.com/aws/smithy-go v1.25.0 // indirect github.com/boj/redistore v1.4.1 // indirect github.com/bytedance/gopkg v0.1.3 // 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/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect - github.com/davecgh/go-spew v1.1.1 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dgraph-io/ristretto/v2 v2.2.0 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/dustin/go-humanize v1.0.1 // indirect @@ -112,16 +113,16 @@ require ( github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.28.0 // 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-yaml v1.18.0 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect github.com/gomodule/redigo v1.9.3 // indirect github.com/google/btree v1.0.0 // indirect github.com/gorilla/context v1.1.2 // indirect github.com/gorilla/securecookie v1.1.2 // indirect github.com/gorilla/sessions v1.4.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.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/jackc/pgpassfile v1.0.0 // 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-sqlite3 v1.14.22 // 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/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // 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/pelletier/go-toml/v2 v2.2.4 // 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/quic-go v0.55.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/sys v0.44.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 google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529 // indirect diff --git a/Wavelet/go.sum b/Wavelet/go.sum index 0613a8d2..51dc78a3 100644 --- a/Wavelet/go.sum +++ b/Wavelet/go.sum @@ -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/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro= github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= -github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY= -github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o= -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/config v1.32.14 h1:opVIRo/ZbbI8OIqSOKmpFaY7IwfFUOCCXBsUpJOwDdI= -github.com/aws/aws-sdk-go-v2/config v1.32.14/go.mod h1:U4/V0uKxh0Tl5sxmCBZ3AecYny4UNlVmObYjKuuaiOo= -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.14/go.mod h1:cJKuyWB59Mqi0jM3nFYQRmnHVQIcgoxjEMAbLkpr62w= -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.21/go.mod h1:YWNWJQNjKigKY1RHVJCuupeWDrrHjRqHm0N9rdrWzYI= -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.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c= -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.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps= -github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 h1:qYQ4pzQ2Oz6WpQ8T3HvGHnZydA72MnLuFK9tJwmrbHw= -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.22 h1:rWyie/PxDRIdhNf4DzRk0lvjVOqFJuNnO8WwaIRVxzQ= -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.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY= -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.13 h1:JRaIgADQS/U6uXDqlPiefP32yXTda7Kqfx+LgspooZM= -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.21 h1:c31//R3xgIJMSC8S6hEVq+38DcvUlgFY0FM6mSI5oto= -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.21 h1:ZlvrNcHSFFWURB8avufQq9gFsheUgjVD9536obIknfM= -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.0 h1:hlSuz394kV0vhv9drL5lhuEFbEOEP1VyQpy15qWh1Pk= -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.9 h1:QKZH0S178gCmFEgst8hN0mCX1KxLgHBKKY/CLqwP8lg= -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.15 h1:lFd1+ZSEYJZYvv9d6kXzhkZu07si3f+GQ1AaYwa2LUM= -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.19 h1:dzztQ1YmfPrxdrOiuZRMF6fuOwWlWpD2StNLTceKpys= -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.41.10 h1:p8ogvvLugcR/zLBXTXrTkj0RYBUdErbMnAFFp12Lm/U= -github.com/aws/aws-sdk-go-v2/service/sts v1.41.10/go.mod h1:60dv0eZJfeVXfbT1tFJinbHrDfSJ2GZl4Q//OSSNAVw= -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/aws/aws-sdk-go-v2 v1.41.6 h1:1AX0AthnBQzMx1vbmir3Y4WsnJgiydmnJjiLu+LvXOg= +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.9 h1:adBsCIIpLbLmYnkQU+nAChU5yhVTvu5PerROm+/Kq2A= +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.16 h1:Q0iQ7quUgJP0F/SCRTieScnaMdXr9h/2+wze1u3cNeM= +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.15 h1:fyvgWTszojq8hEnMi8PPBTvZdTtEVmAVyo+NFLHBhH4= +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.22 h1:IOGsJ1xVWhsi+ZO7/NW8OuZZBtMJLZbk4P5HDjJO0jQ= +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.22 h1:GmLa5Kw1ESqtFpXsx5MmC84QWa/ZrLZvlJGa2y+4kcQ= +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.22 h1:dY4kWZiSaXIzxnKlj17nHnBcXXBfac6UlsAx2qL6XrU= +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/v4a v1.4.23 h1:FPXsW9+gMuIeKmz7j6ENWcWtBGTe1kH8r9thNt5Uxx4= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.23/go.mod h1:7J8iGMdRKk6lw2C+cMIphgAnT8uTwBwNOsGkyOCm80U= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.8 h1:HtOTYcbVcGABLOVuPYaIihj6IlkqubBwFj10K5fxRek= +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/checksum v1.9.14 h1:xnvDEnw+pnj5mctWiYuFbigrEzSm35x7k4KS/ZkCANg= +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/presigned-url v1.13.22 h1:PUmZeJU6Y1Lbvt9WFuJ0ugUK2xn6hIWUBBbKuOWF30s= +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/s3shared v1.19.22 h1:SE+aQ4DEqG53RRCAIHlCf//B2ycxGH7jFkpnAh/kKPM= +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/s3 v1.99.1 h1:kU/eBN5+MWNo/LcbNa4hWDdN76hdcd7hocU5kvu7IsU= +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/signin v1.0.10 h1:a1Fq/KXn75wSzoJaPQTgZO0wHGqE9mjFnylnqEPTchA= +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/sso v1.30.16 h1:x6bKbmDhsgSZwv6q19wY/u3rLk/3FGjJWyqKcIRufpE= +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/ssooidc v1.35.20 h1:oK/njaL8GtyEihkWMD4k3VgHCT64RQKkZwh0DG5j8ak= +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/sts v1.42.0 h1:ks8KBcZPh3PYISr5dAiXCM5/Thcuxk8l+PG4+A0exds= +github.com/aws/aws-sdk-go-v2/service/sts v1.42.0/go.mod h1:pFw33T0WLvXU3rw1WBkpMlkgIn54eCB5FYLhjDc9Foo= +github.com/aws/smithy-go v1.25.0 h1:Sz/XJ64rwuiKtB6j98nDIPyYrV1nVNJ4YU74gttcl5U= +github.com/aws/smithy-go v1.25.0/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/boj/redistore v1.4.1 h1:lP9ZZWqKMq2RIqexlZX1w1ODSnegL+puxGIujkU5tIw= github.com/boj/redistore v1.4.1/go.mod h1:c0Tvw6aMjslog4jHIAcNv6EtJM849YoOAhMY7JBbWpI= 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/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.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.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/go.mod h1:J8Ap+HAixxpKKRN9IpEeSKlfvhsef1v43jKTO7m3f4c= 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/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/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/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= 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/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= +github.com/go-acme/lego/v4 v4.35.2 h1:uVQg+KC/yj9R2g7Q9W5wDqhvQvxV5SMu5eqFVoN5xZU= +github.com/go-acme/lego/v4 v4.35.2/go.mod h1:pX2jN5n8OphMGY1IaMjYm5DAEzguBaKRt8AvJAgJXpc= github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw= github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw= github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg= @@ -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-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-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= -github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +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/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= -github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= -github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +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/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= 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/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/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4= -github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hashicorp/go-version v1.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaXPSCnA= +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/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= 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/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= 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-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= 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/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY= github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/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/go.mod h1:maruOxsPnIG2yHHyo8UqKWXYKFcH7Q76csUV7+7KYoM= 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.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= -golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= -golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +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-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 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.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= -golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= -golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +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-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= diff --git a/Wavelet/internal/apps/openflare/agent/waf_ip_group.go b/Wavelet/internal/apps/openflare/agent/waf_ip_group.go index cc80fa11..11e4f174 100644 --- a/Wavelet/internal/apps/openflare/agent/waf_ip_group.go +++ b/Wavelet/internal/apps/openflare/agent/waf_ip_group.go @@ -29,6 +29,11 @@ type activeConfigSnapshot struct { 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. func ChangedWAFIPGroupsForAgent(ctx context.Context, ids []uint, checksums map[string]string) ([]WAFIPGroup, error) { targetIDs := uniqueUintIDs(ids) diff --git a/Wavelet/internal/apps/openflare/option/logics.go b/Wavelet/internal/apps/openflare/option/logics.go index 5cff9e82..2d4604dc 100644 --- a/Wavelet/internal/apps/openflare/option/logics.go +++ b/Wavelet/internal/apps/openflare/option/logics.go @@ -11,6 +11,8 @@ import ( "sync" "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/model" ) @@ -214,27 +216,31 @@ func lookupGeoIP(_ context.Context, provider, rawIP string) (*geoIPLookupView, e }, nil } -func cleanupDatabaseObservability(_ context.Context, input databaseCleanupInput) (*databaseCleanupResult, error) { +func cleanupDatabaseObservability(ctx context.Context, input databaseCleanupInput) (*databaseCleanupResult, error) { target := strings.TrimSpace(input.Target) if target == "" { return nil, errors.New(errInvalidParams) } - if input.RetentionDays != nil && *input.RetentionDays <= 0 { - return nil, errors.New("retention_days 必须为大于 0 的整数") + + result, err := oftasks.CleanupDatabaseObservability(ctx, oftasks.DatabaseCleanupInput{ + Target: target, + RetentionDays: input.RetentionDays, + }) + if err != nil { + return nil, err } return &databaseCleanupResult{ - Target: target, - TargetLabel: target, - DeletedCount: 0, - DeleteAll: input.RetentionDays == nil, - RetentionDays: input.RetentionDays, + Target: result.Target, + TargetLabel: result.TargetLabel, + DeletedCount: result.DeletedCount, + DeleteAll: result.DeleteAll, + RetentionDays: result.RetentionDays, }, nil } -func syncUptimeKuma(_ context.Context) error { - // Stub: full Uptime Kuma sync is implemented in T-MISC. - return nil +func syncUptimeKuma(ctx context.Context) error { + return uptimekuma.SyncToUptimeKuma(ctx) } func isSecretOptionKey(key string) bool { diff --git a/Wavelet/internal/apps/openflare/option/logics_test.go b/Wavelet/internal/apps/openflare/option/logics_test.go index f0602ed7..b749f9a8 100644 --- a/Wavelet/internal/apps/openflare/option/logics_test.go +++ b/Wavelet/internal/apps/openflare/option/logics_test.go @@ -6,6 +6,7 @@ package option import ( "context" "testing" + "time" "github.com/Rain-kl/Wavelet/internal/db" "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) } -func TestCleanupDatabaseObservabilityStub(t *testing.T) { +func TestCleanupDatabaseObservabilityDeletesRows(t *testing.T) { cleanup := setupOptionTestDB(t) defer cleanup() 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 result, err := cleanupDatabaseObservability(ctx, databaseCleanupInput{ Target: "node_access_logs", @@ -112,8 +134,14 @@ func TestCleanupDatabaseObservabilityStub(t *testing.T) { }) require.NoError(t, err) 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) require.NotNil(t, 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) } diff --git a/Wavelet/internal/apps/openflare/tasks/database_cleanup.go b/Wavelet/internal/apps/openflare/tasks/database_cleanup.go new file mode 100644 index 00000000..94488419 --- /dev/null +++ b/Wavelet/internal/apps/openflare/tasks/database_cleanup.go @@ -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") + } +} diff --git a/Wavelet/internal/apps/openflare/tasks/database_cleanup_test.go b/Wavelet/internal/apps/openflare/tasks/database_cleanup_test.go new file mode 100644 index 00000000..fc4d008d --- /dev/null +++ b/Wavelet/internal/apps/openflare/tasks/database_cleanup_test.go @@ -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) +} diff --git a/Wavelet/internal/apps/openflare/tasks/doc.go b/Wavelet/internal/apps/openflare/tasks/doc.go new file mode 100644 index 00000000..7d97782c --- /dev/null +++ b/Wavelet/internal/apps/openflare/tasks/doc.go @@ -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 diff --git a/Wavelet/internal/apps/openflare/tasks/registry.go b/Wavelet/internal/apps/openflare/tasks/registry.go new file mode 100644 index 00000000..76c821df --- /dev/null +++ b/Wavelet/internal/apps/openflare/tasks/registry.go @@ -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 +} diff --git a/Wavelet/internal/apps/openflare/tasks/ssl_renew.go b/Wavelet/internal/apps/openflare/tasks/ssl_renew.go new file mode 100644 index 00000000..37b1ee96 --- /dev/null +++ b/Wavelet/internal/apps/openflare/tasks/ssl_renew.go @@ -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 +} diff --git a/Wavelet/internal/apps/openflare/tasks/ssl_renew_test.go b/Wavelet/internal/apps/openflare/tasks/ssl_renew_test.go new file mode 100644 index 00000000..c59261d6 --- /dev/null +++ b/Wavelet/internal/apps/openflare/tasks/ssl_renew_test.go @@ -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) +} diff --git a/Wavelet/internal/apps/openflare/tasks/uptimekuma_sync.go b/Wavelet/internal/apps/openflare/tasks/uptimekuma_sync.go new file mode 100644 index 00000000..045c64d3 --- /dev/null +++ b/Wavelet/internal/apps/openflare/tasks/uptimekuma_sync.go @@ -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") +} diff --git a/Wavelet/internal/apps/openflare/tasks/waf_ip_group_sync.go b/Wavelet/internal/apps/openflare/tasks/waf_ip_group_sync.go new file mode 100644 index 00000000..a430e340 --- /dev/null +++ b/Wavelet/internal/apps/openflare/tasks/waf_ip_group_sync.go @@ -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) + } + }) +} diff --git a/Wavelet/internal/apps/openflare/tls/acme/client.go b/Wavelet/internal/apps/openflare/tls/acme/client.go new file mode 100644 index 00000000..c3c06eeb --- /dev/null +++ b/Wavelet/internal/apps/openflare/tls/acme/client.go @@ -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 +} diff --git a/Wavelet/internal/apps/openflare/tls/acme_obtain_test.go b/Wavelet/internal/apps/openflare/tls/acme_obtain_test.go new file mode 100644 index 00000000..53d59b23 --- /dev/null +++ b/Wavelet/internal/apps/openflare/tls/acme_obtain_test.go @@ -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") +} diff --git a/Wavelet/internal/apps/openflare/tls/errs.go b/Wavelet/internal/apps/openflare/tls/errs.go index 37ac4304..d90d93b4 100644 --- a/Wavelet/internal/apps/openflare/tls/errs.go +++ b/Wavelet/internal/apps/openflare/tls/errs.go @@ -23,6 +23,4 @@ const ( errManagedDomainCertNotFound = "所选证书不存在" errDNSAccountInUse = "该 DNS 账号已被证书使用,无法删除" - - errACMENotImplemented = "ACME certificate obtain is not implemented yet" ) diff --git a/Wavelet/internal/apps/openflare/tls/logics.go b/Wavelet/internal/apps/openflare/tls/logics.go index ea8209b9..7c32c2ff 100644 --- a/Wavelet/internal/apps/openflare/tls/logics.go +++ b/Wavelet/internal/apps/openflare/tls/logics.go @@ -176,7 +176,7 @@ func DeleteCertificate(ctx context.Context, id uint) error { return model.DeleteTLSCertificateRecord(ctx, id) } -// ApplyCertificate 申请 ACME 证书(当前为占位实现)。 +// ApplyCertificate 申请 ACME 证书。 func ApplyCertificate(ctx context.Context, input ApplyInput) (*model.TLSCertificate, error) { cert := &model.TLSCertificate{ Provider: "acme", @@ -193,10 +193,15 @@ func ApplyCertificate(ctx context.Context, input ApplyInput) (*model.TLSCertific } 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) { cert, err := model.GetTLSCertificateByID(ctx, id) if err != nil { @@ -215,10 +220,15 @@ func UpdateACMECertificate(ctx context.Context, id uint, input ApplyInput) (*mod } 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) { cert, err := model.GetTLSCertificateByID(ctx, id) if err != nil { @@ -241,10 +251,25 @@ func ConvertCertificateToACME(ctx context.Context, id uint, input ApplyInput) (* } 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) { cert, err := model.GetTLSCertificateByID(ctx, id) if err != nil { @@ -253,12 +278,17 @@ func RenewCertificate(ctx context.Context, id uint) (*model.TLSCertificate, erro if cert.Provider != "acme" { return nil, errors.New(errCertificateOnlyACMERenew) } + + go func(c *model.TLSCertificate) { + _ = obtainTLSCertificate(context.Background(), c) + }(cert) + cert.ApplyStatus = "applying" cert.ApplyMessage = "" if err := model.SaveTLSCertificate(ctx, cert); err != nil { return nil, err } - return markACMEStubFailure(ctx, cert) + return sanitizeCertificateForResponse(cert), nil } // ListDNSAccounts 列出 DNS 账号。 @@ -386,19 +416,9 @@ func fillAcmeCertificateFields(cert *model.TLSCertificate, input ApplyInput) { cert.SkipDNS = input.SkipDNS cert.DNS1 = strings.TrimSpace(input.DNS1) cert.DNS2 = strings.TrimSpace(input.DNS2) - cert.Provider = "acme" 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 { routes, err := model.ListTLSProxyRouteRefs(ctx) if err != nil { diff --git a/Wavelet/internal/apps/openflare/tls/logics_test.go b/Wavelet/internal/apps/openflare/tls/logics_test.go index 9e0f1d26..00909c08 100644 --- a/Wavelet/internal/apps/openflare/tls/logics_test.go +++ b/Wavelet/internal/apps/openflare/tls/logics_test.go @@ -12,6 +12,7 @@ import ( "encoding/pem" "math/big" "strings" + "sync" "testing" "time" @@ -24,8 +25,11 @@ import ( "gorm.io/gorm" ) +var tlsTestDBMu sync.Mutex + func setupTLSTestDB(t *testing.T) func() { t.Helper() + tlsTestDBMu.Lock() sqliteDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{ DisableForeignKeyConstraintWhenMigrating: true, @@ -44,6 +48,7 @@ func setupTLSTestDB(t *testing.T) func() { return func() { db.SetDB(nil) config.Config.App.SessionSecret = oldSecret + tlsTestDBMu.Unlock() } } diff --git a/Wavelet/internal/apps/openflare/tls/obtain.go b/Wavelet/internal/apps/openflare/tls/obtain.go new file mode 100644 index 00000000..223845f9 --- /dev/null +++ b/Wavelet/internal/apps/openflare/tls/obtain.go @@ -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 +} diff --git a/Wavelet/internal/apps/openflare/uptimekuma/client.go b/Wavelet/internal/apps/openflare/uptimekuma/client.go new file mode 100644 index 00000000..199fc9fa --- /dev/null +++ b/Wavelet/internal/apps/openflare/uptimekuma/client.go @@ -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 +} diff --git a/Wavelet/internal/apps/openflare/uptimekuma/sync.go b/Wavelet/internal/apps/openflare/uptimekuma/sync.go new file mode 100644 index 00000000..a8310386 --- /dev/null +++ b/Wavelet/internal/apps/openflare/uptimekuma/sync.go @@ -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 +} diff --git a/Wavelet/internal/apps/openflare/uptimekuma/sync_test.go b/Wavelet/internal/apps/openflare/uptimekuma/sync_test.go new file mode 100644 index 00000000..3bbabaa3 --- /dev/null +++ b/Wavelet/internal/apps/openflare/uptimekuma/sync_test.go @@ -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)") +} diff --git a/Wavelet/internal/apps/openflare/waf/ip_group_sync.go b/Wavelet/internal/apps/openflare/waf/ip_group_sync.go new file mode 100644 index 00000000..e46d72da --- /dev/null +++ b/Wavelet/internal/apps/openflare/waf/ip_group_sync.go @@ -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) +} diff --git a/Wavelet/internal/apps/openflare/waf/ip_group_sync_test.go b/Wavelet/internal/apps/openflare/waf/ip_group_sync_test.go new file mode 100644 index 00000000..c119ad95 --- /dev/null +++ b/Wavelet/internal/apps/openflare/waf/ip_group_sync_test.go @@ -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) + } +} diff --git a/Wavelet/internal/apps/openflare/waf/logics.go b/Wavelet/internal/apps/openflare/waf/logics.go index 6458887d..7f3ea1ab 100644 --- a/Wavelet/internal/apps/openflare/waf/logics.go +++ b/Wavelet/internal/apps/openflare/waf/logics.go @@ -18,6 +18,8 @@ import ( "unicode" "github.com/Rain-kl/Wavelet/internal/model" + + exprlang "github.com/expr-lang/expr" "gorm.io/gorm" ) @@ -486,39 +488,29 @@ func DeleteIPGroup(ctx context.Context, id uint) error { 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) { group, err := model.GetOpenFlareWAFIPGroupByID(ctx, id) if err != nil { return nil, err } - now := 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 + return syncOpenFlareWAFIPGroup(ctx, group, time.Now().UTC()) } -// 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) { - _ = ctx config, err := parseIPGroupAutoConfig(input.AutoConfig) if err != nil { return nil, err } now := time.Now().UTC() + ips, err := evaluateParsedIPGroupAutoConfig(ctx, config, now) + if err != nil { + return nil, err + } return &IPGroupAutoTestResult{ - MatchedIPs: []string{}, - MatchedCount: 0, + MatchedIPs: ips, + MatchedCount: len(ips), LookbackMinutes: config.LookbackMinutes, RuleCount: len(config.Rules), TestedAt: now.Format(time.RFC3339), @@ -1104,6 +1096,9 @@ func parseIPGroupAutoConfig(raw json.RawMessage) (ipGroupAutoConfig, error) { if rule.Expr == "" { 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 } return config, nil diff --git a/Wavelet/internal/apps/openflare/waf/register_tasks.go b/Wavelet/internal/apps/openflare/waf/register_tasks.go new file mode 100644 index 00000000..a396a110 --- /dev/null +++ b/Wavelet/internal/apps/openflare/waf/register_tasks.go @@ -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) +} diff --git a/Wavelet/internal/apps/openflare/websocket/agent_hub.go b/Wavelet/internal/apps/openflare/websocket/agent_hub.go index 8140c9e0..c2d73632 100644 --- a/Wavelet/internal/apps/openflare/websocket/agent_hub.go +++ b/Wavelet/internal/apps/openflare/websocket/agent_hub.go @@ -18,6 +18,7 @@ const ( AgentWSConnectedLastSeenValue = "__OPENFLARE_AGENT_WS_CONNECTED__" agentMessageTypeForceSyncConfig = "force_sync_config" + agentMessageTypeWAFIPGroups = "waf_ip_groups" ) 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. func SendForceSyncConfig(nodeID string, payload any) bool { defaultAgentHub.mu.RLock() diff --git a/Wavelet/internal/bootstrap/bootstrap.go b/Wavelet/internal/bootstrap/bootstrap.go index dbfc094a..12aba587 100644 --- a/Wavelet/internal/bootstrap/bootstrap.go +++ b/Wavelet/internal/bootstrap/bootstrap.go @@ -11,6 +11,8 @@ import ( admin_push "github.com/Rain-kl/Wavelet/internal/apps/admin/push" "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" taskhandlers "github.com/Rain-kl/Wavelet/internal/task/handlers" "github.com/Rain-kl/Wavelet/pkg/logger" @@ -23,10 +25,11 @@ type Options struct { } var ( - registerTasksOnce sync.Once - registerPushDomainEventsOnce sync.Once - registerTaskListenersOnce sync.Once - initRuntimeOnce sync.Once + registerTasksOnce sync.Once + registerPushDomainEventsOnce sync.Once + registerTaskListenersOnce sync.Once + registerOpenFlareBackgroundTasksOnce sync.Once + initRuntimeOnce sync.Once ) // 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. func RegisterAPI() { RegisterTasks() RegisterPushDomainEvents() + RegisterOpenFlareBackgroundTasks() } // RegisterWorker wires integrations required by the task worker process. @@ -72,6 +81,7 @@ func RegisterAll() { RegisterTasks() RegisterPushDomainEvents() RegisterTaskListeners() + RegisterOpenFlareBackgroundTasks() } // Init runs shared runtime bootstrap exactly once per process. @@ -83,6 +93,7 @@ func Init(ctx context.Context, opts Options) { } if opts.API { risk_control.InitLogWriter(ctx) + oftasks.Start(ctx) } }) } diff --git a/Wavelet/internal/model/openflare_access_log.go b/Wavelet/internal/model/openflare_access_log.go index abcd8af2..cc719c08 100644 --- a/Wavelet/internal/model/openflare_access_log.go +++ b/Wavelet/internal/model/openflare_access_log.go @@ -51,6 +51,11 @@ type openFlareAccessLogIPTrendRow struct { 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. func ListOpenFlareAccessLogs(ctx context.Context, query OpenFlareAccessLogQuery) ([]*OpenFlareAccessLog, error) { conn := db.DB(ctx) @@ -236,6 +241,22 @@ ORDER BY bucket_epoch ASC`, bucketExpr, openFlareAccessLogTable, queryClause) 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. func DeleteOpenFlareAccessLogsBefore(ctx context.Context, cutoff time.Time) (int64, error) { conn := db.DB(ctx) diff --git a/Wavelet/internal/model/openflare_acme_account.go b/Wavelet/internal/model/openflare_acme_account.go index c29bc9f0..42d5a217 100644 --- a/Wavelet/internal/model/openflare_acme_account.go +++ b/Wavelet/internal/model/openflare_acme_account.go @@ -40,6 +40,24 @@ func GetAcmeAccountByID(ctx context.Context, id uint) (*AcmeAccount, error) { 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 账号,不存在时创建占位记录。 func GetDefaultAcmeAccount(ctx context.Context) (*AcmeAccount, error) { conn := db.DB(ctx) diff --git a/Wavelet/internal/model/openflare_observability.go b/Wavelet/internal/model/openflare_observability.go index c49963a1..8611b5fb 100644 --- a/Wavelet/internal/model/openflare_observability.go +++ b/Wavelet/internal/model/openflare_observability.go @@ -375,6 +375,70 @@ func ListOpenFlareHealthEvents(ctx context.Context, nodeID string, activeOnly bo 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. func DeleteOpenFlareHealthEventsByNodeID(ctx context.Context, nodeID string) (int64, error) { conn := db.DB(ctx) diff --git a/Wavelet/internal/model/openflare_waf.go b/Wavelet/internal/model/openflare_waf.go index 3d0dd3f1..127ecc45 100644 --- a/Wavelet/internal/model/openflare_waf.go +++ b/Wavelet/internal/model/openflare_waf.go @@ -246,6 +246,37 @@ func UpdateOpenFlareWAFIPGroup(ctx context.Context, group *OpenFlareWAFIPGroup) }).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. func DeleteOpenFlareWAFIPGroup(ctx context.Context, id uint) error { conn, err := wafDB(ctx) diff --git a/docs/changelog/index.md b/docs/changelog/index.md index ed7aba8a..1ea45c20 100644 --- a/docs/changelog/index.md +++ b/docs/changelog/index.md @@ -34,6 +34,8 @@ sidebar: false - 扩展 Relay/Flared heartbeat 载荷与可观测性持久化(frps 观测、健康事件);新增 `of_node_obs_frpc` 单表。 - Agent heartbeat 恢复 Geo 自动更新、访问日志地域解析与 90 天保留清理;对齐 config `support_files` 过滤规则。 - 补全 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