Skip to content

marstack-access

Identity-aware access platform for Linux infrastructure. Users reach a target through it, never around it, and it holds no credential that is useful to an attacker who is not inside an approved session.

Usage guide →

Pre-release. Read what is not covered yet before putting this in front of anything you care about — in particular, the CA signing key currently sits in a file on the gateway.

What makes it different

Most bastions are a credential vault with a proxy attached: they store target passwords, rotate them, and hand them out. That concentrates every standing credential in the estate onto the one host an attacker most wants.

This one inverts that.

  • No standing credentials. There is no target password and no target key anywhere. Reading the whole database gives an attacker no way to authenticate to any target. Sessions authenticate with certificates minted per session, scoped to one principal, one target, and a validity window measured in minutes. The CA signing key is the one exception and it is not solved yet — today it sits in a file on the gateway. docs/SECURITY.md invariant 3 says exactly what that costs and what would close it.
  • Agentless. Nothing is installed on a target. A target opts in with two files in its sshd configuration, and sshd — not our code — enforces the certificate's principal, expiry, and source address.
  • An audit trail that can only grow. Every event is written to a local append-only file and shipped to Loki, and every recording is uploaded to MinIO under an object lock in compliance mode. Nothing in the code can delete, truncate, or rotate any of it — architecture tests fail the build if a sink or the object client grows such a method. docs/SECURITY.md says which parts depend on the store being configured correctly, and what has not been demonstrated yet.
  • Greppable sessions. Recordings are asciicast, not video. "Who ran this command last month" is a query, not an afternoon of playback.

Deliberate boundaries

These are excluded by design, not pending. See docs/SECURITY.md for the reasoning behind each.

Not supported Because
RDP, VNC a graphical session cannot be audited without pixels, and this platform records text
Video recording unsearchable, and it drags in transcoding, retention, and a player
Command blocking by pattern a PTY byte stream is not a command list; every blacklist is trivially bypassed
A credential vault for targets reintroduces the standing credential the design exists to remove
Device posture checks meaningless without hardware attestation, which is a separate product

Requirements

  • Go 1.26 or newer
  • Linux or macOS for development; Linux for deployment
  • No hypervisor, no privileged container, no kernel modules — a small VPS is enough

Quickstart

make hooks      # install the pre-commit hook, once per clone
make tools      # install the security scanners
make build
./bin/marac version
./bin/marac server

The control plane binds 127.0.0.1:7443 by default and creates its database under ./data. It binds loopback rather than every interface on purpose — exposing it is a deployment decision, not a default.

On the first start against an empty store it creates an admin user and writes that account's token to ./data/bootstrap-token with mode 0600. The log names the path, never the secret. A restart does not issue a second one.

export MARAC_TOKEN=$(cat data/bootstrap-token)
rm data/bootstrap-token

Every route except GET /healthz needs a token. Roles are ranked, and a check means at least:

Route Requires
GET /healthz nothing — a load balancer probe carries no credential
GET /v1/version viewer
marac target … operator
marac request create/list/get/cancel operator
marac request approve/deny/grant admin
marac user …, marac token …, marac key …, marac policy … admin

Create a working account rather than using the bootstrap admin for daily work:

marac user create --name deployer --role operator
marac token issue --user usr-xxxxxxxxxxxxx --ttl 720h

The secret prints once. Nothing stores it, so losing it means issuing another. To take an account out of service:

marac token list --user usr-xxxxxxxxxxxxx     # metadata only, never secrets
marac token revoke tok-xxxxxxxxxxxxx          # effective on the next request
marac user delete usr-xxxxxxxxxxxxx           # also revokes every token it holds
curl -s localhost:7443/healthz
curl -s localhost:7443/v1/version

Register a target. A target declares the accounts it will accept — principal mirrors what its sshd is configured to allow, and at least one is required, because a target nobody can land on is not reachable.

marac target register --name db-1 --address 10.0.0.4 \
  --principal deploy --principal postgres

marac target list
marac target get tgt-xxxxxxxxxxxxx
marac target delete tgt-xxxxxxxxxxxxx

--port defaults to 22 and is omitted from the request when unset, so the platform owns the default rather than the client. There is no update command yet — delete and re-register.

A registered target is reachable by nobody until its host key is pinned. Pipe it in, usually from ssh-keyscan:

ssh-keyscan -t ed25519 10.0.0.4 | marac target trust --target tgt-xxxxxxxxxxxxx

The fingerprint then shows in marac target list, and an unpinned target shows -. Replacing a pin needs --replace: a silent replacement is how a man-in-the-middle becomes permanent. Re-pinning the same key is a conflict too, because a caller that cannot tell "already correct" from "silently changed" cannot act on either.

