4 Commits

Author SHA1 Message Date
35bf24f0f3 feat(cli): add -v debug flags to trace file writes and env path creations during deployment 2026-03-24 17:16:58 +08:00
b1c7b9f8f7 feat: implement smart self-updater with robust windows file-lock bypasses and semantic version parsing 2026-03-24 17:09:28 +08:00
4e2aba5560 fix(ci): restrict gitignore to root binary files to avoid ignoring source subdirectories
Some checks failed
Release / Build & Release (push) Has been cancelled
2026-03-24 16:19:08 +08:00
3be3e19e49 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.
2026-03-24 16:07:22 +08:00
14 changed files with 760 additions and 240 deletions

View File

@@ -1,10 +1,9 @@
name: Release name: Release
on: on:
push: push:
tags: tags:
- 'v*' # 当推送标签形如 v1.0.0 时触发自动构建 - 'v*' # 褰撴帹閫佹爣绛惧舰濡?v1.0.0 鏃惰Е鍙戣嚜鍔ㄦ瀯寤?
permissions: permissions:
contents: write contents: write
@@ -31,16 +30,16 @@ jobs:
mkdir -p build_output mkdir -p build_output
echo "Building Windows amd64..." echo "Building Windows amd64..."
GOOS=windows GOARCH=amd64 go build -ldflags="-s -w" -o build_output/smart-monitor_windows_amd64.exe ./cmd/smart-monitor GOOS=windows GOARCH=amd64 go build -ldflags="-X 'main.AppVersion=${{ github.ref_name }}' -s -w" -o build_output/smart-shutdown_windows_amd64.exe ./cmd/smart-shutdown
echo "Building Windows arm64..." echo "Building Windows arm64..."
GOOS=windows GOARCH=arm64 go build -ldflags="-s -w" -o build_output/smart-monitor_windows_arm64.exe ./cmd/smart-monitor GOOS=windows GOARCH=arm64 go build -ldflags="-X 'main.AppVersion=${{ github.ref_name }}' -s -w" -o build_output/smart-shutdown_windows_arm64.exe ./cmd/smart-shutdown
echo "Building Linux amd64..." echo "Building Linux amd64..."
GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o build_output/smart-monitor_linux_amd64 ./cmd/smart-monitor GOOS=linux GOARCH=amd64 go build -ldflags="-X 'main.AppVersion=${{ github.ref_name }}' -s -w" -o build_output/smart-shutdown_linux_amd64 ./cmd/smart-shutdown
echo "Building Linux arm64..." echo "Building Linux arm64..."
GOOS=linux GOARCH=arm64 go build -ldflags="-s -w" -o build_output/smart-monitor_linux_arm64 ./cmd/smart-monitor GOOS=linux GOARCH=arm64 go build -ldflags="-X 'main.AppVersion=${{ github.ref_name }}' -s -w" -o build_output/smart-shutdown_linux_arm64 ./cmd/smart-shutdown
- name: Publish GitHub Release - name: Publish GitHub Release
uses: softprops/action-gh-release@v1 uses: softprops/action-gh-release@v1
@@ -51,3 +50,4 @@ jobs:
generate_release_notes: true generate_release_notes: true
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

4
.gitignore vendored
View File

@@ -11,8 +11,8 @@
# Output directory for cross-platform builds # Output directory for cross-platform builds
/bin/ /bin/
/build/ /build/
smart-monitor /smart-shutdown
smart-monitor.exe /smart-shutdown.exe
# Output of the go coverage tool # Output of the go coverage tool
*.out *.out

91
README.md Normal file
View File

