
先说个我自己也没想到的场景。我平时的工作是写PHP接口有一天产品拉了个需求客户想在自己手机上装我们的测试App但既不能发到App Store审核周期太长也没法让每个人都跑到办公室连电脑装Xcode。于是就有了这个项目——SignApp签名工具一个挂在后端PHP服务里的iOS在线签名平台。用户把IPA包传上来选一个证书几分钟后就能拿到一个链接用iPhone自带的Safari点开应用直接装进手机。当时我连描述文件是什么都不知道后来它成了这个项目里坑最深、也最关键的一环。这篇文章不打算写得太“教学”我想把从零搭这样一个在线签名工具的全过程记录下来后端怎么选型、重签名到底做了什么、安装分发链路怎么打通以及上线半年后我才真正理解的几个坑。如果你也是做Web开发的或者正准备给公司内部搞一个应用分发工具这篇应该对你有用。1. SignApp要解决的真实问题从“装不上包”到“在线签名分发”1.1 需求场景还原美淘这边的情况其实很典型客户端版本迭代特别快经常一周出好几个测试包。产品和销售要拿最新的包给客户演示客户也得在真机上用一段时间才能给反馈。最早期我们用最原始的方式——把IPA包放到网盘客户自己下载再拿数据线连电脑用Xcode或者第三方工具装进手机。这套流程很快就撑不住了。客户不是每个都会用Xcode家里那台Windows也不一定能装对驱动更别提每次都要经历“下载、解压、信任开发者、拖拽安装”这一大串操作。后来我们试过蒲公英和TestFlight但都有点别扭蒲公英对分发数量和有效期有限制TestFlight又要走App Store Connect的审核流程一个临时测试包根本等不起。于是决定自己做一个在线签名工具让整个过程变成这样管理员在后台把IPA传上去选好证书和描述文件点击签名。系统跑完签名流程后生成一个链接客户手机浏览器打开链接、点击安装应用直接出现在桌面上。这个工具内部就叫SignApp。1.2 “在线签名”到底是在签什么很多Web端同事第一次听到“iOS签名”时第一反应是“给App写个许可证”。实际上完全不是这回事。iOS系统为了保证安全规定所有跑在真机上的App都必须经过Apple信任的证书签名。没有有效签名的包iPhone会直接拒绝安装更别说运行了。分发测试包用的是一套“企业分发”机制——开发者用Apple开发者账号生成企业证书Certificate和描述文件Provisioning Profile然后给IPA包签名签名后的包可以安装到描述文件允许的设备上。所以在线签名工具做的事本质上非常简单把一个IPA包里的旧签名信息替换成我们自己的证书和描述文件对应的一套新签名信息。App的代码和功能基本不变但手机的信任体系认这套新签名了于是就能装。1.3 为什么后端选了PHP团队里其他后端代码基本都是PHP写的我们没有人专职搞过iOS工具链。如果硬要引一个Go或者Node的服务进来运维成本、部署成本、团队学习成本都会往上涨。而且这类工具的后端本质上是一个“调度系统”接收上传、管理任务队列、调用系统命令去解压和重签名、再把结果回传给前端。真正的CPU密集型工作都发生在codesign和zip这些系统级工具里PHP在这里只是“胶水”完全够用。如果从零开始选型Go或者Python可能更“优雅”。但你让我现在用跑的这套项目来说PHP配合CLI模式处理这类任务没有任何瓶颈反倒是很多人担心的“PHP性能不够”在真实场景里根本不成立——几百兆的IPA包瓶颈在磁盘IO和签名进程不在语言本身。2. 重签名链路拆解PHP只是调度员真正干活的是系统命令行2.1 签名身份证书、私钥和描述文件的三件套在线签名要跑起来服务器上必须先有一整套“签名身份”。这套东西从上往下分三层私钥和证书P12这就是你的数字身份用Apple开发者账号在Certificates页面生成导出成.p12格式里面有私钥和证书。签名身份Signing Identity证书导入到Mac钥匙串之后系统会把它识别成一个身份比如iPhone Distribution: Meitao (XXXXXXXXXX)。描述文件.mobileprovision一个打包好的配置文件里面声明了三个关键信息这个包允许的Bundle ID列表、允许安装的设备UDID列表、以及签名的entitlements权限。这三样缺一样都不行。证书过期了签出来的包装不上描述文件里没有用户的设备UDID装的时候会报“未受信任”Bundle ID对不上手机同样拒绝安装。P12导入钥匙串这一步是在服务器上完成的用的命令是security import cert.p12 -k ~/Library/Keychains/login.keychain-db -P 证书密码 -T /usr/bin/codesign -T /usr/bin/security这里有个容易忽略的点-T参数是把codesign和security加入允许访问该私钥的应用名单。不加的话后面PHP调用codesign时可能会弹出GUI授权框而服务器上根本没有界面弹签名就会卡住。P12导入之后我习惯用PHP的OpenSSL扩展去读取证书信息做校验$p12 file_get_contents($p12Path); openssl_pkcs12_read($p12, $certs, $password); $cert openssl_x509_parse($certs[cert]); // $cert[validFrom_time_t] / $cert[validTo_time_t] 就是证书有效期 // $cert[subject][CN] 就是签名身份的名称这样在后台证书列表里就能提前展示“还有多少天过期”后面实在帮了我大忙。2.2 重签名的五个核心操作IPA本质上是一个zip压缩包里面有一个Payload/目录放着真正的.app应用包。重签名说白了就是五步# 1. 解压 unzip -q original.ipa -d workdir/ # 2. 删除旧的签名信息 rm -rf workdir/Payload/YourApp.app/_CodeSignature # 3. 把新的描述文件复制进应用包 cp embedded.mobileprovision workdir/Payload/YourApp.app/embedded.mobileprovision # 4. 用指定身份重新签名 codesign -f -s iPhone Distribution: Meitao (XXXXXXXXXX) --entitlements ent.plist workdir/Payload/YourApp.app # 5. 重新打包 cd workdir/ zip -qry signed.ipa Payload/每一步背后都有具体的意图我拆开说。第1步解压没太多好讲的但要注意解压目标目录必须是干净的否则上次残留的Payload/会混进来最后打出来的包体积会莫名其妙变大。第2步删除_CodeSignature是整个流程的关键。这个目录里存的是旧开发者的签名哈希。如果不删掉直接签codesign会报错或者签出来的包依然带旧签名信息安装时会出各种诡异问题。第3步把描述文件放进应用包。iOS安装时主要就是看这个embedded.mobileprovision来判断“这个包是谁签的、允许哪些设备装”。注意文件名必须是这个固定名字大小写也不能错。第4步是真正的签名动作。-f是强制替换已存在的签名-s指定签名身份--entitlements指定权限声明文件。这个entitlements文件不是随手写的后面单独说。第5步重新打包成IPAzip命令必须在workdir/目录下执行这样zip里的第一层路径是Payload/而不是workdir/Payload/否则安装时系统找不到应用。2.3 entitlements文件怎么来这是我在重签名过程中踩过的最莫名其妙的坑之一。entitlements记录了App申请的权限比如推送、钥匙串、App Groups等。如果新证书的描述文件不支持这些权限或者你干脆没写这个文件签名后的包可能在安装后闪退或者在启动时被系统杀掉。正确的做法是从描述文件里提取而不是手写一堆权限security cms -D -i embedded.mobileprovision profile.plist # 用 PlistBuddy 把 Entitlements 节点单独导出来 /usr/libexec/PlistBuddy -x -c Print:Entitlements profile.plist ent.plist用描述文件自带的entitlements来签名可以保证权限声明和证书匹配基本上不会出现“签名成功了但装完闪退”的情况。还有一种做法是从原包的签名里提取entitlementscodesign -d --entitlements :- workdir/Payload/YourApp.app 2/dev/null old-ent.plist不过我更推荐前者因为描述文件里的才是最权威的。2.4 用PHP调用命令行工具的边界PHP调用系统命令的方式有几种最常见的三个exec($cmd)只拿最后一行输出shell_exec($cmd)拿全部输出proc_open()能实时拿stdout和stderr还能传入环境变量重签名这种耗时操作我强烈建议用proc_open。因为你在CLI worker里跑签名任务时需要把每行日志写进任务日志文件签名失败的时候能回溯到具体是哪一步出了问题。另外所有传给shell的参数必须用escapeshellarg()包一层。签名任务的IPA文件名很可能是用户上传时自己起的中文、空格、括号都可能出现。不转义的话轻则命令执行错误重则被注入额外命令。这一点在PHP里做Web开发时很多同事容易忽略但在直接操作shell的场景里是生死线。还有一个细节codesign本身对路径里的空格是敏感的如果路径里有空格哪怕用双引号包住有时候也会有意外。我在代码里统一把工作目录设置成/tmp/sign/{任务ID}/再配合chdir()去操作不从用户给的文件名直接拼路径这样能省掉一半的路径问题。3. 安装分发的关键一跳manifest描述文件和itms-services协议3.1 签名完成后用户怎么把App装进手机签名完成了IPA也生成好了这只是第一步。最麻烦的是怎么让用户用手机直接安装。iOS不像Android那样可以直接下载APK文件点击安装。手机上想要通过网页安装一个企业应用必须走一条Apple定死的路先下载一个manifest.plist文件再由这个plist文件通过itms-services://协议唤起系统安装器。manifest.plist长这样?xml version1.0 encodingUTF-8? !DOCTYPE plist PUBLIC -//Apple//DTD PLIST 1.0//EN http://www.apple.com/DTDs/PropertyList-1.0.dtd plist version1.0 dict keyitems/key array dict keymetadata/key dict keybundle-identifier/key stringcom.meitao.app/string keykind/key stringsoftware/string keytitle/key string美淘/string /dict keyassets/key array dict keykind/key stringsoftware-package/string keyurl/key stringhttps://cdn.meitao.example.com/packages/signed_20240612.ipa/string /dict /array /dict /array /dict /plist每个字段都别改动。bundle-identifier必须和IPA包里的Bundle ID一致不一致时手机会提示“无法安装”。title是安装时显示的名字。url必须指向HTTPS地址这是硬性要求——iOS只允许HTTPS加载安装包。然后前端给用户的一个按钮它的href写成a hrefitms-services://?actiondownload-manifesturlhttps%3A%2F%2Fmeitao.example.com%2Fmanifests%2Ftask_1024.plist点击安装/a这里的itms-services://是Apple注册的私有协议Safari识别到以后会弹出“是否安装”的确认框。3.2 整个安装链路的真实顺序装一个包的完整过程排查问题时百分之八十的人都理不清用户用Safari打开安装页点击“安装”按钮。Safari请求manifest.plist。iOS系统读取plist拿到software-package的URL。系统自动下载IPA包。系统检查签名、描述文件和设备UDID。全部通过后桌面出现App图标并开始安装。这意味着我在后端必须保证两件事manifest和IPA的URL都要能公网访问且必须是HTTPSIPA的域名还必须支持直接下载不能有跳转登录之类的动作。我这里遇到过客户拿到的链接是HTTP的浏览器打开后进度条一直转就是装不上。换成HTTPS后立刻好了。另一个坑是如果用户手机上已经装了同Bundle ID的应用新装这个会把旧的覆盖掉但一般不会提示“卸载再装”。真正需要让用户先卸载旧包的情况是旧包是用另一个证书签的Bundle ID相同但签名身份不同。iOS会给出一个很奇怪的提示“无法安装此App因为它的签名不受信任”然后你不得不写一个“如果已安装过旧版本请先卸载”的说明。3.3 新设备的UDID和iOS 16的开发者模式企业分发的描述文件里有一份允许安装的设备UDID白名单。新用户的设备如果没有在这个名单里就算签名成功、链接也没问题最后一步依然会安装失败。所以SignApp后台必须要有一套“获取用户UDID”的流程。最通用的做法是生成一个.mobileconfig描述文件下发用户用Safari打开这个文件系统会提示“此网站想要下载一个配置描述文件”装完后系统会把设备的UDID通过回调传到我们指定的URL。keyPayloadContent/key dict keyURL/key stringhttps://meitao.example.com/api/udid/callback/string keyDeviceAttributes/key array stringUDID/string stringIMEI/string stringVERSION/string /array /dict keyPayloadType/key stringProfile Service/string回调URL收到UDID后后台自动把设备加进描述文件的设备列表里下一次重新签名生成的包才能覆盖这台设备。另外iOS 16之后设备安装企业自签应用前还会卡一道“开发者模式”。用户在设置里把开发者模式打开否则安装过程会一直停在“正在安装”的状态。这个提示有时候不太显眼我后来直接在安装页面放了一句“如果安装失败请检查设置-隐私与安全性-开发者模式是否已开启”。4. 从“能跑”到“能上线”任务队列、状态机和并发控制4.1 为什么不能直接在PHP-FPM请求里同步签名第一版我确实想省事用户提交签名请求后直接在请求里同步执行解压、签名、打包、返回链接。结果很快就出了问题。签名一个60MB的App在服务器上跑完整流程大概需要40秒到1分钟。PHP-FPM并发能力本来就不高几个签名任务同时进来worker进程全被占满了。前端请求的响应时间无所谓但签名请求执行到一半时用户可能已经关掉了浏览器——他的TCP连接断了但后端脚本还在跑。问题是Nginx和PHP-FPM的配置默认会有一个超时时间超过以后worker直接被杀掉任务就落了半个包在工作目录里下次再来一个同任务ID的请求就会拿到一个损坏的中间产物。所以第二个版本改成Web请求只负责“接单”把任务写入数据库实际签名过程由独立的CLI进程去消费。4.2 一个可落地的任务表和状态机设计任务表我设计了这些字段字段类型说明idint自增主键task_novarchar对外的任务编号前端轮询用ipa_pathvarchar原始IPA存储路径cert_idint使用的证书IDstatustinyint0待处理 1处理中 2成功 3失败error_msgvarchar失败原因retry_countint重试次数extratext签名参数和产物信息created_atdatetime创建时间状态流转很简单pending - processing - signed / failed。失败后可以重试重试走回pending。CLI worker是个常驻进程用supervisor守护循环逻辑大概是while true; do php /var/www/signapp/worker.php sleep 2 doneworker.php里做的事情就是查一条status0的任务加锁改成processing然后调签名函数。前端拿任务编号去轮询/api/task/{task_no}拿到signed就展示安装链接。这个方案虽然土但极其稳定。4.3 同一证书并发签名时的互斥问题证书和私钥是全局唯一的资源。两个任务同时用同一个证书去签名第二个codesign可能会因为钥匙串访问冲突直接报errSecInternalComponent或者签名结果异常。解决方式很简单按证书维度加文件锁同一把证书同时只允许一个签名流程跑。$lockFile /tmp/sign_{$certId}.lock; $fp fopen($lockFile, w); if (!flock($fp, LOCK_EX)) { throw new Exception(签名服务繁忙请稍后重试); } try { // 执行签名 } finally { flock($fp, LOCK_UN); fclose($fp); }这样一来即使有多个worker进程在跑同一证书的任务也会被自然串行。不同证书的任务则可以并行互不干扰。线上跑下来很稳。4.4 日志签名任务排错的地基Worker里每做一步都要往任务日志里写一行。我甚至把环境变量、签名身份、描述文件路径都打印出来。因为签名失败的时候如果只有一句codesign: errSecInternalComponent你根本不知道是钥匙串没解锁还是证书权限没给。有了完整日志排查通常五分钟内能定位。5. 上线半年后真正把我坑到怀疑人生的几件事5.1 证书过期没有预警一场安静的事故有一次客户反馈说“安装完打开闪退”我检查了签名状态显示全部成功。查了很久才发现证书过期了。证书过期这个事很坑。它不像登录失效那样会明确报错而是签名流程照常走zip也正常打二维码也能扫iPhone上也显示“正在安装”但装完之后立刻闪退或者干脆安装失败。而且描述文件的签名关系又是另一套有效期很多人只盯着证书忘了profile也会过期。从那以后我在证书管理页面加了过期倒计时每天定时任务检查证书和描述文件的过期时间提前7天在后台和钉钉群里告警。实现起来不复杂就是上面提到过的用OpenSSL读取证书信息。5.2 errSecInternalComponent90%的人都会卡一晚的错误我第一次从命令行手动签名完全正常但同样的命令放到PHP里就报codesign_allocate: cant create output file /usr/bin/codesign: errSecInternalComponent排查过程特别折磨。后来才明白PHP-FPM和CLI运行时的用户环境不一样FPM服务跑在_www用户下这个用户没有权限访问登录钥匙串也没权限解锁钥匙串。解决方法是把签名过程全部放到CLI worker里跑再用当前用户的钥匙串。同时在导入P12时用-T /usr/bin/codesign把codesign加入允许访问列表。如果还是不行就手动执行一次钥匙串解锁security unlock-keychain -p 你的登录密码 ~/Library/Keychains/login.keychain-db但要注意这个命令即使写了重启后又会锁上。更稳的做法是给签名任务单独建一个keychain文件导入临时keychain在worker启动时解锁一次避免影响系统主钥匙串。我后来就是这么做签名这块再也没出过这类问题。5.3 包含Frameworks和插件包的App只签主工程是假成功后来有同事上传了一个带多个framework的App用上面的五步流程签名提示成功下载安装也成功了但一启动就崩。后来才发现虽然iOS系统安装时只看主工程的签名但主工程里嵌的Frameworks和App Extensions每个都需要单独签名而且它们的签名身份必须和主工程一致。正确做法是对Frameworks/*和PlugIns/*.appex都跑一遍codesign。我习惯用循环find workdir/Payload/YourApp.app/Frameworks -name *.framework -exec codesign -f -s iPhone Distribution: Meitao (XXXXXXXXXX) {} \; find workdir/Payload/YourApp.app/PlugIns -name *.appex -exec codesign -f -s iPhone Distribution: Meitao (XXXXXXXXXX) {} \;如果原包里用了swift-support这类支持库也需要一并处理。判断原包有没有额外二进制很直接解压后看Frameworks和PlugIns目录是否为空不为空就要签。5.4 原文包被“加固”过之后解压目录不干净有些第三方平台会对自己生成的IPA做特殊处理比如包内多了__MACOSX目录、符号链接或者隐藏文件。直接强制覆盖签名有时候会提示找不到主工程或者签名完发现包多了一堆垃圾文件。我在每次解压后会先看目录结构ls -la workdir/Payload/如果解压出来的结构和你预期的完全不一致就要先判断是文件损坏还是被特殊处理过。尽量不要直接用rm -rf Payload——先看清楚里面到底是什么再决定清理路径。这个习惯让我避免了好几次把生产数据误删的事故。反正不搞清楚包里面有什么就永远不要直接删。写在最后的几条经验做SignApp这个工具技术上确实不复杂它就是一个把“系统命令”串起来的调度器。真正花时间的不是写代码而是理解iOS这套信任体系——证书、描述文件、UDID、itms-services协议每一层都有它自己的规则和过期时间。你漏掉任何一个环节用户看到的结果都是同一个装不上。如果你也准备做类似的工具我个人的建议是先把两件事做扎实第一证书和描述文件的生命周期管理该告警就告警别等到用户发现闪退第二所有签名过程的日志要完整任务失败时能一路追到是哪一步出的问题。这两件事做好了这个工具才算真正“能上线”。最后再分享一个我保留到现在的习惯每次签名任务结束我会把原始包、签名后包的sha256和日志一起存档。等哪天真有客户说“装了这个包有问题”我能五分钟之内定位到他到底装的是哪一个版本、哪个证书、哪个环节。排查安装失败的效率全靠这些看似不起眼的痕迹。