Skip to content

Security

An NMS holds the keys to the network it watches: SNMP communities, SNMPv3 credentials, and API keys for the services it polls.

Yagra’s security model starts from that fact. Credentials are encrypted at rest, never logged, and scoped tightly on the wire.

The rest of the attack surface is kept small too: non-root containers, internal-only stores, validated outbound targets, and an audit trail on every change.

Monitoring credentials are envelope-encrypted at rest. Each secret is encrypted with its own data key, and the data keys are wrapped by a master key (KEK). The database holds only ciphertext, so a database dump alone reveals no credential.

The KEK is a mounted file, never an environment value. YAGRA_KEK_FILE carries the path to the key, so the secret itself is not exposed through docker inspect or the process environment. The bundled compose files generate it once into a dedicated volume and mount it read-only into the core.

Without a persistent KEK, the core falls back to an ephemeral development key, regenerated on every restart, and warns loudly. Stored credentials encrypted under it do not survive a restart. Never run production on the fallback.

Credentials are also never logged and never returned by the API. They are redacted from logs, API responses, and metric labels alike.

All three application containers run as non-root users:

Image User Privileges
yagra-core uid 10001 None beyond a normal process
yagra-poller uid 10002 NET_RAW only — a file capability on the binary, for raw-socket ICMP
yagra-web nginx uid 101 Unprivileged nginx, listening on container port 8080 (not 80)

Only the poller holds CAP_NET_RAW, because ICMP liveness probing needs a raw socket. No other container gets it.

One consequence is worth knowing. The unprivileged poller cannot bind host ports below 1024, which is why its passive listeners use high container ports (1514, 1162), with the standard low ports mapped or redirected in front. See Ports & firewall.

The server composition adds a fourth container, yagra-updater, and it is the exception. It runs as root and mounts the Docker socket, because installing a release means recreating the stack.

Core does not get the socket. That separation is the whole reason the sidecar exists.

This creates a path from Yagra’s Admin role to root on the host that did not exist before. Four things bound it:

  • The image repository is fixed by the host environment (YAGRA_UPGRADE_REPO), so an Admin can install a tag we published and nothing else. No API request names a registry.
  • The command set the sidecar accepts is closed, and nothing in the shared volume is ever executed. It carries a request file, a heartbeat and uploaded archives, never a script.
  • Requesting an upgrade needs manage-the-deployment, which only an Admin holds. It is audited, and it is not on the MCP surface.
  • Turning the mechanism off at Settings ▸ Upgrade stops requests and outbound registry calls. The container keeps the socket until it is removed from the composition.

One setting widens this: YAGRA_UPGRADE_ALLOW_BUNDLE, which lets an uploaded docker save archive be installed. That changes the scope from “a tag we published” to “any image the archive contains”.

It is off by default, it is a host setting, and it deliberately cannot be turned on from the WebUI. The UI may act within the capability the host granted; it may never widen it.

A deployment that does not want the capability at all should remove the service from its compose file. Note that it comes back on the next upgrade, because each version installs the composition carried inside its own image. Such a deployment belongs on the command-line upgrade path.

From v0.3.3 the same shape of sidecar runs at each monitored site, by default. A bundle issued at Settings ▸ Pollers writes COMPOSE_PROFILES=self-upgrade into that site’s .env, which starts a yagra-poller-updater holding that site host’s Docker socket. The four bounds above hold there too, and two more apply because the command travels over the bus:

  • The command carries a version tag and never a repository, so the worst a forged one could install is a Yagra poller release we published.
  • The bus permits a poller to receive an upgrade command and never to send one, so no site can act on another.

What it does mean is that whoever controls the central deployment can replace the Yagra poller at every site. Untick the box before issuing a bundle, or empty COMPOSE_PROFILES in that site’s .env afterwards, and no container there holds a socket. A site issued its bundle before v0.3.3 is unaffected until it is handed a re-issued one.

All five stores (PostgreSQL, Redis, VictoriaMetrics, VictoriaLogs, ClickHouse) and the NATS bus live on the internal Docker network. None of the bundled compose files publish them, and nothing outside the stack ever needs to reach them. Keep it that way.

The bus deserves special care. Job messages carry plaintext device credentials from the core to the poller that will use them. On a single host that traffic never leaves the internal network.

The moment you run remote-site pollers, publishing the bus port is only acceptable with the bundled TLS + authentication configuration in place. Never expose plaintext NATS :4222 across a trust boundary.

Yagra can go further and scope what each poller may even ask the bus for.

With NATS Auth Callout enabled, the core acts as the bus’s authorization service. It mints each connecting poller a short-scoped identity, limited to its own assignment subject and its own pool’s traffic.

A compromised poller then cannot subscribe to another site’s jobs, and therefore cannot receive another site’s credentials. Setup and the remote-poller architecture are covered in Distributed polling.

The WebUI is HTTPS on host port 443, and there is no plain-HTTP listener.

Everything it carries — the login password, bearer session tokens, and device credentials on their way to encrypted storage — used to cross the network in the clear by default. Now the secure shape is the one you get by doing nothing.

The certificate of record lives in PostgreSQL, envelope-encrypted under the same KEK as every other secret. Core materializes it into a volume the web container reads.

On a first start with nothing imported, core generates a self-signed certificate covering loopback and the container’s hostname. A browser will warn, and usually complain about the name too, because nothing inside the container can know the address you will type.

Import a real certificate at Settings ▸ TLS, or regenerate the self-signed one there with the names you actually use. The private key is never returned by the API. The certificate is downloadable, so you can hand it to a Prometheus ca_file, a curl --cacert, or an operating-system trust store.

