Share Notes

chundev

View the Project on GitHub latteouka/share-notes

Node.js Axios 與 Proxy 環境變數的三個陷阱

日期:2026-04-08 環境:Ubuntu + Node.js 18 + Axios + PM2 + Squid Proxy


TL;DR

部署在有 Squid proxy 的封閉網路環境中,Node.js 應用程式的內網 API 請求莫名失敗,回傳 403 ERR_ACCESS_DENIED。根本原因是三個問題疊加:(1) 系統設了 http_proxy 但沒設 no_proxy,導致內網請求被送到 Squid;(2) axios 底層的 proxy-from-env 不支援 CIDR 格式的 no_proxy;(3) PM2 的環境變數快照機制讓修復無法持久化。


背景

一個 Next.js 應用部署在封閉內網中,需要呼叫同網段內的認證服務(HTTPS API)。同時,這台主機透過 Squid proxy 連到外部網路寄送告警信件。系統在 /etc/environment 設定了 http_proxyhttps_proxy 指向 Squid,但沒有設定 no_proxy

某天使用者回報「無法登入」,但應用本身運行正常、資料庫連線正常,從外部完全無法判斷原因。


問題 / 錯誤訊息

使用 axios 打內網 HTTPS API 時,收到的回應不是預期的 JSON,而是一個 HTML 頁面:

<body id=ERR_ACCESS_DENIED>
<h1>ERROR</h1>
<h2>The requested URL could not be retrieved</h2>
<p><b>Access Denied.</b></p>
<p>Generated by localhost (squid/6.14)</p>
</body>

HTTP status code: 403

但程式碼裡的 catch 區塊把所有錯誤都吞掉了,只回傳 false

catch (error) {
  console.log(error);  // 印在 server log,外部看不到
  return false;         // 認證服務掛了 = 密碼錯誤,無法區分
}

除錯過程

步驟 做法 結果 分析
❌ 1 直接 curl 目標 IP:port HTTP 000, timeout 以為主機掛了
❌ 2 no_proxy=192.168.0.0/16(CIDR) 仍被 Squid 攔截 axios 不認 CIDR
✅ 3 nc -zv 測 TCP port succeeded 確認主機活著
✅ 4 curl 加 --noproxy '*' EHOSTUNREACH 確認是走 proxy 才通(因為 proxy 和目標同網段)
✅ 5 axios 加 proxy: false EHOSTUNREACH 確認 axios 預設走了 proxy
✅ 6 no_proxy 改用具體 IP 200 OK 解決

陷阱一:proxy-from-env 不支援 CIDR

Axios 透過依賴鏈 axios → follow-redirects → proxy-from-env 來解析 proxy 環境變數。proxy-from-env 支援的 no_proxy 格式:

格式 範例 支援?
完整 hostname api.internal.com
域名後綴 .internal.com
具體 IP 192.168.1.100
萬用字元 *
CIDR 192.168.0.0/16
IP 範圍 192.168.1.1-192.168.1.255

CIDR 格式(如 192.168.0.0/16)會被當成字面字串比對,永遠不會匹配任何實際 IP。

正確寫法:

# ❌ 不會生效
no_proxy=192.168.0.0/16,10.0.0.0/8

# ✅ 逐一列出需要排除的 IP
no_proxy=localhost,127.0.0.1,192.168.160.99,192.168.98.59

# ✅ 如果內網用域名,可以用後綴匹配
no_proxy=localhost,127.0.0.1,.internal.local

注意: cURL、wget、Python requests 等工具對 no_proxy CIDR 的支援各不相同。cURL 7.86+ 支援 CIDR,但 Node.js 生態系普遍不支援。不要假設所有工具的行為一致。

參考:


陷阱二:PM2 環境變數快照不會自動更新

PM2 在第一次 pm2 start 時會快照所有環境變數,存入 ~/.pm2/dump.pm2。之後的行為:

操作 環境變數來源
pm2 restart app dump 快照(不讀當前 shell
pm2 restart app --update-env 當前 shell 環境變數
pm2 resurrect(重開機) dump 快照
pm2 save 把當前 runtime 的環境變數寫入 dump

這表示: 修改 /etc/environment 後,即使 SSH 新 session 能讀到新值,PM2 管理的 process 仍然用舊值。重開機後 pm2 resurrect 恢復的也是舊的 dump。

完整的環境變數更新 SOP:

# 1. 修改系統環境變數
sudo vim /etc/environment

# 2. 在當前 shell export(/etc/environment 需要新 session 才生效)
export no_proxy=localhost,127.0.0.1,192.168.160.99
export NO_PROXY=localhost,127.0.0.1,192.168.160.99

# 3. 重啟 PM2 process 並更新快照
pm2 restart all --update-env
pm2 save

# 4. 驗證 dump 內容
cat ~/.pm2/dump.pm2 | python3 -c '
import json, sys
for app in json.load(sys.stdin):
    print(f"{app[\"name\"]}: no_proxy={app.get(\"env\",{}).get(\"no_proxy\",\"NOT SET\")}")
'

少了 pm2 save 這一步,重開機就會打回原形。


陷阱三:錯誤處理吞掉了關鍵資訊

原始程式碼把所有 HTTP 錯誤(包括 Squid 的 403)都當成「認證失敗」處理:

// ❌ 壞的模式:所有錯誤都變成同一個結果
try {
  const response = await axios.post(ideUrl, credentials);
  if (response.data?.errorCode !== 0) throw new Error("INVALID");
  return true;
} catch (error) {
  console.log(error);  // 只印在本地,外部看不到
  return false;         // Squid 403 = IDE 掛了 = 密碼錯 = 全部一樣
}

更好的做法是區分「系統錯誤」和「業務錯誤」:

// ✅ 區分錯誤類型
try {
  const response = await axios.post(ideUrl, credentials, { timeout: 10000 });
  if (response.data?.errorCode !== 0) {
    return { ok: false, reason: "invalid_credentials" };
  }
  return { ok: true };
} catch (error) {
  const code = error.code; // ECONNREFUSED, ECONNABORTED, EHOSTUNREACH...
  const status = error.response?.status; // 403, 502...

  // 寄出告警信 — 這是系統問題,不是使用者的錯
  await sendAlert(`認證服務異常: ${code ?? `HTTP ${status}`}`);

  return { ok: false, reason: "system_error", detail: code ?? status };
}

這樣外部至少能收到告警信知道「認證服務掛了」,而不是猜測使用者是不是打錯密碼。


學到的事


參考資料