diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..630bf99 --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +# AdGuard Home Configuration +ADGUARD_HOST=http://localhost +ADGUARD_PORT=3000 +ADGUARD_USERNAME=admin +ADGUARD_PASSWORD=password \ No newline at end of file diff --git a/README.md b/README.md index dcd8d73..3e87a07 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..1a509ff --- /dev/null +++ b/requirements.txt @@ -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 \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..ce2ba6c --- /dev/null +++ b/setup.py @@ -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", + ], +) \ No newline at end of file diff --git a/src/simpleguardhome/__init__.py b/src/simpleguardhome/__init__.py new file mode 100644 index 0000000..b5f72a0 --- /dev/null +++ b/src/simpleguardhome/__init__.py @@ -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"] \ No newline at end of file diff --git a/src/simpleguardhome/adguard.py b/src/simpleguardhome/adguard.py new file mode 100644 index 0000000..5dd4263 --- /dev/null +++ b/src/simpleguardhome/adguard.py @@ -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() \ No newline at end of file diff --git a/src/simpleguardhome/config.py b/src/simpleguardhome/config.py new file mode 100644 index 0000000..5be14ce --- /dev/null +++ b/src/simpleguardhome/config.py @@ -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() \ No newline at end of file diff --git a/src/simpleguardhome/main.py b/src/simpleguardhome/main.py new file mode 100644 index 0000000..fbc0414 --- /dev/null +++ b/src/simpleguardhome/main.py @@ -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() \ No newline at end of file diff --git a/src/simpleguardhome/templates/index.html b/src/simpleguardhome/templates/index.html new file mode 100644 index 0000000..67670b0 --- /dev/null +++ b/src/simpleguardhome/templates/index.html @@ -0,0 +1,147 @@ + + +
+ + +