@@ -0,0 +1,91 @@
# Smart Network Shutdown Monitor
持续监测目标网络连通性并在网络断开超时后自动关闭系统的跨平台常驻服务程序。
## 安装指南 (Installation)
本程序支持安装为操作系统的后台服务,并自动注册全局环境变量以便统一管理。
### Windows 系统
推荐以拥有管理员权限的系统服务形式部署。
1. **获取程序**
前往 [Releases](https://github.com/UnbalancedCat/smart-shutdown/releases) 页面,下载最新的 `smart-shutdown_windows_amd64.exe` (或其它架构版本)。
2. **终端安装**
在下载目录的空白处右键选择 **以管理员身份打开 PowerShell 或 CMD**,执行如下指令:
```powershell
.\smart-shutdown_windows_amd64.exe install
smart-shutdown start
```
> **说明:** `install` 指令会自动将程序复制到 `C:\Program Files\SmartShutdown\` 并写入系统 `PATH` 环境变量中,同时注册为 Windows 开机自启服务。此后您可在任意终端直接使用 `smart-shutdown` 命令。
### Linux (Ubuntu/Debian/CentOS 等)
如您具备 `sudo` 权限,可在终端中执行以下单行命令进行自动化下载与 `systemd` 服务部署:
```bash
sudo curl -sSL https://github.com/UnbalancedCat/smart-shutdown/releases/latest/download/smart-shutdown_linux_amd64 -o /usr/local/bin/smart-shutdown && \
sudo chmod +x /usr/local/bin/smart-shutdown && \
sudo smart-shutdown install && \
sudo smart-shutdown start
```
## CLI 终端管理指令 (Commands)
完成服务注册后,可通过以下指令直接管理守护进程状态流。
*(注: 启停服务及修改配置的指令需在 **管理员级别 / root 权限** 终端内执行)*
### 运行状态查询
输出当前服务存活状态、载入的配置参数、日志存放路径及最近 10 条日志:
```bash
smart-shutdown status
```
### 修改系统配置
直接修改配置参数并执行合法性校验。(注: 改写配置后必须执行 `smart-shutdown restart` 重启服务才能应用生效)
```bash
# 修改目标监控 IP 地址
smart-shutdown config set TargetIP 192.168.0.1
# 修改断网响应的容忍监控窗口
smart-shutdown config set MonitorWindowSeconds 300
```
### 服务生命周期控制
```bash
# 停止当前运行的后台网络探测服务
smart-shutdown stop
# 启动后台服务并恢复网络探测
smart-shutdown start
# 重启后台服务
smart-shutdown restart
```
### 服务与环境卸载
停止并注销后台服务,清理系统目录中的程序副本与对应的系统环境变量:
```bash
smart-shutdown uninstall
```
*(如需查看完整的帮助说明,可执行 `smart-shutdown help`)*
## 默认配置参数详情
程序会在对应操作系统的后台数据存放区建立或读取 JSON 配置文件:
- Windows: `C:\ProgramData\SmartNetworkMonitor\config.json`
- Linux: `/etc/smart-network-monitor/config.json`
| 参数项 | 含义与功能说明 | 默认值 |
|:---|:---|:---|
| `TargetIP` | 需发送 ICMP 包验证联通性的目标 IPv4 地址。 | `192.168.3.3` |
| `MonitorWindowSeconds` | 网络中断被判定为异常并触发系统关机前,所能容忍的最长超时时长 (秒)。 | `180` |
| `ShutdownCountdown` | 容忍超限后,执行正式关机系统指令的警告倒计时缓冲时间 (秒)。 | `60` |
| `NormalPingInterval` | 网络连通性正常时,每次静默发包探测的间隔时间 (秒)。 | `15` |
## 本地日志存放位置
程序按日切割保存网络探测及中断记录,默认留存最近 30 天的文件:
- **Windows**: `C:\ProgramData\SmartNetworkMonitor\logs\network_monitor.log`
- **Linux**: `/var/log/smart-network-monitor/network_monitor.log`

View File

@@ -1,123 +0,0 @@
package main
import (
"fmt"
"os"
"github.com/kardianos/service"
"github.com/spf13/cobra"
"smart-shutdown/pkg/config"
"smart-shutdown/pkg/daemon"
"smart-shutdown/pkg/logger"
)
func main() {
// 初始化全局系统日志
if err := logger.InitLogger(); err != nil {
fmt.Printf("无法初始化日志系统: %v\n", err)
os.Exit(1)
}
// 初始化配置加载
cfg, err := config.LoadConfig()
if err != nil {
logger.Crit("读取配置失败: %v", err)
os.Exit(1)
}
// 注册为跨平台系统服务
svc, err := daemon.GetService(cfg)
if err != nil {
logger.Crit("构建服务对象失败: %v", err)
os.Exit(1)
}
// 定义根命令,无后续子命令会直接触发
var rootCommand = &cobra.Command{
Use: "smart-monitor",
Short: "智能网络监控自动关机",
Run: func(cmd *cobra.Command, args []string) {
// 如果没有输入子命令,尝试作为服务直接前台或者后台跑起来
// 这个分支也是系统启动服务systemd/services.msc自动拉起进程时的必然入口
err := svc.Run()
if err != nil {
logger.Fail("运行抛出异常: %v", err)
}
},
}
// 系统服务管理一键控制逻辑,使用 kardianos/service 提供的内置 Control方法
cmds := []*cobra.Command{
{
Use: "install",
Short: "将本程序装载并注册为系统常驻服务 (例如 systemd / windows registry)",
Run: func(cmd *cobra.Command, args []string) {
handleServiceControl(svc, "install")
},
},
{
Use: "uninstall",
Short: "从系统中彻底卸载此后台监控服务",
Run: func(cmd *cobra.Command, args []string) {
handleServiceControl(svc, "uninstall")
},
},
{
Use: "start",
Short: "令已注册的系统服务开始运行",
Run: func(cmd *cobra.Command, args []string) {
handleServiceControl(svc, "start")
},
},
{
Use: "stop",
Short: "停止系统服务",
Run: func(cmd *cobra.Command, args []string) {
handleServiceControl(svc, "stop")
},
},
{
Use: "restart",
Short: "重启系统服务",
Run: func(cmd *cobra.Command, args []string) {
handleServiceControl(svc, "restart")
},
},
{
Use: "status",
Short: "查询服务存活进程状态",
Run: func(cmd *cobra.Command, args []string) {
status, err := svc.Status()
if err != nil {
fmt.Printf("❌ 获取服务状态失败: %v\n", err)
return
}
switch status {
case service.StatusRunning:
fmt.Println("✅ 服务存活 [运行中]")
case service.StatusStopped:
fmt.Println("💤 服务处于 [已停止] 状态")
default:
fmt.Println("❓ 服务尚未在本机注册或者状态未知。请确认你是否执行过 smart-monitor install")
}
},
},
}
for _, c := range cmds {
rootCommand.AddCommand(c)
}
if err := rootCommand.Execute(); err != nil {
os.Exit(1)
}
}
func handleServiceControl(s service.Service, action string) {
err := service.Control(s, action)
if err != nil {
logger.Crit("执行动作 [%s] 遭到拒绝或失败: %v", action, err)
return
}
logger.Succ("成功对系统后台服务执行 [%s] 指令!", action)
}

349
cmd/smart-shutdown/main.go Normal file
View File

@@ -0,0 +1,349 @@
package main
import (
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"github.com/kardianos/service"
"github.com/spf13/cobra"
"smart-shutdown/pkg/config"
"smart-shutdown/pkg/daemon"
"smart-shutdown/pkg/logger"
"smart-shutdown/pkg/updater"
)
var AppVersion = "dev"
var verbose bool
func main() {
if err := logger.InitLogger(); err != nil {
fmt.Printf("无法初始化日志系统: %v\n", err)
os.Exit(1)
}
cfg, err := config.LoadConfig()
if err != nil {
logger.Crit("读取配置文件失败: %v", err)
os.Exit(1)
}
svc, err := daemon.GetService(cfg)
if err != nil {
logger.Crit("构建后台服务实例失败: %v", err)
os.Exit(1)
}
var showVersion bool
var rootCommand = &cobra.Command{
Use: "smart-shutdown",
Short: "智能网络状态检测与自动关机后台服务",
PersistentPreRun: func(cmd *cobra.Command, args []string) {
if verbose {
logger.EnableDebug()
}
},
Run: func(cmd *cobra.Command, args []string) {
if showVersion {
fmt.Printf("========== Smart Network Shutdown Monitor ==========\n")
fmt.Printf("执行架构体基准: %s/%s\n", runtime.GOOS, runtime.GOARCH)
updater.CheckAndPrintUpdate(AppVersion)
return
}
err := svc.Run()
if err != nil {
logger.Fail("后台监控流崩溃: %v", err)
}
},
}
rootCommand.CompletionOptions.DisableDefaultCmd = true
rootCommand.Flags().BoolVarP(&showVersion, "version", "V", false, "输出当前二进制内核版本并联网拉取发布树状态")
rootCommand.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "打印底层部署及环境追溯 Debug 信息")
cmds := []*cobra.Command{
{
Use: "install",
Short: "将执行文件拷贝至系统目录并全局注入环境变量体系",
Run: func(cmd *cobra.Command, args []string) {
targetDir, targetExe := getTargetSystemPath()
currentExe, _ := os.Executable()
if !strings.EqualFold(filepath.Clean(currentExe), filepath.Clean(targetExe)) {
logger.Debug("检测到跨域部署: 待本体克隆存放底层根目录: %s", targetExe)
if err := os.MkdirAll(targetDir, 0755); err != nil {
logger.Fail("创建系统目录核心区路径溃败 (请核查最高系统权限身份执行需求): %v", err)
return
}
if err := copyFile(currentExe, targetExe); err != nil {
logger.Fail("注入独立执行程序副本被拒: %v", err)
return
}
if runtime.GOOS != "windows" {
os.Chmod(targetExe, 0755)
} else {
envSetupCmd := fmt.Sprintf(`$p=[Environment]::GetEnvironmentVariable("Path","Machine");if(-not($p -split ';' -contains "%s")){[Environment]::SetEnvironmentVariable("Path",$p+";%s","Machine")}`, targetDir, targetDir)
err := exec.Command("powershell", "-Command", envSetupCmd).Run()
if err != nil {
logger.Warn("改写操作系统的环境变量集合遭受阻拦: %v", err)
} else {
logger.Debug("成功挂载底层环境变量: Windows Machine Path 追加了 %s 指引。", targetDir)
}
}
}
targetSvc, err := daemon.GetService(cfg, targetExe)
if err != nil {
logger.Crit("获取安装节点子域结构体构建回执失败: %v", err)
return
}
handleServiceControl(targetSvc, "install")
},
},
{
Use: "uninstall",
Short: "废除并移除在册的后台服务、扫除环境关联残留",
Run: func(cmd *cobra.Command, args []string) {
service.Control(svc, "stop")
handleServiceControl(svc, "uninstall")
targetDir, targetExe := getTargetSystemPath()
currentExe, _ := os.Executable()
if !strings.EqualFold(filepath.Clean(currentExe), filepath.Clean(targetExe)) {
if _, err := os.Stat(targetExe); err == nil {
logger.Debug("查明环境曾记录过全局部署逻辑,现已启动强力移除可执行源地址流: %s", targetExe)
os.Remove(targetExe)
if runtime.GOOS == "windows" {
os.Remove(targetDir)
envClearCmd := fmt.Sprintf(`$p=[Environment]::GetEnvironmentVariable("Path","Machine");$np=($p -split ';' | Where-Object {$_ -ne "%s" -and $_ -ne ""}) -join ';';[Environment]::SetEnvironmentVariable("Path",$np,"Machine")`, targetDir)
exec.Command("powershell", "-Command", envClearCmd).Run()
logger.Debug("环境变量系统清理Windows Global Path 中的指向挂载项业已剥除卸载完成。")
}
}
}
},
},
{
Use: "update",
Short: "获取并免干预热部署在线的最新系统构建程序",
Run: func(cmd *cobra.Command, args []string) {
fmt.Printf("当前内核架构版本: %s\n正在向全向节点请求效验最新发版记录...\n", AppVersion)
hasNew, latestVer, dlURL := updater.CheckForUpdate(AppVersion)
if !hasNew {
fmt.Println("\n[核验完毕] 您当前的程序正处于主线分支顶点,无需任何更新动作。")
return
}
fmt.Printf("\n[匹配成功] 查收最新稳定版释出包裹: %s\n", latestVer)
fmt.Println("预备接管环境树并构建热更新映射管道...")
status, _ := svc.Status()
isRunningService := (status == service.StatusRunning)
if isRunningService {
fmt.Println("[生命周期管控] 已探明应用正交由系统守护树作为后台运行,正在为其下放休眠截停指派以退换抢驻的内核独占锁...")
service.Control(svc, "stop")
}
fmt.Println("[数据流传输] 正在拉取远端预编译二进制核心封包...")
if err := updater.DownloadAndReplace(dlURL); err != nil {
fmt.Printf("[中断] 内核代码层执行热重载遭挫回滚: %v\n", err)
if isRunningService {
service.Control(svc, "start")
}
return
}
fmt.Println("[实体部署] 核心节点原子级替换覆写完成,系统块检验通过!")
if isRunningService {
fmt.Println("[后端苏醒] 重新挂载启动系统最高级权限守护网络接驳中心...")
service.Control(svc, "start")
fmt.Println("============ 热更新无感流闭环执行彻底成功! ============")
} else {
fmt.Println("============ 热更核心接替执行完毕!============\n(注: 您的当前终端并不作为真正的宿存执行体。\n如目前您另行开启了 CMD 窗格在死循环执行此包前台,请人工叉掉那个窗口使其重新载入新版本!)")
}
},
},
{
Use: "start",
Short: "唤起执行守护后台",
Run: func(cmd *cobra.Command, args []string) {
handleServiceControl(svc, "start")
},
},
{
Use: "stop",
Short: "停机系统守护状态流",
Run: func(cmd *cobra.Command, args []string) {
handleServiceControl(svc, "stop")
},
},
{
Use: "restart",
Short: "阻断并复用重新起跳服务控制主程",
Run: func(cmd *cobra.Command, args []string) {
handleServiceControl(svc, "restart")
},
},
{
Use: "status",
Short: "展示全向服务参数结构、后端存活性报告与探针汇集",
Run: func(cmd *cobra.Command, args []string) {
status, err := svc.Status()
fmt.Println("\n========== 运行状态 ==========")
if err != nil {
fmt.Printf("特权访问受抵 (查验核算需要系统特权根源准允): %v\n", err)
} else {
switch status {
case service.StatusRunning:
fmt.Println("全向系统基石服务: [运转值守中]")
case service.StatusStopped:
fmt.Println("全向系统基石服务: [已被静默挂起]")
default:
fmt.Println("全向系统基石服务: [尚未注册落位 / 孤儿状态]")
}
}
fmt.Println("\n========== 核心配置 ==========")
fmt.Printf("探测标的机器 IP : %s\n", cfg.TargetIP)
fmt.Printf("脱网迟滞容忍 (秒) : %d\n", cfg.MonitorWindowSeconds)
fmt.Printf("临危核准倒计 (秒) : %d\n", cfg.ShutdownCountdown)
fmt.Printf("静默发包跳频 (秒) : %d\n", cfg.NormalPingInterval)
logFilePath := filepath.Join(config.GetLogDir(), "network_monitor.log")
if fi, fileErr := os.Stat(logFilePath); fileErr == nil {
fmt.Println("\n========== 日志系统 ==========")
fmt.Printf("硬盘归档落点: %s\n", logFilePath)
fmt.Printf("现时容量尺寸: %.2f KB\n", float64(fi.Size())/1024)
}
printLastLogLines(10)
},
},
}
var configCmd = &cobra.Command{
Use: "config",
Short: "提供运行参数结构的修载校验准入系统",
}
var configSetCmd = &cobra.Command{
Use: "set [键] [值]",
Short: "安全的对 JSON 属性执行改写封装验证 (如 config set TargetIP 192.168.3.1)",
Args: cobra.ExactArgs(2),
Run: func(cmd *cobra.Command, args []string) {
key := args[0]
val := args[1]
err := config.UpdateConfig(key, val)
if err != nil {
fmt.Printf("基于硬编码的防呆数据验证发回抵拦截口指令: %v\n", err)
} else {
fmt.Printf("系统确认承接了新的设参: [%s] 指标结构被赋予了 [%s] 新制规格 \n(附留: 更易此配置文件必须人为发起 'smart-shutdown restart' 才能覆盖常驻内存的解析图谱。)\n", key, val)
}
},
}
configCmd.AddCommand(configSetCmd)
rootCommand.AddCommand(configCmd)
for _, c := range cmds {
rootCommand.AddCommand(c)
}
rootCommand.InitDefaultHelpCmd()
for _, cmd := range rootCommand.Commands() {
if cmd.Name() == "help" {
cmd.Short = "按需展示其它操作指令及其详情"
}
}
if err := rootCommand.Execute(); err != nil {
os.Exit(1)
}
}
func handleServiceControl(s service.Service, action string) {
err := service.Control(s, action)
if err != nil {
errStr := err.Error()
if strings.Contains(errStr, "Access is denied") || strings.Contains(errStr, "permission denied") || strings.Contains(errStr, "拒绝访问") {
logger.Fail("管控流程 [%s] 触发越级防卫!无核准身份特设记录。请开具含有全 Root 及高配权限窗格承接口径。", action)
return
}
logger.Crit("后台执行流程组块阻断报错 [%s] : %v", action, err)
return
}
logger.Succ("指令动作 [%s] 解析下发执行完毕,无阻断警告。", action)
}
func printLastLogLines(n int) {
logFilePath := filepath.Join(config.GetLogDir(), "network_monitor.log")
file, err := os.Open(logFilePath)
if err != nil {
fmt.Printf("\n(排障辅录: 获取探针断联记录池为空,全空栈态)\n")
return
}
defer file.Close()
data, err := io.ReadAll(file)
if err != nil {
return
}
lines := strings.Split(string(data), "\n")
validLines := make([]string, 0, len(lines))
for _, l := range lines {
if strings.TrimSpace(l) != "" {
validLines = append(validLines, l)
}
}
start := len(validLines) - n
if start < 0 {
start = 0
}
fmt.Printf("\n========== 最新沿线抓取探测轨迹尾部排 %d 行 ==========\n", n)
for i := start; i < len(validLines); i++ {
fmt.Println(validLines[i])
}
}
func getTargetSystemPath() (dir string, exe string) {
if runtime.GOOS == "windows" {
dir = filepath.Join(os.Getenv("ProgramFiles"), "SmartShutdown")
exe = filepath.Join(dir, "smart-shutdown.exe")
} else {
dir = "/usr/local/bin"
exe = filepath.Join(dir, "smart-shutdown")
}
return dir, exe
}
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.Create(dst)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, in)
return err
}

1
go.mod
View File

@@ -7,6 +7,7 @@ require (
github.com/kardianos/service v1.2.4 github.com/kardianos/service v1.2.4
github.com/prometheus-community/pro-bing v0.8.0 github.com/prometheus-community/pro-bing v0.8.0
github.com/spf13/cobra v1.10.2 github.com/spf13/cobra v1.10.2
golang.org/x/mod v0.34.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1 gopkg.in/natefinch/lumberjack.v2 v2.2.1
) )

2
go.sum
View File

@@ -19,6 +19,8 @@ github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiT
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=

View File

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

@@ -16,8 +16,7 @@ type program struct {
} }
func (p *program) Start(s service.Service) error { 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()) ctx, cancel := context.WithCancel(context.Background())
p.cancel = cancel p.cancel = cancel
@@ -28,14 +27,12 @@ func (p *program) Start(s service.Service) error {
} }
func (p *program) run(ctx context.Context) { func (p *program) run(ctx context.Context) {
// 挂载执行核心循环逻辑
monitor.Run(ctx, p.cfg) monitor.Run(ctx, p.cfg)
close(p.exit) close(p.exit)
} }
func (p *program) Stop(s service.Service) error { 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 { if p.cancel != nil {
p.cancel() p.cancel()
} }
@@ -43,12 +40,15 @@ func (p *program) Stop(s service.Service) error {
return nil return nil
} }
// GetService 构建 service 实例供 CLI 控制(安装,启动,停止,卸载)和前台直接 Run。 func GetService(cfg *config.Config, execPath ...string) (service.Service, error) {
func GetService(cfg *config.Config) (service.Service, error) {
svcConfig := &service.Config{ svcConfig := &service.Config{
Name: "SmartNetworkMonitor", Name: "SmartNetworkMonitor",
DisplayName: "Smart Network Shutdown Monitor", 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.",
}
if len(execPath) > 0 && execPath[0] != "" {
svcConfig.Executable = execPath[0]
} }
prg := &program{ prg := &program{

View File

@@ -1,70 +1,102 @@
package logger package logger
import ( import (
"io" "fmt"
"log"
"os" "os"
"path/filepath" "path/filepath"
"time"
"github.com/fatih/color" "github.com/fatih/color"
"gopkg.in/natefinch/lumberjack.v2" "gopkg.in/natefinch/lumberjack.v2"
"smart-shutdown/pkg/config" "smart-shutdown/pkg/config"
) )
var ( var fileLogger *lumberjack.Logger
infoLogger *log.Logger var debugEnabled bool
succLogger *log.Logger
warnLogger *log.Logger
failLogger *log.Logger
critLogger *log.Logger
)
// InitLogger 初始化全局日志系统,将日志同时输出到终端色彩日志和滚动文件
func InitLogger() error { func InitLogger() error {
logDir := config.GetLogDir() logDir := config.GetLogDir()
if err := os.MkdirAll(logDir, 0755); err != nil { if err := os.MkdirAll(logDir, 0755); err != nil {
// 降级为当前目录,适用于未具有系统写权限的 Local 执行
logDir = "logs" logDir = "logs"
os.MkdirAll(logDir, 0755) os.MkdirAll(logDir, 0755)
} }
logFile := filepath.Join(logDir, "network_monitor.log") logFile := filepath.Join(logDir, "network_monitor.log")
// 使用 lumberjack 实现每天轮转和最大保留 30 天功能 fileLogger = &lumberjack.Logger{
ljLogger := &lumberjack.Logger{
Filename: logFile, Filename: logFile,
MaxSize: 10, // 每个切片最大兆字节 (MB) MaxSize: 10,
MaxBackups: 30, // 保留最近 30 个切片 MaxBackups: 30,
MaxAge: 30, // 保留最近 30 天的文件 MaxAge: 30,
Compress: false, // 是否压缩 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 return nil
} }
// 封装导出供业务调用的函数 func EnableDebug() {
func Info(format string, v ...interface{}) { infoLogger.Printf(format, v...) } debugEnabled = true
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 writeLog(level, plainPrefix, format string, v ...interface{}) {
func Crit(format string, v ...interface{}) { critLogger.Printf(format, v...) } 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()
case "DEBUG":
return color.New(color.FgCyan).SprintFunc()
default:
return color.New(color.Reset).SprintFunc()
}
}
func Debug(format string, v ...interface{}) {
if debugEnabled {
writeLog("DEBUG", "[DEBUG]", format, v...)
}
}
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" "smart-shutdown/pkg/system"
) )
// Run 开启核心状态机循环监控,接收 ctx 以便主程序或者服务管理器控制安全退出
func Run(ctx context.Context, cfg *config.Config) { func Run(ctx context.Context, cfg *config.Config) {
logger.Info("========== 智能关机监控已启动 ==========") logger.Info("启动后台网络监控")
logger.Info("监控目标: %s", cfg.TargetIP) logger.Info("监控目标 IP: %s", cfg.TargetIP)
logger.Info("监控窗口: %d秒", cfg.MonitorWindowSeconds) logger.Info("故障判定窗口: %d秒", cfg.MonitorWindowSeconds)
logger.Info("关机倒计时: %d秒", cfg.ShutdownCountdown) logger.Info("关机倒计时阈值: %d秒", cfg.ShutdownCountdown)
var failureStartTime time.Time var failureStartTime time.Time
var inFailureWindow bool = false var inFailureWindow bool = false
normalStatusCounter := 0 normalStatusCounter := 0
// 每 24 次正常检测输出一次日志(约 6 分钟)
const normalStatusLogInterval = 24 const normalStatusLogInterval = 24
for { for {
select { select {
case <-ctx.Done(): case <-ctx.Done():
logger.Info("========== 收到停止信号,退出监控 ==========") logger.Info("接收到系统停止指令, 退出核心调度监控")
return return
default: default:
} }
isOnline := pinger.Ping(cfg.TargetIP, 3) // 3 秒超时探测一次 isOnline := pinger.Ping(cfg.TargetIP, 3)
if isOnline { if isOnline {
if inFailureWindow { if inFailureWindow {
logger.Succ("网络连接已恢复!重置断网计时器。") logger.Succ("网络连通性恢复, 监控窗口重置")
inFailureWindow = false inFailureWindow = false
normalStatusCounter = 0 normalStatusCounter = 0
} else { } else {
normalStatusCounter++ normalStatusCounter++
if normalStatusCounter >= normalStatusLogInterval { if normalStatusCounter >= normalStatusLogInterval {
logger.Info("网络连接持续正常(已检测 %d 次)", normalStatusCounter) logger.Info("网络探测状态正常 (定期状态更新)")
normalStatusCounter = 0 normalStatusCounter = 0
} }
} }
// 正常状态下休眠等待下一次探测
time.Sleep(time.Duration(cfg.NormalPingInterval) * time.Second) time.Sleep(time.Duration(cfg.NormalPingInterval) * time.Second)
} else { } else {
logger.Fail("Ping 测试失败 - 目标: %s", cfg.TargetIP) logger.Warn("目标节点未响应探针包: %s", cfg.TargetIP)
if !inFailureWindow { if !inFailureWindow {
// 首次断网
failureStartTime = time.Now() failureStartTime = time.Now()
inFailureWindow = true inFailureWindow = true
logger.Warn("网络首次中断,开始进入监控窗口计时。") logger.Warn("触发网络状态失效, 开始累计中断时间监控")
} }
// 计算当前断网累计时间
failureDuration := time.Since(failureStartTime).Seconds() failureDuration := time.Since(failureStartTime).Seconds()
if int(failureDuration) >= cfg.MonitorWindowSeconds { if int(failureDuration) >= cfg.MonitorWindowSeconds {
logger.Crit("网络持续中断已超过 %d 秒,触发关机流程", cfg.MonitorWindowSeconds) logger.Crit("持续中断时长超越设定的阈值上限 (%d秒), 进入关机响应流程", cfg.MonitorWindowSeconds)
// 进入倒计时阶段,倒计时期间高频检测(阻塞调用)
shutdownCancelled := startCountdown(ctx, cfg.TargetIP, cfg.ShutdownCountdown) shutdownCancelled := startCountdown(ctx, cfg.TargetIP, cfg.ShutdownCountdown)
if !shutdownCancelled { if !shutdownCancelled {
logger.Crit("倒计时结束,执行最终关机!") logger.Crit("倒计时时限完毕, 开始下发系统级级终态关机指令")
system.ExecuteShutdown() system.ExecuteShutdown()
// 关机指令发出,理论上系统即将终止进程,为了优雅,此处也退出
return return
} else { } else {
// 倒计时中途网络恢复被取消了,重置状态重新进入监控环节
inFailureWindow = false inFailureWindow = false
} }
} else { } else {
// 还在容忍窗口期内,仅仅打印日志并休眠 logger.Info("网络持续断失: %.0f/%d 秒", failureDuration, cfg.MonitorWindowSeconds)
logger.Info("网络已持续中断 %.0f / %d 秒,继续监控...", failureDuration, cfg.MonitorWindowSeconds)
time.Sleep(time.Duration(cfg.NormalPingInterval) * time.Second) time.Sleep(time.Duration(cfg.NormalPingInterval) * time.Second)
} }
} }
} }
} }
// startCountdown 执行关机前倒计时的轮询,如果中途恢复网络返回 true (代表取消关机)。
// 返回 false 代表断网到底,坚决关机。
func startCountdown(ctx context.Context, targetIP string, countdownSec int) bool { func startCountdown(ctx context.Context, targetIP string, countdownSec int) bool {
logger.Warn("============================================") logger.Warn("执行系统关机倒计时流程 (预设: %d秒)...", countdownSec)
logger.Warn("准备关机!倒计时 %d 秒期间将快速探测网络...", countdownSec)
logger.Warn("============================================")
endTime := time.Now().Add(time.Duration(countdownSec) * time.Second) endTime := time.Now().Add(time.Duration(countdownSec) * time.Second)
// 倒计时期间提高探测频率:每 3 秒一次
ticker := time.NewTicker(3 * time.Second) ticker := time.NewTicker(3 * time.Second)
defer ticker.Stop() defer ticker.Stop()
for { for {
remaining := int(time.Until(endTime).Seconds()) remaining := int(time.Until(endTime).Seconds())
if remaining <= 0 { if remaining <= 0 {
return false // 时间到,确认无法恢复 return false
} }
logger.Warn("距离关机还有 %d 秒... 正在高频检测网络", remaining) logger.Warn("距离关机指令处分剩余: %d 秒", remaining)
select { select {
case <-ctx.Done(): case <-ctx.Done():
logger.Info("收到退出系统服务信号,中止倒计时关机!") logger.Info("挂起关机倒计流程:收到系统强制干预信号")
return true return true
case <-ticker.C: case <-ticker.C:
// 高频重试检测网络
if pinger.Ping(targetIP, 3) { if pinger.Ping(targetIP, 3) {
logger.Succ("============================================") logger.Succ("倒计时周期内网络指标恢复, 全局关机流程已被重置并取消")
logger.Succ("网络在倒计时期间奇迹般地恢复了!立刻取消关机!")
logger.Succ("============================================")
return true return true
} }
} }

View File

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

View File

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

146
pkg/updater/updater.go Normal file
View File

@@ -0,0 +1,146 @@
package updater
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"runtime"
"strings"
"time"
"golang.org/x/mod/semver"
)
type githubRelease struct {
TagName string `json:"tag_name"`
Assets []struct {
Name string `json:"name"`
BrowserDownloadURL string `json:"browser_download_url"`
} `json:"assets"`
}
func CheckForUpdate(currentVersion string) (hasNew bool, latestVersion string, downloadURL string) {
if currentVersion == "dev" || currentVersion == "" {
return false, "", ""
}
client := http.Client{Timeout: 8 * time.Second}
resp, err := client.Get("https://api.github.com/repos/UnbalancedCat/smart-shutdown/releases/latest")
if err != nil || resp.StatusCode != 200 {
return false, "", ""
}
defer resp.Body.Close()
var release githubRelease
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
return false, "", ""
}
latestVersion = release.TagName
cv := currentVersion
if !semver.IsValid(cv) && !strings.HasPrefix(cv, "v") {
cv = "v" + cv
}
lv := latestVersion
if !semver.IsValid(lv) && !strings.HasPrefix(lv, "v") {
lv = "v" + lv
}
if semver.Compare(cv, lv) < 0 {
expectedAsset := fmt.Sprintf("smart-shutdown_%s_%s", runtime.GOOS, runtime.GOARCH)
if runtime.GOOS == "windows" {
expectedAsset += ".exe"
}
for _, asset := range release.Assets {
if asset.Name == expectedAsset {
return true, latestVersion, asset.BrowserDownloadURL
}
}
}
return false, "", ""
}
func CheckAndPrintUpdate(currentVersion string) {
fmt.Printf("当前内核版本: %s\n", currentVersion)
fmt.Println("正在检测云端发布节点是否有可用新版本...")
hasNew, latest, _ := CheckForUpdate(currentVersion)
if hasNew {
fmt.Printf("\n[发现更新] 获取到最新稳定版本: %s\n", latest)
fmt.Println("请执行 'smart-shutdown update' 以全自动获取并覆盖部署该更新。")
} else {
fmt.Println("当前已是最新运行版本,暂无可用更新。")
}
}
func DownloadAndReplace(downloadURL string) error {
currentExe, err := os.Executable()
if err != nil {
return fmt.Errorf("无法溯源自身执行路径: %v", err)
}
tempExe := filepath.Join(os.TempDir(), "smart-shutdown-update.tmp")
out, err := os.Create(tempExe)
if err != nil {
return fmt.Errorf("系统缓存区句柄开辟失败: %v", err)
}
resp, err := http.Get(downloadURL)
if err != nil || resp.StatusCode != 200 {
out.Close()
os.Remove(tempExe)
return fmt.Errorf("网络传输流建立失败,节点远端可能受限")
}
_, err = io.Copy(out, resp.Body)
resp.Body.Close()
out.Close()
if err != nil {
os.Remove(tempExe)
return fmt.Errorf("物理覆盖字节流中断: %v", err)
}
if runtime.GOOS == "windows" {
oldExe := currentExe + ".old"
os.Remove(oldExe)
if err := os.Rename(currentExe, oldExe); err != nil {
return fmt.Errorf("操作系统拒绝进程脱壳 (文件锁互斥): %v", err)
}
}
if err := copyFile(tempExe, currentExe); err != nil {
if runtime.GOOS == "windows" {
os.Rename(currentExe+".old", currentExe)
}
return fmt.Errorf("原子级覆盖执行核心挫败: %v", err)
}
os.Remove(tempExe)
if runtime.GOOS != "windows" {
os.Chmod(currentExe, 0755)
}
return nil
}
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.Create(dst)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, in)
return err
}