Skip to main content

shpool_protocol/
lib.rs

1// Copyright 2024 Google LLC
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use std::{default::Default, fmt};
16
17use anyhow::anyhow;
18use clap::ValueEnum;
19use serde_derive::{Deserialize, Serialize};
20
21pub const VERSION: &str = env!("CARGO_PKG_VERSION");
22
23/// The header used to advertize daemon version.
24///
25/// This header gets written by the daemon to every stream as
26/// soon as it is opened, which allows the client to compare
27/// version strings for protocol negotiation (basically just
28/// deciding if the user ought to be warned about mismatched
29/// versions).
30#[derive(Serialize, Deserialize, Debug)]
31pub struct VersionHeader {
32    pub version: String,
33}
34
35/// The blob of metadata that a client transmits when it
36/// first connects.
37///
38/// It uses an enum to allow different connection types
39/// to be initiated on the same socket. The ConnectHeader is always prefixed
40/// with a 4 byte little endian unsigned word to indicate length.
41#[derive(Serialize, Deserialize, Debug)]
42pub enum ConnectHeader {
43    /// Attach to the named session indicated by the given header.
44    ///
45    /// Responds with an AttachReplyHeader.
46    Attach(AttachHeader),
47    /// List all of the currently active sessions.
48    List,
49    /// A message for a named, running sessions. This
50    /// provides a mechanism for RPC-like calls to be
51    /// made to running sessions. Messages are only
52    /// delivered if there is currently a client attached
53    /// to the session because we need a servicing thread
54    /// with access to the SessionInner to respond to requests
55    /// (we could implement a mailbox system or something
56    /// for detached threads, but so far we have not needed to).
57    SessionMessage(SessionMessageRequest),
58    /// A message to request that a list of running
59    /// sessions get detached from.
60    Detach(DetachRequest),
61    /// A message to request that a list of running
62    /// sessions get killed.
63    Kill(KillRequest),
64    /// A request to set the log level to a new value.
65    SetLogLevel(SetLogLevelRequest),
66    /// A request to get the current vars. The reply is just a Vars struct.
67    GetVars,
68    /// A request to modify the variable environment.
69    ModifyVar(ModifyVarRequest),
70}
71
72/// KillRequest represents a request to kill
73/// the given named sessions.
74#[derive(Serialize, Deserialize, Debug)]
75pub struct KillRequest {
76    /// The sessions to detach
77    #[serde(default)]
78    pub sessions: Vec<String>,
79}
80
81#[derive(Serialize, Deserialize, Debug)]
82pub struct KillReply {
83    #[serde(default)]
84    pub not_found_sessions: Vec<String>,
85}
86
87/// DetachRequest represents a request to detach
88/// from the given named sessions.
89#[derive(Serialize, Deserialize, Debug)]
90pub struct DetachRequest {
91    /// The sessions to detach
92    #[serde(default)]
93    pub sessions: Vec<String>,
94}
95
96#[derive(Serialize, Deserialize, Debug)]
97pub struct DetachReply {
98    /// sessions that are not even in the session table
99    #[serde(default)]
100    pub not_found_sessions: Vec<String>,
101    /// sessions that are in the session table, but have no
102    /// tty attached
103    #[serde(default)]
104    pub not_attached_sessions: Vec<String>,
105}
106
107#[derive(Serialize, Deserialize, Debug, Default, ValueEnum, Clone)]
108pub enum LogLevel {
109    #[default]
110    Off,
111    Error,
112    Warn,
113    Info,
114    Debug,
115    Trace,
116}
117
118// SetLogLevelRequest contains a request to set a new
119// log level
120#[derive(Serialize, Deserialize, Debug)]
121pub struct SetLogLevelRequest {
122    #[serde(default)]
123    pub level: LogLevel,
124}
125
126#[derive(Serialize, Deserialize, Debug)]
127pub struct SetLogLevelReply {}
128
129#[derive(Serialize, Deserialize, Debug)]
130pub struct ModifyVarRequest {
131    pub var: String,
132    /// If none, this is a request to remove the var from the
133    /// environment entirely.
134    pub val: Option<String>,
135}
136
137#[derive(Serialize, Deserialize, Debug)]
138pub struct ModifyVarReply {}
139
140/// SessionMessageRequest represents a request that
141/// ought to be routed to the session indicated by
142/// `session_name`.
143#[derive(Serialize, Deserialize, Debug)]
144pub struct SessionMessageRequest {
145    /// The session to route this request to.
146    #[serde(default)]
147    pub session_name: String,
148    /// The actual message to send to the session.
149    #[serde(default)]
150    pub payload: SessionMessageRequestPayload,
151}
152
153/// SessionMessageRequestPayload contains a request for
154/// a running session.
155#[derive(Serialize, Deserialize, Debug, Default)]
156pub enum SessionMessageRequestPayload {
157    /// Resize a named session's pty. Generated when
158    /// a `shpool attach` process receives a SIGWINCH.
159    Resize(ResizeRequest),
160    /// Detach the given session. Generated internally
161    /// by the server from a batch detach request.
162    #[default]
163    Detach,
164}
165
166/// ResizeRequest resizes the pty for a named session.
167///
168/// We use an out-of-band request rather than doing this
169/// in the input stream because we don't want to have to
170/// introduce a framing protocol for the input stream.
171#[derive(Serialize, Deserialize, Debug)]
172pub struct ResizeRequest {
173    /// The size of the client's tty
174    #[serde(default)]
175    pub tty_size: TtySize,
176}
177
178#[derive(Serialize, Deserialize, Debug, PartialEq)]
179pub enum SessionMessageReply {
180    /// The session was not found in the session table
181    NotFound,
182    /// There is not terminal attached to the session so
183    /// it can't handle messages right now.
184    NotAttached,
185    /// The response to a resize message
186    Resize(ResizeReply),
187    /// The response to a detach message
188    Detach(SessionMessageDetachReply),
189}
190
191/// A reply to a detach message
192#[derive(Serialize, Deserialize, Debug, PartialEq)]
193pub enum SessionMessageDetachReply {
194    Ok,
195}
196
197/// A reply to a resize message
198#[derive(Serialize, Deserialize, Debug, PartialEq)]
199pub enum ResizeReply {
200    Ok,
201}
202
203/// AttachHeader is the blob of metadata that a client transmits when it
204/// first dials into the shpool daemon indicating which shell it wants
205/// to attach to.
206#[derive(Serialize, Deserialize, Debug, Default)]
207pub struct AttachHeader {
208    /// The name of the session to create or attach to.
209    #[serde(default)]
210    pub name: String,
211    /// The size of the local tty. Passed along so that the remote
212    /// pty can be kept in sync (important so curses applications look
213    /// right).
214    #[serde(default)]
215    pub local_tty_size: TtySize,
216    /// A subset of the environment of the shell that `shpool attach` is run
217    /// in. Contains only some variables needed to set up the shell when
218    /// shpool forks off a process. For now the list is just `SSH_AUTH_SOCK`
219    /// and `TERM`.
220    #[serde(default)]
221    pub local_env: Vec<(String, String)>,
222    /// If specified, sets a time limit on how long the shell will be open
223    /// when the shell is first created (does nothing in the case of a
224    /// reattach). The daemon is responsible for automatically killing the
225    /// session once the ttl is over.
226    #[serde(default)]
227    pub ttl_secs: Option<u64>,
228    /// If specified, a command to run instead of the users default shell.
229    #[serde(default)]
230    pub cmd: Option<String>,
231    /// If specified, the directory to start the shell in. If not, $HOME
232    /// should be used.
233    #[serde(default)]
234    pub dir: Option<String>,
235}
236
237impl AttachHeader {
238    pub fn local_env_get(&self, var: &str) -> Option<&str> {
239        self.local_env.iter().find(|(k, _)| k == var).map(|(_, v)| v.as_str())
240    }
241}
242
243/// AttachReplyHeader is the blob of metadata that the shpool service prefixes
244/// the data stream with after an attach. In can be used to indicate a
245/// connection error.
246#[derive(Serialize, Deserialize, Debug)]
247pub struct AttachReplyHeader {
248    #[serde(default)]
249    pub status: AttachStatus,
250}
251
252/// ListReply is contains a list of active sessions to be displayed to the user.
253#[derive(Serialize, Deserialize, Debug)]
254pub struct ListReply {
255    #[serde(default)]
256    pub sessions: Vec<Session>,
257}
258
259/// Session describes an active session.
260#[derive(Serialize, Deserialize, Debug)]
261pub struct Session {
262    #[serde(default)]
263    pub name: String,
264    #[serde(default)]
265    pub started_at_unix_ms: i64,
266    #[serde(default)]
267    pub last_connected_at_unix_ms: Option<i64>,
268    #[serde(default)]
269    pub last_disconnected_at_unix_ms: Option<i64>,
270    #[serde(default)]
271    pub status: SessionStatus,
272}
273
274/// Indicates if a shpool session currently has a client attached.
275#[derive(Serialize, Deserialize, Debug, Default)]
276pub enum SessionStatus {
277    #[default]
278    Attached,
279    Disconnected,
280}
281
282impl fmt::Display for SessionStatus {
283    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
284        match self {
285            SessionStatus::Attached => write!(f, "attached"),
286            SessionStatus::Disconnected => write!(f, "disconnected"),
287        }
288    }
289}
290
291/// AttachStatus indicates what happened during an attach attempt.
292#[derive(PartialEq, Eq, Serialize, Deserialize, Debug, Clone)]
293pub enum AttachStatus {
294    /// Attached indicates that there was an existing shell session with
295    /// the given name, and `shpool attach` successfully connected to it.
296    ///
297    /// NOTE: warnings is not currently used, but it used to be, and we
298    /// might want it in the future, so it is not worth breaking the protocol
299    /// over.
300    Attached { warnings: Vec<String> },
301    /// Created indicates that there was no existing shell session with the
302    /// given name, so `shpool` created a new one.
303    ///
304    /// NOTE: warnings is not currently used, see above.
305    Created { warnings: Vec<String> },
306    /// Busy indicates that there is an existing shell session with the given
307    /// name, but another shpool session is currently connected to
308    /// it, so the connection attempt was rejected.
309    Busy,
310    /// Forbidden indicates that the daemon has rejected the connection
311    /// attempt for security reasons.
312    Forbidden(String),
313    /// Some unexpected error
314    UnexpectedError(String),
315}
316
317impl Default for AttachStatus {
318    fn default() -> Self {
319        AttachStatus::UnexpectedError(String::from("default"))
320    }
321}
322
323#[derive(Serialize, Deserialize, Debug, Default, Clone)]
324pub struct TtySize {
325    pub rows: u16,
326    pub cols: u16,
327    pub xpixel: u16,
328    pub ypixel: u16,
329}
330
331// Some metadata that may or may not require a shpool attach process
332// to switch sessions.
333#[derive(Serialize, Deserialize, Debug, Default, Clone)]
334pub struct MaybeSwitch {
335    /// If non-empty, this indicates that the receiving attach process
336    /// should hang up, then reattach to the given session name.
337    ///
338    /// Session switching is incompatible with session name templates,
339    /// so the attach process should exit and display an error to the
340    /// user if it gets a MaybeSwitch telling to to switch to a specific
341    /// session name.
342    pub switch_to: Option<String>,
343    /// The shpool wide variable environment. The attach process should
344    /// hang up and reattach if it has a session name template which
345    /// produces a new result with this new environment.
346    pub vars: Vec<(String, String)>,
347}
348
349/// ChunkKind is a tag that indicates what type of frame is being transmitted
350/// through the socket.
351#[derive(Copy, Clone, Debug, PartialEq)]
352pub enum ChunkKind {
353    /// After the kind tag, the chunk will have a 4 byte little endian length
354    /// prefix followed by the actual data.
355    Data = 0,
356    /// An empty chunk sent so that the daemon can check to make sure the attach
357    /// process is still listening.
358    Heartbeat = 1,
359    /// The child shell has exited. After the kind tag, the chunk will
360    /// have exactly 4 bytes of data, which will contain a little endian
361    /// code indicating the child's exit status.
362    ExitStatus = 2,
363    /// After the kind tag, the chunk contains a 4 byte little endian length
364    /// prefix, followed by a msgpack encoded MaybeSwitch struct.
365    MaybeSwitch = 3,
366}
367
368impl TryFrom<u8> for ChunkKind {
369    type Error = anyhow::Error;
370
371    fn try_from(v: u8) -> anyhow::Result<Self> {
372        match v {
373            0 => Ok(ChunkKind::Data),
374            1 => Ok(ChunkKind::Heartbeat),
375            2 => Ok(ChunkKind::ExitStatus),
376            3 => Ok(ChunkKind::MaybeSwitch),
377            _ => Err(anyhow!("unknown ChunkKind {}", v)),
378        }
379    }
380}
381
382/// Chunk represents of a chunk of data in the output stream
383///
384/// format:
385///
386/// ```text
387/// 1 byte: kind tag
388/// little endian 4 byte word: length prefix
389/// N bytes: data
390/// ```
391#[derive(Debug, PartialEq)]
392pub struct Chunk<'data> {
393    pub kind: ChunkKind,
394    pub buf: &'data [u8],
395}