Files
safetwitch/tools/refresh-hashes.py

212 lines
7.4 KiB
Python
Executable File

#!/usr/bin/env python3
"""
Safetwitch Automated Hash Refresher & Health Watcher
- Verifies Safetwitch backend API health.
- If endpoints fail due to expired/rotated Twitch GraphQL hashes, uses browserless/playwright
to crawl Twitch, extract live sha256 persistedQuery hashes, patches the backend binary/image,
and reloads the container stack cleanly.
"""
import os
import sys
import json
import time
import subprocess
import urllib.request
import urllib.error
SAFETWITCH_DIR = "/srv/safetwitch"
BACKEND_HEALTH_URL = os.environ.get("SAFETWITCH_HEALTH_URL", "http://127.0.0.1:7100/api/discover")
BROWSERLESS_CONTAINER = os.environ.get("BROWSERLESS_CONTAINER", "browserless")
# Key GraphQL operations Safetwitch relies on
OPERATION_NAMES = [
"BrowsePage_AllDirectories",
"DirectoryPage_Game",
"Directory_DirectoryBanner",
"UseLive",
"VideoPreviewOverlay",
"StreamTagsTrackingChannel",
"ChannelShell",
"VideoPlayerStreamMetadata"
]
def check_backend_health() -> bool:
try:
req = urllib.request.Request(
BACKEND_HEALTH_URL,
headers={"User-Agent": "Safetwitch-Watcher/1.0", "Accept": "application/json"}
)
with urllib.request.urlopen(req, timeout=10) as resp:
if resp.status == 200:
data = json.loads(resp.read().decode("utf-8"))
if data.get("status") == "ok" and len(data.get("data", [])) > 0:
return True
except Exception as e:
print(f"[!] Health check failed: {e}")
return False
def extract_live_hashes() -> dict:
print("[*] Launching browser extraction via browserless container...")
js_script = r'''
const { chromium } = require("/usr/src/app/node_modules/playwright-core");
(async () => {
const browser = await chromium.launch({
executablePath: "/usr/local/bin/playwright-browsers/chromium-1217/chrome-linux64/chrome",
args: ["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage", "--disable-gpu"]
});
const page = await browser.newPage();
const operations = {};
page.on("request", req => {
if (req.url().includes("gql")) {
try {
const post = req.postData();
if (post) {
const data = JSON.parse(post);
const items = Array.isArray(data) ? data : [data];
for (const item of items) {
if (item.operationName && item.extensions?.persistedQuery?.sha256Hash) {
operations[item.operationName] = item.extensions.persistedQuery.sha256Hash;
}
}
}
} catch (e) {}
}
});
const urls = [
"https://www.twitch.tv/directory",
"https://www.twitch.tv/directory/category/just-chatting",
"https://www.twitch.tv/gaules"
];
for (const u of urls) {
try {
await page.goto(u, { waitUntil: "domcontentloaded", timeout: 25000 });
await page.waitForTimeout(4000);
} catch (e) {}
}
console.log("EXTRACTED_HASHES=" + JSON.stringify(operations));
await browser.close();
})();
'''
res = subprocess.run(
["docker", "exec", "-i", BROWSERLESS_CONTAINER, "node", "-e", js_script],
capture_output=True,
text=True,
timeout=120
)
for line in res.stdout.splitlines():
if line.startswith("EXTRACTED_HASHES="):
json_str = line.split("=", 1)[1]
return json.loads(json_str)
raise RuntimeError(f"Failed to extract hashes from browserless: {res.stderr or res.stdout}")
def patch_backend_binary(extracted_hashes: dict):
print("[*] Checking and patching backend image...")
# Extract current binary from safetwitch-backend-patched:latest
temp_container = "temp-patch-backend-" + str(int(time.time()))
subprocess.run(["docker", "create", "--name", temp_container, "safetwitch-backend-patched:latest"], check=True)
tmp_bin = f"/tmp/server-{int(time.time())}"
subprocess.run(["docker", "cp", f"{temp_container}:/server", tmp_bin], check=True)
subprocess.run(["docker", "rm", temp_container], check=True)
with open(tmp_bin, "rb") as f:
data = bytearray(f.read())
# Map operations to their old / known hashes in binary
# 7: BrowsePage_AllDirectories
# 8: DirectoryPage_Game
# 9: Directory_DirectoryBanner
# 17: UseLive
# 14: VideoPreviewOverlay
# 13: StreamTagsTrackingChannel
op_map = {
"BrowsePage_AllDirectories": b"2f67f71ba89f3c0ed26a141ec00da1defecb2303595f5cda4298169549783d9e",
"DirectoryPage_Game": b"86bcceb4e8b1a51256ff8eed8bd8aae4acacf80d737efe904f84f3aeadf8cafd",
"Directory_DirectoryBanner": b"822ecf40c2a77568d2b223fd5bc4dfdc9c863f081dd1ca7611803a5330e88277",
"UseLive": b"639d5f11bfb8bf3053b424d9ef650d04c4ebb7d94711d644afb08fe9a0fad5d9",
"VideoPreviewOverlay": b"9515480dee68a77e667cb19de634739d33f243572b007e98e67184b1a5d8369f",
"StreamTagsTrackingChannel": b"6aa3851aaaf88c320d514eb173563d430b28ed70fdaaf7eeef6ed4b812f48608",
}
modified = False
for op_name, new_hash in extracted_hashes.items():
if op_name in op_map and new_hash:
new_hash_bytes = new_hash.encode("ascii")
curr_target = op_map[op_name]
if curr_target in data and curr_target != new_hash_bytes:
print(f"[*] Updating {op_name}: {curr_target.decode()} -> {new_hash}")
data = data.replace(curr_target, new_hash_bytes)
modified = True
if not modified:
print("[+] Backend binary is already up to date with extracted hashes.")
os.remove(tmp_bin)
return False
with open(tmp_bin, "wb") as f:
f.write(data)
# Build updated docker image
dockerfile_content = f"""FROM alpine:3.24
COPY {os.path.basename(tmp_bin)} /server
RUN chmod +x /server
ENTRYPOINT ["/server"]
"""
tmp_df = f"/tmp/Dockerfile-{int(time.time())}"
with open(tmp_df, "w") as f:
f.write(dockerfile_content)
print("[*] Rebuilding safetwitch-backend-patched:latest image...")
subprocess.run(
["docker", "build", "-t", "safetwitch-backend-patched:latest", "-f", tmp_df, "/tmp"],
check=True
)
# Cleanup temp files
os.remove(tmp_bin)
os.remove(tmp_df)
# Recreate backend containers in compose
print("[*] Restarting backend containers...")
subprocess.run(
["docker", "compose", "up", "-d", "--force-recreate", "safetwitch-backend", "safetwitch-backend-tor"],
cwd=os.path.join(SAFETWITCH_DIR, "docker"),
check=True
)
print("[+] Successfully refreshed hashes and reloaded backend.")
return True
def run(force=False):
print(f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] Checking Safetwitch status...")
if not force and check_backend_health():
print("[+] Safetwitch backend is healthy and responding.")
return 0
print("[!] Safetwitch backend unhealthy or refresh forced. Starting automatic refresh...")
try:
hashes = extract_live_hashes()
print(f"[+] Found {len(hashes)} live operations.")
patch_backend_binary(hashes)
time.sleep(3)
if check_backend_health():
print("[+] Verification passed: Safetwitch is healthy!")
return 0
else:
print("[!] Verification failed after refresh.")
return 1
except Exception as e:
print(f"[!] Refresh error: {e}")
return 1
if __name__ == "__main__":
force_run = "--force" in sys.argv
sys.exit(run(force=force_run))