├── images └── tva.png ├── docker-compose.yml ├── Makefile ├── .github ├── workflows │ └── go.yml └── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md ├── .gitignore ├── Dockerfile ├── LICENSE ├── internal ├── api │ ├── templates │ │ ├── blocklists.html │ │ ├── components │ │ │ └── nav.html │ │ ├── nav.html │ │ ├── sidebar.html │ │ └── dashboard.html │ ├── embed.go │ ├── blocklist_handlers.go │ ├── server.go │ └── static │ │ └── js │ │ └── dashboard.js ├── config │ ├── config.go │ └── config_test.go ├── dns │ ├── server.go │ └── server_test.go └── blocker │ └── blocker.go ├── go.mod ├── cmd └── server │ └── main.go ├── README.md ├── go.sum └── CODE_OF_CONDUCT.md /images/tva.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vivek-pk/GoAdBlock/HEAD/images/tva.png -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | services: 3 | adgoblock: 4 | build: 5 | context: . 6 | dockerfile: Dockerfile 7 | args: 8 | PLATFORM_VERSION: linux/amd64 9 | ports: 10 | - "53:53" 11 | - "8080:8080" 12 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | MAIN_PATH := cmd/server/main.go 2 | 3 | APP_NAME := goAdBlock 4 | 5 | BUILD_DIR := build 6 | 7 | .PHONY: build 8 | build: 9 | @echo "Building $(APP_NAME)..." 10 | @mkdir -p $(BUILD_DIR) 11 | @go build -o $(BUILD_DIR)/$(APP_NAME) $(MAIN_PATH) 12 | @echo "Build complete: $(BUILD_DIR)/$(APP_NAME)" 13 | 14 | .PHONY: run 15 | run: build 16 | @echo "Running $(APP_NAME) with default settings..." 17 | @$(BUILD_DIR)/$(APP_NAME) 18 | 19 | -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a golang project 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go 3 | 4 | name: Go 5 | 6 | on: 7 | push: 8 | branches: [ "main" ] 9 | pull_request: 10 | branches: [ "main" ] 11 | 12 | jobs: 13 | 14 | build: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v4 18 | 19 | - name: Set up Go 20 | uses: actions/setup-go@v4 21 | with: 22 | go-version: '1.23' 23 | 24 | - name: Build 25 | run: go build -v ./... 26 | 27 | - name: Test 28 | run: go test -v ./... 29 | -------------------------------------------------------------------------------- /.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 | # Ignore the build folder 28 | build/ 29 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. Windows, Mac...] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Additional context** 32 | Add any other context about the problem here. 33 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Stage 1: Build the Go binary 2 | ARG GO_VERSION=1.23 3 | ARG PLATFORM=linux/arm64 4 | 5 | # Use build arguments in the builder stage 6 | FROM --platform=$PLATFORM golang:$GO_VERSION-alpine AS builder 7 | 8 | # Install dependencies 9 | RUN apk add --no-cache git 10 | 11 | # Set the working directory 12 | WORKDIR /GoAdBlock 13 | 14 | # Copy Go modules files and download dependencies 15 | COPY go.mod go.sum ./ 16 | 17 | # Copy the source code 18 | COPY . . 19 | 20 | # Build the Go application 21 | RUN GOOS=linux GOARCH=arm64 go build -o goadblock ./cmd/server/main.go 22 | 23 | # Stage 2: Create a minimal runtime image 24 | 25 | 26 | FROM --platform=$PLATFORM alpine:latest 27 | 28 | 29 | RUN adduser -D goadblock && mkdir /GoAdBlock 30 | # Set the working directory 31 | WORKDIR /GoAdBlock/ 32 | 33 | # Copy the binary from the builder stage 34 | COPY --from=builder /GoAdBlock/goadblock . 35 | 36 | # Set execution permissions 37 | RUN chmod +x goadblock 38 | EXPOSE 8080 53 39 | # Command to run the app 40 | CMD ["./goadblock"] 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Vivek Pradeepkumar 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /internal/api/templates/blocklists.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | GoAdBlock - Blocklists 7 | 8 | 9 |
10 | 11 | 12 | 13 | 14 |
15 |
18 |

19 | Blocklist Management 20 |

21 | 22 |
23 | 24 | 25 |
26 |

27 | Active Blocklists 28 |

