Skip to content

面板 REST API

面板上的每一个界面都是这套 API 的客户端,按钮能做的事没有一件是 curl 够不着的。 从 GitHub Actions 任务里重装一个容器,部署完把它重启,在自己的机器上开一个, 把流量表读进监控,按计划轮换 shell 密码。

这一页是参考手册。现成的做法里是可以直接拿去跑的完整脚本。


总则

  • 基地址是你的面板加上 /api/v1。在 https://hqno.de 登录,那就是 https://hqno.de/api/v1。下面每一条路径都是相对它写的。
  • 每个应答都是 application/json,带 Cache-Control: no-store
  • 请求体是 JSON,要带 content-type: application/json。没有一个接口收表单编码, 查询参数只有 ?since=?apply=
  • 所有大小都是字节 —— 就是整数,不会出现 "2G"。所有时间戳都是 UTC 的 RFC 3339(2026-08-20T06:39:00Z)。
  • 它分成两半。/me/… 是你拿着的:别人给你的容器。/machines/… 是你在跑的: 你自己的机器,以及机器上的每一个容器。一个账号,一个 cookie,两半都通 —— 大多数人一辈子只用其中一半。
  • id:容器 id(cid)是 12 个十六进制字符;机器 id(mid)是 mch_ 加 8 个十六进制字符。两个都是那样东西在面板里 URL 的最后一段。

不要在你自己的容器里面调这套 API。 容器里故意不放任何面板凭据 —— 它有 dashboardapp-setup domainpasswd,这三个通过它的宿主机够到面板, 盒子里不需要放任何秘密。见在容器里面


认证

没有 API key。这套 API 收的就是浏览器拿到的那个会话 cookie,从同一个登录表单来: 登录一次,把 cookie 留着,之后每个请求都带上它。

sh
PANEL=https://hqno.de/api/v1

# 登录。凭据是那个 cookie,不是应答体里的任何东西。
curl -sS -c jar.txt "$PANEL/auth/login" \
  -H 'content-type: application/json' \
  -d '{"identifier":"you","password":"…"}'

# 之后每一个调用都带上这个 jar。
curl -sS -b jar.txt "$PANEL/me/containers"
Cookiehq_session
有效期7 天
属性HttpOnlySameSite=Lax,面板是 HTTPS 时还有 Secure
提前失效POST /auth/logout,或者在面板里登出

为什么是 cookie 而不是 token。 token 是一种会泄漏的东西,而它不得不存放的每一个 地方,恰恰都是这个设计要把秘密赶出去的地方。会话是短命的、能在一个界面上吊销的, 而且就是浏览器已经拿着的那一份 —— 一个入口,一样可以收回的东西。在 CI 里, 存面板密码,每次登录;永远不要存 cookie。存下来的 cookie 是一把有效七天、 而你看不出它还剩多久的钥匙,重新登录一次不过多一个请求。

下面每个接口都标了认证是「会话」还是「无」。


应答格式

成功时是这个接口自己的形状。失败时永远是这三个键:

json
{"error":"Sign in first.","code":"unauthorized","message":"Sign in first."}

code 分支。errormessage 是同一句英文,带两遍, 这样读其中任何一个的客户端都拿得到。有些拒绝会多带字段 —— 端口冲突会说出是哪个号码,证书拒绝会加上 next_manual_at —— 但那三个永远都在。

HTTP 状态码

状态码意思
200做完了
201创建了 —— 一个容器,或者一个分享码
202收下了 —— 一个证书申请,机器接着去办
400你的请求体不对。那句话会说哪里不对
401没有 cookie,或者它过期了
402你账号能拿的容器数用完了
403当前密码不对,或者这个面板轮不到你管
404没有这个东西 —— 或者它不是你的,见下
409它已经存在,或者它现在的状态不允许这么做
429被限流了
502面板够不着那台机器
500面板自己坏了。这一个是 bug

404 同时也表示「不是你的」。 授权是结构性的:不在你自己列表里的容器根本解析不出来, 所以没有一个 403 来区分「存在但不许你碰」和「不存在」。 这里没有任何东西会报告别人的容器,包括它存不存在。

502 是正常的,而且是暂时的。 你容器做的任何事都不经过面板; 面板通过一条会断的链路去够那台机器。链路断着的时候,读还是能从面板的记录里答出来, 写会回 agent_unreachable。重试就好 —— 这不是一次失败的部署。

错误码

code状态码什么时候
bad_request400请求体不对
unauthorized401没登录
bad_credentials401登录失败。用户名错和密码错给的是同一个答案
limit_reached402账号能拿的容器数用完了
bad_password403current 对不上
forbidden403只有管理员能调的接口
not_found404没有,或者不是你的
no_route404没有这个接口 —— 你把路径打错了
exists409用户名或邮箱已经注册过了
name_taken409那台机器上已经有一个同名容器
gone409这个容器已经被删了
expired409已到期:只能关,不能开
suspended409被你的房东停用了
quota_exceeded409这个窗口的流量超了
unavailable409已到期或已停用,所以网络不给改
machine_offline409那台机器没有上报过
too_many_domains409到了这台机器的域名上限
domain_taken409那台机器上另一个容器在服务这个名字
not_managed409这个名字不是「由机器托管证书」那一档
port_takenport_overlapport_in_poolspan_too_largeno_free_port409主机拒绝了这个端口。字段里会说出号码和池子
port_in_use409还有域名路由到那个端口
rate_limited429你这个地址登录或重置得太频繁了
agent_unreachableagent_error502那台机器不应答
agent_refused4xx主机拒绝了,但没给出自己的错误码
internal500一个 bug

频率限制

接口限制
/auth/login/auth/signup每个 IP 每分钟 10 次
/auth/forgot/auth/reset每个 IP 每 15 分钟 5 次
其余全部

超了就是 429 rate_limited,密码再对也一样。只登录一次的任务永远碰不到它; 一个十二路矩阵、每一步都登录一次的,会碰到。


通用接口

探活

GET /api/v1/ping

认证:无 · 参数:无

