روی کلاینت دسکتاپ یک نود Lightning Network کار کردهام؛ برنامهای با PyQt5 برای یک مشتری که کاربرانش آن را روی لینوکس خودشان نصب میکنند. چیزی که کاربر میبیند نمودارهای زندهی mempool بیتکوین است، ولی بیشتر وقتم صرف چیزهایی شد که در اسکرینشات نیست: باینری چطور ساخته میشود، چطور به کاربر میرسد، چطور خودش را بهروز میکند و وقتی کرش میکند چه ردی از آن میماند.
شکل برنامه
یک پنجرهی ورود دومرحلهای، و بعد پنجرهی اصلی با دو تب. تب اول کنترلهای اجرا، نقشهی شبکه (بلوکهای پیشبینیشده و آخرین بلوک تأییدشده)، لاگ فعالیت و جدولهای تراکنشهای اخیر و جایگزینیهای RBF را دارد؛ تب دوم، تحلیل mempool، نمودار دوساعتهی تراکنشهای ورودی را. چند QThread جدا، هرکدام با زمانبندی خودش، داده میآورند (خلاصهی mempool هر 25 ثانیه، جدولها هر 15، آمار نمودار هر 90 و heartbeat بکاند هر 120 ثانیه) و هیچکدام پیش از اجرای نود شروع نمیشوند. داده از یک proxy روی بکاند Django میآید، نه مستقیم از mempool.space که در بعضی شبکهها فیلتر است.
ورکر نمودار آمار را به آرایههای NumPy زمان و vB/s تبدیل میکند و یک pg.GraphicsObject سفارشی هر پارهخط را در نقطهی عبور از آستانه میبُرد تا رنگ دقیقاً همانجا عوض شود. یک دام pyqtgraph: پیش از رسیدن داده، view چند دهه زمان unix را میپوشاند و محور زمان با گام ثابت 15 دقیقه آنجا حدود 1٫9 میلیون tick ساخت و UI را قفل کرد. حالا محور وقتی tickها از سقفی بیشتر شوند گامش را درشت میکند و هر دو محور همیشه از روی داده تنظیم میشوند.
ورود، شروع و توقف
ورود با نام کاربری و کد فعالسازی شروع میشود و با کدی که به ایمیل میرود تمام میشود. با کد درست، سرور دستگاه را به یک device_key گره میزند و یک secret_code بلند برمیگرداند؛ هر دو در keyring سیستمعامل میمانند و اجرای بعدی با auto-login/ بیصدا وارد میشود. اجرا با start/ شروع میشود و heartbeat همان endpoint با status=update است. سرور زمان آنلاین را فقط وقتی حساب میکند که فاصلهی heartbeatها حداکثر چهار دقیقه باشد؛ پس برنامه هر دو دقیقه یکی میفرستد، شکست را 15 ثانیه بعد تکرار میکند و فقط بعد از ده دقیقه بیجوابی دست میکشد. هر پایان اجرا stop/ میفرستد، نه فقط دکمهی STOP.
همهی POSTها از یک تابع رد میشوند که body را یک بار encode میکند و همان بایتها را هم امضا میکند و هم میفرستد. 403 با JSON خود API یعنی «این بیلد نمیتواند با این سرور حرف بزند» و پیام Update Required نشان میدهد، نه «رمز اشتباه است».
ورود خودکاری که هیچوقت کار نکرد
مدتی هر اجرا به صفحهی ورود میرسید. علت یک خط در سرور بود:
product.set_secret_code()
product.device_key = device_key
product.is_app_login = True
# ...then, in response_to_login():
if must_update:
product.save(update_fields=["app"])
پاسخ از روی شیء داخل حافظه ساخته میشد، پس یک 200 تمیز با secret تازه برمیگشت، در حالی که فقط ستون app به دیتابیس میرسید. خود محدود کردن فیلدها عمدی بود (لاگ کرش با یک UPDATE روی queryset اضافه میشود و یک save() ساده نسخهی کهنه را رویش مینوشت)؛ فقط فهرست فیلدها هیچوقت بزرگ نشد. حالا هر فراخواننده میگوید چه چیزی را عوض کرده و تستها ردیف را دوباره از دیتابیس میخوانند، نه پاسخ را. سه ایراد سمت کلاینت هم مشکل را پنهان کرده بودند، از جمله secret ردشدهای که تا ابد دوباره امتحان میشد، چون تشخیص روی متن خطا بود.
بیلد انتشار
نسخهی انتشار فقط با build.sh و داخل یک image ثابت Docker بر پایهی Ubuntu 22.04 ساخته میشود. سه باینری --onefile بیرون میآید (برنامه، updater و نصبکننده) و فقط برنامه کلید دارد:
cleanup() {
$PY bake_secrets.py --clean || rm -f _embedded_secrets.py
}
trap cleanup EXIT
$PY bake_secrets.py
$PYINSTALLER --onefile --noconfirm \
--name <app> \
--collect-all PyQt5 \
--hidden-import _embedded_secrets \
--hidden-import socks \
--hidden-import urllib3.contrib.socks \
main.py
ماژول _embedded_secrets داخل try/except import میشود و PyInstaller آن را نمیبیند؛ بدون --hidden-import بیلد بیصدا بدون کلید بیرون میآید. پس اسکریپت در پایان PYZ-00.toc را برای این ماژول میگردد و اگر نبود شکست میخورد. importهای socks برای کاربری است که VPNش پروکسی SOCKS سیستم است.
کلید اپ، و چرا راز نیست
یک .env متنی کنار فایل اجرایی خواندنی، کپیشدنی و قابل تغییر است، پس هرگز از ماشین بیلد بیرون نمیرود. bake_secrets.py مقدارها را اول از environment و بعد از .env میخواند، XORشده با یک mask تصادفی مخصوص همان بیلد در یک ماژول تولیدی مینویسد، و trap ماژول را در خروج پاک میکند، چه بیلد موفق باشد چه نه:
def obfuscate(value, mask):
raw = value.encode("utf-8")
blob = bytes(b ^ mask[i % len(mask)] for i, b in enumerate(raw))
return base64.b64encode(blob).decode("ascii")
این پنهانسازی است، نه رمزنگاری: mask کنار blob در همان باینری است. جلوی strings را میگیرد، نه کسی را که باینری را باز کند. همهی نصبها یک کلید دارند، پس یک نسخهی استخراجشده برای همیشه کار میکند و چرخاندنش همهی نسخههای نصبشده را قفل میکند. برای همین کلید در طراحی شناسهی بیلد است، نه منبع اعتماد؛ اختیار واقعی با device_key است، و یک secret مخصوص هر دستگاه طراحی شده ولی هنوز ساخته نشده.
طرح HMAC ارزش یک درخواست لورفته را کم میکند: امضا path:timestamp:sha256(body) را میپوشاند و timestamp فقط 300 ثانیه اعتبار دارد، پس امضای شنودشده را نمیشود با payload دیگری فرستاد. بهایش این است که دستگاهی که ساعتش پنج دقیقه جابهجاست اصلاً به بکاند نمیرسد.
نصبکننده
نصب واقعی را یک اسکریپت bash انجام میدهد که روی ماشین کاربرها جا افتاده بود (قفل APT، عوض کردن mirror با rollback، سرور فیلترشده)، پس نصبکننده بهجای بازنویسی، یک پنجرهی PyQt5 دور همان اسکریپت است. پنجره اسکریپت را با --ui=pipe اجرا میکند و stdout را میخواند: خطهای عادی لاگاند و خطهای @@ (STEP، PHASE، DONE و …) صفحهی پیشرفت را جلو میبرند؛ رمز sudo هم از مسیر askpass و یک FIFO میرود، نه روی دیسک.
اسکریپت پیش از نصب سرآیند zip را میسنجد، آرشیو را تست میکند و SHA-256 آن را با digest منتشرشده مقایسه میکند، و بعد از نصب یک manifest از sha256 فایلها مینویسد تا اجرای بعدی نصب کامل را از نصبی که فایلی از آن گم یا عوض شده تشخیص دهد، و Repair آن را برگرداند. نصبکننده نه کلید دارد و نه config را import میکند، تا روی شبکهی فیلترشده هم فوراً باز شود.
آپدیتر
نسخهی جدید در پاسخهای login/، auto-login/ و start/ میآید: new_version، force_update و شمارهی آخرین نسخه. آپدیت عادی را میشود چند روز عقب انداخت؛ آپدیت اجباری فقط دانلود یا خروج دارد. دانلود باینری updater را در session جدا اجرا میکند و برنامه را میبندد. updater هم کلید ندارد: latest/ و latest/sha256/ عمداً بازند، چون بیلدی که از چک کلید رد نمیشود باید بتواند بیلدی را که رد میشود بگیرد.
expected = self.fetch_expected_checksum() or (
response.headers.get("X-Checksum-Sha256", "").strip().lower()
)
if expected:
actual = self.file_sha256(self.save_path)
if actual != expected:
self.cleanup_partial_download()
self.finished.emit(False, "... (checksum mismatch) ...")
return
اختلاف یعنی پاک شدن فایل و شکست آپدیت. اگر هیچ digestی در دسترس نباشد، updater فقط در لاگ مینویسد و ادامه میدهد؛ اسکریپت نصب برای این حالت --strict دارد و updater ندارد، و این جای خالی هنوز باز است. بعد نسخههای در حال اجرای برنامه را با psutil میبندد، باینری فعلی را با پسوند تاریخ کنار میگذارد، اسم خودش را به .old عوض میکند تا updater تازه جا بگیرد، فایلها را با یک محافظ commonpath در برابر path traversal باز میکند، بیت اجرا را میگذارد و نسخهی جدید را اجرا میکند. هر شکستی backup و اسم updater را برمیگرداند.
ثبت کرش
نودی که کرش میکند به سرور هم مطمئن نمیرسد، پس چیزی زنده گزارش نمیشود. app_log.py اولین چیزی است که در main.py نصب میشود، پیش از importهایی که ممکن است شکست بخورند، و خروجی sys.excepthook، threading.excepthook، پیامهای critical و fatal خود Qt و faulthandler را در فایلی کنار برنامه نگه میدارد. handle فایل faulthandler تا آخر اجرا باز میماند، چون در signal handler نمیشود فایل باز کرد، و اجرای بعدی محتوای آن را به بافر اضافه میکند.
بافر، با سقف 20 هزار کاراکتر، بهصورت یک فیلد فرم همراه فراخوانی بعدی میرود و فقط با پاسخ 2xxی که JSON باشد پاک میشود؛ captive portal هم 200 میدهد. هیچ چیز در این ماژول هم اجازهی raise ندارد؛ لاگی که خودش برنامه را از کار بیندازد از نبودنش بدتر است.
آنچه برایم ماند
- کلید داخل کلاینت شناسهی بیلد است؛ اختیار را به چیزی بدهید که برای هر دستگاه صادر میشود.
- بیلد باید خودش را بسنجد، وگرنه روزی بدون کلیدش منتشر میشود.
- تست را روی ردیف ذخیرهشده بنویسید، نه روی پاسخ.
- updater و نصبکننده باید بدون برنامهی اصلی و روی بدترین شبکه کار کنند.