简介这是一份用于开源网络电话客户端图片消息本地中转的服务端脚本代码可替代官方转发服务解决局域网或私有服务器中脱离外网使用的问题。代码重写了原始中转逻辑涵盖图片上传格式与容量校验、图片转码优化、消息会话中继、身份验证和日志记录等关键环节。资源以zip压缩包装载仅含一个脚本文件大小2KB部署简单、无额外依赖。已有294人学习下载适合具备基础服务端开发能力的读者快速应用。文件虽小但可直接放到服务器对应目录修改配置就能实现图片消息在内部网络中安全接力也可作为二次开发模板用于扩展API接口、调整并发策略或对接用户体系对需要构建安全可控内部通信环境的团队而言这份精简实现能明显减少协议对接和排错成本是私有化部署的一项实用参考。1. 从一条 Linphone 图片消息说起lft.php 到底在中间干了什么前阵子帮某公司接内部话务系统用户从 Linphone 客户端发消息、顺手甩了一张现场照片业务组希望这条消息自动落到工单系统里。问题是 SIP 网关在一个内网段工单系统在另一个内网段两边谁都不愿意给对方开双向端口。折腾到最后解决方式不是改网关也不是上重量级消息中间件而是加了一个极轻量的 PHP 中转脚本 lft.php网关把消息用 JSON 推过来脚本验签、判断是文本还是图片、图片做安全下载和格式校验再转发给目标端最后把回执返回给网关。它适合这几类人维护 Linphone/SIP 消息链路、想把 SIP 消息桥接到 IM 机器人或工单接口或者只是想看一个单文件 PHP 中间件怎么把“验签、下载、转发、回执”串起来。下文这份源码笔记会完整拆开它的结构和跑法。2. 项目骨架与消息模型单文件入口、配置分离、三种数据流2.1 目录结构核心代码不到 300 行日志与配置分开这个中转服务的目录结构很小核心就两个 PHP 文件lft-demo/ ├── config.php # 鉴权、白名单、目标端地址、超时参数 ├── lft.php # 中转入口验签、路由、下载、转发、回执 ├── logs/ # 运行日志目录按天拆分 └── tests/ ├── mock_text.json # 文本消息样例 ├── mock_image.json # 图片消息样例 └── last_request.json # 联调时临时生成看目标端实际收到的内容config.php 和 lft.php 分开是刻意的。中转服务的鉴权密钥、目标端 Webhook、图片大小限制这些属于环境相关配置如果写死在入口文件里每次部署到新环境都要改业务代码容易把验签逻辑改坏。所以这里沿用常见做法配置文件只负责提供参数业务文件只负责读参数。文件职责改动频率config.phpapp_id/app_secret、时间窗口、图片 MIME 白名单、目标端 URL 与 Token每次部署都可能改lft.php验签、路由、图片安全下载、转发、统一响应只在加功能时改logs/请求摘要和错误记录只读tests/联调用 JSON 样例与回显脚本调试时改2.2 消息流转模型文本、图片、回执三条主链路lft.php 里一共有三条消息链路在跑对应 SIP 侧最常见的三种事件。链路一是纯文本消息网关 POST 一个 JSON 过来字段里带 from、to、msg_typetext 和 content。lft.php 验签通过后直接把 content 透传给 target_webhook目标端返回 2xx 就算成功lft.php 再给网关返回 code0 的回执。链路二是图片消息网关给的不是图片二进制而是一个图片 URL。因为 SIP 网关和 lft.php 之间走的是 HTTP JSON传二进制要么用 base64 要么用 multipart都不如传一个 URL 干净。lft.php 拿到 URL 之后先去拉图片检查文件大小和 MIME 类型再转换成 base64 塞给目标端。这条链路里最容易翻车的是 URL 指向内网、图片伪装成合法后缀、或者目标端响应超时后面第 3 章和第 5 章会重点展开。链路三是回执类事件比如已读回执、正在输入。这类事件不涉及业务数据转发lft.php 只把它们记录到日志然后直接返回成功。为什么还要走一遍验签因为如果网关上配置了消息回执推送不处理就会一直重试日志会被刷爆。单独占一个分支既能让网关安心又不会污染下游业务数据。2.3 接口约定与鉴权设计请求字段、响应码、签名算法lft.php 对外只暴露一个 POST 接口Content-Type 统一为 application/json请求体是一个扁平 JSON字段如下字段类型必填说明fromstring是SIP 账号例如 sip:1001sip.exampletostring是接收方例如 sip:2002sip.examplemsg_idstring是消息唯一 ID用来做幂等和日志关联msg_typestring是text / image / readcontentstring是文本消息内容或图片 URLtsint否业务侧时间仅用于日志响应体也固定成统一结构方便网关那边配置解析{ code: 0, msg: ok, data: {} }code 非 0 时网关会按 SIP 侧的失败策略重试所以 lft.php 内部所有异常分支都必须返回非 0 code不能吞异常。鉴权部分用的是请求头签名网关在推送时带三个头X-Timestamp、X-Nonce、X-Sign。签名串拼接规则是 app_id timestamp nonce 原始请求体然后用 HMAC-SHA256 计算密钥是 config.php 里的 app_secret。这里强调“原始请求体”是因为如果先把 JSON 解析成数组再重新编码字段顺序、中文转义都可能和网关侧不一致签名必挂。这个拆解会在第 5.1 节的坑里看到完整教训。3. 核心模块拆解从请求进入到目标端返回的完整链路3.1 入口鉴权与路由分发先验签名再进业务lft.php 入口的前半段只干两件事验签、识别消息类型。代码如下?php // lft.php 入口只处理 POST application/json require __DIR__ . /config.php; // 过滤非 POST 请求 if ($_SERVER[REQUEST_METHOD] ! POST) { http_response_code(405); exit(json_encode([code 405, msg method not allowed])); } $body file_get_contents(php://input); if (empty($body)) { http_response_code(400); exit(json_encode([code 400, msg empty body])); } // 从请求头取时间戳、随机串和签名 $ts $_SERVER[HTTP_X_TIMESTAMP] ?? ; $nonce $_SERVER[HTTP_X_NONCE] ?? ; $sign $_SERVER[HTTP_X_SIGN] ?? ; // 时间窗口校验默认 300 秒防止重放 if (abs(time() - intval($ts)) $cfg[time_window]) { http_response_code(401); exit(json_encode([code 401, msg timestamp expired])); } // 签名串app_id timestamp nonce 原始请求体 $signStr $cfg[app_id] . $ts . $nonce . $body; $expect hash_hmac(sha256, $signStr, $cfg[app_secret]); if (!hash_equals($expect, strtolower($sign))) { http_response_code(401); exit(json_encode([code 401, msg invalid sign])); } $msg json_decode($body, true); if (!is_array($msg)) { http_response_code(400); exit(json_encode([code 400, msg invalid json])); } // 路由分发text / image / read 三条链路 if ($msg[msg_type] text) { $payload build_text_payload($msg); $resp push_to_target($cfg, $payload); } elseif ($msg[msg_type] image) { list($ok, $imageData) fetch_image_safely($cfg, $msg[content]); if (!$ok) { $resp [code 502, msg $imageData]; } else { $payload build_image_payload($msg, $imageData); $resp push_to_target($cfg, $payload); } } else { // read 等回执事件只记日志不转发 log_line($cfg, RECEIPT, json_encode($msg, JSON_UNESCAPED_UNICODE)); $resp [code 0, msg ok]; } http_response_code(200); echo json_encode($resp);几个关键点解释一下。时间窗口用的是abs(time() - intval($ts))如果网关侧时钟跑偏超过 300 秒请求会被拒掉这是防重放的第一道闸。签名比较用hash_equals不是避免时序攻击。strtolower($sign)是为了兼容网关侧签名串可能输出大写十六进制的情况。路由分发没有用框架的路由表而是三个 if 分支因为这个项目只需要处理三种消息类型。如果后续要扩展文件消息、语音消息建议按第 6.2 节的适配器模式重构而不是继续加 else if。3.2 图片安全下载与伪装文件拦截finfo curl 双保险图片链路是整个中转服务风险最高的地方因为 lft.php 要主动去访问一个网络 URL处理不好就是 SSRF 的入口。这里的思路是协议白名单、内网段拦截、限制跳转次数、结束后再做 MIME 校验。function fetch_image_safely($cfg, $url) { // 只允许 http/https避免 file:// 等协议绕过 $parts parse_url($url); $scheme strtolower($parts[scheme] ?? ); if (!in_array($scheme, [http, https], true)) { return [false, bad scheme]; } // 默认禁止访问内网地址防止 SSRF if (!$cfg[allow_private]) { $ip gethostbyname($parts[host]); if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) false) { return [false, private ip blocked]; } } $ch curl_init($url); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER true, CURLOPT_FOLLOWLOCATION true, CURLOPT_MAXREDIRS 3, CURLOPT_CONNECTTIMEOUT_MS 5000, CURLOPT_TIMEOUT_MS 15000, CURLOPT_HTTPHEADER [User-Agent: lft-middleware/1.0], CURLOPT_SSL_VERIFYPEER true, ]); $data curl_exec($ch); $errno curl_errno($ch); $httpCode curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($errno 0) { return [false, curl errno . $errno]; } if ($httpCode ! 200) { return [false, http . $httpCode]; } if (strlen($data) $cfg[max_file_size]) { return [false, file too large]; } // 用文件头判断真实类型而不是信 Content-Type $finfo finfo_open(FILEINFO_MIME_TYPE); $mime finfo_buffer($finfo, $data); finfo_close($finfo); if (!in_array($mime, $cfg[allowed_mime], true)) { return [false, mime not allowed: . $mime]; } return [true, $data]; }这段代码里的每个参数都有讲究。CURLOPT_FOLLOWLOCATION 允许网关返回的图片 URL 发生跳转但 MAXREDIRS 限制为 3 次防止恶意跳转链把请求引到内网。CURLOPT_CONNECTTIMEOUT_MS 是连接超时CURLOPT_TIMEOUT_MS 是总超时分别设 5 秒和 15 秒避免某个图片 URL 永远不响应时把 PHP-FPM 进程占死。MIME 校验用 finfo 读文件头而不是看 HTTP 头的 Content-Type是因为图片服务完全可能返回错的 Content-Type。比如某些对象存储对 .png 文件返回 application/octet-stream光看响应头会把正常业务误杀反过来一个伪装成 .jpg 的 PHP 脚本它的文件头是文本类型finfo 一眼就能识别出来。关于内网段拦截gethostbyname只解析第一个 A 记录如果对方域名做了多 IP 轮询会漏掉后面的内网地址。严谨项目的做法是用dns_get_record遍历所有记录再逐个检查我一般会在联调后补上这一层。3.3 目标端推送与统一响应JSON 透传、超时和错误码设计图片校验通过后下一步是推送给目标端。这里封装了一个通用的push_to_target文本和图片都走它区别只在 payload 结构。function push_to_target($cfg, $payload) { $ch curl_init($cfg[target_webhook]); curl_setopt_array($ch, [ CURLOPT_POST true, CURLOPT_POSTFIELDS json_encode($payload, JSON_UNESCAPED_UNICODE), CURLOPT_HTTPHEADER [ Content-Type: application/json; charsetutf-8, Authorization: Bearer . $cfg[target_token], ], CURLOPT_RETURNTRANSFER true, CURLOPT_TIMEOUT_MS 10000, ]); $resp curl_exec($ch); $errno curl_errno($ch); $status curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($errno 0) { return [code 502, msg upstream error: . $errno]; } if ($status 400) { return [code 502, msg upstream http . $status]; } return [code 0, msg ok, data [upstream $status]]; }文本消息的 payload 直接透传from、to、msg_id、content。图片消息的 payload 里多了 content_base64 字段中转服务把下载到的图片二进制 base64 编码后塞进去。这样目标端拿到 JSON 后可以自己选择落库还是再上传对象存储。为什么不用 multipart 表单因为大多数目标端都是 JSON APIbase64 在任何语言里都有现成解码函数通用性最好。代价是请求体会膨胀约三分之一10MB 图片会变成约 13.3MB 的 base64 字符串所以 config.php 里把 max_file_size 默认压到 10MBphp.ini 的 memory_limit 建议调到 128M 以上。返回结果统一包装成 code/msg/data 三段。这里有个设计取舍只要目标端返回 4xx/5xx或者 curl 出错lft.php 都返回 code502 给网关。网关看到非 0 code 会按自己的策略重试这样就等于把“目标端是否成功”这个信号透明地传递到了消息源头。如果不这样设计lft.php 自己吞掉错误并返回 code0网关会以为消息成功了业务端就永远收不到那条图片这在线上是很隐蔽的数据丢失事故。4. 本地部署与真实联调从 php -S 到 Nginx 反向代理4.1 环境准备PHP 版本、扩展、配置文件逐项说明跑这个项目需要 PHP 7.4 及以上版本以及三个扩展curl、fileinfo、json。PHP 8 默认内置 json 扩展curl 和 fileinfo 在绝大多数发行版里是默认装好的没有的话需要手动安装。检查命令php -m | grep -E curl|fileinfo|json接下来打开 config.php逐项确认配置项示例值作用app_idbridge_01签名拼接用app_secretchange_me_to_random_string签名密钥务必换成长随机串time_window300时间戳有效窗口单位秒target_webhookhttp://127.0.0.1:9000/hook目标端接收接口target_token空字符串或业务 Token目标端鉴权头max_file_size10485760图片大小上限10MBallowed_mimejpg/png/gif/webp 数组图片类型白名单allow_privatefalse是否允许下载内网图片联调可临时开生产保持 false4.2 用 php -S 快速拉起中转服务项目没有 Composer 依赖不需要安装任何第三方包直接用 PHP 内置服务器就能起来cd lft-demo php -S 127.0.0.1:8080 lft.php注意命令最后指定了 lft.php 作为路由脚本。PHP 内置服务器默认找 index.php指定 lft.php 后所有请求都会经过这个入口。如果后面加了新的路由文件也可以改成用 router.php 做统一入口但目前只有一个接口直接指定 lft.php 最简洁。启动后先确认端口在监听curl -sS -X POST http://127.0.0.1:8080/lft.php -d {}因为没带签名正常会返回 invalid sign这说明服务已经起来了。4.3 curl 模拟网关请求文本与图片两条验证路径先用 tests/mock_text.json 验证文本链路。在 shell 里拼签名比较繁琐但这一步值得仔细做因为生产环境的网关配置也是同样的签名算法export APP_IDbridge_01 export APP_SECRETchange_me_to_random_string TS$(date %s) NONCE$(cat /proc/sys/kernel/random/uuid) BODY$(cat tests/mock_text.json) SIGN$(printf %s $APP_ID$TS$NONCE$BODY | openssl dgst -sha256 -hmac $APP_SECRET | awk {print $2}) curl -sS -X POST http://127.0.0.1:8080/lft.php \ -H Content-Type: application/json \ -H X-Timestamp: $TS \ -H X-Nonce: $NONCE \ -H X-Sign: $SIGN \ -d $BODY这里的签名串拼接必须和 PHP 端完全一致app_id timestamp nonce 原始 BODY中间没有分隔符。openssl dgst -sha256 -hmac输出的前导部分要用 awk 取第二列如果系统输出格式不同取最后一列更保险。图片链路测试前先在 tests 目录放一个 receiver.php 回显脚本用来接收 target_webhook 转发过来的内容?php // 联调专用把收到的请求体写文件方便确认 payload 结构 $raw file_get_contents(php://input); file_put_contents(__DIR__ . /last_request.json, $raw); echo json_encode([code 0]);再开一个终端启动它cd tests php -S 127.0.0.1:9000 receiver.php然后把 config.php 的 target_webhook 改为 http://127.0.0.1:9000/receiver.php用 mock_image.json 里的 URL 走一遍图片链路。请求返回 code0 后打开 tests/last_request.json能看到 content_base64 字段用 base64 解码后应该能得到一张真实图片。4.4 生产化Nginx 配置、日志定位与进程守护内置服务器只适合开发生产环境建议用 PHP-FPM Nginx。核心配置如下server { listen 80; server_name sipbridge.example; root /var/www/lft-demo; index lft.php; location /lft.php { fastcgi_pass unix:/run/php/php-fpm.sock; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; include fastcgi_params; } # 配置文件禁止直接被访问 location ~ \.php$ { deny all; } }这里的关键是第二个 location默认 PHP 配置会允许目录下所有 .php 文件被执行但 config.php 不是业务入口绝对不能被 web 请求命中。用location /lft.php严格匹配入口再用正则把所有其他 .php 文件全部 deny双保险。日志定位方面lft.php 每处理完一个请求会在 logs/ 下追加一行格式是时间、msg_id、msg_type、目标端 HTTP 状态码。排查问题时先看 logs 再看 Nginx 的 error.log基本能定位是验签失败、图片下载超时还是目标端报错。5. 避坑记录签名、超时、重复推送与配置泄露这四个深坑5.1 签名一直 invalid sign时间戳单位与 raw body现象按文档拼好签名后 curl返回 code401 invalid sign怎么调都不对。我最初也在这上面卡了半小时。原因有两个常见点。第一个是时间戳单位不一致网关侧可能发的是毫秒时间戳而 lft.php 里用time()比较秒级时间差了一千倍永远超时。第二个是签名串里的 body 不是原始字符串有人先jq处理 JSON 再拼签名字段顺序变了签名自然对不上。解决先确认 X-Timestamp 是秒还是毫秒是毫秒就在 PHP 端除以 1000 再比较签名串必须用 curl 实际发送的-d $BODY同一个字符串中间不要经过任何重新编码。调试时可以临时在 PHP 端把$signStr和$expect写进日志和本地 openssl 命令的输出逐字符对比。5.2 图片下载超时却返回成功curl 错误码与 HTTP 状态要分开判断现象目标端确实收到了请求但图片内容是空白的或者业务日志显示图片 URL 不可达而 lft.php 却返回了 code0。原因最初只判断了curl_errnocurl 成功拿到响应不代表响应内容是有效的。有些内网网关在图片服务挂掉时返回 502 页面但 curl 的 errno 是 0下载 “成功” 了另一个情况是对方返回 302 跳转到登录页最后拉回来的是一坨 HTML。解决判断标准不能只有一个必须同时满足三个条件errno 为 0、HTTP 状态码为 200、finfo 识别出的 MIME 在白名单里。这也是 3.2 节代码里三层判断并存的原因。从那以后我每写一个下载逻辑都默认遵守“错误码、状态码、内容校验三者都通过才算成功”。5.3 目标端重复收到同一消息缺少幂等控制引发回调风暴现象目标端同一个 msg_id 的消息收到三四次工单系统里出现重复单。原因网关推送超时后会重试第一次请求其实已经转发给目标端但响应在网络里丢了网关没收到 code0 就再推一次。lft.php 没有记忆每次收到都当作新消息处理。解决用 msg_id 做幂等常见做法是把 msg_id 扔进 Redis SETEX有效期 1 小时处理前先查一下存在就直接返回上次结果。没有 Redis 的话也可以建一张 MySQL 唯一键表msg_id 为主键插入成功才继续转发。注意幂等键必须是 msg_id 目标端类型多目标端时同一 msg_id 还是需要各推各的。5.4 配置文件被直接下载根目录暴露了 app_secret现象扫描日志里出现 GET /config.php 返回 200配置内容被明文读出。原因Nginx 配置里只是简单地try_files $uri $uri/ /index.php没有限制 PHP 文件访问而 config.php 在 web 根目录下PHP-FPM 会正常解析并返回空内容但 MySQL/Redis 连接信息和密钥字符串会被当成纯文本输出不会PHP 文件会被执行但如果代码里直接写了app_secret xxx配过扩展名的请求就只输出空白。真正危险的是某些环境把 .php 当静态文件直接下载了。解决把 config.php 移到 web 根目录之外例如 /var/www/conf/config.phplft.php 里用绝对路径 require。同时参考第 4.4 节的 Nginx 配置对所有非业务入口的 .php 文件直接 deny。另外生产环境务必关闭 display_errors否则签名参数、目标端 URL 这些变量值会随错误堆栈一起出现在响应里等于把密钥拱手送出。6. 二次开发路线队列化、适配器化与回归验证6.1 把同步转发改成 Redis 队列异步消费现在的 push_to_target 是同步调用目标端偶尔抖动时网关那边要等 10 秒超时才拿到失败回执。改造思路是lft.php 只负责验签和入队转发逻辑交给独立 worker 消费。入队代码大概长这样$redis-lpush(lft:target:queue, json_encode([ payload $payload, created_at time(), ])); $resp [code 0, msg queued];worker 端用 BLPOP 阻塞取队列取到后再调 push_to_target。同步问题变成异步后网关响应时间会降到毫秒级但代价是需要保证 worker 只能有一个实例在跑否则还是会出现重复消费Redis 的 lpush blpop 在多个 worker 下要配合幂等键一起用。6.2 用适配器模式扩展目标端而不是堆 if-else如果目标端不止一个比如既有工单系统又要同步给 IM 机器人当前代码里的 else if 结构就不够用了。我会定义一个目标端适配器接口每个平台一个实现interface TargetAdapter { public function supports($targetType); public function format($msg, $imageData); public function getWebhook($targetType); }路由分发时按 target_type 字段选适配器新增平台只需要加一个实现类不动 lft.php 主流程。配置里增加 target_type 参数例如 switchboard 和 im_bot 两个值。6.3 改造后的回归清单与流程闭环每次改完 lft.php我会把这几条用例完整跑一遍文本消息验签通过并能透传、图片 URL 下载成功且 MIME 校验通过、内网 URL 被拦截返回 502、签名错误返回 401、重复 msg_id 不触发二次转发。整个流程只用两个终端加 tests 目录里的 mock 数据就能完成不需要真连网关。这套验证方法并不复杂但它救过我几次。以前改完代码只测文本链路图片链路出了问题要等到业务方反馈才知道然后回头定位日志一来一回浪费一个下午。从那以后我每次改 lft.php无论改的是路由还是推送都会把 tests 目录里的 mock 数据从头跑一遍再对着日志确认没有 502 和 invalid sign 才敢发到服务器上。几个简单的测试文件换来的是对接现场的底气。希望帮到你。本文还有配套的精品资源点击获取