sh
curl -sS "$PANEL/ping"
json
{"ok":true}

面板健康

GET /api/v1/healthz

认证:无 · 参数:无

sh
curl -sS "$PANEL/healthz"
json
{"ok":true,"live_agents":true,"agents_linked":3}
字段类型说明
live_agentsbool这个面板是真的在跟主机说话,还是在模拟它们
agents_linkedint此刻链路活着的机器数

面板配置

GET /api/v1/config

认证:无 · 参数:无

agent 应该跑的是哪个版本,好让机器页面提供升级。

sh
curl -sS "$PANEL/config"
json
{"agent_version":"0.1.0.g6a739b2b0867","agent_downloads":true}

镜像目录

GET /api/v1/catalog

认证:会话 · 参数:无

可选的系统。创建或重装时 image_id 要填的就是这里的 id

sh
curl -sS -b jar.txt "$PANEL/catalog"
json
{"images":[{
  "id":"debian-12","name":"Debian 12","ref":"ghcr.io/hqnode/debian:12",
  "digest":"sha256:…","arch":["amd64","arm64"],"size_bytes":124780544,
  "built_at":"2026-07-01T00:00:00Z","blurb":"Plain Debian, systemd, nothing else.",
  "kind":"builtin","status":"ready","default":true}]}

账号接口

登录

POST /api/v1/auth/login

认证:无

名字类型位置必填说明
identifierstring请求体你的用户名或者邮箱
passwordstring请求体
sh
curl -sS -c jar.txt "$PANEL/auth/login" \
  -H 'content-type: application/json' \
  -d '{"identifier":"you","password":"…"}'
json
{"principal":{"hash":"9f2a…","username":"you","email":"[email protected]"},
 "home":"/containers"}

有用的那一半是 Set-Cookie: hq_session=… 这个头。用户名错和密码错给的是同一个 401 bad_credentials,而且一样快 —— 这个表单不是一个能拿来试探账号存不存在的东西。

当前会话

GET /api/v1/auth/session

认证:无 · 参数:无

sh
curl -sS -b jar.txt "$PANEL/auth/session"
json
{"principal":{"hash":"9f2a…","username":"you","email":"[email protected]"},
 "home":"/containers"}

不管你登没登录,这个接口都答 200 没登录是 {"principal":null},不是 401。 只看状态码的脚本会把一个过期的 cookie 读成一切正常 —— 要看那个字段。

登出

POST /api/v1/auth/logout

认证:无(有 cookie 就用)· 参数:无

sh
curl -sS -b jar.txt -X POST "$PANEL/auth/logout"
json
{"ok":true}

销毁这个 cookie 背后的那个会话。你其他的会话照常。

注册

POST /api/v1/auth/signup

认证:无

名字类型位置必填说明
usernamestring请求体小写字母、数字、点、横线和下划线;2–31 个字符。永久不变
emailstring请求体
passwordstring请求体至少 8 个字符
sh
curl -sS -c jar.txt "$PANEL/auth/signup" \
  -H 'content-type: application/json' \
  -d '{"username":"you","email":"[email protected]","password":"…"}'

201,应答体和登录一样,而且你已经是登录状态了。用户名或邮箱被占了就是 409 exists

改面板密码

POST /api/v1/auth/change-password

认证:会话

这是你的面板账号密码,不是容器 SSH 问你要的那个 shell 登录密码 —— 那个在重置 shell 登录

名字类型位置必填说明
currentstring请求体错了就是 403 bad_password
passwordstring请求体至少 8 个字符
sh
curl -sS -b jar.txt -X POST "$PANEL/auth/change-password" \
  -H 'content-type: application/json' \
  -d '{"current":"…","password":"…"}'
json
{"ok":true,"message":"Password changed."}

改了它不会让别的东西失效 —— 你其他的会话仍然是登录着的。

申请重置密码

POST /api/v1/auth/forgot

认证:无

名字类型位置必填说明
emailstring请求体

不管这个地址有没有账号,答的都是同一句话,所以这个表单没法拿来查谁注册过。

json
{"ok":true,"message":"If that address has an account, a reset link is on its way."}

完成密码重置

POST /api/v1/auth/reset

认证:无

名字类型位置必填说明
tokenstring请求体从重置链接里来
passwordstring请求体至少 8 个字符

成功后顺带把你登录上。


容器对象

大多数容器接口答的都是这个东西。它是套着的:外层是容器以及它住在哪儿, 里面那个 container 才是记录本身。

json
{"container":{
   "cid":"a1b2c3d4e5f6",
   "name":"web",
   "state":"active",
   "created_at":"2026-06-01T10:00:00Z",
   "expires_at":"2026-12-31T00:00:00Z",
   "image":{"ref":"ghcr.io/hqnode/debian:12","digest":"sha256:…"},
   "allowed_images":["debian-12","ubuntu-24.04"],
   "limits":{"cpu_cores":2,"cpu_idle":false,"mem_bytes":2147483648,
             "swap_bytes":2147483648,"disk_bytes":21474836480,
             "data_bytes":10737418240,"net_quota_bytes":1099511627776},
   "net_usage":{"window_start":"2026-08-01T00:00:00Z",
                "rx_bytes":214748364800,"tx_bytes":126100789760},
   "access":{"ssh_username":"u7k2m9p","ssh_port":22,
             "domains":["example.com"],"ports":[80,443]},
   "live":{"run_state":"running","cpu_pct":3.1,"mem_bytes":734003200,
           "swap_bytes":0,"disk_bytes":4294967296,
           "loopback":"127.100.0.7","started_at":"2026-08-19T09:12:00Z"},
   "history":[{"ts":"2026-08-19T09:12:00Z","event":"restart","by":"9f2a…"}]},
 "machine":{"id":"mch_1a2b3c4d","name":"hk-1","ssh_host":"hk-1.example.com",
            "ssh_port":22,"status":"online"},
 "ssh":"ssh [email protected] -p 22"}
