fix(posts): apply consultant round-1 audit to 01 and 03
Deploy / build (push) Successful in 32s

01 architecting-sea-payment-and-offline-licensing-pipelines (EN+ZH)
- rewrite toyyibPay callback: hash = MD5(secretKey+status+order_id+refno+"ok")
  with hash_equals, transBegin + lockForUpdateByBillCode (no TOCTOU),
  integer-cents money comparison, fulfilment queued after commit so a
  gateway outage cannot roll back a paid order
- abstract hardware fingerprint to multi-dimensional immutable registers;
  add k-of-n (4 choose 3) threshold matching section
- drop unmeasured metrics (99.8% / 1.8s / 70%); fix duplicated 离线离线 typo

03 traefik-yaegi-plugin-package-name-trap (EN+ZH)
- title: drop second-person "Your"
- fix YAIEGI -> YAEGI code comment typo
- add Go-spec root cause (hyphen is subtraction, go.mod decoupling vs Yaegi)
- add .traefik.yml manifest contract paragraph
- sanitize YAML thresholds as illustrative; add "where Yaegi stops being
  the right tool" boundary section
- de-second-person the closing paragraph

Consultant review pack v1.1 shipped separately (docx 05 = response ledger).
This commit is contained in:
hoelee committed 2026-10-29 13:59:02 +08:00
1 parent c0ea7ea066
commit a7077a3a1d
4 files changed
+209 -46

No files matched your search

@@ -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.
@@ -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.
<details class="tech-deep-dive">
<summary>🔍 View corrected dynamic Traefik configuration (YAML excerpt, 29 lines)</summary>
```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.
@@ -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 秒内被快速发现并处理。
@@ -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 评分。