From 6d811fd7e096918a460248e9cbd6fd4a562e6cfd Mon Sep 17 00:00:00 2001 From: sanasol Date: Fri, 20 Feb 2026 20:22:57 +0100 Subject: [PATCH] v2.3.5: hardened fallback chain for patch URL discovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 6-step fallback: auth.sanasol.ws → htdwnldsan.top → DNS TXT via DoH → disk cache → hardcoded URL. Practically unkillable by DMCA. Co-Authored-By: Claude Opus 4.6 --- backend/services/versionManager.js | 161 ++++++++++++++++++++++++----- package.json | 2 +- 2 files changed, 139 insertions(+), 24 deletions(-) diff --git a/backend/services/versionManager.js b/backend/services/versionManager.js index 15f6f3a..f31e020 100644 --- a/backend/services/versionManager.js +++ b/backend/services/versionManager.js @@ -1,11 +1,17 @@ const axios = require('axios'); const crypto = require('crypto'); const fs = require('fs'); +const path = require('path'); const { getOS, getArch } = require('../utils/platformUtils'); -// Patches base URL fetched dynamically from auth server +// Patches base URL fetched dynamically via multi-source fallback chain const AUTH_DOMAIN = process.env.HYTALE_AUTH_DOMAIN || 'auth.sanasol.ws'; -const PATCHES_CONFIG_URL = `https://${AUTH_DOMAIN}/api/patches-config`; +const PATCHES_CONFIG_SOURCES = [ + { type: 'http', url: `https://${AUTH_DOMAIN}/api/patches-config`, name: 'auth-server' }, + { type: 'http', url: 'https://htdwnldsan.top/patches-config', name: 'backup-http' }, + { type: 'doh', name: '_patches.htdwnldsan.top', name_label: 'dns-txt' }, +]; +const HARDCODED_FALLBACK = 'https://dl.vboro.de/patches'; // Fallback: latest known build number if manifest is unreachable const FALLBACK_LATEST_BUILD = 11; @@ -18,37 +24,146 @@ let manifestCache = null; let manifestCacheTime = 0; const MANIFEST_CACHE_DURATION = 60000; // 1 minute +// Disk cache path for patches URL (survives restarts) +function getDiskCachePath() { + const os = require('os'); + const home = os.homedir(); + let appDir; + if (process.platform === 'win32') { + appDir = path.join(home, 'AppData', 'Local', 'HytaleF2P'); + } else if (process.platform === 'darwin') { + appDir = path.join(home, 'Library', 'Application Support', 'HytaleF2P'); + } else { + appDir = path.join(home, '.hytalef2p'); + } + return path.join(appDir, 'patches-url-cache.json'); +} + +function saveDiskCache(url) { + try { + const cachePath = getDiskCachePath(); + const dir = path.dirname(cachePath); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(cachePath, JSON.stringify({ patches_url: url, ts: Date.now() }), 'utf8'); + } catch (e) { + // Non-critical, ignore + } +} + +function loadDiskCache() { + try { + const cachePath = getDiskCachePath(); + if (fs.existsSync(cachePath)) { + const data = JSON.parse(fs.readFileSync(cachePath, 'utf8')); + if (data && data.patches_url) return data.patches_url; + } + } catch (e) { + // Non-critical, ignore + } + return null; +} + /** - * Fetch patches base URL from auth server config endpoint + * Fetch patches URL from a single HTTP config endpoint + */ +async function fetchFromHttp(url) { + const response = await axios.get(url, { + timeout: 8000, + headers: { 'User-Agent': 'Hytale-F2P-Launcher' } + }); + if (response.data && response.data.patches_url) { + return response.data.patches_url.replace(/\/+$/, ''); + } + throw new Error('Invalid response'); +} + +/** + * Fetch patches URL from DNS TXT record via DNS-over-HTTPS + */ +async function fetchFromDoh(recordName) { + const dohEndpoints = [ + { url: 'https://dns.google/resolve', params: { name: recordName, type: 'TXT' } }, + { url: 'https://cloudflare-dns.com/dns-query', params: { name: recordName, type: 'TXT' }, headers: { 'Accept': 'application/dns-json' } }, + ]; + + for (const endpoint of dohEndpoints) { + try { + const response = await axios.get(endpoint.url, { + params: endpoint.params, + headers: { 'User-Agent': 'Hytale-F2P-Launcher', ...(endpoint.headers || {}) }, + timeout: 5000 + }); + const answers = response.data && response.data.Answer; + if (answers && answers.length > 0) { + // TXT records are quoted, strip quotes + const txt = answers[0].data.replace(/^"|"$/g, ''); + if (txt.startsWith('http')) return txt.replace(/\/+$/, ''); + } + } catch (e) { + // Try next DoH endpoint + } + } + throw new Error('All DoH endpoints failed'); +} + +/** + * Fetch patches base URL with hardened multi-source fallback chain: + * 1. Memory cache (5 min) + * 2. HTTP: auth.sanasol.ws (primary) + * 3. HTTP: htdwnldsan.top (backup, different host/domain/registrar) + * 4. DNS TXT: _patches.htdwnldsan.top via DoH (different protocol layer) + * 5. Disk cache (survives restarts, never expires) + * 6. Hardcoded fallback URL (last resort) */ async function getPatchesBaseUrl() { const now = Date.now(); + + // 1. Memory cache if (patchesBaseUrl && (now - patchesConfigTime) < PATCHES_CONFIG_CACHE_DURATION) { return patchesBaseUrl; } - try { - console.log('[Mirror] Fetching patches config from:', PATCHES_CONFIG_URL); - const response = await axios.get(PATCHES_CONFIG_URL, { - timeout: 10000, - headers: { 'User-Agent': 'Hytale-F2P-Launcher' } - }); - - if (response.data && response.data.patches_url) { - patchesBaseUrl = response.data.patches_url.replace(/\/+$/, ''); - patchesConfigTime = now; - console.log('[Mirror] Patches base URL:', patchesBaseUrl); - return patchesBaseUrl; + // 2-4. Try all sources: HTTP endpoints first, then DoH + for (const source of PATCHES_CONFIG_SOURCES) { + try { + let url; + if (source.type === 'http') { + console.log(`[Mirror] Trying ${source.name}: ${source.url}`); + url = await fetchFromHttp(source.url); + } else if (source.type === 'doh') { + console.log(`[Mirror] Trying ${source.name_label}: ${source.name}`); + url = await fetchFromDoh(source.name); + } + if (url) { + patchesBaseUrl = url; + patchesConfigTime = now; + saveDiskCache(url); + console.log(`[Mirror] Patches URL (via ${source.name || source.name_label}): ${url}`); + return url; + } + } catch (e) { + console.warn(`[Mirror] ${source.name || source.name_label} failed: ${e.message}`); } - throw new Error('Invalid patches config'); - } catch (error) { - console.error('[Mirror] Error fetching patches config:', error.message); - if (patchesBaseUrl) { - console.log('[Mirror] Using cached patches URL:', patchesBaseUrl); - return patchesBaseUrl; - } - throw error; } + + // 5. Stale memory cache (any age) + if (patchesBaseUrl) { + console.log('[Mirror] All sources failed, using stale memory cache:', patchesBaseUrl); + return patchesBaseUrl; + } + + // 6. Disk cache (survives restarts) + const diskUrl = loadDiskCache(); + if (diskUrl) { + patchesBaseUrl = diskUrl; + console.log('[Mirror] All sources failed, using disk cache:', diskUrl); + return diskUrl; + } + + // 7. Hardcoded fallback + console.warn('[Mirror] All sources + caches exhausted, using hardcoded fallback:', HARDCODED_FALLBACK); + patchesBaseUrl = HARDCODED_FALLBACK; + return HARDCODED_FALLBACK; } /** diff --git a/package.json b/package.json index f5c956f..a833176 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hytale-f2p-launcher", - "version": "2.3.4", + "version": "2.3.5", "description": "A modern, cross-platform launcher for Hytale with automatic updates and multi-client support", "homepage": "https://git.sanhost.net/sanasol/hytale-f2p", "main": "main.js",