Use -t when scanning. Without it ssh-keyscan emits one line per algorithm, and the platform refuses an ambiguous scan rather than pinning whichever came first.

Registering a target does not grant anyone access to it. A policy binds a subject — a user or a role — to one target and an explicit set of accounts:

marac policy create --name ops-db --role operator \
  --target tgt-xxxxxxxxxxxxx --principal deploy

marac policy list
marac policy delete pol-xxxxxxxxxxxxx

A role is matched exactly. Granting the operator role does not also grant admins — a platform administrator is not automatically root on every host. A policy cannot name a principal the target refuses, so a rule that could never work is rejected when it is written rather than when a session fails.

Ask what a request would decide, without opening a session:

marac policy evaluate --user usr-xxxxxxxxxxxxx --role operator \
  --target tgt-xxxxxxxxxxxxx --principal deploy
allowed: true
reason:  granted by policy pol-xxxxxxxxxxxxx

A denial always carries a reason, because an undiagnosable denial gets worked around by granting too much.

A policy is a bound, not an entitlement. Access becomes live only through an approved request:

marac request create --target tgt-xxxxxxxxxxxxx --principal deploy \
  --reason 'incident 4821' --ttl 2h

marac request list
marac request approve req-xxxxxxxxxxxxx     # admin, and never the requester
marac request grant --user usr-xxxxxxxxxxxxx \
  --target tgt-xxxxxxxxxxxxx --principal deploy

The requester is taken from the token — there is no flag for it, and a body naming one is rejected. A request is refused up front if no policy could ever permit it, so approval is a gate in front of policy rather than a way around it.

Self-approval is impossible, including for admins. The account that raised a request cannot approve or deny it. A single-account install therefore cannot grant itself access, which is the control working rather than a bug.

Grants are time-boxed and expiry is derived from the clock, so there is no background job that can stop running and leave access open.

Register the SSH public key an account will connect with. The key is piped in, so it can come from a file, a clipboard, or ssh-add -L:

marac key add --user usr-xxxxxxxxxxxxx --name laptop < ~/.ssh/id_ed25519.pub
marac key list --user usr-xxxxxxxxxxxxx
marac key remove key-xxxxxxxxxxxxx

The fingerprint shown matches ssh-keygen -lf exactly, so a key can be matched by eye. A key may belong to only one account, and ssh-dss and RSA under 2048 bits are refused when the key is added rather than when a session fails. A key carries no privilege of its own — the account's role and policies decide everything.

The web console

The gateway serves a console at /console/ for the two jobs a terminal is bad at: watching live sessions and clearing the approval queue. It ships inside the binary — no build step, no node_modules, nothing fetched at runtime.

http://127.0.0.1:7443/console/

Sign in by pasting an API token, the same one marac uses. The page swaps it for an HttpOnly cookie immediately and never holds the credential itself. Everything the console shows comes from the public API under the caller's own role, so it can see exactly what that account's token can see and nothing more — a viewer is refused the session list here just as it is on the command line.

It will not open a session over cleartext. Reach it over HTTPS, or terminate TLS in a proxy on the same host so the hop to the gateway stays on loopback. http://127.0.0.1 works for local development. The reasoning is in docs/SECURITY.md.

Sessions can be closed from the Sessions screen, and requests approved, denied, or withdrawn from the Requests screen. There is nothing the console can do that marac cannot; every button is a call to a documented endpoint.

Connecting

Start the SSH data plane alongside the control plane. It is off unless asked for:

marac server --ssh-listen 127.0.0.1:2222

Then connect with a plain ssh client. The username carries where you are going:

ssh -p 2222 deploy:db-1@gateway
marstack-access: authorized
  user       alice (operator)
  target     db-1 at 10.0.0.4:22
  principal  deploy

An unregistered key gets Permission denied (publickey) and learns nothing about what exists. Anything refused after that — unknown target, a principal the host does not accept, no policy, no grant — says which check refused it, because a caller who has proved who they are gains nothing from a blank refusal.

Port forwarding is refused in both directions, and so are agent forwarding, X11, and subsystems. A gateway that forwards ports is a route into the network that no policy describes and no recording captures.

An authorised session now reaches the target. Point the gateway at a signing key and connect:

marac ca init --path ./data/ca
marac server --ssh-listen 127.0.0.1:2222 --dev-ca-key ./data/ca
ssh -p 2222 deploy:db-1@gateway

The gateway mints a certificate for that session alone, verifies the target's host key against the pin, and proxies the shell while recording the output to ./data/recordings/ses-xxxxx.cast. Play one back with any asciicast player:

