Skip to content

Security model

The router holds an agent that listens and never connects out, and every credential that opens something other than the agent stays on your host. The table below is the whole map: what runs where, what it reaches, and which credential it holds.

Piece Runs Reaches Credentials
mikroscope-agent in a scratch container on the router serves HTTP on the veth address only; no outbound connection none it presents; an optional bearer token it requires
mikroscope doctor, install, upgrade, uninstall, status your host, when you run them the router over your own admin ssh; install, upgrade and status also probe the agent’s /healthz, and doctor reads /healthz and then the agent’s ring, the newest 10 000 samples at most; uninstall --targets dashboard or data also reaches your Grafana and the stores your ssh key; doctor also presents the agent’s token if one is set; GRAFANA_TOKEN and the sink credentials for those targets
mikroscope plan, install --dry-run, upgrade --dry-run your host nothing: they build the image and print the listing without connecting to the router none, unless plan --rsc is given a token (below)
mikroscope record, forward your host the agent over HTTP; the router’s binary API for forward’s API tier, the relay transport and record --log-markers the agent’s token if one is set; a dedicated read-only API user; GRAFANA_TOKEN with --grafana
mikroscope mark your host the recording’s local files; the router’s binary API only with --log-markers; never the agent the API user, only with --log-markers
sinks your host, inside forward serve /metrics for your Prometheus; push to InfluxDB and the other destinations you name their tokens, in your environment
mikroscope dashboards publish, import, check your host your Grafana; publish creates or corrects the datasources and the folder there GRAFANA_TOKEN, in your environment; publish also the sink credentials it copies into the datasources

plan, install --dry-run and upgrade --dry-run never reach the router, so they do not run the ownership checks either; those run only on a real install.

Nothing about the tool is reported anywhere. There is no update check. In the code, the agent’s only network code is its HTTP server. The outbound connections all live in the CLI — the sinks (HTTP, TCP or UDP), the Grafana client, the RouterOS API client, the direct transport, the /healthz probe and doctor’s read of the ring, the last three over plain HTTP — and each goes only to an address you gave it or, for the agent, to the .2 of --subnet (172.30.10.2 unless you change it).

The collector’s own Prometheus exposition, forward --prom <address>, listens on the address you pass and serves /metrics with no authentication. Bind it to an address only your Prometheus reaches.

Over the binary API, /container/print returns every property of every container, cmd and envlist included, to a user with only read,api (verified). Whether such a user can also read the envlist entries’ values, in /container/envs, is not tested. The design assumes it can: whatever is in an envlist is treated as readable by every read user on that router, not only by administrators.

That is why the agent has no push sink: a sink token on the router would be readable by every read user. If push ever comes, it will be agent → collector with the same NDJSON, never agent → InfluxDB.

What install does put in the envlist <name>-env is configuration, and nothing that opens anything else:

The entries install writes into the agent's envlist
KeyWrittenFromHolds
MIKROSCOPE_TAGalways--namethe ownership marker mikroscope:<name> (managed by mikroscope), written first and removed last; the agent ignores it
RATE_HZalways--rate, default 10, 1–100the sampler rate, in Hz
BUFFER_Salways--buffer, default 60, 10–3600the ring's length, in seconds
PORTalways--port, default 9123, 1–65535the agent's HTTP port
ADDRalways--subnetthe agent's address, the .2 of the /30; the agent binds only there
MEM_LIMIT_MBalways--mem-limit-mb, 8–1024the agent's Go soft memory limit, in MiB; derived from the ring (rate × buffer × line, × 2.5, at least 16 MiB, at most three quarters of --memory-max while that still holds the ring) unless --mem-limit-mb gives it
FLOOR_HZonly when above 0--floor-hz, default 0, 0–1000one cadence for every level source, in Hz
CAPTURE_MBalways--capture-mb, default 4, 0–256the triggered-capture budget, in MiB; 0 turns captures off
TRIGGERSonly when set--triggersthe trigger conditions; unset, the agent uses its default set
TOKENonly when set--tokenthe bearer token the agent requires, from --token or MIKROSCOPE_TOKEN, with or without --expose

The last row is the one secret that does live on the router. It is written whenever --token or MIKROSCOPE_TOKEN is set, with or without --expose, and like the rest of the envlist it is treated as readable by every read user. It opens the agent’s own HTTP paths and nothing else. plan and --dry-run print it as value="(token)", the options line as token=(set), and the agent’s start line in the router log as token=true.

plan --rsc is the exception to that masking. It writes the install as a RouterOS script to run on the router itself, so the envlist line has to carry the real token; the script says so in its own header. A generated .rsc with a token in it is a credential — Installer safeguards has how to handle it.

