init for public push

This commit is contained in:
ljp 2025-06-24 11:06:45 +08:00
commit c2d1ac9af5
Signed by: ljp
GPG Key ID: 72439F1F5C7BC795
46 changed files with 7843 additions and 0 deletions

58
.gitignore vendored Normal file
View File

@ -0,0 +1,58 @@
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary, built with `go test -c`
*.test
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
# Dependency directories (remove the comment below to include it)
# vendor/
# Go workspace file
go.work
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Project specific
/dist/
/dist-*/
/build/
/bin/
/tmp/
/logs/
*.log
*.bak
*.backup
# Config files (if they contain sensitive data)
config.local.json
.env
.env.local
# Test coverage
coverage.html
coverage.out
profile.out
# Air live reload
tmp/

152
.goreleaser.yaml Normal file
View File

@ -0,0 +1,152 @@
version: 2
project_name: hostsync
# 构建前的准备工作
before:
hooks:
- go mod tidy
- go generate ./...
builds:
- id: hostsync
binary: "{{ .ProjectName }}"
main: .
env:
- CGO_ENABLED=0
goos:
- linux
- windows
- darwin
goarch:
- "386"
- amd64
- arm
- arm64
goarm:
- "6"
- "7"
ignore:
- goos: darwin
goarch: "386"
- goos: darwin
goarch: arm
- goos: windows
goarch: arm
ldflags:
- -s -w
- -X main.version={{ .Version }}
- -X main.commit={{ .Commit }}
- -X main.date={{ .Date }}
- -X main.builtBy=goreleaser
flags:
- -trimpath
upx:
- enabled: true
# 仅在需要时启用 UPX 压缩
# 注意UPX 压缩可能会导致某些平台不兼容
# 需要确保 UPX 已安装并在 PATH 中
compress: best
# 压缩级别best 为最高压缩率
lzma: true
# 打包配置
archives:
- id: hostsync-archive
name_template: "{{ .ProjectName }}-{{ .Version }}-{{ .Os }}-{{ .Arch }}{{ with .Arm }}v{{ . }}{{ end }}{{ with .Mips }}-{{ . }}{{ end }}{{ if not (eq .Amd64 \"v1\") }}{{ .Amd64 }}{{ end }}"
formats: [binary]
builds_info:
mode: 0755 # 设置可执行文件权限
files:
- LICENSE
- readme.md
- install.sh
- install.ps1
checksum:
name_template: "SHA256SUMS"
algorithm: sha256
changelog:
sort: asc
filters:
exclude:
- "^docs:"
- "^test:"
- "^ci:"
- "^chore:"
- "^style:"
groups:
- title: "🚀 新功能"
regexp: "^.*feat[(\\w)]*:+.*$"
order: 0
- title: "🐛 Bug修复"
regexp: "^.*fix[(\\w)]*:+.*$"
order: 1
- title: "⚡ 性能优化"
regexp: "^.*perf[(\\w)]*:+.*$"
order: 2
- title: "📚 文档更新"
regexp: "^.*docs[(\\w)]*:+.*$"
order: 3
- title: "🔧 其他更新"
order: 999
release:
# 发布配置 - 强大的 Hosts 文件管理工具
name_template: "hostSync {{ .Tag }}"
mode: replace
header: |
## 快速安装
- **Linux/macOS**:
```bash
curl -fsSL https://git.xykqyy.com/ljp/hostSync/raw/branch/main/install.sh | bash
```
- **Windows**:
```powershell
irm "https://git.xykqyy.com/ljp/hostSync/raw/branch/main/install.ps1" | iex
```
- 📖 [完整文档](https://git.xykqyy.com/ljp/hostSync#readme)
- 🐛 [问题反馈](https://git.xykqyy.com/ljp/hostSync/issues)
- 💬 [讨论区](https://git.xykqyy.com/ljp/hostSync/discussions)
> [!IMPORTANT]
> **权限提醒**: 由于需要修改系统 hosts 文件,请确保以适当的权限运行程序。
>
> - **Windows**: 以管理员身份运行
> - **Linux/macOS**: 使用 `sudo` 命令
>
> 在 Linux/macOS 上,初始化后会将二进制文件安装到 `~/.hostsync/` 目录,并自动设置 PATH 环境变量。
> 在 Windows 上,初始化后会将二进制文件安装到 `%USERPROFILE%\.hostsync\` 目录,并自动设置 PATH 环境变量。
>
> 如果需要卸载 HostSync请手动删除 `~/.hostsync/` 或 `%USERPROFILE%\.hostsync\` 目录,并从 PATH 中移除相关路径。
prerelease: auto
footer: |
## 📈 校验和信息
下载文件后,可以使用以下命令验证文件完整性:
```bash
# 下载校验和文件
curl -L "https://git.xykqyy.com/ljp/hostSync/releases/download/{{ .Tag }}/SHA256SUMS" -o "SHA256SUMS"
# 验证文件 (Linux/macOS)
sha256sum -c SHA256SUMS
# 验证文件 (Windows PowerShell)
Get-FileHash .\{{ .ProjectName }}.exe -Algorithm SHA256
```
**感谢使用 HostSync** 🎉
如果您觉得这个工具有用,请给我们一个 ⭐ Star
gitea_urls:
api: https://git.xykqyy.com/api/v1
download: https://git.xykqyy.com

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 evil7@deepwn
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

6
banner.txt Normal file
View File

@ -0,0 +1,6 @@
_ _
| |__ ___ ___| |_ ___ _ _ _ __ ___
| '_ \ / _ \/ __| __/ __| | | | '_ \ / __|
| | | | (_) \__ \ |_\__ \ |_| | | | | (__
|_| |_|\___/|___/\__|___/\__, |_| |_|\___|
by evil7@deepwn |___/

120
build.ps1 Normal file
View File

@ -0,0 +1,120 @@
# .\build.ps1 [name] [version]
# 手动编译脚本,与 GoReleaser 配置保持一致
$binName = $args[0]
if (-Not $binName) {
$binName = 'hostsync' # 项目默认名称
}
# version by args or git tag
$version = $args[1]
if (-Not $version) {
try {
$version = git describe --tags --abbrev=0 2>&1 | Where-Object { $_.GetType().Name -eq 'String' }
# only use version number \d+\.\d+\.\d+ format
if ($version -match 'v(\d+\.\d+\.\d+)') {
$version = "$($matches[1])"
}
else {
$version = $null # 如果没有匹配到版本号,则设置为 null
}
}
catch {
$version = $null
}
}
# use short commit hash if no tag
if (-Not $version) {
$version = "$(git rev-parse --short HEAD)"
}
Write-Host "Building $binName $version" -ForegroundColor Green
# output dir
$outputDir = "dist-${version}"
Remove-Item -Recurse -Force $outputDir -ErrorAction SilentlyContinue | Out-Null
New-Item -ItemType Directory -Path $outputDir -ErrorAction Stop | Out-Null
# check if upx is available
$upxAvailable = $false
try {
$null = Get-Command upx -ErrorAction Stop
$upxAvailable = $true
Write-Host 'UPX detected, will compress binaries after build'
}
catch {
Write-Host 'UPX not found, binaries will not be compressed'
}
# 与 GoReleaser 配置完全一致的平台组合
$targets = @(
# Darwin (macOS) - 忽略 386 和 arm
@{ GOOS = 'darwin'; GOARCH = 'amd64'; GOARM = ''; EXT = ''; Name = 'darwin-amd64' },
@{ GOOS = 'darwin'; GOARCH = 'arm64'; GOARM = ''; EXT = ''; Name = 'darwin-arm64' },
# Linux - 支持所有架构
@{ GOOS = 'linux'; GOARCH = '386'; GOARM = ''; EXT = ''; Name = 'linux-386' },
@{ GOOS = 'linux'; GOARCH = 'amd64'; GOARM = ''; EXT = ''; Name = 'linux-amd64' },
@{ GOOS = 'linux'; GOARCH = 'arm'; GOARM = '6'; EXT = ''; Name = 'linux-armv6' },
@{ GOOS = 'linux'; GOARCH = 'arm'; GOARM = '7'; EXT = ''; Name = 'linux-armv7' },
@{ GOOS = 'linux'; GOARCH = 'arm64'; GOARM = ''; EXT = ''; Name = 'linux-arm64' },
# Windows - 忽略 arm (32位)
@{ GOOS = 'windows'; GOARCH = '386'; GOARM = ''; EXT = '.exe'; Name = 'windows-386' },
@{ GOOS = 'windows'; GOARCH = 'amd64'; GOARM = ''; EXT = '.exe'; Name = 'windows-amd64' },
@{ GOOS = 'windows'; GOARCH = 'arm64'; GOARM = ''; EXT = '.exe'; Name = 'windows-arm64' }
)
foreach ($target in $targets) {
$index = $targets.IndexOf($target)
$total = $targets.Count
$prg = '{0:P0}' -f ($index / $total)
# 构建符合 GoReleaser 规范的文件名
$output = "$outputDir\$binName-$version-$($target.Name)$($target.EXT)"
$time = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
Write-Host -NoNewline ("`r[$prg] Building $output ...").PadRight($host.ui.rawui.windowsize.width / 2, ' ')$time
# 设置环境变量
$env:GOOS = $target.GOOS
$env:GOARCH = $target.GOARCH
if ($target.GOARM) {
$env:GOARM = $target.GOARM
}
else {
$env:GOARM = ''
}
# 构建
try {
$commitHash = git rev-parse --short HEAD 2>&1 | Where-Object { $_.GetType().Name -eq 'String' }
}
catch {
$commitHash = 'unknown'
}
$ldflags = "-s -w -X main.version=$version -X main.commit=$commitHash -X main.date=$(Get-Date -Format 'yyyy-MM-ddTHH:mm:ssZ') -X main.builtBy=manual"
go build -ldflags="$ldflags" -trimpath -o $output # compress with upx if available
if ($upxAvailable) {
$time = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
Write-Host -NoNewline ("`r[$prg] Compressing $output ...").PadRight($host.ui.rawui.windowsize.width / 2, ' ')$time
upx --best --lzma -q $output | Out-Null
}
}
$time = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
Write-Host -NoNewline ("`r[100%] All done.").padright($host.ui.rawui.windowsize.width / 2, ' ')$time
# reset env
$env:GOOS = $env:GOARCH = $env:GOARM = ''
# 生成校验和文件,文件名与 GoReleaser 一致
Write-Host "`n📋 Generating checksums..." -ForegroundColor Yellow
$checksumFile = "$outputDir\SHA256SUMS"
$hash = Get-FileHash -Algorithm SHA256 -Path $outputDir\* | Where-Object { $_.Path -notlike '*SHA256SUMS' }
$hash | ForEach-Object {
$filename = Split-Path $_.Path -Leaf
$_.Hash.ToLower() + ' ' + $filename
} | Out-File -FilePath $checksumFile -Encoding utf8
Write-Host "✅ Build completed. Files generated in $outputDir" -ForegroundColor Green
Write-Host "📁 Total files: $($hash.Count + 1) (including checksum)" -ForegroundColor Cyan

127
build.sh Normal file
View File

@ -0,0 +1,127 @@
#!/usr/bin/env bash
# ./build.sh [name] [version]
# 手动编译脚本,与 GoReleaser 配置保持一致
BIN_NAME=$1
if [ -z "$BIN_NAME" ]; then
BIN_NAME="hostsync" # 项目默认名称
fi
VERSION=$2
if [ -z "$VERSION" ]; then
VERSION=$(git describe --tags --abbrev=0 2>/dev/null)
# only use tags that start with 'v' and match semantic versioning
# then use the number part as version
# e.g. v1.2.3 => 1.2.3
if [[ ! "$VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
VERSION=""
else
VERSION="${VERSION#v}" # 去掉 'v' 前缀
fi
fi
# use short commit hash if no tag
if [ -z "$VERSION" ]; then
VERSION="$(git rev-parse --short HEAD)"
fi
echo "Building $BIN_NAME $VERSION"
OUTPUT_DIR="dist-$VERSION"
# output dir
rm -rf "$OUTPUT_DIR" >/dev/null 2>&1
mkdir -p "$OUTPUT_DIR" >/dev/null 2>&1
# check if upx is available
UPX_AVAILABLE=false
if command -v upx >/dev/null 2>&1; then
UPX_AVAILABLE=true
echo "UPX detected, will compress binaries after build"
else
echo "UPX not found, binaries will not be compressed"
fi
# 与 GoReleaser 配置完全一致的平台组合
declare -A targets=(
# Darwin (macOS) - 忽略 386 和 arm
["darwin-amd64"]=""
["darwin-arm64"]=""
# Linux - 支持所有架构,包括 ARM 版本
["linux-386"]=""
["linux-amd64"]=""
["linux-arm-v6"]="" # goarm=6
["linux-arm-v7"]="" # goarm=7
["linux-arm64"]=""
# Windows - 忽略 arm (32位)
["windows-386"]=".exe"
["windows-amd64"]=".exe"
["windows-arm64"]=".exe"
)
# build
k=1
total=${#targets[@]}
for target_key in "${!targets[@]}"; do
time=$(date "+%Y-%m-%d %H:%M:%S")
# 解析目标平台信息
if [[ "$target_key" == *"-arm-"* ]]; then
# 处理 ARM 版本,如 linux-arm-v6, linux-arm-v7
IFS='-' read -r GOOS GOARCH GOARM_VER <<<"$target_key"
GOARM=${GOARM_VER#v} # 移除 'v' 前缀
NAME_SUFFIX="armv$GOARM"
else
# 普通平台,如 linux-amd64, windows-386
IFS='-' read -r GOOS GOARCH <<<"$target_key"
GOARM=""
NAME_SUFFIX="$GOARCH"
fi
EXT="${targets[$target_key]}"
# 构建符合 GoReleaser 规范的文件名
OUTPUT="$OUTPUT_DIR/${BIN_NAME}-${VERSION}-${GOOS}-${NAME_SUFFIX}${EXT}"
# padding with spaces half of the terminal width
COLUMNS=$(tput cols 2>/dev/null || echo 80)
PADWIDTH=$((COLUMNS / 2))
OUTPUT_MSG=$(printf "%-${PADWIDTH}s" "Building $OUTPUT ...")
prg=$(echo "scale=2; ($k * 100) / $total" | bc 2>/dev/null || echo "$k")
printf "\r[%3.0f%%] %s%s" "$prg" "$OUTPUT_MSG" "$time"
# 设置环境变量并构建
COMMIT=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")
DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
LDFLAGS="-s -w -X main.version=$VERSION -X main.commit=$COMMIT -X main.date=$DATE -X main.builtBy=manual"
env GOOS=$GOOS GOARCH=$GOARCH GOARM=$GOARM go build -ldflags="$LDFLAGS" -trimpath -o "$OUTPUT" >/dev/null 2>&1
# compress with upx if available
if [ "$UPX_AVAILABLE" = true ]; then
OUTPUT_MSG=$(printf "%-${PADWIDTH}s" "Compressing $OUTPUT ...")
time=$(date "+%Y-%m-%d %H:%M:%S")
printf "\r[%3.0f%%] %s%s" "$prg" "$OUTPUT_MSG" "$time"
upx --best --lzma -q "$OUTPUT" >/dev/null 2>&1 || echo -e "\nWarning: Failed to compress $OUTPUT"
fi
k=$((k + 1))
done
printf "\r%-${PADWIDTH}s%s\n" "[100%] All done." "$(date "+%Y-%m-%d %H:%M:%S")"
# reset env
unset BIN_NAME VERSION OUTPUT_DIR targets time GOOS GOARCH GOARM EXT UPX_AVAILABLE
unset COMMIT DATE LDFLAGS OUTPUT_MSG PADWIDTH COLUMNS prg k total target_key GOARM_VER NAME_SUFFIX
# 生成校验和文件,文件名与 GoReleaser 一致
echo ""
echo "📋 Generating checksums..."
CHECKSUM_FILE="$OUTPUT_DIR/SHA256SUMS"
cd "$OUTPUT_DIR" && sha256sum * | grep -v SHA256SUMS >"$(basename "$CHECKSUM_FILE")" && cd ..
echo "✅ Build completed. Files generated in $OUTPUT_DIR"
echo "📁 Total files: $(ls -1 "$OUTPUT_DIR" | wc -l) (including checksum)"

66
cmd/add.go Normal file
View File

@ -0,0 +1,66 @@
package cmd
import (
"os"
"github.com/evil7/hostsync/core"
"github.com/evil7/hostsync/utils"
"github.com/spf13/cobra"
)
// addCmd 添加命令
var addCmd = &cobra.Command{
Use: "add <blockName> <domain> [ip]",
Short: "添加域名记录",
Long: `添加域名记录到指定块如果不存在该块则会自动创建
如果不指定IP地址将自动解析域名
示例:
hostsync add github github.com 140.82.114.3 # 指定IP添加
hostsync add github api.github.com # 自动解析IP添加`,
Args: cobra.RangeArgs(2, 3),
Run: runAdd,
}
func runAdd(cmd *cobra.Command, args []string) {
utils.CheckAdmin()
blockName := args[0]
domain := args[1]
var ip string
if !utils.ValidateBlockName(blockName) {
utils.LogError("无效的块名称: %s", blockName)
utils.LogInfo("块名称只能包含字母、数字、下划线和连字符")
os.Exit(1)
}
hm := core.NewHostsManager()
if err := hm.Load(); err != nil {
utils.LogError("加载hosts文件失败: %v", err)
os.Exit(1)
}
if len(args) == 3 {
// 使用指定的IP
ip = args[2]
} else {
// 自动解析IP
utils.LogInfo("正在解析域名: %s", domain)
resolver := core.NewDNSResolver()
var err error
ip, err = resolver.ResolveDomain(domain, "", "")
if err != nil {
utils.LogError("解析域名失败: %v", err)
os.Exit(1)
}
utils.LogSuccess("解析成功: %s -> %s", domain, ip)
}
if err := hm.AddEntry(blockName, domain, ip); err != nil {
utils.LogError("添加记录失败: %v", err)
os.Exit(1)
}
utils.LogSuccess("已添加记录: %s -> %s (块: %s)", domain, ip, blockName)
}

180
cmd/cron.go Normal file
View File

@ -0,0 +1,180 @@
package cmd
import (
"os"
"sort"
"time"
"github.com/evil7/hostsync/core"
"github.com/evil7/hostsync/utils"
"github.com/robfig/cron/v3"
"github.com/spf13/cobra"
)
// cronCmd 定时任务命令
var cronCmd = &cobra.Command{
Use: "cron [blockName] [cronExpression]",
Short: "管理定时任务", Long: `管理块的定时任务配置使用6字段cron表达式格式支持秒级精度
cron表达式格式: 星期
示例:
hostsync cron # 列出所有定时任务默认
hostsync cron list # 列出所有定时任务
hostsync cron github "0 0 0 * * *" # 设置每日午夜更新
hostsync cron github "*/30 * * * * *" # 每30秒更新一次
hostsync cron github # 清除定时任务
常用cron表达式:
0 0 0 * * * # 每天午夜
0 0 */4 * * * # 每4小时
0 0 9 * * 1 # 每周一上午9点
0 */10 * * * * # 每10分钟
*/30 * * * * * # 每30秒
0 */5 * * * * # 每5分钟
0 0 12 * * * # 每天中午12点`,
Args: cobra.RangeArgs(0, 2), // 修改为允许0-2个参数
Run: runCron,
}
func runCron(cmd *cobra.Command, args []string) {
// 如果没有参数或第一个参数是"list",显示定时任务列表
if len(args) == 0 || (len(args) == 1 && args[0] == "list") {
listCronJobs()
return
}
blockName := args[0]
if !utils.ValidateBlockName(blockName) {
utils.LogError("无效的块名称: %s", blockName)
utils.LogError("块名称只能包含字母、数字、下划线和连字符")
os.Exit(1)
}
// 如果有两个参数先验证cron表达式不需要管理员权限
if len(args) == 2 {
cronExpr := args[1]
cronManager := core.NewCronManager()
if err := cronManager.ValidateCronExpression(cronExpr); err != nil {
utils.LogError("无效的cron表达式: %v", err)
utils.LogInfo("cron表达式格式: 秒 分 时 日 月 星期")
utils.LogInfo("常用示例:")
utils.LogInfo(" \"0 0 0 * * *\" - 每日午夜")
utils.LogInfo(" \"0 */30 * * * *\" - 每30分钟")
utils.LogInfo(" \"*/30 * * * * *\" - 每30秒")
utils.LogInfo("更多信息: https://pkg.go.dev/github.com/robfig/cron")
os.Exit(1)
}
}
// 验证通过后,检查管理员权限
utils.CheckAdmin()
hm := core.NewHostsManager()
if err := hm.Load(); err != nil {
utils.LogError("加载hosts文件失败: %v", err)
os.Exit(1)
}
block := hm.GetBlock(blockName)
if block == nil {
utils.LogWarning("块不存在: %s", blockName)
os.Exit(1)
}
if len(args) == 1 {
// 清除定时任务
clearCronJob(hm, blockName)
} else {
// 设置定时任务
cronExpr := args[1]
setCronJob(hm, blockName, cronExpr)
}
}
func setCronJob(hm *core.HostsManager, blockName, cronExpr string) {
// cron表达式已经在上层验证过了这里直接设置
block := hm.GetBlock(blockName)
block.CronJob = cronExpr
if err := hm.Save(); err != nil {
utils.LogError("保存配置失败: %v", err)
os.Exit(1)
}
utils.LogSuccess("已设置定时任务: %s (%s)", blockName, cronExpr)
}
func clearCronJob(hm *core.HostsManager, blockName string) {
block := hm.GetBlock(blockName)
if block.CronJob == "" {
utils.LogInfo("块 %s 没有设置定时任务", blockName)
return
}
block.CronJob = ""
if err := hm.Save(); err != nil {
utils.LogError("保存配置失败: %v", err)
os.Exit(1)
}
utils.LogSuccess("已清除定时任务: %s", blockName)
}
func listCronJobs() {
hm := core.NewHostsManager()
if err := hm.Load(); err != nil {
utils.LogError("加载hosts文件失败: %v", err)
os.Exit(1)
}
// 收集有定时任务的块名称
var cronBlockNames []string
for name, block := range hm.Blocks {
if block.CronJob != "" {
cronBlockNames = append(cronBlockNames, name)
}
}
if len(cronBlockNames) == 0 {
utils.LogInfo("没有找到任何定时任务")
return
}
// 对块名称进行排序,保持与 list 命令一致
sort.Strings(cronBlockNames)
utils.LogResult("定时任务列表 (%d 个)\n", len(cronBlockNames))
headers := []string{"块名称", "Cron表达式", "最后更新", "下次执行"}
var rows [][]string
for _, name := range cronBlockNames {
block := hm.Blocks[name]
lastUpdate := "从未更新"
if !block.UpdateAt.IsZero() {
lastUpdate = block.UpdateAt.Format("2006-01-02 15:04")
}
// 计算下次执行时间(简化版本)
nextRun := calculateNextRun(block.CronJob)
rows = append(rows, []string{block.Name, block.CronJob, lastUpdate, nextRun})
}
// 设置合适的列宽
columnWidths := []int{12, 20, 17, 20}
utils.FormatTable(headers, rows, columnWidths)
}
// calculateNextRun 计算下次执行时间
func calculateNextRun(cronExpr string) string {
// 使用robfig/cron解析器解析6字段cron表达式
parser := cron.NewParser(cron.Second | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor)
schedule, err := parser.Parse(cronExpr)
if err != nil {
return "解析错误"
}
// 计算下次执行时间
nextTime := schedule.Next(time.Now())
return nextTime.Format("2006-01-02 15:04:05")
}

45
cmd/disable.go Normal file
View File

@ -0,0 +1,45 @@
package cmd
import (
"os"
"github.com/evil7/hostsync/core"
"github.com/evil7/hostsync/utils"
"github.com/spf13/cobra"
)
// disableCmd 禁用命令
var disableCmd = &cobra.Command{
Use: "disable <blockName>",
Short: "禁用指定块",
Long: `禁用指定的配置块其中的域名记录将被注释停用但不删除
示例:
hostsync disable github # 禁用 github `,
Args: cobra.ExactArgs(1),
Run: runDisable,
}
func runDisable(cmd *cobra.Command, args []string) {
utils.CheckAdmin()
blockName := args[0]
if !utils.ValidateBlockName(blockName) {
utils.LogError("无效的块名称: %s", blockName)
utils.LogInfo("块名称只能包含字母、数字、下划线和连字符")
os.Exit(1)
}
hm := core.NewHostsManager()
if err := hm.Load(); err != nil {
utils.LogError("加载hosts文件失败: %v", err)
os.Exit(1)
}
if err := hm.DisableBlock(blockName); err != nil {
utils.LogError("禁用块失败: %v", err)
os.Exit(1)
}
utils.LogSuccess("已禁用块: %s", blockName)
}

45
cmd/enable.go Normal file
View File

@ -0,0 +1,45 @@
package cmd
import (
"os"
"github.com/evil7/hostsync/core"
"github.com/evil7/hostsync/utils"
"github.com/spf13/cobra"
)
// enableCmd 启用命令
var enableCmd = &cobra.Command{
Use: "enable <blockName>",
Short: "启用指定块",
Long: `启用指定的配置块使其中的所有域名记录生效
示例:
hostsync enable github # 启用 github `,
Args: cobra.ExactArgs(1),
Run: runEnable,
}
func runEnable(cmd *cobra.Command, args []string) {
utils.CheckAdmin()
blockName := args[0]
if !utils.ValidateBlockName(blockName) {
utils.LogError("无效的块名称: %s", blockName)
utils.LogInfo("块名称只能包含字母、数字、下划线和连字符")
os.Exit(1)
}
hm := core.NewHostsManager()
if err := hm.Load(); err != nil {
utils.LogError("加载hosts文件失败: %v", err)
os.Exit(1)
}
if err := hm.EnableBlock(blockName); err != nil {
utils.LogError("启用块失败: %v", err)
os.Exit(1)
}
utils.LogSuccess("已启用块: %s", blockName)
}

100
cmd/format.go Normal file
View File

@ -0,0 +1,100 @@
package cmd
import (
"os"
"strings"
"github.com/evil7/hostsync/core"
"github.com/evil7/hostsync/utils"
"github.com/spf13/cobra"
)
// formatCmd 格式化命令
var formatCmd = &cobra.Command{
Use: "format [blockName]",
Short: "格式化配置文件",
Long: `格式化hosts配置文件整理缩进和间距
可以格式化整个文件或指定块
示例:
hostsync format # 格式化整个文件
hostsync format github # 格式化指定块
hostsync format --sort # 格式化并排序`,
Args: cobra.MaximumNArgs(1),
Run: runFormat,
}
var sortEntries bool
func init() {
formatCmd.Flags().BoolVar(&sortEntries, "sort", false, "格式化并排序")
}
func runFormat(cmd *cobra.Command, args []string) {
utils.CheckAdmin()
hm := core.NewHostsManager()
if err := hm.Load(); err != nil {
utils.LogError("加载hosts文件失败: %v\n", err)
os.Exit(1)
}
if len(args) == 0 {
// 格式化整个文件
formatAllBlocks(hm)
} else {
// 格式化指定块
blockName := args[0]
formatBlock(hm, blockName)
}
if err := hm.Save(); err != nil {
utils.LogError("保存文件失败: %v", err)
os.Exit(1)
}
utils.LogSuccess("格式化完成")
}
func formatAllBlocks(hm *core.HostsManager) {
utils.LogInfo("格式化所有块 (%d 个)", len(hm.Blocks))
if sortEntries {
// 排序所有块中的条目
for _, block := range hm.Blocks {
sortBlockEntries(block)
}
utils.LogInfo("已按域名排序")
}
}
func formatBlock(hm *core.HostsManager, blockName string) {
if !utils.ValidateBlockName(blockName) {
utils.LogError("无效的块名称: %s", blockName)
utils.LogInfo("块名称只能包含字母、数字、下划线和连字符")
os.Exit(1)
}
block := hm.GetBlock(blockName)
if block == nil {
utils.LogError("块不存在: %s", blockName)
os.Exit(1)
}
utils.LogInfo("格式化块: %s", blockName)
if sortEntries {
sortBlockEntries(block)
utils.LogInfo("已按域名排序")
}
}
func sortBlockEntries(block *core.HostBlock) {
// 按域名排序
for i := 0; i < len(block.Entries)-1; i++ {
for j := i + 1; j < len(block.Entries); j++ {
if strings.Compare(block.Entries[i].Domain, block.Entries[j].Domain) > 0 {
block.Entries[i], block.Entries[j] = block.Entries[j], block.Entries[i]
}
}
}
}

532
cmd/init.go Normal file
View File

@ -0,0 +1,532 @@
package cmd
import (
"encoding/json"
"fmt"
"os"
"os/user"
"path/filepath"
"runtime"
"strings"
"time"
"github.com/evil7/hostsync/core"
"github.com/evil7/hostsync/service"
"github.com/evil7/hostsync/utils"
"github.com/spf13/cobra"
)
var (
initForce bool
)
// initCmd 初始化命令
var initCmd = &cobra.Command{
Use: "init",
Short: "初始化系统配置",
Long: `初始化HostSync系统配置包括
- 在用户目录下创建 .hostsync 文件夹
- 安装程序到用户目录如果不在用户目录运行则复制
- 设置环境变量和别名方便直接调用
- 解析现有hosts文件现有内容归入default块
- 创建配置文件和服务器配置
- 注册系统服务Windows服务/Linux systemd
示例:
hostsync init # 初始化系统
hostsync init --force # 强制重新初始化`,
Run: runInit,
}
func init() {
initCmd.Flags().BoolVar(&initForce, "force", false, "强制重新初始化")
}
func runInit(cmd *cobra.Command, args []string) {
utils.CheckAdmin()
utils.LogInfo("开始初始化HostSync (force=%t)", initForce)
// 1. 获取用户目录和程序安装路径
userDir, installDir, err := getInstallPaths()
if err != nil {
utils.LogError("获取安装路径失败: %v", err)
utils.LogError("获取安装路径失败: %v", err)
os.Exit(1)
}
utils.LogInfo("用户配置目录: %s", userDir)
utils.LogInfo("程序安装目录: %s", installDir)
// 2. 创建用户配置目录
if err := createUserConfigDirs(userDir); err != nil {
utils.LogError("创建用户配置目录失败: %v", err)
os.Exit(1)
}
// 3. 安装程序文件到用户目录
if err := installProgram(installDir); err != nil {
utils.LogError("安装程序失败: %v", err)
os.Exit(1)
}
// 4. 设置环境变量和别名
if err := setupEnvironment(installDir); err != nil {
utils.LogError("设置环境失败: %v", err)
utils.LogInfo("💡 提示: 你可能需要手动将程序目录添加到 PATH 环境变量")
}
// 5. 创建配置文件
if err := createConfigFiles(userDir); err != nil {
utils.LogError("创建配置文件失败: %v", err)
os.Exit(1)
} // 6. 解析现有hosts文件
if err := parseExistingHosts(); err != nil {
utils.LogError("解析hosts文件失败: %v", err)
os.Exit(1)
}
// 7. 注册系统服务
if err := registerSystemService(installDir); err != nil {
if initForce {
utils.LogError("注册系统服务失败: %v", err)
utils.LogInfo("💡 提示: 可以稍后手动使用 'hostsync service install' 安装服务")
} else {
utils.LogError("注册系统服务失败: %v", err)
utils.LogInfo("💡 提示: 使用 'hostsync init --force' 强制重新初始化服务")
utils.LogInfo("💡 或者稍后手动使用 'hostsync service install' 安装服务")
}
} else {
utils.LogSuccess("系统服务注册成功")
}
utils.LogSuccess("HostSync 初始化完成!")
utils.LogInfo("接下来你可以:")
utils.LogInfo(" - 使用 'hostsync list' 查看现有配置")
utils.LogInfo(" - 使用 'hostsync add <block> <domain>' 添加新域名")
utils.LogInfo(" - 使用 'hostsync service status' 查看服务状态")
if runtime.GOOS == "windows" {
utils.LogWarning("重启命令行窗口以生效环境变量设置")
}
}
func createConfigFiles(userDir string) error {
utils.LogInfo("创建配置文件...")
configDir := filepath.Join(userDir, "config")
// 创建主配置文件 - 使用JSON编码避免转义问题
config := map[string]interface{}{
"hostsPath": utils.GetHostsPath(),
"backupCount": 5,
"dnsTimeout": 5000,
"maxConcurrent": 10,
"logLevel": "info",
"LogPath": filepath.Join(userDir, "logs"),
}
configBytes, err := json.MarshalIndent(config, "", " ")
if err != nil {
return fmt.Errorf("生成配置文件内容失败: %v", err)
}
configPath := filepath.Join(configDir, "config.json")
if !utils.FileExists(configPath) || initForce {
if err := utils.WriteFile(configPath, configBytes); err != nil {
return fmt.Errorf("创建配置文件失败: %v", err)
}
}
// 创建DNS服务器配置
serversPath := filepath.Join(configDir, "servers.json")
if !utils.FileExists(serversPath) || initForce {
// 创建默认DNS服务器配置
serversContent := `[
{
"Name": "Cloudflare",
"Dns": "1.1.1.1",
"Doh": "https://1.1.1.1/dns-query"
},
{
"Name": "Google",
"Dns": "8.8.8.8",
"Doh": "https://8.8.4.4/dns-query"
},
{
"Name": "OneDNS",
"Dns": "117.50.10.10",
"Doh": "https://doh.onedns.net/dns-query"
},
{
"Name": "AdGuard",
"Dns": "94.140.14.14",
"Doh": "https://94.140.14.14/dns-query"
},
{
"Name": "Yandex",
"Dns": "77.88.8.8",
"Doh": "https://77.88.8.8/dns-query"
},
{
"Name": "DNSPod",
"Dns": "119.29.29.29",
"Doh": "https://doh.pub/dns-query"
},
{
"Name": "dns.sb",
"Dns": "185.222.222.222",
"Doh": "https://185.222.222.222/dns-query"
},
{
"Name": "Quad101",
"Dns": "101.101.101.101",
"Doh": "https://101.101.101.101/dns-query"
},
{
"Name": "Quad9",
"Dns": "9.9.9.9",
"Doh": "https://9.9.9.9/dns-query"
},
{
"Name": "OpenDNS",
"Dns": "208.67.222.222",
"Doh": "https://208.67.222.222/dns-query"
},
{
"Name": "AliDNS",
"Dns": "223.5.5.5",
"Doh": "https://223.5.5.5/dns-query"
},
{
"Name": "Applied",
"Dns": "",
"Doh": "https://doh.applied-privacy.net/query"
},
{
"Name": "cira.ca",
"Dns": "",
"Doh": "https://private.canadianshield.cira.ca/dns-query"
},
{
"Name": "ControlD",
"Dns": "76.76.2.0",
"Doh": "https://dns.controld.com/p0"
},
{
"Name": "switch.ch",
"Dns": "",
"Doh": "https://dns.switch.ch/dns-query"
},
{
"Name": "Dnswarden",
"Dns": "",
"Doh": "https://dns.dnswarden.com/uncensored"
},
{
"Name": "OSZX",
"Dns": "217.160.156.119",
"Doh": "https://dns.oszx.co/dns-query"
},
{
"Name": "Tiarap",
"Dns": "174.138.21.128",
"Doh": "https://doh.tiar.app/dns-query"
}
]
`
if err := utils.WriteFile(serversPath, []byte(serversContent)); err != nil {
return fmt.Errorf("创建DNS服务器配置失败: %v", err)
}
}
utils.LogSuccess("配置文件创建完成: %s", configDir)
return nil
}
func parseExistingHosts() error {
utils.LogInfo("解析现有hosts文件...")
hm := core.NewHostsManager()
if err := hm.Load(); err != nil {
return err
}
// 如果没有找到任何块说明这是一个全新的hosts文件
if len(hm.Blocks) == 0 {
utils.LogInfo("💡 检测到全新的hosts文件已准备就绪")
return nil
}
utils.LogSuccess("hosts文件解析完成发现 %d 个配置块", len(hm.Blocks))
return nil
}
func registerSystemService(installDir string) error {
utils.LogInfo("注册系统服务...")
// 获取目标执行文件路径
var execPath string
if runtime.GOOS == "windows" {
execPath = filepath.Join(installDir, "hostsync.exe")
} else {
execPath = filepath.Join(installDir, "hostsync")
}
// 确保文件存在
if !utils.FileExists(execPath) {
return fmt.Errorf("执行文件不存在: %s", execPath)
}
serviceManager := service.NewServiceManager()
// 如果是强制初始化,确保完全停止和卸载现有服务
if initForce {
utils.LogInfo("强制模式:确保服务完全清理...")
// 先检查服务状态(可能在 installProgram 中已经卸载了)
status, err := serviceManager.Status()
if err == nil && status != service.StatusNotInstalled {
// 服务仍然存在,需要停止和卸载
utils.LogInfo("停止现有服务...")
if err := serviceManager.Stop(); err != nil {
utils.LogWarning("停止服务时遇到问题: %v", err) // 继续尝试卸载,可能服务已经停止
} else {
utils.LogSuccess("服务已停止")
}
// 等待服务完全停止
utils.LogInfo("等待服务完全停止...")
time.Sleep(2 * time.Second)
// 卸载服务
utils.LogInfo("卸载现有服务...")
if err := serviceManager.Uninstall(); err != nil {
return fmt.Errorf("卸载服务失败: %v", err)
}
utils.LogSuccess("服务已卸载")
// 再次等待,确保系统完全释放相关资源
utils.LogInfo("等待系统释放资源...")
time.Sleep(3 * time.Second)
} else {
utils.LogInfo("服务已清理或未安装")
}
}
// 安装服务
utils.LogInfo("安装系统服务...")
if err := serviceManager.Install(execPath); err != nil {
// 如果不是强制模式且服务已存在,这是正常情况
if !initForce && strings.Contains(err.Error(), "已存在") {
utils.LogInfo("💡 服务已存在,跳过安装")
// 即使服务已存在,也尝试启动它
if err := serviceManager.Start(); err != nil {
utils.LogWarning("启动服务失败: %v", err)
} else {
utils.LogSuccess("服务已启动")
}
return nil
}
return fmt.Errorf("安装服务失败: %v", err)
}
// 安装成功后自动启动服务
utils.LogInfo("启动系统服务...")
if err := serviceManager.Start(); err != nil {
utils.LogWarning("启动服务失败: %v", err)
utils.LogInfo("💡 提示: 可以稍后手动使用 'hostsync service start' 启动服务")
} else {
utils.LogSuccess("系统服务已启动")
}
return nil
}
// getInstallPaths 获取安装路径
func getInstallPaths() (userDir, installDir string, err error) {
// 获取当前用户目录
currentUser, err := user.Current()
if err != nil {
return "", "", fmt.Errorf("获取当前用户信息失败: %v", err)
}
// 用户配置目录
userDir = filepath.Join(currentUser.HomeDir, ".hostsync")
// 程序安装目录(在用户目录下)
installDir = filepath.Join(userDir, "bin")
return userDir, installDir, nil
}
// createUserConfigDirs 创建用户配置目录
func createUserConfigDirs(userDir string) error {
utils.LogInfo("创建用户配置目录...")
dirs := []string{
userDir,
filepath.Join(userDir, "bin"),
filepath.Join(userDir, "config"),
filepath.Join(userDir, "logs"),
filepath.Join(userDir, "backup"),
}
for _, dir := range dirs {
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("创建目录 %s 失败: %v", dir, err)
}
}
utils.LogSuccess("用户配置目录创建完成: %s", userDir)
return nil
}
// installProgram 安装程序到用户目录
func installProgram(installDir string) error {
utils.LogInfo("安装程序文件...")
// 获取当前执行文件路径
currentExecPath, err := os.Executable()
if err != nil {
return fmt.Errorf("获取当前执行文件路径失败: %v", err)
}
currentExecPath, err = filepath.Abs(currentExecPath)
if err != nil {
return fmt.Errorf("获取绝对路径失败: %v", err)
}
// 目标执行文件路径
var targetExecPath string
if runtime.GOOS == "windows" {
targetExecPath = filepath.Join(installDir, "hostsync.exe")
} else {
targetExecPath = filepath.Join(installDir, "hostsync")
}
// 检查是否已经在目标位置运行
if currentExecPath == targetExecPath {
utils.LogSuccess("程序已在目标位置运行")
return nil
}
serviceManager := service.NewServiceManager()
// 在强制模式下,确保服务完全停止和卸载后再进行文件操作
if initForce {
utils.LogInfo("强制模式:确保服务完全停止...")
// 检查服务状态
status, err := serviceManager.Status()
if err == nil && status != service.StatusNotInstalled {
// 停止服务
utils.LogInfo("停止现有服务...")
if err := serviceManager.Stop(); err != nil {
utils.LogWarning("停止服务时遇到问题: %v", err)
} else {
utils.LogSuccess("服务已停止")
}
// 等待服务完全停止
utils.LogInfo("等待服务完全停止...")
time.Sleep(2 * time.Second)
// 卸载服务以释放文件句柄
utils.LogInfo("临时卸载服务以释放文件...")
if err := serviceManager.Uninstall(); err != nil {
utils.LogWarning("卸载服务时遇到问题: %v", err)
} else {
utils.LogSuccess("服务已临时卸载")
}
// 再次等待,确保系统完全释放文件句柄
utils.LogInfo("等待系统释放文件句柄...")
time.Sleep(3 * time.Second)
}
} else {
// 非强制模式下,只是尝试停止服务
utils.LogInfo("检查并停止现有服务...")
if err := serviceManager.Stop(); err == nil {
utils.LogSuccess("已停止现有服务")
// 给一点时间让服务完全停止
time.Sleep(1 * time.Second)
}
}
// 复制程序文件到目标位置
if err := copyFile(currentExecPath, targetExecPath); err != nil {
return fmt.Errorf("复制程序文件失败: %v", err)
}
// 设置执行权限
if runtime.GOOS != "windows" {
if err := os.Chmod(targetExecPath, 0755); err != nil {
return fmt.Errorf("设置执行权限失败: %v", err)
}
}
utils.LogSuccess("程序已安装到: %s", targetExecPath)
return nil
}
// setupEnvironment 设置环境变量
func setupEnvironment(installDir string) error {
utils.LogInfo("检查并设置环境变量...")
// 首先检查当前环境是否已包含安装路径
if isPathInEnvironment(installDir) {
utils.LogSuccess("PATH环境变量已包含程序目录: %s", installDir)
return nil
}
utils.LogInfo("💡 需要将程序目录添加到 PATH 环境变量: %s", installDir)
if runtime.GOOS == "windows" {
return setupWindowsEnvironment(installDir)
} else {
return setupUnixEnvironment(installDir)
}
}
// isPathInEnvironment 检查指定路径是否已在PATH环境变量中
func isPathInEnvironment(checkPath string) bool {
pathEnv := os.Getenv("PATH")
if pathEnv == "" {
return false
}
var separator string
if runtime.GOOS == "windows" {
separator = ";"
} else {
separator = ":"
}
paths := strings.Split(pathEnv, separator)
for _, path := range paths {
// 规范化路径进行比较
if normalizePath(path) == normalizePath(checkPath) {
return true
}
}
return false
}
// normalizePath 规范化路径用于比较
func normalizePath(path string) string {
absPath, err := filepath.Abs(strings.TrimSpace(path))
if err != nil {
return strings.TrimSpace(path)
}
return absPath
}
// copyFile 复制文件
func copyFile(src, dst string) error {
srcFile, err := os.Open(src)
if err != nil {
return err
}
defer srcFile.Close()
dstFile, err := os.Create(dst)
if err != nil {
return err
}
defer dstFile.Close()
_, err = dstFile.ReadFrom(srcFile)
return err
}

84
cmd/init_unix.go Normal file
View File

@ -0,0 +1,84 @@
//go:build !windows
// +build !windows
package cmd
import (
"fmt"
"os"
"os/user"
"path/filepath"
"runtime"
"strings"
"github.com/evil7/hostsync/utils"
)
// setupWindowsEnvironment 在非 Windows 平台上不执行任何操作
func setupWindowsEnvironment(installDir string) error {
return fmt.Errorf("Windows 特定功能不支持当前平台 (%s)", runtime.GOOS)
}
// setupUnixEnvironment 设置Unix/Linux环境
func setupUnixEnvironment(installDir string) error {
utils.LogInfo("配置Unix/Linux环境变量...")
currentUser, err := user.Current()
if err != nil {
return fmt.Errorf("获取当前用户信息失败: %v", err)
}
// 检查常见的shell配置文件
shellRcFiles := []string{
filepath.Join(currentUser.HomeDir, ".bashrc"),
filepath.Join(currentUser.HomeDir, ".zshrc"),
filepath.Join(currentUser.HomeDir, ".profile"),
}
exportLine := fmt.Sprintf("export PATH=\"%s:$PATH\"", installDir)
updated := false
for _, rcFile := range shellRcFiles {
if !utils.FileExists(rcFile) {
continue
}
// 读取文件内容
content, err := os.ReadFile(rcFile)
if err != nil {
utils.LogWarning("读取 %s 失败: %v", rcFile, err)
continue
}
// 检查是否已经添加过
if strings.Contains(string(content), installDir) {
utils.LogSuccess("%s 已包含程序路径", rcFile)
updated = true
continue
}
// 添加PATH导出
f, err := os.OpenFile(rcFile, os.O_APPEND|os.O_WRONLY, 0644)
if err != nil {
utils.LogWarning("打开 %s 失败: %v", rcFile, err)
continue
}
f.WriteString("\n# HostSync PATH\n")
f.WriteString(exportLine + "\n")
f.Close()
utils.LogSuccess("已添加PATH到 %s", rcFile)
updated = true
}
if !updated {
utils.LogWarning("未找到shell配置文件请手动将以下路径添加到PATH")
utils.LogInfo(" %s", installDir)
utils.LogInfo("💡 可以在 ~/.bashrc 或 ~/.zshrc 中添加:")
utils.LogInfo(" %s", exportLine)
} else {
utils.LogInfo("💡 请重新加载shell配置或重启终端以使环境变量生效")
}
return nil
}

131
cmd/init_windows.go Normal file
View File

@ -0,0 +1,131 @@
//go:build windows
// +build windows
package cmd
import (
"fmt"
"os/exec"
"strings"
"unsafe"
"github.com/evil7/hostsync/utils"
"golang.org/x/sys/windows"
"golang.org/x/sys/windows/registry"
)
// setWindowsUserPathEnvironment 使用Windows Registry API设置用户PATH环境变量
func setWindowsUserPathEnvironment(installDir string) error {
// 打开用户环境变量注册表键
key, err := registry.OpenKey(registry.CURRENT_USER, `Environment`, registry.QUERY_VALUE|registry.SET_VALUE)
if err != nil {
return fmt.Errorf("打开用户环境变量注册表失败: %v", err)
}
defer key.Close()
// 读取当前PATH值
currentPath, _, err := key.GetStringValue("PATH")
if err != nil && err != registry.ErrNotExist {
return fmt.Errorf("读取当前PATH环境变量失败: %v", err)
}
// 检查是否已经包含目标路径
if strings.Contains(strings.ToLower(currentPath), strings.ToLower(installDir)) {
utils.LogSuccess("PATH环境变量已包含目标路径: %s", installDir)
return nil
}
// 构建新的PATH值
var newPath string
if currentPath == "" {
newPath = installDir
} else {
newPath = currentPath + ";" + installDir
}
// 设置新的PATH值
err = key.SetStringValue("PATH", newPath)
if err != nil {
return fmt.Errorf("设置PATH环境变量失败: %v", err)
}
// 广播环境变量变更消息
return broadcastEnvironmentChange()
}
// broadcastEnvironmentChange 广播环境变量变更消息
func broadcastEnvironmentChange() error {
// 使用Windows API广播WM_SETTINGCHANGE消息
const (
HWND_BROADCAST = 0xFFFF
WM_SETTINGCHANGE = 0x001A
SMTO_NORMAL = 0x0000
)
envPtr, err := windows.UTF16PtrFromString("Environment")
if err != nil {
return fmt.Errorf("转换环境变量字符串失败: %v", err)
}
user32 := windows.NewLazyDLL("user32.dll")
sendMessageTimeout := user32.NewProc("SendMessageTimeoutW")
ret, _, err := sendMessageTimeout.Call(
uintptr(HWND_BROADCAST),
uintptr(WM_SETTINGCHANGE),
0,
uintptr(unsafe.Pointer(envPtr)),
uintptr(SMTO_NORMAL),
uintptr(5000), // 5秒超时
0,
)
if ret == 0 {
return fmt.Errorf("广播环境变量变更消息失败: %v", err)
}
return nil
}
// setupWindowsEnvironmentFallback PowerShell回退方法
func setupWindowsEnvironmentFallback(installDir string) error {
// 尝试使用PowerShell自动设置用户PATH环境变量
psCmd := fmt.Sprintf(`$userPath = [Environment]::GetEnvironmentVariable('PATH', 'User'); if ($userPath -eq $null) { $userPath = '' }; if ($userPath -notlike '*%s*') { if ($userPath -ne '') { $newPath = $userPath + ';%s' } else { $newPath = '%s' }; [Environment]::SetEnvironmentVariable('PATH', $newPath, 'User'); Write-Host '✅ PATH环境变量已更新' } else { Write-Host '✅ PATH环境变量已包含目标路径' }`, installDir, installDir, installDir)
// 尝试执行PowerShell命令
cmd := exec.Command("powershell.exe", "-Command", psCmd)
output, err := cmd.CombinedOutput()
if err != nil {
utils.LogWarning("自动设置环境变量失败: %v", err)
utils.LogInfo("\n💡 请手动将以下路径添加到用户 PATH 环境变量:")
utils.LogInfo(" %s", installDir)
utils.LogInfo("💡 设置方法: 系统设置 → 高级系统设置 → 环境变量 → 用户变量中的PATH")
return nil
}
utils.LogInfo(strings.TrimSpace(string(output)))
utils.LogWarning("💡 请重启命令行窗口以使环境变量生效")
return nil
}
// setupWindowsEnvironment 设置Windows环境
func setupWindowsEnvironment(installDir string) error {
utils.LogInfo("配置Windows用户环境变量...")
// 使用 Windows API 直接设置用户环境变量
err := setWindowsUserPathEnvironment(installDir)
if err != nil {
// 如果系统API调用失败回退到PowerShell方法
utils.LogWarning("使用系统API设置环境变量失败: %v", err)
utils.LogInfo("尝试使用PowerShell方法...")
return setupWindowsEnvironmentFallback(installDir)
}
utils.LogSuccess("PATH环境变量已成功更新")
utils.LogWarning("💡 请重启命令行窗口以使环境变量生效")
return nil
}
// setupUnixEnvironment 在 Windows 平台上不执行任何操作
func setupUnixEnvironment(installDir string) error {
return fmt.Errorf("Unix/Linux 特定功能不支持当前平台 (windows)")
}

269
cmd/list.go Normal file
View File

@ -0,0 +1,269 @@
package cmd
import (
"fmt"
"os"
"sort"
"github.com/evil7/hostsync/core"
"github.com/evil7/hostsync/utils"
"github.com/spf13/cobra"
)
var (
rawOutput bool
)
// listCmd 列表命令
var listCmd = &cobra.Command{
Use: "list [blockName]",
Short: "查看 Hosts 配置",
Long: `查看 Hosts 配置可以列出所有块或指定块的配置
示例:
hostsync list # 列出所有块配置
hostsync list github # 列出指定块配置
hostsync list --raw # 原始格式输出便于导出`,
Run: runList,
}
func init() {
listCmd.Flags().BoolVar(&rawOutput, "raw", false, "原始格式输出")
}
func runList(cmd *cobra.Command, args []string) {
hm := core.NewHostsManager()
if err := hm.Load(); err != nil {
utils.LogError("加载hosts文件失败: %v", err)
os.Exit(1)
}
if len(args) == 0 {
// 列出所有块
listAllBlocks(hm)
} else {
// 列出指定块
blockName := args[0]
listBlock(hm, blockName)
}
}
func listAllBlocks(hm *core.HostsManager) {
if len(hm.Blocks) == 0 {
utils.LogInfo("没有找到任何配置块")
return
}
if rawOutput {
// 获取排序后的块名称列表用于原始输出
var blockNames []string
for name := range hm.Blocks {
blockNames = append(blockNames, name)
}
sort.Strings(blockNames)
// 原始格式输出
for _, name := range blockNames {
block := hm.Blocks[name]
utils.LogResult("# %s:\n", name)
for _, entry := range block.Entries {
prefix := ""
if !entry.Enabled {
prefix = "# "
}
if entry.Comment != "" {
utils.LogResult("%s%-16s %s # %s\n", prefix, entry.IP, entry.Domain, entry.Comment)
} else {
utils.LogResult("%s%-16s %s\n", prefix, entry.IP, entry.Domain)
}
}
if block.DNS != "" {
utils.LogResult("# useDns: %s\n", block.DNS)
}
if block.DoH != "" {
utils.LogResult("# useDoh: %s\n", block.DoH)
}
if block.Server != "" {
utils.LogResult("# useSrv: %s\n", block.Server)
}
if block.CronJob != "" {
utils.LogResult("# cronJob: %s\n", block.CronJob)
}
if !block.UpdateAt.IsZero() {
utils.LogResult("# updateAt: %s\n", block.UpdateAt.Format("2006-01-02 15:04:05"))
}
utils.LogResult("# %s;\n\n", name)
}
return
}
// 表格格式输出
headers := []string{"块名称", "状态", "条目数", "DNS配置", "最后更新"}
var rows [][]string
// 获取排序后的块名称列表
var blockNames []string
for name := range hm.Blocks {
blockNames = append(blockNames, name)
}
sort.Strings(blockNames)
for _, name := range blockNames {
block := hm.Blocks[name]
// 计算启用的条目数
enabledCount := 0
for _, entry := range block.Entries {
if entry.Enabled {
enabledCount++
}
}
// 根据块状态和条目状态确定显示状态
var status string
if len(block.Entries) == 0 {
status = "空块"
} else if enabledCount == 0 {
status = "全部禁用"
} else if enabledCount == len(block.Entries) {
status = "全部启用"
} else {
status = "部分启用"
}
entryInfo := fmt.Sprintf("%d/%d", enabledCount, len(block.Entries))
dnsConfig := ""
if block.DNS != "" {
dnsConfig = fmt.Sprintf("DNS: %s", block.DNS)
} else if block.DoH != "" {
dnsConfig = fmt.Sprintf("DoH: %s", utils.TruncateWithWidth(block.DoH, 30))
} else if block.Server != "" {
dnsConfig = fmt.Sprintf("服务器: %s", block.Server)
} else {
dnsConfig = "系统默认"
}
updateTime := "从未更新"
if !block.UpdateAt.IsZero() {
updateTime = block.UpdateAt.Format("2006-01-02 15:04")
}
rows = append(rows, []string{name, status, entryInfo, dnsConfig, updateTime})
}
utils.LogInfo("Hosts 配置块列表 (%d 个块)\n", len(hm.Blocks))
// 设置合适的列宽
columnWidths := []int{12, 12, 8, 35, 17}
utils.FormatTable(headers, rows, columnWidths)
}
func listBlock(hm *core.HostsManager, blockName string) {
block := hm.GetBlock(blockName)
if block == nil {
utils.LogError("块不存在: %s", blockName)
os.Exit(1)
}
if rawOutput {
// 原始格式输出
fmt.Printf("# %s:\n", blockName)
for _, entry := range block.Entries {
prefix := ""
if !entry.Enabled {
prefix = "# "
}
if entry.Comment != "" {
fmt.Printf("%s%-16s %s # %s\n", prefix, entry.IP, entry.Domain, entry.Comment)
} else {
fmt.Printf("%s%-16s %s\n", prefix, entry.IP, entry.Domain)
}
}
if block.DNS != "" {
fmt.Printf("# useDns: %s\n", block.DNS)
}
if block.DoH != "" {
fmt.Printf("# useDoh: %s\n", block.DoH)
}
if block.Server != "" {
fmt.Printf("# useSrv: %s\n", block.Server)
}
if block.CronJob != "" {
fmt.Printf("# cronJob: %s\n", block.CronJob)
}
if !block.UpdateAt.IsZero() {
fmt.Printf("# updateAt: %s\n", block.UpdateAt.Format("2006-01-02 15:04:05"))
}
fmt.Printf("# %s;\n", blockName)
return
}
// 计算启用的条目数
enabledCount := 0
for _, entry := range block.Entries {
if entry.Enabled {
enabledCount++
}
}
// 详细信息显示
fmt.Printf("名称: %s\n", blockName)
// 根据块状态和条目状态确定显示状态
if !block.Enabled {
fmt.Printf("状态: 禁用\n")
} else if len(block.Entries) == 0 {
fmt.Printf("状态: 空块\n")
} else if enabledCount == 0 {
fmt.Printf("状态: 全部禁用 (0/%d)\n", len(block.Entries))
} else if enabledCount == len(block.Entries) {
fmt.Printf("状态: 全部启用 (%d/%d)\n", enabledCount, len(block.Entries))
} else {
fmt.Printf("状态: 部分启用 (%d/%d)\n", enabledCount, len(block.Entries))
}
if block.DNS != "" {
fmt.Printf("DNS服务器: %s\n", block.DNS)
}
if block.DoH != "" {
fmt.Printf("DoH服务器: %s\n", block.DoH)
}
if block.Server != "" {
fmt.Printf("预设服务器: %s\n", block.Server)
}
if block.CronJob != "" {
fmt.Printf("定时任务: %s\n", block.CronJob)
}
if !block.UpdateAt.IsZero() {
fmt.Printf("最后更新: %s\n", block.UpdateAt.Format("2006-01-02 15:04:05"))
}
if len(block.Entries) == 0 {
utils.LogInfo("\n没有域名记录")
return
}
utils.LogInfo("\n域名记录 (%d 个条目):\n", len(block.Entries))
// 域名记录表格
headers := []string{"状态", "IP地址", "域名", "备注"}
var rows [][]string
for _, entry := range block.Entries {
status := "启用"
if !entry.Enabled {
status = "禁用"
}
comment := entry.Comment
if comment == "" {
comment = "-"
}
rows = append(rows, []string{status, entry.IP, entry.Domain, comment})
}
// 设置合适的列宽
columnWidths := []int{8, 16, 30, 20}
utils.FormatTable(headers, rows, columnWidths)
}

346
cmd/log.go Normal file
View File

@ -0,0 +1,346 @@
package cmd
import (
"bufio"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/evil7/hostsync/config"
"github.com/evil7/hostsync/utils"
"github.com/spf13/cobra"
)
var (
logLines int
logFollow bool
logLevel string
)
// logCmd 日志命令
var logCmd = &cobra.Command{
Use: "log",
Short: "查看和管理日志",
Long: `查看和管理HostSync的日志文件
示例:
hostsync log # 查看最近50行日志
hostsync log -n 100 # 查看最近100行日志
hostsync log -f # 实时跟踪日志
hostsync log --level error # 只显示错误级别的日志
hostsync log list # 列出所有日志文件`,
Run: runLogShow,
}
// logListCmd 列出日志文件命令
var logListCmd = &cobra.Command{
Use: "list",
Short: "列出所有日志文件",
Long: `列出日志目录中的所有日志文件及其大小和修改时间。`,
Run: runLogList,
}
// logClearCmd 清理日志命令
var logClearCmd = &cobra.Command{
Use: "clear",
Short: "清理旧日志文件",
Long: `清理日志目录中的旧日志备份文件,保留当前日志和最近的备份。`,
Run: runLogClear,
}
func init() {
logCmd.Flags().IntVarP(&logLines, "lines", "n", 50, "显示的行数")
logCmd.Flags().BoolVarP(&logFollow, "follow", "f", false, "实时跟踪日志输出")
logCmd.Flags().StringVar(&logLevel, "level", "", "过滤日志级别 (debug,info,warning,error)")
logCmd.AddCommand(logListCmd)
logCmd.AddCommand(logClearCmd)
}
func runLogShow(cmd *cobra.Command, args []string) {
// 初始化配置以获取日志路径
config.Init()
if config.AppConfig == nil || config.AppConfig.LogPath == "" {
utils.LogError("未找到日志配置")
return
}
logFile := filepath.Join(config.AppConfig.LogPath, "hostsync.log")
// 检查日志文件是否存在
if _, err := os.Stat(logFile); os.IsNotExist(err) {
utils.LogInfo("日志文件尚未创建")
utils.LogInfo("日志文件路径: %s", logFile)
utils.LogInfo("执行一些命令后,日志文件将自动创建")
utils.LogInfo("例如: hostsync list, hostsync add test example.com 等")
return
}
if logFollow {
// 实时跟踪模式
followLog(logFile)
} else {
// 显示最近的日志
showRecentLog(logFile, logLines)
}
}
func runLogList(cmd *cobra.Command, args []string) {
// 初始化配置
config.Init()
if config.AppConfig == nil || config.AppConfig.LogPath == "" {
utils.LogError("未找到日志配置")
return
}
logDir := config.AppConfig.LogPath
// 读取日志目录
files, err := os.ReadDir(logDir)
if err != nil {
utils.LogError("读取日志目录失败: %v", err)
return
}
// 过滤和排序日志文件
var logFiles []os.FileInfo
for _, file := range files {
if strings.HasPrefix(file.Name(), "hostsync.log") {
info, err := file.Info()
if err == nil {
logFiles = append(logFiles, info)
}
}
}
if len(logFiles) == 0 {
utils.LogInfo("未找到任何日志文件")
return
}
// 按修改时间排序
sort.Slice(logFiles, func(i, j int) bool {
return logFiles[i].ModTime().After(logFiles[j].ModTime())
})
// 显示日志文件列表
utils.LogInfo("日志目录: %s", logDir)
utils.LogInfo("")
// 准备表格数据
headers := []string{"文件名", "大小", "修改时间", "状态"}
var rows [][]string
for _, file := range logFiles {
size := formatFileSize(file.Size())
modTime := file.ModTime().Format("2006-01-02 15:04:05")
// 确定文件状态
var status string
if file.Name() == "hostsync.log" {
status = "当前"
} else {
status = "备份"
}
rows = append(rows, []string{file.Name(), size, modTime, status})
}
// 设置列宽
columnWidths := []int{35, 12, 20, 10}
utils.FormatTable(headers, rows, columnWidths)
}
func runLogClear(cmd *cobra.Command, args []string) {
// 初始化配置
config.Init()
if config.AppConfig == nil || config.AppConfig.LogPath == "" {
utils.LogError("未找到日志配置")
return
}
logDir := config.AppConfig.LogPath
// 读取日志目录
files, err := os.ReadDir(logDir)
if err != nil {
utils.LogError("读取日志目录失败: %v", err)
return
}
// 查找备份日志文件(不删除主日志文件)
var backupFiles []string
for _, file := range files {
if strings.HasPrefix(file.Name(), "hostsync.log.") {
backupFiles = append(backupFiles, file.Name())
}
}
if len(backupFiles) == 0 {
utils.LogInfo("没有需要清理的备份日志文件")
return
}
// 删除备份文件
deletedCount := 0
for _, fileName := range backupFiles {
filePath := filepath.Join(logDir, fileName)
if err := os.Remove(filePath); err != nil {
utils.LogWarning("删除文件失败 %s: %v", fileName, err)
} else {
deletedCount++
utils.LogInfo("删除备份日志文件: %s", fileName)
}
}
utils.LogSuccess("已清理 %d 个备份日志文件", deletedCount)
}
func showRecentLog(logFile string, lines int) {
file, err := os.Open(logFile)
if err != nil {
utils.LogError("打开日志文件失败: %v", err)
return
}
defer file.Close()
// 读取文件的最后N行
logLines := readLastLines(file, lines)
// 过滤日志级别
if logLevel != "" {
logLines = filterLogLevel(logLines, logLevel)
}
if len(logLines) == 0 {
utils.LogInfo("没有找到匹配的日志记录")
return
}
utils.LogInfo("最近 %d 行日志 (%s):", len(logLines), logFile)
utils.LogInfo("")
for _, line := range logLines {
utils.LogResult("%s\n", line)
}
}
func followLog(logFile string) {
utils.LogInfo("实时跟踪日志文件: %s", logFile)
utils.LogInfo("按 Ctrl+C 停止跟踪")
utils.LogInfo("")
file, err := os.Open(logFile)
if err != nil {
utils.LogError("打开日志文件失败: %v", err)
return
}
defer file.Close()
// 移动到文件末尾
file.Seek(0, io.SeekEnd)
scanner := bufio.NewScanner(file)
for {
for scanner.Scan() {
line := scanner.Text()
if logLevel == "" || strings.Contains(strings.ToUpper(line), strings.ToUpper("["+logLevel+"]")) {
utils.LogResult("%s\n", line)
}
}
// 等待新内容
time.Sleep(100 * time.Millisecond)
}
}
func readLastLines(file *os.File, lines int) []string {
// 获取文件大小
stat, err := file.Stat()
if err != nil {
return nil
}
fileSize := stat.Size()
if fileSize == 0 {
return nil
}
// 从文件末尾开始读取
var result []string
var buffer []byte
bufferSize := int64(4096)
position := fileSize
for position > 0 && len(result) < lines {
// 计算读取位置
readSize := bufferSize
if position < bufferSize {
readSize = position
}
position -= readSize
// 读取数据
buffer = make([]byte, readSize)
_, err := file.ReadAt(buffer, position)
if err != nil && err != io.EOF {
break
}
// 分割行
text := string(buffer)
if position > 0 {
// 如果不是文件开头,去掉第一行(可能不完整)
if idx := strings.Index(text, "\n"); idx >= 0 {
text = text[idx+1:]
}
}
textLines := strings.Split(text, "\n")
// 反向添加行
for i := len(textLines) - 1; i >= 0; i-- {
if len(textLines[i]) > 0 {
result = append([]string{textLines[i]}, result...)
if len(result) >= lines {
break
}
}
}
}
// 返回指定数量的行
if len(result) > lines {
result = result[len(result)-lines:]
}
return result
}
func filterLogLevel(lines []string, level string) []string {
var filtered []string
levelUpper := strings.ToUpper("[" + level + "]")
for _, line := range lines {
if strings.Contains(strings.ToUpper(line), levelUpper) {
filtered = append(filtered, line)
}
}
return filtered
}
func formatFileSize(bytes int64) string {
const unit = 1024
if bytes < unit {
return fmt.Sprintf("%d B", bytes)
}
div, exp := int64(unit), 0
for n := bytes / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
}

134
cmd/remove.go Normal file
View File

@ -0,0 +1,134 @@
package cmd
import (
"bufio"
"os"
"strings"
"github.com/evil7/hostsync/core"
"github.com/evil7/hostsync/utils"
"github.com/spf13/cobra"
)
var (
skipConfirmation bool
)
// removeCmd 删除命令
var removeCmd = &cobra.Command{
Use: "remove <blockName> [domain]",
Short: "删除域名记录或整个块",
Long: `从指定块中删除域名记录或删除整个块
示例:
hostsync remove github github.com # 删除指定域名记录
hostsync remove github # 删除整个块
hostsync remove github --yes # 删除整个块跳过确认`,
Args: cobra.RangeArgs(1, 2),
Run: runRemove,
}
func init() {
removeCmd.Flags().BoolVarP(&skipConfirmation, "yes", "y", false, "跳过确认提示")
}
func runRemove(cmd *cobra.Command, args []string) {
utils.CheckAdmin()
blockName := args[0]
if !utils.ValidateBlockName(blockName) {
utils.LogError("无效的块名称: %s", blockName)
utils.LogInfo("块名称只能包含字母、数字、下划线和连字符")
os.Exit(1)
}
hm := core.NewHostsManager()
if err := hm.Load(); err != nil {
utils.LogError("加载hosts文件失败: %v", err)
os.Exit(1)
}
// 检查块是否存在
block := hm.GetBlock(blockName)
if block == nil {
utils.LogError("块不存在: %s", blockName)
os.Exit(1)
}
if len(args) == 2 {
// 删除单个域名记录
domain := args[1]
removeDomainEntry(hm, blockName, domain)
} else {
// 删除整个块
removeEntireBlock(hm, blockName, block)
}
}
func removeDomainEntry(hm *core.HostsManager, blockName, domain string) {
if err := hm.RemoveEntry(blockName, domain); err != nil {
utils.LogError("删除记录失败: %v", err)
os.Exit(1)
}
utils.LogSuccess("已删除记录: %s (块: %s)", domain, blockName)
}
func removeEntireBlock(hm *core.HostsManager, blockName string, block *core.HostBlock) {
entryCount := len(block.Entries)
// 显示要删除的块信息
utils.LogInfo("准备删除块: %s", blockName)
utils.LogInfo(" 包含域名记录: %d 个", entryCount)
if block.DNS != "" {
utils.LogInfo(" DNS配置: %s", block.DNS)
}
if block.DoH != "" {
utils.LogInfo(" DoH配置: %s", block.DoH)
}
if block.Server != "" {
utils.LogInfo(" 预设服务器: %s", block.Server)
}
if block.CronJob != "" {
utils.LogInfo(" 定时任务: %s", block.CronJob)
}
utils.LogInfo("")
// 如果没有跳过确认,则要求用户确认
if !skipConfirmation {
if !confirmDeletion(blockName, entryCount) {
utils.LogInfo("操作已取消")
os.Exit(0)
}
}
// 执行删除操作
if err := hm.DeleteBlock(blockName); err != nil {
utils.LogError("删除块失败: %v", err)
os.Exit(1)
}
// 保存文件
if err := hm.Save(); err != nil {
utils.LogError("保存失败: %v", err)
os.Exit(1)
}
utils.LogSuccess("已删除块: %s (包含 %d 个域名记录)", blockName, entryCount)
}
func confirmDeletion(blockName string, entryCount int) bool {
utils.LogWarning("警告: 此操作将永久删除块 '%s' 及其所有 %d 个域名记录!", blockName, entryCount)
utils.LogResult("请输入 'yes' 确认删除,或按 Enter 取消: ")
scanner := bufio.NewScanner(os.Stdin)
if scanner.Scan() {
input := strings.TrimSpace(scanner.Text())
return strings.ToLower(input) == "yes"
}
return false
}

132
cmd/root.go Normal file
View File

@ -0,0 +1,132 @@
package cmd
import (
"fmt"
"os"
"runtime"
"github.com/evil7/hostsync/config"
"github.com/evil7/hostsync/utils"
"github.com/spf13/cobra"
)
var (
debugMode bool
version = "1.0.2"
)
// rootCmd 根命令
var rootCmd = &cobra.Command{
Use: "hostsync",
Short: "一个强大的命令行 Hosts 文件管理工具",
Long: `HostSync 是一个强大的命令行 Hosts 文件管理工具支持分块管理智能 DNS 解析和定时自动更新
功能特点:
- 🎯 按名称分块管理 Hosts 配置
- 🌐 支持多种 DNS 解析方式DNSDoH预设服务器
- 🔄 一键自动更新记录
- 按任务定时自动更新块
- 📝 智能格式化与清理
- 🔧 后台服务模式运行`,
PersistentPreRun: func(cmd *cobra.Command, args []string) {
// 设置调试模式
utils.SetDebugMode(debugMode)
},
}
// Execute 执行根命令
func Execute() {
if err := rootCmd.Execute(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func init() {
// 全局标志
rootCmd.PersistentFlags().BoolVar(&debugMode, "debug", false, "启用调试模式")
// 添加子命令
rootCmd.AddCommand(versionCmd)
rootCmd.AddCommand(listCmd)
rootCmd.AddCommand(enableCmd)
rootCmd.AddCommand(disableCmd)
rootCmd.AddCommand(addCmd)
rootCmd.AddCommand(removeCmd)
rootCmd.AddCommand(updateCmd)
rootCmd.AddCommand(formatCmd)
rootCmd.AddCommand(serverCmd)
rootCmd.AddCommand(cronCmd)
rootCmd.AddCommand(initCmd)
rootCmd.AddCommand(serviceCmd)
rootCmd.AddCommand(logCmd)
}
// versionCmd 版本命令
var versionCmd = &cobra.Command{
Use: "version",
Short: "显示版本信息",
Run: func(cmd *cobra.Command, args []string) {
showVersionInfo()
},
}
// showVersionInfo 显示详细的版本和系统信息
func showVersionInfo() {
utils.LogResult("程序版本: v%s\n", version)
utils.LogResult("Go 版本: %s\n", runtime.Version())
utils.LogResult("系统架构: %s/%s\n", runtime.GOOS, runtime.GOARCH)
utils.LogResult("编译器: %s\n", runtime.Compiler)
// 显示配置信息
if config.AppConfig != nil {
utils.LogResult("\n配置信息:\n")
utils.LogResult(" Hosts 路径: %s\n", config.AppConfig.HostsPath)
utils.LogResult(" 日志级别: %s\n", config.AppConfig.LogLevel)
utils.LogResult(" 日志路径: %s\n", config.AppConfig.LogPath)
utils.LogResult(" 备份数量: %d 个\n", config.AppConfig.BackupCount)
utils.LogResult(" DNS 超时: %d ms\n", config.AppConfig.DNSTimeout)
utils.LogResult(" 最大并发: %d\n", config.AppConfig.MaxConcurrent)
// 显示DNS服务器数量
if len(config.DNSServers) > 0 {
utils.LogResult(" 可用DNS服务器: %d 个\n", len(config.DNSServers))
}
utils.LogResult("\n文件路径:\n")
if config.ConfigPath != "" {
utils.LogResult(" 配置文件: %s\n", config.ConfigPath)
}
if config.ServersPath != "" {
utils.LogResult(" 服务器配置: %s\n", config.ServersPath)
}
} else {
utils.LogResult("\n配置信息: 未初始化\n")
}
// 显示运行时信息
utils.LogResult("\n运行时信息:\n")
utils.LogResult(" CPU 核心数: %d\n", runtime.NumCPU())
utils.LogResult(" Goroutines: %d\n", runtime.NumGoroutine())
// 显示可执行文件信息
utils.LogResult("\n程序信息:\n")
if execPath, err := os.Executable(); err == nil {
if stat, err := os.Stat(execPath); err == nil {
utils.LogResult(" 可执行文件: %s\n", execPath)
utils.LogResult(" 文件大小: %s\n", formatFileSizeSimple(stat.Size()))
utils.LogResult(" 修改时间: %s\n", stat.ModTime().Format("2006-01-02 15:04:05"))
}
}
}
// formatFileSizeSimple 简单的文件大小格式化函数
func formatFileSizeSimple(size int64) string {
const unit = 1024
if size < unit {
return fmt.Sprintf("%d B", size)
}
div, exp := int64(unit), 0
for n := size / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %cB", float64(size)/float64(div), "KMGTPE"[exp])
}

213
cmd/server.go Normal file
View File

@ -0,0 +1,213 @@
package cmd
import (
"os"
"time"
"github.com/evil7/hostsync/config"
"github.com/evil7/hostsync/core"
"github.com/evil7/hostsync/utils"
"github.com/spf13/cobra"
)
// serverCmd 服务器管理命令
var serverCmd = &cobra.Command{
Use: "server [command]",
Short: "管理预设 DNS 服务器",
Long: `管理预设DNS服务器配置支持列出测试服务器
子命令:
list 列出所有预设服务器
test 测试所有服务器响应
test <name> 测试指定服务器
示例:
hostsync server # 列出所有预设服务器默认
hostsync server list # 列出所有预设服务器
hostsync server test # 测试所有服务器响应
hostsync server test Cloudflare # 测试指定服务器`,
Run: runServerDefault, // 添加默认运行函数
}
var serverListCmd = &cobra.Command{
Use: "list",
Short: "列出所有预设服务器",
Run: runServerList,
}
var serverTestCmd = &cobra.Command{
Use: "test [serverName]",
Short: "测试服务器响应时间",
Args: cobra.MaximumNArgs(1),
Run: runServerTest,
}
func init() {
serverCmd.AddCommand(serverListCmd)
serverCmd.AddCommand(serverTestCmd)
}
// runServerDefault 默认运行server命令时列出所有服务器
func runServerDefault(cmd *cobra.Command, args []string) {
runServerList(cmd, args)
}
func runServerList(cmd *cobra.Command, args []string) {
if len(config.DNSServers) == 0 {
utils.LogInfo("没有找到预设DNS服务器配置")
return
}
utils.LogResult("🌐 预设DNS服务器列表 (%d 个)\n", len(config.DNSServers))
headers := []string{"名称", "DNS地址", "DoH地址"}
var rows [][]string
for _, server := range config.DNSServers {
dnsAddr := server.DNS
if dnsAddr == "" {
dnsAddr = "-"
}
dohAddr := server.DoH
if dohAddr == "" {
dohAddr = "-"
} else {
dohAddr = utils.TruncateWithWidth(dohAddr, 50)
}
rows = append(rows, []string{server.Name, dnsAddr, dohAddr})
}
// 设置合适的列宽
columnWidths := []int{15, 20, 50}
utils.FormatTable(headers, rows, columnWidths)
}
func runServerTest(cmd *cobra.Command, args []string) {
resolver := core.NewDNSResolver()
if len(args) == 0 {
// 测试所有服务器
testAllServers(resolver)
} else {
// 测试指定服务器
serverName := args[0]
testServer(resolver, serverName)
}
}
func testAllServers(resolver *core.DNSResolver) {
if len(config.DNSServers) == 0 {
utils.LogInfo("没有找到预设DNS服务器配置")
return
}
utils.LogInfo("测试所有DNS服务器 (%d 个)", len(config.DNSServers))
// 并发测试结果
type testResult struct {
index int
name string
dnsTime string
dohTime string
status string
}
results := make(chan testResult, len(config.DNSServers))
// 并发测试所有服务器
for i, server := range config.DNSServers {
go func(idx int, srv config.DNSServer) {
dnsTime := "-"
dohTime := "-"
status := "❌ 不可用"
// 测试DNS
if srv.DNS != "" {
if duration, err := resolver.TestDNSServer(srv.DNS); err == nil {
dnsTime = duration.String()
status = "✅ 可用"
}
}
// 测试DoH
if srv.DoH != "" {
if duration, err := resolver.TestDoHServer(srv.DoH); err == nil {
dohTime = duration.String()
if status != "✅ 可用" {
status = "✅ 可用"
}
}
}
results <- testResult{
index: idx,
name: srv.Name,
dnsTime: dnsTime,
dohTime: dohTime,
status: status,
}
}(i, server)
}
// 收集结果并显示进度
headers := []string{"服务器", "DNS响应时间", "DoH响应时间", "状态"}
resultMap := make(map[int]testResult)
completed := 0
for completed < len(config.DNSServers) {
result := <-results
resultMap[result.index] = result
completed++
utils.LogInfo("已完成测试: %d/%d", completed, len(config.DNSServers))
}
// 按原始顺序排序结果
var rows [][]string
for i := 0; i < len(config.DNSServers); i++ {
result := resultMap[i]
rows = append(rows, []string{result.name, result.dnsTime, result.dohTime, result.status})
}
// 设置合适的列宽
columnWidths := []int{15, 15, 15, 12}
utils.FormatTable(headers, rows, columnWidths)
}
func testServer(resolver *core.DNSResolver, serverName string) {
server := config.GetDNSServer(serverName)
if server == nil {
utils.LogError("找不到服务器: %s", serverName)
utils.LogInfo("使用 'hostsync server list' 查看可用服务器")
os.Exit(1)
}
utils.LogInfo("测试服务器: %s", serverName)
// 测试DNS
if server.DNS != "" {
utils.LogInfo("测试DNS (%s)...", server.DNS)
start := time.Now()
if duration, err := resolver.TestDNSServer(server.DNS); err != nil {
elapsed := time.Since(start)
utils.LogError("失败: %v (耗时: %v)", err, elapsed)
} else {
utils.LogSuccess("成功 (%v)", duration)
}
} else {
utils.LogInfo("DNS: 未配置")
}
// 测试DoH
if server.DoH != "" {
utils.LogInfo("测试DoH (%s)...", server.DoH)
start := time.Now()
if duration, err := resolver.TestDoHServer(server.DoH); err != nil {
elapsed := time.Since(start)
utils.LogError("失败: %v (耗时: %v)", err, elapsed)
} else {
utils.LogSuccess("成功 (%v)", duration)
}
} else {
utils.LogInfo("DoH: 未配置")
}
}

464
cmd/service.go Normal file
View File

@ -0,0 +1,464 @@
package cmd
import (
"os"
"os/signal"
"syscall"
"time"
"github.com/evil7/hostsync/config"
"github.com/evil7/hostsync/core"
"github.com/evil7/hostsync/service"
"github.com/evil7/hostsync/utils"
"github.com/spf13/cobra"
)
// serviceCmd 系统服务命令
var serviceCmd = &cobra.Command{
Use: "service",
Short: "系统服务管理",
Long: `管理HostSync系统服务支持安装卸载启动停止等操作
支持的操作系统:
- Windows (Windows Service)
- Linux (systemd)
- macOS (LaunchD)
示例:
hostsync service install # 安装系统服务
hostsync service start # 启动服务
hostsync service stop # 停止服务
hostsync service status # 查看服务状态
hostsync service run # 以服务模式运行通常由系统调用`,
}
// serviceInstallCmd 安装服务命令
var serviceInstallCmd = &cobra.Command{
Use: "install",
Short: "安装系统服务",
Long: `将HostSync安装为系统服务支持开机自启动。`,
Run: runServiceInstall,
}
// serviceUninstallCmd 卸载服务命令
var serviceUninstallCmd = &cobra.Command{
Use: "uninstall",
Short: "卸载系统服务",
Long: `从系统中卸载HostSync服务。`,
Run: runServiceUninstall,
}
// serviceStartCmd 启动服务命令
var serviceStartCmd = &cobra.Command{
Use: "start",
Short: "启动系统服务",
Long: `启动HostSync系统服务。`,
Run: runServiceStart,
}
// serviceStopCmd 停止服务命令
var serviceStopCmd = &cobra.Command{
Use: "stop",
Short: "停止系统服务",
Long: `停止HostSync系统服务。`,
Run: runServiceStop,
}
// serviceRestartCmd 重启服务命令
var serviceRestartCmd = &cobra.Command{
Use: "restart",
Short: "重启系统服务",
Long: `重启HostSync系统服务。`,
Run: runServiceRestart,
}
// serviceStatusCmd 查看服务状态命令
var serviceStatusCmd = &cobra.Command{
Use: "status",
Short: "查看服务状态",
Long: `查看HostSync系统服务当前状态。`,
Run: runServiceStatus,
}
// serviceRunCmd 运行服务命令
var serviceRunCmd = &cobra.Command{
Use: "run",
Short: "以服务模式运行",
Long: `以服务模式运行HostSync通常由系统服务管理器调用。`,
Run: runServiceRun,
}
var (
serviceConfigDir string
)
func init() {
serviceRunCmd.Flags().StringVarP(&serviceConfigDir, "config", "c", "", "指定配置文件目录路径")
serviceCmd.AddCommand(serviceInstallCmd)
serviceCmd.AddCommand(serviceUninstallCmd)
serviceCmd.AddCommand(serviceStartCmd)
serviceCmd.AddCommand(serviceStopCmd)
serviceCmd.AddCommand(serviceRestartCmd)
serviceCmd.AddCommand(serviceStatusCmd)
serviceCmd.AddCommand(serviceRunCmd)
}
func runServiceInstall(cmd *cobra.Command, args []string) {
utils.CheckAdmin()
utils.LogInfo("开始安装HostSync系统服务")
// 获取当前执行文件路径
execPath, err := os.Executable()
if err != nil {
utils.LogError("获取执行文件路径失败: %v", err)
os.Exit(1)
}
utils.LogInfo("执行文件路径: %s", execPath)
serviceManager := service.NewServiceManager()
if err := serviceManager.Install(execPath); err != nil {
utils.LogError("安装服务失败: %v", err)
os.Exit(1)
}
utils.LogSuccess("服务安装成功")
utils.LogInfo("💡 可以使用以下命令管理服务:")
utils.LogInfo(" hostsync service start # 启动服务")
utils.LogInfo(" hostsync service stop # 停止服务")
utils.LogInfo(" hostsync service status # 查看状态")
}
func runServiceUninstall(cmd *cobra.Command, args []string) {
utils.CheckAdmin()
utils.LogInfo("开始卸载HostSync系统服务")
serviceManager := service.NewServiceManager()
if err := serviceManager.Uninstall(); err != nil {
utils.LogError("卸载服务失败: %v", err)
os.Exit(1)
}
utils.LogSuccess("服务卸载成功")
}
func runServiceStart(cmd *cobra.Command, args []string) {
utils.CheckAdmin()
utils.LogInfo("启动HostSync系统服务")
serviceManager := service.NewServiceManager()
if err := serviceManager.Start(); err != nil {
utils.LogError("启动服务失败: %v", err)
os.Exit(1)
}
utils.LogSuccess("服务启动成功")
}
func runServiceStop(cmd *cobra.Command, args []string) {
utils.CheckAdmin()
utils.LogInfo("停止HostSync系统服务")
serviceManager := service.NewServiceManager()
if err := serviceManager.Stop(); err != nil {
utils.LogError("停止服务失败: %v", err)
os.Exit(1)
}
utils.LogSuccess("服务停止成功")
}
func runServiceRestart(cmd *cobra.Command, args []string) {
utils.CheckAdmin()
utils.LogInfo("重启HostSync系统服务")
serviceManager := service.NewServiceManager()
if err := serviceManager.Restart(); err != nil {
utils.LogError("重启服务失败: %v", err)
os.Exit(1)
}
utils.LogSuccess("服务重启成功")
}
func runServiceStatus(cmd *cobra.Command, args []string) {
serviceManager := service.NewServiceManager()
status, err := serviceManager.Status()
if err != nil {
utils.LogError("查询服务状态失败: %v", err)
os.Exit(1)
}
// 获取状态描述
statusIcon := ""
statusText := ""
switch status {
case service.StatusRunning:
statusIcon = "🟢"
statusText = "运行中"
case service.StatusStopped:
statusIcon = "🔴"
statusText = "已停止"
case service.StatusNotInstalled:
statusIcon = "⚪"
statusText = "未安装"
default:
statusIcon = "❓"
statusText = "未知"
}
// 显示基本信息
utils.LogResult("服务状态: %s %s\n", statusIcon, statusText)
// 如果服务正在运行,显示详细信息
if status == service.StatusRunning {
// 显示运行时信息
if execPath, err := os.Executable(); err == nil {
utils.LogResult("可执行文件: %s\n", execPath)
}
// 显示进程信息
if pid := getServicePID(); pid > 0 {
utils.LogResult("进程ID: %d\n", pid)
}
// 显示配置信息
if config.AppConfig != nil {
utils.LogResult("日志级别: %s\n", config.AppConfig.LogLevel)
if config.AppConfig.LogPath != "" {
utils.LogResult("日志路径: %s\n", config.AppConfig.LogPath)
}
}
// 显示定时任务信息
showCronJobsStatusSimple()
}
// 如果服务未运行,提示如何启动
if status != service.StatusRunning {
utils.LogResult("\n提示:\n")
if status == service.StatusNotInstalled {
utils.LogResult(" 1. 运行 'hostsync service install' 安装服务\n")
utils.LogResult(" 2. 运行 'hostsync service start' 启动服务\n")
} else {
utils.LogResult(" 运行 'hostsync service start' 启动服务\n")
}
}
}
func runServiceRun(cmd *cobra.Command, args []string) {
// 添加全局 panic 恢复
defer func() {
if r := recover(); r != nil {
utils.LogError("服务出现严重错误: %v", r)
}
}()
// 如果指定了配置目录,则使用指定的目录初始化配置
if serviceConfigDir != "" {
utils.LogInfo("使用指定配置目录: %s", serviceConfigDir)
config.InitWithConfigDir(serviceConfigDir)
} else {
// 使用默认配置初始化
config.Init()
}
// 检测运行环境
utils.LogDebug("检测运行环境...")
if isSystemdService := os.Getenv("INVOCATION_ID") != ""; isSystemdService {
utils.LogInfo("检测到 systemd 服务环境")
}
// 尝试作为系统服务运行
if handled, err := tryRunAsSystemService(); err != nil {
utils.LogError("系统服务运行失败: %v", err)
os.Exit(1)
} else if handled {
// 已作为系统服务运行,直接返回
utils.LogInfo("已由系统服务管理器处理")
return
}
// 作为普通进程运行(开发测试模式)
utils.LogInfo("启动HostSync服务模式...")
// 启动定时任务管理器
if err := startCronService(); err != nil {
utils.LogError("定时任务服务启动失败: %v", err)
os.Exit(1)
}
utils.LogSuccess("HostSync服务已启动")
utils.LogInfo("定时任务管理器正在运行")
// 等待中断信号
c := make(chan os.Signal, 1)
signal.Notify(c, os.Interrupt, syscall.SIGTERM)
// 定期输出运行状态 (30秒)
statusTicker := time.NewTicker(30 * time.Second)
defer statusTicker.Stop()
// 定期重新同步定时任务配置 (5分钟)
syncTicker := time.NewTicker(5 * time.Minute)
defer syncTicker.Stop()
for {
select {
case <-c:
utils.LogInfo("\n收到停止信号正在关闭服务...")
stopCronService()
utils.LogSuccess("服务已安全关闭")
return
case <-statusTicker.C:
utils.LogInfo("%s - HostSync服务运行中", time.Now().Format("2006-01-02 15:04:05"))
case <-syncTicker.C:
// 定期重新同步定时任务配置
if globalCronManager != nil {
utils.LogDebug("开始定期同步定时任务配置...")
if err := globalCronManager.ReloadFromHosts(); err != nil {
utils.LogError("定时任务配置同步失败: %v", err)
} else {
utils.LogDebug("定时任务配置同步完成")
}
}
}
}
}
// 全局定时任务管理器
var globalCronManager *core.CronManager
// startCronService 启动定时任务服务
func startCronService() error {
defer func() {
if r := recover(); r != nil {
utils.LogError("启动定时任务服务时发生错误: %v", r)
}
}()
// 初始化配置
config.Init()
// 创建定时任务管理器
globalCronManager = core.NewCronManager()
globalCronManager.Start()
// 加载hosts文件中的定时任务
hostsManager := core.NewHostsManager()
if err := hostsManager.Load(); err != nil {
utils.LogError("加载hosts文件失败: %v", err)
// 不立即返回错误允许服务在没有hosts文件的情况下运行
utils.LogInfo("hosts文件加载失败服务将在空任务状态下运行")
utils.LogSuccess("定时任务管理器已启动,当前无任务")
return nil
}
if err := globalCronManager.LoadFromHosts(hostsManager); err != nil {
utils.LogError("加载定时任务失败: %v", err)
utils.LogInfo("定时任务加载失败,服务将在空任务状态下运行")
utils.LogSuccess("定时任务管理器已启动,当前无任务")
return nil
}
utils.LogSuccess("定时任务管理器已启动,共加载 %d 个任务", len(globalCronManager.ListJobs()))
return nil
}
// stopCronService 停止定时任务服务
func stopCronService() {
if globalCronManager != nil {
globalCronManager.Stop()
utils.LogSuccess("定时任务管理器已停止")
}
}
// 辅助函数:截断字符串到指定长度
func truncateString(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
if maxLen <= 3 {
return s[:maxLen]
}
return s[:maxLen-3] + "..."
}
// 获取服务进程ID
func getServicePID() int {
// 这里可以通过系统调用获取服务的PID
// 简化实现返回0表示无法获取
return 0
}
// 显示定时任务状态
func showCronJobsStatus() {
// 尝试连接到运行中的服务获取定时任务信息
hm := core.NewHostsManager()
if err := hm.Load(); err != nil {
utils.LogResult("│ 定时任务: 无法加载配置%-36s│\n", "")
return
}
// 统计定时任务
cronCount := 0
var cronBlocks []string
for name, block := range hm.Blocks {
if block.CronJob != "" {
cronCount++
cronBlocks = append(cronBlocks, name)
}
}
utils.LogResult("│ 定时任务: %d 个活跃任务%-36s│\n", cronCount, "")
// 显示前3个任务
for i, blockName := range cronBlocks {
if i >= 3 {
utils.LogResult("│ ... 还有 %d 个任务%-38s│\n", cronCount-3, "")
break
}
block := hm.Blocks[blockName]
lastUpdate := "从未"
if !block.UpdateAt.IsZero() {
lastUpdate = block.UpdateAt.Format("01-02 15:04")
}
utils.LogResult("│ %s: %s (最后: %s)%*s│\n",
blockName, block.CronJob, lastUpdate,
47-len(blockName)-len(block.CronJob)-len(lastUpdate), "")
}
}
// 显示定时任务状态(简化版本)
func showCronJobsStatusSimple() {
// 尝试连接到运行中的服务获取定时任务信息
hm := core.NewHostsManager()
if err := hm.Load(); err != nil {
utils.LogResult("定时任务: 无法加载配置\n")
return
}
// 统计定时任务
cronCount := 0
var cronBlocks []string
for name, block := range hm.Blocks {
if block.CronJob != "" {
cronCount++
cronBlocks = append(cronBlocks, name)
}
}
utils.LogResult("定时任务: %d 个活跃任务\n", cronCount)
// 显示前3个任务
for i, blockName := range cronBlocks {
if i >= 3 {
utils.LogResult(" ... 还有 %d 个任务\n", cronCount-3)
break
}
block := hm.Blocks[blockName]
lastUpdate := "从未"
if !block.UpdateAt.IsZero() {
lastUpdate = block.UpdateAt.Format("01-02 15:04")
}
utils.LogResult(" %s: %s (最后: %s)\n", blockName, block.CronJob, lastUpdate)
}
}

47
cmd/service_unix.go Normal file
View File

@ -0,0 +1,47 @@
//go:build !windows
// +build !windows
package cmd
import (
"os"
"os/user"
)
// tryRunAsSystemService 尝试作为系统服务运行 (非 Windows)
func tryRunAsSystemService() (bool, error) {
// 检查是否由系统服务管理器启动
// 如果是,返回 false 让主服务逻辑继续运行
// 这里只是用来检测运行环境,不影响服务的实际启动
// 在 Linux 下,无论是否由 systemd 启动,都让主服务逻辑运行
return false, nil
}
// isRunningAsSystemService 检查是否作为系统服务运行
func isRunningAsSystemService() bool {
// 检查常见的系统服务环境变量
serviceIndicators := []string{
"INVOCATION_ID", // systemd
"JOURNAL_STREAM", // systemd journal
"NOTIFY_SOCKET", // systemd notify
"MANAGERPID", // systemd
"LISTEN_PID", // systemd socket activation
}
for _, indicator := range serviceIndicators {
if os.Getenv(indicator) != "" {
return true
}
}
// 检查当前用户和进程特征
if currentUser, err := user.Current(); err == nil {
// 如果运行用户是 root 且没有 TTY可能是系统服务
if currentUser.Uid == "0" && os.Getenv("SSH_TTY") == "" && os.Getenv("TERM") == "" {
return true
}
}
return false
}

30
cmd/service_windows.go Normal file
View File

@ -0,0 +1,30 @@
//go:build windows
// +build windows
package cmd
import (
"fmt"
"github.com/evil7/hostsync/service"
"github.com/evil7/hostsync/utils"
"golang.org/x/sys/windows/svc"
)
// tryRunAsSystemService 尝试作为系统服务运行 (Windows)
func tryRunAsSystemService() (bool, error) {
isWindowsService, err := svc.IsWindowsService()
if err != nil {
return false, fmt.Errorf("检查服务环境失败: %v", err)
}
if isWindowsService {
// 作为 Windows 服务运行
utils.LogInfo("作为 Windows 服务启动...")
if err := service.RunAsWindowsService(); err != nil {
return false, fmt.Errorf("windows 服务运行失败: %v", err)
}
return true, nil
}
return false, nil
}

145
cmd/update.go Normal file
View File

@ -0,0 +1,145 @@
package cmd
import (
"os"
"github.com/evil7/hostsync/core"
"github.com/evil7/hostsync/utils"
"github.com/spf13/cobra"
)
var (
forceDNS string
forceDoH string
forceServer string
saveConfig bool
)
// updateCmd 更新命令
var updateCmd = &cobra.Command{
Use: "update [blockName]",
Short: "更新域名记录",
Long: `更新域名记录的IP地址可以更新指定块或所有块
支持强制使用指定的DNS服务器
示例:
hostsync update # 更新所有块
hostsync update github # 更新指定块
hostsync update --dns 1.1.1.1 # 强制使用指定DNS更新
hostsync update --doh https://... # 强制使用DoH更新
hostsync update --srv Cloudflare # 强制使用预设服务器更新
hostsync update --save # 更新后保存DNS配置到块
hostsync update github --save # 更新指定块并保存DNS配置`,
Args: cobra.MaximumNArgs(1),
Run: runUpdate,
}
func init() {
updateCmd.Flags().StringVar(&forceDNS, "dns", "", "强制使用指定DNS服务器")
updateCmd.Flags().StringVar(&forceDoH, "doh", "", "强制使用DoH服务器")
updateCmd.Flags().StringVar(&forceServer, "srv", "", "强制使用预设服务器")
updateCmd.Flags().BoolVar(&saveConfig, "save", false, "保存DNS/DoH设置到块配置")
}
func runUpdate(cmd *cobra.Command, args []string) {
utils.CheckAdmin()
hm := core.NewHostsManager()
if err := hm.Load(); err != nil {
utils.LogError("加载hosts文件失败: %v", err)
os.Exit(1)
}
resolver := core.NewDNSResolver(debugMode)
if len(args) == 0 {
// 更新所有块
updateAllBlocks(hm, resolver)
} else {
// 更新指定块
blockName := args[0]
updateBlock(hm, resolver, blockName)
}
}
func updateAllBlocks(hm *core.HostsManager, resolver *core.DNSResolver) {
if len(hm.Blocks) == 0 {
utils.LogInfo("没有找到任何配置块")
return
}
utils.LogInfo("开始更新所有配置块 (%d 个)", len(hm.Blocks))
// 显示保存配置信息
if saveConfig && (forceDNS != "" || forceDoH != "" || forceServer != "") {
utils.LogInfo("将保存DNS配置到所有块设置中")
}
totalProcessed := 0
for name := range hm.Blocks {
utils.LogInfo("更新块: %s", name)
if err := resolver.UpdateBlock(hm, name, forceDNS, forceDoH, forceServer, saveConfig); err != nil {
utils.LogError("更新失败: %v", err)
} else {
totalProcessed++
}
}
if totalProcessed > 0 {
utils.LogSuccess("已处理 %d 个配置块", totalProcessed)
}
// 显示保存结果
if saveConfig && (forceDNS != "" || forceDoH != "" || forceServer != "") {
utils.LogSuccess("DNS配置已保存到所有块设置")
}
}
func updateBlock(hm *core.HostsManager, resolver *core.DNSResolver, blockName string) {
if !utils.ValidateBlockName(blockName) {
utils.LogError("无效的块名称: %s", blockName)
utils.LogInfo("块名称只能包含字母、数字、下划线和连字符")
os.Exit(1)
}
block := hm.GetBlock(blockName)
if block == nil {
utils.LogError("块不存在: %s", blockName)
os.Exit(1)
}
utils.LogInfo("开始更新块: %s", blockName)
// 显示DNS配置信息
if forceDNS != "" {
utils.LogInfo("强制使用DNS: %s", forceDNS)
} else if forceDoH != "" {
utils.LogInfo("强制使用DoH: %s", forceDoH)
} else if forceServer != "" {
utils.LogInfo("强制使用预设服务器: %s", forceServer)
} else {
// 显示块配置的DNS
if block.DNS != "" {
utils.LogInfo("使用DNS: %s", block.DNS)
} else if block.DoH != "" {
utils.LogInfo("使用DoH: %s", block.DoH)
} else if block.Server != "" {
utils.LogInfo("使用预设服务器: %s", block.Server)
} else {
utils.LogInfo("使用系统默认DNS")
}
}
// 显示保存配置信息
if saveConfig && (forceDNS != "" || forceDoH != "" || forceServer != "") {
utils.LogInfo("将保存DNS配置到块设置中")
}
if err := resolver.UpdateBlock(hm, blockName, forceDNS, forceDoH, forceServer, saveConfig); err != nil {
utils.LogError("更新失败: %v", err)
os.Exit(1)
}
// 显示保存结果
if saveConfig && (forceDNS != "" || forceDoH != "" || forceServer != "") {
utils.LogSuccess("DNS配置已保存到块设置")
}
}

7
config.json Normal file
View File

@ -0,0 +1,7 @@
{
"hostsPath": "C:\\Windows\\System32\\drivers\\etc\\hosts",
"backupCount": 5,
"dnsTimeout": 5000,
"maxConcurrent": 10,
"logLevel": "info"
}

215
config/config.go Normal file
View File

@ -0,0 +1,215 @@
package config
import (
"encoding/json"
"fmt"
"os"
"os/user"
"path/filepath"
"runtime"
"github.com/spf13/viper"
)
// Config 程序配置结构
type Config struct {
HostsPath string `json:"hostsPath"`
BackupCount int `json:"backupCount"`
DNSTimeout int `json:"dnsTimeout"`
MaxConcurrent int `json:"maxConcurrent"`
LogLevel string `json:"logLevel"` // debug, info, warning, error, silent
LogPath string `json:"logPath"` // 空字符串表示只输出到控制台,有路径表示同时输出到文件和控制台
}
// DNSServer DNS服务器配置
type DNSServer struct {
Name string `json:"Name"`
DNS string `json:"Dns"`
DoH string `json:"Doh"`
}
var (
AppConfig *Config
DNSServers []DNSServer
ConfigPath string
ServersPath string
)
// Init 初始化配置(使用默认用户目录)
func Init() {
userConfigDir, err := getUserConfigDir()
if err != nil {
fmt.Fprintf(os.Stderr, "获取用户配置目录失败: %v\n", err)
os.Exit(1)
}
InitWithConfigDir(userConfigDir)
}
// InitWithConfigDir 使用指定配置目录初始化配置
func InitWithConfigDir(userConfigDir string) {
ConfigPath = filepath.Join(userConfigDir, "config", "config.json")
ServersPath = filepath.Join(userConfigDir, "config", "servers.json")
// 初始化默认配置
AppConfig = &Config{
HostsPath: getDefaultHostsPath(),
BackupCount: 5,
DNSTimeout: 5000,
MaxConcurrent: 10,
LogLevel: "info",
LogPath: filepath.Join(userConfigDir, "logs"),
}
// 加载配置文件
loadConfig()
loadServers()
// 创建日志目录
if err := os.MkdirAll(AppConfig.LogPath, 0755); err != nil {
fmt.Fprintf(os.Stderr, "创建日志目录失败: %v\n", err)
}
}
// getUserConfigDir 获取用户配置目录
func getUserConfigDir() (string, error) {
currentUser, err := user.Current()
if err != nil {
return "", fmt.Errorf("获取当前用户信息失败: %v", err)
}
return filepath.Join(currentUser.HomeDir, ".hostsync"), nil
}
// getDefaultHostsPath 获取默认hosts文件路径
func getDefaultHostsPath() string {
switch runtime.GOOS {
case "windows":
return `C:\Windows\System32\drivers\etc\hosts`
default:
return "/etc/hosts"
}
}
// loadConfig 加载配置文件
func loadConfig() {
if _, err := os.Stat(ConfigPath); os.IsNotExist(err) {
// 配置文件不存在,创建默认配置
saveConfig()
return
}
data, err := os.ReadFile(ConfigPath)
if err != nil {
fmt.Fprintf(os.Stderr, "读取配置文件失败: %v\n", err)
return
}
// 创建临时配置对象进行解析测试
tempConfig := &Config{}
if err := json.Unmarshal(data, tempConfig); err != nil {
fmt.Fprintf(os.Stderr, "解析配置文件失败: %v\n", err)
fmt.Fprintln(os.Stderr, "💡 将使用默认配置并重新创建配置文件")
saveConfig()
return
}
// 解析成功,更新当前配置
AppConfig = tempConfig
// 检查并补充缺失的字段
needSave := false
if AppConfig.LogPath == "" {
userConfigDir, _ := getUserConfigDir()
AppConfig.LogPath = filepath.Join(userConfigDir, "logs")
needSave = true
}
if AppConfig.LogLevel == "" {
AppConfig.LogLevel = "info"
needSave = true
}
// 如果有缺失字段,保存更新后的配置
if needSave {
saveConfig()
}
}
// saveConfig 保存配置文件
func saveConfig() {
data, err := json.MarshalIndent(AppConfig, "", " ")
if err != nil {
fmt.Fprintf(os.Stderr, "序列化配置失败: %v\n", err)
return
}
// 确保配置文件目录存在
configDir := filepath.Dir(ConfigPath)
if err := os.MkdirAll(configDir, 0755); err != nil {
fmt.Fprintf(os.Stderr, "创建配置目录失败: %v\n", err)
return
}
if err := os.WriteFile(ConfigPath, data, 0644); err != nil {
fmt.Fprintf(os.Stderr, "保存配置文件失败: %v\n", err)
}
}
// loadServers 加载DNS服务器配置
func loadServers() {
if _, err := os.Stat(ServersPath); os.IsNotExist(err) {
// DNS服务器配置文件不存在时不输出错误信息
// init 命令会创建这个文件
return
}
data, err := os.ReadFile(ServersPath)
if err != nil {
fmt.Fprintf(os.Stderr, "读取DNS服务器配置失败: %v\n", err)
return
}
// 创建临时变量进行解析测试
var tempServers []DNSServer
if err := json.Unmarshal(data, &tempServers); err != nil {
fmt.Fprintf(os.Stderr, "解析DNS服务器配置失败: %v\n", err)
fmt.Fprintln(os.Stderr, "💡 请运行 'hostsync init --force' 重新创建配置文件")
return
}
// 解析成功,更新服务器列表
DNSServers = tempServers
}
// GetDNSServer 根据名称获取DNS服务器
func GetDNSServer(name string) *DNSServer {
for _, server := range DNSServers {
if server.Name == name {
return &server
}
}
return nil
}
// SetupViper 设置viper配置
func SetupViper() {
viper.SetConfigName("config")
viper.SetConfigType("json")
// 添加用户配置目录到搜索路径
if userConfigDir, err := getUserConfigDir(); err == nil {
viper.AddConfigPath(filepath.Join(userConfigDir, "config"))
}
viper.AddConfigPath(".")
// 设置默认值
viper.SetDefault("hostsPath", getDefaultHostsPath())
viper.SetDefault("backupCount", 5)
viper.SetDefault("dnsTimeout", 5000)
viper.SetDefault("maxConcurrent", 10)
viper.SetDefault("logLevel", "info")
}
// GetBackupDir 获取备份目录
func GetBackupDir() string {
if userConfigDir, err := getUserConfigDir(); err == nil {
return filepath.Join(userConfigDir, "backup")
}
return "backup" // 回退到相对路径
}

8
config/config.json Normal file
View File

@ -0,0 +1,8 @@
{
"hostsPath": "C:\\Windows\\System32\\drivers\\etc\\hosts",
"backupCount": 5,
"dnsTimeout": 5000,
"maxConcurrent": 10,
"logLevel": "info",
"logPath": "logs"
}

306
core/cron.go Normal file
View File

@ -0,0 +1,306 @@
package core
import (
"fmt"
"sync"
"time"
"github.com/evil7/hostsync/utils"
"github.com/robfig/cron/v3"
)
// 全局文件保存互斥锁,防止多个定时任务同时保存文件
var hostsSaveMutex sync.Mutex
// CronManager 定时任务管理器
type CronManager struct {
cron *cron.Cron
jobs map[string]cron.EntryID
mutex sync.RWMutex
}
// NewCronManager 创建定时任务管理器
func NewCronManager() *CronManager {
// 创建支持秒级的cron实例支持6位表达式
c := cron.New(cron.WithSeconds(), cron.WithLocation(time.Local))
return &CronManager{
cron: c,
jobs: make(map[string]cron.EntryID),
mutex: sync.RWMutex{},
}
}
// Start 启动调度器
func (cm *CronManager) Start() {
cm.cron.Start()
}
// Stop 停止调度器
func (cm *CronManager) Stop() {
cm.cron.Stop()
}
// AddJob 添加定时任务
func (cm *CronManager) AddJob(blockName, cronExpr string, task func()) error {
cm.mutex.Lock()
defer cm.mutex.Unlock()
// 如果已存在,先删除
if existingID, exists := cm.jobs[blockName]; exists {
cm.cron.Remove(existingID)
delete(cm.jobs, blockName)
}
// 创建新任务
entryID, err := cm.cron.AddFunc(cronExpr, func() {
utils.LogDebug("执行定时任务: %s", blockName)
task()
})
if err != nil {
return fmt.Errorf("添加定时任务失败: %v", err)
}
cm.jobs[blockName] = entryID
utils.LogDebug("已添加定时任务: %s (%s)", blockName, cronExpr)
return nil
}
// RemoveJob 删除定时任务
func (cm *CronManager) RemoveJob(blockName string) error {
cm.mutex.Lock()
defer cm.mutex.Unlock()
entryID, exists := cm.jobs[blockName]
if !exists {
return fmt.Errorf("定时任务不存在: %s", blockName)
}
cm.cron.Remove(entryID)
delete(cm.jobs, blockName)
utils.LogDebug("已删除定时任务: %s", blockName)
return nil
}
// ListJobs 列出所有定时任务
func (cm *CronManager) ListJobs() map[string]cron.EntryID {
cm.mutex.RLock()
defer cm.mutex.RUnlock()
result := make(map[string]cron.EntryID)
for name, entryID := range cm.jobs {
result[name] = entryID
}
return result
}
// GetJob 获取指定的定时任务
func (cm *CronManager) GetJob(blockName string) (cron.EntryID, bool) {
cm.mutex.RLock()
defer cm.mutex.RUnlock()
entryID, exists := cm.jobs[blockName]
return entryID, exists
}
// LoadFromHosts 从hosts文件加载定时任务
func (cm *CronManager) LoadFromHosts(hm *HostsManager) error {
resolver := NewDNSResolver()
for blockName, block := range hm.Blocks {
if block.CronJob != "" {
task := func(bn string) func() {
return func() {
defer func() {
if r := recover(); r != nil {
utils.LogError("定时任务 %s 发生错误: %v", bn, r)
}
}()
utils.LogInfo("开始执行定时更新: %s", bn)
// 使用全局锁确保hosts文件保存的原子性
hostsSaveMutex.Lock()
defer hostsSaveMutex.Unlock()
// 重新加载hosts文件以获取最新配置
freshHM := NewHostsManager()
if err := freshHM.Load(); err != nil {
utils.LogError("定时任务 %s 重新加载hosts文件失败: %v", bn, err)
return
}
// 检查块是否仍然存在
freshBlock := freshHM.GetBlock(bn)
if freshBlock == nil {
utils.LogError("定时任务 %s 对应的块已不存在,跳过执行", bn)
return
}
// 检查定时任务是否仍然启用
if freshBlock.CronJob == "" {
utils.LogInfo("定时任务 %s 已被禁用,跳过执行", bn)
return
}
// 使用最新的hosts数据执行更新
utils.LogInfo("开始DNS解析更新: %s", bn)
err := resolver.UpdateBlock(freshHM, bn, "", "", "", false)
if err != nil {
utils.LogError("定时更新失败 %s: %v", bn, err)
} else {
// 获取更新后的块信息,记录更新时间
updatedBlock := freshHM.GetBlock(bn)
if updatedBlock != nil && !updatedBlock.UpdateAt.IsZero() {
utils.LogInfo("定时更新完成: %s更新时间: %s", bn, updatedBlock.UpdateAt.Format("2006-01-02 15:04:05"))
} else {
utils.LogInfo("定时更新完成: %s", bn)
}
}
}
}(blockName)
if err := cm.AddJob(blockName, block.CronJob, task); err != nil {
utils.LogError("加载定时任务失败 %s: %v", blockName, err)
}
}
}
return nil
}
// ValidateCronExpression 验证cron表达式
func (cm *CronManager) ValidateCronExpression(expr string) error {
// 使用robfig/cron/v3的解析器验证表达式
parser := cron.NewParser(cron.Second | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor)
_, err := parser.Parse(expr)
return err
}
// ReloadFromHosts 重新从hosts文件同步定时任务配置
func (cm *CronManager) ReloadFromHosts() error {
utils.LogDebug("正在重新同步定时任务配置...")
// 加载最新的hosts文件
hm := NewHostsManager()
if err := hm.Load(); err != nil {
return fmt.Errorf("重新加载hosts文件失败: %v", err)
}
cm.mutex.Lock()
defer cm.mutex.Unlock()
// 收集当前活跃的任务
currentJobs := make(map[string]string) // blockName -> cronExpr
for blockName := range cm.jobs {
currentJobs[blockName] = ""
}
// 收集hosts文件中的任务配置
hostsJobs := make(map[string]string)
for blockName, block := range hm.Blocks {
if block.CronJob != "" {
hostsJobs[blockName] = block.CronJob
}
}
// 删除不再存在的任务
for blockName := range currentJobs {
if _, exists := hostsJobs[blockName]; !exists {
if entryID, exists := cm.jobs[blockName]; exists {
cm.cron.Remove(entryID)
delete(cm.jobs, blockName)
utils.LogInfo("已删除不存在的定时任务: %s", blockName)
}
}
}
// 添加或更新任务
resolver := NewDNSResolver()
for blockName, cronExpr := range hostsJobs {
// 检查是否需要更新任务
needsUpdate := false
if _, exists := cm.jobs[blockName]; !exists {
needsUpdate = true
} else {
// 这里可以进一步检查cron表达式是否有变化
// 目前简化处理,每次都重新创建以确保同步
if existingEntryID, exists := cm.jobs[blockName]; exists {
cm.cron.Remove(existingEntryID)
delete(cm.jobs, blockName)
}
needsUpdate = true
}
if needsUpdate {
task := func(bn string) func() {
return func() {
defer func() {
if r := recover(); r != nil {
utils.LogError("定时任务 %s 发生错误: %v", bn, r)
}
}()
utils.LogInfo("开始执行定时更新: %s", bn)
// 使用全局锁确保hosts文件保存的原子性
hostsSaveMutex.Lock()
defer hostsSaveMutex.Unlock()
// 重新加载hosts文件以获取最新配置
freshHM := NewHostsManager()
if err := freshHM.Load(); err != nil {
utils.LogError("定时任务 %s 重新加载hosts文件失败: %v", bn, err)
return
}
// 检查块是否仍然存在
freshBlock := freshHM.GetBlock(bn)
if freshBlock == nil {
utils.LogError("定时任务 %s 对应的块已不存在,跳过执行", bn)
return
}
// 检查定时任务是否仍然启用
if freshBlock.CronJob == "" {
utils.LogInfo("定时任务 %s 已被禁用,跳过执行", bn)
return
}
// 使用最新的hosts数据执行更新
utils.LogInfo("开始DNS解析更新: %s", bn)
err := resolver.UpdateBlock(freshHM, bn, "", "", "", false)
if err != nil {
utils.LogError("定时更新失败 %s: %v", bn, err)
} else {
// 获取更新后的块信息,记录更新时间
updatedBlock := freshHM.GetBlock(bn)
if updatedBlock != nil && !updatedBlock.UpdateAt.IsZero() {
utils.LogInfo("定时更新完成: %s更新时间: %s", bn, updatedBlock.UpdateAt.Format("2006-01-02 15:04:05"))
} else {
utils.LogInfo("定时更新完成: %s", bn)
}
}
}
}(blockName)
entryID, err := cm.cron.AddFunc(cronExpr, func() {
utils.LogDebug("执行定时任务: %s", blockName)
task()
})
if err != nil {
utils.LogError("重新加载定时任务失败 %s: %v", blockName, err)
} else {
cm.jobs[blockName] = entryID
utils.LogDebug("已重新加载定时任务: %s (%s)", blockName, cronExpr)
}
}
}
utils.LogDebug("定时任务配置重新同步完成,当前活跃任务: %d 个", len(cm.jobs))
return nil
}

350
core/dns.go Normal file
View File

@ -0,0 +1,350 @@
package core
import (
"context"
"fmt"
"net"
"strings"
"sync"
"time"
"github.com/evil7/hostsync/config"
"github.com/evil7/hostsync/utils"
"github.com/miekg/dns"
)
// DNSResolver DNS解析器
type DNSResolver struct {
timeout time.Duration
debugMode bool
dohClient *DoHClient
}
// NewDNSResolver 创建DNS解析器
func NewDNSResolver(debugMode ...bool) *DNSResolver {
timeout := time.Duration(config.AppConfig.DNSTimeout) * time.Millisecond
debug := false
if len(debugMode) > 0 {
debug = debugMode[0]
}
return &DNSResolver{
timeout: timeout,
debugMode: debug,
dohClient: NewDoHClient(timeout, debug),
}
}
// ResolveDomain 解析域名
func (r *DNSResolver) ResolveDomain(domain string, dnsServer, dohServer string) (string, error) {
if r.debugMode {
utils.LogDebug("开始解析域名: %s", domain)
if dohServer != "" {
utils.LogDebug("使用DoH服务器: %s", dohServer)
} else if dnsServer != "" {
utils.LogDebug("使用DNS服务器: %s", dnsServer)
} else {
utils.LogDebug("使用系统默认DNS")
}
}
// 优先使用DoH
if dohServer != "" {
if ip, err := r.dohClient.Resolve(domain, dohServer); err == nil {
if r.debugMode {
utils.LogDebug("DoH解析成功: %s -> %s", domain, ip)
}
return ip, nil
} else if r.debugMode {
utils.LogDebug("DoH解析失败: %v", err)
}
}
// 使用传统DNS
if dnsServer != "" {
if ip, err := r.resolveWithDNS(domain, dnsServer); err == nil {
if r.debugMode {
utils.LogDebug("DNS解析成功: %s -> %s\n", domain, ip)
}
return ip, nil
} else if r.debugMode {
utils.LogDebug("DNS解析失败: %v\n", err)
}
}
// 使用系统默认DNS
if ip, err := r.resolveWithSystem(domain); err == nil {
if r.debugMode {
utils.LogDebug("系统DNS解析成功: %s -> %s", domain, ip)
}
return ip, nil
} else {
if r.debugMode {
utils.LogDebug("系统DNS解析失败: %v", err)
}
return "", err
}
}
// resolveWithDNS 使用DNS服务器解析
func (r *DNSResolver) resolveWithDNS(domain, server string) (string, error) {
// 确保服务器地址包含端口
if !strings.Contains(server, ":") {
server += ":53"
}
c := new(dns.Client)
c.Timeout = r.timeout
m := new(dns.Msg)
m.SetQuestion(dns.Fqdn(domain), dns.TypeA)
resp, _, err := c.Exchange(m, server)
if err != nil {
return "", fmt.Errorf("DNS查询失败: %v", err)
}
if len(resp.Answer) == 0 {
return "", fmt.Errorf("没有找到A记录")
}
for _, ans := range resp.Answer {
if a, ok := ans.(*dns.A); ok {
return a.A.String(), nil
}
}
return "", fmt.Errorf("没有有效的A记录")
}
// resolveWithSystem 使用系统默认DNS解析
func (r *DNSResolver) resolveWithSystem(domain string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), r.timeout)
defer cancel()
ips, err := net.DefaultResolver.LookupIPAddr(ctx, domain)
if err != nil {
return "", fmt.Errorf("系统DNS解析失败: %v", err)
}
for _, ip := range ips {
if ip.IP.To4() != nil { // IPv4
return ip.IP.String(), nil
}
}
return "", fmt.Errorf("没有找到IPv4地址")
}
// BatchResolve 批量解析域名
func (r *DNSResolver) BatchResolve(domains []string, dnsServer, dohServer string) map[string]string {
if r.debugMode {
utils.LogDebug("开始批量解析 %d 个域名", len(domains))
}
results := make(map[string]string)
var mu sync.Mutex
var wg sync.WaitGroup
// 控制并发数
semaphore := make(chan struct{}, config.AppConfig.MaxConcurrent)
for _, domain := range domains {
wg.Add(1)
go func(d string) {
defer wg.Done()
semaphore <- struct{}{} // 获取信号量
defer func() { <-semaphore }() // 释放信号量
if ip, err := r.ResolveDomain(d, dnsServer, dohServer); err == nil {
mu.Lock()
results[d] = ip
mu.Unlock()
} else if r.debugMode {
utils.LogDebug("解析失败 %s: %v", d, err)
}
}(domain)
}
wg.Wait()
if r.debugMode {
utils.LogDebug("批量解析完成,成功解析 %d/%d 个域名", len(results), len(domains))
}
return results
}
// TestDNSServer 测试DNS服务器
func (r *DNSResolver) TestDNSServer(server string) (time.Duration, error) {
start := time.Now()
_, err := r.resolveWithDNS("github.com", server)
duration := time.Since(start)
if err != nil {
return 0, err
}
return duration, nil
}
// TestDoHServer 测试DoH服务器
func (r *DNSResolver) TestDoHServer(server string) (time.Duration, error) {
start := time.Now()
_, err := r.dohClient.Resolve("github.com", server)
duration := time.Since(start)
if err != nil {
return 0, err
}
return duration, nil
}
// UpdateBlock 更新块中的域名解析
func (r *DNSResolver) UpdateBlock(hm *HostsManager, blockName string, forceDNS, forceDoH, forceServer string, saveConfig bool) error {
block := hm.GetBlock(blockName)
if block == nil {
return fmt.Errorf("块不存在: %s", blockName)
}
if r.debugMode {
utils.LogDebug("开始更新块: %s", blockName)
}
// 确定使用的DNS服务器
dnsServer := forceDNS
dohServer := forceDoH
if dnsServer == "" && dohServer == "" && forceServer == "" {
// 使用块配置的DNS设置
if block.DNS != "" {
dnsServer = block.DNS
} else if block.DoH != "" {
dohServer = block.DoH
} else if block.Server != "" {
// 使用预设服务器
if srv := config.GetDNSServer(block.Server); srv != nil {
if srv.DNS != "" {
dnsServer = srv.DNS
}
if srv.DoH != "" {
dohServer = srv.DoH
}
}
}
} else if forceServer != "" {
// 强制使用预设服务器
if srv := config.GetDNSServer(forceServer); srv != nil {
if srv.DNS != "" {
dnsServer = srv.DNS
}
if srv.DoH != "" {
dohServer = srv.DoH
}
}
}
if r.debugMode {
utils.LogDebug("DNS配置 - DNS: %s, DoH: %s", dnsServer, dohServer)
}
// 如果开启saveConfig保存DNS配置到块中
if saveConfig {
configUpdated := false
if forceDNS != "" && forceDNS != block.DNS {
block.DNS = forceDNS
block.DoH = "" // 清除DoH设置
block.Server = "" // 清除预设服务器
configUpdated = true
utils.LogInfo("已将DNS服务器 '%s' 保存到块 '%s'", forceDNS, blockName)
}
if forceDoH != "" && forceDoH != block.DoH {
block.DoH = forceDoH
block.DNS = "" // 清除DNS设置
block.Server = "" // 清除预设服务器
configUpdated = true
utils.LogInfo("已将DoH服务器 '%s' 保存到块 '%s'", forceDoH, blockName)
}
if forceServer != "" && forceServer != block.Server {
block.Server = forceServer
block.DNS = "" // 清除DNS设置
block.DoH = "" // 清除DoH设置
configUpdated = true
utils.LogInfo("已将预设服务器 '%s' 保存到块 '%s'", forceServer, blockName)
}
if configUpdated {
if err := hm.Save(); err != nil {
return fmt.Errorf("保存配置失败: %v", err)
}
}
}
// 收集需要解析的域名
domains := make([]string, 0, len(block.Entries))
for _, entry := range block.Entries {
if entry.Enabled {
domains = append(domains, entry.Domain)
}
}
if len(domains) == 0 {
return fmt.Errorf("没有需要更新的域名")
}
if r.debugMode {
utils.LogDebug("需要解析 %d 个域名: %v", len(domains), domains)
}
// 批量解析
results := r.BatchResolve(domains, dnsServer, dohServer)
if r.debugMode {
utils.LogDebug("解析结果: %d 个成功", len(results))
for domain, ip := range results {
utils.LogDebug(" %s -> %s", domain, ip)
}
}
// 更新IP地址
updated := 0
for i, entry := range block.Entries {
if entry.Enabled {
if newIP, ok := results[entry.Domain]; ok {
if r.debugMode {
utils.LogDebug("检查 %s: 当前IP=%s, 新IP=%s", entry.Domain, entry.IP, newIP)
}
if newIP != entry.IP {
if r.debugMode {
utils.LogDebug("更新 %s: %s -> %s", entry.Domain, entry.IP, newIP)
}
block.Entries[i].IP = newIP
updated++
}
} else {
if r.debugMode {
utils.LogDebug("解析失败: %s", entry.Domain)
}
}
}
}
if updated > 0 {
// 更新时间
block.UpdateAt = time.Now()
if err := hm.Save(); err != nil {
return fmt.Errorf("保存文件失败: %v", err)
}
utils.LogInfo("已更新 %d 个域名的IP地址", updated)
} else {
// 即使没有IP需要更新也要更新时间戳让用户知道定时任务确实执行了
block.UpdateAt = time.Now()
if err := hm.Save(); err != nil {
return fmt.Errorf("保存文件失败: %v", err)
}
utils.LogInfo("没有IP地址需要更新已更新检查时间")
}
return nil
}

271
core/doh.go Normal file
View File

@ -0,0 +1,271 @@
package core
import (
"bytes"
"context"
"crypto/tls"
"encoding/base64"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/evil7/hostsync/utils"
"github.com/miekg/dns"
)
// DoHClient DoH客户端
type DoHClient struct {
client *http.Client
debugMode bool
timeout time.Duration
}
// NewDoHClient 创建DoH客户端
func NewDoHClient(timeout time.Duration, debugMode bool) *DoHClient {
// 为DoH请求设置合理的超时时间
dohTimeout := timeout
if dohTimeout < 5*time.Second {
dohTimeout = 5 * time.Second // DoH请求至少5秒超时
}
if dohTimeout > 15*time.Second {
dohTimeout = 15 * time.Second // DoH请求最多15秒超时
}
return &DoHClient{
debugMode: debugMode,
timeout: timeout,
client: &http.Client{
Timeout: dohTimeout,
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 30 * time.Second,
TLSHandshakeTimeout: 5 * time.Second, // TLS握手超时
ExpectContinueTimeout: 1 * time.Second,
ResponseHeaderTimeout: 10 * time.Second, // 响应头超时
DisableKeepAlives: false, // 启用Keep-Alive以提高性能
},
},
}
}
// Resolve 使用DoH解析域名
func (c *DoHClient) Resolve(domain, dohURL string) (string, error) {
// 创建DNS查询消息
m := new(dns.Msg)
m.SetQuestion(dns.Fqdn(domain), dns.TypeA)
// 根据RFC 8484为了HTTP缓存友好性DNS ID应该设置为0
m.Id = 0
if c.debugMode {
utils.LogDebug("DoH 创建DNS查询域名: %s, ID: %d", domain, m.Id)
}
// 将DNS消息打包
data, err := m.Pack()
if err != nil {
return "", fmt.Errorf("打包DNS消息失败: %v", err)
}
if c.debugMode {
utils.LogDebug("DoH DNS消息大小: %d 字节", len(data))
}
// 优先尝试POST方法推荐fallback到GET方法
ip, err := c.doRequest(dohURL, data, "POST")
if err != nil {
if c.debugMode {
utils.LogDebug("DoH POST请求失败尝试GET: %v", err)
}
return c.doRequest(dohURL, data, "GET")
}
return ip, nil
}
// doRequest 执行DoH请求
func (c *DoHClient) doRequest(dohURL string, dnsData []byte, method string) (string, error) {
var req *http.Request
var err error
if method == "POST" {
// POST方法直接发送DNS消息作为请求体 (符合RFC 8484 Section 4.1)
// 确保URL不包含查询参数因为POST使用原始路径
baseURL := strings.Split(dohURL, "?")[0]
req, err = http.NewRequest("POST", baseURL, bytes.NewReader(dnsData))
if err != nil {
return "", fmt.Errorf("创建DoH POST请求失败: %v", err)
}
req.Header.Set("Content-Type", "application/dns-message")
req.Header.Set("Content-Length", fmt.Sprintf("%d", len(dnsData)))
if c.debugMode {
utils.LogDebug("DoH POST 请求URL: %s", baseURL)
utils.LogDebug("DoH POST 数据大小: %d 字节", len(dnsData))
}
} else {
// GET方法Base64url编码并作为URL参数 (符合RFC 8484 Section 6)
// 使用Base64url编码无填充确保URL安全字符转换
b64data := base64.RawURLEncoding.EncodeToString(dnsData)
// 根据RFC 8484 Section 6确保使用URL安全的Base64编码
// 虽然RawURLEncoding已经处理了大部分但我们再次确认字符替换
b64data = strings.ReplaceAll(b64data, "+", "-")
b64data = strings.ReplaceAll(b64data, "/", "_")
// 移除任何可能的填充字符RawURLEncoding应该已经不包含但确保安全
b64data = strings.TrimRight(b64data, "=")
if c.debugMode {
utils.LogDebug("DoH GET Base64url编码后: %s (长度: %d)", b64data, len(b64data))
// 验证编码的正确性
if decoded, err := base64.RawURLEncoding.DecodeString(b64data); err == nil {
utils.LogDebug("DoH GET Base64url解码验证成功原始数据长度: %d", len(decoded))
} else {
utils.LogDebug("DoH GET Base64url解码验证失败: %v", err)
}
}
// 处理URL模板如果URL包含{?dns}模板,替换它;否则直接添加查询参数
var finalURL string
if strings.Contains(dohURL, "{?dns}") {
// URI模板格式替换{?dns}
finalURL = strings.ReplaceAll(dohURL, "{?dns}", "?dns="+b64data)
} else if strings.Contains(dohURL, "?") {
// URL已包含查询参数添加dns参数
finalURL = dohURL + "&dns=" + b64data
} else {
// URL不包含查询参数添加dns参数
finalURL = dohURL + "?dns=" + b64data
}
req, err = http.NewRequest("GET", finalURL, nil)
if err != nil {
return "", fmt.Errorf("创建DoH GET请求失败: %v", err)
}
if c.debugMode {
utils.LogDebug("DoH GET 请求URL: %s", finalURL)
}
}
// 设置标准DoH头部符合RFC 8484 Section 4.1
req.Header.Set("Accept", "application/dns-message")
req.Header.Set("User-Agent", "HostSync/1.0")
// 根据RFC 8484建议GET方法更缓存友好但POST方法通常更小
if method == "GET" {
// GET请求更适合HTTP缓存
req.Header.Set("Cache-Control", "max-age=300")
} else {
// POST请求避免缓存问题
req.Header.Set("Cache-Control", "no-cache")
}
if c.debugMode {
utils.LogDebug("DoH %s请求 URL: %s", method, req.URL.String())
utils.LogDebug("DoH 请求头: %v", req.Header)
}
// 设置超时上下文
timeout := c.client.Timeout
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
req = req.WithContext(ctx)
// 执行请求
resp, err := c.client.Do(req)
if err != nil {
// 检查是否为超时错误
if ctx.Err() == context.DeadlineExceeded {
return "", fmt.Errorf("DoH请求超时 (超过%v): %v", timeout, err)
}
return "", fmt.Errorf("DoH请求失败: %v", err)
}
defer resp.Body.Close()
if c.debugMode {
utils.LogDebug("DoH 响应状态: %d %s", resp.StatusCode, resp.Status)
utils.LogDebug("DoH 响应头: %v", resp.Header)
}
// 根据RFC 8484 Section 4.2.1成功的2xx状态码用于任何有效的DNS响应
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
bodyBytes, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("DoH请求返回错误状态: %d %s, 响应体: %s",
resp.StatusCode, resp.Status, string(bodyBytes))
}
// 验证响应内容类型符合RFC 8484 Section 6
contentType := resp.Header.Get("Content-Type")
if contentType != "" && !strings.Contains(contentType, "application/dns-message") {
if c.debugMode {
utils.LogDebug("DoH 意外的Content-Type: %s (期望: application/dns-message)", contentType)
}
// 继续处理某些服务器可能不设置正确的Content-Type
}
// 读取响应数据
respData, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("读取DoH响应失败: %v", err)
}
if c.debugMode {
utils.LogDebug("DoH 响应数据大小: %d 字节", len(respData))
}
// 检查响应数据是否为空
if len(respData) == 0 {
return "", fmt.Errorf("DoH响应数据为空")
}
// 解析DNS响应
var respMsg dns.Msg
if err := respMsg.Unpack(respData); err != nil {
return "", fmt.Errorf("解析DNS响应失败 (数据可能损坏): %v", err)
}
if c.debugMode {
utils.LogDebug("DoH DNS响应 - ID: %d, 答案数量: %d, 响应代码: %d (%s)",
respMsg.Id, len(respMsg.Answer), respMsg.Rcode, dns.RcodeToString[respMsg.Rcode])
}
// 检查DNS响应代码符合RFC标准
if respMsg.Rcode != dns.RcodeSuccess {
return "", fmt.Errorf("DNS响应错误响应代码: %d (%s)",
respMsg.Rcode, dns.RcodeToString[respMsg.Rcode])
}
if len(respMsg.Answer) == 0 {
return "", fmt.Errorf("DoH响应中没有找到答案记录")
}
// 查找A记录
for _, ans := range respMsg.Answer {
if a, ok := ans.(*dns.A); ok {
if c.debugMode {
utils.LogDebug("DoH 找到A记录: %s (TTL: %d)", a.A.String(), a.Hdr.Ttl)
}
return a.A.String(), nil
}
}
return "", fmt.Errorf("DoH响应中没有找到A记录")
}
// Test 测试DoH服务器
func (c *DoHClient) Test(server string) (time.Duration, error) {
start := time.Now()
_, err := c.Resolve("github.com", server)
duration := time.Since(start)
if err != nil {
return 0, err
}
return duration, nil
}

396
core/hosts.go Normal file
View File

@ -0,0 +1,396 @@
package core
import (
"bufio"
"fmt"
"os"
"regexp"
"sort"
"strings"
"time"
"github.com/evil7/hostsync/config"
"github.com/evil7/hostsync/utils"
)
// HostEntry hosts条目
type HostEntry struct {
IP string
Domain string
Comment string
Enabled bool
}
// HostBlock hosts块
type HostBlock struct {
Name string
Entries []HostEntry
DNS string
DoH string
Server string
CronJob string
UpdateAt time.Time
Enabled bool
Comments []string
}
// HostsManager hosts文件管理器
type HostsManager struct {
FilePath string
Blocks map[string]*HostBlock
Others []string // 非块内容
}
var (
blockStartPattern = regexp.MustCompile(`^#\s*([a-zA-Z0-9_-]+):\s*$`)
blockEndPattern = regexp.MustCompile(`^#\s*([a-zA-Z0-9_-]+);\s*$`)
commentPattern = regexp.MustCompile(`^#\s*(\w+):\s*(.*)$`)
hostPattern = regexp.MustCompile(`^#?\s*([^\s#]+)\s+([^\s#]+)(?:\s*#(.*))?$`)
)
// NewHostsManager 创建hosts管理器
func NewHostsManager() *HostsManager {
return &HostsManager{
FilePath: config.AppConfig.HostsPath,
Blocks: make(map[string]*HostBlock),
Others: make([]string, 0),
}
}
// Load 加载hosts文件
func (hm *HostsManager) Load() error {
if !utils.FileExists(hm.FilePath) {
return fmt.Errorf("hosts文件不存在: %s", hm.FilePath)
}
file, err := os.Open(hm.FilePath)
if err != nil {
return fmt.Errorf("打开hosts文件失败: %v", err)
}
defer file.Close()
scanner := bufio.NewScanner(file)
var currentBlock *HostBlock
var inBlock bool
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
// 先检查是否是配置注释(在块内)
if inBlock && currentBlock != nil {
if matches := commentPattern.FindStringSubmatch(line); matches != nil {
// 这是配置注释,处理块内容
hm.parseBlockLine(line, currentBlock)
continue
}
}
// 检查块开始
if matches := blockStartPattern.FindStringSubmatch(line); matches != nil {
blockName := matches[1]
currentBlock = &HostBlock{
Name: blockName,
Entries: make([]HostEntry, 0),
Comments: make([]string, 0),
Enabled: true,
}
hm.Blocks[blockName] = currentBlock
inBlock = true
continue
}
// 检查块结束
if matches := blockEndPattern.FindStringSubmatch(line); matches != nil {
inBlock = false
currentBlock = nil
continue
}
if inBlock && currentBlock != nil {
// 处理块内容
hm.parseBlockLine(line, currentBlock)
} else {
// 非块内容 - 只保存非空行
originalLine := scanner.Text()
if strings.TrimSpace(originalLine) != "" {
hm.Others = append(hm.Others, originalLine)
}
}
}
return scanner.Err()
}
// parseBlockLine 解析块内的行
func (hm *HostsManager) parseBlockLine(line string, block *HostBlock) {
originalLine := line
trimmedLine := strings.TrimSpace(line)
// 检查是否是配置注释
if matches := commentPattern.FindStringSubmatch(trimmedLine); matches != nil {
key, value := matches[1], matches[2]
switch key {
case "useDns":
block.DNS = value
case "useDoh":
block.DoH = value
case "useSrv":
block.Server = value
case "cronJob":
block.CronJob = value
case "updateAt":
if t, err := time.Parse("2006-01-02 15:04:05", value); err == nil {
block.UpdateAt = t
}
}
return
}
// 检查是否是host条目包括被注释的
if matches := hostPattern.FindStringSubmatch(trimmedLine); matches != nil {
ip, domain := matches[1], matches[2]
comment := ""
enabled := true
if len(matches) > 3 && matches[3] != "" {
comment = strings.TrimSpace(matches[3])
}
// 检查是否被注释掉(以 # 开头)
if strings.HasPrefix(trimmedLine, "#") {
enabled = false
// 需要从注释中提取实际的 IP 和域名
// 重新解析去掉开头 # 后的内容
lineWithoutComment := strings.TrimSpace(strings.TrimPrefix(trimmedLine, "#"))
if newMatches := regexp.MustCompile(`^([^\s#]+)\s+([^\s#]+)(?:\s*#(.*))?$`).FindStringSubmatch(lineWithoutComment); newMatches != nil {
ip, domain = newMatches[1], newMatches[2]
if len(newMatches) > 3 && newMatches[3] != "" {
comment = strings.TrimSpace(newMatches[3])
}
}
}
entry := HostEntry{
IP: ip,
Domain: domain,
Comment: comment,
Enabled: enabled,
}
block.Entries = append(block.Entries, entry)
return
} // 其他注释
if strings.HasPrefix(trimmedLine, "#") {
block.Comments = append(block.Comments, originalLine)
}
}
// Save 保存hosts文件
func (hm *HostsManager) Save() error {
// 备份原文件
if err := utils.BackupFile(hm.FilePath); err != nil {
return fmt.Errorf("备份文件失败: %v", err)
}
file, err := os.Create(hm.FilePath)
if err != nil {
return fmt.Errorf("创建hosts文件失败: %v", err)
}
defer file.Close()
// 写入非块内容 (过滤空行并确保末尾只有一个空行)
hasNonBlockContent := false
for _, line := range hm.Others {
line = strings.TrimSpace(line)
if line != "" {
if hasNonBlockContent {
fmt.Fprintln(file) // 只在有内容时添加空行分隔
}
fmt.Fprintln(file, line)
hasNonBlockContent = true
}
}
// 写入块内容
blockNames := make([]string, 0, len(hm.Blocks))
for name := range hm.Blocks {
blockNames = append(blockNames, name)
}
sort.Strings(blockNames)
for i, name := range blockNames {
block := hm.Blocks[name]
// 在块之间添加空行分隔(除了第一个块且前面有非块内容)
if i > 0 || hasNonBlockContent {
fmt.Fprintln(file)
}
hm.writeBlock(file, block)
}
return nil
}
// writeBlock 写入块内容
func (hm *HostsManager) writeBlock(file *os.File, block *HostBlock) {
fmt.Fprintf(file, "# %s:\n", block.Name)
// 写入hosts条目
for _, entry := range block.Entries {
prefix := ""
if !entry.Enabled {
prefix = "# "
}
if entry.Comment != "" {
fmt.Fprintf(file, "%s%-16s %s # %s\n", prefix, entry.IP, entry.Domain, entry.Comment)
} else {
fmt.Fprintf(file, "%s%-16s %s\n", prefix, entry.IP, entry.Domain)
}
}
// 写入配置注释
if block.DNS != "" {
fmt.Fprintf(file, "# useDns: %s\n", block.DNS)
}
if block.DoH != "" {
fmt.Fprintf(file, "# useDoh: %s\n", block.DoH)
}
if block.Server != "" {
fmt.Fprintf(file, "# useSrv: %s\n", block.Server)
}
if block.CronJob != "" {
fmt.Fprintf(file, "# cronJob: %s\n", block.CronJob)
}
if !block.UpdateAt.IsZero() {
fmt.Fprintf(file, "# updateAt: %s\n", block.UpdateAt.Format("2006-01-02 15:04:05"))
}
// 写入其他注释
for _, comment := range block.Comments {
fmt.Fprintln(file, comment)
}
fmt.Fprintf(file, "# %s;\n", block.Name)
}
// GetBlock 获取指定块
func (hm *HostsManager) GetBlock(name string) *HostBlock {
return hm.Blocks[name]
}
// CreateBlock 创建新块
func (hm *HostsManager) CreateBlock(name string) *HostBlock {
if !utils.ValidateBlockName(name) {
return nil
}
if _, exists := hm.Blocks[name]; exists {
return hm.Blocks[name]
}
block := &HostBlock{
Name: name,
Entries: make([]HostEntry, 0),
Comments: make([]string, 0),
Enabled: true,
}
hm.Blocks[name] = block
return block
}
// DeleteBlock 删除块
func (hm *HostsManager) DeleteBlock(name string) error {
if _, exists := hm.Blocks[name]; !exists {
return fmt.Errorf("块不存在: %s", name)
}
delete(hm.Blocks, name)
return nil
}
// EnableBlock 启用块
func (hm *HostsManager) EnableBlock(name string) error {
block := hm.GetBlock(name)
if block == nil {
return fmt.Errorf("块不存在: %s", name)
}
for i := range block.Entries {
block.Entries[i].Enabled = true
}
block.Enabled = true
return hm.Save()
}
// DisableBlock 禁用块
func (hm *HostsManager) DisableBlock(name string) error {
block := hm.GetBlock(name)
if block == nil {
return fmt.Errorf("块不存在: %s", name)
}
for i := range block.Entries {
block.Entries[i].Enabled = false
}
block.Enabled = false
return hm.Save()
}
// AddEntry 添加条目
func (hm *HostsManager) AddEntry(blockName, domain, ip string) error {
block := hm.GetBlock(blockName)
if block == nil {
block = hm.CreateBlock(blockName)
if block == nil {
return fmt.Errorf("无法创建块: %s", blockName)
}
}
// 检查是否已存在
for i, entry := range block.Entries {
if entry.Domain == domain {
// 更新IP
block.Entries[i].IP = ip
block.Entries[i].Enabled = true
return hm.Save()
}
}
// 添加新条目
entry := HostEntry{
IP: ip,
Domain: domain,
Enabled: true,
}
block.Entries = append(block.Entries, entry)
return hm.Save()
}
// RemoveEntry 删除条目
func (hm *HostsManager) RemoveEntry(blockName, domain string) error {
block := hm.GetBlock(blockName)
if block == nil {
return fmt.Errorf("块不存在: %s", blockName)
}
for i, entry := range block.Entries {
if entry.Domain == domain {
// 删除条目
block.Entries = append(block.Entries[:i], block.Entries[i+1:]...)
return hm.Save()
}
}
return fmt.Errorf("域名不存在: %s", domain)
}
// UpdateBlockTime 更新块的更新时间
func (hm *HostsManager) UpdateBlockTime(blockName string) error {
block := hm.GetBlock(blockName)
if block == nil {
return fmt.Errorf("块不存在: %s", blockName)
}
block.UpdateAt = time.Now()
return hm.Save()
}

34
go.mod Normal file
View File

@ -0,0 +1,34 @@
module github.com/evil7/hostsync
go 1.23.0
toolchain go1.24.4
require (
github.com/miekg/dns v1.1.66
github.com/robfig/cron/v3 v3.0.1
github.com/spf13/cobra v1.9.1
github.com/spf13/viper v1.20.1
go.uber.org/zap v1.27.0
)
require (
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-viper/mapstructure/v2 v2.3.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/sagikazarmark/locafero v0.9.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.14.0 // indirect
github.com/spf13/cast v1.9.2 // indirect
github.com/spf13/pflag v1.0.6 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/mod v0.25.0 // indirect
golang.org/x/net v0.41.0 // indirect
golang.org/x/sync v0.15.0 // indirect
golang.org/x/sys v0.33.0
golang.org/x/text v0.26.0 // indirect
golang.org/x/tools v0.34.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

69
go.sum Normal file
View File

@ -0,0 +1,69 @@
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/go-viper/mapstructure/v2 v2.3.0 h1:27XbWsHIqhbdR5TIC911OfYvgSaW93HM+dX7970Q7jk=
github.com/go-viper/mapstructure/v2 v2.3.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/miekg/dns v1.1.66 h1:FeZXOS3VCVsKnEAd+wBkjMC3D2K+ww66Cq3VnCINuJE=
github.com/miekg/dns v1.1.66/go.mod h1:jGFzBsSNbJw6z1HYut1RKBKHA9PBdxeHrZG8J+gC2WE=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sagikazarmark/locafero v0.9.0 h1:GbgQGNtTrEmddYDSAH9QLRyfAHY12md+8YFTqyMTC9k=
github.com/sagikazarmark/locafero v0.9.0/go.mod h1:UBUyz37V+EdMS3hDF3QWIiVr/2dPrx49OMO0Bn0hJqk=
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA=
github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo=
github.com/spf13/cast v1.9.2 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE=
github.com/spf13/cast v1.9.2/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4=
github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

330
install.ps1 Normal file
View File

@ -0,0 +1,330 @@
# 使用方法: irm "https://git.xykqyy.com/ljp/hostSync/raw/branch/main/install.ps1" | iex
# 设置严格模式和错误处理
Set-StrictMode -Version 3.0
$ErrorActionPreference = 'Stop'
# 默认配置
$Version = 'latest'
$InstallDir = "$env:USERPROFILE\.hostsync\bin"
$NoInit = $false
$Portable = $false
# 颜色输出函数
function Write-ColorOutput {
param([string]$Message, [string]$Color = 'White')
Write-Host $Message -ForegroundColor $Color
}
function Write-Success { param([string]$Message) Write-ColorOutput $Message 'Green' }
function Write-Info { param([string]$Message) Write-ColorOutput $Message 'Cyan' }
function Write-Warning { param([string]$Message) Write-ColorOutput $Message 'Yellow' }
function Write-Error { param([string]$Message) Write-ColorOutput $Message 'Red' }
# 检查管理员权限
function Test-Administrator {
$currentUser = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = New-Object Security.Principal.WindowsPrincipal($currentUser)
return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
}
# 获取最新版本
function Get-LatestVersion {
try {
Write-Info '? 获取最新版本信息...'
$api = 'https://git.xykqyy.com/api/v1/repos/ljp/hostSync/releases/latest'
$response = Invoke-RestMethod -Uri $api -ErrorAction Stop
return $response.tag_name
}
catch {
Write-Warning '?? 无法获取最新版本,使用 v1.0.0'
return 'v1.0.0'
}
}
# 检测系统架构
function Get-Architecture {
$arch = $env:PROCESSOR_ARCHITECTURE
switch ($arch) {
'AMD64' { return 'amd64' }
'x86' { return '386' }
'ARM64' { return 'arm64' }
default { return 'amd64' }
}
}
# 下载文件
function Get-FileFromUrl {
param([string]$Url, [string]$Output)
Write-Info "? 下载: $(Split-Path $Output -Leaf)"
Write-Info "? 地址: $Url"
try {
# 使用 Invoke-WebRequest 下载
$ProgressPreference = 'SilentlyContinue'
Invoke-WebRequest -Uri $Url -OutFile $Output -ErrorAction Stop
$ProgressPreference = 'Continue'
if (Test-Path $Output) {
$size = [math]::Round((Get-Item $Output).Length / 1MB, 2)
Write-Success "? 下载完成 ($size MB)"
return $true
}
}
catch {
Write-Error "? IWR 下载失败: $($_.Exception.Message)"
# 尝试使用 curl 作为备用方案
Write-Info '? 尝试使用 curl...'
try {
$curlArgs = @('-L', $Url, '-o', $Output, '--silent', '--show-error')
$curlProcess = Start-Process -FilePath 'curl.exe' -ArgumentList $curlArgs -Wait -PassThru -WindowStyle Hidden
if ($curlProcess.ExitCode -eq 0 -and (Test-Path $Output)) {
Write-Success '? 使用 curl 下载完成'
return $true
}
else {
Write-Error "? curl 退出码: $($curlProcess.ExitCode)"
}
}
catch {
Write-Error "? curl 执行失败: $($_.Exception.Message)"
}
}
return $false
}
# 检查并卸载已有服务
function Remove-ExistingService {
Write-Info '? 检查现有安装...'
# 检查 PATH 中的 hostsync
$existingPath = $null
$pathDirs = $env:PATH -split ';'
foreach ($dir in $pathDirs) {
$testPath = Join-Path $dir 'hostsync.exe'
if (Test-Path $testPath) {
$existingPath = $testPath
break
}
}
# 检查默认安装位置
if (-not $existingPath) {
$defaultPath = "$env:USERPROFILE\.hostsync\bin\hostsync.exe"
if (Test-Path $defaultPath) {
$existingPath = $defaultPath
}
}
if ($existingPath) {
Write-Info "? 发现现有安装: $existingPath"
try {
# 尝试停止并卸载服务
Write-Info '?? 停止现有服务...'
$stopArgs = @{
FilePath = $existingPath
ArgumentList = @('service', 'stop')
Wait = $true
PassThru = $true
RedirectStandardOutput = $true
RedirectStandardError = $true
WindowStyle = 'Hidden'
}
Start-Process @stopArgs | Out-Null
Write-Info '?? 卸载现有服务...'
$uninstallArgs = @{
FilePath = $existingPath
ArgumentList = @('service', 'uninstall')
Wait = $true
PassThru = $true
RedirectStandardOutput = $true
RedirectStandardError = $true
WindowStyle = 'Hidden'
}
Start-Process @uninstallArgs | Out-Null
Write-Success '? 已清理现有服务'
}
catch {
Write-Warning "?? 清理现有服务时出现错误: $($_.Exception.Message)"
Write-Info '? 继续安装过程...'
}
# 等待一下确保服务完全停止
Start-Sleep -Seconds 2
}
else {
Write-Info '? 未发现现有安装'
}
}
# 主安装函数
function Install-HostSync {
Write-ColorOutput @'
? HostSync 快速安装脚本
===============================
强大的 Hosts 文件管理工具
功能特点:
- ? 分块管理 Hosts 配置
- ? 智能 DNS 解析
- ? 定时自动更新
- ? 后台服务运行
'@ 'Magenta'
# 检查并卸载已有服务
Remove-ExistingService
# 检查管理员权限
if (-not (Test-Administrator)) {
Write-Warning '?? 未检测到管理员权限,将跳过服务安装'
Write-Info '? 如需安装服务,请稍后以管理员身份运行: hostsync service install'
$NoInit = $true
}
# 确定版本
if ($Version -eq 'latest') {
$Version = Get-LatestVersion
}
Write-Info "? 安装版本: $Version"# 处理版本号格式 - 确保有v前缀用于tag去掉v前缀用于文件名
$TagVersion = $Version
$FileVersion = $Version
if ($Version -match '^v(\d+\.\d+\.\d+)$') {
$TagVersion = $Version
$FileVersion = $matches[1]
}
elseif ($Version -match '^(\d+\.\d+\.\d+)$') {
$TagVersion = "v$Version"
$FileVersion = $Version
}
# 确定架构
$Arch = Get-Architecture
Write-Info "?? 系统架构: $Arch" # 确定安装目录 (固定为默认位置)
Write-Info "? 安装目录: $InstallDir"
# 创建安装目录
if (-not (Test-Path $InstallDir)) {
try {
New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null
Write-Success '? 创建安装目录'
}
catch {
Write-Error "? 创建目录失败: $($_.Exception.Message)"
exit 1
}
} # 构建下载 URL
$FileName = "hostsync-$FileVersion-windows-$Arch.exe"
$DownloadUrl = "https://git.xykqyy.com/ljp/hostSync/releases/download/$TagVersion/$FileName"
$OutputFile = Join-Path $InstallDir 'hostsync.exe' # 下载文件
if (-not (Get-FileFromUrl $DownloadUrl $OutputFile)) {
Write-Error '? 下载失败,请检查网络连接或手动下载'
Write-Info '? 手动下载地址: https://git.xykqyy.com/ljp/hostSync/releases'
exit 1
}
# 验证文件
if (-not (Test-Path $OutputFile)) {
Write-Error '? 安装文件不存在'
exit 1
}
Write-Success '? HostSync 下载完成!' # 添加到 PATH
Write-Info '? 配置环境变量...'
try {
$currentPath = [Environment]::GetEnvironmentVariable('Path', 'User')
if ($currentPath -notlike "*$InstallDir*") {
$newPath = "$currentPath;$InstallDir"
[Environment]::SetEnvironmentVariable('Path', $newPath, 'User')
Write-Success '? 已添加到 PATH 环境变量'
Write-Warning '?? 请重启终端以生效环境变量'
}
else {
Write-Info '? PATH 已包含安装目录'
}
}
catch {
Write-Warning "?? 设置环境变量失败: $($_.Exception.Message)"
Write-Info "? 你可以手动将 '$InstallDir' 添加到 PATH"
}# 运行初始化
if (-not $NoInit) {
Write-Info '? 开始初始化 HostSync...'
try {
# 使用 Start-Process 来更好地控制进程执行
Write-Info "? 执行命令: $OutputFile init"
$processArgs = @{
FilePath = $OutputFile
ArgumentList = @('init')
Wait = $true
PassThru = $true
RedirectStandardOutput = $true
RedirectStandardError = $true
WindowStyle = 'Hidden'
}
$process = Start-Process @processArgs
if ($process.ExitCode -eq 0) {
Write-Success '? 初始化完成!'
}
else {
Write-Warning "?? 初始化退出码: $($process.ExitCode)"
Write-Info '? 可能需要管理员权限,你可以稍后手动运行:'
Write-Info ' 以管理员身份打开 PowerShell'
Write-Info ' 运行: hostsync init'
}
}
catch {
Write-Warning "?? 初始化过程出现异常: $($_.Exception.Message)"
Write-Info '? 请尝试手动初始化:'
Write-Info ' 1. 以管理员身份打开 PowerShell'
Write-Info " 2. 运行: `"$OutputFile`" init"
Write-Info ' 3. 或者: hostsync init (如果已添加到PATH)'
}
}
# 显示完成信息
Write-Success @"
? HostSync 安装完成!
=====================
? 安装位置: $OutputFile
? 配置目录: $env:USERPROFILE\.hostsync\
? 快速开始:
"@
Write-Info ' hostsync list # 查看配置'
Write-Info ' hostsync add test example.com # 添加域名'
Write-Info @'
hostsync update # 更新解析
hostsync service status # 查看服务状态
? 更多帮助:
hostsync help # 查看帮助
hostsync version # 查看版本
? 项目地址: https://git.xykqyy.com/ljp/hostSync
'@
}
# 错误处理
trap {
Write-Error "? 安装过程中发生错误: $($_.Exception.Message)"
Write-Info '? 请检查网络连接或手动下载安装'
exit 1
}
# 执行安装
Remove-ExistingService
Install-HostSync

346
install.sh Normal file
View File

@ -0,0 +1,346 @@
#!/bin/bash
# 使用方法: curl -fsSL https://git.xykqyy.com/ljp/hostSync/raw/branch/main/install.sh | bash
set -e
# 默认配置
VERSION="latest"
INSTALL_DIR="$HOME/.hostsync/bin"
NO_INIT=false
PORTABLE=false
FORCE=false
# 颜色输出
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
MAGENTA='\033[0;35m'
NC='\033[0m' # No Color
# 输出函数
info() { echo -e "${CYAN}$1${NC}"; }
success() { echo -e "${GREEN}$1${NC}"; }
warning() { echo -e "${YELLOW}$1${NC}"; }
error() { echo -e "${RED}$1${NC}"; }
# 检查并卸载已有服务
remove_existing_service() {
info "🔍 检查现有安装..."
# 检查 PATH 中的 hostsync
local existing_path=""
if command -v hostsync >/dev/null 2>&1; then
existing_path=$(which hostsync)
elif [[ -f "$HOME/.hostsync/bin/hostsync" ]]; then
existing_path="$HOME/.hostsync/bin/hostsync"
elif [[ -f "/usr/local/bin/hostsync" ]]; then
existing_path="/usr/local/bin/hostsync"
fi
if [[ -n "$existing_path" ]]; then
info "📦 发现现有安装: $existing_path"
# 尝试停止并卸载服务
info "⏹️ 停止现有服务..."
"$existing_path" service stop >/dev/null 2>&1 || true
info "🗑️ 卸载现有服务..."
"$existing_path" service uninstall >/dev/null 2>&1 || true
success "✅ 已清理现有服务"
# 等待一下确保服务完全停止
sleep 2
else
info "💡 未发现现有安装"
fi
}
# 检查root权限
check_root() {
if [[ $EUID -ne 0 ]]; then
warning "⚠️ 未检测到 root 权限,将跳过服务安装"
info "💡 如需安装服务,请稍后以 root 身份运行: hostsync service install"
NO_INIT=true
return
fi
}
# 检测操作系统和架构
detect_platform() {
OS=$(uname -s | tr '[:upper:]' '[:lower:]')
ARCH=$(uname -m)
case $OS in
linux*)
OS="linux"
;;
darwin*)
OS="darwin"
;;
*)
error "❌ 不支持的操作系统: $OS"
exit 1
;;
esac
case $ARCH in
x86_64 | amd64)
ARCH="amd64"
;;
i*86 | x86)
ARCH="386"
;;
aarch64 | arm64)
ARCH="arm64"
;;
armv7* | armv6*)
ARCH="armv7"
;;
*)
error "❌ 不支持的架构: $ARCH"
exit 1
;;
esac
info "🏗️ 检测到平台: $OS-$ARCH"
}
# 获取最新版本
get_latest_version() {
if [[ "$VERSION" == "latest" ]]; then
info "🔍 获取最新版本信息..."
if command -v curl >/dev/null 2>&1; then
VERSION=$(curl -fsSL "https://git.xykqyy.com/api/v1/repos/ljp/hostSync/releases/latest" | grep '"tag_name"' | sed -E 's/.*"tag_name":\s*"([^"]+)".*/\1/')
elif command -v wget >/dev/null 2>&1; then
VERSION=$(wget -qO- "https://git.xykqyy.com/api/v1/repos/ljp/hostSync/releases/latest" | grep '"tag_name"' | sed -E 's/.*"tag_name":\s*"([^"]+)".*/\1/')
else
warning "⚠️ 无法获取最新版本,使用 v1.0.0"
VERSION="v1.0.0"
fi
fi
if [[ -z "$VERSION" ]]; then
warning "⚠️ 无法获取版本信息,使用 v1.0.0"
VERSION="v1.0.0"
fi
info "📦 安装版本: $VERSION"
# 处理版本号格式 - 分离tag版本和文件版本
TAG_VERSION="$VERSION"
FILE_VERSION="$VERSION"
# 确保tag版本有v前缀
if [[ ! "$TAG_VERSION" =~ ^v ]]; then
TAG_VERSION="v$TAG_VERSION"
fi
# 确保文件版本没有v前缀
if [[ "$FILE_VERSION" =~ ^v ]]; then
FILE_VERSION="${FILE_VERSION:1}" # 去掉 'v' 前缀
fi
if [[ ! "$FILE_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
error "❌ 无效的版本号格式: $FILE_VERSION"
exit 1
fi
}
# 确定安装目录
setup_install_dir() {
if [[ -z "$INSTALL_DIR" ]]; then
if [[ "$PORTABLE" == true ]]; then
INSTALL_DIR="./hostsync"
else
INSTALL_DIR="$HOME/.hostsync/bin"
fi
fi
info "📂 安装目录: $INSTALL_DIR"
# 创建安装目录
if [[ ! -d "$INSTALL_DIR" ]]; then
mkdir -p "$INSTALL_DIR" || {
error "❌ 创建目录失败: $INSTALL_DIR"
exit 1
}
success "✅ 创建安装目录"
fi
}
# 下载文件
download_file() {
local url="$1"
local output="$2"
info "📥 下载: $(basename "$output")"
info "🌐 地址: $url"
# 尝试使用 curl
if command -v curl >/dev/null 2>&1; then
if curl -fsSL "$url" -o "$output"; then
success "✅ 下载完成"
return 0
fi
fi
# 尝试使用 wget
if command -v wget >/dev/null 2>&1; then
if wget -q "$url" -O "$output"; then
success "✅ 下载完成"
return 0
fi
fi
error "❌ 下载失败,请检查网络连接"
return 1
}
# 设置环境变量
setup_environment() {
if [[ "$PORTABLE" == true ]]; then
return 0
fi
info "🔧 配置环境变量..."
# 确定 shell 配置文件
local shell_rc=""
case "$SHELL" in
*/zsh)
shell_rc="$HOME/.zshrc"
;;
*/bash)
shell_rc="$HOME/.bashrc"
;;
*/fish)
shell_rc="$HOME/.config/fish/config.fish"
;;
*)
shell_rc="$HOME/.profile"
;;
esac
# 添加到 PATH
local path_line="export PATH=\"$INSTALL_DIR:\$PATH\""
if [[ "$SHELL" == */fish ]]; then
path_line="set -gx PATH $INSTALL_DIR \$PATH"
fi
if [[ -f "$shell_rc" ]] && grep -q "$INSTALL_DIR" "$shell_rc"; then
info "💡 PATH 已包含安装目录"
else
echo "" >>"$shell_rc"
echo "# HostSync" >>"$shell_rc"
echo "$path_line" >>"$shell_rc"
success "✅ 已添加到 PATH ($shell_rc)"
warning "⚠️ 请重新加载终端或运行: source $shell_rc"
fi
}
# 主安装函数
install_hostsync() {
cat <<'EOF'
🚀 HostSync 快速安装脚本
===============================
强大的 Hosts 文件管理工具
功能特点:
- 🎯 分块管理 Hosts 配置
- 🌐 智能 DNS 解析
- ⏰ 定时自动更新
- 🔧 后台服务运行
EOF
# 检查依赖
if ! command -v curl >/dev/null 2>&1 && ! command -v wget >/dev/null 2>&1; then
error "❌ 需要 curl 或 wget 来下载文件"
exit 1
fi
# 检查并卸载已有服务
remove_existing_service
# 检查权限
check_root
# 检测平台
detect_platform
# 获取版本
get_latest_version
# 设置安装目录
setup_install_dir
# 构建下载 URL
local filename="hostsync-$FILE_VERSION-$OS-$ARCH"
local download_url="https://git.xykqyy.com/ljp/hostSync/releases/download/$TAG_VERSION/$filename"
local output_file="$INSTALL_DIR/hostsync"
# 下载文件
if ! download_file "$download_url" "$output_file"; then
error "❌ 下载失败,请检查网络连接或手动下载"
info "🌐 手动下载地址: https://git.xykqyy.com/ljp/hostSync/releases"
exit 1
fi
# 设置执行权限
chmod +x "$output_file" || {
error "❌ 设置执行权限失败"
exit 1
}
success "✅ HostSync 下载完成!"
# 配置环境
setup_environment
# 运行初始化
if [[ "$NO_INIT" != true ]]; then
info "🚀 开始初始化 HostSync..."
if "$output_file" init; then
success "✅ 初始化完成!"
else
warning "⚠️ 初始化可能遇到问题"
info "💡 你可以稍后手动运行: sudo hostsync init"
fi
fi
# 显示完成信息
success "
🎉 HostSync 安装完成!
=====================
📁 安装位置: $output_file
📖 配置目录: $HOME/.hostsync/
🚀 快速开始:"
info " hostsync list # 查看配置"
info " hostsync add test example.com # 添加域名"
cat <<'EOF'
hostsync update # 更新解析
hostsync service status # 查看服务状态
📚 更多帮助:
hostsync help # 查看帮助
hostsync version # 查看版本
🌐 项目地址: https://git.xykqyy.com/ljp/hostSync
EOF
}
# 错误处理
trap 'error "❌ 安装过程中发生错误"; exit 1' ERR
# 执行安装
install_hostsync

56
main.go Normal file
View File

@ -0,0 +1,56 @@
package main
import (
"fmt"
"os"
"strings"
"github.com/evil7/hostsync/cmd"
"github.com/evil7/hostsync/config"
"github.com/evil7/hostsync/utils"
)
func main() {
// 只在版本命令时显示banner
if isVersionCommand() {
showBanner()
}
// 初始化配置
config.Init()
// 初始化日志系统
if err := utils.InitLogger(); err != nil {
// 日志系统初始化失败时直接输出到stderr不使用日志系统
fmt.Fprintf(os.Stderr, "警告: 初始化日志系统失败: %v\n", err)
}
defer utils.CloseLogger()
// 记录用户完整的命令输入(排除程序名,只记录有意义的命令)
if len(os.Args) > 1 && !isVersionCommand() {
commandLine := strings.Join(os.Args[1:], " ")
utils.LogFileOnly("用户执行: %s", commandLine)
}
// 执行命令
cmd.Execute()
}
// isVersionCommand 检查是否是版本命令
func isVersionCommand() bool {
if len(os.Args) < 2 {
return false
}
arg := os.Args[1]
return arg == "version" || arg == "--version" || arg == "-v"
}
func showBanner() {
banner := `
_ _
| |__ ___ ___| |_ ___ _ _ _ __ ___
| '_ \ / _ \/ __| __/ __| | | | '_ \ / __|
| | | | (_) \__ \ |_\__ \ |_| | | | | (__
|_| |_|\___/|___/\__|___/\__, |_| |_|\___|
by evil7@deepwn |___/
`
fmt.Println(banner)
}

248
readme.md Normal file
View File

@ -0,0 +1,248 @@
# HostSync
强大的命令行 Hosts 文件管理工具,支持分块管理、智能 DNS 解析和定时自动更新。
## ✨ 功能特点
- 🎯 **分块管理** - 按名称组织 Hosts 配置,支持独立启用/禁用
- 🌐 **智能解析** - 支持 DNS、DoH、预设服务器多种解析方式
- 🔄 **自动更新** - 一键更新域名 IP支持定时任务
- 🔧 **服务模式** - 后台服务运行,跨平台支持
- 📦 **安全可靠** - 自动备份,权限管理,格式化清理
## 🚀 快速开始
### 🎯 一键安装(推荐)
**Windows (PowerShell):**
```powershell
iex (irm "https://git.xykqyy.com/ljp/hostSync/raw/branch/main/install.ps1")
```
**Linux/macOS:**
```bash
curl -fsSL https://git.xykqyy.com/ljp/hostSync/raw/branch/main/install.sh | bash
```
> [!TIP]
> 一键安装会自动下载最新版本、配置环境变量并完成初始化。支持多种参数,详见 [安装指南](INSTALL.md)。
### 📦 手动安装
1. **下载程序**
从 [Releases](./releases) 下载对应平台的可执行文件。
2. **初始化系统**
```bash
# Windows (管理员权限)
hostsync init
# Linux/macOS (sudo权限)
sudo hostsync init
```
### 3. 基本使用
```bash
# 查看配置
hostsync list
# 添加域名块
hostsync add github github.com
# 启用/禁用块
hostsync enable github
hostsync disable github
# 更新域名解析
hostsync update github
# 设置定时任务
hostsync cron github "0 0 2 * * *" # 每天凌晨2点更新
```
## 📋 命令参考
### 基础管理
| 命令 | 说明 | 示例 |
| --------------------------- | ----------- | ---------------------------------- |
| `list [block]` | 查看配置 | `hostsync list --raw` |
| `add <block> <domain> [ip]` | 添加记录 | `hostsync add dev api.test.com` |
| `remove <block> [domain]` | 删除记录/块 | `hostsync remove dev api.test.com` |
| `enable <block>` | 启用块 | `hostsync enable dev` |
| `disable <block>` | 禁用块 | `hostsync disable dev` |
### DNS 解析与更新
| 命令 | 说明 | 示例 |
| ---------------- | ------------- | ------------------------------------------------- |
| `update [block]` | 更新解析 | `hostsync update --dns 1.1.1.1` |
| `--dns <server>` | 指定 DNS | `hostsync update --dns 8.8.8.8` |
| `--doh <url>` | 使用 DoH | `hostsync update --doh https://1.1.1.1/dns-query` |
| `--srv <name>` | 预设服务器 | `hostsync update --srv Cloudflare` |
| `--save` | 保存 DNS 配置 | `hostsync update github --save` |
### 定时任务
| 命令 | 说明 | 示例 |
| --------------------- | ------------ | ------------------------------------ |
| `cron [block] [expr]` | 管理定时任务 | `hostsync cron github "0 0 2 * * *"` |
| `cron list` | 列出任务 | `hostsync cron list` |
**常用 cron 表达式 (6 字段格式: 秒 分 时 日 月 星期):**
- `0 0 0 * * *` - 每天午夜
- `0 0 */4 * * *` - 每 4 小时
- `0 0 9 * * 1` - 每周一上午 9 点
- `*/30 * * * * *` - 每 30 秒
- `0 */10 * * * *` - 每 10 分钟
### 系统服务
| 命令 | 说明 | 示例 |
| -------------------- | -------- | -------------------------- |
| `service install` | 安装服务 | `hostsync service install` |
| `service start/stop` | 启停服务 | `hostsync service start` |
| `service status` | 查看状态 | `hostsync service status` |
### 其他工具
| 命令 | 说明 | 示例 |
| -------------------- | -------- | --------------------------------- |
| `format [block]` | 格式化 | `hostsync format` |
| `server test [name]` | 测试 DNS | `hostsync server test Cloudflare` |
| `version` | 版本信息 | `hostsync version` |
## ⚙️ 配置说明
### 块格式示例
```text
# github:
140.82.114.3 github.com
185.199.108.133 avatars.githubusercontent.com
# useSrv: Cloudflare
# cronJob: 0 0 2 * * *
# updateAt: 2024-01-15 10:00:00
# github;
```
### DNS 配置选项
```text
# useDns: 1.1.1.1 # 直接DNS
# useDoh: https://1.1.1.1/dns-query # DoH服务器
# useSrv: Cloudflare # 预设服务器
```
## 💡 使用场景
### 开发环境管理
```bash
# 创建开发环境
hostsync add dev api.example.com 192.168.1.100
hostsync add dev web.example.com 192.168.1.101
hostsync enable dev
```
### GitHub 加速
```bash
# 添加GitHub相关域名
hostsync add github github.com
hostsync add github api.github.com
hostsync update github --srv Cloudflare
hostsync cron github "0 0 2 * * *" # 每天更新
```
### 多环境切换
```bash
# 生产环境
hostsync add prod api.myapp.com 1.2.3.4
# 测试环境
hostsync add test api.myapp.com 192.168.1.10
# 切换环境
hostsync disable prod && hostsync enable test
```
## ⚠️ 注意事项
### 权限要求
- **Windows**: 必须以管理员权限运行
- **Linux/macOS**: 需要 sudo 权限访问 `/etc/hosts`
### 系统支持
- Windows 7/8/10/11 (Windows Service)
- Linux (systemd)
- macOS (LaunchD)
### 安全说明
- 程序仅在本地运行,不收集用户数据
- 修改前自动备份 hosts 文件
- 支持配置文件热重载
## 🔧 故障排除
### 常见问题
1. **权限不足**: 确保以管理员/sudo 权限运行
2. **服务无法启动**: 检查系统服务支持 (systemd/LaunchD)
3. **DNS 解析失败**: 尝试更换 DNS 服务器 `hostsync server test`
4. **定时任务不执行**: 检查服务状态 `hostsync service status`
### 调试模式
```bash
hostsync update --debug # 启用详细日志
```
### 重置配置
```bash
hostsync init --force # 强制重新初始化
```
## 📁 配置文件位置
- **Windows**: `%USERPROFILE%\.hostsync\`
- **Linux/macOS**: `~/.hostsync/`
包含:配置文件、日志文件、备份文件、服务器配置
## 🛠️ 开发构建
### 构建要求
- Go 1.19+
- Git
- UPX (可选,用于压缩)
- GoReleaser (可选,用于发布)
### 构建命令
```bash
# Windows
.\build.ps1
# Linux/macOS
./build.sh
# 手动构建
go build -o hostsync main.go
```
## 📄 License
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.

92
servers.json Normal file
View File

@ -0,0 +1,92 @@
[
{
"Name": "Cloudflare",
"Dns": "1.1.1.1",
"Doh": "https://1.1.1.1/dns-query"
},
{
"Name": "Google",
"Dns": "8.8.8.8",
"Doh": "https://8.8.4.4/dns-query"
},
{
"Name": "OneDNS",
"Dns": "117.50.10.10",
"Doh": "https://doh.onedns.net/dns-query"
},
{
"Name": "AdGuard",
"Dns": "94.140.14.14",
"Doh": "https://94.140.14.14/dns-query"
},
{
"Name": "Yandex",
"Dns": "77.88.8.8",
"Doh": "https://77.88.8.8/dns-query"
},
{
"Name": "DNSPod",
"Dns": "119.29.29.29",
"Doh": "https://doh.pub/dns-query"
},
{
"Name": "dns.sb",
"Dns": "185.222.222.222",
"Doh": "https://185.222.222.222/dns-query"
},
{
"Name": "Quad101",
"Dns": "101.101.101.101",
"Doh": "https://101.101.101.101/dns-query"
},
{
"Name": "Quad9",
"Dns": "9.9.9.9",
"Doh": "https://9.9.9.9/dns-query"
},
{
"Name": "OpenDNS",
"Dns": "208.67.222.222",
"Doh": "https://208.67.222.222/dns-query"
},
{
"Name": "AliDNS",
"Dns": "223.5.5.5",
"Doh": "https://223.5.5.5/dns-query"
},
{
"Name": "Applied",
"Dns": "",
"Doh": "https://doh.applied-privacy.net/query"
},
{
"Name": "cira.ca",
"Dns": "",
"Doh": "https://private.canadianshield.cira.ca/dns-query"
},
{
"Name": "ControlD",
"Dns": "76.76.2.0",
"Doh": "https://dns.controld.com/p0"
},
{
"Name": "switch.ch",
"Dns": "",
"Doh": "https://dns.switch.ch/dns-query"
},
{
"Name": "Dnswarden",
"Dns": "",
"Doh": "https://dns.dnswarden.com/uncensored"
},
{
"Name": "OSZX",
"Dns": "217.160.156.119",
"Doh": "https://dns.oszx.co/dns-query"
},
{
"Name": "Tiarap",
"Dns": "174.138.21.128",
"Doh": "https://doh.tiar.app/dns-query"
}
]

248
service/darwin.go Normal file
View File

@ -0,0 +1,248 @@
//go:build darwin
package service
import (
"fmt"
"os"
"os/exec"
"os/user"
"path/filepath"
"strings"
)
// NewServiceManager 创建macOS服务管理器实例
func NewServiceManager() ServiceManager {
return &DarwinServiceManager{}
}
// DarwinServiceManager macOS LaunchD服务管理器
type DarwinServiceManager struct{}
func (m *DarwinServiceManager) Install(execPath string) error {
// 检查是否有足够权限操作系统服务
if err := m.checkPermissions(); err != nil {
return err
}
if err := ensureLogDir(); err != nil {
return fmt.Errorf("创建日志目录失败: %v", err)
}
plistContent := m.generateLaunchDPlist(execPath)
// 获取plist文件路径
plistPath, err := m.getPlistPath()
if err != nil {
return fmt.Errorf("获取plist路径失败: %v", err)
}
// 创建目录
if err := os.MkdirAll(filepath.Dir(plistPath), 0755); err != nil {
return fmt.Errorf("创建plist目录失败: %v", err)
}
// 写入plist文件
if err := os.WriteFile(plistPath, []byte(plistContent), 0644); err != nil {
return fmt.Errorf("写入plist文件失败: %v", err)
}
// 加载服务
cmd := exec.Command("launchctl", "load", plistPath)
if output, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("加载服务失败: %v, 输出: %s", err, string(output))
}
return nil
}
func (m *DarwinServiceManager) Uninstall() error {
// 停止服务
m.Stop()
// 获取plist文件路径
plistPath, err := m.getPlistPath()
if err != nil {
return fmt.Errorf("获取plist路径失败: %v", err)
}
// 卸载服务
cmd := exec.Command("launchctl", "unload", plistPath)
cmd.CombinedOutput() // 忽略错误
// 删除plist文件
if err := os.Remove(plistPath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("删除plist文件失败: %v", err)
}
return nil
}
func (m *DarwinServiceManager) Start() error {
serviceName := m.getServiceIdentifier()
cmd := exec.Command("launchctl", "start", serviceName)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("启动服务失败: %v, 输出: %s", err, string(output))
}
return nil
}
func (m *DarwinServiceManager) Stop() error {
serviceName := m.getServiceIdentifier()
cmd := exec.Command("launchctl", "stop", serviceName)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("停止服务失败: %v, 输出: %s", err, string(output))
}
return nil
}
func (m *DarwinServiceManager) Restart() error {
if err := m.Stop(); err != nil {
return err
}
return m.Start()
}
func (m *DarwinServiceManager) Status() (ServiceStatus, error) {
serviceName := m.getServiceIdentifier()
// 检查plist文件是否存在
plistPath, err := m.getPlistPath()
if err != nil {
return StatusUnknown, err
}
if _, err := os.Stat(plistPath); os.IsNotExist(err) {
return StatusNotInstalled, nil
}
cmd := exec.Command("launchctl", "list", serviceName)
output, err := cmd.CombinedOutput()
if err != nil {
// 如果服务不在列表中,可能是未运行
return StatusStopped, nil
}
// 简单检查输出中是否包含PID
outputStr := string(output)
if strings.Contains(outputStr, serviceName) && !strings.Contains(outputStr, "-") {
return StatusRunning, nil
}
return StatusStopped, nil
}
func (m *DarwinServiceManager) getServiceIdentifier() string {
return fmt.Sprintf("com.deepwn.%s", strings.ToLower(getServiceName()))
}
func (m *DarwinServiceManager) getPlistPath() (string, error) {
serviceName := m.getServiceIdentifier()
// 检查是否以root权限运行
currentUser, err := user.Current()
if err != nil {
return "", err
}
if currentUser.Uid == "0" {
// 系统级服务
return fmt.Sprintf("/Library/LaunchDaemons/%s.plist", serviceName), nil
} else {
// 用户级服务
homeDir, err := os.UserHomeDir()
if err != nil {
return "", err
}
return fmt.Sprintf("%s/Library/LaunchAgents/%s.plist", homeDir, serviceName), nil
}
}
func (m *DarwinServiceManager) generateLaunchDPlist(execPath string) string {
serviceName := m.getServiceIdentifier()
logPath := getLogPath()
// 获取用户配置目录
userConfigDir, err := getUserConfigDir()
if err != nil {
// 如果获取失败,使用默认路径
currentUser, _ := user.Current()
if currentUser != nil {
userConfigDir = filepath.Join(currentUser.HomeDir, ".hostsync")
} else {
userConfigDir = "/Users/Shared/.hostsync"
}
}
return fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>%s</string>
<key>ProgramArguments</key>
<array>
<string>%s</string>
<string>service</string>
<string>run</string>
<string>--config</string>
<string>%s</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>%s</string>
<key>StandardErrorPath</key>
<string>%s</string>
</dict>
</plist>
`, serviceName, execPath, userConfigDir, logPath, logPath)
}
// checkPermissions 检查是否有足够权限操作系统服务
func (m *DarwinServiceManager) checkPermissions() error {
// 检查是否为 root 用户
if os.Getuid() != 0 {
return fmt.Errorf("安装系统服务需要 root 权限,请使用 sudo 运行")
}
// 检查 launchctl 命令是否可用
if _, err := exec.LookPath("launchctl"); err != nil {
return fmt.Errorf("launchctl 命令不可用: %v", err)
}
// 获取 plist 文件路径并检查目录权限
plistPath, err := m.getPlistPath()
if err != nil {
return fmt.Errorf("获取 plist 路径失败: %v", err)
}
plistDir := filepath.Dir(plistPath)
// 检查目录是否存在,如果不存在尝试创建来测试权限
if _, err := os.Stat(plistDir); os.IsNotExist(err) {
if err := os.MkdirAll(plistDir, 0755); err != nil {
return fmt.Errorf("无法创建 plist 目录 %s: %v", plistDir, err)
}
}
// 尝试创建一个测试文件来验证写权限
testFile := filepath.Join(plistDir, ".hostsync-permission-test")
if file, err := os.Create(testFile); err != nil {
return fmt.Errorf("无法写入 plist 目录 %s: %v", plistDir, err)
} else {
file.Close()
os.Remove(testFile) // 清理测试文件
}
return nil
}

289
service/linux.go Normal file
View File

@ -0,0 +1,289 @@
//go:build linux
package service
import (
"fmt"
"os"
"os/exec"
"strings"
)
// NewServiceManager 创建服务管理器 (Linux)
func NewServiceManager() ServiceManager {
return &LinuxServiceManager{}
}
// LinuxServiceManager Linux systemd服务管理器
type LinuxServiceManager struct{}
func (m *LinuxServiceManager) Install(execPath string) error {
// 检查是否有足够权限操作系统服务
if err := m.checkPermissions(); err != nil {
return err
}
if err := ensureLogDir(); err != nil {
return fmt.Errorf("创建日志目录失败: %v", err)
}
serviceName := getServiceName()
serviceContent := m.generateSystemdUnit(execPath)
// 写入systemd服务文件
servicePath := fmt.Sprintf("/etc/systemd/system/%s.service", strings.ToLower(serviceName))
if err := os.WriteFile(servicePath, []byte(serviceContent), 0644); err != nil {
return fmt.Errorf("写入服务文件失败: %v", err)
}
// 处理 SELinux 上下文,确保可执行文件可以被 systemd 执行
if err := m.handleSELinuxContext(execPath); err != nil {
// SELinux 处理失败不算致命错误,只记录警告
fmt.Printf("警告: SELinux 上下文设置失败: %v\n", err)
}
// 重新加载systemd配置
cmd := exec.Command("systemctl", "daemon-reload")
if output, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("重新加载systemd配置失败: %v, 输出: %s", err, string(output))
}
// 启用服务自动启动
cmd = exec.Command("systemctl", "enable", strings.ToLower(serviceName))
if output, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("启用服务自动启动失败: %v, 输出: %s", err, string(output))
}
return nil
}
func (m *LinuxServiceManager) Uninstall() error {
serviceName := strings.ToLower(getServiceName())
// 停止服务
m.Stop()
// 禁用服务
cmd := exec.Command("systemctl", "disable", serviceName)
cmd.CombinedOutput() // 忽略错误
// 删除服务文件
servicePath := fmt.Sprintf("/etc/systemd/system/%s.service", serviceName)
if err := os.Remove(servicePath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("删除服务文件失败: %v", err)
}
// 重新加载systemd配置
cmd = exec.Command("systemctl", "daemon-reload")
cmd.CombinedOutput() // 忽略错误
return nil
}
func (m *LinuxServiceManager) Start() error {
serviceName := strings.ToLower(getServiceName())
cmd := exec.Command("systemctl", "start", serviceName)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("启动服务失败: %v, 输出: %s", err, string(output))
}
return nil
}
func (m *LinuxServiceManager) Stop() error {
serviceName := strings.ToLower(getServiceName())
cmd := exec.Command("systemctl", "stop", serviceName)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("停止服务失败: %v, 输出: %s", err, string(output))
}
return nil
}
func (m *LinuxServiceManager) Restart() error {
serviceName := strings.ToLower(getServiceName())
cmd := exec.Command("systemctl", "restart", serviceName)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("重启服务失败: %v, 输出: %s", err, string(output))
}
return nil
}
func (m *LinuxServiceManager) Status() (ServiceStatus, error) {
serviceName := strings.ToLower(getServiceName())
// 检查服务是否存在
servicePath := fmt.Sprintf("/etc/systemd/system/%s.service", serviceName)
if _, err := os.Stat(servicePath); os.IsNotExist(err) {
return StatusNotInstalled, nil
}
cmd := exec.Command("systemctl", "is-active", serviceName)
output, err := cmd.CombinedOutput()
outputStr := strings.TrimSpace(string(output))
// 处理不同的退出状态
if err != nil {
if exitError, ok := err.(*exec.ExitError); ok {
// systemctl is-active 的退出码含义:
// 0: active, 1: inactive, 3: activating/deactivating
switch exitError.ExitCode() {
case 1:
// 服务未运行
switch outputStr {
case "inactive":
return StatusStopped, nil
case "failed":
return StatusStopped, nil
default:
return StatusStopped, nil
}
case 3:
// 服务正在启动或停止中
switch outputStr {
case "activating":
return StatusRunning, nil // 启动中视为运行状态
case "deactivating":
return StatusStopped, nil // 停止中视为停止状态
default:
return StatusStopped, nil // 其他转换状态默认为停止
}
default:
if strings.Contains(outputStr, "could not be found") {
return StatusNotInstalled, nil
}
return StatusUnknown, fmt.Errorf("获取服务状态失败: %v, 输出: %s", err, outputStr)
}
} else {
return StatusUnknown, fmt.Errorf("执行systemctl命令失败: %v", err)
}
}
// 没有错误的情况下服务应该是active状态
switch outputStr {
case "active":
return StatusRunning, nil
case "activating":
return StatusRunning, nil // 正在启动中,视为运行状态
case "inactive":
return StatusStopped, nil
case "failed":
return StatusStopped, nil
case "deactivating":
return StatusStopped, nil // 正在停止中,视为停止状态
default:
return StatusUnknown, fmt.Errorf("未知的服务状态: %s", outputStr)
}
}
func (m *LinuxServiceManager) generateSystemdUnit(execPath string) string {
description := getServiceDescription()
// 获取用户配置目录
userConfigDir, err := getUserConfigDir()
if err != nil {
// 如果获取失败,使用默认路径
userConfigDir = "/root/.hostsync"
}
return fmt.Sprintf(`[Unit]
Description=%s
After=network.target network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=%s service run -c %s
Restart=always
RestartSec=10
User=root
Group=root
StandardOutput=journal
StandardError=journal
SyslogIdentifier=hostsync
Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
[Install]
WantedBy=multi-user.target
`, description, execPath, userConfigDir)
}
// checkPermissions 检查是否有足够权限操作系统服务
func (m *LinuxServiceManager) checkPermissions() error {
// 检查是否为 root 用户
if os.Getuid() != 0 {
return fmt.Errorf("安装系统服务需要 root 权限,请使用 sudo 运行")
}
// 检查 /etc/systemd/system 目录是否可写
testPath := "/etc/systemd/system"
if _, err := os.Stat(testPath); os.IsNotExist(err) {
return fmt.Errorf("systemd 系统目录不存在: %s", testPath)
}
// 尝试创建一个测试文件来验证写权限
testFile := fmt.Sprintf("%s/.hostsync-permission-test", testPath)
if file, err := os.Create(testFile); err != nil {
return fmt.Errorf("无法写入系统服务目录 %s: %v", testPath, err)
} else {
file.Close()
os.Remove(testFile) // 清理测试文件
}
// 检查 systemctl 命令是否可用
if _, err := exec.LookPath("systemctl"); err != nil {
return fmt.Errorf("系统未安装 systemd 或 systemctl 命令不可用: %v", err)
}
return nil
}
// handleSELinuxContext 处理 SELinux 上下文设置
func (m *LinuxServiceManager) handleSELinuxContext(execPath string) error {
// 检查是否启用了 SELinux
if !m.isSELinuxEnabled() {
return nil // SELinux 未启用,无需处理
}
// 检查 chcon 命令是否可用
if _, err := exec.LookPath("chcon"); err != nil {
return fmt.Errorf("chcon 命令不可用: %v", err)
}
// 为可执行文件设置正确的 SELinux 上下文
// bin_t 类型允许程序被 systemd 执行
cmd := exec.Command("chcon", "-t", "bin_t", execPath)
if output, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("设置 SELinux 上下文失败: %v, 输出: %s", err, string(output))
}
return nil
}
// isSELinuxEnabled 检查 SELinux 是否启用
func (m *LinuxServiceManager) isSELinuxEnabled() bool {
// 检查 /selinux/enforce 文件是否存在
if _, err := os.Stat("/selinux/enforce"); err == nil {
return true
}
// 检查 /sys/fs/selinux/enforce 文件是否存在
if _, err := os.Stat("/sys/fs/selinux/enforce"); err == nil {
return true
}
// 尝试运行 getenforce 命令
if _, err := exec.LookPath("getenforce"); err == nil {
cmd := exec.Command("getenforce")
if output, err := cmd.CombinedOutput(); err == nil {
status := strings.TrimSpace(string(output))
return status == "Enforcing" || status == "Permissive"
}
}
return false
}