字段类型说明
container.cidstring12 个十六进制字符。下面每条路径要的就是它
container.namestring小写,2–31 个字符。重装和删除时 confirm 要等于它
container.statestringpendingactivesuspendedexpireddeleted
container.suspend_reasonstring流量表干的就是 quota。没有这个字段就是人干的
container.expires_atstringRFC 3339,永不到期的容器是 ""
container.imageobjectrefdigest,只有这个容器才有的镜像还会带 private: true
container.allowed_imagesarray持有者能重装成哪些目录 id。空表示整个目录都行
container.limits.cpu_coresfloat保证给你的份额。之上还空着的算力也是你的
container.limits.cpu_idlebool批处理档:只用剩下的算力,永远不耽误别人
container.limits.*_bytesint字节。data_bytes 大于零表示有一块能熬过重装的 /data
container.net_usageobject当前窗口的流量表 —— 见读流量表
container.access.ssh_usernamestring网关的登录名,不是容器里的一个 Unix 用户
container.access.domainsarray路由到这里的名字
container.live.run_statestringrunningstoppedreinstallingunknown
container.live.*机器最后一次上报的东西。loopback 是这个容器的私有地址
container.historyarray{ts, event, by, detail, failed},最新的在前
machine.statusstringonlineoffline —— 说的是机器,不是容器
sshstring整条 SSH 命令,可以直接粘

statelive.run_state 回答的是两个不同的问题。 state 是面板对这个容器 下的判断;run_state 是它这一秒有没有在跑。一个 active 的容器完全可以是 stopped 的 —— 因为是你自己关的。


容器接口

这一节里的所有东西,范围都是你拿着的容器。如果机器是你在跑, 同样这些操作在主机侧接口里,路径是 /machines/{mid}/…

列出你的容器

GET /api/v1/me/containers

认证:会话 · 参数:无

sh
curl -sS -b jar.txt "$PANEL/me/containers"
json
{"containers":[  上面那个容器对象,一个容器一份 ]}

取一个容器

GET /api/v1/me/containers/{cid}

认证:会话

名字类型位置必填说明
cidstring路径容器 id
sh
curl -sS -b jar.txt "$PANEL/me/containers/$CID"
json
{"container":{  那个容器对象 },
 "images":[{"id":"debian-12","name":"Debian 12","size_bytes":124780544,"cached":true}],
 "allow_user_images":true,
 "can_own_image":true}
字段类型说明
imagesarray这台主机能把它重装成什么,已经按 allowed_images 过滤过了
allow_user_imagesbool这台机器让不让你自己报一个仓库引用
can_own_imagebool有没有一块私有盘来放它

开机、关机、重启

POST /api/v1/me/containers/{cid}/power

认证:会话

名字类型位置必填说明
cidstring路径容器 id
actionstring请求体startstoprestart
sh
curl -sS -b jar.txt -X POST "$PANEL/me/containers/$CID/power" \
  -H 'content-type: application/json' -d '{"action":"restart"}'
json
{"ok":true,"message":"Done."}

关机是你自己的事,重新开机也是 —— 这是你的容器, 而一个「因为只有管理员才能关它、所以只好一直开着」的容器, 是在替它的主人白白付钱。停用是另一回事,那件事仍然归房东。

拒绝意思
409 gone这个容器已经被删了
409 expired到期的容器只能关
409 suspended房东把它停用了,只有他们能解
409 quota_exceeded流量超了。窗口滚过去,或者额度调高,它就自己好了

流量这一种停用是你自己能解开的:数字允许了以后,start 会在同一个调用里 把这个停用一起解掉。

重启

POST /api/v1/me/containers/{cid}/restart

认证:会话

名字类型位置必填说明
cidstring路径容器 id

power{"action":"restart"} 是一回事,不用请求体,为最常见的那种情况准备的。

sh
curl -sS -b jar.txt -X POST "$PANEL/me/containers/$CID/restart"

重装

POST /api/v1/me/containers/{cid}/reinstall

认证:会话

同一个容器上换一套干净的系统:id 不变,限额不变,shell 登录不变,域名不变。 / 会被换掉;容器有 /data 的话(limits.data_bytes 大于零)它会留下。

名字类型位置必填说明
cidstring路径容器 id
confirmstring请求体容器自己的名字,不是 "yes"
image_idstring请求体四选一一个目录 id —— GET /catalog
digeststring请求体四选一那台主机上已经有的一个镜像
refstring请求体四选一一个仓库引用,主机先去拉。这次下载算你的流量
archivestring请求体四选一主机镜像目录里的一个文件 —— 列出本地镜像文件

image_iddigestrefarchive 恰好填一个。主机设了 allowed_images 的话, 持有者受它约束;ref 还需要 allow_user_images

sh
curl -sS -b jar.txt -X POST "$PANEL/me/containers/$CID/reinstall" \
  -H 'content-type: application/json' \
  -d '{"image_id":"debian-12","confirm":"web"}'
json
{"container":{  那条容器记录 },
 "message":"Reinstalled from Debian 12. /data kept."}

confirm 写错是一个 400,而且它会告诉你该写什么。其他的拒绝: 409 expired409 suspended409 gone

这个调用在机器干完之前就返回了。用下一个接口轮询。

看重装进度

GET /api/v1/me/containers/{cid}/progress

认证:会话

名字类型位置必填说明
cidstring路径容器 id
sh
curl -sS -b jar.txt "$PANEL/me/containers/$CID/progress"
json
{"operations":[{"kind":"reinstall","key":"a1b2c3d4e5f6","step":"pulling image",
                "index":1,"steps":["pulling image","unpacking","starting"],
                "bytes":48234496,"bytes_total":124780544,
                "started_at":"2026-08-20T06:40:00Z","elapsed_ms":9400,
                "done":false}]}
字段类型说明
kindstring正在发生什么 —— reinstallpullcreate
keystring发生在哪个容器 id 上
stepindexsteps走到序列的哪一步了,以及整个序列是什么
bytesbytes_totalint下载进度。什么都没在拉的时候 bytes_total 是 0
donebool完了。失败时 err 里是原因

