T3MP3ST public launch build
This commit is contained in:
1 parent
972247b881
commit
184c7d4e59
1589 files changed
+57150
-506
No files matched your search
@@ -0,0 +1,281 @@
|
||||
--- model/notification.go
|
||||
@@ -1,10 +1,14 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
+ "context"
|
||||
+ "crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
+ "net"
|
||||
"net/http"
|
||||
+ "net/netip"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -25,6 +29,35 @@ const (
|
||||
NotificationRequestMethodPOST
|
||||
)
|
||||
|
||||
+var errNotificationURLNotAllowed = errors.New("notification URL target is not allowed")
|
||||
+
|
||||
+var notificationBlockedCIDRs = mustParseNotificationCIDRs([]string{
|
||||
+ "0.0.0.0/8",
|
||||
+ "10.0.0.0/8",
|
||||
+ "100.64.0.0/10",
|
||||
+ "127.0.0.0/8",
|
||||
+ "169.254.0.0/16",
|
||||
+ "172.16.0.0/12",
|
||||
+ "192.0.0.0/24",
|
||||
+ "192.0.2.0/24",
|
||||
+ "192.168.0.0/16",
|
||||
+ "198.18.0.0/15",
|
||||
+ "198.51.100.0/24",
|
||||
+ "203.0.113.0/24",
|
||||
+ "224.0.0.0/4",
|
||||
+ "240.0.0.0/4",
|
||||
+ "::/128",
|
||||
+ "::1/128",
|
||||
+ "::ffff:0:0/96",
|
||||
+ "64:ff9b::/96",
|
||||
+ "100::/64",
|
||||
+ "2001::/23",
|
||||
+ "2001:db8::/32",
|
||||
+ "fc00::/7",
|
||||
+ "fe80::/10",
|
||||
+ "ff00::/8",
|
||||
+})
|
||||
+
|
||||
type NotificationServerBundle struct {
|
||||
Notification *Notification
|
||||
Server *Server
|
||||
@@ -111,13 +144,8 @@ func (n *Notification) setRequestHeader(req *http.Request) error {
|
||||
}
|
||||
|
||||
func (ns *NotificationServerBundle) Send(message string) error {
|
||||
- var client *http.Client
|
||||
n := ns.Notification
|
||||
- if n.VerifyTLS != nil && *n.VerifyTLS {
|
||||
- client = utils.HttpClient
|
||||
- } else {
|
||||
- client = utils.HttpClientSkipTlsVerify
|
||||
- }
|
||||
+ verifyTLS := n.VerifyTLS != nil && *n.VerifyTLS
|
||||
|
||||
reqBody, err := ns.reqBody(message)
|
||||
if err != nil {
|
||||
@@ -129,7 +157,13 @@ func (ns *NotificationServerBundle) Send(message string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
- req, err := http.NewRequest(reqMethod, ns.reqURL(message), strings.NewReader(reqBody))
|
||||
+ reqURL := ns.reqURL(message)
|
||||
+ client, err := newNotificationHTTPClient(reqURL, verifyTLS)
|
||||
+ if err != nil {
|
||||
+ return err
|
||||
+ }
|
||||
+
|
||||
+ req, err := http.NewRequest(reqMethod, reqURL, strings.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -149,15 +183,115 @@ func (ns *NotificationServerBundle) Send(message string) error {
|
||||
}()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode > 299 {
|
||||
- body, _ := io.ReadAll(resp.Body)
|
||||
- return fmt.Errorf("%d@%s %s", resp.StatusCode, resp.Status, string(body))
|
||||
+ return notificationResponseError(resp)
|
||||
} else {
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
+func newNotificationHTTPClient(rawURL string, verifyTLS bool) (*http.Client, error) {
|
||||
+ parsedURL, ip, err := resolveNotificationTarget(rawURL)
|
||||
+ if err != nil {
|
||||
+ return nil, err
|
||||
+ }
|
||||
+
|
||||
+ port := parsedURL.Port()
|
||||
+ if port == "" {
|
||||
+ if parsedURL.Scheme == "https" {
|
||||
+ port = "443"
|
||||
+ } else {
|
||||
+ port = "80"
|
||||
+ }
|
||||
+ }
|
||||
+ targetAddress := net.JoinHostPort(ip.String(), port)
|
||||
+ dialer := &net.Dialer{}
|
||||
+
|
||||
+ return &http.Client{
|
||||
+ Transport: &http.Transport{
|
||||
+ DialContext: func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
+ return dialer.DialContext(ctx, network, targetAddress)
|
||||
+ },
|
||||
+ TLSClientConfig: &tls.Config{InsecureSkipVerify: !verifyTLS, ServerName: parsedURL.Hostname()},
|
||||
+ },
|
||||
+ CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
+ return http.ErrUseLastResponse
|
||||
+ },
|
||||
+ Timeout: time.Minute * 10,
|
||||
+ }, nil
|
||||
+}
|
||||
+
|
||||
+func notificationResponseError(resp *http.Response) error {
|
||||
+ _, _ = io.CopyN(io.Discard, resp.Body, 4096)
|
||||
+ return fmt.Errorf("%d@%s", resp.StatusCode, resp.Status)
|
||||
+}
|
||||
+
|
||||
+func resolveNotificationTarget(rawURL string) (*url.URL, net.IP, error) {
|
||||
+ parsedURL, err := url.Parse(rawURL)
|
||||
+ if err != nil {
|
||||
+ return nil, nil, err
|
||||
+ }
|
||||
+ if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
|
||||
+ return nil, nil, errNotificationURLNotAllowed
|
||||
+ }
|
||||
+
|
||||
+ host := parsedURL.Hostname()
|
||||
+ if host == "" {
|
||||
+ return nil, nil, errNotificationURLNotAllowed
|
||||
+ }
|
||||
+ if ip := net.ParseIP(host); ip != nil {
|
||||
+ if !notificationIPAllowed(ip) {
|
||||
+ return nil, nil, errNotificationURLNotAllowed
|
||||
+ }
|
||||
+ return parsedURL, ip, nil
|
||||
+ }
|
||||
+
|
||||
+ ips, err := net.LookupIP(host)
|
||||
+ if err != nil {
|
||||
+ return nil, nil, err
|
||||
+ }
|
||||
+ if len(ips) == 0 {
|
||||
+ return nil, nil, errNotificationURLNotAllowed
|
||||
+ }
|
||||
+ for _, ip := range ips {
|
||||
+ if !notificationIPAllowed(ip) {
|
||||
+ return nil, nil, errNotificationURLNotAllowed
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ return parsedURL, ips[0], nil
|
||||
+}
|
||||
+
|
||||
+func notificationIPAllowed(ip net.IP) bool {
|
||||
+ parsedIP, ok := netipFromIP(ip)
|
||||
+ if !ok {
|
||||
+ return false
|
||||
+ }
|
||||
+ for _, cidr := range notificationBlockedCIDRs {
|
||||
+ if cidr.Contains(parsedIP) {
|
||||
+ return false
|
||||
+ }
|
||||
+ }
|
||||
+ return parsedIP.IsGlobalUnicast()
|
||||
+}
|
||||
+
|
||||
+func netipFromIP(ip net.IP) (netip.Addr, bool) {
|
||||
+ parsedIP, ok := netip.AddrFromSlice(ip)
|
||||
+ if !ok {
|
||||
+ return netip.Addr{}, false
|
||||
+ }
|
||||
+ return parsedIP.Unmap(), true
|
||||
+}
|
||||
+
|
||||
+func mustParseNotificationCIDRs(cidrs []string) []netip.Prefix {
|
||||
+ prefixes := make([]netip.Prefix, 0, len(cidrs))
|
||||
+ for _, cidr := range cidrs {
|
||||
+ prefixes = append(prefixes, netip.MustParsePrefix(cidr))
|
||||
+ }
|
||||
+ return prefixes
|
||||
+}
|
||||
+
|
||||
// replaceParamInString 替换字符串中的占位符
|
||||
func (ns *NotificationServerBundle) replaceParamsInString(str string, message string, mod func(string) string) string {
|
||||
if mod == nil {
|
||||
|
||||
--- model/notification_test.go
|
||||
@@ -1,6 +1,7 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
+ "io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -234,3 +235,70 @@ func TestNotification(t *testing.T) {
|
||||
execCase(t, c)
|
||||
}
|
||||
}
|
||||
+
|
||||
+func TestNotificationResponseErrorDoesNotReflectNonSuccessResponseBody(t *testing.T) {
|
||||
+ const internalResponseBody = "internal service says token=secret"
|
||||
+
|
||||
+ resp := &http.Response{
|
||||
+ StatusCode: http.StatusTeapot,
|
||||
+ Status: "418 I'm a teapot",
|
||||
+ Body: io.NopCloser(strings.NewReader(internalResponseBody)),
|
||||
+ }
|
||||
+
|
||||
+ err := notificationResponseError(resp)
|
||||
+ if strings.Contains(err.Error(), internalResponseBody) {
|
||||
+ t.Fatalf("expected upstream response body to be hidden from error, got %q", err.Error())
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
+func TestNotificationSendRejectsLoopbackTarget(t *testing.T) {
|
||||
+ verifyTLS := true
|
||||
+ notification := &Notification{
|
||||
+ URL: "http://127.0.0.1/internal",
|
||||
+ RequestMethod: NotificationRequestMethodGET,
|
||||
+ VerifyTLS: &verifyTLS,
|
||||
+ }
|
||||
+
|
||||
+ bundle := NotificationServerBundle{
|
||||
+ Notification: notification,
|
||||
+ Loc: time.Local,
|
||||
+ }
|
||||
+
|
||||
+ err := bundle.Send("probe")
|
||||
+ if err == nil {
|
||||
+ t.Fatal("expected loopback notification URL to be rejected")
|
||||
+ }
|
||||
+ if !strings.Contains(err.Error(), "not allowed") {
|
||||
+ t.Fatalf("expected not allowed error, got %q", err.Error())
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
+func TestNotificationTargetRejectsSpecialUseAddresses(t *testing.T) {
|
||||
+ cases := []string{
|
||||
+ "http://100.64.0.1/", // CGNAT
|
||||
+ "http://192.0.2.1/", // documentation range
|
||||
+ "http://[fc00::1]/", // IPv6 unique local
|
||||
+ "http://[2001:db8::1]/", // IPv6 documentation range
|
||||
+ "http://[::ffff:127.0.0.1]/", // IPv4-mapped loopback
|
||||
+ }
|
||||
+
|
||||
+ for _, rawURL := range cases {
|
||||
+ if _, _, err := resolveNotificationTarget(rawURL); err == nil {
|
||||
+ t.Fatalf("expected %s to be rejected", rawURL)
|
||||
+ }
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
+func TestNotificationHTTPClientPreservesTLSServerName(t *testing.T) {
|
||||
+ client, err := newNotificationHTTPClient("https://example.com/webhook", true)
|
||||
+ if err != nil {
|
||||
+ t.Fatalf("expected public HTTPS URL to create client: %v", err)
|
||||
+ }
|
||||
+ transport, ok := client.Transport.(*http.Transport)
|
||||
+ if !ok {
|
||||
+ t.Fatalf("expected http.Transport, got %T", client.Transport)
|
||||
+ }
|
||||
+ if transport.TLSClientConfig == nil || transport.TLSClientConfig.ServerName != "example.com" {
|
||||
+ t.Fatalf("expected TLS ServerName example.com, got %#v", transport.TLSClientConfig)
|
||||
+ }
|
||||
+}
|
||||
@@ -0,0 +1 @@
|
||||
{"ghsa": "GHSA-w4g9-mxgg-j532", "cve": "CVE-2026-46717", "repo": "nezhahq/nezha", "severity": "high", "cwes": ["CWE-863", "CWE-918"], "summary": "Nezha Monitoring: RoleMember-reachable SSRF with full response-body reflection via POST /api/v1/notification", "fix_commit": "d06d539d34c143d842b91e2a64326e8c8f9bc405", "parent_commit": "85b0dd2992733037b019442caffc6c049ba937dd", "vuln_files": ["model/notification.go", "model/notification_test.go"], "vuln_lines": {"model/notification.go": [114, 116, 117, 118, 119, 120, 132, 152, 153], "model/notification_test.go": []}, "published": "2026-05-23T00:08:04Z", "source_files": 40, "scope_dirs": ["model"]}
|
||||
@@ -0,0 +1,141 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"slices"
|
||||
|
||||
"github.com/goccy/go-json"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
ModeAlwaysTrigger = 0
|
||||
ModeOnetimeTrigger = 1
|
||||
)
|
||||
|
||||
type AlertRule struct {
|
||||
Common
|
||||
Name string `json:"name"`
|
||||
RulesRaw string `json:"-"`
|
||||
Enable *bool `json:"enable,omitempty"`
|
||||
TriggerMode uint8 `gorm:"default:0" json:"trigger_mode"` // 触发模式: 0-始终触发(默认) 1-单次触发
|
||||
NotificationGroupID uint64 `json:"notification_group_id"` // 该报警规则所在的通知组
|
||||
FailTriggerTasksRaw string `gorm:"default:'[]'" json:"-"`
|
||||
RecoverTriggerTasksRaw string `gorm:"default:'[]'" json:"-"`
|
||||
Rules []*Rule `gorm:"-" json:"rules"`
|
||||
FailTriggerTasks []uint64 `gorm:"-" json:"fail_trigger_tasks"` // 失败时执行的触发任务id
|
||||
RecoverTriggerTasks []uint64 `gorm:"-" json:"recover_trigger_tasks"` // 恢复时执行的触发任务id
|
||||
}
|
||||
|
||||
func (r *AlertRule) BeforeSave(tx *gorm.DB) error {
|
||||
if data, err := json.Marshal(r.Rules); err != nil {
|
||||
return err
|
||||
} else {
|
||||
r.RulesRaw = string(data)
|
||||
}
|
||||
if data, err := json.Marshal(r.FailTriggerTasks); err != nil {
|
||||
return err
|
||||
} else {
|
||||
r.FailTriggerTasksRaw = string(data)
|
||||
}
|
||||
if data, err := json.Marshal(r.RecoverTriggerTasks); err != nil {
|
||||
return err
|
||||
} else {
|
||||
r.RecoverTriggerTasksRaw = string(data)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *AlertRule) AfterFind(tx *gorm.DB) error {
|
||||
var err error
|
||||
if err = json.Unmarshal([]byte(r.RulesRaw), &r.Rules); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = json.Unmarshal([]byte(r.FailTriggerTasksRaw), &r.FailTriggerTasks); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = json.Unmarshal([]byte(r.RecoverTriggerTasksRaw), &r.RecoverTriggerTasks); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *AlertRule) Enabled() bool {
|
||||
return r.Enable != nil && *r.Enable
|
||||
}
|
||||
|
||||
// Snapshot 对传入的Server进行该报警规则下所有type的检查 返回每项检查结果
|
||||
func (r *AlertRule) Snapshot(cycleTransferStats *CycleTransferStats, server *Server, db *gorm.DB) []bool {
|
||||
point := make([]bool, len(r.Rules))
|
||||
|
||||
for i, rule := range r.Rules {
|
||||
point[i] = rule.Snapshot(cycleTransferStats, server, db)
|
||||
}
|
||||
return point
|
||||
}
|
||||
|
||||
// Check 传入包含当前报警规则下所有type检查结果 返回报警持续时间与是否通过报警检查(通过则返回true)
|
||||
func (r *AlertRule) Check(points [][]bool) (int, bool) {
|
||||
var hasPassedRule bool
|
||||
durations := make([]int, len(r.Rules))
|
||||
|
||||
for ruleIndex, rule := range r.Rules {
|
||||
duration := int(rule.Duration)
|
||||
if rule.IsTransferDurationRule() {
|
||||
// 循环区间流量报警
|
||||
if durations[ruleIndex] < 1 {
|
||||
durations[ruleIndex] = 1
|
||||
}
|
||||
if hasPassedRule {
|
||||
continue
|
||||
}
|
||||
// 只要最后一次检查超出了规则范围 就认为检查未通过
|
||||
if len(points) > 0 && points[len(points)-1][ruleIndex] {
|
||||
hasPassedRule = true
|
||||
}
|
||||
} else if rule.IsOfflineRule() {
|
||||
// 离线报警,检查直到最后一次在线的离线采样点是否大于 duration
|
||||
if hasPassedRule = boundCheck(len(points), duration, hasPassedRule); hasPassedRule {
|
||||
continue
|
||||
}
|
||||
var fail int
|
||||
for _, point := range slices.Backward(points[len(points)-duration:]) {
|
||||
fail++
|
||||
if point[ruleIndex] {
|
||||
hasPassedRule = true
|
||||
break
|
||||
}
|
||||
}
|
||||
durations[ruleIndex] = fail
|
||||
continue
|
||||
} else {
|
||||
// 常规报警
|
||||
if hasPassedRule = boundCheck(len(points), duration, hasPassedRule); hasPassedRule {
|
||||
continue
|
||||
}
|
||||
if duration > durations[ruleIndex] {
|
||||
durations[ruleIndex] = duration
|
||||
}
|
||||
total, fail := duration, 0
|
||||
for timeTick := len(points) - duration; timeTick < len(points); timeTick++ {
|
||||
if !points[timeTick][ruleIndex] {
|
||||
fail++
|
||||
}
|
||||
}
|
||||
// 当70%以上的采样点未通过规则判断时 才认为当前检查未通过
|
||||
if fail*100/total <= 70 {
|
||||
hasPassedRule = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 仅当所有检查均未通过时 才触发告警
|
||||
return slices.Max(durations), hasPassedRule
|
||||
}
|
||||
|
||||
func boundCheck(length, duration int, passed bool) bool {
|
||||
if passed {
|
||||
return true
|
||||
}
|
||||
// 如果采样点数量不足 则认为检查通过
|
||||
return length < duration
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package model
|
||||
|
||||
type AlertRuleForm struct {
|
||||
Name string `json:"name" minLength:"1"`
|
||||
Rules []*Rule `json:"rules"`
|
||||
FailTriggerTasks []uint64 `json:"fail_trigger_tasks"` // 失败时触发的任务id
|
||||
RecoverTriggerTasks []uint64 `json:"recover_trigger_tasks"` // 恢复时触发的任务id
|
||||
NotificationGroupID uint64 `json:"notification_group_id"`
|
||||
TriggerMode uint8 `json:"trigger_mode" default:"0"`
|
||||
Enable bool `json:"enable" validate:"optional"`
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type arSt struct {
|
||||
msg string
|
||||
rule *AlertRule
|
||||
points [][]bool
|
||||
expD int
|
||||
exp bool
|
||||
}
|
||||
|
||||
func TestAlertRules(t *testing.T) {
|
||||
t.Run("CycleRules", testCycleRules)
|
||||
t.Run("OfflineRules", testOfflineRules)
|
||||
t.Run("GeneralRules", testGeneralRules)
|
||||
t.Run("CombinedRules", testCombinedRules)
|
||||
}
|
||||
|
||||
func testCycleRules(t *testing.T) {
|
||||
cases := []arSt{
|
||||
{
|
||||
rule: &AlertRule{
|
||||
Rules: []*Rule{
|
||||
{
|
||||
Type: "_cycle",
|
||||
},
|
||||
},
|
||||
},
|
||||
msg: "CyclePass",
|
||||
points: [][]bool{{false}, {true}},
|
||||
expD: 1,
|
||||
exp: true,
|
||||
},
|
||||
{
|
||||
rule: &AlertRule{
|
||||
Rules: []*Rule{
|
||||
{
|
||||
Type: "_cycle",
|
||||
},
|
||||
},
|
||||
},
|
||||
msg: "CycleFail",
|
||||
points: [][]bool{{true}, {false}},
|
||||
expD: 1,
|
||||
exp: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
d, passed := c.rule.Check(c.points)
|
||||
assertEq(t, c.msg, c.expD, d)
|
||||
assertEq(t, c.msg, c.exp, passed)
|
||||
}
|
||||
}
|
||||
|
||||
func testOfflineRules(t *testing.T) {
|
||||
cases := []arSt{
|
||||
{
|
||||
rule: &AlertRule{
|
||||
Rules: []*Rule{
|
||||
{
|
||||
Type: "offline",
|
||||
Duration: 10,
|
||||
},
|
||||
},
|
||||
},
|
||||
msg: "OfflineLast",
|
||||
points: append([][]bool{{true}}, repeat([]bool{false}, 9)...),
|
||||
expD: 10,
|
||||
exp: true,
|
||||
},
|
||||
{
|
||||
rule: &AlertRule{
|
||||
Rules: []*Rule{
|
||||
{
|
||||
Type: "offline",
|
||||
Duration: 10,
|
||||
},
|
||||
},
|
||||
},
|
||||
msg: "OfflineMiddle",
|
||||
points: mod(repeat([]bool{false}, 10), true, 5),
|
||||
expD: 5,
|
||||
exp: true,
|
||||
},
|
||||
{
|
||||
rule: &AlertRule{
|
||||
Rules: []*Rule{
|
||||
{
|
||||
Type: "offline",
|
||||
Duration: 10,
|
||||
},
|
||||
},
|
||||
},
|
||||
msg: "OfflineFirst",
|
||||
points: mod(repeat([]bool{false}, 10), true, 9),
|
||||
expD: 1,
|
||||
exp: true,
|
||||
},
|
||||
{
|
||||
rule: &AlertRule{
|
||||
Rules: []*Rule{
|
||||
{
|
||||
Type: "offline",
|
||||
Duration: 10,
|
||||
},
|
||||
},
|
||||
},
|
||||
msg: "OfflineBoundCheck",
|
||||
points: repeat([]bool{false}, 9),
|
||||
expD: 0,
|
||||
exp: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
d, passed := c.rule.Check(c.points)
|
||||
assertEq(t, c.msg, c.expD, d)
|
||||
assertEq(t, c.msg, c.exp, passed)
|
||||
}
|
||||
}
|
||||
|
||||
func testGeneralRules(t *testing.T) {
|
||||
cases := []arSt{
|
||||
{
|
||||
rule: &AlertRule{
|
||||
Rules: []*Rule{
|
||||
{
|
||||
Duration: 10,
|
||||
},
|
||||
},
|
||||
},
|
||||
msg: "GeneralFail",
|
||||
points: repeat([]bool{false}, 10),
|
||||
expD: 10,
|
||||
exp: false,
|
||||
},
|
||||
{
|
||||
rule: &AlertRule{
|
||||
Rules: []*Rule{
|
||||
{
|
||||
Duration: 10,
|
||||
},
|
||||
},
|
||||
},
|
||||
msg: "GeneralFail80%",
|
||||
points: slices.Concat(repeat([]bool{false}, 8), repeat([]bool{true}, 2)),
|
||||
expD: 10,
|
||||
exp: false,
|
||||
},
|
||||
{
|
||||
rule: &AlertRule{
|
||||
Rules: []*Rule{
|
||||
{
|
||||
Duration: 10,
|
||||
},
|
||||
},
|
||||
},
|
||||
msg: "GeneralPass30%",
|
||||
points: slices.Concat(repeat([]bool{false}, 7), repeat([]bool{true}, 3)),
|
||||
expD: 10,
|
||||
exp: true,
|
||||
},
|
||||
{
|
||||
rule: &AlertRule{
|
||||
Rules: []*Rule{
|
||||
{
|
||||
Duration: 10,
|
||||
},
|
||||
},
|
||||
},
|
||||
msg: "GeneralPass",
|
||||
points: slices.Concat(repeat([]bool{false}, 4), repeat([]bool{true}, 6)),
|
||||
expD: 10,
|
||||
exp: true,
|
||||
},
|
||||
{
|
||||
rule: &AlertRule{
|
||||
Rules: []*Rule{
|
||||
{
|
||||
Duration: 10,
|
||||
},
|
||||
},
|
||||
},
|
||||
msg: "GeneralBoundCheck",
|
||||
points: repeat([]bool{false}, 9),
|
||||
expD: 0,
|
||||
exp: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
d, passed := c.rule.Check(c.points)
|
||||
assertEq(t, c.msg, c.expD, d)
|
||||
assertEq(t, c.msg, c.exp, passed)
|
||||
}
|
||||
}
|
||||
|
||||
func testCombinedRules(t *testing.T) {
|
||||
cases := []arSt{
|
||||
{
|
||||
rule: &AlertRule{
|
||||
Rules: []*Rule{
|
||||
{
|
||||
Type: "offline",
|
||||
Duration: 10,
|
||||
},
|
||||
{
|
||||
Duration: 10,
|
||||
},
|
||||
},
|
||||
},
|
||||
msg: "OfflineGeneralOfflinePass",
|
||||
points: slices.Concat(repeat([]bool{false, true}, 2), repeat([]bool{true, false}, 8)),
|
||||
expD: 1,
|
||||
exp: true,
|
||||
},
|
||||
{
|
||||
rule: &AlertRule{
|
||||
Rules: []*Rule{
|
||||
{
|
||||
Type: "offline",
|
||||
Duration: 10,
|
||||
},
|
||||
{
|
||||
Duration: 10,
|
||||
},
|
||||
},
|
||||
},
|
||||
msg: "OfflineGeneralOfflineFail",
|
||||
points: slices.Concat(repeat([]bool{false, false}, 2), repeat([]bool{false, true}, 8)),
|
||||
expD: 10,
|
||||
exp: true,
|
||||
},
|
||||
{
|
||||
rule: &AlertRule{
|
||||
Rules: []*Rule{
|
||||
{
|
||||
Duration: 10,
|
||||
},
|
||||
{
|
||||
Type: "offline",
|
||||
Duration: 10,
|
||||
},
|
||||
},
|
||||
},
|
||||
msg: "GeneralOffline",
|
||||
points: slices.Concat(repeat([]bool{false, true}, 2), repeat([]bool{true, false}, 8)),
|
||||
expD: 10,
|
||||
exp: true,
|
||||
},
|
||||
{
|
||||
rule: &AlertRule{
|
||||
Rules: []*Rule{
|
||||
{
|
||||
Duration: 10,
|
||||
},
|
||||
{
|
||||
Duration: 30,
|
||||
},
|
||||
},
|
||||
},
|
||||
msg: "GeneralGeneral",
|
||||
points: slices.Concat(repeat([]bool{false, true}, 2), repeat([]bool{false, false}, 28)),
|
||||
expD: 30,
|
||||
exp: false,
|
||||
},
|
||||
{
|
||||
rule: &AlertRule{
|
||||
Rules: []*Rule{
|
||||
{
|
||||
Duration: 10,
|
||||
},
|
||||
{
|
||||
Duration: 30,
|
||||
},
|
||||
},
|
||||
},
|
||||
msg: "CombinedBoundCheck",
|
||||
points: slices.Concat(repeat([]bool{false, true}, 2), repeat([]bool{false, false}, 27)),
|
||||
expD: 10,
|
||||
exp: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
d, passed := c.rule.Check(c.points)
|
||||
assertEq(t, c.msg, c.expD, d)
|
||||
assertEq(t, c.msg, c.exp, passed)
|
||||
}
|
||||
}
|
||||
|
||||
func repeat[S ~[]E, E any](x S, count int) []S {
|
||||
var slices []S
|
||||
for range count {
|
||||
tmp := make([]E, len(x))
|
||||
copy(tmp, x)
|
||||
slices = append(slices, tmp)
|
||||
}
|
||||
return slices
|
||||
}
|
||||
|
||||
func mod[S ~[][]E, E any](x S, val E, i int) S {
|
||||
x[i][0] = val
|
||||
return x
|
||||
}
|
||||
|
||||
func assertEq(t *testing.T, msg string, exp, act any) {
|
||||
t.Helper()
|
||||
if exp != act {
|
||||
t.Fatalf("failed to test for %s. exp=[%v] but act=[%v]", msg, exp, act)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package model
|
||||
|
||||
const (
|
||||
ApiErrorUnauthorized = 10001
|
||||
)
|
||||
|
||||
type Oauth2LoginResponse struct {
|
||||
Redirect string `json:"redirect,omitempty"`
|
||||
}
|
||||
|
||||
type Oauth2Callback struct {
|
||||
State string `json:"state,omitempty"`
|
||||
Code string `json:"code,omitempty"`
|
||||
}
|
||||
|
||||
type LoginRequest struct {
|
||||
Username string `json:"username,omitempty"`
|
||||
Password string `json:"password,omitempty"`
|
||||
}
|
||||
|
||||
type CommonResponse[T any] struct {
|
||||
Success bool `json:"success,omitempty"`
|
||||
Data T `json:"data,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type PaginatedResponse[S ~[]E, E any] struct {
|
||||
Success bool `json:"success,omitempty"`
|
||||
Data *Value[S] `json:"data,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type Value[T any] struct {
|
||||
Value T `json:"value,omitempty"`
|
||||
Pagination Pagination `json:"pagination,omitempty"`
|
||||
}
|
||||
|
||||
type Pagination struct {
|
||||
Offset int `json:"offset,omitempty"`
|
||||
Limit int `json:"limit,omitempty"`
|
||||
Total int64 `json:"total,omitempty"`
|
||||
}
|
||||
|
||||
type LoginResponse struct {
|
||||
Token string `json:"token,omitempty"`
|
||||
Expire string `json:"expire,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"iter"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/nezhahq/nezha/pkg/utils"
|
||||
)
|
||||
|
||||
const (
|
||||
CtxKeyAuthorizedUser = "ckau"
|
||||
CtxKeyRealIPStr = "ckri"
|
||||
CtxKeyIsIPMismatch = "ckipm"
|
||||
)
|
||||
|
||||
const (
|
||||
CacheKeyOauth2State = "cko2s::"
|
||||
)
|
||||
|
||||
type CtxKeyRealIP struct{}
|
||||
type CtxKeyConnectingIP struct{}
|
||||
|
||||
type Common struct {
|
||||
ID uint64 `gorm:"primaryKey" json:"id,omitempty"`
|
||||
CreatedAt time.Time `gorm:"index;<-:create" json:"created_at,omitempty"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at,omitempty"`
|
||||
|
||||
UserID uint64 `gorm:"index;default:0" json:"-"`
|
||||
}
|
||||
|
||||
func (c *Common) GetID() uint64 {
|
||||
return c.ID
|
||||
}
|
||||
|
||||
func (c *Common) GetUserID() uint64 {
|
||||
return c.UserID
|
||||
}
|
||||
|
||||
func (c *Common) HasPermission(ctx *gin.Context) bool {
|
||||
auth, ok := ctx.Get(CtxKeyAuthorizedUser)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
user := *auth.(*User)
|
||||
if user.Role == RoleAdmin {
|
||||
return true
|
||||
}
|
||||
|
||||
return user.ID == c.UserID
|
||||
}
|
||||
|
||||
type CommonInterface interface {
|
||||
GetID() uint64
|
||||
GetUserID() uint64
|
||||
HasPermission(*gin.Context) bool
|
||||
}
|
||||
|
||||
func FindByUserID[S ~[]E, E CommonInterface](s S, uid uint64) []uint64 {
|
||||
var list []uint64
|
||||
for _, v := range s {
|
||||
if v.GetUserID() == uid {
|
||||
list = append(list, v.GetID())
|
||||
}
|
||||
}
|
||||
|
||||
return list
|
||||
}
|
||||
|
||||
func SearchByIDCtx[S ~[]E, E CommonInterface](c *gin.Context, x S) S {
|
||||
return SearchByID(strings.SplitSeq(c.Query("id"), ","), x)
|
||||
}
|
||||
|
||||
func SearchByID[S ~[]E, E CommonInterface](seq iter.Seq[string], x S) S {
|
||||
if hasPriorityList[E]() {
|
||||
return searchByIDPri(seq, x)
|
||||
}
|
||||
|
||||
var s S
|
||||
for idStr := range seq {
|
||||
id, err := strconv.ParseUint(idStr, 10, 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
s = appendBinarySearch(s, x, id)
|
||||
}
|
||||
return utils.IfOr(len(s) > 0, s, x)
|
||||
}
|
||||
|
||||
func hasPriorityList[T CommonInterface]() bool {
|
||||
var class T
|
||||
|
||||
switch any(class).(type) {
|
||||
case *Server:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
type splitter[S ~[]E, E CommonInterface] interface {
|
||||
// SplitList should split a sorted list into two separate lists:
|
||||
// The first list contains elements with a priority set (DisplayIndex != 0).
|
||||
// The second list contains elements without a priority set (DisplayIndex == 0).
|
||||
// The original slice is not modified. If no element without a priority is found, it returns nil.
|
||||
// Should be safe to use with a nil pointer.
|
||||
SplitList(x S) (S, S)
|
||||
}
|
||||
|
||||
func searchByIDPri[S ~[]E, E CommonInterface](seq iter.Seq[string], x S) S {
|
||||
var class E
|
||||
split, ok := any(class).(splitter[S, E])
|
||||
if !ok {
|
||||
return x
|
||||
}
|
||||
|
||||
plist, list2 := split.SplitList(x)
|
||||
|
||||
var clist1, clist2 S
|
||||
for idStr := range seq {
|
||||
id, err := strconv.ParseUint(idStr, 10, 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
clist1 = appendSearch(clist1, plist, id)
|
||||
clist2 = appendBinarySearch(clist2, list2, id)
|
||||
}
|
||||
|
||||
l := slices.Concat(clist1, clist2)
|
||||
return utils.IfOr(len(l) > 0, l, x)
|
||||
}
|
||||
|
||||
func appendBinarySearch[S ~[]E, E CommonInterface](x, y S, target uint64) S {
|
||||
if i, ok := slices.BinarySearchFunc(y, target, func(e E, t uint64) int {
|
||||
return cmp.Compare(e.GetID(), t)
|
||||
}); ok {
|
||||
x = append(x, y[i])
|
||||
}
|
||||
return x
|
||||
}
|
||||
|
||||
func appendSearch[S ~[]E, E CommonInterface](x, y S, target uint64) S {
|
||||
if i := slices.IndexFunc(y, func(e E) bool {
|
||||
return e.GetID() == target
|
||||
}); i != -1 {
|
||||
x = append(x, y[i])
|
||||
}
|
||||
|
||||
return x
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSearchByID(t *testing.T) {
|
||||
t.Run("WithoutPriorityList", func(t *testing.T) {
|
||||
list, exp := []*DDNSProfile{
|
||||
{Common: Common{ID: 1}},
|
||||
{Common: Common{ID: 2}},
|
||||
{Common: Common{ID: 3}},
|
||||
{Common: Common{ID: 4}},
|
||||
{Common: Common{ID: 5}},
|
||||
}, []*DDNSProfile{
|
||||
{Common: Common{ID: 4}},
|
||||
{Common: Common{ID: 1}},
|
||||
{Common: Common{ID: 3}},
|
||||
}
|
||||
|
||||
searchList := slices.Values([]string{"4", "1", "3"})
|
||||
filtered := SearchByID(searchList, list)
|
||||
if !reflect.DeepEqual(filtered, exp) {
|
||||
t.Fatalf("expected %v, but got %v", exp, filtered)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("WithPriorityTest", func(t *testing.T) {
|
||||
list, exp := []*Server{
|
||||
{Common: Common{ID: 5}, DisplayIndex: 2},
|
||||
{Common: Common{ID: 4}, DisplayIndex: 1},
|
||||
{Common: Common{ID: 1}},
|
||||
{Common: Common{ID: 2}},
|
||||
{Common: Common{ID: 3}},
|
||||
}, []*Server{
|
||||
{Common: Common{ID: 4}, DisplayIndex: 1},
|
||||
{Common: Common{ID: 5}, DisplayIndex: 2},
|
||||
{Common: Common{ID: 3}},
|
||||
}
|
||||
|
||||
searchList := slices.Values([]string{"3", "4", "5"})
|
||||
filtered := SearchByID(searchList, list)
|
||||
if !reflect.DeepEqual(filtered, exp) {
|
||||
t.Fatalf("expected %v, but got %v", exp, filtered)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/go-viper/mapstructure/v2"
|
||||
kmaps "github.com/knadh/koanf/maps"
|
||||
"github.com/knadh/koanf/providers/env"
|
||||
"github.com/knadh/koanf/providers/file"
|
||||
"github.com/knadh/koanf/v2"
|
||||
"sigs.k8s.io/yaml"
|
||||
|
||||
"github.com/nezhahq/nezha/pkg/utils"
|
||||
)
|
||||
|
||||
const (
|
||||
ConfigUsePeerIP = "NZ::Use-Peer-IP"
|
||||
ConfigCoverAll = iota
|
||||
ConfigCoverIgnoreAll
|
||||
)
|
||||
|
||||
type ConfigForGuests struct {
|
||||
Language string `koanf:"language" json:"language"` // 系统语言,默认 zh_CN
|
||||
SiteName string `koanf:"site_name" json:"site_name"`
|
||||
CustomCode string `koanf:"custom_code" json:"custom_code,omitempty"`
|
||||
CustomCodeDashboard string `koanf:"custom_code_dashboard" json:"custom_code_dashboard,omitempty"`
|
||||
}
|
||||
|
||||
type ConfigDashboard struct {
|
||||
InstallHost string `koanf:"install_host" json:"install_host,omitempty"`
|
||||
AgentTLS bool `koanf:"tls" json:"tls,omitempty"` // 用于前端判断生成的安装命令是否启用 TLS
|
||||
|
||||
WebRealIPHeader string `koanf:"web_real_ip_header" json:"web_real_ip_header,omitempty"` // 前端真实IP
|
||||
AgentRealIPHeader string `koanf:"agent_real_ip_header" json:"agent_real_ip_header,omitempty"` // Agent真实IP
|
||||
UserTemplate string `koanf:"user_template" json:"user_template,omitempty"`
|
||||
AdminTemplate string `koanf:"admin_template" json:"admin_template,omitempty"`
|
||||
|
||||
EnablePlainIPInNotification bool `koanf:"enable_plain_ip_in_notification" json:"enable_plain_ip_in_notification,omitempty"` // 通知信息IP不打码
|
||||
|
||||
// IP变更提醒
|
||||
EnableIPChangeNotification bool `koanf:"enable_ip_change_notification" json:"enable_ip_change_notification,omitempty"`
|
||||
IPChangeNotificationGroupID uint64 `koanf:"ip_change_notification_group_id" json:"ip_change_notification_group_id"`
|
||||
Cover uint8 `koanf:"cover" json:"cover"` // 覆盖范围(0:提醒未被 IgnoredIPNotification 包含的所有服务器; 1:仅提醒被 IgnoredIPNotification 包含的服务器;)
|
||||
IgnoredIPNotification string `koanf:"ignored_ip_notification" json:"ignored_ip_notification,omitempty"` // 特定服务器IP(多个服务器用逗号分隔)
|
||||
|
||||
DNSServers string `koanf:"dns_servers" json:"dns_servers,omitempty"`
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
ConfigForGuests
|
||||
ConfigDashboard
|
||||
|
||||
AvgPingCount int `koanf:"avg_ping_count" json:"avg_ping_count,omitempty"`
|
||||
|
||||
Debug bool `koanf:"debug" json:"debug,omitempty"` // debug模式开关
|
||||
Location string `koanf:"location" json:"location,omitempty"` // 时区,默认为 Asia/Shanghai
|
||||
ForceAuth bool `koanf:"force_auth" json:"force_auth,omitempty"` // 强制要求认证
|
||||
AgentSecretKey string `koanf:"agent_secret_key" json:"agent_secret_key,omitempty"`
|
||||
JWTTimeout int `koanf:"jwt_timeout" json:"jwt_timeout,omitempty"` // JWT token过期时间(小时)
|
||||
|
||||
JWTSecretKey string `koanf:"jwt_secret_key" json:"jwt_secret_key,omitempty"`
|
||||
ListenPort uint16 `koanf:"listen_port" json:"listen_port,omitempty"`
|
||||
ListenHost string `koanf:"listen_host" json:"listen_host,omitempty"`
|
||||
|
||||
// oauth2 配置
|
||||
Oauth2 map[string]*Oauth2Config `koanf:"oauth2" json:"oauth2,omitempty"`
|
||||
|
||||
// HTTPS 配置
|
||||
HTTPS HTTPSConf `koanf:"https" json:"https"`
|
||||
|
||||
// TSDB 配置
|
||||
TSDB TSDBConf `koanf:"tsdb" json:"tsdb"`
|
||||
|
||||
// 内存配置
|
||||
Memory MemoryConf `koanf:"memory" json:"memory"`
|
||||
|
||||
k *koanf.Koanf `json:"-"`
|
||||
filePath string `json:"-"`
|
||||
}
|
||||
|
||||
type HTTPSConf struct {
|
||||
InsecureTLS bool `koanf:"insecure_tls" json:"insecure_tls,omitempty"`
|
||||
ListenPort uint16 `koanf:"listen_port" json:"listen_port,omitempty"`
|
||||
TLSCertPath string `koanf:"tls_cert_path" json:"tls_cert_path,omitempty"`
|
||||
TLSKeyPath string `koanf:"tls_key_path" json:"tls_key_path,omitempty"`
|
||||
}
|
||||
|
||||
// TSDBConf TSDB 配置
|
||||
type TSDBConf struct {
|
||||
DataPath string `koanf:"data_path" json:"data_path,omitempty"`
|
||||
RetentionDays uint16 `koanf:"retention_days" json:"retention_days,omitempty"`
|
||||
MinFreeDiskSpaceGB float64 `koanf:"min_free_disk_space_gb" json:"min_free_disk_space_gb,omitempty"`
|
||||
MaxMemoryMB int64 `koanf:"max_memory_mb" json:"max_memory_mb,omitempty"`
|
||||
WriteBufferSize int `koanf:"write_buffer_size" json:"write_buffer_size,omitempty"`
|
||||
WriteBufferFlushInterval int `koanf:"write_buffer_flush_interval" json:"write_buffer_flush_interval,omitempty"`
|
||||
}
|
||||
|
||||
// MemoryConf 内存配置
|
||||
type MemoryConf struct {
|
||||
// GoMemLimitMB Go 运行时内存限制(MB),0 表示不限制
|
||||
GoMemLimitMB int64 `koanf:"go_mem_limit_mb" json:"go_mem_limit_mb,omitempty"`
|
||||
}
|
||||
|
||||
// Read 读取配置文件并应用
|
||||
func (c *Config) Read(path string, frontendTemplates []FrontendTemplate) error {
|
||||
c.k = koanf.New(".")
|
||||
c.filePath = path
|
||||
|
||||
err := c.k.Load(env.Provider("NZ_", ".", func(s string) string {
|
||||
return strings.ReplaceAll(strings.ToLower(strings.TrimPrefix(s, "NZ_")), "_", ".")
|
||||
}), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
err = c.k.Load(file.Provider(path), new(utils.KubeYAML), koanf.WithMergeFunc(mergeDedup))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
err = c.k.UnmarshalWithConf("", c, koanfConf(c))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if c.ListenPort == 0 {
|
||||
c.ListenPort = 8008
|
||||
}
|
||||
if c.Language == "" {
|
||||
c.Language = "en_US"
|
||||
}
|
||||
if c.Location == "" {
|
||||
c.Location = "Asia/Shanghai"
|
||||
}
|
||||
var userTemplateValid, adminTemplateValid bool
|
||||
for _, v := range frontendTemplates {
|
||||
if !userTemplateValid && v.Path == c.UserTemplate && !v.IsAdmin {
|
||||
userTemplateValid = true
|
||||
}
|
||||
if !adminTemplateValid && v.Path == c.AdminTemplate && v.IsAdmin {
|
||||
adminTemplateValid = true
|
||||
}
|
||||
if userTemplateValid && adminTemplateValid {
|
||||
break
|
||||
}
|
||||
}
|
||||
if c.UserTemplate == "" || !userTemplateValid {
|
||||
c.UserTemplate = "user-dist"
|
||||
}
|
||||
if c.AdminTemplate == "" || !adminTemplateValid {
|
||||
c.AdminTemplate = "admin-dist"
|
||||
}
|
||||
if c.AvgPingCount == 0 {
|
||||
c.AvgPingCount = 2
|
||||
}
|
||||
if c.Cover == 0 {
|
||||
c.Cover = 1
|
||||
}
|
||||
if c.JWTSecretKey == "" {
|
||||
c.JWTSecretKey, err = utils.GenerateRandomString(1024)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = c.Save(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Add JWTTimeout default check
|
||||
if c.JWTTimeout == 0 {
|
||||
c.JWTTimeout = 1
|
||||
}
|
||||
|
||||
if c.AgentSecretKey == "" {
|
||||
c.AgentSecretKey, err = utils.GenerateRandomString(32)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = c.Save(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Save 保存配置文件
|
||||
func (c *Config) Save() error {
|
||||
return c.save()
|
||||
}
|
||||
|
||||
func (c *Config) save() error {
|
||||
data, err := yaml.Marshal(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return c.write(data)
|
||||
}
|
||||
|
||||
func (c *Config) write(data []byte) error {
|
||||
dir := filepath.Dir(c.filePath)
|
||||
if err := os.MkdirAll(dir, 0750); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(c.filePath, data, 0600)
|
||||
}
|
||||
|
||||
func koanfConf(c any) koanf.UnmarshalConf {
|
||||
return koanf.UnmarshalConf{
|
||||
DecoderConfig: &mapstructure.DecoderConfig{
|
||||
DecodeHook: mapstructure.ComposeDecodeHookFunc(
|
||||
mapstructure.StringToTimeDurationHookFunc(),
|
||||
utils.TextUnmarshalerHookFunc()),
|
||||
Metadata: nil,
|
||||
Result: c,
|
||||
WeaklyTypedInput: true,
|
||||
MatchName: func(mapKey, fieldName string) bool {
|
||||
return strings.EqualFold(mapKey, fieldName) ||
|
||||
strings.EqualFold(mapKey, strings.ReplaceAll(fieldName, "_", ""))
|
||||
},
|
||||
Squash: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func mergeDedup(src, dst map[string]any) error {
|
||||
for key := range src {
|
||||
if strings.IndexByte(key, '_') == -1 {
|
||||
continue
|
||||
}
|
||||
|
||||
oldKey := strings.ReplaceAll(key, "_", "")
|
||||
if _, ok := dst[oldKey]; ok {
|
||||
src[oldKey] = src[key]
|
||||
delete(src, key)
|
||||
}
|
||||
}
|
||||
|
||||
kmaps.Merge(src, dst)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestReadConfig(t *testing.T) {
|
||||
t.Run("ReadEmptyConfig", func(t *testing.T) {
|
||||
file := newTempConfig(t, "")
|
||||
c := &Config{}
|
||||
|
||||
if err := c.Read(file, nil); err != nil {
|
||||
t.Fatalf("read empty config failed: %v", err)
|
||||
}
|
||||
|
||||
testFields := []struct {
|
||||
Name string
|
||||
Value any
|
||||
Cond bool
|
||||
}{
|
||||
{"jwt_secret_key", c.JWTSecretKey, c.JWTSecretKey != ""},
|
||||
{"user_template", c.UserTemplate, c.UserTemplate == "user-dist"},
|
||||
{"admin_template", c.AdminTemplate, c.AdminTemplate == "admin-dist"},
|
||||
{"agent_secret_key", c.AgentSecretKey, c.AgentSecretKey != ""},
|
||||
}
|
||||
|
||||
for _, field := range testFields {
|
||||
if !field.Cond {
|
||||
t.Fatalf("%s did not passed check, value: %v", field.Name, field.Value)
|
||||
}
|
||||
}
|
||||
|
||||
os.Remove(file)
|
||||
})
|
||||
|
||||
t.Run("ReadFile", func(t *testing.T) {
|
||||
const testCfg = "jwt_secret_key: test\nuser_template: um\nadmin_template: am\nagent_secret_key: none\nsite_name: lowkick"
|
||||
|
||||
var testFrontendTemplates = []FrontendTemplate{
|
||||
{Path: "um"},
|
||||
{Path: "am", IsAdmin: true},
|
||||
}
|
||||
file := newTempConfig(t, testCfg)
|
||||
c := &Config{}
|
||||
|
||||
if err := c.Read(file, testFrontendTemplates); err != nil {
|
||||
t.Fatalf("read config failed: %v", err)
|
||||
}
|
||||
|
||||
testFields := []struct {
|
||||
Name string
|
||||
Value any
|
||||
Cond bool
|
||||
}{
|
||||
{"jwt_secret_key", c.JWTSecretKey, c.JWTSecretKey == "test"},
|
||||
{"user_template", c.UserTemplate, c.UserTemplate == "um"},
|
||||
{"admin_template", c.AdminTemplate, c.AdminTemplate == "am"},
|
||||
{"agent_secret_key", c.AgentSecretKey, c.AgentSecretKey == "none"},
|
||||
{"site_name", c.SiteName, c.SiteName == "lowkick"},
|
||||
}
|
||||
|
||||
for _, field := range testFields {
|
||||
if !field.Cond {
|
||||
t.Fatalf("%s did not passed check, value: %v", field.Name, field.Value)
|
||||
}
|
||||
}
|
||||
|
||||
os.Remove(file)
|
||||
})
|
||||
|
||||
t.Run("ReadEnv", func(t *testing.T) {
|
||||
os.Setenv("NZ_JWTSECRETKEY", "test")
|
||||
os.Setenv("NZ_USERTEMPLATE", "um")
|
||||
os.Setenv("NZ_ADMINTEMPLATE", "am")
|
||||
os.Setenv("NZ_AGENTSECRETKEY", "none")
|
||||
os.Setenv("NZ_HTTPS_LISTENPORT", "9876")
|
||||
|
||||
var testFrontendTemplates = []FrontendTemplate{
|
||||
{Path: "um"},
|
||||
{Path: "am", IsAdmin: true},
|
||||
}
|
||||
file := newTempConfig(t, "")
|
||||
c := &Config{}
|
||||
|
||||
if err := c.Read(file, testFrontendTemplates); err != nil {
|
||||
t.Fatalf("read empty config failed: %v", err)
|
||||
}
|
||||
|
||||
testFields := []struct {
|
||||
Name string
|
||||
Value any
|
||||
Cond bool
|
||||
}{
|
||||
{"jwt_secret_key", c.JWTSecretKey, c.JWTSecretKey == "test"},
|
||||
{"user_template", c.UserTemplate, c.UserTemplate == "um"},
|
||||
{"admin_template", c.AdminTemplate, c.AdminTemplate == "am"},
|
||||
{"agent_secret_key", c.AgentSecretKey, c.AgentSecretKey == "none"},
|
||||
{"https.listenport", c.HTTPS.ListenPort, c.HTTPS.ListenPort == 9876},
|
||||
}
|
||||
|
||||
for _, field := range testFields {
|
||||
if !field.Cond {
|
||||
t.Fatalf("%s did not passed check, value: %v", field.Name, field.Value)
|
||||
}
|
||||
}
|
||||
|
||||
os.Remove(file)
|
||||
})
|
||||
|
||||
t.Run("ReadEnvFile", func(t *testing.T) {
|
||||
os.Setenv("NZ_JWTSECRETKEY", "test1")
|
||||
os.Setenv("NZ_USERTEMPLATE", "um1")
|
||||
os.Setenv("NZ_ADMINTEMPLATE", "am1")
|
||||
os.Setenv("NZ_AGENTSECRETKEY", "none1")
|
||||
os.Setenv("NZ_SITENAME", "lowkick1")
|
||||
|
||||
const testCfg = "jwt_secret_key: test\nuser_template: um\nadmin_template: am\nagent_secret_key: none\nsite_name: lowkick"
|
||||
|
||||
var testFrontendTemplates = []FrontendTemplate{
|
||||
{Path: "um"},
|
||||
{Path: "am", IsAdmin: true},
|
||||
}
|
||||
file := newTempConfig(t, testCfg)
|
||||
c := &Config{}
|
||||
|
||||
if err := c.Read(file, testFrontendTemplates); err != nil {
|
||||
t.Fatalf("read empty config failed: %v", err)
|
||||
}
|
||||
|
||||
testFields := []struct {
|
||||
Name string
|
||||
Value any
|
||||
Cond bool
|
||||
}{
|
||||
{"jwt_secret_key", c.JWTSecretKey, c.JWTSecretKey == "test"},
|
||||
{"user_template", c.UserTemplate, c.UserTemplate == "um"},
|
||||
{"admin_template", c.AdminTemplate, c.AdminTemplate == "am"},
|
||||
{"agent_secret_key", c.AgentSecretKey, c.AgentSecretKey == "none"},
|
||||
{"site_name", c.SiteName, c.SiteName == "lowkick"},
|
||||
}
|
||||
|
||||
for _, field := range testFields {
|
||||
if !field.Cond {
|
||||
t.Fatalf("%s did not passed check, value: %v", field.Name, field.Value)
|
||||
}
|
||||
}
|
||||
|
||||
os.Remove(file)
|
||||
})
|
||||
}
|
||||
|
||||
func newTempConfig(t *testing.T, cfg string) string {
|
||||
t.Helper()
|
||||
|
||||
file, err := os.CreateTemp(os.TempDir(), "nezha-test-config-*.yml")
|
||||
if err != nil {
|
||||
t.Fatalf("create temp file failed: %v", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
_, err = file.ReadFrom(strings.NewReader(cfg))
|
||||
if err != nil {
|
||||
t.Fatalf("write to temp file failed: %v", err)
|
||||
}
|
||||
|
||||
return file.Name()
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/goccy/go-json"
|
||||
"github.com/robfig/cron/v3"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
CronCoverIgnoreAll = iota
|
||||
CronCoverAll
|
||||
CronCoverAlertTrigger
|
||||
CronTypeCronTask = 0
|
||||
CronTypeTriggerTask = 1
|
||||
)
|
||||
|
||||
type Cron struct {
|
||||
Common
|
||||
Name string `json:"name"`
|
||||
TaskType uint8 `gorm:"default:0" json:"task_type"` // 0:计划任务 1:触发任务
|
||||
Scheduler string `json:"scheduler"` // 分钟 小时 天 月 星期
|
||||
Command string `json:"command,omitempty"`
|
||||
Servers []uint64 `gorm:"-" json:"servers"`
|
||||
PushSuccessful bool `json:"push_successful,omitempty"` // 推送成功的通知
|
||||
NotificationGroupID uint64 `json:"notification_group_id"` // 指定通知方式的分组
|
||||
LastExecutedAt time.Time `json:"last_executed_at,omitempty"` // 最后一次执行时间
|
||||
LastResult bool `json:"last_result,omitempty"` // 最后一次执行结果
|
||||
Cover uint8 `json:"cover"` // 计划任务覆盖范围 (0:仅覆盖特定服务器 1:仅忽略特定服务器 2:由触发该计划任务的服务器执行)
|
||||
|
||||
CronJobID cron.EntryID `gorm:"-" json:"cron_job_id,omitempty"`
|
||||
ServersRaw string `json:"-"`
|
||||
}
|
||||
|
||||
func (c *Cron) BeforeSave(tx *gorm.DB) error {
|
||||
if data, err := json.Marshal(c.Servers); err != nil {
|
||||
return err
|
||||
} else {
|
||||
c.ServersRaw = string(data)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Cron) AfterFind(tx *gorm.DB) error {
|
||||
return json.Unmarshal([]byte(c.ServersRaw), &c.Servers)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package model
|
||||
|
||||
type CronForm struct {
|
||||
TaskType uint8 `json:"task_type,omitempty" default:"0"` // 0:计划任务 1:触发任务
|
||||
Name string `json:"name,omitempty" minLength:"1"`
|
||||
Scheduler string `json:"scheduler,omitempty"`
|
||||
Command string `json:"command,omitempty" validate:"optional"`
|
||||
Servers []uint64 `json:"servers,omitempty"`
|
||||
Cover uint8 `json:"cover,omitempty" default:"0"`
|
||||
PushSuccessful bool `json:"push_successful,omitempty" validate:"optional"`
|
||||
NotificationGroupID uint64 `json:"notification_group_id,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"github.com/goccy/go-json"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
ProviderDummy = "dummy"
|
||||
ProviderWebHook = "webhook"
|
||||
ProviderCloudflare = "cloudflare"
|
||||
ProviderTencentCloud = "tencentcloud"
|
||||
ProviderHE = "he"
|
||||
)
|
||||
|
||||
var ProviderList = [...]string{
|
||||
ProviderDummy, ProviderWebHook, ProviderCloudflare, ProviderTencentCloud, ProviderHE,
|
||||
}
|
||||
|
||||
type DDNSProfile struct {
|
||||
Common
|
||||
EnableIPv4 *bool `json:"enable_ipv4,omitempty"`
|
||||
EnableIPv6 *bool `json:"enable_ipv6,omitempty"`
|
||||
MaxRetries uint64 `json:"max_retries"`
|
||||
Name string `json:"name"`
|
||||
Provider string `json:"provider"`
|
||||
AccessID string `json:"access_id,omitempty"`
|
||||
AccessSecret string `json:"access_secret,omitempty"`
|
||||
WebhookURL string `json:"webhook_url,omitempty"`
|
||||
WebhookMethod uint8 `json:"webhook_method,omitempty"`
|
||||
WebhookRequestType uint8 `json:"webhook_request_type,omitempty"`
|
||||
WebhookRequestBody string `json:"webhook_request_body,omitempty"`
|
||||
WebhookHeaders string `json:"webhook_headers,omitempty"`
|
||||
Domains []string `json:"domains" gorm:"-"`
|
||||
DomainsRaw string `json:"-"`
|
||||
}
|
||||
|
||||
func (d *DDNSProfile) TableName() string {
|
||||
return "ddns"
|
||||
}
|
||||
|
||||
func (d *DDNSProfile) BeforeSave(tx *gorm.DB) error {
|
||||
if data, err := json.Marshal(d.Domains); err != nil {
|
||||
return err
|
||||
} else {
|
||||
d.DomainsRaw = string(data)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *DDNSProfile) AfterFind(tx *gorm.DB) error {
|
||||
return json.Unmarshal([]byte(d.DomainsRaw), &d.Domains)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package model
|
||||
|
||||
type DDNSForm struct {
|
||||
MaxRetries uint64 `json:"max_retries,omitempty" default:"3"`
|
||||
EnableIPv4 bool `json:"enable_ipv4,omitempty" validate:"optional"`
|
||||
EnableIPv6 bool `json:"enable_ipv6,omitempty" validate:"optional"`
|
||||
Name string `json:"name,omitempty" minLength:"1"`
|
||||
Provider string `json:"provider,omitempty"`
|
||||
Domains []string `json:"domains,omitempty"`
|
||||
AccessID string `json:"access_id,omitempty" validate:"optional"`
|
||||
AccessSecret string `json:"access_secret,omitempty" validate:"optional"`
|
||||
WebhookURL string `json:"webhook_url,omitempty" validate:"optional"`
|
||||
WebhookMethod uint8 `json:"webhook_method,omitempty" validate:"optional" default:"1"`
|
||||
WebhookRequestType uint8 `json:"webhook_request_type,omitempty" validate:"optional" default:"1"`
|
||||
WebhookRequestBody string `json:"webhook_request_body,omitempty" validate:"optional"`
|
||||
WebhookHeaders string `json:"webhook_headers,omitempty" validate:"optional"`
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package model
|
||||
|
||||
type CreateFMResponse struct {
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
pb "github.com/nezhahq/nezha/proto"
|
||||
)
|
||||
|
||||
const (
|
||||
_ = iota
|
||||
|
||||
MTReportHostState
|
||||
)
|
||||
|
||||
type SensorTemperature struct {
|
||||
Name string
|
||||
Temperature float64
|
||||
}
|
||||
|
||||
type HostState struct {
|
||||
CPU float64 `json:"cpu,omitempty"`
|
||||
MemUsed uint64 `json:"mem_used,omitempty"`
|
||||
SwapUsed uint64 `json:"swap_used,omitempty"`
|
||||
DiskUsed uint64 `json:"disk_used,omitempty"`
|
||||
NetInTransfer uint64 `json:"net_in_transfer,omitempty"`
|
||||
NetOutTransfer uint64 `json:"net_out_transfer,omitempty"`
|
||||
NetInSpeed uint64 `json:"net_in_speed,omitempty"`
|
||||
NetOutSpeed uint64 `json:"net_out_speed,omitempty"`
|
||||
Uptime uint64 `json:"uptime,omitempty"`
|
||||
Load1 float64 `json:"load_1,omitempty"`
|
||||
Load5 float64 `json:"load_5,omitempty"`
|
||||
Load15 float64 `json:"load_15,omitempty"`
|
||||
TcpConnCount uint64 `json:"tcp_conn_count,omitempty"`
|
||||
UdpConnCount uint64 `json:"udp_conn_count,omitempty"`
|
||||
ProcessCount uint64 `json:"process_count,omitempty"`
|
||||
Temperatures []SensorTemperature `json:"temperatures,omitempty"`
|
||||
GPU []float64 `json:"gpu,omitempty"`
|
||||
}
|
||||
|
||||
func (s *HostState) PB() *pb.State {
|
||||
var ts []*pb.State_SensorTemperature
|
||||
for _, t := range s.Temperatures {
|
||||
ts = append(ts, &pb.State_SensorTemperature{
|
||||
Name: t.Name,
|
||||
Temperature: t.Temperature,
|
||||
})
|
||||
}
|
||||
|
||||
return &pb.State{
|
||||
Cpu: s.CPU,
|
||||
MemUsed: s.MemUsed,
|
||||
SwapUsed: s.SwapUsed,
|
||||
DiskUsed: s.DiskUsed,
|
||||
NetInTransfer: s.NetInTransfer,
|
||||
NetOutTransfer: s.NetOutTransfer,
|
||||
NetInSpeed: s.NetInSpeed,
|
||||
NetOutSpeed: s.NetOutSpeed,
|
||||
Uptime: s.Uptime,
|
||||
Load1: s.Load1,
|
||||
Load5: s.Load5,
|
||||
Load15: s.Load15,
|
||||
TcpConnCount: s.TcpConnCount,
|
||||
UdpConnCount: s.UdpConnCount,
|
||||
ProcessCount: s.ProcessCount,
|
||||
Temperatures: ts,
|
||||
Gpu: s.GPU,
|
||||
}
|
||||
}
|
||||
|
||||
func PB2State(s *pb.State) HostState {
|
||||
var ts []SensorTemperature
|
||||
for _, t := range s.GetTemperatures() {
|
||||
ts = append(ts, SensorTemperature{
|
||||
Name: t.GetName(),
|
||||
Temperature: t.GetTemperature(),
|
||||
})
|
||||
}
|
||||
|
||||
return HostState{
|
||||
CPU: s.GetCpu(),
|
||||
MemUsed: s.GetMemUsed(),
|
||||
SwapUsed: s.GetSwapUsed(),
|
||||
DiskUsed: s.GetDiskUsed(),
|
||||
NetInTransfer: s.GetNetInTransfer(),
|
||||
NetOutTransfer: s.GetNetOutTransfer(),
|
||||
NetInSpeed: s.GetNetInSpeed(),
|
||||
NetOutSpeed: s.GetNetOutSpeed(),
|
||||
Uptime: s.GetUptime(),
|
||||
Load1: s.GetLoad1(),
|
||||
Load5: s.GetLoad5(),
|
||||
Load15: s.GetLoad15(),
|
||||
TcpConnCount: s.GetTcpConnCount(),
|
||||
UdpConnCount: s.GetUdpConnCount(),
|
||||
ProcessCount: s.GetProcessCount(),
|
||||
Temperatures: ts,
|
||||
GPU: s.GetGpu(),
|
||||
}
|
||||
}
|
||||
|
||||
type Host struct {
|
||||
Platform string `json:"platform,omitempty"`
|
||||
PlatformVersion string `json:"platform_version,omitempty"`
|
||||
CPU []string `json:"cpu,omitempty"`
|
||||
MemTotal uint64 `json:"mem_total,omitempty"`
|
||||
DiskTotal uint64 `json:"disk_total,omitempty"`
|
||||
SwapTotal uint64 `json:"swap_total,omitempty"`
|
||||
Arch string `json:"arch,omitempty"`
|
||||
Virtualization string `json:"virtualization,omitempty"`
|
||||
BootTime uint64 `json:"boot_time,omitempty"`
|
||||
Version string `json:"version,omitempty"`
|
||||
GPU []string `json:"gpu,omitempty"`
|
||||
}
|
||||
|
||||
func (h *Host) PB() *pb.Host {
|
||||
return &pb.Host{
|
||||
Platform: h.Platform,
|
||||
PlatformVersion: h.PlatformVersion,
|
||||
Cpu: h.CPU,
|
||||
MemTotal: h.MemTotal,
|
||||
DiskTotal: h.DiskTotal,
|
||||
SwapTotal: h.SwapTotal,
|
||||
Arch: h.Arch,
|
||||
Virtualization: h.Virtualization,
|
||||
BootTime: h.BootTime,
|
||||
Version: h.Version,
|
||||
Gpu: h.GPU,
|
||||
}
|
||||
}
|
||||
|
||||
// Filter returns a new instance of Host with some fields redacted.
|
||||
func (h *Host) Filter() *Host {
|
||||
return &Host{
|
||||
Platform: h.Platform,
|
||||
CPU: h.CPU,
|
||||
MemTotal: h.MemTotal,
|
||||
DiskTotal: h.DiskTotal,
|
||||
SwapTotal: h.SwapTotal,
|
||||
Arch: h.Arch,
|
||||
Virtualization: h.Virtualization,
|
||||
BootTime: h.BootTime,
|
||||
GPU: h.GPU,
|
||||
}
|
||||
}
|
||||
|
||||
func PB2Host(h *pb.Host) Host {
|
||||
return Host{
|
||||
Platform: h.GetPlatform(),
|
||||
PlatformVersion: h.GetPlatformVersion(),
|
||||
CPU: h.GetCpu(),
|
||||
MemTotal: h.GetMemTotal(),
|
||||
DiskTotal: h.GetDiskTotal(),
|
||||
SwapTotal: h.GetSwapTotal(),
|
||||
Arch: h.GetArch(),
|
||||
Virtualization: h.GetVirtualization(),
|
||||
BootTime: h.GetBootTime(),
|
||||
Version: h.GetVersion(),
|
||||
GPU: h.GetGpu(),
|
||||
}
|
||||
}
|
||||
|
||||
type IP struct {
|
||||
IPv4Addr string `json:"ipv4_addr,omitempty"`
|
||||
IPv6Addr string `json:"ipv6_addr,omitempty"`
|
||||
}
|
||||
|
||||
func (p *IP) Join() string {
|
||||
if p.IPv4Addr != "" && p.IPv6Addr != "" {
|
||||
return fmt.Sprintf("%s/%s", p.IPv4Addr, p.IPv6Addr)
|
||||
} else if p.IPv4Addr != "" {
|
||||
return p.IPv4Addr
|
||||
}
|
||||
return p.IPv6Addr
|
||||
}
|
||||
|
||||
type GeoIP struct {
|
||||
IP IP `json:"ip,omitempty"`
|
||||
CountryCode string `json:"country_code,omitempty"`
|
||||
}
|
||||
|
||||
func PB2GeoIP(p *pb.GeoIP) GeoIP {
|
||||
pbIP := p.GetIp()
|
||||
return GeoIP{
|
||||
IP: IP{
|
||||
IPv4Addr: pbIP.GetIpv4(),
|
||||
IPv6Addr: pbIP.GetIpv6(),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package model
|
||||
|
||||
type NAT struct {
|
||||
Common
|
||||
Enabled bool `json:"enabled"`
|
||||
Name string `json:"name"`
|
||||
ServerID uint64 `json:"server_id"`
|
||||
Host string `json:"host"`
|
||||
Domain string `json:"domain" gorm:"unique"`
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package model
|
||||
|
||||
type NATForm struct {
|
||||
Name string `json:"name,omitempty" minLength:"1"`
|
||||
Enabled bool `json:"enabled,omitempty"`
|
||||
ServerID uint64 `json:"server_id,omitempty"`
|
||||
Host string `json:"host,omitempty"`
|
||||
Domain string `json:"domain,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/goccy/go-json"
|
||||
"github.com/nezhahq/nezha/pkg/utils"
|
||||
)
|
||||
|
||||
const (
|
||||
_ = iota
|
||||
NotificationRequestTypeJSON
|
||||
NotificationRequestTypeForm
|
||||
)
|
||||
|
||||
const (
|
||||
_ = iota
|
||||
NotificationRequestMethodGET
|
||||
NotificationRequestMethodPOST
|
||||
)
|
||||
|
||||
type NotificationServerBundle struct {
|
||||
Notification *Notification
|
||||
Server *Server
|
||||
Loc *time.Location
|
||||
}
|
||||
|
||||
type Notification struct {
|
||||
Common
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
RequestMethod uint8 `json:"request_method"`
|
||||
RequestType uint8 `json:"request_type"`
|
||||
RequestHeader string `json:"request_header" gorm:"type:longtext"`
|
||||
RequestBody string `json:"request_body" gorm:"type:longtext"`
|
||||
VerifyTLS *bool `json:"verify_tls,omitempty"`
|
||||
FormatMetricUnits *bool `json:"format_metric_units,omitempty"`
|
||||
}
|
||||
|
||||
func (ns *NotificationServerBundle) reqURL(message string) string {
|
||||
n := ns.Notification
|
||||
return ns.replaceParamsInString(n.URL, message, func(msg string) string {
|
||||
return url.QueryEscape(msg)
|
||||
})
|
||||
}
|
||||
|
||||
func (n *Notification) reqMethod() (string, error) {
|
||||
switch n.RequestMethod {
|
||||
case NotificationRequestMethodPOST:
|
||||
return http.MethodPost, nil
|
||||
case NotificationRequestMethodGET:
|
||||
return http.MethodGet, nil
|
||||
}
|
||||
return "", errors.New("不支持的请求方式")
|
||||
}
|
||||
|
||||
func (ns *NotificationServerBundle) reqBody(message string) (string, error) {
|
||||
n := ns.Notification
|
||||
if n.RequestMethod == NotificationRequestMethodGET || message == "" {
|
||||
return "", nil
|
||||
}
|
||||
switch n.RequestType {
|
||||
case NotificationRequestTypeJSON:
|
||||
return ns.replaceParamsInString(n.RequestBody, message, func(msg string) string {
|
||||
msgBytes, _ := json.Marshal(msg)
|
||||
return string(msgBytes)[1 : len(msgBytes)-1]
|
||||
}), nil
|
||||
case NotificationRequestTypeForm:
|
||||
data, err := utils.GjsonIter(n.RequestBody)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
params := url.Values{}
|
||||
for k, v := range data {
|
||||
params.Add(k, ns.replaceParamsInString(v, message, nil))
|
||||
}
|
||||
return params.Encode(), nil
|
||||
}
|
||||
return "", errors.New("不支持的请求类型")
|
||||
}
|
||||
|
||||
func (n *Notification) setContentType(req *http.Request) {
|
||||
if n.RequestMethod == NotificationRequestMethodGET {
|
||||
return
|
||||
}
|
||||
if n.RequestType == NotificationRequestTypeForm {
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
} else {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
}
|
||||
|
||||
func (n *Notification) setRequestHeader(req *http.Request) error {
|
||||
if n.RequestHeader == "" {
|
||||
return nil
|
||||
}
|
||||
m, err := utils.GjsonIter(n.RequestHeader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for k, v := range m {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ns *NotificationServerBundle) Send(message string) error {
|
||||
var client *http.Client
|
||||
n := ns.Notification
|
||||
if n.VerifyTLS != nil && *n.VerifyTLS {
|
||||
client = utils.HttpClient
|
||||
} else {
|
||||
client = utils.HttpClientSkipTlsVerify
|
||||
}
|
||||
|
||||
reqBody, err := ns.reqBody(message)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
reqMethod, err := n.reqMethod()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(reqMethod, ns.reqURL(message), strings.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
n.setContentType(req)
|
||||
|
||||
if err := n.setRequestHeader(req); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode > 299 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("%d@%s %s", resp.StatusCode, resp.Status, string(body))
|
||||
} else {
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// replaceParamInString 替换字符串中的占位符
|
||||
func (ns *NotificationServerBundle) replaceParamsInString(str string, message string, mod func(string) string) string {
|
||||
if mod == nil {
|
||||
mod = func(s string) string { return s }
|
||||
}
|
||||
|
||||
replacements := []string{
|
||||
"#NEZHA#", mod(message),
|
||||
"#DATETIME#", mod(time.Now().In(ns.Loc).String()),
|
||||
}
|
||||
|
||||
if ns.Server != nil {
|
||||
replacements = append(replacements,
|
||||
"#SERVER.NAME#", mod(ns.Server.Name),
|
||||
"#SERVER.ID#", mod(fmt.Sprintf("%d", ns.Server.ID)),
|
||||
|
||||
// Converted metrics
|
||||
"#SERVER.CPU#", mod(ns.formatUsage(false, ns.Server.State.CPU)),
|
||||
"#SERVER.MEM#", mod(ns.formatUsage(true, float64(ns.Server.State.MemUsed)/float64(ns.Server.Host.MemTotal))),
|
||||
"#SERVER.SWAP#", mod(ns.formatUsage(true, float64(ns.Server.State.SwapUsed)/float64(ns.Server.Host.SwapTotal))),
|
||||
"#SERVER.DISK#", mod(ns.formatUsage(true, float64(ns.Server.State.DiskUsed)/float64(ns.Server.Host.DiskTotal))),
|
||||
"#SERVER.SPEEDIN#", mod(fmt.Sprintf("%s/s", ns.formatSize(ns.Server.State.NetInSpeed))),
|
||||
"#SERVER.SPEEDOUT#", mod(fmt.Sprintf("%s/s", ns.formatSize(ns.Server.State.NetOutSpeed))),
|
||||
"#SERVER.TRANSFERIN#", mod(ns.formatSize(ns.Server.State.NetInTransfer)),
|
||||
"#SERVER.TRANSFEROUT#", mod(ns.formatSize(ns.Server.State.NetOutTransfer)),
|
||||
|
||||
// Raw metrics
|
||||
"#SERVER.CPUUSED#", mod(fmt.Sprintf("%f", ns.Server.State.CPU)),
|
||||
"#SERVER.MEMUSED#", mod(fmt.Sprintf("%d", ns.Server.State.MemUsed)),
|
||||
"#SERVER.SWAPUSED#", mod(fmt.Sprintf("%d", ns.Server.State.SwapUsed)),
|
||||
"#SERVER.DISKUSED#", mod(fmt.Sprintf("%d", ns.Server.State.DiskUsed)),
|
||||
"#SERVER.MEMTOTAL#", mod(fmt.Sprintf("%d", ns.Server.Host.MemTotal)),
|
||||
"#SERVER.SWAPTOTAL#", mod(fmt.Sprintf("%d", ns.Server.Host.SwapTotal)),
|
||||
"#SERVER.DISKTOTAL#", mod(fmt.Sprintf("%d", ns.Server.Host.DiskTotal)),
|
||||
"#SERVER.NETINSPEED#", mod(fmt.Sprintf("%d", ns.Server.State.NetInSpeed)),
|
||||
"#SERVER.NETOUTSPEED#", mod(fmt.Sprintf("%d", ns.Server.State.NetOutSpeed)),
|
||||
"#SERVER.NETINTRANSFER#", mod(fmt.Sprintf("%d", ns.Server.State.NetInTransfer)),
|
||||
"#SERVER.NETOUTTRANSFER#", mod(fmt.Sprintf("%d", ns.Server.State.NetOutTransfer)),
|
||||
"#SERVER.LOAD1#", mod(fmt.Sprintf("%f", ns.Server.State.Load1)),
|
||||
"#SERVER.LOAD5#", mod(fmt.Sprintf("%f", ns.Server.State.Load5)),
|
||||
"#SERVER.LOAD15#", mod(fmt.Sprintf("%f", ns.Server.State.Load15)),
|
||||
"#SERVER.TCPCONNCOUNT#", mod(fmt.Sprintf("%d", ns.Server.State.TcpConnCount)),
|
||||
"#SERVER.UDPCONNCOUNT#", mod(fmt.Sprintf("%d", ns.Server.State.UdpConnCount)),
|
||||
)
|
||||
|
||||
var ipv4, ipv6, validIP string
|
||||
ip := ns.Server.GeoIP.IP
|
||||
if ip.IPv4Addr != "" && ip.IPv6Addr != "" {
|
||||
ipv4 = ip.IPv4Addr
|
||||
ipv6 = ip.IPv6Addr
|
||||
validIP = ipv4
|
||||
} else if ip.IPv4Addr != "" {
|
||||
ipv4 = ip.IPv4Addr
|
||||
validIP = ipv4
|
||||
} else {
|
||||
ipv6 = ip.IPv6Addr
|
||||
validIP = ipv6
|
||||
}
|
||||
|
||||
replacements = append(replacements,
|
||||
"#SERVER.IP#", mod(validIP),
|
||||
"#SERVER.IPV4#", mod(ipv4),
|
||||
"#SERVER.IPV6#", mod(ipv6),
|
||||
)
|
||||
}
|
||||
|
||||
replacer := strings.NewReplacer(replacements...)
|
||||
return replacer.Replace(str)
|
||||
}
|
||||
|
||||
func (ns *NotificationServerBundle) formatUsage(toPercentage bool, usage float64) string {
|
||||
if ns.Notification.FormatMetricUnits != nil && *ns.Notification.FormatMetricUnits {
|
||||
if toPercentage {
|
||||
usage = usage * 100
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%.2f %%", usage)
|
||||
}
|
||||
return fmt.Sprintf("%f", usage)
|
||||
}
|
||||
|
||||
func (ns *NotificationServerBundle) formatSize(size uint64) string {
|
||||
if ns.Notification.FormatMetricUnits != nil && *ns.Notification.FormatMetricUnits {
|
||||
return utils.Bytes(size)
|
||||
}
|
||||
return fmt.Sprintf("%d", size)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package model
|
||||
|
||||
type NotificationForm struct {
|
||||
Name string `json:"name,omitempty" minLength:"1"`
|
||||
URL string `json:"url,omitempty"`
|
||||
RequestMethod uint8 `json:"request_method,omitempty"`
|
||||
RequestType uint8 `json:"request_type,omitempty"`
|
||||
RequestHeader string `json:"request_header,omitempty"`
|
||||
RequestBody string `json:"request_body,omitempty"`
|
||||
VerifyTLS bool `json:"verify_tls,omitempty" validate:"optional"`
|
||||
SkipCheck bool `json:"skip_check,omitempty" validate:"optional"`
|
||||
FormatMetricUnits bool `json:"format_metric_units,omitempty" validate:"optional"`
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package model
|
||||
|
||||
type NotificationGroup struct {
|
||||
Common
|
||||
Name string `json:"name"`
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package model
|
||||
|
||||
type NotificationGroupForm struct {
|
||||
Name string `json:"name" minLength:"1"`
|
||||
Notifications []uint64 `json:"notifications"`
|
||||
}
|
||||
|
||||
type NotificationGroupResponseItem struct {
|
||||
Group NotificationGroup `json:"group"`
|
||||
Notifications []uint64 `json:"notifications"`
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package model
|
||||
|
||||
type NotificationGroupNotification struct {
|
||||
Common
|
||||
NotificationGroupID uint64 `json:"notification_group_id" gorm:"uniqueIndex:idx_notification_group_notification"`
|
||||
NotificationID uint64 `json:"notification_id" gorm:"uniqueIndex:idx_notification_group_notification"`
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
msg = "msg"
|
||||
reqTypeForm = "application/x-www-form-urlencoded"
|
||||
reqTypeJSON = "application/json"
|
||||
)
|
||||
|
||||
type testSt struct {
|
||||
url string
|
||||
body string
|
||||
header string
|
||||
reqType uint8
|
||||
reqMethod uint8
|
||||
expectURL string
|
||||
expectBody string
|
||||
expectMethod string
|
||||
expectContentType string
|
||||
expectHeader map[string]string
|
||||
}
|
||||
|
||||
func execCase(t *testing.T, item testSt) {
|
||||
trueBool := true
|
||||
n := Notification{
|
||||
URL: item.url,
|
||||
RequestMethod: item.reqMethod,
|
||||
RequestType: item.reqType,
|
||||
RequestBody: item.body,
|
||||
RequestHeader: item.header,
|
||||
FormatMetricUnits: &trueBool,
|
||||
}
|
||||
server := Server{
|
||||
Common: Common{},
|
||||
Name: "ServerName",
|
||||
Note: "",
|
||||
DisplayIndex: 0,
|
||||
Host: &Host{
|
||||
Platform: "",
|
||||
PlatformVersion: "",
|
||||
CPU: nil,
|
||||
MemTotal: 0,
|
||||
DiskTotal: 0,
|
||||
SwapTotal: 8888,
|
||||
Arch: "",
|
||||
Virtualization: "",
|
||||
BootTime: 0,
|
||||
Version: "",
|
||||
},
|
||||
State: &HostState{
|
||||
CPU: 0,
|
||||
MemUsed: 0,
|
||||
SwapUsed: 8888,
|
||||
DiskUsed: 0,
|
||||
NetInTransfer: 0,
|
||||
NetOutTransfer: 0,
|
||||
NetInSpeed: 0,
|
||||
NetOutSpeed: 0,
|
||||
Uptime: 0,
|
||||
Load1: 0,
|
||||
Load5: 0,
|
||||
Load15: 0,
|
||||
TcpConnCount: 0,
|
||||
UdpConnCount: 0,
|
||||
ProcessCount: 0,
|
||||
},
|
||||
GeoIP: &GeoIP{
|
||||
IP: IP{
|
||||
IPv4Addr: "1.1.1.1",
|
||||
},
|
||||
CountryCode: "",
|
||||
},
|
||||
LastActive: time.Time{},
|
||||
TaskStream: nil,
|
||||
PrevTransferInSnapshot: 0,
|
||||
PrevTransferOutSnapshot: 0,
|
||||
}
|
||||
ns := NotificationServerBundle{
|
||||
Notification: &n,
|
||||
Server: &server,
|
||||
Loc: time.Local,
|
||||
}
|
||||
if item.expectURL != ns.reqURL(msg) {
|
||||
t.Fatalf("Expected %s, but got %s", item.expectURL, ns.reqURL(msg))
|
||||
}
|
||||
reqBody, err := ns.reqBody(msg)
|
||||
if err != nil {
|
||||
t.Fatalf("Error: %s", err)
|
||||
}
|
||||
if item.expectBody != reqBody {
|
||||
t.Fatalf("Expected %s, but got %s", item.expectBody, reqBody)
|
||||
}
|
||||
reqMethod, err := n.reqMethod()
|
||||
if err != nil {
|
||||
t.Fatalf("Error: %s", err)
|
||||
}
|
||||
if item.expectMethod != reqMethod {
|
||||
t.Fatalf("Expected %s, but got %s", item.expectMethod, reqMethod)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("", "", strings.NewReader(""))
|
||||
if err != nil {
|
||||
t.Fatalf("Error: %s", err)
|
||||
}
|
||||
n.setContentType(req)
|
||||
if item.expectContentType != req.Header.Get("Content-Type") {
|
||||
t.Fatalf("Expected %s, but got %s", item.expectContentType, req.Header.Get("Content-Type"))
|
||||
}
|
||||
n.setRequestHeader(req)
|
||||
for k, v := range item.expectHeader {
|
||||
if v != req.Header.Get(k) {
|
||||
t.Fatalf("Expected %s, but got %s", v, req.Header.Get(k))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotification(t *testing.T) {
|
||||
cases := []testSt{
|
||||
{
|
||||
url: "https://example.com",
|
||||
body: `{"asd":"dsa"}`,
|
||||
header: `{"asd":"dsa"}`,
|
||||
reqMethod: NotificationRequestMethodGET,
|
||||
expectURL: "https://example.com",
|
||||
expectMethod: http.MethodGet,
|
||||
expectContentType: "",
|
||||
expectHeader: map[string]string{"asd": "dsa"},
|
||||
expectBody: "",
|
||||
},
|
||||
{
|
||||
url: "https://example.com/?m=#NEZHA#",
|
||||
body: `{"asd":"dsa"}`,
|
||||
reqMethod: NotificationRequestMethodGET,
|
||||
expectURL: "https://example.com/?m=" + msg,
|
||||
expectMethod: http.MethodGet,
|
||||
expectContentType: "",
|
||||
expectBody: "",
|
||||
},
|
||||
{
|
||||
url: "https://example.com/?m=#NEZHA#",
|
||||
body: `{"asd":"#NEZHA#"}`,
|
||||
reqMethod: NotificationRequestMethodPOST,
|
||||
reqType: NotificationRequestTypeForm,
|
||||
expectURL: "https://example.com/?m=" + msg,
|
||||
expectMethod: http.MethodPost,
|
||||
expectContentType: reqTypeForm,
|
||||
expectBody: "asd=" + msg,
|
||||
},
|
||||
{
|
||||
url: "https://example.com/?m=#NEZHA#",
|
||||
body: `{"#NEZHA#":"#NEZHA#"}`,
|
||||
reqMethod: NotificationRequestMethodPOST,
|
||||
reqType: NotificationRequestTypeForm,
|
||||
expectURL: "https://example.com/?m=" + msg,
|
||||
expectMethod: http.MethodPost,
|
||||
expectContentType: reqTypeForm,
|
||||
expectBody: "%23NEZHA%23=" + msg,
|
||||
},
|
||||
{
|
||||
url: "https://example.com/?m=#NEZHA#",
|
||||
body: `{"asd":"#NEZHA#"}`,
|
||||
reqMethod: NotificationRequestMethodPOST,
|
||||
reqType: NotificationRequestTypeJSON,
|
||||
expectURL: "https://example.com/?m=" + msg,
|
||||
expectMethod: http.MethodPost,
|
||||
expectContentType: reqTypeJSON,
|
||||
expectBody: `{"asd":"msg"}`,
|
||||
},
|
||||
{
|
||||
url: "https://example.com/?m=#NEZHA#",
|
||||
body: `{"#NEZHA#":"#NEZHA#"}`,
|
||||
reqMethod: NotificationRequestMethodPOST,
|
||||
header: `{"asd":"dsa11"}`,
|
||||
reqType: NotificationRequestTypeJSON,
|
||||
expectURL: "https://example.com/?m=" + msg,
|
||||
expectMethod: http.MethodPost,
|
||||
expectContentType: reqTypeJSON,
|
||||
expectBody: `{"msg":"msg"}`,
|
||||
expectHeader: map[string]string{"asd": "dsa11"},
|
||||
},
|
||||
{
|
||||
url: "https://example.com/?m=#NEZHA#",
|
||||
body: `{"Server":"#SERVER.NAME#","ServerIP":"#SERVER.IP#","ServerSWAP":"#SERVER.SWAP#"}`,
|
||||
reqMethod: NotificationRequestMethodPOST,
|
||||
header: `{"asd":"dsa11"}`,
|
||||
reqType: NotificationRequestTypeJSON,
|
||||
expectURL: "https://example.com/?m=" + msg,
|
||||
expectMethod: http.MethodPost,
|
||||
expectContentType: reqTypeJSON,
|
||||
expectBody: `{"Server":"ServerName","ServerIP":"1.1.1.1","ServerSWAP":"100.00 %"}`,
|
||||
expectHeader: map[string]string{"asd": "dsa11"},
|
||||
},
|
||||
{
|
||||
url: "https://example.com/?m=#NEZHA#",
|
||||
body: `{"#NEZHA#":"#NEZHA#","Server":"#SERVER.NAME#","ServerIP":"#SERVER.IP#","ServerSWAP":"#SERVER.SWAP#"}`,
|
||||
reqMethod: NotificationRequestMethodPOST,
|
||||
reqType: NotificationRequestTypeForm,
|
||||
expectURL: "https://example.com/?m=" + msg,
|
||||
expectMethod: http.MethodPost,
|
||||
expectContentType: reqTypeForm,
|
||||
expectBody: "%23NEZHA%23=" + msg + "&Server=ServerName&ServerIP=1.1.1.1&ServerSWAP=100.00+%25",
|
||||
},
|
||||
{
|
||||
url: "https://example.com/?m=#NEZHA#",
|
||||
body: `{"Server":"#SERVER.NAME#","ServerIP":"#SERVER.IP#","ServerSWAP":#SERVER.SWAPUSED#}`,
|
||||
reqMethod: NotificationRequestMethodPOST,
|
||||
header: `{"asd":"dsa11"}`,
|
||||
reqType: NotificationRequestTypeJSON,
|
||||
expectURL: "https://example.com/?m=" + msg,
|
||||
expectMethod: http.MethodPost,
|
||||
expectContentType: reqTypeJSON,
|
||||
expectBody: `{"Server":"ServerName","ServerIP":"1.1.1.1","ServerSWAP":8888}`,
|
||||
expectHeader: map[string]string{"asd": "dsa11"},
|
||||
},
|
||||
{
|
||||
url: "https://example.com/?m=#NEZHA#",
|
||||
body: `{"#NEZHA#":"#NEZHA#","Server":"#SERVER.NAME#","ServerIP":"#SERVER.IP#","ServerSWAP":"#SERVER.SWAPUSED#"}`,
|
||||
reqMethod: NotificationRequestMethodPOST,
|
||||
reqType: NotificationRequestTypeForm,
|
||||
expectURL: "https://example.com/?m=" + msg,
|
||||
expectMethod: http.MethodPost,
|
||||
expectContentType: reqTypeForm,
|
||||
expectBody: "%23NEZHA%23=" + msg + "&Server=ServerName&ServerIP=1.1.1.1&ServerSWAP=8888",
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
execCase(t, c)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package model
|
||||
|
||||
type Oauth2Bind struct {
|
||||
Common
|
||||
|
||||
UserID uint64 `gorm:"uniqueIndex:u_p_o" json:"user_id,omitempty"`
|
||||
Provider string `gorm:"uniqueIndex:u_p_o" json:"provider,omitempty"`
|
||||
OpenID string `gorm:"uniqueIndex:u_p_o" json:"open_id,omitempty"`
|
||||
}
|
||||
|
||||
type Oauth2LoginType uint8
|
||||
|
||||
const (
|
||||
_ Oauth2LoginType = iota
|
||||
RTypeLogin
|
||||
RTypeBind
|
||||
)
|
||||
|
||||
type Oauth2State struct {
|
||||
Action Oauth2LoginType
|
||||
Provider string
|
||||
State string
|
||||
RedirectURL string
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
type Oauth2Config struct {
|
||||
ClientID string `koanf:"client_id" json:"client_id,omitempty"`
|
||||
ClientSecret string `koanf:"client_secret" json:"client_secret,omitempty"`
|
||||
Endpoint Oauth2Endpoint `koanf:"endpoint" json:"endpoint,omitempty"`
|
||||
Scopes []string `koanf:"scopes" json:"scopes,omitempty"`
|
||||
|
||||
UserInfoURL string `koanf:"user_info_url" json:"user_info_url,omitempty"`
|
||||
UserIDPath string `koanf:"user_id_path" json:"user_id_path,omitempty"`
|
||||
}
|
||||
|
||||
type Oauth2Endpoint struct {
|
||||
AuthURL string `koanf:"auth_url" json:"auth_url,omitempty"`
|
||||
TokenURL string `koanf:"token_url" json:"token_url,omitempty"`
|
||||
}
|
||||
|
||||
func (c *Oauth2Config) Setup(redirectURL string) *oauth2.Config {
|
||||
return &oauth2.Config{
|
||||
ClientID: c.ClientID,
|
||||
ClientSecret: c.ClientSecret,
|
||||
Endpoint: oauth2.Endpoint{
|
||||
AuthURL: c.Endpoint.AuthURL,
|
||||
TokenURL: c.Endpoint.TokenURL,
|
||||
},
|
||||
RedirectURL: redirectURL,
|
||||
Scopes: c.Scopes,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/nezhahq/nezha/pkg/utils"
|
||||
)
|
||||
|
||||
const (
|
||||
RuleCoverAll = iota
|
||||
RuleCoverIgnoreAll
|
||||
)
|
||||
|
||||
type NResult struct {
|
||||
N uint64
|
||||
}
|
||||
|
||||
type Rule struct {
|
||||
// 指标类型,cpu、memory、swap、disk、net_in_speed、net_out_speed
|
||||
// net_all_speed、transfer_in、transfer_out、transfer_all、offline
|
||||
// transfer_in_cycle、transfer_out_cycle、transfer_all_cycle
|
||||
Type string `json:"type"`
|
||||
Min float64 `json:"min,omitempty" validate:"optional"` // 最小阈值 (百分比、字节 kb ÷ 1024)
|
||||
Max float64 `json:"max,omitempty" validate:"optional"` // 最大阈值 (百分比、字节 kb ÷ 1024)
|
||||
CycleStart *time.Time `json:"cycle_start,omitempty" validate:"optional"` // 流量统计的开始时间
|
||||
CycleInterval uint64 `json:"cycle_interval,omitempty" validate:"optional"` // 流量统计周期
|
||||
CycleUnit string `json:"cycle_unit,omitempty" enums:"hour,day,week,month,year" validate:"optional" default:"hour"` // 流量统计周期单位,默认hour,可选(hour, day, week, month, year)
|
||||
Duration uint64 `json:"duration,omitempty" validate:"optional"` // 持续时间 (秒)
|
||||
Cover uint64 `json:"cover"` // 覆盖范围 RuleCoverAll/IgnoreAll
|
||||
Ignore map[uint64]bool `json:"ignore,omitempty" validate:"optional"` // 覆盖范围的排除
|
||||
|
||||
// 只作为缓存使用,记录下次该检测的时间
|
||||
NextTransferAt map[uint64]time.Time `json:"-"`
|
||||
LastCycleStatus map[uint64]bool `json:"-"`
|
||||
}
|
||||
|
||||
func percentage(used, total uint64) float64 {
|
||||
if total == 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(used) * 100 / float64(total)
|
||||
}
|
||||
|
||||
// Snapshot 未通过规则返回 false, 通过返回 true
|
||||
func (u *Rule) Snapshot(cycleTransferStats *CycleTransferStats, server *Server, db *gorm.DB) bool {
|
||||
// 监控全部但是排除了此服务器
|
||||
if u.Cover == RuleCoverAll && u.Ignore[server.ID] {
|
||||
return true
|
||||
}
|
||||
// 忽略全部但是指定监控了此服务器
|
||||
if u.Cover == RuleCoverIgnoreAll && !u.Ignore[server.ID] {
|
||||
return true
|
||||
}
|
||||
|
||||
// 循环区间流量检测 · 短期无需重复检测
|
||||
if u.IsTransferDurationRule() && u.NextTransferAt[server.ID].After(time.Now()) {
|
||||
return u.LastCycleStatus[server.ID]
|
||||
}
|
||||
|
||||
var src float64
|
||||
|
||||
switch u.Type {
|
||||
case "cpu":
|
||||
src = float64(server.State.CPU)
|
||||
case "gpu_max":
|
||||
src = slices.Max(server.State.GPU)
|
||||
case "memory":
|
||||
src = percentage(server.State.MemUsed, server.Host.MemTotal)
|
||||
case "swap":
|
||||
src = percentage(server.State.SwapUsed, server.Host.SwapTotal)
|
||||
case "disk":
|
||||
src = percentage(server.State.DiskUsed, server.Host.DiskTotal)
|
||||
case "net_in_speed":
|
||||
src = float64(server.State.NetInSpeed)
|
||||
case "net_out_speed":
|
||||
src = float64(server.State.NetOutSpeed)
|
||||
case "net_all_speed":
|
||||
src = float64(server.State.NetOutSpeed + server.State.NetOutSpeed)
|
||||
case "transfer_in":
|
||||
src = float64(server.State.NetInTransfer)
|
||||
case "transfer_out":
|
||||
src = float64(server.State.NetOutTransfer)
|
||||
case "transfer_all":
|
||||
src = float64(server.State.NetOutTransfer + server.State.NetInTransfer)
|
||||
case "offline":
|
||||
if server.LastActive.IsZero() {
|
||||
src = 0
|
||||
} else {
|
||||
src = float64(server.LastActive.Unix())
|
||||
}
|
||||
case "transfer_in_cycle":
|
||||
src = float64(utils.SubUintChecked(server.State.NetInTransfer, server.PrevTransferInSnapshot))
|
||||
if u.CycleInterval != 0 {
|
||||
var res NResult
|
||||
db.Model(&Transfer{}).Select("SUM(`in`) AS n").Where("datetime(`created_at`) >= datetime(?) AND server_id = ?", u.GetTransferDurationStart().UTC(), server.ID).Scan(&res)
|
||||
src += float64(res.N)
|
||||
}
|
||||
case "transfer_out_cycle":
|
||||
src = float64(utils.SubUintChecked(server.State.NetOutTransfer, server.PrevTransferOutSnapshot))
|
||||
if u.CycleInterval != 0 {
|
||||
var res NResult
|
||||
db.Model(&Transfer{}).Select("SUM(`out`) AS n").Where("datetime(`created_at`) >= datetime(?) AND server_id = ?", u.GetTransferDurationStart().UTC(), server.ID).Scan(&res)
|
||||
src += float64(res.N)
|
||||
}
|
||||
case "transfer_all_cycle":
|
||||
src = float64(utils.SubUintChecked(server.State.NetOutTransfer, server.PrevTransferOutSnapshot) + utils.SubUintChecked(server.State.NetInTransfer, server.PrevTransferInSnapshot))
|
||||
if u.CycleInterval != 0 {
|
||||
var res NResult
|
||||
db.Model(&Transfer{}).Select("SUM(`in`+`out`) AS n").Where("datetime(`created_at`) >= datetime(?) AND server_id = ?", u.GetTransferDurationStart().UTC(), server.ID).Scan(&res)
|
||||
src += float64(res.N)
|
||||
}
|
||||
case "load1":
|
||||
src = server.State.Load1
|
||||
case "load5":
|
||||
src = server.State.Load5
|
||||
case "load15":
|
||||
src = server.State.Load15
|
||||
case "tcp_conn_count":
|
||||
src = float64(server.State.TcpConnCount)
|
||||
case "udp_conn_count":
|
||||
src = float64(server.State.UdpConnCount)
|
||||
case "process_count":
|
||||
src = float64(server.State.ProcessCount)
|
||||
case "temperature_max":
|
||||
var temp []float64
|
||||
if server.State.Temperatures != nil {
|
||||
for _, tempStat := range server.State.Temperatures {
|
||||
if tempStat.Temperature != 0 {
|
||||
temp = append(temp, tempStat.Temperature)
|
||||
}
|
||||
}
|
||||
src = slices.Max(temp)
|
||||
}
|
||||
}
|
||||
|
||||
// 循环区间流量检测 · 更新下次需要检测时间
|
||||
if u.IsTransferDurationRule() {
|
||||
seconds := max(1800*((u.Max-src)/u.Max), 180)
|
||||
if u.NextTransferAt == nil {
|
||||
u.NextTransferAt = make(map[uint64]time.Time)
|
||||
}
|
||||
if u.LastCycleStatus == nil {
|
||||
u.LastCycleStatus = make(map[uint64]bool)
|
||||
}
|
||||
u.NextTransferAt[server.ID] = time.Now().Add(time.Second * time.Duration(seconds))
|
||||
if (u.Max > 0 && src > u.Max) || (u.Min > 0 && src < u.Min) {
|
||||
u.LastCycleStatus[server.ID] = false
|
||||
} else {
|
||||
u.LastCycleStatus[server.ID] = true
|
||||
}
|
||||
if cycleTransferStats.ServerName[server.ID] != server.Name {
|
||||
cycleTransferStats.ServerName[server.ID] = server.Name
|
||||
}
|
||||
cycleTransferStats.Transfer[server.ID] = uint64(src)
|
||||
cycleTransferStats.NextUpdate[server.ID] = u.NextTransferAt[server.ID]
|
||||
// 自动更新周期流量展示起止时间
|
||||
cycleTransferStats.From = u.GetTransferDurationStart()
|
||||
cycleTransferStats.To = u.GetTransferDurationEnd()
|
||||
}
|
||||
|
||||
if u.Type == "offline" && float64(time.Now().Unix())-src > 6 {
|
||||
return false
|
||||
} else if (u.Max > 0 && src > u.Max) || (u.Min > 0 && src < u.Min) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// IsTransferDurationRule 判断该规则是否属于周期流量规则 属于则返回true
|
||||
func (u *Rule) IsTransferDurationRule() bool {
|
||||
return strings.HasSuffix(u.Type, "_cycle")
|
||||
}
|
||||
|
||||
func (u *Rule) IsOfflineRule() bool {
|
||||
return u.Type == "offline"
|
||||
}
|
||||
|
||||
// GetTransferDurationStart 获取周期流量的起始时间
|
||||
func (u *Rule) GetTransferDurationStart() time.Time {
|
||||
// Accept uppercase and lowercase
|
||||
unit := strings.ToLower(u.CycleUnit)
|
||||
startTime := *u.CycleStart
|
||||
var nextTime time.Time
|
||||
switch unit {
|
||||
case "year":
|
||||
nextTime = startTime.AddDate(int(u.CycleInterval), 0, 0)
|
||||
for time.Now().After(nextTime) {
|
||||
startTime = nextTime
|
||||
nextTime = nextTime.AddDate(int(u.CycleInterval), 0, 0)
|
||||
}
|
||||
case "month":
|
||||
nextTime = startTime.AddDate(0, int(u.CycleInterval), 0)
|
||||
for time.Now().After(nextTime) {
|
||||
startTime = nextTime
|
||||
nextTime = nextTime.AddDate(0, int(u.CycleInterval), 0)
|
||||
}
|
||||
case "week":
|
||||
nextTime = startTime.AddDate(0, 0, 7*int(u.CycleInterval))
|
||||
for time.Now().After(nextTime) {
|
||||
startTime = nextTime
|
||||
nextTime = nextTime.AddDate(0, 0, 7*int(u.CycleInterval))
|
||||
}
|
||||
case "day":
|
||||
nextTime = startTime.AddDate(0, 0, int(u.CycleInterval))
|
||||
for time.Now().After(nextTime) {
|
||||
startTime = nextTime
|
||||
nextTime = nextTime.AddDate(0, 0, int(u.CycleInterval))
|
||||
}
|
||||
default:
|
||||
// For hour unit or not set.
|
||||
interval := 3600 * int64(u.CycleInterval)
|
||||
startTime = time.Unix(u.CycleStart.Unix()+(time.Now().Unix()-u.CycleStart.Unix())/interval*interval, 0)
|
||||
}
|
||||
|
||||
return startTime
|
||||
}
|
||||
|
||||
// GetTransferDurationEnd 获取周期流量结束时间
|
||||
func (u *Rule) GetTransferDurationEnd() time.Time {
|
||||
// Accept uppercase and lowercase
|
||||
unit := strings.ToLower(u.CycleUnit)
|
||||
startTime := *u.CycleStart
|
||||
var nextTime time.Time
|
||||
switch unit {
|
||||
case "year":
|
||||
nextTime = startTime.AddDate(int(u.CycleInterval), 0, 0)
|
||||
for time.Now().After(nextTime) {
|
||||
startTime = nextTime
|
||||
nextTime = nextTime.AddDate(int(u.CycleInterval), 0, 0)
|
||||
}
|
||||
case "month":
|
||||
nextTime = startTime.AddDate(0, int(u.CycleInterval), 0)
|
||||
for time.Now().After(nextTime) {
|
||||
startTime = nextTime
|
||||
nextTime = nextTime.AddDate(0, int(u.CycleInterval), 0)
|
||||
}
|
||||
case "week":
|
||||
nextTime = startTime.AddDate(0, 0, 7*int(u.CycleInterval))
|
||||
for time.Now().After(nextTime) {
|
||||
startTime = nextTime
|
||||
nextTime = nextTime.AddDate(0, 0, 7*int(u.CycleInterval))
|
||||
}
|
||||
case "day":
|
||||
nextTime = startTime.AddDate(0, 0, int(u.CycleInterval))
|
||||
for time.Now().After(nextTime) {
|
||||
startTime = nextTime
|
||||
nextTime = nextTime.AddDate(0, 0, int(u.CycleInterval))
|
||||
}
|
||||
default:
|
||||
// For hour unit or not set.
|
||||
interval := 3600 * int64(u.CycleInterval)
|
||||
startTime = time.Unix(u.CycleStart.Unix()+(time.Now().Unix()-u.CycleStart.Unix())/interval*interval, 0)
|
||||
nextTime = time.Unix(startTime.Unix()+interval, 0)
|
||||
}
|
||||
|
||||
return nextTime
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"log"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/goccy/go-json"
|
||||
"gorm.io/gorm"
|
||||
|
||||
pb "github.com/nezhahq/nezha/proto"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
Common
|
||||
|
||||
Name string `json:"name"`
|
||||
UUID string `json:"uuid,omitempty" gorm:"unique"`
|
||||
Note string `json:"note,omitempty"` // 管理员可见备注
|
||||
PublicNote string `json:"public_note,omitempty"` // 公开备注
|
||||
DisplayIndex int `json:"display_index"` // 展示排序,越大越靠前
|
||||
HideForGuest bool `json:"hide_for_guest,omitempty"` // 对游客隐藏
|
||||
EnableDDNS bool `json:"enable_ddns,omitempty"` // 启用DDNS
|
||||
DDNSProfilesRaw string `gorm:"default:'[]';column:ddns_profiles_raw" json:"-"`
|
||||
OverrideDDNSDomainsRaw string `gorm:"default:'{}';column:override_ddns_domains_raw" json:"-"`
|
||||
|
||||
DDNSProfiles []uint64 `gorm:"-" json:"ddns_profiles,omitempty" validate:"optional"` // DDNS配置
|
||||
OverrideDDNSDomains map[uint64][]string `gorm:"-" json:"override_ddns_domains,omitempty" validate:"optional"`
|
||||
|
||||
Host *Host `gorm:"-" json:"host,omitempty"`
|
||||
State *HostState `gorm:"-" json:"state,omitempty"`
|
||||
GeoIP *GeoIP `gorm:"-" json:"geoip,omitempty"`
|
||||
LastActive time.Time `gorm:"-" json:"last_active,omitempty"`
|
||||
|
||||
TaskStream pb.NezhaService_RequestTaskServer `gorm:"-" json:"-"`
|
||||
ConfigCache chan any `gorm:"-" json:"-"`
|
||||
|
||||
PrevTransferInSnapshot uint64 `gorm:"-" json:"-"` // 上次数据点时的入站使用量
|
||||
PrevTransferOutSnapshot uint64 `gorm:"-" json:"-"` // 上次数据点时的出站使用量
|
||||
}
|
||||
|
||||
func InitServer(s *Server) {
|
||||
s.Host = &Host{}
|
||||
s.State = &HostState{}
|
||||
s.GeoIP = &GeoIP{}
|
||||
s.ConfigCache = make(chan any, 1)
|
||||
}
|
||||
|
||||
func (s *Server) CopyFromRunningServer(old *Server) {
|
||||
s.Host = old.Host
|
||||
s.State = old.State
|
||||
s.GeoIP = old.GeoIP
|
||||
s.LastActive = old.LastActive
|
||||
s.TaskStream = old.TaskStream
|
||||
s.ConfigCache = old.ConfigCache
|
||||
s.PrevTransferInSnapshot = old.PrevTransferInSnapshot
|
||||
s.PrevTransferOutSnapshot = old.PrevTransferOutSnapshot
|
||||
}
|
||||
|
||||
func (s *Server) AfterFind(tx *gorm.DB) error {
|
||||
if s.DDNSProfilesRaw != "" {
|
||||
if err := json.Unmarshal([]byte(s.DDNSProfilesRaw), &s.DDNSProfiles); err != nil {
|
||||
log.Println("NEZHA>> Server.AfterFind:", err)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if s.OverrideDDNSDomainsRaw != "" {
|
||||
if err := json.Unmarshal([]byte(s.OverrideDDNSDomainsRaw), &s.OverrideDDNSDomains); err != nil {
|
||||
log.Println("NEZHA>> Server.AfterFind:", err)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) SplitList(x []*Server) ([]*Server, []*Server) {
|
||||
pri := func(s *Server) bool {
|
||||
return s.DisplayIndex == 0
|
||||
}
|
||||
|
||||
i := slices.IndexFunc(x, pri)
|
||||
if i == -1 {
|
||||
return nil, x
|
||||
}
|
||||
|
||||
return x[:i], x[i:]
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
type StreamServer struct {
|
||||
ID uint64 `json:"id,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
PublicNote string `json:"public_note,omitempty"` // 公开备注,只第一个数据包有值
|
||||
DisplayIndex int `json:"display_index,omitempty"` // 展示排序,越大越靠前
|
||||
|
||||
Host *Host `json:"host,omitempty"`
|
||||
State *HostState `json:"state,omitempty"`
|
||||
CountryCode string `json:"country_code,omitempty"`
|
||||
LastActive time.Time `json:"last_active,omitempty"`
|
||||
}
|
||||
|
||||
type StreamServerData struct {
|
||||
Now int64 `json:"now,omitempty"`
|
||||
Online int `json:"online,omitempty"`
|
||||
Servers []StreamServer `json:"servers,omitempty"`
|
||||
}
|
||||
|
||||
type ServerForm struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Note string `json:"note,omitempty" validate:"optional"` // 管理员可见备注
|
||||
PublicNote string `json:"public_note,omitempty" validate:"optional"` // 公开备注
|
||||
DisplayIndex int `json:"display_index,omitempty" default:"0"` // 展示排序,越大越靠前
|
||||
HideForGuest bool `json:"hide_for_guest,omitempty" validate:"optional"` // 对游客隐藏
|
||||
EnableDDNS bool `json:"enable_ddns,omitempty" validate:"optional"` // 启用DDNS
|
||||
DDNSProfiles []uint64 `json:"ddns_profiles,omitempty" validate:"optional"` // DDNS配置
|
||||
OverrideDDNSDomains map[uint64][]string `json:"override_ddns_domains,omitempty" validate:"optional"`
|
||||
}
|
||||
|
||||
type ServerConfigForm struct {
|
||||
Servers []uint64 `json:"servers,omitempty"`
|
||||
Config string `json:"config,omitempty"`
|
||||
}
|
||||
|
||||
type ServerTaskResponse struct {
|
||||
Success []uint64 `json:"success,omitempty" validate:"optional"`
|
||||
Failure []uint64 `json:"failure,omitempty" validate:"optional"`
|
||||
Offline []uint64 `json:"offline,omitempty" validate:"optional"`
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package model
|
||||
|
||||
type ServerGroup struct {
|
||||
Common
|
||||
|
||||
Name string `json:"name"`
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package model
|
||||
|
||||
type ServerGroupForm struct {
|
||||
Name string `json:"name" minLength:"1"`
|
||||
Servers []uint64 `json:"servers"`
|
||||
}
|
||||
|
||||
type ServerGroupResponseItem struct {
|
||||
Group ServerGroup `json:"group"`
|
||||
Servers []uint64 `json:"servers"`
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package model
|
||||
|
||||
type ServerGroupServer struct {
|
||||
Common
|
||||
ServerGroupId uint64 `json:"server_group_id" gorm:"uniqueIndex:idx_server_group_server"`
|
||||
ServerId uint64 `json:"server_id" gorm:"uniqueIndex:idx_server_group_server"`
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/goccy/go-json"
|
||||
"github.com/robfig/cron/v3"
|
||||
"gorm.io/gorm"
|
||||
|
||||
pb "github.com/nezhahq/nezha/proto"
|
||||
)
|
||||
|
||||
const (
|
||||
_ = iota
|
||||
TaskTypeHTTPGet
|
||||
TaskTypeICMPPing
|
||||
TaskTypeTCPPing
|
||||
TaskTypeCommand
|
||||
TaskTypeTerminal
|
||||
TaskTypeUpgrade
|
||||
TaskTypeKeepalive
|
||||
TaskTypeTerminalGRPC
|
||||
TaskTypeNAT
|
||||
TaskTypeReportHostInfoDeprecated
|
||||
TaskTypeFM
|
||||
TaskTypeReportConfig
|
||||
TaskTypeApplyConfig
|
||||
)
|
||||
|
||||
type TerminalTask struct {
|
||||
StreamID string
|
||||
}
|
||||
|
||||
type TaskNAT struct {
|
||||
StreamID string
|
||||
Host string
|
||||
}
|
||||
|
||||
type TaskFM struct {
|
||||
StreamID string
|
||||
}
|
||||
|
||||
const (
|
||||
ServiceCoverAll = iota
|
||||
ServiceCoverIgnoreAll
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
Common
|
||||
Name string `json:"name"`
|
||||
Type uint8 `json:"type"`
|
||||
Target string `json:"target"`
|
||||
SkipServersRaw string `json:"-"`
|
||||
Duration uint64 `json:"duration"`
|
||||
DisplayIndex int `json:"display_index"` // 展示排序,越大越靠前
|
||||
Notify bool `json:"notify,omitempty"`
|
||||
NotificationGroupID uint64 `json:"notification_group_id"` // 当前服务监控所属的通知组 ID
|
||||
Cover uint8 `json:"cover"`
|
||||
|
||||
EnableTriggerTask bool `gorm:"default: false" json:"enable_trigger_task,omitempty"`
|
||||
EnableShowInService bool `gorm:"default: false" json:"enable_show_in_service,omitempty"`
|
||||
FailTriggerTasksRaw string `gorm:"default:'[]'" json:"-"`
|
||||
RecoverTriggerTasksRaw string `gorm:"default:'[]'" json:"-"`
|
||||
|
||||
FailTriggerTasks []uint64 `gorm:"-" json:"fail_trigger_tasks"` // 失败时执行的触发任务id
|
||||
RecoverTriggerTasks []uint64 `gorm:"-" json:"recover_trigger_tasks"` // 恢复时执行的触发任务id
|
||||
|
||||
MinLatency float32 `json:"min_latency"`
|
||||
MaxLatency float32 `json:"max_latency"`
|
||||
LatencyNotify bool `json:"latency_notify,omitempty"`
|
||||
|
||||
SkipServers map[uint64]bool `gorm:"-" json:"skip_servers"`
|
||||
CronJobID cron.EntryID `gorm:"-" json:"-"`
|
||||
}
|
||||
|
||||
func (m *Service) PB() *pb.Task {
|
||||
return &pb.Task{
|
||||
Id: m.ID,
|
||||
Type: uint64(m.Type),
|
||||
Data: m.Target,
|
||||
}
|
||||
}
|
||||
|
||||
// CronSpec 返回服务监控请求间隔对应的 cron 表达式
|
||||
func (m *Service) CronSpec() string {
|
||||
if m.Duration == 0 {
|
||||
// 默认间隔 30 秒
|
||||
m.Duration = 30
|
||||
}
|
||||
return fmt.Sprintf("@every %ds", m.Duration)
|
||||
}
|
||||
|
||||
func (m *Service) BeforeSave(tx *gorm.DB) error {
|
||||
if data, err := json.Marshal(m.SkipServers); err != nil {
|
||||
return err
|
||||
} else {
|
||||
m.SkipServersRaw = string(data)
|
||||
}
|
||||
if data, err := json.Marshal(m.FailTriggerTasks); err != nil {
|
||||
return err
|
||||
} else {
|
||||
m.FailTriggerTasksRaw = string(data)
|
||||
}
|
||||
if data, err := json.Marshal(m.RecoverTriggerTasks); err != nil {
|
||||
return err
|
||||
} else {
|
||||
m.RecoverTriggerTasksRaw = string(data)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Service) AfterFind(tx *gorm.DB) error {
|
||||
m.SkipServers = make(map[uint64]bool)
|
||||
if err := json.Unmarshal([]byte(m.SkipServersRaw), &m.SkipServers); err != nil {
|
||||
log.Println("NEZHA>> Service.AfterFind:", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
// 加载触发任务列表
|
||||
if err := json.Unmarshal([]byte(m.FailTriggerTasksRaw), &m.FailTriggerTasks); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := json.Unmarshal([]byte(m.RecoverTriggerTasksRaw), &m.RecoverTriggerTasks); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsServiceSentinelNeeded 判断该任务类型是否需要进行服务监控 需要则返回true
|
||||
func IsServiceSentinelNeeded(t uint64) bool {
|
||||
switch t {
|
||||
case TaskTypeCommand, TaskTypeTerminalGRPC, TaskTypeUpgrade,
|
||||
TaskTypeKeepalive, TaskTypeNAT, TaskTypeFM,
|
||||
TaskTypeReportConfig, TaskTypeApplyConfig:
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
type ServiceForm struct {
|
||||
Name string `json:"name,omitempty" minLength:"1"`
|
||||
Target string `json:"target,omitempty"`
|
||||
Type uint8 `json:"type,omitempty"`
|
||||
Cover uint8 `json:"cover,omitempty"`
|
||||
DisplayIndex int `json:"display_index,omitempty" default:"0"` // 展示排序,越大越靠前
|
||||
Notify bool `json:"notify,omitempty" validate:"optional"`
|
||||
Duration uint64 `json:"duration,omitempty"`
|
||||
MinLatency float32 `json:"min_latency,omitempty" default:"0.0"`
|
||||
MaxLatency float32 `json:"max_latency,omitempty" default:"0.0"`
|
||||
LatencyNotify bool `json:"latency_notify,omitempty" validate:"optional"`
|
||||
EnableTriggerTask bool `json:"enable_trigger_task,omitempty" validate:"optional"`
|
||||
EnableShowInService bool `json:"enable_show_in_service,omitempty" validate:"optional"`
|
||||
FailTriggerTasks []uint64 `json:"fail_trigger_tasks,omitempty"`
|
||||
RecoverTriggerTasks []uint64 `json:"recover_trigger_tasks,omitempty"`
|
||||
SkipServers map[uint64]bool `json:"skip_servers,omitempty"`
|
||||
NotificationGroupID uint64 `json:"notification_group_id,omitempty"`
|
||||
}
|
||||
|
||||
type ServiceResponseItem struct {
|
||||
ServiceName string `json:"service_name,omitempty"`
|
||||
CurrentUp uint64 `json:"current_up"`
|
||||
CurrentDown uint64 `json:"current_down"`
|
||||
TotalUp uint64 `json:"total_up"`
|
||||
TotalDown uint64 `json:"total_down"`
|
||||
Delay *[30]float64 `json:"delay,omitempty"`
|
||||
Up *[30]uint64 `json:"up,omitempty"`
|
||||
Down *[30]uint64 `json:"down,omitempty"`
|
||||
}
|
||||
|
||||
func (r ServiceResponseItem) TotalUptime() float32 {
|
||||
if r.TotalUp+r.TotalDown == 0 {
|
||||
return 0
|
||||
}
|
||||
return float32(r.TotalUp) / (float32(r.TotalUp + r.TotalDown)) * 100
|
||||
}
|
||||
|
||||
type CycleTransferStats struct {
|
||||
Name string `json:"name"`
|
||||
From time.Time `json:"from"`
|
||||
To time.Time `json:"to"`
|
||||
Max uint64 `json:"max"`
|
||||
Min uint64 `json:"min"`
|
||||
ServerName map[uint64]string `json:"server_name,omitempty"`
|
||||
Transfer map[uint64]uint64 `json:"transfer,omitempty"`
|
||||
NextUpdate map[uint64]time.Time `json:"next_update,omitempty"`
|
||||
}
|
||||
|
||||
type ServiceResponse struct {
|
||||
Services map[uint64]ServiceResponseItem `json:"services,omitempty"`
|
||||
CycleTransferStats map[uint64]CycleTransferStats `json:"cycle_transfer_stats,omitempty"`
|
||||
}
|
||||
|
||||
type BatchMoveServerForm struct {
|
||||
Ids []uint64 `json:"ids,omitempty" validate:"required"`
|
||||
ToUser uint64 `json:"to_user,omitempty" validate:"required"`
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type ServiceHistory struct {
|
||||
ID uint64 `gorm:"primaryKey" json:"id,omitempty"`
|
||||
CreatedAt time.Time `gorm:"index;<-:create;index:idx_server_id_created_at_service_id_avg_delay" json:"created_at,omitempty"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at,omitempty"`
|
||||
ServiceID uint64 `gorm:"index:idx_server_id_created_at_service_id_avg_delay" json:"service_id,omitempty"`
|
||||
ServerID uint64 `gorm:"index:idx_server_id_created_at_service_id_avg_delay" json:"server_id,omitempty"`
|
||||
AvgDelay float64 `gorm:"index:idx_server_id_created_at_service_id_avg_delay" json:"avg_delay,omitempty"` // 平均延迟,毫秒
|
||||
Up uint64 `json:"up,omitempty"` // 检查状态良好计数
|
||||
Down uint64 `json:"down,omitempty"` // 检查状态异常计数
|
||||
Data string `json:"data,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package model
|
||||
|
||||
// ServiceInfos 服务监控信息(兼容旧API)
|
||||
type ServiceInfos struct {
|
||||
ServiceID uint64 `json:"monitor_id"`
|
||||
ServerID uint64 `json:"server_id"`
|
||||
ServiceName string `json:"monitor_name"`
|
||||
ServerName string `json:"server_name"`
|
||||
DisplayIndex int `json:"display_index"` // 展示排序,越大越靠前
|
||||
CreatedAt []int64 `json:"created_at"`
|
||||
AvgDelay []float64 `json:"avg_delay"`
|
||||
}
|
||||
|
||||
// DataPoint 数据点
|
||||
type DataPoint struct {
|
||||
Timestamp int64 `json:"ts"`
|
||||
Delay float64 `json:"delay"`
|
||||
Status uint8 `json:"status"` // 1=成功, 0=失败
|
||||
}
|
||||
|
||||
// ServiceHistorySummary 服务历史统计摘要
|
||||
type ServiceHistorySummary struct {
|
||||
AvgDelay float64 `json:"avg_delay"`
|
||||
UpPercent float32 `json:"up_percent"`
|
||||
TotalUp uint64 `json:"total_up"`
|
||||
TotalDown uint64 `json:"total_down"`
|
||||
DataPoints []DataPoint `json:"data_points,omitempty"`
|
||||
}
|
||||
|
||||
// ServerServiceStats 某服务器对某服务的统计
|
||||
type ServerServiceStats struct {
|
||||
ServerID uint64 `json:"server_id"`
|
||||
ServerName string `json:"server_name,omitempty"`
|
||||
Stats ServiceHistorySummary `json:"stats"`
|
||||
}
|
||||
|
||||
// ServiceHistoryResponse 服务历史查询响应
|
||||
type ServiceHistoryResponse struct {
|
||||
ServiceID uint64 `json:"service_id"`
|
||||
ServiceName string `json:"service_name,omitempty"`
|
||||
Servers []ServerServiceStats `json:"servers"`
|
||||
}
|
||||
|
||||
// ServerMetricsDataPoint 服务器指标数据点
|
||||
type ServerMetricsDataPoint struct {
|
||||
Timestamp int64 `json:"ts"`
|
||||
Value float64 `json:"value"`
|
||||
}
|
||||
|
||||
// ServerMetricsResponse 服务器指标历史查询响应
|
||||
type ServerMetricsResponse struct {
|
||||
ServerID uint64 `json:"server_id"`
|
||||
ServerName string `json:"server_name,omitempty"`
|
||||
Metric string `json:"metric"`
|
||||
DataPoints []ServerMetricsDataPoint `json:"data_points"`
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package model
|
||||
|
||||
type SettingForm struct {
|
||||
DNSServers string `json:"dns_servers,omitempty" validate:"optional"`
|
||||
IgnoredIPNotification string `json:"ignored_ip_notification,omitempty" validate:"optional"`
|
||||
IPChangeNotificationGroupID uint64 `json:"ip_change_notification_group_id,omitempty"` // IP变更提醒的通知组
|
||||
Cover uint8 `json:"cover,omitempty"`
|
||||
SiteName string `json:"site_name,omitempty" minLength:"1"`
|
||||
Language string `json:"language,omitempty" minLength:"2"`
|
||||
InstallHost string `json:"install_host,omitempty" validate:"optional"`
|
||||
CustomCode string `json:"custom_code,omitempty" validate:"optional"`
|
||||
CustomCodeDashboard string `json:"custom_code_dashboard,omitempty" validate:"optional"`
|
||||
WebRealIPHeader string `json:"web_real_ip_header,omitempty" validate:"optional"` // 前端真实IP
|
||||
AgentRealIPHeader string `json:"agent_real_ip_header,omitempty" validate:"optional"` // Agent真实IP
|
||||
UserTemplate string `json:"user_template,omitempty" validate:"optional"`
|
||||
|
||||
AgentTLS bool `json:"tls,omitempty" validate:"optional"`
|
||||
EnableIPChangeNotification bool `json:"enable_ip_change_notification,omitempty" validate:"optional"`
|
||||
EnablePlainIPInNotification bool `json:"enable_plain_ip_in_notification,omitempty" validate:"optional"`
|
||||
}
|
||||
|
||||
type Setting struct {
|
||||
ConfigForGuests
|
||||
ConfigDashboard
|
||||
|
||||
IgnoredIPNotificationServerIDs map[uint64]bool `json:"ignored_ip_notification_server_ids,omitempty"`
|
||||
Oauth2Providers []string `json:"oauth2_providers,omitempty"`
|
||||
}
|
||||
|
||||
type FrontendTemplate struct {
|
||||
Path string `json:"path,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Repository string `json:"repository,omitempty"`
|
||||
Author string `json:"author,omitempty"`
|
||||
Version string `json:"version,omitempty"`
|
||||
IsAdmin bool `json:"is_admin,omitempty"`
|
||||
IsOfficial bool `json:"is_official,omitempty"`
|
||||
}
|
||||
|
||||
type SettingResponse struct {
|
||||
Config Setting `json:"config"`
|
||||
|
||||
Version string `json:"version,omitempty"`
|
||||
FrontendTemplates []FrontendTemplate `json:"frontend_templates,omitempty"`
|
||||
TSDBEnabled bool `json:"tsdb_enabled"`
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package model
|
||||
|
||||
type TerminalForm struct {
|
||||
Protocol string `json:"protocol,omitempty"`
|
||||
ServerID uint64 `json:"server_id,omitempty"`
|
||||
}
|
||||
|
||||
type CreateTerminalResponse struct {
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
ServerID uint64 `json:"server_id,omitempty"`
|
||||
ServerName string `json:"server_name,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package model
|
||||
|
||||
type Transfer struct {
|
||||
Common
|
||||
ServerID uint64 `json:"server_id"`
|
||||
In uint64 `json:"in"`
|
||||
Out uint64 `json:"out"`
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/nezhahq/nezha/pkg/utils"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Role uint8
|
||||
|
||||
func (r Role) IsAdmin() bool {
|
||||
return r == RoleAdmin
|
||||
}
|
||||
|
||||
const (
|
||||
RoleAdmin Role = iota
|
||||
RoleMember
|
||||
)
|
||||
|
||||
const DefaultAgentSecretLength = 32
|
||||
|
||||
type User struct {
|
||||
Common
|
||||
Username string `json:"username,omitempty" gorm:"uniqueIndex"`
|
||||
Password string `json:"password,omitempty" gorm:"type:char(72)"`
|
||||
Role Role `json:"role,omitempty"`
|
||||
AgentSecret string `json:"agent_secret,omitempty" gorm:"type:char(32)"`
|
||||
RejectPassword bool `json:"reject_password,omitempty"`
|
||||
}
|
||||
|
||||
type UserInfo struct {
|
||||
Role Role
|
||||
AgentSecret string
|
||||
}
|
||||
|
||||
func (u *User) BeforeSave(tx *gorm.DB) error {
|
||||
if u.AgentSecret != "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
key, err := utils.GenerateRandomString(DefaultAgentSecretLength)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
u.AgentSecret = key
|
||||
return nil
|
||||
}
|
||||
|
||||
type Profile struct {
|
||||
User
|
||||
LoginIP string `json:"login_ip,omitempty"`
|
||||
Oauth2Bind map[string]string `json:"oauth2_bind,omitempty"`
|
||||
}
|
||||
|
||||
type OnlineUser struct {
|
||||
UserID uint64 `json:"user_id,omitempty"`
|
||||
ConnectedAt time.Time `json:"connected_at,omitempty"`
|
||||
IP string `json:"ip,omitempty"`
|
||||
|
||||
Conn *websocket.Conn `json:"-"`
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package model
|
||||
|
||||
type UserForm struct {
|
||||
Role Role `json:"role,omitempty"`
|
||||
Username string `json:"username,omitempty"`
|
||||
Password string `json:"password,omitempty" gorm:"type:char(72)"`
|
||||
}
|
||||
|
||||
type ProfileForm struct {
|
||||
OriginalPassword string `json:"original_password,omitempty"`
|
||||
NewUsername string `json:"new_username,omitempty"`
|
||||
NewPassword string `json:"new_password,omitempty"`
|
||||
RejectPassword bool `json:"reject_password,omitempty" validate:"optional"`
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"math/big"
|
||||
"time"
|
||||
|
||||
"github.com/nezhahq/nezha/pkg/utils"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
_ uint8 = iota
|
||||
WAFBlockReasonTypeLoginFail
|
||||
WAFBlockReasonTypeBruteForceToken
|
||||
WAFBlockReasonTypeAgentAuthFail
|
||||
WAFBlockReasonTypeManual
|
||||
WAFBlockReasonTypeBruteForceOauth2
|
||||
)
|
||||
|
||||
const (
|
||||
BlockIDgRPC = -127 + iota
|
||||
BlockIDToken
|
||||
BlockIDUnknownUser
|
||||
BlockIDManual
|
||||
)
|
||||
|
||||
type WAFApiMock struct {
|
||||
IP string `json:"ip,omitempty"`
|
||||
BlockIdentifier int64 `json:"block_identifier,omitempty"`
|
||||
BlockReason uint8 `json:"block_reason,omitempty"`
|
||||
BlockTimestamp uint64 `json:"block_timestamp,omitempty"`
|
||||
Count uint64 `json:"count,omitempty"`
|
||||
}
|
||||
|
||||
type WAF struct {
|
||||
IP []byte `gorm:"type:binary(16);primaryKey" json:"ip,omitempty"`
|
||||
BlockIdentifier int64 `gorm:"primaryKey" json:"block_identifier,omitempty"`
|
||||
BlockReason uint8 `json:"block_reason,omitempty"`
|
||||
BlockTimestamp uint64 `gorm:"index" json:"block_timestamp,omitempty"`
|
||||
Count uint64 `json:"count,omitempty"`
|
||||
}
|
||||
|
||||
func (w *WAF) TableName() string {
|
||||
return "nz_waf"
|
||||
}
|
||||
|
||||
func CheckIP(db *gorm.DB, ip string) error {
|
||||
if ip == "" {
|
||||
return nil
|
||||
}
|
||||
ipBinary, err := utils.IPStringToBinary(ip)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var blockTimestamp uint64
|
||||
result := db.Model(&WAF{}).Order("block_timestamp desc").Select("block_timestamp").Where("ip = ?", ipBinary).Limit(1).Find(&blockTimestamp)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
|
||||
// 检查是否未找到记录
|
||||
if result.RowsAffected < 1 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var count uint64
|
||||
if err := db.Model(&WAF{}).Select("SUM(count)").Where("ip = ?", ipBinary).Scan(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
now := time.Now().Unix()
|
||||
if powAdd(count, 4, blockTimestamp) > uint64(now) {
|
||||
return errors.New("you were blocked by nezha WAF")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func UnblockIP(db *gorm.DB, ip string, uid int64) error {
|
||||
if ip == "" {
|
||||
return nil
|
||||
}
|
||||
ipBinary, err := utils.IPStringToBinary(ip)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return db.Unscoped().Delete(&WAF{}, "ip = ? and block_identifier = ?", ipBinary, uid).Error
|
||||
}
|
||||
|
||||
func BatchUnblockIP(db *gorm.DB, ip []string) error {
|
||||
if len(ip) < 1 {
|
||||
return nil
|
||||
}
|
||||
ips := make([][]byte, 0, len(ip))
|
||||
for _, s := range ip {
|
||||
ipBinary, err := utils.IPStringToBinary(s)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
ips = append(ips, ipBinary)
|
||||
}
|
||||
return db.Unscoped().Delete(&WAF{}, "ip in (?)", ips).Error
|
||||
}
|
||||
|
||||
func BlockIP(db *gorm.DB, ip string, reason uint8, uid int64) error {
|
||||
if ip == "" {
|
||||
return nil
|
||||
}
|
||||
ipBinary, err := utils.IPStringToBinary(ip)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
w := WAF{
|
||||
IP: ipBinary,
|
||||
BlockIdentifier: uid,
|
||||
}
|
||||
now := uint64(time.Now().Unix())
|
||||
|
||||
var count any
|
||||
if reason == WAFBlockReasonTypeManual {
|
||||
count = 99999
|
||||
} else {
|
||||
count = gorm.Expr("count + 1")
|
||||
}
|
||||
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Where(&w).Attrs(WAF{
|
||||
BlockReason: reason,
|
||||
BlockTimestamp: now,
|
||||
}).FirstOrCreate(&w).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Exec("UPDATE nz_waf SET count = ?, block_reason = ?, block_timestamp = ? WHERE ip = ? and block_identifier = ?", count, reason, now, ipBinary, uid).Error
|
||||
})
|
||||
}
|
||||
|
||||
func powAdd(x, y, z uint64) uint64 {
|
||||
base := big.NewInt(0).SetUint64(x)
|
||||
exp := big.NewInt(0).SetUint64(y)
|
||||
result := big.NewInt(1)
|
||||
result.Exp(base, exp, nil)
|
||||
result.Add(result, big.NewInt(0).SetUint64(z))
|
||||
if !result.IsUint64() {
|
||||
return ^uint64(0) // return max uint64 value on overflow
|
||||
}
|
||||
ret := result.Uint64()
|
||||
return utils.IfOr(ret < z+3, z+3, ret)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"id": "GHSA-w4g9-mxgg-j532", "repo": "nezhahq/nezha", "scope": ["model"], "files": 40, "objective": "Audit this real-world source tree for the single most serious exploitable security vulnerability."}
|
||||
Reference in new issue
Block a user