T3MP3ST public launch build

This commit is contained in:
t3mp3st contributors committed 2026-07-03 10:57:20 -04:00
1 parent 972247b881
commit 184c7d4e59
1589 files changed
+57150 -506

No files matched your search

@@ -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"`
}
@@ -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)
}