FnMessageBots

FnMessageBots

An event monitor for fnOS NAS boxes: it polls the read-only SQLite event database, folds each poll into one summary, and fans it out to 10 channels. Single-process Python, shipped as Docker and FPK, 70k+ App Center downloads.

September 1, 2025 → present
fnOSNASPythonDockeropen source

阅读中文版 →

fnOS doesn’t have an event bus. It has one SQLite log database. If you want “who logged in, which disk just spun up, which container died” to become a message on your phone, you have to build the pipe yourself.

One Python process, 57 modules, about 23,170 lines. 28 test files, 365 cases. 70k+ downloads across App Center and Docker, roughly a thousand people running it steadily.

This page takes it apart: where the data comes from, how it becomes a message, what happens when delivery fails, and the bugs on the way.

One event, from the database write to your phone

The short version: runtime and code size

ItemValue
RuntimePython ≥ 3.9 (official image is python:3.11-slim)
PlatformsfnOS (os_min_version = 1.1.3100) and any Docker host (amd64 / arm64)
Code57 .py files under src/, about 23,170 lines
Tests28 test files, 365 cases (123 of them subtests)
DependenciesEveryday packages: requests, psutil, Flask; the FPK builds a venv on the NAS
Statedata/cursor/ (cursors, outbox), data/logs/ (rotated daily, kept 7 days), config/config.json
src/
├── main.py # 装配、信号、热加载、批汇总刷盘线程
├── monitor/
│ ├── db_log_poller.py # 主日志库轮询(游标 = 自增 id)
│ ├── ssh_journal_poller.py # journalctl 采 SSH(新版主库没有 Sshd 记录)
│ ├── docker_events_poller.py # docker unix socket 事件流
│ ├── media_db_poller.py / music_db_poller.py / photo_db_poller.py
│ ├── scheduler_db_poller.py / backup_db_poller.py / trim_activity_poller.py
│ ├── event_processor.py # 事件 → 汇总、合并窗口、处理函数注册表
│ ├── batch_dedupe.py # 批内指纹去重(按源定制)
│ └── nas_patrol.py / wan_ip_monitor.py
├── notifier/ # 10 个渠道 + 统一分发 + 300 秒去重
├── web/ # Flask 配置页、鉴权、数据源只读自检
└── utils/notification_outbox.py # 落盘待发队列(SQLite)

Four constraints shaped the whole design

ConstraintWhat it forced
One SQLite database, no push APIPolling only; the cursor has to be the autoincrement id, never a timestamp
Nobody gives a log bot rootRuns as the FnMessageBot app account by default; unreadable sources are handled with ACL instructions, not privilege escalation
Installed on small boxes like an i3-8100T with 15GOne process, many threads, no Redis or MQ; all state is files
A notification tool that gets noisy gets uninstalledDedupe, merging, quiet hours and digests aren’t features, they’re survival requirements

Startup order and the threading model

The sequence in Application.initialize() is deliberate:

self.config = Config()                    # env 覆盖 + config.json
init_push_stats(self.config.cursor_dir)   # 推送统计(Web 页上的数字)
has_webhook = any(10 个渠道参数)           # 没配渠道 → 只起 Web 配置页
self.notifier = UnifiedNotifier(config)
self.event_processor = EventProcessor(notifier, config)
self.log_poller = self._build_log_poller()
self._sync_optional_pollers()             # 按勾选起 0~8 个数据源线程
self._register_db_event_handlers()        # eventId → handler 映射
# 再 start() 各 poller、起 UnifiedPollBatchFlush 线程、巡检线程、Flask

Two of those lines are intentional ordering. No channel configured means no polling — otherwise someone installs it, hasn’t set up WeCom yet, and the process is already reading the database and computing dedupe keys for nothing. And optional sources start on demand: eight sources, each with its own poll loop and cursor, and if you didn’t tick them, zero threads.

There’s no shared mutable center between threads. Each poller writes only its own cursor, and events travel downward as immutable dicts.

ThreadJobPeriod
DBLogPollerThe main log database5 seconds
The optional pollersdocker / media / music / photos / scheduler / backup / trim-activity / SSHeach its own switch
UnifiedPollBatchFlushFlushes the accumulated poll batchevent-driven
nas_patrolCPU / temperature / SMART / capacityyour cron setting
wan_ip_monitorPublic IP changes600 seconds

Ingestion: why the cursor is an id, not a timestamp

def _run_loop(self):
    last_id = None
    while self.running:
        if last_id is None:                       # 首次启动
            last_id = self._get_max_log_id()      # SELECT COALESCE(MAX(id),0) FROM log
            self._write_last_id(last_id)          # data/cursor/db_poller_cursor.txt
        last_id = self._poll_once(last_id)

That first read of MAX(id) has to succeed. If it fails, the loop retries — it never guesses a starting cursor. Guess low and you replay history into the phone; guess high and you never see another event.

There’s one query, and id ASC gives you ordering that matches occurrence:

SELECT id, serviceId, uid, uname, logtime, loglevel, eventId, parameter, category
FROM log WHERE id > ? ORDER BY id ASC