29 | 30 |
31 |
32 |
33 | 34 | 35 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/vivek-pk/goadblock 2 | 3 | require ( 4 | github.com/google/uuid v1.6.0 5 | github.com/gorilla/mux v1.8.1 6 | github.com/miekg/dns v1.1.55 7 | github.com/spf13/pflag v1.0.6 8 | github.com/spf13/viper v1.20.0 9 | github.com/stretchr/testify v1.10.0 10 | ) 11 | 12 | require ( 13 | github.com/davecgh/go-spew v1.1.1 // indirect 14 | github.com/fsnotify/fsnotify v1.8.0 // indirect 15 | github.com/go-viper/mapstructure/v2 v2.2.1 // indirect 16 | github.com/pelletier/go-toml/v2 v2.2.3 // indirect 17 | github.com/pmezard/go-difflib v1.0.0 // indirect 18 | github.com/sagikazarmark/locafero v0.7.0 // indirect 19 | github.com/sourcegraph/conc v0.3.0 // indirect 20 | github.com/spf13/afero v1.12.0 // indirect 21 | github.com/spf13/cast v1.7.1 // indirect 22 | github.com/subosito/gotenv v1.6.0 // indirect 23 | go.uber.org/atomic v1.9.0 // indirect 24 | go.uber.org/multierr v1.9.0 // indirect 25 | golang.org/x/mod v0.17.0 // indirect 26 | golang.org/x/net v0.33.0 // indirect 27 | golang.org/x/sync v0.10.0 // indirect 28 | golang.org/x/sys v0.29.0 // indirect 29 | golang.org/x/text v0.21.0 // indirect 30 | golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect 31 | gopkg.in/yaml.v3 v3.0.1 // indirect 32 | ) 33 | 34 | go 1.23.1 35 | -------------------------------------------------------------------------------- /internal/api/embed.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "embed" 5 | "html/template" 6 | "io/fs" 7 | "log" 8 | "net/http" 9 | 10 | "github.com/gorilla/mux" 11 | ) 12 | 13 | //go:embed templates/* static/* 14 | var embeddedFiles embed.FS 15 | 16 | // GetTemplates returns a filesystem with just the templates 17 | func GetTemplates() *template.Template { 18 | tmpl := template.New("") 19 | template.Must(tmpl.ParseFS(embeddedFiles, "templates/*.html")) 20 | return tmpl 21 | } 22 | 23 | // ServeStaticFiles sets up the static file server handler using embedded files 24 | func ServeStaticFiles(router *mux.Router) { 25 | staticFS, err := fs.Sub(embeddedFiles, "static") 26 | if err != nil { 27 | log.Fatalf("Failed to create sub-filesystem for static files: %v", err) 28 | } 29 | 30 | // Debug: List files in the embedded filesystem 31 | log.Println("Listing embedded static files:") 32 | fs.WalkDir(staticFS, ".", func(path string, d fs.DirEntry, err error) error { 33 | if err != nil { 34 | return err 35 | } 36 | log.Printf(" - %s (%v)", path, d.IsDir()) 37 | return nil 38 | }) 39 | 40 | // Create a file server handler for the static files 41 | fileServer := http.FileServer(http.FS(staticFS)) 42 | 43 | // Register the handler for the /static/ path 44 | router.PathPrefix("/static/").Handler( 45 | http.StripPrefix("/static/", fileServer), 46 | ) 47 | } 48 | -------------------------------------------------------------------------------- /internal/api/templates/components/nav.html: -------------------------------------------------------------------------------- 1 | {{ define "nav" }} 2 | 49 | {{ end }} 50 | -------------------------------------------------------------------------------- /internal/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/spf13/pflag" 8 | "github.com/spf13/viper" 9 | ) 10 | 11 | func InitConfig() error { 12 | //VIPER Priority : flags -> env -> config -> default 13 | 14 | // Flags 15 | pflag.Int("dns-port", 53, "Port for the DNS server") 16 | pflag.Int("http-port", 8080, "Port for the HTTP server") 17 | pflag.String("config", "", "Config file path") 18 | 19 | pflag.Parse() 20 | 21 | bindFlagsWithFormatting(pflag.CommandLine) 22 | 23 | // Env 24 | viper.SetEnvPrefix("GOADBLOCK") 25 | viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_", "-", "_")) 26 | viper.AutomaticEnv() 27 | 28 | // Config 29 | configPath := viper.GetString("config") 30 | if configPath != "" { 31 | viper.SetConfigFile(configPath) 32 | } else { 33 | viper.SetConfigName("config") 34 | viper.SetConfigType("yaml") 35 | viper.AddConfigPath(".") 36 | viper.AddConfigPath("$HOME/.goablock") 37 | viper.AddConfigPath("/etc/goablock") 38 | } 39 | 40 | if err := viper.ReadInConfig(); err != nil { 41 | // It's okay if the config file doesn't exist 42 | if _, ok := err.(viper.ConfigFileNotFoundError); !ok { 43 | return fmt.Errorf("error reading config file: %w", err) 44 | } 45 | } 46 | 47 | // Default Values 48 | viper.SetDefault("http.port", 8080) 49 | viper.SetDefault("dns.port", 53) 50 | viper.SetDefault("config", "") 51 | 52 | return nil 53 | } 54 | 55 | func bindFlagsWithFormatting(flagSet *pflag.FlagSet) { 56 | flagSet.VisitAll(func(flag *pflag.Flag) { 57 | // Convert hyphen to dot notation for viper 58 | name := strings.ReplaceAll(flag.Name, "-", ".") 59 | viper.BindPFlag(name, flag) 60 | }) 61 | } 62 | 63 | func GetDnsPort() int { 64 | return viper.GetInt("dns.port") 65 | } 66 | 67 | func GetHttpPort() int { 68 | return viper.GetInt("http.port") 69 | } 70 | 71 | func GetConfigPath() string { 72 | return viper.GetString("config") 73 | } 74 | -------------------------------------------------------------------------------- /internal/api/templates/nav.html: -------------------------------------------------------------------------------- 1 | {{ define "nav" }} 2 | 62 | 63 | 64 |
69 | {{ end }} 70 | -------------------------------------------------------------------------------- /internal/config/config_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/spf13/pflag" 8 | "github.com/spf13/viper" 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func resetViper() { 13 | viper.Reset() 14 | pflag.CommandLine = pflag.NewFlagSet(os.Args[0], pflag.ExitOnError) 15 | os.Args = []string{"cmd"} 16 | } 17 | 18 | func TestConfigDefaults(t *testing.T) { 19 | resetViper() 20 | 21 | err := InitConfig() 22 | assert.NoError(t, err) 23 | assert.EqualValues(t, 53, GetDnsPort()) 24 | assert.EqualValues(t, 8080, GetHttpPort()) 25 | } 26 | 27 | func TestConfigFromFlags(t *testing.T) { 28 | resetViper() 29 | 30 | os.Args = []string{"cmd", "--dns-port=54", "--http-port=3000"} 31 | 32 | err := InitConfig() 33 | assert.NoError(t, err) 34 | assert.EqualValues(t, 54, GetDnsPort()) 35 | assert.EqualValues(t, 3000, GetHttpPort()) 36 | } 37 | 38 | func TestConfigFromEnvVars(t *testing.T) { 39 | resetViper() 40 | 41 | os.Setenv("GOADBLOCK_DNS_PORT", "55") 42 | os.Setenv("GOADBLOCK_HTTP_PORT", "3001") 43 | 44 | err := InitConfig() 45 | assert.NoError(t, err) 46 | assert.EqualValues(t, 55, GetDnsPort()) 47 | assert.EqualValues(t, 3001, GetHttpPort()) 48 | 49 | os.Unsetenv("GOADBLOCK_DNS_PORT") 50 | os.Unsetenv("GOADBLOCK_HTTP_PORT") 51 | } 52 | 53 | func TestConfigFromConfigFile(t *testing.T) { 54 | resetViper() 55 | configContent := `dns: 56 | port: 50 57 | upstream: '8.8.8.8' 58 | cache_size: 5000 59 | cache_ttl: 3600 60 | 61 | http: 62 | port: 5000 63 | username: 'admin' 64 | password: 'changeme'` 65 | 66 | tmpfile, err := os.CreateTemp("", "config*.yaml") 67 | assert.NoError(t, err, "Should create temp file") 68 | defer os.Remove(tmpfile.Name()) 69 | 70 | _, err = tmpfile.WriteString(configContent) 71 | assert.NoError(t, err, "Should write to temp file") 72 | tmpfile.Close() 73 | 74 | // provide temp file as env 75 | os.Setenv("GOADBLOCK_CONFIG", tmpfile.Name()) 76 | 77 | err = InitConfig() 78 | assert.NoError(t, err) 79 | assert.EqualValues(t, 50, GetDnsPort()) 80 | assert.EqualValues(t, 5000, GetHttpPort()) 81 | 82 | os.Unsetenv("GOADBLOCK_CONFIG") 83 | } 84 | 85 | func TestPriorityOrder(t *testing.T) { 86 | resetViper() 87 | 88 | // Flags 89 | os.Args = []string{"cmd", "--dns-port=60"} 90 | 91 | // Env Vars 92 | os.Setenv("GOADBLOCK_DNS_PORT", "55") 93 | os.Setenv("GOADBLOCK_HTTP_PORT", "3678") 94 | 95 | //Config File 96 | configContent := `dns: 97 | port: 50 98 | upstream: '8.8.8.8' 99 | cache_size: 5000 100 | cache_ttl: 3600 101 | 102 | http: 103 | port: 5000 104 | username: 'admin' 105 | password: 'changeme'` 106 | tmpfile, err := os.CreateTemp("", "config*.yaml") 107 | assert.NoError(t, err, "Should create temp file") 108 | defer os.Remove(tmpfile.Name()) 109 | 110 | _, err = tmpfile.WriteString(configContent) 111 | assert.NoError(t, err, "Should write to temp file") 112 | tmpfile.Close() 113 | 114 | // provide temp file as env 115 | os.Setenv("GOADBLOCK_CONFIG", tmpfile.Name()) 116 | 117 | err = InitConfig() 118 | assert.NoError(t, err) 119 | assert.EqualValues(t, 60, GetDnsPort()) 120 | assert.EqualValues(t, 3678, GetHttpPort()) 121 | 122 | os.Unsetenv("GOADBLOCK_DNS_PORT") 123 | os.Unsetenv("GOADBLOCK_HTTP_PORT") 124 | os.Unsetenv("GOADBLOCK_CONFIG") 125 | } 126 | -------------------------------------------------------------------------------- /cmd/server/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "os" 8 | "os/signal" 9 | "syscall" 10 | "time" 11 | 12 | "github.com/vivek-pk/goadblock/internal/api" 13 | "github.com/vivek-pk/goadblock/internal/blocker" 14 | "github.com/vivek-pk/goadblock/internal/config" 15 | "github.com/vivek-pk/goadblock/internal/dns" 16 | ) 17 | 18 | func main() { 19 | configErr := config.InitConfig() 20 | if configErr != nil { 21 | log.Fatalf("Failed to load configs : %v", configErr) 22 | } 23 | 24 | log.Printf("Configuration loaded - Using DNS port: %d, HTTP port: %d", 25 | config.GetDnsPort(), config.GetHttpPort()) 26 | 27 | // Initialize ad blocker 28 | adblocker := blocker.New() 29 | 30 | // Load blocklists with debug info 31 | log.Println("Loading blocklists...") 32 | blocklists := map[string]string{ 33 | "stevenblack": "https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts", 34 | "adaway": "https://adaway.org/hosts.txt", 35 | } 36 | 37 | err := adblocker.LoadMultipleLists(blocklists) 38 | if err != nil { 39 | log.Fatalf("Failed to load blocklists: %v", err) 40 | } 41 | 42 | // Print stats after loading 43 | stats := adblocker.GetBlocklistStats() 44 | log.Printf("Loaded %d blocklists", len(stats)) 45 | for name, stat := range stats { 46 | log.Printf("Blocklist %s: %d domains", name, stat["domains"]) 47 | } 48 | 49 | // Add regex pattern for blocking 50 | err = adblocker.AddBlockRegex(`^ad[0-9]+\.example\.com$`) 51 | if err != nil { 52 | log.Fatalf("Failed to add block regex: %v", err) 53 | } 54 | 55 | // Create context for graceful shutdown 56 | ctx, cancel := context.WithCancel(context.Background()) 57 | defer cancel() 58 | 59 | // Setup signal handling 60 | sigChan := make(chan os.Signal, 1) 61 | signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) 62 | 63 | // Create API server first 64 | apiServer, err := api.NewAPIServer(nil, config.GetHttpPort()) 65 | if err != nil { 66 | log.Fatalf("Failed to create API server: %v", err) 67 | } 68 | 69 | // Create DNS server with API notifier and config 70 | dnsConfig := dns.ServerConfig{ 71 | UpstreamServers: []string{"8.8.8.8:53", "1.1.1.1:53"}, 72 | BlockingMode: "zero_ip", 73 | BlockingIP: "0.0.0.0", 74 | CacheSize: 10000, 75 | } 76 | dnsServer := dns.NewServer(adblocker, apiServer, dnsConfig) 77 | 78 | // Update API server's DNS server reference 79 | apiServer.SetDNSServer(dnsServer) 80 | 81 | // Start servers one by one 82 | log.Printf("Starting DNS server on :%d", config.GetDnsPort()) 83 | dnsErrChan := make(chan error, 1) 84 | go func() { 85 | if err := dnsServer.Start(fmt.Sprintf(":%d", config.GetDnsPort())); err != nil { 86 | dnsErrChan <- err 87 | } 88 | }() 89 | 90 | // Wait for DNS server to be ready 91 | select { 92 | case <-dnsServer.Ready: 93 | log.Println("DNS server started successfully") 94 | case err := <-dnsErrChan: 95 | log.Fatalf("Failed to start DNS server: %v", err) 96 | case <-time.After(5 * time.Second): 97 | log.Fatalf("DNS server startup timed out") 98 | } 99 | 100 | // Now start the API server 101 | log.Printf("Starting API server on :%d", config.GetHttpPort()) 102 | apiErrChan := make(chan error, 1) 103 | go func() { 104 | if err := apiServer.Start(); err != nil { 105 | apiErrChan <- err 106 | } 107 | }() 108 | 109 | // Give API server time to initialize 110 | time.Sleep(500 * time.Millisecond) 111 | log.Println("API server started successfully") 112 | log.Println("GoAdBlock is running. Press Ctrl+C to stop.") 113 | 114 | // Wait for shutdown signal or error 115 | select { 116 | case <-sigChan: 117 | log.Println("Received shutdown signal") 118 | // Give services 5 seconds to shutdown gracefully 119 | ctx, cancel := context.WithTimeout(ctx, 5*time.Second) 120 | defer cancel() 121 | 122 | log.Println("Shutting down API server...") 123 | if err := apiServer.Shutdown(ctx); err != nil { 124 | log.Printf("Error shutting down API server: %v", err) 125 | } 126 | 127 | log.Println("Shutting down DNS server...") 128 | if err := dnsServer.Shutdown(ctx); err != nil { 129 | log.Printf("Error shutting down DNS server: %v", err) 130 | } 131 | 132 | case err := <-dnsErrChan: 133 | log.Fatalf("DNS server error: %v", err) 134 | case err := <-apiErrChan: 135 | log.Fatalf("API server error: %v", err) 136 | } 137 | 138 | log.Println("Servers shutdown complete") 139 | } 140 | -------------------------------------------------------------------------------- /internal/api/blocklist_handlers.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | ) 7 | 8 | type DomainRequest struct { 9 | Domain string `json:"domain"` 10 | List string `json:"list"` 11 | } 12 | 13 | type RegexRequest struct { 14 | Pattern string `json:"pattern"` 15 | } 16 | 17 | // HandleGetBlocklists returns all blocklists 18 | func (s *APIServer) handleGetBlocklists(w http.ResponseWriter, r *http.Request) { 19 | stats := s.dnsServer.GetBlocker().GetBlocklistStats() 20 | 21 | w.Header().Set("Content-Type", "application/json") 22 | json.NewEncoder(w).Encode(stats) 23 | } 24 | 25 | // HandleAddDomainToBlocklist adds a domain to a blocklist 26 | func (s *APIServer) handleAddDomainToBlocklist(w http.ResponseWriter, r *http.Request) { 27 | var req DomainRequest 28 | if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 29 | http.Error(w, "Invalid request", http.StatusBadRequest) 30 | return 31 | } 32 | 33 | if req.Domain == "" || req.List == "" { 34 | http.Error(w, "Domain and list name are required", http.StatusBadRequest) 35 | return 36 | } 37 | 38 | s.dnsServer.GetBlocker().AddDomainToBlocklist(req.Domain, req.List) 39 | 40 | w.WriteHeader(http.StatusCreated) 41 | } 42 | 43 | // HandleRemoveDomainFromBlocklist removes a domain from a blocklist 44 | func (s *APIServer) handleRemoveDomainFromBlocklist(w http.ResponseWriter, r *http.Request) { 45 | var req DomainRequest 46 | if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 47 | http.Error(w, "Invalid request", http.StatusBadRequest) 48 | return 49 | } 50 | 51 | if req.Domain == "" || req.List == "" { 52 | http.Error(w, "Domain and list name are required", http.StatusBadRequest) 53 | return 54 | } 55 | 56 | if !s.dnsServer.GetBlocker().RemoveDomainFromBlocklist(req.Domain, req.List) { 57 | http.Error(w, "Domain not found in blocklist", http.StatusNotFound) 58 | return 59 | } 60 | 61 | w.WriteHeader(http.StatusOK) 62 | } 63 | 64 | // HandleGetWhitelist returns the current whitelist 65 | func (s *APIServer) handleGetWhitelist(w http.ResponseWriter, r *http.Request) { 66 | whitelist := s.dnsServer.GetBlocker().GetWhitelist() 67 | 68 | w.Header().Set("Content-Type", "application/json") 69 | json.NewEncoder(w).Encode(whitelist) 70 | } 71 | 72 | // HandleAddToWhitelist adds a domain to the whitelist 73 | func (s *APIServer) handleAddToWhitelist(w http.ResponseWriter, r *http.Request) { 74 | var req DomainRequest 75 | if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 76 | http.Error(w, "Invalid request", http.StatusBadRequest) 77 | return 78 | } 79 | 80 | if req.Domain == "" { 81 | http.Error(w, "Domain is required", http.StatusBadRequest) 82 | return 83 | } 84 | 85 | s.dnsServer.GetBlocker().AddToWhitelist(req.Domain) 86 | 87 | w.WriteHeader(http.StatusCreated) 88 | } 89 | 90 | // HandleRemoveFromWhitelist removes a domain from the whitelist 91 | func (s *APIServer) handleRemoveFromWhitelist(w http.ResponseWriter, r *http.Request) { 92 | var req DomainRequest 93 | if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 94 | http.Error(w, "Invalid request", http.StatusBadRequest) 95 | return 96 | } 97 | 98 | if req.Domain == "" { 99 | http.Error(w, "Domain is required", http.StatusBadRequest) 100 | return 101 | } 102 | 103 | s.dnsServer.GetBlocker().RemoveFromWhitelist(req.Domain) 104 | 105 | w.WriteHeader(http.StatusOK) 106 | } 107 | 108 | // HandleGetRegexPatterns returns all regex blocking patterns 109 | func (s *APIServer) handleGetRegexPatterns(w http.ResponseWriter, r *http.Request) { 110 | patterns := s.dnsServer.GetBlocker().GetRegexPatterns() 111 | 112 | w.Header().Set("Content-Type", "application/json") 113 | json.NewEncoder(w).Encode(patterns) 114 | } 115 | 116 | // HandleAddRegexPattern adds a regex blocking pattern 117 | func (s *APIServer) handleAddRegexPattern(w http.ResponseWriter, r *http.Request) { 118 | var req RegexRequest 119 | if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 120 | http.Error(w, "Invalid request", http.StatusBadRequest) 121 | return 122 | } 123 | 124 | if req.Pattern == "" { 125 | http.Error(w, "Pattern is required", http.StatusBadRequest) 126 | return 127 | } 128 | 129 | if err := s.dnsServer.GetBlocker().AddBlockRegex(req.Pattern); err != nil { 130 | http.Error(w, "Invalid regex pattern: "+err.Error(), http.StatusBadRequest) 131 | return 132 | } 133 | 134 | w.WriteHeader(http.StatusCreated) 135 | } 136 | 137 | // HandleRemoveRegexPattern removes a regex blocking pattern 138 | func (s *APIServer) handleRemoveRegexPattern(w http.ResponseWriter, r *http.Request) { 139 | var req RegexRequest 140 | if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 141 | http.Error(w, "Invalid request", http.StatusBadRequest) 142 | return 143 | } 144 | 145 | if req.Pattern == "" { 146 | http.Error(w, "Pattern is required", http.StatusBadRequest) 147 | return 148 | } 149 | 150 | s.dnsServer.GetBlocker().RemoveBlockRegex(req.Pattern) 151 | 152 | w.WriteHeader(http.StatusOK) 153 | } 154 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

A DNS-based ad blocker with a stylish dual-themed dashboard

3 |
4 | 5 |
6 |

⚠️ This is a work in progress application and may contain bugs or incomplete features ⚠️

7 |
8 | 9 | GoAdBlock is a lightweight, high-performance DNS-based ad blocker written in Go. It intercepts DNS queries for known advertising and tracking domains and prevents them from resolving, effectively blocking ads at the network level before they're downloaded. 10 | 11 | ## ✨ Features 12 | 13 | - DNS-level ad blocking: Blocks ads at the network level for all devices 14 | - Dual-themed dashboard: Choose between TVA (Time Variance Authority) or Cockpit interface 15 | - Real-time statistics: Monitor blocked requests, cache performance, and more 16 | - Client tracking: See which devices are making requests on your network 17 | - Performance optimized: Written in Go for high throughput and low resource usage 18 | - Self-contained binary: Single binary that includes all assets 19 | - Local caching: Improves response times for frequently accessed domains 20 | - Customizable blocklists: Add or remove domains from blocklists 21 | - Cross-platform: Works on Linux, macOS, and Windows 22 | 23 | ## 📸 Screenshots 24 | 25 |
26 |

TVA Theme

27 | tva 28 | 29 |
30 | 31 | ## 🚀 Installation 32 | 33 | ### Prerequisites 34 | 35 | - Go 1.18 or higher 36 | 37 | ### From Source 38 | 39 | ```sh 40 | # Clone the repository 41 | git clone https://github.com/vivek-pk/GoAdBlock.git 42 | 43 | # Navigate to the project directory 44 | cd GoAdBlock 45 | 46 | # Build the project 47 | go build -o goadblock ./cmd/server/main.go 48 | 49 | # Run the executable 50 | ./goadblock 51 | ``` 52 | 53 | 61 | 62 | ## ⚙️ Configuration 63 | 64 | > ⚠️ **TODO**: This section needs to be completed/reviewed 65 | 66 | GoAdBlock can be configured using flags or a configuration file: 67 | 68 | ```sh 69 | # Run with custom DNS port 70 | ./goadblock --dns-port=5353 71 | 72 | # Run with custom web interface port 73 | ./goadblock --http-port=8080 74 | 75 | # Use a config file 76 | ./goadblock --config=config.yaml 77 | ``` 78 | 79 | Example config file: 80 | 81 | ```yaml 82 | dns: 83 | port: 53 84 | upstream: '8.8.8.8' 85 | cache_size: 5000 86 | cache_ttl: 3600 87 | 88 | http: 89 | port: 8080 90 | username: 'admin' 91 | password: 'changeme' 92 | 93 | blocklists: 94 | - 'https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts' 95 | - 'https://adaway.org/hosts.txt' 96 | ``` 97 | 98 | ## 📊 Usage 99 | 100 | 1. Set your router's DNS server to point to the machine running GoAdBlock 101 | 2. Or configure individual devices to use GoAdBlock as their DNS server 102 | 3. Access the dashboard at http://:8080 103 | 4. Toggle between themes using the theme switcher in the sidebar 104 | 5. Monitor blocking performance through the visual dashboard 105 | 106 | 107 | ## 🛠️ Development 108 | 109 | ### Project Structure 110 | 111 | ``` 112 | / 113 | ├── cmd/ 114 | │ └── server/ # Application entry point 115 | ├── internal/ 116 | │ ├── api/ # Web API and dashboard 117 | │ │ ├── static/ # Static assets (JS, CSS) 118 | │ │ └── templates/ # HTML templates 119 | │ ├── blocklist/ # Blocklist management 120 | │ ├── cache/ # DNS cache implementation 121 | │ ├── config/ # Configuration handling 122 | │ └── dns/ # DNS server implementation 123 | └── pkg/ # Public packages 124 | ``` 125 | 126 | 135 | 136 | ## 🤝 Contributing 137 | 138 | Contributions are welcome! Please feel free to submit a Pull Request. 139 | 140 | 1. Fork the repository 141 | 2. Create your feature branch (`git checkout -b feature/amazing-feature`) 142 | 3. Commit your changes (`git commit -m 'Add some amazing feature'`) 143 | 4. Push to the branch (`git push origin feature/amazing-feature`) 144 | 5. Open a Pull Request 145 | 146 | ## 📝 License 147 | 148 | This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details. 149 | 150 | ## 🙏 Acknowledgments 151 | 152 | - Special thanks to everyone who contributed to this project 153 | - UI themes inspired by Marvel's Time Variance Authority and aviation cockpit designs 154 | - Built with Go, Alpine.js, Chart.js, and TailwindCSS 155 | 156 |
157 |

If you find this project useful, consider giving it a star! ⭐

158 |
159 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 2 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 3 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 4 | github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= 5 | github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 6 | github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= 7 | github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= 8 | github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= 9 | github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= 10 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 11 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 12 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 13 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 14 | github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= 15 | github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= 16 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 17 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 18 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 19 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 20 | github.com/miekg/dns v1.1.55 h1:GoQ4hpsj0nFLYe+bWiCToyrBEJXkQfOOIvFGFy0lEgo= 21 | github.com/miekg/dns v1.1.55/go.mod h1:uInx36IzPl7FYnDcMeVWxj9byh7DutNykX4G9Sj60FY= 22 | github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= 23 | github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= 24 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 25 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 26 | github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= 27 | github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 28 | github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo= 29 | github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k= 30 | github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= 31 | github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= 32 | github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs= 33 | github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4= 34 | github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= 35 | github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= 36 | github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= 37 | github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 38 | github.com/spf13/viper v1.20.0 h1:zrxIyR3RQIOsarIrgL8+sAvALXul9jeEPa06Y0Ph6vY= 39 | github.com/spf13/viper v1.20.0/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4= 40 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 41 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 42 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 43 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 44 | github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= 45 | github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= 46 | go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= 47 | go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= 48 | go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= 49 | go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= 50 | golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= 51 | golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 52 | golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= 53 | golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= 54 | golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= 55 | golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 56 | golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= 57 | golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 58 | golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= 59 | golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= 60 | golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= 61 | golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= 62 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 63 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= 64 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 65 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 66 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 67 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | . 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /internal/dns/server.go: -------------------------------------------------------------------------------- 1 | package dns 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "net" 8 | "sync" 9 | "time" 10 | 11 | "github.com/miekg/dns" 12 | "github.com/vivek-pk/goadblock/internal/blocker" 13 | ) 14 | 15 | // Server represents a DNS server 16 | type Server struct { 17 | blocker *blocker.Blocker 18 | notifier BlockNotifier 19 | server *dns.Server 20 | cache *DNSCache 21 | upstreamAddrs []string 22 | currentUpstream int 23 | metrics *Metrics 24 | shutdown chan struct{} 25 | apiNotifier APINotifier 26 | Ready chan struct{} 27 | blockingMode string 28 | blockingIP net.IP 29 | } 30 | 31 | type ServerConfig struct { 32 | UpstreamServers []string 33 | BlockingMode string 34 | BlockingIP string 35 | CacheSize int 36 | } 37 | 38 | type DNSCache struct { 39 | entries map[string]*CacheEntry 40 | mu sync.RWMutex 41 | } 42 | 43 | type CacheEntry struct { 44 | Answer []dns.RR 45 | ExpiresAt time.Time 46 | } 47 | 48 | type Metrics struct { 49 | TotalQueries int64 50 | BlockedQueries int64 51 | CacheHits int64 52 | CacheMisses int64 53 | mu sync.RWMutex 54 | } 55 | 56 | type APINotifier interface { 57 | AddQuery(domain string, clientIP string, blocked bool) 58 | } 59 | 60 | // BlockNotifier is an interface for components that need to be notified of blocked domains 61 | type BlockNotifier interface { 62 | OnDomainBlocked(domain string, clientIP string, reason string) 63 | } 64 | 65 | // Update NewServer function to accept config 66 | func NewServer(blocker *blocker.Blocker, apiNotifier APINotifier, config ServerConfig) *Server { 67 | // Create default config if needed 68 | if len(config.UpstreamServers) == 0 { 69 | config.UpstreamServers = []string{ 70 | "8.8.8.8:53", // Google 71 | "1.1.1.1:53", // Cloudflare 72 | } 73 | } 74 | if config.BlockingMode == "" { 75 | config.BlockingMode = "zero_ip" 76 | } 77 | if config.BlockingIP == "" { 78 | config.BlockingIP = "0.0.0.0" 79 | } 80 | if config.CacheSize <= 0 { 81 | config.CacheSize = 10000 82 | } 83 | 84 | return &Server{ 85 | blocker: blocker, 86 | apiNotifier: apiNotifier, 87 | cache: &DNSCache{ 88 | entries: make(map[string]*CacheEntry, config.CacheSize), 89 | }, 90 | upstreamAddrs: config.UpstreamServers, 91 | metrics: &Metrics{}, 92 | shutdown: make(chan struct{}), 93 | Ready: make(chan struct{}), 94 | blockingMode: config.BlockingMode, 95 | blockingIP: net.ParseIP(config.BlockingIP), 96 | } 97 | } 98 | 99 | // Backward compatibility wrapper 100 | func NewServerSimple(blocker *blocker.Blocker, apiNotifier APINotifier) *Server { 101 | return NewServer(blocker, apiNotifier, ServerConfig{ 102 | UpstreamServers: []string{ 103 | "8.8.8.8:53", // Google 104 | "1.1.1.1:53", // Cloudflare 105 | }, 106 | BlockingMode: "zero_ip", 107 | BlockingIP: "0.0.0.0", 108 | CacheSize: 10000, 109 | }) 110 | } 111 | 112 | func (s *Server) handleRequest(w dns.ResponseWriter, r *dns.Msg) { 113 | s.metrics.incrementTotal() 114 | 115 | m := new(dns.Msg) 116 | m.SetReply(r) 117 | m.Compress = false 118 | 119 | switch r.Opcode { 120 | case dns.OpcodeQuery: 121 | for _, q := range m.Question { 122 | switch q.Qtype { 123 | case dns.TypeA, dns.TypeAAAA: 124 | clientIP, _, _ := net.SplitHostPort(w.RemoteAddr().String()) 125 | isBlocked, reason := s.blocker.IsBlocked(q.Name) 126 | log.Printf("DNS query: %s, blocked: %v, reason: %s", q.Name, isBlocked, reason) 127 | 128 | // Notify API server of query 129 | if s.apiNotifier != nil { 130 | s.apiNotifier.AddQuery(q.Name, clientIP, isBlocked) 131 | } 132 | 133 | if isBlocked { 134 | // Notify block listeners 135 | if s.notifier != nil { 136 | s.notifier.OnDomainBlocked(q.Name, clientIP, reason) 137 | } 138 | 139 | s.metrics.incrementBlocked() 140 | if q.Qtype == dns.TypeA { 141 | m.Answer = append(m.Answer, &dns.A{ 142 | Hdr: dns.RR_Header{Name: q.Name, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 60}, 143 | A: net.IPv4(0, 0, 0, 0), // Block by returning 0.0.0.0 144 | }) 145 | } else { 146 | m.Answer = append(m.Answer, &dns.AAAA{ 147 | Hdr: dns.RR_Header{Name: q.Name, Rrtype: dns.TypeAAAA, Class: dns.ClassINET, Ttl: 60}, 148 | AAAA: net.IPv6zero, // Block IPv6 too 149 | }) 150 | } 151 | 152 | log.Printf("Blocked domain %s, returning null IP", q.Name) 153 | } else { 154 | // Check cache first 155 | if answer := s.checkCache(q.Name, q.Qtype); answer != nil { 156 | m.Answer = answer 157 | s.metrics.incrementCacheHit() 158 | } else { 159 | s.metrics.incrementCacheMiss() 160 | resp, err := s.queryUpstream(r) 161 | if err == nil && resp != nil { 162 | m.Answer = resp.Answer 163 | s.updateCache(q.Name, q.Qtype, resp.Answer) 164 | } 165 | } 166 | } 167 | } 168 | } 169 | } 170 | 171 | w.WriteMsg(m) 172 | } 173 | 174 | func (s *Server) queryUpstream(r *dns.Msg) (*dns.Msg, error) { 175 | // Round-robin through upstream servers 176 | s.currentUpstream = (s.currentUpstream + 1) % len(s.upstreamAddrs) 177 | return dns.Exchange(r, s.upstreamAddrs[s.currentUpstream]) 178 | } 179 | 180 | func (s *Server) checkCache(name string, qtype uint16) []dns.RR { 181 | s.cache.mu.RLock() 182 | defer s.cache.mu.RUnlock() 183 | 184 | key := getCacheKey(name, qtype) 185 | if entry, exists := s.cache.entries[key]; exists && time.Now().Before(entry.ExpiresAt) { 186 | return entry.Answer 187 | } 188 | return nil 189 | } 190 | 191 | func (s *Server) updateCache(name string, qtype uint16, answer []dns.RR) { 192 | if len(answer) == 0 { 193 | return 194 | } 195 | 196 | s.cache.mu.Lock() 197 | defer s.cache.mu.Unlock() 198 | 199 | // Cache for 5 minutes 200 | s.cache.entries[getCacheKey(name, qtype)] = &CacheEntry{ 201 | Answer: answer, 202 | ExpiresAt: time.Now().Add(5 * time.Minute), 203 | } 204 | } 205 | 206 | func getCacheKey(name string, qtype uint16) string { 207 | return fmt.Sprintf("%s:%d", name, qtype) 208 | } 209 | 210 | // Metrics methods 211 | func (m *Metrics) incrementTotal() { 212 | m.mu.Lock() 213 | defer m.mu.Unlock() 214 | m.TotalQueries++ 215 | log.Printf("Total queries: %d", m.TotalQueries) // Debug log 216 | } 217 | 218 | func (m *Metrics) incrementBlocked() { 219 | m.mu.Lock() 220 | defer m.mu.Unlock() 221 | m.BlockedQueries++ 222 | log.Printf("Blocked queries: %d", m.BlockedQueries) // Debug log 223 | } 224 | 225 | func (m *Metrics) incrementCacheHit() { 226 | m.mu.Lock() 227 | defer m.mu.Unlock() 228 | m.CacheHits++ 229 | log.Printf("Cache hits: %d", m.CacheHits) // Debug log 230 | } 231 | 232 | func (m *Metrics) incrementCacheMiss() { 233 | m.mu.Lock() 234 | defer m.mu.Unlock() 235 | m.CacheMisses++ 236 | log.Printf("Cache misses: %d", m.CacheMisses) // Debug log 237 | } 238 | 239 | func (s *Server) GetMetrics() *Metrics { 240 | return s.metrics 241 | } 242 | 243 | func (s *Server) Start(addr string) error { 244 | s.server = &dns.Server{Addr: addr, Net: "udp"} 245 | dns.HandleFunc(".", s.handleRequest) 246 | 247 | errChan := make(chan error, 1) 248 | go func() { 249 | errChan <- s.server.ListenAndServe() 250 | }() 251 | 252 | // Signal ready after successful bind 253 | close(s.Ready) 254 | 255 | // Wait for either shutdown signal or error 256 | select { 257 | case <-s.shutdown: 258 | return nil 259 | case err := <-errChan: 260 | return err 261 | } 262 | } 263 | 264 | func (s *Server) Shutdown(ctx context.Context) error { 265 | // Signal shutdown 266 | close(s.shutdown) 267 | 268 | // Shutdown the DNS server 269 | if s.server != nil { 270 | return s.server.Shutdown() 271 | } 272 | return nil 273 | } 274 | 275 | func logQuery(domain string, isBlocked bool, clientIP net.IP) { 276 | status := "allowed" 277 | if isBlocked { 278 | status = "blocked" 279 | } 280 | log.Printf("DNS Query from %s: %s - %s", clientIP, domain, status) 281 | } 282 | 283 | // Add this method to your DNS Server struct 284 | func (s *Server) GetBlocker() *blocker.Blocker { 285 | return s.blocker 286 | } 287 | -------------------------------------------------------------------------------- /internal/blocker/blocker.go: -------------------------------------------------------------------------------- 1 | package blocker 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "io" 7 | "log" 8 | "net/http" 9 | "regexp" 10 | "strings" 11 | "sync" 12 | ) 13 | 14 | // BlockList represents a named collection of blocked domains 15 | type BlockList struct { 16 | Name string 17 | Domains map[string]struct{} 18 | Count int 19 | } 20 | 21 | // Blocker holds domain blocking information 22 | type Blocker struct { 23 | blocklists map[string]*BlockList 24 | whitelist map[string]struct{} 25 | blockRegexes []*regexp.Regexp 26 | mu sync.RWMutex 27 | blocklistStats map[string]int // Track blocks per blocklist 28 | } 29 | 30 | // New creates a new Blocker 31 | func New() *Blocker { 32 | return &Blocker{ 33 | blocklists: make(map[string]*BlockList), 34 | whitelist: make(map[string]struct{}), 35 | blockRegexes: make([]*regexp.Regexp, 0), 36 | blocklistStats: make(map[string]int), 37 | } 38 | } 39 | 40 | // Update the IsBlocked method to return both a boolean and a reason string 41 | func (b *Blocker) IsBlocked(domain string) (bool, string) { 42 | b.mu.RLock() 43 | defer b.mu.RUnlock() 44 | 45 | domain = strings.ToLower(domain) 46 | domain = strings.TrimSuffix(domain, ".") // Remove trailing dot which DNS queries often have 47 | 48 | // Check whitelist first 49 | if _, ok := b.whitelist[domain]; ok { 50 | log.Printf("Domain %s is whitelisted, allowing", domain) 51 | return false, "" 52 | } 53 | 54 | // Check exact domain match in blocklists 55 | for listName, list := range b.blocklists { 56 | if _, ok := list.Domains[domain]; ok { 57 | log.Printf("Domain %s found in blocklist %s", domain, listName) 58 | b.blocklistStats[listName]++ 59 | return true, listName 60 | } 61 | 62 | // Check parent domains (subdomains) 63 | parts := strings.Split(domain, ".") 64 | for i := 1; i < len(parts); i++ { 65 | parentDomain := strings.Join(parts[i:], ".") 66 | if _, ok := list.Domains[parentDomain]; ok { 67 | log.Printf("Domain %s matched parent domain %s in blocklist %s", 68 | domain, parentDomain, listName) 69 | b.blocklistStats[listName]++ 70 | return true, listName 71 | } 72 | } 73 | } 74 | 75 | // Check regex patterns 76 | for _, regex := range b.blockRegexes { 77 | if regex.MatchString(domain) { 78 | log.Printf("Domain %s matched regex pattern: %s", domain, regex.String()) 79 | return true, "regex:" + regex.String() 80 | } 81 | } 82 | 83 | log.Printf("Domain %s not found in any blocklist, allowing", domain) 84 | return false, "" 85 | } 86 | 87 | // LoadFromURL loads blocked domains from a URL 88 | func (b *Blocker) LoadFromURL(url string, name string) error { 89 | if name == "" { 90 | name = url // Use URL as name if not provided 91 | } 92 | 93 | resp, err := http.Get(url) 94 | if err != nil { 95 | return err 96 | } 97 | defer resp.Body.Close() 98 | 99 | return b.loadFromReader(resp.Body, name) 100 | } 101 | 102 | func (b *Blocker) loadFromReader(reader io.Reader, listName string) error { 103 | b.mu.Lock() 104 | defer b.mu.Unlock() 105 | 106 | // Create new blocklist or get existing one 107 | list, exists := b.blocklists[listName] 108 | if !exists { 109 | list = &BlockList{ 110 | Name: listName, 111 | Domains: make(map[string]struct{}), 112 | } 113 | b.blocklists[listName] = list 114 | b.blocklistStats[listName] = 0 115 | } 116 | 117 | scanner := bufio.NewScanner(reader) 118 | for scanner.Scan() { 119 | line := strings.TrimSpace(scanner.Text()) 120 | 121 | // Skip empty lines and comments 122 | if line == "" || strings.HasPrefix(line, "#") { 123 | continue 124 | } 125 | 126 | // Parse hosts file format (0.0.0.0 example.com or 127.0.0.1 example.com) 127 | fields := strings.Fields(line) 128 | if len(fields) >= 2 { 129 | domain := strings.ToLower(fields[1]) 130 | list.Domains[domain] = struct{}{} 131 | } 132 | } 133 | 134 | // Update count 135 | list.Count = len(list.Domains) 136 | 137 | return scanner.Err() 138 | } 139 | 140 | // LoadMultipleLists loads multiple blocklists 141 | func (b *Blocker) LoadMultipleLists(sources map[string]string) error { 142 | for name, url := range sources { 143 | if err := b.LoadFromURL(url, name); err != nil { 144 | return fmt.Errorf("failed to load blocklist %s: %w", name, err) 145 | } 146 | } 147 | return nil 148 | } 149 | 150 | // AddToWhitelist adds a domain to the whitelist 151 | func (b *Blocker) AddToWhitelist(domain string) { 152 | b.mu.Lock() 153 | defer b.mu.Unlock() 154 | 155 | domain = strings.ToLower(domain) 156 | b.whitelist[domain] = struct{}{} 157 | } 158 | 159 | // RemoveFromWhitelist removes a domain from the whitelist 160 | func (b *Blocker) RemoveFromWhitelist(domain string) { 161 | b.mu.Lock() 162 | defer b.mu.Unlock() 163 | 164 | domain = strings.ToLower(domain) 165 | delete(b.whitelist, domain) 166 | } 167 | 168 | // IsWhitelisted checks if a domain is whitelisted 169 | func (b *Blocker) IsWhitelisted(domain string) bool { 170 | b.mu.RLock() 171 | defer b.mu.RUnlock() 172 | 173 | domain = strings.ToLower(domain) 174 | _, ok := b.whitelist[domain] 175 | return ok 176 | } 177 | 178 | // AddBlockRegex adds a regex pattern for blocking 179 | func (b *Blocker) AddBlockRegex(pattern string) error { 180 | regex, err := regexp.Compile(pattern) 181 | if err != nil { 182 | return err 183 | } 184 | 185 | b.mu.Lock() 186 | defer b.mu.Unlock() 187 | 188 | b.blockRegexes = append(b.blockRegexes, regex) 189 | return nil 190 | } 191 | 192 | // RemoveBlockRegex removes a regex pattern by its string representation 193 | func (b *Blocker) RemoveBlockRegex(pattern string) { 194 | b.mu.Lock() 195 | defer b.mu.Unlock() 196 | 197 | // Find and remove the regex pattern 198 | for i, regex := range b.blockRegexes { 199 | if regex.String() == pattern { 200 | b.blockRegexes = append(b.blockRegexes[:i], b.blockRegexes[i+1:]...) 201 | break 202 | } 203 | } 204 | } 205 | 206 | // GetBlocklistStats returns statistics about blocklists 207 | func (b *Blocker) GetBlocklistStats() map[string]map[string]int { 208 | b.mu.RLock() 209 | defer b.mu.RUnlock() 210 | 211 | stats := make(map[string]map[string]int) 212 | 213 | for name, list := range b.blocklists { 214 | stats[name] = map[string]int{ 215 | "domains": list.Count, 216 | "blocks": b.blocklistStats[name], 217 | } 218 | } 219 | 220 | return stats 221 | } 222 | 223 | // GetWhitelist returns the current whitelist 224 | func (b *Blocker) GetWhitelist() []string { 225 | b.mu.RLock() 226 | defer b.mu.RUnlock() 227 | 228 | whitelist := make([]string, 0, len(b.whitelist)) 229 | for domain := range b.whitelist { 230 | whitelist = append(whitelist, domain) 231 | } 232 | 233 | return whitelist 234 | } 235 | 236 | // GetRegexPatterns returns the current regex patterns 237 | func (b *Blocker) GetRegexPatterns() []string { 238 | b.mu.RLock() 239 | defer b.mu.RUnlock() 240 | 241 | patterns := make([]string, len(b.blockRegexes)) 242 | for i, regex := range b.blockRegexes { 243 | patterns[i] = regex.String() 244 | } 245 | 246 | return patterns 247 | } 248 | 249 | // AddDomainToBlocklist adds a domain to a specific blocklist 250 | func (b *Blocker) AddDomainToBlocklist(domain, listName string) { 251 | b.mu.Lock() 252 | defer b.mu.Unlock() 253 | 254 | domain = strings.ToLower(domain) 255 | 256 | // Create blocklist if it doesn't exist 257 | if _, exists := b.blocklists[listName]; !exists { 258 | b.blocklists[listName] = &BlockList{ 259 | Name: listName, 260 | Domains: make(map[string]struct{}), 261 | } 262 | b.blocklistStats[listName] = 0 263 | } 264 | 265 | b.blocklists[listName].Domains[domain] = struct{}{} 266 | b.blocklists[listName].Count = len(b.blocklists[listName].Domains) 267 | } 268 | 269 | // RemoveDomainFromBlocklist removes a domain from a specific blocklist 270 | func (b *Blocker) RemoveDomainFromBlocklist(domain, listName string) bool { 271 | b.mu.Lock() 272 | defer b.mu.Unlock() 273 | 274 | domain = strings.ToLower(domain) 275 | 276 | // Check if blocklist exists 277 | list, exists := b.blocklists[listName] 278 | if !exists { 279 | return false 280 | } 281 | 282 | // Check if domain exists in blocklist 283 | if _, ok := list.Domains[domain]; !ok { 284 | return false 285 | } 286 | 287 | // Remove domain 288 | delete(list.Domains, domain) 289 | list.Count = len(list.Domains) 290 | 291 | return true 292 | } 293 | -------------------------------------------------------------------------------- /internal/dns/server_test.go: -------------------------------------------------------------------------------- 1 | package dns 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net" 7 | "testing" 8 | "time" 9 | 10 | "github.com/miekg/dns" 11 | "github.com/vivek-pk/goadblock/internal/blocker" 12 | ) 13 | 14 | // Add after the existing imports 15 | type mockNotifier struct { 16 | queries []struct { 17 | domain string 18 | clientIP string 19 | blocked bool 20 | } 21 | } 22 | 23 | func (m *mockNotifier) AddQuery(domain string, clientIP string, blocked bool) { 24 | m.queries = append(m.queries, struct { 25 | domain string 26 | clientIP string 27 | blocked bool 28 | }{domain, clientIP, blocked}) 29 | } 30 | 31 | // findAvailablePort finds an available UDP port 32 | func findAvailablePort() (int, error) { 33 | addr, err := net.ResolveUDPAddr("udp", "127.0.0.1:0") 34 | if err != nil { 35 | return 0, err 36 | } 37 | 38 | l, err := net.ListenUDP("udp", addr) 39 | if err != nil { 40 | return 0, err 41 | } 42 | defer l.Close() 43 | 44 | return l.LocalAddr().(*net.UDPAddr).Port, nil 45 | } 46 | 47 | // Update setupTestServer function 48 | func setupTestServer(t *testing.T) (*Server, string, func()) { 49 | port, err := findAvailablePort() 50 | if err != nil { 51 | t.Fatalf("Failed to find available port: %v", err) 52 | } 53 | 54 | adblocker := blocker.New() 55 | _ = adblocker.LoadFromURL("https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts", "default") 56 | 57 | // Create mock notifier 58 | notifier := &mockNotifier{} 59 | 60 | // Create server config 61 | config := ServerConfig{ 62 | UpstreamServers: []string{"8.8.8.8:53", "1.1.1.1:53"}, 63 | BlockingMode: "zero_ip", 64 | BlockingIP: "0.0.0.0", 65 | CacheSize: 1000, 66 | } 67 | 68 | // Pass notifier and config to NewServer 69 | server := NewServer(adblocker, notifier, config) 70 | addr := fmt.Sprintf(":%d", port) 71 | errChan := make(chan error, 1) 72 | 73 | go func() { 74 | if err := server.Start(addr); err != nil { 75 | errChan <- err 76 | } 77 | }() 78 | 79 | // Wait for server to start 80 | startTimeout := time.After(5 * time.Second) 81 | for { 82 | select { 83 | case err := <-errChan: 84 | t.Fatalf("Server failed to start: %v", err) 85 | case <-startTimeout: 86 | t.Fatal("Server startup timed out") 87 | case <-time.After(100 * time.Millisecond): 88 | if isServerReady(port) { 89 | return server, fmt.Sprintf("127.0.0.1:%d", port), func() { 90 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 91 | defer cancel() 92 | server.Shutdown(ctx) 93 | } 94 | } 95 | } 96 | } 97 | } 98 | 99 | func isServerReady(port int) bool { 100 | c := &dns.Client{ 101 | Timeout: 500 * time.Millisecond, 102 | } 103 | m := new(dns.Msg) 104 | m.SetQuestion("google.com.", dns.TypeA) 105 | 106 | _, _, err := c.Exchange(m, fmt.Sprintf("127.0.0.1:%d", port)) 107 | return err == nil 108 | } 109 | 110 | func TestDNSServer(t *testing.T) { 111 | _, addr, cleanup := setupTestServer(t) 112 | defer cleanup() 113 | 114 | // Configure DNS client with timeout 115 | c := &dns.Client{ 116 | Timeout: 2 * time.Second, 117 | } 118 | 119 | tests := []struct { 120 | name string 121 | domain string 122 | qtype uint16 123 | shouldBlock bool 124 | }{ 125 | {"Known ad domain A", "doubleclick.net.", dns.TypeA, true}, 126 | {"Known ad domain AAAA", "doubleclick.net.", dns.TypeAAAA, true}, 127 | {"Google ads domain", "googleadservices.com.", dns.TypeA, true}, 128 | {"Regular domain", "google.com.", dns.TypeA, false}, 129 | {"Another regular domain", "github.com.", dns.TypeA, false}, 130 | } 131 | 132 | for _, tt := range tests { 133 | t.Run(tt.name, func(t *testing.T) { 134 | m := new(dns.Msg) 135 | m.SetQuestion(tt.domain, tt.qtype) 136 | 137 | // Retry logic for DNS queries 138 | var resp *dns.Msg 139 | var err error 140 | for retries := 3; retries > 0; retries-- { 141 | resp, _, err = c.Exchange(m, addr) 142 | if err == nil { 143 | break 144 | } 145 | time.Sleep(100 * time.Millisecond) 146 | } 147 | 148 | if err != nil { 149 | t.Fatalf("Query failed: %v", err) 150 | } 151 | 152 | if len(resp.Answer) == 0 { 153 | t.Fatal("Expected answer section in response") 154 | } 155 | 156 | switch tt.qtype { 157 | case dns.TypeA: 158 | if a, ok := resp.Answer[0].(*dns.A); ok { 159 | isZeroIP := a.A.Equal(net.IPv4(0, 0, 0, 0)) 160 | if tt.shouldBlock != isZeroIP { 161 | t.Errorf("Expected blocked=%v for %s, got IP=%v", 162 | tt.shouldBlock, tt.domain, a.A) 163 | } 164 | } 165 | case dns.TypeAAAA: 166 | if aaaa, ok := resp.Answer[0].(*dns.AAAA); ok { 167 | isZeroIP := aaaa.AAAA.Equal(net.IPv6zero) 168 | if tt.shouldBlock != isZeroIP { 169 | t.Errorf("Expected blocked=%v for %s, got IP=%v", 170 | tt.shouldBlock, tt.domain, aaaa.AAAA) 171 | } 172 | } 173 | } 174 | }) 175 | } 176 | } 177 | 178 | func TestCaching(t *testing.T) { 179 | server, addr, cleanup := setupTestServer(t) 180 | defer cleanup() 181 | 182 | domain := "example.com." 183 | metrics := server.GetMetrics() 184 | initialMisses := metrics.CacheMisses 185 | 186 | // Make first query 187 | m := new(dns.Msg) 188 | m.SetQuestion(domain, dns.TypeA) 189 | c := new(dns.Client) 190 | 191 | // First query - should miss cache 192 | _, _, err := c.Exchange(m, addr) 193 | if err != nil { 194 | t.Fatalf("First query failed: %v", err) 195 | } 196 | 197 | // Second query - should hit cache 198 | _, _, err = c.Exchange(m, addr) 199 | if err != nil { 200 | t.Fatalf("Second query failed: %v", err) 201 | } 202 | 203 | if metrics.CacheHits != 1 { 204 | t.Errorf("Expected 1 cache hit, got %d", metrics.CacheHits) 205 | } 206 | if metrics.CacheMisses != initialMisses+1 { 207 | t.Errorf("Expected %d cache misses, got %d", initialMisses+1, metrics.CacheMisses) 208 | } 209 | } 210 | 211 | // Update the TestQueryNotifications function too 212 | func TestQueryNotifications(t *testing.T) { 213 | notifier := &mockNotifier{} 214 | adblocker := blocker.New() 215 | _ = adblocker.LoadFromURL("https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts", "default") 216 | 217 | // Create server config 218 | config := ServerConfig{ 219 | UpstreamServers: []string{"8.8.8.8:53", "1.1.1.1:53"}, 220 | BlockingMode: "zero_ip", 221 | BlockingIP: "0.0.0.0", 222 | CacheSize: 1000, 223 | } 224 | 225 | server := NewServer(adblocker, notifier, config) 226 | port, err := findAvailablePort() 227 | if err != nil { 228 | t.Fatalf("Failed to find available port: %v", err) 229 | } 230 | 231 | go server.Start(fmt.Sprintf(":%d", port)) 232 | defer func() { 233 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 234 | defer cancel() 235 | server.Shutdown(ctx) 236 | }() 237 | 238 | // Wait for server to start 239 | time.Sleep(time.Second) 240 | 241 | // Make some test queries 242 | c := &dns.Client{Timeout: 2 * time.Second} 243 | addr := fmt.Sprintf("127.0.0.1:%d", port) 244 | 245 | queries := []struct { 246 | domain string 247 | shouldBlock bool 248 | }{ 249 | {"google.com.", false}, 250 | {"doubleclick.net.", true}, 251 | {"example.com.", false}, 252 | } 253 | 254 | for _, q := range queries { 255 | m := new(dns.Msg) 256 | m.SetQuestion(q.domain, dns.TypeA) 257 | _, _, err := c.Exchange(m, addr) 258 | if err != nil { 259 | t.Fatalf("Query failed for %s: %v", q.domain, err) 260 | } 261 | } 262 | 263 | // Give some time for notifications to be processed 264 | time.Sleep(100 * time.Millisecond) 265 | 266 | // Verify notifications 267 | if len(notifier.queries) != len(queries) { 268 | t.Errorf("Expected %d notifications, got %d", len(queries), len(notifier.queries)) 269 | } 270 | 271 | for i, q := range queries { 272 | if i >= len(notifier.queries) { 273 | break 274 | } 275 | if notifier.queries[i].domain != q.domain { 276 | t.Errorf("Query %d: expected domain %s, got %s", i, q.domain, notifier.queries[i].domain) 277 | } 278 | if notifier.queries[i].clientIP != "127.0.0.1" { 279 | t.Errorf("Query %d: expected client IP 127.0.0.1, got %s", i, notifier.queries[i].clientIP) 280 | } 281 | if notifier.queries[i].blocked != q.shouldBlock { 282 | t.Errorf("Query %d: expected blocked=%v, got blocked=%v", i, q.shouldBlock, notifier.queries[i].blocked) 283 | } 284 | } 285 | } 286 | -------------------------------------------------------------------------------- /internal/api/server.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "context" 5 | "embed" 6 | "encoding/json" 7 | "fmt" 8 | "html/template" 9 | "io/fs" 10 | "log" 11 | "net/http" 12 | "sort" 13 | "sync" 14 | "time" 15 | 16 | "github.com/google/uuid" 17 | "github.com/gorilla/mux" 18 | "github.com/vivek-pk/goadblock/internal/dns" 19 | ) 20 | 21 | //go:embed templates/* 22 | var templateFS embed.FS 23 | 24 | type Query struct { 25 | ID string `json:"id"` 26 | Domain string `json:"domain"` 27 | Blocked bool `json:"blocked"` 28 | Timestamp time.Time `json:"timestamp"` 29 | } 30 | 31 | type HourlyStats struct { 32 | Requests int 33 | Blocks int 34 | } 35 | 36 | type ClientStats struct { 37 | IP string `json:"ip"` 38 | TotalQueries int64 `json:"totalQueries"` 39 | BlockedQueries int64 `json:"blockedQueries"` 40 | LastSeen time.Time `json:"lastSeen"` 41 | } 42 | 43 | type APIServer struct { 44 | dnsServer *dns.Server 45 | port int 46 | startTime time.Time 47 | recentQueries []Query 48 | queriesLock sync.RWMutex 49 | templates *template.Template 50 | server *http.Server 51 | router *mux.Router 52 | hourlyStats [24]HourlyStats 53 | hourlyStatsMu sync.RWMutex 54 | lastHourIndex int 55 | clientStats map[string]*ClientStats 56 | clientStatsMu sync.RWMutex 57 | } 58 | 59 | func NewAPIServer(dnsServer *dns.Server, port int) (*APIServer, error) { 60 | tmpl := InitTemplates() 61 | 62 | server := &APIServer{ 63 | dnsServer: dnsServer, 64 | port: port, 65 | startTime: time.Now(), 66 | recentQueries: make([]Query, 0, 100), 67 | templates: tmpl, 68 | router: mux.NewRouter(), // Initialize the router 69 | clientStats: make(map[string]*ClientStats), 70 | } 71 | 72 | // Call setupRoutes to register all routes 73 | server.setupRoutes() 74 | 75 | // Set up static file serving from embedded files 76 | ServeStaticFiles(server.router) 77 | 78 | return server, nil 79 | } 80 | 81 | // In your API server code 82 | func InitTemplates() *template.Template { 83 | tmpl := template.New("") 84 | 85 | // Parse sidebar template first to make it available to other templates 86 | template.Must(tmpl.ParseFS(embeddedFiles, "templates/sidebar.html")) 87 | 88 | // Then parse all remaining templates 89 | template.Must(tmpl.ParseFS(embeddedFiles, "templates/*.html")) 90 | 91 | return tmpl 92 | } 93 | 94 | // Add method to track queries 95 | func (s *APIServer) AddQuery(domain string, clientIP string, blocked bool) { 96 | s.queriesLock.Lock() 97 | defer s.queriesLock.Unlock() 98 | 99 | query := Query{ 100 | ID: uuid.New().String(), 101 | Domain: domain, 102 | Blocked: blocked, 103 | Timestamp: time.Now(), 104 | } 105 | 106 | // Add to front of slice 107 | s.recentQueries = append([]Query{query}, s.recentQueries...) 108 | 109 | // Keep only last 100 queries 110 | if len(s.recentQueries) > 100 { 111 | s.recentQueries = s.recentQueries[:100] 112 | } 113 | 114 | s.trackQuery(blocked) 115 | s.trackClientQuery(clientIP, blocked) 116 | } 117 | 118 | func (s *APIServer) trackQuery(blocked bool) { 119 | s.hourlyStatsMu.Lock() 120 | defer s.hourlyStatsMu.Unlock() 121 | 122 | currentHour := time.Now().Hour() 123 | if currentHour != s.lastHourIndex { 124 | // Roll over to new hour 125 | s.hourlyStats[currentHour] = HourlyStats{} 126 | s.lastHourIndex = currentHour 127 | } 128 | 129 | s.hourlyStats[currentHour].Requests++ 130 | if blocked { 131 | s.hourlyStats[currentHour].Blocks++ 132 | } 133 | } 134 | 135 | func (s *APIServer) trackClientQuery(ip string, blocked bool) { 136 | s.clientStatsMu.Lock() 137 | defer s.clientStatsMu.Unlock() 138 | 139 | stats, exists := s.clientStats[ip] 140 | if !exists { 141 | stats = &ClientStats{ 142 | IP: ip, 143 | } 144 | s.clientStats[ip] = stats 145 | } 146 | 147 | stats.TotalQueries++ 148 | if blocked { 149 | stats.BlockedQueries++ 150 | } 151 | stats.LastSeen = time.Now() 152 | } 153 | 154 | // Add new handler for queries 155 | func (s *APIServer) handleQueries(w http.ResponseWriter, r *http.Request) { 156 | s.queriesLock.RLock() 157 | defer s.queriesLock.RUnlock() 158 | 159 | w.Header().Set("Content-Type", "application/json") 160 | json.NewEncoder(w).Encode(map[string]interface{}{ 161 | "queries": s.recentQueries, 162 | }) 163 | } 164 | 165 | func (s *APIServer) handleHourlyStats(w http.ResponseWriter, r *http.Request) { 166 | s.hourlyStatsMu.RLock() 167 | defer s.hourlyStatsMu.RUnlock() 168 | 169 | currentHour := time.Now().Hour() 170 | hours := make([]string, 24) 171 | requests := make([]int, 24) 172 | blocks := make([]int, 24) 173 | 174 | for i := 0; i < 24; i++ { 175 | hour := (currentHour - 23 + i + 24) % 24 176 | hours[i] = fmt.Sprintf("%02d:00", hour) 177 | stats := s.hourlyStats[hour] 178 | requests[i] = stats.Requests 179 | blocks[i] = stats.Blocks 180 | } 181 | 182 | response := map[string]interface{}{ 183 | "hours": hours, 184 | "requests": requests, 185 | "blocks": blocks, 186 | } 187 | 188 | w.Header().Set("Content-Type", "application/json") 189 | json.NewEncoder(w).Encode(response) 190 | } 191 | 192 | func (s *APIServer) handleClients(w http.ResponseWriter, r *http.Request) { 193 | s.clientStatsMu.RLock() 194 | defer s.clientStatsMu.RUnlock() 195 | 196 | clients := make([]*ClientStats, 0, len(s.clientStats)) 197 | for _, stats := range s.clientStats { 198 | clients = append(clients, stats) 199 | } 200 | 201 | // Sort by last seen, most recent first 202 | sort.Slice(clients, func(i, j int) bool { 203 | return clients[i].LastSeen.After(clients[j].LastSeen) 204 | }) 205 | 206 | w.Header().Set("Content-Type", "application/json") 207 | json.NewEncoder(w).Encode(map[string]interface{}{ 208 | "clients": clients, 209 | }) 210 | } 211 | 212 | func (s *APIServer) Start() error { 213 | s.server = &http.Server{ 214 | Addr: fmt.Sprintf(":%d", s.port), 215 | Handler: s.router, // Use the router 216 | ReadTimeout: 10 * time.Second, 217 | WriteTimeout: 10 * time.Second, 218 | } 219 | 220 | return s.server.ListenAndServe() 221 | } 222 | 223 | func (s *APIServer) setupRoutes() { 224 | // Add this debug handler first to log incoming requests 225 | s.router.Use(func(next http.Handler) http.Handler { 226 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 227 | log.Printf("Request: %s %s", r.Method, r.URL.Path) 228 | next.ServeHTTP(w, r) 229 | }) 230 | }) 231 | 232 | // IMPORTANT: Register static file handler BEFORE other routes 233 | staticFS, err := fs.Sub(embeddedFiles, "static") 234 | if err != nil { 235 | log.Fatalf("Failed to create sub-filesystem for static files: %v", err) 236 | } 237 | fileServer := http.FileServer(http.FS(staticFS)) 238 | s.router.PathPrefix("/static/").Handler(http.StripPrefix("/static/", fileServer)) 239 | 240 | // Then add your API and other routes 241 | s.router.HandleFunc("/", s.handleDashboard).Methods("GET") 242 | s.router.HandleFunc("/blocklists", s.handleBlocklistsPage).Methods("GET") 243 | s.router.HandleFunc("/settings", s.handleSettingsPage).Methods("GET") 244 | s.router.HandleFunc("/about", s.handleAboutPage).Methods("GET") 245 | 246 | // API endpoints 247 | s.router.HandleFunc("/api/v1/metrics", s.handleMetrics).Methods("GET") 248 | s.router.HandleFunc("/api/v1/status", s.handleStatus).Methods("GET") 249 | s.router.HandleFunc("/api/v1/queries", s.handleQueries).Methods("GET") 250 | s.router.HandleFunc("/api/v1/stats/hourly", s.handleHourlyStats).Methods("GET") 251 | s.router.HandleFunc("/api/v1/clients", s.handleClients).Methods("GET") 252 | 253 | // Blocklist management routes 254 | s.router.HandleFunc("/api/v1/blocklists", s.handleGetBlocklists).Methods("GET") 255 | s.router.HandleFunc("/api/v1/blocklist/domain", s.handleAddDomainToBlocklist).Methods("POST") 256 | s.router.HandleFunc("/api/v1/blocklist/domain", s.handleRemoveDomainFromBlocklist).Methods("DELETE") 257 | 258 | // Whitelist management routes 259 | s.router.HandleFunc("/api/v1/whitelist", s.handleGetWhitelist).Methods("GET") 260 | s.router.HandleFunc("/api/v1/whitelist", s.handleAddToWhitelist).Methods("POST") 261 | s.router.HandleFunc("/api/v1/whitelist", s.handleRemoveFromWhitelist).Methods("DELETE") 262 | 263 | // Regex pattern routes 264 | s.router.HandleFunc("/api/v1/regex", s.handleGetRegexPatterns).Methods("GET") 265 | s.router.HandleFunc("/api/v1/regex", s.handleAddRegexPattern).Methods("POST") 266 | s.router.HandleFunc("/api/v1/regex", s.handleRemoveRegexPattern).Methods("DELETE") 267 | 268 | // Add static file serving 269 | fs := http.FileServer(http.Dir("./internal/api/static")) 270 | s.router.PathPrefix("/static/").Handler(http.StripPrefix("/static/", fs)) 271 | } 272 | 273 | func (s *APIServer) Shutdown(ctx context.Context) error { 274 | return s.server.Shutdown(ctx) 275 | } 276 | 277 | func (s *APIServer) handleDashboard(w http.ResponseWriter, r *http.Request) { 278 | s.templates.ExecuteTemplate(w, "dashboard.html", nil) 279 | } 280 | 281 | func (s *APIServer) handleMetrics(w http.ResponseWriter, r *http.Request) { 282 | metrics := s.dnsServer.GetMetrics() 283 | response := map[string]interface{}{ 284 | "totalQueries": metrics.TotalQueries, 285 | "blockedQueries": metrics.BlockedQueries, 286 | "cacheHits": metrics.CacheHits, 287 | "cacheMisses": metrics.CacheMisses, 288 | } 289 | 290 | w.Header().Set("Content-Type", "application/json") 291 | if err := json.NewEncoder(w).Encode(response); err != nil { 292 | http.Error(w, "Failed to encode metrics", http.StatusInternalServerError) 293 | return 294 | } 295 | } 296 | 297 | func (s *APIServer) handleStatus(w http.ResponseWriter, r *http.Request) { 298 | status := map[string]interface{}{ 299 | "status": "running", 300 | "uptime": time.Since(s.startTime).String(), 301 | } 302 | w.Header().Set("Content-Type", "application/json") 303 | json.NewEncoder(w).Encode(status) 304 | } 305 | 306 | // Add SetDNSServer method 307 | func (s *APIServer) SetDNSServer(server *dns.Server) { 308 | s.dnsServer = server 309 | } 310 | 311 | // Add handler functions for each page 312 | func (s *APIServer) handleBlocklistsPage(w http.ResponseWriter, r *http.Request) { 313 | s.templates.ExecuteTemplate(w, "blocklists.html", nil) 314 | } 315 | 316 | func (s *APIServer) handleSettingsPage(w http.ResponseWriter, r *http.Request) { 317 | s.templates.ExecuteTemplate(w, "settings.html", nil) 318 | } 319 | 320 | func (s *APIServer) handleAboutPage(w http.ResponseWriter, r *http.Request) { 321 | s.templates.ExecuteTemplate(w, "about.html", nil) 322 | } 323 | -------------------------------------------------------------------------------- /internal/api/templates/sidebar.html: -------------------------------------------------------------------------------- 1 | 2 | 203 | 204 | 205 | 368 | -------------------------------------------------------------------------------- /internal/api/static/js/dashboard.js: -------------------------------------------------------------------------------- 1 | // Tailwind configuration 2 | tailwind.config = { 3 | theme: { 4 | extend: { 5 | colors: { 6 | 'tva': { 7 | 'amber': '#FF8B28', 8 | 'orange': '#FF6B00', 9 | 'brown': '#794B28', 10 | 'dark': '#251F17', 11 | 'tan': '#D6BC97', 12 | 'cream': '#F5E9D7', 13 | 'black': '#1A1512', 14 | }, 15 | }, 16 | fontFamily: { 17 | 'serif': ['"DM Serif Display"', 'serif'], 18 | 'mono': ['"B612 Mono"', 'monospace'], 19 | }, 20 | }, 21 | }, 22 | }; 23 | 24 | // Main dashboard functionality 25 | function dashboard() { 26 | // Define startTime outside the return object 27 | const startTime = new Date(); 28 | let chart = null; 29 | 30 | // Get saved theme first thing 31 | const savedTheme = localStorage.getItem('theme') || 'tva'; 32 | 33 | // Apply theme to body immediately (don't wait for Alpine) 34 | document.body.classList.add(`theme-${savedTheme}`); 35 | 36 | return { 37 | status: 'running', 38 | metrics: { 39 | totalQueries: 0, 40 | blockedQueries: 0, 41 | cacheHits: 0, 42 | cacheMisses: 0, 43 | }, 44 | recentQueries: [], 45 | hourlyStats: { 46 | labels: [], 47 | requests: [], 48 | blocks: [], 49 | }, 50 | clientStats: [], 51 | currentPage: 'dashboard', 52 | currentTheme: savedTheme, 53 | uptimeTick: 0, // Property for Alpine to track 54 | 55 | init() { 56 | // Apply theme on initialization 57 | this.applyTheme(); 58 | 59 | this.initChart(); 60 | this.fetchData(); 61 | 62 | // Update data every 2 seconds 63 | setInterval(() => this.fetchData(), 2000); 64 | 65 | // Add a dedicated timer for updating the uptime display every second 66 | setInterval(() => { 67 | this.uptimeTick++; // This will trigger Alpine to re-evaluate the formatUptime() 68 | }, 1000); 69 | 70 | // Determine current page from URL 71 | const path = window.location.pathname; 72 | if (path.includes('/blocklists')) { 73 | this.currentPage = 'blocklists'; 74 | } else if (path.includes('/settings')) { 75 | this.currentPage = 'settings'; 76 | } else if (path.includes('/about')) { 77 | this.currentPage = 'about'; 78 | } else { 79 | this.currentPage = 'dashboard'; 80 | } 81 | }, 82 | 83 | toggleTheme() { 84 | // Change theme with transition 85 | document.body.classList.add('theme-transitioning'); 86 | 87 | setTimeout(() => { 88 | this.currentTheme = this.currentTheme === 'tva' ? 'cockpit' : 'tva'; 89 | localStorage.setItem('theme', this.currentTheme); 90 | 91 | // Apply the theme 92 | document.body.classList.remove('theme-tva', 'theme-cockpit'); 93 | document.body.classList.add(`theme-${this.currentTheme}`); 94 | 95 | // Recreate chart with new theme 96 | if (this.currentPage === 'dashboard' && chart) { 97 | chart.destroy(); 98 | this.initChart(); 99 | } 100 | 101 | // After theme is applied, remove the transitioning class 102 | setTimeout(() => { 103 | document.body.classList.remove('theme-transitioning'); 104 | }, 500); 105 | }, 100); 106 | }, 107 | 108 | applyTheme() { 109 | document.body.classList.remove('theme-tva', 'theme-cockpit'); 110 | document.body.classList.add(`theme-${this.currentTheme}`); 111 | }, 112 | 113 | formatUptime() { 114 | const now = new Date(); 115 | const diff = now - startTime; // Use the constant from closure 116 | 117 | const days = Math.floor(diff / 86400000); // days 118 | const hours = Math.floor((diff % 86400000) / 3600000); // hours 119 | const minutes = Math.floor((diff % 3600000) / 60000); // minutes 120 | const seconds = Math.floor((diff % 60000) / 1000); // seconds 121 | 122 | let uptimeString = ''; 123 | 124 | if (days > 0) { 125 | uptimeString += `${days}D `; 126 | } 127 | 128 | return ( 129 | uptimeString + 130 | `${hours.toString().padStart(2, '0')}:${minutes 131 | .toString() 132 | .padStart(2, '0')}:${seconds.toString().padStart(2, '0')}` 133 | ); 134 | }, 135 | 136 | getUptimePercentage() { 137 | const now = new Date(); 138 | const diff = now - startTime; // Use the constant from closure 139 | const minutesPassed = Math.floor((diff % 3600000) / 60000); // minutes within current hour 140 | return (minutesPassed / 60) * 100; 141 | }, 142 | 143 | initChart() { 144 | if (!window.Chart) { 145 | console.error('Chart.js not loaded'); 146 | return; 147 | } 148 | 149 | const ctx = document.getElementById('statsChart').getContext('2d'); 150 | if (!ctx) { 151 | console.error('Could not find stats chart context'); 152 | return; 153 | } 154 | 155 | // Set colors based on current theme 156 | let primaryColor, secondaryColor; 157 | let primaryGradient, secondaryGradient; 158 | let chartBackgroundColor; 159 | 160 | if (this.currentTheme === 'tva') { 161 | primaryColor = '#FF6B00'; 162 | secondaryColor = '#794B28'; 163 | chartBackgroundColor = '#1A1512'; // Dark background for TVA theme 164 | 165 | // Custom gradient for the sacred timeline 166 | primaryGradient = ctx.createLinearGradient(0, 0, 0, 300); 167 | primaryGradient.addColorStop(0, 'rgba(255, 107, 0, 0.8)'); 168 | primaryGradient.addColorStop(1, 'rgba(255, 139, 40, 0.3)'); 169 | 170 | // Custom gradient for the variant timeline 171 | secondaryGradient = ctx.createLinearGradient(0, 0, 0, 300); 172 | secondaryGradient.addColorStop(0, 'rgba(121, 75, 40, 0.8)'); 173 | secondaryGradient.addColorStop(1, 'rgba(121, 75, 40, 0.3)'); 174 | } else { 175 | // Cockpit theme 176 | primaryColor = '#10B981'; 177 | secondaryColor = '#38BDF8'; 178 | chartBackgroundColor = '#051826'; // Already dark for cockpit theme 179 | 180 | // Custom gradient for cockpit primary line 181 | primaryGradient = ctx.createLinearGradient(0, 0, 0, 300); 182 | primaryGradient.addColorStop(0, 'rgba(16, 185, 129, 0.8)'); 183 | primaryGradient.addColorStop(1, 'rgba(16, 185, 129, 0.2)'); 184 | 185 | // Custom gradient for cockpit secondary line 186 | secondaryGradient = ctx.createLinearGradient(0, 0, 0, 300); 187 | secondaryGradient.addColorStop(0, 'rgba(56, 189, 248, 0.8)'); 188 | secondaryGradient.addColorStop(1, 'rgba(56, 189, 248, 0.2)'); 189 | } 190 | 191 | Chart.defaults.color = 192 | this.currentTheme === 'tva' ? '#794B28' : '#e0f2f1'; 193 | Chart.defaults.font.family = "'B612 Mono', monospace"; 194 | 195 | const options = { 196 | responsive: true, 197 | maintainAspectRatio: false, 198 | animation: { 199 | duration: 1500, 200 | easing: 'easeOutQuart', 201 | }, 202 | plugins: { 203 | legend: { 204 | position: 'top', 205 | labels: { 206 | boxWidth: 15, 207 | usePointStyle: true, 208 | pointStyle: 'rectRot', 209 | padding: 20, 210 | font: { 211 | family: "'B612 Mono', monospace", 212 | size: 12, 213 | }, 214 | color: this.currentTheme === 'tva' ? '#794B28' : '#e0f2f1', 215 | }, 216 | }, 217 | tooltip: { 218 | backgroundColor: 219 | this.currentTheme === 'tva' 220 | ? 'rgba(214, 188, 151, 0.9)' 221 | : 'rgba(16, 185, 129, 0.8)', 222 | titleFont: { 223 | family: "'DM Serif Display', serif", 224 | size: 14, 225 | weight: 'bold', 226 | }, 227 | bodyFont: { 228 | family: "'B612 Mono', monospace", 229 | size: 12, 230 | }, 231 | borderColor: primaryColor, 232 | borderWidth: 2, 233 | titleColor: this.currentTheme === 'tva' ? '#251F17' : '#e0f2f1', 234 | bodyColor: this.currentTheme === 'tva' ? '#251F17' : '#e0f2f1', 235 | padding: 12, 236 | boxPadding: 5, 237 | displayColors: true, 238 | callbacks: { 239 | title: function (tooltipItems) { 240 | return ( 241 | (this.currentTheme === 'tva' 242 | ? 'TIMELINE POINT: ' 243 | : 'SIGNAL DATA: ') + tooltipItems[0].label 244 | ); 245 | }.bind(this), 246 | }, 247 | }, 248 | }, 249 | scales: { 250 | y: { 251 | beginAtZero: true, 252 | ticks: { 253 | font: { 254 | family: "'B612 Mono', monospace", 255 | size: 10, 256 | }, 257 | color: this.currentTheme === 'tva' ? '#D6BC97' : '#e0f2f1', // Lighter color for better contrast 258 | }, 259 | grid: { 260 | color: 261 | this.currentTheme === 'tva' 262 | ? 'rgba(121, 75, 40, 0.15)' 263 | : 'rgba(16, 185, 129, 0.15)', 264 | drawBorder: false, 265 | }, 266 | title: { 267 | display: true, 268 | text: 269 | this.currentTheme === 'tva' 270 | ? 'TIMELINE MAGNITUDE' 271 | : 'SIGNAL STRENGTH', 272 | font: { 273 | family: "'B612 Mono', monospace", 274 | size: 10, 275 | weight: 'bold', 276 | }, 277 | color: this.currentTheme === 'tva' ? '#D6BC97' : '#e0f2f1', // Lighter color for better contrast 278 | }, 279 | }, 280 | x: { 281 | grid: { 282 | color: 283 | this.currentTheme === 'tva' 284 | ? 'rgba(121, 75, 40, 0.15)' 285 | : 'rgba(16, 185, 129, 0.1)', 286 | drawBorder: false, 287 | }, 288 | ticks: { 289 | font: { 290 | family: "'B612 Mono', monospace", 291 | size: 10, 292 | }, 293 | color: this.currentTheme === 'tva' ? '#D6BC97' : '#e0f2f1', // Lighter color for better contrast 294 | }, 295 | title: { 296 | display: true, 297 | text: 298 | this.currentTheme === 'tva' 299 | ? 'TEMPORAL COORDINATES' 300 | : 'SURVEILLANCE INTERVAL', 301 | font: { 302 | family: "'B612 Mono', monospace", 303 | size: 10, 304 | weight: 'bold', 305 | }, 306 | color: this.currentTheme === 'tva' ? '#D6BC97' : '#e0f2f1', // Lighter color for better contrast 307 | }, 308 | }, 309 | }, 310 | elements: { 311 | line: { 312 | borderWidth: 3, 313 | borderCapStyle: 'round', 314 | }, 315 | point: { 316 | hitRadius: 10, 317 | hoverRadius: 8, 318 | hoverBorderWidth: 2, 319 | }, 320 | }, 321 | }; 322 | 323 | // Set darker grid lines for cockpit theme 324 | if (this.currentTheme === 'cockpit') { 325 | options.scales.y.grid.color = 'rgba(16, 185, 129, 0.1)'; 326 | options.scales.x.grid.color = 'rgba(16, 185, 129, 0.1)'; 327 | options.scales.y.ticks.color = '#10b981'; 328 | options.scales.x.ticks.color = '#10b981'; 329 | } 330 | 331 | chart = new Chart(ctx, { 332 | type: 'line', 333 | data: { 334 | labels: [], 335 | datasets: [ 336 | { 337 | label: 338 | this.currentTheme === 'tva' 339 | ? 'Sacred Timeline' 340 | : 'Primary Signals', 341 | borderColor: primaryColor, 342 | backgroundColor: primaryGradient, 343 | borderWidth: 3, 344 | pointBackgroundColor: primaryColor, 345 | pointBorderColor: 346 | this.currentTheme === 'tva' ? '#1A1512' : '#0a0e17', 347 | pointRadius: 6, 348 | pointHoverRadius: 8, 349 | data: [], 350 | fill: true, 351 | tension: 0.2, 352 | pointStyle: this.currentTheme === 'tva' ? 'rectRot' : 'circle', 353 | borderDash: [], 354 | }, 355 | { 356 | label: 357 | this.currentTheme === 'tva' 358 | ? 'Variant Branches' 359 | : 'Secondary Signals', 360 | borderColor: secondaryColor, 361 | backgroundColor: secondaryGradient, 362 | borderWidth: 2, 363 | pointBackgroundColor: secondaryColor, 364 | pointBorderColor: 365 | this.currentTheme === 'tva' ? '#1A1512' : '#0a0e17', 366 | pointRadius: 6, 367 | pointHoverRadius: 8, 368 | data: [], 369 | fill: true, 370 | tension: 0.2, 371 | borderDash: [5, 5], 372 | pointStyle: this.currentTheme === 'tva' ? 'rectRot' : 'triangle', 373 | }, 374 | ], 375 | options: options, 376 | }, 377 | }); 378 | 379 | // Also update the chart container CSS 380 | const chartContainer = document.querySelector('.chart-container'); 381 | if (chartContainer) { 382 | if (this.currentTheme === 'tva') { 383 | chartContainer.style.backgroundColor = '#1A1512'; // Dark background for TVA theme 384 | chartContainer.style.border = '1px solid #794B28'; 385 | } else { 386 | chartContainer.style.backgroundColor = '#051826'; // Dark for cockpit theme 387 | chartContainer.style.border = '1px solid #38bdf8'; 388 | } 389 | } 390 | 391 | // Add chart annotation line 392 | try { 393 | if (chartContainer) { 394 | // Remove existing timeline divider if any 395 | const existingDivider = 396 | chartContainer.querySelector('.timeline-divider'); 397 | if (existingDivider) { 398 | existingDivider.remove(); 399 | } 400 | 401 | const timelineDivider = document.createElement('div'); 402 | timelineDivider.className = 'timeline-divider'; 403 | chartContainer.appendChild(timelineDivider); 404 | } 405 | } catch (e) { 406 | console.error('Could not add timeline divider:', e); 407 | } 408 | }, 409 | 410 | async fetchData() { 411 | try { 412 | // Fetch metrics 413 | const metricsResponse = await fetch('/api/v1/metrics'); 414 | if (!metricsResponse.ok) { 415 | throw new Error('Metrics fetch failed'); 416 | } 417 | const metricsData = await metricsResponse.json(); 418 | 419 | // Update metrics 420 | this.metrics = { 421 | totalQueries: metricsData.totalQueries || 0, 422 | blockedQueries: metricsData.blockedQueries || 0, 423 | cacheHits: metricsData.cacheHits || 0, 424 | cacheMisses: metricsData.cacheMisses || 0, 425 | }; 426 | 427 | // Fetch recent queries 428 | const queriesResponse = await fetch('/api/v1/queries'); 429 | if (!queriesResponse.ok) { 430 | throw new Error('Queries fetch failed'); 431 | } 432 | const queriesData = await queriesResponse.json(); 433 | this.recentQueries = (queriesData.queries || []).map((q) => ({ 434 | ...q, 435 | time: new Date(q.timestamp).toLocaleTimeString(), 436 | })); 437 | 438 | // Update status 439 | const statusResponse = await fetch('/api/v1/status'); 440 | if (!statusResponse.ok) { 441 | throw new Error('Status fetch failed'); 442 | } 443 | const statusData = await statusResponse.json(); 444 | this.status = statusData.status; 445 | 446 | // Fetch hourly stats 447 | const statsResponse = await fetch('/api/v1/stats/hourly'); 448 | if (!statsResponse.ok) { 449 | throw new Error('Hourly stats fetch failed'); 450 | } 451 | const statsData = await statsResponse.json(); 452 | 453 | // Fetch client stats 454 | const clientStatsResponse = await fetch('/api/v1/clients'); 455 | if (!clientStatsResponse.ok) { 456 | throw new Error('Client stats fetch failed'); 457 | } 458 | const clientStatsData = await clientStatsResponse.json(); 459 | this.clientStats = clientStatsData.clients.map((client) => ({ 460 | ...client, 461 | lastSeen: new Date(client.lastSeen).toLocaleString(), 462 | })); 463 | 464 | // Update chart 465 | if (chart) { 466 | chart.data.labels = statsData.hours; 467 | chart.data.datasets[0].data = statsData.requests; 468 | chart.data.datasets[1].data = statsData.blocks; 469 | chart.update(); 470 | } 471 | } catch (error) { 472 | console.error('Failed to fetch data:', error); 473 | this.status = 'stopped'; 474 | } 475 | }, 476 | }; 477 | } 478 | -------------------------------------------------------------------------------- /internal/api/templates/dashboard.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | TVA Temporal Defense Interface 7 | 8 | 12 | 13 | 17 | 18 | 22 | 23 | 784 | 785 | 786 | 787 |
788 | 789 | {{template "sidebar.html" .}} 790 | 791 | 792 |
793 | 794 |
795 | 796 |
800 |
801 |

