Plugins
Write an HF Panel plugin — a signed marketplace package or a local trusted plugin — with hooks, pages and its own API token.
A plugin adds features to HF Panel without changing the panel itself. It can run a hook when an event happens (a new hosting account, a removed domain …), show pages in the panel, and call the API with its own token. There are two kinds:
| Kind | plugin.json |
Signed | Its hooks run | Who installs it |
|---|---|---|---|---|
Marketplace package (.hfp) |
"manifest": 2 |
by its vendor and countersigned by the marketplace | in a sandbox, as the user hfp-<id>, with only the permissions the administrator approved |
any administrator |
Local trusted plugin (.zip) |
no manifest field (version 1) |
no | as root | an administrator, for their own plugins only |
Where: Server Admin › System › Plugins & hooks, or hf-agent plugin on the server
Warning: a local trusted plugin's hooks run as root, and its pages open inside the panel with the signed-in person's session. Only install your own plugins that way. Anything from someone else should be a signed marketplace package.
A marketplace package
crm-sync/
├── plugin.json manifest 2: id, version, publisher, permissions, hooks, ui
├── hooks/
│ └── created.sh runs in the sandbox for account.created
├── pages/
│ ├── index.html its page in the Server Admin menu
│ └── card.html its card on the customer's Overview
├── MANIFEST.sha256 the sha256 of every other file
├── SIGNATURE.vendor your signature
└── SIGNATURE.market the marketplace's countersignature (added when the version is approved)
plugin.json:
{
"manifest": 2,
"id": "crm-sync",
"name": "CRM sync",
"version": "2.1.0",
"publisher": "Example Ltd",
"homepage": "https://example.com/crm-sync",
"min_panel": "1.0.0",
"permissions": ["accounts:read", "events:account.created", "net:crm.example.com", "ui:admin.menu", "ui:client.account-card"],
"hooks": { "account.created": "hooks/created.sh" },
"ui": [
{ "point": "admin.menu", "group": "System", "label": "CRM" },
{ "point": "client.account-card", "label": "Your CRM", "path": "card.html" }
],
"pages": "pages"
}
Every field is described in plugin.json, every permission in Plugin permissions.
Hooks in the sandbox
A hook runs only for the events it has an events:<event> permission for, and never as root. Each run is a
short-lived service of its own, user hfp-<id>:
- the whole system is read-only,
/home,/rootand/etc/hostfogcan't be seen, and of HF Panel's own files only the plugin's folder (read-only) is there; /tmpis its own and empty, it has no devices, no capabilities and can't gain privileges;- it can open network connections only to the public addresses of its
net:hosts (and of the API's address when it has an API permission); those names are in its/etc/hosts; - it is stopped after 30 seconds and may use 256 MB of memory.
The event's JSON payload comes on standard input; $HF_EVENT is the event and $HF_PLUGIN the plugin's id. The
payload holds the event's details only when the plugin may read their area (for example accounts:read for
account.created); otherwise just id, event, time and server. When the plugin has an API permission, the
payload also has "api": {"url": "https://api.yourdomain.com/v1", "token": "hf_…"}. Its output goes to
/var/log/hostfog-hooks.log.
#!/bin/sh
# hooks/created.sh — tell the CRM about a new hosting account.
set -eu
payload="$(cat)"
account="$(printf '%s' "$payload" | python3 -I -c 'import json, sys; print(json.load(sys.stdin).get("account", ""))')"
case "$account" in
"" | *[!a-z0-9]*) echo "no valid account in the payload"; exit 1 ;;
esac
curl -fsS --max-time 10 -d "account=$account" https://crm.example.com/hostfog/new-account
The plugin's API token
When the plugin is enabled and has an API permission (accounts:read, mail:write …), HF Panel makes a token for it.
The token reaches only the paths its permissions cover — any other path answers 403 — and never the server, staff
or licence endpoints. It is deleted when the plugin is disabled or removed, and made again (with the new
permissions) when an update is approved. It does not appear in anyone's API tokens list.
Pages and the bridge
A page is a static file in the pages folder, shown at an extension point:
| Point | Where |
|---|---|
admin.menu, reseller.menu, client.menu |
an entry in that role's menu (under group), opening the page in the content area |
client.account-card |
a card at the end of the customer's Overview |
admin.account-tab |
an entry in each account's Manage menu (Server Admin › Accounts), opening the page for that account |
The page runs in a sandboxed frame without the panel's origin: it can't read the session, cookies or the panel.
Its Content-Security-Policy allows its own scripts and styles, and no connections. It talks to the panel only with
postMessage:
<script>
let context = null;
window.addEventListener('message', (e) => {
if (e.source !== window.parent || !e.data || e.data.type !== 'hf:context') return;
context = e.data; // { plugin, role, lang, account }
document.getElementById('who').textContent = context.account || context.role;
parent.postMessage({ type: 'hf:resize', height: document.body.scrollHeight }, '*');
});
parent.postMessage({ type: 'hf:ready' }, '*'); // asks for the context again
</script>
| Message | Direction | What it does |
|---|---|---|
{type: "hf:context", plugin, role, lang, account} |
panel → page | sent when the frame has loaded and after hf:ready; account is the hosting account the page is about ("" in a menu page) |
{type: "hf:ready"} |
page → panel | asks for the context |
{type: "hf:resize", height} |
page → panel | sets the frame's height (80–4000 pixels) |
The context holds nothing secret. A page that needs data gets it from your own service, which uses the plugin's API token.
Sign a package
The package is a zip of the plugin's folder with three more files at its top. Make your vendor key once and keep the private half safe:
openssl genpkey -algorithm ed25519 -out vendor.pem
Then, in the plugin's folder:
rm -f MANIFEST.sha256 SIGNATURE.*
find . -type f | sed 's|^\./||' | LC_ALL=C sort | while read -r f; do
echo "$(sha256sum "$f" | cut -c1-64) $f"
done >MANIFEST.sha256
key="$(openssl pkey -in ../vendor.pem -pubout -outform DER | tail -c 32 | base64 -w0)"
{ printf 'hostfog-plugin-v1 vendor\n'; cat MANIFEST.sha256; } >/tmp/msg
printf 'key %s\nsig %s\n' "$key" "$(openssl pkeyutl -sign -inkey ../vendor.pem -rawin -in /tmp/msg | base64 -w0)" >SIGNATURE.vendor
zip -r ../crm-sync-2.1.0.hfp .
MANIFEST.sha256 must list every file of the package (except itself and the signatures), sorted, one
<sha256> <path> line each. The marketplace adds SIGNATURE.market when it approves the version; until then the
package installs only on a server in Developer mode (hf-agent plugin developer on), which is for testing.
What HF Panel checks
The root agent checks everything itself before writing a file (it never trusts the panel's check):
- the zip's paths (no
.., absolute path, backslash, hidden file, symbolic link or special file), at most 2000 files and 50 MB unpacked; plugin.json(every rule);- every file against
MANIFEST.sha256, the vendor's signature, and the marketplace's countersignature of the vendor's key and the file list; - the revocation list
/etc/hostfog/plugins-revoked: a revoked vendor key (key <base64>) or package (package <sha256 of its MANIFEST.sha256>) is refused, and an installed one's hooks stop running.
A tampered file, a file that is not listed, a wrong signature or a revoked key stops the install with the reason.
A local trusted plugin
A folder with a plugin.json without manifest — id, name, version, optional description, author,
homepage, min_panel, hooks, menu (up to 10 entries: role, label, group, path) and pages — packed as a
.zip. It has no signatures and no permissions: its hooks run as root like a
script hook, and its pages open with the panel's origin for the roles of its menu
entries. The example in the HF Panel source, tools/examples/plugin-hello/, is one.
Install a plugin
On the Plugins tab of Plugins & hooks, choose the file and click Install plugin. The permissions dialog shows who signed it and what it asks for; click Install plugin there. For a local trusted plugin, tick This is my own plugin: let its hooks run as root. first. See Plugins & hooks.
On the server, as root:
sudo hf-agent plugin install crm-sync-2.1.0.hfp # or an https:// address
sudo hf-agent plugin install plugin-hello.zip --local # your own unsigned plugin
sudo hf-agent plugin list
sudo hf-agent plugin enable crm-sync # approves its permissions
| Command | What it does |
|---|---|
hf-agent plugin list |
the installed plugins, their versions, trust and whether they are on |
hf-agent plugin install <file|https://…> [--local] [--yes] |
install a package (it asks first unless you add --yes) |
hf-agent plugin enable <id> |
switch a plugin on, approving its permissions |
hf-agent plugin disable <id> |
switch a plugin off; its API token is deleted |
hf-agent plugin remove <id> |
delete the plugin's folder |
hf-agent plugin developer on|off|status |
Developer mode: also install packages signed by their vendor only |
A new plugin stays switched off until you enable it. Installing a plugin with the same id again replaces it (an
update) and keeps it enabled when it was; the dialog marks the permissions the update adds as New.