Gunnar

nordisk/holger

.nornir/agent-design.md

# The agent — design

> **Class:** `design` · **Function:** `agent` (a sibling process to the server; produces
> what `storage` serves). The whole-system view is [`design.md`](design.md).

## What it is

The **agent** is the internet-side half of the airgap split. It runs where there *is*
connectivity, pulls the packages a secure zone needs, and packs them into one signed
`.znippy` archive that an operator carries across the boundary. The secure-side server never
needs connectivity — the write happens outside the wall. Binary **`holger-agent`**
(package `holger-agent-cli`, `agent/cli`); logic in `agent/lib`.

## The connector seam — `ConnectorTrait`

Every source and target implements one trait (`traits/src/lib.rs`):

| method | purpose |
|---|---|
| `list_repositories() -> Vec<RemoteRepository>` | enumerate repos at a source |
| `list_assets(repo) -> Vec<RemoteAsset>` | enumerate a repo's artifacts |
| `download_asset(asset) -> Vec<u8>` | pull one artifact's bytes |
| `upload_asset(repo, path, data)` | push bytes to a writable target |

- **Sources**: crates.io, Nexus, Holger, a `.znippy` archive, a directory.
- **Targets**: a `.znippy` archive, a directory, Nexus, Holger.

Each connector carries a `ConnectorAuth = None | Basic { username, password } |
OidcToken { token } | Mtls`. Concrete connectors ship as their own crates:
`agent/connector/nexus` (Basic auth) and `agent/connector/world` (crates.io / PyPI, the
external-registry connector). The same `ConnectorTrait` symmetry means "pull from Nexus,
pack to znippy" and "pull from crates.io, pack to znippy" are the same code with different
connectors.

## The flow

```
Source ──▶ [agent: list → download → pack + sign] ──▶ .znippy ──(USB/optical)──▶ server
```

1. `list_repositories` / `list_assets` enumerate what to pull;
2. `download_asset` fetches each artifact's bytes;
3. the set is packed into an immutable, content-addressed (blake3) `.znippy` — one archive
   per repository version;
4. the operator carries the `.znippy` across the airgap; the server points a read-only repo
   at it and serves every ecosystem's native protocol out of it.

## Hardening (the internet-side attack surface)

The agent is the component with egress, so its download path is guarded:

- **`safe_join`** rejects absolute or `..`-bearing upstream-supplied asset paths (an upstream
  can't make the agent write a cron/shell file → RCE on the internet-side host).
- **SSRF**: the Nexus connector requires a registry-supplied `downloadUrl` to share
  scheme+host+port with the configured base URL **before** attaching Basic/Bearer creds, so
  credentials can't leak to `169.254.169.254` or internal hosts; the world/pip connector
  restricts downloads to an **https host allow-list**.
- Connector responses / JSON are read through **capped readers**; expanded archive entries
  go through a **size-limited reader**.