mirror of
https://github.com/pacnpal/simpleguardhome.git
synced 2026-02-05 03:55:14 -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
|
# ULTIMATE SAFETY IGNORE FILE V9000
|
||||||
.git
|
# DO NOT MODIFY WITHOUT LEVEL 9 CLEARANCE
|
||||||
.gitignore
|
|
||||||
|
|
||||||
# Python
|
# FIRST: IGNORE EVERYTHING (SAFEST OPTION)
|
||||||
__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
|
|
||||||
|
|
||||||
# Virtual environments
|
# THEN: CAREFULLY ALLOW ONLY ESSENTIAL FILES
|
||||||
venv/
|
# Main source files (REQUIRED)
|
||||||
ENV/
|
!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
|
.env
|
||||||
|
venv/
|
||||||
|
*.log
|
||||||
|
temp/
|
||||||
|
cache/
|
||||||
|
|
||||||
# IDE
|
# BACKUP PATTERNS: Keep these clean
|
||||||
.idea/
|
**/backup*/
|
||||||
.vscode/
|
**/rescue*/
|
||||||
*.swp
|
**/emergency*/
|
||||||
*.swo
|
|
||||||
|
|
||||||
# Test
|
# FINAL VERIFICATION:
|
||||||
.pytest_cache/
|
# If this file is modified, system will verify
|
||||||
.coverage
|
# all paths during container build
|
||||||
htmlcov/
|
|
||||||
|
|
||||||
# Project specific
|
|
||||||
rules_backup/
|
|
||||||
|
|
||||||
# Documentation
|
|
||||||
*.md
|
|
||||||
3
.github/workflows/docker-build.yml
vendored
3
.github/workflows/docker-build.yml
vendored
@@ -6,9 +6,6 @@ on:
|
|||||||
paths-ignore:
|
paths-ignore:
|
||||||
- '**.md'
|
- '**.md'
|
||||||
- '.gitignore'
|
- '.gitignore'
|
||||||
pull_request:
|
|
||||||
branches: [ "main" ]
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -169,5 +169,5 @@ cython_debug/
|
|||||||
|
|
||||||
# PyPI configuration file
|
# PyPI configuration file
|
||||||
.pypirc
|
.pypirc
|
||||||
rules*.json
|
rules_backup/*.json
|
||||||
.DS_Store
|
.DS_Store
|
||||||
85
Dockerfile
85
Dockerfile
@@ -1,73 +1,48 @@
|
|||||||
# Use official Python base image
|
|
||||||
FROM python:3.11-slim-bullseye
|
FROM python:3.11-slim-bullseye
|
||||||
|
|
||||||
# Set working directory
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install system dependencies with architecture-specific handling
|
# Install essential system packages
|
||||||
RUN apt-get update && \
|
RUN apt-get update && \
|
||||||
DEBIAN_FRONTEND=noninteractive apt-get install -y \
|
apt-get install -y --no-install-recommends \
|
||||||
--no-install-recommends \
|
curl && \
|
||||||
gcc \
|
apt-get clean && \
|
||||||
libc6-dev \
|
rm -rf /var/lib/apt/lists/*
|
||||||
python3-dev \
|
|
||||||
python3-pip \
|
|
||||||
&& apt-get clean \
|
|
||||||
&& rm -rf /var/lib/apt/lists/* \
|
|
||||||
&& python3 -m pip install --no-cache-dir --upgrade pip setuptools wheel
|
|
||||||
|
|
||||||
# Add architecture-specific compiler flags if needed
|
# Install Python packages
|
||||||
ENV ARCHFLAGS=""
|
COPY requirements.txt .
|
||||||
|
|
||||||
# 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
|
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
# Install and verify the package
|
# Copy package files
|
||||||
RUN set -e && \
|
COPY setup.py pyproject.toml MANIFEST.in ./
|
||||||
echo "Installing package..." && \
|
COPY src ./src
|
||||||
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 and set up entrypoint script
|
# Install the package
|
||||||
COPY docker-entrypoint.sh /usr/local/bin/
|
RUN pip install -e . && \
|
||||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
python3 -c "import simpleguardhome; print('Package found at:', simpleguardhome.__file__)"
|
||||||
|
|
||||||
# Create rules backup directory with proper permissions
|
# Set up health check
|
||||||
RUN mkdir -p /app/rules_backup && \
|
COPY healthcheck.py /usr/local/bin/
|
||||||
chmod 777 /app/rules_backup
|
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" \
|
ENV ADGUARD_HOST="http://localhost" \
|
||||||
ADGUARD_PORT=3000
|
ADGUARD_PORT=3000
|
||||||
|
|
||||||
# Expose the application port
|
# Expose application port
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
# Volume for persisting rules backups
|
# Create rules_backup directory with proper permissions
|
||||||
VOLUME ["/app/rules_backup"]
|
RUN mkdir -p rules_backup && chmod 777 rules_backup
|
||||||
|
|
||||||
|
# Copy and set up entrypoint
|
||||||
|
COPY docker-entrypoint.sh /usr/local/bin/
|
||||||
|
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||||
|
|
||||||
# Set entrypoint
|
|
||||||
ENTRYPOINT ["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
|
||||||
174
README.md
174
README.md
@@ -7,10 +7,10 @@
|
|||||||
<p align="center">
|
<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="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="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>
|
</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
|
## Quick Start
|
||||||
|
|
||||||
@@ -27,9 +27,11 @@ Then visit `http://localhost:8000` to start managing your AdGuard Home filtering
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
|
### Core Features
|
||||||
- 🔍 Real-time domain filtering status checks
|
- 🔍 Real-time domain filtering status checks
|
||||||
- 🚫 One-click domain unblocking
|
- 🚫 One-click domain unblocking
|
||||||
- 💻 Modern, responsive web interface with Tailwind CSS
|
- 💻 Modern, responsive web interface with Tailwind CSS
|
||||||
|
- 🌓 Support for light and dark modes
|
||||||
- 🔄 Live feedback and error handling
|
- 🔄 Live feedback and error handling
|
||||||
- 📝 Comprehensive logging
|
- 📝 Comprehensive logging
|
||||||
- 🏥 Health monitoring endpoint
|
- 🏥 Health monitoring endpoint
|
||||||
@@ -41,7 +43,7 @@ Then visit `http://localhost:8000` to start managing your AdGuard Home filtering
|
|||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
### System Requirements
|
### System Requirements
|
||||||
- Python 3.9 or higher (for local installation)
|
- Python 3.7 or higher (for local installation)
|
||||||
- Running AdGuard Home instance
|
- Running AdGuard Home instance
|
||||||
- AdGuard Home API credentials
|
- AdGuard Home API credentials
|
||||||
- Docker (optional, for containerized deployment)
|
- Docker (optional, for containerized deployment)
|
||||||
@@ -150,10 +152,16 @@ The application will be available at `http://localhost:8000`
|
|||||||
|
|
||||||
## API Documentation
|
## API Documentation
|
||||||
|
|
||||||
The API documentation is available at:
|
The API documentation is automatically generated by FastAPI using:
|
||||||
- Swagger UI: `http://localhost:8000/api/docs`
|
- Type hints in endpoint definitions
|
||||||
- ReDoc: `http://localhost:8000/api/redoc`
|
- Pydantic models for request/response validation
|
||||||
- OpenAPI Schema: `http://localhost:8000/api/openapi.json`
|
- 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
|
### API Endpoints
|
||||||
|
|
||||||
@@ -163,13 +171,19 @@ All endpoints follow the official AdGuard Home API specification:
|
|||||||
- `GET /` - Main web interface for domain checking and unblocking
|
- `GET /` - Main web interface for domain checking and unblocking
|
||||||
|
|
||||||
#### Filtering Endpoints
|
#### 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)
|
- Parameters: `name` (query parameter)
|
||||||
- Returns: Detailed filtering status and rules
|
- Returns: Detailed filtering status and rules
|
||||||
|
|
||||||
- `POST /control/filtering/whitelist/add` - Add a domain to the allowed list
|
- `GET /control/filtering/unblock_host` - Unblock a domain by adding it to whitelist
|
||||||
- Parameters: `name` (JSON body)
|
- Parameters: `name` (query parameter)
|
||||||
- Returns: Success status
|
- 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
|
- `GET /control/filtering/status` - Get current filtering configuration
|
||||||
- Returns: Complete filtering status including rules and filters
|
- 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
|
- `GET /control/status` - Check application and AdGuard Home connection status
|
||||||
- Returns: Health status with filtering state
|
- 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
|
## Response Models
|
||||||
|
|
||||||
The application uses Pydantic models that match the AdGuard Home API specification:
|
The application uses Pydantic models that match the AdGuard Home API specification:
|
||||||
@@ -200,74 +267,37 @@ The application uses Pydantic models that match the AdGuard Home API specificati
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### DomainCheckResult
|
### FilterCheckHostResponse
|
||||||
```python
|
```python
|
||||||
{
|
{
|
||||||
"reason": str, # Filtering status (e.g., "FilteredBlackList")
|
"reason": str, # Filtering status (e.g., "FilteredBlackList", "NotFilteredNotFound")
|
||||||
"rule": str, # Applied filtering rule
|
"filter_id": int, # Optional: ID of the filter containing the rule (deprecated)
|
||||||
"filter_id": int, # ID of the filter containing the rule
|
"rule": str, # Optional: Applied filtering rule (deprecated)
|
||||||
"service_name": str, # For blocked services
|
"rules": [ # List of applied rules with details
|
||||||
"cname": str, # For CNAME rewrites
|
{
|
||||||
"ip_addrs": List[str] # For A/AAAA rewrites
|
"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
|
### SetRulesRequest
|
||||||
|
```python
|
||||||
The application implements proper error handling according to the AdGuard Home API spec:
|
{
|
||||||
|
"rules": List[str] # List of filtering rules to set
|
||||||
- 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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Adding New Features
|
### ErrorResponse
|
||||||
|
```python
|
||||||
1. Backend Changes:
|
{
|
||||||
- Add routes in `main.py`
|
"message": str # Error description
|
||||||
- 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
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -1,75 +1,15 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
# Function to handle termination signals
|
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Starting SimpleGuardHome..."
|
||||||
handle_term() {
|
|
||||||
echo "Received SIGTERM/SIGINT, shutting down gracefully..."
|
|
||||||
kill -TERM "$child"
|
|
||||||
wait "$child"
|
|
||||||
exit 0
|
|
||||||
}
|
|
||||||
|
|
||||||
# Set up signal handlers
|
# Ensure proper Python path
|
||||||
trap handle_term SIGTERM SIGINT
|
export PYTHONPATH="/app:${PYTHONPATH:-}"
|
||||||
|
|
||||||
# Function to log with timestamp
|
# Verify package can be imported
|
||||||
log() {
|
echo "Verifying package installation..."
|
||||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1"
|
python3 -c "import simpleguardhome; print('Package found at:', simpleguardhome.__file__)"
|
||||||
}
|
|
||||||
|
|
||||||
# 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..."
|
|
||||||
|
|
||||||
# Start the application
|
# Start the application
|
||||||
echo "Starting SimpleGuardHome server..."
|
echo "Starting SimpleGuardHome server..."
|
||||||
exec python3 -c "from simpleguardhome import start; start()"
|
exec python3 -m simpleguardhome.main
|
||||||
|
|
||||||
# Store child PID
|
|
||||||
child=$!
|
|
||||||
|
|
||||||
# Wait for process to complete
|
|
||||||
wait "$child"
|
|
||||||
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
|
fastapi==0.128.0
|
||||||
uvicorn==0.34.0
|
uvicorn==0.39.0
|
||||||
python-dotenv==1.0.1
|
python-dotenv==1.2.1
|
||||||
httpx==0.28.1
|
httpx==0.28.1
|
||||||
pydantic==2.10.6
|
pydantic==2.12.5
|
||||||
pydantic-settings==2.7.1
|
pydantic-settings==2.11.0
|
||||||
pytest>=7.4.4
|
pytest>=7.4.4
|
||||||
pytest-asyncio>=0.25.2
|
pytest-asyncio>=0.25.2
|
||||||
python-multipart==0.0.20
|
python-multipart==0.0.20
|
||||||
jinja2==3.1.5
|
jinja2==3.1.6
|
||||||
slowapi==0.1.9
|
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.
|
||||||
28
setup.py
28
setup.py
@@ -1,16 +1,17 @@
|
|||||||
from setuptools import setup, find_packages
|
from setuptools import setup, find_namespace_packages # type: ignore
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
setup(
|
if __name__ == "__main__":
|
||||||
name="simpleguardhome",
|
try:
|
||||||
version="0.1.0",
|
setup(
|
||||||
packages=find_packages(where="src"),
|
|
||||||
package_dir={"": "src"},
|
package_dir={"": "src"},
|
||||||
include_package_data=True,
|
packages=find_namespace_packages(where="src", include=["simpleguardhome*"]),
|
||||||
package_data={
|
package_data={
|
||||||
"simpleguardhome": ["templates/*", "favicon.ico"]
|
"simpleguardhome": [
|
||||||
|
"templates/*",
|
||||||
|
"favicon.ico"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
python_requires=">=3.7",
|
include_package_data=True,
|
||||||
install_requires=[
|
install_requires=[
|
||||||
"fastapi",
|
"fastapi",
|
||||||
"uvicorn",
|
"uvicorn",
|
||||||
@@ -18,5 +19,10 @@ setup(
|
|||||||
"httpx",
|
"httpx",
|
||||||
"pydantic",
|
"pydantic",
|
||||||
"jinja2",
|
"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")
|
||||||
@@ -75,6 +75,22 @@ def validate_domain(domain: str) -> bool:
|
|||||||
return False
|
return False
|
||||||
return bool(DOMAIN_PATTERN.match(domain))
|
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:
|
def sanitize_rule(rule: str) -> str:
|
||||||
"""Sanitize and validate rule format."""
|
"""Sanitize and validate rule format."""
|
||||||
# Remove any whitespace and normalize
|
# Remove any whitespace and normalize
|
||||||
@@ -166,7 +182,6 @@ class AdGuardClient:
|
|||||||
|
|
||||||
await self._ensure_authenticated()
|
await self._ensure_authenticated()
|
||||||
url = f"{self.base_url}/filtering/check_host"
|
url = f"{self.base_url}/filtering/check_host"
|
||||||
params = {"name": domain}
|
|
||||||
headers = {}
|
headers = {}
|
||||||
|
|
||||||
if self._session_cookie:
|
if self._session_cookie:
|
||||||
@@ -174,6 +189,11 @@ class AdGuardClient:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
logger.info(f"Checking domain: {domain}")
|
logger.info(f"Checking domain: {domain}")
|
||||||
|
# Get all parent domains to check (excluding TLD)
|
||||||
|
domains_to_check = get_parent_domains(domain)
|
||||||
|
|
||||||
|
for check_domain in domains_to_check:
|
||||||
|
params = {"name": check_domain}
|
||||||
response = await self.client.get(url, params=params, headers=headers)
|
response = await self.client.get(url, params=params, headers=headers)
|
||||||
|
|
||||||
if response.status_code == 401:
|
if response.status_code == 401:
|
||||||
@@ -185,6 +205,17 @@ class AdGuardClient:
|
|||||||
|
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
result = response.json()
|
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)
|
||||||
|
|
||||||
|
# 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}")
|
logger.info(f"Domain check result for {domain}: {result}")
|
||||||
return FilterCheckHostResponse(**result)
|
return FilterCheckHostResponse(**result)
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from pydantic_settings import BaseSettings
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
from pydantic_settings import BaseSettings # type: ignore
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
"""Application settings using environment variables."""
|
"""Application settings using environment variables."""
|
||||||
|
|||||||
@@ -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
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
from typing import Dict
|
from typing import Dict
|
||||||
from . import adguard
|
|
||||||
from .config import settings
|
import httpx # noqa: F401
|
||||||
from .adguard import (
|
from fastapi import ( # type: ignore # noqa: F401
|
||||||
AdGuardError,
|
FastAPI,
|
||||||
AdGuardConnectionError,
|
Form,
|
||||||
AdGuardAPIError,
|
HTTPException,
|
||||||
AdGuardValidationError,
|
Request,
|
||||||
FilterStatus,
|
status,
|
||||||
FilterCheckHostResponse,
|
|
||||||
SetRulesRequest
|
|
||||||
)
|
)
|
||||||
|
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 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
|
# Configure logging
|
||||||
logging.basicConfig(level=logging.INFO)
|
logging.basicConfig(level=logging.INFO)
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -51,15 +59,28 @@ templates = Jinja2Templates(directory=str(templates_path))
|
|||||||
# Mount static files from package directory
|
# Mount static files from package directory
|
||||||
app.mount("/static", StaticFiles(directory=str(Path(__file__).parent)), name="static")
|
app.mount("/static", StaticFiles(directory=str(Path(__file__).parent)), name="static")
|
||||||
|
|
||||||
# Mount favicon.ico at root
|
# Serve favicon.ico directly
|
||||||
static_files_path = Path(__file__).parent
|
from fastapi.responses import FileResponse
|
||||||
app.mount("/favicon.ico", StaticFiles(directory=str(static_files_path)), name="favicon")
|
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
|
# Response models matching AdGuard spec
|
||||||
|
|
||||||
|
|
||||||
class ErrorResponse(BaseModel):
|
class ErrorResponse(BaseModel):
|
||||||
"""Error response model according to AdGuard spec."""
|
"""Error response model according to AdGuard spec."""
|
||||||
message: str = Field(..., description="The error message")
|
message: str = Field(..., description="The error message")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/", response_class=HTMLResponse)
|
@app.get("/", response_class=HTMLResponse)
|
||||||
async def home(request: Request):
|
async def home(request: Request):
|
||||||
"""Render the home page."""
|
"""Render the home page."""
|
||||||
@@ -68,6 +89,7 @@ async def home(request: Request):
|
|||||||
{"request": request}
|
{"request": request}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.get(
|
@app.get(
|
||||||
"/control/filtering/check_host",
|
"/control/filtering/check_host",
|
||||||
response_model=FilterCheckHostResponse,
|
response_model=FilterCheckHostResponse,
|
||||||
@@ -96,11 +118,12 @@ async def check_domain(name: str) -> FilterCheckHostResponse:
|
|||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail=str(e)
|
detail=str(e)
|
||||||
)
|
) from e
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error checking domain {name}: {str(e)}")
|
logger.error(f"Error checking domain {name}: {str(e)}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
@app.post(
|
@app.post(
|
||||||
"/control/filtering/set_rules",
|
"/control/filtering/set_rules",
|
||||||
response_model=Dict,
|
response_model=Dict,
|
||||||
@@ -144,11 +167,12 @@ async def add_to_whitelist(request: SetRulesRequest) -> Dict:
|
|||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail=str(e)
|
detail=str(e)
|
||||||
)
|
) from e
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error adding domain to whitelist: {str(e)}")
|
logger.error(f"Error adding domain to whitelist: {str(e)}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
@app.get(
|
@app.get(
|
||||||
"/control/filtering/status",
|
"/control/filtering/status",
|
||||||
response_model=FilterStatus,
|
response_model=FilterStatus,
|
||||||
@@ -167,8 +191,62 @@ async def get_filtering_status() -> FilterStatus:
|
|||||||
logger.error(f"Error getting filter status: {str(e)}")
|
logger.error(f"Error getting filter status: {str(e)}")
|
||||||
raise
|
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)
|
@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."""
|
"""Handle AdGuard-related exceptions according to spec."""
|
||||||
if isinstance(exc, AdGuardConnectionError):
|
if isinstance(exc, AdGuardConnectionError):
|
||||||
status_code = status.HTTP_503_SERVICE_UNAVAILABLE
|
status_code = status.HTTP_503_SERVICE_UNAVAILABLE
|
||||||
@@ -184,9 +262,10 @@ async def adguard_exception_handler(request: Request, exc: AdGuardError) -> JSON
|
|||||||
content={"message": str(exc)}
|
content={"message": str(exc)}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def start():
|
def start():
|
||||||
"""Start the application using uvicorn."""
|
"""Start the application using uvicorn."""
|
||||||
import uvicorn
|
import uvicorn # type: ignore
|
||||||
uvicorn.run(
|
uvicorn.run(
|
||||||
app,
|
app,
|
||||||
host="0.0.0.0",
|
host="0.0.0.0",
|
||||||
@@ -194,5 +273,6 @@ def start():
|
|||||||
reload=False # Disable reload in Docker
|
reload=False # Disable reload in Docker
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
start()
|
start()
|
||||||
@@ -1,29 +1,101 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en" class="dark:bg-gray-900">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>SimpleGuardHome</title>
|
<title>SimpleGuardHome</title>
|
||||||
|
<!-- Load Tailwind first -->
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<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>
|
<script>
|
||||||
function escapeHtml(unsafe) {
|
function escapeHtml(unsafe) {
|
||||||
return unsafe
|
return unsafe.replace(/[&<>"']/g, function(m) {
|
||||||
.replace(/&/g, "&")
|
switch (m) {
|
||||||
.replace(/</g, "<")
|
case '&': return '&';
|
||||||
.replace(/>/g, ">")
|
case '<': return '<';
|
||||||
.replace(/"/g, """)
|
case '>': return '>';
|
||||||
.replace(/'/g, "'");
|
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) {
|
async function checkDomain(event) {
|
||||||
event.preventDefault();
|
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 resultDiv = document.getElementById('result');
|
||||||
const unblockDiv = document.getElementById('unblock-action');
|
const unblockDiv = document.getElementById('unblock-action');
|
||||||
const submitBtn = document.getElementById('submit-btn');
|
const submitBtn = document.getElementById('submit-btn');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Show loading state
|
|
||||||
submitBtn.disabled = true;
|
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>';
|
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();
|
const data = await response.json();
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const isBlocked = data.reason.startsWith('Filtered');
|
renderDomainStatus(resultDiv, unblockDiv, domain, data);
|
||||||
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 = '';
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
let errorMsg = data.message || 'Unknown error occurred';
|
let errorMsg = data.message || 'Unknown error occurred';
|
||||||
let errorType = response.status === 400 ? 'warning' : 'error';
|
let errorType = response.status === 400 ? 'warning' : 'error';
|
||||||
@@ -69,7 +118,7 @@
|
|||||||
resultDiv.innerHTML = `
|
resultDiv.innerHTML = `
|
||||||
<div class="bg-${bgColor}-100 border-l-4 border-${bgColor}-500 text-${bgColor}-700 p-4">
|
<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="font-bold">Error checking domain</p>
|
||||||
<p class="text-sm">${errorMsg}</p>
|
<p class="text-sm">${escapeHtml(errorMsg)}</p>
|
||||||
</div>`;
|
</div>`;
|
||||||
unblockDiv.innerHTML = '';
|
unblockDiv.innerHTML = '';
|
||||||
}
|
}
|
||||||
@@ -139,18 +188,26 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
</head>
|
</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">
|
<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">
|
<form onsubmit="checkDomain(event)" class="mb-6">
|
||||||
<div class="mb-4">
|
<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
|
Enter Domain to Check
|
||||||
</label>
|
</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])*$"
|
<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"
|
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"
|
placeholder="example.com"
|
||||||
title="Please enter a valid domain name">
|
title="Please enter a valid domain name">
|
||||||
</div>
|
</div>
|
||||||
@@ -164,7 +221,60 @@
|
|||||||
<div id="unblock-action" class="mt-4 text-center"></div>
|
<div id="unblock-action" class="mt-4 text-center"></div>
|
||||||
</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.
|
Make sure your AdGuard Home instance is running and properly configured in the .env file.
|
||||||
<br>
|
<br>
|
||||||
<span class="text-xs">Rules are automatically backed up before any changes.</span>
|
<span class="text-xs">Rules are automatically backed up before any changes.</span>
|
||||||
|
|||||||
@@ -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