Pārlūkot izejas kodu

初次提交

基本完成HTTPS-Watcher的功能
SongZihuan 2 mēneši atpakaļ
revīzija
4d800b415a

+ 71 - 0
.gitattributes

@@ -0,0 +1,71 @@
+# 设置所有文本文件使用LF作为行尾
+* text=auto eol=lf
+
+# 排除图片资源文件(保持之前的配置)
+*.png binary
+*.jpg binary
+*.jpeg binary
+*.gif binary
+
+# 排除PDF文档(保持之前的配置)
+*.pdf binary
+
+# 排除视频资源文件(保持之前的配置)
+*.mp4 binary
+*.avi binary
+*.mkv binary
+*.mov binary
+*.wmv binary
+
+# 排除字体资源文件
+*.ttf binary
+*.otf binary
+*.woff binary
+*.woff2 binary
+*.eot binary
+
+# 排除音频文件
+*.mp3 binary
+*.wav binary
+*.aac binary
+*.flac binary
+
+# 排除压缩文件和存档
+*.zip binary
+*.tar.gz binary
+*.rar binary
+
+# 排除数据库文件
+*.db binary
+
+# 排除机器学习模型文件
+*.pb binary
+*.ckpt binary
+*.pth binary
+
+# 排除编译后的二进制可执行文件
+*.exe binary
+*.dll binary
+*.so binary
+*.dylib binary
+
+# 排除虚拟机和容器镜像
+*.dockerimage binary
+*.box binary
+*.vmdk binary
+*.vhd binary
+*.ova binary
+
+# 排除游戏资源文件
+*.obj binary
+*.fbx binary
+*.blend binary
+*.dds binary
+*.3ds binary
+
+# 排除日志文件
+*.log binary
+
+# 排除缓存和临时文件
+*.cache binary
+*.tmp binary

+ 16 - 0
.gitignore

@@ -0,0 +1,16 @@
+.idea
+etc
+tmp
+cert
+
+*.exe
+*.out
+
+.DS_Store
+
+testdata
+remote-testdata
+
+pkg
+
+go-remote.sh

+ 8 - 0
LICENSE

@@ -0,0 +1,8 @@
+The MIT License (MIT)
+Copyright © 2025 宋子桓(Song Zihuan)
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

+ 1 - 0
NAME

@@ -0,0 +1 @@
+HHW-HTTPS监控

+ 90 - 0
README.md

