release: v0.2.0
Some checks failed
Release / Build & Release (push) Has been cancelled

Comprehensive release containing structural, UX, and behavioral upgrades since v0.1.0:

1. Namespace Transition: Renamed core executable and project namespaces from 'smart-monitor' to 'smart-shutdown'.
2. Objective Vocabulary Refactoring: Normalized output strings and logging descriptors system-wide to a strict, professional tone.
3. Advanced Status Query: 'status' subcommand now retrieves the parsed configuration, log locations/sizes, and tails the last 10 lines of the local log file.
4. Runtime Configuration Setter: Introduced 'config set' subcommand to modify the configuration file with strict type validations.
5. Auto-System Deployment: Remapped 'install' to clone the executable into system domains and register global PATH variables.
6. Cleaner Removal: 'uninstall' purges binary clones and clears environmental variables, leaving zero traces.
7. Documentation Rewrite: Generated an objective README file featuring copy-paste ready Markdown blocks.
This commit is contained in:
2026-03-24 16:07:22 +08:00
parent 3525a59976
commit 3be3e19e49
10 changed files with 243 additions and 237 deletions

View File

@@ -2,20 +2,21 @@ package config
import (
"encoding/json"
"fmt"
"net"
"os"
"path/filepath"
"runtime"
"strconv"
)
// Config 存储程序的各类配置常量,支持通过读取 json 文件动态修改
type Config struct {
TargetIP string `json:"TargetIP"` // 检测目标 IP默认 192.168.3.3
MonitorWindowSeconds int `json:"MonitorWindowSeconds"` // 监控窗口期内持续失败才触发关机
ShutdownCountdown int `json:"ShutdownCountdown"` // 关机倒计时时间
NormalPingInterval int `json:"NormalPingInterval"` // 正常状态下的 ping 间隔
TargetIP string `json:"TargetIP"`
MonitorWindowSeconds int `json:"MonitorWindowSeconds"`
ShutdownCountdown int `json:"ShutdownCountdown"`
NormalPingInterval int `json:"NormalPingInterval"`
}
// DefaultConfig 提供一套开箱即用的默认配置
func DefaultConfig() *Config {
return &Config{
TargetIP: "192.168.3.3",
@@ -25,7 +26,6 @@ func DefaultConfig() *Config {
}
}
// GetConfigDir 根据当前操作系统返回标准的配置存放路径
func GetConfigDir() string {
if runtime.GOOS == "windows" {
return `C:\ProgramData\SmartNetworkMonitor`
@@ -33,7 +33,6 @@ func GetConfigDir() string {
return `/etc/smart-network-monitor`
}
// GetLogDir 根据当前操作系统返回标准的日志存放路径
func GetLogDir() string {
if runtime.GOOS == "windows" {
return `C:\ProgramData\SmartNetworkMonitor\logs`
@@ -41,7 +40,6 @@ func GetLogDir() string {
return `/var/log/smart-network-monitor`
}
// LoadConfig 从系统标准路径读取 config.json 并反序列化。如果不存在,则返回默认配置。
func LoadConfig() (*Config, error) {
configDir := GetConfigDir()
configPath := filepath.Join(configDir, "config.json")
@@ -49,7 +47,6 @@ func LoadConfig() (*Config, error) {
data, err := os.ReadFile(configPath)
if err != nil {
if os.IsNotExist(err) {
// 文件不存在时,静默采用默认配置
return DefaultConfig(), nil
}
return nil, err
@@ -65,3 +62,54 @@ func LoadConfig() (*Config, error) {
}
return cfg, nil
}
func SaveConfig(cfg *Config) error {
configDir := GetConfigDir()
if err := os.MkdirAll(configDir, 0755); err != nil {
return err
}
configPath := filepath.Join(configDir, "config.json")
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return err
}
return os.WriteFile(configPath, data, 0644)
}
func UpdateConfig(key string, value string) error {
cfg, err := LoadConfig()
if err != nil {
return fmt.Errorf("加载配置文件失败: %v", err)
}
switch key {
case "TargetIP":
if net.ParseIP(value) == nil {
return fmt.Errorf("非法 IP 地址: %s", value)
}
cfg.TargetIP = value
case "MonitorWindowSeconds":
val, err := strconv.Atoi(value)
if err != nil || val <= 0 {
return fmt.Errorf("非法输入: MonitorWindowSeconds 必须为正整数")
}
cfg.MonitorWindowSeconds = val
case "ShutdownCountdown":
val, err := strconv.Atoi(value)
if err != nil || val <= 0 {
return fmt.Errorf("非法输入: ShutdownCountdown 必须为正整数")
}
cfg.ShutdownCountdown = val
case "NormalPingInterval":
val, err := strconv.Atoi(value)
if err != nil || val <= 0 {
return fmt.Errorf("非法输入: NormalPingInterval 必须为正整数")
}
cfg.NormalPingInterval = val
default:
return fmt.Errorf("未知配置项: %s (开放编辑项: TargetIP, MonitorWindowSeconds, ShutdownCountdown, NormalPingInterval)", key)
}
return SaveConfig(cfg)
}

View File

@@ -3,10 +3,11 @@ package daemon
import (
"context"
"github.com/kardianos/service"
"smart-shutdown/pkg/config"
"smart-shutdown/pkg/logger"
"smart-shutdown/pkg/monitor"
"github.com/kardianos/service"
)
type program struct {
@@ -16,8 +17,7 @@ type program struct {
}
func (p *program) Start(s service.Service) error {
// Start should not block. Do the actual work async.
logger.Info("准备在后台启动服务监控...")
logger.Info("启动系统监控后台服务")
ctx, cancel := context.WithCancel(context.Background())
p.cancel = cancel
@@ -28,14 +28,12 @@ func (p *program) Start(s service.Service) error {
}
func (p *program) run(ctx context.Context) {
// 挂载执行核心循环逻辑
monitor.Run(ctx, p.cfg)
close(p.exit)
}
func (p *program) Stop(s service.Service) error {
// Stop should not block. Return within a few seconds.
logger.Info("正在平滑停止后台服务监控...")
logger.Info("停止系统监控后台服务")
if p.cancel != nil {
p.cancel()
}
@@ -43,12 +41,11 @@ func (p *program) Stop(s service.Service) error {
return nil
}
// GetService 构建 service 实例供 CLI 控制(安装,启动,停止,卸载)和前台直接 Run。
func GetService(cfg *config.Config) (service.Service, error) {
svcConfig := &service.Config{
Name: "SmartNetworkMonitor",
DisplayName: "Smart Network Shutdown Monitor",
Description: "A daemon that pings target IP periodically and shuts down the computer if disconnected for too long.",
Description: "A reliable daemon that periodically monitors network states and triggers node suspension logically.",
}
prg := &program{

View File

@@ -1,70 +1,89 @@
package logger
import (
"io"
"log"
"fmt"
"os"
"path/filepath"
"time"
"github.com/fatih/color"
"gopkg.in/natefinch/lumberjack.v2"
"smart-shutdown/pkg/config"
)
var (
infoLogger *log.Logger
succLogger *log.Logger
warnLogger *log.Logger
failLogger *log.Logger
critLogger *log.Logger
)
var fileLogger *lumberjack.Logger
// InitLogger 初始化全局日志系统,将日志同时输出到终端色彩日志和滚动文件
func InitLogger() error {
logDir := config.GetLogDir()
if err := os.MkdirAll(logDir, 0755); err != nil {
// 降级为当前目录,适用于未具有系统写权限的 Local 执行
logDir = "logs"
os.MkdirAll(logDir, 0755)
}
logFile := filepath.Join(logDir, "network_monitor.log")
// 使用 lumberjack 实现每天轮转和最大保留 30 天功能
ljLogger := &lumberjack.Logger{
fileLogger = &lumberjack.Logger{
Filename: logFile,
MaxSize: 10, // 每个切片最大兆字节 (MB)
MaxBackups: 30, // 保留最近 30 个切片
MaxAge: 30, // 保留最近 30 天的文件
Compress: false, // 是否压缩
MaxSize: 10,
MaxBackups: 30,
MaxAge: 30,
Compress: false,
}
// 各级别终端色彩
cSucc := color.New(color.FgGreen).SprintFunc()
cFail := color.New(color.FgRed).SprintFunc()
cWarn := color.New(color.FgYellow).SprintFunc()
cCrit := color.New(color.FgHiRed, color.Bold).SprintFunc()
// 构建复合输出写入器:终端标准输出 + 日志文件
outGeneral := io.MultiWriter(os.Stdout, ljLogger)
outError := io.MultiWriter(os.Stderr, ljLogger)
// 时间戳格式前缀
flags := log.Ldate | log.Ltime
// 实例化各个级别 Logger
infoLogger = log.New(outGeneral, "[INFO] ", flags)
succLogger = log.New(outGeneral, cSucc("[SUCCESS] "), flags)
warnLogger = log.New(outGeneral, cWarn("[WARN] "), flags)
failLogger = log.New(outError, cFail("[FAIL] "), flags)
critLogger = log.New(outError, cCrit("[CRITICAL] "), flags)
return nil
}
// 封装导出供业务调用的函数
func Info(format string, v ...interface{}) { infoLogger.Printf(format, v...) }
func Succ(format string, v ...interface{}) { succLogger.Printf(format, v...) }
func Warn(format string, v ...interface{}) { warnLogger.Printf(format, v...) }
func Fail(format string, v ...interface{}) { failLogger.Printf(format, v...) }
func Crit(format string, v ...interface{}) { critLogger.Printf(format, v...) }
func writeLog(level, plainPrefix, format string, v ...interface{}) {
msg := fmt.Sprintf(format, v...)
timestamp := time.Now().Format("2006/01/02 15:04:05")
if fileLogger != nil {
plainLine := fmt.Sprintf("%s %s %s\n", plainPrefix, timestamp, msg)
fileLogger.Write([]byte(plainLine))
}
prefixColorFunc := getPrefixColor(level)
coloredPrefix := prefixColorFunc(plainPrefix)
coloredLine := fmt.Sprintf("%s %s %s\n", coloredPrefix, timestamp, msg)
if level == "FAIL" || level == "CRITICAL" {
fmt.Fprint(os.Stderr, coloredLine)
} else {
fmt.Fprint(os.Stdout, coloredLine)
}
}
func getPrefixColor(level string) func(a ...interface{}) string {
switch level {
case "SUCCESS":
return color.New(color.FgGreen).SprintFunc()
case "WARN":
return color.New(color.FgYellow).SprintFunc()
case "FAIL":
return color.New(color.FgRed).SprintFunc()
case "CRITICAL":
return color.New(color.FgHiRed).SprintFunc()
default:
return color.New(color.Reset).SprintFunc()
}
}
func Info(format string, v ...interface{}) {
writeLog("INFO", "[INFO]", format, v...)
}
func Succ(format string, v ...interface{}) {
writeLog("SUCCESS", "[SUCCESS]", format, v...)
}
func Warn(format string, v ...interface{}) {
writeLog("WARN", "[WARN]", format, v...)
}
func Fail(format string, v ...interface{}) {
writeLog("FAIL", "[FAIL]", format, v...)
}
func Crit(format string, v ...interface{}) {
writeLog("CRITICAL", "[CRITICAL]", format, v...)
}

View File

@@ -10,113 +10,96 @@ import (
"smart-shutdown/pkg/system"
)
// Run 开启核心状态机循环监控,接收 ctx 以便主程序或者服务管理器控制安全退出
func Run(ctx context.Context, cfg *config.Config) {
logger.Info("========== 智能关机监控已启动 ==========")
logger.Info("监控目标: %s", cfg.TargetIP)
logger.Info("监控窗口: %d秒", cfg.MonitorWindowSeconds)
logger.Info("关机倒计时: %d秒", cfg.ShutdownCountdown)
logger.Info("启动后台网络监控")
logger.Info("监控目标 IP: %s", cfg.TargetIP)
logger.Info("故障判定窗口: %d秒", cfg.MonitorWindowSeconds)
logger.Info("关机倒计时阈值: %d秒", cfg.ShutdownCountdown)
var failureStartTime time.Time
var inFailureWindow bool = false
normalStatusCounter := 0
// 每 24 次正常检测输出一次日志(约 6 分钟)
const normalStatusLogInterval = 24
for {
select {
case <-ctx.Done():
logger.Info("========== 收到停止信号,退出监控 ==========")
logger.Info("接收到系统停止指令, 退出核心调度监控")
return
default:
}
isOnline := pinger.Ping(cfg.TargetIP, 3) // 3 秒超时探测一次
isOnline := pinger.Ping(cfg.TargetIP, 3)
if isOnline {
if inFailureWindow {
logger.Succ("网络连接已恢复!重置断网计时器。")
logger.Succ("网络连通性恢复, 监控窗口重置")
inFailureWindow = false
normalStatusCounter = 0
} else {
normalStatusCounter++
if normalStatusCounter >= normalStatusLogInterval {
logger.Info("网络连接持续正常(已检测 %d 次)", normalStatusCounter)
logger.Info("网络探测状态正常 (定期状态更新)")
normalStatusCounter = 0
}
}
// 正常状态下休眠等待下一次探测
time.Sleep(time.Duration(cfg.NormalPingInterval) * time.Second)
} else {
logger.Fail("Ping 测试失败 - 目标: %s", cfg.TargetIP)
logger.Warn("目标节点未响应探针包: %s", cfg.TargetIP)
if !inFailureWindow {
// 首次断网
failureStartTime = time.Now()
inFailureWindow = true
logger.Warn("网络首次中断,开始进入监控窗口计时。")
logger.Warn("触发网络状态失效, 开始累计中断时间监控")
}
// 计算当前断网累计时间
failureDuration := time.Since(failureStartTime).Seconds()
if int(failureDuration) >= cfg.MonitorWindowSeconds {
logger.Crit("网络持续中断已超过 %d 秒,触发关机流程", cfg.MonitorWindowSeconds)
logger.Crit("持续中断时长超越设定的阈值上限 (%d秒), 进入关机响应流程", cfg.MonitorWindowSeconds)
// 进入倒计时阶段,倒计时期间高频检测(阻塞调用)
shutdownCancelled := startCountdown(ctx, cfg.TargetIP, cfg.ShutdownCountdown)
if !shutdownCancelled {
logger.Crit("倒计时结束,执行最终关机!")
logger.Crit("倒计时时限完毕, 开始下发系统级级终态关机指令")
system.ExecuteShutdown()
// 关机指令发出,理论上系统即将终止进程,为了优雅,此处也退出
return
} else {
// 倒计时中途网络恢复被取消了,重置状态重新进入监控环节
inFailureWindow = false
}
} else {
// 还在容忍窗口期内,仅仅打印日志并休眠
logger.Info("网络已持续中断 %.0f / %d 秒,继续监控...", failureDuration, cfg.MonitorWindowSeconds)
logger.Info("网络持续断失: %.0f/%d 秒", failureDuration, cfg.MonitorWindowSeconds)
time.Sleep(time.Duration(cfg.NormalPingInterval) * time.Second)
}
}
}
}
// startCountdown 执行关机前倒计时的轮询,如果中途恢复网络返回 true (代表取消关机)。
// 返回 false 代表断网到底,坚决关机。
func startCountdown(ctx context.Context, targetIP string, countdownSec int) bool {
logger.Warn("============================================")
logger.Warn("准备关机!倒计时 %d 秒期间将快速探测网络...", countdownSec)
logger.Warn("============================================")
logger.Warn("执行系统关机倒计时流程 (预设: %d秒)...", countdownSec)
endTime := time.Now().Add(time.Duration(countdownSec) * time.Second)
// 倒计时期间提高探测频率:每 3 秒一次
ticker := time.NewTicker(3 * time.Second)
defer ticker.Stop()
for {
remaining := int(time.Until(endTime).Seconds())
if remaining <= 0 {
return false // 时间到,确认无法恢复
return false
}
logger.Warn("距离关机还有 %d 秒... 正在高频检测网络", remaining)
logger.Warn("距离关机指令处分剩余: %d 秒", remaining)
select {
case <-ctx.Done():
logger.Info("收到退出系统服务信号,中止倒计时关机!")
logger.Info("挂起关机倒计流程:收到系统强制干预信号")
return true
case <-ticker.C:
// 高频重试检测网络
if pinger.Ping(targetIP, 3) {
logger.Succ("============================================")
logger.Succ("网络在倒计时期间奇迹般地恢复了!立刻取消关机!")
logger.Succ("============================================")
logger.Succ("倒计时周期内网络指标恢复, 全局关机流程已被重置并取消")
return true
}
}

View File

@@ -7,35 +7,28 @@ import (
"smart-shutdown/pkg/logger"
)
// Ping 连通性测试,向指定的 targetIP 发送 ICMP 请求。
// 如果规定时间内可达返回 true超时或者全部丢包返回 false。
func Ping(targetIP string, timeoutSec int) bool {
pinger, err := probing.NewPinger(targetIP)
if err != nil {
logger.Warn("解析目标 IP 失败 [%s]: %v", targetIP, err)
logger.Warn("目标 IP 解析异常 [%s]: %v", targetIP, err)
return false
}
// 大部分操作系统为了安全,普通程序发 ICMP 包会被拦截。开启 Privileged 会尝试使用 raw sockets 发行
// 本程序由于本身作为系统服务System 或 Root 权限)运行,故直接开启特权模式以保障发包成功率。
pinger.SetPrivileged(true)
// 只发送 1 个探测包
pinger.Count = 1
pinger.Timeout = time.Duration(timeoutSec) * time.Second
err = pinger.Run()
if err != nil {
logger.Fail("Ping 测试运行时遇到异常: %v", err)
logger.Fail("探针执行失败: %v", err)
return false
}
stats := pinger.Statistics()
// 只要成功收到至少 1 个回包,认为网络通畅
if stats.PacketsRecv > 0 {
return true
}
// 丢包或者无回音
return false
}

View File

@@ -6,11 +6,9 @@ import (
"smart-shutdown/pkg/logger"
)
// ExecuteShutdown 跨平台执行立即无条件关机
func ExecuteShutdown() error {
logger.Crit("执行系统关机操作!")
logger.Crit("系统调用:执行关机指令")
// 注意:在实际正式环境使用前,如果想防误触可暂时注释掉下方的 `cmd.Run()`
var cmd *exec.Cmd
if runtime.GOOS == "windows" {
cmd = exec.Command("shutdown", "-s", "-f", "-t", "0")