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 runs the live TUI: it builds an
9
//! `SshTransport`, drives `tui::run_loop`, and attaches a per-session
10
//! DB-backed `ConsoleEval` so the Console tab evaluates nomiscript
11
//! against the authenticated user's `rpc::Session`.
12

            
13
use crate::auth::{Outcome, authenticate_password, authenticate_publickey};
14
use crate::eval_channel::{EvalChannelState, RusshSink};
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 rpc::ScriptCtx;
20
use russh::keys::{HashAlg, PublicKey};
21
use russh::server::{Auth, Handler, Server, Session};
22
use russh::{Channel, ChannelId, Pty};
23
use server::command::CmdResult;
24
use server::command::ssh_key::AddSshKey;
25
use sqlx::types::Uuid;
26
use std::collections::HashMap;
27
use std::net::SocketAddr;
28
use std::sync::Arc;
29
use tokio::sync::Mutex;
30
use tokio::sync::mpsc::{UnboundedSender, unbounded_channel};
31
use tui::transport::{RawEvent, Transport};
32
use tui::widgets::EditMode;
33

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

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

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

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

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

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

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

            
93
/// Per-connection handler. Odd name because `Session` is taken by
94
/// russh.
95
pub struct Session_ {
96
    peer: Option<SocketAddr>,
97
    limiter: Arc<Mutex<RateLimiter<InstantClock>>>,
98
    connect_info: SshConnectInfo,
99
    user_id: Option<Uuid>,
100
    user_name: Option<String>,
101
    auth_method: Option<AuthMethod>,
102
    /// Buffers for `exec` channels that ssh-copy-id is feeding pubkey
103
    /// lines through. Drained on `channel_eof`.
104
    pubkey_uploads: HashMap<ChannelId, Vec<u8>>,
105
    /// Per-channel TUI bridge state. Disjoint from `pubkey_uploads`:
106
    /// a channel is either an ssh-copy-id upload or a TUI session,
107
    /// never both.
108
    tui_channels: HashMap<ChannelId, TuiChannelState>,
109
    /// Per-channel state for the `nomisync-eval` subsystem. Disjoint
110
    /// from `pubkey_uploads` and `tui_channels`: a channel commits
111
    /// to one channel-type on its first request. Created in
112
    /// `subsystem_request`, drained in `data`, dropped in
113
    /// `channel_eof` / `channel_close`.
114
    eval_channels: HashMap<ChannelId, EvalChannelState>,
115
}
116

            
117
impl Session_ {
118
    fn peer_ip(&self) -> std::net::IpAddr {
119
        self.peer
120
            .map_or(std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED), |s| {
121
                s.ip()
122
            })
123
    }
124

            
125
    fn refuse_request(&self, kind: &str) {
126
        log::warn!(
127
            "refused request kind={kind} peer={:?} user_id={:?}",
128
            self.peer,
129
            self.user_id
130
        );
131
    }
132

            
133
    /// True only for the ssh-copy-id bootstrap window: the session
134
    /// authenticated with a password AND the user does not yet have
135
    /// any registered key.
136
    async fn is_bootstrap_upload_allowed(&self) -> bool {
137
        if self.auth_method != Some(AuthMethod::Password) {
138
            return false;
139
        }
140
        let Some(uid) = self.user_id else {
141
            return false;
142
        };
143
        matches!(
144
            server::command::ssh_key::UserHasSshKey::new()
145
                .user_id(uid)
146
                .run()
147
                .await,
148
            Ok(Some(CmdResult::Bool(false)))
149
        )
150
    }
151

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

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

            
201
        if accepted == 0 {
202
            let detail = if errors.is_empty() {
203
                "no usable pubkey lines".to_string()
204
            } else {
205
                errors.join("; ")
206
            };
207
            return format!("ssh-copy-id: no key accepted ({detail}).\n");
208
        }
