diff --git a/src/content/posts/architecting-sea-payment-and-offline-licensing-pipelines.md b/src/content/posts/architecting-sea-payment-and-offline-licensing-pipelines.md index 6914fd1..b93f7f0 100644 --- a/src/content/posts/architecting-sea-payment-and-offline-licensing-pipelines.md +++ b/src/content/posts/architecting-sea-payment-and-offline-licensing-pipelines.md @@ -13,8 +13,8 @@ summary: audience: "FinTech Engineers, SaaS Architects & Technical Founders" painPoint: "Delivering commercial desktop software in Southeast Asia fails when using Western payment rails (Stripe/PayPal) due to low regional credit card penetration and rampant license piracy." impact: - - metric: "99.8% Local Conversion" - detail: "Integrated toyyibPay for direct FPX online banking and DuitNow QR settlements, bypassing regional credit card checkout drop-offs." + - metric: "Card-Free Checkout Path" + detail: "Integrated toyyibPay so FPX online banking and DuitNow QR settle without a credit card, removing the regional card-checkout drop-off instead of optimising around it." - metric: "Zero Key Collisions" detail: "Bound licenses to cryptographically signed machine fingerprints via Keygate and Ed25519, preventing multi-machine key sharing while supporting offline validation." - metric: "100% Idempotent Delivery" @@ -34,7 +34,7 @@ Asian markets (specifically Malaysia, Singapore, and Indonesia), standard Wester architectures fall short. Dropping standard Stripe Elements or PayPal onto a regional checkout page triggers two immediate barriers: -1. **Low Credit Card Penetration**: Across local businesses and independent operators in Malaysia, over **70% of digital transactions occur via FPX (Financial Process Exchange) direct online banking or DuitNow QR rails**, not Visa or Mastercard. +1. **Low Credit Card Penetration**: A large share of Malaysian online retail settles over **FPX (Financial Process Exchange) direct online banking or DuitNow QR rails** rather than Visa or Mastercard. A checkout that only renders a card form does not fail slowly — it fails at the first screen. 2. **Offline Software Piracy**: Desktop executables distributed as raw binaries suffer immediate redistribution unless tied to cryptographically verified, machine-specific hardware fingerprints. To resolve these barriers for an enterprise desktop media-transcoding product, I architected @@ -76,37 +76,99 @@ any database state mutation: ```php public function callback(): ResponseInterface { - $refNo = $this->request->getPost('refno'); - $status = $this->request->getPost('status'); - $billId = $this->request->getPost('billcode'); - $amount = $this->request->getPost('amount'); + $refNo = (string) $this->request->getPost('refno'); + $status = (string) $this->request->getPost('status'); // 1=success 2=pending 3=fail + $billId = (string) $this->request->getPost('billcode'); + $orderId = (string) $this->request->getPost('order_id'); + $amount = (string) $this->request->getPost('amount'); + $hash = (string) $this->request->getPost('hash'); - // 1. Idempotency Check: prevent duplicate processing on webhook replays - $order = $this->orderModel->findByBillCode($billId); - if (!$order || $order['status'] === 'PAID') { - return $this->response->setStatusCode(200)->setBody('OK'); + // 1. Gateway checksum. toyyibPay ships no signature header: the callback carries + // hash = MD5(userSecretKey + status + order_id + refno + "ok"). + $expected = md5($this->gateway->secretKey() . $status . $orderId . $refNo . 'ok'); + if (!hash_equals($expected, $hash)) { + $this->logger->warning('toyyibpay.callback.bad_hash', ['bill' => $billId]); + return $this->response->setStatusCode(401)->setBody('INVALID_HASH'); } - // 2. Strict Status and Amount Validation - if ($status === '1' && (float)$amount === (float)$order['expected_amount']) { - $this->fulfillmentService->fulfillOrder($order['id']); - return $this->response->setStatusCode(200)->setBody('OK'); + // Non-success statuses never mutate an order: 2=pending, 3=fail. + if ($status !== '1') { + return $this->response->setStatusCode(200)->setBody('IGNORED'); } - return $this->response->setStatusCode(400)->setBody('INVALID_PAYMENT'); + // 2. One transaction, one row-level exclusive lock: the read and the state + // transition are the same critical section, so there is no TOCTOU window. + $this->db->transBegin(); + try { + $order = $this->orderModel->lockForUpdateByBillCode($billId); // SELECT ... FOR UPDATE + if (!$order) { + $this->db->transRollback(); + return $this->response->setStatusCode(404)->setBody('UNKNOWN_BILL'); + } + if ($order['status'] === 'FULFILLED') { + $this->db->transCommit(); // replay storm: absorb it, do not re-issue + return $this->response->setStatusCode(200)->setBody('OK_IDEMPOTENT'); + } + + // 3. Money is never compared as a float. Both sides are integer cents. + if ($this->toCents($amount) !== (int) $order['expected_amount_cents']) { + $this->db->transRollback(); + $this->logger->error('toyyibpay.callback.amount_mismatch', [ + 'bill' => $billId, 'got' => $amount, + 'expected_cents' => $order['expected_amount_cents'], + ]); + return $this->response->setStatusCode(409)->setBody('AMOUNT_MISMATCH'); + } + + $this->orderModel->updateStatus($order['id'], 'PROCESSING'); + $this->db->transCommit(); + } catch (Throwable $e) { + $this->db->transRollback(); + throw $e; + } + + // 4. Fulfilment is queued after commit, never inside the lock: a Keygate or SMTP + // outage must not hold the row lock, and must never roll back a paid order. + // That is what makes [PROCESSING] → [RETRY_QUEUE] a safe transition. + $this->fulfillmentQueue->push($order['id']); + + return $this->response->setStatusCode(200)->setBody('OK'); +} + +/** "12.40" -> 1240, integer arithmetic only; no float ever touches the money path. */ +private function toCents(string $amount): int +{ + [$ringgit, $sen] = array_pad(explode('.', trim($amount), 2), 2, '0'); + return ((int) $ringgit) * 100 + (int) str_pad(substr($sen, 0, 2), 2, '0'); } ``` +A checksum only proves the callback came from the gateway, not that it arrived. The +`[PROCESSING]` state therefore also has an **active reconciliation worker**: any order sitting +in `PROCESSING` past the settlement window triggers a server-to-server +`getBillTransactions` pull against toyyibPay and settles from the authoritative record. Passive +webhook and active reconciliation are two independent paths into the same idempotent state +transition, which is what makes a lost callback survivable. + ### 2. Hardware-bound licensing via Keygate (Asymmetric Verification) Once payment is locked, the fulfillment daemon does not generate a generic text key. It calls the licensing authority API to mint an activation entitlement locked to the buyer's machine architecture: * Each license key encodes an entitlement tier (`standard`, `pro`, `enterprise`), expiration boundary, and feature flags. -* Upon first launch, the desktop client hashes CPU ID, Motherboard UUID, and OS installation GUID into an immutable 256-bit hardware signature. -* Keygate signs the entitlement payload combining the machine hash using an **Ed25519 private key**. +* Upon first launch, the desktop client reads a set of **multi-dimensional immutable hardware registers** — SMBIOS board identifiers, processor architectural signatures, and storage-controller UUIDs — and derives a per-component digest with a server-issued cryptographic salt. The exact selector list and salt derivation are proprietary and deliberately omitted here; publishing them would hand an attacker a precise list of which OS calls to hook. +* Keygate signs the entitlement payload combining those per-component digests using an **Ed25519 private key**. * The desktop client embeds only the corresponding **Ed25519 public key**. On every boot, it independently verifies the signature offline against local hardware registers. Reverse engineers cannot spoof validity by patching plain strings, and the client functions flawlessly in air-gapped environments without phoning home. +**Component-wise digests exist so the licence can survive legitimate hardware changes.** Concatenating +every register into one string and hashing it once is the classic mistake: a single RAM upgrade or an +OS reinstall changes one input, avalanches the whole digest, and locks a paying customer out. Instead +the signed payload carries each component digest as its own field, and the client applies **k-of-n +threshold matching** — the machine is accepted when at least three of four components match *and* at +least one of the two chassis-anchored components (board or CPU) is among them. Swapping a disk or +reinstalling the OS is a legitimate upgrade; transplanting the licence onto a different machine +changes the board and CPU together and fails the check. + --- ## Direct downloads with CDN failover @@ -168,6 +230,6 @@ Deploying commercial software in emerging markets requires rethinking standard S ## The result The automated billing and licensing architecture delivered: -* **Zero manual intervention**: Hundreds of commercial transactions processed end-to-end, from FPX bank debit to instant license key dispatch within **1.8 seconds** of webhook receipt. +* **Zero manual intervention**: Transactions run end-to-end from FPX bank debit to license-key dispatch with no operator in the loop; the fulfilment step is measured in seconds of webhook receipt, and a gateway outage defers it to the retry queue instead of dropping it. * **Eliminated key sharing**: Zero pirated key leaks across customer organizations thanks to hardware-bound Keygate hashing. * **Total visibility**: Instant Telegram operations telemetry ensuring immediate notice of any gateway or SMTP delivery anomalies. diff --git a/src/content/posts/traefik-yaegi-plugin-package-name-trap.md b/src/content/posts/traefik-yaegi-plugin-package-name-trap.md index ad3e757..d0e7a9a 100644 --- a/src/content/posts/traefik-yaegi-plugin-package-name-trap.md +++ b/src/content/posts/traefik-yaegi-plugin-package-name-trap.md @@ -1,7 +1,7 @@ --- ogImage: "/og/traefik-yaegi-plugin-package-name-trap.jpg" banner: "/banners/traefik-yaegi-plugin-package-name-trap.jpg" -title: "Why Your Traefik Yaegi Plugin Fails on Boot: The Module Hyphen to Underscore Trap" +title: "Why Traefik Yaegi Plugins Fail on Boot: The Module Hyphen-to-Underscore Trap" description: "Why Traefik crashes on boot with 'CreateConfig failed with Yaegi' when loading custom Go plugins: diagnosing the hidden package naming convention enforced by the Yaegi dynamic interpreter." pubDate: 2026-10-27 category: notes @@ -83,10 +83,18 @@ Instead, Traefik's dynamic plugin loader derives the expected package symbol ide $$\text{Module Path}: \texttt{github.com/hoelee/traefik-botfilter} \longrightarrow \text{Package}: \texttt{traefik\_botfilter}$$ +There is a language-level reason this trap exists at all. A hyphen is the subtraction operator in +Go, so `package traefik-botfilter` is not merely unusual — it is ungrammatical and can never be +compiled. The standard toolchain therefore relies on `go.mod` to decouple the hyphenated *directory* +and module path from the identifier declared inside the files. Yaegi works from source text, not from +a resolved module graph: it has to synthesise an evaluation context and then reflect for +`CreateConfig` inside a namespace it computed itself. Nothing in that path consults `go.mod`, so the +declared identifier has to agree with the loader's computed one. + Because my Go source files started with: ```go // botfilter.go -package botfilter // <-- YAIEGI COULD NOT FIND THE EXPORTED SYMBOLS +package botfilter // <-- YAEGI COULD NOT FIND THE EXPORTED SYMBOLS ``` Yaegi inspected the AST looking for `traefik_botfilter.CreateConfig`. It found only `botfilter.CreateConfig`. @@ -124,10 +132,17 @@ plugins-local/ └── ... ``` +The `.traefik.yml` manifest in that directory is part of the same contract: its `import` field names +the module path whose final segment the loader will sanitise, and `displayName` / `type` must match +what the dynamic configuration references. A mismatch between the manifest's import path and the +`package` clause of the source files produces exactly the same opaque `symbol not found` failure as +the hyphen trap, which is why both have to be verified together rather than fixed one at a time. +
🔍 View corrected dynamic Traefik configuration (YAML excerpt, 29 lines) ```yaml +# Illustrative sanitized configuration — the live thresholds differ. http: middlewares: botfilter: @@ -138,7 +153,7 @@ http: requireAccept: true browserValidation: true temporaryBanMinutes: 15 - scoreThreshold: 100 + scoreThreshold: 120 blockedPaths: - /.env - /.git @@ -173,6 +188,19 @@ cleanly in **0.42 seconds**: 2026-10-23T04:48:44Z INF Plugin loaded successfully module=github.com/hoelee/traefik-botfilter ``` -When developing custom Traefik plugins, remember: **the Go compiler's acceptance is only half the battle.** -Matching the package declaration to the sanitized final segment of the Go module path is mandatory, -or Yaegi will silently refuse to start the edge proxy. +In custom Traefik plugin engineering, **passing the standard Go compiler is only half the battle.** +The package declaration has to match the sanitised final segment of the module path, and the +`.traefik.yml` import has to point at the same module — otherwise Yaegi refuses to start the edge +proxy and the error text names a symbol that, by every local test, exists. + +## Where a Yaegi plugin stops being the right tool + +An interpreted in-process plugin is the cheapest correct answer for an edge node in the low +thousands of requests per second: no Redis, no sidecar, no separate process to supervise, and the +interceptor sits in the same memory as the router. The cost is paid at two points. Interpretation +runs slower than compiled code, and every plugin allocation lands in the Traefik process's own heap, +so GC pressure scales with traffic rather than with rule complexity. Beyond roughly tens of +thousands of requests per second, or under a volumetric flood, the ordering has to invert: drop +packets in front of the proxy (edge WAF at the CDN, or CrowdSec with nftables/eBPF on the host) and +let the Go plugin handle the semantic cases — header shape, request pacing, per-IP scoring — that a +packet filter cannot see. diff --git a/src/content/posts/zh/architecting-sea-payment-and-offline-licensing-pipelines.md b/src/content/posts/zh/architecting-sea-payment-and-offline-licensing-pipelines.md index 79b6bcc..4b950c0 100644 --- a/src/content/posts/zh/architecting-sea-payment-and-offline-licensing-pipelines.md +++ b/src/content/posts/zh/architecting-sea-payment-and-offline-licensing-pipelines.md @@ -13,10 +13,10 @@ summary: audience: "金融科技工程师、SaaS 架构师与技术业务负责人" painPoint: "在东南亚地区销售桌面客户端软件时,直接采用西方主流支付(Stripe/PayPal)会因极低的信用卡普及率导致弃单率高企,且原始二进制极易遭遇跨机器盗版传播。" impact: - - metric: "99.8% 本地支付成功率" - detail: "原生对接 toyyibPay 聚合网关,覆盖马来西亚 FPX 本地在线网银与 DuitNow QR 直付通道,消解信用卡结算门槛。" + - metric: "零信用卡收银通道" + detail: "原生对接 toyyibPay 聚合网关,覆盖马来西亚 FPX 本地在线网银与 DuitNow QR 直付通道,从根上移除信用卡结算门槛,而不是在卡片表单上做优化。" - metric: "机器码硬件级防盗" - detail: "通过 Keygate 将软件授权与 CPU/主板硬件指纹强绑定并签名,彻底杜绝一码多用,同时支持内网离线离线验活。" + detail: "通过 Keygate 将软件授权与 CPU/主板硬件指纹强绑定并签名,彻底杜绝一码多用,同时支持内网纯离线验活。" - metric: "100% 幂等状态机履约" detail: "设计带分布式锁的 Webhook 消费引擎,确保在银行网络波动与重试风暴下绝不发生重复发码与状态覆盖。" stack: ["toyyibPay API", "Keygate 授权服务", "PHP/CodeIgniter", "PostgreSQL", "Telegram 自动化"] @@ -32,7 +32,7 @@ dynamicCta: true 在面向东南亚区域(尤其是马来西亚、新加坡和印尼)的企业客户与独立专业人员交付桌面客户端工具时,直接套用欧美的常规 SaaS 支付架构往往会在上线第一天遭遇滑铁卢。 如果在结账页面上直接套用常规的 Stripe Elements 或 PayPal,系统会立即撞上两堵现实高墙: -1. **信用卡渗透率低下**:在马来西亚本地企业主与从业者中,超过 **70% 的线上交易严重依赖 FPX(Financial Process Exchange)本地在线网银直连或 DuitNow QR 扫码支付**,绝大多数人不习惯或根本没有可用于扣费的企业信用卡。 +1. **信用卡渗透率低下**:在马来西亚本地企业主与从业者中,相当大比例的马来西亚线上零售结算走的是 **FPX(Financial Process Exchange)本地在线网银直连或 DuitNow QR 扫码**,而非 Visa / Mastercard。只渲染信用卡表单的结账页不会慢慢变差——它在第一屏就直接失效。 2. **离线桌面软件的盗版风险**:如果是纯离线运行的高性能工具(例如批量视频转码器),二进制包一旦交付,如果缺乏基于物理硬件的授权签名机制,极易在团队内部被无限制复制扩散。 为了解决一款商业级媒体转码桌面软件的商业变现与履约闭环,我主导设计并落地了一套全自动化的计费与授权分发架构。该架构深度整合了本地 FPX 网银网关(`toyyibPay`)、多通道自动熔断降级、机器硬件绑定的离线授权系统(`Keygate`),以及联动 Telegram 与事务邮件的全闭环即时交付通知。 @@ -68,35 +68,91 @@ dynamicCta: true ```php public function callback(): ResponseInterface { - $refNo = $this->request->getPost('refno'); - $status = $this->request->getPost('status'); - $billId = $this->request->getPost('billcode'); - $amount = $this->request->getPost('amount'); + $refNo = (string) $this->request->getPost('refno'); + $status = (string) $this->request->getPost('status'); // 1=成功 2=处理中 3=失败 + $billId = (string) $this->request->getPost('billcode'); + $orderId = (string) $this->request->getPost('order_id'); + $amount = (string) $this->request->getPost('amount'); + $hash = (string) $this->request->getPost('hash'); - // 1. 幂等拦截:如果订单已处于履约完成状态,直接返回 200 终止重试风暴 - $order = $this->orderModel->findByBillCode($billId); - if (!$order || $order['status'] === 'PAID') { - return $this->response->setStatusCode(200)->setBody('OK'); + // 1. 网关校验和。toyyibPay 不提供签名头,回调里携带的是 + // hash = MD5(userSecretKey + status + order_id + refno + "ok") + $expected = md5($this->gateway->secretKey() . $status . $orderId . $refNo . 'ok'); + if (!hash_equals($expected, $hash)) { + $this->logger->warning('toyyibpay.callback.bad_hash', ['bill' => $billId]); + return $this->response->setStatusCode(401)->setBody('INVALID_HASH'); } - // 2. 严格核验状态码与应收金额(防止中间人篡改金额参数) - if ($status === '1' && (float)$amount === (float)$order['expected_amount']) { - $this->fulfillmentService->fulfillOrder($order['id']); - return $this->response->setStatusCode(200)->setBody('OK'); + // 非成功状态一律不改动订单:2=处理中,3=失败 + if ($status !== '1') { + return $this->response->setStatusCode(200)->setBody('IGNORED'); } - return $this->response->setStatusCode(400)->setBody('INVALID_PAYMENT'); + // 2. 一个事务、一把行级排他锁:读取与状态扭转属于同一个临界区, + // 因此不存在 TOCTOU 竞态窗口。 + $this->db->transBegin(); + try { + $order = $this->orderModel->lockForUpdateByBillCode($billId); // SELECT ... FOR UPDATE + if (!$order) { + $this->db->transRollback(); + return $this->response->setStatusCode(404)->setBody('UNKNOWN_BILL'); + } + if ($order['status'] === 'FULFILLED') { + $this->db->transCommit(); // 重放风暴:吸收掉,绝不二次发码 + return $this->response->setStatusCode(200)->setBody('OK_IDEMPOTENT'); + } + + // 3. 金额永不使用浮点比对,双方统一为整型分(cents) + if ($this->toCents($amount) !== (int) $order['expected_amount_cents']) { + $this->db->transRollback(); + $this->logger->error('toyyibpay.callback.amount_mismatch', [ + 'bill' => $billId, 'got' => $amount, + 'expected_cents' => $order['expected_amount_cents'], + ]); + return $this->response->setStatusCode(409)->setBody('AMOUNT_MISMATCH'); + } + + $this->orderModel->updateStatus($order['id'], 'PROCESSING'); + $this->db->transCommit(); + } catch (Throwable $e) { + $this->db->transRollback(); + throw $e; + } + + // 4. 履约在事务提交之后入队,绝不在锁内执行:Keygate 或 SMTP 故障既不能占住行锁, + // 也绝不能让一笔已付款订单被回滚。这才让 [PROCESSING] → [RETRY_QUEUE] 成为安全状态。 + $this->fulfillmentQueue->push($order['id']); + + return $this->response->setStatusCode(200)->setBody('OK'); +} + +/** "12.40" -> 1240,纯整数运算,浮点永不进入金额链路 */ +private function toCents(string $amount): int +{ + [$ringgit, $sen] = array_pad(explode('.', trim($amount), 2), 2, '0'); + return ((int) $ringgit) * 100 + (int) str_pad(substr($sen, 0, 2), 2, '0'); } ``` +校验和只能证明回调来自网关,不能证明回调确实到达。因此 `[PROCESSING]` 状态额外挂了一个 +**主动对账 Worker**:任何停留在 `PROCESSING` 超过结算窗口的订单,都会以服务端身份调用 +`getBillTransactions` 向 toyyibPay 反查权威账单记录并据此结算。被动 Webhook 与主动对账 +是两条独立汇入同一个幂等状态转移的路径——这才是「回调丢失」不至于变成「客户付了钱没拿到码」的原因。 + ### 2. 基于 Keygate 的硬件指纹绑定与非对称离线验活 订单扣款成功后,系统不会生成毫无约束的通用明文激活码,而是即时调用授权中心 API,为买家签发绑定其特定计算机硬件特征的专属 License: * 授权码内部编码了功能等级(`standard` / `pro` / `enterprise`)、过期时间戳与特征权限集。 -* 桌面端首次运行时,自动化采集 CPU 序列号、主板 UUID 与操作系统 GUID 进行单向加盐哈希,生成 256 位不可伪造的硬件指纹。 -* Keygate 服务端使用 **Ed25519 私钥** 对机器指纹与授权元数据执行数字签名。 +* 桌面端首次运行时,读取一组**多维不可变硬件寄存器**——SMBIOS 主板标识、处理器架构签名与存储控制器 UUID——并以服务端下发的盐值派生逐组件摘要。具体取哪些寄存器、盐值如何派生属于私有实现,本文有意不公开:公开它等于直接告诉逆向者该 Hook 哪几个系统调用。 +* Keygate 服务端使用 **Ed25519 私钥** 对逐组件摘要与授权元数据执行数字签名。 * 桌面客户端仅内置对应的 **Ed25519 公钥**。此后每次冷启动均在本地直接进行离线数学验签,即使客户将工作站置于完全隔离的物理内网机房,软件依然能零请求离线验证,逆向者也无法通过篡改本地内存字符串实现绕过。 +**逐组件摘要的存在,是为了让授权能扛住合法的硬件变更。** 把所有寄存器拼成一个字符串单次哈希 +是经典错误:加一条内存或重装系统就改变一个输入,整个摘要雪崩式失效,把付了钱的客户锁在门外。 +正确做法是让签名负载分别携带各组件摘要,客户端执行 **k-of-n 阈值匹配**——四项中至少命中三项, +且两项机身锚定组件(主板或 CPU)至少命中其一,即判定为合法硬件微调并放行。换硬盘、重装系统 +属于正常升级;把授权移植到另一台机器会同时改变主板与 CPU,直接卡在阈值之外。 + --- ## 一键极速直下与 CDN 动态容灾 @@ -156,6 +212,6 @@ def notify_fulfillment(order_id, customer_email, bill_code, license_key, downloa ## 交付结果 该全自动计费与分发流水线投产后: -* **全流程零人工干预**:处理数百笔商业交易,从用户网银扣款成功到系统完成机器授权签发并通过邮件送达,平均仅耗时 **1.8 秒**。 +* **全流程零人工干预**:交易从网银扣款成功到授权签发并邮件送达全流程无人值守;履约以「秒」为单位计,而网关故障只会把订单推进重试队列,不会把它丢掉。 * **杜绝私下窜码扩散**:依托硬件级 Keygate 指纹绑定,彻底终结了盗版秘钥在多台设备上的随意复制。 * **高可用可观测性**:全链路运行日志与 Telegram 即时通知紧密联动,任何网关故障或 SMTP 队列堆积均能在 30 秒内被快速发现并处理。 diff --git a/src/content/posts/zh/traefik-yaegi-plugin-package-name-trap.md b/src/content/posts/zh/traefik-yaegi-plugin-package-name-trap.md index f3c08b8..13ca0c2 100644 --- a/src/content/posts/zh/traefik-yaegi-plugin-package-name-trap.md +++ b/src/content/posts/zh/traefik-yaegi-plugin-package-name-trap.md @@ -70,6 +70,12 @@ Traefik 会要求 Yaegi 在插件包中寻找并导出符合中间件契约的 在标准的 Go 语言工程中,模块路径与目录名经常带有连字符(如 `module github.com/hoelee/traefik-botfilter`),而源码文件首行通常声明不带前缀的合法标识符(如 `package botfilter`)。官方 Go 编译器(`gc`)通过模块导入路径可以完美绑定解析。 +这里还有一层语言层面的根因:在 Go 中连字符 `-` 是减法运算符,因此 `package traefik-botfilter` +不是「不常见」,而是不合语法、永远无法编译。标准工具链正是靠 `go.mod` 把带连字符的目录名/模块路径 +与文件内声明的标识符解耦的。Yaegi 不解析模块图,它从源码文本出发,先合成求值上下文,再在自己算出的 +命名空间里反射查找 `CreateConfig`——这条路径根本不读 `go.mod`,所以声明的包名必须与加载器算出的那个 +完全一致。 + **但是,Yaegi 动态解释器并不走标准 Go 模块语义解析流程。** Traefik 的动态插件加载器会直接从静态配置中 **`moduleName` 的最后一个路径段** 提取包标识符,并且**自动将所有的连字符(`-`)强制转换为下划线(`_`)**: @@ -162,4 +168,15 @@ http: 2026-10-23T04:48:44Z INF Plugin loaded successfully module=github.com/hoelee/traefik-botfilter ``` -对于开发 Traefik 动态插件的工程师而言:**Go 官方编译器的通过并不代表动态运行时的认可。** 必须确保包名与模块路径最后一段的下划线转换规则完全一致,才能避免网关在生产启动时发生静默瘫痪。 +在 Traefik 动态插件工程里,**Go 官方编译器的通过只算赢了一半。** 包声明必须与模块路径清洗后的 +末段一致,`.traefik.yml` 的 import 又必须指向同一个模块——否则 Yaegi 会拒绝拉起网关,而报错里点名的 +那个符号,在每一轮本地测试里都明明存在。 + +## Yaegi 插件在什么情况下不再是正解 + +在每秒数千请求量级的边缘节点上,进程内解释型插件是最便宜的正解:不需要 Redis、不需要边车进程、 +不需要额外守护,拦截器与路由共享同一块内存。代价在两个地方兑现——解释执行比编译码慢,且插件的 +每一次分配都落在 Traefik 进程自己的堆上,于是 GC 压力随流量上升,而不是随规则复杂度上升。当单机 +吞吐进入数万 QPS 量级,或面对洪峰型扫描时,顺序必须反过来:先在代理之前丢包(CDN 侧边缘 WAF,或宿主机 +上的 CrowdSec 配合 nftables / eBPF),再让 Go 插件处理包过滤看不见的语义问题——头部形态、请求节流、 +分 IP 评分。