1
//! russh `Handler` + `Server` glue.
2
//!
3
//! Accepts `publickey` and `password` (rate-limited) auth, accepts
4
//! `pty-req` + `shell`, and refuses everything else the threat model
5
//! excludes (`exec`, `subsystem`, `direct-tcpip`, `x11-req`,
6
//! `auth-agent-req`).
7
//!
8
//! The `shell_request` launch site currently writes a placeholder
9
//! banner into the channel — the full `SshTransport` → `tui::run_loop`
10
//! bridge lands in the follow-up commit that owns `transport.rs`.
11
//! The auth + request-filtering surface is live today so operators
12
//! can verify the daemon refuses the things it ought to.
13

            
14
use crate::auth::{Outcome, authenticate_password, authenticate_publickey};
15
use crate::key_decoder::KeyDecoder;
16
use crate::rate_limit::{InstantClock, RateLimiter};
17
use crate::tui_transport::SshTransport;
18
use cli_core::ssh_keys::parse_authorized_keys_line;
19
use russh::keys::{HashAlg, PublicKey};
20
use russh::server::{Auth, Handler, Server, Session};
21
use russh::{Channel, ChannelId, Pty};
22
use server::command::CmdResult;
23
use server::command::ssh_key::AddSshKey;
24
use sqlx::types::Uuid;
25
use std::collections::HashMap;
26
use std::net::SocketAddr;
27
use std::sync::Arc;
28
use tokio::sync::Mutex;
29
use tokio::sync::mpsc::{UnboundedSender, unbounded_channel};
30
use tui::transport::{RawEvent, Transport};
31
use tui::widgets::EditMode;
32

            
33
/// Banner template used after a successful ssh-copy-id upload. The
34
/// daemon substitutes `{hostname}`, `{port}`, `{user}`, `{fingerprint}`
35
/// at runtime.
36
#[derive(Clone)]
37
pub struct SshConnectInfo {
38
    pub hostname: String,
39
    pub port: u16,
40
    pub host_fingerprint: String,
41
}
42

            
43
/// Authentication method used by the active session. Phase C only
44
/// permits the `exec` channel that ssh-copy-id opens when the
45
/// caller authenticated via password.
46
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47
pub enum AuthMethod {
48
    Password,
49
    Publickey,
50
}
51

            
52
/// Shared state across every accepted session.
53
pub struct SshdState {
54
    pub limiter: Arc<Mutex<RateLimiter<InstantClock>>>,
55
    pub connect_info: SshConnectInfo,
56
}
57

            
58
impl Server for SshdState {
59
    type Handler = Session_;
60

            
61
    fn new_client(&mut self, peer_addr: Option<SocketAddr>) -> Self::Handler {
62
        Session_ {
63
            peer: peer_addr,
64
            limiter: Arc::clone(&self.limiter),
65
            connect_info: self.connect_info.clone(),
66
            user_id: None,
67
            user_name: None,
68
            auth_method: None,
69
            pubkey_uploads: HashMap::new(),
70
            tui_channels: HashMap::new(),
71
        }
72
    }
73

            
74
    fn handle_session_error(&mut self, error: <Self::Handler as Handler>::Error) {
75
        log::warn!("session error: {error:?}");
76
    }
77
}
78

            
79
/// Per-channel state for an active TUI session — the keystroke
80
/// decoder, the input mpsc sender, and the PTY geometry the client
81
/// announced. Created in `pty_request`, completed in `shell_request`,
82
/// and dropped in `channel_close`.
83
struct TuiChannelState {
84
    decoder: KeyDecoder,
85
    input_tx: Option<UnboundedSender<RawEvent>>,
86
    term: Option<String>,
87
    cols: u16,
88
    rows: u16,
89
}
90

            
91
/// Per-connection handler. Odd name because `Session` is taken by
92
/// russh.
93
pub struct Session_ {
94
    peer: Option<SocketAddr>,
95
    limiter: Arc<Mutex<RateLimiter<InstantClock>>>,
96
    connect_info: SshConnectInfo,
97
    user_id: Option<Uuid>,
98
    user_name: Option<String>,
99
    auth_method: Option<AuthMethod>,
100
    /// Buffers for `exec` channels that ssh-copy-id is feeding pubkey
101
    /// lines through. Drained on `channel_eof`.
102
    pubkey_uploads: HashMap<ChannelId, Vec<u8>>,
103
    /// Per-channel TUI bridge state. Disjoint from `pubkey_uploads`:
104
    /// a channel is either an ssh-copy-id upload or a TUI session,
105
    /// never both.
106
    tui_channels: HashMap<ChannelId, TuiChannelState>,
107
}
108

            
109
impl Session_ {
110
    fn peer_ip(&self) -> std::net::IpAddr {
111
        self.peer
112
            .map_or(std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED), |s| {
113
                s.ip()
114
            })
115
    }
