feat(migration): upgrade legacy ps1 scripts to single-binary Golang platform
This commit is contained in:
67
pkg/config/config.go
Normal file
67
pkg/config/config.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
// 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 间隔
|
||||
}
|
||||
|
||||
// DefaultConfig 提供一套开箱即用的默认配置
|
||||
func DefaultConfig() *Config {
|
||||
return &Config{
|
||||
TargetIP: "192.168.3.3",
|
||||
MonitorWindowSeconds: 180,
|
||||
ShutdownCountdown: 60,
|
||||
NormalPingInterval: 15,
|
||||
}
|
||||
}
|
||||
|
||||
// GetConfigDir 根据当前操作系统返回标准的配置存放路径
|
||||
func GetConfigDir() string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return `C:\ProgramData\SmartNetworkMonitor`
|
||||
}
|
||||
return `/etc/smart-network-monitor`
|
||||
}
|
||||
|
||||
// GetLogDir 根据当前操作系统返回标准的日志存放路径
|
||||
func GetLogDir() string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return `C:\ProgramData\SmartNetworkMonitor\logs`
|
||||
}
|
||||
return `/var/log/smart-network-monitor`
|
||||
}
|
||||
|
||||
// LoadConfig 从系统标准路径读取 config.json 并反序列化。如果不存在,则返回默认配置。
|
||||
func LoadConfig() (*Config, error) {
|
||||
configDir := GetConfigDir()
|
||||
configPath := filepath.Join(configDir, "config.json")
|
||||
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
// 文件不存在时,静默采用默认配置
|
||||
return DefaultConfig(), nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(data) >= 3 && data[0] == 0xef && data[1] == 0xbb && data[2] == 0xbf {
|
||||
data = data[3:]
|
||||
}
|
||||
|
||||
cfg := &Config{}
|
||||
if err := json.Unmarshal(data, cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
63
pkg/daemon/service.go
Normal file
63
pkg/daemon/service.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/kardianos/service"
|
||||
"smart-shutdown/pkg/config"
|
||||
"smart-shutdown/pkg/logger"
|
||||
"smart-shutdown/pkg/monitor"
|
||||
)
|
||||
|
||||
type program struct {
|
||||
exit chan struct{}
|
||||
cancel context.CancelFunc
|
||||
cfg *config.Config
|
||||
}
|
||||
|
||||
func (p *program) Start(s service.Service) error {
|
||||
// Start should not block. Do the actual work async.
|
||||
logger.Info("准备在后台启动服务监控...")
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
p.cancel = cancel
|
||||
p.exit = make(chan struct{})
|
||||
|
||||
go p.run(ctx)
|
||||
return nil
|
||||
}
|
||||
|
||||
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("正在平滑停止后台服务监控...")
|
||||
if p.cancel != nil {
|
||||
p.cancel()
|
||||
}
|
||||
<-p.exit
|
||||
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.",
|
||||
}
|
||||
|
||||
prg := &program{
|
||||
cfg: cfg,
|
||||
}
|
||||
|
||||
s, err := service.New(prg, svcConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
70
pkg/logger/logger.go
Normal file
70
pkg/logger/logger.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"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
|
||||
)
|
||||
|
||||
// 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{
|
||||
Filename: logFile,
|
||||
MaxSize: 10, // 每个切片最大兆字节 (MB)
|
||||
MaxBackups: 30, // 保留最近 30 个切片
|
||||
MaxAge: 30, // 保留最近 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...) }
|
||||
124
pkg/monitor/statemachine.go
Normal file
124
pkg/monitor/statemachine.go
Normal file
@@ -0,0 +1,124 @@
|
||||
package monitor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"smart-shutdown/pkg/config"
|
||||
"smart-shutdown/pkg/logger"
|
||||
"smart-shutdown/pkg/pinger"
|
||||
"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)
|
||||
|
||||
var failureStartTime time.Time
|
||||
var inFailureWindow bool = false
|
||||
|
||||
normalStatusCounter := 0
|
||||
// 每 24 次正常检测输出一次日志(约 6 分钟)
|
||||
const normalStatusLogInterval = 24
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
logger.Info("========== 收到停止信号,退出监控 ==========")
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
isOnline := pinger.Ping(cfg.TargetIP, 3) // 3 秒超时探测一次
|
||||
|
||||
if isOnline {
|
||||
if inFailureWindow {
|
||||
logger.Succ("网络连接已恢复!重置断网计时器。")
|
||||
inFailureWindow = false
|
||||
normalStatusCounter = 0
|
||||
} else {
|
||||
normalStatusCounter++
|
||||
if normalStatusCounter >= normalStatusLogInterval {
|
||||
logger.Info("网络连接持续正常(已检测 %d 次)", normalStatusCounter)
|
||||
normalStatusCounter = 0
|
||||
}
|
||||
}
|
||||
|
||||
// 正常状态下休眠等待下一次探测
|
||||
time.Sleep(time.Duration(cfg.NormalPingInterval) * time.Second)
|
||||
|
||||
} else {
|
||||
logger.Fail("Ping 测试失败 - 目标: %s", cfg.TargetIP)
|
||||
|
||||
if !inFailureWindow {
|
||||
// 首次断网
|
||||
failureStartTime = time.Now()
|
||||
inFailureWindow = true
|
||||
logger.Warn("网络首次中断,开始进入监控窗口计时。")
|
||||
}
|
||||
|
||||
// 计算当前断网累计时间
|
||||
failureDuration := time.Since(failureStartTime).Seconds()
|
||||
|
||||
if int(failureDuration) >= cfg.MonitorWindowSeconds {
|
||||
logger.Crit("网络持续中断已超过 %d 秒,触发关机流程!", cfg.MonitorWindowSeconds)
|
||||
|
||||
// 进入倒计时阶段,倒计时期间高频检测(阻塞调用)
|
||||
shutdownCancelled := startCountdown(ctx, cfg.TargetIP, cfg.ShutdownCountdown)
|
||||
|
||||
if !shutdownCancelled {
|
||||
logger.Crit("倒计时结束,执行最终关机!")
|
||||
system.ExecuteShutdown()
|
||||
// 关机指令发出,理论上系统即将终止进程,为了优雅,此处也退出
|
||||
return
|
||||
} else {
|
||||
// 倒计时中途网络恢复被取消了,重置状态重新进入监控环节
|
||||
inFailureWindow = false
|
||||
}
|
||||
} else {
|
||||
// 还在容忍窗口期内,仅仅打印日志并休眠
|
||||
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("============================================")
|
||||
|
||||
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 // 时间到,确认无法恢复
|
||||
}
|
||||
|
||||
logger.Warn("距离关机还有 %d 秒... 正在高频检测网络", remaining)
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
logger.Info("收到退出系统服务信号,中止倒计时关机!")
|
||||
return true
|
||||
case <-ticker.C:
|
||||
// 高频重试检测网络
|
||||
if pinger.Ping(targetIP, 3) {
|
||||
logger.Succ("============================================")
|
||||
logger.Succ("网络在倒计时期间奇迹般地恢复了!立刻取消关机!")
|
||||
logger.Succ("============================================")
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
41
pkg/pinger/pinger.go
Normal file
41
pkg/pinger/pinger.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package pinger
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
probing "github.com/prometheus-community/pro-bing"
|
||||
"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)
|
||||
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)
|
||||
return false
|
||||
}
|
||||
|
||||
stats := pinger.Statistics()
|
||||
// 只要成功收到至少 1 个回包,认为网络通畅
|
||||
if stats.PacketsRecv > 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
// 丢包或者无回音
|
||||
return false
|
||||
}
|
||||
28
pkg/system/shutdown.go
Normal file
28
pkg/system/shutdown.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package system
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"smart-shutdown/pkg/logger"
|
||||
)
|
||||
|
||||
// ExecuteShutdown 跨平台执行立即无条件关机
|
||||
func ExecuteShutdown() error {
|
||||
logger.Crit("执行系统关机操作!")
|
||||
|
||||
// 注意:在实际正式环境使用前,如果想防误触可暂时注释掉下方的 `cmd.Run()`
|
||||
var cmd *exec.Cmd
|
||||
if runtime.GOOS == "windows" {
|
||||
cmd = exec.Command("shutdown", "-s", "-f", "-t", "0")
|
||||
} else {
|
||||
cmd = exec.Command("shutdown", "-h", "now")
|
||||
}
|
||||
|
||||
err := cmd.Run()
|
||||
if err != nil {
|
||||
logger.Crit("系统关机指令执行失败: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user