4 Commits
v0.3.0 ... main

12 changed files with 804 additions and 86 deletions

191
API.md Normal file
View File

@@ -0,0 +1,191 @@
# API Reference — Smart Network Shutdown Monitor
完整的 CLI 命令说明与参数参考文档。
> **注意:** 服务管理相关指令(`start`, `stop`, `restart`, `install`, `uninstall`)均需在**管理员 (Windows) / root (Linux)** 权限下执行。
---
## 服务生命周期
### `start`
唤起并注册后台监控守护进程,服务将随系统开机自动启动。
```bash
smart-shutdown start
```
### `stop`
停止当前运行的后台网络探测服务。
```bash
smart-shutdown stop
```
### `restart`
重启后台服务(配置变更后需执行此命令以应用新配置)。
```bash
smart-shutdown restart
```
### `status`
输出当前服务存活状态、载入的配置参数、日志存放路径及最近 10 条日志。
```bash
smart-shutdown status
```
---
## 安装与卸载
### `install`
将可执行文件复制至系统目录并写入全局环境变量,同时注册为开机自启服务。
```bash
smart-shutdown install
```
### `uninstall`
停止并注销后台服务,清理系统目录中的程序副本与环境变量。执行时将询问是否同时清除配置文件与日志。
```bash
smart-shutdown uninstall
```
---
## 配置管理
### `config set <Key> <Value>`
直接修改指定配置项,并执行合法性校验。改写后需执行 `restart` 使其生效。
```bash
# 修改目标监控 IP 地址
smart-shutdown config set TargetIP 192.168.0.1
# 修改断网容忍时长(秒)
smart-shutdown config set MonitorWindowSeconds 300
# 修改关机倒计时缓冲(秒)
smart-shutdown config set ShutdownCountdown 60
# 修改探测发包间隔(秒)
smart-shutdown config set NormalPingInterval 15
```
**可用配置项:**
| 参数项 | 含义 | 默认值 |
|:---|:---|:---|
| `TargetIP` | 目标 IPv4 地址,程序向其发送 ICMP 包检测联通性。 | `192.168.3.1` |
| `MonitorWindowSeconds` | 网络中断超出此时长(秒)则触发关机流程。 | `180` |
| `ShutdownCountdown` | 正式关机前的倒计时缓冲时间(秒)。 | `60` |
| `NormalPingInterval` | 网络正常时的探测发包间隔(秒)。 | `15` |
**配置文件路径:**
- Windows: `C:\ProgramData\SmartNetworkMonitor\config.json`
- Linux: `/etc/smart-network-monitor/config.json`
---
## 后台休眠控制
### `pause`
向正在运行的后台守护进程发送休眠指令,后台停止发包但**不启动前台监控**。必须指定 `--stop-after``--stop-at` 之一(或同时指定,以先到达的时间为准)。
```bash
# 休眠 2 小时后自动唤醒
smart-shutdown pause --stop-after 2h
# 休眠至明早 8 点自动唤醒
smart-shutdown pause --stop-at "2026-03-25 08:00:00"
# 同时指定两个时间,取较早者
smart-shutdown pause --stop-after 3h --stop-at "2026-03-25 08:00:00"
```
| Flag | 说明 | 格式示例 |
|:---|:---|:---|
| `--stop-after` | 休眠时长,到期自动唤醒。 | `30m`, `2h`, `1h30m` |
| `--stop-at` | 休眠至指定绝对时间自动唤醒。 | `"2026-03-25 08:00:00"` |
### `resume`
立即撤销休眠指令,后台将在下一轮探测周期(约 15 秒内)重新激活。
```bash
smart-shutdown resume
```
---
## 前台临时监控模式
直接执行 `smart-shutdown`(不带子命令)可进入前台临时监控模式,日志将写入 `network_monitor_front.log` 与原后台日志隔离。
- 监控参数(`--target-ip` 等)未指定时,使用配置文件中的值。
- **运行时长参数**`--stop-after` / `--stop-at`)均未指定时,前台监控将**一直运行**,直到手动按 `Ctrl+C` 终止。
```bash
smart-shutdown [flags]
```
| Flag | 说明 | 示例 |
|:---|:---|:---|
| `--target-ip` | 临时覆盖目标监控 IP | `--target-ip 8.8.8.8` |
| `--window-sec` | 临时覆盖断网容忍时长(秒) | `--window-sec 60` |
| `--shutdown-cnt` | 临时覆盖关机倒计时(秒) | `--shutdown-cnt 10` |
| `--ping-interval` | 临时覆盖探测发包间隔(秒) | `--ping-interval 5` |
| `--override-bg` | 挂起后台守护进程,由前台全面接管,退出时自动恢复后台 | `--override-bg` |
| `--stop-after` | 前台运行指定时长后自动退出 | `--stop-after 2h30m` |
| `--stop-at` | 前台运行至指定时间自动退出 | `--stop-at "2026-03-25 08:00:00"` |
**使用示例:**
```bash
# 临时将目标 IP 改为 8.8.8.860 秒无响应触发
smart-shutdown --target-ip 8.8.8.8 --window-sec 60
# 接管后台30 分钟后自动退出并恢复后台
smart-shutdown --override-bg --stop-after 30m
# 接管后台,运行至明早 8 点自动退出并恢复后台
smart-shutdown --override-bg --stop-at "2026-03-25 08:00:00"
```
---
## 其他
### `update`
联网拉取最新版本并热部署更新。
```bash
smart-shutdown update
```
### `--version` / `-V`
查看当前版本号并拉取最新发布状态。
```bash
smart-shutdown --version
```
### `--verbose` / `-v`
打印底层部署及环境追溯 Debug 信息(对所有子命令生效)。
```bash
smart-shutdown status --verbose
```
---
## 日志文件位置
| 场景 | Windows | Linux |
|:---|:---|:---|
| 后台服务日志 | `C:\ProgramData\SmartNetworkMonitor\logs\network_monitor.log` | `/var/log/smart-network-monitor/network_monitor.log` |
| 前台临时监控日志 | `C:\ProgramData\SmartNetworkMonitor\logs\network_monitor_front.log` | `/var/log/smart-network-monitor/network_monitor_front.log` |
程序按日自动切割日志,默认保留最近 30 天。

