diff --git a/.gitignore b/.gitignore index e1e33dd..f3f1eb6 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,10 @@ node_modules frontend/dist application-logs.txt Custos*.deb -custos.db \ No newline at end of file +custos.db + +target +.custos/ +Custos* +custos-*.deb +custos-*.rpm \ No newline at end of file diff --git a/app.go b/app.go index e97b1ec..d9ca76c 100644 --- a/app.go +++ b/app.go @@ -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" @@ -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 @@ -79,6 +86,7 @@ func NewApp() *App { proxyServer: proxy.NewServer(s, bm, systemTracker, port), dnsServer: dns.NewServer(s, bm, 5353), systemTracker: systemTracker, + blocklist: bm, } } @@ -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) @@ -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() } @@ -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() } // broadcastLogs sends new logs to frontend events @@ -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() @@ -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 @@ -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 @@ -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, } } @@ -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() + + 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() +} diff --git a/app.json b/app.json index 0306979..cb40d19 100644 --- a/app.json +++ b/app.json @@ -2,8 +2,8 @@ "name": "Custos", "version": "1.0.0", "icon": "", - "description": "A privacy-focused web browser", + "description": "Custos is a desktop application that acts as a “guardian” for your local network, sitting between your devices and the internet to monitor and filter traffic.", "author": "vkhangstack", - "contact" : "https://phamvankhang.name.vn", + "contact": "https://phamvankhang.name.vn", "license": "MIT" } \ No newline at end of file diff --git a/build-deb.sh b/build-deb.sh deleted file mode 100644 index 759ac05..0000000 --- a/build-deb.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/bin/bash - -# Build the app -wails build - -# Package the app -nfpm pkg --packager deb --config nfpm.yaml --target . \ No newline at end of file diff --git a/build-linux.sh b/build-linux.sh new file mode 100755 index 0000000..8c988d3 --- /dev/null +++ b/build-linux.sh @@ -0,0 +1,19 @@ +#!/bin/bash + +# Exit on error +set -e + +echo "Building libadblock Rust library..." +./lib/adblock/build.sh + +# Build the app using Wails +wails build -platform linux/amd64 + +echo "Packaging .deb..." +nfpm pkg --packager deb --config nfpm.yaml --target . + +echo "Packaging .rpm..." +nfpm pkg --packager rpm --config nfpm.yaml --target . + +echo "Build complete!" +ls -lh *.deb *.rpm diff --git a/build-rpm.sh b/build-rpm.sh deleted file mode 100644 index da3692a..0000000 --- a/build-rpm.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/bin/bash - -# Build the app -wails build - -# Package the app -nfpm pkg --packager rpm --config nfpm.yaml --target . \ No newline at end of file diff --git a/build.ps1 b/build.ps1 index 2291f6d..55b497f 100644 --- a/build.ps1 +++ b/build.ps1 @@ -14,6 +14,14 @@ $platform = "windows/amd64" # -platform: target platform # -ldflags: linker flags to reduce binary size and strip debug info # -nsis: generate installer + +# Build the adblock Rust library first +& ".\lib\adblock\build.ps1" +if ($LASTEXITCODE -ne 0) { + Write-Host "Failed to build adblock library. Aborting." -ForegroundColor Red + exit 1 +} + wails build -clean -platform $platform -ldflags "-s -w -H windowsgui" -nsis if ($LASTEXITCODE -eq 0) { diff --git a/build/windows/installer/project.nsi b/build/windows/installer/project.nsi index cd2d2c2..32f5f69 100644 --- a/build/windows/installer/project.nsi +++ b/build/windows/installer/project.nsi @@ -56,6 +56,9 @@ ManifestDPIAware true !define MUI_FINISHPAGE_NOAUTOCLOSE # Wait on the INSTFILES page so the user can take a look into the details of the installation steps !define MUI_ABORTWARNING # This will warn the user if they exit from the installer. +!define MUI_FINISHPAGE_RUN "$INSTDIR\${PRODUCT_EXECUTABLE}" +!define MUI_FINISHPAGE_RUN_TEXT "Launch ${INFO_PRODUCTNAME}" + !insertmacro MUI_PAGE_WELCOME # Welcome to the installer page. # !insertmacro MUI_PAGE_LICENSE "resources\eula.txt" # Adds a EULA page to the installer !insertmacro MUI_PAGE_DIRECTORY # In which folder install page. diff --git a/custos.desktop b/custos.desktop new file mode 100644 index 0000000..9230068 --- /dev/null +++ b/custos.desktop @@ -0,0 +1,11 @@ +[Desktop Entry] +Name=Custos +Comment=Custos - The Privacy First VPN +Exec=custos +Icon=custos +Terminal=false +Type=Application +Categories=Utility;Network; +TerminalOptions= +X-KDE-SubstituteUID=false +X-KDE-Username= diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5662b24..ac3d7ed 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -10,6 +10,7 @@ const Reports = lazy(() => import('./pages/Reports')); const Settings = lazy(() => import('./pages/Settings')); const OpenSource = lazy(() => import('./pages/OpenSource')); const About = lazy(() => import('./pages/About')); +const AdblockFilters = lazy(() => import('./pages/AdblockFilters')); import { EventsOn } from '../wailsjs/runtime/runtime'; import { useNavigate } from 'react-router-dom'; import { useEffect } from 'react'; @@ -48,6 +49,7 @@ function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 0bcc336..f30f047 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -1,6 +1,6 @@ -import { Home, FileText, Settings, Network as NetworkIcon, ChevronLeft, ChevronRight, Globe } from 'lucide-react'; +import { Home, FileText, Settings, Network as NetworkIcon, ChevronLeft, ChevronRight, Globe, Shield } from 'lucide-react'; import { NavLink } from 'react-router-dom'; -import { useEffect, useState } from 'react'; +import { Fragment, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { GetAppInfo } from '../../wailsjs/go/main/App'; @@ -22,7 +22,7 @@ const Sidebar = () => { { icon: NetworkIcon, label: t('sidebar.traffic'), path: '/traffic' }, // { icon: Globe, label: t('sidebar.proxy'), path: '/proxy' }, { icon: FileText, label: t('sidebar.rules'), path: '/rules' }, - // { icon: FileText, label: t('sidebar.reports'), path: '/reports' }, + { icon: Shield, label: t('sidebar.adblock'), path: '/adblock-filters' }, { icon: Settings, label: t('sidebar.settings'), path: '/settings' }, ]; @@ -77,18 +77,12 @@ const Sidebar = () => { {!isCollapsed && {i18n.language === 'en' ? 'EN' : 'VI'}} - {!isCollapsed ? ( - <> + {!isCollapsed && ( + - {/* {config?.name} */} {config?.version} - {/* {t('sidebar.author')}: {config.appAuthor} */} - > - ) : ( - - {config?.version} - + )} diff --git a/frontend/src/components/common/CopyButton.tsx b/frontend/src/components/common/CopyButton.tsx new file mode 100644 index 0000000..8583bf4 --- /dev/null +++ b/frontend/src/components/common/CopyButton.tsx @@ -0,0 +1,30 @@ +import { useState } from 'react'; +import { Copy, Check } from 'lucide-react'; + +interface CopyButtonProps { + text: string; + className?: string; +} + +const CopyButton = ({ text, className = "" }: CopyButtonProps) => { + const [copied, setCopied] = useState(false); + + const handleCopy = (e: React.MouseEvent) => { + e.stopPropagation(); + navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + return ( + + {copied ? : } + + ); +}; + +export default CopyButton; diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 4c787fa..cf376de 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -13,7 +13,8 @@ export const en = { network: "Network", expand: "Expand", collapse: "Collapse", - author: "Author" + author: "Author", + adblock: "Adblock Filters" }, home: { welcome: "Please enter your name below 👇", @@ -30,6 +31,7 @@ export const en = { upload: "Upload", enableProtection: "Enable Protection", disableProtection: "Disable Protection", + adsBlocked: "Ads Blocked", }, traffic: { title: "Traffic Monitoring", diff --git a/frontend/src/i18n/locales/vi.ts b/frontend/src/i18n/locales/vi.ts index e71accf..6800678 100644 --- a/frontend/src/i18n/locales/vi.ts +++ b/frontend/src/i18n/locales/vi.ts @@ -13,7 +13,8 @@ export const vi = { network: "Mạng", expand: "Mở rộng", collapse: "Thu gọn", - author: "Tác giả" + author: "Tác giả", + adblock: "Bộ lọc Adblock" }, home: { welcome: "Vui lòng nhập tên của bạn bên dưới 👇", @@ -30,6 +31,7 @@ export const vi = { upload: "Tải lên", enableProtection: "Bật Bảo vệ", disableProtection: "Tắt Bảo vệ", + adsBlocked: "Quảng cáo đã chặn", }, traffic: { title: "Giám sát Lưu lượng", diff --git a/frontend/src/pages/AdblockFilters.tsx b/frontend/src/pages/AdblockFilters.tsx new file mode 100644 index 0000000..f6ca648 --- /dev/null +++ b/frontend/src/pages/AdblockFilters.tsx @@ -0,0 +1,226 @@ +import { useState, useEffect } from 'react'; +import { Shield, Plus, Search, Filter, X, RefreshCw, Trash2, ExternalLink } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import PageHeader from '../components/common/PageHeader'; +import { GetAdblockFilters, AddAdblockFilter, DeleteAdblockFilter, ToggleAdblockFilter, RefreshAdblockFilters } from '../../wailsjs/go/main/App'; +import { core } from '../../wailsjs/go/models'; +import { useToast } from '../context/ToastContext'; + +export default function AdblockFilters() { + const { t } = useTranslation(); + const { showToast } = useToast(); + const [filters, setFilters] = useState([]); + const [searchTerm, setSearchTerm] = useState(''); + const [isModalOpen, setIsModalOpen] = useState(false); + const [isRefreshing, setIsRefreshing] = useState(false); + + // New Filter Form State + const [newName, setNewName] = useState(''); + const [newURL, setNewURL] = useState(''); + + const fetchFilters = async () => { + try { + const fetched = await GetAdblockFilters(); + setFilters(fetched || []); + } catch (e) { + console.error(e); + } + }; + + useEffect(() => { + fetchFilters(); + }, []); + + const handleToggleFilter = async (id: string, enabled: boolean) => { + try { + await ToggleAdblockFilter(id, enabled); + fetchFilters(); + } catch (e) { + console.error(e); + } + }; + + const handleDeleteFilter = async (id: string) => { + if (!confirm('Are you sure you want to delete this filter?')) return; + try { + await DeleteAdblockFilter(id); + fetchFilters(); + } catch (e) { + console.error(e); + } + }; + + const handleAddFilter = async () => { + if (!newName || !newURL) return; + try { + await AddAdblockFilter(newName, newURL); + setNewName(''); + setNewURL(''); + setIsModalOpen(false); + fetchFilters(); + } catch (e) { + console.error(e); + } + }; + + const handleRefresh = async () => { + setIsRefreshing(true); + try { + await RefreshAdblockFilters(); + showToast("Filters refreshed and reloaded", "success"); + fetchFilters(); + } catch (e) { + console.error(e); + showToast("Failed to refresh filters", "error"); + } finally { + setIsRefreshing(false); + } + }; + + const filteredFilters = filters.filter(f => + f.name.toLowerCase().includes(searchTerm.toLowerCase()) || + f.url.toLowerCase().includes(searchTerm.toLowerCase()) + ); + + const actions = ( + + + + Refresh + + setIsModalOpen(true)} + className="flex items-center gap-2 bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg transition-colors font-medium shadow-lg shadow-blue-900/20" + > + + New List + + + ); + + return ( + + + + {/* Search Bar */} + + + + setSearchTerm(e.target.value)} + className="w-full bg-input border border-border rounded-lg py-2.5 pl-10 pr-4 text-foreground focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all font-medium placeholder:text-muted-foreground" + /> + + + + {/* Filters List */} + + + {filteredFilters.map((filter) => ( + + + + + + + + {filter.name} + {filter.url && ( + + + + )} + + {filter.url} + + Last Updated: {filter.last_updated ? new Date(filter.last_updated).toLocaleString() : 'Never'} + + + + + {/* + Hits + {filter.hits.toLocaleString()} + */} + ToggleAdblockFilter(filter.id, !filter.enabled).then(fetchFilters)} + className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${filter.enabled ? 'bg-blue-600' : 'bg-muted'}`} + > + + + handleDeleteFilter(filter.id)} + className="p-2 text-muted-foreground hover:text-red-500 hover:bg-red-500/10 rounded-lg transition-colors" + > + + + + + ))} + + {filteredFilters.length === 0 && ( + + + No filters found. + + )} + + + + {/* Add Filter Modal */} + {isModalOpen && ( + + + + New Filter List + setIsModalOpen(false)} className="text-muted-foreground hover:text-foreground"> + + + + + + + Name + setNewName(e.target.value)} + /> + + + + URL + setNewURL(e.target.value)} + /> + + + + setIsModalOpen(false)} className="px-4 py-2 rounded-lg text-muted-foreground hover:bg-accent">Cancel + Add Filter + + + + + )} + + ); +} diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 9d90038..65a6573 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -35,7 +35,7 @@ export default function Dashboard() { const currentStats = fetchedStats || new core.Stats(); setStats(currentStats); setConnections(fetchedConns || []); - + console.log("fetchedStats", fetchedStats); // Calculate live rate for the chart (append to history) setChartData(prevData => { const now = new Date(); @@ -49,7 +49,7 @@ export default function Dashboard() { const down = currentStats.total_download || 0; const prevUp = prevStatsRef.current.total_upload || 0; const prevDown = prevStatsRef.current.total_download || 0; - + uploadRate = up - prevUp; downloadRate = down - prevDown; } @@ -120,6 +120,7 @@ export default function Dashboard() { + const getRootDomain = (domain: string) => { if (!domain) return '-'; @@ -151,13 +152,12 @@ export default function Dashboard() { {isProtectionLoading || protectionEnabled === null ? ( @@ -172,13 +172,13 @@ export default function Dashboard() { {protectionEnabled === null ? t('Loading...') : protectionEnabled - ? t('dashboard.disableProtection') - : t('dashboard.enableProtection')} + ? t('dashboard.disableProtection') + : t('dashboard.enableProtection')} {/* Status Cards */} - + + {/* Charts Section */} - + {['1h', '3h', '24h'].map((range) => ( setTimeRange(range)} - className={`px-3 py-1 text-sm rounded-md transition-colors ${ - timeRange === range - ? 'bg-primary text-primary-foreground' - : 'bg-muted text-muted-foreground hover:bg-muted/80' - }`} + className={`px-3 py-1 text-sm rounded-md transition-colors ${timeRange === range + ? 'bg-primary text-primary-foreground' + : 'bg-muted text-muted-foreground hover:bg-muted/80' + }`} > {range} ))} - - - - + + - + ); } diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 7f50547..7bb2cf9 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -61,7 +61,8 @@ export default function Settings() { await SaveAppSettings({ port: port, notifications: notifications, - auto_start: autoStart + auto_start: autoStart, + adblock_enabled: true }); showToast(t('settings.save') + ' Success', 'success'); } catch (error) { @@ -71,7 +72,7 @@ export default function Settings() { }; const actions = ( - diff --git a/frontend/src/pages/Traffic.tsx b/frontend/src/pages/Traffic.tsx index ab556f9..73e824c 100644 --- a/frontend/src/pages/Traffic.tsx +++ b/frontend/src/pages/Traffic.tsx @@ -1,10 +1,11 @@ -import { useState, useEffect } from 'react'; -import { Activity, ArrowDown, ArrowUp, Filter, Search, RefreshCw, XCircle, CheckCircle } from 'lucide-react'; +import { useState, useEffect, useCallback } from 'react'; +import { Activity, ArrowDown, ArrowUp, Search, RefreshCw, XCircle, CheckCircle, ChevronLeft, ChevronRight } from 'lucide-react'; import { useTranslation } from 'react-i18next'; -import { GetLogs, GetStats, GetSystemConnections } from '../../wailsjs/go/main/App'; +import { GetLogsPaginated, GetStats, GetSystemConnections } from '../../wailsjs/go/main/App'; import { core, system } from '../../wailsjs/go/models'; import PageHeader from '../components/common/PageHeader'; import Select from '../components/common/Select'; +import CopyButton from '../components/common/CopyButton'; export default function Traffic() { const { t } = useTranslation(); @@ -17,21 +18,33 @@ export default function Traffic() { const [typeFilter, setTypeFilter] = useState('all'); const [isRefreshing, setIsRefreshing] = useState(false); - const fetchData = async () => { + // Pagination state (Cursor-based) + const [cursorHistory, setCursorHistory] = useState(['']); // Stack of cursors for each page + const [pageSize] = useState(20); + const [totalLogs, setTotalLogs] = useState(0); + const [nextCursor, setNextCursor] = useState(''); + const [hasMore, setHasMore] = useState(false); + + const currentCursor = cursorHistory[cursorHistory.length - 1]; + + const fetchData = useCallback(async () => { try { - const [fetchedLogs, fetchedStats, fetchedConns] = await Promise.all([ - GetLogs(), + const [paginatedLogs, fetchedStats, fetchedConns] = await Promise.all([ + GetLogsPaginated(currentCursor, pageSize, searchQuery, statusFilter, typeFilter), GetStats(), GetSystemConnections() ]); - setLogs(fetchedLogs || []); + setLogs(paginatedLogs.logs || []); + setTotalLogs(paginatedLogs.total || 0); + setNextCursor(paginatedLogs.next_cursor || ''); + setHasMore(paginatedLogs.has_more || false); setStats(fetchedStats || new core.Stats()); setConnections(fetchedConns || []); } catch (error) { console.error("Failed to fetch traffic data:", error); } - }; + }, [currentCursor, pageSize, searchQuery, statusFilter, typeFilter]); const handleRefresh = async () => { setIsRefreshing(true); @@ -44,7 +57,23 @@ export default function Traffic() { // Poll every 2 seconds for fresh data const interval = setInterval(fetchData, 2000); return () => clearInterval(interval); - }, []); + }, [fetchData]); + + const handleNextPage = () => { + if (hasMore && nextCursor) { + setCursorHistory(prev => [...prev, nextCursor]); + } + }; + + const handlePrevPage = () => { + if (cursorHistory.length > 1) { + setCursorHistory(prev => prev.slice(0, prev.length - 1)); + } + }; + + const handleFilterChange = () => { + setCursorHistory(['']); // Reset to first page + }; const formatBytes = (bytes: number) => { if (bytes === 0) return '0 B'; @@ -62,15 +91,7 @@ export default function Traffic() { } }; - const filteredLogs = logs.filter(log => { - const matchesSearch = log.domain?.toLowerCase().includes(searchQuery.toLowerCase()) || - log.process_name?.toLowerCase().includes(searchQuery.toLowerCase()) || - log.dst_ip?.includes(searchQuery); - const matchesStatus = statusFilter === 'all' || log.status === statusFilter; - const matchesType = typeFilter === 'all' || log.type === typeFilter; - return matchesSearch && matchesStatus && matchesType; - }); - + // Connections are still client-side filtered as they come in a small list from system info const filteredConnections = connections.filter(conn => conn.process_name?.toLowerCase().includes(searchQuery.toLowerCase()) || conn.remote_addr?.includes(searchQuery) @@ -80,7 +101,7 @@ export default function Traffic() { { setStatusFilter(val); handleFilterChange(); }} options={[ { label: 'All Status', value: 'all' }, { label: 'Allowed', value: 'allowed' }, @@ -90,7 +111,7 @@ export default function Traffic() { /> { setTypeFilter(val); handleFilterChange(); }} options={[ { label: 'All Types', value: 'all' }, { label: 'DNS', value: 'dns' }, @@ -104,7 +125,7 @@ export default function Traffic() { type="text" placeholder={t('traffic.searchPlaceholder') as string} value={searchQuery} - onChange={(e) => setSearchQuery(e.target.value)} + onChange={(e) => { setSearchQuery(e.target.value); handleFilterChange(); }} className="max-w-32 pl-9 pr-4 py-2 bg-muted border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary/50 text-foreground w-64" /> @@ -175,7 +196,7 @@ export default function Traffic() { {/* Content Table */} - + @@ -185,7 +206,7 @@ export default function Traffic() { Time Process Domain / IP - Type + Reason Protocol Status Size @@ -204,8 +225,8 @@ export default function Traffic() { {activeTab === 'logs' ? ( - filteredLogs.length > 0 ? ( - filteredLogs.slice().reverse().map((log) => ( + logs.length > 0 ? ( + logs.map((log) => ( {new Date(log.timestamp).toLocaleTimeString()} @@ -215,13 +236,18 @@ export default function Traffic() { {log.process_id ? ({log.process_id}) : ''} - {log.domain || '-'} - {log.dst_ip}:{log.dst_port} + + + {log.domain || '-'} + {log.dst_ip}:{log.dst_port} + + + - - - {log.type.toUpperCase()} - + + {log.reason ? + {log.reason.toLocaleUpperCase()} + : ""} {log.protocol} @@ -269,6 +295,39 @@ export default function Traffic() { + + {/* Pagination Controls */} + {activeTab === 'logs' && (logs.length > 0 || cursorHistory.length > 1) && ( + + + {totalLogs > 0 ? ( + <> + Total: {totalLogs} entries + > + ) : ( + "No more entries" + )} + + + + + Previous + + + Next + + + + + )} ); diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index 57eabc6..daa85e4 100755 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -1,15 +1,25 @@ // Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL // This file is automatically generated. DO NOT EDIT -import {main} from '../models'; import {core} from '../models'; +import {main} from '../models'; import {system} from '../models'; +export function AddAdblockFilter(arg1:string,arg2:string):Promise; + export function AddRule(arg1:string,arg2:string):Promise; +export function DeleteAdblockFilter(arg1:string):Promise; + export function DeleteRule(arg1:string):Promise; +export function EnableAdblock(arg1:boolean):Promise; + export function EnableProtection(arg1:boolean):Promise; +export function GetAdblockFilters():Promise>; + +export function GetAdblockStatus():Promise; + export function GetAppInfo():Promise; export function GetAppSettings():Promise; @@ -18,6 +28,8 @@ export function GetChartData(arg1:string):Promise>; export function GetLogs():Promise>; +export function GetLogsPaginated(arg1:string,arg2:number,arg3:string,arg4:string,arg5:string):Promise; + export function GetProtectionStatus():Promise; export function GetRules():Promise>; @@ -32,10 +44,14 @@ export function GetSystemConnections():Promise>; export function Greet(arg1:string):Promise; +export function RefreshAdblockFilters():Promise; + export function SaveAppSettings(arg1:main.AppSettings):Promise; export function SetRunOnStartup(arg1:boolean):Promise; export function SetSystemProxy(arg1:boolean):Promise; +export function ToggleAdblockFilter(arg1:string,arg2:boolean):Promise; + export function ToggleRule(arg1:string,arg2:boolean):Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index 514afb9..5106159 100755 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -2,18 +2,38 @@ // Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL // This file is automatically generated. DO NOT EDIT +export function AddAdblockFilter(arg1, arg2) { + return window['go']['main']['App']['AddAdblockFilter'](arg1, arg2); +} + export function AddRule(arg1, arg2) { return window['go']['main']['App']['AddRule'](arg1, arg2); } +export function DeleteAdblockFilter(arg1) { + return window['go']['main']['App']['DeleteAdblockFilter'](arg1); +} + export function DeleteRule(arg1) { return window['go']['main']['App']['DeleteRule'](arg1); } +export function EnableAdblock(arg1) { + return window['go']['main']['App']['EnableAdblock'](arg1); +} + export function EnableProtection(arg1) { return window['go']['main']['App']['EnableProtection'](arg1); } +export function GetAdblockFilters() { + return window['go']['main']['App']['GetAdblockFilters'](); +} + +export function GetAdblockStatus() { + return window['go']['main']['App']['GetAdblockStatus'](); +} + export function GetAppInfo() { return window['go']['main']['App']['GetAppInfo'](); } @@ -30,6 +50,10 @@ export function GetLogs() { return window['go']['main']['App']['GetLogs'](); } +export function GetLogsPaginated(arg1, arg2, arg3, arg4, arg5) { + return window['go']['main']['App']['GetLogsPaginated'](arg1, arg2, arg3, arg4, arg5); +} + export function GetProtectionStatus() { return window['go']['main']['App']['GetProtectionStatus'](); } @@ -58,6 +82,10 @@ export function Greet(arg1) { return window['go']['main']['App']['Greet'](arg1); } +export function RefreshAdblockFilters() { + return window['go']['main']['App']['RefreshAdblockFilters'](); +} + export function SaveAppSettings(arg1) { return window['go']['main']['App']['SaveAppSettings'](arg1); } @@ -70,6 +98,10 @@ export function SetSystemProxy(arg1) { return window['go']['main']['App']['SetSystemProxy'](arg1); } +export function ToggleAdblockFilter(arg1, arg2) { + return window['go']['main']['App']['ToggleAdblockFilter'](arg1, arg2); +} + export function ToggleRule(arg1, arg2) { return window['go']['main']['App']['ToggleRule'](arg1, arg2); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 396f269..15575d4 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -1,5 +1,46 @@ export namespace core { + export class AdblockFilter { + id: string; + name: string; + url: string; + enabled: boolean; + // Go type: time + last_updated: any; + hits: number; + + static createFrom(source: any = {}) { + return new AdblockFilter(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.id = source["id"]; + this.name = source["name"]; + this.url = source["url"]; + this.enabled = source["enabled"]; + this.last_updated = this.convertValues(source["last_updated"], null); + this.hits = source["hits"]; + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + } export class LogEntry { id: string; // Go type: time @@ -16,6 +57,7 @@ export namespace core { bytes_recv: number; status: string; latency: number; + reason?: string; static createFrom(source: any = {}) { return new LogEntry(source); @@ -37,6 +79,43 @@ export namespace core { this.bytes_recv = source["bytes_recv"]; this.status = source["status"]; this.latency = source["latency"]; + this.reason = source["reason"]; + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + } + export class PaginatedLogs { + logs: LogEntry[]; + next_cursor: string; + has_more: boolean; + total: number; + + static createFrom(source: any = {}) { + return new PaginatedLogs(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.logs = this.convertValues(source["logs"], LogEntry); + this.next_cursor = source["next_cursor"]; + this.has_more = source["has_more"]; + this.total = source["total"]; } convertValues(a: any, classs: any, asMap: boolean = false): any { @@ -117,6 +196,7 @@ export namespace core { total_download: number; active_connections: number; top_domains: Record; + adblock_hits: number; // Go type: time timestamp: any; @@ -130,6 +210,7 @@ export namespace core { this.total_download = source["total_download"]; this.active_connections = source["active_connections"]; this.top_domains = source["top_domains"]; + this.adblock_hits = source["adblock_hits"]; this.timestamp = this.convertValues(source["timestamp"], null); } @@ -217,6 +298,7 @@ export namespace main { port: number; notifications: boolean; auto_start: boolean; + adblock_enabled: boolean; static createFrom(source: any = {}) { return new AppSettings(source); @@ -227,6 +309,7 @@ export namespace main { this.port = source["port"]; this.notifications = source["notifications"]; this.auto_start = source["auto_start"]; + this.adblock_enabled = source["adblock_enabled"]; } } diff --git a/internal/adblock/engine.go b/internal/adblock/engine.go new file mode 100644 index 0000000..7b0b43e --- /dev/null +++ b/internal/adblock/engine.go @@ -0,0 +1,59 @@ +//go:build cgo + +package adblock + +/* +#cgo LDFLAGS: -L${SRCDIR}/../../lib/adblock/target/release -ladblock -ldl -lpthread -lm +#include +#include + +typedef void* adblock_engine_t; + +adblock_engine_t adblock_engine_create(const char* rules); +bool adblock_engine_check(adblock_engine_t engine, const char* url, const char* source_url, const char* resource_type); +void adblock_engine_destroy(adblock_engine_t engine); +*/ +import "C" +import ( + "unsafe" +) + +type Engine struct { + ptr C.adblock_engine_t +} + +func NewEngine(rules string) *Engine { + cRules := C.CString(rules) + defer C.free(unsafe.Pointer(cRules)) + + ptr := C.adblock_engine_create(cRules) + if ptr == nil { + return nil + } + + return &Engine{ptr: ptr} +} + +func (e *Engine) Check(url, sourceURL, resourceType string) bool { + if e.ptr == nil { + return false + } + + cUrl := C.CString(url) + defer C.free(unsafe.Pointer(cUrl)) + + cSourceURL := C.CString(sourceURL) + defer C.free(unsafe.Pointer(cSourceURL)) + + cResourceType := C.CString(resourceType) + defer C.free(unsafe.Pointer(cResourceType)) + + return bool(C.adblock_engine_check(e.ptr, cUrl, cSourceURL, cResourceType)) +} + +func (e *Engine) Close() { + if e.ptr != nil { + C.adblock_engine_destroy(e.ptr) + e.ptr = nil + } +} diff --git a/internal/adblock/engine_test.go b/internal/adblock/engine_test.go new file mode 100644 index 0000000..17b5752 --- /dev/null +++ b/internal/adblock/engine_test.go @@ -0,0 +1,36 @@ +package adblock + +import ( + "testing" +) + +func TestAdblockEngine(t *testing.T) { + rules := `||ads.example.com^ +||doubleclick.net^$domain=example.com +` + engine := NewEngine(rules) + if engine == nil { + t.Fatal("Failed to create adblock engine") + } + defer engine.Close() + + tests := []struct { + url string + sourceURL string + resourceType string + wantBlocked bool + }{ + {"http://ads.example.com/banner.gif", "http://example.com", "image", true}, + {"http://example.com/index.html", "http://example.com", "document", false}, + {"http://doubleclick.net/ad.js", "http://example.com", "script", true}, + {"http://doubleclick.net/ad.js", "http://other.com", "script", false}, + {"http://pagead2.googlesyndication.com/", "http://example.com", "script", false}, + } + + for _, tt := range tests { + got := engine.Check(tt.url, tt.sourceURL, tt.resourceType) + if got != tt.wantBlocked { + t.Errorf("Check(%q, %q, %q) = %v; want %v", tt.url, tt.sourceURL, tt.resourceType, got, tt.wantBlocked) + } + } +} diff --git a/internal/adblock/stub.go b/internal/adblock/stub.go new file mode 100644 index 0000000..ed6d4d4 --- /dev/null +++ b/internal/adblock/stub.go @@ -0,0 +1,23 @@ +//go:build !cgo + +package adblock + +import "log" + +type Engine struct { + ptr unsafePointer // Not used but keeps struct size non-zero if needed +} + +type unsafePointer uintptr + +func NewEngine(rules string) *Engine { + log.Println("WARNING: Adblock engine is disabled because CGO is not enabled.") + return &Engine{} +} + +func (e *Engine) Check(url, sourceURL, resourceType string) bool { + return false +} + +func (e *Engine) Close() { +} diff --git a/internal/core/blocklist.go b/internal/core/blocklist.go index 9cdff49..0b64a86 100644 --- a/internal/core/blocklist.go +++ b/internal/core/blocklist.go @@ -2,7 +2,10 @@ package core import ( "bufio" + "fmt" + "io" "net/http" + "os" "strings" "sync" "time" @@ -19,69 +22,104 @@ type BlocklistManager struct { func NewBlocklistManager() *BlocklistManager { return &BlocklistManager{ domains: make(map[string]bool), - sources: []string{ - "https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts", - }, + sources: []string{}, // Start empty, will be seeded/populated by App } } +// SetSources updates the sources list +func (m *BlocklistManager) SetSources(sources []string) { + m.mu.Lock() + m.sources = sources + m.mu.Unlock() +} + // Load loads all configured sources func (m *BlocklistManager) Load() error { - m.mu.Lock() - defer m.mu.Unlock() + m.mu.RLock() + sources := make([]string, len(m.sources)) + copy(sources, m.sources) + m.mu.RUnlock() - // Clear existing - m.domains = make(map[string]bool) + newDomains := make(map[string]bool) - for _, source := range m.sources { - if err := m.loadSource(source); err != nil { + for _, source := range sources { + fmt.Println("Loading blocklist source:", source) + if err := m.loadSource(source, newDomains); err != nil { // Log error but continue continue } } + + m.mu.Lock() + m.domains = newDomains + m.mu.Unlock() + return nil } -func (m *BlocklistManager) loadSource(url string) error { - // Create a client that explicitly bypasses system proxy - client := &http.Client{ - Transport: &http.Transport{ - Proxy: nil, // Bypass system proxy - }, - Timeout: 30 * time.Second, +func (m *BlocklistManager) loadSource(source string, domains map[string]bool) error { + if source == "" { + return nil } - req, err := http.NewRequest("GET", url, nil) - if err != nil { - return err - } - // Force connection close to avoid "Unsolicited response received on idle HTTP channel" - req.Close = true + var body io.ReadCloser + if strings.HasPrefix(source, "http://") || strings.HasPrefix(source, "https://") { + client := &http.Client{ + Transport: &http.Transport{ + Proxy: nil, // Bypass system proxy + }, + Timeout: 30 * time.Second, + } + + req, err := http.NewRequest("GET", source, nil) + if err != nil { + return err + } + req.Close = true - resp, err := client.Do(req) - if err != nil { - return err + resp, err := client.Do(req) + if err != nil { + return err + } + body = resp.Body + } else { + // Assume local file + f, err := os.Open(source) + if err != nil { + return fmt.Errorf("failed to open local source %s: %v", source, err) + } + body = f } - defer resp.Body.Close() + defer body.Close() - scanner := bufio.NewScanner(resp.Body) + count := 0 + scanner := bufio.NewScanner(body) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if strings.HasPrefix(line, "#") || line == "" { continue } - // Hosts file format: 0.0.0.0 domain.com parts := strings.Fields(line) + var domain string + if len(parts) >= 2 { - domain := parts[1] + // Hosts file format: 0.0.0.0 domain.com + domain = parts[1] + } else if len(parts) == 1 { + // Simple list format: domain.com + domain = parts[0] + } + + if domain != "" { // Simple validation - if !strings.Contains(domain, ".") { - continue + if strings.Contains(domain, ".") { + domains[domain] = true + count++ } - m.domains[domain] = true } } + fmt.Printf("Loaded %d domains from source: %s\n", count, source) return scanner.Err() } diff --git a/internal/core/types.go b/internal/core/types.go index 6d0bddd..c5fa1f9 100644 --- a/internal/core/types.go +++ b/internal/core/types.go @@ -15,6 +15,7 @@ const ( RuleSourceProtocolHttpsBlocked RuleType = "protection_https_blocked" RuleSourceProtocolHttpAllowed RuleType = "protection_http_allowed" RuleSourceProtocolHttpsAllowed RuleType = "protection_https_allowed" + RuleSourceAdsblock RuleType = "adsblock" ) const ( @@ -45,6 +46,7 @@ type LogEntry struct { BytesRecv int64 `json:"bytes_recv"` Status string `json:"status"` // "allowed", "blocked", "error" Latency int64 `json:"latency"` // in ms + Reason *string `json:"reason"` } // Stats represents aggregated statistics @@ -53,6 +55,7 @@ type Stats struct { TotalDownload int64 `json:"total_download"` ActiveConns int `json:"active_connections"` TopDomains map[string]int64 `json:"top_domains"` + AdblockHits int64 `json:"adblock_hits"` Timestamp time.Time `json:"timestamp"` // Unix Milli } @@ -101,12 +104,30 @@ type PaginatedRulesResponse struct { Total int64 `json:"total"` } +// PaginatedLogs wraps logs and total count +type PaginatedLogs struct { + Logs []LogEntry `json:"logs"` + NextCursor string `json:"next_cursor"` + HasMore bool `json:"has_more"` + Total int64 `json:"total"` +} + // AppSetting represents a key-value setting type AppSetting struct { Key string `gorm:"primaryKey" json:"key"` Value string `json:"value"` } +// AdblockFilter represents an adblock filter list (e.g. EasyList) +type AdblockFilter struct { + ID string `gorm:"primaryKey" json:"id"` + Name string `json:"name"` + URL string `json:"url"` + Enabled bool `json:"enabled"` + LastUpdated time.Time `json:"last_updated"` + Hits int64 `json:"hits"` +} + type Process struct { PID int32 `json:"pid"` Name string `json:"name"` diff --git a/internal/proxy/conn.go b/internal/proxy/conn.go index 906fa59..087954e 100644 --- a/internal/proxy/conn.go +++ b/internal/proxy/conn.go @@ -95,8 +95,8 @@ func (c *CountingConn) report() { // Close wraps Close to log final stats func (c *CountingConn) Close() error { - // Force a final report - c.report() - fmt.Printf("[DEBUG] Closed conn %s. Final sent/recv: %d/%d\n", c.entry.ID, c.reportedSent, c.reportedRecv) + // Force a final report in a goroutine to not block the connection close + go c.report() + fmt.Printf("[DEBUG] Closing conn %s. Last reported sent/recv: %d/%d\n", c.entry.ID, c.reportedSent, c.reportedRecv) return c.Conn.Close() } diff --git a/internal/proxy/server.go b/internal/proxy/server.go index 55ab6b1..9758286 100644 --- a/internal/proxy/server.go +++ b/internal/proxy/server.go @@ -5,8 +5,10 @@ import ( "fmt" "log" "net" + "sync" "time" + "github.com/vkhangstack/Custos/internal/adblock" "github.com/vkhangstack/Custos/internal/core" "github.com/vkhangstack/Custos/internal/store" "github.com/vkhangstack/Custos/internal/system" @@ -27,24 +29,66 @@ type Server struct { running bool listener net.Listener protectionEnabled bool + adblockEnabled bool + adblockEngine *adblock.Engine + mu sync.RWMutex } // NewServer creates a new proxy server func NewServer(store store.Store, blocklist *core.BlocklistManager, systemTracker *system.Tracker, port int) *Server { + // Initialize adblock engine with default rules for now + // In the future, this can be loaded from DB or files + adblockRules := `||ads.google.com^ +||doubleclick.net^ +||adnxs.com^ +||googleadservices.com^ +||pagead2.googlesyndication.com^ +||analytics.google.com^ +||facebook.com/tr/^ +` + engine := adblock.NewEngine(adblockRules) + return &Server{ - store: store, - blocklist: blocklist, - systemTracker: systemTracker, - port: port, + store: store, + blocklist: blocklist, + systemTracker: systemTracker, + port: port, + adblockEngine: engine, + protectionEnabled: true, // Default } } // SetProtection enables or disables the HTTP protection func (s *Server) SetProtection(enabled bool) { + s.mu.Lock() s.protectionEnabled = enabled + s.mu.Unlock() log.Printf("Protection mode set to: %v", enabled) } +// SetAdblockEnabled enables or disables the adblock engine +func (s *Server) SetAdblockEnabled(enabled bool) { + s.mu.Lock() + s.adblockEnabled = enabled + s.mu.Unlock() + log.Printf("Adblock engine enabled: %v", enabled) +} + +func (s *Server) ReloadAdblockEngine(rules string) { + newEngine := adblock.NewEngine(rules) + log.Printf("Adblock engine parsed with %d bytes of rules", len(rules)) + + s.mu.Lock() + oldEngine := s.adblockEngine + s.adblockEngine = newEngine + s.mu.Unlock() + + if oldEngine != nil { + oldEngine.Close() + } + log.Printf("Adblock engine swapped successfully") +} + const logIDKey = "logID" // Start starts the SOCKS5 proxy @@ -118,6 +162,9 @@ func (s *Server) Stop() { if s.listener != nil { s.listener.Close() } + if s.adblockEngine != nil { + s.adblockEngine.Close() + } } // GetPort returns the current port @@ -157,27 +204,31 @@ func (r *LoggingRuleSet) Allow(ctx context.Context, req *socks5.Request) (contex procName, procID = r.server.systemTracker.GetProcessFromPort(req.RemoteAddr.Port) } - // Check Protection Mode (Block HTTP Port 80) - if r.server.protectionEnabled { - if req.DestAddr.Port == 80 { - r.logBlock(req, domain, string(core.RuleSourceProtocolHttpBlocked), &core.Process{ + // Check Adblock Engine + sEnabled := false + var engine *adblock.Engine + + r.server.mu.RLock() + sEnabled = r.server.adblockEnabled + engine = r.server.adblockEngine + r.server.mu.RUnlock() + + if sEnabled && engine != nil { + testURL := "http://" + domain + log.Printf("[DEBUG] Checking adblock for: %s", testURL) + if engine.Check(testURL, "http://"+domain, "other") { + r.store.IncrementAdblockHit(domain) + r.logBlock(req, domain, string(core.RuleSourceAdsblock), &core.Process{ PID: procID, Name: procName, }) - log.Printf("Blocked HTTP request to %s (Port 80) due to active protection", domain) + log.Printf("Blocked by adblock engine: %s", domain) return ctx, false + } else { + log.Printf("[DEBUG] Not blocked by adblock: %s", domain) } } - // Check Blocklist - if r.blocklist.IsBlocked(domain) { - r.logBlock(req, domain, string(core.RuleSourceBlocklist), &core.Process{ - PID: procID, - Name: procName, - }) - return ctx, false - } - // Check Custom Rules // Optimized: Could cache this or use a more efficient matcher rules := r.store.GetRules() @@ -192,42 +243,46 @@ func (r *LoggingRuleSet) Allow(ctx context.Context, req *socks5.Request) (contex // Go's filepath.Match is good for globs. if matched, _ := matchDomain(rule.Pattern, domain); matched { r.store.IncrementRuleHit(rule.ID, domain) + + if rule.Type == core.RuleAllow { + r.logAllow(req, domain, &core.Process{ + PID: procID, + Name: procName, + }, utils.GenerateIDString()) + return ctx, true + } + if rule.Type == core.RuleBlock { - r.logBlock(req, domain, string(core.RuleSourceCustom), &core.Process{ + r.store.IncrementAdblockHit(domain) + r.logBlock(req, domain, string(core.RuleSourceAdsblock), &core.Process{ PID: procID, Name: procName, }) return ctx, false } - // If ALLOW, we stop checking other block rules - break } } - // ... continue to allow + // Check Blocklist + if r.blocklist.IsBlocked(domain) { + r.store.IncrementAdblockHit(domain) + r.logBlock(req, domain, string(core.RuleSourceBlocklist), &core.Process{ + PID: procID, + Name: procName, + }) + return ctx, false + } // Log the connection attempt - // Define variables - destIP := req.DestAddr.IP.String() + logID := utils.GenerateIDString() - entry := core.LogEntry{ - ID: utils.GenerateIDString(), - Timestamp: time.Now(), - Type: core.LogSourceProxy, - Domain: domain, // Could be empty if IP - DstIP: destIP, - DstPort: req.DestAddr.Port, - SrcIP: req.RemoteAddr.IP.String(), - Protocol: core.ProtocolTCP, - ProcessName: procName, - ProcessID: procID, - Status: core.LogStatusAllowed, - } - - r.store.AddLog(entry) + r.logAllow(req, domain, &core.Process{ + PID: procID, + Name: procName, + }, logID) // Inject logID into context for Dial to pick up - return context.WithValue(ctx, logIDKey, entry.ID), true + return context.WithValue(ctx, logIDKey, logID), true } // matchDomain checks if domain matches pattern @@ -259,6 +314,27 @@ func (r *LoggingRuleSet) logBlock(req *socks5.Request, domain string, reason str BytesRecv: 0, ProcessName: process.Name, ProcessID: process.PID, + Reason: &reason, + } + r.store.AddLog(entry) +} + +func (r *LoggingRuleSet) logAllow(req *socks5.Request, domain string, process *core.Process, id string) { + entry := core.LogEntry{ + ID: id, + Timestamp: time.Now(), + Type: core.LogSourceProxy, + DstIP: req.DestAddr.IP.String(), + DstPort: req.DestAddr.Port, + SrcIP: req.RemoteAddr.IP.String(), + Domain: domain, + Protocol: core.ProtocolTCP, + Status: core.LogStatusAllowed, + BytesSent: 0, + BytesRecv: 0, + ProcessName: process.Name, + ProcessID: process.PID, } + r.store.AddLog(entry) } diff --git a/internal/store/interface.go b/internal/store/interface.go index 0549e28..4f25924 100644 --- a/internal/store/interface.go +++ b/internal/store/interface.go @@ -13,6 +13,7 @@ type Store interface { AddTraffic(upload, download int64) GetTrafficHistory(duration time.Duration) []core.TrafficDataPoint GetRecentLogs(limit int) []core.LogEntry + GetLogsPaginated(cursor string, limit int, search, status, logType string) ([]core.LogEntry, string, bool, int64, error) GetStats() core.Stats Subscribe(callback func(core.LogEntry)) ResetData() @@ -23,6 +24,13 @@ type Store interface { DeleteRule(id string) error UpdateRule(rule core.Rule) error IncrementRuleHit(id string, domain string) error + IncrementAdblockHit(domain string) error + // Adblock Filters + AddAdblockFilter(filter core.AdblockFilter) error + GetAdblockFilters() []core.AdblockFilter + DeleteAdblockFilter(id string) error + UpdateAdblockFilter(filter core.AdblockFilter) error + ClearAdblockFilters() error // Settings GetSetting(key string) (string, error) SetSetting(key, value string) error diff --git a/internal/store/memory.go b/internal/store/memory.go index c6a0c26..6cc5ab6 100644 --- a/internal/store/memory.go +++ b/internal/store/memory.go @@ -182,6 +182,14 @@ func (s *MemoryStore) GetRulesPaginated(page, pageSize int, search string) ([]co return []core.Rule{}, 0, nil } +func (s *MemoryStore) GetLogsPaginated(cursor string, limit int, search, status, logType string) ([]core.LogEntry, string, bool, int64, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + // Use GetRecentLogs as a base or just return empty for now as MemoryStore is for dev/fallback + return []core.LogEntry{}, "", false, 0, nil +} + func (s *MemoryStore) GetSetting(key string) (string, error) { return "", fmt.Errorf("not implemented") } @@ -192,8 +200,21 @@ func (s *MemoryStore) SetSetting(key, value string) error { func (s *MemoryStore) DeleteRule(id string) error { return nil } func (s *MemoryStore) UpdateRule(rule core.Rule) error { return nil } +func (s *MemoryStore) AddAdblockFilter(filter core.AdblockFilter) error { return nil } +func (s *MemoryStore) GetAdblockFilters() []core.AdblockFilter { return nil } +func (s *MemoryStore) DeleteAdblockFilter(id string) error { return nil } +func (s *MemoryStore) UpdateAdblockFilter(filter core.AdblockFilter) error { return nil } +func (s *MemoryStore) ClearAdblockFilters() error { return nil } + func (s *MemoryStore) IncrementRuleHit(id string, domain string) error { // Not fully implemented for rules in MemoryStore yet as MemoryStore // doesn't actually store/manage rules in the current implementation. return nil } + +func (s *MemoryStore) IncrementAdblockHit(domain string) error { + s.mu.Lock() + defer s.mu.Unlock() + s.stats.AdblockHits++ + return nil +} diff --git a/internal/store/sqlite.go b/internal/store/sqlite.go index b8b96b0..9c6d829 100644 --- a/internal/store/sqlite.go +++ b/internal/store/sqlite.go @@ -5,6 +5,7 @@ import ( "fmt" "log" "net/http" + "strconv" "strings" "sync" "time" @@ -39,7 +40,7 @@ func NewSQLiteStore(dbPath string) (*SQLiteStore, error) { } // Auto-migrate schema - if err := db.AutoMigrate(&core.LogEntry{}, &core.TrafficStatsModel{}, &core.Rule{}, &core.AppSetting{}); err != nil { + if err := db.AutoMigrate(&core.LogEntry{}, &core.TrafficStatsModel{}, &core.Rule{}, &core.AppSetting{}, &core.AdblockFilter{}); err != nil { return nil, err } @@ -165,10 +166,55 @@ func (s *SQLiteStore) AddTraffic(upload, download int64) { // GetRecentLogs returns the last N logs func (s *SQLiteStore) GetRecentLogs(limit int) []core.LogEntry { var logs []core.LogEntry - s.db.Order("timestamp desc").Limit(limit).Find(&logs) + s.db.Order("id desc").Limit(limit).Find(&logs) return logs } +func (s *SQLiteStore) GetLogsPaginated(cursor string, limit int, search, status, logType string) ([]core.LogEntry, string, bool, int64, error) { + var logs []core.LogEntry + var total int64 + + query := s.db.Model(&core.LogEntry{}) + + if search != "" { + likePattern := "%" + search + "%" + query = query.Where("(domain LIKE ? OR process_name LIKE ? OR dst_ip LIKE ?)", likePattern, likePattern, likePattern) + } + + if status != "" && status != "all" { + query = query.Where("status = ?", status) + } + + if logType != "" && logType != "all" { + query = query.Where("type = ?", logType) + } + + if err := query.Count(&total).Error; err != nil { + return nil, "", false, 0, err + } + + if cursor != "" { + query = query.Where("id < ?", cursor) + } + + if err := query.Order("id desc").Limit(limit + 1).Find(&logs).Error; err != nil { + return nil, "", false, 0, err + } + + hasMore := false + nextCursor := "" + if len(logs) > limit { + hasMore = true + logs = logs[:limit] + } + + if len(logs) > 0 { + nextCursor = logs[len(logs)-1].ID + } + + return logs, nextCursor, hasMore, total, nil +} + // GetStats calculates stats from DB func (s *SQLiteStore) GetStats() core.Stats { var stats core.Stats @@ -209,6 +255,12 @@ func (s *SQLiteStore) GetStats() core.Stats { } } + // Adblock Hits + if val, err := s.GetSetting("adblock_hits"); err == nil { + hits, _ := strconv.ParseInt(val, 10, 64) + stats.AdblockHits = hits + } + stats.Timestamp = time.Now() return stats } @@ -293,7 +345,11 @@ func (s *SQLiteStore) ResetData() { s.db.Exec("DELETE FROM log_entries") s.db.Exec("DELETE FROM traffic_stats_models") s.db.Exec("DELETE FROM stats") + s.db.Exec("DELETE FROM settings") + s.db.Exec("DELETE FROM adblock_filters") s.db.Exec("UPDATE rules SET hit_count = 0") + + s.SetSetting("adblock_hits", "0") } // Rule Management Implementation @@ -463,11 +519,12 @@ func (s *SQLiteStore) seedDefaultRules() { existingRules[domain] = true rules = append(rules, core.Rule{ - ID: utils.GenerateIDString(), - Type: core.RuleBlock, - Pattern: domain, - Enabled: true, - Source: core.RuleSourceDefault, + ID: utils.GenerateIDString(), + Type: core.RuleBlock, + Pattern: domain, + Enabled: true, + Source: core.RuleSourceDefault, + HitCount: 0, }) } } @@ -500,12 +557,12 @@ func (s *SQLiteStore) TruncateRules() error { } func (s *SQLiteStore) IncrementRuleHit(id string, domain string) error { - // De-duplicate hits within a 5-second window per rule/domain + // De-duplicate hits within a 5ms window per rule/domain cacheKey := id + ":" + domain now := time.Now() if lastHit, ok := s.hitCache.Load(cacheKey); ok { - if now.Sub(lastHit.(time.Time)) < 1*time.Second { - // Skip incrementing if last hit was less than 1s ago + if now.Sub(lastHit.(time.Time)) < 5*time.Millisecond { + // Skip incrementing if last hit was less than 5ms ago return nil } } @@ -513,3 +570,48 @@ func (s *SQLiteStore) IncrementRuleHit(id string, domain string) error { return s.db.Model(&core.Rule{}).Where("id = ?", id).Update("hit_count", gorm.Expr("hit_count + 1")).Error } + +func (s *SQLiteStore) IncrementAdblockHit(domain string) error { + // De-duplicate hits within a 50ms window per domain + cacheKey := "adblock:" + domain + now := time.Now() + if lastHit, ok := s.hitCache.Load(cacheKey); ok { + if now.Sub(lastHit.(time.Time)) < 50*time.Millisecond { + return nil + } + } + s.hitCache.Store(cacheKey, now) + + // Fetch current hits + currentHits := int64(0) + val, err := s.GetSetting("adblock_hits") + if err == nil { + currentHits, _ = strconv.ParseInt(val, 10, 64) + } + + // Increment and save + currentHits++ + return s.SetSetting("adblock_hits", strconv.FormatInt(currentHits, 10)) +} + +func (s *SQLiteStore) AddAdblockFilter(filter core.AdblockFilter) error { + return s.db.Create(&filter).Error +} + +func (s *SQLiteStore) GetAdblockFilters() []core.AdblockFilter { + var filters []core.AdblockFilter + s.db.Find(&filters) + return filters +} + +func (s *SQLiteStore) DeleteAdblockFilter(id string) error { + return s.db.Delete(&core.AdblockFilter{}, "id = ?", id).Error +} + +func (s *SQLiteStore) UpdateAdblockFilter(filter core.AdblockFilter) error { + return s.db.Save(&filter).Error +} + +func (s *SQLiteStore) ClearAdblockFilters() error { + return s.db.Exec("DELETE FROM adblock_filters").Error +} diff --git a/internal/utils/os.go b/internal/utils/os.go new file mode 100644 index 0000000..d2a0b0e --- /dev/null +++ b/internal/utils/os.go @@ -0,0 +1,25 @@ +package utils + +import ( + "runtime" +) + +const ( + Windows = "windows" + Darwin = "darwin" + Linux = "linux" + Unknown = "unknown" +) + +func GetOS() string { + switch os := runtime.GOOS; os { + case "windows": + return Windows + case "darwin": + return Darwin + case "linux": + return Linux + default: + return Unknown + } +} diff --git a/lib/adblock/Cargo.lock b/lib/adblock/Cargo.lock new file mode 100644 index 0000000..4aeea40 --- /dev/null +++ b/lib/adblock/Cargo.lock @@ -0,0 +1,591 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adblock" +version = "0.2.0" +dependencies = [ + "adblock 0.9.8", + "libc", +] + +[[package]] +name = "adblock" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02efb0e1ce344fd34dfad0e9f3a20e6d104218cb292925a65ecc2cc0588d9655" +dependencies = [ + "addr", + "base64", + "bitflags", + "idna", + "itertools", + "lifeguard", + "memchr", + "once_cell", + "percent-encoding", + "regex", + "rmp-serde", + "seahash", + "serde", + "serde_json", + "thiserror", + "url", +] + +[[package]] +name = "addr" +version = "0.15.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a93b8a41dbe230ad5087cc721f8d41611de654542180586b315d9f4cf6b72bef" +dependencies = [ + "psl", + "psl-types", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "libc" +version = "0.2.178" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" + +[[package]] +name = "lifeguard" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89be94dbd775db37b46ca4f4bf5cf89adfb13ba197bfbcb69b2122848ee73c26" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "proc-macro2" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "psl" +version = "2.1.169" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdecbdf79d08eb7f62ea92e07e9c639a39700bb9dc3755f3c66fb6aeb8d7ee12" +dependencies = [ + "psl-types", +] + +[[package]] +name = "psl-types" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" + +[[package]] +name = "quote" +version = "1.0.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "regex" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" + +[[package]] +name = "rmp" +version = "0.8.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "228ed7c16fa39782c3b3468e974aec2795e9089153cd08ee2e9aefb3613334c4" +dependencies = [ + "byteorder", + "num-traits", + "paste", +] + +[[package]] +name = "rmp-serde" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "723ecff9ad04f4ad92fe1c8ca6c20d2196d9286e9c60727c4cb5511629260e9d" +dependencies = [ + "byteorder", + "rmp", + "serde", +] + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "seahash" +version = "3.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58f57ca1d128a43733fd71d583e837b1f22239a37ebea09cde11d8d9a9080f47" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.145" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", + "serde_core", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "syn" +version = "2.0.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "url" +version = "2.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/lib/adblock/Cargo.toml b/lib/adblock/Cargo.toml new file mode 100644 index 0000000..01f5be7 --- /dev/null +++ b/lib/adblock/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "adblock" +version = "0.2.0" +edition = "2021" + +[lib] +crate-type = ["staticlib"] + +[dependencies] +adblock = "0.9.0" +libc = "0.2" diff --git a/lib/adblock/build.ps1 b/lib/adblock/build.ps1 new file mode 100644 index 0000000..41643c7 --- /dev/null +++ b/lib/adblock/build.ps1 @@ -0,0 +1,13 @@ +# Build script for libadblock on Windows +Write-Host "Building libadblock Rust library..." + +pushd $PSScriptRoot +cargo build --release +popd + +if ($LASTEXITCODE -eq 0) { + Write-Host "libadblock build successful!" -ForegroundColor Green +} else { + Write-Host "libadblock build failed." -ForegroundColor Red + exit 1 +} diff --git a/lib/adblock/build.sh b/lib/adblock/build.sh new file mode 100644 index 0000000..cca1c18 --- /dev/null +++ b/lib/adblock/build.sh @@ -0,0 +1,13 @@ +#!/bin/bash +# Build script for libadblock on Linux/macOS +echo "Building libadblock Rust library..." + +cd "$(dirname "$0")" +cargo build --release + +if [ $? -eq 0 ]; then + echo "libadblock build successful!" +else + echo "libadblock build failed." + exit 1 +fi diff --git a/lib/adblock/src/lib.rs b/lib/adblock/src/lib.rs new file mode 100644 index 0000000..08a097f --- /dev/null +++ b/lib/adblock/src/lib.rs @@ -0,0 +1,65 @@ +use adblock::Engine; +use adblock::lists::ParseOptions; +use adblock::request::Request; +use std::ffi::CStr; +use std::os::raw::c_char; + +pub struct AdblockEngine { + engine: Engine, +} + +#[no_mangle] +pub extern "C" fn adblock_engine_create(rules: *const c_char) -> *mut AdblockEngine { + if rules.is_null() { + return std::ptr::null_mut(); + } + + let c_str = unsafe { CStr::from_ptr(rules) }; + let rules_str = match c_str.to_str() { + Ok(s) => s, + Err(_) => return std::ptr::null_mut(), + }; + + let filter_lines: Vec = rules_str.lines().map(|s| s.to_string()).collect(); + let filter_refs: Vec<&str> = filter_lines.iter().map(|s| s.as_str()).collect(); + + let engine = Engine::from_rules(&filter_refs, ParseOptions::default()); + + Box::into_raw(Box::new(AdblockEngine { engine })) +} + +#[no_mangle] +pub extern "C" fn adblock_engine_check( + engine: *mut AdblockEngine, + url: *const c_char, + source_url: *const c_char, + resource_type: *const c_char, +) -> bool { + if engine.is_null() || url.is_null() || source_url.is_null() || resource_type.is_null() { + return false; + } + + let engine = unsafe { &*engine }; + + let url_str = unsafe { CStr::from_ptr(url) }.to_str().unwrap_or(""); + let source_url_str = unsafe { CStr::from_ptr(source_url) }.to_str().unwrap_or(""); + let resource_type_str = unsafe { CStr::from_ptr(resource_type) }.to_str().unwrap_or(""); + + let request = match Request::new(url_str, source_url_str, resource_type_str) { + Ok(r) => r, + Err(_) => return false, + }; + + let blocker_result = engine.engine.check_network_request(&request); + + blocker_result.matched +} + +#[no_mangle] +pub extern "C" fn adblock_engine_destroy(engine: *mut AdblockEngine) { + if !engine.is_null() { + unsafe { + drop(Box::from_raw(engine)); + } + } +} diff --git a/main.go b/main.go index d8a9fac..678a95d 100644 --- a/main.go +++ b/main.go @@ -9,6 +9,7 @@ import ( "strings" "github.com/getlantern/systray" + "github.com/vkhangstack/Custos/internal/utils" "github.com/wailsapp/wails/v2" "github.com/wailsapp/wails/v2/pkg/logger" "github.com/wailsapp/wails/v2/pkg/menu" @@ -113,17 +114,20 @@ func main() { BackgroundColour: &options.RGBA{R: 27, G: 38, B: 54, A: 1}, OnStartup: func(ctx context.Context) { app.startup(ctx) - go setupTray(app) + if utils.GetOS() == utils.Windows { + go setupTray(app) + } }, OnShutdown: func(ctx context.Context) { app.shutdown(ctx) - systray.Quit() + if utils.GetOS() == utils.Windows { + systray.Quit() + } }, Menu: AppMenu, Linux: &linux.Options{ - Icon: iconData, - ProgramName: strings.ToLower(app.GetAppInfo().Name), - WebviewGpuPolicy: linux.WebviewGpuPolicyAlways, + Icon: iconData, + ProgramName: strings.ToLower(app.GetAppInfo().Name), }, LogLevel: logger.DEBUG, LogLevelProduction: logger.WARNING, @@ -142,7 +146,7 @@ func main() { func setupTray(app *App) { runtime.LockOSThread() systray.Run(func() { - if runtime.GOOS == "windows" { + if utils.GetOS() == utils.Windows { systray.SetIcon(iconIco) } else { systray.SetIcon(iconData) diff --git a/nfpm.yaml b/nfpm.yaml index 338cef9..72348cc 100644 --- a/nfpm.yaml +++ b/nfpm.yaml @@ -11,7 +11,16 @@ contents: - src: build/bin/Custos dst: /usr/bin/custos type: file + - src: custos.desktop + dst: /usr/share/applications/custos.desktop + type: file + - src: build/appicon.png + dst: /usr/share/pixmaps/custos.png + type: file depends: - libgtk-3-0 - libwebkit2gtk-4.1-0 + +scripts: + postremove: scripts/postremove.sh diff --git a/scripts/postremove.sh b/scripts/postremove.sh new file mode 100755 index 0000000..f8f7eff --- /dev/null +++ b/scripts/postremove.sh @@ -0,0 +1,20 @@ +#!/bin/bash + +# Clean up .custos data for all users +echo "Cleaning up Custos data..." + +# Remove from all user home directories +for user_home in /home/*; do + if [ -d "$user_home/.custos" ]; then + echo "Removing data for user home: $user_home" + rm -rf "$user_home/.custos" + fi +done + +# Also check root +if [ -d "/root/.custos" ]; then + echo "Removing data for root" + rm -rf "/root/.custos" +fi + +exit 0
No filters found.