An imported certificate is live within seconds, with nothing restarted. Yagra health reports its expiry, which is also the Prometheus gauge yagra_web_tls_expires_in_days.

Set YAGRA_WEB_TLS=off when an external reverse proxy or load balancer already terminates HTTPS in front of the container. That is the supported way back to a plaintext listener, and the only one.

Core’s own API port (8080) is still plaintext and still published on the LAN. That is deliberate sequencing rather than an oversight.

Closing it in the same upgrade that introduces an untrusted certificate would break every Prometheus scrape and API script at once, with two overlapping causes.

First move those clients to https://<host>/api/v1/… with a certificate they trust. Then set YAGRA_API_BIND=127.0.0.1 to take the port off the network. Settings ▸ TLS shows which of the two states you are in. Do not publish 8080 raw to an untrusted network.

Interactive access is session-based. POST /api/v1/auth/login exchanges a username and password for a bearer session token, which every subsequent REST request presents.

  • Brute-force protection. The login endpoint applies a per-account exponential lockout after repeated failures, plus a global attempt-rate cap. That throttles password-guessing runs and the CPU cost of forced password hashing.
  • Expiry. Sessions expire after an idle period and after an absolute lifetime.
  • Revocation. Logging out revokes the token server-side. Disabling, demoting, deleting, or resetting the password of a user immediately invalidates that account’s active sessions. An admin response to a compromised account cuts off already-issued tokens at once.
  • First boot. If YAGRA_ADMIN_PASSWORD is not set, the core generates a random one-time bootstrap password for admin and logs it once. There is no well-known default password.

For high-availability pairs, an optional mounted signing key (YAGRA_SESSION_KEY_FILE) turns sessions into signed stateless tokens.

Any core in the pair can validate them, and they survive a core restart or failover, so a failover does not log everyone out. Revocation is still enforced: revoked tokens are tracked and refused on every core.

For non-interactive clients, admins can mint long-lived personal access tokens (prefixed yat_) under Settings ▸ API tokens. What bounds one:

  • The surfaces it names. A token reaches the MCP endpoint (/mcp), the REST API, or both, whichever was chosen when it was issued. Tokens created before that field existed carry mcp alone, so upgrading never widens an existing credential.

  • The account that owns it. A token’s effective role is the lower of its own role and the owner’s current role. A demotion narrows it immediately, and disabling or deleting the account revokes it.

    Own unattended tokens with a service account — a machine identity that cannot sign in. The credential then does not depend on an individual, and one switch stops everything it owns.

  • What no token can do, whatever its role: administer users, or use an endpoint that identifies the signed-in account. The first matters most. A credential that could mint its own successor would survive revoking the original, which is exactly what an offboarding process would miss.

  • An optional expiry, plus revocation at any time from the same page.

  • The raw token is shown exactly once, at creation. Only a hash is stored.

  • Tokens are fleet-scoped. A group-restricted token cannot be issued.

  • Issuance, revocation and every write made with a token land in the audit log, attributed to both the owning account and the token (svc-ci (token:grafana)).

Several features let an operator point Yagra at a URL. Each one validates where the request may actually go.

  • URL monitors refuse non-HTTP(S) schemes. They also refuse targets that resolve to loopback, link-local — including the cloud metadata address 169.254.169.254 — or similar non-routable escalation surfaces.

    Validation runs at configuration time and again at poll time, because DNS can change between the two. Redirects are re-checked hop by hop through the same policy.

    Ordinary private address space stays allowed. Monitoring private infrastructure is the point of an NMS.

  • Webhook notification targets are validated the same way when configured, and re-checked at delivery time. The outbound client never follows redirects.

  • PagerDuty deliveries are pinned to the official event endpoints (events.pagerduty.com / events.eu.pagerduty.com) over HTTPS only. A lookalike host or plain-HTTP URL is rejected at configuration time. Jira Service Management is likewise pinned to api.atlassian.com.

  • Cisco Meraki monitoring is strictly read-only. The client issues only GET requests, never follows redirects, and re-checks the host on every paginated request.

Every state-changing API action is recorded with the acting user, what changed, and when — creating a node, rotating a credential, acknowledging an alert, minting a token, opening a maintenance window. MCP write tools record the same trail under the token’s identity.

One write is left out: saving your own WebUI preferences (PUT /api/v1/preferences) — which folders are closed in the inventory tree, column widths, Pinned only. It changes nothing but your own screen, and it is sent each time you open or close a folder.

The log is viewable under Settings ▸ Audit log. Reading it requires the audit permission, which only the Admin role holds by default.

A fresh install with nothing configured makes no egress at all, beyond image pulls. Every outbound path is opt-in and per-feature.

  • Notification channels — webhook, email (SMTP), PagerDuty, Jira Service Management — send alert content to the endpoints you configure.

  • Forwarding relays received syslog, traps, and flow to external collectors you configure, and can stream normalized event/flow rows to BigQuery.

  • AI root-cause analysis is default off. With no provider configured there is no client, no credentials, and no egress.

    When enabled, incident context goes to the one configured provider — never credentials. The Vertex AI option stays inside your own GCP project.

  • MCP tool results — inventory, status, metrics, events — go to whatever AI client you connect to /mcp. Enabling MCP means trusting that client with read access to your monitoring data.

The complete inventory — every destination, protocol, and which process dials out — is the egress table in Ports & firewall.

Report suspected vulnerabilities through the GitHub repository. Please avoid publishing exploit details in a public issue before a fix is available.