├── assets ├── demo.gif └── demo.tape ├── internal ├── hostsfile │ ├── processor_test.go │ ├── file.go │ └── processor.go ├── action │ ├── exit │ │ └── errors.go │ ├── status.go │ ├── restore.go │ ├── config.go │ ├── disable.go │ ├── enable.go │ ├── update.go │ └── action.go ├── config │ ├── validator.go │ ├── validator_test.go │ ├── config.go │ └── config_test.go └── http │ └── http.go ├── pkg └── fsutil │ ├── fsutil.go │ └── fsutil_test.go ├── .gitignore ├── .github └── workflows │ ├── release.yml │ └── main.yml ├── go.mod ├── Makefile ├── .goreleaser.yaml ├── cmd └── adless │ └── main.go ├── .golangci.yml ├── scripts └── install.sh ├── go.sum ├── README.md └── LICENSE /assets/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WIttyJudge/adless/HEAD/assets/demo.gif -------------------------------------------------------------------------------- /internal/hostsfile/processor_test.go: -------------------------------------------------------------------------------- 1 | package hostsfile 2 | 3 | import "testing" 4 | 5 | func TestIsLineComment(_ *testing.T) {} 6 | -------------------------------------------------------------------------------- /internal/action/exit/errors.go: -------------------------------------------------------------------------------- 1 | package exit 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/rs/zerolog/log" 7 | "github.com/urfave/cli/v2" 8 | ) 9 | 10 | const ( 11 | Unknown = iota 12 | Config 13 | HostsFile 14 | ) 15 | 16 | // Error returns a user friendly CLI error. 17 | func Error(exitCode int, err error, format string, args ...any) error { 18 | msg := fmt.Sprintf(format, args...) 19 | log.Error().Err(err).Msg(msg) 20 | 21 | return cli.Exit("", exitCode) 22 | } 23 | -------------------------------------------------------------------------------- /pkg/fsutil/fsutil.go: -------------------------------------------------------------------------------- 1 | package fsutil 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io" 7 | "os" 8 | ) 9 | 10 | // CopyFile copies a file from src to dst. 11 | func CopyFile(src, dst string) error { 12 | srcFile, err := os.Open(src) 13 | if errors.Is(err, os.ErrNotExist) { 14 | return fmt.Errorf("file %s not found", src) 15 | } 16 | if err != nil { 17 | return err 18 | } 19 | 20 | defer srcFile.Close() 21 | 22 | dstFile, err := os.Create(dst) 23 | if err != nil { 24 | return err 25 | } 26 | defer dstFile.Close() 27 | 28 | if _, err := io.Copy(dstFile, srcFile); err != nil { 29 | return err 30 | } 31 | 32 | return nil 33 | } 34 | -------------------------------------------------------------------------------- /internal/action/status.go: -------------------------------------------------------------------------------- 1 | package action 2 | 3 | import ( 4 | "github.com/WIttyJudge/adless/internal/action/exit" 5 | "github.com/WIttyJudge/adless/internal/hostsfile" 6 | 7 | "github.com/rs/zerolog/log" 8 | "github.com/urfave/cli/v2" 9 | ) 10 | 11 | func (a *Action) Status(_ *cli.Context) error { 12 | hosts, err := hostsfile.New() 13 | if err != nil { 14 | return exit.Error(exit.HostsFile, err, "failed to process hosts file") 15 | } 16 | 17 | if hosts.Status() == hostsfile.Enabled { 18 | log.Info().Msg("domains blocking enabled") 19 | return nil 20 | } 21 | 22 | log.Info().Msg("domains blocking disabled") 23 | 24 | return nil 25 | } 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # If you prefer the allow list template instead of the deny list, see community template: 2 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 3 | # 4 | # Binaries for programs and plugins 5 | *.exe 6 | *.exe~ 7 | *.dll 8 | *.so 9 | *.dylib 10 | 11 | # Test binary, built with `go test -c` 12 | *.test 13 | 14 | # Output of the go coverage tool, specifically when used with LiteIDE 15 | *.out 16 | 17 | # Dependency directories (remove the comment below to include it) 18 | # vendor/ 19 | 20 | # Go workspace file 21 | go.work 22 | go.work.sum 23 | 24 | # env file 25 | .env 26 | 27 | build/ 28 | 29 | coverage.* 30 | 31 | dist/ 32 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | permissions: 9 | contents: write 10 | 11 | jobs: 12 | release: 13 | name: Release 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Setup Go 17 | uses: actions/setup-go@v5 18 | with: 19 | go-version: 1.22.0 20 | 21 | - name: Checkout code 22 | uses: actions/checkout@v4 23 | 24 | - name: Build and Release 25 | uses: goreleaser/goreleaser-action@v6 26 | with: 27 | version: '~> v2' 28 | args: release --clean 29 | env: 30 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 31 | -------------------------------------------------------------------------------- /internal/action/restore.go: -------------------------------------------------------------------------------- 1 | package action 2 | 3 | import ( 4 | "github.com/WIttyJudge/adless/internal/action/exit" 5 | "github.com/WIttyJudge/adless/internal/hostsfile" 6 | 7 | "github.com/rs/zerolog/log" 8 | 9 | "github.com/urfave/cli/v2" 10 | ) 11 | 12 | func (a *Action) Restore(_ *cli.Context) error { 13 | log.Info().Msg("restoring hosts file from backup..") 14 | 15 | hosts, err := hostsfile.New() 16 | if err != nil { 17 | return exit.Error(exit.HostsFile, err, "failed to process hosts file") 18 | } 19 | 20 | if err := hosts.Restore(); err != nil { 21 | return exit.Error(exit.HostsFile, err, "failed to restore hosts file") 22 | } 23 | 24 | log.Info().Msg("restoring operation is successful") 25 | 26 | return nil 27 | } 28 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/WIttyJudge/adless 2 | 3 | go 1.22.0 4 | 5 | require ( 6 | github.com/charmbracelet/x/editor v0.1.0 7 | github.com/rs/zerolog v1.33.0 8 | github.com/stretchr/testify v1.9.0 9 | github.com/urfave/cli/v2 v2.27.4 10 | gopkg.in/yaml.v3 v3.0.1 11 | ) 12 | 13 | require ( 14 | github.com/cpuguy83/go-md2man/v2 v2.0.4 // indirect 15 | github.com/davecgh/go-spew v1.1.1 // indirect 16 | github.com/mattn/go-colorable v0.1.13 // indirect 17 | github.com/mattn/go-isatty v0.0.20 // indirect 18 | github.com/pmezard/go-difflib v1.0.0 // indirect 19 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 20 | github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect 21 | golang.org/x/sys v0.26.0 // indirect 22 | ) 23 | -------------------------------------------------------------------------------- /internal/config/validator.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "regexp" 7 | ) 8 | 9 | var ErrNoBlocklistsProvided = errors.New("no blocklists provided") 10 | 11 | func Validate(config *Config) error { 12 | if len(config.Blocklists) == 0 { 13 | return ErrNoBlocklistsProvided 14 | } 15 | 16 | for _, blocklist := range config.Blocklists { 17 | url := blocklist.Target 18 | if hasInvalidURLSymbols(url) { 19 | return fmt.Errorf("invalid blocklist target provided: %s", url) 20 | } 21 | } 22 | 23 | return nil 24 | } 25 | 26 | // hasInvalidURLSymbols checks for characters NOT allowed in URL. 27 | func hasInvalidURLSymbols(url string) bool { 28 | matched, _ := regexp.MatchString("[^a-zA-Z0-9:/?&%=~._()-;]", url) 29 | return matched 30 | } 31 | -------------------------------------------------------------------------------- /internal/action/config.go: -------------------------------------------------------------------------------- 1 | package action 2 | 3 | import ( 4 | "github.com/WIttyJudge/adless/internal/action/exit" 5 | "github.com/WIttyJudge/adless/internal/config" 6 | 7 | "github.com/urfave/cli/v2" 8 | ) 9 | 10 | func (a *Action) Config(_ *cli.Context) error { 11 | a.config.Print() 12 | 13 | return nil 14 | } 15 | 16 | func (a *Action) ConfigInit(_ *cli.Context) error { 17 | if err := config.Init(); err != nil { 18 | return exit.Error(exit.Config, err, "failed to initialize the config file") 19 | } 20 | 21 | return nil 22 | } 23 | 24 | func (a *Action) ConfigEdit(_ *cli.Context) error { 25 | if err := config.Init(); err != nil { 26 | return exit.Error(exit.Config, err, "failed to edit the config file") 27 | } 28 | 29 | if err := config.Edit(); err != nil { 30 | return exit.Error(exit.Config, err, "failed to edit the config file") 31 | } 32 | 33 | return nil 34 | } 35 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: main 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | jobs: 8 | tests: 9 | name: Tests 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Setup Go 13 | uses: actions/setup-go@v5 14 | with: 15 | cache-dependency-path: '**/go.sum' 16 | go-version: 1.22.0 17 | 18 | - name: Checkout code 19 | uses: actions/checkout@v4 20 | 21 | - name: Run tests 22 | run: make test 23 | 24 | linter: 25 | name: Linter 26 | runs-on: ubuntu-latest 27 | steps: 28 | - name: Setup Go 29 | uses: actions/setup-go@v5 30 | with: 31 | go-version: 1.22.0 32 | 33 | - name: Checkout code 34 | uses: actions/checkout@v4 35 | 36 | - name: Run golangci-lint 37 | uses: golangci/golangci-lint-action@v6 38 | with: 39 | version: v1.60 40 | skip-cache: true 41 | -------------------------------------------------------------------------------- /internal/action/disable.go: -------------------------------------------------------------------------------- 1 | package action 2 | 3 | import ( 4 | "github.com/WIttyJudge/adless/internal/action/exit" 5 | "github.com/WIttyJudge/adless/internal/hostsfile" 6 | 7 | "github.com/rs/zerolog/log" 8 | "github.com/urfave/cli/v2" 9 | ) 10 | 11 | func (a *Action) Disable(_ *cli.Context) error { 12 | hosts, err := hostsfile.New() 13 | if err != nil { 14 | return exit.Error(exit.HostsFile, err, "failed to process hosts file") 15 | } 16 | 17 | if hosts.Status() == hostsfile.Disabled { 18 | log.Info().Msg("domains blocking is already disabled") 19 | return nil 20 | } 21 | 22 | if err := hosts.Backup(); err != nil { 23 | return exit.Error(exit.HostsFile, err, "failed to backup hosts file") 24 | } 25 | 26 | if err := hosts.RemoveDomainsBlocking(); err != nil { 27 | return exit.Error(exit.HostsFile, err, "failed to disable domains blocking") 28 | } 29 | 30 | log.Info().Msg("domain blocking successfully disabled") 31 | 32 | return nil 33 | } 34 | -------------------------------------------------------------------------------- /internal/http/http.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "io" 5 | "net" 6 | "net/http" 7 | "time" 8 | ) 9 | 10 | const Timeout = 10 * time.Second 11 | 12 | type HTTP struct { 13 | client *http.Client 14 | } 15 | 16 | func New() *HTTP { 17 | transport := &http.Transport{ 18 | Proxy: http.ProxyFromEnvironment, 19 | Dial: (&net.Dialer{ 20 | Timeout: Timeout, 21 | }).Dial, 22 | IdleConnTimeout: Timeout, 23 | TLSHandshakeTimeout: Timeout, 24 | MaxConnsPerHost: 10, 25 | MaxIdleConns: 10, 26 | MaxIdleConnsPerHost: 10, 27 | } 28 | 29 | client := &http.Client{ 30 | Timeout: Timeout, 31 | Transport: transport, 32 | } 33 | 34 | return &HTTP{ 35 | client: client, 36 | } 37 | } 38 | 39 | func (h *HTTP) Get(url string) (string, error) { 40 | resp, err := h.client.Get(url) 41 | if err != nil { 42 | return "", err 43 | } 44 | 45 | defer resp.Body.Close() 46 | 47 | body, err := io.ReadAll(resp.Body) 48 | if err != nil { 49 | return "", err 50 | } 51 | 52 | return string(body), nil 53 | } 54 | -------------------------------------------------------------------------------- /internal/action/enable.go: -------------------------------------------------------------------------------- 1 | package action 2 | 3 | import ( 4 | "github.com/WIttyJudge/adless/internal/action/exit" 5 | "github.com/WIttyJudge/adless/internal/hostsfile" 6 | 7 | "github.com/rs/zerolog/log" 8 | "github.com/urfave/cli/v2" 9 | ) 10 | 11 | func (a *Action) Enable(_ *cli.Context) error { 12 | hosts, err := hostsfile.New() 13 | if err != nil { 14 | return exit.Error(exit.HostsFile, err, "failed to process hosts file") 15 | } 16 | 17 | if hosts.Status() == hostsfile.Enabled { 18 | log.Info().Msg("domains blocking is already enabled") 19 | return nil 20 | } 21 | 22 | if err := hosts.Backup(); err != nil { 23 | return exit.Error(exit.HostsFile, err, "failed to backup hosts file") 24 | } 25 | 26 | processor := hostsfile.NewProcessor(a.config) 27 | parsedBlocklists, err := processor.Process() 28 | if err != nil { 29 | return err 30 | } 31 | 32 | if err := hosts.Write(parsedBlocklists.FormatToHostsfile()); err != nil { 33 | return exit.Error(exit.HostsFile, err, "failed to write to hosts file") 34 | } 35 | 36 | log.Info().Msg("domain blocking successfully enabled") 37 | 38 | return nil 39 | } 40 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | NAME=adless 2 | 3 | SRC_PATH=./cmd/$(NAME) 4 | BUILD_PATH=./build/$(NAME) 5 | 6 | VERSION=$(shell git describe --abbrev=0 2>/dev/null || echo -n "unknown") 7 | GIT_COMMIT=$(shell git rev-parse --short HEAD 2>/dev/null || echo -n "unknown") 8 | BUILD_DATE=$(shell date +%FT%T%z) 9 | 10 | LDFLAGS=-w -s \ 11 | -X main.version=$(VERSION) \ 12 | -X main.gitCommit=$(GIT_COMMIT) \ 13 | -X main.buildDate=$(BUILD_DATE) 14 | 15 | .PHONY: run build test coverage clean 16 | 17 | run: 18 | go run $(SRC_PATH) 19 | 20 | build: build-linux 21 | 22 | build-linux: 23 | GOOS=linux go build -ldflags "$(LDFLAGS)" -o $(BUILD_PATH) $(SRC_PATH) 24 | 25 | build-windows: 26 | GOOS=windows go build -ldflags "$(LDFLAGS)" -o $(BUILD_PATH).exe $(SRC_PATH) 27 | 28 | build-darwin: 29 | GOOS=darwin go build -ldflags "$(LDFLAGS)" -o $(BUILD_PATH).osx $(SRC_PATH) 30 | 31 | test: 32 | go test -v ./... 33 | 34 | coverage: 35 | go test ./... -coverprofile=coverage.out -covermode=atomic 36 | go tool cover -html=coverage.out -o coverage.html 37 | 38 | clean: 39 | rm -rf ${BUILD_PATH} 40 | rm -rf ${BUILD_PATH}.exe 41 | rm -rf ${BUILD_PATH}.osx 42 | rm -rf dist/ 43 | rm -f coverage.out coverage.html 44 | -------------------------------------------------------------------------------- /internal/action/update.go: -------------------------------------------------------------------------------- 1 | package action 2 | 3 | import ( 4 | "github.com/WIttyJudge/adless/internal/action/exit" 5 | "github.com/WIttyJudge/adless/internal/hostsfile" 6 | 7 | "github.com/rs/zerolog/log" 8 | "github.com/urfave/cli/v2" 9 | ) 10 | 11 | func (a *Action) Update(_ *cli.Context) error { 12 | processor := hostsfile.NewProcessor(a.config) 13 | 14 | hosts, err := hostsfile.New() 15 | if err != nil { 16 | return exit.Error(exit.HostsFile, err, "failed to process hosts file") 17 | } 18 | 19 | if err := hosts.Backup(); err != nil { 20 | return exit.Error(exit.HostsFile, err, "failed to backup hosts file") 21 | } 22 | 23 | if hosts.Status() == hostsfile.Enabled { 24 | if err := hosts.RemoveDomainsBlocking(); err != nil { 25 | return exit.Error(exit.HostsFile, err, "failed to update domains blocking") 26 | } 27 | } 28 | 29 | parsedBlocklists, err := processor.Process() 30 | if err != nil { 31 | return err 32 | } 33 | 34 | if err := hosts.Write(parsedBlocklists.FormatToHostsfile()); err != nil { 35 | return exit.Error(exit.HostsFile, err, "failed to write to hosts file") 36 | } 37 | 38 | log.Info().Msg("domains blocking successfully updated and enabled") 39 | 40 | return nil 41 | } 42 | -------------------------------------------------------------------------------- /assets/demo.tape: -------------------------------------------------------------------------------- 1 | # This is a VHS (https://github.com/charmbracelet/vhs) tape file. 2 | 3 | # +-----------------------------------------------------------+ 4 | # | Settings | 5 | # +-----------------------------------------------------------+ 6 | 7 | Output assets/demo.gif 8 | 9 | Set Shell "bash" 10 | Set Theme "Catppuccin Mocha" 11 | Set FontSize 22 12 | Set Width 1300 13 | Set Height 650 14 | 15 | # +-----------------------------------------------------------+ 16 | # | Required programs on PATH | 17 | # +-----------------------------------------------------------+ 18 | 19 | Require adless 20 | 21 | # +-----------------------------------------------------------+ 22 | # | START | 23 | # +-----------------------------------------------------------+ 24 | 25 | Type "We currently don't block any domains.." 26 | Sleep 2s 27 | Backspace 38 28 | 29 | Type "cat /etc/hosts" 30 | Sleep 1s 31 | Enter 32 | Sleep 2s 33 | 34 | Hide 35 | Backspace 10 36 | Type "clear" 37 | Enter 38 | Show 39 | 40 | Type "Alright, it's time to enable domain blocking.." 41 | Sleep 2s 42 | Backspace 46 43 | 44 | Type "sudo adless enable" 45 | Sleep 1s 46 | Enter 47 | Sleep 4s 48 | 49 | Type "cat /etc/hosts" 50 | Sleep 1s 51 | Enter 52 | Sleep 3s 53 | -------------------------------------------------------------------------------- /internal/config/validator_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestValidate(t *testing.T) { 11 | t.Run("config is valid", func(t *testing.T) { 12 | config := &Config{ 13 | Blocklists: []Domainlist{ 14 | {Target: "https://raw.githubusercontent.com/FadeMind/hosts.extras/master/add.Spam/hosts"}, 15 | }, 16 | } 17 | 18 | validate := Validate(config) 19 | require.NoError(t, validate) 20 | }) 21 | 22 | t.Run("config has no blocklists", func(t *testing.T) { 23 | config := &Config{} 24 | assert.ErrorIs(t, Validate(config), ErrNoBlocklistsProvided) 25 | }) 26 | 27 | t.Run("config has invalid target", func(t *testing.T) { 28 | config := &Config{ 29 | Blocklists: []Domainlist{ 30 | {Target: "https://example.com/test page?query=value&extra#section+details"}, 31 | }, 32 | } 33 | 34 | assert.ErrorContains(t, Validate(config), "invalid blocklist target provided") 35 | }) 36 | } 37 | 38 | func TestHasInvalidURLSymbols(t *testing.T) { 39 | t.Run("invalid URL symbols aren't used", func(t *testing.T) { 40 | testURL := "https://raw.githubusercontent.com/FadeMind/hosts.extras/master/add.Spam/hosts" 41 | assert.False(t, hasInvalidURLSymbols(testURL)) 42 | }) 43 | 44 | t.Run("invalid URL symbols are used", func(t *testing.T) { 45 | testURL := "https://example.com/test page?query=value&extra#section+details" 46 | assert.True(t, hasInvalidURLSymbols(testURL)) 47 | }) 48 | } 49 | -------------------------------------------------------------------------------- /pkg/fsutil/fsutil_test.go: -------------------------------------------------------------------------------- 1 | package fsutil 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestCopyFile(t *testing.T) { 12 | td, err := os.MkdirTemp("", "adless-fsutil") 13 | require.NoError(t, err) 14 | defer os.RemoveAll(td) 15 | 16 | t.Run("return error since there is no source file", func(t *testing.T) { 17 | src := td + "_nofile" 18 | dst := src + "_dst" 19 | err = CopyFile(src, dst) 20 | 21 | assert.Error(t, err) 22 | assert.ErrorContains(t, err, "file not found") 23 | }) 24 | 25 | t.Run("returns error if unable to create destination file", func(t *testing.T) { 26 | srcFile, err := os.CreateTemp(td, "src_") 27 | require.NoError(t, err) 28 | _, err = srcFile.WriteString("some content") 29 | require.NoError(t, err) 30 | 31 | dst := "/invalid/destination.txt" 32 | err = CopyFile(srcFile.Name(), dst) 33 | assert.Error(t, err) 34 | }) 35 | 36 | t.Run("returns no errors and succefully makes copy", func(t *testing.T) { 37 | srcFile, err := os.CreateTemp(td, "src_") 38 | require.NoError(t, err) 39 | 40 | _, err = srcFile.WriteString("some content") 41 | require.NoError(t, err) 42 | 43 | dst := srcFile.Name() + "_dst" 44 | 45 | err = CopyFile(srcFile.Name(), dst) 46 | require.FileExists(t, dst) 47 | require.NoError(t, err) 48 | 49 | dstContent, err := os.ReadFile(dst) 50 | require.NoError(t, err) 51 | srcContent, err := os.ReadFile(srcFile.Name()) 52 | require.NoError(t, err) 53 | 54 | assert.EqualValues(t, dstContent, srcContent) 55 | }) 56 | } 57 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | before: 4 | hooks: 5 | - go mod tidy 6 | 7 | builds: 8 | - main: ./cmd/adless 9 | env: 10 | - CGO_ENABLED=0 11 | ldflags: 12 | - -s -w 13 | - -X main.version={{.Tag}} 14 | - -X main.gitCommit={{.ShortCommit}} 15 | - -X main.buildDate={{.Date}} 16 | goos: 17 | - linux 18 | - windows 19 | - darwin 20 | goarch: 21 | - amd64 22 | - arm64 23 | 24 | archives: 25 | - format: tar.gz 26 | name_template: >- 27 | {{ .ProjectName }}_ 28 | {{- .Tag }}_ 29 | {{- .Os }}_ 30 | {{- if eq .Arch "amd64" }}x86_64 31 | {{- else if eq .Arch "386" }}i386 32 | {{- else }}{{ .Arch }}{{ end }} 33 | format_overrides: 34 | - goos: windows 35 | format: zip 36 | 37 | # brews: 38 | # - repository: 39 | # owner: WittyJudge 40 | # name: homebrew-adless 41 | # directory: Formula 42 | # homepage: https://github.com/WIttyJudge/homebrew-adless 43 | # description: Local domains blocker written in Go 44 | # license: Apache License 2.0 45 | 46 | # nfpms: 47 | # - id: adless-packages 48 | # file_name_template: >- 49 | # {{ .ProjectName }}_ 50 | # {{- .Tag }}_ 51 | # {{- if eq .Arch "amd64" }}x86_64 52 | # {{- else if eq .Arch "386" }}i386 53 | # {{- else }}{{ .Arch }}{{ end }} 54 | # homepage: https://github.com/WIttyJudge/adless 55 | # description: Local domains blocker written in Go 56 | # maintainer: WittyJudge 57 | # license: Apache License 2.0 58 | # vendor: adless 59 | # formats: 60 | # - deb 61 | # - rpm 62 | 63 | changelog: 64 | filters: 65 | exclude: 66 | - '^chore' 67 | - '^docs' 68 | - '^refactor' 69 | - '^style' 70 | - '^test' 71 | -------------------------------------------------------------------------------- /cmd/adless/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "sort" 7 | "time" 8 | 9 | "github.com/WIttyJudge/adless/internal/action" 10 | 11 | "github.com/rs/zerolog" 12 | "github.com/rs/zerolog/log" 13 | "github.com/urfave/cli/v2" 14 | ) 15 | 16 | var ( 17 | version string 18 | gitCommit string 19 | buildDate string 20 | ) 21 | 22 | func main() { 23 | setupLogger() 24 | app := setupApp() 25 | 26 | if err := app.Run(os.Args); err != nil { 27 | log.Fatal().Err(err) 28 | } 29 | } 30 | 31 | func setupLogger() { 32 | log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.TimeOnly}) 33 | zerolog.SetGlobalLevel(zerolog.InfoLevel) 34 | } 35 | 36 | func setupApp() *cli.App { 37 | action := action.New() 38 | 39 | app := cli.NewApp() 40 | app.Name = "adless" 41 | app.Usage = "Local domains blocker writter in Go" 42 | app.UseShortOptionHandling = true 43 | app.Version = version 44 | 45 | cli.VersionFlag = &cli.BoolFlag{ 46 | Name: "version", 47 | Aliases: []string{"V"}, 48 | Usage: "Print the version", 49 | DisableDefaultText: true, 50 | } 51 | 52 | cli.HelpFlag = &cli.BoolFlag{ 53 | Name: "help", 54 | Aliases: []string{"h"}, 55 | Usage: "Show help", 56 | DisableDefaultText: true, 57 | } 58 | 59 | cli.VersionPrinter = func(ctx *cli.Context) { 60 | fmt.Println("Version:\t", ctx.App.Version) 61 | fmt.Println("Git Commit:\t", gitCommit) 62 | fmt.Println("Build Date:\t", buildDate) 63 | } 64 | 65 | app.CommandNotFound = func(_ *cli.Context, command string) { 66 | fmt.Printf("error: unrecognized command: '%s'\n\n", command) 67 | fmt.Println("for more information, try '--help'.") 68 | } 69 | 70 | app.Before = action.BeforeAction 71 | app.Commands = action.GetCommands() 72 | app.Flags = action.GetFlags() 73 | 74 | sort.Sort(cli.CommandsByName(app.Commands)) 75 | sort.Sort(cli.FlagsByName(app.Flags)) 76 | 77 | return app 78 | } 79 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | linters: 2 | enable: 3 | # Drop-in replacement of `golint`. 4 | - revive 5 | 6 | # Forces to put `.` at the end of the comment. Code is poetry. 7 | - godot 8 | 9 | # Fix all the misspells, amazing thing. 10 | - misspell 11 | 12 | # Might not be that important but I prefer to keep all of them. 13 | # `gofumpt` is amazing, kudos to Daniel Marti https://github.com/mvdan/gofumpt 14 | - gofmt 15 | - gofumpt 16 | - goimports 17 | 18 | # Forces comment why another check is disabled. 19 | - nolintlint 20 | 21 | # Remove unnecessary type conversions. 22 | - unconvert 23 | 24 | # Reports unused function parameters. 25 | - unparam 26 | 27 | # Detect the possibility to use variables/constants from stdlib. 28 | - usestdlibvars 29 | 30 | # Checks whether HTTP response body is closed successfully. 31 | - bodyclose 32 | 33 | # Checks `Err-` prefix for var and `-Error` suffix for error type. 34 | - errname 35 | 36 | # Suggests to use `%w` for error-wrapping. 37 | - errorlint 38 | 39 | # Forces to not skip error check. 40 | - errcheck 41 | 42 | # Check struct tags. 43 | - tagliatelle 44 | 45 | # Finds slices that could potentially be pre-allocated. 46 | # Small performance win + cleaner code. 47 | - prealloc 48 | 49 | # Linter that specializes in simplifying code. 50 | - gosimple 51 | 52 | # Official Go tool. Must have. 53 | - govet 54 | 55 | # Finds naked/bare returns and requires change them. 56 | - nakedret 57 | 58 | # Finds the code that returns nil even if it checks that 59 | # the error is not nil. 60 | - nilerr 61 | 62 | # Checks that there is no simultaneous return of nil error 63 | # and an invalid value. 64 | - nilnil 65 | 66 | # Finds shadowing of Go's predeclared identifiers. 67 | - predeclared 68 | 69 | # Finds wasted assignment statements. 70 | - wastedassign 71 | 72 | # Detects when assignments to existing variables are not used 73 | # Last week I caught a bug with it. 74 | - ineffassign 75 | 76 | # Test-related checks. All of them are good. 77 | # - tenv 78 | # - testableexamples 79 | # - thelper 80 | # - tparallel 81 | -------------------------------------------------------------------------------- /scripts/install.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | COL_GREEN='' 6 | COL_RED='' 7 | COL_NC='' 8 | 9 | INFO="[i]" 10 | TICK="[${COL_GREEN}✓${COL_NC}]" 11 | CROSS="[${COL_RED}✗${COL_NC}]" 12 | 13 | TMP_DIR="$(mktemp -d)" 14 | TMP_RELEASE_TAR="$TMP_DIR/release.tar.gz" 15 | OUTPUT_DIR="/usr/local/bin" 16 | PROGRAM_NAME="adless" 17 | 18 | check_root() { 19 | if [ "$EUID" -ne 0 ]; then 20 | echo "${INFO} ${COL_RED}Script called with non-root privileges${COL_NC}" 21 | echo "For safety, please check the installer for any concerns regarding this requirement" 22 | echo "Make sure to download this script from a trusted source" 23 | 24 | exit 1 25 | fi 26 | } 27 | 28 | check_system() { 29 | local uname_os="$(uname -s)" 30 | local uname_arch="$(uname -m)" 31 | 32 | case $uname_os in 33 | Linux*) OS="linux" ;; 34 | Darwin*) OS="darwin" ;; 35 | *) fail "${uname_os} operation system is unsupported" ;; 36 | esac 37 | 38 | case $uname_arch in 39 | x86_64 | amd64) ARCH="x86_64" ;; 40 | arm64 | arm) ARCH="arm64" ;; 41 | *) fail "${uname_arch} arch is unsupported" ;; 42 | esac 43 | 44 | echo "${INFO} Detected OS: ${OS}_${ARCH}" 45 | } 46 | 47 | check_dependencies() { 48 | which curl >/dev/null || fail "curl not installed" 49 | which grep >/dev/null || fail "grep not installed" 50 | which sed >/dev/null || fail "sed not installed" 51 | } 52 | 53 | find_latest_version() { 54 | LATEST_VERSION=$(curl -s "https://api.github.com/repos/wittyjudge/adless/releases/latest" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/') 55 | echo "${INFO} Latest version: $LATEST_VERSION" 56 | } 57 | 58 | find_latest_release_tar_url() { 59 | URL="https://github.com//WIttyJudge/adless/releases/download/${LATEST_VERSION}/adless_${LATEST_VERSION}_${OS}_${ARCH}.tar.gz" 60 | echo "${INFO} Latest release tar: ${URL}" 61 | } 62 | 63 | install_release_tar() { 64 | echo "${INFO} Downloading release tar ${URL}.." 65 | curl --fail --progress-bar -L -o "$TMP_RELEASE_TAR" "$URL" 66 | echo "${TICK} Release tar downloaded" 67 | 68 | tar -xzf "$TMP_RELEASE_TAR" -C "$TMP_DIR" 69 | 70 | mv "$TMP_DIR/${PROGRAM_NAME}" "${OUTPUT_DIR}" 71 | chmod +x "${OUTPUT_DIR}/${PROGRAM_NAME}" 72 | 73 | cleanup 74 | } 75 | 76 | finish() { 77 | echo "" 78 | echo "Adless ${LATEST_VERSION} successfully installed at ${OUTPUT_DIR}/${PROGRAM_NAME}" 79 | echo "run: adless --help" 80 | } 81 | 82 | cleanup() { 83 | rm -rf $TMP_DIR >/dev/null 84 | } 85 | 86 | fail() { 87 | cleanup 88 | msg=$1 89 | echo "${CROSS} $msg" 1>&2 90 | exit 1 91 | } 92 | 93 | # Execution 94 | 95 | cat <<'EOF' 96 | _ _ 97 | __ _ __| | | ___ ___ ___ 98 | / _` |/ _` | |/ _ \/ __/ __| 99 | | (_| | (_| | | __/\__ \__ \ 100 | \__,_|\__,_|_|\___||___/___/ 101 | 102 | EOF 103 | 104 | check_root 105 | check_system 106 | check_dependencies 107 | 108 | find_latest_version 109 | find_latest_release_tar_url 110 | 111 | install_release_tar 112 | 113 | finish 114 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/charmbracelet/x/editor v0.1.0 h1:p69/dpvlwRTs9uYiPeAWruwsHqTFzHhTvQOd/WVSX98= 2 | github.com/charmbracelet/x/editor v0.1.0/go.mod h1:oivrEbcP/AYt/Hpvk5pwDXXrQ933gQS6UzL6fxqAGSA= 3 | github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= 4 | github.com/cpuguy83/go-md2man/v2 v2.0.4 h1:wfIWP927BUkWJb2NmU/kNDYIBTh/ziUX91+lVfRxZq4= 5 | github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 6 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 7 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 8 | github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 9 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 10 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 11 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 12 | github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 13 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 14 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 15 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 16 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 17 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 18 | github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= 19 | github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8= 20 | github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= 21 | github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= 22 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 23 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 24 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 25 | github.com/urfave/cli/v2 v2.27.4 h1:o1owoI+02Eb+K107p27wEX9Bb8eqIoZCfLXloLUSWJ8= 26 | github.com/urfave/cli/v2 v2.27.4/go.mod h1:m4QzxcD2qpra4z7WhzEGn74WZLViBnMpb1ToCAKdGRQ= 27 | github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= 28 | github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= 29 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 30 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 31 | golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 32 | golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= 33 | golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 34 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 35 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 36 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 37 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 38 | -------------------------------------------------------------------------------- /internal/action/action.go: -------------------------------------------------------------------------------- 1 | package action 2 | 3 | import ( 4 | "github.com/WIttyJudge/adless/internal/action/exit" 5 | "github.com/WIttyJudge/adless/internal/config" 6 | 7 | "github.com/rs/zerolog" 8 | "github.com/urfave/cli/v2" 9 | ) 10 | 11 | type Action struct { 12 | config *config.Config 13 | } 14 | 15 | func New() *Action { 16 | return &Action{} 17 | } 18 | 19 | func (a *Action) BeforeAction(ctx *cli.Context) error { 20 | if ctx.NArg() == 0 { 21 | return nil 22 | } 23 | 24 | if ctx.Bool("verbose") { 25 | zerolog.SetGlobalLevel(zerolog.DebugLevel) 26 | } 27 | 28 | if ctx.Bool("quiet") { 29 | zerolog.SetGlobalLevel(zerolog.Disabled) 30 | } 31 | 32 | if err := a.loadConfig(ctx); err != nil { 33 | return exit.Error(exit.Config, err, "failed to load config file") 34 | } 35 | 36 | return nil 37 | } 38 | 39 | func (a *Action) GetCommands() []*cli.Command { 40 | return []*cli.Command{ 41 | { 42 | Name: "config", 43 | Usage: "Manage the configuration file", 44 | Description: "" + 45 | "The command allows you to view the current configuration, edit it using " + 46 | "your preferred editor, or initialize a default configuration file.\n" + 47 | "By default, running this command without arguments will print the entire configuration.", 48 | Action: a.Config, 49 | Subcommands: []*cli.Command{ 50 | { 51 | Name: "edit", 52 | Action: a.ConfigEdit, 53 | Usage: "Edit the configuration file", 54 | Description: "" + 55 | "Opens the configuration file in the editor specified by the $EDITOR environment variable.\n" + 56 | "If the configuration file doesn't exist, it will be created with default settings.", 57 | }, 58 | { 59 | Name: "init", 60 | Usage: "Create a default config file locally", 61 | Action: a.ConfigInit, 62 | }, 63 | }, 64 | }, 65 | { 66 | Name: "disable", 67 | Usage: "Disable domains blocking", 68 | Action: a.Disable, 69 | }, 70 | { 71 | Name: "enable", 72 | Usage: "Enable domains blocking", 73 | Action: a.Enable, 74 | }, 75 | 76 | { 77 | Name: "status", 78 | Usage: "Check if domains blocking enabled or not", 79 | Action: a.Status, 80 | }, 81 | { 82 | Name: "update", 83 | Usage: "Update the list of domains to be blocked", 84 | Action: a.Update, 85 | }, 86 | { 87 | Name: "restore", 88 | Usage: "Restore hosts file from backup to its previous state", 89 | Description: "" + 90 | "When a `enable`, `disable` or `update` command is invoked, it creates a backup of the " + 91 | "original hosts file by copying it to backup file (hosts.backup).\n" + 92 | "The `restore` command copies the backup file (hosts.backup) back to its " + 93 | "original location (hosts).\n" + 94 | "Backup file must already exist to perform a command successfully.", 95 | Action: a.Restore, 96 | }, 97 | } 98 | } 99 | 100 | func (a *Action) GetFlags() []cli.Flag { 101 | return []cli.Flag{ 102 | &cli.StringFlag{ 103 | Name: "config-file", 104 | Usage: "Path to the configuration file", 105 | }, 106 | &cli.BoolFlag{ 107 | Name: "verbose", 108 | Aliases: []string{"v"}, 109 | Usage: "Enable debug mode", 110 | DisableDefaultText: true, 111 | }, 112 | &cli.BoolFlag{ 113 | Name: "quiet", 114 | Aliases: []string{"q"}, 115 | Usage: "Enable quiet mode", 116 | DisableDefaultText: true, 117 | }, 118 | } 119 | } 120 | 121 | func (a *Action) loadConfig(ctx *cli.Context) error { 122 | var ( 123 | cfg *config.Config 124 | err error 125 | ) 126 | 127 | providedConfigPath := ctx.String("config-file") 128 | 129 | if providedConfigPath != "" { 130 | cfg, err = config.LoadByUser(providedConfigPath) 131 | } else { 132 | cfg, err = config.Load() 133 | } 134 | 135 | if err != nil { 136 | return err 137 | } 138 | 139 | a.config = cfg 140 | 141 | return nil 142 | } 143 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # adless 2 | 3 | [![Go Report Card](https://goreportcard.com/badge/github.com/WIttyJudge/adless)](https://goreportcard.com/report/github.com/WIttyJudge/adless) 4 | 5 | Adless is an easy-to-use CLI tool that blocks domains by using your system's hosts file. 6 | 7 | ![demo](./assets/demo.gif) 8 | 9 | ## Features 10 | 11 | - Works without running any background processes. 12 | - You don't need a browser extensions to block ads. 13 | - Supports whitelist domains. 14 | - Lets you specify multiple blocklists and whitelists. 15 | 16 | ## Idea 17 | 18 | The idea for developing Adless was inspired by two projects: [Maza](https://github.com/tanrax/maza-ad-blocking) and [Pi-hole](https://github.com/pi-hole/pi-hole). 19 | For a long time, I used both of them, but eventually, 20 | I wanted to create a tool that combined the best of both worlds. 21 | 22 | I wished to have a tool that, like Pi-hole, would allow users to manage 23 | multiple blocklists and whitelists of domains. At the same time, would work 24 | without running any background processes and rely on use of hosts file, much like Maza. 25 | 26 | And that's how Adless was made. 27 | 28 | ## Installation 29 | 30 | ### Package manager 31 | 32 | On Arch Linux (AUR) 33 | 34 | ```bash 35 | yay -S adless-bin 36 | ``` 37 | 38 | ### Manual Installation 39 | 40 | Download the latest tar from the [releases page](https://github.com/WIttyJudge/adless/releases) and decompress. 41 | 42 | If you use Linux or MacOS, you can simple run: 43 | 44 | ```bash 45 | curl -sL https://raw.githubusercontent.com/WIttyJudge/adless/refs/heads/main/scripts/install.sh | bash 46 | ``` 47 | 48 | ### Building from source 49 | 50 | The [Makefile](https://github.com/WIttyJudge/adless/blob/main/Makefile) has everything you need. 51 | 52 | There are different commands to build a binary for different platforms. 53 | Choose one that you need. 54 | 55 | ```bash 56 | make build-linux 57 | make build-windows 58 | make build-darwnin 59 | ``` 60 | 61 | To run the binary: 62 | 63 | ```bash 64 | ./build/adless 65 | ``` 66 | 67 | ## Usage 68 | 69 | ``` 70 | NAME: 71 | adless - Local domains blocker writter in Go 72 | 73 | USAGE: 74 | adless [global options] command [command options] 75 | 76 | VERSION: 77 | v1.0.0 78 | 79 | COMMANDS: 80 | config Manage the configuration file 81 | disable Disable domains blocking 82 | enable Enable domains blocking 83 | restore Restore hosts file from backup to its previous state 84 | status Check if domains blocking enabled or not 85 | update Update the list of domains to be blocked 86 | help, h Shows a list of commands or help for one command 87 | 88 | GLOBAL OPTIONS: 89 | --config-file value Path to the configuration file 90 | --quiet, -q Enable quiet mode 91 | --verbose, -v Enable debug mode 92 | --help, -h Show help 93 | --version, -V Print the version 94 | ``` 95 | 96 | ## Configuration file 97 | 98 | Adless supports reading and writing configuration files. 99 | The default configuration file is located at `$HOME/.config/adless/config.yml`, 100 | but it can be redefined using `--config` flag or the following environment variables: 101 | 102 | - ADLESS_CONFIG_PATH - Specifies the full path to the configuration file. 103 | - ADLESS_CONFIG_HOME - Specifies the folder where the `config.yml` file is located. 104 | - XDG_CONFIG_HOME - Specifies the base directory for user-specific configuration files. Adless will look for `adless/config.yml` within this directory. 105 | 106 | ### Default configuration 107 | 108 | ```yaml 109 | blocklists: 110 | - target: https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts 111 | whitelists: 112 | - target: https://raw.githubusercontent.com/anudeepND/whitelist/master/domains/whitelist.txt 113 | ``` 114 | 115 | ### Create 116 | 117 | To create a local configuration file, run: 118 | 119 | ```bash 120 | adless config init 121 | ``` 122 | 123 | ### Edit 124 | 125 | To open the configuration file in your preferred editor, run: 126 | 127 | ```bash 128 | adless config edit 129 | ``` 130 | 131 | ## TODO 132 | 133 | 1. Options to add path to local blocklists and whitelists 134 | -------------------------------------------------------------------------------- /internal/hostsfile/file.go: -------------------------------------------------------------------------------- 1 | package hostsfile 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | "runtime" 9 | "strings" 10 | 11 | "github.com/WIttyJudge/adless/pkg/fsutil" 12 | "github.com/rs/zerolog/log" 13 | ) 14 | 15 | const ( 16 | StartTag = "###### START adless\n" 17 | EndTag = "###### END adless" 18 | DescriptionComment = "# Generated by the adless CLI tool. DO NOT EDIT!\n" 19 | ) 20 | 21 | var ( 22 | ErrStartTagNotFound = errors.New("start tag not found") 23 | ErrEndTagNotFound = errors.New("end tag not found") 24 | ) 25 | 26 | type Status int 27 | 28 | const ( 29 | Enabled Status = iota 30 | Disabled 31 | ) 32 | 33 | // File is a hosts file. 34 | type File struct { 35 | file *os.File 36 | 37 | fileLocation string 38 | backupLocation string 39 | } 40 | 41 | // New returns a new hostsfile wrapper. 42 | func New() (*File, error) { 43 | location := location() 44 | backupLocation := location + ".backup" 45 | 46 | osFile, err := os.OpenFile(location, os.O_WRONLY|os.O_APPEND, 0o644) 47 | if err != nil { 48 | return nil, fmt.Errorf("failed to open file: %w", err) 49 | } 50 | 51 | file := &File{ 52 | file: osFile, 53 | fileLocation: location, 54 | backupLocation: backupLocation, 55 | } 56 | 57 | return file, nil 58 | } 59 | 60 | // Backup creates a copy of hosts file with .backup suffix. 61 | func (f *File) Backup() error { 62 | log.Debug().Str("location", f.backupLocation).Msg("backup hosts file..") 63 | return fsutil.CopyFile(f.fileLocation, f.backupLocation) 64 | } 65 | 66 | // Restore restores the original hosts file from its backup. 67 | func (f *File) Restore() error { 68 | return fsutil.CopyFile(f.backupLocation, f.fileLocation) 69 | } 70 | 71 | // Write writes content to file 72 | // It appends content to the end of file intead of rewriting it. 73 | func (f *File) Write(content string) error { 74 | if _, err := f.file.WriteString(content); err != nil { 75 | return err 76 | } 77 | 78 | return nil 79 | } 80 | 81 | // Rewrite rewrites the entire file to the content provided. 82 | // os.Create method truncated file completely, then WriteString writes new 83 | // content. 84 | func (f *File) Rewrite(content string) error { 85 | // If the file already exists, if will be truncated. 86 | newFile, err := os.Create(f.fileLocation) 87 | if err != nil { 88 | return err 89 | } 90 | 91 | if _, err := newFile.WriteString(content); err != nil { 92 | return err 93 | } 94 | 95 | return nil 96 | } 97 | 98 | // Read returns content of the file by its location. 99 | func (f *File) Read() string { 100 | content, _ := os.ReadFile(f.fileLocation) 101 | return string(content) 102 | } 103 | 104 | // Status checks if hosts file already has domains that are being blocked. 105 | // If StartTag exists, it means domains blocking enabled. 106 | func (f *File) Status() Status { 107 | content := f.Read() 108 | 109 | if strings.Contains(content, StartTag) { 110 | return Enabled 111 | } 112 | 113 | return Disabled 114 | } 115 | 116 | // RemoveDomainsBlocking removes domains located between StartTag and EndTag 117 | // that were parsed from blocklists. 118 | func (f *File) RemoveDomainsBlocking() error { 119 | content := f.Read() 120 | 121 | startIndex := strings.Index(content, StartTag) 122 | if startIndex == -1 { 123 | return ErrStartTagNotFound 124 | } 125 | 126 | endIndex := strings.Index(content, EndTag) 127 | if endIndex == -1 { 128 | return ErrEndTagNotFound 129 | } 130 | endIndex += len(EndTag) 131 | 132 | resultContent := content[:startIndex] + content[endIndex:] 133 | 134 | if err := f.Rewrite(resultContent); err != nil { 135 | return err 136 | } 137 | 138 | return nil 139 | } 140 | 141 | // location returns the path to the hosts file based on the operating system. 142 | func location() string { 143 | const unixHostsFile = "/etc/hosts" 144 | const windowsHostsFile = `C:\Windows\System32\drivers\etc\hosts` 145 | 146 | if runtime.GOOS == "windows" { 147 | if systemRoot := os.Getenv("SystemRoot"); systemRoot != "" { 148 | return filepath.Join(systemRoot, "System32", "drivers", "etc", "hosts") 149 | } 150 | 151 | return windowsHostsFile 152 | } 153 | 154 | return unixHostsFile 155 | } 156 | -------------------------------------------------------------------------------- /internal/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | "os/user" 8 | "path/filepath" 9 | 10 | "github.com/charmbracelet/x/editor" 11 | "github.com/rs/zerolog/log" 12 | 13 | "gopkg.in/yaml.v3" 14 | ) 15 | 16 | // Config represents the entire configuration structure. 17 | type Config struct { 18 | // Blocklist is a structure that represents where all these domains that needs 19 | // to be blocked are located. 20 | Blocklists []Domainlist `yaml:"blocklists"` 21 | 22 | // Whitelists contains domains that must not be blocked. 23 | // Adding some domains to whitelist may fix many problems like YouTube 24 | // watch history, videos on news sites and so on. 25 | Whitelists []Domainlist `yaml:"whitelists"` 26 | } 27 | 28 | type Domainlist struct { 29 | Target string `yaml:"target"` 30 | } 31 | 32 | // Load loads config file. 33 | // If config file is located at filesystem, it merges its options with 34 | // default one and returns the result. 35 | // If config file simply doesn't present at filesystem, it returns default one. 36 | func Load() (*Config, error) { 37 | location := location() 38 | 39 | config, err := read(location) 40 | if errors.Is(err, os.ErrNotExist) { 41 | log.Debug().Msg("local config file doesn't exist. Default config loaded") 42 | return defaultConfig(), nil 43 | } 44 | if err != nil { 45 | return nil, err 46 | } 47 | 48 | if err := Validate(config); err != nil { 49 | return nil, err 50 | } 51 | 52 | log.Debug().Str("location", location).Msg("config file loaded") 53 | 54 | return config, nil 55 | } 56 | 57 | // LoadByUser loads config file at the location that was provided 58 | // by the user via config-file CLI flag. 59 | func LoadByUser(location string) (*Config, error) { 60 | config, err := read(location) 61 | if err != nil { 62 | return nil, err 63 | } 64 | 65 | if err := Validate(config); err != nil { 66 | return nil, err 67 | } 68 | 69 | log.Debug().Str("location", location).Msg("config file loaded") 70 | 71 | return config, nil 72 | } 73 | 74 | // Print prints config to stdout. 75 | func (c *Config) Print() { 76 | out, _ := yaml.Marshal(c) 77 | fmt.Print(string(out)) 78 | } 79 | 80 | // Init saves default configuration file locally in case if 81 | // it doesn't exist yet. 82 | func Init() error { 83 | location := location() 84 | dirs := filepath.Dir(location) 85 | 86 | if _, err := os.Stat(location); !os.IsNotExist(err) { 87 | log.Debug().Str("location", location).Msg("config file has already been initialized") 88 | return nil 89 | } 90 | 91 | config, err := yaml.Marshal(defaultConfig()) 92 | if err != nil { 93 | return err 94 | } 95 | 96 | if err := os.MkdirAll(dirs, 0o700); err != nil { 97 | return err 98 | } 99 | 100 | if err := os.Chmod(dirs, 0o777); err != nil { 101 | return err 102 | } 103 | 104 | file, err := os.Create(location) 105 | if err != nil { 106 | return err 107 | } 108 | defer file.Close() 109 | 110 | if _, err := file.Write(config); err != nil { 111 | return err 112 | } 113 | 114 | if err := os.Chmod(location, 0o777); err != nil { 115 | return err 116 | } 117 | 118 | log.Info().Str("location", location).Msg("config file has been initialized successfully") 119 | 120 | return nil 121 | } 122 | 123 | // Edit opens config in text editor. 124 | func Edit() error { 125 | location := location() 126 | 127 | c, err := editor.Cmd("adless", location) 128 | if err != nil { 129 | return err 130 | } 131 | 132 | c.Stdin = os.Stdin 133 | c.Stdout = os.Stdout 134 | c.Stderr = os.Stderr 135 | 136 | if err := c.Run(); err != nil { 137 | return err 138 | } 139 | 140 | return nil 141 | } 142 | 143 | // location returns the location of the config file. 144 | func location() string { 145 | if bcp := os.Getenv("ADLESS_CONFIG_PATH"); bcp != "" { 146 | return bcp 147 | } 148 | 149 | if bch := os.Getenv("ADLESS_CONFIG_HOME"); bch != "" { 150 | return filepath.Join(bch, "config.yml") 151 | } 152 | 153 | if xdgConfig := os.Getenv("XDG_CONFIG_HOME"); xdgConfig != "" { 154 | return filepath.Join(xdgConfig, "adless", "config.yml") 155 | } 156 | 157 | return filepath.Join(homeDir(), ".config", "adless", "config.yml") 158 | } 159 | 160 | // read reads config file by location in file system. 161 | func read(location string) (*Config, error) { 162 | data, err := os.ReadFile(location) 163 | if err != nil { 164 | return nil, err 165 | } 166 | 167 | config := defaultConfig() 168 | if err := yaml.Unmarshal(data, config); err != nil { 169 | return nil, err 170 | } 171 | 172 | return config, nil 173 | } 174 | 175 | func homeDir() string { 176 | username := os.Getenv("SUDO_USER") 177 | if username == "" { 178 | return os.Getenv("HOME") 179 | } 180 | 181 | sudoUser, _ := user.Lookup(username) 182 | return sudoUser.HomeDir 183 | } 184 | 185 | // defaultConfig returns precreated configuration. 186 | // In case if there is no config file stored on user's filesystem, 187 | // default one will be used. 188 | func defaultConfig() *Config { 189 | return &Config{ 190 | Blocklists: []Domainlist{ 191 | {Target: "https://raw.githubusercontent.com/StevenBlack/hosts/master/data/StevenBlack/hosts"}, 192 | }, 193 | Whitelists: []Domainlist{ 194 | {Target: "https://raw.githubusercontent.com/anudeepND/whitelist/master/domains/whitelist.txt"}, 195 | }, 196 | } 197 | } 198 | -------------------------------------------------------------------------------- /internal/config/config_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "os" 5 | "path" 6 | "path/filepath" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | func TestLoad(t *testing.T) { 14 | td, err := os.MkdirTemp("", "adless-config") 15 | defer os.RemoveAll(td) 16 | 17 | require.NoError(t, err) 18 | 19 | t.Run("return default config if there is no local", func(t *testing.T) { 20 | os.Setenv("ADLESS_CONFIG_HOME", "test") 21 | defer os.Unsetenv("ADLESS_CONFIG_HOME") 22 | 23 | config, err := Load() 24 | 25 | assert.NotNil(t, config) 26 | assert.NoError(t, err) 27 | assert.Exactly(t, config, defaultConfig()) 28 | }) 29 | 30 | t.Run("returns errors if yml is invalid", func(t *testing.T) { 31 | testConfig, err := os.CreateTemp(td, "config.yml") 32 | require.NoError(t, err) 33 | defer os.Remove(testConfig.Name()) 34 | 35 | _, err = testConfig.WriteString("invalid yml content") 36 | require.NoError(t, err) 37 | 38 | os.Setenv("ADLESS_CONFIG_PATH", testConfig.Name()) 39 | defer os.Unsetenv("ADLESS_CONFIG_PATH") 40 | 41 | config, err := Load() 42 | 43 | assert.Nil(t, config) 44 | assert.ErrorContains(t, err, "yaml: unmarshal errors") 45 | }) 46 | 47 | t.Run("return error if there are zero blocklists", func(t *testing.T) { 48 | testConfig, err := os.CreateTemp(td, "config.yml") 49 | require.NoError(t, err) 50 | defer os.Remove(testConfig.Name()) 51 | 52 | _, err = testConfig.WriteString("blocklists:\n") 53 | require.NoError(t, err) 54 | 55 | os.Setenv("ADLESS_CONFIG_PATH", testConfig.Name()) 56 | defer os.Unsetenv("ADLESS_CONFIG_PATH") 57 | 58 | config, err := Load() 59 | 60 | assert.Nil(t, config) 61 | assert.ErrorIs(t, err, ErrNoBlocklistsProvided) 62 | }) 63 | 64 | t.Run("returns config successfully", func(t *testing.T) { 65 | testConfig, err := os.CreateTemp(td, "config.yml") 66 | require.NoError(t, err) 67 | defer os.Remove(testConfig.Name()) 68 | 69 | _, err = testConfig.WriteString("blocklists:\n- target: https://test.com") 70 | require.NoError(t, err) 71 | 72 | os.Setenv("ADLESS_CONFIG_PATH", testConfig.Name()) 73 | defer os.Unsetenv("ADLESS_CONFIG_PATH") 74 | 75 | config, err := Load() 76 | 77 | assert.NotNil(t, config) 78 | assert.NoError(t, err) 79 | }) 80 | } 81 | 82 | func TestLoadByUser(t *testing.T) { 83 | td, err := os.MkdirTemp("", "adless-config") 84 | defer os.RemoveAll(td) 85 | 86 | require.NoError(t, err) 87 | 88 | t.Run("config not found", func(t *testing.T) { 89 | config, err := LoadByUser(".test_config") 90 | 91 | assert.Nil(t, config) 92 | assert.NotNil(t, err) 93 | assert.ErrorIs(t, err, os.ErrNotExist) 94 | }) 95 | 96 | t.Run("config is invalid", func(t *testing.T) { 97 | testConfig, err := os.CreateTemp(td, "") 98 | require.NoError(t, err) 99 | 100 | _, err = testConfig.WriteString("blocklists:\n") 101 | require.NoError(t, err) 102 | 103 | config, err := LoadByUser(testConfig.Name()) 104 | 105 | assert.Nil(t, config) 106 | assert.ErrorIs(t, err, ErrNoBlocklistsProvided) 107 | }) 108 | 109 | t.Run("config is loaded successfully", func(t *testing.T) { 110 | testConfig, err := os.CreateTemp(td, "") 111 | require.NoError(t, err) 112 | 113 | _, err = testConfig.WriteString("blocklists:\n- target: https://test.com") 114 | require.NoError(t, err) 115 | 116 | config, err := LoadByUser(testConfig.Name()) 117 | 118 | assert.NotNil(t, config) 119 | assert.NoError(t, err, ErrNoBlocklistsProvided) 120 | }) 121 | } 122 | 123 | func TestLocation(t *testing.T) { 124 | t.Run("ADLESS_CONFIG_PATH environment variable", func(t *testing.T) { 125 | bcp := path.Join(homeDir(), ".config", "test_adless", "config.yml") 126 | 127 | os.Setenv("ADLESS_CONFIG_PATH", bcp) 128 | defer os.Unsetenv("ADLESS_CONFIG_PATH") 129 | 130 | assert.Equal(t, location(), bcp) 131 | }) 132 | 133 | t.Run("ADLESS_CONFIG_HOME environment variable", func(t *testing.T) { 134 | bch := path.Join(homeDir(), ".config", "test_adless") 135 | 136 | os.Setenv("ADLESS_CONFIG_HOME", bch) 137 | defer os.Unsetenv("ADLESS_CONFIG_HOME") 138 | 139 | expected := filepath.Join(bch, "config.yml") 140 | assert.Equal(t, location(), expected) 141 | }) 142 | 143 | t.Run("XDG_CONFIG_HOME environment variable", func(t *testing.T) { 144 | xdgConfig := filepath.Join(homeDir(), ".test_config") 145 | 146 | os.Setenv("XDG_CONFIG_HOME", xdgConfig) 147 | defer os.Unsetenv("XDG_CONFIG_HOME") 148 | 149 | expected := filepath.Join(xdgConfig, "adless", "config.yml") 150 | assert.Equal(t, location(), expected) 151 | }) 152 | 153 | t.Run("default location", func(t *testing.T) { 154 | expected := filepath.Join(homeDir(), ".config", "adless", "config.yml") 155 | assert.Equal(t, location(), expected) 156 | }) 157 | } 158 | 159 | func TestRead(t *testing.T) { 160 | td, err := os.MkdirTemp("", "adless-config") 161 | defer os.RemoveAll(td) 162 | 163 | require.NoError(t, err) 164 | 165 | t.Run("successfully read config", func(t *testing.T) { 166 | testConfig, err := os.CreateTemp(td, "") 167 | require.NoError(t, err) 168 | 169 | _, err = testConfig.WriteString("blocklists:\n- target: https://test.com") 170 | require.NoError(t, err) 171 | 172 | config, err := read(testConfig.Name()) 173 | require.NoError(t, err) 174 | 175 | assert.Len(t, config.Blocklists, 1) 176 | }) 177 | 178 | t.Run("config not found", func(t *testing.T) { 179 | config, err := read("test_config.yml") 180 | 181 | require.Nil(t, config) 182 | require.NotNil(t, err) 183 | 184 | assert.ErrorIs(t, err, os.ErrNotExist) 185 | }) 186 | 187 | t.Run("failed to unmarshal yaml config", func(t *testing.T) { 188 | testConfig, err := os.CreateTemp(td, "") 189 | require.NoError(t, err) 190 | 191 | _, err = testConfig.WriteString("invalid yml content") 192 | require.NoError(t, err) 193 | 194 | config, err := read(testConfig.Name()) 195 | require.Nil(t, config) 196 | require.NotNil(t, err) 197 | 198 | assert.ErrorContains(t, err, "yaml: unmarshal errors") 199 | }) 200 | } 201 | 202 | func TestHomeDir(t *testing.T) { 203 | t.Run("SUDO_USER environment variable", func(t *testing.T) { 204 | os.Setenv("SUDO_USER", "root") 205 | defer os.Unsetenv("SUDO_USER") 206 | 207 | expected := "/root/.config/adless/config.yml" 208 | assert.Equal(t, location(), expected) 209 | }) 210 | } 211 | 212 | func TestDefaultConfig(t *testing.T) { 213 | t.Run("return default config", func(t *testing.T) { 214 | config := defaultConfig() 215 | 216 | require.NotNil(t, config) 217 | 218 | assert.IsType(t, config, &Config{}) 219 | }) 220 | } 221 | -------------------------------------------------------------------------------- /internal/hostsfile/processor.go: -------------------------------------------------------------------------------- 1 | package hostsfile 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | "slices" 7 | "strings" 8 | "sync" 9 | 10 | "github.com/WIttyJudge/adless/internal/config" 11 | "github.com/WIttyJudge/adless/internal/http" 12 | "github.com/rs/zerolog/log" 13 | ) 14 | 15 | const localhost = "127.0.0.1" 16 | 17 | // first part (before +): subdomain pattern. 18 | // second part (after +): top level domain (TLD) pattern. 19 | var validDomainRegexp = regexp.MustCompile(`^([a-z0-9_-]{0,63}\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$`) 20 | 21 | // Processor is a structure that is responsible for processing blocklists, 22 | // whitelists and preparing the result to save to hosts file. 23 | type Processor struct { 24 | config *config.Config 25 | httpClient *http.HTTP 26 | } 27 | 28 | // Result contains multiple parsed blocklists. 29 | type Result struct { 30 | startTag string 31 | endTag string 32 | descriptionComment string 33 | domains map[string]LineContent 34 | } 35 | 36 | // TargetResult represents a parsed result of blocklist 37 | // that is ready to be appended into hosts file. 38 | type TargetResult struct { 39 | DomainsCount int 40 | 41 | linesContent map[string]LineContent 42 | } 43 | 44 | type LineContent struct { 45 | ipAddress string 46 | domainName string 47 | } 48 | 49 | // NewProcessor initializes Processor structure. 50 | func NewProcessor(config *config.Config) *Processor { 51 | httpClient := http.New() 52 | 53 | return &Processor{ 54 | config: config, 55 | httpClient: httpClient, 56 | } 57 | } 58 | 59 | // Process processes blocklists and returns a finished result 60 | // that is ready to save to hosts file. 61 | func (p *Processor) Process() (Result, error) { 62 | wg := &sync.WaitGroup{} 63 | blocklistsResult := p.processBlocklists(wg) 64 | whitelistsResult := p.processWhitelists(wg) 65 | wg.Wait() 66 | 67 | // Merges the results of all targets into one map, where the key 68 | // is a domain name and the value is a content of the line. 69 | // Using a domain as a key allows to avoid duplicates. 70 | blocklistDomains := p.targetDomains(blocklistsResult) 71 | whitelistDomains := p.targetDomains(whitelistsResult) 72 | 73 | p.applyWhitelist(blocklistDomains, whitelistDomains) 74 | 75 | result := Result{ 76 | startTag: StartTag, 77 | endTag: EndTag, 78 | descriptionComment: DescriptionComment, 79 | domains: blocklistDomains, 80 | } 81 | 82 | log.Info().Msgf("total number of uniq domains: %d", len(blocklistDomains)) 83 | 84 | return result, nil 85 | } 86 | 87 | func (p *Processor) processBlocklists(wg *sync.WaitGroup) []TargetResult { 88 | blocklistsResult := make([]TargetResult, len(p.config.Blocklists)) 89 | 90 | for i, blocklist := range p.config.Blocklists { 91 | i := i 92 | target := blocklist.Target 93 | 94 | wg.Add(1) 95 | 96 | go func() { 97 | defer wg.Done() 98 | 99 | blocklistResult, err := p.processBlocklist(target) 100 | if err != nil { 101 | log.Error().Err(err).Str("target", target).Msg("failed to process blocklist") 102 | return 103 | } 104 | 105 | blocklistsResult[i] = blocklistResult 106 | }() 107 | } 108 | 109 | return blocklistsResult 110 | } 111 | 112 | func (p *Processor) processWhitelists(wg *sync.WaitGroup) []TargetResult { 113 | whitelistsResult := make([]TargetResult, len(p.config.Blocklists)) 114 | 115 | for i, whitelist := range p.config.Whitelists { 116 | i := i 117 | target := whitelist.Target 118 | 119 | wg.Add(1) 120 | 121 | go func() { 122 | defer wg.Done() 123 | 124 | whitelistResult, err := p.processWhitelist(target) 125 | if err != nil { 126 | log.Error().Err(err).Str("target", target).Msg("failed to process whitelist") 127 | return 128 | } 129 | 130 | whitelistsResult[i] = whitelistResult 131 | }() 132 | } 133 | 134 | return whitelistsResult 135 | } 136 | 137 | func (p *Processor) processBlocklist(target string) (TargetResult, error) { 138 | log.Info().Str("target", target).Msg("processing blocklist..") 139 | 140 | blocklistResult, err := p.proccessListTarget(target) 141 | if err != nil { 142 | return TargetResult{}, err 143 | } 144 | 145 | log.Info().Str("target", target).Msgf("number of domains: %d", blocklistResult.DomainsCount) 146 | 147 | return blocklistResult, nil 148 | } 149 | 150 | func (p *Processor) processWhitelist(target string) (TargetResult, error) { 151 | log.Info().Str("target", target).Msg("processing whitelist..") 152 | 153 | whitelistResult, err := p.proccessListTarget(target) 154 | if err != nil { 155 | return TargetResult{}, err 156 | } 157 | 158 | log.Info().Str("target", target).Msgf("number of domains: %d", whitelistResult.DomainsCount) 159 | 160 | return whitelistResult, nil 161 | } 162 | 163 | func (p *Processor) proccessListTarget(target string) (TargetResult, error) { 164 | fileContent, err := p.httpClient.Get(target) 165 | if err != nil { 166 | return TargetResult{}, err 167 | } 168 | 169 | linesContent := p.processContent(fileContent) 170 | 171 | blocklistResult := TargetResult{ 172 | DomainsCount: len(linesContent), 173 | linesContent: linesContent, 174 | } 175 | 176 | return blocklistResult, nil 177 | } 178 | 179 | func (p *Processor) processContent(content string) map[string]LineContent { 180 | lines := strings.Split(content, "\n") 181 | linesContent := make(map[string]LineContent) 182 | 183 | for _, rawLine := range lines { 184 | line := p.normalizeLine(rawLine) 185 | 186 | if p.shouldSkipLine(line) { 187 | continue 188 | } 189 | 190 | domainName := p.extractDomain(line) 191 | if p.IsSkippedDomain(domainName) { 192 | continue 193 | } 194 | 195 | if !p.isValidDomain(domainName) { 196 | continue 197 | } 198 | 199 | lineContent := LineContent{ 200 | ipAddress: localhost, 201 | domainName: domainName, 202 | } 203 | 204 | linesContent[domainName] = lineContent 205 | } 206 | 207 | return linesContent 208 | } 209 | 210 | func (p *Processor) targetDomains(targetResult []TargetResult) map[string]LineContent { 211 | targetDomains := make(map[string]LineContent) 212 | 213 | for _, result := range targetResult { 214 | for _, line := range result.linesContent { 215 | targetDomains[line.domainName] = line 216 | } 217 | } 218 | 219 | return targetDomains 220 | } 221 | 222 | func (p *Processor) applyWhitelist(blocklistDomains, whitelistDomains map[string]LineContent) { 223 | for key := range whitelistDomains { 224 | delete(blocklistDomains, key) 225 | } 226 | } 227 | 228 | // 1. Remove empty spaces. 229 | // 2. Remove a comment in the middle of line. 230 | // 3. Convert all characters to lowercase. 231 | func (p *Processor) normalizeLine(rawLine string) string { 232 | line := strings.TrimSpace(rawLine) 233 | line = p.removeInLineComment(line) 234 | line = strings.ToLower(line) 235 | 236 | return line 237 | } 238 | 239 | // skip empty lines, comments, ABP comments and ABP headers. 240 | func (p *Processor) shouldSkipLine(line string) bool { 241 | return line == "" || p.isLineComment(line) || p.isABPComment(line) || p.isABPHeader(line) 242 | } 243 | 244 | func (p *Processor) removeInLineComment(line string) string { 245 | return strings.Split(line, "#")[0] 246 | } 247 | 248 | func (p *Processor) parseABPDomain(line string) string { 249 | line = strings.TrimPrefix(line, "||") 250 | line = strings.TrimSuffix(line, "^") 251 | return line 252 | } 253 | 254 | func (p *Processor) extractDomain(line string) string { 255 | if p.isABPDomain(line) { 256 | line = p.parseABPDomain(line) 257 | } 258 | 259 | var domainName string 260 | parts := strings.Fields(line) 261 | if len(parts) == 1 { 262 | domainName = parts[0] 263 | } else { 264 | domainName = parts[1] 265 | } 266 | 267 | return domainName 268 | } 269 | 270 | func (p *Processor) isLineComment(line string) bool { 271 | return strings.HasPrefix(line, "#") 272 | } 273 | 274 | // supported ABP style: ||subdomain.domain.tlp^ 275 | // Example: https://raw.githubusercontent.com/hagezi/dns-blocklists/main/adblock/light.txt 276 | func (p *Processor) isABPDomain(line string) bool { 277 | return strings.HasPrefix(line, "||") && strings.HasSuffix(line, "^") 278 | } 279 | 280 | func (p *Processor) isABPComment(line string) bool { 281 | return strings.HasPrefix(line, "!") 282 | } 283 | 284 | func (p *Processor) isABPHeader(line string) bool { 285 | return strings.HasPrefix(line, "[") 286 | } 287 | 288 | // IsSkippedDomain checks if a domain is in the skip list. 289 | // Some lists (i.e StevenBlack's) contain these as they are supposed to be used as HOST. 290 | func (p *Processor) IsSkippedDomain(domain string) bool { 291 | skipList := []string{ 292 | "localhost", 293 | "localhost.localdomain", 294 | "local", 295 | "broadcasthost", 296 | "ip6-localhost", 297 | "ip6-loopback", 298 | "lo0 localhost", 299 | "ip6-localnet", 300 | "ip6-mcastprefix", 301 | "ip6-allnodes", 302 | "ip6-allrouters", 303 | "ip6-allhosts", 304 | "0.0.0.0", 305 | } 306 | 307 | return slices.Contains(skipList, domain) 308 | } 309 | 310 | func (p *Processor) isValidDomain(domain string) bool { 311 | return validDomainRegexp.MatchString(domain) 312 | } 313 | 314 | func (r Result) FormatToHostsfile() string { 315 | var builder strings.Builder 316 | 317 | builder.WriteString(r.startTag) 318 | builder.WriteString(r.descriptionComment) 319 | 320 | for _, domain := range r.domains { 321 | builder.WriteString(domain.Format()) 322 | } 323 | 324 | builder.WriteString(r.endTag) 325 | 326 | withoutLastWhitespace := strings.TrimSuffix(builder.String(), "\n") 327 | 328 | return withoutLastWhitespace 329 | } 330 | 331 | func (lc LineContent) Format() string { 332 | return fmt.Sprintf("%s %s\n", lc.ipAddress, lc.domainName) 333 | } 334 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | --------------------------------------------------------------------------------