93
service/service.go Normal file
View File

@ -0,0 +1,93 @@
package service
import (
"fmt"
"os"
"os/user"
"path/filepath"
)
// ServiceStatus 服务状态
type ServiceStatus int
const (
StatusUnknown ServiceStatus = iota
StatusRunning
StatusStopped
StatusNotInstalled
)
// ServiceManager 服务管理器接口
type ServiceManager interface {
Install(execPath string) error
Uninstall() error
Start() error
Stop() error
Restart() error
Status() (ServiceStatus, error)
}
// GenericServiceManager 通用服务管理器(不支持系统服务)
type GenericServiceManager struct{}
func (m *GenericServiceManager) Install(execPath string) error {
return fmt.Errorf("当前操作系统不支持系统服务")
}
func (m *GenericServiceManager) Uninstall() error {
return fmt.Errorf("当前操作系统不支持系统服务")
}
func (m *GenericServiceManager) Start() error {
return fmt.Errorf("当前操作系统不支持系统服务")
}
func (m *GenericServiceManager) Stop() error {
return fmt.Errorf("当前操作系统不支持系统服务")
}
func (m *GenericServiceManager) Restart() error {
return fmt.Errorf("当前操作系统不支持系统服务")
}
func (m *GenericServiceManager) Status() (ServiceStatus, error) {
return StatusNotInstalled, fmt.Errorf("当前操作系统不支持系统服务")
}
// getServiceName 获取服务名称
func getServiceName() string {
return "HostSync"
}
// getServiceDisplayName 获取服务显示名称
func getServiceDisplayName() string {
return "HostSync - Hosts File Manager"
}
// getServiceDescription 获取服务描述
func getServiceDescription() string {
return "HostSync hosts file management service with automatic updates and cron jobs"
}
// getLogPath 获取日志文件路径
func getLogPath() string {
execPath, _ := os.Executable()
execDir := filepath.Dir(execPath)
return filepath.Join(execDir, "logs", "service.log")
}
// ensureLogDir 确保日志目录存在
func ensureLogDir() error {
logPath := getLogPath()
logDir := filepath.Dir(logPath)
return os.MkdirAll(logDir, 0755)
}
// getUserConfigDir 获取用户配置目录
func getUserConfigDir() (string, error) {
currentUser, err := user.Current()
if err != nil {
return "", err
}
return filepath.Join(currentUser.HomeDir, ".hostsync"), nil
}

