3 Commits

12 changed files with 606 additions and 59 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-shutdown_windows_amd64.exe ./cmd/smart-shutdown 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-shutdown_windows_arm64.exe ./cmd/smart-shutdown 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-shutdown_linux_amd64 ./cmd/smart-shutdown 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-shutdown_linux_arm64 ./cmd/smart-shutdown 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 }}

View File

@@ -2,28 +2,37 @@
持续监测目标网络连通性并在网络断开超时后自动关闭系统的跨平台常驻服务程序。 持续监测目标网络连通性并在网络断开超时后自动关闭系统的跨平台常驻服务程序。
> **注意:** 本程序涉及系统服务注册与网络监控,所有安装、卸载及服务管理操作均**必须在管理员 (Windows) 或 root (Linux) 权限**下执行。
## 安装指南 (Installation) ## 安装指南 (Installation)
本程序支持安装为操作系统的后台服务,并自动注册全局环境变量以便统一管理。 本程序支持安装为操作系统的后台服务,并自动注册全局环境变量以便统一管理。
### Windows 系统 ### Windows 系统
推荐以拥有管理员权限的系统服务形式部署。 **以管理员身份打开 PowerShell**,执行以下一键安装命令:
1. **获取程序** ```powershell
前往 [Releases](https://github.com/UnbalancedCat/smart-shutdown/releases) 页面,下载最新的 `smart-shutdown_windows_amd64.exe` (或其它架构版本)。 irm https://raw.githubusercontent.com/UnbalancedCat/smart-shutdown/main/install.ps1 | iex
```
2. **终端安装** > **说明:** 该命令会自动下载最新版本,执行 `install` 将程序复制到 `C:\Program Files\SmartShutdown\` 并写入系统 `PATH` 环境变量,同时注册为 Windows 开机自启服务。此后您可在任意管理员终端直接使用 `smart-shutdown` 命令。
在下载目录的空白处右键选择 **以管理员身份打开 PowerShell 或 CMD**,执行如下指令:
```powershell 如需手动安装,也可前往 [Releases](https://github.com/UnbalancedCat/smart-shutdown/releases) 页面下载对应架构版本,然后在管理员终端中执行:
.\smart-shutdown_windows_amd64.exe install ```powershell
smart-shutdown start .\smart-shutdown_windows_amd64.exe install
``` smart-shutdown start
> **说明:** `install` 指令会自动将程序复制到 `C:\Program Files\SmartShutdown\` 并写入系统 `PATH` 环境变量中,同时注册为 Windows 开机自启服务。此后您可在任意终端直接使用 `smart-shutdown` 命令。 ```
### Linux (Ubuntu/Debian/CentOS 等) ### Linux (Ubuntu/Debian/CentOS 等)
如您具备 `sudo` 权限,可在终端中执行以下单行命令进行自动化下载与 `systemd` 服务部署 在终端中执行以下一键安装命令
```bash
curl -sSL https://raw.githubusercontent.com/UnbalancedCat/smart-shutdown/main/install.sh | sudo sh
```
如需手动安装,可执行以下命令:
```bash ```bash
sudo curl -sSL https://github.com/UnbalancedCat/smart-shutdown/releases/latest/download/smart-shutdown_linux_amd64 -o /usr/local/bin/smart-shutdown && \ sudo curl -sSL https://github.com/UnbalancedCat/smart-shutdown/releases/latest/download/smart-shutdown_linux_amd64 -o /usr/local/bin/smart-shutdown && \
@@ -32,6 +41,41 @@ sudo smart-shutdown install && \
sudo smart-shutdown start sudo smart-shutdown start
``` ```
## 快速开始
安装完成后,在管理员 / root 终端中执行首次启动:
```bash
smart-shutdown start
```
如果程序未检测到配置文件,将自动引导您完成初始设置:
```
========== 首次配置 ==========
未检测到配置文件,将引导您完成初始设置。直接按回车使用 [默认值]。
目标监控 IP 地址 [192.168.3.1]: <输入您的目标 IP 或直接回车>
断网容忍超时时长 (秒) [180]:
关机倒计时缓冲 (秒) [60]:
探测发包间隔 (秒) [15]:
配置已保存至: C:\ProgramData\SmartNetworkMonitor\config.json
```
> **提示:** 服务注册后将**随系统开机自动启动**,无需手动干预。如需暂停监控,请执行 `smart-shutdown stop`。
## Windows 注意事项
如您在 Windows 上使用 `sudo` 命令Windows 11 24H2+ 内置)来执行本程序,请注意:
Windows `sudo` 默认运行在 **ForceNewWindow强制新窗口** 模式下,提权进程的输出将打印到一个瞬间关闭的新窗口中,**导致 `status` 等展示类命令的输出不可见。**
**解决方法(任选其一):**
1. **切换 sudo 为 Inline 模式**:打开 **系统设置 → 开发者选项 → 启用 sudo**,将模式改为 **内联 (Inline)**。此后 `sudo smart-shutdown status` 的输出将正常回显至当前终端。
2. **直接使用管理员终端**:右键点击终端图标,选择 **以管理员身份运行**,随后无需 `sudo` 前缀即可执行所有指令。
## CLI 终端管理指令 (Commands) ## CLI 终端管理指令 (Commands)
完成服务注册后,可通过以下指令直接管理守护进程状态流。 完成服务注册后,可通过以下指令直接管理守护进程状态流。
@@ -80,7 +124,7 @@ smart-shutdown uninstall
| 参数项 | 含义与功能说明 | 默认值 | | 参数项 | 含义与功能说明 | 默认值 |
|:---|:---|:---| |:---|:---|:---|
| `TargetIP` | 需发送 ICMP 包验证联通性的目标 IPv4 地址。 | `192.168.3.3` | | `TargetIP` | 需发送 ICMP 包验证联通性的目标 IPv4 地址。 | `192.168.3.1` |
| `MonitorWindowSeconds` | 网络中断被判定为异常并触发系统关机前,所能容忍的最长超时时长 (秒)。 | `180` | | `MonitorWindowSeconds` | 网络中断被判定为异常并触发系统关机前,所能容忍的最长超时时长 (秒)。 | `180` |
| `ShutdownCountdown` | 容忍超限后,执行正式关机系统指令的警告倒计时缓冲时间 (秒)。 | `60` | | `ShutdownCountdown` | 容忍超限后,执行正式关机系统指令的警告倒计时缓冲时间 (秒)。 | `60` |
| `NormalPingInterval` | 网络连通性正常时,每次静默发包探测的间隔时间 (秒)。 | `15` | | `NormalPingInterval` | 网络连通性正常时,每次静默发包探测的间隔时间 (秒)。 | `15` |