The reason it’s id and not logtime is that logtime is skewed on older fnOS builds — which is exactly why LOGTIME_DISPLAY_OFFSET_SECONDS exists later on. Use a timestamp as a cursor and the first DST or timezone change either drops events or replays them.

Connections always go through connect_readonly_with_fallback(), opened read-only, because this is somebody else’s database. My process must not hold a write lock and stall fnOS’s own logging service.

Every row goes through four steps, and the order matters: map eventId to an internal type → apply special cases (SshdLoginAuthFail with uname=invalid splits into SSH_INVALID_USER, because fnOS crammed two meanings into one eventId) → filter (the ticked event list, the SSH source allow/deny lists) → _parse_parameter() to parse the JSON and add three provenance fields, _source, _source_cursor, _source_event_id. Without those three fields, every dedupe downstream stops working.

Aggregation: one message per poll

This is the most important decision in the project. N events read in one poll do not become N messages. They become one POLL_BATCH_SUMMARY:

batch_events = dedupe_poll_batch_items(batch_events)      # 批内指纹去重
summary_event_data = {
  "count": len(batch_events),
  "by_type": {"SSH_AUTH_FAILED": 1, ...},                 # 按类型计数
  "items": preview_items[:10],                            # 前 10 条摘要
  "grouped_events": grouped_events,                       # 勿扰汇总要用
  "_source_key": md5(拼接 source:cursor),                 # 渠道层去重用
}
self.notifier.send_notification(event_type="POLL_BATCH_SUMMARY", ...)

Why: a WeCom group bot caps out at 20 messages a minute. Power coming back after an outage, a bulk library import, an SSH brute-force run — any of those can produce dozens of events inside one poll. Sending them one by one doesn’t buy you timeliness. It gets you silently dropped by the rate limiter, and the one dropped is the one that mattered.

The raw rows still get written to local storage one at a time (_store_notification_log), so folding loses nothing: the message is the summary, the database has the history.

Three layers of dedupe, each blocking a different repeat

Dedupe isn’t “a window”. It’s three layers, each solving duplicates from a different source:

LayerImplementationWhat it blocks
L1, per-batch fingerprintbatch_dedupe.fingerprint_poll_batch_item()Duplicates from two threads enqueuing, or from a flush race
L2, time-window merging30s for disks / 5s for SSH / 60s of silence for media and musicThe same thing firing repeatedly (disk wake loops, brute force, bulk imports)
L3, channel-level 300s_is_duplicate()The catch-all for repeats across polls and across sources

L1 fingerprints are per-source. The Docker one has to include nanoseconds:

if source == "docker":
    # cid + action + timeNano;漏掉纳秒会把同容器短时间两次同类事件误合并
    return f"docker:{event_type}:{cid}:{action}:{engine_time_nano}"
row_id = item.get("row_id")
return f"{source}:{event_type}:row:{row_id}"

L3 is a dict with a lock and a timestamp:

def _is_duplicate(self, fingerprint: str) -> bool:
    with self._dedup_lock:
        if fingerprint in self.sent_events:
            if time.time() - self.sent_events[fingerprint] < self.dedup_window:  # 300
                return True
            del self.sent_events[fingerprint]
    return False

Failure semantics: advance the cursor only after delivery succeeds

Delivery and retry: failure is an explicit state

Three failure scenarios, three sets of semantics, all in the last few lines of _poll_once:

if self.batch_handler and batch_events and batch_delivery_failed:
    self._batch_fail_count += 1
    if persistence_failed or self._batch_fail_count < self.BATCH_MAX_ATTEMPTS:
        return last_id          # 不推进游标 → 下一轮重来
    self.logger.warning("批量处理连续失败 %s 次,放弃本批", self._batch_fail_count)
self._write_last_id(rows[-1]["id"])      # 只有走到这儿才提交游标
  • OutboxWriteError — couldn’t even write the queue — never advances the cursor. Re-reading next round beats letting an event disappear.
  • An ordinary exception gets BATCH_MAX_ATTEMPTS retries, then the batch is abandoned and the cursor moves on. One poison event shouldn’t wedge the whole pipe.
  • Every channel failing → into the outbox, retried after RETRY_SECONDS = 60. Only some channels failing → the batch is not resent. A duplicate message is likelier to get you uninstalled than a missed one.

The outbox isn’t an in-memory list, it’s a real queue:

self.path = directory / "notification_outbox.db"        # chmod 0600
CREATE TABLE state       (name TEXT PRIMARY KEY, payload TEXT NOT NULL)
CREATE TABLE poll_events (id   TEXT PRIMARY KEY, payload TEXT NOT NULL)
PRAGMA synchronous=FULL
BEGIN IMMEDIATE

Quiet hours don’t lose events either: counts go into the state table and get composed into one message at the end. The call also returns success=True, otherwise upstream thinks it failed and retries the thing it deliberately didn’t send. Fingerprint receipts are kept for 7 days so the morning digest doesn’t double-count.

Bugs I actually hit

None of these are invented. Each one maps to a fix commit in the repo.