322
service/windows.go Normal file
View File

@ -0,0 +1,322 @@
//go:build windows
// +build windows
package service
import (
"fmt"
"time"
"github.com/evil7/hostsync/config"
"github.com/evil7/hostsync/core"
"github.com/evil7/hostsync/utils"
"golang.org/x/sys/windows"
"golang.org/x/sys/windows/svc"
"golang.org/x/sys/windows/svc/mgr"
)
// NewServiceManager 创建服务管理器 (Windows)
func NewServiceManager() ServiceManager {
return &WindowsServiceManager{}
}
// WindowsServiceManager Windows服务管理器
type WindowsServiceManager struct{}
// WindowsService 实现 Windows 服务接口
type WindowsService struct {
stopChan chan struct{}
cronManager *core.CronManager
}
func (ws *WindowsService) Execute(args []string, r <-chan svc.ChangeRequest, changes chan<- svc.Status) (bool, uint32) {
const cmdsAccepted = svc.AcceptStop | svc.AcceptShutdown | svc.AcceptPauseAndContinue
changes <- svc.Status{State: svc.StartPending}
// 启动服务逻辑
if err := ws.startServiceLogic(); err != nil {
utils.LogError("启动服务逻辑失败: %v", err)
return true, 1
}
changes <- svc.Status{State: svc.Running, Accepts: cmdsAccepted}
for c := range r {
switch c.Cmd {
case svc.Interrogate:
changes <- c.CurrentStatus
case svc.Stop, svc.Shutdown:
changes <- svc.Status{State: svc.StopPending}
ws.stopServiceLogic()
return false, 0
case svc.Pause:
changes <- svc.Status{State: svc.Paused, Accepts: cmdsAccepted}
case svc.Continue:
changes <- svc.Status{State: svc.Running, Accepts: cmdsAccepted}
default:
utils.LogWarning("unexpected control request #%d", c)
}
}
return false, 0
}
func (ws *WindowsService) startServiceLogic() error {
utils.LogInfo("启动 HostSync 定时任务服务...")
// 初始化配置
config.Init()
// 创建定时任务管理器
ws.cronManager = core.NewCronManager()
ws.cronManager.Start()
// 加载hosts文件中的定时任务
hostsManager := core.NewHostsManager()
if err := hostsManager.Load(); err != nil {
return fmt.Errorf("加载hosts文件失败: %v", err)
}
if err := ws.cronManager.LoadFromHosts(hostsManager); err != nil {
return fmt.Errorf("加载定时任务失败: %v", err)
}
utils.LogSuccess("HostSync 定时任务服务已启动,共加载 %d 个任务", len(ws.cronManager.ListJobs()))
// 启动状态监控
go func() {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for {
select {
case <-ws.stopChan:
return
case <-ticker.C:
utils.LogDebug("HostSync 定时任务服务运行正常")
}
}
}()
return nil
}
func (ws *WindowsService) stopServiceLogic() {
utils.LogInfo("正在停止 HostSync 定时任务服务...")
if ws.cronManager != nil {
ws.cronManager.Stop()
utils.LogInfo("定时任务管理器已停止")
}
close(ws.stopChan)
utils.LogSuccess("HostSync 定时任务服务已停止")
}
// RunAsWindowsService 作为 Windows 服务运行
func RunAsWindowsService() error {
service := &WindowsService{
stopChan: make(chan struct{}),
}
return svc.Run(getServiceName(), service)
}
func (m *WindowsServiceManager) Install(execPath string) error {
if err := ensureLogDir(); err != nil {
return fmt.Errorf("创建日志目录失败: %v", err)
}
manager, err := mgr.Connect()
if err != nil {
return fmt.Errorf("连接服务管理器失败: %v", err)
}
defer manager.Disconnect()
serviceName := getServiceName()
displayName := getServiceDisplayName()
description := getServiceDescription()
// 检查服务是否已存在
service, err := manager.OpenService(serviceName)
if err == nil {
service.Close()
return fmt.Errorf("服务 %s 已存在", serviceName)
}
// 获取用户配置目录
userConfigDir, err := getUserConfigDir()
if err != nil {
return fmt.Errorf("获取用户配置目录失败: %v", err)
}
// 服务启动参数 - 包含配置目录参数以确保服务以系统权限运行时能找到配置文件
binPath := fmt.Sprintf(`"%s"`, execPath)
args := []string{"service", "run", "--config", userConfigDir}
config := mgr.Config{
ServiceType: windows.SERVICE_WIN32_OWN_PROCESS,
StartType: windows.SERVICE_AUTO_START,
ErrorControl: windows.SERVICE_ERROR_NORMAL,
BinaryPathName: binPath,
DisplayName: displayName,
Description: description,
Dependencies: []string{"Tcpip", "Dnscache"},
}
service, err = manager.CreateService(serviceName, execPath, config, args...)
if err != nil {
return fmt.Errorf("创建服务失败: %v", err)
}
defer service.Close() // 设置服务故障恢复策略
err = service.SetRecoveryActions([]mgr.RecoveryAction{
{Type: windows.SC_ACTION_RESTART, Delay: 5 * time.Second},
{Type: windows.SC_ACTION_RESTART, Delay: 5 * time.Second},
{Type: windows.SC_ACTION_RESTART, Delay: 5 * time.Second}}, uint32((24 * time.Hour).Seconds()))
if err != nil {
utils.LogWarning("设置服务恢复策略失败: %v", err)
}
return nil
}
func (m *WindowsServiceManager) Uninstall() error {
manager, err := mgr.Connect()
if err != nil {
return fmt.Errorf("连接服务管理器失败: %v", err)
}
defer manager.Disconnect()
serviceName := getServiceName()
service, err := manager.OpenService(serviceName)
if err != nil {
// 服务不存在,认为卸载成功
return nil
}
defer service.Close()
// 先停止服务
status, err := service.Query()
if err != nil {
return fmt.Errorf("查询服务状态失败: %v", err)
}
if status.State != svc.Stopped {
_, err = service.Control(svc.Stop)
if err != nil {
return fmt.Errorf("停止服务失败: %v", err)
}
// 等待服务停止
timeout := time.Now().Add(30 * time.Second)
for status.State != svc.Stopped {
if time.Now().After(timeout) {
return fmt.Errorf("等待服务停止超时")
}
time.Sleep(300 * time.Millisecond)
status, err = service.Query()
if err != nil {
return fmt.Errorf("查询服务状态失败: %v", err)
}
}
}
// 删除服务
err = service.Delete()
if err != nil {
return fmt.Errorf("删除服务失败: %v", err)
}
return nil
}
func (m *WindowsServiceManager) Start() error {
manager, err := mgr.Connect()
if err != nil {
return fmt.Errorf("连接服务管理器失败: %v", err)
}
defer manager.Disconnect()
serviceName := getServiceName()
service, err := manager.OpenService(serviceName)
if err != nil {
return fmt.Errorf("打开服务失败: %v", err)
}
defer service.Close()
err = service.Start()
if err != nil {
return fmt.Errorf("启动服务失败: %v", err)
}
return nil
}
func (m *WindowsServiceManager) Stop() error {
manager, err := mgr.Connect()
if err != nil {
return fmt.Errorf("连接服务管理器失败: %v", err)
}
defer manager.Disconnect()
serviceName := getServiceName()
service, err := manager.OpenService(serviceName)
if err != nil {
return fmt.Errorf("打开服务失败: %v", err)
}
defer service.Close()
status, err := service.Query()
if err != nil {
return fmt.Errorf("查询服务状态失败: %v", err)
}
if status.State == svc.Stopped {
return nil // 服务已经停止
}
_, err = service.Control(svc.Stop)
if err != nil {
return fmt.Errorf("停止服务失败: %v", err)
}
return nil
}
func (m *WindowsServiceManager) Restart() error {
if err := m.Stop(); err != nil {
return err
}
// 等待一下再启动
time.Sleep(2 * time.Second)
return m.Start()
}
func (m *WindowsServiceManager) Status() (ServiceStatus, error) {
manager, err := mgr.Connect()
if err != nil {
return StatusUnknown, fmt.Errorf("连接服务管理器失败: %v", err)
}
defer manager.Disconnect()
serviceName := getServiceName()
service, err := manager.OpenService(serviceName)
if err != nil {
return StatusNotInstalled, nil
}
defer service.Close()
status, err := service.Query()
if err != nil {
return StatusUnknown, fmt.Errorf("查询服务状态失败: %v", err)
}
switch status.State {
case svc.Running:
return StatusRunning, nil
case svc.Stopped:
return StatusStopped, nil
case svc.StartPending, svc.ContinuePending:
return StatusRunning, nil
case svc.StopPending, svc.PausePending, svc.Paused:
return StatusStopped, nil
default:
return StatusUnknown, nil
}
}

