- Rust 96.8%
- Dockerfile 1.8%
- Nix 1.4%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
sshd answers Failure for env vars outside AcceptEnv (unset by default), and the relay treats any backend Failure as a fatal refusal, tearing the session down. Clients that SendEnv LANG (Termux default) could not open a session at all. Mirror the OpenSSH client: send env without want_reply so the backend silently drops unaccepted variables. Co-Authored-By: Eva |
||
| crates | ||
| .dockerignore | ||
| .envrc | ||
| .gitignore | ||
| AGENTS.md | ||
| Cargo.lock | ||
| Cargo.toml | ||
| config.example.yaml | ||
| docker-compose.yaml | ||
| Dockerfile | ||
| flake.lock | ||
| flake.nix | ||
| README.md | ||
ssh-router
SSH reverse proxy that routes by username: ssh nero@maide.su → the router
looks nero up in its config or sqlite → proxies to nero@10.0.0.1.
Plain OpenSSH cannot do this: the username only exists inside the encrypted session, so the router terminates SSH itself. Consequences:
- clients authenticate to the router with public keys (per-user lists in config and/or sqlite; no password support);
- the router dials backends with its own key — append its
client_ed25519.pubto the backends'authorized_keys; - clients see the router's host key, not the backend's; backend host keys
are currently not verified (
backend_host_key: accept_any) — keep the router→backend leg on a trusted network.
Session channels are proxied: shell/pty, exec, sftp/scp, window resize, exit codes and signals. No port forwarding, no agent forwarding.
Quick start
cp config.example.yaml config.yaml # adjust routes/auth
docker compose up -d
docker compose logs ssh-router # prints the router's public key
# append that key to the backends' authorized_keys, then:
ssh -p 2222 nero@maide.su
Without docker: nix run .# -- --config config.yaml (or cargo run).
Configuration
Full commented example: config.example.yaml. Minimum:
ssh:
listen: "0.0.0.0:2222"
host_key_file: /var/lib/ssh-router/host_ed25519
client_key_file: /var/lib/ssh-router/client_ed25519
routes:
master: root@10.0.0.1 # user@host[:port]
olesteep: 10.0.0.2 # user = login username, port = 22
auth:
keys:
master: ["ssh-ed25519 AAAA... master@laptop"]
Routing via sqlite
The db section is optional: without it the deployment is fully
immutable — the router only reads config and writes nothing (key
generation can be disabled too with generate_keys: false).
With db.path set, the router creates a database with a single routes
table on startup and only reads it — a fresh query per login attempt.
There is no management API; edit the database directly:
sqlite3 /var/lib/ssh-router/routes.db \
"INSERT INTO routes VALUES ('nero', NULL, '10.0.0.1', 'ssh-ed25519 AAAA... nero@laptop');"
Columns: connect_user, forward_user (NULL = same as connect_user),
forward_to (host[:port]), public_key (full OpenSSH line). One row is
one allowed key and its destination; the row whose key matches the login
decides, so different keys of one user may route differently.
Changes apply on the next connection, no restart needed. On conflict the config wins over the database.
NixOS
inputs.ssh-router.url = "git+https://git.desu.church/eva/ssh-router.git";
# ...
imports = [ inputs.ssh-router.nixosModules.default ];
services.ssh-router = {
enable = true;
settings = {
ssh.listen = "0.0.0.0:2222";
routes.master = "root@10.0.0.1";
auth.keys.master = [ "ssh-ed25519 AAAA..." ];
};
};
users.users.ssh-router = {
isSystemUser = true;
group = "ssh-router";
};
users.groups.ssh-router = { };
Keys and the database live in /var/lib/ssh-router (systemd
StateDirectory). The module does not create the user — declare it on
the host as above.
Development
nix develop (or direnv), then cargo test — unit tests plus in-process
end-to-end (russh client → router → fake backend), no OpenSSH required.