Compare commits

...

3 Commits

Author SHA1 Message Date
ryan 3ec607106d [新增] 对接 Uptime Kuma 2026-06-03 12:17:46 +08:00
ryan f671a96d8c [新增] 对接 Uptime Kuma 2026-06-03 11:58:00 +08:00
ryan fe5cf9021f [优化] 更新Dockerfile,优化构建过程 2026-06-02 23:49:17 +08:00
22 changed files with 1978 additions and 67 deletions
+8
View File
@@ -1,5 +1,6 @@
.git
.idea
.github
anubis-source
**/node_modules
**/.next
@@ -9,3 +10,10 @@ anubis-source
**/coverage
**/*.db
**/*.log
tmp
logs
.DS_Store
.env
.env.*
docker-compose*.yml
+3 -1
View File
@@ -49,7 +49,9 @@ Frontend:
各组件和模块(Server、Agent、Frontend)的物理目录分层职责详见 [仓库结构](../design/repository.md)。在此结构下,开发必须遵守以下核心分层规则:
* **Server 开发规则**:禁止在 `controller/` 堆积业务逻辑,禁止在 `middleware/` 实现业务流程,禁止为简单需求新增平台层抽象。
* **Server 开发规则**:
* 禁止在 `controller/` 堆积业务逻辑,禁止在 `middleware/` 实现业务流程,禁止为简单需求新增平台层抽象。
* **定时任务开发规则**:禁止将不同业务模块(如 Uptime Kuma 整合、WAF IP 同步等)的定时任务具体执行逻辑与状态堆积在单个 `cron.go` 文件中。各模块对应的定时任务结构体和运行逻辑必须在独立的 Go 文件中定义,`cron.go` 只允许承担统一注册、初始化与调度器启停的职责。
* **Agent 开发规则**:每个模块职责单一,外部命令调用集中封装,状态落盘与配置落盘分离。
* **Frontend 开发规则**:页面文件只负责获取路由参数、组织页面结构、调用 feature 组件;不应手写复杂 API 细节、复杂表单校验逻辑或维护大量彼此耦合的局部状态。
+11
View File
@@ -95,6 +95,17 @@ go run . --port 3000 --log-dir ./logs
| `GlobalApiRateLimitNum` / `GlobalApiRateLimitDuration` | 全局 API 限流次数 / 时间窗口 | `300` / `180` |
| `GlobalWebRateLimitNum` / `GlobalWebRateLimitDuration` | 全局 Web 限流次数 / 时间窗口 | `300` / `180` |
| `CriticalRateLimitNum` / `CriticalRateLimitDuration` | 敏感接口限流次数 / 时间窗口 | `100` / `1200` |
| `UptimeKumaEnabled` | 是否启用 Uptime Kuma 自动同步 | `false` |
| `UptimeKumaUrl` | Uptime Kuma 实例地址 | 空 |
| `UptimeKumaUsername` | Uptime Kuma 登录用户名 | 空 |
| `UptimeKumaPassword` | Uptime Kuma 登录密码(写专,接口不回显) | 空 |
| `UptimeKumaMonitorScope` | 监控范围,支持 `all` (全部站点) 或 `selected` (选择站点) | `all` |
| `UptimeKumaSelectedSites` | 已选择监控站点的名称列表(英文逗号分隔) | 空 |
| `UptimeKumaSyncInterval` | 自动差分同步间隔(分钟) | `5` |
| `UptimeKumaInterval` | 监控心跳检测频率(秒) | `60` |
| `UptimeKumaRetry` | 监控最大重试次数 | `0` |
| `UptimeKumaRetryInterval` | 监控重试间隔时间(秒) | `60` |
| `UptimeKumaTimeout` | 监控请求超时断开时间(秒) | `48` |
说明:
+12 -2
View File
@@ -1,3 +1,4 @@
# syntax=docker/dockerfile:1.7
ARG VERSION=dev
FROM golang:1.25-alpine AS builder
@@ -10,12 +11,20 @@ ENV CGO_ENABLED=0 \
GOOS=${TARGETOS} \
GOARCH=${TARGETARCH}
WORKDIR /build
COPY openflare_server/go.mod openflare_server/go.sum ./openflare_server/
COPY openflare_agent/go.mod openflare_agent/go.sum ./openflare_agent/
WORKDIR /build/openflare_agent
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
WORKDIR /build
COPY openflare_server ./openflare_server
COPY openflare_agent ./openflare_agent
WORKDIR /build/openflare_agent
RUN go mod download
RUN go build -trimpath -ldflags "-s -w -X 'openflare-agent/internal/config.Version=$VERSION'" -o /build/openflare-agent ./cmd/agent
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
go build -trimpath -ldflags "-s -w -X 'openflare-agent/internal/config.Version=$VERSION'" -o /build/openflare-agent ./cmd/agent
FROM openresty/openresty:alpine
@@ -32,3 +41,4 @@ COPY --from=builder /build/openflare-agent /usr/local/bin/openflare-agent
EXPOSE 80 443 18081
ENTRYPOINT ["/usr/local/bin/openflare-agent"]
CMD ["-config", "/etc/openflare/agent.json"]
+11 -3
View File
@@ -1,3 +1,4 @@
# syntax=docker/dockerfile:1.7
ARG VERSION=dev
FROM golang:1.25-alpine AS builder
@@ -5,13 +6,19 @@ FROM golang:1.25-alpine AS builder
ARG VERSION
WORKDIR /build
COPY openflare_server/go.mod openflare_server/go.sum /openflare_server/
COPY openflare_relay/go.mod openflare_relay/go.sum /openflare_relay/
WORKDIR /openflare_relay
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
COPY openflare_relay/go.mod openflare_relay/go.sum ./
WORKDIR /build
COPY openflare_server /openflare_server
COPY openflare_relay /openflare_relay
WORKDIR /openflare_relay
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags "-s -w -X 'openflare-relay/internal/config.Version=$VERSION'" -o openflare-relay ./cmd/relay
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags "-s -w -X 'openflare-relay/internal/config.Version=$VERSION'" -o openflare-relay ./cmd/relay
# Final runtime image
FROM fatedier/frps:v0.69.0
@@ -25,3 +32,4 @@ ENV OPENFLARE_FRPS_PATH=/usr/bin/frps
ENV OPENFLARE_DATA_DIR=/var/lib/openflare-relay
ENTRYPOINT ["/usr/local/bin/openflare-relay"]
+11 -5
View File
@@ -1,10 +1,16 @@
.git
.github
node_modules
web/node_modules
web/.next
web/build
web/out
upload
dist
tmp
logs
.git
.github
upload
*.log
.DS_Store
.env
.env.*
docker-compose*.yml
+21 -21
View File
@@ -1,38 +1,38 @@
# syntax=docker/dockerfile:1.7
ARG VERSION=dev
FROM node:20 AS builder
FROM node:20 AS web-builder
ARG VERSION
WORKDIR /build
COPY ./web/package.json ./
COPY ./web/pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile
RUN corepack enable
COPY ./web/package.json ./web/pnpm-lock.yaml ./
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
pnpm install --frozen-lockfile
COPY ./web ./
RUN NEXT_PUBLIC_APP_VERSION="$VERSION" pnpm build
FROM golang:1.25 AS builder2
RUN --mount=type=cache,id=next-cache,target=/build/.next/cache \
NEXT_PUBLIC_APP_VERSION="$VERSION" pnpm build
FROM golang:1.25 AS go-builder
ARG VERSION
ENV GO111MODULE=on \
CGO_ENABLED=0 \
GOOS=linux
WORKDIR /build
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
COPY . .
COPY --from=builder /build/build ./web/build
RUN go mod download
RUN go build -trimpath -ldflags "-s -w -X 'openflare/common.Version=$VERSION'" -o openflare
COPY --from=web-builder /build/build ./web/build
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
go build -trimpath \
-ldflags "-s -w -X 'openflare/common.Version=$VERSION'" \
-o openflare
FROM alpine
RUN apk update \
&& apk upgrade \
&& apk add --no-cache ca-certificates tzdata \
FROM alpine:latest
RUN apk add --no-cache ca-certificates tzdata \
&& update-ca-certificates 2>/dev/null || true
ENV PORT=3000
COPY --from=builder2 /build/openflare /
COPY --from=go-builder /build/openflare /openflare
EXPOSE 3000
WORKDIR /data
ENTRYPOINT ["/openflare"]
+13
View File
@@ -57,6 +57,19 @@ var GeoIPProvider = "ipinfo"
var DatabaseAutoCleanupEnabled = false
var DatabaseAutoCleanupRetentionDays = 30
// Uptime Kuma integration settings
var UptimeKumaEnabled = false
var UptimeKumaUrl = ""
var UptimeKumaUsername = ""
var UptimeKumaPassword = ""
var UptimeKumaMonitorScope = "all" // "all" or "selected"
var UptimeKumaSelectedSites = "" // Comma-separated list of site names
var UptimeKumaSyncInterval = 5 // minutes
var UptimeKumaInterval = 60 // seconds
var UptimeKumaRetry = 0
var UptimeKumaRetryInterval = 60 // seconds
var UptimeKumaTimeout = 48 // seconds
// V5 OpenResty performance settings (hot-reloadable via Option table)
var OpenRestyDefaultServerReturnStatus = 421
+58 -32
View File
@@ -100,6 +100,56 @@ func validateAgentOption(key string, value string) error {
}
}
func validateUptimeKumaOption(key string, value string, state map[string]string) error {
trimmed := strings.TrimSpace(value)
switch key {
case "UptimeKumaEnabled":
if err := validateBooleanOption(key, trimmed); err != nil {
return err
}
if trimmed == "true" {
url := strings.TrimSpace(state["UptimeKumaUrl"])
username := strings.TrimSpace(state["UptimeKumaUsername"])
password := strings.TrimSpace(state["UptimeKumaPassword"])
if url == "" {
return fmt.Errorf("启用 Uptime Kuma 时地址不能为空")
}
if username == "" {
return fmt.Errorf("启用 Uptime Kuma 时用户名不能为空")
}
if password == "" && common.UptimeKumaPassword == "" {
return fmt.Errorf("启用 Uptime Kuma 时密码不能为空")
}
}
case "UptimeKumaUsername":
if trimmed == "" && state["UptimeKumaEnabled"] == "true" {
return fmt.Errorf("启用 Uptime Kuma 时用户名不能为空")
}
case "UptimeKumaPassword":
// No specific format checks needed
case "UptimeKumaUrl":
if trimmed != "" {
if !strings.HasPrefix(trimmed, "http://") && !strings.HasPrefix(trimmed, "https://") {
return fmt.Errorf("Uptime Kuma 地址必须以 http:// 或 https:// 开头")
}
}
case "UptimeKumaMonitorScope":
if trimmed != "all" && trimmed != "selected" {
return fmt.Errorf("监控范围必须为全部站点 (all) 或选择站点 (selected)")
}
case "UptimeKumaSyncInterval", "UptimeKumaInterval", "UptimeKumaRetryInterval", "UptimeKumaTimeout":
if err := validatePositiveIntegerOption(key, trimmed); err != nil {
return err
}
case "UptimeKumaRetry":
intValue, err := strconv.Atoi(trimmed)
if err != nil || intValue < 0 {
return fmt.Errorf("%s 必须为大于等于 0 的整数", key)
}
}
return nil
}
func validateOpenRestyOption(key string, value string) error {
trimmed := strings.TrimSpace(value)
@@ -263,6 +313,9 @@ func validateOptionWithState(option model.Option, state map[string]string) error
if err := validateAgentOption(option.Key, option.Value); err != nil {
return err
}
if err := validateUptimeKumaOption(option.Key, option.Value, state); err != nil {
return err
}
return nil
}
@@ -292,9 +345,9 @@ func updateOptions(options []model.Option) error {
// @Router /api/option/ [get]
func GetOptions(c *gin.Context) {
var options []*model.Option
common.OptionMapRWMutex.Lock()
common.OptionMapRWMutex.RLock()
for k, v := range common.OptionMap {
if strings.Contains(k, "Token") || strings.Contains(k, "Secret") {
if strings.Contains(k, "Token") || strings.Contains(k, "Secret") || strings.Contains(k, "Password") {
continue
}
options = append(options, &model.Option{
@@ -302,7 +355,7 @@ func GetOptions(c *gin.Context) {
Value: utils.Interface2String(v),
})
}
common.OptionMapRWMutex.Unlock()
common.OptionMapRWMutex.RUnlock()
respondSuccess(c, options)
}
@@ -320,35 +373,8 @@ func UpdateOption(c *gin.Context) {
if !bindJSON(c, &option) {
return
}
switch option.Key {
case "GitHubOAuthEnabled":
if option.Value == "true" && common.GitHubClientId == "" {
respondFailure(c, "无法启用 GitHub OAuth,请先填入 GitHub Client ID 以及 GitHub Client Secret!")
return
}
case "WeChatAuthEnabled":
if option.Value == "true" && common.WeChatServerAddress == "" {
respondFailure(c, "无法启用微信登录,请先填入微信登录相关配置信息!")
return
}
}
if err := validateRateLimitOption(option.Key, option.Value); err != nil {
respondFailure(c, err.Error())
return
}
if err := validateOpenRestyOption(option.Key, option.Value); err != nil {
respondFailure(c, err.Error())
return
}
if err := validateGeoIPOption(option.Key, option.Value); err != nil {
respondFailure(c, err.Error())
return
}
if err := validateDatabaseCleanupOption(option.Key, option.Value); err != nil {
respondFailure(c, err.Error())
return
}
if err := validateAgentOption(option.Key, option.Value); err != nil {
state := buildOptionValidationState([]model.Option{option})
if err := validateOptionWithState(option, state); err != nil {
respondFailure(c, err.Error())
return
}
+55 -1
View File
@@ -1,6 +1,8 @@
package controller
import "testing"
import (
"testing"
)
func TestValidateOpenRestyOption(t *testing.T) {
testCases := []struct {
@@ -63,3 +65,55 @@ func TestValidateAgentOption(t *testing.T) {
t.Fatal("expected websocket upgrade option to reject non-boolean value")
}
}
func TestValidateUptimeKumaOption(t *testing.T) {
state := map[string]string{
"UptimeKumaUrl": "http://localhost:3001",
"UptimeKumaUsername": "admin",
"UptimeKumaPassword": "password",
}
testCases := []struct {
name string
key string
value string
wantErr bool
}{
{name: "enabled true", key: "UptimeKumaEnabled", value: "true"},
{name: "enabled false", key: "UptimeKumaEnabled", value: "false"},
{name: "enabled invalid", key: "UptimeKumaEnabled", value: "on", wantErr: true},
{name: "url http valid", key: "UptimeKumaUrl", value: "http://192.168.1.100:3001"},
{name: "url https valid", key: "UptimeKumaUrl", value: "https://kuma.example.com"},
{name: "url invalid", key: "UptimeKumaUrl", value: "kuma.example.com", wantErr: true},
{name: "scope all", key: "UptimeKumaMonitorScope", value: "all"},
{name: "scope selected", key: "UptimeKumaMonitorScope", value: "selected"},
{name: "scope invalid", key: "UptimeKumaMonitorScope", value: "none", wantErr: true},
{name: "sync interval valid", key: "UptimeKumaSyncInterval", value: "5"},
{name: "sync interval invalid", key: "UptimeKumaSyncInterval", value: "0", wantErr: true},
{name: "interval valid", key: "UptimeKumaInterval", value: "60"},
{name: "interval invalid", key: "UptimeKumaInterval", value: "-60", wantErr: true},
{name: "retry valid", key: "UptimeKumaRetry", value: "0"},
{name: "retry positive valid", key: "UptimeKumaRetry", value: "3"},
{name: "retry invalid", key: "UptimeKumaRetry", value: "-1", wantErr: true},
}
for _, tc := range testCases {
err := validateUptimeKumaOption(tc.key, tc.value, state)
if tc.wantErr && err == nil {
t.Fatalf("%s: expected error", tc.name)
}
if !tc.wantErr && err != nil {
t.Fatalf("%s: unexpected error: %v", tc.name, err)
}
}
// Test enabling Uptime Kuma when URL or credentials are empty in state
stateEmpty := map[string]string{
"UptimeKumaUrl": "",
"UptimeKumaUsername": "",
"UptimeKumaPassword": "",
}
if err := validateUptimeKumaOption("UptimeKumaEnabled", "true", stateEmpty); err == nil {
t.Fatal("expected error when enabling Uptime Kuma with empty URL/credentials in state")
}
}
+24
View File
@@ -0,0 +1,24 @@
package controller
import (
"openflare/service"
"github.com/gin-gonic/gin"
)
// SyncUptimeKuma godoc
// @Summary Manually trigger Uptime Kuma sync
// @Tags UptimeKuma
// @Accept json
// @Produce json
// @Security BearerAuth
// @Success 200 {object} map[string]interface{}
// @Router /api/uptimekuma/sync [post]
func SyncUptimeKuma(c *gin.Context) {
err := service.SyncToUptimeKuma()
if err != nil {
respondFailure(c, err.Error())
return
}
respondSuccessMessage(c, "同步成功")
}
+8
View File
@@ -26,6 +26,14 @@ func InitCronJobs() {
slog.Info("registered WAF IP group sync cron job")
}
// Register Uptime Kuma sync job (check every minute)
_, err = cronRunner.AddJob("* * * * *", &UptimeKumaSyncJob{})
if err != nil {
slog.Error("failed to register Uptime Kuma sync cron job", "error", err)
} else {
slog.Info("registered Uptime Kuma sync cron job")
}
cronRunner.Start()
}
+43
View File
@@ -0,0 +1,43 @@
package job
import (
"log/slog"
"openflare/common"
"openflare/service"
"sync"
"time"
)
var lastUptimeKumaSyncTime time.Time
var uptimeKumaSyncMutex sync.Mutex
type UptimeKumaSyncJob struct{}
func (j *UptimeKumaSyncJob) Run() {
if !common.UptimeKumaEnabled {
return
}
interval := common.UptimeKumaSyncInterval
if interval <= 0 {
interval = 5
}
if time.Since(lastUptimeKumaSyncTime) < time.Duration(interval)*time.Minute {
return
}
if !uptimeKumaSyncMutex.TryLock() {
slog.Warn("Uptime Kuma sync job is already running, skipping this scheduled run")
return
}
defer uptimeKumaSyncMutex.Unlock()
slog.Info("Starting scheduled Uptime Kuma sync")
if err := service.SyncToUptimeKuma(); err != nil {
slog.Error("Uptime Kuma sync failed", "error", err)
} else {
lastUptimeKumaSyncTime = time.Now()
slog.Info("Uptime Kuma sync completed successfully")
}
}
+49
View File
@@ -52,6 +52,17 @@ func InitOptionMap() {
common.OptionMap["AgentUpdateRepo"] = common.AgentUpdateRepo
common.OptionMap["GeoIPProvider"] = common.GeoIPProvider
common.OptionMap["DatabaseAutoCleanupEnabled"] = strconv.FormatBool(common.DatabaseAutoCleanupEnabled)
common.OptionMap["UptimeKumaEnabled"] = strconv.FormatBool(common.UptimeKumaEnabled)
common.OptionMap["UptimeKumaUrl"] = common.UptimeKumaUrl
common.OptionMap["UptimeKumaUsername"] = common.UptimeKumaUsername
common.OptionMap["UptimeKumaPassword"] = common.UptimeKumaPassword
common.OptionMap["UptimeKumaMonitorScope"] = common.UptimeKumaMonitorScope
common.OptionMap["UptimeKumaSelectedSites"] = common.UptimeKumaSelectedSites
common.OptionMap["UptimeKumaSyncInterval"] = strconv.Itoa(common.UptimeKumaSyncInterval)
common.OptionMap["UptimeKumaInterval"] = strconv.Itoa(common.UptimeKumaInterval)
common.OptionMap["UptimeKumaRetry"] = strconv.Itoa(common.UptimeKumaRetry)
common.OptionMap["UptimeKumaRetryInterval"] = strconv.Itoa(common.UptimeKumaRetryInterval)
common.OptionMap["UptimeKumaTimeout"] = strconv.Itoa(common.UptimeKumaTimeout)
common.OptionMap["DatabaseAutoCleanupRetentionDays"] = strconv.Itoa(common.DatabaseAutoCleanupRetentionDays)
common.OptionMap["OpenRestyDefaultServerReturnStatus"] = strconv.Itoa(common.OpenRestyDefaultServerReturnStatus)
common.OptionMap["OpenRestyWorkerProcesses"] = common.OpenRestyWorkerProcesses
@@ -116,6 +127,9 @@ func UpdateOptions(options []Option) error {
if err := DB.Transaction(func(tx *gorm.DB) error {
for _, item := range options {
if item.Key == "UptimeKumaPassword" && strings.TrimSpace(item.Value) == "" {
continue
}
option := Option{
Key: item.Key,
}
@@ -133,6 +147,9 @@ func UpdateOptions(options []Option) error {
}
for _, item := range options {
if item.Key == "UptimeKumaPassword" && strings.TrimSpace(item.Value) == "" {
continue
}
updateOptionMap(item.Key, item.Value)
}
return nil
@@ -209,6 +226,38 @@ func updateOptionMap(key string, value string) {
common.GeoIPProvider = value
shouldRefreshGeoIP = true
}
case "UptimeKumaEnabled":
common.UptimeKumaEnabled = value == "true"
case "UptimeKumaUrl":
common.UptimeKumaUrl = value
case "UptimeKumaUsername":
common.UptimeKumaUsername = value
case "UptimeKumaPassword":
common.UptimeKumaPassword = value
case "UptimeKumaMonitorScope":
common.UptimeKumaMonitorScope = value
case "UptimeKumaSelectedSites":
common.UptimeKumaSelectedSites = value
case "UptimeKumaSyncInterval":
if v, err := strconv.Atoi(value); err == nil && v > 0 {
common.UptimeKumaSyncInterval = v
}
case "UptimeKumaInterval":
if v, err := strconv.Atoi(value); err == nil && v > 0 {
common.UptimeKumaInterval = v
}
case "UptimeKumaRetry":
if v, err := strconv.Atoi(value); err == nil && v >= 0 {
common.UptimeKumaRetry = v
}
case "UptimeKumaRetryInterval":
if v, err := strconv.Atoi(value); err == nil && v > 0 {
common.UptimeKumaRetryInterval = v
}
case "UptimeKumaTimeout":
if v, err := strconv.Atoi(value); err == nil && v > 0 {
common.UptimeKumaTimeout = v
}
case "DatabaseAutoCleanupEnabled":
common.DatabaseAutoCleanupEnabled = value == "true"
case "DatabaseAutoCleanupRetentionDays":
+5
View File
@@ -67,6 +67,11 @@ func SetApiRouter(router *gin.Engine) {
optionRoute.POST("/geoip/lookup", controller.LookupGeoIP)
optionRoute.POST("/database/cleanup", controller.CleanupDatabaseObservability)
}
uptimekumaRoute := apiRouter.Group("/uptimekuma")
uptimekumaRoute.Use(middleware.RootAuth(), middleware.NoTokenAuth())
{
uptimekumaRoute.POST("/sync", controller.SyncUptimeKuma)
}
authSourceRoute := apiRouter.Group("/auth-sources")
authSourceRoute.Use(middleware.RootAuth(), middleware.NoTokenAuth())
{
@@ -0,0 +1,454 @@
package router_test
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"openflare/common"
"openflare/model"
"openflare/router"
"strings"
"sync"
"testing"
"time"
"github.com/gin-contrib/sessions"
"github.com/gin-contrib/sessions/cookie"
"github.com/gin-gonic/gin"
)
// mockKumaServer simulates Uptime Kuma's Engine.IO/Socket.IO polling endpoints
type mockKumaServer struct {
mu sync.Mutex
postsReceived []string
pendingPackets chan string
monitorList string // JSON representing map[string]UptimeKumaMonitor
}
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 == "GET" {
if transport == "polling" && sid == "" {
// Handshake response
w.Header().Set("Content-Type", "text/plain;charset=UTF-8")
_, _ = w.Write([]byte(`0{"sid":"mock-sid"}`))
return
}
if transport == "polling" && sid == "mock-sid" {
// Long-polling GET request
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 == "POST" {
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" {
// Namespace Connect event
// Immediately queue the monitorList payload to be fetched by the next GET poll
s.pendingPackets <- fmt.Sprintf(`42["monitorList",%s]`, s.monitorList)
return
}
if strings.HasPrefix(bodyStr, "42") {
// Socket.IO message: 42<ackID>[...]
payload := bodyStr[2:]
// Find ack ID (digits at the start of payload)
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 TestUptimeKumaSyncDisabled(t *testing.T) {
gin.SetMode(gin.TestMode)
common.RedisEnabled = false
setupTestDB(t)
engine := gin.New()
engine.Use(sessions.Sessions("session", cookie.NewStore([]byte("test-secret"))))
router.SetApiRouter(engine)
loginCookie := loginAsRoot(t, engine)
// Keep integration disabled
common.UptimeKumaEnabled = false
// Request sync, should fail
req := httptest.NewRequest(http.MethodPost, "/api/uptimekuma/sync", nil)
req.AddCookie(loginCookie)
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, req)
if recorder.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", recorder.Code)
}
var resp apiResponse
if err := json.Unmarshal(recorder.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if resp.Success {
t.Fatal("expected sync request to fail when integration is disabled")
}
if !strings.Contains(resp.Message, "disabled") {
t.Fatalf("expected error message to mention integration is disabled, got: %s", resp.Message)
}
}
func TestUptimeKumaSyncSuccess(t *testing.T) {
gin.SetMode(gin.TestMode)
common.RedisEnabled = false
setupTestDB(t)
// Clean up route table just in case
_ = model.DB.Where("1 = 1").Delete(&model.ProxyRoute{}).Error
// Seed proxy routes
// Route 1: site-a (exists in Uptime Kuma but has different check parameters - should trigger editMonitor)
routeA := &model.ProxyRoute{
SiteName: "site-a",
Domain: "site-a.com",
Domains: `["site-a.com"]`,
OriginURL: "http://10.0.0.1",
Enabled: true,
EnableHTTPS: false,
}
// Route 2: site-b (does not exist in Uptime Kuma - should trigger add & addMonitorTag)
routeB := &model.ProxyRoute{
SiteName: "site-b",
Domain: "site-b.com",
Domains: `["site-b.com"]`,
OriginURL: "https://10.0.0.2",
Enabled: true,
EnableHTTPS: true,
}
// Route 3: site-c (disabled locally - should NOT be processed/created)
routeC := &model.ProxyRoute{
SiteName: "site-c",
Domain: "site-c.com",
Domains: `["site-c.com"]`,
OriginURL: "http://10.0.0.3",
Enabled: false,
EnableHTTPS: false,
}
if err := model.DB.Create(routeA).Error; err != nil {
t.Fatalf("failed to seed routeA: %v", err)
}
if err := model.DB.Create(routeB).Error; err != nil {
t.Fatalf("failed to seed routeB: %v", err)
}
if err := model.DB.Create(routeC).Error; err != nil {
t.Fatalf("failed to seed routeC: %v", err)
}
// Prepare mock monitorList
// 1. "site-old": tagged with OpenFlare but doesn't exist locally anymore -> should trigger deleteMonitor
// 2. "site-a": matches routeA but has interval = 30 (default UptimeKumaInterval is 60) -> should trigger editMonitor
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()
// Backup and set configs
oldEnabled := common.UptimeKumaEnabled
oldUrl := common.UptimeKumaUrl
oldUsername := common.UptimeKumaUsername
oldPassword := common.UptimeKumaPassword
oldScope := common.UptimeKumaMonitorScope
oldInterval := common.UptimeKumaInterval
oldRetry := common.UptimeKumaRetry
oldRetryInterval := common.UptimeKumaRetryInterval
oldTimeout := common.UptimeKumaTimeout
common.UptimeKumaEnabled = true
common.UptimeKumaUrl = server.URL
common.UptimeKumaUsername = "admin"
common.UptimeKumaPassword = "password"
common.UptimeKumaMonitorScope = "all"
common.UptimeKumaInterval = 60
common.UptimeKumaRetry = 0
common.UptimeKumaRetryInterval = 60
common.UptimeKumaTimeout = 48
defer func() {
common.UptimeKumaEnabled = oldEnabled
common.UptimeKumaUrl = oldUrl
common.UptimeKumaUsername = oldUsername
common.UptimeKumaPassword = oldPassword
common.UptimeKumaMonitorScope = oldScope
common.UptimeKumaInterval = oldInterval
common.UptimeKumaRetry = oldRetry
common.UptimeKumaRetryInterval = oldRetryInterval
common.UptimeKumaTimeout = oldTimeout
}()
engine := gin.New()
engine.Use(sessions.Sessions("session", cookie.NewStore([]byte("test-secret"))))
router.SetApiRouter(engine)
loginCookie := loginAsRoot(t, engine)
req := httptest.NewRequest(http.MethodPost, "/api/uptimekuma/sync", nil)
req.AddCookie(loginCookie)
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, req)
if recorder.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d. Body: %s", recorder.Code, recorder.Body.String())
}
var resp apiResponse
if err := json.Unmarshal(recorder.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if !resp.Success {
t.Fatalf("sync request failed: %s", resp.Message)
}
mockSrv.mu.Lock()
posts := mockSrv.postsReceived
mockSrv.mu.Unlock()
// Verify events received
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
}
}
if !hasLogin {
t.Error("expected login event to be called")
}
if !hasGetTags {
t.Error("expected getTags event to be called")
}
if !hasAddSiteB {
t.Error("expected site-b to be added")
}
if !hasTagSiteB {
t.Error("expected site-b to be tagged")
}
if !hasEditSiteA {
t.Error("expected site-a to be edited/updated")
}
if !hasDeleteOld {
t.Error("expected site-old to be deleted")
}
}
func TestUptimeKumaSyncSelectedScope(t *testing.T) {
gin.SetMode(gin.TestMode)
common.RedisEnabled = false
setupTestDB(t)
// Clean up route table
_ = model.DB.Where("1 = 1").Delete(&model.ProxyRoute{}).Error
// Seed proxy routes
// Route 1: site-a (enabled, in selected list)
routeA := &model.ProxyRoute{
SiteName: "site-a",
Domain: "site-a.com",
Domains: `["site-a.com"]`,
OriginURL: "http://10.0.0.1",
Enabled: true,
EnableHTTPS: false,
}
// Route 2: site-b (enabled, NOT in selected list)
routeB := &model.ProxyRoute{
SiteName: "site-b",
Domain: "site-b.com",
Domains: `["site-b.com"]`,
OriginURL: "http://10.0.0.2",
Enabled: true,
EnableHTTPS: false,
}
if err := model.DB.Create(routeA).Error; err != nil {
t.Fatalf("failed to seed routeA: %v", err)
}
if err := model.DB.Create(routeB).Error; err != nil {
t.Fatalf("failed to seed routeB: %v", err)
}
mockSrv := newMockKumaServer(`{}`)
server := httptest.NewServer(mockSrv)
defer server.Close()
// Backup and set configs
oldEnabled := common.UptimeKumaEnabled
oldUrl := common.UptimeKumaUrl
oldUsername := common.UptimeKumaUsername
oldPassword := common.UptimeKumaPassword
oldScope := common.UptimeKumaMonitorScope
oldSelected := common.UptimeKumaSelectedSites
common.UptimeKumaEnabled = true
common.UptimeKumaUrl = server.URL
common.UptimeKumaUsername = "admin"
common.UptimeKumaPassword = "password"
common.UptimeKumaMonitorScope = "selected"
common.UptimeKumaSelectedSites = "site-a" // site-b is excluded
defer func() {
common.UptimeKumaEnabled = oldEnabled
common.UptimeKumaUrl = oldUrl
common.UptimeKumaUsername = oldUsername
common.UptimeKumaPassword = oldPassword
common.UptimeKumaMonitorScope = oldScope
common.UptimeKumaSelectedSites = oldSelected
}()
engine := gin.New()
engine.Use(sessions.Sessions("session", cookie.NewStore([]byte("test-secret"))))
router.SetApiRouter(engine)
loginCookie := loginAsRoot(t, engine)
req := httptest.NewRequest(http.MethodPost, "/api/uptimekuma/sync", nil)
req.AddCookie(loginCookie)
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, req)
if recorder.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", recorder.Code)
}
var resp apiResponse
if err := json.Unmarshal(recorder.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if !resp.Success {
t.Fatalf("sync request failed: %s", resp.Message)
}
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
}
}
if !hasLogin {
t.Error("expected login event to be called")
}
if !hasAddSiteA {
t.Error("expected site-a to be added")
}
if hasAddSiteB {
t.Error("expected site-b NOT to be added (not in selected scope)")
}
}
+304
View File
@@ -0,0 +1,304 @@
package service
import (
"fmt"
"log/slog"
"openflare/common"
"openflare/model"
"openflare/utils/uptimekuma"
"strings"
"sync/atomic"
"time"
)
var isSyncing atomic.Bool
func SyncToUptimeKuma() error {
if !common.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(common.UptimeKumaUrl)
kumaUsername := strings.TrimSpace(common.UptimeKumaUsername)
kumaPassword := strings.TrimSpace(common.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", common.UptimeKumaMonitorScope)
// 1. Fetch expected sites
allRoutes, err := model.ListProxyRoutes()
if err != nil {
return fmt.Errorf("failed to list local proxy routes: %w", err)
}
var expectedRoutes []*model.ProxyRoute
scope := common.UptimeKumaMonitorScope
if scope == "selected" {
selectedList := strings.Split(common.UptimeKumaSelectedSites, ",")
selectedMap := make(map[string]bool)
for _, name := range selectedList {
trimmedName := strings.TrimSpace(name)
if trimmedName != "" {
selectedMap[trimmedName] = true
}
}
for _, route := range allRoutes {
if route.Enabled && selectedMap[route.SiteName] {
expectedRoutes = append(expectedRoutes, route)
}
}
} else {
for _, route := range allRoutes {
if route.Enabled {
expectedRoutes = append(expectedRoutes, route)
}
}
}
// 2. Connect to Uptime Kuma
slog.Debug("Connecting to Uptime Kuma socket endpoint", "url", kumaUrl)
client := uptimekuma.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()
// 3. Login
slog.Debug("Sending login request to Uptime Kuma", "username", kumaUsername)
var loginAck string
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 := uptimekuma.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)
// 4. Wait for monitor list event
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")
}
// 5. Get existing tags to find "OpenFlare"
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 fmt.Errorf("failed to fetch tags: %w", err)
}
var tagsResult struct {
Ok bool `json:"ok"`
Tags []uptimekuma.UptimeKumaTagItem `json:"tags"`
}
if err := uptimekuma.ParseAckResponse(tagsAck, &tagsResult); err != nil {
slog.Error("Failed to parse tags response from Uptime Kuma", "error", err)
return fmt.Errorf("parse tags response failed: %w", err)
}
var openFlareTagID int
for _, t := range tagsResult.Tags {
if t.Name == "OpenFlare" {
openFlareTagID = t.ID
break
}
}
// Create "OpenFlare" tag if not exists
if openFlareTagID == 0 {
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 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 := uptimekuma.ParseAckResponse(addTagAck, &tagResult); err != nil || tagResult.Tag.ID == 0 {
slog.Error("Failed to parse addTag response from Uptime Kuma", "error", err)
return fmt.Errorf("parse addTag response failed: %w", err)
}
openFlareTagID = tagResult.Tag.ID
slog.Debug("Successfully created OpenFlare tag", "tag_id", openFlareTagID)
} else {
slog.Debug("Found existing OpenFlare tag", "tag_id", openFlareTagID)
}
// 6. Filter existing monitors by "OpenFlare" tag
existingOpenFlareMonitors := make(map[string]uptimekuma.UptimeKumaMonitor)
monitors := client.GetMonitorList()
for _, m := range monitors {
hasOpenFlareTag := false
for _, tag := range m.Tags {
if tag.Name == "OpenFlare" || tag.ID == openFlareTagID {
hasOpenFlareTag = true
break
}
}
if hasOpenFlareTag {
existingOpenFlareMonitors[m.Name] = m
}
}
// Helper to format route URL
getRouteURL := func(route *model.ProxyRoute) string {
domains, err := decodeStoredDomains(route.Domains, route.Domain)
domain := route.Domain
if err == nil && len(domains) > 0 {
domain = domains[0]
}
if route.EnableHTTPS {
return "https://" + domain
}
return "http://" + domain
}
expectedSitesMap := make(map[string]bool)
// 7. Sync Loop
for _, route := range expectedRoutes {
expectedSitesMap[route.SiteName] = true
targetURL := getRouteURL(route)
existing, exists := existingOpenFlareMonitors[route.SiteName]
if !exists {
// Create monitor
slog.Info("Creating monitor in Uptime Kuma", "name", route.SiteName, "url", targetURL)
monitorPayload := map[string]any{
"type": "http",
"name": route.SiteName,
"url": targetURL,
"interval": common.UptimeKumaInterval,
"maxretries": common.UptimeKumaRetry,
"retryInterval": common.UptimeKumaRetryInterval,
"timeout": common.UptimeKumaTimeout,
"active": true,
"resendInterval": 0,
"expiryNotification": false,
"ignoreTls": false,
"accepted_statuscodes": []string{"200-299"},
"dns_resolve_type": "A",
"conditions": []any{},
}
addAck, err := client.Emit("add", monitorPayload)
if err != nil {
slog.Error("Failed to add monitor to Uptime Kuma", "name", route.SiteName, "error", err)
continue
}
var addResult struct {
Ok bool `json:"ok"`
MonitorID int `json:"monitorID"`
}
if err := uptimekuma.ParseAckResponse(addAck, &addResult); err != nil || addResult.MonitorID == 0 {
slog.Error("Failed to parse add monitor result", "name", route.SiteName, "error", err)
continue
}
// Add tag
slog.Debug("Adding OpenFlare tag to the new monitor", "name", route.SiteName, "monitor_id", addResult.MonitorID, "tag_id", openFlareTagID)
tagAck, err := client.Emit("addMonitorTag", openFlareTagID, addResult.MonitorID, "")
if err != nil {
slog.Error("Failed to add tag to monitor in Uptime Kuma", "name", route.SiteName, "monitorID", addResult.MonitorID, "error", err)
} else {
if err := uptimekuma.ParseAckResponse(tagAck, nil); err != nil {
slog.Error("Failed to parse add tag result", "name", route.SiteName, "monitorID", addResult.MonitorID, "error", err)
} else {
slog.Debug("OpenFlare tag successfully added to monitor", "name", route.SiteName, "monitor_id", addResult.MonitorID)
}
}
} else {
// Check if updates are needed
needsUpdate := existing.Url != targetURL ||
existing.Interval != common.UptimeKumaInterval ||
existing.MaxRetries != common.UptimeKumaRetry ||
existing.RetryInterval != common.UptimeKumaRetryInterval ||
existing.Timeout != common.UptimeKumaTimeout
if needsUpdate {
slog.Info("Updating monitor in Uptime Kuma due to settings mismatch",
"name", route.SiteName,
"url_changed", existing.Url != targetURL,
"interval_changed", existing.Interval != common.UptimeKumaInterval,
"max_retries_changed", existing.MaxRetries != common.UptimeKumaRetry,
"retry_interval_changed", existing.RetryInterval != common.UptimeKumaRetryInterval,
"timeout_changed", existing.Timeout != common.UptimeKumaTimeout,
)
monitorPayload := map[string]any{
"id": existing.ID,
"type": "http",
"name": route.SiteName,
"url": targetURL,
"interval": common.UptimeKumaInterval,
"maxretries": common.UptimeKumaRetry,
"retryInterval": common.UptimeKumaRetryInterval,
"timeout": common.UptimeKumaTimeout,
"active": true,
"resendInterval": 0,
"expiryNotification": false,
"ignoreTls": false,
"accepted_statuscodes": []string{"200-299"},
"dns_resolve_type": "A",
"conditions": []any{},
}
editAck, err := client.Emit("editMonitor", monitorPayload)
if err != nil {
slog.Error("Failed to edit monitor in Uptime Kuma", "name", route.SiteName, "error", err)
} else {
if err := uptimekuma.ParseAckResponse(editAck, nil); err != nil {
slog.Error("Failed to parse edit monitor result", "name", route.SiteName, "error", err)
} else {
slog.Info("Successfully updated monitor in Uptime Kuma", "name", route.SiteName)
}
}
}
}
}
// 8. Delete Loop
for name, m := range existingOpenFlareMonitors {
if !expectedSitesMap[name] {
slog.Info("Deleting monitor in Uptime Kuma", "name", name, "monitorID", m.ID)
deleteAck, err := client.Emit("deleteMonitor", m.ID)
if err != nil {
slog.Error("Failed to delete monitor in Uptime Kuma", "name", name, "monitorID", m.ID, "error", err)
} else {
if err := uptimekuma.ParseAckResponse(deleteAck, nil); err != nil {
slog.Error("Failed to parse delete monitor result", "name", name, "monitorID", m.ID, "error", err)
}
}
}
}
return nil
}
+373
View File
@@ -0,0 +1,373 @@
package uptimekuma
import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"strconv"
"strings"
"sync"
"time"
)
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"`
}
type UptimeKumaTag struct {
ID int `json:"tag_id"`
Name string `json:"name"`
Color string `json:"color"`
}
type UptimeKumaTagItem struct {
ID int `json:"id"`
Name string `json:"name"`
Color string `json:"color"`
}
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
}
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,
}
}
func (c *SocketIOClient) Connect() error {
slog.Debug("Uptime Kuma client starting handshake", "baseURL", c.baseURL)
// 1. Handshake
u := fmt.Sprintf("%s/socket.io/?EIO=4&transport=polling", c.baseURL)
reqHandshake, err := http.NewRequestWithContext(c.ctx, "GET", 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)
// 2. Namespace Connect
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, "POST", 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)
// 3. Start Polling Loop
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, "GET", 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': // Ping
slog.Debug("Received engine.io ping, responding with pong", "sid", c.sid)
c.sendPong()
case '4': // Message
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': // Event
c.handleEvent(socketIOPayload)
case '3': // Ack
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, "POST", 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()
}
}
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, "POST", 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)
}
}
func (c *SocketIOClient) Close() {
c.closeOnce.Do(func() {
c.cancel()
close(c.doneChan)
})
}
func (c *SocketIOClient) GetMonitorListChan() <-chan struct{} {
return c.monitorListChan
}
func (c *SocketIOClient) GetMonitorList() map[string]UptimeKumaMonitor {
c.monitorListMutex.RLock()
defer c.monitorListMutex.RUnlock()
// Return a copy to prevent concurrent map read/write access
m := make(map[string]UptimeKumaMonitor, len(c.monitorList))
for k, v := range c.monitorList {
m[k] = v
}
return m
}
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
}
@@ -126,3 +126,9 @@ export function bindEmail(email: string, code: string) {
export function getAboutContent() {
return apiRequest<string>('/about');
}
export function syncUptimeKuma() {
return apiRequest<void>('/uptimekuma/sync', {
method: 'POST',
});
}
@@ -31,8 +31,10 @@ import {
rotateBootstrapToken,
updateOptions,
updateSelf,
syncUptimeKuma,
} from '@/features/settings/api/settings';
import { AuthSourceModal } from '@/features/settings/components/auth-source-modal';
import { UptimeKumaSiteSelectModal } from './uptimekuma-modal';
import type {
BootstrapTokenPayload,
DatabaseCleanupResult,
@@ -87,6 +89,17 @@ const defaultOperationFields = {
NodeOfflineThreshold: '120000',
AgentUpdateRepo: 'Rain-kl/OpenFlare',
GeoIPProvider: 'ipinfo',
UptimeKumaEnabled: false,
UptimeKumaUrl: '',
UptimeKumaUsername: '',
UptimeKumaPassword: '',
UptimeKumaMonitorScope: 'all',
UptimeKumaSelectedSites: '',
UptimeKumaSyncInterval: '5',
UptimeKumaInterval: '60',
UptimeKumaRetry: '0',
UptimeKumaRetryInterval: '60',
UptimeKumaTimeout: '48',
OpenRestyDefaultServerReturnStatus: '421',
OpenRestyWorkerProcesses: 'auto',
OpenRestyWorkerConnections: '4096',
@@ -255,6 +268,7 @@ export function SettingsPage() {
const [cleanupModalState, setCleanupModalState] =
useState<CleanupModalState | null>(null);
const [cleanupRetentionDays, setCleanupRetentionDays] = useState('');
const [uptimeKumaModalOpen, setUptimeKumaModalOpen] = useState(false);
const isRoot = (user?.role ?? 0) >= 100;
@@ -428,6 +442,17 @@ export function SettingsPage() {
GlobalWebRateLimitDuration: optionMap.GlobalWebRateLimitDuration ?? '180',
CriticalRateLimitNum: optionMap.CriticalRateLimitNum ?? '100',
CriticalRateLimitDuration: optionMap.CriticalRateLimitDuration ?? '1200',
UptimeKumaEnabled: toBoolean(optionMap.UptimeKumaEnabled, false),
UptimeKumaUrl: optionMap.UptimeKumaUrl ?? '',
UptimeKumaUsername: optionMap.UptimeKumaUsername ?? '',
UptimeKumaPassword: '',
UptimeKumaMonitorScope: optionMap.UptimeKumaMonitorScope ?? 'all',
UptimeKumaSelectedSites: optionMap.UptimeKumaSelectedSites ?? '',
UptimeKumaSyncInterval: optionMap.UptimeKumaSyncInterval ?? '5',
UptimeKumaInterval: optionMap.UptimeKumaInterval ?? '60',
UptimeKumaRetry: optionMap.UptimeKumaRetry ?? '0',
UptimeKumaRetryInterval: optionMap.UptimeKumaRetryInterval ?? '60',
UptimeKumaTimeout: optionMap.UptimeKumaTimeout ?? '48',
ServerAddress: resolvedServerAddress,
});
@@ -656,6 +681,64 @@ export function SettingsPage() {
});
};
const handleUptimeKumaSave = () => {
void runBusyAction('uptimekuma-save', async () => {
const syncInt = Number.parseInt(operationFields.UptimeKumaSyncInterval, 10);
const interval = Number.parseInt(operationFields.UptimeKumaInterval, 10);
const retry = Number.parseInt(operationFields.UptimeKumaRetry, 10);
const retryInt = Number.parseInt(operationFields.UptimeKumaRetryInterval, 10);
const timeout = Number.parseInt(operationFields.UptimeKumaTimeout, 10);
if (operationFields.UptimeKumaEnabled) {
if (!operationFields.UptimeKumaUrl.trim()) {
throw new Error('请输入 Uptime Kuma 地址。');
}
if (!operationFields.UptimeKumaUsername.trim()) {
throw new Error('请输入 Uptime Kuma 用户名。');
}
}
if (Number.isNaN(syncInt) || syncInt <= 0) {
throw new Error('同步间隔必须为正整数。');
}
if (Number.isNaN(interval) || interval <= 0) {
throw new Error('心跳间隔必须为正整数。');
}
if (Number.isNaN(retry) || retry < 0) {
throw new Error('重试次数必须为非负整数。');
}
if (Number.isNaN(retryInt) || retryInt <= 0) {
throw new Error('心跳重试间隔必须为正整数。');
}
if (Number.isNaN(timeout) || timeout <= 0) {
throw new Error('请求超时必须为正整数。');
}
await saveOptionEntries(
[
['UptimeKumaEnabled', String(operationFields.UptimeKumaEnabled)],
['UptimeKumaUrl', operationFields.UptimeKumaUrl.trim()],
['UptimeKumaUsername', operationFields.UptimeKumaUsername.trim()],
['UptimeKumaPassword', operationFields.UptimeKumaPassword],
['UptimeKumaMonitorScope', operationFields.UptimeKumaMonitorScope],
['UptimeKumaSelectedSites', operationFields.UptimeKumaSelectedSites],
['UptimeKumaSyncInterval', String(syncInt)],
['UptimeKumaInterval', String(interval)],
['UptimeKumaRetry', String(retry)],
['UptimeKumaRetryInterval', String(retryInt)],
['UptimeKumaTimeout', String(timeout)],
],
'Uptime Kuma 设置已保存。',
);
});
};
const handleUptimeKumaSync = () => {
void runBusyAction('uptimekuma-sync', async () => {
await syncUptimeKuma();
setFeedback({ tone: 'success', message: '同步任务已成功执行!' });
});
};
const renderTabContent = () => {
if (profileQuery.isLoading || publicStatusQuery.isLoading) {
return <LoadingState />;
@@ -1307,6 +1390,198 @@ export function SettingsPage() {
</div>
)}
</AppCard>
<AppCard
title="Uptime Kuma 集成"
description="通过 API 将 OpenFlare 反代站点同步至 Uptime Kuma 监控。"
action={
<div className="flex flex-wrap gap-2">
<SecondaryButton
type="button"
onClick={handleUptimeKumaSync}
disabled={busyKey === 'uptimekuma-sync' || !operationFields.UptimeKumaEnabled}
>
{busyKey === 'uptimekuma-sync' ? '同步中...' : '立即同步'}
</SecondaryButton>
<PrimaryButton
type="button"
onClick={handleUptimeKumaSave}
disabled={busyKey === 'uptimekuma-save'}
>
{busyKey === 'uptimekuma-save' ? '保存中...' : '保存设置'}
</PrimaryButton>
</div>
}
>
<div className="space-y-6">
<ToggleField
label="开启 Uptime Kuma"
description="启用后会自动差分同步反代规则站点到 Uptime Kuma 实例。"
checked={operationFields.UptimeKumaEnabled}
onChange={(checked) =>
setOperationFields((previous) => ({
...previous,
UptimeKumaEnabled: checked,
}))
}
/>
{operationFields.UptimeKumaEnabled ? (
<div className="space-y-5 border-t border-[var(--border-default)] pt-5">
<div className="grid gap-5 md:grid-cols-2">
<ResourceField label="Uptime Kuma 地址" hint="例如 http://192.168.1.100:3001">
<ResourceInput
value={operationFields.UptimeKumaUrl}
onChange={(event) =>
setOperationFields((previous) => ({
...previous,
UptimeKumaUrl: event.target.value,
}))
}
placeholder="http://localhost:3001"
/>
</ResourceField>
<ResourceField label="Uptime Kuma 用户名">
<ResourceInput
value={operationFields.UptimeKumaUsername}
onChange={(event) =>
setOperationFields((previous) => ({
...previous,
UptimeKumaUsername: event.target.value,
}))
}
placeholder="请输入用户名"
/>
</ResourceField>
</div>
<div className="grid gap-5 md:grid-cols-2">
<ResourceField
label="Uptime Kuma 密码"
hint="安全原因不显示历史密码,留空表示不更新。"
>
<ResourceInput
type="password"
value={operationFields.UptimeKumaPassword}
onChange={(event) =>
setOperationFields((previous) => ({
...previous,
UptimeKumaPassword: event.target.value,
}))
}
placeholder="请输入密码(留空表示不更新)"
/>
</ResourceField>
<ResourceField label="同步间隔 (分钟)">
<ResourceInput
type="number"
value={operationFields.UptimeKumaSyncInterval}
onChange={(event) =>
setOperationFields((previous) => ({
...previous,
UptimeKumaSyncInterval: event.target.value,
}))
}
/>
</ResourceField>
<ResourceField label="监控范围">
<ResourceSelect
value={operationFields.UptimeKumaMonitorScope}
onChange={(event) =>
setOperationFields((previous) => ({
...previous,
UptimeKumaMonitorScope: event.target.value,
}))
}
>
<option value="all">全部站点</option>
<option value="selected">选择站点</option>
</ResourceSelect>
</ResourceField>
</div>
{operationFields.UptimeKumaMonitorScope === 'selected' ? (
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] p-4 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-[var(--foreground-primary)]">已选站点</span>
<SecondaryButton type="button" onClick={() => setUptimeKumaModalOpen(true)}>
选择监控站点
</SecondaryButton>
</div>
<div className="text-xs text-[var(--foreground-secondary)] break-all max-h-[80px] overflow-y-auto leading-5">
{operationFields.UptimeKumaSelectedSites
? operationFields.UptimeKumaSelectedSites.split(',').join(', ')
: '未选择任何站点,同步不会执行。'}
</div>
</div>
) : null}
<div className="border-t border-[var(--border-default)] pt-5 space-y-4">
<p className="text-sm font-medium text-[var(--foreground-primary)]">
Uptime Kuma 属性配置
</p>
<div className="grid gap-5 md:grid-cols-2">
<ResourceField label="心跳间隔 (检测频率秒)" hint="服务检测频率,单位:秒">
<ResourceInput
type="number"
value={operationFields.UptimeKumaInterval}
onChange={(event) =>
setOperationFields((previous) => ({
...previous,
UptimeKumaInterval: event.target.value,
}))
}
/>
</ResourceField>
<ResourceField label="重试次数" hint="服务被标记为故障前的最大重试次数">
<ResourceInput
type="number"
value={operationFields.UptimeKumaRetry}
onChange={(event) =>
setOperationFields((previous) => ({
...previous,
UptimeKumaRetry: event.target.value,
}))
}
/>
</ResourceField>
<ResourceField label="心跳重试间隔 (秒)" hint="重试间隔时间,单位:秒">
<ResourceInput
type="number"
value={operationFields.UptimeKumaRetryInterval}
onChange={(event) =>
setOperationFields((previous) => ({
...previous,
UptimeKumaRetryInterval: event.target.value,
}))
}
/>
</ResourceField>
<ResourceField label="请求超时 (秒)" hint="超时断开时间,单位:秒">
<ResourceInput
type="number"
value={operationFields.UptimeKumaTimeout}
onChange={(event) =>
setOperationFields((previous) => ({
...previous,
UptimeKumaTimeout: event.target.value,
}))
}
/>
</ResourceField>
</div>
</div>
</div>
) : null}
</div>
</AppCard>
<AppCard title="版本与构建信息">
<div className="grid gap-4 md:grid-cols-2">
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
@@ -2050,6 +2325,22 @@ export function SettingsPage() {
}}
/>
<UptimeKumaSiteSelectModal
isOpen={uptimeKumaModalOpen}
selectedSites={
operationFields.UptimeKumaSelectedSites
? operationFields.UptimeKumaSelectedSites.split(',')
: []
}
onClose={() => setUptimeKumaModalOpen(false)}
onSave={(sites) =>
setOperationFields((previous) => ({
...previous,
UptimeKumaSelectedSites: sites.join(','),
}))
}
/>
<AppModal
isOpen={cleanupModalState !== null}
title={`清理${cleanupModalState?.label ?? ''}`}
@@ -0,0 +1,211 @@
'use client';
import { useEffect, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { ErrorState } from '@/components/feedback/error-state';
import { LoadingState } from '@/components/feedback/loading-state';
import { AppModal } from '@/components/ui/app-modal';
import { getProxyRoutes } from '@/features/proxy-routes/api/proxy-routes';
import {
PrimaryButton,
ResourceField,
ResourceInput,
SecondaryButton,
} from '@/features/shared/components/resource-primitives';
function getErrorMessage(error: unknown) {
return error instanceof Error ? error.message : '加载站点列表失败。';
}
export function UptimeKumaSiteSelectModal({
isOpen,
selectedSites,
onClose,
onSave,
}: {
isOpen: boolean;
selectedSites: string[];
onClose: () => void;
onSave: (sites: string[]) => void;
}) {
const [searchTerm, setSearchTerm] = useState('');
const [tempSelected, setTempSelected] = useState<Set<string>>(new Set());
const { data: routes = [], isLoading, error } = useQuery({
queryKey: ['proxy-routes'],
queryFn: getProxyRoutes,
enabled: isOpen,
});
useEffect(() => {
if (isOpen) {
setTempSelected(new Set(selectedSites.map(s => s.trim()).filter(Boolean)));
setSearchTerm('');
}
}, [isOpen, selectedSites]);
const toggleSite = (siteName: string) => {
setTempSelected((prev) => {
const next = new Set(prev);
if (next.has(siteName)) {
next.delete(siteName);
} else {
next.add(siteName);
}
return next;
});
};
const handleSelectAll = () => {
setTempSelected((prev) => {
const next = new Set(prev);
filteredRoutes.forEach((route) => {
next.add(route.site_name);
});
return next;
});
};
const handleDeselectAll = () => {
setTempSelected((prev) => {
const next = new Set(prev);
filteredRoutes.forEach((route) => {
next.delete(route.site_name);
});
return next;
});
};
const handleSave = () => {
onSave(Array.from(tempSelected));
onClose();
};
const filteredRoutes = routes.filter(
(route) =>
route.site_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
route.primary_domain.toLowerCase().includes(searchTerm.toLowerCase()),
);
return (
<AppModal
isOpen={isOpen}
title="选择监控站点"
description="请选择要同步到 Uptime Kuma 监控的站点,支持按站点名称和域名搜索。"
size="lg"
onClose={onClose}
footer={
<div className="flex justify-end gap-3">
<SecondaryButton type="button" onClick={onClose}>
取消
</SecondaryButton>
<PrimaryButton type="button" onClick={handleSave}>
保存选择
</PrimaryButton>
</div>
}
>
<div className="space-y-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="flex-1">
<ResourceField label="搜索站点">
<ResourceInput
value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)}
placeholder="按名称或域名搜索..."
/>
</ResourceField>
</div>
<div className="flex gap-2 sm:mt-6">
<SecondaryButton type="button" onClick={handleSelectAll}>
全选过滤项
</SecondaryButton>
<SecondaryButton type="button" onClick={handleDeselectAll}>
清空过滤项
</SecondaryButton>
</div>
</div>
{isLoading ? <LoadingState /> : null}
{error ? (
<ErrorState
title="站点加载失败"
description={getErrorMessage(error)}
/>
) : null}
{!isLoading && !error && routes.length === 0 ? (
<div className="rounded-2xl border border-dashed border-[var(--border-default)] px-5 py-8 text-center text-sm text-[var(--foreground-secondary)]">
暂无可用的代理站点。
</div>
) : null}
{!isLoading && !error && routes.length > 0 ? (
<div className="max-h-[350px] overflow-y-auto rounded-2xl border border-[var(--border-default)] bg-[var(--surface-base)]">
<table className="w-full text-left text-sm">
<thead className="sticky top-0 bg-[var(--surface-elevated)] text-xs text-[var(--foreground-secondary)] uppercase">
<tr>
<th className="w-12 px-4 py-3">选择</th>
<th className="px-4 py-3 font-medium">站点名称</th>
<th className="px-4 py-3 font-medium">主域名</th>
<th className="px-4 py-3 font-medium">状态</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--border-default)]">
{filteredRoutes.map((route) => {
const isChecked = tempSelected.has(route.site_name);
return (
<tr
key={route.id}
onClick={() => toggleSite(route.site_name)}
className="cursor-pointer hover:bg-[var(--surface-elevated)]"
>
<td className="px-4 py-3" onClick={(e) => e.stopPropagation()}>
<input
type="checkbox"
checked={isChecked}
onChange={() => toggleSite(route.site_name)}
className="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
/>
</td>
<td className="px-4 py-3 font-medium text-[var(--foreground-primary)]">
{route.site_name}
</td>
<td className="px-4 py-3 text-[var(--foreground-secondary)]">
{route.primary_domain}
</td>
<td className="px-4 py-3">
<span
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${
route.enabled
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400'
: 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-400'
}`}
>
{route.enabled ? '启用' : '禁用'}
</span>
</td>
</tr>
);
})}
{filteredRoutes.length === 0 ? (
<tr>
<td colSpan={4} className="px-4 py-8 text-center text-[var(--foreground-secondary)]">
无匹配的站点
</td>
</tr>
) : null}
</tbody>
</table>
</div>
) : null}
<div className="text-xs text-[var(--foreground-muted)] text-right">
已选择 {tempSelected.size} 个监控站点
</div>
</div>
</AppModal>
);
}
+7 -2
View File
@@ -1,3 +1,4 @@
# syntax=docker/dockerfile:1.7
ARG VERSION=dev
FROM golang:1.25-alpine AS builder
@@ -8,11 +9,14 @@ WORKDIR /build
COPY openflared/go.mod openflared/go.sum ./
COPY openflare_server/go.mod openflare_server/go.sum ../openflare_server/
RUN go mod download
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
COPY openflare_server/ ../openflare_server/
COPY openflared/ .
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags "-s -w -X 'openflare-flared/internal/config.Version=$VERSION'" -o flared ./cmd/flared
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags "-s -w -X 'openflare-flared/internal/config.Version=$VERSION'" -o flared ./cmd/flared
# Final runtime image
FROM fatedier/frpc:v0.69.0
@@ -27,3 +31,4 @@ ENV OPENFLARE_FRPC_PATH=/usr/bin/frpc
ENTRYPOINT ["/app/flared"]
CMD []