Skip to main content

cliban_server/
server.rs

1//! russh server plumbing: pubkey auth against the tenancy registry +
2//! per-session handlers. Exec routes to the control-command router; shell
3//! serves the board TUI to enrolled keys over the channel (picker first
4//! when a key belongs to several tenants).
5
6use std::collections::HashMap;
7use std::sync::mpsc;
8use std::sync::Arc;
9
10use cliban_tenancy::{Caps, Tenant, TenantManager, User};
11use cliban_tui::remote::RemoteInput;
12use russh::keys::ssh_key::HashAlg;
13use russh::keys::PrivateKey;
14use russh::server::{Auth, ChannelOpenHandle, Handler, Msg, Session};
15use russh::{Channel, ChannelId, MethodKind, MethodSet, Pty};
16
17use crate::config::{ServerConfig, SignupPolicy};
18use crate::ServerError;
19use crate::{commands, shell};
20
21/// Build the russh server config around the persisted host key.
22pub fn russh_config(key: PrivateKey) -> Arc<russh::server::Config> {
23    Arc::new(russh::server::Config {
24        keys: vec![key],
25        inactivity_timeout: Some(std::time::Duration::from_secs(3600)),
26        auth_rejection_time: std::time::Duration::from_secs(3),
27        auth_rejection_time_initial: Some(std::time::Duration::from_secs(0)),
28        ..Default::default()
29    })
30}
31
32/// Shared server state: tenancy routing + signup policy.
33pub struct AppState {
34    pub manager: TenantManager,
35    pub signup_policy: SignupPolicy,
36    pub signup_token: Option<String>,
37}
38
39impl AppState {
40    /// Open the tenancy layer under the configured data dir. Config caps use
41    /// 0 = unlimited; the tenancy layer wants actual bounds.
42    pub fn from_config(cfg: &ServerConfig) -> Result<AppState, ServerError> {
43        fn cap(v: u32) -> i64 {
44            if v == 0 {
45                i64::MAX
46            } else {
47                i64::from(v)
48            }
49        }
50        let manager = TenantManager::open(
51            &cfg.data_dir,
52            Caps {
53                max_tenants_per_user: cap(cfg.max_tenants_per_key),
54                max_tenants_global: cap(cfg.max_tenants),
55            },
56        )?;
57        Ok(AppState {
58            manager,
59            signup_policy: cfg.signup_policy,
60            signup_token: cfg.signup_token.clone(),
61        })
62    }
63}
64
65/// The authenticated key for one connection, resolved at auth time.
66pub struct KeyInfo {
67    /// "SHA256:..." fingerprint — the registry lookup handle.
68    pub fingerprint: String,
69    /// Full OpenSSH-encoded public key, stored on enrollment.
70    pub openssh: String,
71    /// SSH username offered at auth; becomes the display name on enrollment.
72    pub username: String,
73    /// Registry user, `Some` iff the key is enrolled. Signup/accept set it.
74    pub user: Option<User>,
75}
76
77/// Connection factory handed to `russh::server::Server::run_on_socket`.
78pub struct ClibandServer {
79    pub state: Arc<AppState>,
80}
81
82impl russh::server::Server for ClibandServer {
83    type Handler = SessionHandler;
84
85    fn new_client(&mut self, _peer: Option<std::net::SocketAddr>) -> SessionHandler {
86        SessionHandler {
87            state: self.state.clone(),
88            key: None,
89            ptys: HashMap::new(),
90            boards: HashMap::new(),
91        }
92    }
93}
94
95/// Per-connection handler.
96pub struct SessionHandler {
97    state: Arc<AppState>,
98    /// Set by `auth_publickey`; `None` until auth completes.
99    key: Option<KeyInfo>,
100    /// pty size per channel (pty-req arrives before the shell request).
101    ptys: HashMap<ChannelId, (u16, u16)>,
102    /// Live board sessions: input senders keyed by channel. Dropping a
103    /// sender is the teardown signal for that channel's board task.
104    boards: HashMap<ChannelId, mpsc::Sender<RemoteInput>>,
105}
106
107/// SSH sends u32 dimensions; terminals are u16 and zero is useless.
108fn dim(v: u32) -> u16 {
109    u16::try_from(v).unwrap_or(u16::MAX).max(1)
110}
111
112/// Reject, telling the client publickey is the only way in.
113fn pubkey_only() -> Auth {
114    let mut methods = MethodSet::empty();
115    methods.push(MethodKind::PublicKey);
116    Auth::Reject {
117        proceed_with_methods: Some(methods),
118        partial_success: false,
119    }
120}
121
122impl Handler for SessionHandler {
123    type Error = ServerError;
124
125    async fn auth_none(&mut self, _user: &str) -> Result<Auth, Self::Error> {
126        Ok(pubkey_only())
127    }
128
129    async fn auth_password(&mut self, _user: &str, _password: &str) -> Result<Auth, Self::Error> {
130        Ok(pubkey_only())
131    }
132
133    /// Accept every well-signed key: unknown keys must be able to connect to
134    /// run `signup`/`accept`. Known keys get their registry user attached;
135    /// command-level gating happens in the router.
136    async fn auth_publickey(
137        &mut self,
138        user: &str,
139        key: &russh::keys::ssh_key::PublicKey,
140    ) -> Result<Auth, Self::Error> {
141        let fingerprint = key.fingerprint(HashAlg::Sha256).to_string();
142        let openssh = key.to_openssh()?;
143        let known = self
144            .state
145            .manager
146            .registry()
147            .user_for_pubkey(&fingerprint)?;
148        self.key = Some(KeyInfo {
149            fingerprint,
150            openssh,
151            username: user.to_string(),
152            user: known,
153        });
154        Ok(Auth::Accept)
155    }
156
157    async fn channel_open_session(
158        &mut self,
159        _channel: Channel<Msg>,
160        reply: ChannelOpenHandle,
161        _session: &mut Session,
162    ) -> Result<(), Self::Error> {
163        reply.accept().await;
164        Ok(())
165    }
166
167    async fn pty_request(
168        &mut self,
169        channel: ChannelId,
170        _term: &str,
171        col_width: u32,
172        row_height: u32,
173        _pix_width: u32,
174        _pix_height: u32,
175        _modes: &[(Pty, u32)],
176        session: &mut Session,
177    ) -> Result<(), Self::Error> {
178        self.ptys.insert(channel, (dim(col_width), dim(row_height)));
179        session.channel_success(channel)?;
180        Ok(())
181    }
182
183    async fn window_change_request(
184        &mut self,
185        channel: ChannelId,
186        col_width: u32,
187        row_height: u32,
188        _pix_width: u32,
189        _pix_height: u32,
190        _session: &mut Session,
191    ) -> Result<(), Self::Error> {
192        let size = (dim(col_width), dim(row_height));
193        // Track the size even before a shell starts, so a resize in the
194        // pty-req -> shell gap isn't lost; then forward to a live board.
195        self.ptys.insert(channel, size);
196        if let Some(tx) = self.boards.get(&channel) {
197            let _ = tx.send(RemoteInput::Resize(size.0, size.1));
198        }
199        Ok(())
200    }
201
202    /// Enrolled keys with a pty get the board (picker first when they belong
203    /// to several tenants); unknown keys get usage guidance and a closed
204    /// channel; pty-less requests are told to reconnect with `ssh -t`.
205    async fn shell_request(
206        &mut self,
207        channel: ChannelId,
208        session: &mut Session,
209    ) -> Result<(), Self::Error> {
210        session.channel_success(channel)?;
211
212        // Re-checked per shell request: exec commands (signup/accept) can
213        // enroll the key mid-connection.
214        let Some(user) = self.key.as_ref().and_then(|k| k.user.clone()) else {
215            // \r\n: the client usually has a pty in raw mode here.
216            let text = commands::USAGE.replace('\n', "\r\n");
217            session.data(channel, text.into_bytes())?;
218            session.exit_status_request(channel, 1)?;
219            session.eof(channel)?;
220            session.close(channel)?;
221            return Ok(());
222        };
223
224        let Some(&size) = self.ptys.get(&channel) else {
225            session.data(channel, shell::NO_TTY.as_bytes().to_vec())?;
226            session.exit_status_request(channel, 1)?;
227            session.eof(channel)?;
228            session.close(channel)?;
229            return Ok(());
230        };
231
232        if self.boards.contains_key(&channel) {
233            return Ok(()); // one shell per channel; ignore repeats
234        }
235
236        let tenants: Vec<Tenant> = match self.state.manager.registry().tenants_for_user(user.id) {
237            Ok(ts) => ts.into_iter().map(|(t, _)| t).collect(),
238            Err(e) => {
239                // Same posture as commands::tenancy_fail: log details
240                // server-side, answer generically.
241                eprintln!("cliband: shell: {e}");
242                session.data(channel, b"cliband: internal error\r\n".to_vec())?;
243                session.exit_status_request(channel, 1)?;
244                session.eof(channel)?;
245                session.close(channel)?;
246                return Ok(());
247            }
248        };
249        if tenants.is_empty() {
250            let text = format!(
251                "no boards yet — create or join one first:\r\n\r\n{}",
252                commands::USAGE.replace('\n', "\r\n")
253            );
254            session.data(channel, text.into_bytes())?;
255            session.exit_status_request(channel, 1)?;
256            session.eof(channel)?;
257            session.close(channel)?;
258            return Ok(());
259        }
260
261        let (tx, rx) = mpsc::channel();
262        self.boards.insert(channel, tx);
263        let task = shell::BoardTask {
264            rt: tokio::runtime::Handle::current(),
265            state: self.state.clone(),
266            handle: session.handle(),
267            channel,
268            size,
269            tenants,
270            input: rx,
271        };
272        tokio::task::spawn_blocking(move || shell::run_board(task));
273        Ok(())
274    }
275
276    async fn exec_request(
277        &mut self,
278        channel: ChannelId,
279        data: &[u8],
280        session: &mut Session,
281    ) -> Result<(), Self::Error> {
282        session.channel_success(channel)?;
283        let line = String::from_utf8_lossy(data);
284        let out = match self.key.as_mut() {
285            Some(key) => commands::run(&self.state, key, &line),
286            // Unreachable in practice: exec only arrives post-auth.
287            None => commands::Output {
288                text: commands::USAGE.to_string(),
289                exit: 1,
290            },
291        };
292        session.data(channel, out.text.into_bytes())?;
293        session.exit_status_request(channel, out.exit)?;
294        session.eof(channel)?;
295        session.close(channel)?;
296        Ok(())
297    }
298
299    /// Channel bytes are the board's input stream.
300    async fn data(
301        &mut self,
302        channel: ChannelId,
303        data: &[u8],
304        _session: &mut Session,
305    ) -> Result<(), Self::Error> {
306        if let Some(tx) = self.boards.get(&channel) {
307            let _ = tx.send(RemoteInput::Bytes(data.to_vec()));
308        }
309        Ok(())
310    }
311
312    async fn channel_eof(
313        &mut self,
314        channel: ChannelId,
315        _session: &mut Session,
316    ) -> Result<(), Self::Error> {
317        self.boards.remove(&channel);
318        Ok(())
319    }
320
321    async fn channel_close(
322        &mut self,
323        channel: ChannelId,
324        _session: &mut Session,
325    ) -> Result<(), Self::Error> {
326        self.boards.remove(&channel);
327        self.ptys.remove(&channel);
328        Ok(())
329    }
330}