fresh start
This commit is contained in:
commit
6ce10f673e
10 changed files with 1652 additions and 0 deletions
43
.dockerignore
Normal file
43
.dockerignore
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
# Git
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
|
||||||
|
# Python cache files
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
.pytest_cache/
|
||||||
|
htmlcov/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
.cache/
|
||||||
|
|
||||||
|
# Environments
|
||||||
|
.env
|
||||||
|
.venv
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env.bak/
|
||||||
|
venv.bak/
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
Dockerfile
|
||||||
|
docker-compose.yml
|
||||||
|
.dockerignore
|
||||||
|
|
||||||
|
# Database
|
||||||
|
*.db
|
||||||
|
*.sqlite3
|
||||||
|
*.json
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# VS Code
|
||||||
|
.vscode/
|
||||||
|
|
||||||
|
# Local data
|
||||||
|
uniteddomain.csv
|
98
.gitignore
vendored
Normal file
98
.gitignore
vendored
Normal file
|
@ -0,0 +1,98 @@
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
MANIFEST
|
||||||
|
*.manifest
|
||||||
|
*.spec
|
||||||
|
pip-log.txt
|
||||||
|
pip-delete-this-directory.txt
|
||||||
|
|
||||||
|
# Environments
|
||||||
|
.env
|
||||||
|
.venv
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env.bak/
|
||||||
|
venv.bak/
|
||||||
|
|
||||||
|
# Unit tests / coverage reports
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.nox/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
.cache
|
||||||
|
nosetests.xml
|
||||||
|
coverage.xml
|
||||||
|
*.cover
|
||||||
|
.hypothesis/
|
||||||
|
.pytest_cache/
|
||||||
|
pytestdebug.log
|
||||||
|
|
||||||
|
# Database
|
||||||
|
*.db
|
||||||
|
*.sqlite3
|
||||||
|
*.sqlite
|
||||||
|
domains_db.json
|
||||||
|
|
||||||
|
# FastAPI project
|
||||||
|
domains_db.json
|
||||||
|
*.csv
|
||||||
|
!requirements.txt
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# IDE specific files
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
.docker/
|
||||||
|
docker-data/
|
||||||
|
data/
|
||||||
|
|
||||||
|
# Jupyter Notebook
|
||||||
|
.ipynb_checkpoints
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
pids
|
||||||
|
*.pid
|
||||||
|
*.seed
|
||||||
|
*.pid.lock
|
||||||
|
|
||||||
|
# Node dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Temporary files
|
||||||
|
tmp/
|
||||||
|
temp/
|
||||||
|
*.tmp
|
||||||
|
.temp
|
261
CONTAINER_INSTRUCTIONS.md
Normal file
261
CONTAINER_INSTRUCTIONS.md
Normal file
|
@ -0,0 +1,261 @@
|
||||||
|
# Container Instructions for FastAPI Domains Application
|
||||||
|
|
||||||
|
This guide explains how to run the FastAPI Domains application in a secure rootless container with persistent data storage using Podman or Docker.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- [Podman](https://podman.io/getting-started/installation) (version 3.0 or higher) or [Docker](https://docs.docker.com/get-docker/) (version 20.10 or higher)
|
||||||
|
|
||||||
|
## Security Features
|
||||||
|
|
||||||
|
This deployment includes the following security features:
|
||||||
|
|
||||||
|
1. **Rootless container**: The application runs as a non-root user (UID 1000)
|
||||||
|
2. **Read-only filesystem**: The container's filesystem is mounted read-only
|
||||||
|
3. **Dropped capabilities**: All Linux capabilities are dropped
|
||||||
|
4. **No privilege escalation**: The container cannot gain additional privileges
|
||||||
|
5. **Minimal base image**: Uses a slim Python image to reduce attack surface
|
||||||
|
6. **Non-privileged ports**: Uses port 8000 instead of privileged ports (<1024)
|
||||||
|
7. **Persistent volume**: Data is stored in a volume for persistence
|
||||||
|
|
||||||
|
## Quick Start with Podman
|
||||||
|
|
||||||
|
### Building the Container
|
||||||
|
|
||||||
|
```bash
|
||||||
|
podman build -t fastapi-domains:latest .
|
||||||
|
```
|
||||||
|
|
||||||
|
### Creating a Volume
|
||||||
|
|
||||||
|
```bash
|
||||||
|
podman volume create domain-data
|
||||||
|
```
|
||||||
|
|
||||||
|
### Running the Container
|
||||||
|
|
||||||
|
```bash
|
||||||
|
podman run --name fastapi-domains \
|
||||||
|
-p 8000:8000 \
|
||||||
|
-v domain-data:/home/appuser/app/data:Z \
|
||||||
|
-e DB_DIR=/home/appuser/app/data \
|
||||||
|
--security-opt no-new-privileges:true \
|
||||||
|
--read-only \
|
||||||
|
--tmpfs /tmp \
|
||||||
|
--cap-drop ALL \
|
||||||
|
--user 1000:1000 \
|
||||||
|
-d fastapi-domains:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
### Checking Container Status
|
||||||
|
|
||||||
|
```bash
|
||||||
|
podman ps
|
||||||
|
```
|
||||||
|
|
||||||
|
### Accessing the Application
|
||||||
|
|
||||||
|
Open your browser to:
|
||||||
|
```
|
||||||
|
http://localhost:8000
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Start with Docker
|
||||||
|
|
||||||
|
### Building the Container
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t fastapi-domains:latest .
|
||||||
|
```
|
||||||
|
|
||||||
|
### Creating a Volume
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker volume create domain-data
|
||||||
|
```
|
||||||
|
|
||||||
|
### Running the Container
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --name fastapi-domains \
|
||||||
|
-p 8000:8000 \
|
||||||
|
-v domain-data:/home/appuser/app/data \
|
||||||
|
-e DB_DIR=/home/appuser/app/data \
|
||||||
|
--security-opt no-new-privileges:true \
|
||||||
|
--read-only \
|
||||||
|
--tmpfs /tmp \
|
||||||
|
--cap-drop ALL \
|
||||||
|
--user 1000:1000 \
|
||||||
|
-d fastapi-domains:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
### Checking Container Status
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker ps
|
||||||
|
```
|
||||||
|
|
||||||
|
### Accessing the Application
|
||||||
|
|
||||||
|
Open your browser to:
|
||||||
|
```
|
||||||
|
http://localhost:8000
|
||||||
|
```
|
||||||
|
|
||||||
|
## Persistent Data
|
||||||
|
|
||||||
|
The application stores all data in a volume named `domain-data`. This volume persists even when the container is stopped or removed.
|
||||||
|
|
||||||
|
To see information about the volume:
|
||||||
|
|
||||||
|
**Podman:**
|
||||||
|
```bash
|
||||||
|
podman volume inspect domain-data
|
||||||
|
```
|
||||||
|
|
||||||
|
**Docker:**
|
||||||
|
```bash
|
||||||
|
docker volume inspect domain-data
|
||||||
|
```
|
||||||
|
|
||||||
|
## Maintenance
|
||||||
|
|
||||||
|
### View Logs
|
||||||
|
|
||||||
|
**Podman:**
|
||||||
|
```bash
|
||||||
|
podman logs fastapi-domains
|
||||||
|
```
|
||||||
|
|
||||||
|
**Docker:**
|
||||||
|
```bash
|
||||||
|
docker logs fastapi-domains
|
||||||
|
```
|
||||||
|
|
||||||
|
### Restart the Application
|
||||||
|
|
||||||
|
**Podman:**
|
||||||
|
```bash
|
||||||
|
podman restart fastapi-domains
|
||||||
|
```
|
||||||
|
|
||||||
|
**Docker:**
|
||||||
|
```bash
|
||||||
|
docker restart fastapi-domains
|
||||||
|
```
|
||||||
|
|
||||||
|
### Stop the Application
|
||||||
|
|
||||||
|
**Podman:**
|
||||||
|
```bash
|
||||||
|
podman stop fastapi-domains
|
||||||
|
```
|
||||||
|
|
||||||
|
**Docker:**
|
||||||
|
```bash
|
||||||
|
docker stop fastapi-domains
|
||||||
|
```
|
||||||
|
|
||||||
|
### Remove the Container
|
||||||
|
|
||||||
|
**Podman:**
|
||||||
|
```bash
|
||||||
|
podman rm fastapi-domains
|
||||||
|
```
|
||||||
|
|
||||||
|
**Docker:**
|
||||||
|
```bash
|
||||||
|
docker rm fastapi-domains
|
||||||
|
```
|
||||||
|
|
||||||
|
## Backup and Restore
|
||||||
|
|
||||||
|
### Backup the Database
|
||||||
|
|
||||||
|
**Podman:**
|
||||||
|
```bash
|
||||||
|
podman run --rm -v domain-data:/data:Z -v ./:/backup:Z alpine sh -c "cp /data/domains_db.json /backup/domains_backup_$(date +%Y%m%d).json"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Docker:**
|
||||||
|
```bash
|
||||||
|
docker run --rm -v domain-data:/data -v $(pwd):/backup alpine sh -c "cp /data/domains_db.json /backup/domains_backup_$(date +%Y%m%d).json"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Restore from Backup
|
||||||
|
|
||||||
|
**Podman:**
|
||||||
|
```bash
|
||||||
|
podman run --rm -v domain-data:/data:Z -v ./:/backup:Z alpine sh -c "cp /backup/domains_backup_YYYYMMDD.json /data/domains_db.json"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Docker:**
|
||||||
|
```bash
|
||||||
|
docker run --rm -v domain-data:/data -v $(pwd):/backup alpine sh -c "cp /backup/domains_backup_YYYYMMDD.json /data/domains_db.json"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Creating a Systemd Service (Podman Only)
|
||||||
|
|
||||||
|
1. Generate a systemd service file:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p ~/.config/systemd/user
|
||||||
|
podman generate systemd --name fastapi-domains --files --new
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Move the generated file:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mv container-fastapi-domains.service ~/.config/systemd/user/
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Enable and start the service:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl --user enable container-fastapi-domains.service
|
||||||
|
systemctl --user start container-fastapi-domains.service
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Check service status:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl --user status container-fastapi-domains.service
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Check Container Status
|
||||||
|
|
||||||
|
**Podman:**
|
||||||
|
```bash
|
||||||
|
podman ps -a
|
||||||
|
```
|
||||||
|
|
||||||
|
**Docker:**
|
||||||
|
```bash
|
||||||
|
docker ps -a
|
||||||
|
```
|
||||||
|
|
||||||
|
### Inspect the Container
|
||||||
|
|
||||||
|
**Podman:**
|
||||||
|
```bash
|
||||||
|
podman inspect fastapi-domains
|
||||||
|
```
|
||||||
|
|
||||||
|
**Docker:**
|
||||||
|
```bash
|
||||||
|
docker inspect fastapi-domains
|
||||||
|
```
|
||||||
|
|
||||||
|
### Access Container Shell
|
||||||
|
|
||||||
|
**Podman:**
|
||||||
|
```bash
|
||||||
|
podman exec -it fastapi-domains bash
|
||||||
|
```
|
||||||
|
|
||||||
|
**Docker:**
|
||||||
|
```bash
|
||||||
|
docker exec -it fastapi-domains bash
|
||||||
|
```
|
36
Dockerfile
Normal file
36
Dockerfile
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
# Use Python 3.11 slim image for a smaller footprint
|
||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
# Set environment variables
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
PIP_NO_CACHE_DIR=1 \
|
||||||
|
PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
||||||
|
HOME=/home/appuser \
|
||||||
|
APP_HOME=/home/appuser/app
|
||||||
|
|
||||||
|
# Create non-root user and setup directories
|
||||||
|
RUN groupadd -g 1000 appgroup && \
|
||||||
|
useradd -m -u 1000 -g appgroup -s /bin/bash -d ${HOME} appuser && \
|
||||||
|
mkdir -p ${APP_HOME} && \
|
||||||
|
mkdir -p ${APP_HOME}/data && \
|
||||||
|
chown -R appuser:appgroup ${HOME}
|
||||||
|
|
||||||
|
# Set the working directory
|
||||||
|
WORKDIR ${APP_HOME}
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
COPY --chown=appuser:appgroup requirements.txt ${APP_HOME}/
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Copy application code
|
||||||
|
COPY --chown=appuser:appgroup . ${APP_HOME}/
|
||||||
|
|
||||||
|
# Switch to non-root user
|
||||||
|
USER appuser
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
# Command to run the application
|
||||||
|
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
107
README.md
Normal file
107
README.md
Normal file
|
@ -0,0 +1,107 @@
|
||||||
|
# Domain and DNS Management System
|
||||||
|
|
||||||
|
A FastAPI application that allows users to upload CSV files containing domain and DNS records, storing them in a database and displaying them via an interactive UI and API interface.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- File upload system with detailed upload history, including ability to delete uploads
|
||||||
|
- Main SLD view with filtering by upload and time
|
||||||
|
- Comprehensive DNS Records view with multi-faceted filtering
|
||||||
|
- TinyDB database storage with precise timestamps for data persistence
|
||||||
|
- Deduplicated DNS records (by domain, class, and type) and domains (by FQDN) showing most recent entries
|
||||||
|
- Option to view only records from a specific upload
|
||||||
|
- Clean, simplified interface showing only the latest records
|
||||||
|
- Server-side filtering by upload, record type, class, TLD, and SLD (no JavaScript needed)
|
||||||
|
- Provides comprehensive RESTful API endpoints for programmatic access
|
||||||
|
- Responsive design that works on desktop and mobile
|
||||||
|
|
||||||
|
## Containerized Deployment
|
||||||
|
|
||||||
|
The application can be easily deployed in a secure rootless container using Podman or Docker.
|
||||||
|
|
||||||
|
For detailed container instructions, see [CONTAINER_INSTRUCTIONS.md](CONTAINER_INSTRUCTIONS.md).
|
||||||
|
|
||||||
|
Quick start:
|
||||||
|
```bash
|
||||||
|
# Make the run script executable
|
||||||
|
chmod +x run_container.sh
|
||||||
|
|
||||||
|
# Run the container (automatically uses podman if available, falls back to docker)
|
||||||
|
./run_container.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Setup with Virtual Environment (Development)
|
||||||
|
|
||||||
|
1. Create a virtual environment:
|
||||||
|
```
|
||||||
|
python -m venv venv
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Activate the virtual environment:
|
||||||
|
- On Windows:
|
||||||
|
```
|
||||||
|
venv\Scripts\activate
|
||||||
|
```
|
||||||
|
- On macOS/Linux:
|
||||||
|
```
|
||||||
|
source venv/bin/activate
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Install dependencies:
|
||||||
|
```
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Run the application:
|
||||||
|
```
|
||||||
|
python main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Access the application:
|
||||||
|
- Web interface: http://localhost:8000
|
||||||
|
- API endpoints:
|
||||||
|
- http://localhost:8000/api/slds
|
||||||
|
- http://localhost:8000/api/slds/{sld}
|
||||||
|
|
||||||
|
6. Deactivate the virtual environment when finished:
|
||||||
|
```
|
||||||
|
deactivate
|
||||||
|
```
|
||||||
|
|
||||||
|
## CSV File Format
|
||||||
|
|
||||||
|
The application accepts CSV files with the following format:
|
||||||
|
|
||||||
|
```
|
||||||
|
domain.example.com,3600,IN,A,192.168.1.1
|
||||||
|
domain.example.com,3600,IN,MX,10 mail.example.com.
|
||||||
|
subdomain.example.com,3600,IN,CNAME,domain.example.com.
|
||||||
|
```
|
||||||
|
|
||||||
|
Where columns are:
|
||||||
|
1. Domain name (fully qualified domain name)
|
||||||
|
2. TTL (Time To Live) in seconds
|
||||||
|
3. Record Class (usually IN for Internet)
|
||||||
|
4. Record Type (A, AAAA, MX, CNAME, TXT, etc.)
|
||||||
|
5. Record Data (IP address, hostname, or other data depending on record type)
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
- `/api/uploads` - Get all uploads
|
||||||
|
- `/api/slds` - Get all SLDs (Second Level Domains)
|
||||||
|
- `/api/slds/{sld}` - Get domains by SLD
|
||||||
|
- `/api/dns` - Get all DNS records
|
||||||
|
- `/api/dns/types` - Get unique values for filters
|
||||||
|
|
||||||
|
### Query Parameters
|
||||||
|
|
||||||
|
You can filter the API results using the following query parameters:
|
||||||
|
|
||||||
|
- `upload_id` - Filter by specific upload
|
||||||
|
- `record_type` - Filter by DNS record type
|
||||||
|
- `record_class` - Filter by DNS record class
|
||||||
|
- `tld` - Filter by Top Level Domain
|
||||||
|
- `sld` - Filter by Second Level Domain
|
||||||
|
- `domain` - Search by domain name
|
||||||
|
|
||||||
|
Example: `/api/dns?record_type=A&tld=com&upload_id=upload_20250408120000`
|
465
main.py
Normal file
465
main.py
Normal file
|
@ -0,0 +1,465 @@
|
||||||
|
import csv
|
||||||
|
import re
|
||||||
|
import io
|
||||||
|
import datetime
|
||||||
|
from fastapi import FastAPI, Request, HTTPException, Query, UploadFile, File, Form
|
||||||
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
import uvicorn
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Dict, Optional, Set
|
||||||
|
from tinydb import TinyDB, Query as TinyDBQuery
|
||||||
|
|
||||||
|
# Initialize FastAPI app
|
||||||
|
app = FastAPI(title="Domain Viewer", description="Upload and view domains and DNS records")
|
||||||
|
|
||||||
|
# Setup templates
|
||||||
|
templates = Jinja2Templates(directory="templates")
|
||||||
|
|
||||||
|
# Setup TinyDB with configurable path
|
||||||
|
import os
|
||||||
|
DB_DIR = os.environ.get('DB_DIR', '.')
|
||||||
|
DB_PATH = Path(f"{DB_DIR}/domains_db.json")
|
||||||
|
db = TinyDB(DB_PATH)
|
||||||
|
domains_table = db.table('domains')
|
||||||
|
dns_records_table = db.table('dns_records')
|
||||||
|
uploads_table = db.table('uploads')
|
||||||
|
|
||||||
|
# Process a domain entry and return its components
|
||||||
|
def process_domain_entry(domain_entry):
|
||||||
|
# Remove trailing dot if present
|
||||||
|
if domain_entry.endswith('.'):
|
||||||
|
domain_entry = domain_entry[:-1]
|
||||||
|
|
||||||
|
# Parse domain components
|
||||||
|
parts = domain_entry.split('.')
|
||||||
|
if len(parts) > 1:
|
||||||
|
# For domain.tld format
|
||||||
|
if len(parts) == 2:
|
||||||
|
sld = parts[0] # Second Level Domain
|
||||||
|
tld = parts[1] # Top Level Domain
|
||||||
|
domain_info = {
|
||||||
|
"sld": sld,
|
||||||
|
"tld": tld,
|
||||||
|
"full_domain": domain_entry
|
||||||
|
}
|
||||||
|
# For subdomain.domain.tld format
|
||||||
|
else:
|
||||||
|
sld = parts[-2] # Second Level Domain
|
||||||
|
tld = parts[-1] # Top Level Domain
|
||||||
|
subdomain = '.'.join(parts[:-2]) # Subdomains
|
||||||
|
domain_info = {
|
||||||
|
"sld": sld,
|
||||||
|
"tld": tld,
|
||||||
|
"full_domain": domain_entry,
|
||||||
|
"subdomain": subdomain
|
||||||
|
}
|
||||||
|
return domain_info
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Upload CSV file and store in database
|
||||||
|
async def process_csv_upload(file_content, upload_id, description=None):
|
||||||
|
domains_to_insert = []
|
||||||
|
dns_records_to_insert = []
|
||||||
|
unique_domains = set()
|
||||||
|
timestamp = datetime.datetime.now().isoformat()
|
||||||
|
row_count = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Read CSV content
|
||||||
|
file_text = file_content.decode('utf-8')
|
||||||
|
csv_reader = csv.reader(io.StringIO(file_text))
|
||||||
|
|
||||||
|
# Print first few lines for debugging
|
||||||
|
preview_lines = file_text.split('\n')[:5]
|
||||||
|
print(f"CSV preview (first 5 lines):")
|
||||||
|
for i, line in enumerate(preview_lines):
|
||||||
|
print(f" Line {i+1}: {line}")
|
||||||
|
|
||||||
|
for row_num, row in enumerate(csv_reader, 1):
|
||||||
|
row_count += 1
|
||||||
|
if not row: # Skip empty rows
|
||||||
|
print(f"Skipping empty row at line {row_num}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Extract domain from first column
|
||||||
|
domain_entry = row[0]
|
||||||
|
|
||||||
|
# Process domain for domains table
|
||||||
|
domain_info = process_domain_entry(domain_entry)
|
||||||
|
if domain_info:
|
||||||
|
# Create a unique key to avoid duplicates within this upload
|
||||||
|
unique_key = f"{domain_info['sld']}.{domain_info['tld']}"
|
||||||
|
|
||||||
|
if unique_key not in unique_domains:
|
||||||
|
unique_domains.add(unique_key)
|
||||||
|
# Add upload metadata
|
||||||
|
domain_info['upload_id'] = upload_id
|
||||||
|
domain_info['timestamp'] = timestamp
|
||||||
|
domains_to_insert.append(domain_info)
|
||||||
|
else:
|
||||||
|
print(f"Warning: Could not process domain entry at line {row_num}: {domain_entry}")
|
||||||
|
|
||||||
|
# Process DNS record if we have enough fields
|
||||||
|
if len(row) >= 5:
|
||||||
|
domain = row[0]
|
||||||
|
ttl = row[1]
|
||||||
|
record_class = row[2]
|
||||||
|
record_type = row[3]
|
||||||
|
record_data = ','.join(row[4:]) # Join remaining parts as record data
|
||||||
|
|
||||||
|
# Remove trailing dot from domain if present
|
||||||
|
if domain.endswith('.'):
|
||||||
|
domain = domain[:-1]
|
||||||
|
|
||||||
|
# Parse domain components
|
||||||
|
domain_parts = domain.split('.')
|
||||||
|
|
||||||
|
# Create entry
|
||||||
|
entry = {
|
||||||
|
"domain": domain,
|
||||||
|
"ttl": ttl,
|
||||||
|
"record_class": record_class,
|
||||||
|
"record_type": record_type,
|
||||||
|
"record_data": record_data,
|
||||||
|
"upload_id": upload_id,
|
||||||
|
"timestamp": timestamp
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add domain components
|
||||||
|
if len(domain_parts) > 1:
|
||||||
|
if domain_parts[0].startswith('_'): # Service records like _dmarc
|
||||||
|
entry["service"] = domain_parts[0]
|
||||||
|
# Adjust domain parts
|
||||||
|
domain_parts = domain_parts[1:]
|
||||||
|
|
||||||
|
# For domain.tld format
|
||||||
|
if len(domain_parts) == 2:
|
||||||
|
entry["sld"] = domain_parts[0] # Second Level Domain
|
||||||
|
entry["tld"] = domain_parts[1] # Top Level Domain
|
||||||
|
# For subdomain.domain.tld format
|
||||||
|
elif len(domain_parts) > 2:
|
||||||
|
entry["sld"] = domain_parts[-2] # Second Level Domain
|
||||||
|
entry["tld"] = domain_parts[-1] # Top Level Domain
|
||||||
|
entry["subdomain"] = '.'.join(domain_parts[:-2]) # Subdomains
|
||||||
|
|
||||||
|
dns_records_to_insert.append(entry)
|
||||||
|
|
||||||
|
print(f"Processed {row_count} rows from CSV")
|
||||||
|
print(f"Records to insert: {len(domains_to_insert)} domains, {len(dns_records_to_insert)} DNS records")
|
||||||
|
|
||||||
|
# Insert data into tables
|
||||||
|
if domains_to_insert:
|
||||||
|
print(f"Inserting {len(domains_to_insert)} domains into database")
|
||||||
|
domains_table.insert_multiple(domains_to_insert)
|
||||||
|
else:
|
||||||
|
print("No domains to insert")
|
||||||
|
|
||||||
|
if dns_records_to_insert:
|
||||||
|
print(f"Inserting {len(dns_records_to_insert)} DNS records into database")
|
||||||
|
dns_records_table.insert_multiple(dns_records_to_insert)
|
||||||
|
else:
|
||||||
|
print("No DNS records to insert")
|
||||||
|
|
||||||
|
return len(domains_to_insert), len(dns_records_to_insert)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
import traceback
|
||||||
|
print(f"Error processing CSV file: {e}")
|
||||||
|
print(traceback.format_exc())
|
||||||
|
return 0, 0
|
||||||
|
|
||||||
|
# Load domains from database - deduplicated by full domain name
|
||||||
|
def load_domains(specific_upload_id: str = None) -> List[Dict]:
|
||||||
|
try:
|
||||||
|
domains = domains_table.all()
|
||||||
|
|
||||||
|
# If a specific upload ID is provided, only show domains from that upload
|
||||||
|
if specific_upload_id:
|
||||||
|
domains = [d for d in domains if d.get('upload_id') == specific_upload_id]
|
||||||
|
return domains
|
||||||
|
|
||||||
|
# Sort by timestamp in descending order (newest first)
|
||||||
|
domains.sort(key=lambda x: x.get('timestamp', ''), reverse=True)
|
||||||
|
|
||||||
|
# Create a dictionary to track unique domains by full domain name
|
||||||
|
unique_domains = {}
|
||||||
|
|
||||||
|
for domain in domains:
|
||||||
|
# Create a unique key based on the full domain name
|
||||||
|
unique_key = domain.get('full_domain', '')
|
||||||
|
|
||||||
|
# Only keep the most recent entry for each unique domain
|
||||||
|
if unique_key and unique_key not in unique_domains:
|
||||||
|
domain['is_latest'] = True
|
||||||
|
unique_domains[unique_key] = domain
|
||||||
|
|
||||||
|
# Return the deduplicated list
|
||||||
|
return list(unique_domains.values())
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error loading domains from database: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Load DNS entries from database - deduplicated by domain, class, and type (no history)
|
||||||
|
def load_dns_entries(specific_upload_id: str = None) -> List[Dict]:
|
||||||
|
try:
|
||||||
|
entries = dns_records_table.all()
|
||||||
|
|
||||||
|
# If a specific upload ID is provided, only show records from that upload
|
||||||
|
if specific_upload_id:
|
||||||
|
entries = [e for e in entries if e.get('upload_id') == specific_upload_id]
|
||||||
|
return entries
|
||||||
|
|
||||||
|
# Sort by timestamp in descending order (newest first)
|
||||||
|
entries.sort(key=lambda x: x.get('timestamp', ''), reverse=True)
|
||||||
|
|
||||||
|
# Create a dictionary to track unique entries (most recent only)
|
||||||
|
unique_entries = {}
|
||||||
|
|
||||||
|
for entry in entries:
|
||||||
|
# Create a unique key based on domain, class, and type
|
||||||
|
unique_key = f"{entry.get('domain')}:{entry.get('record_class')}:{entry.get('record_type')}"
|
||||||
|
|
||||||
|
# Only keep the most recent entry for each unique combination
|
||||||
|
if unique_key not in unique_entries:
|
||||||
|
# Mark as most recent entry
|
||||||
|
entry['is_latest'] = True
|
||||||
|
unique_entries[unique_key] = entry
|
||||||
|
|
||||||
|
# Return the deduplicated list with only the most recent entries
|
||||||
|
return list(unique_entries.values())
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error loading DNS records from database: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Get unique values for filter dropdowns
|
||||||
|
def get_unique_values(entries: List[Dict]) -> Dict[str, Set]:
|
||||||
|
unique_values = {
|
||||||
|
"record_type": set(),
|
||||||
|
"record_class": set(),
|
||||||
|
"tld": set(),
|
||||||
|
"sld": set()
|
||||||
|
}
|
||||||
|
|
||||||
|
for entry in entries:
|
||||||
|
for key in unique_values.keys():
|
||||||
|
if key in entry and entry[key]:
|
||||||
|
unique_values[key].add(entry[key])
|
||||||
|
|
||||||
|
# Convert sets to sorted lists
|
||||||
|
return {k: sorted(list(v)) for k, v in unique_values.items()}
|
||||||
|
|
||||||
|
# Get all uploads
|
||||||
|
def get_uploads():
|
||||||
|
uploads = uploads_table.all()
|
||||||
|
# Sort uploads by timestamp (newest first)
|
||||||
|
uploads.sort(key=lambda x: x.get('timestamp', ''), reverse=True)
|
||||||
|
return uploads
|
||||||
|
|
||||||
|
# Delete an upload and its associated data
|
||||||
|
def delete_upload(upload_id):
|
||||||
|
try:
|
||||||
|
# Remove the upload from uploads table
|
||||||
|
Upload = TinyDBQuery()
|
||||||
|
uploads_table.remove(Upload.id == upload_id)
|
||||||
|
|
||||||
|
# Remove associated domain and DNS records
|
||||||
|
Domain = TinyDBQuery()
|
||||||
|
domains_table.remove(Domain.upload_id == upload_id)
|
||||||
|
|
||||||
|
DNSRecord = TinyDBQuery()
|
||||||
|
dns_records_table.remove(DNSRecord.upload_id == upload_id)
|
||||||
|
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error deleting upload {upload_id}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Routes
|
||||||
|
@app.get("/", response_class=HTMLResponse)
|
||||||
|
async def home(request: Request, upload_id: Optional[str] = None):
|
||||||
|
"""Home page with upload form and SLD listing"""
|
||||||
|
domains = load_domains(upload_id)
|
||||||
|
uploads = get_uploads()
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"index.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"domains": domains,
|
||||||
|
"uploads": uploads
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.get("/delete-upload/{upload_id}", response_class=RedirectResponse)
|
||||||
|
async def delete_upload_route(upload_id: str):
|
||||||
|
"""Delete an upload and all associated records"""
|
||||||
|
success = delete_upload(upload_id)
|
||||||
|
if not success:
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to delete upload")
|
||||||
|
|
||||||
|
# Redirect back to home page
|
||||||
|
return RedirectResponse(url="/", status_code=303)
|
||||||
|
|
||||||
|
@app.post("/upload", response_class=RedirectResponse)
|
||||||
|
async def upload_csv(request: Request, file: UploadFile = File(...), description: str = Form(None)):
|
||||||
|
"""Handle file upload"""
|
||||||
|
try:
|
||||||
|
# Read file content
|
||||||
|
content = await file.read()
|
||||||
|
|
||||||
|
# Ensure content is not empty
|
||||||
|
if not content or len(content) == 0:
|
||||||
|
raise ValueError("Uploaded file is empty")
|
||||||
|
|
||||||
|
# Generate a unique ID for this upload with timestamp and a random suffix for extra uniqueness
|
||||||
|
now = datetime.datetime.now()
|
||||||
|
import random
|
||||||
|
random_suffix = ''.join(random.choices('abcdefghijklmnopqrstuvwxyz0123456789', k=6))
|
||||||
|
upload_id = f"upload_{now.strftime('%Y%m%d_%H%M%S')}_{random_suffix}"
|
||||||
|
|
||||||
|
print(f"Processing upload: ID={upload_id}, Filename={file.filename}, Content size={len(content)} bytes")
|
||||||
|
|
||||||
|
# Process the CSV content
|
||||||
|
domains_count, records_count = await process_csv_upload(content, upload_id, description)
|
||||||
|
|
||||||
|
print(f"Upload processed: {domains_count} domains and {records_count} DNS records inserted")
|
||||||
|
|
||||||
|
if domains_count == 0 and records_count == 0:
|
||||||
|
print("WARNING: No records were inserted. Content may be invalid or empty.")
|
||||||
|
# Try to decode and print the first few lines for debugging
|
||||||
|
try:
|
||||||
|
preview = content.decode('utf-8')[:500]
|
||||||
|
print(f"File preview: {preview}")
|
||||||
|
except:
|
||||||
|
print("Could not decode file content for preview")
|
||||||
|
|
||||||
|
# Store upload information with file hash to identify content changes
|
||||||
|
import hashlib
|
||||||
|
content_hash = hashlib.md5(content).hexdigest()
|
||||||
|
|
||||||
|
upload_info = {
|
||||||
|
"id": upload_id,
|
||||||
|
"filename": file.filename,
|
||||||
|
"description": description,
|
||||||
|
"timestamp": datetime.datetime.now().isoformat(),
|
||||||
|
"domains_count": domains_count,
|
||||||
|
"records_count": records_count,
|
||||||
|
"content_hash": content_hash
|
||||||
|
}
|
||||||
|
uploads_table.insert(upload_info)
|
||||||
|
|
||||||
|
# Redirect back to home page
|
||||||
|
return RedirectResponse(url="/", status_code=303)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error in upload_csv: {e}")
|
||||||
|
return {"error": str(e)}
|
||||||
|
|
||||||
|
@app.get("/dns-records", response_class=HTMLResponse)
|
||||||
|
async def dns_records(
|
||||||
|
request: Request,
|
||||||
|
upload_id: Optional[str] = None,
|
||||||
|
record_type: Optional[str] = None,
|
||||||
|
record_class: Optional[str] = None,
|
||||||
|
tld: Optional[str] = None,
|
||||||
|
sld: Optional[str] = None,
|
||||||
|
domain: Optional[str] = None
|
||||||
|
):
|
||||||
|
"""DNS Records page with filtering"""
|
||||||
|
# Get all entries first, based on upload_id if provided
|
||||||
|
entries = load_dns_entries(upload_id)
|
||||||
|
|
||||||
|
# Apply additional filters if provided
|
||||||
|
if record_type:
|
||||||
|
entries = [e for e in entries if e.get("record_type") == record_type]
|
||||||
|
if record_class:
|
||||||
|
entries = [e for e in entries if e.get("record_class") == record_class]
|
||||||
|
if tld:
|
||||||
|
entries = [e for e in entries if e.get("tld") == tld]
|
||||||
|
if sld:
|
||||||
|
entries = [e for e in entries if e.get("sld") == sld]
|
||||||
|
if domain:
|
||||||
|
entries = [e for e in entries if domain.lower() in e.get("domain", "").lower()]
|
||||||
|
|
||||||
|
# Get unique values for filter dropdowns from all entries (not filtered)
|
||||||
|
all_entries = load_dns_entries(upload_id)
|
||||||
|
unique_values = get_unique_values(all_entries)
|
||||||
|
uploads = get_uploads()
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"dns_records.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"entries": entries,
|
||||||
|
"unique_values": unique_values,
|
||||||
|
"uploads": uploads
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# API Routes
|
||||||
|
@app.get("/api/uploads", response_model=List[Dict])
|
||||||
|
async def get_all_uploads():
|
||||||
|
"""API endpoint that returns all uploads"""
|
||||||
|
return get_uploads()
|
||||||
|
|
||||||
|
@app.get("/api/slds", response_model=List[Dict])
|
||||||
|
async def get_slds(upload_id: Optional[str] = None):
|
||||||
|
"""API endpoint that returns all SLDs with optional filter by upload_id"""
|
||||||
|
# The load_domains function now handles deduplication and upload_id filtering
|
||||||
|
domains = load_domains(upload_id)
|
||||||
|
return domains
|
||||||
|
|
||||||
|
@app.get("/api/slds/{sld}", response_model=List[Dict])
|
||||||
|
async def get_domains_by_sld(sld: str, upload_id: Optional[str] = None):
|
||||||
|
"""API endpoint that returns domains for a specific SLD with optional filter by upload_id"""
|
||||||
|
# Get domains, already deduplicated and optionally filtered by upload_id
|
||||||
|
all_domains = load_domains(upload_id)
|
||||||
|
|
||||||
|
# Filter by SLD
|
||||||
|
filtered = [item for item in all_domains if item["sld"].lower() == sld.lower()]
|
||||||
|
|
||||||
|
if not filtered:
|
||||||
|
raise HTTPException(status_code=404, detail=f"No domains found with SLD: {sld}")
|
||||||
|
|
||||||
|
return filtered
|
||||||
|
|
||||||
|
@app.get("/api/dns", response_model=List[Dict])
|
||||||
|
async def get_dns_entries(
|
||||||
|
record_type: Optional[str] = None,
|
||||||
|
record_class: Optional[str] = None,
|
||||||
|
tld: Optional[str] = None,
|
||||||
|
sld: Optional[str] = None,
|
||||||
|
domain: Optional[str] = None,
|
||||||
|
upload_id: Optional[str] = None
|
||||||
|
):
|
||||||
|
"""API endpoint that returns filtered DNS entries"""
|
||||||
|
# Get entries - if upload_id is specified, only those entries are returned
|
||||||
|
entries = load_dns_entries(upload_id)
|
||||||
|
|
||||||
|
# Apply additional filters if provided
|
||||||
|
if record_type:
|
||||||
|
entries = [e for e in entries if e.get("record_type") == record_type]
|
||||||
|
if record_class:
|
||||||
|
entries = [e for e in entries if e.get("record_class") == record_class]
|
||||||
|
if tld:
|
||||||
|
entries = [e for e in entries if e.get("tld") == tld]
|
||||||
|
if sld:
|
||||||
|
entries = [e for e in entries if e.get("sld") == sld]
|
||||||
|
if domain:
|
||||||
|
entries = [e for e in entries if domain.lower() in e.get("domain", "").lower()]
|
||||||
|
|
||||||
|
return entries
|
||||||
|
|
||||||
|
@app.get("/api/dns/types", response_model=Dict[str, List])
|
||||||
|
async def get_unique_filter_values(upload_id: Optional[str] = None):
|
||||||
|
"""API endpoint that returns unique values for filters"""
|
||||||
|
# Get entries - if upload_id is specified, only those entries are returned
|
||||||
|
entries = load_dns_entries(upload_id)
|
||||||
|
|
||||||
|
return get_unique_values(entries)
|
||||||
|
|
||||||
|
# Create templates directory and HTML file
|
||||||
|
Path("templates").mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
|
6
requirements.txt
Normal file
6
requirements.txt
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
fastapi==0.104.1
|
||||||
|
uvicorn==0.23.2
|
||||||
|
python-multipart==0.0.6
|
||||||
|
jinja2==3.1.2
|
||||||
|
pandas==2.1.1
|
||||||
|
tinydb==4.8.0
|
61
run_container.sh
Executable file
61
run_container.sh
Executable file
|
@ -0,0 +1,61 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Function to check if a command exists
|
||||||
|
command_exists() {
|
||||||
|
command -v "$1" >/dev/null 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Determine if we use podman or docker
|
||||||
|
if command_exists podman; then
|
||||||
|
CONTAINER_CMD="podman"
|
||||||
|
VOLUME_FLAG=":Z"
|
||||||
|
echo "Using Podman for container management."
|
||||||
|
elif command_exists docker; then
|
||||||
|
CONTAINER_CMD="docker"
|
||||||
|
VOLUME_FLAG=""
|
||||||
|
echo "Using Docker for container management."
|
||||||
|
else
|
||||||
|
echo "Error: Neither Podman nor Docker found. Please install one of them first."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Stop and remove container if it exists
|
||||||
|
echo "Checking for existing container..."
|
||||||
|
if $CONTAINER_CMD ps -a --format '{{.Names}}' | grep -q "^fastapi-domains$"; then
|
||||||
|
echo "Stopping and removing existing fastapi-domains container..."
|
||||||
|
$CONTAINER_CMD stop fastapi-domains
|
||||||
|
$CONTAINER_CMD rm fastapi-domains
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create volume if it doesn't exist
|
||||||
|
echo "Creating volume for persistent data storage..."
|
||||||
|
$CONTAINER_CMD volume create domain-data
|
||||||
|
|
||||||
|
# Build the container image
|
||||||
|
echo "Building container image..."
|
||||||
|
$CONTAINER_CMD build -t fastapi-domains:latest .
|
||||||
|
|
||||||
|
# Run the container
|
||||||
|
echo "Starting container..."
|
||||||
|
$CONTAINER_CMD run --name fastapi-domains \
|
||||||
|
-p 8000:8000 \
|
||||||
|
-v domain-data:/home/appuser/app/data${VOLUME_FLAG} \
|
||||||
|
-e DB_DIR=/home/appuser/app/data \
|
||||||
|
--security-opt no-new-privileges:true \
|
||||||
|
--read-only \
|
||||||
|
--tmpfs /tmp \
|
||||||
|
--cap-drop ALL \
|
||||||
|
--user 1000:1000 \
|
||||||
|
-d fastapi-domains:latest
|
||||||
|
|
||||||
|
# Check if container started successfully
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
echo "Container started successfully!"
|
||||||
|
echo "Application is available at: http://localhost:8000"
|
||||||
|
echo ""
|
||||||
|
echo "Container logs:"
|
||||||
|
$CONTAINER_CMD logs fastapi-domains
|
||||||
|
else
|
||||||
|
echo "Failed to start container."
|
||||||
|
exit 1
|
||||||
|
fi
|
316
templates/dns_records.html
Normal file
316
templates/dns_records.html
Normal file
|
@ -0,0 +1,316 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>DNS Records Viewer</title>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
margin: 0;
|
||||||
|
padding: 20px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
color: #333;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.nav {
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
.nav-link {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background-color: #4CAF50;
|
||||||
|
color: white;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
.nav-link:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
.filter-section {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 15px;
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 5px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.filter-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.filter-group label {
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
.filter-group select, .filter-group input {
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
min-width: 150px;
|
||||||
|
}
|
||||||
|
.filter-buttons {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
padding: 8px 16px;
|
||||||
|
background-color: #4CAF50;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
button.reset {
|
||||||
|
background-color: #f44336;
|
||||||
|
}
|
||||||
|
button:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-top: 20px;
|
||||||
|
table-layout: fixed;
|
||||||
|
}
|
||||||
|
th, td {
|
||||||
|
padding: 12px 15px;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid #ddd;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
th {
|
||||||
|
background-color: #f2f2f2;
|
||||||
|
font-weight: bold;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
tr:hover {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
}
|
||||||
|
/* History elements removed */
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 3px 7px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
.record-type-badge {
|
||||||
|
background-color: #e0e0e0;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
.sld-badge {
|
||||||
|
background-color: #d1e7dd;
|
||||||
|
color: #0f5132;
|
||||||
|
}
|
||||||
|
.tld-badge {
|
||||||
|
background-color: #cfe2ff;
|
||||||
|
color: #0a58ca;
|
||||||
|
}
|
||||||
|
.service-badge {
|
||||||
|
background-color: #fff3cd;
|
||||||
|
color: #664d03;
|
||||||
|
}
|
||||||
|
.api-section {
|
||||||
|
margin-top: 30px;
|
||||||
|
padding: 15px;
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
code {
|
||||||
|
background: #f0f0f0;
|
||||||
|
padding: 2px 4px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
.count-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 3px 8px;
|
||||||
|
background-color: #6c757d;
|
||||||
|
color: white;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 0.8em;
|
||||||
|
margin-left: 10px;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
.tooltip {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
.tooltip .tooltiptext {
|
||||||
|
visibility: hidden;
|
||||||
|
width: 200px;
|
||||||
|
background-color: #555;
|
||||||
|
color: #fff;
|
||||||
|
text-align: center;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 5px;
|
||||||
|
position: absolute;
|
||||||
|
z-index: 1;
|
||||||
|
bottom: 125%;
|
||||||
|
left: 50%;
|
||||||
|
margin-left: -100px;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s;
|
||||||
|
}
|
||||||
|
.tooltip:hover .tooltiptext {
|
||||||
|
visibility: visible;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.filter-section {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.filter-group {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>DNS Entry Viewer</h1>
|
||||||
|
|
||||||
|
<div class="nav">
|
||||||
|
<a href="/" class="nav-link">SLD View</a>
|
||||||
|
<a href="/dns-records" class="nav-link">DNS Records</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="filter-section" method="get" action="/dns-records">
|
||||||
|
<div class="filter-group">
|
||||||
|
<label for="upload-filter">Upload:</label>
|
||||||
|
<select id="upload-filter" name="upload_id">
|
||||||
|
<option value="">All Uploads</option>
|
||||||
|
{% for upload in uploads %}
|
||||||
|
<option value="{{ upload.id }}" {% if request.query_params.get('upload_id') == upload.id %}selected{% endif %}>
|
||||||
|
{{ upload.filename }} - {{ upload.timestamp.replace('T', ' ').split('.')[0] }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="filter-group">
|
||||||
|
<label for="record-type">Record Type:</label>
|
||||||
|
<select id="record-type" name="record_type">
|
||||||
|
<option value="">All Types</option>
|
||||||
|
{% for type in unique_values.record_type %}
|
||||||
|
<option value="{{ type }}" {% if request.query_params.get('record_type') == type %}selected{% endif %}>{{ type }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="filter-group">
|
||||||
|
<label for="record-class">Record Class:</label>
|
||||||
|
<select id="record-class" name="record_class">
|
||||||
|
<option value="">All Classes</option>
|
||||||
|
{% for class in unique_values.record_class %}
|
||||||
|
<option value="{{ class }}" {% if request.query_params.get('record_class') == class %}selected{% endif %}>{{ class }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="filter-group">
|
||||||
|
<label for="tld">TLD:</label>
|
||||||
|
<select id="tld" name="tld">
|
||||||
|
<option value="">All TLDs</option>
|
||||||
|
{% for tld in unique_values.tld %}
|
||||||
|
<option value="{{ tld }}" {% if request.query_params.get('tld') == tld %}selected{% endif %}>{{ tld }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="filter-group">
|
||||||
|
<label for="sld">SLD:</label>
|
||||||
|
<select id="sld" name="sld">
|
||||||
|
<option value="">All SLDs</option>
|
||||||
|
{% for sld in unique_values.sld %}
|
||||||
|
<option value="{{ sld }}" {% if request.query_params.get('sld') == sld %}selected{% endif %}>{{ sld }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="filter-group">
|
||||||
|
<label for="domain-search">Domain Search:</label>
|
||||||
|
<input type="text" id="domain-search" name="domain" placeholder="Enter domain name..." value="{{ request.query_params.get('domain', '') }}">
|
||||||
|
</div>
|
||||||
|
<div class="filter-buttons">
|
||||||
|
<button type="submit">Apply Filters</button>
|
||||||
|
<a href="/dns-records" class="reset-button" style="padding: 8px 16px; background-color: #f44336; color: white; text-decoration: none; border-radius: 4px; font-weight: bold; display: inline-block;">Reset</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<h2>DNS Records <span class="count-badge">{{ entries|length }}</span></h2>
|
||||||
|
|
||||||
|
{% if entries %}
|
||||||
|
<table id="dns-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width: 20%;">Domain</th>
|
||||||
|
<th style="width: 8%;">TTL</th>
|
||||||
|
<th style="width: 7%;">Class</th>
|
||||||
|
<th style="width: 8%;">Type</th>
|
||||||
|
<th style="width: 40%;">Data</th>
|
||||||
|
<th style="width: 17%;">Last Updated</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for entry in entries %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
{% if entry.get('service') %}
|
||||||
|
<span class="badge service-badge">{{ entry.service }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if entry.get('subdomain') %}
|
||||||
|
{{ entry.subdomain }}.
|
||||||
|
{% endif %}
|
||||||
|
<span class="badge sld-badge">{{ entry.sld }}</span>.
|
||||||
|
<span class="badge tld-badge">{{ entry.tld }}</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ entry.ttl }}</td>
|
||||||
|
<td>{{ entry.record_class }}</td>
|
||||||
|
<td><span class="badge record-type-badge">{{ entry.record_type }}</span></td>
|
||||||
|
<td class="tooltip">
|
||||||
|
{{ entry.record_data }}
|
||||||
|
{% if entry.record_type == "SOA" or entry.record_data|length > 50 %}
|
||||||
|
<span class="tooltiptext">{{ entry.record_data }}</span>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- History elements removed -->
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{ entry.timestamp.replace('T', ' ').split('.')[0] }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<p>No DNS entries found. Please upload a CSV file to get started.</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="api-section">
|
||||||
|
<h2>API Endpoints</h2>
|
||||||
|
<p>Get all DNS entries: <code>/api/dns</code></p>
|
||||||
|
<p>Get filtered DNS entries: <code>/api/dns?record_type=A&tld=de</code></p>
|
||||||
|
<p>Filter by upload: <code>/api/dns?upload_id={upload_id}</code></p>
|
||||||
|
<p>Get unique filter values: <code>/api/dns/types</code></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- All JavaScript removed, using server-side FastAPI for filtering -->
|
||||||
|
<script>
|
||||||
|
// No JavaScript needed - all filtering handled by FastAPI
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
259
templates/index.html
Normal file
259
templates/index.html
Normal file
|
@ -0,0 +1,259 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Domain Viewer</title>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
margin: 0;
|
||||||
|
padding: 20px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
h1, h2 {
|
||||||
|
color: #333;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
}
|
||||||
|
.nav {
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
.nav-link {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background-color: #4CAF50;
|
||||||
|
color: white;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
.nav-link:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
th, td {
|
||||||
|
padding: 12px 15px;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid #ddd;
|
||||||
|
}
|
||||||
|
th {
|
||||||
|
background-color: #f2f2f2;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
tr:hover {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
}
|
||||||
|
.sld-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 3px 7px;
|
||||||
|
background-color: #d1e7dd;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: #0f5132;
|
||||||
|
}
|
||||||
|
.tld-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 3px 7px;
|
||||||
|
background-color: #cfe2ff;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: #0a58ca;
|
||||||
|
}
|
||||||
|
.api-section {
|
||||||
|
margin-top: 30px;
|
||||||
|
padding: 15px;
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
code {
|
||||||
|
background: #f0f0f0;
|
||||||
|
padding: 2px 4px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
.upload-form {
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 5px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.form-control {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
padding: 10px 15px;
|
||||||
|
background-color: #4CAF50;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
.btn:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
.delete-button {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 6px 12px;
|
||||||
|
background-color: #dc3545;
|
||||||
|
color: white;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.delete-button:hover {
|
||||||
|
background-color: #c82333;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.upload-history {
|
||||||
|
margin-top: 20px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
.filter-form {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.filter-select {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>Domain Management System</h1>
|
||||||
|
|
||||||
|
<div class="nav">
|
||||||
|
<a href="/" class="nav-link">SLD View</a>
|
||||||
|
<a href="/dns-records" class="nav-link">DNS Records</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="upload-form">
|
||||||
|
<h2>Upload CSV File</h2>
|
||||||
|
<form action="/upload" method="post" enctype="multipart/form-data">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="file">CSV File:</label>
|
||||||
|
<input type="file" id="file" name="file" class="form-control" accept=".csv" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="description">Description (optional):</label>
|
||||||
|
<input type="text" id="description" name="description" class="form-control" placeholder="Enter a description for this upload">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn">Upload</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if uploads %}
|
||||||
|
<div class="upload-history">
|
||||||
|
<h2>Upload History</h2>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Filename</th>
|
||||||
|
<th>Description</th>
|
||||||
|
<th>Domains</th>
|
||||||
|
<th>DNS Records</th>
|
||||||
|
<th>View</th>
|
||||||
|
<th>Delete</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for upload in uploads %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ upload.timestamp.replace('T', ' ').split('.')[0] }}</td>
|
||||||
|
<td>{{ upload.filename }}</td>
|
||||||
|
<td>{{ upload.description or "N/A" }}</td>
|
||||||
|
<td>{{ upload.domains_count }}</td>
|
||||||
|
<td>{{ upload.records_count }}</td>
|
||||||
|
<td>
|
||||||
|
<a href="/?upload_id={{ upload.id }}" class="nav-link">View</a>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a href="/delete-upload/{{ upload.id }}" class="delete-button" onclick="return confirm('Are you sure you want to delete this upload? This will remove all associated domain and DNS records.')">Delete</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="filter-form">
|
||||||
|
<h2>Domain List</h2>
|
||||||
|
<form id="filterForm" method="get">
|
||||||
|
<label for="upload_filter">Filter by upload:</label>
|
||||||
|
<select id="upload_filter" name="upload_id" class="filter-select" onchange="this.form.submit()">
|
||||||
|
<option value="">All uploads</option>
|
||||||
|
{% for upload in uploads %}
|
||||||
|
<option value="{{ upload.id }}" {% if request.query_params.get('upload_id') == upload.id %}selected{% endif %}>
|
||||||
|
{{ upload.filename }} - {{ upload.timestamp.replace('T', ' ').split('.')[0] }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="api-section">
|
||||||
|
<h3>API Endpoints</h3>
|
||||||
|
<p>Get all uploads: <code>/api/uploads</code></p>
|
||||||
|
<p>Get all domains: <code>/api/slds</code></p>
|
||||||
|
<p>Get domains by SLD: <code>/api/slds/{sld}</code></p>
|
||||||
|
<p>Filter by upload: <code>/api/slds?upload_id={upload_id}</code></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if domains %}
|
||||||
|
<p>Found {{ domains|length }} domains{% if request.query_params.get('upload_id') %} in this upload{% endif %}.</p>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>SLD</th>
|
||||||
|
<th>TLD</th>
|
||||||
|
<th>Subdomain</th>
|
||||||
|
<th>Full Domain</th>
|
||||||
|
<th>Upload Date</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for item in domains %}
|
||||||
|
<tr>
|
||||||
|
<td><span class="sld-badge">{{ item.sld }}</span></td>
|
||||||
|
<td><span class="tld-badge">{{ item.tld }}</span></td>
|
||||||
|
<td>{{ item.get('subdomain', 'N/A') }}</td>
|
||||||
|
<td>{{ item.full_domain }}</td>
|
||||||
|
<td>{{ item.timestamp.replace('T', ' ').split('.')[0] if item.get('timestamp') else 'N/A' }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<p>No domains found. Please upload a CSV file to get started.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
Loading…
Add table
Add a link
Reference in a new issue