Add initial implementation of SimpleGuardHome web app with configuration and dependencies

This commit is contained in:
pacnpal
2025-01-28 14:46:36 +00:00
parent a4a1d4d379
commit e44a6685c8
9 changed files with 535 additions and 5 deletions

5
.env.example Normal file
View File

@@ -0,0 +1,5 @@
# AdGuard Home Configuration
ADGUARD_HOST=http://localhost
ADGUARD_PORT=3000
ADGUARD_USERNAME=admin
ADGUARD_PASSWORD=password

View File

@@ -1,13 +1,65 @@
# SimpleGuardHome
**WARNING** Very early work in progress. Use at your own risk.
SimpleGuardHome is a Python project designed to provide a simple method to check if a domain is blocked by AdGuard Home and then add a custom rule for it.
A simple web application for checking and managing domain filtering in AdGuard Home.
## Features
- Check if a domain is blocked by AdGuard Home.
- Add a custom blocking rule for a domain in AdGuard Home.
- Check if domains are blocked by your AdGuard Home instance
- One-click domain unblocking
- Modern, responsive web interface
- Secure integration with AdGuard Home API
## Setup
1. Clone this repository:
```bash
git clone https://github.com/yourusername/simpleguardhome.git
cd simpleguardhome
```
2. Create a virtual environment and install dependencies:
```bash
python -m venv venv
source venv/bin/activate # On Windows use: venv\Scripts\activate
pip install -r requirements.txt
```
3. Configure your environment:
```bash
cp .env.example .env
```
Edit `.env` with your AdGuard Home instance details:
```
ADGUARD_HOST=http://localhost
ADGUARD_PORT=3000
ADGUARD_USERNAME=your_username
ADGUARD_PASSWORD=your_password
```
## Running the Application
Start the application:
```bash
python -m src.simpleguardhome.main
```
Visit `http://localhost:8000` in your web browser.
## Usage
1. Enter a domain in the input field
2. Click "Check Domain" or press Enter
3. View the domain's blocking status
4. If blocked, use the "Unblock Domain" button to whitelist it
## Development
The application is built with:
- FastAPI for the backend
- Tailwind CSS for styling
- Modern JavaScript for frontend interactivity
## License
This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.
MIT License - See LICENSE file for details

10
requirements.txt Normal file
View File

@@ -0,0 +1,10 @@
fastapi==0.109.0
uvicorn==0.27.0
python-dotenv==1.0.0
httpx==0.26.0
pydantic==2.5.3
pydantic-settings==2.1.0
pytest==7.4.4
pytest-asyncio==0.23.3
python-multipart==0.0.6
jinja2==3.1.3

19
setup.py Normal file
View File

@@ -0,0 +1,19 @@
from setuptools import setup, find_packages
setup(
name="simpleguardhome",
version="0.1.0",
packages=find_packages(),
include_package_data=True,
package_data={
"simpleguardhome": ["templates/*"]
},
install_requires=[
"fastapi",
"uvicorn",
"python-dotenv",
"httpx",
"pydantic",
"jinja2",
],
)

View File

@@ -0,0 +1,6 @@
"""SimpleGuardHome - Web app for managing AdGuard Home domain filtering."""
from .main import app, start
__version__ = "0.1.0"
__all__ = ["app", "start"]

View File