57
test_hosts Normal file
View File

@ -0,0 +1,57 @@
# custom:
127.0.0.1 localhost
192.168.15.5 dev.server
192.168.15.99 bi.server
192.168.15.250 web.server
# custom;
# gfont:
120.253.253.34 fonts.gstatic.com
120.253.253.98 fonts.googleapis.com
# useDns: 1.1.1.1
# updateAt: 2025-04-25 09:17:15
# gfont;
# github:
140.82.113.25 alive.github.com
140.82.116.6 api.github.com
185.199.109.133 avatars.githubusercontent.com
185.199.108.133 avatars0.githubusercontent.com
185.199.111.133 avatars1.githubusercontent.com
185.199.108.133 avatars2.githubusercontent.com
185.199.108.133 avatars3.githubusercontent.com
185.199.108.133 avatars4.githubusercontent.com
185.199.111.133 avatars5.githubusercontent.com
185.199.110.133 camo.githubusercontent.com
140.82.114.21 central.github.com
185.199.111.133 cloud.githubusercontent.com
140.82.116.9 codeload.github.com
140.82.114.22 collector.github.com
185.199.108.133 desktop.githubusercontent.com
185.199.109.133 favicons.githubusercontent.com
78.16.49.15 gist.github.com
3.5.29.52 github-cloud.s3.amazonaws.com
3.5.17.32 github-com.s3.amazonaws.com
52.216.142.4 github-production-release-asset-2e65be.s3.amazonaws.com
3.5.27.137 github-production-repository-file-5c1aeb.s3.amazonaws.com
52.216.152.180 github-production-user-asset-6210df.s3.amazonaws.com
192.0.66.2 github.blog
140.82.116.3 github.com
140.82.113.17 github.community
185.199.111.154 github.githubassets.com
208.101.60.87 github.global.ssl.fastly.net
185.199.110.153 github.io
185.199.109.133 github.map.fastly.net
185.199.110.153 githubstatus.com
140.82.113.25 live.github.com
185.199.109.133 media.githubusercontent.com
185.199.111.133 objects.githubusercontent.com
13.107.42.16 pipelines.actions.githubusercontent.com
185.199.111.133 raw.githubusercontent.com
185.199.109.133 user-images.githubusercontent.com
140.82.114.21 education.github.com
185.199.110.133 private-user-images.githubusercontent.com
# useDns: 185.222.222.222
# cronJob: */30 * * * *
# updateAt: 2025-04-25 09:17:31
# github;