802 | Variant Detection Center 803 |

804 |

805 | ACTIVE MONITORING: DOMAIN NEXUS BRANCH-38F 806 |

807 |
808 | 809 |
812 |
813 | TIMELINE INTEGRITY: 814 |
815 |
816 | 820 | 821 |
822 |
823 |
824 | 825 | 826 |
830 |
831 |

834 | Defense System 835 |

836 |

837 | Active Surveillance: Monitor Alpha-3 838 |

839 |
840 | 841 |
842 |
843 |
LOCAL TIME
844 |
848 |
849 | 850 |
851 |
STATUS
852 |
857 |
858 |
859 |
860 | 861 | 862 |
863 |
866 | TIMESTAMP: 867 |
868 |
869 | OPERATION: 870 | TIMELINE DEFENSE PROTOCOL 871 |
872 |
873 | 874 | 875 |
878 |
879 |
880 |
881 |
882 |
883 | 890 | 896 | 897 |
898 |
899 |

900 | Total Nexus Events 901 |

902 |

906 |
907 |
908 |
909 |
910 | 911 |
912 |
913 |
914 |
915 |
916 | 923 | 929 | 930 |
931 |
932 |

933 | Pruned Branches 934 |

935 |

939 |
940 |
941 |
942 |
943 | 944 |
945 |
946 |
947 |
948 |
949 | 956 | 962 | 963 |
964 |
965 |