On your host, the credentials that open something other than the agent are read from the environment only, never from a flag, with one exception: the PostgreSQL DSN, below. The code gives the reason: a flag is visible in ps and in a shell history.

  • The API user’s password: MIKROSCOPE_API_PASSWORD. The address and user come from --api and --api-user, or MIKROSCOPE_API_ADDR and MIKROSCOPE_API_USER.
  • Sink credentials: MIKROSCOPE_INFLUX_TOKEN, MIKROSCOPE_LOKI_TOKEN, MIKROSCOPE_OTLP_TOKEN, MIKROSCOPE_ELASTIC_AUTH, MIKROSCOPE_TELEGRAF_TOKEN.
  • PostgreSQL (--postgres, or MIKROSCOPE_POSTGRES_DSN): the DSN is a flag, so keep the password out of it and let the driver read PGPASSWORD or ~/.pgpass, as psql does. A password written in a DSN passed as the flag is visible in ps, and wherever the DSN comes from, forward --grafana and dashboards publish copy a password written in it into the Grafana datasource they create. A password found only in the environment or the password file is not copied.
  • forward --grafana and dashboards publish also copy the InfluxDB token (MIKROSCOPE_INFLUX_TOKEN, which can write) and MIKROSCOPE_ELASTIC_AUTH into the datasources they create. To give Grafana a read-only credential, create the datasource yourself and pass --grafana-datasource-uid.
  • Grafana: GRAFANA_TOKEN.

The agent’s token is the exception: it has a --token flag as well as MIKROSCOPE_TOKEN. The same reasoning applies to it, so prefer the variable.

The CLI does not read .env itself. Export the variables into the shell that runs it, for example with set -a; . ./.env; set +a.

The container runs an image, and which route puts it there decides what you are trusting.

  • install with a Go toolchain and a checkout builds the image on your host from the source in front of you and uploads it over your own ssh session. You trust your own tree.
  • install --agent-tar <file> uploads the tar the release publishes, over the same ssh session. The CLI checks that the tar is a mikroscope agent image of the architecture --arch names before it sends it; checking that it is the file the release published is yours to do, against checksums.txt.
  • install --remote-image <reference> uploads nothing: the router itself fetches the image from the registry the reference names, which mikroscope writes into remote-image= with its host, registry-1.docker.io for Docker Hub; the global /container/config registry-url is neither needed nor written. You trust that registry and the router’s path to it, and mikroscope verifies nothing about what arrives. /container/config also holds one registry username and password for the whole device, and a Docker Hub login sent to GHCR fails the pull with auth error (tested); Installer safeguards has what that means and what doctor checks.
  • plan --rsc writes the same commands as a RouterOS script for you to paste or /import; the image still has to come from one of the two routes above that need no upload from the CLI.

install creates one container with these settings, all printed by plan before anything is written (where verified):

  • privileged=yes by default, --privileged=false to opt out. The setting needs RouterOS 7.24 or later, whose changelog added privileged mode for containers. It drops the container’s user namespace (verified), which is what makes the kernel log, /proc/slabinfo, /proc/pagetypeinfo and the MTD ECC counters readable. It does not drop the network or PID namespace: no interface counters, no router conntrack table, no view of RouterOS’s processes. Privileged mode has what it makes readable.
  • memory-max=64M (--memory-max), enforced as the container’s cgroup limit, with the agent’s Go soft limit (--mem-limit-mb) inside it. Unless you pass a number, that limit is derived from the ring (rate × buffer × line, × 2.5, at least 16 MiB, at most three quarters of memory-max while that still holds the ring): 16 MiB at the defaults.
  • restart-policy=on-failure, bounded to five retries ten seconds apart, so a broken image cannot loop at boot.
  • start-on-boot=yes, or no with --ephemeral, whose root lives on the tmpfs disk and does not survive a reboot. Surviving a reboot with yes is not tested.
  • logging=yes, so the agent’s lifecycle lines reach the router log.
  • ignore-remote-image-change=yes: with the default, RouterOS watches the image and, once the tar is removed, stops and removes the container and re-extracts it minutes later (verified). install removes the tar right after extraction, which is why it sets this.
  • Root and image on the disk you chose with --disk: the internal flash by default.
  • No bind mount. install mounts no host path into the container.

The agent reads /proc, /sys (/sys/fs/cgroup for its own accounting, /sys/class/thermal and /sys/class/mtd for the device), and — privileged — /dev/kmsg and the hardware performance counters. It writes nothing to its root at runtime: the ring and the triggered captures are held in memory. It catches SIGTERM, because RouterOS kills a container that does not, at once.

The last setting in the list is deliberate. Host /proc and /sys bind-mounted into a privileged container add nothing, because the namespaces hold, but host / exposes the RouterOS flash filesystem, configuration and secrets included (Host mounts). mikroscope does not do this, and a container you build yourself should not either.

Report a vulnerability privately, never with its details in a public issue or discussion: open the repository’s Security tab and choose Report a vulnerability. GitHub then opens a private advisory that only the maintainer can see. If that button is not there, start a discussion in General that asks for a private channel and says nothing else about the problem, and wait there for a reply that names one; issues go through forms that ask for the details, so an issue cannot stay that empty. Include the version (mikroscope version, and the agent’s from the agent: line of mikroscope status if it differs), the RouterOS version and board, what an attacker gains, and the smallest way to reproduce it. If a proof of concept needs a credential, a router address or an export, do not send it: describe it.

The policy itself is SECURITY.md. It promises an acknowledgement within a week and, once a report is confirmed, a fix released before the advisory is published; a reporter who wants credit gets it in the advisory, and one who does not is not named. Only the latest release is supported, because a fix ships as a new tag rather than as a patch to an older one. It also sets out what is worth reporting: anything that breaks the separation this page describes, such as a way for something other than the configured collector to reach the agent, or a credential appearing where it does not belong. The prerequisites RouterOS imposes, and the agent’s token being readable by every read user, are documented choices, not vulnerabilities.