227
utils/logger.go Normal file
View File

@ -0,0 +1,227 @@
package utils
import (
"fmt"
"os"
"path/filepath"
"sync"
"time"
"github.com/evil7/hostsync/config"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)
var (
fileLogger *zap.Logger
fileSugar *zap.SugaredLogger
consoleLogger *zap.Logger
consoleSugar *zap.SugaredLogger
debugMode bool
logMutex sync.Mutex
)
// InitLogger 初始化日志系统
func InitLogger() error {
if config.AppConfig == nil {
return nil
}
// 根据配置设置日志级别
level := zapcore.InfoLevel
if config.AppConfig.LogLevel != "" {
switch config.AppConfig.LogLevel {
case "debug":
level = zapcore.DebugLevel
case "info":
level = zapcore.InfoLevel
case "warning", "warn":
level = zapcore.WarnLevel
case "error":
level = zapcore.ErrorLevel
case "silent":
level = zapcore.ErrorLevel + 1 // 静默模式,不记录任何日志
}
}
// 创建控制台logger配置只用于错误输出
consoleConfig := zap.Config{
Level: zap.NewAtomicLevelAt(zapcore.ErrorLevel), // 只显示错误
Development: false,
Encoding: "console",
EncoderConfig: zapcore.EncoderConfig{
MessageKey: "msg",
LineEnding: zapcore.DefaultLineEnding,
EncodeLevel: zapcore.CapitalLevelEncoder,
},
OutputPaths: []string{"stderr"},
ErrorOutputPaths: []string{"stderr"},
}
var err error
consoleLogger, err = consoleConfig.Build(zap.AddStacktrace(zapcore.DPanicLevel))
if err != nil {
return fmt.Errorf("创建控制台logger失败: %v", err)
}
consoleSugar = consoleLogger.Sugar()
// 如果配置了文件路径创建文件logger
if config.AppConfig.LogPath != "" {
// 确保日志目录存在
if err := os.MkdirAll(config.AppConfig.LogPath, 0755); err != nil {
return fmt.Errorf("创建日志目录失败: %v", err)
}
logFileName := filepath.Join(config.AppConfig.LogPath, "hostsync.log")
// 检查并轮转日志文件
if err := rotateLogIfNeeded(logFileName); err != nil {
return fmt.Errorf("轮转日志文件失败: %v", err)
}
// 创建文件logger配置
fileConfig := zap.Config{
Level: zap.NewAtomicLevelAt(level),
Development: false,
Encoding: "console",
EncoderConfig: zapcore.EncoderConfig{
TimeKey: "time",
LevelKey: "level",
MessageKey: "msg",
LineEnding: zapcore.DefaultLineEnding,
EncodeLevel: zapcore.CapitalLevelEncoder,
EncodeTime: func(t time.Time, enc zapcore.PrimitiveArrayEncoder) {
enc.AppendString(t.Format("2006-01-02 15:04:05"))
},
},
OutputPaths: []string{logFileName},
ErrorOutputPaths: []string{logFileName},
}
fileLogger, err = fileConfig.Build(zap.AddStacktrace(zapcore.DPanicLevel))
if err != nil {
return fmt.Errorf("创建文件logger失败: %v", err)
}
fileSugar = fileLogger.Sugar()
}
return nil
}
// rotateLogIfNeeded 检查并轮转日志文件
func rotateLogIfNeeded(logFileName string) error {
info, err := os.Stat(logFileName)
if os.IsNotExist(err) {
return nil
}
if err != nil {
return err
}
// 检查文件大小是否超过10MB
if info.Size() < 10*1024*1024 {
return nil
}
// 轮转日志文件
backupName := fmt.Sprintf("%s.%s", logFileName, time.Now().Format("20060102_150405"))
if err := os.Rename(logFileName, backupName); err != nil {
return err
}
// 异步清理旧日志文件
go cleanOldLogs(filepath.Dir(logFileName))
return nil
}
// cleanOldLogs 清理旧的日志备份文件保留最近5个
func cleanOldLogs(logDir string) {
files, err := filepath.Glob(filepath.Join(logDir, "hostsync.log.*"))
if err != nil || len(files) <= 5 {
return
}
// 删除最旧的文件
for i := 0; i < len(files)-5; i++ {
os.Remove(files[i])
}
}
// SetDebugMode 设置调试模式
func SetDebugMode(debug bool) {
debugMode = debug
}
// CloseLogger 关闭日志系统
func CloseLogger() {
if fileLogger != nil {
fileLogger.Sync()
}
if consoleLogger != nil {
consoleLogger.Sync()
}
}
// LogInfo 记录信息日志(只记录到文件)
func LogInfo(format string, args ...interface{}) {
if fileSugar != nil {
fileSugar.Infof(format, args...)
}
}
// LogError 记录错误日志(记录到文件和控制台)
func LogError(format string, args ...interface{}) {
if fileSugar != nil {
fileSugar.Errorf(format, args...)
}
if consoleSugar != nil {
consoleSugar.Errorf(format, args...)
} else {
fmt.Fprintf(os.Stderr, format+"\n", args...)
}
}
// LogDebug 记录调试日志(只记录到文件)
func LogDebug(format string, args ...interface{}) {
if fileSugar != nil {
fileSugar.Debugf(format, args...)
} else if debugMode {
fmt.Printf("DEBUG: "+format+"\n", args...)
}
}
// LogWarning 记录警告日志(只记录到文件)
func LogWarning(format string, args ...interface{}) {
if fileSugar != nil {
fileSugar.Warnf(format, args...)
}
}
// LogSuccess 记录成功日志(只记录到文件)
func LogSuccess(format string, args ...interface{}) {
if fileSugar != nil {
fileSugar.Infof(format, args...)
}
}
// LogFatal 记录致命错误并退出(记录到文件和控制台)
func LogFatal(format string, args ...interface{}) {
if fileSugar != nil {
fileSugar.Fatalf(format, args...)
} else {
fmt.Fprintf(os.Stderr, format+"\n", args...)
os.Exit(1)
}
}
// LogFileOnly 只记录到文件,不显示在控制台
func LogFileOnly(format string, args ...interface{}) {
if fileSugar != nil {
fileSugar.Infof(format, args...)
}
}
// LogResult 用户界面结果输出(直接输出,不使用日志系统)
func LogResult(format string, args ...interface{}) {
fmt.Printf(format, args...)
}