只有这个容器的操作 —— 别人的那些,键是别的租户的容器名和镜像引用。 空的表示没有事情在进行中。机器失联时它答的是 {"operations":[],"unreachable":true},而不是报错。

判断做完了,最简单的办法是看容器自己的 live.run_state 回到 running现成的做法里就是这么干的。

读流量表

两种不同的读法,大多数脚本两个都要。

离额度还有多远,在容器自己身上,不用多一个调用:

sh
curl -sS -b jar.txt "$PANEL/me/containers/$CID" \
  | jq '.container.container | {used: (.net_usage.rx_bytes + .net_usage.tx_bytes),
                                quota: .limits.net_quota_bytes,
                                since: .net_usage.window_start}'
字段类型说明
net_usage.window_startstring当前窗口是什么时候开的。哪一天由你的房东挑
net_usage.rx_bytesint这个窗口进来的
net_usage.tx_bytesint这个窗口出去的
limits.net_quota_bytesint两个方向都算进去。0 表示不限流量

到 100% 时容器会被停用,不是删掉,suspend_reasonquota。 窗口滚过去,或者额度调高,它就回来了。

它随时间的形状,是用量序列:

GET /api/v1/me/containers/{cid}/usage

认证:会话

名字类型位置必填说明
cidstring路径容器 id
sincestring查询参数一个 Go duration(24h168h)或者一个 RFC 3339 时间戳。不填就是留存的全部。天不是一个单位 —— 写 168h,不要写 7d
sh
curl -sS -b jar.txt "$PANEL/me/containers/$CID/usage?since=24h"
json
{"points":[{"ts":"2026-08-20T06:10:00Z","cid":"a1b2c3d4e5f6","cpu_pct":2.4,
            "mem_bytes":712500000,"disk_bytes":4294967296,
            "rx_delta":18874368,"tx_delta":5242880}]}
字段类型说明
tsstring这个点的时间
cpu_pctfloat占容器自己那份额度的百分比
mem_bytesdisk_bytesint那一刻的值
rx_deltatx_deltaint相对上一个点新增的字节,不是累计值

要一段时间的量就把 delta 加起来,要这个月的就读 net_usage

重置 shell 登录

POST /api/v1/me/containers/{cid}/credentials

认证:会话

SSH 问你要的那个登录。它属于机器上的网关,不属于容器里的 /etc/shadow —— 这就是为什么它是一个面板调用,也是为什么重装不会把它弄丢。

名字类型位置必填说明
cidstring路径容器 id
ssh_passwordstring请求体不填就生成一个强的 —— 脚本通常就该这么做。要自己设的话:至少 6 个字符,字母、数字、标点里占两类
ssh_usernamestring请求体顺便把登录名也改了。小写字母、数字、横线、下划线;2–31 个字符,以字母开头
sh
curl -sS -b jar.txt -X POST "$PANEL/me/containers/$CID/credentials" \
  -H 'content-type: application/json' -d '{}'
json
{"credentials":{"ssh_username":"u7k2m9p","ssh_password":"…",
                "ssh_host":"hk-1.example.com","ssh_port":22,
                "ssh":"ssh [email protected] -p 22"},
 "message":"Updated inside the container. Shown once."}

「只显示一次」是字面意思。 面板从不保存 shell 密码:它去到机器上, 再进到这个应答里,别的地方哪儿都没有。在发起这个请求的那一步就把它接住 —— 没有任何接口能把它读回来。

列出本地镜像文件

GET /api/v1/me/containers/{cid}/archives

认证:会话

运维手工放到机器上的镜像文件 —— 一台网络扛不动 200 MB 拉取的主机, 拿这个来顶。这里的一个文件名,可以填到重装的 archive 里。

名字类型位置必填说明
cidstring路径容器 id
json
{"sideload":{"dir":"/srv/hqnode/images",
             "files":[{"name":"debian-12.tar","size_bytes":124780544,
                       "modified_at":"2026-07-02T11:00:00Z"}]}}

失联的机器答的是 200、一个空列表加一个 error 字段,而不是一个失败 —— 别的重装方式还是能用的。

兑换分享码

POST /api/v1/me/containers/bind

认证:会话

一步把一个容器接过来:你的账号成为它的持有者,shell 凭据被重写成只有你知道的。 之前的持有者或者管理员设过的东西全部作废 —— 绑定就是这个意思。

名字类型位置必填说明
codestring请求体分享码。不区分大小写
ssh_passwordstring请求体不填就生成一个
ssh_usernamestring请求体不填就沿用它现在的名字
sh
curl -sS -b jar.txt -X POST "$PANEL/me/containers/bind" \
  -H 'content-type: application/json' -d '{"code":"K4M7PQR2"}'
json
{"cid":"a1b2c3d4e5f6",
 "credentials":{"ssh_username":"u7k2m9p","ssh_password":"…",
                "ssh":"ssh [email protected] -p 22"},
 "message":"web is yours. These credentials are shown once."}

码不认识、已经用过或者过期了,都是 400

把容器转给别人

POST /api/v1/me/containers/{cid}/bind

认证:会话

名字类型位置必填说明
cidstring路径容器 id
usernamestring请求体一个已经存在的账号。造用户名是机器主人的权力
json
{"ok":true,
 "message":"alice holds web now. Your shell login still works until they or the host reset it."}

没有这个账号就是 404 no_such_user。容器里面什么都不动: 变的是归属关系,shell 登录由他们自己去重置。

把容器交回去

DELETE /api/v1/me/containers/{cid}/bind

认证:会话

名字类型位置必填说明
cidstring路径容器 id

什么都不会被删。它回到机器上「没人持有」的状态,等房东再分配。


域名与路由接口

域名是你网络那部分里唯一一件你自己管的事。一个名字不花机器什么 —— 所有名字共用同一个 80 和 443,靠请求里的名字分派 —— 所以没有什么要配给的,也不用问谁。

把 DNS 指到机器上是你在域名服务商那边自己做的事;要指的那个地址是下面的 host_ip

列出域名

GET /api/v1/me/containers/{cid}/domains

认证:会话