@@ -0,0 +1,140 @@
from typing import Dict, List, Optional
import httpx
import logging
from .config import settings
# Configure logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
class AdGuardError(Exception):
"""Base exception for AdGuard Home API errors."""
pass
class AdGuardConnectionError(AdGuardError):
"""Raised when connection to AdGuard Home fails."""
pass
class AdGuardAPIError(AdGuardError):
"""Raised when AdGuard Home API returns an error."""
pass
class AdGuardClient:
"""Client for interacting with AdGuard Home API."""
def __init__(self):
"""Initialize the AdGuard Home API client."""
self.base_url = settings.adguard_base_url
self.client = httpx.AsyncClient(timeout=10.0) # 10 second timeout
self._auth = None
if settings.ADGUARD_USERNAME and settings.ADGUARD_PASSWORD:
self._auth = (settings.ADGUARD_USERNAME, settings.ADGUARD_PASSWORD)
logger.info(f"Initialized AdGuard Home client with base URL: {self.base_url}")
async def check_domain(self, domain: str) -> Dict:
"""Check if a domain is blocked by AdGuard Home.
Args:
domain: The domain to check
Returns:
Dict containing the filtering status
Raises:
AdGuardConnectionError: If connection to AdGuard Home fails
AdGuardAPIError: If AdGuard Home API returns an error
"""
url = f"{self.base_url}/filtering/check_host"
params = {"name": domain}
try:
logger.info(f"Checking domain: {domain}")
response = await self.client.get(url, params=params, auth=self._auth)
response.raise_for_status()
result = response.json()
logger.info(f"Domain check result for {domain}: {result}")
return result
except httpx.ConnectError as e:
logger.error(f"Connection error while checking domain {domain}: {str(e)}")
raise AdGuardConnectionError(f"Failed to connect to AdGuard Home: {str(e)}")
except httpx.HTTPError as e:
logger.error(f"HTTP error while checking domain {domain}: {str(e)}")
raise AdGuardAPIError(f"AdGuard Home API error: {str(e)}")
except Exception as e:
logger.error(f"Unexpected error while checking domain {domain}: {str(e)}")
raise AdGuardError(f"Unexpected error: {str(e)}")
async def get_filter_status(self) -> Dict:
"""Get the current filtering status.
Returns:
Dict containing the filtering status
Raises:
AdGuardConnectionError: If connection to AdGuard Home fails
AdGuardAPIError: If AdGuard Home API returns an error
"""
url = f"{self.base_url}/filtering/status"
try:
logger.info("Getting filter status")
response = await self.client.get(url, auth=self._auth)
response.raise_for_status()
result = response.json()
logger.info("Successfully retrieved filter status")
return result
except httpx.ConnectError as e:
logger.error(f"Connection error while getting filter status: {str(e)}")
raise AdGuardConnectionError(f"Failed to connect to AdGuard Home: {str(e)}")
except httpx.HTTPError as e:
logger.error(f"HTTP error while getting filter status: {str(e)}")
raise AdGuardAPIError(f"AdGuard Home API error: {str(e)}")
except Exception as e:
logger.error(f"Unexpected error while getting filter status: {str(e)}")
raise AdGuardError(f"Unexpected error: {str(e)}")
async def add_allowed_domain(self, domain: str) -> bool:
"""Add a domain to the allowed list.
Args:
domain: The domain to allow
Returns:
bool: True if successful
Raises:
AdGuardConnectionError: If connection to AdGuard Home fails
AdGuardAPIError: If AdGuard Home API returns an error
"""
url = f"{self.base_url}/filtering/whitelist/add"
data = {"name": domain}
try:
logger.info(f"Adding domain to whitelist: {domain}")
response = await self.client.post(url, json=data, auth=self._auth)
response.raise_for_status()
logger.info(f"Successfully added {domain} to whitelist")
return True
except httpx.ConnectError as e:
logger.error(f"Connection error while whitelisting domain {domain}: {str(e)}")
raise AdGuardConnectionError(f"Failed to connect to AdGuard Home: {str(e)}")
except httpx.HTTPError as e:
logger.error(f"HTTP error while whitelisting domain {domain}: {str(e)}")
raise AdGuardAPIError(f"AdGuard Home API error: {str(e)}")
except Exception as e:
logger.error(f"Unexpected error while whitelisting domain {domain}: {str(e)}")
raise AdGuardError(f"Unexpected error: {str(e)}")
async def close(self):
"""Close the HTTP client."""
await self.client.aclose()
logger.info("Closed AdGuard Home client")
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
await self.close()

View File

@@ -0,0 +1,22 @@
from pydantic_settings import BaseSettings
from typing import Optional
class Settings(BaseSettings):
"""Application settings using environment variables."""
ADGUARD_HOST: str = "http://localhost"
ADGUARD_PORT: int = 3000
ADGUARD_USERNAME: Optional[str] = None
ADGUARD_PASSWORD: Optional[str] = None
@property
def adguard_base_url(self) -> str:
"""Get the base URL for AdGuard Home API."""
return f"{self.ADGUARD_HOST}:{self.ADGUARD_PORT}"
class Config:
env_file = ".env"
settings = Settings()