View File

@@ -4,17 +4,22 @@ import (
"fmt" "fmt"
"io" "io"
"os" "os"
"os/exec"
"path/filepath" "path/filepath"
"runtime"
"strings" "strings"
"smart-shutdown/pkg/config"
"smart-shutdown/pkg/daemon"
"smart-shutdown/pkg/logger"
"github.com/kardianos/service" "github.com/kardianos/service"
"github.com/spf13/cobra" "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() { func main() {
if err := logger.InitLogger(); err != nil { if err := logger.InitLogger(); err != nil {
fmt.Printf("无法初始化日志系统: %v\n", err) fmt.Printf("无法初始化日志系统: %v\n", err)
@@ -23,98 +28,218 @@ func main() {
cfg, err := config.LoadConfig() cfg, err := config.LoadConfig()
if err != nil { if err != nil {
logger.Crit("读取配置失败: %v", err) logger.Crit("读取配置文件失败: %v", err)
os.Exit(1) os.Exit(1)
} }
svc, err := daemon.GetService(cfg) svc, err := daemon.GetService(cfg)
if err != nil { if err != nil {
logger.Crit("构建服务对象失败: %v", err) logger.Crit("构建后台服务实例失败: %v", err)
os.Exit(1) os.Exit(1)
} }
var showVersion bool
var rootCommand = &cobra.Command{ var rootCommand = &cobra.Command{
Use: "smart-shutdown", Use: "smart-shutdown",
Short: "智能网络状态检测与自动关机后台服务", Short: "智能网络状态检测与自动关机后台服务",
PersistentPreRun: func(cmd *cobra.Command, args []string) {
if verbose {
logger.EnableDebug()
}
},
Run: func(cmd *cobra.Command, args []string) { 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() err := svc.Run()
if err != nil { if err != nil {
logger.Fail("运行异常: %v", err) logger.Fail("后台监控流崩溃: %v", err)
} }
}, },
} }
// 禁用生成补全帮助
rootCommand.CompletionOptions.DisableDefaultCmd = true rootCommand.CompletionOptions.DisableDefaultCmd = true
rootCommand.Flags().BoolVarP(&showVersion, "version", "V", false, "输出当前二进制内核版本并联网拉取发布树状态")
rootCommand.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "打印底层部署及环境追溯 Debug 信息")
cmds := []*cobra.Command{ cmds := []*cobra.Command{
{ {
Use: "install", Use: "install",
Short: "安装系统常驻服务", Short: "将执行文件拷贝至系统目录并全局注入环境变量体系",
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
handleServiceControl(svc, "install") 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", Use: "uninstall",
Short: "卸载系统常驻服务", Short: "废除并移除在册的后台服务、扫除环境关联残留",
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
service.Control(svc, "stop") service.Control(svc, "stop")
handleServiceControl(svc, "uninstall") 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 中的指向挂载项业已剥除卸载完成。")
}
}
}
if config.ConfirmConfigCleanup() {
configDir := config.GetConfigDir()
os.RemoveAll(configDir)
fmt.Println("配置文件与日志已清除。")
}
},
},
{
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", Use: "start",
Short: "启动后台运行服务", Short: "唤起执行守护后台",
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
if !config.ConfigFileExists() {
newCfg, err := config.InteractiveSetup()
if err != nil {
logger.Fail("配置初始化失败: %v", err)
return
}
cfg = newCfg
}
handleServiceControl(svc, "start") handleServiceControl(svc, "start")
}, },
}, },
{ {
Use: "stop", Use: "stop",
Short: "停止后台运行服务", Short: "停机系统守护状态流",
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
handleServiceControl(svc, "stop") handleServiceControl(svc, "stop")
}, },
}, },
{ {
Use: "restart", Use: "restart",
Short: "重启服务", Short: "阻断并复用重新起跳服务控制主程",
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
handleServiceControl(svc, "restart") handleServiceControl(svc, "restart")
}, },
}, },
{ {
Use: "status", Use: "status",
Short: "查询核心配置、服务运行状态及近期日志", Short: "展示全向服务参数结构、后端存活性报告与探针汇集",
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
status, err := svc.Status() status, err := svc.Status()
fmt.Println("\n========== 运行状态 ==========") fmt.Println("\n========== 运行状态 ==========")
if err != nil { if err != nil {
fmt.Printf("服务状态查询失败 (需系统管理员权限): %v\n", err) fmt.Printf("特权访问受抵 (查验核算需要系统特权根源准允): %v\n", err)
} else { } else {
switch status { switch status {
case service.StatusRunning: case service.StatusRunning:
fmt.Println("系统服务: [运中]") fmt.Println("全向系统基石服务: [运转值守中]")
case service.StatusStopped: case service.StatusStopped:
fmt.Println("系统服务: [已停止]") fmt.Println("全向系统基石服务: [已被静默挂起]")
default: default:
fmt.Println("系统服务: [未注册或状态未知]") fmt.Println("全向系统基石服务: [未注册落位 / 孤儿状态]")
} }
} }
fmt.Println("\n========== 核心配置 ==========") fmt.Println("\n========== 核心配置 ==========")
fmt.Printf("探测目标 IP : %s\n", cfg.TargetIP) fmt.Printf("探测标的机器 IP : %s\n", cfg.TargetIP)
fmt.Printf("容忍断连窗口 (秒) : %d\n", cfg.MonitorWindowSeconds) fmt.Printf("脱网迟滞容忍 (秒) : %d\n", cfg.MonitorWindowSeconds)
fmt.Printf("预警关机倒计 (秒) : %d\n", cfg.ShutdownCountdown) fmt.Printf("临危核准倒计 (秒) : %d\n", cfg.ShutdownCountdown)
fmt.Printf("平稳探测频次 (秒) : %d\n", cfg.NormalPingInterval) fmt.Printf("静默发包跳频 (秒) : %d\n", cfg.NormalPingInterval)
logFilePath := filepath.Join(config.GetLogDir(), "network_monitor.log") logFilePath := filepath.Join(config.GetLogDir(), "network_monitor.log")
if fi, fileErr := os.Stat(logFilePath); fileErr == nil { if fi, fileErr := os.Stat(logFilePath); fileErr == nil {
fmt.Println("\n========== 日志系统 ==========") fmt.Println("\n========== 日志系统 ==========")
fmt.Printf("日志位置: %s\n", logFilePath) fmt.Printf("硬盘归档落点: %s\n", logFilePath)
fmt.Printf("当前占用: %.2f KB\n", float64(fi.Size())/1024) fmt.Printf("现时容量尺寸: %.2f KB\n", float64(fi.Size())/1024)
} }
printLastLogLines(10) printLastLogLines(10)
@@ -124,21 +249,21 @@ func main() {
var configCmd = &cobra.Command{ var configCmd = &cobra.Command{
Use: "config", Use: "config",
Short: "管理运行配置文件", Short: "提供运行参数结构的修载校验准入系统",
} }
var configSetCmd = &cobra.Command{ var configSetCmd = &cobra.Command{
Use: "set [键] [值]", Use: "set [键] [值]",
Short: "修改指定的配置参数 (如 config set TargetIP 192.168.1.1)", Short: "安全的对 JSON 属性执行改写封装验证 (如 config set TargetIP 192.168.3.1)",
Args: cobra.ExactArgs(2), Args: cobra.ExactArgs(2),
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
key := args[0] key := args[0]
val := args[1] val := args[1]
err := config.UpdateConfig(key, val) err := config.UpdateConfig(key, val)
if err != nil { if err != nil {
fmt.Printf("修改配置失败: %v\n", err) fmt.Printf("基于硬编码的防呆数据验证发回抵拦截口指令: %v\n", err)
} else { } else {
fmt.Printf("成功将配置 [%s] 更新为 [%s]。\n(提示: 请主动执行 smart-shutdown restart 使更改生效)\n", key, val) fmt.Printf("系统确认承接了新的设参: [%s] 指标结构被赋予了 [%s] 新制规格 \n(附留: 更易此配置文件必须人为发起 'smart-shutdown restart' 才能覆盖常驻内存的解析图谱。)\n", key, val)
} }
}, },
} }
@@ -150,11 +275,10 @@ func main() {
rootCommand.AddCommand(c) rootCommand.AddCommand(c)
} }
// 初始化并汉化帮助菜单
rootCommand.InitDefaultHelpCmd() rootCommand.InitDefaultHelpCmd()
for _, cmd := range rootCommand.Commands() { for _, cmd := range rootCommand.Commands() {
if cmd.Name() == "help" { if cmd.Name() == "help" {
cmd.Short = "获取任意指令的帮助文档" cmd.Short = "按需展示其它操作指令及其详情"
} }
} }
@@ -168,20 +292,20 @@ func handleServiceControl(s service.Service, action string) {
if err != nil { if err != nil {
errStr := err.Error() errStr := err.Error()
if strings.Contains(errStr, "Access is denied") || strings.Contains(errStr, "permission denied") || strings.Contains(errStr, "拒绝访问") { if strings.Contains(errStr, "Access is denied") || strings.Contains(errStr, "permission denied") || strings.Contains(errStr, "拒绝访问") {
logger.Fail("执行动作 [%s] 失败: 权限遭拒。请以 Administrator 或 Root 权限重新执行。", action) logger.Fail("管控流程 [%s] 触发越级防卫!无核准身份特设记录。请开具含有全 Root 及高配权限窗格承接口径。", action)
return return
} }
logger.Crit("执行动作 [%s] 失败: %v", action, err) logger.Crit("后台执行流程组块阻断报错 [%s] : %v", action, err)
return return
} }
logger.Succ("执行动作 [%s] 成功", action) logger.Succ("指令动作 [%s] 解析下发执行完毕,无阻断警告。", action)
} }
func printLastLogLines(n int) { func printLastLogLines(n int) {
logFilePath := filepath.Join(config.GetLogDir(), "network_monitor.log") logFilePath := filepath.Join(config.GetLogDir(), "network_monitor.log")
file, err := os.Open(logFilePath) file, err := os.Open(logFilePath)
if err != nil { if err != nil {
fmt.Printf("\n(暂无历史探测日志)\n") fmt.Printf("\n(排障辅录: 获取探针断联记录池为空,全空栈态)\n")
return return
} }
defer file.Close() defer file.Close()
@@ -204,8 +328,36 @@ func printLastLogLines(n int) {
start = 0 start = 0
} }
fmt.Printf("\n========== 最近 %d 条抓取日志 ==========\n", n) fmt.Printf("\n========== 最新沿线抓取探测轨迹尾部排 %d 行 ==========\n", n)
for i := start; i < len(validLines); i++ { for i := start; i < len(validLines); i++ {
fmt.Println(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=

27
install.ps1 Normal file
View File

@@ -0,0 +1,27 @@
# Smart Network Shutdown Monitor - Windows Installer
# Usage: irm https://raw.githubusercontent.com/UnbalancedCat/smart-shutdown/main/install.ps1 | iex
$ErrorActionPreference = "Stop"
# Check admin privileges
if (-not ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
Write-Host "[FAIL] 此安装脚本必须以管理员身份运行。请右键 PowerShell 选择「以管理员身份运行」后重试。" -ForegroundColor Red
exit 1
}
$arch = if ([Environment]::Is64BitOperatingSystem) { "amd64" } else { "386" }
$fileName = "smart-shutdown_windows_${arch}.exe"
$downloadUrl = "https://github.com/UnbalancedCat/smart-shutdown/releases/latest/download/$fileName"
$tempPath = Join-Path $env:TEMP "smart-shutdown.exe"
Write-Host "正在下载最新版本: $fileName ..." -ForegroundColor Cyan
Invoke-WebRequest -Uri $downloadUrl -OutFile $tempPath -UseBasicParsing
Write-Host "正在执行安装与服务注册..." -ForegroundColor Cyan
& $tempPath install
Write-Host "正在启动后台服务..." -ForegroundColor Cyan
smart-shutdown start
Remove-Item $tempPath -Force -ErrorAction SilentlyContinue
Write-Host "安装完成。此后可在管理员终端中直接使用 smart-shutdown 命令。" -ForegroundColor Green

35
install.sh Normal file
View File

@@ -0,0 +1,35 @@
#!/bin/sh
# Smart Network Shutdown Monitor - Linux Installer
# Usage: curl -sSL https://raw.githubusercontent.com/UnbalancedCat/smart-shutdown/main/install.sh | sudo sh
set -e
# Check root privileges
if [ "$(id -u)" -ne 0 ]; then
echo "[FAIL] 此安装脚本必须以 root 权限运行。请使用 sudo 执行。"
exit 1
fi
ARCH=$(uname -m)
case "$ARCH" in
x86_64) ARCH="amd64" ;;
aarch64) ARCH="arm64" ;;
armv7l) ARCH="armv7" ;;
*) echo "[FAIL] 不支持的架构: $ARCH"; exit 1 ;;
esac
FILE_NAME="smart-shutdown_linux_${ARCH}"
DOWNLOAD_URL="https://github.com/UnbalancedCat/smart-shutdown/releases/latest/download/$FILE_NAME"
TARGET_PATH="/usr/local/bin/smart-shutdown"
echo "正在下载最新版本: $FILE_NAME ..."
curl -sSL "$DOWNLOAD_URL" -o "$TARGET_PATH"
chmod +x "$TARGET_PATH"
echo "正在执行安装与服务注册..."
smart-shutdown install
echo "正在启动后台服务..."
smart-shutdown start
echo "安装完成。此后可直接使用 smart-shutdown 命令。"

