Files

378 lines
13 KiB
Markdown

# openHop Plugins
The plugin system installs and supervises **external** applications around the
Repeater. Plugins are not imported into the Repeater process. They talk to
existing interfaces (REST, WebSocket/SSE, Companion frame server, static web).
## Trust and access
Plugins are trusted applications, **not sandboxed code**. A separate process and
virtualenv keep Python dependencies separate; they do not create a filesystem,
network, or credential boundary. Install only plugins and dependencies you trust.
Native plugins currently run under the `repeater` service account; container
plugins run under the configured container account. Never run a plugin manager as
root merely to work around installation permissions.
The current Repeater API tokens are administrator-equivalent. A token that can
read status can also administer plugins, install uploaded wheels, and read plugin
settings. Do not hand these tokens to untrusted or telemetry-only consumers.
Scoped integration credentials would require a separate authorization feature.
Schema-2 catalogue checksums approve the plugin wheel itself. Python dependencies
resolved by pip are not covered by that checksum and can change on installation
or environment rebuild. The curated catalogue is not a claim that the complete
transitive dependency set is hash-locked. Deployments requiring reproducibility
should use a controlled, pinned dependency source.
## Layout
Default root (under Repeater storage):
```text
/var/lib/openhop_repeater/plugins/
└── openhop.nomad/
├── releases/0.1.0/
│ ├── openhop-plugin.json
│ ├── ui/ # optional application UI assets
│ └── venv/ # isolated Python environment
├── current -> releases/0.1.0
├── data/ # persistent plugin-owned data
├── logs/plugin.log
└── state.json # manager metadata only (id, version, enabled)
```
- Code and virtualenv are **version-specific**.
- The installed wheel is retained with each release so its virtualenv can be
rebuilt automatically if a future container image changes Python versions.
- `data/` is **not** version-specific and is never interpreted by the manager.
- IPC socket default: `/var/lib/openhop_repeater/plugin-manager.sock`.
Override with config:
```yaml
plugins:
enabled: true
# root: "/var/lib/openhop_repeater/plugins"
# socket: "/var/lib/openhop_repeater/plugin-manager.sock"
```
## Manifest (`openhop-plugin.json`)
Schema version **1**:
```json
{
"schema": 1,
"id": "openhop.nomad",
"name": "NOMAD Bridge",
"version": "0.1.0",
"runtime": {
"type": "python",
"entrypoint": "meshcore-nomad-bridge"
}
}
```
UI-only application plugin:
```json
{
"schema": 1,
"id": "openhop.console",
"name": "openHop Console",
"version": "1.0.0",
"ui": {
"type": "application",
"entry": "ui/index.html"
}
}
```
A plugin may declare both `runtime` and `ui`.
The UI entry must be inside a dedicated asset subtree such as `ui/index.html`.
A release-root entry (`index.html`), dot/parent path segments, and reserved
runtime/storage subtrees (`venv`, `data`, `logs`, `releases`, `current`) are
rejected. Existing plugins using these invalid layouts must be repackaged;
serving the whole release directory is intentionally no longer supported.
## Plugin manager service
Native installs:
```bash
sudo cp openhop-plugin-manager.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now openhop-plugin-manager
```
The manager starts enabled service plugins on boot and supervises crashes
(restarts with a simple crash-loop limit: 5 unexpected exits in 60s → `FAILED`).
Repeater does **not** require the manager. If the socket is missing, plugin API
calls return HTTP 503 and normal Repeater operation continues.
The Docker image runs Repeater and the manager under a small process supervisor,
with `tini` as PID 1 to reap orphaned children. It forwards shutdown signals to
both services and their plugin processes, and restarts the manager if it exits
unexpectedly while Repeater remains running. Set `OPENHOP_PLUGIN_MANAGER=0` to
run Repeater without the manager.
### Native upgrade prerequisite
Existing native installations must run the updated `manage.sh upgrade` once as
an administrator to replace the privileged OTA helper. Installing only a new
Python wheel cannot safely replace an existing root-owned helper. Subsequent
OTA upgrades through the refreshed helper install the manager unit from the
installed package; no source checkout is required on the host.
The helper runs from a trusted directory with a clean environment and isolated
Python module lookup. It rejects writable/non-root-owned execution trees instead
of silently granting existing code root ownership. If this check fails, an
administrator must restore the application environment from a trusted package.
Do not bypass the check or make the venv service-writable.
## Operation safety and limits
The manager serves bounded concurrent IPC requests, so a slow installation does
not monopolize the socket. Mutations of the same plugin are serialized;
conflicting requests return a busy response rather than interleaving state.
Local wheel inputs are copied to manager-owned staging before installation.
Installation has a longer IPC budget than ordinary status requests. If transport
is lost after dispatch, HTTP 504 with `outcome: unknown` means the operation may
still finish: **it does not mean cancellation or rollback**. Inspect plugin
status before retrying. An uploaded source is retained when completion is
ambiguous so an accepted install cannot lose its input.
Venv creation and pip execution are time-limited, installer output is bounded,
and plugin stdout/stderr are drained into bounded logs while the plugin runs.
Stop/disable terminate the supervised process group, including ordinary child
workers. This is process supervision, not containment of malicious applications
that deliberately detach into new sessions or write other files.
Failed Python-version rebuilds restore the previous venv and remain retryable;
the new marker is accepted only after the new environment and entrypoint exist.
A rebuild-intent record is reconciled before version checks after a manager
restart, so interruption during replacement cannot silently accept a partial
venv. This provides process-interruption recovery, not guaranteed filesystem
durability across power loss; interruption during cleanup can leave an unused
backup.
Archive extraction limits expanded member size and total size and rejects
symlink-backed destinations. Catalogue JSON and wheel response bodies have size
limits and elapsed-time budgets; configured catalogue URLs must use HTTPS.
DNS resolution and urllib connection/header setup are not guaranteed to obey a
strict whole-request wall-clock deadline.
## Install a local wheel
Via API (authenticated):
```bash
curl -X POST -H "Authorization: Bearer $TOKEN" \
-F "wheel=@openhop_nomad_plugin-0.1.0-py3-none-any.whl" \
http://127.0.0.1:8000/api/plugins/install
```
Or JSON with a host path:
```bash
curl -X POST -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"wheel_path":"/tmp/plugin.whl"}' \
http://127.0.0.1:8000/api/plugins/install
```
Installation creates an isolated venv under the plugin release directory and
**never** mutates the Repeater virtualenv.
## Enable / disable / lifecycle
```bash
# Enable (also starts service plugins)
curl -X POST -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"id":"openhop.nomad"}' \
http://127.0.0.1:8000/api/plugins/enable
curl -X POST .../api/plugins/disable -d '{"id":"openhop.nomad"}'
curl -X POST .../api/plugins/start -d '{"id":"openhop.nomad"}'
curl -X POST .../api/plugins/stop -d '{"id":"openhop.nomad"}'
curl -X POST .../api/plugins/restart -d '{"id":"openhop.nomad"}'
```
List / status / logs:
```bash
curl -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8000/api/plugins/
curl -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8000/api/plugins/openhop.nomad
curl -H "Authorization: Bearer $TOKEN" \
"http://127.0.0.1:8000/api/plugins/logs?id=openhop.nomad&tail=100"
```
## Plugin configuration
Each plugin owns `$OPENHOP_PLUGIN_DATA/config.json`. The manager treats it as an
opaque JSON object — it does not validate plugin-specific keys.
Plugins can declare editor defaults in the manifest:
```json
{
"config": {
"defaults": {
"meshcore_host": "127.0.0.1",
"nomad_url": "http://127.0.0.1:8080"
}
}
}
```
Optionally ship `config.default.json` next to `openhop-plugin.json` in the wheel
(`share/openhop/plugins/<id>/`). On install, if `data/config.json` is missing,
the manager seeds it from those defaults. The settings UI shows defaults when no
saved config exists, and offers **Reset to defaults**.
API:
```bash
# Read
curl -H "Authorization: Bearer $TOKEN" \
"http://127.0.0.1:8000/api/plugins/settings?id=openhop.nomad"
# Write (optionally restart service plugins)
curl -X POST -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"id": "openhop.nomad",
"restart": true,
"config": {
"nomad_url": "http://192.168.0.170:8080",
"nomad_model": "qwen2.5:3b-instruct",
"meshcore_host": "127.0.0.1",
"meshcore_port": 5001
}
}' \
http://127.0.0.1:8000/api/plugins/settings
```
The Plugins page in the web UI provides a Settings dialog for the same file.
## Service plugin environment
Started processes receive:
| Variable | Example |
|----------|---------|
| `OPENHOP_PLUGIN_ID` | `openhop.nomad` |
| `OPENHOP_PLUGIN_DATA` | `/var/lib/openhop_repeater/plugins/openhop.nomad/data` |
Plugin-specific configuration (for example NOMAD `config.json`) lives under
`$OPENHOP_PLUGIN_DATA` and is owned by the plugin.
## Application UI plugins
Enabled UI plugins are served by Repeater at:
```text
/plugins/{id}/
/plugins/{id}/assets/...
```
Path traversal is rejected. Missing paths fall back to the manifest `ui.entry`
document (SPA-friendly). Disabled plugins are not exposed.
## Uninstall
Default **keeps** `data/`:
```bash
curl -X DELETE -H "Authorization: Bearer $TOKEN" \
http://127.0.0.1:8000/api/plugins/openhop.nomad
```
Delete data as well:
```bash
curl -X DELETE -H "Authorization: Bearer $TOKEN" \
"http://127.0.0.1:8000/api/plugins/openhop.nomad?delete_data=true"
```
## NOMAD acceptance
1. Build the `openhop-nomad-plugin` wheel.
2. Install via `/api/plugins/install`.
3. Enable a Repeater Companion frame server reachable from the plugin. In the
standard Docker image, `127.0.0.1:5001` reaches the same container.
4. Write `$OPENHOP_PLUGIN_DATA/config.json` with `nomad_url` / `nomad_model`.
5. Enable the plugin — `meshcore-nomad-bridge` should report `RUNNING`.
6. Confirm its logs show a successful Companion connection, not only a running
retry loop. A `RUNNING` process can still be reconnecting to a disabled frame
server.
## Future work (not in this version)
- Web Component dashboard widgets and settings panels
- Permission scopes, signing, non-Python runtimes
- Plugin SDK
## Plugin catalogue
Repeater browses the curated static catalogue from openHop's R2-backed endpoint.
Update checks read only that catalogue and do not query the GitHub API. When a
user installs an approved plugin, Repeater downloads the exact wheel directly
from the plugin's GitHub Release.
Default catalogue URL:
```text
https://repeater-plugins.openhop.dev/catalogue.json
```
Override with:
```yaml
plugins:
catalogue_url: "https://repeater-plugins.openhop.dev/catalogue.json"
```
Catalogue schema 2 records each plugin's currently approved `version`, exact
GitHub Release `wheel_url`, and lowercase `sha256`. The manager validates that
the URL belongs to the plugin repository, follows only GitHub's HTTPS release
asset redirect, verifies the checksum before installation, and confirms the
wheel manifest ID and version match the catalogue. Publishing a newer GitHub
Release does not make it available until the catalogue maintainers approve its
metadata in the R2 catalogue.
Schema 1 repository-only catalogues remain readable for compatibility with
custom deployments, but the default openHop catalogue uses schema 2 and needs
no GitHub token.
```bash
# List catalogue (annotated with installed / latest when available)
curl -H "Authorization: Bearer $TOKEN" \
http://127.0.0.1:8000/api/plugins/catalogue
# Install the currently approved catalogue version (enabled by default)
curl -X POST -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"id":"openhop.nomad"}' \
http://127.0.0.1:8000/api/plugins/catalogue_install
# Check for updates
curl -H "Authorization: Bearer $TOKEN" \
"http://127.0.0.1:8000/api/plugins/updates?id=openhop.nomad"
# Apply update
curl -X POST -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"id":"openhop.nomad"}' \
http://127.0.0.1:8000/api/plugins/update
```
R2 catalogue or GitHub Release download outages do not affect already-installed
plugins or Repeater startup. Local `.whl` install continues to work unchanged.