Files
hytale-f2p/backend/utils/clientPatcher.js
sanasol 2efecd168f fix: Ultra-minimal patching - only main domain
Only patch hytale.com -> anasol.ws
Skip ALL subdomain patches (sessions, account-data, tools, telemetry, sentry)

Testing if fewer patches = no crash.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 06:10:08 +01:00

635 lines
20 KiB
JavaScript

const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const AdmZip = require('adm-zip');
const { execSync, spawn } = require('child_process');
const { getJavaExec, getBundledJavaPath } = require('../managers/javaManager');
const { JRE_DIR } = require('../core/paths');
// Domain configuration
const ORIGINAL_DOMAIN = 'hytale.com';
const MIN_DOMAIN_LENGTH = 4;
const MAX_DOMAIN_LENGTH = 16;
function getTargetDomain() {
if (process.env.HYTALE_AUTH_DOMAIN) {
return process.env.HYTALE_AUTH_DOMAIN;
}
try {
const { getAuthDomain } = require('../core/config');
return getAuthDomain();
} catch (e) {
return 'auth.sanasol.ws';
}
}
const DEFAULT_NEW_DOMAIN = 'auth.sanasol.ws';
/**
* Patches HytaleClient and HytaleServer binaries to replace hytale.com with custom domain
* This allows the game to connect to a custom authentication server
*
* Supports domains from 4 to 16 characters:
* - All F2P traffic routes to single endpoint: https://{domain} (no subdomains)
* - Domains <= 10 chars: Direct replacement, subdomains stripped
* - Domains 11-16 chars: Split mode - first 6 chars replace subdomain prefix, rest replaces domain
*
* Official hytale.com keeps original subdomain behavior (sessions., account-data., etc.)
*/
class ClientPatcher {
constructor() {
this.patchedFlag = '.patched_custom';
}
/**
* Get the target domain for patching
*/
getNewDomain() {
const domain = getTargetDomain();
if (domain.length < MIN_DOMAIN_LENGTH) {
console.warn(`Warning: Domain "${domain}" is too short (min ${MIN_DOMAIN_LENGTH} chars)`);
console.warn(`Using default domain: ${DEFAULT_NEW_DOMAIN}`);
return DEFAULT_NEW_DOMAIN;
}
if (domain.length > MAX_DOMAIN_LENGTH) {
console.warn(`Warning: Domain "${domain}" is too long (max ${MAX_DOMAIN_LENGTH} chars)`);
console.warn(`Using default domain: ${DEFAULT_NEW_DOMAIN}`);
return DEFAULT_NEW_DOMAIN;
}
return domain;
}
/**
* Calculate the domain patching strategy based on length
* @returns {object} Strategy with mainDomain and subdomainPrefix
*/
getDomainStrategy(domain) {
if (domain.length <= 10) {
return {
mode: 'direct',
mainDomain: domain,
subdomainPrefix: '',
description: `Direct replacement: hytale.com -> ${domain}`
};
} else {
const prefix = domain.slice(0, 6);
const suffix = domain.slice(6);
return {
mode: 'split',
mainDomain: suffix,
subdomainPrefix: prefix,
description: `Split mode: subdomain prefix="${prefix}", main domain="${suffix}"`
};
}
}
/**
* Convert a string to the length-prefixed byte format used by the client
* Format: [length byte] [00 00 00 padding] [char1] [00] [char2] [00] ... [lastChar]
*/
stringToLengthPrefixed(str) {
const length = str.length;
const result = Buffer.alloc(4 + length + (length - 1));
result[0] = length;
result[1] = 0x00;
result[2] = 0x00;
result[3] = 0x00;
let pos = 4;
for (let i = 0; i < length; i++) {
result[pos++] = str.charCodeAt(i);
if (i < length - 1) {
result[pos++] = 0x00;
}
}
return result;
}
/**
* Convert a string to UTF-16LE bytes (how .NET stores strings)
*/
stringToUtf16LE(str) {
const buf = Buffer.alloc(str.length * 2);
for (let i = 0; i < str.length; i++) {
buf.writeUInt16LE(str.charCodeAt(i), i * 2);
}
return buf;
}
/**
* Convert a string to UTF-8 bytes (how Java stores strings)
*/
stringToUtf8(str) {
return Buffer.from(str, 'utf8');
}
/**
* Find all occurrences of a pattern in a buffer
*/
findAllOccurrences(buffer, pattern) {
const positions = [];
let pos = 0;
while (pos < buffer.length) {
const index = buffer.indexOf(pattern, pos);
if (index === -1) break;
positions.push(index);
pos = index + 1;
}
return positions;
}
/**
* Replace bytes in buffer - only overwrites the length of new bytes
* Does NOT null-pad to avoid corrupting adjacent data
*/
replaceBytes(buffer, oldBytes, newBytes) {
let count = 0;
const result = Buffer.from(buffer);
if (newBytes.length > oldBytes.length) {
console.warn(` Warning: New pattern (${newBytes.length}) longer than old (${oldBytes.length}), skipping`);
return { buffer: result, count: 0 };
}
const positions = this.findAllOccurrences(result, oldBytes);
for (const pos of positions) {
// Only overwrite the length of the new bytes - don't null-fill!
newBytes.copy(result, pos);
count++;
}
return { buffer: result, count };
}
/**
* UTF-8 domain replacement for Java JAR files
*/
findAndReplaceDomainUtf8(data, oldDomain, newDomain) {
let count = 0;
const result = Buffer.from(data);
const oldUtf8 = this.stringToUtf8(oldDomain);
const newUtf8 = this.stringToUtf8(newDomain);
const positions = this.findAllOccurrences(result, oldUtf8);
for (const pos of positions) {
newUtf8.copy(result, pos);
count++;
}
return { buffer: result, count };
}
/**
* Smart domain replacement for .NET AOT binaries
*/
findAndReplaceDomainSmart(data, oldDomain, newDomain) {
let count = 0;
const result = Buffer.from(data);
if (newDomain.length > oldDomain.length) {
console.warn(` Warning: New domain longer than old, skipping smart replacement`);
return { buffer: result, count: 0 };
}
const oldUtf16NoLast = this.stringToUtf16LE(oldDomain.slice(0, -1));
const newUtf16NoLast = this.stringToUtf16LE(newDomain.slice(0, -1));
const oldLastCharByte = oldDomain.charCodeAt(oldDomain.length - 1);
const newLastCharByte = newDomain.charCodeAt(newDomain.length - 1);
const positions = this.findAllOccurrences(result, oldUtf16NoLast);
for (const pos of positions) {
const lastCharPos = pos + oldUtf16NoLast.length;
if (lastCharPos + 1 > result.length) continue;
const lastCharFirstByte = result[lastCharPos];
if (lastCharFirstByte === oldLastCharByte) {
// Only overwrite, don't null-fill
newUtf16NoLast.copy(result, pos);
result[lastCharPos] = newLastCharByte;
count++;
}
}
return { buffer: result, count };
}
/**
* Apply all domain patches using length-prefixed format
*/
applyDomainPatches(data, domain, protocol = 'https://') {
let result = Buffer.from(data);
let totalCount = 0;
const strategy = this.getDomainStrategy(domain);
console.log(` Patching strategy: ${strategy.description}`);
// ULTRA-MINIMAL PATCHING - only domain, no subdomain patches
console.log(` Ultra-minimal mode: only patching main domain`);
// Only patch main domain (hytale.com -> mainDomain)
const domainResult = this.replaceBytes(
result,
this.stringToLengthPrefixed(ORIGINAL_DOMAIN),
this.stringToLengthPrefixed(strategy.mainDomain)
);
result = domainResult.buffer;
if (domainResult.count > 0) {
console.log(` Patched ${domainResult.count} domain occurrence(s)`);
totalCount += domainResult.count;
}
// Skip ALL subdomain patches - let them stay as sessions.hytale.com etc
console.log(` Skipping all subdomain patches (ultra-minimal mode)`);
return { buffer: result, count: totalCount };
}
/**
* Check if the client binary has already been patched
*/
isPatchedAlready(clientPath) {
const newDomain = this.getNewDomain();
const patchFlagFile = clientPath + this.patchedFlag;
if (fs.existsSync(patchFlagFile)) {
try {
const flagData = JSON.parse(fs.readFileSync(patchFlagFile, 'utf8'));
if (flagData.targetDomain === newDomain) {
// Verify the binary actually contains the patched domain
const data = fs.readFileSync(clientPath);
const strategy = this.getDomainStrategy(newDomain);
const domainPattern = this.stringToLengthPrefixed(strategy.mainDomain);
if (data.includes(domainPattern)) {
return true;
} else {
console.log(' Flag exists but binary not patched (was updated?), re-patching...');
return false;
}
}
} catch (e) {
// Flag file corrupt
}
}
return false;
}
/**
* Mark the client as patched
*/
markAsPatched(clientPath) {
const newDomain = this.getNewDomain();
const strategy = this.getDomainStrategy(newDomain);
const patchFlagFile = clientPath + this.patchedFlag;
const flagData = {
patchedAt: new Date().toISOString(),
originalDomain: ORIGINAL_DOMAIN,
targetDomain: newDomain,
patchMode: strategy.mode,
mainDomain: strategy.mainDomain,
subdomainPrefix: strategy.subdomainPrefix,
patcherVersion: '2.1.0'
};
fs.writeFileSync(patchFlagFile, JSON.stringify(flagData, null, 2));
}
/**
* Create a backup of the original client binary
*/
backupClient(clientPath) {
const backupPath = clientPath + '.original';
if (!fs.existsSync(backupPath)) {
console.log(` Creating backup at ${path.basename(backupPath)}`);
fs.copyFileSync(clientPath, backupPath);
return backupPath;
}
const currentSize = fs.statSync(clientPath).size;
const backupSize = fs.statSync(backupPath).size;
if (currentSize !== backupSize) {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
const oldBackupPath = `${clientPath}.original.${timestamp}`;
console.log(` File updated, archiving old backup to ${path.basename(oldBackupPath)}`);
fs.renameSync(backupPath, oldBackupPath);
fs.copyFileSync(clientPath, backupPath);
return backupPath;
}
console.log(' Backup already exists');
return backupPath;
}
/**
* Restore the original client binary from backup
*/
restoreClient(clientPath) {
const backupPath = clientPath + '.original';
if (fs.existsSync(backupPath)) {
fs.copyFileSync(backupPath, clientPath);
const patchFlagFile = clientPath + this.patchedFlag;
if (fs.existsSync(patchFlagFile)) {
fs.unlinkSync(patchFlagFile);
}
console.log('Client restored from backup');
return true;
}
console.log('No backup found to restore');
return false;
}
/**
* Patch the client binary to use the custom domain
*/
async patchClient(clientPath, progressCallback) {
const newDomain = this.getNewDomain();
const strategy = this.getDomainStrategy(newDomain);
console.log('=== Client Patcher v2.1 ===');
console.log(`Target: ${clientPath}`);
console.log(`Domain: ${newDomain} (${newDomain.length} chars)`);
console.log(`Mode: ${strategy.mode}`);
if (!fs.existsSync(clientPath)) {
const error = `Client binary not found: ${clientPath}`;
console.error(error);
return { success: false, error };
}
if (this.isPatchedAlready(clientPath)) {
console.log(`Client already patched for ${newDomain}, skipping`);
if (progressCallback) progressCallback('Client already patched', 100);
return { success: true, alreadyPatched: true, patchCount: 0 };
}
if (progressCallback) progressCallback('Preparing to patch client...', 10);
console.log('Creating backup...');
this.backupClient(clientPath);
if (progressCallback) progressCallback('Reading client binary...', 20);
console.log('Reading client binary...');
const data = fs.readFileSync(clientPath);
console.log(`Binary size: ${(data.length / 1024 / 1024).toFixed(2)} MB`);
if (progressCallback) progressCallback('Patching domain references...', 50);
console.log('Applying domain patches...');
const { buffer: patchedData, count } = this.applyDomainPatches(data, newDomain);
if (count === 0) {
// Try legacy UTF-16LE format
console.log('No occurrences found - trying legacy UTF-16LE format...');
const legacyResult = this.findAndReplaceDomainSmart(data, ORIGINAL_DOMAIN, strategy.mainDomain);
if (legacyResult.count > 0) {
console.log(`Found ${legacyResult.count} occurrences with legacy format`);
fs.writeFileSync(clientPath, legacyResult.buffer);
this.markAsPatched(clientPath);
return { success: true, patchCount: legacyResult.count, format: 'legacy' };
}
console.log('No occurrences found - binary may already be modified');
return { success: true, patchCount: 0, warning: 'No occurrences found' };
}
if (progressCallback) progressCallback('Writing patched binary...', 80);
console.log('Writing patched binary...');
fs.writeFileSync(clientPath, patchedData);
this.markAsPatched(clientPath);
if (progressCallback) progressCallback('Patching complete', 100);
console.log(`Successfully patched ${count} occurrences`);
console.log('=== Patching Complete ===');
return { success: true, patchCount: count };
}
/**
* Patch the server JAR by downloading pre-patched version
*/
async patchServer(serverPath, progressCallback, javaPath = null) {
const newDomain = this.getNewDomain();
console.log('=== Server Patcher ===');
console.log(`Target: ${serverPath}`);
console.log(`Domain: ${newDomain}`);
if (!fs.existsSync(serverPath)) {
const error = `Server JAR not found: ${serverPath}`;
console.error(error);
return { success: false, error };
}
const patchFlagFile = serverPath + '.dualauth_patched';
if (fs.existsSync(patchFlagFile)) {
try {
const flagData = JSON.parse(fs.readFileSync(patchFlagFile, 'utf8'));
if (flagData.domain === newDomain) {
console.log(`Server already patched for ${newDomain}, skipping`);
if (progressCallback) progressCallback('Server already patched', 100);
return { success: true, alreadyPatched: true };
}
} catch (e) {
// Re-patch
}
}
if (progressCallback) progressCallback('Creating backup...', 10);
console.log('Creating backup...');
this.backupClient(serverPath);
if (progressCallback) progressCallback('Downloading patched server JAR...', 30);
console.log('Downloading pre-patched HytaleServer.jar');
try {
const https = require('https');
const url = 'https://pub-027b315ece074e2e891002ca38384792.r2.dev/HytaleServer.jar';
await new Promise((resolve, reject) => {
const handleResponse = (response) => {
if (response.statusCode === 302 || response.statusCode === 301) {
https.get(response.headers.location, handleResponse).on('error', reject);
return;
}
if (response.statusCode !== 200) {
reject(new Error(`HTTP ${response.statusCode}`));
return;
}
const file = fs.createWriteStream(serverPath);
const totalSize = parseInt(response.headers['content-length'], 10);
let downloaded = 0;
response.on('data', (chunk) => {
downloaded += chunk.length;
if (progressCallback && totalSize) {
const percent = 30 + Math.floor((downloaded / totalSize) * 60);
progressCallback(`Downloading... ${(downloaded / 1024 / 1024).toFixed(2)} MB`, percent);
}
});
response.pipe(file);
file.on('finish', () => {
file.close();
resolve();
});
};
https.get(url, handleResponse).on('error', (err) => {
fs.unlink(serverPath, () => {});
reject(err);
});
});
console.log(' Download successful');
fs.writeFileSync(patchFlagFile, JSON.stringify({
domain: newDomain,
patchedAt: new Date().toISOString(),
patcher: 'PrePatchedDownload',
source: url
}));
if (progressCallback) progressCallback('Server patching complete', 100);
console.log('=== Server Patching Complete ===');
return { success: true, patchCount: 1 };
} catch (downloadError) {
console.error(`Failed to download patched JAR: ${downloadError.message}`);
const backupPath = serverPath + '.original';
if (fs.existsSync(backupPath)) {
fs.copyFileSync(backupPath, serverPath);
console.log('Restored backup after download failure');
}
return { success: false, error: `Failed to download patched server: ${downloadError.message}` };
}
}
/**
* Find Java executable
*/
findJava() {
try {
const bundled = getBundledJavaPath(JRE_DIR);
if (bundled && fs.existsSync(bundled)) {
return bundled;
}
} catch (e) {}
try {
const javaExec = getJavaExec(JRE_DIR);
if (javaExec && fs.existsSync(javaExec)) {
return javaExec;
}
} catch (e) {}
if (process.env.JAVA_HOME) {
const javaBin = path.join(process.env.JAVA_HOME, 'bin', process.platform === 'win32' ? 'java.exe' : 'java');
if (fs.existsSync(javaBin)) {
return javaBin;
}
}
try {
execSync('java -version 2>&1', { encoding: 'utf8' });
return 'java';
} catch (e) {}
return null;
}
/**
* Find the client binary path based on platform
*/
findClientPath(gameDir) {
const candidates = [];
if (process.platform === 'darwin') {
candidates.push(path.join(gameDir, 'Client', 'Hytale.app', 'Contents', 'MacOS', 'HytaleClient'));
candidates.push(path.join(gameDir, 'Client', 'HytaleClient'));
} else if (process.platform === 'win32') {
candidates.push(path.join(gameDir, 'Client', 'HytaleClient.exe'));
} else {
candidates.push(path.join(gameDir, 'Client', 'HytaleClient'));
}
for (const candidate of candidates) {
if (fs.existsSync(candidate)) {
return candidate;
}
}
return null;
}
findServerPath(gameDir) {
const candidates = [
path.join(gameDir, 'Server', 'HytaleServer.jar'),
path.join(gameDir, 'Server', 'server.jar')
];
for (const candidate of candidates) {
if (fs.existsSync(candidate)) {
return candidate;
}
}
return null;
}
/**
* Ensure both client and server are patched before launching
*/
async ensureClientPatched(gameDir, progressCallback, javaPath = null) {
const results = {
client: null,
server: null,
success: true
};
const clientPath = this.findClientPath(gameDir);
if (clientPath) {
if (progressCallback) progressCallback('Patching client binary...', 10);
results.client = await this.patchClient(clientPath, (msg, pct) => {
if (progressCallback) progressCallback(`Client: ${msg}`, pct ? pct / 2 : null);
});
} else {
console.warn('Could not find HytaleClient binary');
results.client = { success: false, error: 'Client binary not found' };
}
const serverPath = this.findServerPath(gameDir);
if (serverPath) {
if (progressCallback) progressCallback('Patching server JAR...', 50);
results.server = await this.patchServer(serverPath, (msg, pct) => {
if (progressCallback) progressCallback(`Server: ${msg}`, pct ? 50 + pct / 2 : null);
}, javaPath);
} else {
console.warn('Could not find HytaleServer.jar');
results.server = { success: false, error: 'Server JAR not found' };
}
results.success = (results.client && results.client.success) || (results.server && results.server.success);
results.alreadyPatched = (results.client && results.client.alreadyPatched) && (results.server && results.server.alreadyPatched);
results.patchCount = (results.client ? results.client.patchCount || 0 : 0) + (results.server ? results.server.patchCount || 0 : 0);
if (progressCallback) progressCallback('Patching complete', 100);
return results;
}
}
module.exports = new ClientPatcher();