package model import ( "context" "crypto/tls" "errors" "fmt" "io" "net" "net/http" "net/netip" "net/url" "strings" "time" "github.com/goccy/go-json" "github.com/nezhahq/nezha/pkg/utils" ) const ( _ = iota NotificationRequestTypeJSON NotificationRequestTypeForm ) const ( _ = iota NotificationRequestMethodGET 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 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 { n := ns.Notification verifyTLS := n.VerifyTLS != nil && *n.VerifyTLS reqBody, err := ns.reqBody(message) if err != nil { return err } reqMethod, err := n.reqMethod() if err != nil { return err } 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 } 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 { 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 { 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) }