116

            
117
    fn refuse_request(&self, kind: &str) {
118
        log::warn!(
119
            "refused request kind={kind} peer={:?} user_id={:?}",
120
            self.peer,
121
            self.user_id
122
        );
123
    }
124

            
125
    /// True only for the ssh-copy-id bootstrap window: the session
126
    /// authenticated with a password AND the user does not yet have
127
    /// any registered key.
128
    async fn is_bootstrap_upload_allowed(&self) -> bool {
129
        if self.auth_method != Some(AuthMethod::Password) {
130
            return false;
131
        }
132
        let Some(uid) = self.user_id else {
133
            return false;
134
        };
135
        match server::command::ssh_key::UserHasSshKey::new()
136
            .user_id(uid)
137
            .run()
138
            .await
139
        {
140
            Ok(Some(CmdResult::Bool(false))) => true,
141
            _ => false,
142
        }
143
    }
144

            
145
    /// Parse the bytes ssh-copy-id wrote to stdin (one or more
146
    /// `authorized_keys` lines) and register each line via
147
    /// [`AddSshKey`]. Returns the operator-facing banner that the
148
    /// channel writes back before closing.
149
    async fn process_pubkey_upload(&self, buf: &[u8]) -> String {
150
        let Some(uid) = self.user_id else {
151
            return "ssh-copy-id: no authenticated user; aborting.\n".to_string();
152
        };
153
        let raw = match std::str::from_utf8(buf) {
154
            Ok(s) => s,
155
            Err(_) => {
156
                return "ssh-copy-id: pubkey payload is not UTF-8.\n".to_string();
157
            }
158
        };
159

            
160
        let mut accepted = 0usize;
161
        let mut errors: Vec<String> = Vec::new();
162
        for line in raw.lines() {
163
            let trimmed = line.trim();
164
            if trimmed.is_empty() || trimmed.starts_with('#') {
165
                continue;
166
            }
167
            let parsed = match parse_authorized_keys_line(trimmed) {
168
                Ok(p) => p,
169
                Err(e) => {
170
                    errors.push(format!("parse: {e}"));
171
                    continue;
172
                }
173
            };
174
            let annotation = if parsed.comment.is_empty() {
175
                "ssh-copy-id".to_string()
176
            } else {
177
                format!("ssh-copy-id ({})", parsed.comment)
178
            };
179
            match AddSshKey::new()
180
                .user_id(uid)
181
                .key_type(parsed.key_type)
182
                .key_blob(parsed.key_blob)
183
                .fingerprint(parsed.fingerprint)
184
                .annotation(annotation)
185
                .run()
186
                .await
187
            {
188
                Ok(Some(CmdResult::Uuid(_))) => accepted += 1,
189
                Ok(_) => errors.push("server returned no key id".to_string()),
190
                Err(e) => errors.push(format!("{e:?}")),
191
            }
192
        }
193

            
194
        if accepted == 0 {
195
            let detail = if errors.is_empty() {
196
                "no usable pubkey lines".to_string()
197
            } else {
198
                errors.join("; ")
199
            };
200
            return format!("ssh-copy-id: no key accepted ({detail}).\n");
201
        }
202

            
203
        let user_label = self.user_name.as_deref().unwrap_or("YOUR_LOGIN");
204
        format!(
205
            "Identity added; password auth disabled.\r\n\
206
             \r\n\
207
             Suggested ~/.ssh/config entry:\r\n\
208
             \r\n\
209
             Host nomisync\r\n\
210
             \x20   HostName       {hostname}\r\n\
211
             \x20   Port           {port}\r\n\
212
             \x20   User           {user}\r\n\
213
             \x20   IdentitiesOnly yes\r\n\
214
             \x20   # IdentityAgent ${{SSH_AUTH_SOCK}}    # uncomment if using gpg-agent\r\n\
215
             \x20   # IdentityFile  ~/.ssh/id_ed25519     # or point at your private key\r\n\
216
             \r\n\
217
             Host key fingerprint (verify on first connect):\r\n\
218
             \x20   {fp}\r\n",
219
            hostname = self.connect_info.hostname,
220
            port = self.connect_info.port,
221
            user = user_label,
222
            fp = self.connect_info.host_fingerprint,
223
        )
224
    }
