diff --git a/.gitignore b/.gitignore index 495dcec..5f4b98f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,20 @@ **/__pycache__/ +*.pyc +*.pyo +*.pyd +.Python +*.egg-info/ + **/venv/ **/env/ +.venv/ + **/.idea/ +.vscode/ +*.swp + LeagueClientDebugger/config*.json LeagueClientDebugger/fullconfig*.txt LeagueClientDebugger/all_requests*.txt -LeagueClientDebugger/saves/ \ No newline at end of file +LeagueClientDebugger/saves/ +LeagueClientDebugger/chat_cert_config.json \ No newline at end of file diff --git a/CHAT_PROXY_SETUP.md b/CHAT_PROXY_SETUP.md new file mode 100644 index 0000000..d14d78b --- /dev/null +++ b/CHAT_PROXY_SETUP.md @@ -0,0 +1,240 @@ +# Custom chat certificate setup + +By default the debugger downloads its TLS certificate from `pfx.lolcert.online` and points the Riot client at `localhost.lolcert.online`. That just works — no setup needed. + +If you'd rather host your own certificate (because you don't trust the default infrastructure, want to control renewal, or are running this in a place where `lolcert.online` is blocked), follow this guide. + +## What you need + +- A domain you control (cheap `.xyz` from Namecheap, Porkbun, etc. — around $1-2/year). +- A free Cloudflare account. +- A place to host a small file over HTTPS. This guide uses Cloudflare R2 (free tier, no egress cost), but any static hosting works (GitHub Pages, Backblaze B2, S3, your own VPS). +- A GitHub account (for the renewal workflow). + +The total setup takes about 30 minutes the first time. After that it runs itself. + +## How the pieces fit together + +1. A public DNS A record points `localhost.your-domain.example.com` to `127.0.0.1`. When the Riot client tries to resolve that hostname it gets `127.0.0.1`, which is your own machine. +2. A real Let's Encrypt certificate is issued for that hostname using the DNS-01 challenge (since the hostname doesn't point to a real server, HTTP-01 won't work). +3. The certificate is exported as a passwordless `.pfx` and uploaded to a public HTTPS URL. +4. The debugger downloads that `.pfx`, caches it locally, and uses it to serve TLS on its local chat proxy. +5. The Riot client connects to `localhost.your-domain.example.com:5223`, gets `127.0.0.1`, performs a TLS handshake against a valid cert, and the proxy intercepts the chat traffic. + +A GitHub Actions workflow re-issues the cert monthly and re-uploads it. Let's Encrypt certs are valid for 90 days, so monthly renewal leaves plenty of margin. + +## Step 1 — Buy a domain + +Register any cheap domain. Skip `.xyz` if you want — `.click`, `.online`, `.site`, anything works. You won't be hosting a website on it. + +## Step 2 — Move the domain to Cloudflare + +1. Sign up at [cloudflare.com](https://cloudflare.com) (free). +2. Dashboard → **Add a site** → enter your domain → **Continue**. +3. Pick the **Free** plan → **Continue**. +4. Cloudflare scans existing DNS records → **Continue to activation**. +5. Cloudflare gives you two nameservers, like: + ``` + blake.ns.cloudflare.com + lia.ns.cloudflare.com + ``` + Copy them. +6. Go to your registrar (Namecheap, Porkbun, etc.) → dashboard → your domain → Nameservers → switch to **Custom DNS** → paste Cloudflare's two nameservers → save. +7. Wait for Cloudflare to email you confirming the domain is active. Usually 10-30 minutes. + +## Step 3 — Create the DNS record + +In Cloudflare, with your domain active: + +1. **DNS → Records → Add record**: + +| Field | Value | +|---|---| +| Type | `A` | +| Name | `localhost` | +| IPv4 address | `127.0.0.1` | +| Proxy status | **DNS only** (gray cloud, not orange) | +| TTL | Auto | + +Save. Verify from a terminal: + +``` +nslookup localhost.your-domain.example.com +``` + +Should return `127.0.0.1`. If not, wait a couple of minutes and retry. + +## Step 4 — Create a Cloudflare API token + +This is used by the GitHub Actions workflow to add the DNS-01 challenge record. + +1. Cloudflare → top right user icon → **My Profile** → **API Tokens** → **Create Token**. +2. Use the **Edit zone DNS** template. +3. **Zone Resources**: pick your specific domain (not "all zones"). +4. **Continue to summary** → **Create Token**. +5. Copy the token (only shown once). + +While you're there, grab the **Zone ID** of your domain from Cloudflare → your domain dashboard → right sidebar. + +## Step 5 — Set up Cloudflare R2 for hosting + +This is where the `.pfx` will live. + +1. Cloudflare → left sidebar → **R2 Object Storage**. +2. Accept the terms (you'll need to add a payment method, but you won't be charged within the free tier). +3. **Create bucket** → name it `cert` (or whatever) → **Create bucket**. +4. Open the bucket → **Settings** tab → **Custom Domains** → **Connect Domain**. +5. Domain: `pfx.your-domain.example.com` (any subdomain works). +6. **Continue** — Cloudflare creates the DNS record automatically. Wait a minute for it to show as **Active**. + +Then create an R2 API token: + +1. Sidebar → **R2 Object Storage** → **API** → **Manage API tokens** → **Create Account API token**. +2. Token name: anything (`cert-uploader`). +3. Permissions: **Object Read and Write**. +4. **Apply to specific buckets only** → select your bucket. +5. **Create**. +6. Copy the **Access Key ID**, **Secret Access Key**, and the **Account ID** (visible in the S3 API URL, e.g. `https://.r2.cloudflarestorage.com`). Only shown once. + +## Step 6 — Create the renewal workflow in GitHub + +1. Create a new GitHub repository (private is fine). Add a README so it's not empty. +2. **Settings → Secrets and variables → Actions → New repository secret**. Add five secrets: + +| Name | Value | +|---|---| +| `CF_API_TOKEN` | Cloudflare DNS token from step 4 | +| `CF_ZONE_ID` | Zone ID from step 4 | +| `R2_ACCESS_KEY_ID` | From step 5 | +| `R2_SECRET_ACCESS_KEY` | From step 5 | +| `R2_ACCOUNT_ID` | From step 5 | + +3. Create `.github/workflows/renew.yml` in the repo: + +```yaml +name: Renew certificate + +on: + schedule: + - cron: '0 6 1 * *' + workflow_dispatch: + +jobs: + renew: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Install acme.sh + run: curl https://get.acme.sh | sh -s email=your@email.com + + - name: Issue cert with DNS-01 via Cloudflare + env: + CF_Token: ${{ secrets.CF_API_TOKEN }} + CF_Zone_ID: ${{ secrets.CF_ZONE_ID }} + run: | + ~/.acme.sh/acme.sh --issue \ + --dns dns_cf \ + -d "localhost.your-domain.example.com" \ + --server letsencrypt + + - name: Export as PFX (no password) + run: | + CERT_DIR=$(find ~/.acme.sh/ -maxdepth 1 -type d -name "localhost.your-domain.example.com*" | head -1) + openssl pkcs12 -export \ + -in "$CERT_DIR/fullchain.cer" \ + -inkey "$CERT_DIR/localhost.your-domain.example.com.key" \ + -out localhost.pfx \ + -passout pass: + + - name: Upload PFX to R2 + env: + AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} + AWS_DEFAULT_REGION: auto + R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }} + run: | + aws s3 cp localhost.pfx s3://cert/localhost.pfx \ + --endpoint-url https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com \ + --content-type application/x-pkcs12 +``` + +Replace `your@email.com`, `localhost.your-domain.example.com`, and `cert` (the bucket name) with your values. Commit. + +4. Go to **Actions** → **Renew certificate** → **Run workflow** → **Run workflow**. Wait about a minute. Should turn green. + +## Step 7 — Verify + +``` +curl -L -o test.pfx https://pfx.your-domain.example.com/localhost.pfx +openssl pkcs12 -info -noout -in test.pfx -passin pass: +``` + +The `openssl` call should list certificates without asking for a password. If you don't have openssl installed, just check that `test.pfx` is a few KB binary file. + +## Step 8 — Point the debugger at your infrastructure + +You have two ways to override the debugger's defaults. Pick whichever you prefer. + +### Option A — Config file + +Create a file called `chat_cert_config.json` next to `ChatCert.py` (same directory) with: + +```json +{ + "pfx_url": "https://pfx.your-domain.example.com/localhost.pfx", + "chat_proxy_host": "localhost.your-domain.example.com" +} +``` + +This file is in `.gitignore`, so it won't accidentally end up in version control. + +### Option B — Environment variables + +Set before running the debugger: + +``` +set LCD_PFX_URL=https://pfx.your-domain.example.com/localhost.pfx +set LCD_CHAT_PROXY_HOST=localhost.your-domain.example.com +python main.py +``` + +On Linux/macOS use `export` instead of `set`. + +Environment variables override the JSON file if both are set. + +## Step 9 — Wipe the cache and restart + +The debugger caches the downloaded PFX in `~/.lcd_chat/`. After switching to your own infrastructure, clear it once so the new cert gets fetched: + +``` +rmdir /s /q "%USERPROFILE%\.lcd_chat" +``` + +(Linux/macOS: `rm -rf ~/.lcd_chat`) + +Close Riot Client completely (including the tray icon), then start the debugger. Console should show: + +``` +[ChatCert] Downloading certificate from https://pfx.your-domain.example.com/localhost.pfx +[ChatCert] Chain with 2 certificates +[ChatCert] Certificate cached at ... +[XMPP] Proxy server started on 127.0.0.1:XXXXX (TLS) +[XMPP] League client connected to proxy (...) +[XMPP] Client connected to real riot server (...) +``` + +Chat should load normally in League — friends list, presence, messages, the works. + +## Troubleshooting + +**`HTTP 403 Forbidden` when downloading the PFX.** The CDN in front of your storage is blocking the request based on User-Agent. `ChatCert.py` already sends a real browser User-Agent so this shouldn't happen with R2 or GitHub Pages, but some providers are stricter. Try a different host. + +**`WinError 64: The specified network name is no longer available`.** The TLS handshake is failing because intermediate certificates are missing. Confirm the workflow used `acme.sh`'s `fullchain.cer` (not just `.cer`) when building the PFX. The example workflow above already does this correctly. + +**Chat icon stays red.** The Riot client recovered an old cached chat config. Quit the Riot Client completely (system tray → Quit), kill any leftover `RiotClient*.exe` and `LeagueClient*.exe` from Task Manager, then relaunch the client through the debugger. + +**`nslookup` returns something other than `127.0.0.1`.** Your DNS hasn't propagated yet, or the registrar still points to its own nameservers. Wait. Verify the nameservers at the registrar. + +**Certificate expired.** The workflow runs once a month on the 1st. If you set it up between runs, trigger it manually with **Run workflow** to get the initial cert. diff --git a/LeagueClientDebugger/ChatCert.py b/LeagueClientDebugger/ChatCert.py new file mode 100644 index 0000000..8d425a0 --- /dev/null +++ b/LeagueClientDebugger/ChatCert.py @@ -0,0 +1,123 @@ +import json +import os +import pathlib +import ssl +import urllib.request +from datetime import datetime, timedelta, timezone + +from cryptography.hazmat.primitives.serialization import ( + pkcs12, + Encoding, + PrivateFormat, + NoEncryption, +) + + +DEFAULT_PFX_URL = "https://pfx.lolcert.online/localhost.pfx" +DEFAULT_CHAT_PROXY_HOST = "localhost.lolcert.online" + +CONFIG_FILENAME = "chat_cert_config.json" +CACHE_DIR = pathlib.Path.home() / ".lcd_chat" +CACHE_DIR.mkdir(exist_ok=True) +PFX_PATH = CACHE_DIR / "localhost.pfx" +FULLCHAIN_PEM = CACHE_DIR / "fullchain.pem" +KEY_PEM = CACHE_DIR / "key.pem" + + +def _load_config(): + pfx_url = os.environ.get("LCD_PFX_URL") + chat_host = os.environ.get("LCD_CHAT_PROXY_HOST") + + config_path = pathlib.Path(__file__).parent / CONFIG_FILENAME + if config_path.exists(): + try: + with open(config_path, "r", encoding="utf-8") as f: + cfg = json.load(f) + pfx_url = pfx_url or cfg.get("pfx_url") + chat_host = chat_host or cfg.get("chat_proxy_host") + except Exception as e: + print(f"[ChatCert] Failed to read {CONFIG_FILENAME}: {e}") + + return { + "pfx_url": pfx_url or DEFAULT_PFX_URL, + "chat_proxy_host": chat_host or DEFAULT_CHAT_PROXY_HOST, + } + + +_config = None + + +def _get_config(): + global _config + if _config is None: + _config = _load_config() + return _config + + +def get_chat_proxy_host(): + return _get_config()["chat_proxy_host"] + + +def _http_download(url, dest): + req = urllib.request.Request( + url, + headers={ + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "Accept": "*/*", + }, + ) + with urllib.request.urlopen(req, timeout=30) as resp, open(dest, "wb") as out: + out.write(resp.read()) + + +def _cert_is_fresh(min_days_left=20): + if not PFX_PATH.exists(): + return False + try: + data = PFX_PATH.read_bytes() + _, cert, _ = pkcs12.load_key_and_certificates(data, password=None) + if cert is None: + return False + not_after = getattr(cert, "not_valid_after_utc", None) or cert.not_valid_after + if not_after.tzinfo is None: + not_after = not_after.replace(tzinfo=timezone.utc) + return not_after > datetime.now(timezone.utc) + timedelta(days=min_days_left) + except Exception as e: + print(f"[ChatCert] Could not read cached cert: {e}") + return False + + +def _download_and_extract(): + cfg = _get_config() + print(f"[ChatCert] Downloading certificate from {cfg['pfx_url']}") + _http_download(cfg["pfx_url"], PFX_PATH) + + data = PFX_PATH.read_bytes() + key, cert, additional_certs = pkcs12.load_key_and_certificates( + data, password=None + ) + if cert is None or key is None: + raise RuntimeError("Downloaded PFX does not contain a valid cert or key") + + chain_pem = cert.public_bytes(Encoding.PEM) + if additional_certs: + for extra in additional_certs: + chain_pem += extra.public_bytes(Encoding.PEM) + print(f"[ChatCert] Chain with {1 + len(additional_certs)} certificates") + else: + print("[ChatCert] Warning: leaf cert only, no intermediates") + + FULLCHAIN_PEM.write_bytes(chain_pem) + KEY_PEM.write_bytes( + key.private_bytes(Encoding.PEM, PrivateFormat.PKCS8, NoEncryption()) + ) + print(f"[ChatCert] Certificate cached at {CACHE_DIR}") + + +def get_proxy_ssl_context(): + if not _cert_is_fresh() or not FULLCHAIN_PEM.exists() or not KEY_PEM.exists(): + _download_and_extract() + + ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + ctx.load_cert_chain(certfile=str(FULLCHAIN_PEM), keyfile=str(KEY_PEM)) + return ctx diff --git a/LeagueClientDebugger/ChatProxy.py b/LeagueClientDebugger/ChatProxy.py index 823e675..1f56c43 100644 --- a/LeagueClientDebugger/ChatProxy.py +++ b/LeagueClientDebugger/ChatProxy.py @@ -192,15 +192,17 @@ def log_and_edit_message(message, is_outgoing) -> str: return message - async def start_client_proxy(self, proxy_host, proxy_port, real_host, real_port): + async def start_client_proxy(self, proxy_host, proxy_port, real_host, real_port, ssl_context=None): try: loop = asyncio.get_running_loop() server = await loop.create_server( lambda: self.ProtocolFromClient(real_host, real_port), - proxy_host, proxy_port) + proxy_host, proxy_port, + ssl=ssl_context) - print(f'[XMPP] Proxy server started on {proxy_host}:{proxy_port}') + print(f'[XMPP] Proxy server started on {proxy_host}:{proxy_port}' + f'{" (TLS)" if ssl_context is not None else ""}') async with server: await server.serve_forever() diff --git a/LeagueClientDebugger/ConfigProxy.py b/LeagueClientDebugger/ConfigProxy.py index d241cdd..4b6cc3a 100644 --- a/LeagueClientDebugger/ConfigProxy.py +++ b/LeagueClientDebugger/ConfigProxy.py @@ -1,5 +1,6 @@ import asyncio, requests, re, base64, json, platform from ChatProxy import ChatProxy +from ChatCert import get_proxy_ssl_context, get_chat_proxy_host from HttpProxy import HttpProxy from ProxyServers import ProxyServers, find_free_port from UiObjects import UiObjects @@ -419,9 +420,8 @@ def override_system_yaml(patchline: str): if ConfigProxy.geo_pas_url == "": ConfigProxy.geo_pas_url = config["keystone.player-affinity.playerAffinityServiceURL"] + "/pas/v1/service/chat" - replace_value("chat.use_tls.enabled", False) - replace_value("chat.host", "127.0.0.1") - replace_value("chat.allow_bad_cert.enabled", True) + chat_proxy_host = get_chat_proxy_host() + replace_value("chat.host", chat_proxy_host) if "chat.port" in config: self.real_chat_port = config["chat.port"] config["chat.port"] = self.chat_port @@ -433,13 +433,18 @@ def override_system_yaml(patchline: str): self.real_chat_host = config["chat.affinities"][affinity] for host in config["chat.affinities"]: - config["chat.affinities"][host] = "127.0.0.1" + config["chat.affinities"][host] = chat_proxy_host if not ConfigProxy.is_chat_proxy_running: ConfigProxy.is_chat_proxy_running = True chatProxy = ChatProxy() loop = asyncio.get_event_loop() - loop.create_task(chatProxy.start_client_proxy("127.0.0.1", self.chat_port, self.real_chat_host, self.real_chat_port)) + ssl_ctx = get_proxy_ssl_context() + loop.create_task(chatProxy.start_client_proxy( + "127.0.0.1", self.chat_port, + self.real_chat_host, self.real_chat_port, + ssl_ctx, + )) return config diff --git a/LeagueClientDebugger/chat_cert_config.example.json b/LeagueClientDebugger/chat_cert_config.example.json new file mode 100644 index 0000000..9e56bf4 --- /dev/null +++ b/LeagueClientDebugger/chat_cert_config.example.json @@ -0,0 +1,4 @@ +{ + "pfx_url": "https://your-host.example.com/localhost.pfx", + "chat_proxy_host": "localhost.your-domain.example.com" +} diff --git a/LeagueClientDebugger/requirements.txt b/LeagueClientDebugger/requirements.txt index 1e2f69f..55d4d35 100644 Binary files a/LeagueClientDebugger/requirements.txt and b/LeagueClientDebugger/requirements.txt differ