140
README.md
View File

@@ -2,90 +2,102 @@
持续监测目标网络连通性并在网络断开超时后自动关闭系统的跨平台常驻服务程序。
> **注意:** 本程序涉及系统服务注册与网络监控,所有安装、卸载及服务管理操作均**必须在管理员 (Windows) 或 root (Linux) 权限**下执行。
## 安装指南 (Installation)
本程序支持安装为操作系统的后台服务,并自动注册全局环境变量以便统一管理。
### Windows 系统
推荐以拥有管理员权限的系统服务形式部署。
**以管理员身份打开 PowerShell**,执行以下一键安装命令:
1. **获取程序**
前往 [Releases](https://github.com/UnbalancedCat/smart-shutdown/releases) 页面,下载最新的 `smart-shutdown_windows_amd64.exe` (或其它架构版本)。
```powershell
irm https://raw.githubusercontent.com/UnbalancedCat/smart-shutdown/main/install.ps1 | iex
```
2. **终端安装**
在下载目录的空白处右键选择 **以管理员身份打开 PowerShell 或 CMD**,执行如下指令:
```powershell
.\smart-shutdown_windows_amd64.exe install
smart-shutdown start
```
> **说明:** `install` 指令会自动将程序复制到 `C:\Program Files\SmartShutdown\` 并写入系统 `PATH` 环境变量中,同时注册为 Windows 开机自启服务。此后您可在任意终端直接使用 `smart-shutdown` 命令。
> **说明:** 该命令会自动下载最新版本,执行 `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
curl -sSL https://raw.githubusercontent.com/UnbalancedCat/smart-shutdown/main/install.sh | sudo sh
```
## CLI 终端管理指令 (Commands)
## 快速开始
完成服务注册后,可通过以下指令直接管理守护进程状态流。
*(注: 启停服务及修改配置的指令需在 **管理员级别 / root 权限** 终端内执行)*
安装完成后,默认会自动运行如下引导,进行初始设置:
```
========== 首次配置 ==========
未检测到配置文件,将引导您完成初始设置。直接按回车使用 [默认值]。
目标监控 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` 前缀即可执行所有指令。
## 命令参考
完整的命令说明、参数列表与使用示例,请查阅 [API.md](./API.md)。
常用快捷参考:
| 命令 | 说明 |
|:---|:---|
| `smart-shutdown start` | 启动后台监控服务 |
| `smart-shutdown stop` | 停止后台监控服务 |
| `smart-shutdown restart` | 重启服务 |
| `smart-shutdown status` | 查看服务状态与最近日志 |
| `smart-shutdown config set <Key> <Value>` | 修改配置项 |
| `smart-shutdown pause --stop-after 2h` | 临时挂起后台 2 小时 |
| `smart-shutdown resume` | 立即恢复挂起的后台 |
| `smart-shutdown uninstall` | 卸载服务及环境变量 |
## 免安装便携使用与前台监控模式
如果您只是想临时使用网络检测自动关机功能,而**不想将程序安装为系统常驻服务**,您可以直接下载可执行文件进行免安装的便携式运行(基于前台模式)。
前往 [Releases](https://github.com/UnbalancedCat/smart-shutdown/releases) 页面下载对应您系统的可执行文件后,在同一目录下的终端中,直接带参数执行即可。例如:
### 运行状态查询
输出当前服务存活状态、载入的配置参数、日志存放路径及最近 10 条日志:
```bash
smart-shutdown status
# 临时在前台启动监控(非后台服务),检测目标 IP 8.8.8.8,脱机容忍设为 60 秒
.\smart-shutdown --target-ip 8.8.8.8 --window-sec 60
```
### 修改系统配置
直接修改配置参数并执行合法性校验。(注: 改写配置后必须执行 `smart-shutdown restart` 重启服务才能应用生效)
```bash
# 修改目标监控 IP 地址
smart-shutdown config set TargetIP 192.168.0.1
> **提示:**
> 1. 前台模式下产生的日志将单独写入 `network_monitor_front.log`,不会影响原有数据。关闭当前终端即可停止监控进程。
> 2. 关于前台模式的 `--stop-after` (设定运行多少时间后退出)、`--override-bg` (接管正在运行的后台服务) 等高阶参数详细说明,请参阅 [API.md](./API.md)。
# 修改断网响应的容忍监控窗口
smart-shutdown config set MonitorWindowSeconds 300
```
**扩展:手动安装为服务**
### 服务生命周期控制
```bash
# 停止当前运行的后台网络探测服务
smart-shutdown stop
如果您下载了二进制文件后改变主意,想手动将其注册为系统服务,也可以直接使用 `install` 命令:
# 启动后台服务并恢复网络探测
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`
- **Windows**:
```powershell
.\smart-shutdown install
```
- **Linux**:
```bash
sudo mv ./smart-shutdown_linux_amd64 /usr/local/bin/smart-shutdown
sudo chmod +x /usr/local/bin/smart-shutdown
sudo smart-shutdown install
```

View File

@@ -1,23 +1,29 @@
package main
import (
"context"
"fmt"
"io"
"net"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
"github.com/kardianos/service"
"github.com/spf13/cobra"
"smart-shutdown/pkg/config"
"smart-shutdown/pkg/daemon"
"smart-shutdown/pkg/logger"
"smart-shutdown/pkg/monitor"
"smart-shutdown/pkg/updater"
)
var AppVersion = "dev"
var verbose bool
var configPath string
func main() {
if err := logger.InitLogger(); err != nil {
@@ -25,7 +31,22 @@ func main() {
os.Exit(1)
}
cfg, err := config.LoadConfig()
// Pre-parse -c / --config before cobra so we can load correct config early
for i, arg := range os.Args[1:] {
if (arg == "-c" || arg == "--config") && i+1 < len(os.Args)-1 {
configPath = os.Args[i+2]
break
}
}
var cfg *config.Config
var err error
if configPath != "" {
logger.Debug("使用自定义配置文件: %s", configPath)
cfg, err = config.LoadConfigFrom(configPath)
} else {
cfg, err = config.LoadConfig()
}
if err != nil {
logger.Crit("读取配置文件失败: %v", err)
os.Exit(1)
@@ -37,11 +58,25 @@ func main() {
os.Exit(1)
}
var showVersion bool
var (
showVersion bool
tmpTargetIP string
tmpWindowSec int
tmpShutdownCnt int
tmpPingInt int
tmpOverrideBg bool
tmpStopAfterStr string
tmpStopAtStr string
)
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")
@@ -50,15 +85,94 @@ func main() {
return
}
if !service.Interactive() {
err := svc.Run()
if err != nil {
logger.Fail("后台监控流崩溃: %v", err)
}
return
}
// Interactive foreground mode
logger.SwitchToFrontLog()
if tmpTargetIP != "" {
if net.ParseIP(tmpTargetIP) != nil {
cfg.TargetIP = tmpTargetIP
} else {
logger.Fail("参数解析错误: 非法的 IP 地址 %s", tmpTargetIP)
return
}
}
if tmpWindowSec > 0 { cfg.MonitorWindowSeconds = tmpWindowSec }
if tmpShutdownCnt > 0 { cfg.ShutdownCountdown = tmpShutdownCnt }
if tmpPingInt > 0 { cfg.NormalPingInterval = tmpPingInt }
if tmpOverrideBg {
status, _ := svc.Status()
if status == service.StatusRunning {
logger.Info("检测到后台服务运行中,正在挂起以接管前台监控...")
service.Control(svc, "stop")
defer func() {
logger.Info("前台监控结束,恢复后台服务...")
service.Control(svc, "start")
}()
}
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
importTime := false
var earliest = time.Now().Add(100 * 365 * 24 * time.Hour) // very far
if tmpStopAfterStr != "" {
d, err := time.ParseDuration(tmpStopAfterStr)
if err != nil {
logger.Fail("解析持续时间失败 (如 120m, 2h): %v", err)
return
}
t := time.Now().Add(d)
if t.Before(earliest) { earliest = t; importTime = true }
}
if tmpStopAtStr != "" {
layout := "2006-01-02 15:04:05"
t, err := time.Parse(layout, tmpStopAtStr)
if err != nil {
logger.Fail("解析绝对时间失败 (格式需为 2006-01-02 15:04:05): %v", err)
return
}
if t.Before(earliest) { earliest = t; importTime = true }
}
if importTime {
if earliest.Before(time.Now()) {
logger.Fail("指定的结束时间不能早于当前时间")
cancel()
return
}
var deadlineCancel context.CancelFunc
ctx, deadlineCancel = context.WithDeadline(ctx, earliest)
defer deadlineCancel()
logger.Info("已设定前台监控截止退出时间: %s", earliest.Format("2006-01-02 15:04:05"))
}
logger.Info("开始执行前台监控进程...")
monitor.Run(ctx, cfg)
},
}
rootCommand.CompletionOptions.DisableDefaultCmd = true
rootCommand.Flags().BoolVarP(&showVersion, "version", "V", false, "输出当前二进制内核版本并联网拉取发布树状态")
rootCommand.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "打印底层部署及环境追溯 Debug 信息")
rootCommand.PersistentFlags().StringVarP(&configPath, "config", "c", "", "指定自定义配置文件路径 (如 ./example_config.json)")
rootCommand.Flags().StringVar(&tmpTargetIP, "target-ip", "", "临时覆盖: 目标监控 IP")
rootCommand.Flags().IntVar(&tmpWindowSec, "window-sec", 0, "临时覆盖: 容忍超时时长 (秒)")
rootCommand.Flags().IntVar(&tmpShutdownCnt, "shutdown-cnt", 0, "临时覆盖: 关机倒计时 (秒)")
rootCommand.Flags().IntVar(&tmpPingInt, "ping-interval", 0, "临时覆盖: 发包间隔 (秒)")
rootCommand.Flags().BoolVar(&tmpOverrideBg, "override-bg", false, "挂起后台运行的守护进程,由前台全面接管")
rootCommand.Flags().StringVar(&tmpStopAfterStr, "stop-after", "", "前台运行多少时长后自动退出 (如 120m, 2h)")
rootCommand.Flags().StringVar(&tmpStopAtStr, "stop-at", "", "前台运行至指定时间自动退出 (如 '2026-03-24 23:59:00')")
cmds := []*cobra.Command{
{
@@ -69,7 +183,7 @@ func main() {
currentExe, _ := os.Executable()
if !strings.EqualFold(filepath.Clean(currentExe), filepath.Clean(targetExe)) {
logger.Info("预备自动配置系统级全局环境,本体克隆下放目录: %s", targetExe)
logger.Debug("检测到跨域部署: 待本体克隆存放底层根目录: %s", targetExe)
if err := os.MkdirAll(targetDir, 0755); err != nil {
logger.Fail("创建系统目录核心区路径溃败 (请核查最高系统权限身份执行需求): %v", err)
return
@@ -87,7 +201,7 @@ func main() {
if err != nil {
logger.Warn("改写操作系统的环境变量集合遭受阻拦: %v", err)
} else {
logger.Info("执行路径已完备挂载进 Windows Global PATH 域内,用户可通过全局指引调取 smart-shutdown。")
logger.Debug("成功挂载底层环境变量: Windows Machine Path 追加了 %s 指引。", targetDir)
}
}
}
@@ -105,23 +219,39 @@ func main() {
Short: "废除并移除在册的后台服务、扫除环境关联残留",
Run: func(cmd *cobra.Command, args []string) {
service.Control(svc, "stop")
handleServiceControl(svc, "uninstall")
if !handleServiceControl(svc, "uninstall") {
return
}
targetDir, targetExe := getTargetSystemPath()
currentExe, _ := os.Executable()
isSelfExe := strings.EqualFold(filepath.Clean(currentExe), filepath.Clean(targetExe))
if !strings.EqualFold(filepath.Clean(currentExe), filepath.Clean(targetExe)) {
if _, err := os.Stat(targetExe); err == nil {
logger.Info("查明曾执行过部署注入逻辑,现在开始彻底摘毁并清理目录: %s", targetExe)
os.Remove(targetExe)
// Clean up PATH (Windows)
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 中的指向挂载项业已剥除卸载完成。")
}
// Delete executable
if _, err := os.Stat(targetExe); err == nil {
if isSelfExe && runtime.GOOS == "windows" {
// Windows: cannot delete a running exe, use delayed self-deletion
delCmd := fmt.Sprintf(`ping 127.0.0.1 -n 2 > nul & del "%s" & rmdir "%s"`, targetExe, targetDir)
exec.Command("cmd", "/C", "start", "/min", "cmd", "/C", delCmd).Start()
logger.Debug("已调度延迟自删除任务: %s", targetExe)
} else {
os.Remove(targetExe)
if runtime.GOOS == "windows" {
os.Remove(targetDir)
}
logger.Debug("已移除可执行文件: %s", targetExe)
}
}
}
// Prompt for config and log cleanup
config.ConfirmAndCleanup()
},
},
{
@@ -162,7 +292,7 @@ func main() {
service.Control(svc, "start")
fmt.Println("============ 热更新无感流闭环执行彻底成功! ============")
} else {
fmt.Println("============ 热更核心接替执行完毕!============\n(注: 您的当前终端并不作为真正的宿存执行体。如目前您另行开启了 CMD 窗格在死循环执行此包前台,请人工叉掉那个窗口使其重新载入新版本!)")
fmt.Println("============ 热更核心接替执行完毕!============\n(注: 您的当前终端并不作为真正的宿存执行体。\n如目前您另行开启了 CMD 窗格在死循环执行此包前台,请人工叉掉那个窗口使其重新载入新版本!)")
}
},
},
@@ -170,6 +300,14 @@ func main() {
Use: "start",
Short: "唤起执行守护后台",
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")
},
},
@@ -246,8 +384,63 @@ func main() {
},
}
var pauseCmd = &cobra.Command{
Use: "pause",
Short: "临时休眠后台运行的守护进程(不开启前台监控)",
Run: func(cmd *cobra.Command, args []string) {
if tmpStopAfterStr == "" && tmpStopAtStr == "" {
logger.Fail("必须指定 --stop-after 或 --stop-at 参数才能执行休眠指令")
return
}
var until = time.Now().Add(100 * 365 * 24 * time.Hour)
if tmpStopAfterStr != "" {
d, err := time.ParseDuration(tmpStopAfterStr)
if err != nil {
logger.Fail("解析持续时间失败: %v", err)
return
}
t := time.Now().Add(d)
if t.Before(until) { until = t }
}
if tmpStopAtStr != "" {
layout := "2006-01-02 15:04:05"
t, err := time.Parse(layout, tmpStopAtStr)
if err != nil {
logger.Fail("解析绝对时间失败: %v", err)
return
}
if t.Before(until) { until = t }
}
if until.Before(time.Now()) {
logger.Fail("指定的唤醒时间不能早于当前时间")
return
}
if err := config.SetPause(until); err != nil {
logger.Fail("写入休眠指令失败: %v", err)
return
}
logger.Succ("指令下达成功!后台守护进程将挂机休息至 %s", until.Format("2006-01-02 15:04:05"))
},
}
pauseCmd.Flags().StringVar(&tmpStopAfterStr, "stop-after", "", "休眠多少时长后自动唤醒 (如 120m, 2h)")
pauseCmd.Flags().StringVar(&tmpStopAtStr, "stop-at", "", "休眠至指定时间自动唤醒 (如 '2026-03-24 23:59:00')")
var resumeCmd = &cobra.Command{
Use: "resume",
Short: "撤销休眠指令,立即唤醒后台网络探测",
Run: func(cmd *cobra.Command, args []string) {
config.ClearPause()
logger.Succ("休眠指令已撤销,后台将在下一轮探测周期中重新激活!")
},
}
configCmd.AddCommand(configSetCmd)
rootCommand.AddCommand(configCmd)
rootCommand.AddCommand(pauseCmd)
rootCommand.AddCommand(resumeCmd)
for _, c := range cmds {
rootCommand.AddCommand(c)
@@ -265,18 +458,19 @@ func main() {
}
}
func handleServiceControl(s service.Service, action string) {
func handleServiceControl(s service.Service, action string) bool {
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
return false
}
logger.Crit("后台执行流程组块阻断报错 [%s] : %v", action, err)
return
return false
}
logger.Succ("指令动作 [%s] 解析下发执行完毕,无阻断警告。", action)
return true
}
func printLastLogLines(n int) {

6
example_config.json Normal file
View File

@@ -0,0 +1,6 @@
{
"TargetIP": "192.168.3.1",
"MonitorWindowSeconds": 180,
"ShutdownCountdown": 60,
"NormalPingInterval": 15
}

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 {
return &Config{
TargetIP: "192.168.3.3",
TargetIP: "192.168.3.1",
MonitorWindowSeconds: 180,
ShutdownCountdown: 60,
NormalPingInterval: 15,
@@ -43,7 +43,14 @@ func GetLogDir() string {
func LoadConfig() (*Config, error) {
configDir := GetConfigDir()
configPath := filepath.Join(configDir, "config.json")
return loadConfigFile(configPath)
}
func LoadConfigFrom(path string) (*Config, error) {
return loadConfigFile(path)
}
func loadConfigFile(configPath string) (*Config, error) {
data, err := os.ReadFile(configPath)
if err != nil {
if os.IsNotExist(err) {

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

@@ -0,0 +1,138 @@
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
}
// ConfirmAndCleanup prompts the user to confirm deletion of config and log files separately.
// Non-terminal stdin skips all prompts.
func ConfirmAndCleanup() {
if !isTerminal() {
return
}
scanner := bufio.NewScanner(os.Stdin)
configDir := GetConfigDir()
configPath := filepath.Join(configDir, "config.json")
logDir := GetLogDir()
if _, err := os.Stat(configPath); err == nil {
fmt.Printf("检测到配置文件: %s\n是否清除配置文件[y/N]: ", configPath)
if scanner.Scan() && strings.EqualFold(strings.TrimSpace(scanner.Text()), "y") {
os.Remove(configPath)
fmt.Println("配置文件已清除。")
}
}
if _, err := os.Stat(logDir); err == nil {
fmt.Printf("检测到日志目录: %s\n是否清除日志文件[y/N]: ", logDir)
if scanner.Scan() && strings.EqualFold(strings.TrimSpace(scanner.Text()), "y") {
os.RemoveAll(logDir)
fmt.Println("日志文件已清除。")
}
}
// If configDir is now empty, remove it too
entries, err := os.ReadDir(configDir)
if err == nil && len(entries) == 0 {
os.Remove(configDir)
}
}
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
}
}

52
pkg/config/pause.go Normal file
View File

@@ -0,0 +1,52 @@
package config
import (
"os"
"path/filepath"
"strconv"
"strings"
"time"
)
// SetPause writes a timestamp into pause_until.txt
func SetPause(until time.Time) error {
dir := GetConfigDir()
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
path := filepath.Join(dir, "pause_until.txt")
ts := strconv.FormatInt(until.Unix(), 10)
return os.WriteFile(path, []byte(ts), 0644)
}
// ClearPause removes the pause_until.txt file
func ClearPause() error {
path := filepath.Join(GetConfigDir(), "pause_until.txt")
return os.Remove(path)
}
// IsPaused checks if the service is currently paused via IPC file
func IsPaused() (bool, int64) {
path := filepath.Join(GetConfigDir(), "pause_until.txt")
data, err := os.ReadFile(path)
if err != nil {
return false, 0
}
tsStr := strings.TrimSpace(string(data))
untilUnix, err := strconv.ParseInt(tsStr, 10, 64)
if err != nil {
// Invalid file, clean it up
os.Remove(path)
return false, 0
}
now := time.Now().Unix()
if now < untilUnix {
return true, untilUnix - now
}
// Expired
os.Remove(path)
return false, 0
}

View File

@@ -3,11 +3,10 @@ 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 {
@@ -41,13 +40,17 @@ func (p *program) Stop(s service.Service) error {
return nil
}
func GetService(cfg *config.Config) (service.Service, error) {
func GetService(cfg *config.Config, execPath ...string) (service.Service, error) {
svcConfig := &service.Config{
Name: "SmartNetworkMonitor",
DisplayName: "Smart Network Shutdown Monitor",
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{
cfg: cfg,
}

View File

@@ -12,6 +12,7 @@ import (
)
var fileLogger *lumberjack.Logger
var debugEnabled bool
func InitLogger() error {
logDir := config.GetLogDir()
@@ -33,6 +34,28 @@ func InitLogger() error {
return nil
}
func SwitchToFrontLog() {
if fileLogger != nil {
fileLogger.Close()
}
logDir := config.GetLogDir()
if err := os.MkdirAll(logDir, 0755); err != nil {
logDir = "logs"
}
logFile := filepath.Join(logDir, "network_monitor_front.log")
fileLogger = &lumberjack.Logger{
Filename: logFile,
MaxSize: 10,
MaxBackups: 30,
MaxAge: 30,
Compress: false,
}
}
func EnableDebug() {
debugEnabled = true
}
func writeLog(level, plainPrefix, format string, v ...interface{}) {
msg := fmt.Sprintf(format, v...)
timestamp := time.Now().Format("2006/01/02 15:04:05")
@@ -63,11 +86,19 @@ func getPrefixColor(level string) func(a ...interface{}) string {
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...)
}

View File

@@ -18,6 +18,7 @@ func Run(ctx context.Context, cfg *config.Config) {
var failureStartTime time.Time
var inFailureWindow bool = false
var wasPausedBefore bool = false
normalStatusCounter := 0
const normalStatusLogInterval = 24
@@ -30,6 +31,27 @@ func Run(ctx context.Context, cfg *config.Config) {
default:
}
isPaused, remainingSec := config.IsPaused()
if isPaused {
if !wasPausedBefore {
mins := remainingSec / 60
if mins < 1 {
mins = 1
}
logger.Info("进入管理员特设休眠挂起状态, 预计将在 %d 分钟后自动唤醒", mins)
wasPausedBefore = true
if inFailureWindow {
inFailureWindow = false
normalStatusCounter = 0
}
}
time.Sleep(15 * time.Second)
continue
} else if wasPausedBefore {
logger.Info("休眠指令逾期或已被撤消, 唤醒并恢复网络探测")
wasPausedBefore = false
}
isOnline := pinger.Ping(cfg.TargetIP, 3)
if isOnline {