966 | Archive Retrievals 967 |

968 |

972 |
973 |
974 |
975 |
976 | 977 |
978 |
979 |
980 |
981 |
982 | 989 | 995 | 996 |
997 |
998 |

999 | Pruning Rate 1000 |

1001 |

1005 |
1006 |
1007 |
1008 |
1009 |
1010 | 1011 | 1012 |
1015 |
1016 |
1017 |
Total Requests
1018 |
1019 | 1020 |
1021 |
1022 |
Threats Blocked
1023 |
1024 | 1025 |
1026 |
1027 |
Cache Retrieval
1028 |
1029 | 1030 |
1031 |
1035 |
Protection Rate
1036 |
1037 |
1038 | 1039 | 1040 |
1043 |
1046 |

TIMELINE BRANCH ANALYSIS

1047 |
1050 | CLASSIFIED 1051 |
1052 |
1053 |
1054 |
1055 | 1056 | 1057 |
1058 |
1059 |
1060 | 1061 | 1062 |
1063 |
1064 | THREAT DETECTION RADAR 1065 |
1066 |
1067 | 1068 |
1069 |
1070 | 1071 | 1072 |
1073 | 1074 |
1077 |
1078 |

SUBJECT REGISTRY

1079 |
1080 |
1081 | 1082 | 1083 | 1084 | 1090 | 1096 | 1102 | 1108 | 1109 | 1110 | 1113 | 1135 | 1136 |
1088 | IP IDENTIFIER 1089 | 1094 | EVENTS 1095 | 1100 | PRUNED 1101 | 1106 | LAST DETECTION 1107 |
1137 |
1138 |
1139 | 1140 | 1141 |
1144 |
1145 |

