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 handler;
9
mod hostkey;
10
mod key_decoder;
11
mod rate_limit;
12
mod tui_transport;
13

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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