- Rust 85.6%
- Shell 13.3%
- Makefile 1.1%
| .forgejo/workflows | ||
| ci | ||
| examples | ||
| packaging | ||
| src | ||
| tools | ||
| .codex | ||
| .gitignore | ||
| ACCESS.md | ||
| AGENTS.md | ||
| Cargo.lock | ||
| Cargo.toml | ||
| disection.md | ||
| LICENSE | ||
| Makefile | ||
| op-guide.md | ||
| README.md | ||
| SECURITY-SNDL.md | ||
CentralSSH
CentralSSH is a hardened, OpenSSH-compatible SSH gateway. It is a broker, not a shell host.
Users connect with a normal SSH client, authenticate through CentralSSH (username -> password -> TOTP), select an approved target, and CentralSSH proxies the session to that target with a per-user key.
Protocol Boundary
CentralSSH is strictly an SSH server.
- OpenSSH client compatible.
- No custom SSH protocol extensions.
- Accepts only standard SSH mechanisms needed for gateway flow.
- Proxies standard SSH behavior after target selection, including shell,
exec, subsystem/SFTP, and forwarding requests. - Denies gateway-local shell, gateway-local command execution, gateway filesystem access, and agent forwarding.
- Gateway login auth is internal only (
username/password/TOTP), not SSH public-key auth.
What You Get
- SSH transport compatible with standard
sshclients. - Internal auth flow:
username -> password -> TOTP. - Argon2id password hashing with automatic bootstrap migration.
- Forced first-login password change and TOTP enrollment.
- Per-user allowed-server authorization.
- Strict outbound host-key verification against known_hosts.
- Structured JSONL audit logging.
- Startup reconciliation for missing per-user outbound keys.
- Hot config reload on
SIGHUP. - Centralized SSH crypto policy; see
SECURITY-SNDL.mdfor Store Now, Decrypt Later limitations and migration work.
Install Targets and Paths
Default runtime paths:
- Config:
/etc/centralssh/config.toml - Servers map:
/etc/centralssh/servers.toml - Known hosts:
/etc/centralssh/known_hosts - User key root:
/var/lib/centralssh/keys - Audit log:
/var/log/centralssh/audit.jsonl - Binary:
/usr/local/sbin/centralssh - Helper tool:
/usr/local/bin/cssh-keyscan - Gateway server host key:
/etc/centralssh/host_ed25519
Quick Start (FreeBSD First)
- Build and install:
cd /path/to/centralSSH
make
sudo make install
- Enable and start service:
sudo sysrc centralssh_enable=YES
sudo service centralssh start
sudo service centralssh status
- Populate host keys for each target server:
sudo cssh-keyscan 192.168.122.123
- Connect from a client:
ssh -p 7788 <gateway-host>
Linux (systemd)
sudo systemctl daemon-reload
sudo systemctl enable --now centralssh
sudo systemctl status centralssh
FreeBSD rc.conf Overrides
Optional rc.conf keys:
centralssh_enable="YES"
centralssh_listen="0.0.0.0:7788"
centralssh_config="/etc/centralssh/config.toml"
centralssh_servers="/etc/centralssh/servers.toml"
centralssh_known_hosts="/etc/centralssh/known_hosts"
centralssh_user_key_root="/var/lib/centralssh/keys"
centralssh_audit_log="/var/log/centralssh/audit.jsonl"
centralssh_whitelist="/etc/centralssh/whitelist.txt"
Makefile Behavior
Standard pipeline:
cd /path/to/centralSSH
make
sudo make install
make install:
- Installs
centralsshandcssh-keyscan. - Creates
/etc/centralsshlayout. - Installs example
config.tomlandservers.tomlif missing. - Creates
/etc/centralssh/known_hostsif missing. - Creates
/var/log/centralssh/audit.jsonlif missing. - Installs FreeBSD rc script on FreeBSD.
- Installs systemd unit on non-FreeBSD hosts.
Configuration
CentralSSH reads config from TOML files.
/etc/centralssh/config.toml
Example with two users:
[[users]]
name = "alice"
password = "REPLACE_WITH_UNIQUE_TEMPORARY_PASSWORD"
must_change_password = true
allowed_servers = ["git", "httpd"]
[[users]]
name = "bob"
password = "REPLACE_WITH_UNIQUE_TEMPORARY_PASSWORD"
must_change_password = true
allowed_servers = ["dns"]
[settings]
user_key_root = "/var/lib/centralssh/keys"
per_user_per_server = true
known_hosts_path = "/etc/centralssh/known_hosts"
audit_log_path = "/var/log/centralssh/audit.jsonl"
whitelist_path = "/etc/centralssh/whitelist.txt"
enforce_password_policy = true
min_password_policy = 12
[fail2ban]
enabled = true
max_failures = 5
find_time = "60s"
ban_time = "10m"
max_ban_time = "24h"
backoff_multiplier = 2.0
delay_before_ban = true
delay_time = "2s"
persist_state = true
state_path = "/var/lib/centralssh/fail2ban_state.json"
[fail2ban.whitelist]
ips = ["127.0.0.1/32", "::1/128", "192.168.0.0/16"]
Fields:
users: required non-empty array.users[].name: required, unique,1..64, chars[a-zA-Z0-9._-].users[].password: Argon2id hash or bootstrap plaintext.users[].totp_secret: optional base32 TOTP secret.users[].must_change_password: boolean.users[].allowed_servers: required non-empty list of server names inservers.toml.settings.user_key_root: optional path override.settings.per_user_per_server: optional bool, defaulttrue. Whentrue, CentralSSH uses one outbound key per user and server. Whenfalse, it uses one outbound key per user.settings.known_hosts_path: optional path override.settings.audit_log_path: optional path override.settings.whitelist_path: optional path to a fail2ban bypass file with one IPv4 or IPv6 address per row.settings.enforce_password_policy: optional bool, defaulttrue.settings.min_password_policy: optional integer minimum password length, default12.fail2ban.enabled: optional bool, defaulttrue.fail2ban.max_failures: optional integer threshold inside the sliding window, default5.fail2ban.find_time: optional duration string, default60s.fail2ban.ban_time: optional duration string for the first ban, default10m.fail2ban.max_ban_time: optional duration string cap for repeated bans, default24h.fail2ban.backoff_multiplier: optional float, default2.0.fail2ban.delay_before_ban: optional bool, defaulttrue.fail2ban.delay_time: optional duration string, default2s.fail2ban.persist_state: optional bool, defaulttrue.fail2ban.state_path: optional path for persisted abuse state, default/var/lib/centralssh/fail2ban_state.json.fail2ban.whitelist.ips: optional IPv4/IPv6 CIDR list. Defaults include127.0.0.1/32and::1/128.
Notes:
- Outbound target SSH username is always the authenticated CentralSSH username.
- If bootstrap plaintext passwords are present, CentralSSH hashes them with Argon2id at startup and atomically rewrites config.
- The documented placeholder password is rejected at config validation time. Replace it before starting the service.
Built-In Abuse Protection
CentralSSH includes an internal fail2ban-style tracker keyed primarily by remote IP.
Behavior:
- Failures are tracked in a sliding window, not a fixed reset bucket.
max_failuresinsidefind_timecreates a ban forban_time.- Repeated bans for the same IP use exponential backoff and stop growing at
max_ban_time. - Optional tarpitting applies
delay_timejust before the ban threshold whendelay_before_ban=true. - CIDR whitelist entries and
settings.whitelist_pathfile entries bypass bans and failure tracking. - If persistence is enabled, ban state is atomically saved and reloaded from
fail2ban.state_path.
Operational notes:
- Whitelist trusted admin networks deliberately to avoid locking out operators during maintenance.
- Keep the whitelist as narrow as practical; prefer a management subnet over broad RFC1918 ranges.
settings.whitelist_pathexpects one exact IP address per line and accepts both IPv4 and IPv6.- The persisted state file contains timestamps, IPs, usernames, target names, and ban metadata only. It does not contain passwords or TOTP material.
/etc/centralssh/servers.toml
[servers]
git = "192.168.86.44"
httpd = "192.168.86.41"
dns = "192.168.86.53"
Rules:
- User
allowed_serversentries must exactly match keys in this file. - Values are target host/IP strings used for outbound SSH.
Auth and Session Flow
- Client connects to CentralSSH SSH port (default
7788). - CentralSSH prompts for
Username. - CentralSSH prompts for
Password. - Only after password success, CentralSSH prompts for
TOTP Code. - If password change is required, user must change password.
- If TOTP secret is missing, user must complete TOTP enrollment.
- User sees menu of allowed servers.
- User selects a server.
- CentralSSH opens outbound SSH session to
<target>:22. - On proxy session exit, user returns to gateway menu.
Password and TOTP Lifecycle
- Password hashing algorithm: Argon2id.
- Bootstrap plaintext is migrated to Argon2id on startup.
- First-login password change enforced via
must_change_password. - TOTP uses RFC6238 semantics with 30s step and drift tolerance.
- TOTP secrets are base32 and never logged.
How to set user passwords
Supported approaches:
- Recommended bootstrap flow: set a temporary plaintext in
config.tomlandmust_change_password=true; CentralSSH migrates it to Argon2id at startup. - Pre-hash offline and place Argon2id string directly in
users[].password.
Per-User Outbound Keys
Outbound private key path pattern:
- Default:
<user_key_root>/<username>/<server>/id_ed25519 - With
PER_USER_PER_SERVER=false:<user_key_root>/<username>/id_ed25519
Behavior:
- On startup, CentralSSH reconciles configured users and allowed servers.
- Missing user directories, server directories when enabled, and
id_ed25519files are created automatically. - Existing keys are not overwritten.
- Keys are for outbound proxy auth only, not gateway login.
Host Key Management (cssh-keyscan)
cssh-keyscan fetches target host keys and updates CentralSSH known_hosts.
Basic usage:
sudo cssh-keyscan <server IP or domain>
Scriptable verified usage with expected key material:
sudo cssh-keyscan 192.168.122.123 'AAAAC3NzaC1lZDI1NTE5AAAAIHiKldTfYnX3R0tRkMA6Xy1z9NJ+IGp8H7wQy2kCoGM/'
sudo cssh-keyscan 192.168.122.123 'ssh-ed25519 SHA256:FXTNTbOFUWoYI7C4ND351UCvqSY8fhafJsjqqxInbUo'
Accepted expected-key formats:
<base64-key-blob><algorithm> <base64-key-blob>SHA256:<fingerprint><algorithm> SHA256:<fingerprint>
Security behavior:
- New host without expected key: interactive TOFU prompt.
- New host with expected key: requires at least one scanned key to match; TOFU prompt skipped.
- New host with key overlap: TOFU output shows overlapping hostnames.
- Existing host with any newly presented key: hard fail; no file modification.
- MD5 fingerprints are not accepted.
File Ownership and Modes (Production)
Required for strict mode (--enforce-strict-security true, default):
/etc/centralssh/config.toml: ownerroot, mode0600/etc/centralssh/servers.toml: ownerroot, mode0600/etc/centralssh/known_hosts: ownerroot, mode0600/var/lib/centralssh/keys: ownerroot, mode0700/var/log/centralssh/audit.jsonl: ownerroot, mode0600
Post-Install Validation Checklist
Run these checks after installation:
sudo ls -ld /etc/centralssh /var/lib/centralssh/keys /var/log/centralssh
sudo ls -l /etc/centralssh/config.toml /etc/centralssh/servers.toml /etc/centralssh/known_hosts /etc/centralssh/host_ed25519 /var/log/centralssh/audit.jsonl
sudo service centralssh status || sudo systemctl status centralssh
Expected posture:
- Sensitive files are mode
0600. - User key root directory is mode
0700. - Ownership is root in strict production mode.
CLI and Environment Overrides
Show help:
/usr/local/sbin/centralssh --help
Run manually:
/usr/local/sbin/centralssh \
--listen 0.0.0.0:7788 \
--config /etc/centralssh/config.toml \
--servers /etc/centralssh/servers.toml \
--known-hosts /etc/centralssh/known_hosts \
--user-key-root /var/lib/centralssh/keys \
--audit-log /var/log/centralssh/audit.jsonl
Flags:
--listen--config--servers--known-hosts--user-key-root--per-user-per-server--audit-log--enforce-strict-security(defaulttrue)
Environment variables:
CENTRALSSH_LISTENCENTRALSSH_CONFIGCENTRALSSH_SERVERSCENTRALSSH_KNOWN_HOSTSCENTRALSSH_USER_KEY_ROOTPER_USER_PER_SERVERCENTRALSSH_AUDIT_LOGCENTRALSSH_ENFORCE_STRICT_SECURITY
Reload and Operations
FreeBSD service commands
sudo service centralssh start
sudo service centralssh stop
sudo service centralssh restart
sudo service centralssh status
systemd commands
sudo systemctl start centralssh
sudo systemctl stop centralssh
sudo systemctl restart centralssh
sudo systemctl status centralssh
Reload config without dropping active sessions
sudo kill -HUP $(pgrep -x centralssh)
Reload behavior:
- Valid config: applied in-memory.
- Invalid config: rejected, previous config remains active.
Auditing
Audit file:
/var/log/centralssh/audit.jsonl
Event schema fields:
timestampevent_typerequest_idremote_ipremote_portusernametarget_serverauth_methodresultreasonban_duration_secondsban_until
Representative events:
connection_openedconnection_rejected_bannedauth_attemptauth_successauth_failureunknown_username_attemptauthorization_deniedprotocol_errorban_createdban_extendedban_expiredwhitelist_bypassrate_limit_delay_applied
Example JSONL record:
{"timestamp":"2026-05-02T20:00:00Z","event_type":"ban_created","request_id":"c7b2d8d7-66bc-4b68-a4d1-d7c0a0f4a7d9","remote_ip":"203.0.113.44","remote_port":51422,"username":"alice","target_server":null,"auth_method":"keyboard_interactive","result":"banned","reason":"fail2ban threshold reached","ban_duration_seconds":600,"ban_until":"2026-05-02T20:10:00Z"}
Secrets are not logged.
Troubleshooting
centralssh error: I/O error: No such file or directory (os error 2)
Missing one or more required paths.
Check:
/etc/centralssh/config.toml/etc/centralssh/servers.toml/etc/centralssh/known_hosts/var/log/centralssh/audit.jsonl
Fix:
sudo make install
make install fails under sudo with cargo: No such file or directory
Cause: root shell PATH does not include cargo.
Fix:
- Build as normal user first:
make - Install as root after build:
sudo make install
install ... Operation not permitted or Permission denied
Cause: writing into /usr/local without privileges.
Fix:
- Use
sudo make install.
Service restart reports stale pid or "already running"
Fix:
sudo service centralssh stop
sudo service centralssh start
sudo service centralssh status
invalid configuration: user '<name>' references unknown server '<server>'
Cause: allowed_servers entry does not exactly match any key in servers.toml.
Fix:
- Correct spelling/case in
allowed_servers. - Ensure matching server key exists in
/etc/centralssh/servers.toml.
Host key verification failures when selecting a server
Cause: target host key missing or changed in known_hosts.
Fix:
- Verify target identity out-of-band.
- Update
/etc/centralssh/known_hostsviacssh-keyscan. - Retry connection.
Non-interactive cssh-keyscan fails with TOFU-required message
Cause: new host and no expected key argument in a non-TTY context.
Fix:
- Provide expected key argument for scriptable mode.
- Or run interactively and confirm TOFU prompt.
Development Mode
For local/dev-only runs where production file ownership/modes are not available:
mkdir -p ./tmp/users
cp examples/config.toml ./tmp/config.toml
cp examples/servers.toml ./tmp/servers.toml
touch ./tmp/known_hosts ./tmp/audit.jsonl
cargo run -- \
--config ./tmp/config.toml \
--servers ./tmp/servers.toml \
--known-hosts ./tmp/known_hosts \
--user-key-root ./tmp/users \
--audit-log ./tmp/audit.jsonl
Disable strict-security checks for that run with:
CENTRALSSH_ENFORCE_STRICT_SECURITY=false cargo run -- \
--config ./tmp/config.toml \
--servers ./tmp/servers.toml \
--known-hosts ./tmp/known_hosts \
--user-key-root ./tmp/users \
--audit-log ./tmp/audit.jsonl
Do not use this mode in production.
Connect from Clients
ssh -p 7788 <gateway-host>
OpenSSH client options like -J/ProxyJump work as normal when targeting CentralSSH.
License
See LICENSE.