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:
2026-07-12 12:44:19 +00:00
parent 134c01fe5e
commit 518cdbbce5
12 changed files with 2009 additions and 2 deletions

View 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>
);
}

View File

@@ -0,0 +1,101 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { dnsFilterApi, type CustomEntry } from '../services/api/dnsfilter';
export function useDnsFilter() {
const queryClient = useQueryClient();
const statusQuery = useQuery({
queryKey: ['dns-filter', 'status'],
queryFn: () => dnsFilterApi.getStatus(),
});
const configQuery = useQuery({
queryKey: ['dns-filter', 'config'],
queryFn: () => dnsFilterApi.getConfig(),
});
const listsQuery = useQuery({
queryKey: ['dns-filter', 'lists'],
queryFn: () => dnsFilterApi.getLists(),
});
const customQuery = useQuery({
queryKey: ['dns-filter', 'custom'],
queryFn: () => dnsFilterApi.getCustomEntries(),
});
const suggestedQuery = useQuery({
queryKey: ['dns-filter', 'suggested'],
queryFn: () => dnsFilterApi.getSuggested(),
staleTime: Infinity,
});
const invalidateAll = () => {
queryClient.invalidateQueries({ queryKey: ['dns-filter'] });
};
const updateConfigMutation = useMutation({
mutationFn: dnsFilterApi.updateConfig,
onSuccess: invalidateAll,
});
const addListMutation = useMutation({
mutationFn: (data: { url: string; name: string }) => dnsFilterApi.addList(data),
onSuccess: invalidateAll,
});
const removeListMutation = useMutation({
mutationFn: (id: string) => dnsFilterApi.removeList(id),
onSuccess: invalidateAll,
});
const toggleListMutation = useMutation({
mutationFn: ({ id, enabled }: { id: string; enabled: boolean }) => dnsFilterApi.toggleList(id, enabled),
onSuccess: invalidateAll,
});
const addCustomMutation = useMutation({
mutationFn: (entry: CustomEntry) => dnsFilterApi.setCustomEntry(entry),
onSuccess: invalidateAll,
});
const removeCustomMutation = useMutation({
mutationFn: (domain: string) => dnsFilterApi.removeCustomEntry(domain),
onSuccess: invalidateAll,
});
const triggerUpdateMutation = useMutation({
mutationFn: () => dnsFilterApi.triggerUpdate(),
onSuccess: () => {
// Delay invalidation to give the runner time to download + compile
setTimeout(invalidateAll, 3000);
},
});
const uploadListMutation = useMutation({
mutationFn: ({ name, file }: { name: string; file: File }) => dnsFilterApi.uploadList(name, file),
onSuccess: invalidateAll,
});
return {
status: statusQuery.data,
isLoadingStatus: statusQuery.isLoading,
config: configQuery.data,
lists: listsQuery.data?.lists ?? [],
customEntries: customQuery.data?.customEntries ?? [],
suggestedLists: suggestedQuery.data?.lists ?? [],
updateConfig: updateConfigMutation.mutateAsync,
isUpdatingConfig: updateConfigMutation.isPending,
addList: addListMutation.mutateAsync,
isAddingList: addListMutation.isPending,
removeList: removeListMutation.mutateAsync,
toggleList: toggleListMutation.mutateAsync,
addCustomEntry: addCustomMutation.mutateAsync,
removeCustomEntry: removeCustomMutation.mutateAsync,
triggerUpdate: triggerUpdateMutation.mutateAsync,
isUpdating: triggerUpdateMutation.isPending,
uploadList: uploadListMutation.mutateAsync,
isUploading: uploadListMutation.isPending,
refetch: invalidateAll,
};
}

View File

@@ -0,0 +1,10 @@
import { ErrorBoundary } from '../../components';
import { DnsFilterComponent } from '../../components/DnsFilterComponent';
export function DnsFilterPage() {
return (
<ErrorBoundary>
<DnsFilterComponent />
</ErrorBoundary>
);
}

View File

@@ -0,0 +1,94 @@
import { apiClient } from './client';
import { getApiBaseUrl } from './config';
export interface QueryStats {
totalQueries: number;
blockedQueries: number;
blockedPercent: number;
period: string;
}
export interface DNSFilterStats {
enabled: boolean;
totalBlocked: number;
listCount: number;
enabledLists: number;
lastUpdated: string;
queryStats?: QueryStats;
}
export interface DNSFilterConfig {
enabled: boolean;
intervalHours: number;
}
export interface Blocklist {
id: string;
url?: string;
name: string;
enabled: boolean;
entryCount: number;
lastUpdated: string;
lastError?: string;
}
export interface CustomEntry {
domain: string;
type: 'allow' | 'block';
}
export const dnsFilterApi = {
getStatus: () =>
apiClient.get<DNSFilterStats>('/api/v1/dns-filter/status'),
getConfig: () =>
apiClient.get<DNSFilterConfig>('/api/v1/dns-filter/config'),
updateConfig: (data: Partial<DNSFilterConfig>) =>
apiClient.put<{ message: string }>('/api/v1/dns-filter/config', data),
getLists: () =>
apiClient.get<{ lists: Blocklist[] }>('/api/v1/dns-filter/lists'),
addList: (data: { url: string; name: string }) =>
apiClient.post<{ message: string }>('/api/v1/dns-filter/lists', data),
removeList: (id: string) =>
apiClient.delete<{ message: string }>(`/api/v1/dns-filter/lists/${id}`),
toggleList: (id: string, enabled: boolean) =>
apiClient.put<{ message: string }>(`/api/v1/dns-filter/lists/${id}/toggle`, { enabled }),
getCustomEntries: () =>
apiClient.get<{ customEntries: CustomEntry[] }>('/api/v1/dns-filter/custom'),
setCustomEntry: (entry: CustomEntry) =>
apiClient.post<{ message: string }>('/api/v1/dns-filter/custom', entry),
removeCustomEntry: (domain: string) =>
apiClient.delete<{ message: string }>(`/api/v1/dns-filter/custom/${domain}`),
triggerUpdate: () =>
apiClient.post<{ message: string }>('/api/v1/dns-filter/update'),
getSuggested: () =>
apiClient.get<{ lists: Blocklist[] }>('/api/v1/dns-filter/lists/suggested'),
uploadList: async (name: string, file: File): Promise<{ id: string; message: string }> => {
const formData = new FormData();
formData.append('name', name);
formData.append('file', file);
const response = await fetch(`${getApiBaseUrl()}/api/v1/dns-filter/lists/upload`, {
method: 'POST',
body: formData,
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || `HTTP ${response.status}`);
}
return response.json();
},
};