129
src/simpleguardhome/main.py Normal file
View File

@@ -0,0 +1,129 @@
from fastapi import FastAPI, Request, Form, HTTPException
from fastapi.templating import Jinja2Templates
from fastapi.staticfiles import StaticFiles
from fastapi.responses import HTMLResponse, JSONResponse
from pathlib import Path
import httpx
import logging
from typing import Dict
from . import adguard
from .config import settings
from .adguard import AdGuardError, AdGuardConnectionError, AdGuardAPIError
# Configure logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
app = FastAPI(title="SimpleGuardHome")
# Setup templates directory
templates_path = Path(__file__).parent / "templates"
templates = Jinja2Templates(directory=str(templates_path))
@app.get("/", response_class=HTMLResponse)
async def home(request: Request):
"""Render the home page."""
return templates.TemplateResponse(
"index.html",
{"request": request}
)
@app.get("/health")
async def health_check() -> Dict:
"""Check the health of the application and AdGuard Home connection."""
try:
async with adguard.AdGuardClient() as client:
status = await client.get_filter_status()
return {
"status": "healthy",
"adguard_connection": "connected",
"filtering_enabled": status.get("enabled", False)
}
except AdGuardConnectionError:
return {
"status": "degraded",
"adguard_connection": "failed",
"error": "Could not connect to AdGuard Home"
}
except Exception as e:
logger.error(f"Health check failed: {str(e)}")
return {
"status": "error",
"error": str(e)
}
@app.exception_handler(AdGuardError)
async def adguard_exception_handler(request: Request, exc: AdGuardError) -> JSONResponse:
"""Handle AdGuard-related exceptions."""
if isinstance(exc, AdGuardConnectionError):
status_code = 503 # Service Unavailable
elif isinstance(exc, AdGuardAPIError):
status_code = 502 # Bad Gateway
else:
status_code = 500 # Internal Server Error
return JSONResponse(
status_code=status_code,
content={
"success": False,
"error": exc.__class__.__name__,
"detail": str(exc)
}
)
@app.post("/check-domain")
async def check_domain(domain: str = Form(...)) -> Dict:
"""Check if a domain is blocked by AdGuard Home."""
if not domain:
raise HTTPException(status_code=400, detail="Domain is required")
logger.info(f"Checking domain: {domain}")
try:
async with adguard.AdGuardClient() as client:
result = await client.check_domain(domain)
response = {
"success": True,
"domain": domain,
"blocked": result.get("filtered", False),
"rule": result.get("rule", ""),
"filter_list": result.get("filter_list", "")
}
logger.info(f"Domain check result: {response}")
return response
except Exception as e:
logger.error(f"Error checking domain {domain}: {str(e)}")
raise
@app.post("/unblock-domain")
async def unblock_domain(domain: str = Form(...)) -> Dict:
"""Add a domain to the allowed list."""
if not domain:
raise HTTPException(status_code=400, detail="Domain is required")
logger.info(f"Unblocking domain: {domain}")
try:
async with adguard.AdGuardClient() as client:
await client.add_allowed_domain(domain)
response = {
"success": True,
"domain": domain,
"message": f"Successfully unblocked {domain}"
}
logger.info(f"Domain unblock result: {response}")
return response
except Exception as e:
logger.error(f"Error unblocking domain {domain}: {str(e)}")
raise
def start():
"""Start the application using uvicorn."""
import uvicorn
uvicorn.run(
"simpleguardhome.main:app",
host="0.0.0.0",
port=8000,
reload=True
)
if __name__ == "__main__":
start()

View File

