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.
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.
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.mdinvariant 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
sshdconfiguration, andsshd— 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.mdsays 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.
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 |
- 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
make hooks # install the pre-commit hook, once per clone
make tools # install the security scanners
make build
./bin/marac version
./bin/marac serverThe 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-tokenEvery 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 720hThe 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 holdscurl -s localhost:7443/healthz
curl -s localhost:7443/v1/versionRegister 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-xxxxxxxxxxxxxThe 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-xxxxxxxxxxxxxA 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 deployallowed: 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 deployThe 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-xxxxxxxxxxxxxThe 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 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.
Start the SSH data plane alongside the control plane. It is off unless asked for:
marac server --ssh-listen 127.0.0.1:2222Then connect with a plain ssh client. The username carries where you are going:
ssh -p 2222 deploy:db-1@gatewaymarstack-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/cassh -p 2222 deploy:db-1@gatewayThe 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/*.castOnly 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 onlyEvery 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:3100target.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 2160hThe 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-keyholds 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 — seedocs/SECURITY.mdinvariant 3.
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/cafingerprint 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 cakeeps 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 inmarstack-secretsis the plan and is not built.docs/SECURITY.mdinvariant 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 jsonmake check # vet, test, staticcheck, govulncheck, gosecmake check must pass before every commit. The pre-commit hook runs a faster subset.
| 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 |
Apache License 2.0. See LICENSE.