名字类型位置必填说明
cidstring路径容器 id
json
{"domains":[{"domain":"example.com"}],"max":10}

加一个域名

POST /api/v1/me/containers/{cid}/domains

认证:会话

名字类型位置必填说明
cidstring路径容器 id
domainstring请求体一个主机名。会被规范成小写,不带协议,不带结尾的点
sh
curl -sS -b jar.txt -X POST "$PANEL/me/containers/$CID/domains" \
  -H 'content-type: application/json' -d '{"domain":"example.com"}'

答的是整个列表,所以不用再来一次调用。在这里加的名字, 一开始是把 HTTP 和 HTTPS 送到容器里的 80 端口;要改就用下面的保存一条路由

拒绝意思
409 too_many_domains到了这台机器的上限。删一个
409 domain_taken那台机器上另一个容器已经在服务它
409 unavailable容器已到期或已停用

加一个你已经有的名字不算错 —— 它就答那个列表。

删一个域名

DELETE /api/v1/me/containers/{cid}/domains/{domain}

认证:会话

名字类型位置必填说明
cidstring路径容器 id
domainstring路径那个名字,要 URL 编码

答的是剩下的列表。

列出路由

GET /api/v1/me/containers/{cid}/routes

认证:会话

你每个名字实际上在干什么。名字的列表是面板的; 这里其余的东西每次都从机器上读,因为决定包往哪走的那一份,在机器上。

名字类型位置必填说明
cidstring路径容器 id
json
{"routes":[{
   "domain":"example.com","cid":"a1b2c3d4e5f6","mode":"both",
   "http_port":80,"tls_port":443,"tls_mode":"managed",
   "compress":"on","proxy_protocol":false,
   "ports":{"http":{"port":80,"published":true},
            "https":{"port":80,"published":true}},
   "checks":{"dns":{"state":"ok","addresses":["203.0.113.10"],
                    "expected":["203.0.113.10"],"checked_at":"2026-08-20T06:00:00Z"},
             "http":{"state":"ok","status":200,"took_ms":14},
             "https":{"state":"ok","days_left":74,"issuer":"Let's Encrypt"}},
   "cert":{"state":"ready","expires_at":"2026-11-02T00:00:00Z","days_left":74,
           "issued_7d":1,"failed_1h":0}}],
 "max":10,"host_ip":"203.0.113.10","host_tls_port":443,
 "host_online":true,"managed_available":true}
字段类型说明
modestringbothhttptlsnone —— 由那两个开关推出来
tls_modestringmanaged(机器持有证书)或 sni(字节原样穿过去;你的私钥不出容器)
checks.*.statestring每个徽标背后主机缓存的答案
host_ipstring你的 DNS 记录要指到哪儿
host_onlineboolfalse 不是错误:名字都是真的,只是这会儿改不了
managed_availablebool机器的 TLS 监听离开了 443 就是 false —— 挑战是从 443 进来的

保存一条路由

PUT /api/v1/me/containers/{cid}/routes/{domain}

认证:会话

名字类型位置必填说明
cidstring路径容器 id
domainstring路径这个容器已经有的一个名字
http.enabledbool请求体提供明文 HTTP
http.portint请求体它进到容器的哪个端口。1–65535
https.enabledbool请求体提供 HTTPS
https.modestring请求体managedsni。默认 sni
https.portint请求体HTTPS 进到容器的哪个端口。managed 下会被忽略 —— 机器自己终结 TLS,再把明文转给 http.port
compressstring请求体onoff,或者 "" 表示随主机的默认
proxy_protocolbool请求体前面加一个 PROXY 头,好让你的服务看到真实的客户端 IP
sh
curl -sS -b jar.txt -X PUT "$PANEL/me/containers/$CID/routes/example.com" \
  -H 'content-type: application/json' \
  -d '{"http":{"enabled":true,"port":8080},
       "https":{"enabled":true,"mode":"managed","port":443},
       "compress":"on","proxy_protocol":false}'
json
{"route":{  上面那个路由视图 }}

两个开关都关掉,这个名字还列在那里,但什么都不应答。 名字不是这个容器的就是 404 —— 先把它加上。

检查一个域名

POST /api/v1/me/containers/{cid}/routes/{domain}/probe

认证:会话

名字类型位置必填说明
cidstring路径容器 id
domainstring路径
checksarray请求体dnshttphttps 里的任意几个。默认三个都查
forcebool请求体现在就去拨一次,而不是拿主机缓存里的答案

容器、以及拿来跟 DNS 比对的那些地址,是面板自己填的,不是你填的 —— 一个能指名另一个容器的请求体,就是一次带着会话 cookie 的端口扫描。

json
{"dns":{"state":"ok","addresses":["203.0.113.10"],"expected":["203.0.113.10"]},
 "http":{"state":"ok","status":200,"took_ms":14},
 "https":{"state":"ok","days_left":74}}

申请证书

POST /api/v1/me/containers/{cid}/routes/{domain}/certificate

认证:会话 · 参数:路径里的 ciddomain

只对 tls_modemanaged 的名字有效,否则 409 not_managed。 机器平时会自己去签、自己去续 —— 这个是「我现在就要」的那个按钮。

json
{"cert":{"state":"pending","issued_7d":1,"failed_1h":0}}

202。Let's Encrypt 对一个名字一周只发五张证书,所以一个会把最后一张用掉的申请, 在真的发出去之前就被拒了,请求体里带 next_manual_at,应答头上带 Retry-After


端口接口

公网端口是机器自己地址上的一个号码,连过去就直接进到某一个容器里 —— 203.0.113.10:31000 落到你盒子里的 3000 端口上。 它适合游戏服务器、数据库、VPN;网站要的是域名。

你读它们、测它们。开它们的是你的房东。 一个域名不花机器什么, 但 31000 就是一个,只能给一个容器,两个租户都想要它,就是一件得有人来定的事。 所以 /me/containers/{cid}/ports 下面没有 POST 也没有 DELETE —— 这不是某个处理函数里的一次权限检查,而是这些路由压根没注册过, 所以往那里写,得到的是路由器给的一个 404。公网端口是完整的说法; 开一个在主机那一侧,是开一个公网端口

列出公网端口

GET /api/v1/me/containers/{cid}/ports

认证:会话

名字类型位置必填说明
cidstring路径容器 id
json
{"mappings":[
   {"id":"tcp-3000-0.0.0.0","proto":"tcp","container":3000,"public":31000,
    "public_facing":true,"live":true,"added_at":"2026-08-01T09:00:00Z","by":"host",
    "check":{"state":"ok","took_ms":3,"checked_at":"2026-08-20T06:00:00Z"}},
   {"id":"tcp-80","proto":"tcp","container":80,"host":8080,
    "public_facing":false,"live":true,
    "used_by":{"kind":"domain","domain":"example.com"}}],
 "host_ip":"203.0.113.10","loopback":"127.100.0.7",
 "host_online":true,"can_edit":false}
字段类型说明
idstringproto-container-bind,仅本机可达的映射是 proto-container。检查路径里的 {id} 就是它
publicint互联网够到的那个号码。只有 public_facing 时才有
hostint容器私有 loopback 上的号码。另外那一种情况
countint连续覆盖了几个端口。没有或者是 1 就是一个
livebool这会儿真占着。false 是对一条等着下次启动的映射的老实说法
checkobject最后一次检查:okclosedunpublishederroruntested
used_byobject一条仅本机可达的映射为什么关不掉:一个 domain,或者 SSH gateway
can_editbool在这条路径上永远是 false。对机器的主人才是 true

关于机器内部的四个字段 —— public_rangeprivate_rangepublic_max_spanholder —— 在主人那一份应答里有,这里是故意不给的。

测一个端口

POST /api/v1/me/containers/{cid}/ports/{id}/probe

认证:会话

到底有没有东西在听?它拨一次这个端口,什么都不改。

名字类型位置必填说明
cidstring路径容器 id
idstring路径上面列表里的一个映射 id,比如 tcp-3000-0.0.0.0
json
{"state":"ok","took_ms":3,"port":3000,"checked_at":"2026-08-20T06:41:00Z"}

UDP 直接答 untested,不去拨 —— 一个 UDP 端口不出声,什么也说明不了。


主机侧接口

这套 API 的另一半:你自己的机器,以及上面的每一个容器。 同一个 cookie,同一个基地址。{mid} 是机器 id。

容器接口里的每一个,这里都有一个孪生的 —— 开关机、重装、 用量、凭据 —— 在 /machines/{mid}/containers/{cid}/… 下面,请求体一模一样。 下面写的是只有主人才能做的事。

重装的孪生版有两处不同,两处都是因为调用者拥有这台机器: allowed_imagesallow_user_images 不对它生效 —— 不管主机对持有者的策略怎么写,你自己的 ref 都能用 —— 以及,一个机器上已经没有了的容器,是被重新造出来而不是重装, 造是 create,所以它收 image_iddigestarchive,但绝不收 ref

列出你的机器

GET /api/v1/machines

认证:会话 · 参数:无

sh
curl -sS -b jar.txt "$PANEL/machines"
json
{"machines":[{"id":"mch_1a2b3c4d","name":"hk-1",
  "host":{"hostname":"hk-1","arch":"amd64","cpu_cores":8,
          "mem_bytes":34359738368,"ssh_host":"hk-1.example.com","ssh_port":22},
  "policy":{"allow_user_images":true,"net_reset_day":1,"max_domains":10,
            "default_limits":{  }},
  "cache":{"status":"online"},
  "containers":[  完整的容器记录 ],
  "link":{"connected":true,"token_sealed":true}}]}

link.connected 是 websocket 此刻通不通;cache.status 是上一次轮询得出的结论。 要判断「我现在能不能对这台机器动手」,看前一个。

取一台机器

GET /api/v1/machines/{mid}

认证:会话

名字类型位置必填说明
midstring路径机器 id
json
{"machine":{  同上 },
 "host_live":{"hostname":"hk-1","arch":"amd64","kernel":"6.12.0",
              "agent_version":"0.1.0.g6a739b2b0867","ip":"203.0.113.10",
              "cpu_cores":8,"cpu_pct":18.2,
              "mem_bytes":34359738368,"mem_used_bytes":12884901888,
              "zram_bytes":8589934592,"zram_used_bytes":1073741824,
              "disk_bytes":1099511627776,"disk_used_bytes":329853488332,
              "psi_mem_avg60":0.4,"psi_cpu_avg60":11.2,
              "net_rx_mbit":42.1,"net_tx_mbit":18.7,"containers_running":6},
 "host_error":""}

psi_cpu_avg60 是超售的那个表盘:长期在 25% 上下以上, 就说明租户在排队等 CPU,机器也会开始自己拒绝新容器。

host_live 是你问的时候现从机器上读的。失联的机器会把 host_error 填上, 并且仍然答 200 —— 那是关于机器的一个事实,不是一次失败的请求。

创建容器

POST /api/v1/machines/{mid}/containers

认证:会话

名字类型位置必填说明
midstring路径机器 id
namestring请求体小写字母、数字和横线,2–31 个字符。在那台机器上唯一
image_idstring请求体二选一一个目录 id
archivestring请求体二选一改用机器镜像目录里的一个文件
usernamestring请求体直接交给这个账号,需要的话把账号建出来。不填的话你拿到的是一个分享码
emailstring请求体只有在要建那个账号时才用得上
ssh_usernamestring请求体网关登录名。不填就生成
ssh_passwordstring请求体不填就生成。至少 6 个字符,两类字符
limitsobject请求体不写的字段取机器的默认值
limits.cpu_coresfloat请求体比如 0.52
limits.cpu_idlebool请求体批处理档:只用剩下的算力
limits.mem_bytesint请求体字节
limits.swap_bytesint请求体字节。0 表示没有 —— 撞到内存墙就被杀
limits.reserve_bytesint请求体内核不会回收到这个数以下。默认是内存的一半
limits.disk_bytesint请求体字节。0 表示跟主机文件系统共用
limits.data_bytesint请求体一块能熬过重装的 /data 盘,多少字节
limits.net_quota_bytesint请求体每个窗口多少字节,两个方向都算
portsarray请求体创建时就发布出去的容器端口
domainsarray请求体第一天就路由到这里的名字
expires_atstring请求体一个日期(2026-12-31)或者一个 RFC 3339 时间戳。不填取机器的默认;"" 表示永不到期
allowed_imagesarray请求体持有者能重装成哪些目录 id。不填就是整个目录
sh
curl -sS -b jar.txt -X POST "$PANEL/machines/$MID/containers" \
  -H 'content-type: application/json' -d '{
    "name":"web",
    "image_id":"debian-12",
    "limits":{"cpu_cores":2,"mem_bytes":2147483648,
              "disk_bytes":21474836480,"net_quota_bytes":1099511627776},
    "expires_at":"2026-12-31",
    "ports":[80,443]
  }'

