Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,10 @@ node_modules
frontend/dist
application-logs.txt
Custos*.deb
custos.db
custos.db

target
.custos/
Custos*
custos-*.deb
custos-*.rpm
293 changes: 286 additions & 7 deletions app.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
package main

import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"

"github.com/vkhangstack/Custos/internal/system"
Expand All @@ -34,6 +39,8 @@ type App struct {
proxyServer *proxy.Server
dnsServer *dns.Server
systemTracker *system.Tracker
blocklist *core.BlocklistManager
refreshMu sync.Mutex
}

// NewApp creates a new App application struct
Expand Down Expand Up @@ -79,6 +86,7 @@ func NewApp() *App {
proxyServer: proxy.NewServer(s, bm, systemTracker, port),
dnsServer: dns.NewServer(s, bm, 5353),
systemTracker: systemTracker,
blocklist: bm,
}
}

Expand All @@ -96,7 +104,6 @@ func (a *App) startup(ctx context.Context) {
// fmt.Printf("Failed to set system proxy on startup: %v\n", err)
// }

// Restore Protection State
if enabled := a.GetProtectionStatus(); enabled {
a.proxyServer.SetProtection(true)
a.SetSystemProxy(true)
Expand All @@ -105,6 +112,19 @@ func (a *App) startup(ctx context.Context) {
a.SetSystemProxy(false)
}

// Restore Adblock State
if enabled := a.GetAdblockStatus(); enabled {
a.proxyServer.SetAdblockEnabled(true)
} else {
a.proxyServer.SetAdblockEnabled(false)
}

// Seed and Refresh Filters
go func() {
a.seedFilters()
a.RefreshAdblockFilters()
}()

// Start a ticker to emit logs to frontend
go a.broadcastLogs()
}
Expand All @@ -116,6 +136,7 @@ func (a *App) shutdown(ctx context.Context) {
fmt.Printf("Failed to disable system proxy on shutdown: %v\n", err)
}
a.proxyServer.Stop()
ctx.Done()
Copy link

Copilot AI Dec 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Calling ctx.Done() has no effect as it only returns a channel. If the intention is to cancel the context or wait for cancellation, this line should be removed or replaced with appropriate context cancellation logic.

Suggested change
ctx.Done()

Copilot uses AI. Check for mistakes.
}

// broadcastLogs sends new logs to frontend events
Expand All @@ -133,6 +154,20 @@ func (a *App) GetLogs() []core.LogEntry {
return a.store.GetRecentLogs(50)
}

// GetLogsPaginated returns paginated logs for the frontend
func (a *App) GetLogsPaginated(cursor string, limit int, search, status, logType string) core.PaginatedLogs {
logs, nextCursor, hasMore, total, err := a.store.GetLogsPaginated(cursor, limit, search, status, logType)
if err != nil {
return core.PaginatedLogs{Logs: []core.LogEntry{}, Total: 0}
}
return core.PaginatedLogs{
Logs: logs,
NextCursor: nextCursor,
HasMore: hasMore,
Total: total,
}
}