209

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

            
234
impl Handler for Session_ {
235
    type Error = russh::Error;
236

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

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

            
272
    async fn channel_open_session(
273
        &mut self,
274
        _channel: Channel<russh::server::Msg>,
275
        _session: &mut Session,
276
    ) -> Result<bool, Self::Error> {
277
        Ok(true)
278
    }
279

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

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

            
322
        log::info!(
323
            "shell_request accepted user_id={uid} peer={:?} term={:?} cols={} rows={}",
324
            self.peer,
325
            state.term,
326
            state.cols,
327
            state.rows,
328
        );
329

            
330
        let (input_tx, input_rx) = unbounded_channel::<RawEvent>();
331
        state.input_tx = Some(input_tx);
332
        let term = state.term.clone();
333
        let cols = state.cols;
334
        let rows = state.rows;
335

            
336
        let handle = session.handle();
337
        let runtime = tokio::runtime::Handle::current();
338
        let runtime_for_spawn = runtime.clone();
339
        let runtime_for_console = runtime.clone();
340

            
341
        tokio::task::spawn_blocking(move || {
342
            match SshTransport::new(
343
                handle,
344
                channel,
345
                runtime_for_spawn,
346
                input_rx,
347
                term,
348
                cols,
349
                rows,
350
            ) {
351
                Ok(mut transport) => {
352
                    let mut app = tui::App::new(uid, EditMode::Emacs);
353
                    match tui::ConsoleEval::spawn(&runtime_for_console, uid) {
354
                        Ok(eval) => app.attach_console(eval),
355
                        Err(err) => {
356
                            log::warn!("console eval unavailable for user_id={uid}: {err}");
357
                            app.set_status(format!("console unavailable: {err}"));
358
                        }
359
                    }
360
                    if let Err(err) = tui::run_loop(&mut transport, &mut app) {
361
                        log::info!("tui session ended: {err}");
362
                    }
363
                    if let Err(err) = transport.finish() {
364
                        log::warn!("tui transport finish failed: {err}");
365
                    }
366
                }
367
                Err(err) => {
368
                    log::error!("tui transport build failed: {err}");
369
                }
370
            }
371
        });
372

            
373
        Ok(())
374
    }
375

            
376
    async fn window_change_request(
377
        &mut self,
378
        channel: ChannelId,
379
        col_width: u32,
380
        row_height: u32,
381
        _pix_width: u32,
382
        _pix_height: u32,
383
        _session: &mut Session,
384
    ) -> Result<(), Self::Error> {
385
        if let Some(state) = self.tui_channels.get_mut(&channel) {
386
            let cols = u16::try_from(col_width).unwrap_or(state.cols);
387
            let rows = u16::try_from(row_height).unwrap_or(state.rows);
388
            state.cols = cols;
389
            state.rows = rows;
390
            if let Some(tx) = &state.input_tx {
391
                let _ = tx.send(RawEvent::Resize(cols, rows));
392
            }
393
        }
394
        Ok(())
395
    }
396

            
397
    async fn exec_request(
398
        &mut self,
399
        channel: ChannelId,
400
        _data: &[u8],
401
        _session: &mut Session,
402
    ) -> Result<(), Self::Error> {
403
        // The only `exec` we accept is the one ssh-copy-id opens to
404
        // append to authorized_keys. Gate is "session authenticated
405
        // via password AND user has zero keys on file" — which is
406
        // exactly the bootstrap window. The requested command string
407
        // is ignored: stdin is consumed verbatim as authorized_keys
408
        // content, deferred to channel_eof.
409
        if !self.is_bootstrap_upload_allowed().await {
410
            self.refuse_request("exec");
411
            return Err(russh::Error::Disconnect);
412
        }
413
        log::info!(
414
            "exec_request accepted as ssh-copy-id upload user_id={:?} peer={:?}",
415
            self.user_id,
416
            self.peer
417
        );
418
        self.pubkey_uploads.entry(channel).or_default();
419
        Ok(())
420
    }
