diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..a0c329d --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,29 @@ +name: release + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + +jobs: + goreleaser: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version-file: 'go.mod' + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v5 + with: + version: v1.23.0 + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ac0b529 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.idea +dist \ No newline at end of file diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..97576ef --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,46 @@ +project_name: hosts-go +before: + hooks: + - go mod tidy +builds: + - id: hosts-go + main: ./ + binary: hosts-go + env: + - CGO_ENABLED=0 + goos: + - linux + - windows + - darwin + goarch: + - "386" + - amd64 + - arm + - arm64 + goarm: + - "6" + - "7" + ignore: + - goos: windows + goarch: arm + - goos: darwin + goarch: arm + flags: + - -trimpath + ldflags: + - -s -w -X main.version={{.Version}} + +archives: + - format: binary + +changelog: + use: github + groups: + - title: Features + regexp: '^.*?feat(\([[:word:]]+\))??!?:.+$' + order: 0 + - title: "Bug fixes" + regexp: '^.*?(?:bug|fix)(\([[:word:]]+\))??!?:.+$' + order: 1 + - title: Others + order: 999 \ No newline at end of file diff --git a/README.EN.md b/README.EN.md new file mode 100644 index 0000000..99f4eb4 --- /dev/null +++ b/README.EN.md @@ -0,0 +1,54 @@ +# hosts-go + +[中文](README.md) | English + +## Overview + +hosts-go is a command-line tool for fetching and merging hosts files from the internet. It can periodically fetch hosts files from specified URLs and merge them into the local hosts file. + +## Installation + +To install hosts-go to your system, use the following command: + +``` +go install github.com/hunshcn/hosts-go +``` + +## Usage + +hosts-go provides the following command-line options: + +- `--url` or `-u`: Specify the URLs to fetch hosts files from. Multiple URLs can be specified. +- `--test` or `-t`: Only output the merged hosts file content. +- `--content-only`: Only output the fetched hosts file content. +- `--service` or `-s`: Install or uninstall hosts-go as a system service. +- `--duration` or `-d`: Specify the duration between each fetch of hosts files. The default is 1 hour. +- `--reload-command`:Command to execute after successfully updating the hosts file. + +### Examples + +Fetch and merge hosts files: + +``` +hosts-go --url https://gitlab.com/ineo6/hosts +``` + +Install hosts-go as a system service: + +``` +hosts-go -s install +``` + +Uninstall hosts-go service: + +``` +hosts-go -s uninstall +``` + +### Notes + +- Before running the hosts-go command, make sure you have sufficient permissions to read and write the hosts file. + +## License + +MIT License. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..8f469d5 --- /dev/null +++ b/README.md @@ -0,0 +1,56 @@ +# hosts-go + +中文 | [English](README.EN.md) + +## 概述 + +hosts-go 是一个用于从互联网上获取和合并 hosts 文件的命令行工具。它可以定期从指定的 URL 获取 hosts 文件,并将其合并到本地的 hosts 文件中。 + +## 安装 + +使用以下命令将 hosts-go 安装到您的系统中: + +``` +go install github.com/hunshcn/hosts-go +``` + +## 使用 +> [!NOTE] +> v1.0.0 发布之前可能进行较大 API 变更。 + +hosts-go 提供了以下命令行选项: + +- `--url` 或 `-u`:指定要获取 hosts 文件的 URL。可以指定多个 URL。 +- `--test` 或 `-t`:仅输出合并后的 hosts 文件内容。 +- `--content-only`:仅输出获取的 hosts 文件内容。 +- `--service` 或 `-s`:安装或卸载 hosts-go 作为系统服务。 +- `--duration` 或 `-d`:指定更新 hosts 文件的时间间隔,默认为 1 小时。 +- `--reload-command`:在更新成功 hosts 文件后执行的命令。 + +### 示例 + +获取并合并 hosts 文件: + +``` +hosts-go -u https://gitlab.com/ineo6/hosts +``` + +安装 hosts-go 作为系统服务: + +``` +hosts-go -s install +``` + +卸载 hosts-go 服务: + +``` +hosts-go -s uninstall +``` + +### 注意事项 + +- 在运行 hosts-go 之前,请确保您具有足够的权限来读取和写入 hosts 文件。 + +## License + +MIT License. \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..dcda0f7 --- /dev/null +++ b/go.mod @@ -0,0 +1,15 @@ +module github.com/hunshcn/hosts-go + +go 1.21 + +require ( + github.com/kardianos/service v1.2.2 + github.com/sirupsen/logrus v1.9.3 + github.com/spf13/cobra v1.8.0 +) + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..33298a2 --- /dev/null +++ b/go.sum @@ -0,0 +1,27 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/kardianos/service v1.2.2 h1:ZvePhAHfvo0A7Mftk/tEzqEZ7Q4lgnR8sGz4xu1YX60= +github.com/kardianos/service v1.2.2/go.mod h1:CIMRFEJVL+0DS1a3Nx06NaMn4Dz63Ng6O7dl0qH0zVM= +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/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= +github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +golang.org/x/sys v0.0.0-20201015000850-e3ed0017c211/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 h1:0A+M6Uqn+Eje4kHMK80dtF3JCXC4ykBgQG4Fe06QRhQ= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +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/hosts_windows.go b/hosts_windows.go new file mode 100644 index 0000000..73622fd --- /dev/null +++ b/hosts_windows.go @@ -0,0 +1,10 @@ +package main + +import ( + "os" + "path/filepath" +) + +func init() { + systemHostsPath = filepath.Join(os.Getenv("SystemRoot"), "System32", "drivers", "etc", "hosts") +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..09c523f --- /dev/null +++ b/main.go @@ -0,0 +1,197 @@ +package main + +import ( + "bufio" + "bytes" + "errors" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "strings" + "time" + + "github.com/sirupsen/logrus" + "github.com/spf13/cobra" +) + +const ( + BlockHeader = "### hosts-go ###" + BlockFooter = "### hosts-go end ###" +) + +var ( + version = "dev" + systemHostsPath = "/etc/hosts" +) + +func main() { + rootCmd := newCmd() + rootCmd.Version = version + + if err := rootCmd.Execute(); err != nil { + logrus.Fatal(err) + os.Exit(1) + } +} + +func newCmd() *cobra.Command { + var ( + testOnly bool + contentOnly bool + serviceAction string + urls []string + duration time.Duration + reloadCommand string + ) + + cmd := &cobra.Command{ + Use: "hosts-go", + Short: "Fetch and merge hosts files from the internet", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if len(urls) == 0 { + return errors.New("please provide at least one URL using --url flag") + } + + switch serviceAction { + case "install": + return installService(urls) + case "uninstall": + return uninstallService(urls) + case "": + default: + return errors.New("invalid service action") + } + + if !testOnly { + runLoop(urls, duration, reloadCommand) + return nil + } + + hostsBytes, err := FetchAndMergeHosts(urls) + if err != nil { + return err + } + + if contentOnly { + fmt.Println(string(hostsBytes)) + return nil + } + + hostsContent, err := RenderHostsFile(systemHostsPath, hostsBytes) + if err != nil { + return err + } + + fmt.Println(hostsContent) + return nil + + }, + } + + cmd.Flags().StringSliceVarP(&urls, "url", "u", nil, "URLs to fetch hosts files from") + cmd.Flags().BoolVarP(&testOnly, "test", "t", false, "Only output the rendered hosts content") + cmd.Flags().BoolVar(&contentOnly, "content-only", false, "Output only the fetched hosts content") + cmd.Flags().StringVarP(&serviceAction, "service", "s", "", "Install or uninstall service") + cmd.Flags().DurationVarP(&duration, "duration", "d", 1*time.Hour, "Duration between each fetch") + cmd.Flags().StringVar(&reloadCommand, "reload-command", "", "Command to execute after hosts file updated") + return cmd +} + +func FetchAndMergeHosts(urls []string) ([]byte, error) { + var merged bytes.Buffer + + for _, url := range urls { + resp, err := http.Get(url) + if err == nil && resp.StatusCode != http.StatusOK { + err = fmt.Errorf("http status %s", resp.Status) + } + if err != nil { + return nil, fmt.Errorf("failed to fetch hosts file from %s: %v", url, err) + } + + _, err = io.Copy(&merged, resp.Body) + _ = resp.Body.Close() + if err != nil { + return nil, fmt.Errorf("failed to read hosts file from %s: %v", url, err) + } + } + + return merged.Bytes(), nil +} + +func RenderHostsFile(hostsFile string, content []byte) (string, error) { + file, err := os.OpenFile(hostsFile, os.O_RDONLY, 0644) + if err != nil { + return "", fmt.Errorf("failed to open hosts file: %v", err) + } + defer file.Close() + + scanner := bufio.NewScanner(file) + var result, after strings.Builder + var step int + for scanner.Scan() { + line := scanner.Text() + if line == BlockHeader && step == 0 { + step = 1 + } else if line == BlockFooter && step == 1 { + step = 2 + continue + } + if step == 0 { + result.WriteString(line + "\n") + } else if step == 2 { + after.WriteString(line + "\n") + } + } + + if scanner.Err() != nil { + return "", fmt.Errorf("failed to read hosts file: %v", scanner.Err()) + } + result.WriteString(BlockHeader + "\n") + result.Write(content) + result.WriteString("\n\n" + "# hosts-go updated at " + time.Now().Format(time.RFC3339) + "\n") + result.WriteString(BlockFooter + "\n") + result.WriteString(after.String()) + return result.String(), nil +} + +func WriteHostsFile(hostsFile, content string) error { + if err := os.WriteFile(hostsFile, []byte(content), 0644); err != nil { + return fmt.Errorf("failed to write hosts file: %v", err) + } + return nil +} + +func runLoop(urls []string, duration time.Duration, reloadCommand string) { + tick := time.Tick(duration) + for ; true; <-tick { + logrus.Info("start service") + if err := update(urls); err != nil { + logrus.Error(err) + continue + } + logrus.Info("update hosts file successfully") + if reloadCommand != "" { + logrus.Info("run reload command") + cmd := exec.Command("sh", "-c", reloadCommand) + if err := cmd.Run(); err != nil { + logrus.Error(err) + } + } + } +} + +func update(urls []string) error { + hostsBytes, err := FetchAndMergeHosts(urls) + if err != nil { + return err + } + hostsContent, err := RenderHostsFile(systemHostsPath, hostsBytes) + if err != nil { + return err + } + return WriteHostsFile(systemHostsPath, hostsContent) +} diff --git a/service.go b/service.go new file mode 100644 index 0000000..888494f --- /dev/null +++ b/service.go @@ -0,0 +1,125 @@ +package main + +import ( + "fmt" + + "github.com/kardianos/service" + "github.com/sirupsen/logrus" +) + +// copy from https://github.com/jeessy2/ddns-go/blob/1576b7bfd8272bb1dda1352919d367bab2f4ce94/main.go#L327 +const sysvScript = `#!/bin/sh /etc/rc.common +DESCRIPTION="{{.Description}}" +cmd="{{.Path}}{{range .Arguments}} {{.|cmd}}{{end}}" +name="ddns-go" +pid_file="/var/run/$name.pid" +stdout_log="/var/log/$name.log" +stderr_log="/var/log/$name.err" +START=99 +get_pid() { + cat "$pid_file" +} +is_running() { + [ -f "$pid_file" ] && cat /proc/$(get_pid)/stat > /dev/null 2>&1 +} +start() { + if is_running; then + echo "Already started" + else + echo "Starting $name" + {{if .WorkingDirectory}}cd '{{.WorkingDirectory}}'{{end}} + $cmd >> "$stdout_log" 2>> "$stderr_log" & + echo $! > "$pid_file" + if ! is_running; then + echo "Unable to start, see $stdout_log and $stderr_log" + exit 1 + fi + fi +} +stop() { + if is_running; then + echo -n "Stopping $name.." + kill $(get_pid) + for i in $(seq 1 10) + do + if ! is_running; then + break + fi + echo -n "." + sleep 1 + done + echo + if is_running; then + echo "Not stopped; may still be shutting down or shutdown may have failed" + exit 1 + else + echo "Stopped" + if [ -f "$pid_file" ]; then + rm "$pid_file" + fi + fi + else + echo "Not running" + fi +} +restart() { + stop + if is_running; then + echo "Unable to stop, will not attempt to start" + exit 1 + fi + start +} +` + +func getService(urls []string) service.Service { + var depends []string + options := make(service.KeyValue) + switch service.ChosenSystem().String() { + case "unix-systemv": + options["SysvScript"] = sysvScript + case "windows-service": + options["DelayedAutoStart"] = true + default: + depends = append(depends, "Requires=network.target", + "After=network-online.target") + } + svcConfig := &service.Config{ + Name: "hosts-go", + DisplayName: "hosts-go", + Description: "Fetch and merge hosts files from the internet", + Dependencies: depends, + Option: options, + } + for _, u := range urls { + svcConfig.Arguments = append(svcConfig.Arguments, "-u", u) + } + + s, err := service.New(nil, svcConfig) + if err != nil { + logrus.Fatal(err) + } + return s +} + +func installService(urls []string) error { + s := getService(urls) + err := s.Install() + if err != nil { + return fmt.Errorf("failed to install service: %v", err) + + } + fmt.Println("Service installed successfully!") + return nil +} + +func uninstallService(urls []string) error { + s := getService(urls) + err := s.Uninstall() + if err != nil { + return fmt.Errorf("failed to install service: %v", err) + } + + logrus.Info("Service uninstalled successfully!") + return nil +}