1
//! Nomisync SSH daemon.
2
//!
3
//! See `doc/sshd.org` for the full operator guide. Key defaults
4
//! (loopback-only bind, pubkey-preferred auth, no-forwarding policy)
5
//! live in this crate's documentation comments on each module.
6

            
7
mod auth;
8
mod eval_channel;
9
mod handler;
10
mod hostkey;
11
mod key_decoder;
12
mod rate_limit;
13
mod tui_transport;
14

            
15
use clap::Parser;
16
use exitfailure::ExitFailure;
17
use log::LevelFilter;
18
use russh::server::{Config, Server};
19
use std::ffi::OsString;
20
use std::net::SocketAddr;
21
use std::path::PathBuf;
22
use std::sync::Arc;
23
use std::time::Duration;
24
use tokio::net::TcpListener;
25

            
26
#[derive(Parser, Debug)]
27
#[command(
28
    name = "nomisync-sshd",
29
    about = "Nomisync SSH TUI daemon",
30
    long_about = "Nomisync SSH TUI daemon.\n\n\
31
        Binds 127.0.0.1:2222 by default; the daemon accepts only \
32
        PTY + shell and rejects exec, subsystem, port-forwarding, and \
33
        X11. See doc/sshd.org for the operator guide (first-run \
34
        host-key setup, adding user SSH keys, external exposure, \
35
        hardened systemd unit)."