385
utils/utils.go Normal file
View File

@ -0,0 +1,385 @@
package utils
import (
"fmt"
"os"
"os/user"
"path/filepath"
"regexp"
"runtime"
"sort"
"strings"
"time"
)
// GetHostsPath 获取系统hosts文件路径
func GetHostsPath() string {
switch runtime.GOOS {
case "windows":
return "C:\\Windows\\System32\\drivers\\etc\\hosts"
case "linux", "darwin":
return "/etc/hosts"
default:
return "/etc/hosts"
}
}
// CheckAdmin 检查是否以管理员权限运行
func CheckAdmin() {
if !isRunningAsAdmin() {
if runtime.GOOS == "windows" {
LogError("需要管理员权限运行")
LogInfo("请右键点击命令提示符或PowerShell选择'以管理员身份运行'")
} else {
LogError("需要root权限请使用sudo运行")
}
os.Exit(1)
}
if runtime.GOOS == "windows" {
LogSuccess("已以管理员权限运行")
}
}
// ValidateBlockName 验证块名称是否有效
func ValidateBlockName(name string) bool {
// 块名称只能包含字母、数字、下划线和连字符
matched, _ := regexp.MatchString(`^[a-zA-Z0-9_-]+$`, name)
return matched && len(name) > 0 && len(name) <= 50
}
// ValidateDomain 验证域名格式
func ValidateDomain(domain string) bool {
// 简单的域名格式验证
matched, _ := regexp.MatchString(`^[a-zA-Z0-9][a-zA-Z0-9-]{1,61}[a-zA-Z0-9](\.[a-zA-Z0-9][a-zA-Z0-9-]{1,61}[a-zA-Z0-9])*$`, domain)
return matched
}
// ValidateIP 验证IP地址格式
func ValidateIP(ip string) bool {
// 简单的IP地址格式验证
parts := strings.Split(ip, ".")
if len(parts) != 4 {
return false
}
for _, part := range parts {
if len(part) == 0 || len(part) > 3 {
return false
}
// 检查是否为数字且在0-255范围内
num := 0
for _, c := range part {
if c < '0' || c > '9' {
return false
}
num = num*10 + int(c-'0')
}
if num > 255 {
return false
}
}
return true
}
// TrimString 去除字符串前后空白字符
func TrimString(s string) string {
return strings.TrimSpace(s)
}
// IsComment 检查行是否为注释
func IsComment(line string) bool {
trimmed := TrimString(line)
return strings.HasPrefix(trimmed, "#")
}
// FileExists 检查文件是否存在
func FileExists(path string) bool {
_, err := os.Stat(path)
return !os.IsNotExist(err)
}
// WriteFile 写入文件
func WriteFile(path string, data []byte) error {
// 创建目录
dir := strings.ReplaceAll(path, "\\", "/")
if idx := strings.LastIndex(dir, "/"); idx != -1 {
if err := os.MkdirAll(dir[:idx], 0755); err != nil {
return err
}
}
return os.WriteFile(path, data, 0644)
}
// ReadFile 读取文件
func ReadFile(path string) ([]byte, error) {
return os.ReadFile(path)
}
// BackupFile 备份文件到用户备份目录
func BackupFile(path string) error {
data, err := ReadFile(path)
if err != nil {
return err
}
// 导入config包获取备份目录
// 注意这里需要确保config已经初始化
backupDir := getBackupDirectory()
// 确保备份目录存在
if err := os.MkdirAll(backupDir, 0755); err != nil {
return fmt.Errorf("创建备份目录失败: %v", err)
}
// 生成备份文件名(包含时间戳)
filename := filepath.Base(path)
timestamp := time.Now().Format("20060102_150405")
backupFileName := fmt.Sprintf("%s.%s.backup", filename, timestamp)
backupPath := filepath.Join(backupDir, backupFileName)
// 写入备份文件
if err := WriteFile(backupPath, data); err != nil {
return fmt.Errorf("写入备份文件失败: %v", err)
}
// 清理旧备份文件,只保留最新的几个
if err := cleanOldBackups(backupDir, filename, 5); err != nil {
// 清理失败不影响备份操作,只记录日志
LogWarning("清理旧备份文件失败: %v", err)
}
return nil
}
// IsEmptyLine 检查是否为空行
func IsEmptyLine(line string) bool {
return len(TrimString(line)) == 0
}
// ParseHostsLine 解析hosts文件行
func ParseHostsLine(line string) (ip, domain, comment string) {
line = TrimString(line)
// 检查是否为注释行
if IsComment(line) {
return "", "", line
}
// 分离注释部分
commentIndex := strings.Index(line, "#")
if commentIndex != -1 {
comment = TrimString(line[commentIndex:])
line = TrimString(line[:commentIndex])
}
// 分离IP和域名
parts := strings.Fields(line)
if len(parts) >= 2 {
ip = parts[0]
domain = parts[1]
}
return ip, domain, comment
}
// FormatHostsLine 格式化hosts文件行
func FormatHostsLine(ip, domain, comment string) string {
if ip == "" || domain == "" {
if comment != "" {
return comment
}
return ""
}
line := fmt.Sprintf("%-15s %s", ip, domain)
if comment != "" && !strings.HasPrefix(comment, "#") {
line += " # " + comment
} else if comment != "" {
line += " " + comment
}
return line
}
// getBackupDirectory 获取备份目录
func getBackupDirectory() string {
// 获取用户配置目录
currentUser, err := user.Current()
if err != nil {
return "backup" // 回退到相对路径
}
return filepath.Join(currentUser.HomeDir, ".hostsync", "backup")
}
// cleanOldBackups 清理旧备份文件只保留最新的count个
func cleanOldBackups(backupDir, originalFileName string, keepCount int) error {
// 读取备份目录中的文件
files, err := os.ReadDir(backupDir)
if err != nil {
return err
}
// 筛选出匹配原文件名的备份文件
var backupFiles []os.DirEntry
prefix := originalFileName + "."
suffix := ".backup"
for _, file := range files {
if !file.IsDir() && strings.HasPrefix(file.Name(), prefix) && strings.HasSuffix(file.Name(), suffix) {
backupFiles = append(backupFiles, file)
}
}
// 如果备份文件数量不超过保留数量,直接返回
if len(backupFiles) <= keepCount {
return nil
}
// 按修改时间排序(最新的在前)
sort.Slice(backupFiles, func(i, j int) bool {
infoI, _ := backupFiles[i].Info()
infoJ, _ := backupFiles[j].Info()
return infoI.ModTime().After(infoJ.ModTime())
})
// 删除多余的旧备份文件
for i := keepCount; i < len(backupFiles); i++ {
oldBackupPath := filepath.Join(backupDir, backupFiles[i].Name())
if err := os.Remove(oldBackupPath); err != nil {
LogWarning("删除旧备份文件失败: %s, %v", oldBackupPath, err)
}
}
return nil
}
// DisplayWidth 计算字符串的显示宽度中文字符占2个宽度
func DisplayWidth(s string) int {
width := 0
for _, r := range s {
if r > 127 { // 非ASCII字符
width += 2
} else {
width += 1
}
}
return width
}
// PadString 根据显示宽度填充字符串
func PadString(s string, width int) string {
currentWidth := DisplayWidth(s)
if currentWidth >= width {
return s
}
padding := strings.Repeat(" ", width-currentWidth)
return s + padding
}
// TruncateWithWidth 根据显示宽度截断字符串
func TruncateWithWidth(s string, maxWidth int) string {
if DisplayWidth(s) <= maxWidth {
return s
}
width := 0
var result []rune
for _, r := range s {
charWidth := 1
if r > 127 {
charWidth = 2
}
if width+charWidth > maxWidth-3 { // 为"..."预留3个字符
break
}
result = append(result, r)
width += charWidth
}
return string(result) + "..."
}
// FormatTable 格式化表格输出
func FormatTable(headers []string, rows [][]string, columnWidths []int) {
// 如果没有指定列宽,自动计算
if len(columnWidths) == 0 {
columnWidths = make([]int, len(headers))
// 计算标题宽度
for i, header := range headers {
columnWidths[i] = DisplayWidth(header)
}
// 计算数据行宽度
for _, row := range rows {
for i, cell := range row {
if i < len(columnWidths) {
width := DisplayWidth(cell)
if width > columnWidths[i] {
columnWidths[i] = width
}
}
}
}
// 设置最小和最大宽度
for i := range columnWidths {
if columnWidths[i] < 8 {
columnWidths[i] = 8
}
if columnWidths[i] > 40 {
columnWidths[i] = 40
}
}
}
// 打印顶部边框
printTableBorder(columnWidths, "┌", "┬", "┐")
// 打印标题行
fmt.Print("│")
for i, header := range headers {
paddedHeader := PadString(" "+header+" ", columnWidths[i]+2)
fmt.Print(paddedHeader + "│")
}
fmt.Println()
// 打印标题分隔线
printTableBorder(columnWidths, "├", "┼", "┤")
// 打印数据行
for _, row := range rows {
fmt.Print("│")
for i, cell := range row {
if i < len(columnWidths) {
// 截断过长的内容
truncatedCell := TruncateWithWidth(cell, columnWidths[i])
paddedCell := PadString(" "+truncatedCell+" ", columnWidths[i]+2)
fmt.Print(paddedCell + "│")
}
}
fmt.Println()
}
// 打印底部边框
printTableBorder(columnWidths, "└", "┴", "┘")
}
// printTableBorder 打印表格边框
func printTableBorder(columnWidths []int, left, middle, right string) {
fmt.Print(left)
for i, width := range columnWidths {
fmt.Print(strings.Repeat("─", width+2))
if i < len(columnWidths)-1 {
fmt.Print(middle)
}
}
fmt.Println(right)
}

12
utils/utils_unix.go Normal file
View File

@ -0,0 +1,12 @@
//go:build !windows
// +build !windows
package utils
import "os"
// isRunningAsAdmin 检查当前进程是否以管理员权限运行 (Unix/Linux/macOS)
func isRunningAsAdmin() bool {
// 在Unix系统中检查是否为root用户
return os.Getuid() == 0
}

35
utils/utils_windows.go Normal file
View File

@ -0,0 +1,35 @@
//go:build windows
// +build windows
package utils
import (
"golang.org/x/sys/windows"
)
// isRunningAsAdmin 检查当前进程是否以管理员权限运行 (Windows)
func isRunningAsAdmin() bool {
var sid *windows.SID
// 获取内置管理员组的SID
err := windows.AllocateAndInitializeSid(
&windows.SECURITY_NT_AUTHORITY,
2,
windows.SECURITY_BUILTIN_DOMAIN_RID,
windows.DOMAIN_ALIAS_RID_ADMINS,
0, 0, 0, 0, 0, 0,
&sid)
if err != nil {
return false
}
defer windows.FreeSid(sid)
// 获取当前进程的访问令牌
token := windows.Token(0)
member, err := token.IsMember(sid)
if err != nil {
return false
}
return member
}