mirror of
https://github.com/pacnpal/simpleguardhome.git
synced 2026-02-04 19:45:13 -05:00
Compare commits
69 Commits
alert-auto
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
143f2025fa | ||
|
|
52fe72b387 | ||
|
|
276a32b235 | ||
|
|
ac26cc0c02 | ||
|
|
0a7c8d96ed | ||
|
|
6d32553b5f | ||
|
|
99695d5a57 | ||
|
|
c1b65da6e1 | ||
|
|
1bbb265632 | ||
|
|
d99c7b973b | ||
|
|
a858acf4bb | ||
|
|
05ff931ed3 | ||
|
|
3ea87de40e | ||
|
|
577063e1c6 | ||
|
|
519f231b6d | ||
|
|
db2fb20932 | ||
|
|
73bf12b083 | ||
|
|
55ecee5407 | ||
|
|
8629bfc822 | ||
|
|
1d33ebdc36 | ||
|
|
433738d06e | ||
|
|
4f64aabb32 | ||
|
|
480d1c2fa0 | ||
|
|
396703f5ba | ||
|
|
bedacc5631 | ||
|
|
0bb99d3c6d | ||
|
|
719845b1c8 | ||
|
|
99d8265235 | ||
|
|
0d482fa33e | ||
|
|
4ed14db4f7 | ||
|
|
913545c420 | ||
|
|
d9f2308808 | ||
|
|
79e6bc3f9a | ||
|
|
5430ec6ad4 | ||
|
|
65a3429b7a | ||
|
|
c5da870762 | ||
|
|
26c2adf1d5 | ||
|
|
18d1125243 | ||
|
|
ceee425fab | ||
|
|
6baa26eb80 | ||
|
|
31b66c6d65 | ||
|
|
47efc3c4b0 | ||
|
|
1a231ba6f5 | ||
|
|
f18432e46d | ||
|
|
dafcb0278a | ||
|
|
88e29c31a6 | ||
|
|
0144943997 | ||
|
|
216d8137f8 | ||
|
|
170d8a997b | ||
|
|
c0bc1ffbf8 | ||
|
|
0b59d7ac1f | ||
|
|
a8fc3d5746 | ||
|
|
a73c8a3a20 | ||
|
|
0bc9dded41 | ||
|
|
64d09b8842 | ||
|
|
860ed4583b | ||
|
|
0a33684d71 | ||
|
|
818d5f8962 | ||
|
|
1d629ff214 | ||
|
|
1f91b5f06b | ||
|
|
b8ef333639 | ||
|
|
3011065182 | ||
|
|
0c7d5a2a9b | ||
|
|
3079b8ce40 | ||
|
|
b1297b5ada | ||
|
|
57539fa56e | ||
|
|
c9f8ebbe7d | ||
|
|
56e05a967d | ||
|
|
b25fe5cb2b |
@@ -1,47 +1,56 @@
|
||||
# Version control
|
||||
.git
|
||||
.gitignore
|
||||
# ULTIMATE SAFETY IGNORE FILE V9000
|
||||
# DO NOT MODIFY WITHOUT LEVEL 9 CLEARANCE
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
env/
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
# FIRST: IGNORE EVERYTHING (SAFEST OPTION)
|
||||
*
|
||||
|
||||
# Virtual environments
|
||||
venv/
|
||||
ENV/
|
||||
# THEN: CAREFULLY ALLOW ONLY ESSENTIAL FILES
|
||||
# Main source files (REQUIRED)
|
||||
!src/
|
||||
!src/simpleguardhome/
|
||||
!src/simpleguardhome/**/*
|
||||
|
||||
# Critical system files (REQUIRED)
|
||||
!requirements.txt
|
||||
!docker-entrypoint.sh
|
||||
!healthcheck.py
|
||||
!setup.py
|
||||
!pyproject.toml
|
||||
!MANIFEST.in
|
||||
|
||||
# VERIFICATION: Required files that MUST exist:
|
||||
# - src/simpleguardhome/__init__.py
|
||||
# - src/simpleguardhome/main.py
|
||||
# - src/simpleguardhome/adguard.py
|
||||
# - src/simpleguardhome/config.py
|
||||
# - src/simpleguardhome/templates/index.html
|
||||
# - src/simpleguardhome/favicon.ico
|
||||
# - healthcheck.py
|
||||
# - setup.py
|
||||
|
||||
# SAFETY: Never include these files even if allowed above
|
||||
**/__pycache__/
|
||||
**/*.pyc
|
||||
**/*.pyo
|
||||
**/*.pyd
|
||||
**/*.so
|
||||
**/*.egg
|
||||
**/*.egg-info/
|
||||
**/.DS_Store
|
||||
|
||||
# DOUBLE VERIFICATION: These paths must be blocked
|
||||
.git/
|
||||
.env
|
||||
venv/
|
||||
*.log
|
||||
temp/
|
||||
cache/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
# BACKUP PATTERNS: Keep these clean
|
||||
**/backup*/
|
||||
**/rescue*/
|
||||
**/emergency*/
|
||||
|
||||
# Test
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
|
||||
# Project specific
|
||||
rules_backup/
|
||||
|
||||
# Documentation
|
||||
*.md
|
||||
# FINAL VERIFICATION:
|
||||
# If this file is modified, system will verify
|
||||
# all paths during container build
|
||||
3
.github/workflows/docker-build.yml
vendored
3
.github/workflows/docker-build.yml
vendored
@@ -6,9 +6,6 @@ on:
|
||||
paths-ignore:
|
||||
- '**.md'
|
||||
- '.gitignore'
|
||||
pull_request:
|
||||
branches: [ "main" ]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -169,5 +169,5 @@ cython_debug/
|
||||
|
||||
# PyPI configuration file
|
||||
.pypirc
|
||||
rules*.json
|
||||
rules_backup/*.json
|
||||
.DS_Store
|
||||
87
Dockerfile
87
Dockerfile
@@ -1,73 +1,48 @@
|
||||
# Use official Python base image
|
||||
FROM python:3.11-slim-bullseye
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies with architecture-specific handling
|
||||
# Install essential system packages
|
||||
RUN apt-get update && \
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y \
|
||||
--no-install-recommends \
|
||||
gcc \
|
||||
libc6-dev \
|
||||
python3-dev \
|
||||
python3-pip \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& python3 -m pip install --no-cache-dir --upgrade pip setuptools wheel
|
||||
apt-get install -y --no-install-recommends \
|
||||
curl && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Add architecture-specific compiler flags if needed
|
||||
ENV ARCHFLAGS=""
|
||||
|
||||
# Create necessary directories and set permissions
|
||||
RUN mkdir -p /app/src/simpleguardhome && \
|
||||
chmod -R 755 /app
|
||||
|
||||
# Copy source code, maintaining directory structure
|
||||
COPY setup.py requirements.txt /app/
|
||||
COPY src /app/src/
|
||||
|
||||
# Set PYTHONPATH
|
||||
ENV PYTHONPATH=/app/src
|
||||
|
||||
# Install Python requirements
|
||||
# Install Python packages
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Install and verify the package
|
||||
RUN set -e && \
|
||||
echo "Installing package..." && \
|
||||
pip uninstall -y simpleguardhome || true && \
|
||||
# First install dependencies only
|
||||
pip install --no-deps -v -e . && \
|
||||
# Then install package with dependencies
|
||||
pip install -e . && \
|
||||
echo "Verifying installation..." && \
|
||||
pip show simpleguardhome && \
|
||||
# List all package files
|
||||
echo "Package contents:" && \
|
||||
find /app/src/simpleguardhome -type f -ls && \
|
||||
# Verify import works
|
||||
echo "Testing import..." && \
|
||||
python3 -c "import simpleguardhome; from simpleguardhome.main import app; print(f'Package found at: {simpleguardhome.__file__}')" && \
|
||||
echo "Package installation successful"
|
||||
# Copy package files
|
||||
COPY setup.py pyproject.toml MANIFEST.in ./
|
||||
COPY src ./src
|
||||
|
||||
# Copy and set up entrypoint script
|
||||
COPY docker-entrypoint.sh /usr/local/bin/
|
||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
# Install the package
|
||||
RUN pip install -e . && \
|
||||
python3 -c "import simpleguardhome; print('Package found at:', simpleguardhome.__file__)"
|
||||
|
||||
# Create rules backup directory with proper permissions
|
||||
RUN mkdir -p /app/rules_backup && \
|
||||
chmod 777 /app/rules_backup
|
||||
# Set up health check
|
||||
COPY healthcheck.py /usr/local/bin/
|
||||
RUN chmod +x /usr/local/bin/healthcheck.py
|
||||
|
||||
# Default environment variables
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD ["python3", "/usr/local/bin/healthcheck.py"]
|
||||
|
||||
# Environment setup
|
||||
ENV ADGUARD_HOST="http://localhost" \
|
||||
ADGUARD_PORT=3000
|
||||
|
||||
# Expose the application port
|
||||
# Expose application port
|
||||
EXPOSE 8000
|
||||
|
||||
# Volume for persisting rules backups
|
||||
VOLUME ["/app/rules_backup"]
|
||||
# Create rules_backup directory with proper permissions
|
||||
RUN mkdir -p rules_backup && chmod 777 rules_backup
|
||||
|
||||
# Set entrypoint
|
||||
ENTRYPOINT ["docker-entrypoint.sh"]
|
||||
# Copy and set up entrypoint
|
||||
COPY docker-entrypoint.sh /usr/local/bin/
|
||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
ENTRYPOINT ["docker-entrypoint.sh"]
|
||||
|
||||
# Mark rules_backup as a volume
|
||||
VOLUME ["/app/rules_backup"]
|
||||
18
MANIFEST.in
Normal file
18
MANIFEST.in
Normal file
@@ -0,0 +1,18 @@
|
||||
# Include all package Python files
|
||||
graft src/simpleguardhome
|
||||
|
||||
# Include package data files
|
||||
include src/simpleguardhome/favicon.ico
|
||||
include src/simpleguardhome/templates/*.html
|
||||
|
||||
# Include important project files
|
||||
include README.md
|
||||
include LICENSE
|
||||
include requirements.txt
|
||||
include pyproject.toml
|
||||
include setup.py
|
||||
|
||||
# Exclude bytecode files
|
||||
global-exclude *.py[cod]
|
||||
global-exclude __pycache__
|
||||
global-exclude *.so
|
||||
176
README.md
176
README.md
@@ -7,10 +7,10 @@
|
||||
<p align="center">
|
||||
<a href="https://github.com/pacnpal/simpleguardhome/releases"><img src="https://img.shields.io/badge/version-0.1.0-blue.svg" alt="Version 0.1.0"></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-green.svg" alt="MIT License"></a>
|
||||
<a href="#requirements"><img src="https://img.shields.io/badge/python-3.9+-blue.svg" alt="Python 3.9+"></a>
|
||||
<a href="#requirements"><img src="https://img.shields.io/badge/python-3.7+-blue.svg" alt="Python 3.7+"></a>
|
||||
</p>
|
||||
|
||||
A modern web application for checking and managing domain filtering in AdGuard Home. Built with FastAPI and modern JavaScript, following the official AdGuard Home OpenAPI specification.
|
||||
A modern web application for checking and adding domains to custom filtering rules in AdGuard Home. Built with FastAPI and modern JavaScript, following the official AdGuard Home OpenAPI specification. Meant as a simple AdGuard Home web interface for users to check if a domain is blocked, and then add it.
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -27,9 +27,11 @@ Then visit `http://localhost:8000` to start managing your AdGuard Home filtering
|
||||
|
||||
## Features
|
||||
|
||||
### Core Features
|
||||
- 🔍 Real-time domain filtering status checks
|
||||
- 🚫 One-click domain unblocking
|
||||
- 💻 Modern, responsive web interface with Tailwind CSS
|
||||
- 🌓 Support for light and dark modes
|
||||
- 🔄 Live feedback and error handling
|
||||
- 📝 Comprehensive logging
|
||||
- 🏥 Health monitoring endpoint
|
||||
@@ -41,7 +43,7 @@ Then visit `http://localhost:8000` to start managing your AdGuard Home filtering
|
||||
## Requirements
|
||||
|
||||
### System Requirements
|
||||
- Python 3.9 or higher (for local installation)
|
||||
- Python 3.7 or higher (for local installation)
|
||||
- Running AdGuard Home instance
|
||||
- AdGuard Home API credentials
|
||||
- Docker (optional, for containerized deployment)
|
||||
@@ -150,10 +152,16 @@ The application will be available at `http://localhost:8000`
|
||||
|
||||
## API Documentation
|
||||
|
||||
The API documentation is available at:
|
||||
- Swagger UI: `http://localhost:8000/api/docs`
|
||||
- ReDoc: `http://localhost:8000/api/redoc`
|
||||
- OpenAPI Schema: `http://localhost:8000/api/openapi.json`
|
||||
The API documentation is automatically generated by FastAPI using:
|
||||
- Type hints in endpoint definitions
|
||||
- Pydantic models for request/response validation
|
||||
- Function docstrings for descriptions
|
||||
- Response models and status codes
|
||||
|
||||
Documentation is available at:
|
||||
- Swagger UI: `http://localhost:8000/api/docs` - Interactive API documentation
|
||||
- ReDoc: `http://localhost:8000/api/redoc` - Alternative documentation UI
|
||||
- OpenAPI Schema: `http://localhost:8000/api/openapi.json` - Raw OpenAPI specification
|
||||
|
||||
### API Endpoints
|
||||
|
||||
@@ -163,13 +171,19 @@ All endpoints follow the official AdGuard Home API specification:
|
||||
- `GET /` - Main web interface for domain checking and unblocking
|
||||
|
||||
#### Filtering Endpoints
|
||||
- `POST /control/filtering/check_host` - Check if a domain is blocked
|
||||
- `GET /control/filtering/check_host` - Check if a domain is blocked
|
||||
- Parameters: `name` (query parameter)
|
||||
- Returns: Detailed filtering status and rules
|
||||
|
||||
- `POST /control/filtering/whitelist/add` - Add a domain to the allowed list
|
||||
- Parameters: `name` (JSON body)
|
||||
- Returns: Success status
|
||||
- `GET /control/filtering/unblock_host` - Unblock a domain by adding it to whitelist
|
||||
- Parameters: `name` (query parameter)
|
||||
- Returns: Success message with domain status
|
||||
- Status: Returns whether domain was unblocked, already unblocked, or not blocked
|
||||
|
||||
- `POST /control/filtering/set_rules` - Add domains to the filtering rules
|
||||
- Parameters: Array of rules in request body
|
||||
- Returns: Success message on successful update
|
||||
- Note: Used internally by unblock_host endpoint
|
||||
|
||||
- `GET /control/filtering/status` - Get current filtering configuration
|
||||
- Returns: Complete filtering status including rules and filters
|
||||
@@ -178,6 +192,59 @@ All endpoints follow the official AdGuard Home API specification:
|
||||
- `GET /control/status` - Check application and AdGuard Home connection status
|
||||
- Returns: Health status with filtering state
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
simpleguardhome/
|
||||
├── src/
|
||||
│ └── simpleguardhome/
|
||||
│ ├── __init__.py
|
||||
│ ├── main.py # FastAPI application
|
||||
│ ├── config.py # Configuration management
|
||||
│ ├── adguard.py # AdGuard Home API client
|
||||
│ └── templates/
|
||||
│ └── index.html # Web interface
|
||||
├── static/
|
||||
│ └── simpleguardhome.png # Project logo
|
||||
├── rules_backup/ # Backup storage location
|
||||
├── requirements.txt
|
||||
├── setup.py
|
||||
├── pyproject.toml # Project metadata and dependencies
|
||||
├── .env.example
|
||||
├── Dockerfile
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## Security Notes
|
||||
|
||||
- API credentials are handled via environment variables
|
||||
- Connections use proper error handling and timeouts
|
||||
- Input validation is performed on all endpoints
|
||||
- CORS protection with proper headers
|
||||
- Rate limiting on sensitive endpoints
|
||||
- Session-based authentication with AdGuard Home
|
||||
- Sensitive information is not exposed in responses
|
||||
|
||||
## Error Handling
|
||||
|
||||
The application implements comprehensive error handling according to endpoint:
|
||||
|
||||
GET /control/filtering/check_host:
|
||||
- 400: Invalid domain format or missing name parameter
|
||||
- 503: AdGuard Home service unavailable
|
||||
|
||||
GET /control/filtering/unblock_host:
|
||||
- 400: Invalid domain format or missing name parameter
|
||||
- 500: Failed to unblock domain
|
||||
- 503: AdGuard Home service unavailable
|
||||
|
||||
POST /control/filtering/set_rules:
|
||||
- 400: Invalid rule format or missing rules
|
||||
- 500: Failed to update rules
|
||||
- 503: AdGuard Home service unavailable
|
||||
|
||||
All endpoints return an ErrorResponse model with a descriptive message.
|
||||
|
||||
## Response Models
|
||||
|
||||
The application uses Pydantic models that match the AdGuard Home API specification:
|
||||
@@ -200,75 +267,38 @@ The application uses Pydantic models that match the AdGuard Home API specificati
|
||||
}
|
||||
```
|
||||
|
||||
### DomainCheckResult
|
||||
### FilterCheckHostResponse
|
||||
```python
|
||||
{
|
||||
"reason": str, # Filtering status (e.g., "FilteredBlackList")
|
||||
"rule": str, # Applied filtering rule
|
||||
"filter_id": int, # ID of the filter containing the rule
|
||||
"service_name": str, # For blocked services
|
||||
"cname": str, # For CNAME rewrites
|
||||
"ip_addrs": List[str] # For A/AAAA rewrites
|
||||
"reason": str, # Filtering status (e.g., "FilteredBlackList", "NotFilteredNotFound")
|
||||
"filter_id": int, # Optional: ID of the filter containing the rule (deprecated)
|
||||
"rule": str, # Optional: Applied filtering rule (deprecated)
|
||||
"rules": [ # List of applied rules with details
|
||||
{
|
||||
"filter_list_id": int, # Filter list ID
|
||||
"text": str # Rule text
|
||||
}
|
||||
],
|
||||
"service_name": str, # Optional: For blocked services
|
||||
"cname": str, # Optional: For CNAME rewrites
|
||||
"ip_addrs": List[str] # Optional: For A/AAAA rewrites
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
The application implements proper error handling according to the AdGuard Home API spec:
|
||||
|
||||
- 400 Bad Request - Invalid input
|
||||
- 401 Unauthorized - Authentication required
|
||||
- 403 Forbidden - Authentication failed
|
||||
- 502 Bad Gateway - AdGuard Home API error
|
||||
- 503 Service Unavailable - AdGuard Home unreachable
|
||||
|
||||
## Development
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
simpleguardhome/
|
||||
├── src/
|
||||
│ └── simpleguardhome/
|
||||
│ ├── __init__.py
|
||||
│ ├── main.py # FastAPI application
|
||||
│ ├── config.py # Configuration management
|
||||
│ ├── adguard.py # AdGuard Home API client
|
||||
│ └── templates/
|
||||
│ └── index.html # Web interface
|
||||
├── static/
|
||||
│ └── simpleguardhome.png # Project logo
|
||||
├── requirements.txt
|
||||
├── setup.py
|
||||
├── .env.example
|
||||
├── Dockerfile
|
||||
├── docker-compose.yml
|
||||
└── README.md
|
||||
### SetRulesRequest
|
||||
```python
|
||||
{
|
||||
"rules": List[str] # List of filtering rules to set
|
||||
}
|
||||
```
|
||||
|
||||
### Adding New Features
|
||||
|
||||
1. Backend Changes:
|
||||
- Add routes in `main.py`
|
||||
- Extend AdGuard client in `adguard.py`
|
||||
- Update configuration in `config.py`
|
||||
- Follow AdGuard Home OpenAPI spec
|
||||
|
||||
2. Frontend Changes:
|
||||
- Modify `templates/index.html`
|
||||
- Use Tailwind CSS for styling
|
||||
- Follow existing error handling patterns
|
||||
|
||||
## Security Notes
|
||||
|
||||
- API credentials are handled via environment variables
|
||||
- Connections use proper error handling and timeouts
|
||||
- Input validation is performed on all endpoints
|
||||
- CORS protection with proper headers
|
||||
- Rate limiting on sensitive endpoints
|
||||
- Session-based authentication with AdGuard Home
|
||||
- Sensitive information is not exposed in responses
|
||||
### ErrorResponse
|
||||
```python
|
||||
{
|
||||
"message": str # Error description
|
||||
}
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT License - See LICENSE file for details
|
||||
MIT License - See LICENSE file for details
|
||||
|
||||
@@ -1,75 +1,15 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Function to handle termination signals
|
||||
handle_term() {
|
||||
echo "Received SIGTERM/SIGINT, shutting down gracefully..."
|
||||
kill -TERM "$child"
|
||||
wait "$child"
|
||||
exit 0
|
||||
}
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Starting SimpleGuardHome..."
|
||||
|
||||
# Set up signal handlers
|
||||
trap handle_term SIGTERM SIGINT
|
||||
# Ensure proper Python path
|
||||
export PYTHONPATH="/app:${PYTHONPATH:-}"
|
||||
|
||||
# Function to log with timestamp
|
||||
log() {
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1"
|
||||
}
|
||||
|
||||
# Function to check package installation
|
||||
check_package() {
|
||||
log "System information:"
|
||||
uname -a
|
||||
log "Python version:"
|
||||
python3 --version
|
||||
|
||||
log "Verifying package files..."
|
||||
if [ ! -d "/app/src/simpleguardhome" ]; then
|
||||
log "ERROR: Package directory not found!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log "Checking critical files..."
|
||||
for file in "__init__.py" "main.py" "adguard.py" "config.py"; do
|
||||
if [ ! -f "/app/src/simpleguardhome/$file" ]; then
|
||||
log "ERROR: Required file $file not found!"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
log "Environment variables:"
|
||||
echo "PYTHONPATH=$PYTHONPATH"
|
||||
echo "PWD=$(pwd)"
|
||||
|
||||
log "Package contents:"
|
||||
find /app/src/simpleguardhome -type f
|
||||
|
||||
log "Testing package import..."
|
||||
PYTHONPATH=/app/src python3 -c "
|
||||
import sys
|
||||
import simpleguardhome
|
||||
from simpleguardhome.main import app
|
||||
print('Python path:', sys.path)
|
||||
print('Package location:', simpleguardhome.__file__)
|
||||
print('Package imported successfully')
|
||||
" || {
|
||||
log "ERROR: Package import failed!"
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
# Run checks
|
||||
check_package
|
||||
|
||||
log "All checks passed. Starting server..."
|
||||
# Verify package can be imported
|
||||
echo "Verifying package installation..."
|
||||
python3 -c "import simpleguardhome; print('Package found at:', simpleguardhome.__file__)"
|
||||
|
||||
# Start the application
|
||||
echo "Starting SimpleGuardHome server..."
|
||||
exec python3 -c "from simpleguardhome import start; start()"
|
||||
|
||||
# Store child PID
|
||||
child=$!
|
||||
|
||||
# Wait for process to complete
|
||||
wait "$child"
|
||||
exec python3 -m simpleguardhome.main
|
||||
21
healthcheck.py
Normal file
21
healthcheck.py
Normal file
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env python3
|
||||
import sys
|
||||
import httpx
|
||||
import os
|
||||
|
||||
def check_health():
|
||||
try:
|
||||
host = os.environ.get('ADGUARD_HOST', 'localhost')
|
||||
port = os.environ.get('ADGUARD_PORT', '8000')
|
||||
url = f'http://{host}:{port}/health'
|
||||
with httpx.Client() as client:
|
||||
response = client.get(url)
|
||||
response.raise_for_status()
|
||||
print('✅ Service is healthy')
|
||||
sys.exit(0)
|
||||
except Exception as e:
|
||||
print(f'❌ Health check failed: {str(e)}')
|
||||
sys.exit(1)
|
||||
|
||||
if __name__ == '__main__':
|
||||
check_health()
|
||||
32
pyproject.toml
Normal file
32
pyproject.toml
Normal file
@@ -0,0 +1,32 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=64.0.0", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "simpleguardhome"
|
||||
version = "0.1.0"
|
||||
description = "SimpleGuardHome - A lightweight AdGuardHome UI"
|
||||
authors = [
|
||||
{name = "SimpleGuardHome Team"}
|
||||
]
|
||||
readme = "README.md"
|
||||
license = {file = "LICENSE"}
|
||||
requires-python = ">=3.7"
|
||||
dependencies = [
|
||||
"fastapi",
|
||||
"uvicorn",
|
||||
"python-dotenv",
|
||||
"httpx",
|
||||
"pydantic",
|
||||
"jinja2",
|
||||
]
|
||||
|
||||
[tool.setuptools]
|
||||
package-dir = {"" = "src"}
|
||||
packages = ["simpleguardhome"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
simpleguardhome = [
|
||||
"templates/*.html",
|
||||
"favicon.ico"
|
||||
]
|
||||
@@ -1,11 +1,11 @@
|
||||
fastapi==0.115.7
|
||||
uvicorn==0.34.0
|
||||
python-dotenv==1.0.1
|
||||
fastapi==0.128.0
|
||||
uvicorn==0.39.0
|
||||
python-dotenv==1.2.1
|
||||
httpx==0.28.1
|
||||
pydantic==2.10.6
|
||||
pydantic-settings==2.7.1
|
||||
pydantic==2.12.5
|
||||
pydantic-settings==2.11.0
|
||||
pytest>=7.4.4
|
||||
pytest-asyncio>=0.25.2
|
||||
python-multipart==0.0.20
|
||||
jinja2==3.1.5
|
||||
jinja2==3.1.6
|
||||
slowapi==0.1.9
|
||||
3
rules_backup/rules_backups_go_here
Normal file
3
rules_backup/rules_backups_go_here
Normal file
@@ -0,0 +1,3 @@
|
||||
This directory is used for storing AdGuard rule backups.
|
||||
Each backup is stored as a JSON file with a timestamp.
|
||||
These backup files are git-ignored but the directory structure is maintained.
|
||||
48
setup.py
48
setup.py
@@ -1,22 +1,28 @@
|
||||
from setuptools import setup, find_packages
|
||||
from pathlib import Path
|
||||
from setuptools import setup, find_namespace_packages # type: ignore
|
||||
|
||||
setup(
|
||||
name="simpleguardhome",
|
||||
version="0.1.0",
|
||||
packages=find_packages(where="src"),
|
||||
package_dir={"": "src"},
|
||||
include_package_data=True,
|
||||
package_data={
|
||||
"simpleguardhome": ["templates/*", "favicon.ico"]
|
||||
},
|
||||
python_requires=">=3.7",
|
||||
install_requires=[
|
||||
"fastapi",
|
||||
"uvicorn",
|
||||
"python-dotenv",
|
||||
"httpx",
|
||||
"pydantic",
|
||||
"jinja2",
|
||||
],
|
||||
)
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
setup(
|
||||
package_dir={"": "src"},
|
||||
packages=find_namespace_packages(where="src", include=["simpleguardhome*"]),
|
||||
package_data={
|
||||
"simpleguardhome": [
|
||||
"templates/*",
|
||||
"favicon.ico"
|
||||
]
|
||||
},
|
||||
include_package_data=True,
|
||||
install_requires=[
|
||||
"fastapi",
|
||||
"uvicorn",
|
||||
"python-dotenv",
|
||||
"httpx",
|
||||
"pydantic",
|
||||
"jinja2",
|
||||
]
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"\n\nAn error occurred while building the project: {e}\n"
|
||||
"Please ensure you have the most updated version of setuptools, "
|
||||
"setuptools_scm and wheel with:\n"
|
||||
" pip install -U setuptools setuptools_scm wheel\n\n")
|
||||
@@ -74,6 +74,22 @@ def validate_domain(domain: str) -> bool:
|
||||
if not domain or len(domain) > 255:
|
||||
return False
|
||||
return bool(DOMAIN_PATTERN.match(domain))
|
||||
|
||||
def get_parent_domains(domain: str) -> List[str]:
|
||||
"""Get all parent domains for a given domain, excluding the TLD.
|
||||
|
||||
Example:
|
||||
Input: track.soclevercomm.jmsend.com
|
||||
Output: [
|
||||
'track.soclevercomm.jmsend.com',
|
||||
'soclevercomm.jmsend.com',
|
||||
'jmsend.com'
|
||||
]
|
||||
"""
|
||||
parts = domain.split('.')
|
||||
# Only return domains that have at least one subdomain (length >= 2)
|
||||
# This excludes TLDs like 'com', 'net', 'org', etc.
|
||||
return ['.'.join(parts[i:]) for i in range(len(parts)-1)]
|
||||
|
||||
def sanitize_rule(rule: str) -> str:
|
||||
"""Sanitize and validate rule format."""
|
||||
@@ -157,7 +173,7 @@ class AdGuardClient:
|
||||
"""Ensure we have a valid session cookie."""
|
||||
if not self._session_cookie:
|
||||
await self.login()
|
||||
|
||||
|
||||
async def check_domain(self, domain: str) -> FilterCheckHostResponse:
|
||||
"""Check if a domain is blocked by AdGuard Home according to spec."""
|
||||
# Validate domain format
|
||||
@@ -166,7 +182,6 @@ class AdGuardClient:
|
||||
|
||||
await self._ensure_authenticated()
|
||||
url = f"{self.base_url}/filtering/check_host"
|
||||
params = {"name": domain}
|
||||
headers = {}
|
||||
|
||||
if self._session_cookie:
|
||||
@@ -174,16 +189,32 @@ class AdGuardClient:
|
||||
|
||||
try:
|
||||
logger.info(f"Checking domain: {domain}")
|
||||
response = await self.client.get(url, params=params, headers=headers)
|
||||
# Get all parent domains to check (excluding TLD)
|
||||
domains_to_check = get_parent_domains(domain)
|
||||
|
||||
if response.status_code == 401:
|
||||
logger.info("Session expired, attempting reauth")
|
||||
await self.login()
|
||||
if self._session_cookie:
|
||||
headers['Cookie'] = f'agh_session={self._session_cookie}'
|
||||
for check_domain in domains_to_check:
|
||||
params = {"name": check_domain}
|
||||
response = await self.client.get(url, params=params, headers=headers)
|
||||
|
||||
if response.status_code == 401:
|
||||
logger.info("Session expired, attempting reauth")
|
||||
await self.login()
|
||||
if self._session_cookie:
|
||||
headers['Cookie'] = f'agh_session={self._session_cookie}'
|
||||
response = await self.client.get(url, params=params, headers=headers)
|
||||
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
|
||||
# If this domain is filtered, return the result
|
||||
if result.get("reason", "").startswith("Filtered"):
|
||||
logger.info(f"Domain {domain} is filtered due to parent domain {check_domain}")
|
||||
logger.info(f"Domain check result: {result}")
|
||||
return FilterCheckHostResponse(**result)
|
||||
|
||||
response.raise_for_status()
|
||||
# If no parent domains are filtered, return the result for the original domain
|
||||
params = {"name": domain}
|
||||
response = await self.client.get(url, params=params, headers=headers)
|
||||
result = response.json()
|
||||
logger.info(f"Domain check result for {domain}: {result}")
|
||||
return FilterCheckHostResponse(**result)
|
||||
@@ -307,4 +338,4 @@ class AdGuardClient:
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
await self.close()
|
||||
await self.close()
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
from pydantic_settings import BaseSettings
|
||||
from typing import Optional
|
||||
|
||||
from pydantic_settings import BaseSettings # type: ignore
|
||||
|
||||
|
||||
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()
|
||||
settings = Settings()
|
||||
|
||||
@@ -1,25 +1,33 @@
|
||||
from fastapi import FastAPI, Request, Form, HTTPException, status
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from pathlib import Path
|
||||
import httpx
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Dict
|
||||
from . import adguard
|
||||
from .config import settings
|
||||
from .adguard import (
|
||||
AdGuardError,
|
||||
AdGuardConnectionError,
|
||||
AdGuardAPIError,
|
||||
AdGuardValidationError,
|
||||
FilterStatus,
|
||||
FilterCheckHostResponse,
|
||||
SetRulesRequest
|
||||
|
||||
import httpx # noqa: F401
|
||||
from fastapi import ( # type: ignore # noqa: F401
|
||||
FastAPI,
|
||||
Form,
|
||||
HTTPException,
|
||||
Request,
|
||||
status,
|
||||
)
|
||||
from fastapi.middleware.cors import CORSMiddleware # type: ignore
|
||||
from fastapi.responses import HTMLResponse, JSONResponse # type: ignore
|
||||
from fastapi.staticfiles import StaticFiles # type: ignore
|
||||
from fastapi.templating import Jinja2Templates # type: ignore
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from . import adguard
|
||||
from .adguard import (
|
||||
AdGuardAPIError,
|
||||
AdGuardConnectionError,
|
||||
AdGuardError,
|
||||
AdGuardValidationError,
|
||||
FilterCheckHostResponse,
|
||||
FilterStatus,
|
||||
SetRulesRequest,
|
||||
)
|
||||
from .config import settings # noqa: F401
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -51,15 +59,28 @@ templates = Jinja2Templates(directory=str(templates_path))
|
||||
# Mount static files from package directory
|
||||
app.mount("/static", StaticFiles(directory=str(Path(__file__).parent)), name="static")
|
||||
|
||||
# Mount favicon.ico at root
|
||||
static_files_path = Path(__file__).parent
|
||||
app.mount("/favicon.ico", StaticFiles(directory=str(static_files_path)), name="favicon")
|
||||
# Serve favicon.ico directly
|
||||
from fastapi.responses import FileResponse
|
||||
favicon_path = Path(__file__).parent / "favicon.ico"
|
||||
|
||||
@app.get("/favicon.ico")
|
||||
async def favicon():
|
||||
"""Serve favicon."""
|
||||
return FileResponse(favicon_path)
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
"""Health check endpoint."""
|
||||
return {"status": "healthy"}
|
||||
|
||||
# Response models matching AdGuard spec
|
||||
|
||||
|
||||
class ErrorResponse(BaseModel):
|
||||
"""Error response model according to AdGuard spec."""
|
||||
message: str = Field(..., description="The error message")
|
||||
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def home(request: Request):
|
||||
"""Render the home page."""
|
||||
@@ -68,6 +89,7 @@ async def home(request: Request):
|
||||
{"request": request}
|
||||
)
|
||||
|
||||
|
||||
@app.get(
|
||||
"/control/filtering/check_host",
|
||||
response_model=FilterCheckHostResponse,
|
||||
@@ -85,7 +107,7 @@ async def check_domain(name: str) -> FilterCheckHostResponse:
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Domain name is required"
|
||||
)
|
||||
|
||||
|
||||
logger.info(f"Checking domain: {name}")
|
||||
try:
|
||||
async with adguard.AdGuardClient() as client:
|
||||
@@ -96,11 +118,12 @@ async def check_domain(name: str) -> FilterCheckHostResponse:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e)
|
||||
)
|
||||
) from e
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking domain {name}: {str(e)}")
|
||||
raise
|
||||
|
||||
|
||||
@app.post(
|
||||
"/control/filtering/set_rules",
|
||||
response_model=Dict,
|
||||
@@ -118,7 +141,7 @@ async def add_to_whitelist(request: SetRulesRequest) -> Dict:
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Rules are required"
|
||||
)
|
||||
|
||||
|
||||
# Extract domain from whitelist rule
|
||||
rule = request.rules[0]
|
||||
if not rule.startswith("@@||") or not rule.endswith("^"):
|
||||
@@ -126,10 +149,10 @@ async def add_to_whitelist(request: SetRulesRequest) -> Dict:
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid whitelist rule format"
|
||||
)
|
||||
|
||||
|
||||
domain = rule[4:-1] # Remove @@|| prefix and ^ suffix
|
||||
logger.info(f"Adding domain to whitelist: {domain}")
|
||||
|
||||
|
||||
try:
|
||||
async with adguard.AdGuardClient() as client:
|
||||
success = await client.add_allowed_domain(domain)
|
||||
@@ -144,11 +167,12 @@ async def add_to_whitelist(request: SetRulesRequest) -> Dict:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e)
|
||||
)
|
||||
) from e
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding domain to whitelist: {str(e)}")
|
||||
raise
|
||||
|
||||
|
||||
@app.get(
|
||||
"/control/filtering/status",
|
||||
response_model=FilterStatus,
|
||||
@@ -167,8 +191,62 @@ async def get_filtering_status() -> FilterStatus:
|
||||
logger.error(f"Error getting filter status: {str(e)}")
|
||||
raise
|
||||
|
||||
|
||||
@app.get(
|
||||
"/control/filtering/unblock_host",
|
||||
response_model=Dict,
|
||||
responses={
|
||||
200: {"description": "OK"},
|
||||
400: {"description": "Bad Request", "model": ErrorResponse},
|
||||
503: {"description": "AdGuard Home service unavailable", "model": ErrorResponse}
|
||||
},
|
||||
tags=["filtering"]
|
||||
)
|
||||
async def unblock_host(name: str) -> Dict:
|
||||
"""Unblock a domain by adding it to the whitelist if it's blocked."""
|
||||
if not name:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Domain name is required"
|
||||
)
|
||||
|
||||
logger.info(f"Checking domain status: {name}")
|
||||
try:
|
||||
async with adguard.AdGuardClient() as client:
|
||||
# First check if domain is blocked
|
||||
check_result = await client.check_domain(name)
|
||||
|
||||
# If domain isn't blocked, no need to check whitelist or do anything else
|
||||
if check_result.reason != "FilteredBlackList":
|
||||
return {"message": f"Domain {name} is not blocked (Status: {check_result.reason})"}
|
||||
|
||||
# Domain is blocked, check if it's already in whitelist
|
||||
status_rules = await client.get_filter_status()
|
||||
whitelist_rule = f"@@||{name}^"
|
||||
if status_rules.user_rules and whitelist_rule in status_rules.user_rules:
|
||||
return {"message": f"Domain {name} is already unblocked"}
|
||||
|
||||
# Domain is blocked and not in whitelist, proceed with unblocking
|
||||
success = await client.add_allowed_domain(name)
|
||||
if success:
|
||||
return {"message": f"Domain {name} has been unblocked"}
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to unblock domain"
|
||||
)
|
||||
except AdGuardValidationError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e)
|
||||
) from e
|
||||
except Exception as e:
|
||||
logger.error(f"Error unblocking domain: {str(e)}")
|
||||
raise
|
||||
|
||||
|
||||
@app.exception_handler(AdGuardError)
|
||||
async def adguard_exception_handler(request: Request, exc: AdGuardError) -> JSONResponse:
|
||||
async def adguard_exception_handler(_request: Request, exc: AdGuardError) -> JSONResponse:
|
||||
"""Handle AdGuard-related exceptions according to spec."""
|
||||
if isinstance(exc, AdGuardConnectionError):
|
||||
status_code = status.HTTP_503_SERVICE_UNAVAILABLE
|
||||
@@ -178,15 +256,16 @@ async def adguard_exception_handler(request: Request, exc: AdGuardError) -> JSON
|
||||
status_code = status.HTTP_502_BAD_GATEWAY
|
||||
else:
|
||||
status_code = status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
|
||||
|
||||
return JSONResponse(
|
||||
status_code=status_code,
|
||||
content={"message": str(exc)}
|
||||
)
|
||||
|
||||
|
||||
def start():
|
||||
"""Start the application using uvicorn."""
|
||||
import uvicorn
|
||||
import uvicorn # type: ignore
|
||||
uvicorn.run(
|
||||
app,
|
||||
host="0.0.0.0",
|
||||
@@ -194,5 +273,6 @@ def start():
|
||||
reload=False # Disable reload in Docker
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
start()
|
||||
start()
|
||||
|
||||
@@ -1,29 +1,101 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="en" class="dark:bg-gray-900">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>SimpleGuardHome</title>
|
||||
<!-- Load Tailwind first -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
darkMode: 'class'
|
||||
}
|
||||
|
||||
// Initialize theme before page load
|
||||
const storedTheme = localStorage.getItem('color-theme');
|
||||
if (storedTheme === 'dark') {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else if (storedTheme === 'light') {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
</script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/dompurify/2.3.4/purify.min.js" integrity="sha384-KGmzmwrs7oAU2sG5qfETslFsscVcCaxQrX2d7PW7I9bTrsuTD/eSMFr9jaMS9i+b" crossorigin="anonymous"></script>
|
||||
<script>
|
||||
function escapeHtml(unsafe) {
|
||||
return unsafe
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
return unsafe.replace(/[&<>"']/g, function(m) {
|
||||
switch (m) {
|
||||
case '&': return '&';
|
||||
case '<': return '<';
|
||||
case '>': return '>';
|
||||
case '"': return '"';
|
||||
case "'": return ''';
|
||||
default: return m;
|
||||
}
|
||||
});
|
||||
}
|
||||
function renderDomainStatus(resultDiv, unblockDiv, domain, data) {
|
||||
const isBlocked = data.reason.startsWith('Filtered');
|
||||
if (isBlocked) {
|
||||
resultDiv.innerHTML = `
|
||||
<div class="bg-red-100 dark:bg-red-900/30 border-l-4 border-red-500 text-red-700 dark:text-red-300 p-4 mb-4">
|
||||
<p class="font-bold">Domain is blocked</p>
|
||||
<p class="text-sm"><strong>${escapeHtml(domain)}</strong> is blocked</p>
|
||||
<p class="text-sm">Reason: ${escapeHtml(data.reason)}</p>
|
||||
${data.rules?.length ? `<p class="text-sm font-mono bg-red-50 dark:bg-red-900/50 p-2 mt-1 rounded">Rule: ${escapeHtml(data.rules[0].text)}</p>` : ''}
|
||||
${data.service_name ? `<p class="text-sm mt-2">Service: ${escapeHtml(data.service_name)}</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 dark:bg-green-900/30 border-l-4 border-green-500 text-green-700 dark:text-green-300 p-4">
|
||||
<p class="font-bold">Domain is not blocked</p>
|
||||
<p class="text-sm"><strong>${escapeHtml(domain)}</strong> is allowed</p>
|
||||
<p class="text-xs mt-2">Status: ${escapeHtml(data.reason)}</p>
|
||||
</div>`;
|
||||
unblockDiv.innerHTML = '';
|
||||
}
|
||||
}
|
||||
|
||||
function preprocessDomain(input) {
|
||||
// Strip http:// or https:// from the beginning
|
||||
let domain = input.replace(/^https?:\/\//i, '');
|
||||
|
||||
// Strip any paths or query parameters
|
||||
domain = domain.split('/')[0];
|
||||
|
||||
// Basic domain validation
|
||||
const domainRegex = /^[a-zA-Z0-9][a-zA-Z0-9.-]*[a-zA-Z0-9]$/;
|
||||
if (!domainRegex.test(domain)) {
|
||||
throw new Error('Invalid domain format. Please enter a valid domain name (e.g., example.com)');
|
||||
}
|
||||
|
||||
return domain;
|
||||
}
|
||||
|
||||
async function checkDomain(event) {
|
||||
event.preventDefault();
|
||||
const domain = document.getElementById('domain').value;
|
||||
const rawInput = DOMPurify.sanitize(document.getElementById('domain').value);
|
||||
let domain;
|
||||
try {
|
||||
domain = preprocessDomain(rawInput);
|
||||
} catch (error) {
|
||||
resultDiv.innerHTML = `
|
||||
<div class="bg-yellow-100 dark:bg-yellow-900/30 border-l-4 border-yellow-500 text-yellow-700 dark:text-yellow-300 p-4">
|
||||
<p class="font-bold">Invalid Input</p>
|
||||
<p class="text-sm">${escapeHtml(error.message)}</p>
|
||||
</div>`;
|
||||
unblockDiv.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
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>';
|
||||
|
||||
@@ -37,30 +109,7 @@
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
const isBlocked = data.reason.startsWith('Filtered');
|
||||
if (isBlocked) {
|
||||
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>${escapeHtml(domain)}</strong> is blocked</p>
|
||||
<p class="text-sm">Reason: ${escapeHtml(data.reason)}</p>
|
||||
${data.rules?.length ? `<p class="text-sm font-mono bg-red-50 p-2 mt-1 rounded">Rule: ${escapeHtml(data.rules[0].text)}</p>` : ''}
|
||||
${data.service_name ? `<p class="text-sm mt-2">Service: ${escapeHtml(data.service_name)}</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>${escapeHtml(domain)}</strong> is allowed</p>
|
||||
<p class="text-xs mt-2">Status: ${escapeHtml(data.reason)}</p>
|
||||
</div>`;
|
||||
unblockDiv.innerHTML = '';
|
||||
}
|
||||
renderDomainStatus(resultDiv, unblockDiv, domain, data);
|
||||
} else {
|
||||
let errorMsg = data.message || 'Unknown error occurred';
|
||||
let errorType = response.status === 400 ? 'warning' : 'error';
|
||||
@@ -69,7 +118,7 @@
|
||||
resultDiv.innerHTML = `
|
||||
<div class="bg-${bgColor}-100 border-l-4 border-${bgColor}-500 text-${bgColor}-700 p-4">
|
||||
<p class="font-bold">Error checking domain</p>
|
||||
<p class="text-sm">${errorMsg}</p>
|
||||
<p class="text-sm">${escapeHtml(errorMsg)}</p>
|
||||
</div>`;
|
||||
unblockDiv.innerHTML = '';
|
||||
}
|
||||
@@ -139,18 +188,26 @@
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body class="bg-gray-100 min-h-screen">
|
||||
<body class="bg-gray-100 dark:bg-gray-900 min-h-screen transition-colors duration-200">
|
||||
<button id="theme-toggle" class="fixed top-4 right-4 p-2 rounded-lg bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors duration-200">
|
||||
<svg id="theme-toggle-dark-icon" class="hidden w-5 h-5" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"></path>
|
||||
</svg>
|
||||
<svg id="theme-toggle-light-icon" class="hidden w-5 h-5" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<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>
|
||||
<h1 class="text-3xl font-bold text-center mb-8 text-gray-800 dark:text-white">SimpleGuardHome</h1>
|
||||
|
||||
<div class="bg-white rounded-lg shadow-md p-6">
|
||||
<div class="bg-white dark:bg-gray-800 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">
|
||||
<label for="domain" class="block text-gray-700 dark:text-gray-300 text-sm font-bold mb-2">
|
||||
Enter Domain to Check
|
||||
</label>
|
||||
<input type="text" id="domain" name="domain" required pattern="^[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9](\.[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9])*$"
|
||||
class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
|
||||
<input type="text" id="domain" name="domain" required
|
||||
class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 dark:text-gray-200 dark:bg-gray-700 dark:border-gray-600 leading-tight focus:outline-none focus:shadow-outline"
|
||||
placeholder="example.com"
|
||||
title="Please enter a valid domain name">
|
||||
</div>
|
||||
@@ -164,11 +221,64 @@
|
||||
<div id="unblock-action" class="mt-4 text-center"></div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 text-center text-gray-600 text-sm">
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Theme toggle functionality
|
||||
const themeToggleDarkIcon = document.getElementById('theme-toggle-dark-icon');
|
||||
const themeToggleLightIcon = document.getElementById('theme-toggle-light-icon');
|
||||
|
||||
// Change the icons inside the button based on previous settings
|
||||
function setThemeIcons() {
|
||||
if (document.documentElement.classList.contains('dark')) {
|
||||
themeToggleDarkIcon.classList.add('hidden');
|
||||
themeToggleLightIcon.classList.remove('hidden');
|
||||
} else {
|
||||
themeToggleLightIcon.classList.add('hidden');
|
||||
themeToggleDarkIcon.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
// Check for theme preference
|
||||
const storedTheme = localStorage.getItem('color-theme');
|
||||
if (!storedTheme) {
|
||||
// Only use system preference if no stored preference exists
|
||||
const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
document.documentElement.classList.toggle('dark', systemPrefersDark);
|
||||
localStorage.setItem('color-theme', systemPrefersDark ? 'dark' : 'light');
|
||||
}
|
||||
|
||||
|
||||
setThemeIcons();
|
||||
|
||||
// Add click event to toggle button
|
||||
document.getElementById('theme-toggle').addEventListener('click', function() {
|
||||
// Toggle dark class
|
||||
document.documentElement.classList.toggle('dark');
|
||||
|
||||
// Update localStorage
|
||||
if (document.documentElement.classList.contains('dark')) {
|
||||
localStorage.setItem('color-theme', 'dark');
|
||||
} else {
|
||||
localStorage.setItem('color-theme', 'light');
|
||||
}
|
||||
|
||||
setThemeIcons();
|
||||
});
|
||||
|
||||
// System preference changes are now ignored if there's a stored preference
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function(e) {
|
||||
if (!localStorage.getItem('color-theme')) {
|
||||
document.documentElement.classList.toggle('dark', e.matches);
|
||||
localStorage.setItem('color-theme', e.matches ? 'dark' : 'light');
|
||||
setThemeIcons();
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
<div class="mt-4 text-center text-gray-600 dark:text-gray-400 text-sm">
|
||||
Make sure your AdGuard Home instance is running and properly configured in the .env file.
|
||||
<br>
|
||||
<span class="text-xs">Rules are automatically backed up before any changes.</span>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
# SimpleGuardHome 404 Checker Userscript
|
||||
|
||||
A Tampermonkey userscript that detects 404 responses while browsing and automatically checks if the domains are blocked by your AdGuard Home instance. This helps identify when DNS blocking might be causing page load failures.
|
||||
|
||||
## Features
|
||||
|
||||
- Automatically detects 404 responses from both fetch and XMLHttpRequest calls
|
||||
- Checks failed domains against your AdGuard Home instance
|
||||
- Shows notifications for blocked domains with unblock option
|
||||
- Configurable AdGuard Home instance settings
|
||||
- Caches results to minimize API calls
|
||||
- Error handling with configuration shortcuts
|
||||
|
||||
## Installation
|
||||
|
||||
1. Install the [Tampermonkey](https://www.tampermonkey.net/) browser extension
|
||||
2. Click on the Tampermonkey icon and select "Create a new script"
|
||||
3. Copy the contents of `simpleguardhome-404-checker.user.js` into the editor
|
||||
4. Save the script (Ctrl+S or File -> Save)
|
||||
|
||||
## Configuration
|
||||
|
||||
1. Click on the Tampermonkey icon in your browser
|
||||
2. Select "Configure SimpleGuardHome Instance" under the script's menu
|
||||
3. Enter your AdGuard Home host (e.g., `http://localhost`)
|
||||
4. Enter your AdGuard Home port (default: 3000)
|
||||
|
||||
### Default Settings
|
||||
- Host: `http://localhost`
|
||||
- Port: `3000`
|
||||
|
||||
## How It Works
|
||||
|
||||
1. The script monitors all web requests on any website
|
||||
2. When a 404 response is detected:
|
||||
- Extracts the domain from the failed URL
|
||||
- Checks if the domain is blocked by AdGuard Home
|
||||
- Shows a notification if the domain is blocked
|
||||
- Provides a quick "Unblock" button to open SimpleGuardHome
|
||||
|
||||
3. Error handling:
|
||||
- Connection issues show a notification with configuration options
|
||||
- Results are cached for 1 hour to reduce API load
|
||||
- Failed requests provide clear error messages
|
||||
|
||||
## Technical Details
|
||||
|
||||
### Required Permissions
|
||||
- `GM_xmlhttpRequest`: For making cross-origin requests to AdGuard Home
|
||||
- `GM_getValue`/`GM_setValue`: For storing configuration
|
||||
- `GM_registerMenuCommand`: For adding configuration menu
|
||||
- `@connect *`: For connecting to custom AdGuard Home instances
|
||||
|
||||
### Cache System
|
||||
- Domain check results are cached for 1 hour
|
||||
- Cache includes:
|
||||
- Block status
|
||||
- Blocking reason
|
||||
- Applied rules
|
||||
- Timestamp
|
||||
|
||||
### Error Handling
|
||||
- Connection failures
|
||||
- Request timeouts
|
||||
- API errors
|
||||
- JSON parsing errors
|
||||
|
||||
## Development
|
||||
|
||||
The userscript is part of the SimpleGuardHome project and is designed to complement the main application by providing real-time feedback during web browsing.
|
||||
|
||||
To modify or extend the script:
|
||||
1. Make changes to `simpleguardhome-404-checker.user.js`
|
||||
2. Update version number in the metadata block
|
||||
3. Reinstall in Tampermonkey to test changes
|
||||
|
||||
## License
|
||||
|
||||
Same as the main SimpleGuardHome project
|
||||
@@ -1,197 +0,0 @@
|
||||
// ==UserScript==
|
||||
// @name SimpleGuardHome 404 Checker
|
||||
// @namespace http://tampermonkey.net/
|
||||
// @version 0.1
|
||||
// @description Detects 404 responses and checks if they are blocked by AdGuard Home
|
||||
// @author SimpleGuardHome
|
||||
// @match *://*/*
|
||||
// @grant GM_xmlhttpRequest
|
||||
// @grant GM_getValue
|
||||
// @grant GM_setValue
|
||||
// @grant GM_registerMenuCommand
|
||||
// @connect *
|
||||
// @run-at document-start
|
||||
// ==/UserScript==
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// Default configuration
|
||||
const DEFAULT_CONFIG = {
|
||||
host: 'http://localhost',
|
||||
port: 3000
|
||||
};
|
||||
|
||||
// Get current configuration
|
||||
function getConfig() {
|
||||
return {
|
||||
host: GM_getValue('host', DEFAULT_CONFIG.host),
|
||||
port: GM_getValue('port', DEFAULT_CONFIG.port)
|
||||
};
|
||||
}
|
||||
|
||||
// Show configuration dialog
|
||||
function showConfigDialog() {
|
||||
const config = getConfig();
|
||||
const host = prompt('Enter SimpleGuardHome host (e.g. http://localhost):', config.host);
|
||||
if (host === null) return;
|
||||
|
||||
const port = prompt('Enter SimpleGuardHome port:', config.port);
|
||||
if (port === null) return;
|
||||
|
||||
GM_setValue('host', host);
|
||||
GM_setValue('port', parseInt(port, 10) || DEFAULT_CONFIG.port);
|
||||
|
||||
alert('Configuration saved! The new settings will be used for future checks.');
|
||||
}
|
||||
|
||||
// Register configuration menu command
|
||||
GM_registerMenuCommand('Configure SimpleGuardHome Instance', showConfigDialog);
|
||||
|
||||
// Store check results to avoid repeated API calls
|
||||
const checkedDomains = new Map();
|
||||
|
||||
// Intercept 404 responses using a fetch handler
|
||||
const originalFetch = window.fetch;
|
||||
window.fetch = async function(...args) {
|
||||
try {
|
||||
const response = await originalFetch.apply(this, args);
|
||||
if (response.status === 404) {
|
||||
const url = new URL(args[0].toString());
|
||||
checkDomain(url.hostname);
|
||||
}
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('SimpleGuardHome 404 Checker Error:', error);
|
||||
return originalFetch.apply(this, args);
|
||||
}
|
||||
};
|
||||
|
||||
// Also intercept XHR for broader compatibility
|
||||
const originalXHROpen = XMLHttpRequest.prototype.open;
|
||||
XMLHttpRequest.prototype.open = function(method, url, ...rest) {
|
||||
const originalOnReadyStateChange = this.onreadystatechange;
|
||||
this.onreadystatechange = function() {
|
||||
if (this.readyState === 4 && this.status === 404) {
|
||||
const urlObj = new URL(url, window.location.href);
|
||||
checkDomain(urlObj.hostname);
|
||||
}
|
||||
if (originalOnReadyStateChange) {
|
||||
originalOnReadyStateChange.apply(this, arguments);
|
||||
}
|
||||
};
|
||||
originalXHROpen.apply(this, [method, url, ...rest]);
|
||||
};
|
||||
|
||||
// Check if domain is blocked by AdGuard Home
|
||||
async function checkDomain(domain) {
|
||||
// Skip if already checked recently
|
||||
if (checkedDomains.has(domain)) {
|
||||
const cachedResult = checkedDomains.get(domain);
|
||||
if (Date.now() - cachedResult.timestamp < 3600000) { // Cache for 1 hour
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const config = getConfig();
|
||||
const apiUrl = `${config.host}:${config.port}/control/filtering/check_host?name=${encodeURIComponent(domain)}`;
|
||||
|
||||
GM_xmlhttpRequest({
|
||||
method: 'GET',
|
||||
url: apiUrl,
|
||||
headers: {
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
onload: function(response) {
|
||||
try {
|
||||
const data = JSON.parse(response.responseText);
|
||||
const isBlocked = data.reason.startsWith('Filtered');
|
||||
|
||||
// Cache the result
|
||||
checkedDomains.set(domain, {
|
||||
isBlocked,
|
||||
reason: data.reason,
|
||||
rules: data.rules,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
// Show notification if blocked
|
||||
if (isBlocked) {
|
||||
showNotification(domain, data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('SimpleGuardHome parsing error:', error);
|
||||
}
|
||||
},
|
||||
onerror: function(error) {
|
||||
console.error('SimpleGuardHome API error:', error);
|
||||
showNotification(domain, null, 'Unable to connect to SimpleGuardHome instance. Please check your configuration.');
|
||||
},
|
||||
onabort: function() {
|
||||
console.error('SimpleGuardHome API request aborted');
|
||||
showNotification(domain, null, 'Request to SimpleGuardHome instance was aborted. Please check your configuration.');
|
||||
},
|
||||
ontimeout: function() {
|
||||
console.error('SimpleGuardHome API request timed out');
|
||||
showNotification(domain, null, 'Request to SimpleGuardHome instance timed out. Please check your configuration.');
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('SimpleGuardHome check error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Show a notification when a blocked domain is detected
|
||||
function showNotification(domain, data, error = null) {
|
||||
const notification = document.createElement('div');
|
||||
const config = getConfig();
|
||||
|
||||
notification.style.cssText = `
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
padding: 15px;
|
||||
background: ${error ? '#fff3cd' : '#f8d7d9'};
|
||||
border-left: 4px solid ${error ? '#ffc107' : '#dc3545'};
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
|
||||
z-index: 9999;
|
||||
max-width: 400px;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
`;
|
||||
|
||||
if (error) {
|
||||
notification.innerHTML = `
|
||||
<div style="font-weight: bold; margin-bottom: 5px;">SimpleGuardHome Error</div>
|
||||
<div style="font-size: 14px;">${error}</div>
|
||||
<button style="margin-top: 10px; background: #ffc107; color: black; border: none; padding: 5px 10px; border-radius: 3px; cursor: pointer;">Configure Instance</button>
|
||||
`;
|
||||
|
||||
const configButton = notification.querySelector('button');
|
||||
configButton.addEventListener('click', () => {
|
||||
showConfigDialog();
|
||||
notification.remove();
|
||||
});
|
||||
} else {
|
||||
notification.innerHTML = `
|
||||
<div style="font-weight: bold; margin-bottom: 5px;">404 Domain is Blocked</div>
|
||||
<div style="font-size: 14px;"><strong>${domain}</strong></div>
|
||||
<div style="font-size: 12px; margin-top: 5px;">Reason: ${data.reason}</div>
|
||||
${data.rules?.length ? `<div style="font-size: 12px; margin-top: 5px; background: rgba(0,0,0,0.05); padding: 5px; border-radius: 3px;">Rule: ${data.rules[0].text}</div>` : ''}
|
||||
<button style="margin-top: 10px; background: #0d6efd; color: white; border: none; padding: 5px 10px; border-radius: 3px; cursor: pointer;">Unblock Domain</button>
|
||||
`;
|
||||
|
||||
const unblockButton = notification.querySelector('button');
|
||||
unblockButton.addEventListener('click', () => {
|
||||
window.open(`${config.host}:${config.port}/?domain=${encodeURIComponent(domain)}`, '_blank');
|
||||
notification.remove();
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-remove after 10 seconds
|
||||
setTimeout(() => notification.remove(), 10000);
|
||||
|
||||
document.body.appendChild(notification);
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user