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::net::SocketAddr;
20
use std::path::PathBuf;
21
use std::sync::Arc;
22
use std::time::Duration;
23
use tokio::net::TcpListener;
24

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

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

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

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

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

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

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

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

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

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

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

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

            
98
2
fn resolve_bind(cli_value: Option<SocketAddr>) -> Result<SocketAddr, std::net::AddrParseError> {
99
2
    if let Some(v) = cli_value {
100
1
        return Ok(v);
101
1
    }
102
1
    let raw = std::env::var("NOMISYNC_SSHD_BIND").unwrap_or_else(|_| DEFAULT_BIND.to_string());
103
1
    raw.parse()
104
2
}
105

            
106
1
fn resolve_host_key(cli_value: Option<PathBuf>) -> PathBuf {
107
1
    cli_value.unwrap_or_else(|| {
108
1
        std::env::var("NOMISYNC_SSHD_HOST_KEY")
109
1
            .map_or_else(|_| PathBuf::from(DEFAULT_HOST_KEY), PathBuf::from)
110
1
    })
111
1
}
112

            
113
fn resolve_str_or_env(cli_value: Option<String>, env: &str, fallback: &str) -> String {
114
    cli_value
115
        .or_else(|| std::env::var(env).ok())
116
        .unwrap_or_else(|| fallback.to_string())
117
}
118

            
119
fn resolve_port_or_env(cli_value: Option<u16>, env: &str, bind: SocketAddr) -> u16 {
120
    cli_value
121
        .or_else(|| {
122
            std::env::var(env)
123
                .ok()
124
                .and_then(|raw| raw.parse::<u16>().ok())
125
        })
126
        .unwrap_or_else(|| bind.port())
127
}
128

            
129
#[tokio::main]
130
async fn main() -> Result<(), ExitFailure> {
131
    let cli = Cli::parse();
132
    env_logger::Builder::new()
133
        .filter_level(cli.loglevel)
134
        .target(env_logger::Target::Stderr)
135
        .init();
136

            
137
    cli_core::start_server(cli.database, None).await?;
138

            
139
    let bind = resolve_bind(cli.bind)?;
140
    let host_key_path = resolve_host_key(cli.host_key);
141
    let host_key = hostkey::load_or_generate(host_key_path.as_path())?;
142

            
143
    let limiter = Arc::new(tokio::sync::Mutex::new(rate_limit::RateLimiter::new(
144
        rate_limit::Config {
145
            max_attempts_per_ip: cli.max_auth_attempts_per_ip,
146
            max_pubkey_attempts_per_ip: cli.max_pubkey_attempts_per_ip,
147
            window: Duration::from_mins(1),
148
            account_lockout_threshold: cli.lockout_threshold,
149
            account_lockout_duration: Duration::from_secs(cli.lockout_duration_secs),
150
        },
151
        rate_limit::InstantClock,
152
    )));
153

            
154
    let connect_info = handler::SshConnectInfo {
155
        hostname: resolve_str_or_env(
156
            cli.public_hostname,
157
            "NOMISYNC_SSHD_HOSTNAME",
158
            &bind.ip().to_string(),
159
        ),
160
        port: resolve_port_or_env(cli.public_port, "NOMISYNC_SSHD_PORT", bind),
161
        host_fingerprint: resolve_str_or_env(
162
            cli.host_fingerprint,
163
            "NOMISYNC_SSHD_HOST_FINGERPRINT",
164
            "(not configured)",
165
        ),
166
    };
167

            
168
    let mut state = handler::SshdState {
169
        limiter,
170
        connect_info,
171
    };
172

            
173
    let config = Arc::new(Config {
174
        inactivity_timeout: Some(Duration::from_mins(10)),
175
        auth_rejection_time: Duration::from_secs(2),
176
        keys: vec![host_key],
177
        ..Config::default()
178
    });
179

            
180
    let listener = TcpListener::bind(bind).await?;
181
    log::info!("nomisync-sshd listening on {bind}");
182
    if !bind.ip().is_loopback() {
183
        log::warn!(
184
            "bind {bind} is not loopback — this daemon has no TLS, no \
185
             brute-force protection beyond per-IP rate limiting; expose \
186
             deliberately."
187
        );
188
    }
189

            
190
    state.run_on_socket(config, &listener).await?;
191
    Ok(())
192
}
193

            
194
#[cfg(test)]
195
mod tests {
196
    use super::*;
197

            
198
    #[test]
199
1
    fn cli_parses_defaults() {
200
1
        let cli = Cli::try_parse_from(["nomisync-sshd"]).expect("defaults should parse");
201
1
        assert!(
202
1
            cli.bind.is_none(),
203
            "bind unset on CLI falls through to env/default"
204
        );
205
1
        assert!(cli.host_key.is_none());
206
1
        assert_eq!(cli.max_auth_attempts_per_ip, 6);
207
1
        assert_eq!(cli.max_pubkey_attempts_per_ip, 30);
208
1
        assert_eq!(cli.lockout_threshold, 3);
209
1
        assert_eq!(cli.lockout_duration_secs, 900);
210
1
        assert!(cli.public_hostname.is_none());
211
1
        assert!(cli.public_port.is_none());
212
1
        assert!(cli.host_fingerprint.is_none());
213
1
    }
214

            
215
    #[test]
216
1
    fn cli_accepts_explicit_bind_and_key_path() {
217
1
        let cli = Cli::try_parse_from([
218
1
            "nomisync-sshd",
219
1
            "--bind",
220
1
            "0.0.0.0:22",
221
1
            "--host-key",
222
1
            "/tmp/k",
223
1
        ])
224
1
        .unwrap();
225
1
        assert_eq!(cli.bind.unwrap().to_string(), "0.0.0.0:22");
226
1
        assert_eq!(cli.host_key.unwrap().to_string_lossy(), "/tmp/k");
227
1
    }
228

            
229
    #[test]
230
1
    fn resolve_bind_uses_default_when_env_and_cli_absent() {
231
        // Safety: tests run single-threaded so this env manipulation
232
        // does not race other tests in this module.
233
1
        unsafe {
234
1
            std::env::remove_var("NOMISYNC_SSHD_BIND");
235
1
        }
236
1
        let resolved = resolve_bind(None).unwrap();
237
1
        assert_eq!(resolved.to_string(), DEFAULT_BIND);
238
1
    }
239

            
240
    #[test]
241
1
    fn resolve_bind_prefers_cli_over_env() {
242
1
        let explicit: SocketAddr = "10.0.0.1:22".parse().unwrap();
243
1
        unsafe {
244
1
            std::env::set_var("NOMISYNC_SSHD_BIND", "127.0.0.1:9999");
245
1
        }
246
1
        let resolved = resolve_bind(Some(explicit)).unwrap();
247
1
        assert_eq!(resolved, explicit);
248
1
        unsafe {
249
1
            std::env::remove_var("NOMISYNC_SSHD_BIND");
250
1
        }
251
1
    }
252

            
253
    #[test]
254
1
    fn resolve_host_key_uses_default_when_env_and_cli_absent() {
255
1
        unsafe {
256
1
            std::env::remove_var("NOMISYNC_SSHD_HOST_KEY");
257
1
        }
258
1
        let resolved = resolve_host_key(None);
259
1
        assert_eq!(resolved.to_string_lossy(), DEFAULT_HOST_KEY);
260
1
    }
261
}