NEXUS EVENT LOG

1146 |
1147 |
1148 | 1149 | 1150 | 1151 | 1157 | 1163 | 1169 | 1170 | 1171 | 1174 | 1195 | 1196 |
1155 | DOMAIN 1156 | 1161 | STATUS 1162 | 1167 | TIMESTAMP 1168 |
1197 |
1198 |
1199 | 1200 | 1201 |
1202 |
1203 | NETWORK SUBJECTS 1204 |
1205 |
1206 | 1207 | 1208 | 1209 | 1210 | 1211 | 1212 | 1213 | 1214 | 1215 | 1216 | 1224 | 1225 |
IP ADDRESSREQUESTSBLOCKSLAST SEEN
1226 |
1227 |
1228 | 1229 |
1230 |
1231 | SECURITY LOG 1232 |
1233 |
1234 | 1235 | 1236 | 1237 | 1238 | 1239 | 1240 | 1241 | 1242 | 1243 | 1256 | 1257 |
DOMAINSTATUSTIME
1258 |
1259 |
1260 |
1261 | 1262 | 1263 | 1264 |
1267 |
1268 |
1271 |
1272 |
1273 |
1274 | SYSTEM OPERATIONAL TIME 1275 |
1276 |
1277 | 1281 | 1284 |
1285 |
1286 | 1301 |
1302 |
1303 | 1304 | 1307 |
1308 |
1309 | 1310 | 1311 |
1314 |
1315 |
1316 |
UPTIME
1317 |
1318 |
1319 | 1320 | 1325 |
1326 |
1327 |
1328 | 1329 | 1330 |
1331 |
1334 |

1335 | Blocklist Management 1336 |

1337 | 1338 |
1339 | 1340 | 1341 |
1342 | 1343 |
1344 |
1345 | 1346 | 1347 |
1348 | 1349 |
1350 | 1351 | 1352 |
1353 | 1354 |
1355 |
1356 |
1357 | 1358 | 1359 | 1360 | 1367 | 1368 | 1369 | --------------------------------------------------------------------------------