Add DNS filtering with dnsmasq address=/ directives for wildcard blocking
Adds Pi-hole-style DNS filtering using dnsmasq's native address=/ directives, which block domains and all subdomains. Users subscribe to blocklists by URL or upload files, with suggested lists from Hagezi, Steven Black, and OISD. Background runner refreshes lists on a configurable interval (default 24h).
This commit is contained in:
501
web/src/components/DnsFilterComponent.tsx
Normal file
501
web/src/components/DnsFilterComponent.tsx
Normal file
@@ -0,0 +1,501 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from './ui/card';
|
||||
import { Button } from './ui/button';
|
||||
import { Input, Label } from './ui';
|
||||
import { Alert, AlertDescription } from './ui/alert';
|
||||
import { Switch } from './ui/switch';
|
||||
import { Badge } from './ui/badge';
|
||||
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from './ui/select';
|
||||
import {
|
||||
ShieldBan, Loader2, Plus, Trash2, RotateCw, Upload,
|
||||
CheckCircle, AlertCircle, X, ExternalLink, List,
|
||||
} from 'lucide-react';
|
||||
import { useDnsFilter } from '../hooks/useDnsFilter';
|
||||
|
||||
export function DnsFilterComponent() {
|
||||
const {
|
||||
status, isLoadingStatus, config,
|
||||
lists, customEntries, suggestedLists,
|
||||
updateConfig, isUpdatingConfig,
|
||||
addList, isAddingList,
|
||||
removeList, toggleList,
|
||||
addCustomEntry, removeCustomEntry,
|
||||
triggerUpdate, isUpdating,
|
||||
uploadList, isUploading,
|
||||
} = useDnsFilter();
|
||||
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
// Add list form
|
||||
const [newListUrl, setNewListUrl] = useState('');
|
||||
const [newListName, setNewListName] = useState('');
|
||||
const [showAddList, setShowAddList] = useState(false);
|
||||
|
||||
// Upload form
|
||||
const [uploadName, setUploadName] = useState('');
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Custom entry form
|
||||
const [showAddCustom, setShowAddCustom] = useState(false);
|
||||
const [newCustomDomain, setNewCustomDomain] = useState('');
|
||||
const [newCustomType, setNewCustomType] = useState<'allow' | 'block'>('block');
|
||||
|
||||
const showSuccess = (msg: string) => {
|
||||
setSuccessMessage(msg);
|
||||
setErrorMessage(null);
|
||||
setTimeout(() => setSuccessMessage(null), 5000);
|
||||
};
|
||||
|
||||
const showError = (msg: string) => {
|
||||
setErrorMessage(msg);
|
||||
setSuccessMessage(null);
|
||||
setTimeout(() => setErrorMessage(null), 8000);
|
||||
};
|
||||
|
||||
const handleToggleEnabled = async () => {
|
||||
try {
|
||||
await updateConfig({ enabled: !status?.enabled });
|
||||
showSuccess(status?.enabled ? 'DNS filtering disabled' : 'DNS filtering enabled');
|
||||
} catch (e) {
|
||||
showError(`Failed to update config: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddList = async () => {
|
||||
if (!newListUrl || !newListName) return;
|
||||
try {
|
||||
await addList({ url: newListUrl, name: newListName });
|
||||
setNewListUrl('');
|
||||
setNewListName('');
|
||||
setShowAddList(false);
|
||||
showSuccess(`Added list: ${newListName}`);
|
||||
} catch (e) {
|
||||
showError(`Failed to add list: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddSuggested = async (url: string, name: string) => {
|
||||
try {
|
||||
await addList({ url, name });
|
||||
showSuccess(`Added list: ${name}`);
|
||||
} catch (e) {
|
||||
showError(`Failed to add list: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = async () => {
|
||||
const file = fileInputRef.current?.files?.[0];
|
||||
if (!file || !uploadName) return;
|
||||
try {
|
||||
await uploadList({ name: uploadName, file });
|
||||
setUploadName('');
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
showSuccess(`Uploaded list: ${uploadName}`);
|
||||
} catch (e) {
|
||||
showError(`Failed to upload: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveList = async (id: string) => {
|
||||
try {
|
||||
await removeList(id);
|
||||
showSuccess('List removed');
|
||||
} catch (e) {
|
||||
showError(`Failed to remove list: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleList = async (id: string, enabled: boolean) => {
|
||||
try {
|
||||
await toggleList({ id, enabled });
|
||||
} catch (e) {
|
||||
showError(`Failed to toggle list: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddCustom = async () => {
|
||||
if (!newCustomDomain) return;
|
||||
try {
|
||||
await addCustomEntry({ domain: newCustomDomain, type: newCustomType });
|
||||
setNewCustomDomain('');
|
||||
setShowAddCustom(false);
|
||||
showSuccess(`Added ${newCustomType} entry for ${newCustomDomain}`);
|
||||
} catch (e) {
|
||||
showError(`Failed to add entry: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveCustom = async (domain: string) => {
|
||||
try {
|
||||
await removeCustomEntry(domain);
|
||||
showSuccess('Custom entry removed');
|
||||
} catch (e) {
|
||||
showError(`Failed to remove entry: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTriggerUpdate = async () => {
|
||||
try {
|
||||
await triggerUpdate();
|
||||
showSuccess('Update triggered — lists will refresh in the background');
|
||||
} catch (e) {
|
||||
showError(`Failed to trigger update: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Filter suggested lists to exclude already-added ones
|
||||
const addedIds = new Set(lists.map(l => l.id));
|
||||
const availableSuggested = suggestedLists.filter(s => !addedIds.has(s.id));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-2 bg-primary/10 rounded-lg">
|
||||
<ShieldBan className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold">DNS Filter</h2>
|
||||
<p className="text-muted-foreground">Block ads, trackers, and malware at the DNS level</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Alerts */}
|
||||
{successMessage && (
|
||||
<Alert className="border-green-500 bg-green-50 dark:bg-green-900/20">
|
||||
<CheckCircle className="h-4 w-4 text-green-600" />
|
||||
<AlertDescription className="flex items-center justify-between">
|
||||
<span className="text-green-800 dark:text-green-200">{successMessage}</span>
|
||||
<Button variant="ghost" size="sm" onClick={() => setSuccessMessage(null)}><X className="h-3 w-3" /></Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{errorMessage && (
|
||||
<Alert variant="error">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription className="flex items-center justify-between">
|
||||
<span>{errorMessage}</span>
|
||||
<Button variant="ghost" size="sm" onClick={() => setErrorMessage(null)}><X className="h-3 w-3" /></Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Status Card */}
|
||||
<Card className="border-l-4 border-l-blue-500">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg">Status</CardTitle>
|
||||
<div className="flex items-center gap-3">
|
||||
{status?.enabled && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleTriggerUpdate}
|
||||
disabled={isUpdating}
|
||||
>
|
||||
{isUpdating ? <Loader2 className="h-4 w-4 animate-spin mr-1" /> : <RotateCw className="h-4 w-4 mr-1" />}
|
||||
Update Now
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor="dns-filter-toggle" className="text-sm">
|
||||
{status?.enabled ? 'Enabled' : 'Disabled'}
|
||||
</Label>
|
||||
<Switch
|
||||
id="dns-filter-toggle"
|
||||
checked={status?.enabled ?? false}
|
||||
onCheckedChange={handleToggleEnabled}
|
||||
disabled={isUpdatingConfig || isLoadingStatus}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
{status?.enabled && (
|
||||
<CardContent className="space-y-4">
|
||||
{/* Query stats */}
|
||||
{status.queryStats && (
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="bg-muted rounded-md p-3 text-center">
|
||||
<p className="text-2xl font-bold font-mono">{status.queryStats.totalQueries.toLocaleString()}</p>
|
||||
<p className="text-xs text-muted-foreground">Queries ({status.queryStats.period})</p>
|
||||
</div>
|
||||
<div className="bg-muted rounded-md p-3 text-center">
|
||||
<p className="text-2xl font-bold font-mono text-red-500">{status.queryStats.blockedQueries.toLocaleString()}</p>
|
||||
<p className="text-xs text-muted-foreground">Blocked</p>
|
||||
</div>
|
||||
<div className="bg-muted rounded-md p-3 text-center">
|
||||
<p className="text-2xl font-bold font-mono text-red-500">{status.queryStats.blockedPercent.toFixed(1)}%</p>
|
||||
<p className="text-xs text-muted-foreground">Block rate</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* List stats */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||
<div className="bg-muted rounded-md p-3 text-center">
|
||||
<p className="text-2xl font-bold font-mono">{status.totalBlocked.toLocaleString()}</p>
|
||||
<p className="text-xs text-muted-foreground">Domains in blocklist</p>
|
||||
</div>
|
||||
<div className="bg-muted rounded-md p-3 text-center">
|
||||
<p className="text-2xl font-bold font-mono">{status.enabledLists}</p>
|
||||
<p className="text-xs text-muted-foreground">Active lists</p>
|
||||
</div>
|
||||
<div className="bg-muted rounded-md p-3 text-center">
|
||||
<p className="text-2xl font-bold font-mono">{status.listCount}</p>
|
||||
<p className="text-xs text-muted-foreground">Total lists</p>
|
||||
</div>
|
||||
<div className="bg-muted rounded-md p-3 text-center">
|
||||
<p className="text-sm font-mono">
|
||||
{status.lastUpdated ? new Date(status.lastUpdated).toLocaleString() : 'Never'}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">Last updated</p>
|
||||
</div>
|
||||
</div>
|
||||
{config && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Lists update every {config.intervalHours || 24} hours
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Blocklists Card */}
|
||||
<Card className="border-l-4 border-l-green-500">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<List className="h-5 w-5" />
|
||||
Blocklists
|
||||
</CardTitle>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setShowAddList(!showAddList)}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Add List
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Add list form */}
|
||||
{showAddList && (
|
||||
<Card className="p-4 bg-muted/50">
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-medium text-sm">Add blocklist by URL</h4>
|
||||
<div>
|
||||
<Label htmlFor="list-name">Name</Label>
|
||||
<Input
|
||||
id="list-name"
|
||||
placeholder="e.g. My Custom Blocklist"
|
||||
value={newListName}
|
||||
onChange={e => setNewListName(e.target.value)}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="list-url">URL</Label>
|
||||
<Input
|
||||
id="list-url"
|
||||
placeholder="https://example.com/blocklist.txt"
|
||||
value={newListUrl}
|
||||
onChange={e => setNewListUrl(e.target.value)}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={handleAddList} disabled={isAddingList || !newListUrl || !newListName}>
|
||||
{isAddingList ? <Loader2 className="h-4 w-4 animate-spin mr-1" /> : <Plus className="h-4 w-4 mr-1" />}
|
||||
Add
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setShowAddList(false)}>Cancel</Button>
|
||||
</div>
|
||||
|
||||
{/* Upload */}
|
||||
<div className="border-t pt-3 mt-3">
|
||||
<h4 className="font-medium text-sm mb-2">Or upload a file</h4>
|
||||
<div className="flex gap-2 items-end">
|
||||
<div className="flex-1">
|
||||
<Label htmlFor="upload-name">Name</Label>
|
||||
<Input
|
||||
id="upload-name"
|
||||
placeholder="My uploaded list"
|
||||
value={uploadName}
|
||||
onChange={e => setUploadName(e.target.value)}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Label htmlFor="upload-file">File</Label>
|
||||
<Input id="upload-file" type="file" ref={fileInputRef} accept=".txt,.hosts,.conf" className="mt-1" />
|
||||
</div>
|
||||
<Button size="sm" onClick={handleUpload} disabled={isUploading || !uploadName}>
|
||||
{isUploading ? <Loader2 className="h-4 w-4 animate-spin mr-1" /> : <Upload className="h-4 w-4 mr-1" />}
|
||||
Upload
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Current lists */}
|
||||
{lists.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<ShieldBan className="h-12 w-12 mx-auto mb-3 opacity-50" />
|
||||
<p className="font-medium">No blocklists added</p>
|
||||
<p className="text-sm mt-1">Add a blocklist URL or choose from the suggestions below</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{lists.map(list => (
|
||||
<div key={list.id} className="flex items-center gap-3 p-3 bg-muted/50 rounded-md">
|
||||
<Switch
|
||||
checked={list.enabled}
|
||||
onCheckedChange={(checked) => handleToggleList(list.id, checked)}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm truncate">{list.name}</span>
|
||||
{list.entryCount > 0 && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{list.entryCount.toLocaleString()} domains
|
||||
</Badge>
|
||||
)}
|
||||
{list.lastError && (
|
||||
<Badge variant="destructive" className="text-xs">Error</Badge>
|
||||
)}
|
||||
</div>
|
||||
{list.url && (
|
||||
<p className="text-xs text-muted-foreground font-mono truncate mt-0.5">{list.url}</p>
|
||||
)}
|
||||
{!list.url && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Uploaded file</p>
|
||||
)}
|
||||
{list.lastError && (
|
||||
<p className="text-xs text-red-500 mt-0.5">{list.lastError}</p>
|
||||
)}
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => handleRemoveList(list.id)}>
|
||||
<Trash2 className="h-4 w-4 text-muted-foreground hover:text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Suggested lists */}
|
||||
{availableSuggested.length > 0 && (
|
||||
<div className="border-t pt-4 mt-4">
|
||||
<h4 className="text-sm font-medium mb-3 text-muted-foreground">Suggested lists</h4>
|
||||
<div className="space-y-2">
|
||||
{availableSuggested.map(suggested => (
|
||||
<div key={suggested.id} className="flex items-center gap-3 p-2 rounded-md hover:bg-muted/50 transition-colors">
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium">{suggested.name}</span>
|
||||
{suggested.url && (
|
||||
<p className="text-xs text-muted-foreground font-mono truncate">{suggested.url}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
{suggested.url && (
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<a href={suggested.url} target="_blank" rel="noopener noreferrer">
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleAddSuggested(suggested.url!, suggested.name)}
|
||||
disabled={isAddingList}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5 mr-1" />
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Custom Entries Card */}
|
||||
<Card className="border-l-4 border-l-blue-500">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg">Custom Entries</CardTitle>
|
||||
<Button variant="outline" size="sm" onClick={() => setShowAddCustom(!showAddCustom)}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Add Entry
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{showAddCustom && (
|
||||
<Card className="p-4 bg-muted/50">
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-3 items-end">
|
||||
<div className="flex-1">
|
||||
<Label htmlFor="custom-domain">Domain</Label>
|
||||
<Input
|
||||
id="custom-domain"
|
||||
placeholder="ads.example.com"
|
||||
value={newCustomDomain}
|
||||
onChange={e => setNewCustomDomain(e.target.value)}
|
||||
className="mt-1 font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-32">
|
||||
<Label htmlFor="custom-type">Type</Label>
|
||||
<Select value={newCustomType} onValueChange={(v) => setNewCustomType(v as 'allow' | 'block')}>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="block">Block</SelectItem>
|
||||
<SelectItem value="allow">Allow</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button size="sm" onClick={handleAddCustom} disabled={!newCustomDomain}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Add
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setShowAddCustom(false)}>Cancel</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<strong>Block</strong> adds a domain to the blocklist. <strong>Allow</strong> overrides blocklists to permit a specific domain.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{customEntries.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
No custom entries. Use allow entries to override blocklists for specific domains.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{customEntries.map(entry => (
|
||||
<div key={entry.domain} className="flex items-center gap-3 p-2 rounded-md hover:bg-muted/50">
|
||||
<Badge variant={entry.type === 'block' ? 'destructive' : 'default'} className="text-xs w-14 justify-center">
|
||||
{entry.type}
|
||||
</Badge>
|
||||
<span className="flex-1 font-mono text-sm">{entry.domain}</span>
|
||||
<Button variant="ghost" size="sm" onClick={() => handleRemoveCustom(entry.domain)}>
|
||||
<Trash2 className="h-4 w-4 text-muted-foreground hover:text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user