Public Access
`dotnet ef database update --project src/DodoSSH.Infrastructure` — the command the README documents — failed on a clean machine. The design-time default named `dodossh_design` as user `postgres` with no password, which is a database this repository never creates, while the development compose stack creates `dodossh`/`dodossh`. The failure arrives as a SCRAM authentication error, so it reads like a broken container rather than a stale default. The default is now the compose stack, since that is the only local database the repo defines. DODOSSH_DESIGN_CONNECTION still overrides it, and a real deployment migrates through that or the migrator job. Also documents running the thing end to end, which the README never covered: the four commands in order, that migrations are a separate step because the API deliberately fails readiness rather than migrating, and the three M1 gaps visible in the first five minutes — so they are expected rather than diagnosed.
196 lines
9.5 KiB
Markdown
196 lines
9.5 KiB
Markdown
# DodoSSH
|
|
|
|
A self-hosted, team-oriented SSH client with an end-to-end encrypted vault.
|
|
|
|
Manage hosts, credentials and keys in a desktop app; sync them across your devices and share
|
|
them with teammates through a server you run yourself. **The server stores ciphertext and never
|
|
holds a key** — the operator cannot read the credentials it stores.
|
|
|
|
> Status: early development. See [the milestone plan](#milestones) for what exists today.
|
|
|
|
## Why
|
|
|
|
Teams either scatter SSH credentials across individual `~/.ssh` directories with no sharing
|
|
story, or pay per-seat for a hosted product that holds their infrastructure credentials.
|
|
DodoSSH keeps the convenience of a synced, shareable vault while remaining self-hostable and
|
|
zero-knowledge.
|
|
|
|
## Architecture
|
|
|
|
| Component | Choice |
|
|
| --- | --- |
|
|
| Backend | ASP.NET Core on .NET 10, PostgreSQL + EF Core |
|
|
| Client | Avalonia (C#) for Windows/Linux/macOS; terminal pane is a WebView running xterm.js |
|
|
| Auth | OIDC, provider-agnostic (Entra ID, Keycloak, Auth0, Authentik) |
|
|
| Vault | End-to-end encrypted; X25519 + Ed25519 + XChaCha20-Poly1305, Argon2id unlock |
|
|
| Connections | Client-direct SSH by default, with an optional raw-TCP server relay |
|
|
|
|
Two consequences worth knowing before you read further:
|
|
|
|
- **Revocation is not retroactive.** A removed member keeps what they already downloaded. The
|
|
real remediation is rotating the SSH credential, so offboarding is built around a rotation
|
|
checklist rather than a button that implies more than it delivers.
|
|
- **No session recording in relay mode.** The relay forwards SSH ciphertext, so it cannot see
|
|
commands. That is the cost of the relay not being able to read your traffic.
|
|
|
|
The reasoning behind each major decision is recorded in [`docs/adr/`](docs/adr/), starting with
|
|
[the E2EE trust model](docs/adr/0001-e2ee-trust-model.md).
|
|
|
|
## Repository layout
|
|
|
|
```
|
|
src/
|
|
DodoSSH.Contracts DTOs shared with the client — the real API contract
|
|
DodoSSH.Crypto DSH1 envelope, AAD derivation, the key hierarchy
|
|
DodoSSH.Domain entities and invariants, no EF
|
|
DodoSSH.Infrastructure DbContext, configurations, migrations
|
|
DodoSSH.Api the server
|
|
DodoSSH.Client.Auth OIDC code+PKCE on a loopback redirect, and the key binding
|
|
DodoSSH.Client.Api the typed server client, and client-side enrollment
|
|
DodoSSH.Client.Domain the decrypted item model and the three-way merge — no I/O at all
|
|
DodoSSH.Client.Storage the local cache: ciphertext mirror, outbox, offline unlock material
|
|
DodoSSH.Client.Sync the pull/apply/push loop and the conflict policy
|
|
DodoSSH.Client.Session where a profile lives, unlocking it, and getting one in the first place
|
|
DodoSSH.Client.Ssh connections, PTY shells, host key trust
|
|
DodoSSH.Client.Terminal the loopback data plane and credit-based flow control
|
|
DodoSSH.Client.App Avalonia; the only project that knows about a UI toolkit
|
|
tests/ one test project per source project
|
|
docs/adr/ architecture decision records
|
|
```
|
|
|
|
Everything under `src/DodoSSH.Client.*` except `App` is deliberately free of Avalonia. That is the
|
|
seam that lets the SSH layer, the terminal's flow control and the OIDC flow be tested without a UI
|
|
toolkit or a browser engine — which is most of why they are testable at all.
|
|
|
|
## Building
|
|
|
|
Requires the .NET SDK pinned in [`global.json`](global.json) (10.0.x).
|
|
|
|
```bash
|
|
dotnet build DodoSSH.slnx
|
|
```
|
|
|
|
```bash
|
|
dotnet test DodoSSH.slnx
|
|
```
|
|
|
|
The tests need a Docker daemon. Everything that touches the database, the identity provider or an SSH
|
|
server uses Testcontainers rather than a stub or a shared instance, so there is nothing to start first and
|
|
nothing to clean up after — but with no daemon those suites fail rather than skip.
|
|
|
|
## Running it
|
|
|
|
Four commands, in order. The first two are once per machine.
|
|
|
|
**1. The development dependencies** — PostgreSQL and Keycloak, with the `dodossh` realm imported:
|
|
|
|
```bash
|
|
docker compose -f deploy/docker-compose.dev.yml up -d
|
|
```
|
|
|
|
**2. The schema.** The API never migrates anything: it fails readiness while a migration is pending, and
|
|
says which one. `dotnet-ef` is pinned in `.config/dotnet-tools.json`, so run `dotnet tool restore` first if
|
|
you have not:
|
|
|
|
```bash
|
|
dotnet ef database update --project src/DodoSSH.Infrastructure
|
|
```
|
|
|
|
With nothing else configured this targets the compose stack above. Set `DODOSSH_DESIGN_CONNECTION` to point
|
|
it at another database.
|
|
|
|
**3. The server:**
|
|
|
|
```bash
|
|
dotnet run --project src/DodoSSH.Api
|
|
```
|
|
|
|
It listens on `http://localhost:5233`, serving `/healthz/live`, `/healthz/ready` and — in
|
|
Development — `/openapi/v1.json`.
|
|
|
|
**4. The desktop client:**
|
|
|
|
```bash
|
|
dotnet run --project src/DodoSSH.Client.App
|
|
```
|
|
|
|
In the app, enter `http://localhost:5233` as the server. Your browser opens for sign-in — the realm ships
|
|
`alice` / `alice` — then choose a vault passphrase and **write down the recovery code**, which cannot be
|
|
skipped and cannot be recovered from the server. You can then add a host and open a shell on it. Keycloak's
|
|
admin console is at `http://localhost:18080` (`admin` / `admin`).
|
|
|
|
Three of M1's known gaps are visible immediately, so they are worth expecting rather than diagnosing: a
|
|
connection asks for the host's password every time, because credentials are not a synced entity type yet;
|
|
host key trust lasts one session, because known hosts do not live in the vault yet; and unlock asks for the
|
|
passphrase on every launch, because no device key is registered.
|
|
|
|
### End-to-end verification
|
|
|
|
One suite runs against a real server rather than a stub. It needs a Docker daemon and nothing else, so it
|
|
is part of the ordinary test run:
|
|
|
|
```bash
|
|
dotnet test tests/DodoSSH.SystemTests
|
|
```
|
|
|
|
It brings up PostgreSQL, Keycloak and an OpenSSH server in containers, applies the committed migrations,
|
|
starts the API as a child process out of its own build output, and then drives the real client: sign in
|
|
through Keycloak, enroll, unlock, create a host, sync it, read it back on a second simulated machine,
|
|
unlock again with no network, and open a shell on the `sshd`. Roughly 25 seconds once the images are
|
|
pulled.
|
|
|
|
What makes it worth its weight is that it consumes the artefacts that ship — the realm file from
|
|
`deploy/keycloak`, the EF migrations, the API's own `appsettings` — rather than a fixture written to match
|
|
them. On its first run it found a loopback redirect URI the realm registered in a form Keycloak rejects,
|
|
and a JSON configuration gap that made the whole sync surface unreachable from the real client while every
|
|
other test passed. Both are the same class of bug: two sides of a stub agreeing with each other about
|
|
something the specification never said.
|
|
|
|
The one value it cannot take from a committed file is `Oidc:Authority`, since the container's port is
|
|
assigned at start. Everything that authority points at is still the real realm.
|
|
|
|
Development and testing are currently **Windows-only**. Anything known or suspected to differ on
|
|
Linux and macOS is tracked in [`docs/platform-flags.md`](docs/platform-flags.md), along with the
|
|
deployment gotchas that have already cost time once. Read it before assuming something works
|
|
off-Windows.
|
|
|
|
### Conventions the build enforces
|
|
|
|
- Warnings are errors. `dotnet format --verify-no-changes` gates CI.
|
|
- Package versions are centralised in `Directory.Packages.props`; `packages.lock.json` is
|
|
committed and CI restores in locked mode.
|
|
- [`BannedSymbols.txt`](BannedSymbols.txt) bans `DateTime.UtcNow` (use `TimeProvider`),
|
|
`Guid.NewGuid` (use `CreateVersion7`), sync-over-async, MD5/SHA1 and PBKDF2.
|
|
- Public members of `DodoSSH.Contracts` must be declared in `PublicAPI.Unshipped.txt`, so a
|
|
contract change is a build error rather than a client-side surprise.
|
|
|
|
## Milestones
|
|
|
|
- **M0 — foundation.** Repo structure, build conventions, CI, ADRs. *Done.*
|
|
- **M1 — vertical slice.** OIDC login → enroll → create a host → open a shell.
|
|
*Server done:* the DSH1 crypto core, the data model, sync push/pull for hosts, `/me`, and
|
|
enrollment with the identity-provider key binding.
|
|
*Client done:* the key hierarchy, the OIDC flow with the key binding, SSH connections with host key
|
|
trust, the terminal data plane, the encrypted local cache with the sync client — offline unlock, an
|
|
outbox and a field-level three-way merge, conflict matrix green — and an Avalonia shell that is
|
|
vault-backed: server URL → browser sign-in → enroll → unlock → host list → terminal. The shell's whole
|
|
path is covered by tests against an in-memory server, so the states that matter most (the recovery code
|
|
that cannot be skipped, the unlock that needs no network) are checked rather than remembered.
|
|
*Verified end to end:* `tests/DodoSSH.SystemTests` drives the whole slice against a real Keycloak, a
|
|
real API, a real PostgreSQL and a real `sshd` — sign-in, the identity-provider key binding, enrollment,
|
|
offline unlock, a host through the vault to a second machine, and an interactive shell. See
|
|
[End-to-end verification](#end-to-end-verification).
|
|
|
|
Known gaps in the client, stated rather than implied by the interface: credentials are not a synced
|
|
entity type yet, so a connection still asks for a password; known host keys live in memory for one
|
|
session instead of in the vault; and no device key is registered, so the passphrase is needed on every
|
|
launch until the OS keystore is wired.
|
|
- **M2 — full personal vault**, robust sync, relay.
|
|
- **M3 — teams**, sharing, ACLs.
|
|
- **M4 — hardening and ops**, packaging, self-hosting guide.
|
|
- **M5 — multi-provider OIDC**, key rotation, per-item content keys.
|
|
|
|
## Licence
|
|
|
|
[MIT](LICENSE).
|