@@ -0,0 +1,147 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SimpleGuardHome</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
async function checkDomain(event) {
event.preventDefault();
const domain = document.getElementById('domain').value;
const resultDiv = document.getElementById('result');
const unblockDiv = document.getElementById('unblock-action');
const submitBtn = document.getElementById('submit-btn');
try {
// Show loading state
submitBtn.disabled = true;
submitBtn.innerHTML = '<span class="inline-flex items-center">Checking... <svg class="animate-spin ml-2 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg></span>';
const response = await fetch('/check-domain', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: `domain=${encodeURIComponent(domain)}`
});
const data = await response.json();
if (data.success) {
if (data.blocked) {
resultDiv.innerHTML = `
<div class="bg-red-100 border-l-4 border-red-500 text-red-700 p-4 mb-4">
<p class="font-bold">Domain is blocked</p>
<p class="text-sm"><strong>${domain}</strong> is blocked by rule:</p>
<p class="text-sm font-mono bg-red-50 p-2 mt-1 rounded">${data.rule || 'Unknown rule'}</p>
${data.filter_list ? `<p class="text-sm mt-2">Filter List: ${data.filter_list}</p>` : ''}
</div>`;
unblockDiv.innerHTML = `
<button onclick="unblockDomain('${domain}')"
class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded transition-colors duration-200">
Unblock Domain
</button>`;
} else {
resultDiv.innerHTML = `
<div class="bg-green-100 border-l-4 border-green-500 text-green-700 p-4">
<p class="font-bold">Domain is not blocked</p>
<p class="text-sm"><strong>${domain}</strong> is allowed</p>
</div>`;
unblockDiv.innerHTML = '';
}
} else {
// Show error message
resultDiv.innerHTML = `
<div class="bg-yellow-100 border-l-4 border-yellow-500 text-yellow-700 p-4">
<p class="font-bold">${data.error}</p>
<p class="text-sm">${data.detail}</p>
</div>`;
unblockDiv.innerHTML = '';
}
} catch (error) {
resultDiv.innerHTML = `
<div class="bg-red-100 border-l-4 border-red-500 text-red-700 p-4">
<p class="font-bold">Error checking domain</p>
<p class="text-sm">${error.message}</p>
</div>`;
unblockDiv.innerHTML = '';
} finally {
// Reset button state
submitBtn.disabled = false;
submitBtn.innerHTML = 'Check Domain';
}
}
async function unblockDomain(domain) {
const resultDiv = document.getElementById('result');
const unblockDiv = document.getElementById('unblock-action');
const unblockBtn = unblockDiv.querySelector('button');
try {
// Show loading state
unblockBtn.disabled = true;
unblockBtn.innerHTML = '<span class="inline-flex items-center">Unblocking... <svg class="animate-spin ml-2 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg></span>';
const response = await fetch('/unblock-domain', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: `domain=${encodeURIComponent(domain)}`
});
const data = await response.json();
if (data.success) {
resultDiv.innerHTML = `
<div class="bg-green-100 border-l-4 border-green-500 text-green-700 p-4">
<p class="font-bold">Success!</p>
<p class="text-sm">${data.message}</p>
</div>`;
unblockDiv.innerHTML = '';
} else {
resultDiv.innerHTML = `
<div class="bg-yellow-100 border-l-4 border-yellow-500 text-yellow-700 p-4">
<p class="font-bold">${data.error}</p>
<p class="text-sm">${data.detail}</p>
</div>`;
}
} catch (error) {
resultDiv.innerHTML = `
<div class="bg-red-100 border-l-4 border-red-500 text-red-700 p-4">
<p class="font-bold">Error unblocking domain</p>
<p class="text-sm">${error.message}</p>
</div>`;
}
}
</script>
</head>
<body class="bg-gray-100 min-h-screen">
<div class="container mx-auto px-4 py-8 max-w-2xl">
<h1 class="text-3xl font-bold text-center mb-8 text-gray-800">SimpleGuardHome</h1>
<div class="bg-white rounded-lg shadow-md p-6">
<form onsubmit="checkDomain(event)" class="mb-6">
<div class="mb-4">
<label for="domain" class="block text-gray-700 text-sm font-bold mb-2">
Enter Domain to Check
</label>
<input type="text" id="domain" name="domain" required
class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
placeholder="example.com">
</div>
<button id="submit-btn" type="submit"
class="bg-green-500 hover:bg-green-700 text-white font-bold py-2 px-4 rounded w-full transition-colors duration-200">
Check Domain
</button>
</form>
<div id="result"></div>
<div id="unblock-action" class="mt-4 text-center"></div>
</div>
<div class="mt-4 text-center text-gray-600 text-sm">
Make sure your AdGuard Home instance is running and properly configured in the .env file.
</div>
</div>
</body>
</html>