Lines
49.61 %
Functions
30.3 %
Branches
100 %
//! Nomisync SSH daemon.
//!
//! See `doc/sshd.org` for the full operator guide. Key defaults
//! (loopback-only bind, pubkey-preferred auth, no-forwarding policy)
//! live in this crate's documentation comments on each module.
mod auth;
mod handler;
mod hostkey;
mod key_decoder;
mod rate_limit;
mod tui_transport;
use clap::Parser;
use exitfailure::ExitFailure;
use log::LevelFilter;
use russh::server::{Config, Server};
use std::net::SocketAddr;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use tokio::net::TcpListener;
#[derive(Parser, Debug)]
#[command(
name = "nomisync-sshd",
about = "Nomisync SSH TUI daemon",
long_about = "Nomisync SSH TUI daemon.\n\n\
Binds 127.0.0.1:2222 by default; the daemon accepts only \
PTY + shell and rejects exec, subsystem, port-forwarding, and \
X11. See doc/sshd.org for the operator guide (first-run \
host-key setup, adding user SSH keys, external exposure, \
hardened systemd unit)."
)]
struct Cli {
/// Address:port to bind. Loopback by default — explicit override
/// required for external exposure. Also honours
/// `NOMISYNC_SSHD_BIND` if set.
#[arg(long)]
bind: Option<SocketAddr>,
/// Path to the Ed25519 host-key file. Generated on first start
/// if missing. Also honours `NOMISYNC_SSHD_HOST_KEY` if set.
host_key: Option<PathBuf>,
/// Maximum *password* authentication attempts allowed per IP in
/// one window before subsequent attempts are refused.
#[arg(long, default_value_t = 6)]
max_auth_attempts_per_ip: u32,
/// Maximum *publickey* authentication attempts allowed per IP in
/// one window. Looser than the password bucket because a single
/// ssh client legitimately offers several identities per
/// connection.
#[arg(long, default_value_t = 30)]
max_pubkey_attempts_per_ip: u32,
/// Password-failures threshold that trips per-account lockout.
#[arg(long, default_value_t = 3)]
lockout_threshold: u32,
/// How long an account stays locked out, in seconds.
#[arg(long, default_value_t = 900)]
lockout_duration_secs: u64,
/// Public hostname this daemon answers on. Templated into the
/// post-`ssh-copy-id` ~/.ssh/config snippet sent to the client.
/// Honours `NOMISYNC_SSHD_HOSTNAME` if the flag is omitted.
public_hostname: Option<String>,
/// Public port this daemon answers on (matches the ingress TCP
/// service). Honours `NOMISYNC_SSHD_PORT`.
public_port: Option<u16>,
/// Host key fingerprint operators publish out-of-band. Templated
/// into the post-`ssh-copy-id` snippet so the client can verify
/// it on first connect. Honours `NOMISYNC_SSHD_HOST_FINGERPRINT`.
host_fingerprint: Option<String>,
#[arg(long, default_value = "info")]
loglevel: LevelFilter,
/// Database connection URL. Defaults to `$DATABASE_URL` if
/// unset; omitted here only because clap won't happily read the
/// env in a default-value expression.
#[arg(short = 'd', long)]
database: Option<String>,
}
const DEFAULT_BIND: &str = "127.0.0.1:2222";
const DEFAULT_HOST_KEY: &str = "/etc/nomisync/ssh_host_ed25519_key";
fn resolve_bind(cli_value: Option<SocketAddr>) -> Result<SocketAddr, std::net::AddrParseError> {
if let Some(v) = cli_value {
return Ok(v);
let raw = std::env::var("NOMISYNC_SSHD_BIND").unwrap_or_else(|_| DEFAULT_BIND.to_string());
raw.parse()
fn resolve_host_key(cli_value: Option<PathBuf>) -> PathBuf {
cli_value.unwrap_or_else(|| {
std::env::var("NOMISYNC_SSHD_HOST_KEY")
.map_or_else(|_| PathBuf::from(DEFAULT_HOST_KEY), PathBuf::from)
})
fn resolve_str_or_env(cli_value: Option<String>, env: &str, fallback: &str) -> String {
cli_value
.or_else(|| std::env::var(env).ok())
.unwrap_or_else(|| fallback.to_string())
fn resolve_port_or_env(cli_value: Option<u16>, env: &str, bind: SocketAddr) -> u16 {
.or_else(|| {
std::env::var(env)
.ok()
.and_then(|raw| raw.parse::<u16>().ok())
.unwrap_or_else(|| bind.port())
#[tokio::main]
async fn main() -> Result<(), ExitFailure> {
let cli = Cli::parse();
env_logger::Builder::new()
.filter_level(cli.loglevel)
.target(env_logger::Target::Stderr)
.init();
cli_core::start_server(cli.database, None).await?;
let bind = resolve_bind(cli.bind)?;
let host_key_path = resolve_host_key(cli.host_key);
let host_key = hostkey::load_or_generate(host_key_path.as_path())?;
let limiter = Arc::new(tokio::sync::Mutex::new(rate_limit::RateLimiter::new(
rate_limit::Config {
max_attempts_per_ip: cli.max_auth_attempts_per_ip,
max_pubkey_attempts_per_ip: cli.max_pubkey_attempts_per_ip,
window: Duration::from_mins(1),
account_lockout_threshold: cli.lockout_threshold,
account_lockout_duration: Duration::from_secs(cli.lockout_duration_secs),
},
rate_limit::InstantClock,
)));
let connect_info = handler::SshConnectInfo {
hostname: resolve_str_or_env(
cli.public_hostname,
"NOMISYNC_SSHD_HOSTNAME",
&bind.ip().to_string(),
),
port: resolve_port_or_env(cli.public_port, "NOMISYNC_SSHD_PORT", bind),
host_fingerprint: resolve_str_or_env(
cli.host_fingerprint,
"NOMISYNC_SSHD_HOST_FINGERPRINT",
"(not configured)",
};
let mut state = handler::SshdState {
limiter,
connect_info,
let config = Arc::new(Config {
inactivity_timeout: Some(Duration::from_mins(10)),
auth_rejection_time: Duration::from_secs(2),
keys: vec![host_key],
..Config::default()
});
let listener = TcpListener::bind(bind).await?;
log::info!("nomisync-sshd listening on {bind}");
if !bind.ip().is_loopback() {
log::warn!(
"bind {bind} is not loopback — this daemon has no TLS, no \
brute-force protection beyond per-IP rate limiting; expose \
deliberately."
);
state.run_on_socket(config, &listener).await?;
Ok(())
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cli_parses_defaults() {
let cli = Cli::try_parse_from(["nomisync-sshd"]).expect("defaults should parse");
assert!(
cli.bind.is_none(),
"bind unset on CLI falls through to env/default"
assert!(cli.host_key.is_none());
assert_eq!(cli.max_auth_attempts_per_ip, 6);
assert_eq!(cli.max_pubkey_attempts_per_ip, 30);
assert_eq!(cli.lockout_threshold, 3);
assert_eq!(cli.lockout_duration_secs, 900);
assert!(cli.public_hostname.is_none());
assert!(cli.public_port.is_none());
assert!(cli.host_fingerprint.is_none());
fn cli_accepts_explicit_bind_and_key_path() {
let cli = Cli::try_parse_from([
"nomisync-sshd",
"--bind",
"0.0.0.0:22",
"--host-key",
"/tmp/k",
])
.unwrap();
assert_eq!(cli.bind.unwrap().to_string(), "0.0.0.0:22");
assert_eq!(cli.host_key.unwrap().to_string_lossy(), "/tmp/k");
fn resolve_bind_uses_default_when_env_and_cli_absent() {
// Safety: tests run single-threaded so this env manipulation
// does not race other tests in this module.
unsafe {
std::env::remove_var("NOMISYNC_SSHD_BIND");
let resolved = resolve_bind(None).unwrap();
assert_eq!(resolved.to_string(), DEFAULT_BIND);
fn resolve_bind_prefers_cli_over_env() {
let explicit: SocketAddr = "10.0.0.1:22".parse().unwrap();
std::env::set_var("NOMISYNC_SSHD_BIND", "127.0.0.1:9999");
let resolved = resolve_bind(Some(explicit)).unwrap();
assert_eq!(resolved, explicit);
fn resolve_host_key_uses_default_when_env_and_cli_absent() {
std::env::remove_var("NOMISYNC_SSHD_HOST_KEY");
let resolved = resolve_host_key(None);
assert_eq!(resolved.to_string_lossy(), DEFAULT_HOST_KEY);