asciinema play data/recordings/ses-xxxxxxxxxxxxx.cast
grep -o 'sudo [^"]*' data/recordings/*.cast

Only output is recorded. A shell echoes what is typed, so commands stay greppable, but a password typed at a sudo prompt is never echoed and so never written down.

Every session is listed, and an admin can close one:

marac session list
marac session get ses-xxxxxxxxxxxxx      # includes where the recording is
marac session kill ses-xxxxxxxxxxxxx     # admin only

Every change and every decision lands in the audit trail at ./data/audit/audit.jsonl, and ships to Loki when asked:

marac server --audit-loki-url http://loki:3100
target.registered          allowed  admin    tgt-cgrne5bf33bct
target.host_key_pinned     allowed  admin    tgt-cgrne5bf33bct
user.created               allowed  admin    usr-k8fr3y3btzsat
token.issued               allowed  admin    tok-86gxy0v1zrxna
policy.created             allowed  admin    pol-w0hkcdx1zgng8
request.raised             allowed  alice    req-7b6q3sjrrff8w
request.approved           allowed  admin    req-7b6q3sjrrff8w
api.denied                 denied   alice    insufficient_role
api.denied                 denied   -        missing_token

A requester and an approver are different accounts in the trail, which is what self-approval being impossible looks like from the outside. Reads are not recorded, and a token secret never is.

Without that flag the trail stays on the host, which the log warns about — a trail root can delete is durable against a crash but not against whoever owns the machine. Shipping never fails a session: if Loki is down the event is already on disk and the drop is counted.

A kill that closed nothing says so rather than reporting success — the row may have been opened by a run that has since stopped. Rows left open by a crash are closed on the next start with a reason naming the restart, so the list of live sessions does not fill with sessions that are not running.

Recordings upload to MinIO when a session closes, under an object lock:

export MARAC_S3_ACCESS_KEY=... MARAC_S3_SECRET_KEY=...
marac server --ssh-listen 127.0.0.1:2222 --dev-ca-key ./data/ca \
  --recording-endpoint https://minio.internal:9000 \
  --recording-bucket marac-recordings \
  --recording-retain-for 2160h

The bucket has to be created with object lock enabled — mc mb --with-lock — because it cannot be turned on afterwards. Credentials come from the environment rather than flags, since a secret on the command line is visible in ps. The local copy is kept: uploading never deletes, so pruning old recordings is a job for outside this process, done after confirming the object exists.

--dev-ca-key holds the signing key in a local file. Without it the front door still authorises but cannot connect, and it says so. That file is the platform's weakest point today — see docs/SECURITY.md invariant 3.

Preparing a target

A target trusts the platform through two files and no daemon. Create a signing authority and print what to install:

marac ca init --path ./data/ca
fingerprint SHA256:...

Install this on every target, then reload sshd:

  echo "ssh-ed25519 AAAA..." | sudo tee /etc/ssh/marstack_ca.pub
  # in /etc/ssh/sshd_config
  TrustedUserCAKeys /etc/ssh/marstack_ca.pub
  AuthorizedPrincipalsFile /etc/ssh/principals/%u

Sessions authenticate with a certificate minted for that session alone: one principal, pinned to the gateway's own IP, valid for minutes, and carrying permit-pty and nothing else — so port forwarding and agent forwarding are not granted on the target either.

marac ca keeps the signing key in a local file, and that is currently the only way to sign. Anyone who can read that file can mint access to every target that trusts the CA. Moving it behind a signing call in marstack-secrets is the plan and is not built. docs/SECURITY.md invariant 3 has the detail.

The client talks to $MARAC_ENDPOINT, or --endpoint, or loopback. --output json prints the raw API response for scripting:

export MARAC_ENDPOINT=http://control.internal:7443
marac target list --output json

Development

make check      # vet, test, staticcheck, govulncheck, gosec

make check must pass before every commit. The pre-commit hook runs a faster subset.

Documentation

Document Covers
Usage guide install, first run, targets, policy, just-in-time access, connecting, the console
docs/ARCHITECTURE.md the modular monolith, the two planes, module layout, the access model
docs/SECURITY.md threat model, invariants, rules for connection code, exclusions
docs/ENGINEERING-PRINCIPLES.md working conventions, principles, and the rules that are easy to violate
docs/adr/ decisions worth the argument they saved, with the options that lost

License

Apache License 2.0. See LICENSE.

About

Identity-aware access platform for Linux infrastructure: no standing credentials, tamper-evident audit, greppable sessions

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages