Security

Keys stay on your hardware

The ushr control plane schedules jobs from metadata. It never holds your GitHub App private key. This page lists what crosses the wire, what we store, and what happens when we are down.

Keyless design

Each host runs the open-source ushr agent. The agent holds the GitHub App private key on the host. The agent polls GitHub for queued jobs and mints a just-in-time runner registration with its own key. The control plane only decides which host runs which job.

The control plane never holds your App private key. It has no installation token and no runner registration token. It cannot call the GitHub API for you. It stores the App's webhook secret, which GitHub issues, to check webhook signatures.

When you create the GitHub App from the dashboard, GitHub returns a one-time manifest code. ushr.io stores that code until the host finishes setup or starts a new setup for the same scope. If neither happens, ushr.io deletes the code 90 days after the host's last token is revoked. ushr.io passes the code only to the host that started the setup. The host proves this with a PKCE verifier. With CLI versions after v0.2.10, ushr.io stores the code encrypted to a key that only the host holds. With v0.2.10 or older, it stores the code in plain text. GitHub accepts the code for 1 hour only. The host exchanges the code with GitHub for the App key, so the key never passes through ushr.io.

GitHub App permissions
ScopePermissions
Organisationactions: read · metadata: read · organization_self_hosted_runners: write
Single repositoryactions: read · metadata: read · administration: write

What crosses the wire

The agent dials out to the control plane over HTTPS. Nothing dials in to your host. These are the fields on the dispatch path.

POST /v1/agents/{name}/poll · agent → control plane
FieldContent
capacityNumber of concurrent slots on the host
busyHandles of the slots in use
labelsRunner labels the host advertises
queues[].orgScope: GitHub organisation name, or owner/repo for a single repository
queues[].priorityYour priority for that organisation
queues[].jobs[].job_idNumeric GitHub job ID
queues[].jobs[].labelsThe labels the job requests
queues[].jobs[].waiting_secsSeconds the job has waited
disk_free_bytes, disk_total_bytesFree and total space of the VM image store
blockedTrue when the host refuses work for low disk space
version, update_protocol, update_stateAgent version and managed-update state
update_errorError text from the last failed managed update
failed_request, failed_versionThe update request and target version that failed
offer · control plane → agent
FieldContent
idDispatch ID, also the runner name
orgScope the runner registers to: organisation, or owner/repo
job_idThe job to run
labelsLabels the runner advertises
claim and done · agent → control plane
RequestContent
claimNo body. Accepts the offer.
done.statusdone, failed or lost
done.errorError text when a mint or provision fails. The control plane writes it to its server logs.

The poll and the offer carry no workflow, no source code, no secrets and no logs. They carry a repository name only for a single-repository scope, where the scope is owner/repo.

The hosted setup also points the App's webhook at the control plane. GitHub then sends workflow_job events for start and completion. From those events the control plane keeps the job ID, organisation, repository name, workflow name, run ID, conclusion, labels, runner name and timestamps. It ignores events for runners that ushr did not start.

What the control plane stores

hosted control plane tables
DataContentKept
Host statusHost name, organisations, labels, capacity, busy slots, queue depth, disk space, agent version, update state and update error textLatest value, while the host is enrolled, then 90 days after its last token is revoked
Job runsThe dispatch and webhook fields above13 months after the job ends
Dispatch eventsOrganisation, dispatch ID, event name, host name, job ID, status30 days
Enrollment tokensSHA-256 hash, organisations, host name, approver and approval IP address90 days after the token is revoked or replaced. A replaced token that the host's App record still uses stays until the host is revoked, then 90 days.
Login sessionsHost name, confirmation code, requester IP address and the approved token, encrypted to the CLI key (plain text for CLI v0.2.10 or older)Until the CLI collects the token, or 1 day after the 10-minute expiry
Hosted GitHub AppsApp ID, App slug, webhook secret and, until setup finishes, the one-time manifest codeWhile the host is enrolled, then 90 days after its last token is revoked

Enrollment tokens

ushr login enrolls a host. You approve the request in a signed-in browser. The approval page shows the host name, the IP address of the request and a confirmation code. Check that the code matches the one in your terminal.

  • The enrollment token table stores only the SHA-256 hash of each token.
  • The approved token passes through the login session to the CLI. With CLI versions after v0.2.10, the session holds it encrypted to a key that only the CLI holds, in memory. With v0.2.10 or older, the session holds it in plain text.
  • A token serves only the organisations that your workspace has verified. A token with no organisations is refused.
  • A token is bound to one host name.
  • Each host has one live token. Enrolling the host again revokes the old token.
  • A login session expires after 10 minutes. The CLI collects the token once, with a PKCE verifier, and the session is then deleted.

Webhook signatures

GitHub creates a webhook secret with the App. The host receives it with the App key and registers only the secret with ushr.io. GitHub signs each delivery with HMAC SHA-256 and that secret. The control plane rejects any delivery with a missing or wrong signature.

Each App has its own webhook URL and secret. An event counts only if it matches the App's verified organisation and a runner name from that host. The webhook secret authenticates deliveries. It cannot call the GitHub API.

Signed releases

Each ushr release publishes a checksum file. v0.2.10 and earlier releases publish checksums only. The release pipeline adds a cosign signature for the checksum file and a software bill of materials (SBOM) from the first release after v0.2.10. Verify the signature with cosign, then check each binary against the checksum file.

The installer and managed agent updates download the official release and verify its published checksum before they install it. With cosign installed, the installer also verifies the signature of a signed release.

When the control plane is down

  • Running jobs continue. Runners connect directly to GitHub, not to ushr.io.
  • No new jobs start on ushr hosts. Queued jobs wait in GitHub's queue.
  • The agent retries its poll until the control plane answers.
  • The agent keeps each job's completion report in memory and sends it when the control plane is back. The agent loses the report if it restarts first.
  • Your GitHub App key and your hosts are not affected.

You can also run the open-source controller yourself. Then the controller and the agent send no data to ushr.io. The installer downloads releases through get.ushr.io unless you set GITHUB_TOKEN. Hosts also download tools and images from other sources, such as Homebrew and Docker Hub.

Report a vulnerability

Report it privately through GitHub security advisories. Include the steps to reproduce, the affected version and the impact. Please do not open a public issue for a vulnerability.