201

json
{"container":{"cid":"a1b2c3d4e5f6","name":"web","state":"active",  },
 "machine_id":"mch_1a2b3c4d",
 "credentials":{"ssh_username":"u7k2m9p","ssh_password":"…",
                "ssh":"ssh [email protected] -p 22"},
 "share_code":"K4M7PQR2",
 "share_url":"https://hqno.de/containers/redeem?code=K4M7PQR2",
 "share_expires_at":"2026-08-27T06:39:00Z"}

密码和分享码都只显示一次。 这个应答是它们唯一的一份;没有接口能把它们读回来。 分享码只有在 username 没填时才出现 —— 没人持有它,那把交出去的钥匙就跟着它一起来。

一台机器能扛多少容器,是机器的答案,不是面板里的一个数字: 主机按真实的内存和 CPU 压力来拒绝一次创建,并且说出原因。 面板管的上限是你的账号 —— 402 limit_reached

拒绝意思
409 name_taken那台机器上有一个同名的容器
402 limit_reached你账号能拿的容器数用完了
409 machine_offline一个还不存在的用户名,没法由一台没上报过的机器造出来
502 agent_unreachable那台机器不应答

改限额、到期、名字或状态

PATCH /api/v1/machines/{mid}/containers/{cid}

认证:会话

只有你发过去的字段会变。

名字类型位置必填说明
midcidstring路径
limitsobject请求体和创建时一样的形状。不写的字段保持原值
expires_atstring请求体三态:不写就不动,一个日期就挪过去,"" 就取消到期
namestring请求体改名。规则和创建时一样
statestring请求体activesuspended
allowed_imagesarray请求体
domainsarray请求体整个替换掉那个列表
sh
curl -sS -b jar.txt -X PATCH "$PANEL/machines/$MID/containers/$CID" \
  -H 'content-type: application/json' \
  -d '{"limits":{"mem_bytes":4294967296},"expires_at":"2027-06-30"}'

给一个到期的容器续期,就是这个调用 —— 把 expires_at 往后挪,它就直接回来了, 盘还是那块盘,登录还是那个登录。

删除容器

DELETE /api/v1/machines/{mid}/containers/{cid}

认证:会话

唯一一个会毁掉数据的调用。到期从不删东西,别的也都不删。

名字类型位置必填说明
midcidstring路径
confirmstring请求体容器自己的名字
forcebool请求体机器没应答时,把面板里的记录删掉。容器本身不动,可能还在跑
sh
curl -sS -b jar.txt -X DELETE "$PANEL/machines/$MID/containers/$CID" \
  -H 'content-type: application/json' -d '{"confirm":"web"}'
json
{"ok":true,"warning":""}

把容器绑给一个账号

POST /api/v1/machines/{mid}/containers/{cid}/bind

认证:会话

名字类型位置必填说明
midcidstring路径
usernamestring请求体不存在就建出来 —— 这是唯一一个会造用户名的地方。需要一台上报过的机器
emailstring请求体只有在建这个账号时才用得上

这样建出来的账号还没有密码;它的主人拿那个邮箱走忘记密码。 同一条路径上的 DELETE 是解绑 —— 容器还在,里面什么都不动。

生成分享码

POST /api/v1/machines/{mid}/containers/{cid}/bind-code

认证:会话 · 参数:路径里的 midcid

给一个没人持有的容器用。谁兑换了它,谁就成为持有者,并设定 shell 登录。

json
{"code":"K4M7PQR2","expires_at":"2026-08-27T06:39:00Z",
 "redeem_url":"https://hqno.de/containers/redeem?code=K4M7PQR2",
 "container":{"cid":"a1b2c3d4e5f6","name":"web","machine":"hk-1"}}

201,而且只显示一次 —— 存下来的只有哈希。同一条路径上的 GET 会把这个容器已经有的那个码交回来,而不是再生成第二个。已经有持有者就是 400

重置某个容器的 shell 登录

POST /api/v1/machines/{mid}/containers/{cid}/credentials

认证:会话

重置 shell 登录在主人这一侧的版本,同样的请求体, 同样只显示一次的应答。管理员就是这样把一个密码丢了的容器救回来的。

开一个公网端口

POST /api/v1/machines/{mid}/containers/{cid}/ports

认证:会话

名字类型位置必填说明
midcidstring路径
containerint请求体容器里面的那个端口。1–65535
protostring请求体tcpudp。默认 tcp
publicint请求体外面那个号码:1024–65535。不填或者填 0,主机就挑一个空的,并告诉你是哪个
countint请求体连续几个端口,最多 4096 个。public+i → container+i
applystring请求体now(默认)或 next_startnow 会重启这个容器的网络,正在跑的连接会断
sh
curl -sS -b jar.txt -X POST "$PANEL/machines/$MID/containers/$CID/ports" \
  -H 'content-type: application/json' -d '{"proto":"tcp","container":3000}'

答的是整个端口列表,跟 GET 一样,真的有东西重启了的话再多一个 notice

