Getting Started
Prerequisites
Section titled “Prerequisites”- Go 1.26 (to build;
go.modpins the toolchain, currently go1.26.9) or Docker (to run via compose). - PostgreSQL 14+.
- Node/npm only when building the console outside Docker.
Run with Docker Compose
Section titled “Run with Docker Compose”cp .env.example .env# replace RMM_ADMIN_TOKEN with a generated value; AI keys are optionaldocker compose up -dcurl localhost:3005/healthz # -> okRun locally
Section titled “Run locally”# start a Postgres (example)docker run -d --name rmm-pg -e POSTGRES_USER=rmm -e POSTGRES_PASSWORD=rmm \ -e POSTGRES_DB=rmm -p 5432:5432 postgres:16-alpine
export RMM_ADMIN_TOKEN=$(openssl rand -hex 32)export RMM_SETTINGS_ENCRYPTION_KEY=$(openssl rand -hex 32)export RMM_DATABASE_URL="postgres://rmm:rmm@localhost:5432/rmm?sslmode=disable"export RMM_AI_API_KEY=sk-... # or point RMM_AI_BASE_URL at Ollama/vLLMmake build./bin/rmm-serverImmutable migrations (internal/store/migrations, 001_baseline through
019_oidc_browser_handoff today) are embedded and applied before the listener
starts; recorded names and checksums are validated on every start.
db/schema.sql is a current snapshot, not the migration runner.
In production the serving role should not own the schema — the audit trail’s append-only guarantee depends on it being unable to delete from its own table. Run the migrations first with the owner’s credentials, then start the server with the restricted ones:
RMM_DATABASE_URL=<owner dsn> ./bin/rmm-server -migrate-onlyA server whose role cannot change the schema stops with that instruction instead
of a bare permission error. With Compose, make migrate runs the same thing in a
one-shot server container, using the owner DSN in RMM_MIGRATE_DATABASE_URL
(see .env.example).
The remaining direct-server examples use port 8080; replace it with 3005 when
using the bundled Docker Compose frontend/proxy.
Create the first console user
Section titled “Create the first console user”The console signs in with a username and password (plus a second factor once you
enable one) or single sign-on — never with RMM_ADMIN_TOKEN. Create the first
administrator with that token; the call works only while no user exists:
curl -s -X POST localhost:8080/api/auth/bootstrap \ -H "Authorization: Bearer $RMM_ADMIN_TOKEN" -H "Content-Type: application/json" \ -d '{"username":"admin","password":"<at least 12 characters>"}' | jq '{role, user: .user.username}'Then open the console (http://localhost:3005 with Compose), sign in, and turn on
two-factor authentication in Settings ▸ Users. Further users are created there,
or through POST /api/users. The admin token stays valid for the API and
automation, as in the examples below; keep it out of day-to-day use.
Enroll an agent
Section titled “Enroll an agent”# 1. Create an enrollment token (admin).TOKEN=$(curl -s -X POST localhost:8080/api/enroll-tokens \ -H "Authorization: Bearer $RMM_ADMIN_TOKEN" \ -H "Content-Type: application/json" \ -d '{"description":"lab","uses":1}' | jq -r .token)
# 2. Enroll once (stores the identity), then run the long-lived agent without# retaining the bootstrap token in its process/service arguments../bin/rmm-agent -server ws://localhost:8080/ws/agent -enroll-token "$TOKEN" -enroll-only./bin/rmm-agent -server ws://localhost:8080/ws/agent
# 3. Confirm it registered.curl -s localhost:8080/api/devices -H "Authorization: Bearer $RMM_ADMIN_TOKEN" | jqCross-compile the agent for endpoints with make agent-all (publish with
make agent-dist). Both amd64 and arm64 are built for Windows, Linux, and
macOS — the agent is CGO-free, so every target cross-compiles from any
host. The native remote sidecar (make remote-dist) likewise ships
Windows/Linux amd64 + arm64 (macOS needs CGO — remote-dist-darwin, run on a
Mac). The install.sh / install.ps1 scripts auto-detect the host architecture.
After a device identity is revoked, create a new enrollment token and use the
installer’s explicit identity-reset option (--reset-identity on Linux/macOS or
-ResetIdentity on Windows); normal reinstalls preserve the existing identity.
Production installer requirements
Section titled “Production installer requirements”The served installers default to production mode and require HTTPS, OpenSSL 3, an enrollment token, and the release’s Ed25519 public update key. For example:
curl -fsSL https://agents.rmm.example.com/install.sh | sudo bash -s -- \ --server https://agents.rmm.example.com --token "$TOKEN" \ --update-public-key '<BASE64_ED25519_PUBLIC_KEY>' --require-tls& ([scriptblock]::Create((iwr -UseBasicParsing https://agents.rmm.example.com/install.ps1))) ` -Server https://agents.rmm.example.com -Token '<ENROLL_TOKEN>' ` -UpdatePublicKey '<BASE64_ED25519_PUBLIC_KEY>' -RequireTlsPlain HTTP or no key requires explicit --lab-mode/-LabMode and is only for an
isolated lab. The installer validates the signed checksum manifest and selected
artifact, rejects downgrades, and rolls back if the installed service does not
become healthy. See 13-security-rollout.
Windows — Microsoft Defender. Add -DefenderExclusion to exclude
C:\ProgramData\rmm-agent from Defender at install time. It is opt-in: an
antivirus exclusion is a security decision an operator makes, not something an
installer does behind their back. Without it, remote-desktop sessions on some
hosts fail with a black screen, because Defender quarantines the sidecar the
agent downloads — see
08-remote-access for why,
and scripts/windows-defender-exclusion.ps1
to apply or revert it on endpoints that are already installed.
Windows — Programs and Features. The agent registers itself under
HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\OpenRMMAgent at every
service start, so it appears as OpenRMM Agent there, in Settings ▸ Apps and to
inventory tools. Registering a Windows service alone leaves no such entry, and
software nobody can see on a managed endpoint is a support and audit problem. It
is not a tamper control either way: a local administrator can stop the service
regardless, so omitting the entry buys friction rather than protection.
The service does it rather than the installer because agents update themselves
and installers do not re-run: an endpoint that self-updated past the release
introducing the entry would never get one, and where one existed its version
froze at whatever first wrote it. The uninstall.ps1 its UninstallString
points at is embedded in the agent and written out when it differs — it stops
and deletes the service, removes the binaries and enrolment state, drops the
Defender exclusion if one was added, and removes the registry entry itself.
-action uninstall on its own only removes the service.
Where the agent is installed, and why the directory matters. Linux keeps
/usr/local/bin/rmm-agent; macOS uses /usr/local/libexec/openrmm/, because
Homebrew hands /usr/local to the logged-in administrator, and a service running
as root must not execute from a directory another account can write to — a
signature is checked at download and says nothing about a swap afterwards. The
installer takes ownership of its directory, refuses to run if the target is not
root-owned or is group/world-writable, and moves an endpoint installed at the old
macOS path over on the next run. On Windows the same reasoning applies to
C:\ProgramData\rmm-agent: a standard user may create folders under
C:\ProgramData and owns what they create, so the installer breaks ACL
inheritance and rewrites the entry list (SYSTEM and Administrators full control,
Users read and execute) on every run, which is also what repairs an endpoint
installed by an older installer.
Removing an agent
Section titled “Removing an agent”Run uninstall.sh (Linux/macOS, served at
/uninstall.sh) or C:\ProgramData\rmm-agent\uninstall.ps1 (Windows). Both stop
and remove the service, the binaries and /etc/rmm-agent (Windows:
C:\ProgramData\rmm-agent) — which holds credentials that still authenticate to
the server — and on macOS also the remote-desktop bundle, its LaunchAgent and the
privacy grants recorded for it. --keep-state keeps the enrolment so a reinstall
returns as the same device.
curl -fsSL https://agents.rmm.example.com/uninstall.sh | sudo bashRemoval is local only. It revokes nothing: delete the device in the console so its credentials stop being accepted.
Updating agents (self-update)
Section titled “Updating agents (self-update)”Agents upgrade themselves. Each connected agent periodically HEADs
/dl/rmm-agent-<os>-<arch> and compares the ETag with the build it last
applied (the same mechanism the remote sidecar uses). When a new binary is
published it downloads it, verifies the detached Ed25519 signature when
configured, validates it runs (-print-version), confirms it is not
byte-identical to the running one, atomically swaps it in, and reloads (re-exec
on Unix; a detached service restart on Windows).
Serving /dl on Kubernetes
Section titled “Serving /dl on Kubernetes”Compose bind-mounts deploy/public into the web container, which is why /dl
works out of the box there. A Kubernetes install has no such directory, and the
web image carries none: the route answers and has nothing to serve, so no agent
can install or update.
The artifacts build target packages a published deploy/public into an image
that copies itself into a volume:
docker build --target artifacts -t openrmm-artifacts:v0.6.2 .Run it as an initContainer writing to a volume the web container mounts at
/usr/share/nginx/dl, read-only — nothing that serves HTTP should be able to
rewrite what endpoints are about to install. Shared storage is not needed, and
the image tag becomes the release identity, so promoting one installation is
a single value rather than a file sync nobody can reconstruct afterwards.
The image is a transport, not a trust boundary: every artifact carries its Ed25519 signature and the agent verifies it against the key pinned at install time, so a compromised registry still cannot deliver an accepted update.
So a fleet-wide upgrade is just republish to /dl:
make agent-dist # rebuild + publish rmm-agent-<os>-<arch> into deploy/public (served at /dl)# connected agents pick it up within RMM_UPDATE_INTERVAL (default 30m)- Controls (agent env):
RMM_AUTO_UPDATE=0disables it;RMM_UPDATE_INTERVALsets the cadence (e.g.15m; minimum 5m). - Signature controls:
RMM_UPDATE_VERIFY=warn|strict|offandRMM_UPDATE_PUBLIC_KEY=<base64-ed25519-public-key>. See 13-security-rollout. - Safety: a download that fails to execute or returns no version is discarded
and the running agent is left untouched; the previous binary is kept as
*.olduntil the next clean start. - Bootstrap: self-update only works once an agent is already running a build
that contains it. The first rollout onto self-updating agents is a normal
(re)install via
install.sh/install.ps1(served at/install.sh).
Run a job (with approval)
Section titled “Run a job (with approval)”AID=<agent id from /api/devices>
# Propose (lands in awaiting_approval)JID=$(curl -s -X POST localhost:8080/api/jobs \ -H "Authorization: Bearer $RMM_ADMIN_TOKEN" -H "Content-Type: application/json" \ -d "{\"agent_id\":\"$AID\",\"shell\":\"bash\",\"script\":\"echo hi && uname -a\",\"description\":\"smoke\"}" \ | jq -r .id)
# Approve -> dispatched -> executedcurl -s -X POST localhost:8080/api/jobs/$JID/approve -H "Authorization: Bearer $RMM_ADMIN_TOKEN" | jq
# Read the resultcurl -s localhost:8080/api/jobs/$JID -H "Authorization: Bearer $RMM_ADMIN_TOKEN" | jq '{status, exit_code, stdout}'Use the AI chat
Section titled “Use the AI chat”SID=$(curl -s -X POST localhost:8080/api/chat/sessions \ -H "Authorization: Bearer $RMM_ADMIN_TOKEN" -d '{"title":"ops"}' | jq -r .id)
curl -s -X POST localhost:8080/api/chat/sessions/$SID/messages \ -H "Authorization: Bearer $RMM_ADMIN_TOKEN" -H "Content-Type: application/json" \ -d '{"content":"List my Linux devices and propose a script to install htop on the first one."}' | jqThe reply includes created_job_ids for any jobs the AI proposed — surface those
in the UI for approval. (Requires a working AI provider; set RMM_AI_API_KEY or
point RMM_AI_BASE_URL at a local model.)
Running the tests
Section titled “Running the tests”make test # unit tests onlycd openrmm && npm testThat is not the whole suite. Most of what verifies scope isolation, the
approval gates, migrations and the alert lifecycle is DB-backed, and those tests
t.Skip when there is no database. A green run without one proves much less than
it looks like it does:
# any Postgres 16 you are willing to have written toexport RMM_TEST_DATABASE_URL='postgres://rmm:rmm@localhost:5432/rmm_test?sslmode=disable'make testIn CI, the Database migrations workflow runs them against PostgreSQL 16 for the packages that have DB-backed tests (store, settings, api, notify, ai, mcpserver, patches, scheduler), on pull requests and pushes that touch those paths. The main CI job runs without a database, so they skip there. Run them locally before trusting a change to anything security-relevant.
Each DB-backed test gets a schema of its own (storetest.NewSchema), created
and dropped around the test, so packages running in parallel cannot see each
other’s fixtures. Add new DB tests that way rather than deleting or truncating
shared tables: a truncate reaches into whatever another package is doing at the
time, which shows up as a vanished fixture or a deadlock rather than as an
obvious mistake.