SymptomRoot causeFix
Quiet hours off by 8 hours, silent by day, notifying at nightContainers default to TZ=UTC, quiet hours compare in local timeTZ=Asia/Shanghai in the Dockerfile and compose, plus LOGTIME_DISPLAY_OFFSET_SECONDS for skewed times in old databases
Scrolling halfway in the fnOS App Center (an iframe) gives a blank pageAn extra scroll container around the event list; nested scrolling inside an iframe trips a repaint bugDrop the inner scroll and let the iframe scroll
Mobile browser bounces back to the password page after loginMobile session cookie policy differences meant the session wasn’t sentLoosen session validation to accept the session wherever it’s presented, instead of relying on a single cookie attribute
Source self-check says fine, but no data is readThe read-only probe only ran SELECT 1; a half-corrupted database still returned successThe probe now reads real business tables and checks the contents, and hands you an ACL command when it fails
Events from different sources knock each other out, occasional missesThe dedupe key was just row_id, which collides across sourcesFingerprint is now source:type:row_id, isolated per source
Overwrite install wipes config and themeThe upgrade script wrote defaults unconditionallyWrite defaults only on a fresh install; preserve config.json on an overwrite
Music library unreadable with an empty configNo fallback to the standard path when no path was configuredFall back to the standard path and suggest the ACL command
SSH tests silently skipped in CIEnvironment detection failed without systemd, so the cases were skippedInject a mock into the probe; tests no longer depend on the host

Two more that aren’t bugs but are worth writing down. The container health check has to verify two things — the process is up and /healthz answers. A bare pgrep misses “process alive, service wedged”. And the health check must bypass the proxy, otherwise a 200 from the proxy masks a local backend that’s down:

if ! pgrep -f "python.*src/main.py" > /dev/null; then echo "Python进程不存在"; exit 1; fi
python - <<'PY'
from urllib.request import ProxyHandler, build_opener     # 显式禁用代理
host = {"0.0.0.0": "127.0.0.1", "::": "::1"}.get(host, host)
PY

Config and auth

There’s exactly one hand-edited source of config, config/config.json, and environment variables win:

VariablePurpose
LOGGER_DB_PATH / LOGGER_POLL_INTERVALDatabase path / poll interval (5s by default)
DEDUP_WINDOWChannel-level dedupe window (300s by default)
MONITOR_EVENTSThe ticked event list
HTTP_TIMEOUT / HTTP_POOL_SIZE / HTTP_RETRY_COUNTChannel HTTP parameters
UI_PORT / UI_HOST / FNMB_WEB_PASSWORDConfig page port and initial password
FNMB_TRUSTED_PROXIESOnly these IPs/CIDRs are trusted for X-Forwarded-For
LOGTIME_DISPLAY_OFFSET_SECONDSTime offset compensation for old databases

One implementation detail: any key set from the environment is recorded in _env_set_keys, and env_skip() skips those keys when the config file is read. “Something I set in env can’t be changed in the UI” is design, not a bug.

Auth comes in three layers. On first run you set the password using a one-time init code from the startup log — otherwise anyone on the same subnet could grab it first. Only a salt and a PBKDF2-HMAC-SHA256 hash are stored (ITERATIONS = 100000). Sessions live in memory, expire after 900 seconds idle, and a password change invalidates all of them.

Two distributions

Docker: python:3.11-slim plus four system packages — procps (so the health check can pgrep), nut-client (to read UPS state), smartmontools (to read SMART), systemd (for the journalctl binary that collects SSH events). HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=40s, with amd64 and arm64 built separately and the digests merged.

FPK: the manifest sets platform = all and checkport = false (the wizard picks the port). install_callback does four things: copy default config only on a fresh install; write the wizard password as salt + hash only, with chmod 600; build a venv and install dependencies (some fnOS volumes don’t support symlinks, so --copies first with a fallback to pip install --target); and hand out the Docker fallback URL if any of it fails. An overwrite install keeps the existing port and password.

Tradeoffs and known limits

ChoiceAlternativeWhy not
Poll SQLiteinotify / journalctl -fThe main database has no event stream; inotify only tells you a file was written, without the structured fields
One merged message per pollPush each event in real timeHits channel rate limits and takes the one that mattered down with the rest
One process, many threadsSplit ingestion and delivery into two containersOne more deployable unit, and a bigger blast radius
Hand-rolled SQLite outboxCelery / RedisAsking users to run another daemon on their own box is a tax
Fixed 60-second retryExponential backoffThe value of an event notification decays; a push 5 minutes late is worse than none
Least privilege by defaultJust run as rootIt’s installed on other people’s NAS boxes. My bug shouldn’t break their system

Known limits: 5 seconds is the floor on latency. No backfill — phone offline for two hours and you get nothing, go read the log database. Single-writer outbox — hundreds of events a day is fine, high throughput isn’t. And the event list is tied to the fnOS version: rename an eventId or change a table and it needs adapting, which is the main maintenance cost of this project.

Where to get it

  • 日志推送 in the fnOS App Center, or the .fpk from GitHub Releases
  • Docker: sunanang/fn-message-bots:latest (amd64 / arm64)
  • Source (MIT): github.com/Sunanang/FNMessageBots

Install, which events to tick, how to grant permissions: FnMessageBots: get your NAS to message you first