225
}
226

            
227
impl Handler for Session_ {
228
    type Error = russh::Error;
229

            
230
    async fn auth_publickey(
231
        &mut self,
232
        user: &str,
233
        public_key: &PublicKey,
234
    ) -> Result<Auth, Self::Error> {
235
        let fingerprint = public_key.fingerprint(HashAlg::Sha256).to_string();
236
        let outcome = authenticate_publickey(user, &fingerprint, self.peer_ip(), &self.limiter)
237
            .await
238
            .map_err(|_| russh::Error::Disconnect)?;
239
        match outcome {
240
            Outcome::Accept(uid) => {
241
                self.user_id = Some(uid);
242
                self.user_name = Some(user.to_string());
243
                self.auth_method = Some(AuthMethod::Publickey);
244
                Ok(Auth::Accept)
245
            }
246
            Outcome::Reject => Ok(Auth::reject()),
247
        }
248
    }
249

            
250
    async fn auth_password(&mut self, user: &str, password: &str) -> Result<Auth, Self::Error> {
251
        let outcome = authenticate_password(user, password, self.peer_ip(), &self.limiter)
252
            .await
253
            .map_err(|_| russh::Error::Disconnect)?;
254
        match outcome {
255
            Outcome::Accept(uid) => {
256
                self.user_id = Some(uid);
257
                self.user_name = Some(user.to_string());
258
                self.auth_method = Some(AuthMethod::Password);
259
                Ok(Auth::Accept)
260
            }
261
            Outcome::Reject => Ok(Auth::reject()),
262
        }
263
    }
264

            
265
    async fn channel_open_session(
266
        &mut self,
267
        _channel: Channel<russh::server::Msg>,
268
        _session: &mut Session,
269
    ) -> Result<bool, Self::Error> {
270
        Ok(true)
271
    }
272

            
273
    #[allow(clippy::too_many_arguments)]
274
    async fn pty_request(
275
        &mut self,
276
        channel: ChannelId,
277
        term: &str,
278
        col_width: u32,
279
        row_height: u32,
280
        _pix_width: u32,
281
        _pix_height: u32,
282
        _modes: &[(Pty, u32)],
283
        _session: &mut Session,
284
    ) -> Result<(), Self::Error> {
285
        let cols = u16::try_from(col_width).unwrap_or(80);
286
        let rows = u16::try_from(row_height).unwrap_or(24);
287
        self.tui_channels.insert(
288
            channel,
289
            TuiChannelState {
290
                decoder: KeyDecoder::new(),
291
                input_tx: None,
292
                term: Some(term.to_string()),
293
                cols,
294
                rows,
295
            },
296
        );
297
        Ok(())
298
    }
299

            
300
    async fn shell_request(
301
        &mut self,
302
        channel: ChannelId,
303
        session: &mut Session,
304
    ) -> Result<(), Self::Error> {
305
        let Some(uid) = self.user_id else {
306
            log::warn!("shell_request without authenticated user; rejecting");
307
            session.close(channel)?;
308
            return Err(russh::Error::Disconnect);
309
        };
310
        let Some(state) = self.tui_channels.get_mut(&channel) else {
311
            log::warn!("shell_request without prior pty-req on channel {channel:?}; rejecting");
312
            session.close(channel)?;
313
            return Err(russh::Error::Disconnect);
314
        };
315

            
316
        log::info!(
317
            "shell_request accepted user_id={uid} peer={:?} term={:?} cols={} rows={}",
318
            self.peer,
319
            state.term,
320
            state.cols,
321
            state.rows,
322
        );
323

            
324
        let (input_tx, input_rx) = unbounded_channel::<RawEvent>();
325
        state.input_tx = Some(input_tx);
326
        let term = state.term.clone();
327
        let cols = state.cols;
328
        let rows = state.rows;
329

            
330
        let handle = session.handle();
331
        let runtime = tokio::runtime::Handle::current();
332
        let runtime_for_spawn = runtime.clone();
333

            
334
        tokio::task::spawn_blocking(move || {
335
            match SshTransport::new(
336
                handle,
337
                channel,
338
                runtime_for_spawn,
339
                input_rx,
340
                term,
341
                cols,
342
                rows,
343
            ) {
344
                Ok(mut transport) => {
345
                    let mut app = tui::App::new(uid, EditMode::Emacs);
346
                    if let Err(err) = tui::run_loop(&mut transport, &mut app) {
347
                        log::info!("tui session ended: {err}");
348
                    }
349
                    if let Err(err) = transport.finish() {
350
                        log::warn!("tui transport finish failed: {err}");
351
                    }
352
                }
353
                Err(err) => {
354
                    log::error!("tui transport build failed: {err}");
355
                }
356
            }
357
        });
358

            
359
        Ok(())
360
    }
361

            
362
    async fn window_change_request(
363
        &mut self,
364
        channel: ChannelId,
365
        col_width: u32,
366
        row_height: u32,
367
        _pix_width: u32,
368
        _pix_height: u32,
369
        _session: &mut Session,
370
    ) -> Result<(), Self::Error> {
371
        if let Some(state) = self.tui_channels.get_mut(&channel) {
372
            let cols = u16::try_from(col_width).unwrap_or(state.cols);
373
            let rows = u16::try_from(row_height).unwrap_or(state.rows);
374
            state.cols = cols;
375
            state.rows = rows;
376
            if let Some(tx) = &state.input_tx {
377
                let _ = tx.send(RawEvent::Resize(cols, rows));
378
            }
379
        }
380
        Ok(())
381
    }
382

            
383
    async fn exec_request(
384
        &mut self,
385
        channel: ChannelId,
386
        _data: &[u8],
387
        _session: &mut Session,
388
    ) -> Result<(), Self::Error> {
389
        // The only `exec` we accept is the one ssh-copy-id opens to
390
        // append to authorized_keys. Gate is "session authenticated
391
        // via password AND user has zero keys on file" — which is
392
        // exactly the bootstrap window. The requested command string
393
        // is ignored: stdin is consumed verbatim as authorized_keys
394
        // content, deferred to channel_eof.
395
        if !self.is_bootstrap_upload_allowed().await {
396
            self.refuse_request("exec");
397
            return Err(russh::Error::Disconnect);
398
        }
399
        log::info!(
400
            "exec_request accepted as ssh-copy-id upload user_id={:?} peer={:?}",
401
            self.user_id,
402
            self.peer
403
        );
404
        self.pubkey_uploads.entry(channel).or_default();
405
        Ok(())
406
    }
407

            
408
    async fn data(
409
        &mut self,
410
        channel: ChannelId,
411
        data: &[u8],
412
        _session: &mut Session,
413
    ) -> Result<(), Self::Error> {
414
        if let Some(buf) = self.pubkey_uploads.get_mut(&channel) {
415
            buf.extend_from_slice(data);
416
            return Ok(());
417
        }
418
        if let Some(state) = self.tui_channels.get_mut(&channel) {
419
            let events = state.decoder.feed(data);
420
            if let Some(tx) = &state.input_tx {
421
                for ev in events {
422
                    if tx.send(ev).is_err() {
423
                        // run_loop has exited — drop subsequent input
424
                        // until channel_close cleans up.
425
                        break;
426
                    }
427
                }
428
            }
429
        }
430
        Ok(())
431
    }
432

            
433
    async fn channel_eof(
434
        &mut self,
435
        channel: ChannelId,
436
        session: &mut Session,
437
    ) -> Result<(), Self::Error> {
438
        if let Some(buf) = self.pubkey_uploads.remove(&channel) {
439
            let banner = self.process_pubkey_upload(&buf).await;
440
            session.data(channel, banner.into_bytes())?;
441
            session.close(channel)?;
442
        } else {
443
            self.tui_channels.remove(&channel);
444
        }
445
        Ok(())
446
    }
447

            
448
    async fn channel_close(
449
        &mut self,
450
        channel: ChannelId,
451
        _session: &mut Session,
452
    ) -> Result<(), Self::Error> {
453
        // Either path is harmless if the entry is already gone.
454
        self.pubkey_uploads.remove(&channel);
455
        self.tui_channels.remove(&channel);
456
        Ok(())
457
    }
458

            
459
    async fn subsystem_request(
460
        &mut self,
461
        _channel: ChannelId,
462
        _name: &str,
463
        _session: &mut Session,
464
    ) -> Result<(), Self::Error> {
465
        self.refuse_request("subsystem");
466
        Err(russh::Error::Disconnect)
467
    }
468

            
469
    async fn channel_open_direct_tcpip(
470
        &mut self,
471
        _channel: Channel<russh::server::Msg>,
472
        _host_to_connect: &str,
473
        _port_to_connect: u32,
474
        _originator_address: &str,
475
        _originator_port: u32,
476
        _session: &mut Session,
477
    ) -> Result<bool, Self::Error> {
478
        self.refuse_request("direct-tcpip");
479
        Ok(false)
480
    }
481

            
482
    async fn tcpip_forward(
483
        &mut self,
484
        _address: &str,
485
        _port: &mut u32,
486
        _session: &mut Session,
487
    ) -> Result<bool, Self::Error> {
488
        self.refuse_request("tcpip-forward");
489
        Ok(false)
490
    }
491
}