公网端口从 1024 起,因为绑更低的号码需要一个这个设计不打算要的 capability。 什么号码空着、什么号码在它的私有池里、一段最长能有多长,都由主机说了算 —— 这些会以 409 port_takenport_in_poolspan_too_largeport_overlapno_free_port 回来,每一个都在字段里、也在那句话里说出具体的号码。

关一个公网端口

DELETE /api/v1/machines/{mid}/containers/{cid}/ports/{id}

认证:会话

名字类型位置必填说明
midcidstring路径
idstring路径一个映射 id,比如 tcp-3000-0.0.0.0
applystring查询参数now(默认)或 next_start

409 port_in_use 会说出还在路由到它的那个域名。

其余机器接口

这一页不写它们,因为它们讲的是怎么运营这台盒子,而不是容器: GET/POST /machines/{mid}/deps/storage/tuning/ports/ports/sshd/upgrade/images/progressPATCH /machines/{mid}/policyDELETE /machines/{mid},以及接入。 它们遵守同样的约定。它们各自做什么, 自己跑一台机器里有。


在容器里面

不要在你自己盒子里的 shell 上用上面任何一个。容器里没有面板凭据 —— 这是一个刻意的性质,不是疏忽 —— 而你会想要的那三件事,本来就已经在里面了:

命令它做什么
dashboard限额、用掉多少、流量还剩多少、到期、地址
app-setup domain add example.com认领一个名字并把它路由到这里
passwd改 shell 登录密码

它们走的是机器自己那条已经认证过的链路,通过一个 socket 到面板, 而你是哪个容器,是由「哪个监听端接下了这个连接」认出来的。 你敲什么都不可能指到另一个容器上,盒子里也没有任何秘密可偷。 见使用你的容器


现成的做法

一个 GitHub Actions 任务

重装一个容器,等它回来,没回来就让任务失败。

yaml
name: rebuild
on: workflow_dispatch

jobs:
  rebuild:
    runs-on: ubuntu-latest
    env:
      PANEL: https://hqno.de/api/v1
      CID: a1b2c3d4e5f6
      NAME: web            # confirm 要等于它
    steps:
      - name: 登录
        run: |
          curl -sS --fail-with-body -c "$RUNNER_TEMP/jar" "$PANEL/auth/login" \
            -H 'content-type: application/json' \
            -d "$(jq -nc --arg u "${{ secrets.PANEL_USER }}" \
                         --arg p "${{ secrets.PANEL_PASSWORD }}" \
                         '{identifier:$u,password:$p}')" > /dev/null

      - name: 重装
        run: |
          curl -sS --fail-with-body -b "$RUNNER_TEMP/jar" \
            -X POST "$PANEL/me/containers/$CID/reinstall" \
            -H 'content-type: application/json' \
            -d "$(jq -nc --arg c "$NAME" '{image_id:"debian-12",confirm:$c}')"

      - name: 等它回来
        run: |
          for i in $(seq 1 60); do
            state=$(curl -sS -b "$RUNNER_TEMP/jar" "$PANEL/me/containers/$CID" \
                    | jq -r '.container.container.live.run_state')
            echo "run_state=$state"
            [ "$state" = "running" ] && exit 0
            sleep 10
          done
          echo "容器没有回来"; exit 1

      - name: 登出
        if: always()
        run: |
          curl -sS -b "$RUNNER_TEMP/jar" -X POST "$PANEL/auth/logout" > /dev/null
          rm -f "$RUNNER_TEMP/jar"

不管你的任务干什么,上面有四件事值得照抄:

  • --fail-with-body 光用 curl,遇到 409 也会退出 0, 于是一个不看状态码的任务,会在一个根本没重启的容器上打出绿色的勾。 这个参数会把面板那句话打出来,并让这一步失败。
  • jq -nc 拼 JSON,这样一个带引号的密码不会变成一个坏掉的请求体, 或者一次 shell 注入。
  • cookie jar 放在 $RUNNER_TEMP 里,最后删掉。 把密码存成 secret,永远不要存 cookie。
  • 容忍 502。 agent_unreachable 是机器短暂失联,不是一次失败的部署。

部署完重启

sh
#!/bin/sh
set -e
PANEL=https://hqno.de/api/v1
CID=a1b2c3d4e5f6

curl -sS --fail-with-body -c /tmp/jar "$PANEL/auth/login" \
  -H 'content-type: application/json' \
  -d "$(jq -nc --arg p "$PANEL_PASSWORD" '{identifier:"you",password:$p}')" > /dev/null

curl -sS --fail-with-body -b /tmp/jar -X POST "$PANEL/me/containers/$CID/restart"
rm -f /tmp/jar

流量到额度 80% 就报警

sh
curl -sS -b jar.txt "$PANEL/me/containers/$CID" | jq -e '
  .container.container
  | (.net_usage.rx_bytes + .net_usage.tx_bytes) as $used
  | .limits.net_quota_bytes as $quota
  | if $quota > 0 and $used / $quota > 0.8
    then "USED \($used) OF \($quota)" | halt_error(1)
    else empty end'

退出码 1 表示过线了。到 100% 容器会被停用 —— 是关掉,不是删掉 —— 窗口滚过去它就回来。

每月轮换 shell 密码

sh
new=$(curl -sS --fail-with-body -b jar.txt \
  -X POST "$PANEL/me/containers/$CID/credentials" \
  -H 'content-type: application/json' -d '{}' \
  | jq -r '.credentials.ssh_password')

# 这是它唯一的一份。在这个脚本结束之前把它放到某个地方去。
printf '%s' "$new" | your-secret-store put hqnode/web/ssh

这一页不承诺什么

这是面板自己的 API,而面板和调用它的那个页面是一起发布的。v1 这个前缀没有变过形状, 上面这些调用也不会悄悄消失 —— 但这不是一份带着弃用政策的、厂商公开的契约。 按 code 分支,不要按 message 里的英文分支;脚本写短一点, 短到升级之后还能重读一遍;优先用现成的做法里那些调用, 它们是面板自己的界面靠得最重的那几个。

没有 webhook,也没有事件流:自己轮询。也没有批量接口 —— 一次一个容器。