feat: add automated hash refresh tooling and hardening fixes

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-09-11 12:36:06 +02:00
parent c365247ca0
commit 6f5e9166e1
12 changed files with 374 additions and 51 deletions
+3
View File
@@ -50,6 +50,9 @@ Changes:
- `safetwitch.darkness.services`
- `api.safetwitch.darkness.services`
- onion equivalents
- Compose runtime defaults now target local port-mapped endpoints (`localhost:8280` / `localhost:7100`).
- Public hostnames are configured explicitly through Traefik host override variables instead of being baked into runtime defaults.
- `docker/.env` is the Compose-facing domain configuration so running `docker compose` from `docker/` resolves the intended clearnet hosts.
## Playback and UX Reliability Changes
+4
View File
@@ -69,6 +69,10 @@ Instead, please donate your money to one of these charities which mean a lot to
All documentation can be found on the [wiki](https://codeberg.org/SafeTwitch/safetwitch/wiki)
## Deployment Notes
For the `darkness.services` instance setup and hardening details, see [README.darkness.md](README.darkness.md).
## Translate
<a href="https://translate.codeberg.org/engage/safetwitch/">
+9
View File
@@ -0,0 +1,9 @@
SAFETWITCH_BACKEND_DOMAIN=api.safetwitch.darkness.services
SAFETWITCH_INSTANCE_DOMAIN=safetwitch.darkness.services
SAFETWITCH_HTTPS=true
SAFETWITCH_BACKEND_URL=https://api.safetwitch.darkness.services
SAFETWITCH_TRAEFIK_FRONTEND_HOST=safetwitch.darkness.services
SAFETWITCH_TRAEFIK_BACKEND_HOST=api.safetwitch.darkness.services
SAFETWITCH_DEFAULT_LOCALE=en
SAFETWITCH_FALLBACK_LOCALE=en
SAFETWITCH_DEFAULT_THEME=dark
+1 -1
View File
@@ -22,7 +22,7 @@ ENV SAFETWITCH_DEFAULT_THEME SAFETWITCH_DEFAULT_THEME_PLACEHOLDER
COPY ./ .
RUN ls
# install node modules and build assets
# install node modules and build assets (skip vue-tsc in container build)
RUN npm i && npm run build-only
# nginx state for serving content
+2 -2
View File
@@ -13,7 +13,7 @@ services:
environment:
- SAFETWITCH_BACKEND_DOMAIN=localhost:7100
- SAFETWITCH_INSTANCE_DOMAIN=localhost:8280
- SAFETWITCH_HTTPS=true
- SAFETWITCH_HTTPS=false
- SAFETWITCH_DEFAULT_THEME=dark
- SAFETWITCH_DEFAULT_LOCALE=en-US
- SAFETWITCH_FALLBACK_LOCALE=en-US
@@ -32,4 +32,4 @@ services:
- "127.0.0.1:7100:7000"
environment:
- PORT=7000
- URL=changeme
- URL=http://localhost:7100
+17 -9
View File
@@ -12,15 +12,15 @@ services:
ports:
- "127.0.0.1:${SAFETWITCH_FRONTEND_PORT:-8280}:8280"
environment:
- SAFETWITCH_BACKEND_DOMAIN=${SAFETWITCH_BACKEND_DOMAIN:-api.safetwitch.darkness.services}
- SAFETWITCH_INSTANCE_DOMAIN=${SAFETWITCH_INSTANCE_DOMAIN:-safetwitch.darkness.services}
- SAFETWITCH_HTTPS=${SAFETWITCH_HTTPS:-true}
- SAFETWITCH_BACKEND_DOMAIN=${SAFETWITCH_BACKEND_DOMAIN:-localhost:7100}
- SAFETWITCH_INSTANCE_DOMAIN=${SAFETWITCH_INSTANCE_DOMAIN:-localhost:8280}
- SAFETWITCH_HTTPS=${SAFETWITCH_HTTPS:-false}
- SAFETWITCH_DEFAULT_LOCALE=${SAFETWITCH_DEFAULT_LOCALE:-en}
- SAFETWITCH_FALLBACK_LOCALE=${SAFETWITCH_FALLBACK_LOCALE:-en}
- SAFETWITCH_DEFAULT_THEME=${SAFETWITCH_DEFAULT_THEME:-dark}
labels:
- "traefik.enable=true"
- "traefik.http.routers.safetwitch.rule=Host(`safetwitch.darkness.services`)"
- "traefik.http.routers.safetwitch.rule=Host(`${SAFETWITCH_TRAEFIK_FRONTEND_HOST:-safetwitch.darkness.services}`)"
- "traefik.http.routers.safetwitch.entrypoints=https"
- "traefik.http.routers.safetwitch.tls.certresolver=myresolver"
- "traefik.http.services.safetwitch.loadbalancer.server.port=8280"
@@ -54,15 +54,19 @@ services:
cap_drop:
- ALL
restart: always
image: codeberg.org/safetwitch/safetwitch-backend:latest
image: safetwitch-backend-patched:latest
ports:
- "127.0.0.1:${SAFETWITCH_BACKEND_PORT:-7100}:7000"
environment:
- PORT=7000
- URL=${SAFETWITCH_BACKEND_URL:-https://api.safetwitch.darkness.services}
- URL=${SAFETWITCH_BACKEND_URL:-http://localhost:7100}
deploy:
resources:
limits:
cpus: "1.0"
labels:
- "traefik.enable=true"
- "traefik.http.routers.apisafetwitch.rule=Host(`api.safetwitch.darkness.services`)"
- "traefik.http.routers.apisafetwitch.rule=Host(`${SAFETWITCH_TRAEFIK_BACKEND_HOST:-api.safetwitch.darkness.services}`)"
- "traefik.http.routers.apisafetwitch.entrypoints=https"
- "traefik.http.routers.apisafetwitch.tls.certresolver=myresolver"
- "traefik.http.services.apisafetwitch.loadbalancer.server.port=7000"
@@ -76,12 +80,16 @@ services:
cap_drop:
- ALL
restart: always
image: codeberg.org/safetwitch/safetwitch-backend:latest
image: safetwitch-backend-patched:latest
ports:
- "127.0.0.1:${SAFETWITCH_BACKEND_TOR_PORT:-7101}:7000"
environment:
- PORT=7000
- URL=http://api.safetwitch.darknessrdor43qkl2ngwitj72zdavfz2cead4t5ed72bybgauww5lyd.onion
deploy:
resources:
limits:
cpus: "0.5"
labels:
- "traefik.enable=true"
- "traefik.http.routers.apisafetwitch-tor.rule=Host(`api.safetwitch.darknessrdor43qkl2ngwitj72zdavfz2cead4t5ed72bybgauww5lyd.onion`)"
@@ -108,7 +116,7 @@ services:
- BACKEND_BASE_URL=http://safetwitch-backend:7000
labels:
- "traefik.enable=true"
- "traefik.http.routers.apisafetwitch-search.rule=Host(`api.safetwitch.darkness.services`) && PathPrefix(`/api/search`)"
- "traefik.http.routers.apisafetwitch-search.rule=Host(`${SAFETWITCH_TRAEFIK_BACKEND_HOST:-api.safetwitch.darkness.services}`) && PathPrefix(`/api/search`)"
- "traefik.http.routers.apisafetwitch-search.entrypoints=https"
- "traefik.http.routers.apisafetwitch-search.tls.certresolver=myresolver"
- "traefik.http.routers.apisafetwitch-search.priority=100"
+8
View File
@@ -23,6 +23,14 @@ http {
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Range $http_range;
proxy_set_header If-Range $http_if_range;
proxy_buffering off;
proxy_request_buffering off;
proxy_max_temp_file_size 0;
proxy_read_timeout 1h;
proxy_send_timeout 1h;
add_header Accept-Ranges bytes always;
}
location / {
+62 -28
View File
@@ -1,39 +1,73 @@
#!/bin/sh
ROOT_DIR=/app
# Save old variables
set -eu
# Get old vars
if [ -f .env.old ]; then
export $(echo $(cat .env.old | sed 's/#.*//g'| xargs) | envsubst)
ROOT_DIR=/app
# Get previous placeholder values if available.
if [ -f ./.env.old ]; then
# shellcheck disable=SC1091
. ./.env.old
else
export SAFETWITCH_BACKEND_DOMAIN_PLACEHOLDER=SAFETWITCH_BACKEND_DOMAIN_PLACEHOLDER
export SAFETWITCH_INSTANCE_DOMAIN_PLACEHOLDER=SAFETWITCH_INSTANCE_DOMAIN_PLACEHOLDER
export SAFETWITCH_HTTPS_PLACEHOLDER=SAFETWITCH_HTTPS_PLACEHOLDER
export SAFETWITCH_DEFAULT_THEME_PLACEHOLDER=SAFETWITCH_DEFAULT_THEME_PLACEHOLDER
export SAFETWITCH_DEFAULT_LOCALE_PLACEHOLDER=SAFETWITCH_DEFAULT_LOCALE_PLACEHOLDER
export SAFETWITCH_FALLBACK_LOCALE_PLACEHOLDER=SAFETWITCH_FALLBACK_LOCALE_PLACEHOLDER
SAFETWITCH_BACKEND_DOMAIN_PLACEHOLDER=SAFETWITCH_BACKEND_DOMAIN_PLACEHOLDER
SAFETWITCH_INSTANCE_DOMAIN_PLACEHOLDER=SAFETWITCH_INSTANCE_DOMAIN_PLACEHOLDER
SAFETWITCH_HTTPS_PLACEHOLDER=SAFETWITCH_HTTPS_PLACEHOLDER
SAFETWITCH_DEFAULT_THEME_PLACEHOLDER=SAFETWITCH_DEFAULT_THEME_PLACEHOLDER
SAFETWITCH_DEFAULT_LOCALE_PLACEHOLDER=SAFETWITCH_DEFAULT_LOCALE_PLACEHOLDER
SAFETWITCH_FALLBACK_LOCALE_PLACEHOLDER=SAFETWITCH_FALLBACK_LOCALE_PLACEHOLDER
fi
SAFETWITCH_BACKEND_DOMAIN_PLACEHOLDER=${SAFETWITCH_BACKEND_DOMAIN_PLACEHOLDER:-SAFETWITCH_BACKEND_DOMAIN_PLACEHOLDER}
SAFETWITCH_INSTANCE_DOMAIN_PLACEHOLDER=${SAFETWITCH_INSTANCE_DOMAIN_PLACEHOLDER:-SAFETWITCH_INSTANCE_DOMAIN_PLACEHOLDER}
SAFETWITCH_HTTPS_PLACEHOLDER=${SAFETWITCH_HTTPS_PLACEHOLDER:-SAFETWITCH_HTTPS_PLACEHOLDER}
SAFETWITCH_DEFAULT_THEME_PLACEHOLDER=${SAFETWITCH_DEFAULT_THEME_PLACEHOLDER:-SAFETWITCH_DEFAULT_THEME_PLACEHOLDER}
SAFETWITCH_DEFAULT_LOCALE_PLACEHOLDER=${SAFETWITCH_DEFAULT_LOCALE_PLACEHOLDER:-SAFETWITCH_DEFAULT_LOCALE_PLACEHOLDER}
SAFETWITCH_FALLBACK_LOCALE_PLACEHOLDER=${SAFETWITCH_FALLBACK_LOCALE_PLACEHOLDER:-SAFETWITCH_FALLBACK_LOCALE_PLACEHOLDER}
SAFETWITCH_BACKEND_DOMAIN=${SAFETWITCH_BACKEND_DOMAIN:-}
SAFETWITCH_INSTANCE_DOMAIN=${SAFETWITCH_INSTANCE_DOMAIN:-}
SAFETWITCH_HTTPS=${SAFETWITCH_HTTPS:-}
SAFETWITCH_DEFAULT_THEME=${SAFETWITCH_DEFAULT_THEME:-}
SAFETWITCH_DEFAULT_LOCALE=${SAFETWITCH_DEFAULT_LOCALE:-}
SAFETWITCH_FALLBACK_LOCALE=${SAFETWITCH_FALLBACK_LOCALE:-}
escape_sed_replacement() {
printf '%s' "$1" | sed 's/[&|]/\\&/g'
}
replace_placeholder() {
placeholder="$1"
value="$2"
file="$3"
# Avoid invalid sed expressions like s||...|g when placeholder is empty.
[ -n "$placeholder" ] || return 0
escaped_value=$(escape_sed_replacement "$value")
sed -i "s|$placeholder|$escaped_value|g" "$file"
}
# Replace env vars in files served by NGINX
for file in $ROOT_DIR/assets/*.js $ROOT_DIR/index.html;
for file in "$ROOT_DIR"/assets/*.js "$ROOT_DIR"/index.html;
do
sed -i 's|'${SAFETWITCH_BACKEND_DOMAIN_PLACEHOLDER}'|'${SAFETWITCH_BACKEND_DOMAIN}'|g' $file
sed -i 's|'${SAFETWITCH_INSTANCE_DOMAIN_PLACEHOLDER}'|'${SAFETWITCH_INSTANCE_DOMAIN}'|g' $file
sed -i 's|'${SAFETWITCH_HTTPS_PLACEHOLDER}'|'${SAFETWITCH_HTTPS}'|g' $file
sed -i 's|'${SAFETWITCH_DEFAULT_THEME_PLACEHOLDER}'|'${SAFETWITCH_DEFAULT_THEME}'|g' $file
sed -i 's|'${SAFETWITCH_DEFAULT_LOCALE_PLACEHOLDER}'|'${SAFETWITCH_DEFAULT_LOCALE}'|g' $file
sed -i 's|'${SAFETWITCH_FALLBACK_LOCALE_PLACEHOLDER}'|'${SAFETWITCH_FALLBACK_LOCALE}'|g' $file
# Your other variables here...
[ -e "$file" ] || continue
replace_placeholder "$SAFETWITCH_BACKEND_DOMAIN_PLACEHOLDER" "$SAFETWITCH_BACKEND_DOMAIN" "$file"
replace_placeholder "$SAFETWITCH_INSTANCE_DOMAIN_PLACEHOLDER" "$SAFETWITCH_INSTANCE_DOMAIN" "$file"
replace_placeholder "$SAFETWITCH_HTTPS_PLACEHOLDER" "$SAFETWITCH_HTTPS" "$file"
replace_placeholder "$SAFETWITCH_DEFAULT_THEME_PLACEHOLDER" "$SAFETWITCH_DEFAULT_THEME" "$file"
replace_placeholder "$SAFETWITCH_DEFAULT_LOCALE_PLACEHOLDER" "$SAFETWITCH_DEFAULT_LOCALE" "$file"
replace_placeholder "$SAFETWITCH_FALLBACK_LOCALE_PLACEHOLDER" "$SAFETWITCH_FALLBACK_LOCALE" "$file"
done
# Save old variables
echo -e "SAFETWITCH_BACKEND_DOMAIN_PLACEHOLDER=${SAFETWITCH_BACKEND_DOMAIN}" > .env.old
echo -e "SAFETWITCH_INSTANCE_DOMAIN_PLACEHOLDER=${SAFETWITCH_INSTANCE_DOMAIN}" >> .env.old
echo -e "SAFETWITCH_HTTPS_PLACEHOLDER=${SAFETWITCH_HTTPS}" >> .env.old
echo -e "SAFETWITCH_DEFAULT_THEME_PLACEHOLDER=${SAFETWITCH_DEFAULT_THEME}" >> .env.old
echo -e "SAFETWITCH_DEFAULT_LOCALE_PLACEHOLDER=${SAFETWITCH_DEFAULT_LOCALE}" >> .env.old
echo -e "SAFETWITCH_FALLBACK_LOCALE_PLACEHOLDER=${SAFETWITCH_FALLBACK_LOCALE}" >> .env.old
# Save current variables for next container restart.
cat > ./.env.old <<EOF
SAFETWITCH_BACKEND_DOMAIN_PLACEHOLDER=${SAFETWITCH_BACKEND_DOMAIN}
SAFETWITCH_INSTANCE_DOMAIN_PLACEHOLDER=${SAFETWITCH_INSTANCE_DOMAIN}
SAFETWITCH_HTTPS_PLACEHOLDER=${SAFETWITCH_HTTPS}
SAFETWITCH_DEFAULT_THEME_PLACEHOLDER=${SAFETWITCH_DEFAULT_THEME}
SAFETWITCH_DEFAULT_LOCALE_PLACEHOLDER=${SAFETWITCH_DEFAULT_LOCALE}
SAFETWITCH_FALLBACK_LOCALE_PLACEHOLDER=${SAFETWITCH_FALLBACK_LOCALE}
EOF
# Starting NGINX
nginx -g 'daemon off;'
exec nginx -g 'daemon off;'
+47 -1
View File
@@ -1,4 +1,4 @@
import type { FollowingStreamer } from '@/types'
import type { CategoryPreview, FollowingStreamer } from '@/types'
const language = localStorage.getItem('language') || 'en-us'
const https = import.meta.env.SAFETWITCH_HTTPS.slice() === 'true'
@@ -74,6 +74,52 @@ export async function postEndpoint(endpoint: string, data: any) {
return finalData
}
export function ensureArray<T>(value: T[] | null | undefined): T[] {
return Array.isArray(value) ? value : []
}
const fallbackDiscoverCategorySeeds = [
{ name: 'Just Chatting', tags: ['IRL'] },
{ name: 'League of Legends', tags: ['MOBA'] },
{ name: 'VALORANT', tags: ['FPS'] },
{ name: 'Minecraft', tags: ['Sandbox'] },
{ name: 'Fortnite', tags: ['Battle Royale'] },
{ name: 'Counter-Strike', tags: ['FPS'] },
{ name: 'Apex Legends', tags: ['Battle Royale'] },
{ name: 'Dota 2', tags: ['MOBA'] },
{ name: 'World of Warcraft', tags: ['MMO'] },
{ name: 'Grand Theft Auto V', tags: ['Open World'] },
{ name: 'Diablo IV', tags: ['ARPG'] },
{ name: 'Marvel Rivals', tags: ['Hero Shooter'] }
] as const
function getProxyImageUrl(sourceUrl: string) {
return `${rootBackendUrl}proxy/img/${btoa(sourceUrl)}`
}
export function getFallbackDiscoverCategories(query = ''): CategoryPreview[] {
const loweredQuery = query.trim().toLowerCase()
return fallbackDiscoverCategorySeeds
.filter(({ name, tags }) => {
if (!loweredQuery) {
return true
}
const loweredName = name.toLowerCase()
return loweredName.includes(loweredQuery) || tags.some((tag) => tag.toLowerCase().includes(loweredQuery))
})
.map(({ name, tags }) => ({
name,
displayName: name,
viewers: 0,
tags,
image: getProxyImageUrl(
`https://static-cdn.jtvnw.net/ttv-boxart/${encodeURIComponent(name)}-285x380.jpg`
)
}))
}
/**
* Converts a twitch timestamp (0h0m0s) to seconds
* @param query 0h0m0s
+4 -8
View File
@@ -6,7 +6,7 @@ import ErrorMessage from '@/components/ErrorMessage.vue'
import LoadingScreen from '@/components/LoadingScreen.vue'
import CategoryPreview from '@/components/CategoryPreview.vue'
import { getEndpoint, followersStreaming } from '@/mixins'
import { ensureArray, getEndpoint, getFallbackDiscoverCategories, followersStreaming } from '@/mixins'
import type { CategoryPreview as CategoryPreviewInterface } from '@/types'
import { getFollows } from '@/settingsManager'
import VueTitle from '@/components/VueTitle.vue'
@@ -67,13 +67,9 @@ export default {
window.onscroll = this.getNextCategory
// get discover page
await getEndpoint('api/discover')
.catch(() => {
this.status = 'error'
})
.then((data: CategoryPreviewInterface[]) => {
this.data = data
})
const discoverData = await getEndpoint('api/discover').catch(() => null)
const categories = ensureArray(discoverData as CategoryPreviewInterface[] | null | undefined)
this.data = categories.length > 0 ? categories : getFallbackDiscoverCategories()
this.following = await getFollows()
this.followingStreaming = []
+6 -2
View File
@@ -7,7 +7,7 @@ import LoadingScreen from '@/components/LoadingScreen.vue'
import StreamPreviewVue from '@/components/StreamPreview.vue'
import ChannelPreview from '@/components/ChannelPreview.vue'
import { getEndpoint } from '@/mixins'
import { ensureArray, getEndpoint, getFallbackDiscoverCategories } from '@/mixins'
import type { SearchResult, StreamerData } from '@/types'
export default {
@@ -59,7 +59,7 @@ export default {
await getEndpoint('api/discover')
.then((categories) => {
const loweredQuery = query.toLowerCase()
fallback.categories = categories
fallback.categories = ensureArray(categories)
.filter((category: { name: string; displayName?: string }) => {
const name = String(category.name || '').toLowerCase()
const displayName = String(category.displayName || '').toLowerCase()
@@ -71,6 +71,10 @@ export default {
// Ignore fallback category lookup errors.
})
if (fallback.categories.length === 0) {
fallback.categories = getFallbackDiscoverCategories(query).slice(0, 24)
}
return fallback
},
async runSearch() {
+211
View File
@@ -0,0 +1,211 @@
#!/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))