Per-username SSH reverse proxy: routes ssh user@host to backends by login name (config + sqlite)
  • Rust 96.8%
  • Dockerfile 1.8%
  • Nix 1.4%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Aleksandr 39b0eeec89 relay: forward env requests with want_reply=false
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
2026-08-24 15:25:32 +03:00
crates relay: forward env requests with want_reply=false 2026-08-24 15:25:32 +03:00
.dockerignore ssh-router: per-username SSH reverse proxy 2026-08-23 16:43:06 +03:00
.envrc ssh-router: per-username SSH reverse proxy 2026-08-23 16:43:06 +03:00
.gitignore ssh-router: per-username SSH reverse proxy 2026-08-23 16:43:06 +03:00
AGENTS.md Single-table db schema, CompactString, English docs 2026-08-23 17:27:11 +03:00
Cargo.lock ssh-router: per-username SSH reverse proxy 2026-08-23 16:43:06 +03:00
Cargo.toml ssh-router: per-username SSH reverse proxy 2026-08-23 16:43:06 +03:00
config.example.yaml Single-table db schema, CompactString, English docs 2026-08-23 17:27:11 +03:00
docker-compose.yaml ssh-router: per-username SSH reverse proxy 2026-08-23 16:43:06 +03:00
Dockerfile ssh-router: per-username SSH reverse proxy 2026-08-23 16:43:06 +03:00
flake.lock nix: keep migrations/*.sql in the build source 2026-08-24 12:19:29 +03:00
flake.nix nix: keep migrations/*.sql in the build source 2026-08-24 12:19:29 +03:00
README.md Single-table db schema, CompactString, English docs 2026-08-23 17:27:11 +03:00

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.pub to 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.