178 lines
9.1 KiB
Markdown
178 lines
9.1 KiB
Markdown
# NetBox Store host agent (MVP)
|
||
|
||
This package is the deliberately small privileged boundary between the NetBox Store plugin and a
|
||
NetBox 4.6.5–4.6.8 Linux host. It does not import the Store implementation or assume Django: every
|
||
decision is revalidated against the configured JSON API immediately before a lifecycle action.
|
||
|
||
The daemon is **dry-run by default**. It serializes operations, persists idempotency and state in
|
||
SQLite, accepts one bounded JSON line per Unix-stream connection, verifies Linux peer credentials,
|
||
downloads only an approved immutable wheel or commit-bound source archive, and invokes subprocesses only as fixed argument arrays
|
||
with `shell=False`.
|
||
|
||
## Install and one-time operator setup
|
||
|
||
Build/install into a dedicated administrative environment, copy
|
||
[`examples/agent.toml`](examples/agent.toml) to `/etc/netbox-store-agent/agent.toml`, replace all
|
||
site-specific paths, hosts, UIDs/GIDs and versions, then make the file root-owned and mode `0600`.
|
||
The optional bearer-token file must also be root-owned `0600`. Store credentials are sent only to
|
||
the exact scheme/host/port origin of `store.base_url`, never to a separately allow-listed artifact
|
||
host.
|
||
|
||
`allow_private_addresses = false` is intentionally fail-closed. If the Store or an approved
|
||
artifact host consciously runs on RFC1918/ULA infrastructure, set it to `true` only after pinning
|
||
every expected hostname in `allowed_hosts` and deploying correctly verified TLS (including the
|
||
private CA via `ca_file` when needed). The host allow-list and exact-origin credential rule remain
|
||
active; this switch only permits private/reserved DNS results.
|
||
|
||
The agent owns only these two configured files:
|
||
|
||
- `paths.include_path`, which contains only `STORE_PLUGINS = [...]`;
|
||
- `paths.requirements_path`, which contains locked wheel references. Locally built wheels are cached below `/opt/netbox/.netbox-store-wheels`.
|
||
|
||
In the operator-owned NetBox `configuration.py`, add once, after the normal `PLUGINS` declaration:
|
||
|
||
```python
|
||
from store_plugins import STORE_PLUGINS
|
||
|
||
PLUGINS += STORE_PLUGINS
|
||
```
|
||
|
||
Place `store_plugins.py` where that import resolves in your deployment. Optionally add a one-time
|
||
`-r /opt/netbox/local_requirements_store.txt` line to the operator-owned
|
||
`/opt/netbox/local_requirements.txt` for reproducible maintenance installs. The agent never edits
|
||
either operator-owned file.
|
||
|
||
Install the example systemd units, review their `ReadWritePaths`, `SocketGroup`, and `ExecStart`, then:
|
||
|
||
```text
|
||
systemctl daemon-reload
|
||
systemctl enable --now netbox-store-agent.socket
|
||
netbox-store-agent capabilities
|
||
```
|
||
|
||
The canonical socket path used by the unit, example config, CLI default, and NetBox Store plugin is
|
||
`/run/netbox-store-agent/agent.sock`.
|
||
|
||
With peer checks enabled (the production default), Linux `SO_PEERCRED` must be available and either
|
||
the peer's effective UID must appear in `allowed_peer_uids` or its effective GID in
|
||
`allowed_peer_gids`; Unix-socket filesystem permissions still apply. Prefer allow-listing the exact
|
||
NetBox service UID, especially when socket access is granted through a supplementary group.
|
||
|
||
Keep `dry_run = true` through catalog and lifecycle acceptance tests. Enabling real mutation is an
|
||
explicit operator configuration change.
|
||
|
||
## Store JSON contract
|
||
|
||
Both endpoint templates may be configured with or without a trailing slash. A 404 is retried once
|
||
using the alternate form. Path parameters are URL-quoted. The agent accepts exactly these fields.
|
||
|
||
`GET /api/v1/plugins/{slug}/`:
|
||
|
||
```json
|
||
{
|
||
"api_version": "v1",
|
||
"slug": "netbox-example",
|
||
"name": "Example",
|
||
"summary": "Example plugin",
|
||
"description": "Description",
|
||
"repository_url": "https://git.example/repo",
|
||
"latest_version": "1.2.3",
|
||
"package_name": "netbox-example",
|
||
"import_name": "netbox_example",
|
||
"min_netbox_version": "4.6.5",
|
||
"max_netbox_version": "4.6.8",
|
||
"approved": true,
|
||
"status": "approved",
|
||
"releases": []
|
||
}
|
||
```
|
||
|
||
`GET /api/v1/plugins/{slug}/releases/{version}/` returns a release object directly:
|
||
|
||
```json
|
||
{
|
||
"version": "1.2.3",
|
||
"download_url": "https://store.example/artifacts/netbox_example-1.2.3-py3-none-any.whl",
|
||
"sha256": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||
"artifact_size": 12345,
|
||
"commit_sha": "",
|
||
"min_netbox_version": "4.6.5",
|
||
"max_netbox_version": "4.6.8",
|
||
"published_at": "2026-08-24T12:00:00Z",
|
||
"approved": true,
|
||
"status": "approved",
|
||
"immutable": true,
|
||
"approved_payload_sha256": "abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd"
|
||
}
|
||
```
|
||
|
||
If release detail returns 404, exactly one matching object in plugin `releases[]` is accepted. The
|
||
historic field `approved_payload_sha256` is protocol-v1 naming: clients treat its lowercase 64-hex
|
||
value as an opaque Store marker, and the agent compares it exactly with the freshly fetched value.
|
||
Artifact `sha256`, in contrast, is always a lowercase 64-character SHA-256.
|
||
|
||
The plugin and release must be approved, the release immutable and compatible with the configured
|
||
NetBox version. A wheel's filename distribution and version must match
|
||
the catalog, and its byte count, digest, host, scheme and DNS addresses are checked while streaming.
|
||
Redirects, source distributions, private/reserved DNS targets (unless explicitly enabled for a test
|
||
environment), and catalog additions outside the v1 schema fail closed.
|
||
|
||
## Client protocol
|
||
|
||
Transport is Unix `SOCK_STREAM`, UTF-8 JSON-lines, exactly one request and one response per
|
||
connection, maximum 64 KiB. Response shape is always
|
||
`{"protocol_version":1,"status":...,"body":{...}}`.
|
||
|
||
```json
|
||
{"protocol_version":1,"method":"GET","path":"/v1/capabilities"}
|
||
```
|
||
|
||
```json
|
||
{"protocol_version":1,"method":"POST","path":"/v1/operations","idempotency_key":"20f4274f-d4e5-42bf-9164-967b1a774481","body":{"request_id":"eea17d87-8944-4ee2-a076-363338ab746d","action":"install","plugin_slug":"netbox-example","version":"1.2.3","approved_payload_sha256":"abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd","requested_by":"netbox:alice"}}
|
||
```
|
||
|
||
```json
|
||
{"protocol_version":1,"method":"GET","path":"/v1/operations/20f4274f-d4e5-42bf-9164-967b1a774481"}
|
||
```
|
||
|
||
`POST` returns 202. Repeating the same idempotency UUID with the identical body returns the existing
|
||
operation (`created:false`); a different body returns 409. States are `queued`, `running`,
|
||
`dry_run`, `succeeded`, `failed`, or `manual_recovery`.
|
||
|
||
CLI examples:
|
||
|
||
```text
|
||
netbox-store-agent submit install netbox-example --version 1.2.3 \
|
||
--approved-payload-sha256 abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd
|
||
netbox-store-agent status 20f4274f-d4e5-42bf-9164-967b1a774481
|
||
```
|
||
|
||
## Lifecycle and fail-closed boundaries
|
||
|
||
- `install` and `update` re-fetch plugin and release, exactly match the approval token, download and
|
||
validate the wheel, then use `pip --no-index --no-deps --only-binary=:all: --require-hashes`.
|
||
- Source archives must be tied to an exact commit. The agent rejects unsafe archive members and requires configured `unshare`/`setpriv` executables plus a dedicated build UID/GID. It builds without network access and with dropped privileges via `pip wheel --no-index --no-deps --no-build-isolation`, validates the result, and caches only that wheel for future NetBox upgrades. Reviewing source code remains a privileged trust decision because Python build backends execute code.
|
||
- New installs remain disabled. Updating an enabled plugin runs NetBox `migrate`, `collectstatic`,
|
||
and restarts every configured service (`netbox` and `netbox-rq` in the example).
|
||
- `enable` revalidates its installed release, writes the include, migrates, collects static files,
|
||
and restarts both services. `disable` writes the include and restarts both. `uninstall` is allowed
|
||
only after disable and revalidates current approved plugin identity first.
|
||
- Self-management slugs are denied for every action. Package/import names and all executable paths,
|
||
service units, managed paths, Store origins and NetBox compatibility are configuration/catalog
|
||
policy—not caller-controlled command fragments.
|
||
- A root-owned global file lock prevents simultaneous host mutations. Interrupted `running`
|
||
operations become `manual_recovery` at startup. Managed files are backed up per operation and
|
||
atomically replaced; best-effort restoration does not claim package/service rollback.
|
||
|
||
This MVP intentionally has no TUF metadata, dependency-wheel set, transactional virtualenv switch,
|
||
or reliable rollback for package installation/database migrations/service restarts. `--no-deps`
|
||
means an approved plugin's dependencies must already be provisioned by the operator/base image.
|
||
Any failure after host mutation is marked `manual_recovery`; inspect the journal, backup directory,
|
||
installed distributions, migrations, both services and both managed files before retrying. If the
|
||
Store is unavailable or an entry is no longer approved, lifecycle calls fail closed; emergency
|
||
manual recovery remains an operator procedure outside this API.
|
||
|
||
The security boundary still depends on root ownership and permissions of the daemon executable,
|
||
configuration, token, journal/state directories, socket and NetBox paths; TLS/CA integrity; Store
|
||
approval operations; artifact build provenance; and a correctly restricted NetBox service account.
|