What it is
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 inverts it. The platform is a certificate authority client, not a vault. There is no
target password and no target private key in the binary or its database. When a session is
authorised, the gateway mints a certificate scoped to one principal, one target, one session
id, a validity window measured in minutes, and the source address that asked for it. The
target's own sshd enforces all of that — not this code.
The certificate never reaches the user. They are handed a connection, not a credential. That is what makes recording unbypassable and revocation immediate: closing the socket ends the session, because there is no other way in.
Install
One binary — marac — which is the control plane, the SSH gateway, the web
console and the client at once. Download a release archive, or build from source with Go
1.26 and nothing else.
VERSION=v0.1.0
BASE=https://github.com/MarStack-Labs/marstack-access/releases/download/$VERSION
curl -fsSLO $BASE/marac_${VERSION}_linux_amd64.tar.gz
curl -fsSLO $BASE/SHA256SUMS
sha256sum --check --ignore-missing SHA256SUMS
tar -xzf marac_${VERSION}_linux_amd64.tar.gz
sudo install -m 0755 marac /usr/local/bin/marac
marac version
Archives are published for linux/amd64, linux/arm64,
darwin/amd64 and darwin/arm64. Check the checksum before you
install it — an access gateway is a poor thing to take on trust.
From source:
git clone https://github.com/MarStack-Labs/marstack-access.git
cd marstack-access
make build
./bin/marac version
A small VPS is enough. No hypervisor, no privileged container, no kernel modules, no database server — state is one SQLite file under the data directory.
First run
marac server
The control plane comes up on 127.0.0.1:7443. The SSH listener does not: a
security product should not open a port nobody asked for. On the very first start the
gateway creates one admin account and writes its token to
./data/bootstrap-token, warning you to read it and delete the file.
export MARAC_ENDPOINT=http://127.0.0.1:7443
export MARAC_TOKEN=$(cat ./data/bootstrap-token)
rm ./data/bootstrap-token
marac user list
A token looks like mat_<selector>_<verifier>. It is shown once, at
issue, and never again — nothing stores it, nothing logs it, and no endpoint can produce it
a second time. Losing one means issuing another.
Register a target
A target is a host the platform may reach, and the accounts it will accept a session as. Registering it does not grant anyone access to it.
marac target register \
--name db-primary \
--address 10.20.0.11 \
--principal deploy \
--principal readonly
Pin its host key
A target with no pinned host key cannot be connected to at all. Its identity cannot be verified, so a session to it is refused before it is opened — there is no trust-on-first-use and no way to disable the check.
ssh-keyscan -t ed25519 10.20.0.11 | marac target trust tgt-xxxxxxxxxxxxx
Compare the fingerprint against what the host itself reports before you accept it. Replacing an existing pin has to be asked for explicitly.
Let the target trust the CA
This is the whole of the agent-side installation. marac ca init prints exactly
what to add:
# /etc/ssh/sshd_config
TrustedUserCAKeys /etc/ssh/marac-ca.pub
AuthorizedPrincipalsFile /etc/ssh/principals/%u
The first line says which authority the host trusts. The second says which names it will
accept. From then on sshd enforces the principal, the expiry and the source
address on every certificate — so a bug in this code cannot hand out access the host has not
agreed to.
Users and keys
Two credentials, for two different doors. An API token talks to the control plane. An SSH public key is how the front door recognises you.
marac user create --name rina --role operator
marac token issue --user usr-xxxxxxxxxxxxx
marac key add --user usr-xxxxxxxxxxxxx --name laptop < ~/.ssh/id_ed25519.pub
| Role | Can |
|---|---|
viewer | read version and health |
operator | targets, sessions, raise and withdraw access requests |
admin | users, tokens, policies, approve and deny, kill sessions, read the audit trail |
A key carries no privilege of its own; the account's role and policies decide everything.
ssh-dss and RSA under 2048 bits are refused when the key is added, rather than
when a session fails at 3am.
Write a policy
A policy answers "may this subject reach that host as that account". A role never implies it.
marac policy create \
--name rina-db \
--user usr-xxxxxxxxxxxxx \
--target tgt-xxxxxxxxxxxxx \
--principal readonly
A policy naming a principal the target does not accept is refused at creation, not at
connection time. Writing policy is admin; an operator who could write it could
write themselves one.
A policy grants nothing on its own. It is an upper bound. Reaching the host still needs an approved request inside its window.
Ask for access
Nothing raises a request automatically. An ssh attempt with no grant is refused
and that is the end of it — a gateway that queued a request on every failed attempt would let
anyone with a policy flood the approval queue.
marac request create \
--target tgt-xxxxxxxxxxxxx \
--principal readonly \
--reason "investigating slow queries on the reporting replica, INC-4471" \
--ttl 2h
A reason is required. It is the only part of the record a later reader cannot reconstruct from anything else. The default grant is one hour and the maximum is twelve; a pending request expires by itself after a day, so the queue does not silt up.
Someone else decides it:
marac request list
marac request approve req-xxxxxxxxxxxxx
marac request deny req-xxxxxxxxxxxxx
A request cannot be decided by the account that raised it — including an admin. The grant window starts when it is approved, not when it was asked for. Expiry is computed from the clock rather than stored as state, so there is no reaper job that can quietly stop running and leave access alive.
Connect
Start the data plane, and point it at a signing key:
marac ca init --path ./data/ca
marac server --ssh-listen 0.0.0.0:2222 --dev-ca-key ./data/ca
Then a plain ssh client. The username carries the destination:
ssh -p 2222 readonly:db-primary@gateway
That reads as "take me to db-primary, landing as readonly". Five
checks run in order, and a refusal says which one refused:
| # | Question | Refused when |
|---|---|---|
| 1 | Does the target exist? | it is not registered |
| 2 | Does it accept this principal? | the account is not one it allows |
| 3 | Is its host key pinned? | its identity cannot be verified |
| 4 | Does a policy allow it? | no policy covers this combination |
| 5 | Is a grant live? | no approved, unexpired request |
An unregistered key gets Permission denied (publickey) and learns nothing about
what exists behind the gateway. Everything after that names the check, because a caller who
has proved who they are gains nothing from a blank refusal.
What the gateway keeps refusing
Accepting a session is not stepping aside. For the whole life of the connection the gateway answers every channel request the client makes:
session channel accepted
anything else rejected ← ssh -L dies here
pty-req, shell, exec accepted
env, window-change accepted
auth-agent-req refused
x11-req refused
subsystem refused ← no sftp, no scp
tcpip-forward refused ← ssh -R dies here
A gateway that forwards ports is a route into the network that no policy describes and no recording captures.
Recordings
Every session is recorded as asciicast v2, and a session that cannot be recorded does not open — the recorder is opened before the target is dialled, and there is no flag to turn it off.
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 is never written down.
That is a property of recording output rather than a filter that has to be maintained.
Ship them somewhere the gateway cannot reach back into:
export MARAC_S3_ACCESS_KEY=... MARAC_S3_SECRET_KEY=...
marac server --ssh-listen 0.0.0.0:2222 --dev-ca-key ./data/ca \
--recording-endpoint https://minio.internal:9000 \
--recording-bucket marac-recordings \
--recording-retain-for 2160h
The bucket must 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 a command line is visible in ps.
The console
A web console ships inside the binary at /console/. Five screens: sessions,
requests, targets, policies, and the tail of the audit trail. No build step, no
node_modules, nothing fetched at runtime.
Sign in by pasting the same API token marac uses. The page swaps it immediately
for an HttpOnly cookie it cannot read, so an injected script cannot steal the
credential. Because the cookie carries the token itself rather than a new session id,
revoking the token closes the console at the same instant it closes every CLI using it.
Everything the console shows comes from the same guarded API under the caller's own role. A viewer signing in is refused the session list here exactly as on the command line, and told why rather than shown an empty table.
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 audit trail
Every change and every decision is recorded. Nothing in this codebase can delete, truncate, or rotate the trail, and an architecture test fails the build if a sink grows a method that could.
target.registered allowed admin tgt-cgrne5bf33bct
target.host_key_pinned allowed admin tgt-cgrne5bf33bct
request.raised allowed rina req-7b6q3sjrrff8w
request.approved allowed admin req-7b6q3sjrrff8w
api.denied denied rina insufficient_role
api.denied denied - missing_token
A requester and an approver are always different accounts in the trail. That is what self-approval being impossible looks like from the outside.
By default the trail is a local append-only file, which survives a crash but not whoever owns the host. Ship it:
marac server --audit-loki-url http://loki:3100
Shipping never fails a session — if Loki is down the event is already on disk and the drop is counted. Reads are not recorded, and a token secret never is.
Before production
The signing key has no home outside the gateway process. It sits in the
file named by --dev-ca-key, and anyone who can read that file — or the memory
of the process — can mint access to every target that trusts the CA. Moving it behind a
signing call in marstack-secrets is planned and not built.
This is the largest open item in the project and it is written up in full as invariant 3 of docs/SECURITY.md.
Three more things worth knowing before you rely on this:
-
A grant expiring does not end a session already open. The check runs once,
before the session opens. Close it with
marac session kill. - The SigV4 signing has never been checked against a real MinIO. A canonicalisation mistake would surface as a 403 from the store, not as a failing test.
- Control plane audit events are written after the change commits, so a crash in between loses the event while keeping the change. The session path does not have this problem — its row and its recording are both opened before the target is touched.
This list is not marketing copy with the awkward parts removed. It is the same list the repository keeps under "What is not covered yet", and it is kept current in the same commit as the code.
What it will not do
These are excluded by design, not pending.
| 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 |