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, ChannelOpenHandle, Handler, Server, Session};
22
use russh::{Channel, ChannelId, ChannelOpenFailure, 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
        reply: ChannelOpenHandle,
276
        _session: &mut Session,
277
    ) -> Result<(), Self::Error> {
278
        reply.accept().await;
279
        Ok(())
280
    }
281

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

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

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

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

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

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

            
375
        Ok(())
376
    }
377

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

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

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

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

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

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

            
522
    async fn channel_open_direct_tcpip(
523
        &mut self,
524
        _channel: Channel<russh::server::Msg>,
525
        _host_to_connect: &str,
526
        _port_to_connect: u32,
527
        _originator_address: &str,
528
        _originator_port: u32,
529
        reply: ChannelOpenHandle,
530
        _session: &mut Session,
531
    ) -> Result<(), Self::Error> {
532
        self.refuse_request("direct-tcpip");
533
        reply
534
            .reject(ChannelOpenFailure::AdministrativelyProhibited)
535
            .await;
536
        Ok(())
537
    }
538

            
539
    async fn tcpip_forward(
540
        &mut self,
541
        _address: &str,
542
        _port: &mut u32,
543
        _session: &mut Session,
544
    ) -> Result<bool, Self::Error> {
545
        self.refuse_request("tcpip-forward");
546
        Ok(false)
547
    }
548
}