This commit is contained in:
Mateusz Gruszczyński
2025-08-28 22:54:52 +02:00
parent fe932e7a9f
commit 01b8ff656e
6 changed files with 419 additions and 256 deletions

41
.env.example Normal file
View File

@@ -0,0 +1,41 @@
# Podstawy
FLASK_DEBUG=false
SECRET_KEY=change-me
# Limity
MAX_CONTENT_LENGTH=52428800
RATE_LIMIT_DEFAULT="100 per minute"
RATE_LIMIT_CONVERT="100 per minute"
# Redis wybierz REDIS_URL lub HOST/PORT/DB
REDIS_URL=redis://localhost:6379/7
# REDIS_HOST=localhost
# REDIS_PORT=6379
# REDIS_DB=7
# Basic Auth dla /stats
STATS_BASIC_AUTH_ENABLED=true
STATS_BASIC_AUTH_REALM=Stats
STATS_BASIC_AUTH_USER=admin
STATS_BASIC_AUTH_PASS=strong-password
# Cache (Varnish-friendly)
CACHE_ENABLED=true
CACHE_S_MAXAGE=43200
CACHE_MAX_AGE=3600
USE_REDIS_BODY_CACHE=false
# Stream / aiohttp
AIOHTTP_TOTAL_TIMEOUT=70
AIOHTTP_CONNECT_TIMEOUT=10
AIOHTTP_SOCK_CONNECT_TIMEOUT=10
AIOHTTP_SOCK_READ_TIMEOUT=60
READ_CHUNK=65536
STREAM_LINE_LIMIT=4096
# Serwer
BIND_HOST=127.0.0.1
BIND_PORT=8283
# Domyślny URL źródłowy (opcjonalnie)
DEFAULT_SOURCE_URL="https://raw.githubusercontent.com/217heidai/adblockfilters/main/rules/adblockdns.txt"

538
app.py
View File

