mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-08 08:36:37 +08:00
migrate
This commit is contained in:
@@ -0,0 +1,383 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package uptimekuma
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// UptimeKumaMonitor represents a monitor entry from Uptime Kuma.
|
||||
type UptimeKumaMonitor struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Url string `json:"url"`
|
||||
Type string `json:"type"`
|
||||
Interval int `json:"interval"`
|
||||
MaxRetries int `json:"maxretries"`
|
||||
RetryInterval int `json:"retryInterval"`
|
||||
Timeout int `json:"timeout"`
|
||||
Tags []UptimeKumaTag `json:"tags"`
|
||||
}
|
||||
|
||||
// UptimeKumaTag represents a tag attached to a monitor.
|
||||
type UptimeKumaTag struct {
|
||||
ID int `json:"tag_id"`
|
||||
Name string `json:"name"`
|
||||
Color string `json:"color"`
|
||||
}
|
||||
|
||||
// UptimeKumaTagItem represents a tag returned by getTags.
|
||||
type UptimeKumaTagItem struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Color string `json:"color"`
|
||||
}
|
||||
|
||||
// SocketIOClient is a minimal Engine.IO/Socket.IO polling client for Uptime Kuma.
|
||||
type SocketIOClient struct {
|
||||
baseURL string
|
||||
httpClient *http.Client
|
||||
sid string
|
||||
ackMutex sync.Mutex
|
||||
ackID int
|
||||
ackChanMap map[int]chan string
|
||||
doneChan chan struct{}
|
||||
closeOnce sync.Once
|
||||
|
||||
monitorListMutex sync.RWMutex
|
||||
monitorList map[string]UptimeKumaMonitor
|
||||
monitorListChan chan struct{}
|
||||
monitorListOnce sync.Once
|
||||
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
|
||||
err error
|
||||
}
|
||||
|
||||
// NewSocketIOClient creates a Socket.IO polling client for the given base URL.
|
||||
func NewSocketIOClient(baseURL string) *SocketIOClient {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
return &SocketIOClient{
|
||||
baseURL: strings.TrimSuffix(baseURL, "/"),
|
||||
httpClient: &http.Client{
|
||||
Timeout: 60 * time.Second,
|
||||
},
|
||||
ackChanMap: make(map[int]chan string),
|
||||
doneChan: make(chan struct{}),
|
||||
monitorListChan: make(chan struct{}),
|
||||
monitorList: make(map[string]UptimeKumaMonitor),
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
}
|
||||
}
|
||||
|
||||
// Connect performs the Engine.IO handshake and starts the polling loop.
|
||||
func (c *SocketIOClient) Connect() error {
|
||||
slog.Debug("Uptime Kuma client starting handshake", "baseURL", c.baseURL)
|
||||
u := fmt.Sprintf("%s/socket.io/?EIO=4&transport=polling", c.baseURL)
|
||||
reqHandshake, err := http.NewRequestWithContext(c.ctx, http.MethodGet, u, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create handshake request failed: %w", err)
|
||||
}
|
||||
resp, err := c.httpClient.Do(reqHandshake)
|
||||
if err != nil {
|
||||
slog.Error("Uptime Kuma handshake connection failed", "url", u, "error", err)
|
||||
return fmt.Errorf("handshake request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
bs, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
slog.Error("Failed to read Uptime Kuma handshake response body", "error", err)
|
||||
return fmt.Errorf("read handshake body failed: %w", err)
|
||||
}
|
||||
|
||||
bodyStr := string(bs)
|
||||
slog.Debug("Received handshake response from Uptime Kuma", "body", bodyStr)
|
||||
if len(bodyStr) == 0 || bodyStr[0] != '0' {
|
||||
return fmt.Errorf("invalid handshake response format: %s", bodyStr)
|
||||
}
|
||||
|
||||
var hs struct {
|
||||
Sid string `json:"sid"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(bodyStr[1:]), &hs); err != nil {
|
||||
return fmt.Errorf("unmarshal handshake sid failed: %w", err)
|
||||
}
|
||||
c.sid = hs.Sid
|
||||
slog.Debug("Uptime Kuma handshake success", "sid", c.sid)
|
||||
|
||||
slog.Debug("Sending namespace connect request to Uptime Kuma", "sid", c.sid)
|
||||
connectURL := fmt.Sprintf("%s/socket.io/?EIO=4&transport=polling&sid=%s", c.baseURL, c.sid)
|
||||
req, err := http.NewRequestWithContext(c.ctx, http.MethodPost, connectURL, strings.NewReader("40"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("create connect request failed: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "text/plain;charset=UTF-8")
|
||||
respConnect, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
slog.Error("Uptime Kuma namespace connect request failed", "sid", c.sid, "error", err)
|
||||
return fmt.Errorf("namespace connect failed: %w", err)
|
||||
}
|
||||
respConnect.Body.Close()
|
||||
slog.Debug("Namespace connected successfully to Uptime Kuma", "sid", c.sid)
|
||||
|
||||
go c.pollLoop()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *SocketIOClient) pollLoop() {
|
||||
slog.Debug("Uptime Kuma polling loop started", "sid", c.sid)
|
||||
defer c.Close()
|
||||
for {
|
||||
select {
|
||||
case <-c.doneChan:
|
||||
slog.Debug("Uptime Kuma polling loop stopped (doneChan closed)", "sid", c.sid)
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
u := fmt.Sprintf("%s/socket.io/?EIO=4&transport=polling&sid=%s", c.baseURL, c.sid)
|
||||
reqPoll, err := http.NewRequestWithContext(c.ctx, http.MethodGet, u, nil)
|
||||
if err != nil {
|
||||
slog.Error("Failed to create Uptime Kuma polling request", "sid", c.sid, "error", err)
|
||||
c.err = err
|
||||
return
|
||||
}
|
||||
resp, err := c.httpClient.Do(reqPoll)
|
||||
if err != nil {
|
||||
slog.Error("Uptime Kuma polling request failed", "sid", c.sid, "error", err)
|
||||
c.err = err
|
||||
return
|
||||
}
|
||||
|
||||
bs, err := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
slog.Error("Failed to read Uptime Kuma polling body", "sid", c.sid, "error", err)
|
||||
c.err = err
|
||||
return
|
||||
}
|
||||
|
||||
bodyStr := string(bs)
|
||||
if len(bodyStr) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
slog.Debug("Received polling payload from Uptime Kuma", "length", len(bodyStr))
|
||||
packets := strings.Split(bodyStr, "\x1e")
|
||||
for _, pkt := range packets {
|
||||
if len(pkt) == 0 {
|
||||
continue
|
||||
}
|
||||
engineIOType := pkt[0]
|
||||
payload := pkt[1:]
|
||||
|
||||
slog.Debug("Parsing engine.io packet", "type", string(engineIOType), "payload_len", len(payload))
|
||||
switch engineIOType {
|
||||
case '2':
|
||||
slog.Debug("Received engine.io ping, responding with pong", "sid", c.sid)
|
||||
c.sendPong()
|
||||
case '4':
|
||||
if len(payload) == 0 {
|
||||
continue
|
||||
}
|
||||
socketIOType := payload[0]
|
||||
socketIOPayload := payload[1:]
|
||||
|
||||
slog.Debug("Parsing socket.io packet", "type", string(socketIOType), "payload", socketIOPayload)
|
||||
switch socketIOType {
|
||||
case '2':
|
||||
c.handleEvent(socketIOPayload)
|
||||
case '3':
|
||||
c.handleAck(socketIOPayload)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *SocketIOClient) sendPong() {
|
||||
u := fmt.Sprintf("%s/socket.io/?EIO=4&transport=polling&sid=%s", c.baseURL, c.sid)
|
||||
req, err := http.NewRequestWithContext(c.ctx, http.MethodPost, u, strings.NewReader("3"))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
req.Header.Set("Content-Type", "text/plain;charset=UTF-8")
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err == nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func (c *SocketIOClient) handleEvent(payload string) {
|
||||
var arr []json.RawMessage
|
||||
if err := json.Unmarshal([]byte(payload), &arr); err != nil || len(arr) < 2 {
|
||||
return
|
||||
}
|
||||
var eventName string
|
||||
if err := json.Unmarshal(arr[0], &eventName); err != nil {
|
||||
return
|
||||
}
|
||||
if eventName == "monitorList" {
|
||||
var list map[string]UptimeKumaMonitor
|
||||
if err := json.Unmarshal(arr[1], &list); err == nil {
|
||||
c.monitorListMutex.Lock()
|
||||
c.monitorList = list
|
||||
c.monitorListMutex.Unlock()
|
||||
c.monitorListOnce.Do(func() {
|
||||
close(c.monitorListChan)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *SocketIOClient) handleAck(payload string) {
|
||||
idx := strings.IndexByte(payload, '[')
|
||||
if idx == -1 {
|
||||
return
|
||||
}
|
||||
ackIDStr := payload[:idx]
|
||||
ackID, err := strconv.Atoi(ackIDStr)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
c.ackMutex.Lock()
|
||||
ch, ok := c.ackChanMap[ackID]
|
||||
if ok {
|
||||
delete(c.ackChanMap, ackID)
|
||||
c.ackMutex.Unlock()
|
||||
select {
|
||||
case ch <- payload[idx:]:
|
||||
default:
|
||||
}
|
||||
} else {
|
||||
c.ackMutex.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// Emit sends a Socket.IO event and waits for the corresponding ack.
|
||||
func (c *SocketIOClient) Emit(event string, args ...any) (string, error) {
|
||||
c.ackMutex.Lock()
|
||||
id := c.ackID
|
||||
c.ackID++
|
||||
ch := make(chan string, 1)
|
||||
c.ackChanMap[id] = ch
|
||||
c.ackMutex.Unlock()
|
||||
|
||||
payloadArr := []any{event}
|
||||
payloadArr = append(payloadArr, args...)
|
||||
bs, err := json.Marshal(payloadArr)
|
||||
if err != nil {
|
||||
c.ackMutex.Lock()
|
||||
delete(c.ackChanMap, id)
|
||||
c.ackMutex.Unlock()
|
||||
slog.Error("Failed to marshal event payload", "event", event, "error", err)
|
||||
return "", err
|
||||
}
|
||||
|
||||
body := fmt.Sprintf("42%d%s", id, string(bs))
|
||||
slog.Debug("Emitting Socket.IO event", "event", event, "ackID", id, "payload", string(bs))
|
||||
|
||||
u := fmt.Sprintf("%s/socket.io/?EIO=4&transport=polling&sid=%s", c.baseURL, c.sid)
|
||||
req, err := http.NewRequestWithContext(c.ctx, http.MethodPost, u, strings.NewReader(body))
|
||||
if err != nil {
|
||||
c.ackMutex.Lock()
|
||||
delete(c.ackChanMap, id)
|
||||
c.ackMutex.Unlock()
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "text/plain;charset=UTF-8")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
c.ackMutex.Lock()
|
||||
delete(c.ackChanMap, id)
|
||||
c.ackMutex.Unlock()
|
||||
slog.Error("Failed to send Emit request", "event", event, "ackID", id, "error", err)
|
||||
return "", err
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
select {
|
||||
case result := <-ch:
|
||||
slog.Debug("Received Ack for event", "event", event, "ackID", id, "response", result)
|
||||
return result, nil
|
||||
case <-time.After(10 * time.Second):
|
||||
c.ackMutex.Lock()
|
||||
delete(c.ackChanMap, id)
|
||||
c.ackMutex.Unlock()
|
||||
slog.Error("Timeout waiting for event Ack", "event", event, "ackID", id)
|
||||
return "", fmt.Errorf("timeout waiting for ack for event: %s", event)
|
||||
case <-c.doneChan:
|
||||
c.ackMutex.Lock()
|
||||
delete(c.ackChanMap, id)
|
||||
c.ackMutex.Unlock()
|
||||
slog.Error("Client closed while waiting for event Ack", "event", event, "ackID", id)
|
||||
return "", fmt.Errorf("client closed while waiting for event ack: %s", event)
|
||||
}
|
||||
}
|
||||
|
||||
// Close shuts down the polling loop.
|
||||
func (c *SocketIOClient) Close() {
|
||||
c.closeOnce.Do(func() {
|
||||
c.cancel()
|
||||
close(c.doneChan)
|
||||
})
|
||||
}
|
||||
|
||||
// GetMonitorListChan returns a channel closed when the first monitorList event arrives.
|
||||
func (c *SocketIOClient) GetMonitorListChan() <-chan struct{} {
|
||||
return c.monitorListChan
|
||||
}
|
||||
|
||||
// GetMonitorList returns a copy of the current monitor list.
|
||||
func (c *SocketIOClient) GetMonitorList() map[string]UptimeKumaMonitor {
|
||||
c.monitorListMutex.RLock()
|
||||
defer c.monitorListMutex.RUnlock()
|
||||
|
||||
m := make(map[string]UptimeKumaMonitor, len(c.monitorList))
|
||||
for k, v := range c.monitorList {
|
||||
m[k] = v
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// ParseAckResponse unmarshals an ack payload and validates the ok status when present.
|
||||
func ParseAckResponse(response string, target any) error {
|
||||
var arr []json.RawMessage
|
||||
if err := json.Unmarshal([]byte(response), &arr); err != nil || len(arr) == 0 {
|
||||
return fmt.Errorf("invalid ack response format: %s", response)
|
||||
}
|
||||
|
||||
var status struct {
|
||||
Ok bool `json:"ok"`
|
||||
Msg string `json:"msg"`
|
||||
}
|
||||
if err := json.Unmarshal(arr[0], &status); err == nil {
|
||||
if !status.Ok {
|
||||
errMsg := status.Msg
|
||||
if errMsg == "" {
|
||||
errMsg = "unknown error from Uptime Kuma"
|
||||
}
|
||||
return fmt.Errorf("Uptime Kuma error response: %s", errMsg)
|
||||
}
|
||||
}
|
||||
|
||||
if target != nil {
|
||||
return json.Unmarshal(arr[0], target)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package uptimekuma
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/internal/model"
|
||||
)
|
||||
|
||||
var isSyncing atomic.Bool
|
||||
|
||||
// SyncToUptimeKuma synchronizes enabled proxy routes to Uptime Kuma monitors.
|
||||
func SyncToUptimeKuma(ctx context.Context) error {
|
||||
if !model.UptimeKumaEnabled {
|
||||
return fmt.Errorf("Uptime Kuma integration is disabled")
|
||||
}
|
||||
|
||||
if !isSyncing.CompareAndSwap(false, true) {
|
||||
return fmt.Errorf("sync task is already in progress, please try again later")
|
||||
}
|
||||
defer isSyncing.Store(false)
|
||||
|
||||
kumaURL := strings.TrimSpace(model.UptimeKumaUrl)
|
||||
kumaUsername := strings.TrimSpace(model.UptimeKumaUsername)
|
||||
kumaPassword := strings.TrimSpace(model.UptimeKumaPassword)
|
||||
if kumaURL == "" || kumaUsername == "" || kumaPassword == "" {
|
||||
return fmt.Errorf(
|
||||
"Uptime Kuma URL, username, or password is not configured (URL: %q, Username: %q, PasswordLength: %d)",
|
||||
kumaURL, kumaUsername, len(kumaPassword),
|
||||
)
|
||||
}
|
||||
|
||||
slog.Info("Starting Uptime Kuma sync process",
|
||||
"url", kumaURL,
|
||||
"username", kumaUsername,
|
||||
"scope", model.UptimeKumaMonitorScope,
|
||||
)
|
||||
|
||||
allRoutes, err := model.ListProxyRoutes(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list local proxy routes: %w", err)
|
||||
}
|
||||
|
||||
expectedRoutes, err := filterExpectedRoutes(allRoutes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
slog.Debug("Connecting to Uptime Kuma socket endpoint", "url", kumaURL)
|
||||
client := NewSocketIOClient(kumaURL)
|
||||
if err := client.Connect(); err != nil {
|
||||
slog.Error("Failed to connect to Uptime Kuma endpoint", "url", kumaURL, "error", err)
|
||||
return fmt.Errorf("failed to connect to Uptime Kuma: %w", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
slog.Debug("Sending login request to Uptime Kuma", "username", kumaUsername)
|
||||
loginPayload := map[string]string{
|
||||
"username": kumaUsername,
|
||||
"password": kumaPassword,
|
||||
}
|
||||
loginAck, err := client.Emit("login", loginPayload)
|
||||
if err != nil {
|
||||
slog.Error("Failed to send login request to Uptime Kuma", "username", kumaUsername, "error", err)
|
||||
return fmt.Errorf("login request failed: %w", err)
|
||||
}
|
||||
|
||||
var loginResult struct {
|
||||
Ok bool `json:"ok"`
|
||||
}
|
||||
if err := ParseAckResponse(loginAck, &loginResult); err != nil || !loginResult.Ok {
|
||||
slog.Error("Uptime Kuma login verification failed", "username", kumaUsername, "error", err)
|
||||
return fmt.Errorf("login failed: %w", err)
|
||||
}
|
||||
slog.Debug("Successfully logged into Uptime Kuma", "username", kumaUsername)
|
||||
|
||||
slog.Debug("Waiting for monitor list push from Uptime Kuma")
|
||||
select {
|
||||
case <-client.GetMonitorListChan():
|
||||
slog.Debug("Received monitor list from Uptime Kuma")
|
||||
case <-time.After(5 * time.Second):
|
||||
slog.Error("Timeout waiting for Uptime Kuma monitorList push event")
|
||||
return fmt.Errorf("timeout waiting for monitorList event from Uptime Kuma")
|
||||
}
|
||||
|
||||
openFlareTagID, err := ensureOpenFlareTag(client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
existingOpenFlareMonitors := filterOpenFlareMonitors(client.GetMonitorList(), openFlareTagID)
|
||||
expectedSitesMap := make(map[string]bool)
|
||||
|
||||
for _, route := range expectedRoutes {
|
||||
expectedSitesMap[route.SiteName] = true
|
||||
targetURL, urlErr := routeMonitorURL(route)
|
||||
if urlErr != nil {
|
||||
slog.Error("Failed to resolve monitor URL", "name", route.SiteName, "error", urlErr)
|
||||
continue
|
||||
}
|
||||
|
||||
existing, exists := existingOpenFlareMonitors[route.SiteName]
|
||||
if !exists {
|
||||
if err := createMonitor(client, route.SiteName, targetURL, openFlareTagID); err != nil {
|
||||
slog.Error("Failed to add monitor to Uptime Kuma", "name", route.SiteName, "error", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if monitorNeedsUpdate(existing, targetURL) {
|
||||
if err := updateMonitor(client, existing.ID, route.SiteName, targetURL); err != nil {
|
||||
slog.Error("Failed to edit monitor in Uptime Kuma", "name", route.SiteName, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for name, monitor := range existingOpenFlareMonitors {
|
||||
if expectedSitesMap[name] {
|
||||
continue
|
||||
}
|
||||
slog.Info("Deleting monitor in Uptime Kuma", "name", name, "monitorID", monitor.ID)
|
||||
deleteAck, err := client.Emit("deleteMonitor", monitor.ID)
|
||||
if err != nil {
|
||||
slog.Error("Failed to delete monitor in Uptime Kuma", "name", name, "monitorID", monitor.ID, "error", err)
|
||||
continue
|
||||
}
|
||||
if err := ParseAckResponse(deleteAck, nil); err != nil {
|
||||
slog.Error("Failed to parse delete monitor result", "name", name, "monitorID", monitor.ID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func filterExpectedRoutes(allRoutes []*model.ProxyRoute) ([]*model.ProxyRoute, error) {
|
||||
scope := model.UptimeKumaMonitorScope
|
||||
if scope == "selected" {
|
||||
selectedList := strings.Split(model.UptimeKumaSelectedSites, ",")
|
||||
selectedMap := make(map[string]bool)
|
||||
for _, name := range selectedList {
|
||||
trimmedName := strings.TrimSpace(name)
|
||||
if trimmedName != "" {
|
||||
selectedMap[trimmedName] = true
|
||||
}
|
||||
}
|
||||
var expectedRoutes []*model.ProxyRoute
|
||||
for _, route := range allRoutes {
|
||||
if route.Enabled && selectedMap[route.SiteName] {
|
||||
expectedRoutes = append(expectedRoutes, route)
|
||||
}
|
||||
}
|
||||
return expectedRoutes, nil
|
||||
}
|
||||
|
||||
var expectedRoutes []*model.ProxyRoute
|
||||
for _, route := range allRoutes {
|
||||
if route.Enabled {
|
||||
expectedRoutes = append(expectedRoutes, route)
|
||||
}
|
||||
}
|
||||
return expectedRoutes, nil
|
||||
}
|
||||
|
||||
func ensureOpenFlareTag(client *SocketIOClient) (int, error) {
|
||||
slog.Debug("Fetching tags from Uptime Kuma")
|
||||
tagsAck, err := client.Emit("getTags")
|
||||
if err != nil {
|
||||
slog.Error("Failed to request tags from Uptime Kuma", "error", err)
|
||||
return 0, fmt.Errorf("failed to fetch tags: %w", err)
|
||||
}
|
||||
|
||||
var tagsResult struct {
|
||||
Ok bool `json:"ok"`
|
||||
Tags []UptimeKumaTagItem `json:"tags"`
|
||||
}
|
||||
if err := ParseAckResponse(tagsAck, &tagsResult); err != nil {
|
||||
slog.Error("Failed to parse tags response from Uptime Kuma", "error", err)
|
||||
return 0, fmt.Errorf("parse tags response failed: %w", err)
|
||||
}
|
||||
|
||||
for _, tag := range tagsResult.Tags {
|
||||
if tag.Name == "OpenFlare" {
|
||||
slog.Debug("Found existing OpenFlare tag", "tag_id", tag.ID)
|
||||
return tag.ID, nil
|
||||
}
|
||||
}
|
||||
|
||||
slog.Debug("OpenFlare tag not found, creating new tag")
|
||||
addTagAck, err := client.Emit("addTag", map[string]string{
|
||||
"name": "OpenFlare",
|
||||
"color": "#4f46e5",
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("Failed to create OpenFlare tag in Uptime Kuma", "error", err)
|
||||
return 0, fmt.Errorf("failed to create tag: %w", err)
|
||||
}
|
||||
|
||||
var tagResult struct {
|
||||
Ok bool `json:"ok"`
|
||||
Tag struct {
|
||||
ID int `json:"id"`
|
||||
} `json:"tag"`
|
||||
}
|
||||
if err := ParseAckResponse(addTagAck, &tagResult); err != nil || tagResult.Tag.ID == 0 {
|
||||
slog.Error("Failed to parse addTag response from Uptime Kuma", "error", err)
|
||||
return 0, fmt.Errorf("parse addTag response failed: %w", err)
|
||||
}
|
||||
|
||||
slog.Debug("Successfully created OpenFlare tag", "tag_id", tagResult.Tag.ID)
|
||||
return tagResult.Tag.ID, nil
|
||||
}
|
||||
|
||||
func filterOpenFlareMonitors(monitors map[string]UptimeKumaMonitor, openFlareTagID int) map[string]UptimeKumaMonitor {
|
||||
existingOpenFlareMonitors := make(map[string]UptimeKumaMonitor)
|
||||
for _, monitor := range monitors {
|
||||
hasOpenFlareTag := false
|
||||
for _, tag := range monitor.Tags {
|
||||
if tag.Name == "OpenFlare" || tag.ID == openFlareTagID {
|
||||
hasOpenFlareTag = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if hasOpenFlareTag {
|
||||
existingOpenFlareMonitors[monitor.Name] = monitor
|
||||
}
|
||||
}
|
||||
return existingOpenFlareMonitors
|
||||
}
|
||||
|
||||
func routeMonitorURL(route *model.ProxyRoute) (string, error) {
|
||||
domains, err := decodeStoredDomains(route.Domains, route.Domain)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
domain := route.Domain
|
||||
if len(domains) > 0 {
|
||||
domain = domains[0]
|
||||
}
|
||||
if route.EnableHTTPS {
|
||||
return "https://" + domain, nil
|
||||
}
|
||||
return "http://" + domain, nil
|
||||
}
|
||||
|
||||
func decodeStoredDomains(raw string, fallbackDomain string) ([]string, error) {
|
||||
text := strings.TrimSpace(raw)
|
||||
if text == "" {
|
||||
if strings.TrimSpace(fallbackDomain) == "" {
|
||||
return nil, fmt.Errorf("domain is empty")
|
||||
}
|
||||
return []string{fallbackDomain}, nil
|
||||
}
|
||||
|
||||
var domains []string
|
||||
if err := json.Unmarshal([]byte(text), &domains); err != nil {
|
||||
return nil, fmt.Errorf("domains payload is invalid: %w", err)
|
||||
}
|
||||
if len(domains) == 0 {
|
||||
if strings.TrimSpace(fallbackDomain) == "" {
|
||||
return nil, fmt.Errorf("domain is empty")
|
||||
}
|
||||
return []string{fallbackDomain}, nil
|
||||
}
|
||||
return domains, nil
|
||||
}
|
||||
|
||||
func monitorPayload(id int, name, targetURL string) map[string]any {
|
||||
payload := map[string]any{
|
||||
"type": "http",
|
||||
"name": name,
|
||||
"url": targetURL,
|
||||
"interval": model.UptimeKumaInterval,
|
||||
"maxretries": model.UptimeKumaRetry,
|
||||
"retryInterval": model.UptimeKumaRetryInterval,
|
||||
"timeout": model.UptimeKumaTimeout,
|
||||
"active": true,
|
||||
"resendInterval": 0,
|
||||
"expiryNotification": false,
|
||||
"ignoreTls": false,
|
||||
"accepted_statuscodes": []string{"200-299"},
|
||||
"dns_resolve_type": "A",
|
||||
"conditions": []any{},
|
||||
}
|
||||
if id > 0 {
|
||||
payload["id"] = id
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func monitorNeedsUpdate(existing UptimeKumaMonitor, targetURL string) bool {
|
||||
return existing.Url != targetURL ||
|
||||
existing.Interval != model.UptimeKumaInterval ||
|
||||
existing.MaxRetries != model.UptimeKumaRetry ||
|
||||
existing.RetryInterval != model.UptimeKumaRetryInterval ||
|
||||
existing.Timeout != model.UptimeKumaTimeout
|
||||
}
|
||||
|
||||
func createMonitor(client *SocketIOClient, siteName, targetURL string, openFlareTagID int) error {
|
||||
slog.Info("Creating monitor in Uptime Kuma", "name", siteName, "url", targetURL)
|
||||
addAck, err := client.Emit("add", monitorPayload(0, siteName, targetURL))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var addResult struct {
|
||||
Ok bool `json:"ok"`
|
||||
MonitorID int `json:"monitorID"`
|
||||
}
|
||||
if err := ParseAckResponse(addAck, &addResult); err != nil || addResult.MonitorID == 0 {
|
||||
return fmt.Errorf("parse add monitor result failed: %w", err)
|
||||
}
|
||||
|
||||
slog.Debug("Adding OpenFlare tag to the new monitor",
|
||||
"name", siteName,
|
||||
"monitor_id", addResult.MonitorID,
|
||||
"tag_id", openFlareTagID,
|
||||
)
|
||||
tagAck, err := client.Emit("addMonitorTag", openFlareTagID, addResult.MonitorID, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ParseAckResponse(tagAck, nil); err != nil {
|
||||
return fmt.Errorf("parse add tag result failed: %w", err)
|
||||
}
|
||||
|
||||
slog.Debug("OpenFlare tag successfully added to monitor", "name", siteName, "monitor_id", addResult.MonitorID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func updateMonitor(client *SocketIOClient, monitorID int, siteName, targetURL string) error {
|
||||
slog.Info("Updating monitor in Uptime Kuma due to settings mismatch", "name", siteName)
|
||||
editAck, err := client.Emit("editMonitor", monitorPayload(monitorID, siteName, targetURL))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ParseAckResponse(editAck, nil); err != nil {
|
||||
return fmt.Errorf("parse edit monitor result failed: %w", err)
|
||||
}
|
||||
slog.Info("Successfully updated monitor in Uptime Kuma", "name", siteName)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package uptimekuma
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/internal/db"
|
||||
"github.com/Rain-kl/Wavelet/internal/model"
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type mockKumaServer struct {
|
||||
mu sync.Mutex
|
||||
postsReceived []string
|
||||
pendingPackets chan string
|
||||
monitorList string
|
||||
}
|
||||
|
||||
func newMockKumaServer(monitorList string) *mockKumaServer {
|
||||
return &mockKumaServer{
|
||||
pendingPackets: make(chan string, 100),
|
||||
monitorList: monitorList,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *mockKumaServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
transport := r.URL.Query().Get("transport")
|
||||
sid := r.URL.Query().Get("sid")
|
||||
|
||||
if r.Method == http.MethodGet {
|
||||
if transport == "polling" && sid == "" {
|
||||
w.Header().Set("Content-Type", "text/plain;charset=UTF-8")
|
||||
_, _ = w.Write([]byte(`0{"sid":"mock-sid"}`))
|
||||
return
|
||||
}
|
||||
|
||||
if transport == "polling" && sid == "mock-sid" {
|
||||
w.Header().Set("Content-Type", "text/plain;charset=UTF-8")
|
||||
select {
|
||||
case pkt := <-s.pendingPackets:
|
||||
_, _ = w.Write([]byte(pkt))
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
_, _ = w.Write([]byte(""))
|
||||
}
|
||||
return
|
||||
}
|
||||
} else if r.Method == http.MethodPost {
|
||||
bodyBytes, _ := io.ReadAll(r.Body)
|
||||
bodyStr := string(bodyBytes)
|
||||
s.postsReceived = append(s.postsReceived, bodyStr)
|
||||
|
||||
w.Header().Set("Content-Type", "text/plain;charset=UTF-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
if bodyStr == "40" {
|
||||
s.pendingPackets <- fmt.Sprintf(`42["monitorList",%s]`, s.monitorList)
|
||||
return
|
||||
}
|
||||
|
||||
if strings.HasPrefix(bodyStr, "42") {
|
||||
payload := bodyStr[2:]
|
||||
digitsEnd := 0
|
||||
for digitsEnd < len(payload) && payload[digitsEnd] >= '0' && payload[digitsEnd] <= '9' {
|
||||
digitsEnd++
|
||||
}
|
||||
if digitsEnd == 0 {
|
||||
return
|
||||
}
|
||||
ackIDStr := payload[:digitsEnd]
|
||||
jsonArrayStr := payload[digitsEnd:]
|
||||
|
||||
var arr []json.RawMessage
|
||||
if err := json.Unmarshal([]byte(jsonArrayStr), &arr); err != nil || len(arr) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
var eventName string
|
||||
_ = json.Unmarshal(arr[0], &eventName)
|
||||
|
||||
switch eventName {
|
||||
case "login", "loginByToken":
|
||||
s.pendingPackets <- fmt.Sprintf("43%s[{\"ok\":true}]", ackIDStr)
|
||||
case "getTags":
|
||||
s.pendingPackets <- fmt.Sprintf("43%s[{\"ok\":true,\"tags\":[{\"id\":10,\"name\":\"OpenFlare\",\"color\":\"#4f46e5\"}]}]", ackIDStr)
|
||||
case "addTag":
|
||||
s.pendingPackets <- fmt.Sprintf("43%s[{\"ok\":true,\"tag\":{\"id\":10}}]", ackIDStr)
|
||||
case "add":
|
||||
s.pendingPackets <- fmt.Sprintf("43%s[{\"ok\":true,\"monitorID\":100}]", ackIDStr)
|
||||
case "addMonitorTag":
|
||||
s.pendingPackets <- fmt.Sprintf("43%s[{\"ok\":true}]", ackIDStr)
|
||||
case "editMonitor":
|
||||
s.pendingPackets <- fmt.Sprintf("43%s[{\"ok\":true}]", ackIDStr)
|
||||
case "deleteMonitor":
|
||||
s.pendingPackets <- fmt.Sprintf("43%s[{\"ok\":true}]", ackIDStr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func setupSyncTestDB(t *testing.T) func() {
|
||||
t.Helper()
|
||||
|
||||
sqliteDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
|
||||
DisableForeignKeyConstraintWhenMigrating: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, sqliteDB.AutoMigrate(&model.ProxyRoute{}))
|
||||
|
||||
db.SetDB(sqliteDB)
|
||||
return func() {
|
||||
db.SetDB(nil)
|
||||
}
|
||||
}
|
||||
|
||||
func backupUptimeKumaConfig() func() {
|
||||
oldEnabled := model.UptimeKumaEnabled
|
||||
oldURL := model.UptimeKumaUrl
|
||||
oldUsername := model.UptimeKumaUsername
|
||||
oldPassword := model.UptimeKumaPassword
|
||||
oldScope := model.UptimeKumaMonitorScope
|
||||
oldSelected := model.UptimeKumaSelectedSites
|
||||
oldInterval := model.UptimeKumaInterval
|
||||
oldRetry := model.UptimeKumaRetry
|
||||
oldRetryInterval := model.UptimeKumaRetryInterval
|
||||
oldTimeout := model.UptimeKumaTimeout
|
||||
|
||||
return func() {
|
||||
model.UptimeKumaEnabled = oldEnabled
|
||||
model.UptimeKumaUrl = oldURL
|
||||
model.UptimeKumaUsername = oldUsername
|
||||
model.UptimeKumaPassword = oldPassword
|
||||
model.UptimeKumaMonitorScope = oldScope
|
||||
model.UptimeKumaSelectedSites = oldSelected
|
||||
model.UptimeKumaInterval = oldInterval
|
||||
model.UptimeKumaRetry = oldRetry
|
||||
model.UptimeKumaRetryInterval = oldRetryInterval
|
||||
model.UptimeKumaTimeout = oldTimeout
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncToUptimeKumaDisabled(t *testing.T) {
|
||||
cleanup := setupSyncTestDB(t)
|
||||
defer cleanup()
|
||||
restore := backupUptimeKumaConfig()
|
||||
defer restore()
|
||||
|
||||
model.UptimeKumaEnabled = false
|
||||
|
||||
err := SyncToUptimeKuma(context.Background())
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "disabled")
|
||||
}
|
||||
|
||||
func TestSyncToUptimeKumaSuccess(t *testing.T) {
|
||||
cleanup := setupSyncTestDB(t)
|
||||
defer cleanup()
|
||||
restore := backupUptimeKumaConfig()
|
||||
defer restore()
|
||||
ctx := context.Background()
|
||||
|
||||
require.NoError(t, db.DB(ctx).Where("1 = 1").Delete(&model.ProxyRoute{}).Error)
|
||||
|
||||
routeA := &model.ProxyRoute{
|
||||
SiteName: "site-a",
|
||||
Domain: "site-a.com",
|
||||
Domains: `["site-a.com"]`,
|
||||
OriginURL: "http://10.0.0.1",
|
||||
Enabled: true,
|
||||
EnableHTTPS: false,
|
||||
}
|
||||
routeB := &model.ProxyRoute{
|
||||
SiteName: "site-b",
|
||||
Domain: "site-b.com",
|
||||
Domains: `["site-b.com"]`,
|
||||
OriginURL: "https://10.0.0.2",
|
||||
Enabled: true,
|
||||
EnableHTTPS: true,
|
||||
}
|
||||
routeC := &model.ProxyRoute{
|
||||
SiteName: "site-c",
|
||||
Domain: "site-c.com",
|
||||
Domains: `["site-c.com"]`,
|
||||
OriginURL: "http://10.0.0.3",
|
||||
Enabled: false,
|
||||
EnableHTTPS: false,
|
||||
}
|
||||
|
||||
require.NoError(t, model.CreateProxyRouteRecord(ctx, routeA))
|
||||
require.NoError(t, model.CreateProxyRouteRecord(ctx, routeB))
|
||||
require.NoError(t, model.CreateProxyRouteRecord(ctx, routeC))
|
||||
|
||||
monitorListJSON := `{
|
||||
"99": {
|
||||
"id": 99,
|
||||
"name": "site-old",
|
||||
"url": "http://site-old.com",
|
||||
"interval": 60,
|
||||
"tags": [{"tag_id": 10, "name": "OpenFlare"}]
|
||||
},
|
||||
"98": {
|
||||
"id": 98,
|
||||
"name": "site-a",
|
||||
"url": "http://site-a.com",
|
||||
"interval": 30,
|
||||
"tags": [{"tag_id": 10, "name": "OpenFlare"}]
|
||||
}
|
||||
}`
|
||||
|
||||
mockSrv := newMockKumaServer(monitorListJSON)
|
||||
server := httptest.NewServer(mockSrv)
|
||||
defer server.Close()
|
||||
|
||||
model.UptimeKumaEnabled = true
|
||||
model.UptimeKumaUrl = server.URL
|
||||
model.UptimeKumaUsername = "admin"
|
||||
model.UptimeKumaPassword = "password"
|
||||
model.UptimeKumaMonitorScope = "all"
|
||||
model.UptimeKumaInterval = 60
|
||||
model.UptimeKumaRetry = 0
|
||||
model.UptimeKumaRetryInterval = 60
|
||||
model.UptimeKumaTimeout = 48
|
||||
|
||||
require.NoError(t, SyncToUptimeKuma(ctx))
|
||||
|
||||
mockSrv.mu.Lock()
|
||||
posts := mockSrv.postsReceived
|
||||
mockSrv.mu.Unlock()
|
||||
|
||||
hasLogin := false
|
||||
hasGetTags := false
|
||||
hasAddSiteB := false
|
||||
hasTagSiteB := false
|
||||
hasEditSiteA := false
|
||||
hasDeleteOld := false
|
||||
|
||||
for _, body := range posts {
|
||||
if strings.Contains(body, `"login"`) && strings.Contains(body, `"admin"`) && strings.Contains(body, `"password"`) {
|
||||
hasLogin = true
|
||||
}
|
||||
if strings.Contains(body, `"getTags"`) {
|
||||
hasGetTags = true
|
||||
}
|
||||
if strings.Contains(body, `"add"`) && strings.Contains(body, `"site-b"`) && strings.Contains(body, `"https://site-b.com"`) {
|
||||
hasAddSiteB = true
|
||||
}
|
||||
if strings.Contains(body, `"addMonitorTag"`) && strings.Contains(body, `10`) && strings.Contains(body, `100`) {
|
||||
hasTagSiteB = true
|
||||
}
|
||||
if strings.Contains(body, `"editMonitor"`) && strings.Contains(body, `98`) && strings.Contains(body, `"site-a"`) && strings.Contains(body, `"interval":60`) {
|
||||
hasEditSiteA = true
|
||||
}
|
||||
if strings.Contains(body, `"deleteMonitor"`) && strings.Contains(body, `99`) {
|
||||
hasDeleteOld = true
|
||||
}
|
||||
}
|
||||
|
||||
assert.True(t, hasLogin, "expected login event to be called")
|
||||
assert.True(t, hasGetTags, "expected getTags event to be called")
|
||||
assert.True(t, hasAddSiteB, "expected site-b to be added")
|
||||
assert.True(t, hasTagSiteB, "expected site-b to be tagged")
|
||||
assert.True(t, hasEditSiteA, "expected site-a to be edited/updated")
|
||||
assert.True(t, hasDeleteOld, "expected site-old to be deleted")
|
||||
}
|
||||
|
||||
func TestSyncToUptimeKumaSelectedScope(t *testing.T) {
|
||||
cleanup := setupSyncTestDB(t)
|
||||
defer cleanup()
|
||||
restore := backupUptimeKumaConfig()
|
||||
defer restore()
|
||||
ctx := context.Background()
|
||||
|
||||
require.NoError(t, db.DB(ctx).Where("1 = 1").Delete(&model.ProxyRoute{}).Error)
|
||||
|
||||
routeA := &model.ProxyRoute{
|
||||
SiteName: "site-a",
|
||||
Domain: "site-a.com",
|
||||
Domains: `["site-a.com"]`,
|
||||
OriginURL: "http://10.0.0.1",
|
||||
Enabled: true,
|
||||
EnableHTTPS: false,
|
||||
}
|
||||
routeB := &model.ProxyRoute{
|
||||
SiteName: "site-b",
|
||||
Domain: "site-b.com",
|
||||
Domains: `["site-b.com"]`,
|
||||
OriginURL: "http://10.0.0.2",
|
||||
Enabled: true,
|
||||
EnableHTTPS: false,
|
||||
}
|
||||
|
||||
require.NoError(t, model.CreateProxyRouteRecord(ctx, routeA))
|
||||
require.NoError(t, model.CreateProxyRouteRecord(ctx, routeB))
|
||||
|
||||
mockSrv := newMockKumaServer(`{}`)
|
||||
server := httptest.NewServer(mockSrv)
|
||||
defer server.Close()
|
||||
|
||||
model.UptimeKumaEnabled = true
|
||||
model.UptimeKumaUrl = server.URL
|
||||
model.UptimeKumaUsername = "admin"
|
||||
model.UptimeKumaPassword = "password"
|
||||
model.UptimeKumaMonitorScope = "selected"
|
||||
model.UptimeKumaSelectedSites = "site-a"
|
||||
|
||||
require.NoError(t, SyncToUptimeKuma(ctx))
|
||||
|
||||
mockSrv.mu.Lock()
|
||||
posts := mockSrv.postsReceived
|
||||
mockSrv.mu.Unlock()
|
||||
|
||||
hasLogin := false
|
||||
hasAddSiteA := false
|
||||
hasAddSiteB := false
|
||||
|
||||
for _, body := range posts {
|
||||
if strings.Contains(body, `"login"`) && strings.Contains(body, `"admin"`) && strings.Contains(body, `"password"`) {
|
||||
hasLogin = true
|
||||
}
|
||||
if strings.Contains(body, `"add"`) && strings.Contains(body, `"site-a"`) {
|
||||
hasAddSiteA = true
|
||||
}
|
||||
if strings.Contains(body, `"add"`) && strings.Contains(body, `"site-b"`) {
|
||||
hasAddSiteB = true
|
||||
}
|
||||
}
|
||||
|
||||
assert.True(t, hasLogin, "expected login event to be called")
|
||||
assert.True(t, hasAddSiteA, "expected site-a to be added")
|
||||
assert.False(t, hasAddSiteB, "expected site-b NOT to be added (not in selected scope)")
|
||||
}
|
||||
Reference in New Issue
Block a user