前言

Hack543 開站以來,一直跑在自己架設的 VPS 主機上,用 WordPress 寫文章。

架站很簡單,維護卻是另一回事。每個禮拜,我都要花時間升級佈景主題、更新 VPS 主機的軟體套件,還要留意網站上每個安裝元件的資安漏洞新聞,看看有沒有輪到自己中標。

長期下來,我有一半的力氣,都花在「讓這台主機能安全地上線」這件事上,而不是寫文章。

直到 nginx 與 php2shell 的漏洞發生後,我才發覺:在這樣的 AI 時代,找漏洞的速度只會越來越快。

以個人的 side project 來說,我維護的力氣,已經追不上目前漏洞被發現的速度,更何況很多還是 zero day。

與其繼續追著漏洞跑,不如換一個維護方式:把網站變成一個沒有資料庫、沒有後台、沒有 PHP 的靜態網站。沒有盲腸,就沒有盲腸炎。

所以我決定搬家,把 Hack543 從 WordPress 搬到 Hugo 靜態網站。

封面圖片出處:AI 生成。

先說清楚:這次搬家主要是 AI 完成的

這一篇要先老實交代:這次搬家,主要是靠 AI 完成的。

我用的是 AI 程式助理 (Claude Code)。從分析主機狀況、寫轉換程式、建置 Hugo 網站、客製化版面,到用 Playwright 逐篇驗證,大部分的工作都是 AI 做的,連這篇文章的初稿也是 AI 依照我的口吻寫的,我再修改。

我負責的部分,主要是「做決定」跟「需要主機權限的操作」。下面的步驟裡,凡是我自己動手做的,都會標註 【我手動完成】,其他的就是 AI 做的。

工作項目誰做的
決定要搬到 Hugo、佈署到 Cloudflare Pages、草稿與留言不搬我
開放主機防火牆,讓 AI 的工作環境可以直連 WordPress 主機我
在主機上安裝 WP-CLI、匯出文章、安裝缺少的 PHP 套件我(照 AI 給的指令)
打包圖片、把檔案交給 AI我
診斷錯誤訊息、發現主機憑證過期AI
撰寫 WordPress 轉 Hugo 的轉換程式AI
建置 Hugo 網站、首頁、側欄、文章頁範本AI
安裝 Playwright,逐篇比對內容、一對一比對 CSSAI
版面風格、底色、字型、標題字重等審美決定與驗收我
這篇文章的初稿AI,我修改
封面圖片AI

為什麼選 Hugo?

靜態網站產生器 (Static Site Generator) 有很多選擇,像是 Hugo、Astro、Jekyll 等。最後選了 Hugo,原因很簡單:

  • 一個執行檔就搞定:Hugo 是 Go 寫的,下載一個執行檔就能用,不用裝 Node.js,也不用擔心 npm 套件的供應鏈攻擊。對資安人來說,相依套件越少,睡得越安穩。
  • 建置速度超快:Hack543 幾十篇文章,建置不到一秒。
  • 程式碼高亮內建:我的文章有一大堆指令跟程式碼,Hugo 在建置時就會幫程式碼上色,不需要再載入 JavaScript。

佈景主題用的是 PaperMod,再由 AI 客製化首頁、側欄與文章頁,維持跟原本 Hack543 一樣的樣子,讓老讀者不會覺得走錯地方。

搬家步驟

接下來是實際的搬家步驟。

*以下操作請在自己的主機上進行,並記得先備份!

STEP 1

讓 AI 連得到 WordPress 主機 【我手動完成】

我的網站前面有 Cloudflare 代管,但 AI 的工作環境需要繞過 Cloudflare,直接連到 WordPress 主機抓資料、比對網頁。

我不想為此把主機整個打開,所以在主機防火牆上,只開放 AI 工作環境的對外 IP 當作白名單。搬家完成後,這條規則就撤掉了。

AI 連線時用 curl 的 --resolve 參數,把網域直接指到主機 IP,不需要改 DNS。

$ curl --resolve hack543.com:443:<主機 IP> https://hack543.com/

結果 AI 一連上去就發現:主機上的 SSL 憑證,早在兩個月前就過期了。因為前面有 Cloudflare 擋著,我完全沒察覺。

其實原因很單純:我之前把這台 VPS 關機了快三個月。主機沒開,憑證自動續期的排程當然也不會跑,重新開機以後,憑證早就過期了。

主機關機,維護也跟著停擺。

STEP 2

匯出 WordPress 文章 【我手動完成,照 AI 給的指令】

WordPress 文章可以用 WP-CLI 的 wp export 指令,匯出成 WXR 格式 (WordPress eXtended RSS) 的 XML 檔案。我在主機上安裝 WP-CLI 之後執行匯出。

$ cd /var/www/html
$ wp export --dir=/tmp/

結果第一個坑馬上就來了。

Error: Strange wp-config.php file: wp-settings.php is not loaded directly.

這個錯誤訊息看起來像是 wp-config.php 有問題。我把 ls -l 的結果貼給 AI,AI 一看就判斷出原因:wp-config.php 的權限是 400,只有 www-data 讀得到。WP-CLI 讀不到檔案內容,才報了這個看起來八竿子打不著的錯誤。