@@ -0,0 +1,90 @@
+# Huan-HTTPSWatcher
+## 介绍
+简单的 HTTPS 监控系统,可以监控网站证书是否过期、即将过期。
+
+## 如何配置
+### 命令行参数
+```text
+Usage of HSWv1.exe:
+  --help
+  --h
+          Show usage of HSBv1.exe. If this option is set, the backend service
+          will not run.
+
+  --version
+  --v
+          Show version of HSBv1.exe. If this option is set, the backend service
+          will not run.
+
+  --license
+  --l
+          Show license of HSBv1.exe. If this option is set, the backend service
+          will not run.
+
+  --report
+  --r
+          Show how to report questions/errors of HSBv1.exe. If this option is
+          set, the backend service will not run.
+
+  --config string
+  --c string
+          The location of the running configuration file of the backend service.
+          The option is a string, the default value is config.yaml in the
+          running directory.
+
+  --output-config string
+          The location of the reverse output after the backend service running
+          configuration file is parsed. The option is a string and the default
+          is config.output.yaml in the running directory.
+```
+
+根据上面的描述,我们主要使用`--config`参数,该参数表示配置文件的位置。默认值是:`config.yaml`。
+
+当`--config`为`config.yaml`(默认值)时,`--output-config`则会默认设置为`config.output.yaml`,并将配置文件输出到此位置。
+输出的配置文件是完整版,包含全部选项和默认选项的,同时过滤非法选项。
+
+### 配置文件
+配置文件是`yaml`文件,请看以下配置文件:
+
+```yaml
+mode: debug  # 运行模式(Debug/Release/Test)
+log-level: debug  # 日志记录登记
+log-tag: enable  # 是否输出标签日志(Debug使用)
+time-zone: Local  # 时区(UTC/Local/指定时区),若指定时区不存在,会退化到Local(本地电脑时区),若仍不存在则退化到UTC
+name: 001  # 服务名称(会显示在消息推送中)
+
+watcher:
+  urls:
+    - name: '百度' # URL的名字,当URL比较长的时候可以设定名字来缩短显示的URL,若不设置则默认 name = url
+      url: https://www.baidu.com  # 网站的URL(必须是https协议)
+      deadline: 150d  # 即将过期的标准,若证书在 deadline 时间内过期,则会发出警告。例如此处设置为150d则表示证书在150天内过期则会发出警报。
+      
+api:
+  webhook: # 企业微信机器人 Webhook,可为空,关闭企业微信推送
+
+smtp:  # 发送邮件消息推送
+  address: # smtp 服务器地址,可为空,为空表示关闭smtp
+  user: # smtp 用户名(邮件),可为空,为空表示关闭smtp
+  password: # smtp 用户密码
+  recipient:
+    - xxx@wxample.com  # 接收邮件通知的用户
+```
+
+## 构建与运行
+### 构建
+使用`go build`指令进行编译。
+```shell
+$ go build github.com/SongZihuan/https-watcher/src/cmd/httpswatcher/hhwv1
+```
+
+生产环境下可以使用一些编译标志来压缩目标文件大小。
+```shell
+$ go build -trimpath -ldflags='-s -w' github.com/SongZihuan/https-watcher/src/cmd/httpswatcher/hhwv1
+```
+
+### 运行
+执行编译好的可执行文件即可。具体命令行参数可参见上文。
+
+## 协议
+本软件基于 [MIT LICENSE](/LICENSE) 发布。
+了解更多关于 MIT LICENSE , 请 [点击此处](https://mit-license.song-zh.com) 。

+ 13 - 0
REPORT

@@ -0,0 +1,13 @@
+How to report of Huan-SSHWatcher
+
+Author: 宋子桓(Song Zihuan)
+Author Github: https://github.com/SongZihuan
+Author Website: https://song-zh.com
+Author Email: contact@song-zh.com
+
+Github: https://github.com/SongZihuan/https-watcher/
+Github Issues: https://github.com/SongZihuan/https-watcher/issues
+
+Report: You can report issues and contact the author through Github Issues or Author Email.
+Quality Assurance: If you only have the license in the LICENSE file in the root directory of this project, you will not get any quality assurance. But the author is happy to solve the problem for you, unless this project has been archived as read-only.
+Other Fork versions: Please contact the author of the Fork version for assistance.

+ 1 - 0
VERSION

@@ -0,0 +1 @@
+v1.0.0

+ 14 - 0
go.mod

@@ -0,0 +1,14 @@
+module github.com/SongZihuan/https-watcher
+
+go 1.23.4
+
+require (
+	github.com/mattn/go-isatty v0.0.20
+	gopkg.in/yaml.v3 v3.0.1
+)
+
+require (
+	golang.org/x/sys v0.13.0 // indirect
+	gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
+	gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df // indirect
+)

+ 13 - 0
go.sum

@@ -0,0 +1,13 @@
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
+golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
+gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df h1:n7WqCuqOuCbNr617RXOY0AWRXxgwEyPp2z+p0+hgMuE=
+gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df/go.mod h1:LRQQ+SO6ZHR7tOkpBDuZnXENFzX8qRjMDMyPD6BRkCw=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

+ 17 - 0
resource.go

@@ -0,0 +1,17 @@
+package resource
+
+import (
+	_ "embed"
+)
+
+//go:embed VERSION
+var Version string
+
+//go:embed LICENSE
+var License string
+
+//go:embed REPORT
+var Report string
+
+//go:embed NAME
+var Name string

+ 4 - 0
src/cmd/httpswatcher/hhwv1/README

@@ -0,0 +1,4 @@
+关于命名:
+
+hhw - 表示 Huan-HTTPSWatcher
+v1 - 表示 version 1.x.x

+ 10 - 0
src/cmd/httpswatcher/hhwv1/main.go

@@ -0,0 +1,10 @@
+package main
+
+import (
+	"github.com/SongZihuan/https-watcher/src/mainfunc/httpswatcher"
+	"github.com/SongZihuan/https-watcher/src/utils"
+)
+
+func main() {
+	utils.Exit(httpswatcher.MainV1())
+}

+ 13 - 0
src/config/apiconfig.go

@@ -0,0 +1,13 @@
+package config
+
+type ApiConfig struct {
+	Webhook string `yaml:"webhook"`
+}
+
+func (a *ApiConfig) setDefault() {
+	return
+}
+
+func (a *ApiConfig) check() (err ConfigError) {
+	return nil
+}

+ 244 - 0
src/config/config.go

@@ -0,0 +1,244 @@
+package config
+
+import (
+	"github.com/SongZihuan/https-watcher/src/flagparser"
+	"github.com/SongZihuan/https-watcher/src/utils"
+	"path/filepath"
+	"sync"
+)
+
+type ConfigStruct struct {
+	ConfigLock sync.Mutex
+
+	configReady    bool
+	yamlHasParser  bool
+	configPath     string
+	configDir      string
+	configFileName string
+	Yaml           *YamlConfig
+}
+
+func newConfig(configPath string) (*ConfigStruct, error) {
+	if configPath == "" {
+		if !flagparser.IsReady() {
+			panic("flag is not ready")
+		}
+
+		configPath = flagparser.ConfigFile()
+	}
+
+	configPath, err := utils.CleanFilePathAbs(configPath)
+	if err != nil {
+		return nil, err
+	}
+
+	configDir := filepath.Dir(configPath)
+	configFileName := filepath.Base(configPath)
+
+	return &ConfigStruct{
+		// Lock不用初始化
+		configReady:    false,
+		yamlHasParser:  false,
+		configPath:     configPath,
+		configDir:      configDir,
+		configFileName: configFileName,
+		Yaml:           nil,
+	}, nil
+}
+
+func (c *ConfigStruct) Init() (err ConfigError) {
+	if c.IsReady() { // 使用IsReady而不是isReady,确保上锁
+		return c.Reload()
+	}
+
+	initErr := c.init()
+	if initErr != nil {
+		return NewConfigError("init error: " + initErr.Error())
+	}
+
+	parserErr := c.parser(c.configPath)
+	if parserErr != nil {
+		return NewConfigError("parser error: " + parserErr.Error())
+	} else if !c.yamlHasParser {
+		return NewConfigError("parser error: unknown")
+	}
+
+	c.SetDefault()
+
+	err = c.check()
+	if err != nil && err.IsError() {
+		return err
+	}
+
+	locationOnce = new(sync.Once)
+	c.configReady = true
+	return nil
+}
+
+func (c *ConfigStruct) Reload() (err ConfigError) {
+	if !c.IsReady() { // 使用IsReady而不是isReady,确保上锁
+		return c.Init()
+	}
+
+	bak := ConfigStruct{
+		configReady:    c.configReady,
+		yamlHasParser:  c.yamlHasParser,
+		configPath:     c.configPath,
+		configDir:      c.configDir,
+		configFileName: c.configFileName,
+		Yaml:           c.Yaml,
+		// 新建类型
+	}
+
+	defer func() {
+		if err != nil {
+			*c = ConfigStruct{
+				configReady:    bak.configReady,
+				yamlHasParser:  bak.yamlHasParser,
+				configPath:     bak.configPath,
+				configDir:      bak.configDir,
+				configFileName: bak.configFileName,
+				Yaml:           bak.Yaml,
+				// 新建类型 Lock不需要复制
+			}
+		}
+	}()
+
+	c.ConfigLock.Lock()
+	defer c.ConfigLock.Unlock()
+
+	reloadErr := c.reload()
+	if reloadErr != nil {
+		return NewConfigError("reload error: " + reloadErr.Error())
+	}
+
+	parserErr := c.parser(c.configPath)
+	if parserErr != nil {
+		return NewConfigError("reload parser error: " + parserErr.Error())
+	} else if !c.yamlHasParser {
+		return NewConfigError("reload parser error: unknown")
+	}
+
+	c.SetDefault()
+
+	err = c.check()
+	if err != nil && err.IsError() {
+		return err
+	}
+
+	locationOnce = new(sync.Once)
+	c.configReady = true
+	return nil
+}
+
+func (c *ConfigStruct) clear() error {
+	c.configReady = false
+	c.yamlHasParser = false
+	// sigchan和watcher 不变
+	c.Yaml = nil
+	return nil
+}
+
+func (c *ConfigStruct) parser(filepath string) ParserError {
+	err := c.Yaml.parser(filepath)
+	if err != nil {
+		return err
+	}
+
+	c.yamlHasParser = true
+	return nil
+}
+
+func (c *ConfigStruct) SetDefault() {
+	if !c.yamlHasParser {
+		panic("yaml must parser first")
+	}
+
+	c.Yaml.setDefault()
+}
+
+func (c *ConfigStruct) check() (err ConfigError) {
+	err = c.Yaml.check()
+	if err != nil && err.IsError() {
+		return err
+	}
+
+	return nil
+}
+
+func (c *ConfigStruct) isReady() bool {
+	return c.yamlHasParser && c.configReady
+}
+
+func (c *ConfigStruct) init() error {
+	c.configReady = false
+	c.yamlHasParser = false
+
+	c.Yaml = new(YamlConfig)
+	err := c.Yaml.Init()
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func (c *ConfigStruct) reload() error {
+	err := c.clear()
+	if err != nil {
+		return err
+	}
+
+	c.Yaml = new(YamlConfig)
+	err = c.Yaml.Init()
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+// export func
+
+func (c *ConfigStruct) IsReady() bool {
+	c.ConfigLock.Lock()
+	defer c.ConfigLock.Unlock()
+	return c.isReady()
+}
+
+func (c *ConfigStruct) GetConfig() *YamlConfig {
+	c.ConfigLock.Lock()
+	defer c.ConfigLock.Unlock()
+
+	if !c.isReady() {
+		panic("config is not ready")
+	}
+
+	return c.Yaml
+}
+
+func (c *ConfigStruct) GetConfigPathFile() string {
+	c.ConfigLock.Lock()
+	defer c.ConfigLock.Unlock()
+
+	// 不需要检查Ready
+
+	return c.configPath
+}
+
+func (c *ConfigStruct) GetConfigFileDir() string {
+	c.ConfigLock.Lock()
+	defer c.ConfigLock.Unlock()
+
+	// 不需要检查Ready
+
+	return c.configDir
+}
+
+func (c *ConfigStruct) GetConfigFileName() string {
+	c.ConfigLock.Lock()
+	defer c.ConfigLock.Unlock()
+
+	// 不需要检查Ready
+	return c.configFileName
+}

+ 40 - 0
src/config/dbcleanconfig.go

@@ -0,0 +1,40 @@
+package config
+
+import (
+	"github.com/SongZihuan/https-watcher/src/utils"
+	"time"
+)
+
+type DBCleanConfig struct {
+	ExecutionIntervalHour        int64         `yaml:"execution-interval-hour"`
+	SSHRecordSaveRetentionPeriod string        `yaml:"ssh-record-save-retention-period"`
+	SSHRecordSaveTime            time.Duration `yaml:"-"`
+}
+
+func (d *DBCleanConfig) setDefault() {
+	if d.ExecutionIntervalHour <= 0 {
+		d.ExecutionIntervalHour = 6
+	}
+
+	if d.SSHRecordSaveRetentionPeriod == "" {
+		d.SSHRecordSaveRetentionPeriod = "3M"
+	}
+
+	return
+}
+
+func (d *DBCleanConfig) check() (err ConfigError) {
+	d.SSHRecordSaveTime = utils.ReadTimeDuration(d.SSHRecordSaveRetentionPeriod)
+
+	if d.SSHRecordSaveTime == 0 {
+		return NewConfigError("bad ssh-record-save-retention-period")
+	}
+
+	if d.SSHRecordSaveTime == -1 {
+		_ = NewConfigWarning("ssh-record-save-retention-period is set to be saved permanently")
+	} else if d.SSHRecordSaveTime < time.Minute*5 {
+		return NewConfigError("bad ssh-record-save-retention-period, must more than 5 minute")
+	}
+
+	return nil
+}

+ 83 - 0
src/config/error.go

@@ -0,0 +1,83 @@
+package config
+
+import (
+	"fmt"
+	"github.com/SongZihuan/https-watcher/src/utils"
+)
+
+type ConfigError interface {
+	error
+	Msg() string
+	Error() string
+	Warning() string
+	IsError() bool
+	IsWarning() bool
+}
+
+func NewConfigError(msg string) ConfigError {
+	fmt.Println(utils.FormatTextToWidth(fmt.Sprintf("config error: %s", msg), utils.NormalConsoleWidth))
+	return &configError{msg: msg, isError: true}
+}
+
+func NewConfigWarning(msg string) ConfigError {
+	fmt.Println(utils.FormatTextToWidth(fmt.Sprintf("config warning: %s", msg), utils.NormalConsoleWidth))
+	return &configError{msg: msg, isError: false}
+}
+
+type configError struct {
+	msg     string
+	isError bool
+}
+
+func (e *configError) Msg() string {
+	if e.isError {
+		return "config error: " + e.Error()
+	}
+	return "config warning: " + e.Warning()
+}
+
+func (e *configError) Error() string {
+	return e.msg
+}
+
+func (e *configError) Warning() string {
+	return e.msg
+}
+
+func (e *configError) IsError() bool {
+	return e.isError
+}
+
+func (e *configError) IsWarning() bool {
+	return !e.isError
+}
+
+type ParserError interface {
+	error
+	Error() string
+	Data() interface{}
+}
+
+type parserError struct {
+	msg  string
+	data interface{}
+}
+
+func NewParserError(data interface{}, msg ...string) ParserError {
+	if len(msg) == 1 {
+		return &parserError{msg[0], data}
+	}
+	return &parserError{"config parser error: " + fmt.Sprint(data), data}
+}
+
+func WarpParserError(err error) ParserError {
+	return &parserError{"config parser error: " + err.Error(), err}
+}
+
+func (e *parserError) Error() string {
+	return e.msg
+}
+
+func (e *parserError) Data() interface{} {
+	return e.data
+}

+ 101 - 0
src/config/globalconfig.go

@@ -0,0 +1,101 @@
+package config
+
+import (
+	"fmt"
+	resource "github.com/SongZihuan/https-watcher"
+	"github.com/SongZihuan/https-watcher/src/utils"
+	"os"
+)
+
+const EnvModeName = "HUAN_SPRINGBOARD_MODE"
+
+const (
+	DebugMode   = "debug"
+	ReleaseMode = "release"
+	TestMode    = "test"
+)
+
+type LoggerLevel string
+
+var levelMap = map[string]bool{
+	"debug": true,
+	"info":  true,
+	"warn":  true,
+	"error": true,
+	"panic": true,
+	"none":  true,
+}
+
+type GlobalConfig struct {
+	Mode     string           `yaml:"mode"`
+	LogLevel string           `yaml:"log-level"`
+	LogTag   utils.StringBool `yaml:"log-tag"`
+	Timezone string           `yaml:"time-zone"`
+	Name     string           `yaml:"name"`
+
+	SystemName string `yaml:"-"`
+}
+
+func (g *GlobalConfig) setDefault() {
+	if g.Mode == "" {
+		g.Mode = os.Getenv(EnvModeName)
+	}
+
+	if g.Mode == "" {
+		g.Mode = DebugMode
+	}
+
+	_ = os.Setenv(EnvModeName, g.Mode)
+
+	if g.LogLevel == "" && (g.Mode == DebugMode || g.Mode == TestMode) {
+		g.LogLevel = "debug"
+	} else if g.LogLevel == "" {
+		g.LogLevel = "warn"
+	}
+
+	if g.Mode == DebugMode || g.Mode == TestMode {
+		g.LogTag.SetDefaultEnable()
+	} else {
+		g.LogTag.SetDefaultDisable()
+	}
+
+	if g.Timezone == "" {
+		g.Timezone = "Local"
+	}
+
+	if g.Name == "" {
+		g.Name = "001"
+	}
+
+	return
+}
+
+func (g *GlobalConfig) check() ConfigError {
+	if g.Mode != DebugMode && g.Mode != ReleaseMode && g.Mode != TestMode {
+		return NewConfigError("bad mode")
+	}
+
+	if _, ok := levelMap[g.LogLevel]; !ok {
+		return NewConfigError("log level error")
+	}
+
+	g.SystemName = fmt.Sprintf("%s-%s", resource.Name, g.Name)
+
+	return nil
+}
+
+func (g *GlobalConfig) GetRunMode() string {
+	return g.Mode
+}
+
+func (g *GlobalConfig) IsDebug() bool {
+	return g.Mode == DebugMode
+}
+
+func (g *GlobalConfig) IsRelease() bool {
+	return g.Mode == ReleaseMode
+}
+
+func (g *GlobalConfig) IsTest() bool {
+	return g.Mode == TestMode
+}

+ 92 - 0
src/config/main.go

@@ -0,0 +1,92 @@
+package config
+
+import (
+	"fmt"
+	"github.com/SongZihuan/https-watcher/src/flagparser"
+	"gopkg.in/yaml.v3"
+	"os"
+)
+
+func InitConfig(configPath string) ConfigError {
+	var err error
+
+	config, err = newConfig(configPath)
+	if err != nil {
+		return NewConfigError(err.Error())
+	}
+
+	cfgErr := config.Init()
+	if cfgErr != nil && cfgErr.IsError() {
+		return cfgErr
+	}
+
+	if !config.IsReady() {
+		return NewConfigError("config not ready")
+	}
+
+	err = OutputConfig()
+	if err != nil {
+		fmt.Printf("output config error: %s\n", err.Error())
+	}
+
+	return nil
+}
+
+func ReloadConfig() ConfigError {
+	cfgErr := config.Reload()
+	if cfgErr != nil && cfgErr.IsError() {
+		return cfgErr
+	}
+
+	if !config.IsReady() {
+		return NewConfigError("config not ready")
+	}
+
+	err := OutputConfig()
+	if err != nil {
+		fmt.Printf("output config error: %s\n", err.Error())
+	}
+
+	return nil
+}
+
+func OutputConfig() error {
+	outputPath := flagparser.OutputConfigFile()
+	if outputPath == "" {
+		return nil
+	}
+
+	out, err := yaml.Marshal(config.Yaml)
+	if err != nil {
+		return err
+	}
+
+	err = os.WriteFile(outputPath, out, 0644)
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func IsReady() bool {
+	return config.IsReady()
+}
+
+func GetConfig() *YamlConfig {
+	return config.GetConfig()
+}
+
+func GetConfigPathFile() string {
+	return config.GetConfigPathFile()
+}
+
+func GetConfigFileDir() string {
+	return config.GetConfigFileDir()
+}
+
+func GetConfigFileName() string {
+	return config.GetConfigFileName()
+}
+
+var config *ConfigStruct

+ 16 - 0
src/config/smtpconfig.go

@@ -0,0 +1,16 @@
+package config
+
+type SMTPConfig struct {
+	Address   string   `yaml:"address"`
+	User      string   `yaml:"user"`
+	Password  string   `yaml:"password"`
+	Recipient []string `yaml:"recipient"`
+}
+
+func (s *SMTPConfig) setDefault() {
+	return
+}
+
+func (s *SMTPConfig) check() (err ConfigError) {
+	return nil
+}

+ 57 - 0
src/config/timezone.go

@@ -0,0 +1,57 @@
+package config
+
+import (
+	"fmt"
+	"strings"
+	"sync"
+	"time"
+)
+
+var location *time.Location
+var locationOnce *sync.Once = new(sync.Once)
+
+func TimeZone() *time.Location {
+	locationOnce.Do(func() {
+		if !IsReady() {
+			panic("config not ready")
+		}
+
+		if strings.ToLower(config.GetConfig().Timezone) == "utc" {
+			_location := time.UTC
+			if _location == nil {
+				_location = time.Local
+			}
+
+			if _location != nil {
+				location = _location
+			}
+		} else if strings.ToLower(config.GetConfig().Timezone) == "local" || config.GetConfig().Timezone == "" {
+			_location := time.Local
+			if _location == nil {
+				_location = time.UTC
+			}
+
+			if _location != nil {
+				location = _location
+			}
+		} else {
+			_location, err := time.LoadLocation(config.GetConfig().Timezone)
+			if err != nil || _location == nil {
+				_location = time.UTC
+			}
+
+			if _location != nil {
+				location = _location
+			}
+		}
+
+		if location == nil {
+			if config.GetConfig().Timezone == "UTC" || config.GetConfig().Timezone == "Local" || config.GetConfig().Timezone == "" {
+				panic(fmt.Errorf("can not get location UTC or Local"))
+			}
+			panic(fmt.Errorf("can not get location UTC, Local or %s", config.GetConfig().Timezone))
+		}
+	})
+
+	return location
+}

+ 51 - 0
src/config/watcher.go

@@ -0,0 +1,51 @@
+package config
+
+import (
+	"fmt"
+	"github.com/SongZihuan/https-watcher/src/utils"
+	"time"
+)
+
+type URLConfig struct {
+	Name     string `yaml:"name"`
+	URL      string `yaml:"url"`
+	Deadline string `yaml:"deadline"`
+
+	DeadlineDuration time.Duration `yaml:"-"`
+}
+
+type WatcherConfig struct {
+	URLs []*URLConfig `yaml:"urls"`
+}
+
+func (w *WatcherConfig) setDefault() {
+	for _, url := range w.URLs {
+		if url.Name == "" {
+			url.Name = url.URL
+		}
+
+		if url.Deadline == "" {
+			url.Deadline = "15d"
+		}
+	}
+	return
+}
+
+func (w *WatcherConfig) check() (err ConfigError) {
+	if len(w.URLs) == 0 {
+		return NewConfigError("not any urls")
+	}
+
+	for _, url := range w.URLs {
+		if !utils.IsValidHTTPSURL(url.URL) {
+			return NewConfigError(fmt.Sprintf("'%s' is not a valid https url", url))
+		}
+
+		url.DeadlineDuration = utils.ReadTimeDuration(url.Deadline)
+		if url.DeadlineDuration <= 0 {
+			return NewConfigError(fmt.Sprintf("'%s' is not a valid deadline", url.Deadline))
+		}
+	}
+
+	return nil
+}

+ 63 - 0
src/config/yamlconfig.go

@@ -0,0 +1,63 @@
+package config
+
+import (
+	"gopkg.in/yaml.v3"
+	"os"
+)
+
+type YamlConfig struct {
+	GlobalConfig `yaml:",inline"`
+
+	Watcher WatcherConfig `yaml:"watcher"`
+	API     ApiConfig     `yaml:"api"`
+	SMTP    SMTPConfig    `yaml:"smtp"`
+}
+
+func (y *YamlConfig) Init() error {
+	return nil
+}
+
+func (y *YamlConfig) setDefault() {
+	y.GlobalConfig.setDefault()
+	y.Watcher.setDefault()
+	y.API.setDefault()
+	y.SMTP.setDefault()
+}
+
+func (y *YamlConfig) check() (err ConfigError) {
+	err = y.Watcher.check()
+	if err != nil && err.IsError() {
+		return err
+	}
+
+	err = y.GlobalConfig.check()
+	if err != nil && err.IsError() {
+		return err
+	}
+
+	err = y.API.check()
+	if err != nil && err.IsError() {
+		return err
+	}
+
+	err = y.SMTP.check()
+	if err != nil && err.IsError() {
+		return err
+	}
+
+	return nil
+}
+
+func (y *YamlConfig) parser(filepath string) ParserError {
+	file, err := os.ReadFile(filepath)
+	if err != nil {
+		return NewParserError(err, err.Error())
+	}
+
+	err = yaml.Unmarshal(file, y)
+	if err != nil {
+		return NewParserError(err, err.Error())
+	}
+
+	return nil
+}

+ 405 - 0
src/flagparser/data.go

@@ -0,0 +1,405 @@
+package flagparser
+
+import (
+	"flag"
+	"fmt"
+	resource "github.com/SongZihuan/https-watcher"
+	"github.com/SongZihuan/https-watcher/src/utils"
+	"io"
+	"reflect"
+	"strings"
+)
+
+const OptionIdent = "  "
+const OptionPrefix = "--"
+const UsagePrefixWidth = 10
+
+type flagData struct {
+	flagReady  bool
+	flagSet    bool
+	flagParser bool
+
+	HelpData  bool
+	HelpName  string
+	HelpUsage string
+
+	VersionData  bool
+	VersionName  string
+	VersionUsage string
+
+	LicenseData  bool
+	LicenseName  string
+	LicenseUsage string
+
+	ReportData  bool
+	ReportName  string
+	ReportUsage string
+
+	ConfigFileData  string
+	ConfigFileName  string
+	ConfigFileUsage string
+
+	OutputConfigFileData      string
+	OutputConfigFileName      string
+	OutputConfigFileShortName string
+	OutputConfigFileUsage     string
+
+	Usage string
+}
+
+func initData() {
+	data = flagData{
+		flagReady:  false,
+		flagSet:    false,
+		flagParser: false,
+
+		HelpData:  false,
+		HelpName:  "help",
+		HelpUsage: fmt.Sprintf("Show usage of %s. If this option is set, the backend service will not run.", utils.GetArgs0Name()),
+
+		VersionData:  false,
+		VersionName:  "version",
+		VersionUsage: fmt.Sprintf("Show version of %s. If this option is set, the backend service will not run.", utils.GetArgs0Name()),
+
+		LicenseData:  false,
+		LicenseName:  "license",
+		LicenseUsage: fmt.Sprintf("Show license of %s. If this option is set, the backend service will not run.", utils.GetArgs0Name()),
+
+		ReportData:  false,
+		ReportName:  "report",
+		ReportUsage: fmt.Sprintf("Show how to report questions/errors of %s. If this option is set, the backend service will not run.", utils.GetArgs0Name()),
+
+		ConfigFileData:  "",
+		ConfigFileName:  "config",
+		ConfigFileUsage: fmt.Sprintf("%s", "The location of the running configuration file of the backend service. The option is a string, the default value is config.yaml in the running directory."),
+
+		OutputConfigFileData:      "",
+		OutputConfigFileName:      "output-config",
+		OutputConfigFileShortName: "",
+		OutputConfigFileUsage:     fmt.Sprintf("%s", "The location of the reverse output after the backend service running configuration file is parsed. The option is a string and the default is config.output.yaml in the running directory."),
+
+		Usage: "",
+	}
+
+	data.ready()
+}
+
+func (d *flagData) writeUsage() {
+	if len(d.Usage) != 0 {
+		return
+	}
+
+	if d.isFlagSet() || d.isFlagParser() {
+		panic("flag is parser")
+	}
+
+	var result strings.Builder
+	result.WriteString(utils.FormatTextToWidth(fmt.Sprintf("Usage of %s:", utils.GetArgs0Name()), utils.NormalConsoleWidth))
+	result.WriteString("\n")
+
+	val := reflect.ValueOf(*d)
+	typ := val.Type()
+
+	for i := 0; i < val.NumField(); i++ {
+		field := typ.Field(i)
+
+		if !strings.HasSuffix(field.Name, "Data") {
+			continue
+		}
+
+		option := field.Name[:len(field.Name)-4]
+		optionName := ""
+		optionShortName := ""
+		optionUsage := ""
+
+		if utils.HasFieldByReflect(typ, option+"Name") {
+			var ok bool
+			optionName, ok = val.FieldByName(option + "Name").Interface().(string)
+			if !ok {
+				panic("can not get option name")
+			}
+		}
+
+		if utils.HasFieldByReflect(typ, option+"ShortName") {
+			var ok bool
+			optionShortName, ok = val.FieldByName(option + "ShortName").Interface().(string)
+			if !ok {
+				panic("can not get option short name")
+			}
+		} else if len(optionName) > 1 {
+			optionShortName = optionName[:1]
+		}
+
+		if utils.HasFieldByReflect(typ, option+"Usage") {
+			var ok bool
+			optionUsage, ok = val.FieldByName(option + "Usage").Interface().(string)
+			if !ok {
+				panic("can not get option usage")
+			}
+		}
+
+		var title string
+		var title1 string
+		var title2 string
+		if field.Type.Kind() == reflect.Bool {
+			var optionData bool
+			if utils.HasFieldByReflect(typ, option+"Data") {
+				var ok bool
+				optionData, ok = val.FieldByName(option + "Data").Interface().(bool)
+				if !ok {
+					panic("can not get option data")
+				}
+			}
+
+			if optionData == true {
+				panic("bool option can not be true")
+			}
+
+			if optionName != "" {
+				title1 = fmt.Sprintf("%s%s%s", OptionIdent, OptionPrefix, utils.FormatTextToWidth(optionName, utils.NormalConsoleWidth-len(OptionIdent)-len(OptionPrefix)))
+			}
+
+			if optionShortName != "" {
+				title2 = fmt.Sprintf("%s%s%s", OptionIdent, OptionPrefix, utils.FormatTextToWidth(optionShortName, utils.NormalConsoleWidth-len(OptionIdent)-len(OptionPrefix)))
+			}
+		} else if field.Type.Kind() == reflect.String {
+			var optionData string
+			if utils.HasFieldByReflect(typ, option+"Data") {
+				var ok bool
+				optionData, ok = val.FieldByName(option + "Data").Interface().(string)
+				if !ok {
+					panic("can not get option data")
+				}
+			}
+
+			if optionName != "" && optionData != "" {
+				title1 = fmt.Sprintf("%s%s%s", OptionIdent, OptionPrefix, utils.FormatTextToWidth(fmt.Sprintf("%s string, default: '%s'", optionName, optionData), utils.NormalConsoleWidth-len(OptionIdent)-len(OptionPrefix)))
+			} else if optionName != "" && optionData == "" {
+				title1 = fmt.Sprintf("%s%s%s", OptionIdent, OptionPrefix, utils.FormatTextToWidth(fmt.Sprintf("%s string", optionName), utils.NormalConsoleWidth-len(OptionIdent)-len(OptionPrefix)))
+			}
+
+			if optionShortName != "" && optionData != "" {
+				title2 = fmt.Sprintf("%s%s%s", OptionIdent, OptionPrefix, utils.FormatTextToWidth(fmt.Sprintf("%s string, default: '%s'", optionShortName, optionData), utils.NormalConsoleWidth-len(OptionIdent)-len(OptionPrefix)))
+			} else if optionShortName != "" && optionData == "" {
+				title2 = fmt.Sprintf("%s%s%s", OptionIdent, OptionPrefix, utils.FormatTextToWidth(fmt.Sprintf("%s string", optionShortName), utils.NormalConsoleWidth-len(OptionIdent)-len(OptionPrefix)))
+			}
+		} else if field.Type.Kind() == reflect.Uint {
+			var optionData uint
+			if utils.HasFieldByReflect(typ, option+"Data") {
+				var ok bool
+				optionData, ok = val.FieldByName(option + "Data").Interface().(uint)
+				if !ok {
+					panic("can not get option data")
+				}
+			}
+
+			if optionName != "" && optionData != 0 {
+				title1 = fmt.Sprintf("%s%s%s", OptionIdent, OptionPrefix, utils.FormatTextToWidth(fmt.Sprintf("%s number, default: %d", optionName, optionData), utils.NormalConsoleWidth-len(OptionIdent)-len(OptionPrefix)))
+			} else if optionName != "" && optionData == 0 {
+				title1 = fmt.Sprintf("%s%s%s", OptionIdent, OptionPrefix, utils.FormatTextToWidth(fmt.Sprintf("%s number", optionName), utils.NormalConsoleWidth-len(OptionIdent)-len(OptionPrefix)))
+			}
+
+			if optionShortName != "" && optionData != 0 {
+				title2 = fmt.Sprintf("%s%s%s", OptionIdent, OptionPrefix, utils.FormatTextToWidth(fmt.Sprintf("%s number, default: %d", optionShortName, optionData), utils.NormalConsoleWidth-len(OptionIdent)-len(OptionPrefix)))
+			} else if optionShortName != "" && optionData == 0 {
+				title2 = fmt.Sprintf("%s%s%s", OptionIdent, OptionPrefix, utils.FormatTextToWidth(fmt.Sprintf("%s number", optionShortName), utils.NormalConsoleWidth-len(OptionIdent)-len(OptionPrefix)))
+			}
+		} else {
+			panic("error flag type")
+		}
+
+		if title1 == "" && title2 == "" {
+			continue
+		} else if title1 != "" && title2 == "" {
+			title = title1
+		} else if title1 == "" {
+			title = title2
+		} else {
+			title = fmt.Sprintf("%s\n%s", title1, title2)
+		}
+
+		result.WriteString(title)
+		result.WriteString("\n")
+
+		usegae := utils.FormatTextToWidthAndPrefix(optionUsage, UsagePrefixWidth, utils.NormalConsoleWidth)
+		result.WriteString(usegae)
+		result.WriteString("\n\n")
+	}
+
+	d.Usage = strings.TrimRight(result.String(), "\n")
+}
+
+func (d *flagData) setFlag() {
+	if d.isFlagSet() {
+		return
+	}
+
+	flag.BoolVar(&d.HelpData, data.HelpName, data.HelpData, data.HelpUsage)
+	flag.BoolVar(&d.HelpData, data.HelpName[0:1], data.HelpData, data.HelpUsage)
+
+	flag.BoolVar(&d.VersionData, data.VersionName, data.VersionData, data.VersionUsage)
+	flag.BoolVar(&d.VersionData, data.VersionName[0:1], data.VersionData, data.VersionUsage)
+
+	flag.BoolVar(&d.LicenseData, data.LicenseName, data.LicenseData, data.LicenseUsage)
+	flag.BoolVar(&d.LicenseData, data.LicenseName[0:1], data.LicenseData, data.LicenseUsage)
+
+	flag.BoolVar(&d.ReportData, data.ReportName, data.ReportData, data.ReportUsage)
+	flag.BoolVar(&d.ReportData, data.ReportName[0:1], data.ReportData, data.ReportUsage)
+
+	flag.StringVar(&d.ConfigFileData, data.ConfigFileName, data.ConfigFileData, data.ConfigFileUsage)
+	flag.StringVar(&d.ConfigFileData, data.ConfigFileName[0:1], data.ConfigFileData, data.ConfigFileUsage)
+
+	flag.StringVar(&d.OutputConfigFileData, data.OutputConfigFileName, data.OutputConfigFileData, data.OutputConfigFileUsage)
+	flag.StringVar(&d.OutputConfigFileData, data.OutputConfigFileName[0:1], data.OutputConfigFileData, data.OutputConfigFileUsage)
+
+	flag.Usage = func() {
+		_, _ = d.PrintUsage()
+	}
+
+	d.flagSet = true
+}
+
+func (d *flagData) parser() {
+	if d.flagParser {
+		return
+	}
+
+	if !d.isFlagSet() {
+		panic("flag not set")
+	}
+
+	flag.Parse()
+
+	d.setDefault()
+	d.flagParser = true
+}
+
+func (d *flagData) setDefault() {
+	if d.ConfigFileData == "" {
+		d.ConfigFileData = "config.yaml"
+
+		if d.OutputConfigFileData == "" {
+			d.OutputConfigFileData = "config.output.yaml"
+		}
+	}
+}
+
+func (d *flagData) ready() {
+	if d.isReady() {
+		return
+	}
+
+	d.writeUsage()
+	d.setFlag()
+	d.parser()
+	d.flagReady = true
+}
+
+func (d *flagData) isReady() bool {
+	return d.isFlagSet() && d.isFlagParser() && d.flagReady
+}
+
+func (d *flagData) isFlagSet() bool {
+	return d.flagSet
+}
+
+func (d *flagData) isFlagParser() bool {
+	return d.flagParser
+}
+
+func (d *flagData) Help() bool {
+	if !d.isReady() {
+		panic("flag not ready")
+	}
+
+	return d.HelpData
+}
+
+func (d *flagData) FprintUsage(writer io.Writer) (int, error) {
+	return fmt.Fprintf(writer, "%s\n", d.Usage)
+}
+
+func (d *flagData) PrintUsage() (int, error) {
+	return d.FprintUsage(flag.CommandLine.Output())
+}
+
+func (d *flagData) Version() bool {
+	if !d.isReady() {
+		panic("flag not ready")
+	}
+
+	return d.VersionData
+}
+
+func (d *flagData) FprintVersion(writer io.Writer) (int, error) {
+	version := utils.FormatTextToWidth(fmt.Sprintf("Version of %s: %s", utils.GetArgs0Name(), resource.Version), utils.NormalConsoleWidth)
+	return fmt.Fprintf(writer, "%s\n", version)
+}
+
+func (d *flagData) PrintVersion() (int, error) {
+	return d.FprintVersion(flag.CommandLine.Output())
+}
+
+func (d *flagData) FprintLicense(writer io.Writer) (int, error) {
+	title := utils.FormatTextToWidth(fmt.Sprintf("License of %s:", utils.GetArgs0Name()), utils.NormalConsoleWidth)
+	license := utils.FormatTextToWidth(resource.License, utils.NormalConsoleWidth)
+	return fmt.Fprintf(writer, "%s\n%s\n", title, license)
+}
+
+func (d *flagData) PrintLicense() (int, error) {
+	return d.FprintLicense(flag.CommandLine.Output())
+}
+
+func (d *flagData) FprintReport(writer io.Writer) (int, error) {
+	// 不需要title
+	report := utils.FormatTextToWidth(resource.Report, utils.NormalConsoleWidth)
+	return fmt.Fprintf(writer, "%s\n", report)
+}
+
+func (d *flagData) PrintReport() (int, error) {
+	return d.FprintReport(flag.CommandLine.Output())
+}
+
+func (d *flagData) FprintLF(writer io.Writer) (int, error) {
+	return fmt.Fprintf(writer, "\n")
+}
+
+func (d *flagData) PrintLF() (int, error) {
+	return d.FprintLF(flag.CommandLine.Output())
+}
+
+func (d *flagData) License() bool {
+	if !d.isReady() {
+		panic("flag not ready")
+	}
+
+	return d.LicenseData
+}
+
+func (d *flagData) Report() bool {
+	if !d.isReady() {
+		panic("flag not ready")
+	}
+
+	return d.ReportData
+}
+
+func (d *flagData) ConfigFile() string {
+	if !d.isReady() {
+		panic("flag not ready")
+	}
+
+	return d.ConfigFileData
+}
+
+func (d *flagData) OutputConfigFile() string {
+	if !d.isReady() {
+		panic("flag not ready")
+	}
+
+	return d.OutputConfigFileData
+}
+
+func (d *flagData) SetOutput(writer io.Writer) {
+	flag.CommandLine.SetOutput(writer)
+}

+ 29 - 0
src/flagparser/error.go

@@ -0,0 +1,29 @@
+package flagparser
+
+import "fmt"
+
+type FlagError interface {
+	error
+	Error() string
+	Data() interface{}
+}
+
+type flagError struct {
+	msg  string
+	data interface{}
+}
+
+func NewFlagError(data interface{}, msg ...string) FlagError {
+	if len(msg) == 1 {
+		return &flagError{msg[0], data}
+	}
+	return &flagError{"flag error: " + fmt.Sprint(data), data}
+}
+
+func (e *flagError) Error() string {
+	return e.msg
+}
+
+func (e *flagError) Data() interface{} {
+	return e.data
+}

+ 82 - 0
src/flagparser/flag.go

@@ -0,0 +1,82 @@
+package flagparser
+
+import (
+	"fmt"
+	"github.com/SongZihuan/https-watcher/src/utils"
+	"os"
+)
+
+var isReady = false
+
+func IsReady() bool {
+	return data.isReady() && isReady
+}
+
+var StopFlag = fmt.Errorf("stop")
+
+func InitFlag() (err error) {
+	if isReady {
+		return nil
+	}
+
+	defer func() {
+		if e := recover(); e != nil {
+			err = NewFlagError(e)
+			return
+		}
+	}()
+
+	initData()
+
+	SetOutput(os.Stdout)
+
+	var hasPrint = false
+
+	if Version() {
+		_, _ = PrintVersion()
+		hasPrint = true
+	}
+
+	if License() {
+		if hasPrint {
+			_, _ = PrintLF()
+		}
+		_, _ = PrintLicense()
+		hasPrint = true
+	}
+
+	if Report() {
+		if hasPrint {
+			_, _ = PrintLF()
+		}
+		_, _ = PrintReport()
+	}
+
+	if Help() {
+		if hasPrint {
+			_, _ = PrintLF()
+		}
+		_, _ = PrintUsage()
+		hasPrint = true
+	}
+
+	if NotRunMode() {
+		return StopFlag
+	}
+
+	err = checkFlag()
+	if err != nil {
+		return err
+	}
+
+	isReady = true
+	return nil
+}
+
+func checkFlag() error {
+	if !utils.IsExists(ConfigFile()) {
+		return fmt.Errorf("config file not exists")
+	}
+
+	return nil
+}

+ 107 - 0
src/flagparser/main.go

@@ -0,0 +1,107 @@
+package flagparser
+
+import (
+	"fmt"
+	"io"
+	"strings"
+)
+
+var data flagData
+
+func Help() bool {
+	return data.Help()
+}
+
+func FprintUsage(writer io.Writer) (int, error) {
+	return data.FprintUsage(writer)
+}
+
+func PrintUsage() (int, error) {
+	return data.PrintUsage()
+}
+
+func FprintVersion(writer io.Writer) (int, error) {
+	return data.FprintVersion(writer)
+}
+
+func PrintVersion() (int, error) {
+	return data.PrintVersion()
+}
+
+func FprintLicense(writer io.Writer) (int, error) {
+	return data.FprintLicense(writer)
+}
+
+func PrintLicense() (int, error) {
+	return data.PrintLicense()
+}
+
+func FprintReport(writer io.Writer) (int, error) {
+	return data.FprintReport(writer)
+}
+
+func PrintReport() (int, error) {
+	return data.PrintReport()
+}
+
+func FprintLF(writer io.Writer) (int, error) {
+	return data.FprintLF(writer)
+}
+
+func PrintLF() (int, error) {
+	return data.PrintLF()
+}
+
+func Version() bool {
+	return data.Version()
+}
+
+func License() bool {
+	return data.License()
+}
+
+func Report() bool {
+	return data.Report()
+}
+
+func NotRunMode() bool {
+	return Help() || Version() || License() || Report()
+}
+
+func NotRunModeOption() string {
+	if !NotRunMode() {
+		return ""
+	}
+
+	var result strings.Builder
+
+	if data.Help() {
+		result.WriteString(fmt.Sprintf("%s%s, ", OptionPrefix, data.HelpName))
+	}
+
+	if data.Version() {
+		result.WriteString(fmt.Sprintf("%s%s, ", OptionPrefix, data.VersionName))
+	}
+
+	if data.License() {
+		result.WriteString(fmt.Sprintf("%s%s, ", OptionPrefix, data.LicenseName))
+	}
+
+	if data.Report() {
+		result.WriteString(fmt.Sprintf("%s%s, ", OptionPrefix, data.ReportName))
+	}
+
+	return strings.TrimSuffix(result.String(), ", ")
+}
+
+func ConfigFile() string {
+	return data.ConfigFile()
+}
+
+func OutputConfigFile() string {
+	return data.OutputConfigFile()
+}
+
+func SetOutput(writer io.Writer) {
+	data.SetOutput(writer)
+}

+ 373 - 0
src/logger/logger.go

@@ -0,0 +1,373 @@
+package logger
+
+import (
+	"fmt"
+	"github.com/SongZihuan/https-watcher/src/config"
+	"github.com/SongZihuan/https-watcher/src/utils"
+	"github.com/mattn/go-isatty"
+	"io"
+	"os"
+)
+
+type LoggerLevel string
+
+const (
+	LevelDebug LoggerLevel = "debug"
+	LevelInfo  LoggerLevel = "info"
+	LevelWarn  LoggerLevel = "warn"
+	LevelError LoggerLevel = "error"
+	LevelPanic LoggerLevel = "panic"
+	LevelNone  LoggerLevel = "none"
+)
+
+type loggerLevel int64
+
+const (
+	levelDebug loggerLevel = 1
+	levelInfo  loggerLevel = 2
+	levelWarn  loggerLevel = 3
+	levelError loggerLevel = 4
+	levelPanic loggerLevel = 5
+	levelNone  loggerLevel = 6
+)
+
+var levelMap = map[LoggerLevel]loggerLevel{
+	LevelDebug: levelDebug,
+	LevelInfo:  levelInfo,
+	LevelWarn:  levelWarn,
+	LevelError: levelError,
+	LevelPanic: levelPanic,
+	LevelNone:  levelNone,
+}
+
+type Logger struct {
+	level      LoggerLevel
+	logLevel   loggerLevel
+	logTag     bool
+	warnWriter io.Writer
+	errWriter  io.Writer
+	args0      string
+	args0Name  string
+}
+
+var globalLogger *Logger = nil
+var DefaultWarnWriter = os.Stdout
+var DefaultErrorWriter = os.Stderr
+
+func InitLogger(warnWriter, errWriter io.Writer) error {
+	if !config.IsReady() {
+		panic("config is not ready")
+	}
+
+	level := LoggerLevel(config.GetConfig().GlobalConfig.LogLevel)
+	logLevel, ok := levelMap[level]
+	if !ok {
+		return fmt.Errorf("invalid log level: %s", level)
+	}
+
+	if warnWriter == nil {
+		warnWriter = DefaultWarnWriter
+	}
+
+	if errWriter == nil {
+		errWriter = DefaultErrorWriter
+	}
+
+	logger := &Logger{
+		level:      level,
+		logLevel:   logLevel,
+		logTag:     config.GetConfig().LogTag.ToBool(true),
+		warnWriter: os.Stdout,
+		errWriter:  os.Stderr,
+		args0:      utils.GetArgs0(),
+		args0Name:  utils.GetArgs0Name(),
+	}
+
+	globalLogger = logger
+	return nil
+}
+
+func IsReady() bool {
+	return globalLogger != nil
+}
+
+func (l *Logger) Executablef(format string, args ...interface{}) string {
+	str := fmt.Sprintf(format, args...)
+	if str == "" {
+		_, _ = fmt.Fprintf(l.warnWriter, "[Executable]: %s\n", l.args0)
+	} else {
+		_, _ = fmt.Fprintf(l.warnWriter, "[Executable %s]: %s\n", l.args0, str)
+	}
+	return l.args0
+}
+
+func (l *Logger) Tagf(format string, args ...interface{}) {
+	l.TagSkipf(1, format, args...)
+}
+
+func (l *Logger) TagSkipf(skip int, format string, args ...interface{}) {
+	if !l.logTag {
+		return
+	}
+
+	funcName, file, _, line := utils.GetCallingFunctionInfo(skip + 1)
+
+	str := fmt.Sprintf(format, args...)
+	_, _ = fmt.Fprintf(l.warnWriter, "[Tag %s]: %s %s %s:%d\n", l.args0Name, str, funcName, file, line)
+}
+
+func (l *Logger) Debugf(format string, args ...interface{}) {
+	if l.logLevel > levelDebug {
+		return
+	}
+
+	str := fmt.Sprintf(format, args...)
+	_, _ = fmt.Fprintf(l.warnWriter, "[Debug %s]: %s\n", l.args0Name, str)
+}
+
+func (l *Logger) Infof(format string, args ...interface{}) {
+	if l.logLevel > levelInfo {
+		return
+	}
+
+	str := fmt.Sprintf(format, args...)
+	_, _ = fmt.Fprintf(l.warnWriter, "[Info %s]: %s\n", l.args0Name, str)
+}
+
+func (l *Logger) Warnf(format string, args ...interface{}) {
+	if l.logLevel > levelWarn {
+		return
+	}
+
+	str := fmt.Sprintf(format, args...)
+	_, _ = fmt.Fprintf(l.warnWriter, "[Warning %s]: %s\n", l.args0Name, str)
+}
+
+func (l *Logger) Errorf(format string, args ...interface{}) {
+	if l.logLevel > levelError {
+		return
+	}
+
+	str := fmt.Sprintf(format, args...)
+	_, _ = fmt.Fprintf(l.errWriter, "[Error %s]: %s\n", l.args0Name, str)
+}
+
+func (l *Logger) Panicf(format string, args ...interface{}) {
+	if l.logLevel > levelPanic {
+		return
+	}
+
+	str := fmt.Sprintf(format, args...)
+	_, _ = fmt.Fprintf(l.errWriter, "[Panic %s]: %s\n", l.args0Name, str)
+}
+
+func (l *Logger) Tag(args ...interface{}) {
+	l.TagSkip(1, args...)
+}
+
+func (l *Logger) TagSkip(skip int, args ...interface{}) {
+	if !l.logTag {
+		return
+	}
+
+	funcName, file, _, line := utils.GetCallingFunctionInfo(skip + 1)
+
+	str := fmt.Sprint(args...)
+	_, _ = fmt.Fprintf(l.warnWriter, "[Tag %s]: %s %s %s:%d\n", l.args0Name, str, funcName, file, line)
+}
+
+func (l *Logger) Debug(args ...interface{}) {
+	if l.logLevel > levelDebug {
+		return
+	}
+
+	str := fmt.Sprint(args...)
+	_, _ = fmt.Fprintf(l.warnWriter, "[Debug %s]: %s\n", l.args0Name, str)
+}
+
+func (l *Logger) Info(args ...interface{}) {
+	if l.logLevel > levelInfo {
+		return
+	}
+
+	str := fmt.Sprint(args...)
+	_, _ = fmt.Fprintf(l.warnWriter, "[Info %s]: %s\n", l.args0Name, str)
+}
+
+func (l *Logger) Warn(args ...interface{}) {
+	if l.logLevel > levelWarn {
+		return
+	}
+
+	str := fmt.Sprint(args...)
+	_, _ = fmt.Fprintf(l.warnWriter, "[Warning %s]: %s\n", l.args0Name, str)
+}
+
+func (l *Logger) Error(args ...interface{}) {
+	if l.logLevel > levelError {
+		return
+	}
+
+	str := fmt.Sprint(args...)
+	_, _ = fmt.Fprintf(l.errWriter, "[Error %s]: %s\n", l.args0Name, str)
+}
+
+func (l *Logger) Panic(args ...interface{}) {
+	if l.logLevel > levelPanic {
+		return
+	}
+
+	str := fmt.Sprint(args...)
+	_, _ = fmt.Fprintf(l.errWriter, "[Panic %s]: %s\n", l.args0Name, str)
+}
+
+func (l *Logger) TagWrite(msg string) {
+	l.TagSkipWrite(1, msg)
+}
+
+func (l *Logger) TagSkipWrite(skip int, msg string) {
+	if !l.logTag {
+		return
+	}
+
+	funcName, file, _, line := utils.GetCallingFunctionInfo(skip + 1)
+
+	_, _ = fmt.Fprintf(l.warnWriter, "[Debug %s]: %s %s %s:%d\n", l.args0Name, msg, funcName, file, line)
+}
+
+func (l *Logger) DebugWrite(msg string) {
+	if l.logLevel > levelDebug {
+		return
+	}
+
+	_, _ = fmt.Fprintf(l.warnWriter, "[Debug %s]: %s\n", l.args0Name, msg)
+}
+
+func (l *Logger) InfoWrite(msg string) {
+	if l.logLevel > levelInfo {
+		return
+	}
+
+	_, _ = fmt.Fprintf(l.warnWriter, "[Info %s]: %s\n", l.args0Name, msg)
+}
+
+func (l *Logger) WarnWrite(msg string) {
+	if l.logLevel > levelWarn {
+		return
+	}
+
+	_, _ = fmt.Fprintf(l.warnWriter, "[Warning %s]: %s\n", l.args0Name, msg)
+}
+
+func (l *Logger) ErrorWrite(msg string) {
+	if l.logLevel > levelError {
+		return
+	}
+
+	_, _ = fmt.Fprintf(l.errWriter, "[Error %s]: %s\n", l.args0Name, msg)
+}
+
+func (l *Logger) PanicWrite(msg string) {
+	if l.logLevel > levelPanic {
+		return
+	}
+
+	_, _ = fmt.Fprintf(l.errWriter, "[Panic %s]: %s\n", l.args0Name, msg)
+}
+
+func (l *Logger) GetDebugWriter() io.Writer {
+	return l.warnWriter
+}
+
+func (l *Logger) GetInfoWriter() io.Writer {
+	return l.warnWriter
+}
+
+func (l *Logger) GetWarningWriter() io.Writer {
+	return l.warnWriter
+}
+
+func (l *Logger) GetTagWriter() io.Writer {
+	return l.warnWriter
+}
+
+func (l *Logger) GetErrorWriter() io.Writer {
+	return l.errWriter
+}
+
+func (l *Logger) GetPanicWriter() io.Writer {
+	return l.errWriter
+}
+
+func (l *Logger) isWarnWriterTerm() bool {
+	w, ok := l.warnWriter.(*os.File)
+	if !ok {
+		return false
+	} else if !isatty.IsTerminal(w.Fd()) && !isatty.IsCygwinTerminal(w.Fd()) { // 非终端
+		return false
+	}
+	return true
+}
+
+func (l *Logger) isErrWriterTerm() bool {
+	w, ok := l.errWriter.(*os.File)
+	if !ok {
+		return false
+	} else if !isatty.IsTerminal(w.Fd()) && !isatty.IsCygwinTerminal(w.Fd()) { // 非终端
+		return false
+	}
+	return true
+}
+
+func (l *Logger) isTermDump() bool {
+	// TERM为dump表示终端为基础模式,不支持高级显示
+	return os.Getenv("TERM") == "dumb"
+}
+
+func (l *Logger) IsDebugTerm() bool {
+	return l.isWarnWriterTerm()
+}
+
+func (l *Logger) IsInfoTerm() bool {
+	return l.isWarnWriterTerm()
+}
+
+func (l *Logger) IsWarnTerm() bool {
+	return l.isWarnWriterTerm()
+}
+
+func (l *Logger) IsTagTerm() bool {
+	return l.isWarnWriterTerm()
+}
+
+func (l *Logger) IsErrorTerm() bool {
+	return l.isErrWriterTerm()
+}
+
+func (l *Logger) IsPanicTerm() bool {
+	return l.isErrWriterTerm()
+}
+
+func (l *Logger) IsDebugTermNotDumb() bool {
+	return l.isWarnWriterTerm() && !l.isTermDump()
+}
+
+func (l *Logger) IsInfoTermNotDumb() bool {
+	return l.isWarnWriterTerm() && !l.isTermDump()
+}
+
+func (l *Logger) IsWarnTermNotDumb() bool {
+	return l.isWarnWriterTerm() && !l.isTermDump()
+}
+
+func (l *Logger) IsTagTermNotDumb() bool {
+	return l.isWarnWriterTerm() && !l.isTermDump()
+}
+
+func (l *Logger) IsErrorTermNotDumb() bool {
+	return l.isErrWriterTerm() && !l.isTermDump()
+}
+
+func (l *Logger) IsPanicTermNotDumb() bool {
+	return l.isErrWriterTerm() && !l.isTermDump()
+}

+ 264 - 0
src/logger/main.go

@@ -0,0 +1,264 @@
+package logger
+
+import (
+	"io"
+)
+
+func Executablef(format string, args ...interface{}) string {
+	if !IsReady() {
+		return ""
+	}
+	return globalLogger.Executablef(format, args...)
+}
+
+func Tagf(format string, args ...interface{}) {
+	if !IsReady() {
+		return
+	}
+	globalLogger.TagSkipf(1, format, args...)
+}
+
+func Debugf(format string, args ...interface{}) {
+	if !IsReady() {
+		return
+	}
+	globalLogger.Debugf(format, args...)
+}
+
+func Infof(format string, args ...interface{}) {
+	if !IsReady() {
+		return
+	}
+	globalLogger.Infof(format, args...)
+}
+
+func Warnf(format string, args ...interface{}) {
+	if !IsReady() {
+		return
+	}
+	globalLogger.Warnf(format, args...)
+}
+
+func Errorf(format string, args ...interface{}) {
+	if !IsReady() {
+		return
+	}
+	globalLogger.Errorf(format, args...)
+}
+
+func Panicf(format string, args ...interface{}) {
+	if !IsReady() {
+		return
+	}
+	globalLogger.Panicf(format, args...)
+}
+
+func Tag(args ...interface{}) {
+	if !IsReady() {
+		return
+	}
+	globalLogger.TagSkip(1, args...)
+}
+
+func Debug(args ...interface{}) {
+	if !IsReady() {
+		return
+	}
+	globalLogger.Debug(args...)
+}
+
+func Info(args ...interface{}) {
+	if !IsReady() {
+		return
+	}
+	globalLogger.Info(args...)
+}
+
+func Warn(args ...interface{}) {
+	if !IsReady() {
+		return
+	}
+	globalLogger.Warn(args...)
+}
+
+func Error(args ...interface{}) {
+	if !IsReady() {
+		return
+	}
+	globalLogger.Error(args...)
+}
+
+func Panic(args ...interface{}) {
+	if !IsReady() {
+		return
+	}
+	globalLogger.Panic(args...)
+}
+
+func TagWrite(msg string) {
+	if !IsReady() {
+		return
+	}
+	globalLogger.TagSkip(1, msg)
+}
+
+func DebugWrite(msg string) {
+	if !IsReady() {
+		return
+	}
+	globalLogger.DebugWrite(msg)
+}
+
+func InfoWrite(msg string) {
+	if !IsReady() {
+		return
+	}
+	globalLogger.InfoWrite(msg)
+}
+
+func WarnWrite(msg string) {
+	if !IsReady() {
+		return
+	}
+	globalLogger.WarnWrite(msg)
+}
+
+func ErrorWrite(msg string) {
+	if !IsReady() {
+		return
+	}
+	globalLogger.ErrorWrite(msg)
+}
+
+func PanicWrite(msg string) {
+	if !IsReady() {
+		return
+	}
+	globalLogger.PanicWrite(msg)
+}
+
+func GetDebugWriter() io.Writer {
+	if !IsReady() {
+		return DefaultWarnWriter
+	}
+	return globalLogger.GetDebugWriter()
+}
+
+func GetInfoWriter() io.Writer {
+	if !IsReady() {
+		return DefaultWarnWriter
+	}
+	return globalLogger.GetInfoWriter()
+}
+
+func GetWarningWriter() io.Writer {
+	if !IsReady() {
+		return DefaultWarnWriter
+	}
+	return globalLogger.GetWarningWriter()
+}
+
+func GetTagWriter() io.Writer {
+	if !IsReady() {
+		return DefaultWarnWriter
+	}
+	return globalLogger.GetTagWriter()
+}
+
+func GetErrorWriter() io.Writer {
+	if !IsReady() {
+		return DefaultWarnWriter
+	}
+	return globalLogger.GetErrorWriter()
+}
+
+func GetPanicWriter() io.Writer {
+	if !IsReady() {
+		return DefaultWarnWriter
+	}
+	return globalLogger.GetPanicWriter()
+}
+
+func IsDebugTerm() bool {
+	if !IsReady() {
+		return false
+	}
+	return globalLogger.IsDebugTerm()
+}
+
+func IsInfoTerm() bool {
+	if !IsReady() {
+		return false
+	}
+	return globalLogger.IsDebugTerm()
+}
+
+func IsTagTerm() bool {
+	if !IsReady() {
+		return false
+	}
+	return globalLogger.IsTagTerm()
+}
+
+func IsWarnTerm() bool {
+	if !IsReady() {
+		return false
+	}
+	return globalLogger.IsWarnTerm()
+}
+
+func IsErrorTerm() bool {
+	if !IsReady() {
+		return false
+	}
+	return globalLogger.IsErrorTerm()
+}
+
+func IsPanicTerm() bool {
+	if !IsReady() {
+		return false
+	}
+	return globalLogger.IsPanicTerm()
+}
+
+func IsDebugTermNotDumb() bool {
+	if !IsReady() {
+		return false
+	}
+	return globalLogger.IsDebugTerm()
+}
+
+func IsInfoTermNotDumb() bool {
+	if !IsReady() {
+		return false
+	}
+	return globalLogger.IsInfoTermNotDumb()
+}
+
+func IsTagTermNotDumb() bool {
+	if !IsReady() {
+		return false
+	}
+	return globalLogger.IsTagTermNotDumb()
+}
+
+func IsWarnTermNotDumb() bool {
+	if !IsReady() {
+		return false
+	}
+	return globalLogger.IsWarnTermNotDumb()
+}
+
+func IsErrorTermNotDumb() bool {
+	if !IsReady() {
+		return false
+	}
+	return globalLogger.IsErrorTermNotDumb()
+}
+
+func IsPanicTermNotDumb() bool {
+	if !IsReady() {
+		return false
+	}
+	return globalLogger.IsPanicTermNotDumb()
+}

+ 62 - 0
src/mainfunc/httpswatcher/v1.go

@@ -0,0 +1,62 @@
+package httpswatcher
+
+import (
+	"errors"
+	"github.com/SongZihuan/https-watcher/src/config"
+	"github.com/SongZihuan/https-watcher/src/flagparser"
+	"github.com/SongZihuan/https-watcher/src/logger"
+	"github.com/SongZihuan/https-watcher/src/notify"
+	"github.com/SongZihuan/https-watcher/src/utils"
+	"github.com/SongZihuan/https-watcher/src/watcher"
+	"os"
+)
+
+func MainV1() (exitcode int) {
+	var err error
+
+	err = flagparser.InitFlag()
+	if errors.Is(err, flagparser.StopFlag) {
+		return 0
+	} else if err != nil {
+		return utils.ExitByError(err)
+	}
+
+	if !flagparser.IsReady() {
+		return utils.ExitByErrorMsg("flag parser unknown error")
+	}
+
+	cfgErr := config.InitConfig(flagparser.ConfigFile())
+	if cfgErr != nil && cfgErr.IsError() {
+		return utils.ExitByError(cfgErr)
+	}
+
+	if !config.IsReady() {
+		return utils.ExitByErrorMsg("config parser unknown error")
+	}
+
+	err = logger.InitLogger(os.Stdout, os.Stderr)
+	if err != nil {
+		return utils.ExitByError(err)
+	}
+
+	if !logger.IsReady() {
+		return utils.ExitByErrorMsg("logger unknown error")
+	}
+
+	err = notify.InitNotify()
+	if err != nil {
+		logger.Errorf("init notify fail: %s", err.Error())
+		return 1
+	}
+
+	logger.Executablef("%s", "ready")
+	logger.Infof("run mode: %s", config.GetConfig().GlobalConfig.GetRunMode())
+
+	err = watcher.Run()
+	if err != nil {
+		logger.Errorf("run watcher fail: %s", err.Error())
+		return 1
+	}
+
+	return 0
+}

+ 97 - 0
src/notify/action.go

@@ -0,0 +1,97 @@
+package notify
+
+import (
+	"fmt"
+	"github.com/SongZihuan/https-watcher/src/config"
+	"github.com/SongZihuan/https-watcher/src/smtpserver"
+	"github.com/SongZihuan/https-watcher/src/utils"
+	"github.com/SongZihuan/https-watcher/src/wxrobot"
+	"strings"
+	"sync"
+	"time"
+)
+
+type urlRecord struct {
+	Name     string
+	URL      string
+	Deadline time.Duration
+}
+
+var startTime time.Time
+var records sync.Map
+
+func InitNotify() error {
+	if !config.IsReady() {
+		panic("config is not ready")
+	}
+
+	startTime = time.Now().In(config.TimeZone())
+
+	err := smtpserver.InitSmtp()
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func NewRecord(name string, url string, deadline time.Duration) {
+	if name == "" {
+		name = url
+	}
+
+	records.Store(name, &urlRecord{
+		Name:     name,
+		URL:      url,
+		Deadline: deadline,
+	})
+}
+
+func SendNotify() {
+	var res strings.Builder
+	var expiredCount uint64 = 0
+	var expiringSoonCount uint64 = 0
+
+	res.WriteString(fmt.Sprintf("日期:%s %s\n", startTime.Format("2006-01-02 15:04:05"), startTime.Location().String()))
+
+	records.Range(func(key, value any) bool {
+		record, ok := value.(*urlRecord)
+		if !ok {
+			return true
+		}
+
+		if record.Deadline <= 0 {
+			expiredCount += 1
+			res.WriteString(fmt.Sprintf("- %s 已过期\n", record.Name))
+		} else {
+			expiringSoonCount += 1
+			res.WriteString(fmt.Sprintf("- %s 剩余时间: %s\n", record.Name, utils.TimeDurationToStringCN(record.Deadline)))
+		}
+
+		return true
+	})
+
+	if expiredCount+expiringSoonCount <= 0 {
+		// 无任何记录
+		return
+	}
+
+	res.WriteString(fmt.Sprintf("共计:过期 %d 条,即将过期 %d 条。\n", expiredCount, expiringSoonCount))
+	res.WriteString("完毕\n")
+	msg := res.String()
+
+	var wg sync.WaitGroup
+	wg.Add(2)
+
+	go func() {
+		defer wg.Done()
+		wxrobot.SendNotify(msg)
+	}()
+
+	go func() {
+		defer wg.Done()
+		smtpserver.SendNotify(msg)
+	}()
+
+	wg.Wait()
+}

+ 17 - 0
src/smtpserver/action.go

@@ -0,0 +1,17 @@
+package smtpserver
+
+import (
+	"github.com/SongZihuan/https-watcher/src/logger"
+)
+
+func printError(err error) {
+	if err == nil {
+		return
+	}
+
+	logger.Errorf("SMTP Send Error: %s", err.Error())
+}
+
+func SendNotify(msg string) {
+	printError(Send("HTTPS 到期通知", msg))
+}

+ 285 - 0
src/smtpserver/server.go

@@ -0,0 +1,285 @@
+package smtpserver
+
+import (
+	"crypto/tls"
+	"errors"
+	"fmt"
+	"github.com/SongZihuan/https-watcher/src/config"
+	"github.com/SongZihuan/https-watcher/src/utils"
+	"gopkg.in/gomail.v2"
+	"net"
+	"net/mail"
+	"net/smtp"
+	"os"
+	"strings"
+	"sync"
+	"time"
+)
+
+var smtpAddress string = ""
+var smtpUser string = ""
+var smtpPassword string = ""
+var smtpRecipient []*mail.Address
+
+var once sync.Once
+
+func InitSmtp() (err error) {
+	once.Do(func() {
+		recipientList := config.GetConfig().SMTP.Recipient
+
+		smtpAddress = config.GetConfig().SMTP.Address
+		smtpUser = config.GetConfig().SMTP.User
+		smtpPassword = config.GetConfig().SMTP.Password
+		smtpRecipient = make([]*mail.Address, 0, len(recipientList))
+
+		if !config.IsReady() {
+			panic("config is not ready")
+		} else if smtpAddress == "" || smtpUser == "" {
+			return
+		} else if len(recipientList) == 0 {
+			err = fmt.Errorf("not smt recopient")
+			return
+		}
+
+		for _, rec := range recipientList {
+			addr, err := mail.ParseAddress(strings.TrimSpace(rec))
+			if err != nil {
+				fmt.Printf("%s parser failled, ignore\n", rec)
+				continue
+			}
+
+			if !utils.IsValidEmail(addr.Address) {
+				fmt.Printf("%s is not a valid email, ignore\n", addr.Address)
+				continue
+			}
+
+			smtpRecipient = append(smtpRecipient, addr)
+		}
+
+		if len(smtpRecipient) == 0 {
+			err = fmt.Errorf("not any valid email address to be self recipient")
+			return
+		}
+	})
+	return err
+}
+
+func Send(subject string, msg string) error {
+	if !config.IsReady() {
+		panic("config is not ready")
+	} else if smtpAddress == "" || smtpUser == "" {
+		return nil
+	}
+
+	subject = fmt.Sprintf("【%s 消息提醒】 %s", config.GetConfig().SystemName, subject)
+	now := time.Now()
+
+	err := _sendTo(subject, msg, nil, nil, smtpRecipient, "", now)
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func _sendTo(subject string, msg string, fromAddr *mail.Address, replyToAddr *mail.Address, toAddr []*mail.Address, messageID string, t time.Time) (err error) {
+	if smtpAddress == "" || smtpUser == "" {
+		return nil
+	}
+
+	defer func() {
+		r := recover()
+		if r != nil && err == nil {
+			if _err, ok := r.(error); ok {
+				err = _err
+			} else {
+				err = fmt.Errorf("panic: %v", r)
+			}
+		}
+	}()
+
+	sender := smtpUser
+
+	if fromAddr == nil {
+		fromAddr = &mail.Address{
+			Name:    config.GetConfig().SystemName,
+			Address: smtpUser,
+		}
+	}
+
+	if replyToAddr == nil {
+		replyToAddr = &mail.Address{
+			Name:    fromAddr.Name,
+			Address: fromAddr.Address,
+		}
+	}
+
+	const missingPort = "missing port in address"
+	host, port, err := net.SplitHostPort(smtpAddress)
+	var addrErr *net.AddrError
+	if errors.As(err, &addrErr) {
+		if addrErr.Err == missingPort {
+			host = smtpAddress
+			port = "25"
+		} else {
+			return err
+		}
+	} else if err != nil {
+		return err
+	}
+
+	tlsconfig := &tls.Config{
+		ServerName:         host,
+		InsecureSkipVerify: false,
+	}
+
+	conn, err := net.Dial("tcp", net.JoinHostPort(host, port))
+	if err != nil {
+		return err
+	}
+	defer func() {
+		_ = conn.Close()
+	}()
+
+	isSecureConn := false
+	_conn := tls.Client(conn, tlsconfig)
+	err = _conn.Handshake()
+	if err == nil {
+		conn = _conn
+		isSecureConn = true
+	}
+
+	smtpClient, err := smtp.NewClient(conn, host)
+	if err != nil {
+		return fmt.Errorf("new smtp client: %v", err)
+	}
+	defer func() {
+		_ = smtpClient.Quit()
+		smtpClient = nil
+	}()
+
+	hostname, err := os.Hostname()
+	if err != nil {
+		return err
+	}
+
+	if err = smtpClient.Hello(hostname); err != nil {
+		return fmt.Errorf("hello: %v", err)
+	}
+
+	// If not using SMTPS, always use STARTTLS if available
+	hasStartTLS, _ := smtpClient.Extension("STARTTLS")
+	if !isSecureConn && hasStartTLS {
+		if err = smtpClient.StartTLS(tlsconfig); err != nil {
+			return fmt.Errorf("start tls: %v", err)
+		}
+	}
+
+	canAuth, options := smtpClient.Extension("AUTH")
+	if canAuth {
+		var auth smtp.Auth
+		if strings.Contains(options, "CRAM-MD5") {
+			auth = smtp.CRAMMD5Auth(sender, smtpPassword)
+		} else if strings.Contains(options, "PLAIN") {
+			auth = smtp.PlainAuth("", sender, smtpPassword, host)
+		} else if strings.Contains(options, "LOGIN") {
+			auth = LoginAuth(sender, smtpPassword)
+		}
+
+		if auth != nil {
+			if err = smtpClient.Auth(auth); err != nil {
+				return fmt.Errorf("auth: %s", err.Error())
+			}
+		}
+	}
+
+	err = smtpClient.Mail(sender)
+	if err != nil {
+		return fmt.Errorf("mail: %v", err)
+	}
+
+	recList := make([]string, 0, len(toAddr))
+
+	for _, addr := range toAddr {
+		if addr.Address == "" || !utils.IsValidEmail(addr.Address) {
+			fmt.Printf("%s is not a valid email, ignore\n", addr.Address)
+			continue
+		}
+
+		err = smtpClient.Rcpt(addr.Address)
+		if err != nil {
+			fmt.Printf("%s set rcpt error: %s, ignore\n", addr.String(), err.Error())
+			continue
+		}
+
+		recList = append(recList, addr.String())
+	}
+
+	if len(recList) == 0 {
+		return fmt.Errorf("no any valid recipient")
+	}
+
+	if fromAddr.Address == "" {
+		fromAddr.Address = smtpUser
+	}
+
+	gomsg := gomail.NewMessage()
+	gomsg.SetHeader("From", fromAddr.String())
+	gomsg.SetHeader("To", recList...)
+	gomsg.SetHeader("Reply-To", replyToAddr.String())
+	gomsg.SetHeader("Subject", subject)
+	gomsg.SetDateHeader("Date", t)
+	if messageID != "" {
+		gomsg.SetHeader("In-Reply-To", messageID)
+		gomsg.SetHeader("References", messageID)
+	}
+	gomsg.SetBody("text/plain", msg)
+
+	w, err := smtpClient.Data()
+	if err != nil {
+		return fmt.Errorf("data: %v", err)
+	}
+
+	if _, err = gomsg.WriteTo(w); err != nil {
+		return fmt.Errorf("write to: %v", err)
+	}
+
+	err = w.Close()
+	if err != nil {
+		return fmt.Errorf("close: %v", err)
+	}
+
+	return nil
+}
+
+type loginAuth struct {
+	username, password string
+}
+
+func (*loginAuth) Start(_ *smtp.ServerInfo) (string, []byte, error) {
+	return "LOGIN", []byte{}, nil
+}
+
+func (a *loginAuth) Next(fromServer []byte, more bool) ([]byte, error) {
+	if more {
+		switch string(fromServer) {
+		case "Username:":
+			return []byte(a.username), nil
+		case "Password:":
+			return []byte(a.password), nil
+		default:
+			return nil, fmt.Errorf("unknwon fromServer: %s", string(fromServer))
+		}
+	}
+	return nil, nil
+}
+
+func LoginAuth(username, password string) smtp.Auth {
+	return &loginAuth{username, password}
+}
+
+type Message struct {
+	Info string // Message information for log purpose.
+	*gomail.Message
+	confirmChan chan struct{}
+}

+ 53 - 0
src/utils/cmd.go

@@ -0,0 +1,53 @@
+package utils
+
+import (
+	"fmt"
+	"os"
+	"path/filepath"
+)
+
+var _args0 = ""
+
+func init() {
+	var err error
+	if len(os.Args) > 0 {
+		_args0, err = os.Executable()
+		if err != nil {
+			_args0 = os.Args[0]
+		}
+	}
+
+	if _args0 == "" {
+		panic("args was empty")
+	}
+}
+
+func GetArgs0() string {
+	return _args0
+}
+
+func GetArgs0Name() string {
+	return filepath.Base(_args0)
+}
+
+func SayHellof(format string, args ...interface{}) {
+	var msg string
+	if len(format) == 0 && len(args) == 0 {
+		msg = fmt.Sprintf("%s: %s", GetArgs0Name(), "Normal startup, thank you.")
+	} else {
+		str := fmt.Sprintf(format, args...)
+		msg = fmt.Sprintf("%s: %s", GetArgs0Name(), str)
+	}
+	fmt.Println(FormatTextToWidth(msg, NormalConsoleWidth))
+}
+
+func SayGoodByef(format string, args ...interface{}) {
+	var msg string
+	if len(format) == 0 && len(args) == 0 {
+		msg = fmt.Sprintf("%s: %s", GetArgs0Name(), "Normal shutdown, thank you.")
+	} else {
+		str := fmt.Sprintf(format, args...)
+		msg = fmt.Sprintf("%s: %s", GetArgs0Name(), str)
+	}
+	fmt.Println(FormatTextToWidth(msg, NormalConsoleWidth))
+}

+ 45 - 0
src/utils/exit.go

@@ -0,0 +1,45 @@
+package utils
+
+import (
+	"os"
+)
+
+func ExitByError(err error, code ...int) int {
+	if err == nil {
+		return ExitByErrorMsg("")
+	} else {
+		return ExitByErrorMsg(err.Error(), code...)
+	}
+}
+
+func ExitByErrorMsg(msg string, code ...int) int {
+	if len(msg) == 0 {
+		msg = "exit: unknown error"
+	}
+
+	return ErrorExit(msg, code...)
+}
+
+func ErrorExit(msg string, code ...int) int {
+	if len(msg) == 0 {
+		SayGoodByef("%s", "Encountered an error, abnormal offline/shutdown.")
+	} else {
+		SayGoodByef("Encountered an error, abnormal offline/shutdown: %s\n", msg)
+	}
+
+	if len(code) == 1 && code[0] != 0 {
+		return Exit(code[0])
+	} else {
+		return Exit(1)
+	}
+}
+
+func Exit(code ...int) int {
+	if len(code) == 1 {
+		os.Exit(code[0])
+		return code[0]
+	} else {
+		os.Exit(0)
+		return 0
+	}
+}

+ 32 - 0
src/utils/file.go

@@ -0,0 +1,32 @@
+package utils
+
+import (
+	"errors"
+	"os"
+)
+
+func IsExists(path string) bool {
+	_, err := os.Stat(path)
+	if err != nil && errors.Is(err, os.ErrNotExist) {
+		return false
+	}
+	return true
+}
+
+func IsDir(path string) bool {
+	s, err := os.Stat(path)
+	if err != nil {
+		return false
+	}
+
+	return s.IsDir()
+}
+
+func IsFile(path string) bool {
+	s, err := os.Stat(path)
+	if err != nil {
+		return false
+	}
+
+	return !s.IsDir()
+}

+ 65 - 0
src/utils/path.go

@@ -0,0 +1,65 @@
+package utils
+
+import (
+	"path/filepath"
+	"runtime"
+	"strings"
+)
+
+func CleanFilePathAbs(pathstr string) (string, error) {
+	pathstr, err := filepath.Abs(filepath.Clean(pathstr))
+	if err != nil {
+		return "", err
+	}
+
+	if runtime.GOOS == "windows" {
+		index := strings.Index(pathstr, `:\`)
+		pf := strings.ToUpper(pathstr[:index])
+		ph := pathstr[index:]
+		pathstr = pf + ph
+	}
+
+	return pathstr, nil
+}
+
+func FilePathEqual(path1, path2 string) bool {
+	path1, err := CleanFilePathAbs(path1)
+	if err != nil {
+		return false
+	}
+
+	path2, err = CleanFilePathAbs(path2)
+	if err != nil {
+		return false
+	}
+
+	return path1 == path2
+}
+
+func CheckIfSubPath(parentPath, childPath string) bool {
+	parentPath, err := CleanFilePathAbs(parentPath)
+	if err != nil {
+		return false
+	}
+
+	childPath, err = CleanFilePathAbs(childPath)
+	if err != nil {
+		return false
+	}
+
+	return strings.HasPrefix(childPath, parentPath)
+}
+
+func CheckIfSubPathNotEqual(parentPath, childPath string) bool {
+	parentPath, err := CleanFilePathAbs(parentPath)
+	if err != nil {
+		return false
+	}
+
+	childPath, err = CleanFilePathAbs(childPath)
+	if err != nil {
+		return false
+	}
+
+	return strings.HasPrefix(childPath, parentPath) && childPath != parentPath
+}

+ 20 - 0
src/utils/rand.go

@@ -0,0 +1,20 @@
+package utils
+
+import (
+	"math/rand"
+	"time"
+)
+
+var _rand *rand.Rand = nil
+
+func init() {
+	_rand = rand.New(rand.NewSource(time.Now().UnixNano()))
+}
+
+func Rand() *rand.Rand {
+	if _rand == nil {
+		panic("nil Rand")
+	}
+
+	return _rand
+}

+ 25 - 0
src/utils/runtime.go

@@ -0,0 +1,25 @@
+package utils
+
+import (
+	"path/filepath"
+	"runtime"
+	"strings"
+)
+
+func GetCallingFunctionInfo(skip int) (string, string, string, int) {
+	pc, file, line, ok := runtime.Caller(skip + 1)
+	if !ok {
+		return "", "", "", 0
+	}
+
+	var funcName string
+	tmp := runtime.FuncForPC(pc).Name()
+	tmpLst := strings.Split(tmp, "/")
+	if len(tmpLst) == 0 {
+		funcName = tmp
+	} else {
+		funcName = tmpLst[len(tmpLst)-1]
+	}
+
+	return funcName, file, filepath.Base(file), line
+}

+ 424 - 0
src/utils/string.go

@@ -0,0 +1,424 @@
+package utils
+
+import (
+	"fmt"
+	"net/url"
+	"regexp"
+	"strconv"
+	"strings"
+	"time"
+	"unicode"
+)
+
+const BASE_CHAR = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
+
+func RandStr(length int) string {
+	bytes := []byte(BASE_CHAR)
+
+	var result []byte
+	for i := 0; i < length; i++ {
+		result = append(result, bytes[Rand().Intn(len(bytes))])
+	}
+
+	return string(result)
+}
+
+func InvalidPhone(phone string) bool {
+	pattern := `^1[3-9]\d{9}$`
+	matched, _ := regexp.MatchString(pattern, phone)
+	return matched
+}
+
+func IsValidEmail(email string) bool {
+	pattern := `^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`
+	matched, _ := regexp.MatchString(pattern, email)
+	return matched
+}
+
+const NormalConsoleWidth = 80
+
+func FormatTextToWidth(text string, width int) string {
+	return FormatTextToWidthAndPrefix(text, 0, width)
+}
+
+func FormatTextToWidthAndPrefix(text string, prefixWidth int, overallWidth int) string {
+	var result strings.Builder
+
+	width := overallWidth - prefixWidth
+	if width <= 0 {
+		panic("bad width")
+	}
+
+	text = strings.ReplaceAll(text, "\r\n", "\n")
+
+	for _, line := range strings.Split(text, "\n") {
+		result.WriteString(strings.Repeat(" ", prefixWidth))
+
+		if line == "" {
+			result.WriteString("\n")
+			continue
+		}
+
+		spaceCount := CountSpaceInStringPrefix(line) % width
+		newLineLength := 0
+		if spaceCount < 80 {
+			result.WriteString(strings.Repeat(" ", spaceCount))
+			newLineLength = spaceCount
+		}
+
+		for _, word := range strings.Fields(line) {
+			if newLineLength+len(word) >= width {
+				result.WriteString("\n")
+				result.WriteString(strings.Repeat(" ", prefixWidth))
+				newLineLength = 0
+			}
+
+			// 不是第一个词时,添加空格
+			if newLineLength != 0 {
+				result.WriteString(" ")
+				newLineLength += 1
+			}
+
+			result.WriteString(word)
+			newLineLength += len(word)
+		}
+
+		if newLineLength != 0 {
+			result.WriteString("\n")
+			newLineLength = 0
+		}
+	}
+
+	return strings.TrimRight(result.String(), "\n")
+}
+
+func CountSpaceInStringPrefix(str string) int {
+	var res int
+	for _, r := range str {
+		if r == ' ' {
+			res += 1
+		} else {
+			break
+		}
+	}
+
+	return res
+}
+
+func IsValidURLPath(path string) bool {
+	if path == "" {
+		return true
+	} else if path == "/" {
+		return false
+	}
+
+	pattern := `^\/[a-zA-Z0-9\-._~:/?#\[\]@!$&'()*+,;%=]+$`
+	matched, _ := regexp.MatchString(pattern, path)
+	return matched
+}
+
+func IsValidDomain(domain string) bool {
+	pattern := `^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$`
+	matched, _ := regexp.MatchString(pattern, domain)
+	return matched
+}
+
+func StringToOnlyPrint(str string) string {
+	runeLst := []rune(str)
+	res := make([]rune, 0, len(runeLst))
+
+	for _, r := range runeLst {
+		if unicode.IsPrint(r) {
+			res = append(res, r)
+		}
+	}
+
+	return string(res)
+}
+
+func IsGoodQueryKey(key string) bool {
+	pattern := `^[a-zA-Z0-9\-._~]+$`
+	matched, _ := regexp.MatchString(pattern, key)
+	return matched
+}
+
+func IsValidHTTPHeaderKey(key string) bool {
+	pattern := `^[a-zA-Z0-9!#$%&'*+.^_` + "`" + `|~-]+$`
+	matched, _ := regexp.MatchString(pattern, key)
+	return matched
+}
+
+func ReadTimeDuration(str string) time.Duration {
+	if str == "forever" || str == "none" {
+		return -1
+	}
+
+	if strings.HasSuffix(strings.ToUpper(str), "Y") {
+		numStr := str[:len(str)-1]
+		num, _ := strconv.ParseUint(numStr, 10, 64)
+		return time.Hour * 24 * 365 * time.Duration(num)
+	} else if strings.HasSuffix(strings.ToLower(str), "year") {
+		numStr := str[:len(str)-4]
+		num, _ := strconv.ParseUint(numStr, 10, 64)
+		return time.Hour * 24 * 365 * time.Duration(num)
+	}
+
+	if strings.HasSuffix(strings.ToUpper(str), "M") {
+		numStr := str[:len(str)-1]
+		num, _ := strconv.ParseUint(numStr, 10, 64)
+		return time.Hour * 24 * 31 * time.Duration(num)
+	} else if strings.HasSuffix(strings.ToLower(str), "month") {
+		numStr := str[:len(str)-5]
+		num, _ := strconv.ParseUint(numStr, 10, 64)
+		return time.Hour * 24 * 31 * time.Duration(num)
+	}
+
+	if strings.HasSuffix(strings.ToUpper(str), "W") {
+		numStr := str[:len(str)-1]
+		num, _ := strconv.ParseUint(numStr, 10, 64)
+		return time.Hour * 24 * 7 * time.Duration(num)
+	} else if strings.HasSuffix(strings.ToLower(str), "week") {
+		numStr := str[:len(str)-4]
+		num, _ := strconv.ParseUint(numStr, 10, 64)
+		return time.Hour * 24 * 7 * time.Duration(num)
+	}
+
+	if strings.HasSuffix(strings.ToUpper(str), "D") {
+		numStr := str[:len(str)-1]
+		num, _ := strconv.ParseUint(numStr, 10, 64)
+		return time.Hour * 24 * time.Duration(num)
+	} else if strings.HasSuffix(strings.ToLower(str), "day") {
+		numStr := str[:len(str)-3]
+		num, _ := strconv.ParseUint(numStr, 10, 64)
+		return time.Hour * 24 * time.Duration(num)
+	}
+
+	if strings.HasSuffix(strings.ToUpper(str), "H") {
+		numStr := str[:len(str)-1]
+		num, _ := strconv.ParseUint(numStr, 10, 64)
+		return time.Hour * time.Duration(num)
+	} else if strings.HasSuffix(strings.ToLower(str), "hour") {
+		numStr := str[:len(str)-4]
+		num, _ := strconv.ParseUint(numStr, 10, 64)
+		return time.Hour * time.Duration(num)
+	}
+
+	if strings.HasSuffix(strings.ToUpper(str), "Min") { // 不能用M,否则会和 Month 冲突
+		numStr := str[:len(str)-3]
+		num, _ := strconv.ParseUint(numStr, 10, 64)
+		return time.Minute * time.Duration(num)
+	} else if strings.HasSuffix(strings.ToLower(str), "minute") {
+		numStr := str[:len(str)-6]
+		num, _ := strconv.ParseUint(numStr, 10, 64)
+		return time.Minute * time.Duration(num)
+	}
+
+	if strings.HasSuffix(strings.ToUpper(str), "S") {
+		numStr := str[:len(str)-1]
+		num, _ := strconv.ParseUint(numStr, 10, 64)
+		return time.Second * time.Duration(num)
+	} else if strings.HasSuffix(strings.ToLower(str), "second") {
+		numStr := str[:len(str)-6]
+		num, _ := strconv.ParseUint(numStr, 10, 64)
+		return time.Second * time.Duration(num)
+	}
+
+	if strings.HasSuffix(strings.ToUpper(str), "MS") {
+		numStr := str[:len(str)-2]
+		num, _ := strconv.ParseUint(numStr, 10, 64)
+		return time.Millisecond * time.Duration(num)
+	} else if strings.HasSuffix(strings.ToLower(str), "millisecond") {
+		numStr := str[:len(str)-11]
+		num, _ := strconv.ParseUint(numStr, 10, 64)
+		return time.Millisecond * time.Duration(num)
+	}
+
+	if strings.HasSuffix(strings.ToUpper(str), "MiS") { // 不能用 MS , 否则会和 millisecond 冲突
+		numStr := str[:len(str)-3]
+		num, _ := strconv.ParseUint(numStr, 10, 64)
+		return time.Microsecond * time.Duration(num)
+	} else if strings.HasSuffix(strings.ToUpper(str), "MicroS") {
+		numStr := str[:len(str)-6]
+		num, _ := strconv.ParseUint(numStr, 10, 64)
+		return time.Microsecond * time.Duration(num)
+	} else if strings.HasSuffix(strings.ToLower(str), "microsecond") {
+		numStr := str[:len(str)-11]
+		num, _ := strconv.ParseUint(numStr, 10, 64)
+		return time.Microsecond * time.Duration(num)
+	}
+
+	if strings.HasSuffix(strings.ToUpper(str), "NS") {
+		numStr := str[:len(str)-2]
+		num, _ := strconv.ParseUint(numStr, 10, 64)
+		return time.Nanosecond * time.Duration(num)
+	} else if strings.HasSuffix(strings.ToLower(str), "nanosecond") {
+		numStr := str[:len(str)-10]
+		num, _ := strconv.ParseUint(numStr, 10, 64)
+		return time.Nanosecond * time.Duration(num)
+	}
+
+	num, _ := strconv.ParseUint(str, 10, 64)
+	return time.Duration(num) * time.Second
+}
+
+func ReadBytes(str string) uint64 {
+	if strings.HasSuffix(strings.ToUpper(str), "TB") {
+		numStr := str[:len(str)-2]
+		num, _ := strconv.ParseUint(numStr, 10, 64)
+		return num * 1024 * 1024 * 1024 * 1024
+	} else if strings.HasSuffix(strings.ToLower(str), "tbytes") {
+		numStr := str[:len(str)-6]
+		num, _ := strconv.ParseUint(numStr, 10, 64)
+		return num * 1024 * 1024 * 1024 * 1024
+	} else if strings.HasSuffix(strings.ToLower(str), "tbyte") {
+		numStr := str[:len(str)-5]
+		num, _ := strconv.ParseUint(numStr, 10, 64)
+		return num * 1024 * 1024 * 1024 * 1024
+	} else if strings.HasSuffix(strings.ToLower(str), "terabytes") {
+		numStr := str[:len(str)-9]
+		num, _ := strconv.ParseUint(numStr, 10, 64)
+		return num * 1024 * 1024 * 1024 * 1024
+	} else if strings.HasSuffix(strings.ToLower(str), "terabyte") {
+		numStr := str[:len(str)-8]
+		num, _ := strconv.ParseUint(numStr, 10, 64)
+		return num * 1024 * 1024 * 1024 * 1024
+	}
+
+	if strings.HasSuffix(strings.ToUpper(str), "GB") {
+		numStr := str[:len(str)-2]
+		num, _ := strconv.ParseUint(numStr, 10, 64)
+		return num * 1024 * 1024 * 1024
+	} else if strings.HasSuffix(strings.ToLower(str), "gbytes") {
+		numStr := str[:len(str)-6]
+		num, _ := strconv.ParseUint(numStr, 10, 64)
+		return num * 1024 * 1024 * 1024
+	} else if strings.HasSuffix(strings.ToLower(str), "gbyte") {
+		numStr := str[:len(str)-5]
+		num, _ := strconv.ParseUint(numStr, 10, 64)
+		return num * 1024 * 1024 * 1024
+	} else if strings.HasSuffix(strings.ToLower(str), "gigabytes") {
+		numStr := str[:len(str)-9]
+		num, _ := strconv.ParseUint(numStr, 10, 64)
+		return num * 1024 * 1024 * 1024
+	} else if strings.HasSuffix(strings.ToLower(str), "gigabyte") {
+		numStr := str[:len(str)-8]
+		num, _ := strconv.ParseUint(numStr, 10, 64)
+		return num * 1024 * 1024 * 1024
+	}
+
+	if strings.HasSuffix(strings.ToUpper(str), "MB") {
+		numStr := str[:len(str)-2]
+		num, _ := strconv.ParseUint(numStr, 10, 64)
+		return num * 1024 * 1024
+	} else if strings.HasSuffix(strings.ToLower(str), "mbytes") {
+		numStr := str[:len(str)-6]
+		num, _ := strconv.ParseUint(numStr, 10, 64)
+		return num * 1024 * 1024
+	} else if strings.HasSuffix(strings.ToLower(str), "mbyte") {
+		numStr := str[:len(str)-5]
+		num, _ := strconv.ParseUint(numStr, 10, 64)
+		return num * 1024 * 1024
+	} else if strings.HasSuffix(strings.ToLower(str), "megabytes") {
+		numStr := str[:len(str)-9]
+		num, _ := strconv.ParseUint(numStr, 10, 64)
+		return num * 1024 * 1024
+	} else if strings.HasSuffix(strings.ToLower(str), "megabyte") {
+		numStr := str[:len(str)-8]
+		num, _ := strconv.ParseUint(numStr, 10, 64)
+		return num * 1024 * 1024
+	}
+
+	if strings.HasSuffix(strings.ToUpper(str), "KB") {
+		numStr := str[:len(str)-2]
+		num, _ := strconv.ParseUint(numStr, 10, 64)
+		return num * 1024
+	} else if strings.HasSuffix(strings.ToLower(str), "kbytes") {
+		numStr := str[:len(str)-6]
+		num, _ := strconv.ParseUint(numStr, 10, 64)
+		return num * 1024
+	} else if strings.HasSuffix(strings.ToLower(str), "kbyte") {
+		numStr := str[:len(str)-5]
+		num, _ := strconv.ParseUint(numStr, 10, 64)
+		return num * 1024
+	} else if strings.HasSuffix(strings.ToLower(str), "kilobytes") {
+		numStr := str[:len(str)-9]
+		num, _ := strconv.ParseUint(numStr, 10, 64)
+		return num * 1024
+	} else if strings.HasSuffix(strings.ToUpper(str), "kilobyte") {
+		numStr := str[:len(str)-8]
+		num, _ := strconv.ParseUint(numStr, 9, 64)
+		return num * 1024
+	}
+
+	if strings.HasSuffix(strings.ToUpper(str), "B") {
+		numStr := str[:len(str)-1]
+		num, _ := strconv.ParseUint(numStr, 10, 64)
+		return num
+	} else if strings.HasSuffix(strings.ToLower(str), "bytes") {
+		numStr := str[:len(str)-5]
+		num, _ := strconv.ParseUint(numStr, 10, 64)
+		return num
+	} else if strings.HasSuffix(strings.ToLower(str), "byte") {
+		numStr := str[:len(str)-4]
+		num, _ := strconv.ParseUint(numStr, 10, 64)
+		return num
+	}
+
+	num, _ := strconv.ParseUint(str, 10, 64)
+	return num
+}
+
+func StringOrDefault(str string, defaultString string) string {
+	str = strings.TrimSpace(str)
+	if str == "" {
+		return defaultString
+	}
+
+	return str
+}
+
+func IsValidHTTPSURL(str string) bool {
+	if str == "" {
+		return false
+	}
+	u, err := url.Parse(str)
+	return err == nil && u.Scheme == "https"
+}
+
+func TimeDurationToStringCN(t time.Duration) string {
+	const day = 24 * time.Hour
+	const year = 365 * day
+
+	if t > year {
+		return fmt.Sprintf("%d年", t/year)
+	} else if t > day {
+		return fmt.Sprintf("%d天", t/day)
+	} else if t > time.Hour {
+		return fmt.Sprintf("%d小时", t/time.Hour)
+	} else if t > time.Minute {
+		return fmt.Sprintf("%d分钟", t/time.Minute)
+	} else if t > time.Second {
+		return fmt.Sprintf("%d秒", t/time.Second)
+	}
+
+	return "0秒"
+}
+
+func TimeDurationToString(t time.Duration) string {
+	const day = 24 * time.Hour
+	const year = 365 * day
+
+	if t > year {
+		return fmt.Sprintf("%dY", t/year)
+	} else if t > day {
+		return fmt.Sprintf("%dD", t/day)
+	} else if t > time.Hour {
+		return fmt.Sprintf("%dh", t/time.Hour)
+	} else if t > time.Minute {
+		return fmt.Sprintf("%dmin", t/time.Minute)
+	} else if t > time.Second {
+		return fmt.Sprintf("%ds", t/time.Second)
+	}
+
+	return "0s"
+}

+ 79 - 0
src/utils/stringbool.go

@@ -0,0 +1,79 @@
+package utils
+
+import (
+	"strings"
+)
+
+type StringBool string
+
+const enable StringBool = "enable"
+const disable StringBool = "disable"
+const enableBool StringBool = "true"
+const disableBool StringBool = "false"
+
+func (s *StringBool) check() bool {
+	*s = StringBool(strings.ToLower(string(*s)))
+	return *s == enable || *s == disable || *s == enableBool || *s == disableBool
+}
+
+func (s *StringBool) is(v StringBool, defaultVal ...bool) (res bool) {
+	if !s.check() {
+		if len(defaultVal) == 1 {
+			res = defaultVal[0]
+			return
+		} else {
+			return false
+		}
+	}
+
+	return *s == v
+}
+
+func (s *StringBool) IsEnable(defaultVal ...bool) (res bool) {
+	res = s.is(enable, defaultVal...) || s.is(enableBool, defaultVal...)
+	return
+}
+
+func (s *StringBool) IsDisable(defaultVal ...bool) (res bool) {
+	res = s.is(disable, defaultVal...) || s.is(disableBool, defaultVal...)
+	return
+}
+
+func (s *StringBool) setDefault(v StringBool) {
+	if !s.check() {
+		*s = v
+	}
+}
+
+func (s *StringBool) SetDefaultEnable() {
+	s.setDefault(enable)
+}
+
+func (s *StringBool) SetDefaultDisable() {
+	s.setDefault(disable)
+}
+
+func (s *StringBool) ToString() string {
+	if s.IsEnable() {
+		return string(enable)
+	}
+	return string(disable)
+}
+
+func (s *StringBool) ToStringDefaultEnable() string {
+	if s.IsEnable(true) {
+		return string(enable)
+	}
+	return string(disable)
+}
+
+func (s *StringBool) ToStringDefaultDisable() string {
+	if s.IsEnable(false) {
+		return string(enable)
+	}
+	return string(disable)
+}
+
+func (s *StringBool) ToBool(defaultVal ...bool) bool {
+	return s.IsEnable(defaultVal...)
+}

+ 12 - 0
src/utils/struct.go

@@ -0,0 +1,12 @@
+package utils
+
+import "reflect"
+
+func HasFieldByReflect(typ reflect.Type, fieldName string) bool {
+	for i := 0; i < typ.NumField(); i++ {
+		if typ.Field(i).Name == fieldName {
+			return true
+		}
+	}
+	return false
+}

+ 90 - 0
src/watcher/watcher.go

@@ -0,0 +1,90 @@
+package watcher
+
+import (
+	"crypto/tls"
+	"errors"
+	"fmt"
+	"github.com/SongZihuan/https-watcher/src/config"
+	"github.com/SongZihuan/https-watcher/src/logger"
+	"github.com/SongZihuan/https-watcher/src/notify"
+	"net/http"
+	"time"
+)
+
+var errNotTLS = fmt.Errorf("no TLS connection was made")
+
+func Run() error {
+	if !config.IsReady() {
+		panic("config is not ready")
+	}
+
+	now := time.Now()
+
+MainCycle:
+	for _, url := range config.GetConfig().Watcher.URLs {
+		logger.Infof("开始请求 %s", url.Name)
+
+		tlsState, err := getCertificate(url.URL)
+		if err != nil {
+			if errors.Is(err, errNotTLS) {
+				logger.Errorf("请求 %s 出现异常:未返回TLS证书", url.Name)
+				continue MainCycle
+			}
+
+			logger.Errorf("请求 %s 出现异常:%s", url.Name, err.Error())
+			continue MainCycle
+		}
+
+		if len(tlsState.PeerCertificates) == 0 {
+			logger.Errorf("请求 %s 出现异常:证书链为空", url.Name)
+			continue MainCycle
+		}
+
+		logger.Infof("开始处理 %s", url.Name)
+
+		if now.After(tlsState.PeerCertificates[0].NotAfter) {
+			// 证书已过期
+			logger.Infof("%s 已过期", url.Name)
+			notify.NewRecord(url.Name, url.URL, 0)
+		} else if deadline := tlsState.PeerCertificates[0].NotAfter.Sub(now); deadline <= url.DeadlineDuration {
+			// 证书即将过期
+			logger.Infof("%s 即将过期", url.Name)
+			notify.NewRecord(url.Name, url.URL, deadline)
+		} else {
+			logger.Infof("%s 正常", url.Name)
+		}
+
+		logger.Infof("处理 %s 完成", url.Name)
+	}
+
+	notify.SendNotify()
+
+	return nil
+}
+
+func getCertificate(url string) (*tls.ConnectionState, error) {
+	// 创建一个自定义的Transport,这样我们可以访问TLS连接状态
+	tr := &http.Transport{
+		TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, // 忽略服务器证书验证
+	}
+
+	// 使用自定义的Transport创建一个HTTP客户端
+	client := &http.Client{Transport: tr}
+
+	// 发送请求
+	resp, err := client.Get(url)
+	if err != nil {
+		return nil, err
+	}
+	defer func() {
+		_ = resp.Body.Close()
+	}()
+
+	// 从响应中获取TLS连接状态
+	tlsState := resp.TLS
+	if tlsState == nil {
+		return nil, errNotTLS
+	}
+
+	return tlsState, nil
+}

+ 17 - 0
src/wxrobot/action.go

@@ -0,0 +1,17 @@
+package wxrobot
+
+import (
+	"github.com/SongZihuan/https-watcher/src/logger"
+)
+
+func printError(err error) {
+	if err == nil {
+		return
+	}
+
+	logger.Errorf("WxRobot Send Error: %s", err.Error())
+}
+
+func SendNotify(msg string) {
+	printError(Send(msg, true))
+}

+ 101 - 0
src/wxrobot/wxrobot.go

@@ -0,0 +1,101 @@
+package wxrobot
+
+import (
+	"bytes"
+	"encoding/json"
+	"fmt"
+	"github.com/SongZihuan/https-watcher/src/config"
+	"io"
+	"net/http"
+)
+
+const (
+	msgtypetext     = "text"
+	msgtypemarkdown = "markdown"
+)
+const atall = "@all"
+
+type WebhookText struct {
+	Content             string   `json:"content"`
+	MentionedList       []string `json:"mentioned_list"`
+	MentionedMobileList []string `json:"mentioned_mobile_list"`
+}
+
+type WebhookMarkdown struct {
+	Content string `json:"content"`
+}
+
+type ReqWebhookMsg struct {
+	MsgType  string           `json:"msgtype"`
+	Text     *WebhookText     `json:"text,omitempty"`
+	Markdown *WebhookMarkdown `json:"markdown,omitempty"`
+}
+
+type RespWebhookMsg struct {
+	ErrCode int    `json:"errcode"`
+	ErrMsg  string `json:"errmsg"`
+}
+
+func Send(msg string, atAll bool) error {
+	if msg == "" {
+		return nil
+	}
+
+	return send(fmt.Sprintf("【%s 消息提醒】\n%s", config.GetConfig().SystemName, msg), atAll)
+}
+
+func send(msg string, atAll bool) error {
+	if !config.IsReady() {
+		panic("config is not ready")
+	}
+
+	webhook := config.GetConfig().API.Webhook
+
+	if webhook == "" || msg == "" {
+		return nil
+	}
+
+	if len([]byte(msg)) >= 2048 {
+		return fmt.Errorf("msg too long")
+	}
+
+	data := ReqWebhookMsg{
+		MsgType: msgtypetext,
+		Text: &WebhookText{
+			Content: msg,
+		},
+	}
+	if atAll {
+		data.Text.MentionedMobileList = []string{atall}
+	}
+
+	webhookData, err := json.Marshal(data)
+	if err != nil {
+		return fmt.Errorf("json marshal error: %s", err.Error())
+	}
+
+	resp, err := http.Post(webhook, "application/json", bytes.NewBuffer(webhookData))
+	if err != nil {
+		return fmt.Errorf("http post error: %s", err.Error())
+	}
+	defer func() {
+		_ = resp.Body.Close()
+	}()
+
+	respData, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return fmt.Errorf("read response body error: %s", err.Error())
+	}
+
+	var respWebhook RespWebhookMsg
+	err = json.Unmarshal(respData, &respWebhook)
+	if err != nil {
+		return fmt.Errorf("json unmarshal response body error: %s", err.Error())
+	}
+
+	if respWebhook.ErrCode != 0 {
+		return fmt.Errorf("send message error [code: %d]: %s", respWebhook.ErrCode, respWebhook.ErrMsg)
+	}
+
+	return nil
+}