6f5e9166e1
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
212 lines
7.4 KiB
Python
Executable File
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))
|