$ ls -l wp-config.php
-r-------- 1 www-data www-data 4484 Mar  4  2022 wp-config.php

wp-config.php 裡面有資料庫密碼,權限設成 400 是對的,不要為了匯出就把權限打開。改用 www-data 的身分執行就好。

$ sudo -u www-data wp export --dir=/tmp/

第二個坑接著來。

PHP Fatal error:  Uncaught Error: Class "DOMDocument" not found

AI 判斷是主機的 PHP 沒有裝 XML 擴充套件,我裝了以後就解決了。

$ sudo apt install php-xml

如果你沒有主機的 shell 權限,也可以直接從後台匯出:「工具」→「匯出」→「所有內容」,產出的 XML 檔案跟 wp export 一樣。

STEP 3

打包圖片 【我手動完成】

文章裡的圖片都放在 wp-content/uploads 資料夾裡,直接打包,連同 XML 檔一起交給 AI。

$ tar czf uploads.tar.gz -C /var/www/html/wp-content uploads

這裡有個資安小提醒:uploads 資料夾裡不是只有圖片!

AI 在解壓縮之前,先檢查了壓縮檔的內容,發現裡面還混了資安外掛的紀錄檔,內容有登入失敗紀錄、登入成功紀錄等資料。外掛的快取、表單上傳的檔案,也都放在這裡。

所以 AI 只取出「年份資料夾」裡的圖片,其他的一律不碰。這些東西如果一起推到 GitHub,萬一 repo 哪天設成公開,那就真的是自己把資料送給駭客了。

STEP 4

轉換成 Markdown

接下來要把 XML 裡的文章,轉成 Hugo 用的 Markdown 檔案。AI 寫了一支 Python 轉換程式來處理,過程中踩到好幾個坑,這邊特別拿出來分享。

坑一:XML 解析失敗

XML 一讀進來就報錯。

xml.etree.ElementTree.ParseError: not well-formed (invalid token): line 4851, column 43

追進去看,是某一篇文章裡混了一個看不見的退格字元 (Backspace,0x08),大概是當年從別的地方複製貼上時帶進來的。XML 1.0 規格不允許這種控制字元,解析前先濾掉就好。

坑二:程式碼區塊吞掉後半篇文章

有一篇文章轉完之後,後半段的標題、圖片跟內文全部不見了。

原因是那篇文章在引用區塊 (blockquote) 裡面放了程式碼區塊。轉成 Markdown 時,只有程式碼的第一行有加上引用的 > 符號,後面幾行沒有,結果程式碼區塊永遠沒有結束,把後面整篇文章都吞進去了。

坑三:整行文字憑空消失

C2 複刻系列有一篇文章,清單裡有這麼一行:

- [esp]: 解密完成的字串會放在此變數中

轉完之後,這一行在網頁上直接消失。

