feat: add NetBox plugin store
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
# 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, 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 the locked direct wheel references.
|
||||
|
||||
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. The artifact must be a valid wheel whose filename distribution and version 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`.
|
||||
- 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.
|
||||
Reference in New Issue
Block a user