標籤: 推播服務

自架推播服務 ntfy:從安裝、認證到 HTTPS,一路踩坑記

No Comments

手機上裝的那個 MQTT IoT Panel,最近查出一個限制:App 在背景時不會跳出即時通知,要打開 App 才看得到訊息,官方說法是「即時彈出通知」這個功能只有付費 Pro 版才有。與其為了這一個功能付費升級,不如自己架一套推播服務,順便補上「巡邏找到異常,能不能直接推播到手機」這個一直沒解的缺口。這篇記錄用 ntfy 這套開源推播服務,在一台原本閒置的舊機器上從零架起來的過程。

為什麼選 ntfy

ntfy(讀作 “notify”)是一套很單純的 pub-sub 推播服務:你發訊息到某個「topic」,訂閱這個 topic 的手機/瀏覽器就會收到推播。不需要註冊帳號、不需要寫 App,官方就有現成的 Android/iOS App,訂閱時填伺服器網址跟 topic 名稱即可。自己架設也很單純,官方直接提供 .deb 安裝檔,裝完就是一個 systemd 服務。

安裝

官方在 GitHub 發布 .deb 安裝包,直接下載安裝即可,不需要自己編譯:

curl -sL -o /tmp/ntfy.deb https://github.com/binwiederhier/ntfy/releases/download/v2.28.0/ntfy_2.28.0_linux_amd64.deb
sudo dpkg -i /tmp/ntfy.deb

裝完會多一個 ntfy.service,設定檔在 /etc/ntfy/server.yml,預設完全沒有設定(整份都是註解),什麼都不做的話,啟動後就是一個「任何人都能讀寫任何 topic」的公開服務。

認證:別重蹈 MQTT 的覆轍