// GetStats returns current stats
func (a *App) GetStats() core.Stats {
return a.store.GetStats()
Expand Down Expand Up @@ -174,6 +209,27 @@ func (a *App) GetProtectionStatus() bool {
return val == "true"
}

// EnableAdblock toggles the adblock engine
func (a *App) EnableAdblock(enabled bool) {
a.proxyServer.SetAdblockEnabled(enabled)
// Persist
val := "false"
if enabled {
val = "true"
}
a.store.SetSetting("adblock_enabled", val)
}

// GetAdblockStatus returns the current status
func (a *App) GetAdblockStatus() bool {
val, err := a.store.GetSetting("adblock_enabled")
if err != nil || val == "" {
// Default to enabled if not set
return true
}
return val == "true"
}

// GetChartData returns historical traffic data for the chart
func (a *App) GetChartData(durationStr string) []core.TrafficDataPoint {
// Parse duration
Expand Down Expand Up @@ -279,9 +335,10 @@ func (a *App) GetAppInfo() *AppInfo {

// AppSettings defines configurable settings
type AppSettings struct {
Port int `json:"port"`
Notifications bool `json:"notifications"`
AutoStart bool `json:"auto_start"`
Port int `json:"port"`
Notifications bool `json:"notifications"`
AutoStart bool `json:"auto_start"`
AdblockEnabled bool `json:"adblock_enabled"`
}

// GetAppSettings returns current settings
Expand All @@ -299,10 +356,14 @@ func (a *App) GetAppSettings() AppSettings {
// AutoStart
autoStart := a.GetStartupStatus()

// Adblock
adblockEnabled := a.GetAdblockStatus()

return AppSettings{
Port: port,
Notifications: notifications,
AutoStart: autoStart,
Port: port,
Notifications: notifications,
AutoStart: autoStart,
AdblockEnabled: adblockEnabled,
}
}

Expand Down Expand Up @@ -337,5 +398,223 @@ func (a *App) SaveAppSettings(settings AppSettings) error {
}
}

// Adblock
a.EnableAdblock(settings.AdblockEnabled)

return nil
}

// Adblock Filter Management

func (a *App) GetAdblockFilters() []core.AdblockFilter {
return a.store.GetAdblockFilters()
}

func (a *App) AddAdblockFilter(name, url string) error {
filter := core.AdblockFilter{
ID: utils.GenerateIDString(),
Name: name,
URL: url,
Enabled: true,
}
err := a.store.AddAdblockFilter(filter)
if err == nil {
go a.RefreshAdblockFilters()
}
return err
}

func (a *App) DeleteAdblockFilter(id string) error {
err := a.store.DeleteAdblockFilter(id)
if err == nil {
go a.RefreshAdblockFilters()
}
return err
}

func (a *App) ToggleAdblockFilter(id string, enabled bool) error {
filters := a.store.GetAdblockFilters()
for _, f := range filters {
if f.ID == id {
f.Enabled = enabled
err := a.store.UpdateAdblockFilter(f)
if err == nil {
go a.RefreshAdblockFilters()
}
return err
}
}
return fmt.Errorf("filter not found")
}

func (a *App) RefreshAdblockFilters() error {
a.refreshMu.Lock()
defer a.refreshMu.Unlock()

filters := a.store.GetAdblockFilters()
var allRules strings.Builder
var blocklistSources []string
// Always include the default hosts list as a base for the blocklist
blocklistSources = append(blocklistSources, "https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts")

// Default hardcoded rules for adblock engine
allRules.WriteString(`||ads.google.com^
||doubleclick.net^
||adnxs.com^
||googleadservices.com^
||pagead2.googlesyndication.com^
||analytics.google.com^
||facebook.com/tr/^
`)

for _, f := range filters {
if !f.Enabled {
continue
}

// Add to blocklist sources
homeDir, _ := os.UserHomeDir()
filterDir := filepath.Join(homeDir, ".custos", "filters")
filePath := filepath.Join(filterDir, f.ID+".txt")

if _, err := os.Stat(filePath); err == nil {
blocklistSources = append(blocklistSources, filePath)
} else if f.URL != "" {
blocklistSources = append(blocklistSources, f.URL)
}

content, err := a.getFilterContent(f)
if err == nil {
allRules.WriteString("\n")
allRules.WriteString(a.normalizeFilterRules(content))
}
}

// Update and reload adblock engine
a.proxyServer.ReloadAdblockEngine(allRules.String())

// Update and reload blocklist
if a.blocklist != nil {
a.blocklist.SetSources(blocklistSources)
a.blocklist.Load()
}

return nil
}

func (a *App) getFilterContent(f core.AdblockFilter) (string, error) {
homeDir, _ := os.UserHomeDir()
filterDir := filepath.Join(homeDir, ".custos", "filters")
os.MkdirAll(filterDir, 0755)
filePath := filepath.Join(filterDir, f.ID+".txt")

if f.URL == "" {
return "", nil
}

// Check if file exists and is less than 24h old
info, err := os.Stat(filePath)
if err == nil && time.Since(info.ModTime()) < 24*time.Hour {
content, err := os.ReadFile(filePath)
if err == nil {
return string(content), nil
}
}

// Download
log.Printf("Downloading adblock filter: %s from %s", f.Name, f.URL)
resp, err := http.Get(f.URL)
if err != nil {
return "", err
}
defer resp.Body.Close()

content, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}

os.WriteFile(filePath, content, 0644)

f.LastUpdated = time.Now()
a.store.UpdateAdblockFilter(f)

return string(content), nil
}

func (a *App) seedFilters() {
// Truncate before seeding as requested
a.store.ClearAdblockFilters()

Comment on lines +546 to +548
Copy link

Copilot AI Dec 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clearing all adblock filters on every startup will delete user-added filters. This should only be done on first run or when explicitly requested by the user.

Suggested change
// Truncate before seeding as requested
a.store.ClearAdblockFilters()

Copilot uses AI. Check for mistakes.
defaults := []struct {
Name string
URL string
}{
{"AdGuard DNS", "https://justdomains.github.io/blocklists/lists/adguarddns-justdomains.txt"},
{"Easy List", "https://justdomains.github.io/blocklists/lists/easylist-justdomains.txt"},
{"Easy Privacy", "https://justdomains.github.io/blocklists/lists/easyprivacy-justdomains.txt"},
{"NoCoin", "https://justdomains.github.io/blocklists/lists/nocoin-justdomains.txt"},
{"Pi-hole", "https://raw.githubusercontent.com/xxcriticxx/.pl-host-file/master/hosts.txt"},
{"Ramnit", "https://1275.ru/DGA/ramnit.txt"},
{"SharkBot", "https://1275.ru/DGA/sharkbot.txt"},
{"QSnatch", "https://1275.ru/DGA/qsnatch.txt"},
{"CryptoLocker", "https://1275.ru/DGA/cryptolocker.txt"},
{"1024 Hosts", "https://raw.githubusercontent.com/Goooler/1024_hosts/master/hosts"},
}

existingFilters := a.store.GetAdblockFilters()
existingURLs := make(map[string]bool)
for _, f := range existingFilters {
existingURLs[f.URL] = true
}

added := false
for _, d := range defaults {
if !existingURLs[d.URL] {
filter := core.AdblockFilter{
ID: utils.GenerateIDString(),
Name: d.Name,
URL: d.URL,
Enabled: true,
}
if err := a.store.AddAdblockFilter(filter); err == nil {
added = true
}
}
}

if added {
go a.RefreshAdblockFilters()
}
}

func (a *App) normalizeFilterRules(content string) string {
var normalized strings.Builder
scanner := bufio.NewScanner(strings.NewReader(content))
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "!") || strings.HasPrefix(line, "#") {
normalized.WriteString(line + "\n")
continue
}

// Check if it's hosts format: IP Domain
parts := strings.Fields(line)
if len(parts) >= 2 {
firstPart := parts[0]
// Very simple check if first part is an IP-like string
if firstPart == "127.0.0.1" || firstPart == "0.0.0.0" || strings.Contains(firstPart, ":") {
// It's a hosts line, convert to ||domain^
domain := parts[1]
if strings.Contains(domain, ".") {
normalized.WriteString("||" + domain + "^\n")
continue
}
}
}

// Default: keep as is (already an adblock rule or comment)
normalized.WriteString(line + "\n")
}
return normalized.String()
}
Loading
Loading