@@ -1,203 +1,224 @@
import re import re
import redis import redis
import requests import requests
import aiohttp
import asyncio
import socket import socket
import time import time
import json import json
import base64
import hashlib
from datetime import datetime from datetime import datetime
from flask import Flask, request, render_template, abort, jsonify, g
from urllib.parse import urlparse, quote, unquote, urljoin from urllib.parse import urlparse, quote, unquote, urljoin
from functools import wraps from functools import wraps
from typing import Optional
from flask import Flask, request, render_template, abort, jsonify, stream_with_context, g, Response
from flask_compress import Compress from flask_compress import Compress
from flask_limiter import Limiter from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
import config
app = Flask(__name__) app = Flask(__name__)
app.config['MAX_CONTENT_LENGTH'] = 50 * 1024 * 1024 # limit app.config["MAX_CONTENT_LENGTH"] = config.MAX_CONTENT_LENGTH
redis_client = redis.Redis(host='localhost', port=6379, db=7) app.config["SECRET_KEY"] = config.SECRET_KEY
app.debug = config.FLASK_DEBUG
def build_redis():
if config.REDIS_URL:
return redis.Redis.from_url(config.REDIS_URL)
return redis.Redis(host=config.REDIS_HOST, port=config.REDIS_PORT, db=config.REDIS_DB)
redis_client = build_redis()
# Ustawienia do rate limiting 100 żądań na minutę
def get_client_ip(): def get_client_ip():
"""Pobranie prawdziwego adresu IP klienta (uwzględniając proxy)""" xff = request.headers.get("X-Forwarded-For", "").split(",")
x_forwarded_for = request.headers.get('X-Forwarded-For', '').split(',') if xff and xff[0].strip():
if x_forwarded_for and x_forwarded_for[0].strip(): return xff[0].strip()
return x_forwarded_for[0].strip()
return request.remote_addr return request.remote_addr
limiter = Limiter(key_func=get_client_ip, default_limits=["100 per minute"], app=app) limiter = Limiter(
Compress(app) key_func=get_client_ip,
app=app,
default_limits=[config.RATE_LIMIT_DEFAULT],
storage_uri=config.REDIS_URL
)
ALLOWED_IPS = {'127.0.0.1', '109.173.163.139'} Compress(app)
ALLOWED_DOMAIN = ''
@app.before_request @app.before_request
def track_request_data(): def track_request_data():
"""Rejestracja IP klienta, User-Agent, metody HTTP oraz rozpoczęcie pomiaru czasu requestu""" g.start_time = time.perf_counter()
g.start_time = time.perf_counter() # rozpoczęcie pomiaru czasu redis_client.incr(f"stats:user_agents:{quote(request.headers.get('User-Agent', 'Unknown'), safe='')}")
client_ip = get_client_ip() redis_client.incr(f"stats:client_ips:{get_client_ip()}")
user_agent = request.headers.get('User-Agent', 'Unknown') redis_client.incr(f"stats:methods:{request.method}")
method = request.method
# Rejestracja User-Agent
redis_client.incr(f'stats:user_agents:{quote(user_agent, safe="")}')
# Rejestracja adresu IP klienta
redis_client.incr(f'stats:client_ips:{client_ip}')
# Rejestracja metody HTTP
redis_client.incr(f'stats:methods:{method}')
@app.after_request @app.after_request
def after_request(response): def after_request(response):
"""Pomiar i rejestracja czasu przetwarzania żądania"""
elapsed = time.perf_counter() - g.start_time elapsed = time.perf_counter() - g.start_time
# Aktualizacja statystyk czasu przetwarzania (w sekundach) redis_client.incrbyfloat("stats:processing_time_total", elapsed)
redis_client.incrbyfloat('stats:processing_time_total', elapsed) redis_client.incr("stats:processing_time_count")
redis_client.incr('stats:processing_time_count')
# Aktualizacja minimalnego czasu przetwarzania
try: try:
current_min = float(redis_client.get('stats:processing_time_min') or elapsed) current_min = float(redis_client.get("stats:processing_time_min") or elapsed)
if elapsed < current_min: if elapsed < current_min:
redis_client.set('stats:processing_time_min', elapsed) redis_client.set("stats:processing_time_min", elapsed)
except Exception: except Exception:
redis_client.set('stats:processing_time_min', elapsed) redis_client.set("stats:processing_time_min", elapsed)
# Aktualizacja maksymalnego czasu przetwarzania
try: try:
current_max = float(redis_client.get('stats:processing_time_max') or elapsed) current_max = float(redis_client.get("stats:processing_time_max") or elapsed)
if elapsed > current_max: if elapsed > current_max:
redis_client.set('stats:processing_time_max', elapsed) redis_client.set("stats:processing_time_max", elapsed)
except Exception: except Exception:
redis_client.set('stats:processing_time_max', elapsed) redis_client.set("stats:processing_time_max", elapsed)
return response return response
@app.template_filter('datetimeformat') @app.template_filter("datetimeformat")
def datetimeformat_filter(value, format='%Y-%m-%d %H:%M'): def datetimeformat_filter(value, format="%Y-%m-%d %H:%M"):
try: try:
dt = datetime.fromisoformat(value) dt = datetime.fromisoformat(value)
return dt.strftime(format) return dt.strftime(format)
except (ValueError, AttributeError): except (ValueError, AttributeError):
return value return value
def ip_restriction(f): def basic_auth_required(realm: str, user: str, password: str):
@wraps(f) def decorator(f):
def decorated(*args, **kwargs): @wraps(f)
client_ip = get_client_ip() def wrapper(*args, **kwargs):
host = request.host.split(':')[0] if not config.STATS_BASIC_AUTH_ENABLED:
return f(*args, **kwargs)
allowed_conditions = [ auth = request.headers.get("Authorization", "")
client_ip in ALLOWED_IPS, if auth.startswith("Basic "):
host == ALLOWED_DOMAIN, try:
request.headers.get('X-Forwarded-For', '').split(',')[0].strip() in ALLOWED_IPS decoded = base64.b64decode(auth[6:]).decode("utf-8", errors="ignore")
] u, p = decoded.split(":", 1)
if u == user and p == password:
if any(allowed_conditions): return f(*args, **kwargs)
return f(*args, **kwargs) except Exception:
redis_client.incr('stats:errors_403') pass
abort(403) resp = Response(status=401)
return decorated resp.headers["WWW-Authenticate"] = f'Basic realm="{realm}"'
return resp
return wrapper
return decorator
def cache_key(source_url, ip): def cache_key(source_url, ip):
return f"cache:{source_url}:{ip}" return f"cache:{source_url}:{ip}"
def should_ignore_domain(domain): def should_ignore_domain(domain):
"""Sprawdza, czy domena zaczyna się od kropki i powinna być ignorowana.""" return domain.startswith(".") or any(ch in domain for ch in ["~", "=", "$", "'", "^", "_", ">", "<", ":"])
return domain.startswith('.') or any(char in domain for char in ['~', '=', '$', "'", "^", "_", ">", "<", ":"])
def should_ignore_line(line): def should_ignore_line(line):
"""Sprawdza, czy linia zawiera określone znaki i powinna być ignorowana.""" return any(sym in line for sym in ["<", ">", "##", "###", "div", "span"])
return any(symbol in line for symbol in ['<', '>', '##', '###', "div", "span"])
def is_valid_domain(domain): def is_valid_domain(domain):
"""Sprawdza, czy domena ma poprawną składnię.""" return bool(re.compile(r"^(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}$").match(domain))
domain_regex = re.compile(r'^(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}$')
return bool(domain_regex.match(domain))
def convert_hosts(content, target_ip): def convert_host_line(line: str, target_ip: str):
"""Konwersja treści pliku hosts oraz reguł AdGuard DNS.""" # szybkie odrzucenia
converted = [] if not line:
invalid_lines = [] return None
line = line.strip()
for line in content.splitlines(): # komentarze/puste
line = line.strip() if not line or line.startswith(("!", "#", "/", ";")):
return None
# Pomijanie pustych linii, komentarzy i linii do ignorowania # wytnij komentarz końcowy (# lub ;) ostrożnie ze 'http://'
if not line or line[0] in ('!', '#', '/') or should_ignore_line(line): # usuwamy wszystko od ' #' lub ' ;' (spacja przed znacznikiem komentarza)
continue for sep in (" #", " ;"):
idx = line.find(sep)
if idx != -1:
line = line[:idx].rstrip()
# Obsługa reguł AdGuard DNS if not line:
match = re.match(r'^\|\|([^\^]+)\^.*', line) return None
if match:
domain = match.group(1)
if should_ignore_domain(domain):
continue
if not is_valid_domain(domain):
invalid_lines.append(line)
continue
converted.append(f"{target_ip} {domain}")
continue
# Obsługa klasycznego formatu hosts # 1) AdGuard / uBlock DNS: ||domain^ (opcjonalnie z dodatkami po '^')
parts = line.split() m = re.match(r"^\|\|([a-z0-9.-]+)\^", line, re.IGNORECASE)
if len(parts) > 1: if m:
domain_part = parts[1] domain = m.group(1).strip(".")
if should_ignore_domain(domain_part): if not should_ignore_domain(domain) and is_valid_domain(domain):
continue return f"{target_ip} {domain}"
if not is_valid_domain(domain_part): return None
invalid_lines.append(line)
continue
converted.append(re.sub(r'^\S+', target_ip, line, count=1))
if invalid_lines:
print("Niepoprawne linie:")
for invalid in invalid_lines:
print(invalid)
return '\n'.join(converted)
parts = line.split()
# 2) Klasyczny hosts: "IP domena [...]" (IPv4 lub IPv6)
if len(parts) >= 2 and (
re.match(r"^\d{1,3}(?:\.\d{1,3}){3}$", parts[0]) or ":" in parts[0]
):
domain = parts[1].strip().split("#", 1)[0].strip().strip(".")
if not should_ignore_domain(domain) and is_valid_domain(domain):
return f"{target_ip} {domain}"
return None
# 3) dnsmasq: address=/domain/0.0.0.0 czy server=/domain/...
m = re.match(r"^(?:address|server)=/([a-z0-9.-]+)/", line, re.IGNORECASE)
if m:
domain = m.group(1).strip(".")
if not should_ignore_domain(domain) and is_valid_domain(domain):
return f"{target_ip} {domain}"
return None
# 4) Domain-only: "example.com" lub "example.com # komentarz"
token = parts[0].split("#", 1)[0].strip().strip(".")
if token and not should_ignore_domain(token) and is_valid_domain(token):
return f"{target_ip} {token}"
return None
def build_etag(up_etag: Optional[str], up_lastmod: Optional[str], target_ip: str) -> str:
base = (up_etag or up_lastmod or "no-upstream") + f"::{target_ip}::v1"
return 'W/"' + hashlib.sha1(base.encode("utf-8")).hexdigest() + '"'
def cache_headers(etag: str, up_lm: Optional[str]):
headers = {
"ETag": etag,
"Vary": "Accept-Encoding",
"Content-Type": "text/plain; charset=utf-8",
"X-Content-Type-Options": "nosniff",
"Content-Disposition": "inline; filename=converted_hosts.txt",
}
if config.CACHE_ENABLED:
headers["Cache-Control"] = f"public, s-maxage={config.CACHE_S_MAXAGE}, max-age={config.CACHE_MAX_AGE}"
else:
headers["Cache-Control"] = "no-store"
if up_lm:
headers["Last-Modified"] = up_lm
return headers
def validate_and_normalize_url(url): def validate_and_normalize_url(url):
"""Walidacja i normalizacja adresu URL"""
parsed = urlparse(url) parsed = urlparse(url)
if not parsed.scheme: if not parsed.scheme:
url = f'https://{url}' url = f"https://{url}"
parsed = urlparse(url) parsed = urlparse(url)
if not parsed.netloc: if not parsed.netloc:
raise ValueError("Missing host in URL") raise ValueError("Missing host in URL")
return parsed.geturl() return parsed.geturl()
def track_url_request(url): def track_url_request(url):
"""Rejestracja żądania dla określonego URL""" redis_client.incr(f"stats:url_requests:{quote(url, safe='')}")
redis_key = f"stats:url_requests:{quote(url, safe='')}"
redis_client.incr(redis_key)
def add_recent_link(url, target_ip): def add_recent_link(url, target_ip):
"""Dodanie ostatniego linku do historii (ostatnie 10)""" ts = datetime.now().isoformat()
timestamp = datetime.now().isoformat() link_data = f"{ts}|{url}|{target_ip}"
link_data = f"{timestamp}|{url}|{target_ip}"
with redis_client.pipeline() as pipe: with redis_client.pipeline() as pipe:
pipe.lpush("recent_links", link_data) pipe.lpush("recent_links", link_data)
pipe.ltrim("recent_links", 0, 9) pipe.ltrim("recent_links", 0, 9)
pipe.execute() pipe.execute()
redis_client.incr('stats:recent_links_added') redis_client.incr("stats:recent_links_added")
def get_recent_links(): def get_recent_links():
"""Pobranie ostatnich 10 linków"""
links = redis_client.lrange("recent_links", 0, 9) links = redis_client.lrange("recent_links", 0, 9)
parsed_links = [] out = []
for link in links: for link in links:
parts = link.decode().split("|") parts = link.decode().split("|")
if len(parts) >= 3: if len(parts) >= 3:
parsed_links.append((parts[0], parts[1], parts[2])) out.append((parts[0], parts[1], parts[2]))
elif len(parts) == 2: elif len(parts) == 2:
parsed_links.append((parts[0], parts[1], "127.0.0.1")) out.append((parts[0], parts[1], "127.0.0.1"))
return parsed_links return out
def get_hostname(ip): def get_hostname(ip):
"""Cacheowanie wyników reverse DNS dla danego IP"""
key = f"reverse_dns:{ip}" key = f"reverse_dns:{ip}"
cached = redis_client.get(key) cached = redis_client.get(key)
if cached: if cached:
@@ -206,202 +227,215 @@ def get_hostname(ip):
hostname = socket.gethostbyaddr(ip)[0] hostname = socket.gethostbyaddr(ip)[0]
except Exception: except Exception:
hostname = ip hostname = ip
# Cache na 1 godzinę
redis_client.setex(key, 3600, hostname) redis_client.setex(key, 3600, hostname)
return hostname return hostname
# Nowa funkcja do logowania requestów dla endpointu /convert
def add_recent_convert(): def add_recent_convert():
"""Dodaje dane żądania do listy ostatnich konwersji (/convert)"""
ip = get_client_ip() ip = get_client_ip()
hostname = get_hostname(ip) hostname = get_hostname(ip)
user_agent = request.headers.get('User-Agent', 'Unknown') ua = request.headers.get("User-Agent", "Unknown")
time_str = datetime.now().astimezone().isoformat() time_str = datetime.now().astimezone().isoformat()
url = request.full_path # pełna ścieżka wraz z query string url = request.full_path
data = { data = {"url": url, "ip": ip, "hostname": hostname, "time": time_str, "user_agent": ua}
"url": url, redis_client.lpush("recent_converts", json.dumps(data))
"ip": ip,
"hostname": hostname,
"time": time_str,
"user_agent": user_agent
}
json_data = json.dumps(data)
redis_client.lpush("recent_converts", json_data)
redis_client.ltrim("recent_converts", 0, 49) redis_client.ltrim("recent_converts", 0, 49)
@app.route('/', methods=['GET']) @app.route("/favicon.ico", methods=["GET"])
def favicon():
return Response(status=204)
@app.route("/", methods=["GET"])
def index(): def index():
"""Strona główna z formularzem"""
generated_link = None generated_link = None
recent_links = get_recent_links() recent_links = get_recent_links()
url_param = request.args.get('url') url_param = request.args.get("url", config.DEFAULT_SOURCE_URL)
target_ip = request.args.get('ip', '127.0.0.1') target_ip = request.args.get("ip", "127.0.0.1")
client_ip = get_client_ip()
user_agent = request.headers.get('User-Agent', 'Unknown')
if url_param: if url_param:
try: try:
normalized_url = validate_and_normalize_url(unquote(url_param)) normalized = validate_and_normalize_url(unquote(url_param))
encoded_url = quote(normalized_url, safe='') encoded = quote(normalized, safe="")
generated_link = urljoin( generated_link = urljoin(request.host_url, f"convert?url={encoded}&ip={target_ip}")
request.host_url, add_recent_link(normalized, target_ip)
f"convert?url={encoded_url}&ip={target_ip}"
)
add_recent_link(normalized_url, target_ip)
recent_links = get_recent_links() recent_links = get_recent_links()
except Exception as e: except Exception as e:
app.logger.error(f"Error processing URL: {str(e)}") app.logger.error(f"Error processing URL: {str(e)}")
return render_template('form.html',
generated_link=generated_link,
recent_links=recent_links,
client_ip=client_ip,
user_agent=user_agent)
@app.route('/convert')
@limiter.limit("100 per minute")
async def convert():
"""Asynchroniczny endpoint do konwersji z weryfikacją typu zawartości"""
try: try:
redis_client.incr('stats:convert_requests') return render_template(
# Logowanie danych dla requestu do /convert "form.html",
generated_link=generated_link,
recent_links=recent_links,
client_ip=get_client_ip(),
user_agent=request.headers.get("User-Agent", "Unknown"),
)
except Exception:
return jsonify(
{
"generated_link": generated_link,
"recent_links": recent_links,
"client_ip": get_client_ip(),
"user_agent": request.headers.get("User-Agent", "Unknown"),
}
)
@app.route("/convert")
@limiter.limit(config.RATE_LIMIT_CONVERT)
def convert():
try:
redis_client.incr("stats:convert_requests")
add_recent_convert() add_recent_convert()
encoded_url = request.args.get('url') encoded_url = request.args.get("url")
if not encoded_url: if not encoded_url:
redis_client.incr('stats:errors_400') redis_client.incr("stats:errors_400")
abort(400, description="Missing URL parameter") abort(400, description="Missing URL parameter")
decoded_url = unquote(encoded_url) decoded_url = unquote(encoded_url)
normalized_url = validate_and_normalize_url(decoded_url) normalized_url = validate_and_normalize_url(decoded_url)
target_ip = request.args.get('ip', '127.0.0.1') target_ip = request.args.get("ip", "127.0.0.1")
# Rejestracja statystyk dotyczących URL
track_url_request(normalized_url) track_url_request(normalized_url)
redis_client.incr(f'stats:target_ips:{target_ip}') redis_client.incr(f"stats:target_ips:{target_ip}")
# Sprawdzenie pamięci podręcznej req_headers = {}
cached = redis_client.get(cache_key(normalized_url, target_ip)) inm = request.headers.get("If-None-Match")
if cached: ims = request.headers.get("If-Modified-Since")
redis_client.incr('stats:cache_hits') if inm:
return cached.decode('utf-8'), 200, {'Content-Type': 'text/plain'} req_headers["If-None-Match"] = inm
if ims:
req_headers["If-Modified-Since"] = ims
redis_client.incr('stats:cache_misses') with requests.get(normalized_url, headers=req_headers, stream=True, timeout=(10, 60)) as r:
ct = r.headers.get("Content-Type", "")
# pozwól na text/* oraz octet-stream (często używane przez listy)
if "text" not in ct and "octet-stream" not in ct and ct != "":
abort(415, description="Unsupported Media Type")
# Asynchroniczne pobranie zasobu za pomocą aiohttp if r.status_code == 304:
async with aiohttp.ClientSession() as session: etag = build_etag(r.headers.get("ETag"), r.headers.get("Last-Modified"), target_ip)
async with session.get(normalized_url, timeout=60) as response: resp = Response(status=304)
# Sprawdzanie typu zawartości musi zawierać "text" resp.headers.update(cache_headers(etag, r.headers.get("Last-Modified")))
content_type = response.headers.get("Content-Type", "") resp.direct_passthrough = True
if "text" not in content_type: return resp
abort(415, description="Unsupported Media Type")
content = b""
while True:
try:
chunk = await response.content.read(4096)
except asyncio.TimeoutError:
abort(504, description="Timeout reading remote data")
if not chunk:
break
content += chunk
if len(content) > app.config['MAX_CONTENT_LENGTH']:
redis_client.incr('stats:errors_413')
abort(413)
# Rejestracja rozmiaru pobranej treści up_etag = r.headers.get("ETag")
content_size = len(content) up_lm = r.headers.get("Last-Modified")
redis_client.incrby('stats:content_size_total', content_size) etag = build_etag(up_etag, up_lm, target_ip)
redis_client.incr('stats:content_size_count')
converted = convert_hosts(content.decode('utf-8'), target_ip) @stream_with_context
redis_client.setex(cache_key(normalized_url, target_ip), 43200, converted) # 12h cache def body_gen():
redis_client.incr('stats:conversions_success') total = 0
return converted, 200, {'Content-Type': 'text/plain'} # iter_lines pewnie tnie po \n/\r\n i dekoduje do str
for line in r.iter_lines(decode_unicode=True, chunk_size=config.READ_CHUNK):
if line is None:
continue
# zabezpieczenie przed megadługimi wierszami
if len(line) > config.STREAM_LINE_LIMIT:
continue
out = convert_host_line(line, target_ip)
if out:
s = out + "\n"
total += len(s)
yield s
# statystyki po zakończeniu streamu
redis_client.incrby("stats:content_size_total", total)
redis_client.incr("stats:content_size_count")
except aiohttp.ClientError as e: resp = Response(body_gen(), mimetype="text/plain; charset=utf-8")
resp.headers.update(cache_headers(etag, up_lm))
# wyłącz kompresję/buforowanie dla strumienia
resp.direct_passthrough = True
redis_client.incr("stats:conversions_success")
return resp
except requests.exceptions.RequestException as e:
app.logger.error(f"Request error: {str(e)}") app.logger.error(f"Request error: {str(e)}")
redis_client.incr('stats:errors_500') redis_client.incr("stats:errors_500")
abort(500) abort(500)
except ValueError as e: except ValueError as e:
app.logger.error(f"URL validation error: {str(e)}") app.logger.error(f"URL validation error: {str(e)}")
redis_client.incr('stats:errors_400') redis_client.incr("stats:errors_400")
abort(400) abort(400)
@app.route("/convert", methods=["HEAD"])
def convert_head():
encoded_url = request.args.get("url", config.DEFAULT_SOURCE_URL)
if not encoded_url:
abort(400)
decoded_url = unquote(encoded_url)
validate_and_normalize_url(decoded_url)
target_ip = request.args.get("ip", "127.0.0.1")
etag = build_etag(None, None, target_ip)
resp = Response(status=200)
resp.headers.update(cache_headers(etag, None))
resp.direct_passthrough = True
return resp
@app.route('/stats') @app.route("/stats")
@ip_restriction @basic_auth_required(
realm=config.STATS_BASIC_AUTH_REALM,
user=config.STATS_BASIC_AUTH_USER,
password=config.STATS_BASIC_AUTH_PASS,
)
def stats(): def stats():
"""Endpoint statystyk""" stats_data, target_ips, url_requests, user_agents, client_ips = {}, {}, {}, {}, {}
stats_data = {}
target_ips = {}
url_requests = {}
user_agents = {}
client_ips = {}
# Agregacja statystyk z Redisa
for key in redis_client.scan_iter("stats:*"): for key in redis_client.scan_iter("stats:*"):
key_str = key.decode() key_str = key.decode()
value = redis_client.get(key).decode() value = redis_client.get(key).decode()
if key_str.startswith("stats:target_ips:"):
if key_str.startswith('stats:target_ips:'): ip = key_str.split(":", 2)[2]
ip = key_str.split(':', 2)[2]
target_ips[ip] = value target_ips[ip] = value
elif key_str.startswith('stats:url_requests:'): elif key_str.startswith("stats:url_requests:"):
url = unquote(key_str.split(':', 2)[2]) url = unquote(key_str.split(":", 2)[2])
url_requests[url] = value url_requests[url] = value
elif key_str.startswith('stats:user_agents:'): elif key_str.startswith("stats:user_agents:"):
ua = unquote(key_str.split(':', 2)[2]) ua = unquote(key_str.split(":", 2)[2])
user_agents[ua] = value user_agents[ua] = value
elif key_str.startswith('stats:client_ips:'): elif key_str.startswith("stats:client_ips:"):
ip = key_str.split(':', 2)[2] ip = key_str.split(":", 2)[2]
client_ips[ip] = value client_ips[ip] = value
else: else:
stats_data[key_str] = value stats_data[key_str] = value
# Pobranie ostatnich 50 requestów dla endpointu /convert
recent_converts = [] recent_converts = []
convert_entries = redis_client.lrange("recent_converts", 0, 49) for entry in redis_client.lrange("recent_converts", 0, 49):
for entry in convert_entries:
try: try:
data = json.loads(entry.decode()) recent_converts.append(json.loads(entry.decode()))
recent_converts.append(data)
except Exception: except Exception:
pass pass
# Obliczenie średniego czasu przetwarzania żądań processing_time_total = float(redis_client.get("stats:processing_time_total") or 0)
processing_time_total = float(redis_client.get('stats:processing_time_total') or 0) processing_time_count = int(redis_client.get("stats:processing_time_count") or 0)
processing_time_count = int(redis_client.get('stats:processing_time_count') or 0)
avg_processing_time = processing_time_total / processing_time_count if processing_time_count > 0 else 0 avg_processing_time = processing_time_total / processing_time_count if processing_time_count > 0 else 0
# Obliczenie średniego rozmiaru pobranej treści dla /convert content_size_total = int(redis_client.get("stats:content_size_total") or 0)
content_size_total = int(redis_client.get('stats:content_size_total') or 0) content_size_count = int(redis_client.get("stats:content_size_count") or 0)
content_size_count = int(redis_client.get('stats:content_size_count') or 0)
avg_content_size = content_size_total / content_size_count if content_size_count > 0 else 0 avg_content_size = content_size_total / content_size_count if content_size_count > 0 else 0
# Rozszerzone statystyki dotyczące wydajności i rozmiarów danych
detailed_stats = { detailed_stats = {
"processing_time_total_sec": processing_time_total, "processing_time_total_sec": processing_time_total,
"processing_time_count": processing_time_count, "processing_time_count": processing_time_count,
"processing_time_avg_sec": avg_processing_time, "processing_time_avg_sec": avg_processing_time,
"processing_time_min_sec": float(redis_client.get('stats:processing_time_min') or 0), "processing_time_min_sec": float(redis_client.get("stats:processing_time_min") or 0),
"processing_time_max_sec": float(redis_client.get('stats:processing_time_max') or 0), "processing_time_max_sec": float(redis_client.get("stats:processing_time_max") or 0),
"content_size_total_bytes": content_size_total, "content_size_total_bytes": content_size_total,
"content_size_count": content_size_count, "content_size_count": content_size_count,
"content_size_avg_bytes": avg_content_size "content_size_avg_bytes": avg_content_size,
} }
# Struktura odpowiedzi return jsonify(
response_data = { {
**stats_data, **stats_data,
'target_ips': target_ips, "target_ips": target_ips,
'url_requests': url_requests, "url_requests": url_requests,
'user_agents': user_agents, "user_agents": user_agents,
'client_ips': client_ips, "client_ips": client_ips,
'recent_converts': recent_converts, "recent_converts": recent_converts,
'detailed_stats': detailed_stats "detailed_stats": detailed_stats,
} }
)
return jsonify(response_data)
@app.errorhandler(400) @app.errorhandler(400)
@app.errorhandler(403) @app.errorhandler(403)
@@ -410,13 +444,13 @@ def stats():
@app.errorhandler(415) @app.errorhandler(415)
@app.errorhandler(500) @app.errorhandler(500)
def handle_errors(e): def handle_errors(e):
"""Obsługa błędów""" try:
return render_template('error.html', error=e), e.code return render_template("error.html", error=e), e.code
except Exception:
return jsonify({"error": getattr(e, "description", str(e)), "code": e.code}), e.code
# Jeśli aplikacja jest uruchamiana bezpośrednio, korzystamy z Flask's run if __name__ == "__main__":
if __name__ == '__main__': app.run(host=config.BIND_HOST, port=config.BIND_PORT)
app.run(host='0.0.0.0', port=8283)
# W przeciwnym razie (np. przy uruchamianiu przez Gunicorn) opakowujemy aplikację w adapter ASGI
else: else:
from asgiref.wsgi import WsgiToAsgi from asgiref.wsgi import WsgiToAsgi
asgi_app = WsgiToAsgi(app) asgi_app = WsgiToAsgi(app)

