pre-release · v0.1.0

Reach a Linux host through it, never around it.

An SSH gateway that stores no target password and no target key. Every session authenticates with a certificate minted for that session alone, and the user never receives it.

No standing credentials Read the whole database and you still cannot log into anything.
Agentless Two lines of sshd_config on the target. Nothing installed.
Greppable sessions asciicast, not video. "Who ran this last month" is a query.
A trail that only grows Nothing in the code can delete, truncate or rotate it. Tests enforce that.

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
RoleCan
viewerread version and health
operatortargets, sessions, raise and withdraw access requests
adminusers, 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:

#QuestionRefused when
1Does the target exist?it is not registered
2Does it accept this principal?the account is not one it allows
3Is its host key pinned?its identity cannot be verified
4Does a policy allow it?no policy covers this combination
5Is 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 supportedBecause
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