commit c2d1ac9af5ae92bcdcc0e4d523944636a049cf72 Author: ИЭΛЭS 7ΙΛЭ <6292673+evil7@users.noreply.github.com> Date: Tue Jun 24 11:06:45 2025 +0800 init for public push diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4811f48 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..d7fcb92 --- /dev/null +++ b/.goreleaser.yaml @@ -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 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0bc71ea --- /dev/null +++ b/LICENSE @@ -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. \ No newline at end of file diff --git a/banner.txt b/banner.txt new file mode 100644 index 0000000..8c0aa91 --- /dev/null +++ b/banner.txt @@ -0,0 +1,6 @@ + _ _ + | |__ ___ ___| |_ ___ _ _ _ __ ___ + | '_ \ / _ \/ __| __/ __| | | | '_ \ / __| + | | | | (_) \__ \ |_\__ \ |_| | | | | (__ + |_| |_|\___/|___/\__|___/\__, |_| |_|\___| + by evil7@deepwn |___/ \ No newline at end of file diff --git a/build.ps1 b/build.ps1 new file mode 100644 index 0000000..c6f9028 --- /dev/null +++ b/build.ps1 @@ -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 \ No newline at end of file diff --git a/build.sh b/build.sh new file mode 100644 index 0000000..ed3f5c7 --- /dev/null +++ b/build.sh @@ -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)" diff --git a/cmd/add.go b/cmd/add.go new file mode 100644 index 0000000..2e6577e --- /dev/null +++ b/cmd/add.go @@ -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 [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) +} diff --git a/cmd/cron.go b/cmd/cron.go new file mode 100644 index 0000000..ac4f7af --- /dev/null +++ b/cmd/cron.go @@ -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") +} diff --git a/cmd/disable.go b/cmd/disable.go new file mode 100644 index 0000000..83b6f96 --- /dev/null +++ b/cmd/disable.go @@ -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 ", + 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) +} diff --git a/cmd/enable.go b/cmd/enable.go new file mode 100644 index 0000000..ea24cd4 --- /dev/null +++ b/cmd/enable.go @@ -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 ", + 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) +} diff --git a/cmd/format.go b/cmd/format.go new file mode 100644 index 0000000..7ddc6f6 --- /dev/null +++ b/cmd/format.go @@ -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] + } + } + } +} diff --git a/cmd/init.go b/cmd/init.go new file mode 100644 index 0000000..81d1476 --- /dev/null +++ b/cmd/init.go @@ -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 ' 添加新域名") + 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 +} diff --git a/cmd/init_unix.go b/cmd/init_unix.go new file mode 100644 index 0000000..9ba36b1 --- /dev/null +++ b/cmd/init_unix.go @@ -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 +} diff --git a/cmd/init_windows.go b/cmd/init_windows.go new file mode 100644 index 0000000..edff22c --- /dev/null +++ b/cmd/init_windows.go @@ -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)") +} diff --git a/cmd/list.go b/cmd/list.go new file mode 100644 index 0000000..31b9abd --- /dev/null +++ b/cmd/list.go @@ -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) +} diff --git a/cmd/log.go b/cmd/log.go new file mode 100644 index 0000000..19853b4 --- /dev/null +++ b/cmd/log.go @@ -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]) +} diff --git a/cmd/remove.go b/cmd/remove.go new file mode 100644 index 0000000..b7346b2 --- /dev/null +++ b/cmd/remove.go @@ -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 [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 +} diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..f7ea700 --- /dev/null +++ b/cmd/root.go @@ -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 解析方式(DNS、DoH、预设服务器) +- 🔄 一键自动更新记录 +- ⏰ 按任务定时自动更新块 +- 📝 智能格式化与清理 +- 🔧 后台服务模式运行`, + 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]) +} diff --git a/cmd/server.go b/cmd/server.go new file mode 100644 index 0000000..94b0673 --- /dev/null +++ b/cmd/server.go @@ -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 测试指定服务器 + +示例: + 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: 未配置") + } +} diff --git a/cmd/service.go b/cmd/service.go new file mode 100644 index 0000000..0549226 --- /dev/null +++ b/cmd/service.go @@ -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) + } +} diff --git a/cmd/service_unix.go b/cmd/service_unix.go new file mode 100644 index 0000000..2c0aab6 --- /dev/null +++ b/cmd/service_unix.go @@ -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 +} diff --git a/cmd/service_windows.go b/cmd/service_windows.go new file mode 100644 index 0000000..97ef340 --- /dev/null +++ b/cmd/service_windows.go @@ -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 +} diff --git a/cmd/update.go b/cmd/update.go new file mode 100644 index 0000000..34f3ada --- /dev/null +++ b/cmd/update.go @@ -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配置已保存到块设置") + } +} diff --git a/config.json b/config.json new file mode 100644 index 0000000..6982f0e --- /dev/null +++ b/config.json @@ -0,0 +1,7 @@ +{ + "hostsPath": "C:\\Windows\\System32\\drivers\\etc\\hosts", + "backupCount": 5, + "dnsTimeout": 5000, + "maxConcurrent": 10, + "logLevel": "info" +} \ No newline at end of file diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..a3e79a7 --- /dev/null +++ b/config/config.go @@ -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" // 回退到相对路径 +} diff --git a/config/config.json b/config/config.json new file mode 100644 index 0000000..5379601 --- /dev/null +++ b/config/config.json @@ -0,0 +1,8 @@ +{ + "hostsPath": "C:\\Windows\\System32\\drivers\\etc\\hosts", + "backupCount": 5, + "dnsTimeout": 5000, + "maxConcurrent": 10, + "logLevel": "info", + "logPath": "logs" +} \ No newline at end of file diff --git a/core/cron.go b/core/cron.go new file mode 100644 index 0000000..26ae3f8 --- /dev/null +++ b/core/cron.go @@ -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 +} diff --git a/core/dns.go b/core/dns.go new file mode 100644 index 0000000..a1ecc64 --- /dev/null +++ b/core/dns.go @@ -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 +} diff --git a/core/doh.go b/core/doh.go new file mode 100644 index 0000000..2da0d88 --- /dev/null +++ b/core/doh.go @@ -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 +} diff --git a/core/hosts.go b/core/hosts.go new file mode 100644 index 0000000..6ccca13 --- /dev/null +++ b/core/hosts.go @@ -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() +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..a988c91 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..abcd17a --- /dev/null +++ b/go.sum @@ -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= diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 0000000..8f803e0 --- /dev/null +++ b/install.ps1 @@ -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 diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..4dc8887 --- /dev/null +++ b/install.sh @@ -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 diff --git a/main.go b/main.go new file mode 100644 index 0000000..8221a7d --- /dev/null +++ b/main.go @@ -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) +} diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..c323717 --- /dev/null +++ b/readme.md @@ -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 [ip]` | 添加记录 | `hostsync add dev api.test.com` | +| `remove [domain]` | 删除记录/块 | `hostsync remove dev api.test.com` | +| `enable ` | 启用块 | `hostsync enable dev` | +| `disable ` | 禁用块 | `hostsync disable dev` | + +### DNS 解析与更新 + +| 命令 | 说明 | 示例 | +| ---------------- | ------------- | ------------------------------------------------- | +| `update [block]` | 更新解析 | `hostsync update --dns 1.1.1.1` | +| `--dns ` | 指定 DNS | `hostsync update --dns 8.8.8.8` | +| `--doh ` | 使用 DoH | `hostsync update --doh https://1.1.1.1/dns-query` | +| `--srv ` | 预设服务器 | `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. diff --git a/servers.json b/servers.json new file mode 100644 index 0000000..855d016 --- /dev/null +++ b/servers.json @@ -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" + } +] diff --git a/service/darwin.go b/service/darwin.go new file mode 100644 index 0000000..71ee19c --- /dev/null +++ b/service/darwin.go @@ -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(` + + + + Label + %s + ProgramArguments + + %s + service + run + --config + %s + + RunAtLoad + + KeepAlive + + StandardOutPath + %s + StandardErrorPath + %s + + +`, 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 +} diff --git a/service/linux.go b/service/linux.go new file mode 100644 index 0000000..f8a0c4f --- /dev/null +++ b/service/linux.go @@ -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 +} diff --git a/service/service.go b/service/service.go new file mode 100644 index 0000000..f7f6844 --- /dev/null +++ b/service/service.go @@ -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 +} diff --git a/service/windows.go b/service/windows.go new file mode 100644 index 0000000..bc2400a --- /dev/null +++ b/service/windows.go @@ -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 + } +} diff --git a/test_hosts b/test_hosts new file mode 100644 index 0000000..f8c763f --- /dev/null +++ b/test_hosts @@ -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; diff --git a/utils/logger.go b/utils/logger.go new file mode 100644 index 0000000..32d0521 --- /dev/null +++ b/utils/logger.go @@ -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...) +} diff --git a/utils/utils.go b/utils/utils.go new file mode 100644 index 0000000..46dd3c8 --- /dev/null +++ b/utils/utils.go @@ -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) +} diff --git a/utils/utils_unix.go b/utils/utils_unix.go new file mode 100644 index 0000000..6387906 --- /dev/null +++ b/utils/utils_unix.go @@ -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 +} diff --git a/utils/utils_windows.go b/utils/utils_windows.go new file mode 100644 index 0000000..f4079dc --- /dev/null +++ b/utils/utils_windows.go @@ -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 +}