65
config.py Normal file
View File

@@ -0,0 +1,65 @@
import os
def getenv_bool(key: str, default: bool = False) -> bool:
v = os.getenv(key)
if v is None:
return default
return v.lower() in ("1", "true", "t", "yes", "y", "on")
def getenv_int(key: str, default: int) -> int:
try:
return int(os.getenv(key, str(default)))
except ValueError:
return default
def getenv_float(key: str, default: float) -> float:
try:
return float(os.getenv(key, str(default)))
except ValueError:
return default
# Podstawowe
FLASK_DEBUG = getenv_bool("FLASK_DEBUG", True)
SECRET_KEY = os.getenv("SECRET_KEY", "change-me")
# Rozmiary/limity
MAX_CONTENT_LENGTH = getenv_int("MAX_CONTENT_LENGTH", 50 * 1024 * 1024) # 50MB
RATE_LIMIT_DEFAULT = os.getenv("RATE_LIMIT_DEFAULT", "100 per minute")
RATE_LIMIT_CONVERT = os.getenv("RATE_LIMIT_CONVERT", "100 per minute")
# Redis
REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/7")
REDIS_HOST = os.getenv("REDIS_HOST", "localhost")
REDIS_PORT = getenv_int("REDIS_PORT", 6379)
REDIS_DB = getenv_int("REDIS_DB", 7)
# Basic Auth dla /stats
STATS_BASIC_AUTH_ENABLED = getenv_bool("STATS_BASIC_AUTH_ENABLED", True)
STATS_BASIC_AUTH_REALM = os.getenv("STATS_BASIC_AUTH_REALM", "Stats")
STATS_BASIC_AUTH_USER = os.getenv("STATS_BASIC_AUTH_USER", "admin")
STATS_BASIC_AUTH_PASS = os.getenv("STATS_BASIC_AUTH_PASS", "change-me")
# Cache/ETag dla Varnisha
CACHE_ENABLED = getenv_bool("CACHE_ENABLED", True)
CACHE_S_MAXAGE = getenv_int("CACHE_S_MAXAGE", 43200) # 12h
CACHE_MAX_AGE = getenv_int("CACHE_MAX_AGE", 3600) # 1h
USE_REDIS_BODY_CACHE = getenv_bool("USE_REDIS_BODY_CACHE", False)
# AIOHTTP/stream
AIOHTTP_TOTAL_TIMEOUT = getenv_float("AIOHTTP_TOTAL_TIMEOUT", 70.0)
AIOHTTP_CONNECT_TIMEOUT = getenv_float("AIOHTTP_CONNECT_TIMEOUT", 10.0)
AIOHTTP_SOCK_CONNECT_TIMEOUT= getenv_float("AIOHTTP_SOCK_CONNECT_TIMEOUT", 10.0)
AIOHTTP_SOCK_READ_TIMEOUT = getenv_float("AIOHTTP_SOCK_READ_TIMEOUT", 60.0)
READ_CHUNK = getenv_int("READ_CHUNK", 64 * 1024) # 64 KiB
STREAM_LINE_LIMIT= getenv_int("STREAM_LINE_LIMIT", 4096)
# Serwer
BIND_HOST = os.getenv("BIND_HOST", "127.0.0.1")
BIND_PORT = getenv_int("BIND_PORT", 8283)
# Domyślny URL źródłowy (opcjonalny)
DEFAULT_SOURCE_URL = os.getenv(
"DEFAULT_SOURCE_URL",
""
)