421

            
422
    async fn data(
423
        &mut self,
424
        channel: ChannelId,
425
        data: &[u8],
426
        session: &mut Session,
427
    ) -> Result<(), Self::Error> {
428
        if let Some(buf) = self.pubkey_uploads.get_mut(&channel) {
429
            buf.extend_from_slice(data);
430
            return Ok(());
431
        }
432
        if let Some(state) = self.tui_channels.get_mut(&channel) {
433
            let events = state.decoder.feed(data);
434
            if let Some(tx) = &state.input_tx {
435
                for ev in events {
436
                    if tx.send(ev).is_err() {
437
                        // run_loop has exited — drop subsequent input
438
                        // until channel_close cleans up.
439
                        break;
440
                    }
441
                }
442
            }
443
            return Ok(());
444
        }
445
        if let Some(state) = self.eval_channels.get_mut(&channel)
446
            && let Err(err) = state.feed(data)
447
        {
448
            // Non-UTF8 on an s-expr channel — surface as a parse
449
            // error envelope and drop the connection.
450
            let msg = format!("(:id 0 :error (:code parse :message \"{err}\"))\n");
451
            session.data(channel, msg.into_bytes())?;
452
            session.close(channel)?;
453
            self.eval_channels.remove(&channel);
454
            return Ok(());
455
        }
456
        // Responses are delivered out-of-band by the channel's
457
        // worker via the russh server Handle (see `RusshSink`).
458
        // data() returns immediately so future bytes — including
459
        // ETX (C-g) — get processed without waiting on the
460
        // current eval.
461
        Ok(())
462
    }
463

            
464
    async fn channel_eof(
465
        &mut self,
466
        channel: ChannelId,
467
        session: &mut Session,
468
    ) -> Result<(), Self::Error> {
469
        if let Some(buf) = self.pubkey_uploads.remove(&channel) {
470
            let banner = self.process_pubkey_upload(&buf).await;
471
            session.data(channel, banner.into_bytes())?;
472
            session.close(channel)?;
473
        } else {
474
            self.tui_channels.remove(&channel);
475
            self.eval_channels.remove(&channel);
476
        }
477
        Ok(())
478
    }
479

            
480
    async fn channel_close(
481
        &mut self,
482
        channel: ChannelId,
483
        _session: &mut Session,
484
    ) -> Result<(), Self::Error> {
485
        // Either path is harmless if the entry is already gone.
486
        self.pubkey_uploads.remove(&channel);
487
        self.tui_channels.remove(&channel);
488
        self.eval_channels.remove(&channel);
489
        Ok(())
490
    }
491

            
492
    async fn subsystem_request(
493
        &mut self,
494
        channel: ChannelId,
495
        name: &str,
496
        session: &mut Session,
497
    ) -> Result<(), Self::Error> {
498
        if name != "nomisync-eval" {
499
            self.refuse_request("subsystem");
500
            return Err(russh::Error::Disconnect);
501
        }
502
        let Some(user_id) = self.user_id else {
503
            log::warn!("nomisync-eval requested before authentication; refusing");
504
            self.refuse_request("subsystem");
505
            return Err(russh::Error::Disconnect);
506
        };
507
        let sink = Box::new(RusshSink::new(session.handle(), channel));
508
        match EvalChannelState::new(ScriptCtx::new(user_id), sink) {
509
            Ok(state) => {
510
                self.eval_channels.insert(channel, state);
511
                Ok(())
512
            }
513
            Err(err) => {
514
                log::error!("nomisync-eval session init failed: {err}");
515
                Err(russh::Error::Disconnect)
516
            }
517
        }
518
    }
519

            
520
    async fn channel_open_direct_tcpip(
521
        &mut self,
522
        _channel: Channel<russh::server::Msg>,
523
        _host_to_connect: &str,
524
        _port_to_connect: u32,
525
        _originator_address: &str,
526
        _originator_port: u32,
527
        _session: &mut Session,
528
    ) -> Result<bool, Self::Error> {
529
        self.refuse_request("direct-tcpip");
530
        Ok(false)
531
    }
532

            
533
    async fn tcpip_forward(
534
        &mut self,
535
        _address: &str,
536
        _port: &mut u32,
537
        _session: &mut Session,
538
    ) -> Result<bool, Self::Error> {
539
        self.refuse_request("tcpip-forward");
540
        Ok(false)
541
    }
542
}