Files
SamWaf/utils/common.go
T
2026-08-12 16:41:30 +08:00

662 lines
19 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package utils
import (
"SamWaf/common/zlog"
"SamWaf/global"
"SamWaf/model"
"SamWaf/wafenginecore/clientip"
"SamWaf/wafenginecore/ipset"
"crypto/tls"
"errors"
"fmt"
"io/ioutil"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
)
// isPublicIP 判断 IP 是否为可安全对外访问的公网地址(用于防 SSRF)。
// 拒绝:环回、私网(RFC1918 / RFC4193 fc00::/7)、链路本地(含云元数据 169.254.169.254)、
// 未指定(0.0.0.0 / ::)、多播、CGNAT(100.64.0.0/10)。
func isPublicIP(ip net.IP) bool {
if ip == nil {
return false
}
if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() ||
ip.IsUnspecified() || ip.IsMulticast() {
return false
}
if v4 := ip.To4(); v4 != nil {
// 云元数据地址显式兜底(169.254.0.0/16 已被链路本地覆盖,这里再拦一次)
if v4[0] == 169 && v4[1] == 254 {
return false
}
// CGNAT 100.64.0.0/10 视为内网
if v4[0] == 100 && v4[1] >= 64 && v4[1] <= 127 {
return false
}
}
return true
}
// IsSafeOutboundHost 校验对外访问的主机(域名或IP)是否安全:解析出的所有 IP 必须为公网。
// 域名经 net.LookupIP 解析后逐个判定,阻断"域名解析到内网/元数据"。返回 (是否安全, 原因)。
// 注意:这是"解析时"校验,无法完全防御 DNS 重绑定/30x 跳转到内网(需连接期再校验),属已知残留。
func IsSafeOutboundHost(host string) (bool, string) {
host = strings.TrimSpace(host)
if host == "" {
return false, "目标主机为空"
}
if ip := net.ParseIP(host); ip != nil {
if !isPublicIP(ip) {
return false, fmt.Sprintf("目标IP %s 属于内网/环回/保留地址,已拒绝", host)
}
return true, ""
}
ips, err := net.LookupIP(host)
if err != nil {
return false, fmt.Sprintf("主机名 %s 解析失败: %v", host, err)
}
if len(ips) == 0 {
return false, fmt.Sprintf("主机名 %s 无解析结果", host)
}
for _, ip := range ips {
if !isPublicIP(ip) {
return false, fmt.Sprintf("主机名 %s 解析到内网/保留地址 %s,已拒绝", host, ip.String())
}
}
return true, ""
}
// IsSafeOutboundURL 校验对外请求 URL 是否安全(防 SSRF):仅允许 http/https,
// 且主机(域名解析后的所有IP)必须为公网。用于通知渠道 WebhookURL 等用户可控的对外地址。
func IsSafeOutboundURL(rawURL string) (bool, string) {
u, err := url.Parse(strings.TrimSpace(rawURL))
if err != nil {
return false, "URL 解析失败"
}
scheme := strings.ToLower(u.Scheme)
if scheme != "http" && scheme != "https" {
return false, "仅允许 http/https 协议"
}
host := u.Hostname()
if host == "" {
return false, "URL 缺少主机"
}
return IsSafeOutboundHost(host)
}
// SafeHTTPClient 返回带 SSRF 防护的 http.Client:每次 30x 跳转都重新校验目标主机,
// 拒绝跳转到内网/环回/链路本地(云元数据)/保留地址,堵住"公网URL跳转到内网"的绕过。
// 初始 URL 的主机需由调用方先行 IsSafeOutboundURL 校验;本客户端负责后续整条跳转链。
func SafeHTTPClient() *http.Client {
return &http.Client{
Timeout: 30 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 10 {
return errors.New("重定向次数过多")
}
if ok, reason := IsSafeOutboundHost(req.URL.Hostname()); !ok {
return fmt.Errorf("重定向目标不被允许: %s", reason)
}
return nil
},
}
}
func GetExternalIp() string {
// 创建带超时的HTTP客户端,避免DNS解析问题
client := &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
DialContext: (&net.Dialer{
Timeout: 5 * time.Second,
KeepAlive: 5 * time.Second,
}).DialContext,
TLSHandshakeTimeout: 5 * time.Second,
ResponseHeaderTimeout: 5 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
},
}
resp, err := client.Get("http://myexternalip.com/raw")
if err != nil {
zlog.Debug("GetExternalIp failed: %v", err)
return ""
}
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
zlog.Debug("ReadAll failed: %v", err)
return ""
}
clientIP := strings.TrimSpace(string(body))
return clientIP
}
func GetCurrentDir() string {
// 检测环境变量是否存在
envVar := "SamWafIDE"
if _, exists := os.LookupEnv(envVar); exists {
return "."
}
exePath, err := os.Executable()
if err != nil {
fmt.Println(fmt.Sprintf("Failed to get executable path:%v", err))
return ""
}
exeDir := filepath.Dir(exePath)
return exeDir
}
// CheckDebugEnvInfo 检测是否打印debug信息
func CheckDebugEnvInfo() bool {
// 检测环境变量是否存在
envVar := "SamWafIDEDebugLog"
if _, exists := os.LookupEnv(envVar); exists {
return true
}
return false
}
func GetServerByHosts(hosts model.Hosts) string {
if hosts.Ssl == 1 {
return "https"
} else {
return "http"
}
}
/*
*
计算两个自然天数的间隔数
*/
func DiffNatureDays(t1, t2 int64) int {
var SecondsOfDay int64 = 86400
if t1 == t2 {
return -1
}
if t1 > t2 {
t1, t2 = t2, t1
}
diffDays := 0
secDiff := t2 - t1
if secDiff > SecondsOfDay {
tmpDays := int(secDiff / SecondsOfDay)
t1 += int64(tmpDays) * SecondsOfDay
diffDays += tmpDays
}
st := time.Unix(t1, 0)
et := time.Unix(t2, 0)
dateFormatTpl := "20060102"
if st.Format(dateFormatTpl) != et.Format(dateFormatTpl) {
diffDays += 1
}
return diffDays
}
/**字符串->时间对象*/
func Str2Time(formatTimeStr string) time.Time {
timeLayout := "20060102"
loc, _ := time.LoadLocation("Local")
theTime, _ := time.ParseInLocation(timeLayout, formatTimeStr, loc) //使用模板在对应时区转化为time.time类型
return theTime
}
/*
*
时间转int天
*/
func TimeToDayInt(t time.Time) int {
day, _ := strconv.Atoi(t.Format("20060102"))
return day
}
func GetPublicIP() string {
conn, err := net.Dial("udp", "8.8.8.8:80")
if err != nil {
return "localhost"
// log.Fatal(err)
}
defer conn.Close()
localAddr := conn.LocalAddr().String()
idx := strings.LastIndex(localAddr, ":")
return localAddr[0:idx]
}
func GetCountry(ip string) []string {
region, _ := GetCountryEx(ip)
return region
}
// GetCountryEx 与 GetCountry 相同,但额外返回本次地区是否可判定。
//
// 第二个返回值为 false 表示"这次查不出来"(没有可用的地区库,或查询报错),
// 而不是"查出来是未知"。两者必须区分:IPv6 地区库缺失时若只返回"未知",
// `MF.COUNTRY != "中国"` 这类规则会把 IPv6 访客整片误杀,所以调用方需要据此放行。
func GetCountryEx(ip string) ([]string, bool) {
if global.GIPLOCATION_MANAGER != nil {
result := global.GIPLOCATION_MANAGER.Lookup(ip)
return result.ToSlice(), !result.Unresolved // [国家, 区域, 省份, 城市, ISP]
}
return []string{"未知", "", "", "", ""}, false
}
// FormatIPLocation 把 IP 解析成一行可读的归属地文本(国家 省份 城市 运营商)
//
// GetCountry 返回 [国家, 区域, 省份, 城市, ISP],这里丢掉"区域"(华东/华南这类太粗,
// 和省份并排显示反而冗余),其余去空去重后拼成一行 —— 直接 strings.Join 会拼出
// "中国,,,,"这种带空段的串,不能给人看。
func FormatIPLocation(ip string) string {
parts := GetCountry(ip)
var seg []string
seen := map[string]bool{}
for i, p := range parts {
if i == 1 { // 跳过"区域"
continue
}
v := strings.TrimSpace(p)
if v == "" || v == "0" || seen[v] {
continue
}
seen[v] = true
seg = append(seg, v)
}
if len(seg) == 0 {
return "未知"
}
return strings.Join(seg, " ")
}
// TruncateString 按字符数截断字符串(按 rune 切,避免把中文/UA 里的多字节字符切成乱码)
func TruncateString(s string, max int) string {
if max <= 0 {
return ""
}
r := []rune(s)
if len(r) <= max {
return s
}
return string(r[:max])
}
// BoolToInt 布尔转 0/1,用于落库的标志位字段
func BoolToInt(b bool) int {
if b {
return 1
}
return 0
}
// CloseIPDatabase 关闭IP数据库
func CloseIPDatabase() {
if global.GIPLOCATION_MANAGER != nil {
global.GIPLOCATION_MANAGER.Close()
}
}
/*
*
通过ip段(CIDR)来查询是否在很多CIDR段内
*/
func CheckIPInRanges(ip string, ipRanges []string) bool {
parsedIP := net.ParseIP(ip)
if parsedIP == nil {
return false // 如果IP地址解析失败,则返回false
}
for _, ipRange := range ipRanges {
_, ipNet, err := net.ParseCIDR(ipRange)
if err != nil {
return false // 如果CIDR格式解析失败,则返回false
}
if ipNet.Contains(parsedIP) {
return true // 如果IP地址在指定的CIDR范围内,则返回true
}
}
return false // 如果IP地址不在任何一个CIDR范围内,则返回false
}
/*
*
通过ip段(CIDR)来查询是否在这个段内
*/
func CheckIPInCIDR(ip string, ipRange string) bool {
parts := strings.Split(ipRange, "/")
if len(parts) != 2 {
// 如果是普通ip
if ip == ipRange {
return true
} else {
return false
}
} else {
//如果是网段IP
parsedIP := net.ParseIP(ip)
if parsedIP == nil {
return false // 如果IP地址解析失败,则返回false
}
_, ipNet, err := net.ParseCIDR(ipRange)
if err != nil {
return false // 如果CIDR格式解析失败,则返回false
}
if ipNet.Contains(parsedIP) {
return true // 如果IP地址在指定的CIDR范围内,则返回true
}
return false // 如果IP地址不在任何一个CIDR范围内,则返回false
}
}
// MatchIPPattern 判断 ip 是否命中一个「IP 模式」,统一支持
// 单 IP / CIDR 网段 / 通配符(10.10.*.*、2001:db8:*:*:*:*:*:*) / 闭区间(1.2.3.4-1.2.3.99)。
//
// 与 CheckIPInCIDR 的区别:CheckIPInCIDR 只认单 IP 与 CIDR,且非 CIDR 时做的是
// 纯字符串相等比较。二者不能互换——CheckIPInCIDR 还被「可信代理 IP 列表」
// (wafenginecore/clientip.go) 使用,那里绝不能接受通配符:
// 一旦允许 *.*.*.*,任何人都能伪造 XFF 头冒充任意来源 IP。
//
// 本函数只用于「用户配置的黑/白名单、IP 组、自定义规则」这类判定场景。
func MatchIPPattern(ip string, pattern string) bool {
return ipset.MatchPatternStrCached(ip, pattern)
}
// IsValidIPPattern 校验一个「IP 模式」是否合法,返回 (是否合法, 不合法时的中文原因)。
// 语法范围同 MatchIPPattern。用于黑/白名单与 IP 组条目的写入校验。
//
// 注意与 IsValidIPOrNetwork 的分工:后者只认单 IP 与 CIDR,是严格模式,
// 供系统防火墙下发、威胁情报源解析等「不能出现通配语法」的场景使用,不要混用。
func IsValidIPPattern(input string) (bool, string) {
return ipset.IsValidPattern(input)
}
// IsCatchAllIPPattern 判断是否为「全通配」写法(*.*.*.* 或 8 段全 * 的 IPv6)。
//
// 这类写法语法合法但极易误写:填进白名单等于全站不设防,填进黑名单等于封禁所有人。
// 写入侧应当拒绝并提示用户改写成显式的 0.0.0.0/0,让意图无歧义。
func IsCatchAllIPPattern(input string) bool {
return ipset.IsCatchAllWildcard(input)
}
// IsValidIPOrNetwork 检查给定的字符串是否为有效的 IP 地址或 IP 段(CIDR)
//
// 严格模式:不接受通配符与区间。系统防火墙(iptables/netsh)下发、
// 威胁情报源解析等场景必须用本函数;用户可配的黑/白名单请用 IsValidIPPattern。
func IsValidIPOrNetwork(input string) (bool, string) {
// 尝试解析为 IP 地址
if ip := net.ParseIP(input); ip != nil {
return true, "valid IP address"
}
// 尝试解析为 IP 段(CIDR)
_, _, err := net.ParseCIDR(input)
if err == nil {
return true, "valid IP network (CIDR)"
}
return false, "not a valid IP or IP network (CIDR)"
}
// 检查 JSON 字符串是否为有效的 UTF-8 编码
func CheckJSONValidity(jsonStr []byte) bool {
for i := 0; i < len(jsonStr); {
r, size := rune(jsonStr[i]), 1
switch {
case r >= 0xF0:
r, size = rune(jsonStr[i+3]), 4
case r >= 0xE0:
r, size = rune(jsonStr[i+2]), 3
case r >= 0xC0:
r, size = rune(jsonStr[i+1]), 2
default:
}
if r > 0xFFFF {
return false
}
i += size
}
return true
}
/*
*
删除老旧数据
*/
func DeleteOldFiles(dir string, duration time.Duration) error {
// 遍历目录下的文件
return filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
// 如果是文件并且文件创建时间超过指定的时间
if !info.IsDir() {
// 获取文件的修改时间(可以近似认为是创建时间)
fileModTime := info.ModTime()
// 判断文件是否超过指定的时间(比如30分钟)
if time.Since(fileModTime) > duration {
// 删除文件
zlog.Info("删除陈旧文件:", path)
err := os.Remove(path)
if err != nil {
return err
}
}
}
return nil
})
}
// 检测是否属于ipv4
func IsValidIPv4(ip string) bool {
parsedIP := net.ParseIP(ip)
return parsedIP != nil && parsedIP.To4() != nil
}
// 验证IPv6地址是否有效
func IsValidIPv6(ip string) bool {
parsedIP := net.ParseIP(ip)
// 如果解析出的 IP 类型是 IPv6 且不是 IPv4 映射地址(IPv4-mapped IPv6 addresses)
return parsedIP != nil && strings.Contains(ip, ":") && parsedIP.To4() == nil
}
// 获取纯域名
func GetPureDomain(host string) string {
// 检查是否包含端口号
if strings.Contains(host, ":") {
host, _, _ = net.SplitHostPort(host)
}
return host
}
// UpdateFileIsHasNewInfo 检查文件内容并更新
func UpdateFileIsHasNewInfo(filePath, newContent string) error {
// 检查文件是否存在
if _, err := os.Stat(filePath); os.IsNotExist(err) {
// 文件不存在,写入新内容
if err := ioutil.WriteFile(filePath, []byte(newContent), 0644); err != nil {
return fmt.Errorf("failed to write file %s: %v", filePath, err)
}
} else {
// 文件存在,读取当前内容
currentContent, err := ioutil.ReadFile(filePath)
if err != nil {
return fmt.Errorf("failed to read file %s: %v", filePath, err)
}
// 比较内容是否一致
if string(currentContent) != newContent {
// 内容不一致,更新文件
if err := ioutil.WriteFile(filePath, []byte(newContent), 0644); err != nil {
return fmt.Errorf("failed to update file %s: %v", filePath, err)
}
}
}
return nil
}
// IsValidChallengeFile 检查challenge文件是否合法
func IsValidChallengeFile(challengeFile string) bool {
// 定义一个正则表达式:允许字母、数字、连字符、下划线,且长度在 1 到 255 之间
// 这里假设合法的文件名不含有特殊字符和空格
re := regexp.MustCompile(`^[a-zA-Z0-9_-]{1,255}$`)
// 判断文件名是否符合正则表达式
return re.MatchString(challengeFile)
}
// CheckPathAndCreate 检测路径如果不存在则创建
func CheckPathAndCreate(path string) error {
// 检测目录是否存在
_, err := os.Stat(path)
if os.IsNotExist(err) {
// 目录不存在,创建目录
err := os.MkdirAll(path, os.ModePerm)
if err != nil {
return errors.New("创建目录失败" + err.Error())
}
return nil
} else if err != nil {
// 发生其他错误
return errors.New("检查目录失败" + err.Error())
} else {
return nil
}
}
// CheckSSLCertificateExpiry 检查指定主机的 SSL 证书到期情况
// host: 需要检查的主机地址,格式为 "ssl.samwaf.com:443"
func CheckSSLCertificateExpiry(host string) (time.Time, error) {
conn, err := tls.Dial("tcp", host, nil)
if err != nil {
return time.Time{}, fmt.Errorf("无法连接到主机 %s: %v", host, err)
}
defer conn.Close()
// 获取 SSL 证书的到期时间
cert := conn.ConnectionState().PeerCertificates[0]
expiryDate := cert.NotAfter
// 返回到期时间
return expiryDate, nil
}
// GetSourceClientIP 获取原始IP
func GetSourceClientIP(ipAndPort string) string {
ip, _, err := net.SplitHostPort(ipAndPort)
if err != nil {
return ""
} else {
return ip
}
}
// IsIP 判断输入是否为 IP 地址(IPv4 或 IPv6)
func IsIP(input string) bool {
if input == "" {
return false
}
// 去除首尾空格
input = strings.TrimSpace(input)
// 使用 net.ParseIP 解析
// 如果是有效的 IP,返回非 nil
return net.ParseIP(input) != nil
}
// GetManageClientIP 获取管理端客户端真实IP。
// 安全默认:未配置代理头(GCONFIG_MANAGE_PROXY_HEADER 为空)时直接返回网络层 IP(c.RemoteIP())。
// 即便配置了代理头,也仅当“直连对端 c.RemoteIP() 属于可信代理网段 GCONFIG_MANAGE_TRUSTED_PROXIES”
// 时才采信代理头;否则一律用网络层 IP。防止任意直连客户端伪造 X-Forwarded-For/X-Real-IP 绕过
// 登录错误锁定 / 管理端 IP 白名单 / 令牌 IP 绑定。
func GetManageClientIP(c *gin.Context) string {
remoteIP := c.RemoteIP()
// 未配置代理头 → 直接用网络层 IP
if global.GCONFIG_MANAGE_PROXY_HEADER == "" {
return remoteIP
}
// 配了代理头,但只信任来自“可信代理”的头;否则用网络层 IP
if !isTrustedManageProxy(remoteIP) {
return remoteIP
}
for _, header := range strings.Split(global.GCONFIG_MANAGE_PROXY_HEADER, ",") {
header = strings.TrimSpace(header)
if header == "" {
continue
}
val := c.GetHeader(header)
if val == "" {
continue
}
// 从右往左取第一个“非可信代理”的 IP:反向代理按追加语义
// (nginx $proxy_add_x_forwarded_for) 把真实客户端 IP 追加在右侧、客户端伪造的值留在左侧,
// 故取最右侧的非可信 hop 才是真实客户端;逐个跳过可信代理链。取最左会取到伪造值。
parts := strings.Split(val, ",")
for i := len(parts) - 1; i >= 0; i-- {
ip := strings.TrimSpace(parts[i])
if !IsValidIPv4(ip) && !IsValidIPv6(ip) {
continue
}
if isTrustedManageProxy(ip) {
continue // 跳过可信代理 hop
}
return ip
}
}
return remoteIP
}
// isTrustedManageProxy 判断直连对端 IP 是否落在管理端可信代理网段
// GCONFIG_MANAGE_TRUSTED_PROXIES(CIDR 或单 IP,逗号分隔)内。
// 留空 → 返回 false(不信任任何代理头,安全默认)。
func isTrustedManageProxy(remoteIP string) bool {
// 管理端引用了某 CDN 厂商 → 直连对端属于该厂商中心库最新回源段即视为可信(自动跟随更新)
if global.GCONFIG_MANAGE_CDN_PROVIDER != "" &&
clientip.IsProviderIP(global.GCONFIG_MANAGE_CDN_PROVIDER, strings.TrimSpace(remoteIP)) {
return true
}
if global.GCONFIG_MANAGE_TRUSTED_PROXIES == "" {
return false
}
ip := net.ParseIP(strings.TrimSpace(remoteIP))
if ip == nil {
return false
}
for _, entry := range strings.Split(global.GCONFIG_MANAGE_TRUSTED_PROXIES, ",") {
entry = strings.TrimSpace(entry)
if entry == "" {
continue
}
if strings.Contains(entry, "/") {
if _, ipnet, err := net.ParseCIDR(entry); err == nil && ipnet.Contains(ip) {
return true
}
continue
}
if single := net.ParseIP(entry); single != nil && single.Equal(ip) {
return true
}
}
return false
}