v1
This commit is contained in:
851
network.py
Normal file
851
network.py
Normal file
@@ -0,0 +1,851 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import socket
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from PySide6.QtCore import QObject, Signal
|
||||
|
||||
from config import DISCORD_TELEMETRY_BLOCKS
|
||||
|
||||
from blocklists import (
|
||||
BlocklistEngine,
|
||||
)
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
REDACT_HEADERS = {
|
||||
"authorization",
|
||||
"cookie",
|
||||
"set-cookie",
|
||||
"x-super-properties",
|
||||
"x-fingerprint",
|
||||
"token",
|
||||
"access_token",
|
||||
"refresh_token",
|
||||
}
|
||||
|
||||
DISCORD_GATEWAY_HOSTS = {
|
||||
"gateway.discord.gg",
|
||||
"remote-auth-gateway.discord.gg",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ProxyConfig:
|
||||
enabled: bool = False
|
||||
proxy_type: str = "http"
|
||||
host: str = ""
|
||||
port: int = 0
|
||||
username: str = ""
|
||||
password: str = ""
|
||||
|
||||
@property
|
||||
def valid(self) -> bool:
|
||||
return (
|
||||
self.enabled
|
||||
and bool(self.host)
|
||||
and self.port > 0
|
||||
)
|
||||
|
||||
def to_dict(
|
||||
self,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"enabled": self.enabled,
|
||||
"proxy_type": self.proxy_type,
|
||||
"host": self.host,
|
||||
"port": self.port,
|
||||
"username": self.username,
|
||||
"password": self.password,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(
|
||||
cls,
|
||||
data: dict[str, Any] | None,
|
||||
) -> "ProxyConfig":
|
||||
data = data or {}
|
||||
|
||||
return cls(
|
||||
enabled=bool(
|
||||
data.get(
|
||||
"enabled",
|
||||
False,
|
||||
)
|
||||
),
|
||||
proxy_type=str(
|
||||
data.get(
|
||||
"proxy_type",
|
||||
"http",
|
||||
)
|
||||
),
|
||||
host=str(
|
||||
data.get(
|
||||
"host",
|
||||
"",
|
||||
)
|
||||
),
|
||||
port=int(
|
||||
data.get(
|
||||
"port",
|
||||
0,
|
||||
)
|
||||
),
|
||||
username=str(
|
||||
data.get(
|
||||
"username",
|
||||
"",
|
||||
)
|
||||
),
|
||||
password=str(
|
||||
data.get(
|
||||
"password",
|
||||
"",
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class ProxyManager(QObject):
|
||||
proxyChanged = Signal(dict)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
db,
|
||||
):
|
||||
super().__init__()
|
||||
self.db = db
|
||||
|
||||
def get_proxy(
|
||||
self,
|
||||
profile_name: str,
|
||||
) -> ProxyConfig:
|
||||
return ProxyConfig.from_dict(
|
||||
self.db.get_setting(
|
||||
f"proxy:{profile_name}",
|
||||
{},
|
||||
)
|
||||
)
|
||||
|
||||
def save_proxy(
|
||||
self,
|
||||
profile_name: str,
|
||||
proxy: ProxyConfig,
|
||||
) -> None:
|
||||
self.db.set_setting(
|
||||
f"proxy:{profile_name}",
|
||||
proxy.to_dict(),
|
||||
)
|
||||
|
||||
self.proxyChanged.emit(
|
||||
proxy.to_dict()
|
||||
)
|
||||
|
||||
def clear_proxy(
|
||||
self,
|
||||
profile_name: str,
|
||||
) -> None:
|
||||
self.save_proxy(
|
||||
profile_name,
|
||||
ProxyConfig(),
|
||||
)
|
||||
|
||||
def test_proxy(
|
||||
self,
|
||||
proxy: ProxyConfig,
|
||||
timeout: float = 5.0,
|
||||
) -> bool:
|
||||
if not proxy.valid:
|
||||
return False
|
||||
|
||||
try:
|
||||
with socket.create_connection(
|
||||
(
|
||||
proxy.host,
|
||||
proxy.port,
|
||||
),
|
||||
timeout=timeout,
|
||||
):
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
# v2
|
||||
class DomainBlocker:
|
||||
|
||||
DEFAULT_BLOCKS = set(
|
||||
DISCORD_TELEMETRY_BLOCKS
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
db,
|
||||
blocklist_engine=None,
|
||||
):
|
||||
self.db = db
|
||||
self.blocklist = (
|
||||
blocklist_engine
|
||||
)
|
||||
|
||||
def domains(
|
||||
self,
|
||||
) -> set[str]:
|
||||
domains = set(
|
||||
self.DEFAULT_BLOCKS
|
||||
)
|
||||
|
||||
try:
|
||||
domains.update(
|
||||
self.db.blocked_domains()
|
||||
)
|
||||
except Exception:
|
||||
log.exception(
|
||||
"blocked domain load failed"
|
||||
)
|
||||
|
||||
return {
|
||||
str(domain)
|
||||
.lower()
|
||||
.strip()
|
||||
for domain in domains
|
||||
if domain
|
||||
}
|
||||
|
||||
def add(
|
||||
self,
|
||||
domain: str,
|
||||
) -> None:
|
||||
domain = (
|
||||
domain
|
||||
.lower()
|
||||
.strip()
|
||||
)
|
||||
|
||||
if domain:
|
||||
self.db.add_blocked_domain(
|
||||
domain
|
||||
)
|
||||
|
||||
def remove(
|
||||
self,
|
||||
domain: str,
|
||||
) -> None:
|
||||
try:
|
||||
with self.db.connection() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
DELETE FROM blocked_domains
|
||||
WHERE domain=?
|
||||
""",
|
||||
(
|
||||
domain.lower()
|
||||
.strip(),
|
||||
),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
|
||||
except Exception:
|
||||
log.exception(
|
||||
"domain removal failed"
|
||||
)
|
||||
|
||||
# v2
|
||||
def is_blocked(
|
||||
self,
|
||||
host: str,
|
||||
) -> bool:
|
||||
|
||||
host = (
|
||||
host.lower()
|
||||
.strip()
|
||||
)
|
||||
|
||||
if (
|
||||
self.blocklist
|
||||
and self.blocklist.contains(
|
||||
host
|
||||
)
|
||||
):
|
||||
return True
|
||||
|
||||
for domain in self.domains():
|
||||
|
||||
if (
|
||||
host == domain
|
||||
or host.endswith(
|
||||
f".{domain}"
|
||||
)
|
||||
):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
class HeaderRules:
|
||||
def __init__(
|
||||
self,
|
||||
db,
|
||||
):
|
||||
self.db = db
|
||||
|
||||
def all(
|
||||
self,
|
||||
) -> list[dict]:
|
||||
return self.db.get_setting(
|
||||
"header_rules",
|
||||
[],
|
||||
)
|
||||
|
||||
def save(
|
||||
self,
|
||||
rules: list[dict],
|
||||
) -> None:
|
||||
self.db.set_setting(
|
||||
"header_rules",
|
||||
rules,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def redact(
|
||||
headers: dict,
|
||||
) -> dict:
|
||||
result = {}
|
||||
|
||||
for key, value in (
|
||||
headers or {}
|
||||
).items():
|
||||
if (
|
||||
str(key)
|
||||
.lower()
|
||||
.strip()
|
||||
in REDACT_HEADERS
|
||||
):
|
||||
result[key] = (
|
||||
"[REDACTED]"
|
||||
)
|
||||
else:
|
||||
result[key] = value
|
||||
|
||||
return result
|
||||
|
||||
def apply(
|
||||
self,
|
||||
headers: dict,
|
||||
) -> dict:
|
||||
result = dict(
|
||||
headers or {}
|
||||
)
|
||||
|
||||
for rule in self.all():
|
||||
action = str(
|
||||
rule.get(
|
||||
"action",
|
||||
"",
|
||||
)
|
||||
).lower()
|
||||
|
||||
name = str(
|
||||
rule.get(
|
||||
"name",
|
||||
"",
|
||||
)
|
||||
)
|
||||
|
||||
value = rule.get(
|
||||
"value",
|
||||
"",
|
||||
)
|
||||
|
||||
if not name:
|
||||
continue
|
||||
|
||||
if action in {
|
||||
"add",
|
||||
"replace",
|
||||
}:
|
||||
result[name] = value
|
||||
|
||||
elif action == "remove":
|
||||
result.pop(
|
||||
name,
|
||||
None,
|
||||
)
|
||||
|
||||
return result
|
||||
# v5
|
||||
|
||||
|
||||
# v3
|
||||
class NetworkRules:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
settings: dict,
|
||||
):
|
||||
self.settings = settings
|
||||
|
||||
def load(
|
||||
self,
|
||||
profile_name: str | None = None,
|
||||
plugin_name: str | None = None,
|
||||
) -> list[dict]:
|
||||
|
||||
try:
|
||||
rules = self.settings.get(
|
||||
"network_rules",
|
||||
[],
|
||||
)
|
||||
|
||||
if not isinstance(
|
||||
rules,
|
||||
list,
|
||||
):
|
||||
return []
|
||||
|
||||
except Exception:
|
||||
log.exception(
|
||||
"network rule load failed"
|
||||
)
|
||||
return []
|
||||
|
||||
output: list[dict] = []
|
||||
|
||||
for rule in rules:
|
||||
|
||||
if not isinstance(
|
||||
rule,
|
||||
dict,
|
||||
):
|
||||
continue
|
||||
|
||||
if (
|
||||
profile_name
|
||||
and rule.get(
|
||||
"profile"
|
||||
)
|
||||
not in {
|
||||
None,
|
||||
profile_name,
|
||||
}
|
||||
):
|
||||
continue
|
||||
|
||||
if (
|
||||
plugin_name
|
||||
and rule.get(
|
||||
"plugin"
|
||||
)
|
||||
not in {
|
||||
None,
|
||||
plugin_name,
|
||||
}
|
||||
):
|
||||
continue
|
||||
|
||||
output.append(
|
||||
rule
|
||||
)
|
||||
|
||||
return output
|
||||
|
||||
def evaluate(
|
||||
self,
|
||||
host: str,
|
||||
profile_name: str | None = None,
|
||||
plugin_name: str | None = None,
|
||||
) -> tuple[bool, str | None]:
|
||||
|
||||
host = (
|
||||
host.lower()
|
||||
.strip()
|
||||
)
|
||||
|
||||
rules = sorted(
|
||||
self.load(
|
||||
profile_name,
|
||||
plugin_name,
|
||||
),
|
||||
key=lambda r: (
|
||||
0
|
||||
if r.get(
|
||||
"plugin"
|
||||
)
|
||||
else (
|
||||
1
|
||||
if r.get(
|
||||
"profile"
|
||||
)
|
||||
else 2
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
for rule in rules:
|
||||
|
||||
pattern = (
|
||||
str(
|
||||
rule.get(
|
||||
"pattern",
|
||||
"",
|
||||
)
|
||||
)
|
||||
.lower()
|
||||
.strip()
|
||||
)
|
||||
|
||||
if not pattern:
|
||||
continue
|
||||
|
||||
matched = (
|
||||
host == pattern
|
||||
or host.endswith(
|
||||
f".{pattern}"
|
||||
)
|
||||
)
|
||||
|
||||
if not matched:
|
||||
continue
|
||||
|
||||
action = (
|
||||
str(
|
||||
rule.get(
|
||||
"action",
|
||||
"",
|
||||
)
|
||||
)
|
||||
.lower()
|
||||
.strip()
|
||||
)
|
||||
|
||||
if action in {
|
||||
"block",
|
||||
"deny",
|
||||
}:
|
||||
return (
|
||||
False,
|
||||
None,
|
||||
)
|
||||
|
||||
if action == "allow":
|
||||
return (
|
||||
True,
|
||||
None,
|
||||
)
|
||||
|
||||
if action == "redirect":
|
||||
target = (
|
||||
str(
|
||||
rule.get(
|
||||
"target",
|
||||
"",
|
||||
)
|
||||
)
|
||||
.strip()
|
||||
)
|
||||
|
||||
return (
|
||||
True,
|
||||
target or None,
|
||||
)
|
||||
|
||||
return (
|
||||
True,
|
||||
None,
|
||||
)
|
||||
|
||||
class RequestReplay:
|
||||
def __init__(
|
||||
self,
|
||||
db,
|
||||
):
|
||||
self.db = db
|
||||
|
||||
def get_request(
|
||||
self,
|
||||
request_id: int,
|
||||
):
|
||||
with self.db.connection() as conn:
|
||||
return conn.execute(
|
||||
"""
|
||||
SELECT *
|
||||
FROM network_requests
|
||||
WHERE id=?
|
||||
""",
|
||||
(
|
||||
request_id,
|
||||
),
|
||||
).fetchone()
|
||||
|
||||
|
||||
class HARExporter:
|
||||
def __init__(
|
||||
self,
|
||||
db,
|
||||
):
|
||||
self.db = db
|
||||
|
||||
def export(
|
||||
self,
|
||||
output_file: str | Path,
|
||||
limit: int = 5000,
|
||||
) -> Path:
|
||||
output_file = Path(
|
||||
output_file
|
||||
)
|
||||
|
||||
entries = []
|
||||
|
||||
for row in self.db.recent_requests(
|
||||
limit
|
||||
):
|
||||
entries.append(
|
||||
{
|
||||
"startedDateTime": row[
|
||||
"timestamp"
|
||||
],
|
||||
"request": {
|
||||
"method": row[
|
||||
"method"
|
||||
],
|
||||
"url": row[
|
||||
"url"
|
||||
],
|
||||
},
|
||||
"response": {
|
||||
"status": row[
|
||||
"status_code"
|
||||
],
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
output_file.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"log": {
|
||||
"version": "1.2",
|
||||
"creator": {
|
||||
"name": "Discord Client",
|
||||
"version": "1.0",
|
||||
},
|
||||
"entries": entries,
|
||||
}
|
||||
},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
return output_file
|
||||
|
||||
|
||||
# v4# v5
|
||||
class NetworkBackend(QObject):
|
||||
requestAdded = Signal(dict)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
db,
|
||||
settings: dict,
|
||||
):
|
||||
super().__init__()
|
||||
|
||||
self.db = db
|
||||
self.settings = settings
|
||||
|
||||
mode = self.db.get_setting(
|
||||
"blocklist_mode",
|
||||
"pro",
|
||||
)
|
||||
|
||||
blocklist_dir = Path(
|
||||
"config/blocklists"
|
||||
)
|
||||
|
||||
blocklists = sorted(
|
||||
path
|
||||
for path in blocklist_dir.glob(
|
||||
"*.txt"
|
||||
)
|
||||
if path.is_file()
|
||||
)
|
||||
|
||||
if mode == "pro":
|
||||
blocklists = [
|
||||
path
|
||||
for path in blocklists
|
||||
if "ultimate"
|
||||
not in path.name.lower()
|
||||
]
|
||||
|
||||
cache_file = (
|
||||
blocklist_dir
|
||||
/ f"{mode}.cache.bin"
|
||||
)
|
||||
|
||||
self.blocklist_engine = (
|
||||
BlocklistEngine(
|
||||
blocklists=blocklists,
|
||||
cache_file=cache_file,
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
self.blocklist_engine.load()
|
||||
except Exception:
|
||||
log.exception(
|
||||
"blocklist load failed"
|
||||
)
|
||||
|
||||
self.blocker = DomainBlocker(
|
||||
db,
|
||||
self.blocklist_engine,
|
||||
)
|
||||
|
||||
self.headers = HeaderRules(
|
||||
db
|
||||
)
|
||||
|
||||
self.rules = NetworkRules(
|
||||
settings
|
||||
)
|
||||
|
||||
self.proxy = ProxyManager(
|
||||
db
|
||||
)
|
||||
|
||||
self.har = HARExporter(
|
||||
db
|
||||
)
|
||||
|
||||
self.replay = RequestReplay(
|
||||
db
|
||||
)
|
||||
|
||||
def recent(
|
||||
self,
|
||||
limit: int = 1000,
|
||||
):
|
||||
return self.db.recent_requests(
|
||||
limit
|
||||
)
|
||||
|
||||
def search(
|
||||
self,
|
||||
query: str,
|
||||
limit: int = 1000,
|
||||
):
|
||||
return self.db.search_requests(
|
||||
query,
|
||||
limit,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def host_from_url(
|
||||
url: str,
|
||||
) -> str:
|
||||
try:
|
||||
return (
|
||||
urlparse(url)
|
||||
.hostname
|
||||
or ""
|
||||
).lower()
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
def process_request(
|
||||
self,
|
||||
request: dict,
|
||||
) -> tuple[dict, bool]:
|
||||
|
||||
url = str(
|
||||
request.get(
|
||||
"url",
|
||||
"",
|
||||
)
|
||||
)
|
||||
|
||||
host = self.host_from_url(
|
||||
url
|
||||
)
|
||||
|
||||
request["host"] = host
|
||||
|
||||
if host in DISCORD_GATEWAY_HOSTS:
|
||||
|
||||
self.requestAdded.emit(
|
||||
request
|
||||
)
|
||||
|
||||
return request, False
|
||||
|
||||
if (
|
||||
host
|
||||
and self.blocker.is_blocked(
|
||||
host
|
||||
)
|
||||
):
|
||||
|
||||
request["_blocked"] = True
|
||||
|
||||
self.requestAdded.emit(
|
||||
request
|
||||
)
|
||||
|
||||
return request, True
|
||||
|
||||
allowed, redirect_url = (
|
||||
self.rules.evaluate(
|
||||
host=host,
|
||||
profile_name=request.get(
|
||||
"profile"
|
||||
),
|
||||
plugin_name=request.get(
|
||||
"plugin"
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
if not allowed:
|
||||
|
||||
request["_blocked"] = True
|
||||
|
||||
self.requestAdded.emit(
|
||||
request
|
||||
)
|
||||
|
||||
return request, True
|
||||
|
||||
if redirect_url:
|
||||
|
||||
request[
|
||||
"redirect_url"
|
||||
] = redirect_url
|
||||
|
||||
headers = self.headers.apply(
|
||||
request.get(
|
||||
"headers",
|
||||
{},
|
||||
)
|
||||
)
|
||||
|
||||
request["headers"] = (
|
||||
self.headers.redact(
|
||||
headers
|
||||
)
|
||||
)
|
||||
|
||||
self.requestAdded.emit(
|
||||
request
|
||||
)
|
||||
|
||||
return request, False
|
||||
Reference in New Issue
Block a user