View File

@@ -19,7 +19,7 @@ type Config struct {
func DefaultConfig() *Config { func DefaultConfig() *Config {
return &Config{ return &Config{
TargetIP: "192.168.3.3", TargetIP: "192.168.3.1",
MonitorWindowSeconds: 180, MonitorWindowSeconds: 180,
ShutdownCountdown: 60, ShutdownCountdown: 60,
NormalPingInterval: 15, NormalPingInterval: 15,

124
pkg/config/interactive.go Normal file
View File

@@ -0,0 +1,124 @@
package config
import (
"bufio"
"fmt"
"net"
"os"
"path/filepath"
"strconv"
"strings"
)
// ConfigFileExists checks if the config.json file exists at the expected location.
func ConfigFileExists() bool {
configPath := filepath.Join(GetConfigDir(), "config.json")
_, err := os.Stat(configPath)
return err == nil
}
// isTerminal checks if stdin is connected to a terminal (not a pipe).
func isTerminal() bool {
fi, err := os.Stdin.Stat()
if err != nil {
return false
}
return fi.Mode()&os.ModeCharDevice != 0
}
// InteractiveSetup prompts the user to configure each parameter interactively.
// If stdin is not a terminal (pipe mode), it silently saves and returns defaults.
func InteractiveSetup() (*Config, error) {
cfg := DefaultConfig()
if !isTerminal() {
if err := SaveConfig(cfg); err != nil {
return nil, fmt.Errorf("写入默认配置失败: %v", err)
}
return cfg, nil
}
fmt.Println("\n========== 首次配置 ==========")
fmt.Println("未检测到配置文件,将引导您完成初始设置。直接按回车使用 [默认值]。")
scanner := bufio.NewScanner(os.Stdin)
cfg.TargetIP = promptString(scanner, "目标监控 IP 地址", cfg.TargetIP, func(s string) error {
if net.ParseIP(s) == nil {
return fmt.Errorf("非法 IP 地址: %s", s)
}
return nil
})
cfg.MonitorWindowSeconds = promptInt(scanner, "断网容忍超时时长 (秒)", cfg.MonitorWindowSeconds)
cfg.ShutdownCountdown = promptInt(scanner, "关机倒计时缓冲 (秒)", cfg.ShutdownCountdown)
cfg.NormalPingInterval = promptInt(scanner, "探测发包间隔 (秒)", cfg.NormalPingInterval)
if err := SaveConfig(cfg); err != nil {
return nil, fmt.Errorf("写入配置文件失败: %v", err)
}
configPath := filepath.Join(GetConfigDir(), "config.json")
fmt.Printf("\n配置已保存至: %s\n", configPath)
return cfg, nil
}
// ConfirmConfigCleanup prompts the user to confirm deletion of config and log files.
// Returns true if the user confirms. Non-terminal stdin defaults to no.
func ConfirmConfigCleanup() bool {
if !isTerminal() {
return false
}
configDir := GetConfigDir()
configPath := filepath.Join(configDir, "config.json")
if _, err := os.Stat(configPath); err != nil {
return false
}
fmt.Printf("检测到配置文件: %s\n是否一并清除配置文件与日志[y/N]: ", configDir)
scanner := bufio.NewScanner(os.Stdin)
if scanner.Scan() {
return strings.EqualFold(strings.TrimSpace(scanner.Text()), "y")
}
return false
}
func promptString(scanner *bufio.Scanner, label, defaultVal string, validate func(string) error) string {
for {
fmt.Printf("\n%s [%s]: ", label, defaultVal)
if !scanner.Scan() {
return defaultVal
}
input := strings.TrimSpace(scanner.Text())
if input == "" {
return defaultVal
}
if validate != nil {
if err := validate(input); err != nil {
fmt.Printf(" 输入无效: %v请重新输入。\n", err)
continue
}
}
return input
}
}
func promptInt(scanner *bufio.Scanner, label string, defaultVal int) int {
for {
fmt.Printf("%s [%d]: ", label, defaultVal)
if !scanner.Scan() {
return defaultVal
}
input := strings.TrimSpace(scanner.Text())
if input == "" {
return defaultVal
}
val, err := strconv.Atoi(input)
if err != nil || val <= 0 {
fmt.Println(" 输入无效: 必须为正整数,请重新输入。")
continue
}
return val
}
}

View File

@@ -3,11 +3,10 @@ package daemon
import ( import (
"context" "context"
"github.com/kardianos/service"
"smart-shutdown/pkg/config" "smart-shutdown/pkg/config"
"smart-shutdown/pkg/logger" "smart-shutdown/pkg/logger"
"smart-shutdown/pkg/monitor" "smart-shutdown/pkg/monitor"
"github.com/kardianos/service"
) )
type program struct { type program struct {
@@ -41,13 +40,17 @@ func (p *program) Stop(s service.Service) error {
return nil return nil
} }
func GetService(cfg *config.Config) (service.Service, error) { func GetService(cfg *config.Config, execPath ...string) (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 reliable daemon that periodically monitors network states and triggers node suspension logically.", 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{
cfg: cfg, cfg: cfg,
} }

View File

@@ -12,6 +12,7 @@ import (
) )
var fileLogger *lumberjack.Logger var fileLogger *lumberjack.Logger
var debugEnabled bool
func InitLogger() error { func InitLogger() error {
logDir := config.GetLogDir() logDir := config.GetLogDir()
@@ -33,6 +34,10 @@ func InitLogger() error {
return nil return nil
} }
func EnableDebug() {
debugEnabled = true
}
func writeLog(level, plainPrefix, format string, v ...interface{}) { func writeLog(level, plainPrefix, format string, v ...interface{}) {
msg := fmt.Sprintf(format, v...) msg := fmt.Sprintf(format, v...)
timestamp := time.Now().Format("2006/01/02 15:04:05") timestamp := time.Now().Format("2006/01/02 15:04:05")
@@ -63,11 +68,19 @@ func getPrefixColor(level string) func(a ...interface{}) string {
return color.New(color.FgRed).SprintFunc() return color.New(color.FgRed).SprintFunc()
case "CRITICAL": case "CRITICAL":
return color.New(color.FgHiRed).SprintFunc() return color.New(color.FgHiRed).SprintFunc()
case "DEBUG":
return color.New(color.FgCyan).SprintFunc()
default: default:
return color.New(color.Reset).SprintFunc() 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{}) { func Info(format string, v ...interface{}) {
writeLog("INFO", "[INFO]", format, v...) writeLog("INFO", "[INFO]", format, v...)
} }

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
}