View File

@@ -1,15 +1,27 @@
# /etc/systemd/system/listapp.service
[Unit] [Unit]
Description=ListApp - Flask application for hosts file conversion Description=ListApp - Flask application for hosts file conversion
After=network.target redis.service After=network-online.target redis.service
Wants=network-online.target
[Service] [Service]
User=www-data User=www-data
Group=www-data Group=www-data
WorkingDirectory=/var/www/listapp WorkingDirectory=/var/www/listapp
# Globalne env + nadpisania (opcjonalne; minus oznacza „jeśli istnieje”)
EnvironmentFile=-/var/www/listapp/.env
# Ścieżka do virtualenv
Environment="PATH=/var/www/listapp/venv/bin" Environment="PATH=/var/www/listapp/venv/bin"
#ExecStart=/var/www/listapp/bin/gunicorn -w 2 --bind 127.0.0.1:8283 app:app
ExecStart=/var/www/listapp/bin/gunicorn -k uvicorn.workers.UvicornWorker -w 4 --bind 127.0.0.1:8283 app:asgi_app # Gunicorn + UvicornWorker (ASGI)
ExecStart=/var/www/listapp/venv/bin/gunicorn \
-k uvicorn.workers.UvicornWorker \
--workers 4 \
--bind 127.0.0.1:8283 \
--keep-alive 30 \
--timeout 90 \
app:asgi_app
Restart=always Restart=always
RestartSec=5 RestartSec=5

10
requirements.txt Normal file
View File

@@ -0,0 +1,10 @@
Flask
Flask-Compress
Flask-Limiter
redis
requests
aiohttp
asgiref
unicorn
gunicorn
uvicorn

1
start_dev.sh Normal file
View File

@@ -0,0 +1 @@
venv/bin/gunicorn -k uvicorn.workers.UvicornWorker --workers 4 --bind 127.0.0.1:8283 --keep-alive 30 --timeout 90 app:asgi_app