36
)]
37
struct Cli {
38
    /// Address:port to bind. Loopback by default — explicit override
39
    /// required for external exposure. Also honours
40
    /// `NOMISYNC_SSHD_BIND` if set.
41
    #[arg(long)]
42
    bind: Option<SocketAddr>,
43

            
44
    /// Path to the Ed25519 host-key file. Generated on first start
45
    /// if missing. Also honours `NOMISYNC_SSHD_HOST_KEY` if set.
46
    #[arg(long)]
47
    host_key: Option<PathBuf>,
48

            
49
    /// Maximum *password* authentication attempts allowed per IP in
50
    /// one window before subsequent attempts are refused.
51
    #[arg(long, default_value_t = 6)]
52
    max_auth_attempts_per_ip: u32,
53

            
54
    /// Maximum *publickey* authentication attempts allowed per IP in
55
    /// one window. Looser than the password bucket because a single
56
    /// ssh client legitimately offers several identities per
57
    /// connection.
58
    #[arg(long, default_value_t = 30)]
59
    max_pubkey_attempts_per_ip: u32,
60

            
61
    /// Password-failures threshold that trips per-account lockout.
62
    #[arg(long, default_value_t = 3)]
63
    lockout_threshold: u32,
64

            
65
    /// How long an account stays locked out, in seconds.
66
    #[arg(long, default_value_t = 900)]
67
    lockout_duration_secs: u64,
68

            
69
    /// Public hostname this daemon answers on. Templated into the
70
    /// post-`ssh-copy-id` ~/.ssh/config snippet sent to the client.
71
    /// Honours `NOMISYNC_SSHD_HOSTNAME` if the flag is omitted.
72
    #[arg(long)]
73
    public_hostname: Option<String>,
74

            
75
    /// Public port this daemon answers on (matches the ingress TCP
76
    /// service). Honours `NOMISYNC_SSHD_PORT`.
77
    #[arg(long)]
78
    public_port: Option<u16>,
79

            
80
    /// Host key fingerprint operators publish out-of-band. Templated
81
    /// into the post-`ssh-copy-id` snippet so the client can verify
82
    /// it on first connect. Honours `NOMISYNC_SSHD_HOST_FINGERPRINT`.
83
    #[arg(long)]
84
    host_fingerprint: Option<String>,
85

            
86
    #[arg(long, default_value = "info")]
87
    loglevel: LevelFilter,
88

            
89
    /// Database connection URL. Defaults to `$DATABASE_URL` if
90
    /// unset; omitted here only because clap won't happily read the
91
    /// env in a default-value expression.
92
    #[arg(short = 'd', long)]
93
    database: Option<String>,
94
}
95

            
96
const DEFAULT_BIND: &str = "127.0.0.1:2222";
97
const DEFAULT_HOST_KEY: &str = "/etc/nomisync/ssh_host_ed25519_key";
98

            
99
1
fn resolve_bind(cli_value: Option<SocketAddr>) -> Result<SocketAddr, std::net::AddrParseError> {
100
1
    pick_bind(cli_value, std::env::var("NOMISYNC_SSHD_BIND").ok())
101
1
}
102

            
103
5
fn pick_bind(
104
5
    cli_value: Option<SocketAddr>,
105
5
    env_value: Option<String>,
106
5
) -> Result<SocketAddr, std::net::AddrParseError> {
107
5
    cli_value.map_or_else(|| env_value.as_deref().unwrap_or(DEFAULT_BIND).parse(), Ok)
108
5
}
109

            
110
1
fn resolve_host_key(cli_value: Option<PathBuf>) -> PathBuf {
111
1
    pick_host_key(cli_value, std::env::var_os("NOMISYNC_SSHD_HOST_KEY"))
112
1
}
113

            
114
4
fn pick_host_key(cli_value: Option<PathBuf>, env_value: Option<OsString>) -> PathBuf {
115
4
    cli_value
116
4
        .or_else(|| env_value.map(PathBuf::from))
117
4
        .unwrap_or_else(|| PathBuf::from(DEFAULT_HOST_KEY))
118
4
}
119

            
120
fn resolve_str_or_env(cli_value: Option<String>, env: &str, fallback: &str) -> String {
121
    cli_value
122
        .or_else(|| std::env::var(env).ok())
123
        .unwrap_or_else(|| fallback.to_string())
124
}
125

            
126
fn resolve_port_or_env(cli_value: Option<u16>, env: &str, bind: SocketAddr) -> u16 {
127
    cli_value
128
        .or_else(|| {
129
            std::env::var(env)
130
                .ok()
131
                .and_then(|raw| raw.parse::<u16>().ok())
132
        })
133
        .unwrap_or_else(|| bind.port())
134
}
135

            
136
#[tokio::main]
137
async fn main() -> Result<(), ExitFailure> {
138
    let cli = Cli::parse();
139
    env_logger::Builder::new()
140
        .filter_level(cli.loglevel)
141
        .target(env_logger::Target::Stderr)
142
        .init();
143

            
144
    cli_core::start_server(cli.database, None).await?;
145

            
146
    let bind = resolve_bind(cli.bind)?;
147
    let host_key_path = resolve_host_key(cli.host_key);
148
    let host_key = hostkey::load_or_generate(host_key_path.as_path())?;
149

            
150
    let limiter = Arc::new(tokio::sync::Mutex::new(rate_limit::RateLimiter::new(
151
        rate_limit::Config {
152
            max_attempts_per_ip: cli.max_auth_attempts_per_ip,
153
            max_pubkey_attempts_per_ip: cli.max_pubkey_attempts_per_ip,
154
            window: Duration::from_mins(1),
155
            account_lockout_threshold: cli.lockout_threshold,
156
            account_lockout_duration: Duration::from_secs(cli.lockout_duration_secs),
157
        },
158
        rate_limit::InstantClock,
159
    )));
160

            
161
    let connect_info = handler::SshConnectInfo {
162
        hostname: resolve_str_or_env(
163
            cli.public_hostname,
164
            "NOMISYNC_SSHD_HOSTNAME",
165
            &bind.ip().to_string(),
166
        ),
167
        port: resolve_port_or_env(cli.public_port, "NOMISYNC_SSHD_PORT", bind),
168
        host_fingerprint: resolve_str_or_env(
169
            cli.host_fingerprint,
170
            "NOMISYNC_SSHD_HOST_FINGERPRINT",
171
            "(not configured)",
172
        ),
173
    };
174

            
175
    let mut state = handler::SshdState {
176
        limiter,
177
        connect_info,
178
    };
179

            
180
    let config = Arc::new(Config {
181
        inactivity_timeout: Some(Duration::from_mins(10)),
182
        auth_rejection_time: Duration::from_secs(2),
183
        keys: vec![host_key],
184
        ..Config::default()
185
    });
186

            
187
    let listener = TcpListener::bind(bind).await?;
188
    log::info!("nomisync-sshd listening on {bind}");
189
    if !bind.ip().is_loopback() {
190
        log::warn!(
191
            "bind {bind} is not loopback — this daemon has no TLS, no \
192
             brute-force protection beyond per-IP rate limiting; expose \
193
             deliberately."
194
        );
195
    }
196

            
197
    state.run_on_socket(config, &listener).await?;
198
    Ok(())
199
}
200

            
201
#[cfg(test)]
202
mod eval_subsystem_tests;
203
#[cfg(test)]
204
mod tests;