原來 [esp]: 文字 這種寫法,在 Markdown 裡剛好是「連結參照定義」(Link Reference Definition) 的語法,Markdown 把它當成連結定義吃掉了,不會顯示出來。解法是把一般文字裡的 [ 跳脫成 \[。

這種 bug 很難用肉眼發現,坑二跟坑三,都是靠下一個步驟抓出來的。

STEP 5

用 Playwright 逐篇比對內容

38 篇文章、161 個程式碼區塊,一篇一篇用眼睛比對,眼睛會脫窗。

我請 AI 自己安裝 Playwright 來驗證。AI 在工作環境裡裝好 Playwright 跟 Chromium 瀏覽器,連瀏覽器缺少的系統函式庫、顯示中文需要的字型,都自己補齊了。

接著 AI 寫了一支比對程式,用 Playwright 同時打開舊的 WordPress 跟新的 Hugo 網站,一篇一篇比對:

  • 標題與段落標題是否一致
  • 程式碼區塊數量,以及每一塊的內容長度是否一致
  • 舊網站的每一段文字,在新網站是否都找得到
  • 圖片是否都正常顯示
  • 手機版與桌面版是否有版面跑掉

第一輪比對就抓出了坑二跟坑三。修到最後,38 篇文章全部通過,161 個程式碼區塊一個都沒少。

STEP 6

用 Playwright 一對一比對 CSS

內容對了,接下來是版面。我希望新網站看起來跟舊網站一樣。

一開始 AI 是看截圖調整版面,但截圖看久了,總有一些「好像哪裡怪怪的」地方,例如首頁標題的字重跟文章頁不一樣,就是我自己看出來的。

所以我請 AI 換個方法,把 CSS 一對一比對清楚。AI 的做法是:

  1. 在舊網站跟新網站上,用同一段文字找到對應的元素,例如同一篇文章的標題、同一個「閱讀更多」按鈕、同一個側欄標籤
  2. 用 Playwright 讀出兩邊元素實際套用的樣式 (Computed Style)
  3. 逐一比對 24 種屬性,包括字型、字級、字重、行高、顏色、底色、內外距、邊框等

範圍涵蓋首頁、文章頁、分類頁、頁首、頁尾與側欄,一共 40 個比對點。

第一輪比對,有 39 個比對點不一致,大部分是當初看截圖估出來的數值不夠精準,例如藍色其實有兩種、摘要的字級其實是 20px、目前頁碼其實是橘色。修正後剩下 15 個,AI 逐項確認過,不是畫面上看不出差別的結構差異,就是刻意保留的設計。

唯一刻意不一樣的地方,是程式碼區塊的字型。AI 比較了幾款等寬字型給我看,我選了 JetBrains Mono,0 跟 O、1 跟 l 比較好分辨,看指令比較不會看錯。【這個決定是我做的】

STEP 7

保留原本的網址

搬家最怕的就是網址改變。Google 搜尋結果、別人文章裡的連結,全部都會變成 404。

Hugo 可以設定 permalinks,讓文章網址維持跟 WordPress 一樣的 /文章代稱/ 格式。

permalinks:
  page:
    posts: /:slug/

分類頁的 /category/分類名稱/、圖片的 /wp-content/uploads/... 路徑,也都照原樣保留。舊的 RSS 網址 /feed/,則用 Cloudflare Pages 的 _redirects 檔案轉址到新的 /index.xml。

/feed/        /index.xml   301
/author/*     /            301

佈署:GitHub 私人 repo + Cloudflare Pages

網站轉好了,接下來要決定放在哪裡。我選的組合是:

  • GitHub 私人 repo:放網站原始碼,文章就是 Markdown 檔案,所有修改都有 Git 紀錄
  • Cloudflare Pages:每次 push 到 GitHub,就自動建置並佈署到全球 CDN

私人 repo 也能接 Cloudflare Pages。連接時,Cloudflare 會請你在 GitHub 安裝 Cloudflare Pages 的 GitHub App,授權範圍記得選「Only select repositories」,只開放部落格這一個 repo,Cloudflare 就看不到其他 repo。

Cloudflare Pages 的建置設定很簡單:

Framework preset : Hugo
Build command    : hugo --gc --minify
Output directory : public
環境變數          : HUGO_VERSION = 0.168.0

記得用 HUGO_VERSION 指定 Hugo 版本,讓 Cloudflare 建置時用的版本,跟自己電腦上的一樣,不然哪天 Hugo 改版,網站可能就莫名其妙壞掉。

為什麼選私人 repo?

理由很簡單:免得麻煩。

網站本身是公開的,但網站的原始碼不一定要公開。用公開 repo,每次 commit 都要想一下:這個檔案能不能給人看?草稿會不會被看到?Git 紀錄裡有沒有不該出現的東西?

用私人 repo,這些麻煩就少了一大半,草稿也可以放心 commit。

不過私人 repo 不是保險箱,該注意的事一樣要注意。

  • 不要放任何密碼、Token、API Key:Cloudflare 的設定請放在 Cloudflare 後台的環境變數
  • WordPress 匯出的 XML 跟 uploads 打包檔不要 commit:XML 裡有作者帳號等資訊,uploads 打包檔則可能混了外掛的紀錄檔
  • commit 的作者 email 用 GitHub 的 noreply 地址:萬一哪天 repo 改成公開,Git 歷史紀錄裡的 email 就會跟著曝光
  • commit 之前先檢查一次:git status、git diff --cached 養成習慣

如果你想用公開 repo,上面這些就更重要了:推上去的東西全世界都看得到,而且 Git 歷史紀錄會永遠留著。

另外,AI 也在 Cloudflare Pages 的 _headers 檔案加上了基本的安全性標頭 (Security Headers)。

/*
  X-Content-Type-Options: nosniff
  X-Frame-Options: DENY
  Referrer-Policy: strict-origin-when-cross-origin
  Strict-Transport-Security: max-age=31536000

切換以後

Cloudflare Pages 上線、網域切換完成,原本的 WordPress 主機就功成身退了。我先撤掉給 AI 用的防火牆白名單,關掉主機對外的 80、443 通訊埠,觀察一陣子沒問題之後,再把主機整個關掉。

那張過期兩個月的憑證,也終於不用再管它了。

結論

這次搬家,我自己動手的部分其實不多,大部分的時間,我是在「做決定」跟「驗收」。

AI 做得又快又細,連我自己都沒注意到的過期憑證、混在 uploads 資料夾裡的登入紀錄、會讓一整行文字消失的 Markdown 語法,都是 AI 抓出來的。

但也不是全部丟給 AI 就好。要不要開防火牆、開給誰、哪些資料不能外流、網站要長什麼樣子,這些還是得自己判斷。AI 的速度很快,人要負責踩煞車跟把關方向。

回頭看前言說的那件事:AI 讓找漏洞變快了,也讓修東西、搬家變快了。與其用個人的力氣去追漏洞,不如用 AI 幫自己把攻擊面整個拿掉。

現在,Hack543 是一個沒有資料庫、沒有後台、沒有 PHP 的靜態網站了。如果你也有一個 WordPress 網站,也可以考慮這樣做。