前陣子處理手機 MQTT 通知時學到一個教訓:「topic 名稱取得夠複雜、夠難猜」不是真的安全,只要有人拿一組合法憑證訂閱萬用字元(例如 #),就能把所有訂得到的內容全部收下來,複雜路徑防得了「亂猜」,防不了「知道帳密的人訂閱一切」。這次架 ntfy,一開始就用正規的帳號密碼機制,不靠 topic 名稱藏東西。

設定檔加上這三行,先把預設權限鎖死:

# /etc/ntfy/server.yml
base-url: "http://你的網域或IP"
listen-http: ":80"
auth-file: "/var/lib/ntfy-server/auth.db"
auth-default-access: "deny-all"

auth-default-access: "deny-all" 這行是關鍵——沒有明確授權的 topic,一律拒絕讀寫,包括匿名訪客。存檔後重啟服務:

sudo systemctl enable --now ntfy
sudo systemctl restart ntfy

接著建立帳號、授權特定 topic。密碼可以用環境變數傳,不用互動輸入,方便寫進腳本:

# 建一個管理員帳號(全 topic 讀寫)
sudo NTFY_PASSWORD='你的密碼' ntfy user add --role=admin ken_admin

# 建一個一般帳號,只給特定 topic 權限
sudo NTFY_PASSWORD='你的密碼' ntfy user add ken
sudo ntfy access ken alerts rw

查目前的權限設定:

sudo ntfy user list

踩坑:防火牆把自己擋在外面

服務啟動、帳號也建好了,從機器本身測試(curl http://localhost/alerts)一切正常,正確回應 403(沒帶帳密的匿名請求被拒絕)。但從另一台機器透過公網 IP 測試,卻是整個卡住、逾時沒有任何回應——不是被拒絕,是連不上。

查了才發現:這台機器的 UFW 防火牆一直都是啟用狀態,只開放了 22(SSH),80 port 完全沒開。從外部連進來的封包直接被防火牆丟掉,連線端只會看到逾時,不會收到任何「拒絕」的訊息,第一時間很容易誤判成程式本身沒回應,其實是更前面那一層防火牆的問題。補上規則就正常了:

sudo ufw allow 80/tcp

教訓:機器本身測試正常、跨機器測試卡住不回應(不是明確被拒絕),要優先懷疑防火牆,而不是程式本身的問題。

測試

確認匿名存取被拒絕、帳號能正常發送/讀取:

# 匿名發送,應該回 403
curl -d "test" http://你的網域或IP/alerts

# 用帳密發送,應該成功
curl -u ken:你的密碼 -d "測試推播" http://你的網域或IP/alerts

手機端設定

手機裝官方 ntfy App(Android/iOS 都有),新增伺服器,網址填自己架的網址,訂閱 topic 填 alerts,登入時輸入剛建立的帳密,就能收到推播了。

補上網域名稱與 HTTPS

純 HTTP+IP 直連堪用,但正式長期使用還是配一個網域名稱、走 HTTPS 比較妥當。剛好手上有一個放了很久沒真正用起來的網域,拿來給這台機器用正合適。

意外插曲:網域擱置太久,DNS 代管其實從沒真正啟用過

網域是在 HiNet 註冊的。原本以為只要在管理後台隨便找個地方填一筆 A 記錄,等個幾十分鐘傳播完就會生效——結果查了老半天,用 DNS-over-HTTPS 直接問權威名稱伺服器(用 curl 打 Cloudflare/Google 的 DoH API,避開 sandbox 環境對外部 UDP:53 查詢的限制),得到的答案是 REFUSED:

curl -s "https://cloudflare-dns.com/dns-query?name=你的網域&type=A" -H "accept: application/dns-json"

REFUSED 跟一般「還在傳播中」的查不到不一樣——它代表那台伺服器根本沒有這個網域的任何設定資料,不是「還沒收到」,是「壓根不知道有這回事」。半小時後重查依然一樣,排除純粹等待的可能。

查證後發現:那兩台被拒絕回答的伺服器,其實正是 HiNet 官方的 admns1.hinet.net/admns2.hinet.net——代表「名稱伺服器指到哪裡」這一步其實老早就設定對了。真正的問題在於 HiNet 的網域管理後台把「DNS 異動與查詢」(設定名稱伺服器)跟「DNS 代管設定」(真正填 A 記錄等內容的地方)分成兩個獨立頁面——網域擱置多年、從未真正用過,這台代管伺服器上等於完全是空的,難怪查什麼都是 REFUSED。找到真正該填資料的那個頁面重新設定後,才總算正常解析。

教訓:網域名稱的 DNS 設定,「名稱伺服器指對了」跟「底下真的有資料代管」是兩件事,兩者都要確認,光看名稱伺服器指到哪裡不夠。遇到 REFUSED(不是 NXDOMAIN、不是查不到)時,先往「代管服務本身是不是沒被啟用」這個方向想,而不是預設是傳播延遲。

nginx 反向代理

DNS 生效後,把 ntfy 改成只聽本機的一個內部埠號,把 80/443 讓給 nginx:

# /etc/ntfy/server.yml
base-url: "https://你的網域"
listen-http: "127.0.0.1:2586"
auth-file: "/var/lib/ntfy-server/auth.db"
auth-default-access: "deny-all"

nginx 反向代理設定,ntfy 用長連線做即時推播,除了一般反代設定外,要記得加上 WebSocket/關閉緩衝/拉長逾時時間這幾項,不然推播會斷斷續續:

server {
    listen 80;
    server_name 你的網域;

    location /.well-known/acme-challenge/ {
        root /var/www/acme-challenge;
    }

    location / {
        return 301 https://$host$request_uri;
    }
}

server {
    listen 443 ssl;
    server_name 你的網域;

    ssl_certificate /etc/nginx/ssl/你的網域.crt;
    ssl_certificate_key /etc/nginx/ssl/你的網域.key;

    location / {
        proxy_pass http://127.0.0.1:2586;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";

        proxy_buffering off;
        proxy_request_buffering off;
        client_max_body_size 20m;

        proxy_read_timeout 3600s;
        proxy_send_timeout 3600s;
    }
}

憑證:試試看 Let’s Encrypt 以外的選擇——ZeroSSL

說到免費憑證,大家直覺就是 Let’s Encrypt+certbot。這次刻意換一組沒用過的組合:acme.sh(輕量的 shell script 版 ACME 客戶端,不需要 Python,很適合這種資源有限的機器)搭配 ZeroSSL(另一家免費簽發 90 天憑證的 CA,跟 Let’s Encrypt 是不同機構)。

curl -s https://get.acme.sh | sh -s email=你的信箱

sudo /home/你的帳號/.acme.sh/acme.sh --set-default-ca --server zerossl --home /root/.acme.sh
sudo /home/你的帳號/.acme.sh/acme.sh --register-account -m 你的信箱 --server zerossl --home /root/.acme.sh
sudo /home/你的帳號/.acme.sh/acme.sh --issue -d 你的網域 --webroot /var/www/acme-challenge --home /root/.acme.sh

裝進 nginx,並設定 nginx reload 為續約後自動執行的動作:

sudo mkdir -p /etc/nginx/ssl
sudo /home/你的帳號/.acme.sh/acme.sh --install-cert -d 你的網域 --home /root/.acme.sh 
  --key-file /etc/nginx/ssl/你的網域.key 
  --fullchain-file /etc/nginx/ssl/你的網域.crt 
  --reloadcmd "systemctl reload nginx"

踩坑:續約排程裝到錯的帳號家目錄

安裝 acme.sh 那一步是用一般帳號執行的,它會自動幫你把續約排程寫進那個帳號自己的 crontab,指向那個帳號自己的 ~/.acme.sh 家目錄。但簽發憑證、裝進 nginx 這幾步是用 sudo 執行、資料實際存放在 /root/.acme.sh——兩個路徑對不起來,等於排程永遠檢查一個空的地方,續約永遠不會真的發生,卻不會有任何錯誤提示。

解法是把續約排程改成用 root 的 crontab、且路徑指到真正存放憑證的家目錄:

sudo crontab -e
# 加入這一行(時間可依需求調整):
24 1,7,13,19 * * * /home/你的帳號/.acme.sh/acme.sh --cron --home /root/.acme.sh > /var/log/acme-renew.log 2>&1

教訓:只要「用一般帳號安裝工具」跟「用 sudo 實際操作」混著用,工具自己幫你寫的排程、設定路徑,很容易悄悄對不上,而且不會報錯——事後要驗證的話,直接看 crontab 裡實際寫的路徑,跟你真正操作時用的路徑是不是同一個。

最終驗證

# HTTP 應該自動導向 HTTPS
curl -I http://你的網域/

# 匿名發送應該被拒絕(403)
curl -d "test" https://你的網域/alerts

# 用帳密發送應該成功
curl -u ken:你的密碼 -d "測試" https://你的網域/alerts

全部通過,一套完整、有正規帳密驗證、走 HTTPS 的自架推播服務就這樣上線了。之後巡邏找到的異常、安全性事件,都可以直接推播到手機,不用再等被動去翻留言板。

順手再多做一件事:看門狗的看門狗

服務上線後,回頭想了一下:目前所有的巡邏、掃描、監控,通通是從主要那台網站主機自己身上跑出來的。萬一哪天那台主機整個當機、斷網,或硬體出問題,不會有任何東西發現、更不會通知人——因為負責監控的人自己先倒下了。

這台跑 ntfy 的機器剛好是完全獨立的另一台實體主機、另一條網路路徑,很適合拿來當這個「旁觀者」的角色——順手寫一支簡單的看門狗腳本,定期檢查那幾個網站是否正常,狀態改變(正常→異常,或異常→恢復)才推播一次,不會因為持續異常就每隔幾分鐘轟炸一次通知:

#!/usr/bin/env python3
import json, os, subprocess, time

STATE_FILE = "/var/lib/site-watchdog/state.json"
NTFY_URL = "https://你的網域/alerts"
NTFY_AUTH = ("帳號", os.environ.get("NTFY_ADMIN_PASSWORD", ""))
SITES = {"站台1": "https://...", "站台2": "https://..."}

def check(url):
    out = subprocess.run(
        ["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}", "--max-time", "10", url],
        capture_output=True, text=True, timeout=15,
    )
    return out.stdout.strip() == "200", out.stdout.strip()

def notify(message, priority=None):
    args = ["curl", "-s", "-u", f"{NTFY_AUTH[0]}:{NTFY_AUTH[1]}"]
    if priority:
        args += ["-H", f"Priority: {priority}"]
    args += ["-d", message, NTFY_URL]
    subprocess.run(args, capture_output=True, timeout=15)

def main():
    state = json.load(open(STATE_FILE)) if os.path.exists(STATE_FILE) else {}
    for name, url in SITES.items():
        ok, detail = check(url)
        prev = state.get(name)
        if prev is None:
            state[name] = {"up": ok}
            continue
        if prev["up"] != ok:
            notify(f"[看門狗] {name} {'已恢復正常' if ok else '連不上或異常!'}({url})",
                   priority=None if ok else "high")
            state[name] = {"up": ok}
    os.makedirs(os.path.dirname(STATE_FILE), exist_ok=True)
    json.dump(state, open(STATE_FILE, "w"))

if __name__ == "__main__":
    main()

密碼不寫死在腳本裡,另外存到一個權限收緊的環境變數檔案:

echo "NTFY_ADMIN_PASSWORD=你的密碼" | sudo tee /etc/site-watchdog.env
sudo chmod 600 /etc/site-watchdog.env

排進 root 的 crontab,每 5 分鐘跑一次:

sudo crontab -e
# 加入這一行:
*/5 * * * * . /etc/site-watchdog.env; export NTFY_ADMIN_PASSWORD; /usr/bin/python3 /opt/site-watchdog/site_watchdog.py

測試方式:手動把狀態檔裡某個站台的狀態改成假的(例如改成「原本是異常」),重跑一次腳本,看它是不是正確判斷「狀態改變了」並推播出「已恢復正常」;反過來也可以拿一個真的連不上的網址試「異常」那條路徑。兩條路徑都測過,收到推播,就代表這隻看門狗真的在盯著了。

這算是「看門狗的看門狗」——多一層獨立於主機之外的保險,跟這幾天處理的資安主題(後門帳號、持續攻擊)算是同一條精神:不要讓監控機制本身變成單點故障。

Categories: 未分類

Tags: ,

PHP Code Snippets Powered By : XYZScripts.com