PanelDOCSv1.0.0

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

text
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:

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, /root and /etc/hostfog can't be seen, and of HF Panel's own files only the plugin's folder (read-only) is there;
  • /tmp is 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.

sh
#!/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:

html
<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:

bash
openssl genpkey -algorithm ed25519 -out vendor.pem

Then, in the plugin's folder:

bash
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:

bash
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.