package main import ( "bufio" "encoding/json" "flag" "fmt" "io/ioutil" "log" "net/http" "os" "path/filepath" "strconv" "strings" "sync" "time" "github.com/gin-gonic/gin" ) type Config struct { AccessToken string `json:"access_token"` Keyword string `json:"keyword"` Timeout int `json:"timeout"` MaxTime int `json:"max_time"` MaxFileSize int `json:"max_file_size"` WebhookURL string `json:"webhook_url"` TaskFilePath string `json:"task_file_path"` Port int `json:"port"` // 新增:自定义端口号 BindAddress string `json:"bind_address"` // 新增:绑定地址(127.0.0.1 或 0.0.0.0) } type CronJob struct { ID int `json:"id"` Name string `json:"name"` URL string `json:"url"` ExpectedKey string `json:"expected_key"` Interval int `json:"interval"` LastExecution string `json:"last_execution"` } var ( config Config cronJobs = make(map[int]CronJob) jobIDMutex sync.Mutex jobID = 0 // 日志文件 checkLog *log.Logger accessLog *log.Logger ) const ( defaultConfigFile = "~/.config/checkurl/cron.conf" defaultTaskFile = "~/.config/checkurl/task.list" defaultWebhookURL = "https://oapi.dingtalk.com/robot/send?access_token=%s" ) func main() { // 解析命令行参数 configFilePath := flag.String("config", defaultConfigFile, "Path to the configuration file") flag.Parse() // 初始化日志 initLogs() // 初始化配置 loadConfig(*configFilePath) // 加载任务 loadCronJobs() // 启动定时任务 go startCronJobs() // 设置Gin路由 r := gin.Default() // 设置Gin的日志输出到access.log r.Use(gin.LoggerWithWriter(accessLog.Writer())) r.POST("/check_url", checkURLHandler) r.POST("/add_cron", addCronHandler) r.POST("/del_cron", delCronHandler) r.GET("/get_cron_list", getCronListHandler) r.POST("/config_cron", configCronHandler) r.GET("/get_config", getConfigHandler) // 启动Gin服务 bindAddress := fmt.Sprintf("%s:%d", config.BindAddress, config.Port) r.Run(bindAddress) } func initLogs() { // 创建check.log文件 checkLogFile, err := os.OpenFile("check.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666) if err != nil { log.Fatalf("无法打开check.log文件: %v", err) } checkLog = log.New(checkLogFile, "[CHECK] ", log.Ldate|log.Ltime|log.Lshortfile) // 创建access.log文件 accessLogFile, err := os.OpenFile("access.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666) if err != nil { log.Fatalf("无法打开access.log文件: %v", err) } accessLog = log.New(accessLogFile, "[ACCESS] ", log.Ldate|log.Ltime|log.Lshortfile) } func loadConfig(configFilePath string) { // 默认配置 config = Config{ AccessToken: getEnv("ACCESS_TOKEN", "b2a92184affebb3f26dfcab1eff9571b46c27a85380fd9ff56ca4c66cf93e1a1"), Keyword: getEnv("KEYWORD", "BWH:"), Timeout: getEnvInt("TIMEOUT", 5), MaxTime: getEnvInt("MAX_TIME", 10), MaxFileSize: getEnvInt("MAX_FILESIZE", 300), WebhookURL: getEnv("WEBHOOK_URL", defaultWebhookURL), TaskFilePath: getEnv("TASK_FILE_PATH", defaultTaskFile), Port: getEnvInt("PORT", 2809), BindAddress: getEnv("BIND_ADDRESS", "127.0.0.1"), } // 检查用户家目录下的 .config/checkurl/cron.conf 文件 homeDir, err := os.UserHomeDir() if err != nil { checkLog.Printf("无法获取用户家目录: %v\n", err) } else { userConfigPath := filepath.Join(homeDir, ".config", "checkurl", "cron.conf") if _, err := os.Stat(userConfigPath); os.IsNotExist(err) { // 如果文件不存在,创建目录和文件 createUserConfigFile(userConfigPath) } else { // 如果文件存在,从该文件读取配置 loadUserConfig(userConfigPath) } } // 从指定的配置文件中读取配置项 if configFilePath != defaultConfigFile { file, err := os.Open(configFilePath) if err != nil { checkLog.Printf("未找到配置文件 %s,将使用默认配置\n", configFilePath) } else { defer file.Close() scanner := bufio.NewScanner(file) for scanner.Scan() { line := scanner.Text() if strings.HasPrefix(line, "#") || line == "" { continue } fields := strings.Fields(line) if len(fields) != 2 { checkLog.Printf("忽略无效配置行: %s\n", line) continue } key := fields[0] value := strings.Trim(fields[1], `"`) switch key { case "ACCESS_TOKEN": config.AccessToken = value case "KEYWORD": config.Keyword = value case "TIMEOUT": if timeout, err := strconv.Atoi(value); err == nil { config.Timeout = timeout } case "MAX_TIME": if maxTime, err := strconv.Atoi(value); err == nil { config.MaxTime = maxTime } case "MAX_FILESIZE": if maxFileSize, err := strconv.Atoi(value); err == nil { config.MaxFileSize = maxFileSize } case "WEBHOOK_URL": config.WebhookURL = value case "TASK_FILE_PATH": config.TaskFilePath = value case "PORT": if port, err := strconv.Atoi(value); err == nil { config.Port = port } case "BIND_ADDRESS": config.BindAddress = value default: checkLog.Printf("忽略未知配置项: %s\n", key) } } } } // 如果 TaskFilePath 没有定义,则默认放在 ~/.config/checkurl/ 文件夹中 if config.TaskFilePath == "" { config.TaskFilePath = filepath.Join(homeDir, ".config", "checkurl", "task.list") } } // 创建用户配置文件 func createUserConfigFile(userConfigPath string) { // 创建目录 configDir := filepath.Dir(userConfigPath) if err := os.MkdirAll(configDir, 0755); err != nil { checkLog.Printf("无法创建用户配置目录 %s: %v\n", configDir, err) return } // 创建文件 file, err := os.Create(userConfigPath) if err != nil { checkLog.Printf("无法创建用户配置文件 %s: %v\n", userConfigPath, err) return } defer file.Close() // 写入默认配置 writer := bufio.NewWriter(file) _, _ = writer.WriteString("# 用户配置文件\n") _, _ = writer.WriteString(fmt.Sprintf("ACCESS_TOKEN %s\n", config.AccessToken)) _, _ = writer.WriteString(fmt.Sprintf("KEYWORD %s\n", config.Keyword)) _, _ = writer.WriteString(fmt.Sprintf("TIMEOUT %d\n", config.Timeout)) _, _ = writer.WriteString(fmt.Sprintf("MAX_TIME %d\n", config.MaxTime)) _, _ = writer.WriteString(fmt.Sprintf("MAX_FILESIZE %d\n", config.MaxFileSize)) _, _ = writer.WriteString(fmt.Sprintf("WEBHOOK_URL %s\n", config.WebhookURL)) _, _ = writer.WriteString(fmt.Sprintf("TASK_FILE_PATH %s\n", config.TaskFilePath)) _, _ = writer.WriteString(fmt.Sprintf("PORT %d\n", config.Port)) _, _ = writer.WriteString(fmt.Sprintf("BIND_ADDRESS %s\n", config.BindAddress)) writer.Flush() checkLog.Printf("已创建用户配置文件: %s\n", userConfigPath) } func loadUserConfig(userConfigPath string) { file, err := os.Open(userConfigPath) if err != nil { checkLog.Printf("无法打开用户配置文件 %s: %v\n", userConfigPath, err) return } defer file.Close() scanner := bufio.NewScanner(file) for scanner.Scan() { line := scanner.Text() if strings.HasPrefix(line, "#") || line == "" { continue } fields := strings.Fields(line) if len(fields) != 2 { checkLog.Printf("忽略无效配置行: %s\n", line) continue } key := fields[0] value := strings.Trim(fields[1], `"`) switch key { case "ACCESS_TOKEN": config.AccessToken = value case "KEYWORD": config.Keyword = value case "TIMEOUT": if timeout, err := strconv.Atoi(value); err == nil { config.Timeout = timeout } case "MAX_TIME": if maxTime, err := strconv.Atoi(value); err == nil { config.MaxTime = maxTime } case "MAX_FILESIZE": if maxFileSize, err := strconv.Atoi(value); err == nil { config.MaxFileSize = maxFileSize } case "WEBHOOK_URL": config.WebhookURL = value case "TASK_FILE_PATH": config.TaskFilePath = value default: checkLog.Printf("忽略未知配置项: %s\n", key) } } checkLog.Printf("已从用户配置文件 %s 加载配置\n", userConfigPath) } func loadCronJobs() { file, err := os.Open(config.TaskFilePath) if err != nil { checkLog.Printf("未找到任务文件 %s,将创建新文件\n", config.TaskFilePath) return } defer file.Close() scanner := bufio.NewScanner(file) for scanner.Scan() { line := scanner.Text() // 跳过注释行(以 # 开头的行) if strings.HasPrefix(line, "#") || line == "" { continue } // 手动解析任务行 fields := parseTaskLine(line) if len(fields) != 5 { checkLog.Printf("忽略无效任务行: %s\n", line) continue } id, _ := strconv.Atoi(fields[0]) name := fields[1] url := fields[2] expectedKey := fields[3] interval, _ := strconv.Atoi(fields[4]) cronJobs[id] = CronJob{ ID: id, Name: name, URL: url, ExpectedKey: expectedKey, Interval: interval, } if id > jobID { jobID = id } } } // 手动解析任务行 func parseTaskLine(line string) []string { var fields []string var currentField string inQuotes := false for _, char := range line { switch char { case ' ': if inQuotes { currentField += " " } else if currentField != "" { fields = append(fields, currentField) currentField = "" } case '"': inQuotes = !inQuotes default: currentField += string(char) } } if currentField != "" { fields = append(fields, currentField) } return fields } func saveCronJobs() error { file, err := os.Create(config.TaskFilePath) if err != nil { return err } defer file.Close() writer := bufio.NewWriter(file) _, _ = writer.WriteString("# task_id task_name api_url expected_keyword cron_time\n") for _, job := range cronJobs { line := fmt.Sprintf("%d \"%s\" \"%s\" \"%s\" %d\n", job.ID, job.Name, job.URL, job.ExpectedKey, job.Interval) _, _ = writer.WriteString(line) } return writer.Flush() } func checkURLHandler(c *gin.Context) { var request struct { URL string `json:"url"` ExpectedKey string `json:"expected_key"` } if err := c.ShouldBindJSON(&request); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } result := checkAPI(request.URL, request.ExpectedKey) c.JSON(http.StatusOK, gin.H{"result": result}) } func addCronHandler(c *gin.Context) { var request struct { Name string `json:"name"` URL string `json:"url"` ExpectedKey string `json:"expected_key"` Interval int `json:"interval"` } if err := c.ShouldBindJSON(&request); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } jobIDMutex.Lock() jobID++ newJob := CronJob{ ID: jobID, Name: request.Name, URL: request.URL, ExpectedKey: request.ExpectedKey, Interval: request.Interval, } cronJobs[jobID] = newJob jobIDMutex.Unlock() // 保存任务到任务文件 if err := saveCronJobs(); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "无法保存任务到任务文件"}) return } c.JSON(http.StatusOK, gin.H{"message": "Cron job added", "job_id": jobID}) } func delCronHandler(c *gin.Context) { var request struct { ID int `json:"id"` } if err := c.ShouldBindJSON(&request); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } jobIDMutex.Lock() delete(cronJobs, request.ID) jobIDMutex.Unlock() // 保存任务到任务文件 if err := saveCronJobs(); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "无法保存任务到任务文件"}) return } c.JSON(http.StatusOK, gin.H{"message": "Cron job deleted"}) } func getCronListHandler(c *gin.Context) { var jobList []CronJob for _, job := range cronJobs { jobList = append(jobList, job) } c.JSON(http.StatusOK, gin.H{"cron_jobs": jobList}) } func configCronHandler(c *gin.Context) { var newConfig Config if err := c.ShouldBindJSON(&newConfig); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } config = newConfig c.JSON(http.StatusOK, gin.H{"message": "Config updated"}) } func getConfigHandler(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"config": config}) } func startCronJobs() { for { for _, job := range cronJobs { go func(job CronJob) { result := checkAPI(job.URL, job.ExpectedKey) if result != "success" { sendAlert(job.Name, result) } job.LastExecution = time.Now().Format(time.RFC3339) jobIDMutex.Lock() cronJobs[job.ID] = job jobIDMutex.Unlock() }(job) time.Sleep(time.Duration(job.Interval) * time.Second) } time.Sleep(1 * time.Second) } } func checkAPI(apiURL, expectedKeyword string) string { client := &http.Client{ Timeout: time.Duration(config.Timeout) * time.Second, } resp, err := client.Get(apiURL) if err != nil { checkLog.Printf("任务失败: URL=%s, 错误=%s\n", apiURL, err.Error()) return "服务请求失败(可能是网络问题或端口未开放)" } defer resp.Body.Close() body, err := ioutil.ReadAll(resp.Body) if err != nil { checkLog.Printf("任务失败: URL=%s, 错误=%s\n", apiURL, err.Error()) return "服务请求失败(读取响应失败)" } if !strings.Contains(string(body), expectedKeyword) { checkLog.Printf("任务失败: URL=%s, 期望关键词=%s, 响应=%s\n", apiURL, expectedKeyword, string(body)) return fmt.Sprintf("服务返回异常内容(不包含期望的关键词 '%s'),状态码:%d 响应: %s", expectedKeyword, resp.StatusCode, string(body)) } checkLog.Printf("任务成功: URL=%s, 期望关键词=%s\n", apiURL, expectedKeyword) return "success" } func sendAlert(jobName, msg string) { msg = strings.ReplaceAll(msg, "{", "<") msg = strings.ReplaceAll(msg, "}", ">") msg = strings.ReplaceAll(msg, "\"", "'") alertMessage := fmt.Sprintf("任务:%s, 结果: %s", jobName, msg) webhookURL := fmt.Sprintf(config.WebhookURL, config.AccessToken) payload := map[string]interface{}{ "msgtype": "text", "text": map[string]string{ "content": config.Keyword + alertMessage, }, } payloadBytes, _ := json.Marshal(payload) http.Post(webhookURL, "application/json", strings.NewReader(string(payloadBytes))) } func getEnv(key, defaultValue string) string { value := os.Getenv(key) if value == "" { return defaultValue } return value } func getEnvInt(key string, defaultValue int) int { value := os.Getenv(key) if value == "" { return defaultValue } intValue, err := strconv.Atoi(value) if err != nil { return defaultValue } return intValue }