Add CyberPanel v2 API reverse-engineering post (EN + ZH)
Deploy / build (push) Successful in 44s

This commit is contained in:
2026-09-09 07:53:07 +08:00
parent 618b01e4bd
commit 6aa9105460
6 changed files with 403 additions and 0 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

+20
View File
@@ -208,6 +208,26 @@ const BANNERS = {
{ n: '5', label: 'verdict ✓' },
],
},
'automating-cyberpanel-without-the-ui': {
titlebar: 'root@cyberpanel — reverse-engineering the v2 API',
lines: [
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'curl -X POST .../api/verifyConnection' },
{ t: 'err', text: '404 — the /api/ prefix was dropped in v2' },
{ t: 'dim', text: 'docs say adminUser/adminPass · panel says "This request need session."' },
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'GET / → csrftoken → POST /verifyLogin (X-CSRFToken)' },
{ t: 'hl', text: 'loginStatus: 1 · session cookie set' },
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'POST /websites/fetchWebsitesList' },
{ t: 'ok', text: '→ all sites + SSL expiry ✓ (no UI)' },
],
flow: [
{ n: '1', label: 'docs 404', err: true },
{ n: '2', label: 'session wall' },
{ n: '3', label: 'CSRF token' },
{ n: '4', label: 'verifyLogin' },
{ n: '5', label: 'sites ✓' },
],
},
};
const DEFAULT_BANNER = {
+5
View File
@@ -78,6 +78,11 @@ const TERMINALS = {
<div class="line"><span class="prompt">$</span><span class="cmd">fan-out → 3 subagents × 20 providers</span></div>
<div class="line"><span class="prompt">&nbsp;</span><span class="fix">whois · AUP · reviews · retention — in parallel</span></div>
<div class="line"><span class="prompt">$</span><span class="cmd">merge scorecard · rank · audit trail</span><span class="fix">→ verdict ✓</span></div>`,
'automating-cyberpanel-without-the-ui': `
<div class="line"><span class="prompt">$</span><span class="cmd">POST /api/verifyConnection</span></div>
<div class="line"><span class="prompt">&nbsp;</span><span class="err">404 · API prefix dropped in v2</span></div>
<div class="line"><span class="prompt">$</span><span class="cmd">csrftoken → /verifyLogin → fetchWebsitesList</span><span class="fix">→ sites ✓</span></div>`,
};
const DEFAULT_TERMINAL = `
@@ -0,0 +1,201 @@
---
title: "Automating CyberPanel Without the UI: Reverse-Engineering an Undocumented v2 API"
description: "CyberPanel v2 killed the documented JSON API and left only the Angular UI's own session+CSRF endpoints. Here's the exact auth flow and how I found the real functions behind the pages."
pubDate: 2026-09-09
category: devops
tags: [cyberpanel, api, reverse-engineering, automation, django, self-hosting, curl]
ogImage: /og/automating-cyberpanel-without-the-ui.png
banner: /banners/automating-cyberpanel-without-the-ui.png
---
I run CyberPanel 2.4.4.1 inside an Ubuntu VM on my home lab to host a handful
of small sites. It sits at `https://panel.hoelee.com`, reverse-proxied to an
internal box. Recently I wanted to script two things against it — list my
sites and delete one — without opening the browser. This is the story of
finding that the "official" API doesn't exist anymore, and reverse-engineering
the real one from what the Angular UI quietly calls under the hood.
## The problem: the documented API is gone
Every search result, the Apiary docs, and the Knowledge Base point you at the
same thing — a JSON API where you `POST` your `adminUser` and `adminPass` to
`/api/verifyConnection`:
```bash
# What the old docs tell you to do. This does NOT work on CyberPanel v2.
curl -k -X POST https://panel.hoelee.com:8090/api/verifyConnection \
-d '{"adminUser":"admin","adminPass":"...","serverUserName":"..."}'
```
On CyberPanel v2 (2.4.x), this returns nothing useful. The `/api/` prefix is
the legacy surface — it was dropped. There's no `adminUser`/`adminPass`
exchange anymore. The official documentation is simply out of date, which is
the first trap: you can spend a long time trusting docs that describe a
version you're not running.
## What I tried, and why it failed
**Attempt 1 — trust the docs.** `POST /api/verifyConnection` with credentials,
exactly as Apiary says. Result: 404. The route doesn't exist.
**Attempt 2 — hammer the obvious paths.** I tried `/api/`, `/verifyLogin` with
an `email` field, a bare `GET` on a few guesses. I got back a response I kept
misreading:
```
"This request need session."
```
That message is actually *good news* — it means the API **is** mounted and
reachable, it just refuses to talk without a valid session. The endpoint isn't
missing; the auth is different.
**Attempt 3 — the CSRF wall.** I finally sent the right fields to
`/verifyLogin` and hit a hard **403 Forbidden**. That's the part that stalls
most people: POSTs to a Django-backed panel are protected by a CSRF token, and
a bare JSON POST with no token gets dropped before your credentials are even
looked at.
The breakthrough was treating the panel not as "a thing with an API" but as
**a Django app with an Angular front-end** — and then just watching what the
front-end sends.
## The fix: the real auth flow
The whole thing is three steps, all with plain `curl` (and `-k` since the
panel uses a self-signed cert):
### 1. Grab the CSRF token from the login page
Django sets a `csrftoken` cookie on the very first GET. That cookie's value is
the token you echo back in a header on every write:
```bash
curl -sk -c /tmp/cp_cookies.txt "https://192.168.1.124:8090/" -o /dev/null
CSRF=$(grep csrftoken /tmp/cp_cookies.txt | awk '{print $7}')
```
### 2. Log in to get a session cookie
`POST /verifyLogin` with a JSON body and the CSRF token in an `X-CSRFToken`
header:
```bash
curl -sk -b /tmp/cp_cookies.txt -c /tmp/cp_cookies.txt \
-X POST "https://192.168.1.124:8090/verifyLogin" \
-H "Content-Type: application/json" \
-H "X-CSRFToken: $CSRF" \
-H "Referer: https://192.168.1.124:8090/" \
-d '{"username":"admin","password":"...","languageSelection":"EN","twofa":""}'
# => {"userID": 1, "loginStatus": 1, "error_message": "None"}
```
`loginStatus: 1` means the session cookie is now valid. The `Referer` header
matters more than you'd expect — some of these views check it.
### 3. Call the data endpoints
The "API" is just the same POST endpoints the Angular UI calls.
`/<module>/<function>`, same cookie jar, same `X-CSRFToken` + `Referer`:
```bash
curl -sk -b /tmp/cp_cookies.txt \
-X POST "https://192.168.1.124:8090/websites/fetchWebsitesList" \
-H "Content-Type: application/json" \
-H "X-CSRFToken: $CSRF" \
-H "Referer: https://192.168.1.124:8090/" \
-d '{"page":1,"recordsToShow":50}'
```
That returned every one of my sites, with SSL status, disk usage, PHP version,
and per-site days-until-cert-expiry. The same pattern deletes a site:
```bash
curl -sk -b /tmp/cp_cookies.txt \
-X POST "https://192.168.1.124:8090/websites/submitWebsiteDeletion" \
-H "Content-Type: application/json" \
-H "X-CSRFToken: $CSRF" \
-H "Referer: https://192.168.1.124:8090/" \
-d '{"websiteName":"blog.hoelee.com"}'
```
## The two gotchas that ate most of the time
**1. The 404 on `/api/...` was never a network problem — it was the wrong era.**
The `/api/` prefix belongs to the old API. Modern CyberPanel uses
root-relative `/<module>/<function>` with no `/api/` segment. Once I stopped
looking for an "API" and started looking for the UI's own routes, everything
snapped into place.
**2. List responses are double-encoded JSON.** `fetchWebsitesList` returns
something shaped like:
```json
{ "data": "[{\"domain\":\"...\",\"ssl\":{\"days\":17}, ...}]" }
```
The `data` key is a **JSON-encoded string**, not an array. If you pipe it
straight into `jq` and try `.[]`, you get nothing sensible. You have to unwrap
one layer first:
```bash
curl ... | jq -r .data | jq # unwrap the string, then parse again
```
## How to find the other endpoints
The function names aren't random — they map directly to Django view functions
in the CyberPanel source (`usmannasir/cyberpanel` on GitHub, `stable` branch).
The relevant files are `websiteFunctions/views.py`, `mailFunctions/views.py`,
`manageSSL/views.py`, and so on. So the workflow is:
1. Find the page in the UI (say, "Create Website").
2. Open the browser's network tab and watch what URL it POSTs to — or grep the
matching `views.py` for the view name.
3. Call the same `/<module>/<function>` path with your session + CSRF headers.
A useful distinction: a `GET /<module>/<page>` usually returns the **HTML of
the UI page**, while the **data** comes from a POST to a sibling function. If
you GET a page and get markup back, you haven't found the real endpoint yet —
keep looking for the AJAX call.
## What I'd do differently
1. **Start from the front-end, not the docs.** Watching the Angular app's
network requests would have saved me the whole "trust the obsolete Apiary
docs" detour. The UI is always the ground truth for its own API.
2. **Treat "This request need session." as a beacon, not an error.** The first
few times I read it as "wrong endpoint" when it was really "right endpoint,
wrong session state."
3. **Script the login once into a reusable helper.** The session cookie dies
on VM reboot and on expiry, so every one-off `curl` started from scratch.
A tiny wrapper that logs in, captures the cookie, and re-logins on
`"This request need session."` would have made the whole session repeatable
instead of exploratory.
## The result
I can now list, create, and delete sites on my CyberPanel panel entirely from
the shell — no browser, no GUI — and the whole authenticated API surface is
open for scripting (SSL, mail, DNS, cron). I found this by scrapping the docs,
watching the real requests, and mapping them back to their Django views, and
the `cyberpanel` module + session flow now lives in my own automation toolkit.
The broader lesson: "it's not documented" rarely means "it's not possible."
When a tool exposes a web UI, that UI is a living, exact reference for the API
— you just have to watch what it actually sends.
---
## Want your server administration scripted?
If you're clicking through a hosting panel — or worse, doing the same manual
steps across several servers — I automate exactly this kind of thing: turn
repetitive admin into a tested script or a small internal tool, wired to your
existing stack, with a handover so you're never locked in. Whether it's
CyberPanel, cPanel, Docker, or a bespoke dashboard, if it has a web UI, it can
almost certainly be driven without one.
Reach me at [[email protected]](mailto:[email protected]?subject=Scripting%20my%20server%20admin) or WhatsApp
[+60 12-797 2969](https://wa.me/60127972969), or see what I do at
[hoelee.com](https://hoelee.com).
@@ -0,0 +1,177 @@
---
title: "不用界面管理 CyberPanel:逆向一个没有文档的 v2 API"
description: "CyberPanel v2 砍掉了有文档的 JSON API,只留下 Angular 界面自己调用的 session + CSRF 端点。这篇文章讲清确切的认证流程,以及我怎么从页面背后挖出真正的函数。"
pubDate: 2026-09-09
category: devops
tags: [cyberpanel, api, reverse-engineering, automation, django, self-hosting, curl]
ogImage: /og/automating-cyberpanel-without-the-ui.png
banner: /banners/automating-cyberpanel-without-the-ui.png
---
我在家庭实验里的一台 Ubuntu 虚拟机里跑着 CyberPanel 2.4.4.1,用来托管几个小站,
入口是 `https://panel.hoelee.com`,反向代理到内网的一台机器。最近我想不打开浏览器、
直接用脚本做两件事:列出我的站点、删掉其中一个。这篇文章讲的就是我怎么发现"官方
API"根本已经不存在了,然后从 Angular 界面偷偷调用的那些请求里把真正的 API 逆向出来。
## 问题:有文档的 API 已经没了
网上每一个搜索结果、Apiary 文档、还有 Knowledge Base,都指向同一套东西——一个 JSON
API,把 `adminUser``adminPass` 通过 `POST` 发给 `/api/verifyConnection`
```bash
# 旧文档教你这么干。在 CyberPanel v2 上这根本行不通。
curl -k -X POST https://panel.hoelee.com:8090/api/verifyConnection \
-d '{"adminUser":"admin","adminPass":"...","serverUserName":"..."}'
```
在 CyberPanel v22.4.x)上,这只会返回一堆没用的东西。`/api/` 前缀是遗留的旧接口,早
就被砍掉了。已经没有 `adminUser`/`adminPass` 那套交换了。官方文档就是过时了——这是第
一个陷阱:你很容易花上一大把时间去信一套描述的并不是你正在跑的那个版本的文档。
## 我试了什么,以及为什么失败
**第一次尝试——信文档。** 按 Apiary 说的,带上凭据 `POST /api/verifyConnection`。结果:
404。这个路由根本不存在。
**第二次尝试——硬猜路径。** 我试了 `/api/`、带一个 `email` 字段的 `/verifyLogin`
还有几个瞎猜的裸 `GET`。拿回一个我一开始老是读错的响应:
```
"This request need session."
```
这句话其实是**好消息**——它说明 API **是**挂着的、能连上的,只是没有有效会话就拒绝
跟你说话。端点没缺,缺的是认证方式不一样。
**第三次尝试——CSRF 这堵墙。** 我终于把正确的字段发给 `/verifyLogin`,结果撞上一个
硬邦邦的 **403 Forbidden**。这是卡住绝大多数人的地方:发往 Django 后端面板的 POST 都
受 CSRF token 保护,一个不带 token 的裸 JSON POST 会在你的凭据都没被看上一眼之前就被
丢掉了。
突破口在于:别把面板当成"一个带 API 的东西",而是当成**一个带 Angular 前端的 Django
应用**——然后只要盯着前端到底发了什么就行。
## 修复:真正的认证流程
整件事就三步,全部用普通的 `curl`(加上 `-k`,因为面板用的是自签证书):
### 1. 从登录页拿到 CSRF token
Django 在第一次 GET 时就会设一个 `csrftoken` cookie。这个 cookie 的值就是你要在每次写
操作时用头部回显回去的 token
```bash
curl -sk -c /tmp/cp_cookies.txt "https://192.168.1.124:8090/" -o /dev/null
CSRF=$(grep csrftoken /tmp/cp_cookies.txt | awk '{print $7}')
```
### 2. 登录,拿到会话 cookie
`POST /verifyLogin`,带上 JSON body,并在 `X-CSRFToken` 头部带上 CSRF token
```bash
curl -sk -b /tmp/cp_cookies.txt -c /tmp/cp_cookies.txt \
-X POST "https://192.168.1.124:8090/verifyLogin" \
-H "Content-Type: application/json" \
-H "X-CSRFToken: $CSRF" \
-H "Referer: https://192.168.1.124:8090/" \
-d '{"username":"admin","password":"...","languageSelection":"EN","twofa":""}'
# => {"userID": 1, "loginStatus": 1, "error_message": "None"}
```
`loginStatus: 1` 说明会话 cookie 现在有效了。`Referer` 头部比你想的还重要——有
些 view 会去检查它。
### 3. 调用数据端点
所谓"API",其实就是 Angular 界面调用的那几个 POST 端点。`/<module>/<function>`,同
一个 cookie jar,同一个 `X-CSRFToken` + `Referer`
```bash
curl -sk -b /tmp/cp_cookies.txt \
-X POST "https://192.168.1.124:8090/websites/fetchWebsitesList" \
-H "Content-Type: application/json" \
-H "X-CSRFToken: $CSRF" \
-H "Referer: https://192.168.1.124:8090/" \
-d '{"page":1,"recordsToShow":50}'
```
这把我所有站点都吐出来了,带 SSL 状态、磁盘占用、PHP 版本,还有每个站离证书过期还剩
几天。同样的套路能删站:
```bash
curl -sk -b /tmp/cp_cookies.txt \
-X POST "https://192.168.1.124:8090/websites/submitWebsiteDeletion" \
-H "Content-Type: application/json" \
-H "X-CSRFToken: $CSRF" \
-H "Referer: https://192.168.1.124:8090/" \
-d '{"websiteName":"blog.hoelee.com"}'
```
## 最耗时间的两个坑
**1. `/api/...` 那个 404 从来不是网络问题——是时代搞错了。** `/api/` 前缀属于旧 API。
现代 CyberPanel 用的是根相对路径 `/<module>/<function>`,没有 `/api/` 这一段。一旦我不
再去找"API",而是去找 UI 自己的路由,一切就都对上了。
**2. 列表响应是双重编码的 JSON。** `fetchWebsitesList` 返回的东西长这样:
```json
{ "data": "[{\"domain\":\"...\",\"ssl\":{\"days\":17}, ...}]" }
```
`data` 这个 key 是一个 **JSON 编码的字符串**,不是一个数组。你要是直接把它喂给 `jq`
`.[]`,拿不到任何有意义的东西。得先剥掉一层:
```bash
curl ... | jq -r .data | jq # 先把字符串解出来,再解析一次
```
## 怎么找到其它端点
函数名不是随便起的——它们直接对应 CyberPanel 源码里的 Django view 函数(GitHub 上
`usmannasir/cyberpanel``stable` 分支)。相关文件是 `websiteFunctions/views.py`
`mailFunctions/views.py``manageSSL/views.py` 等等。所以流程是:
1. 在 UI 里找到那个页面(比如"创建站点")。
2. 打开浏览器 network 面板,看它 POST 到哪个 URL——或者去对应的 `views.py` 里 grep 那
个 view 名。
3. 用你的会话 + CSRF 头部,调用同一个 `/<module>/<function>` 路径。
有个很有用的区分:`GET /<module>/<page>` 通常返回**那个 UI 页面的 HTML**,而**数据**
来自对一个姊妹函数的 POST。如果你 GET 一个页面拿回来的是 markup,那你还没找到真正的
端点——继续找那个 AJAX 调用。
## 我会怎么做不一样
1. **从前端而不是文档入手。** 盯着 Angular 应用的网络请求,本来能帮我省掉"盲信过时
Apiary 文档"这整段弯路。对于它自己的 API 来说,UI 永远是真话。
2. **把 "This request need session." 当信标,不是错误。** 前几次我把它读成"端点错了",
其实它是"端点对了,会话状态不对"。
3. **把登录一次性脚本化成一个可复用的 helper。** 会话 cookie 会在 VM 重启和过期时死掉,
所以每一次零散的 `curl` 都得从头来。一个能登录、抓 cookie、遇到
`"This request need session."` 就重新登录的小 wrapper,能把整场操作从"摸索"变成
"可复现"。
## 结果
现在我能完全从 shell 里列出、创建、删除我 CyberPanel 面板上的站点——不需要浏览器、不
需要 GUI——而且整个带认证的 API 面都敞开来供脚本化了(SSL、邮件、DNS、cron)。我是靠
丢掉文档、观察真实请求、再把这些请求映射回它们的 Django view 来做到的,而
`cyberpanel` 模块和会话流程现在也进了我自己的自动化工具箱。
更大的教训是:"没有文档"很少意味着"不可能"。当一个工具暴露了 web UI,那个 UI 就是它
API 的一份活的、精确的参考——你只需要去看它到底发了什么。
---
## 想把你的服务器管理脚本化吗?
如果你还在点托管面板——更糟的是,在好几台服务器上重复做同样的手动步骤——我专门自动化
这类事情:把重复的后台操作变成一份测试过的脚本或一个小的内部工具,接到你现有的技术栈
上,并附上交接说明,让你永远不会被锁死。不管是 CyberPanel、cPanel、Docker,还是定制
的后台,凡是有 web UI 的,几乎都能脱离 UI 去驱动它。
联系我 [[email protected]](mailto:[email protected]?subject=Scripting%20my%20server%20admin) 或
WhatsApp [+60 12-797 2969](https://wa.me/60127972969),或者到
[hoelee.com](https://hoelee.com) 看看我做什么。