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    /// If specified, shpool will inject the given command into the shell
236    /// when it first starts up. This option is ignored for reattaches.
237    /// Note that this differs from the cmd option in that it is run
238    /// directly in the shell rather than replacing the shell. Think of
239    /// it as running `source cmd` rather than `exec cmd`. The main
240    /// usecase is to be able to automatically enter some useful context
241    /// such as a particular directory with a python virtual environment
242    /// already set up for example.
243    #[serde(default)]
244    pub start_cmd: Option<String>,
245}
246
247impl AttachHeader {
248    pub fn local_env_get(&self, var: &str) -> Option<&str> {
249        self.local_env.iter().find(|(k, _)| k == var).map(|(_, v)| v.as_str())
250    }
251}
252
253/// AttachReplyHeader is the blob of metadata that the shpool service prefixes
254/// the data stream with after an attach. In can be used to indicate a
255/// connection error.
256#[derive(Serialize, Deserialize, Debug)]
257pub struct AttachReplyHeader {
258    #[serde(default)]
259    pub status: AttachStatus,
260}
261
262/// ListReply is contains a list of active sessions to be displayed to the user.
263#[derive(Serialize, Deserialize, Debug)]
264pub struct ListReply {
265    #[serde(default)]
266    pub sessions: Vec<Session>,
267}
268
269/// Session describes an active session.
270#[derive(Serialize, Deserialize, Debug)]
271pub struct Session {
272    #[serde(default)]
273    pub name: String,
274    #[serde(default)]
275    pub started_at_unix_ms: i64,
276    #[serde(default)]
277    pub last_connected_at_unix_ms: Option<i64>,
278    #[serde(default)]
279    pub last_disconnected_at_unix_ms: Option<i64>,
280    #[serde(default)]
281    pub status: SessionStatus,
282}
283
284/// Indicates if a shpool session currently has a client attached.
285#[derive(Serialize, Deserialize, Debug, Default)]
286pub enum SessionStatus {
287    #[default]
288    Attached,
289    Disconnected,
290}
291
292impl fmt::Display for SessionStatus {
293    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
294        match self {
295            SessionStatus::Attached => write!(f, "attached"),
296            SessionStatus::Disconnected => write!(f, "disconnected"),
297        }
298    }
299}
300
301/// AttachStatus indicates what happened during an attach attempt.
302#[derive(PartialEq, Eq, Serialize, Deserialize, Debug, Clone)]
303pub enum AttachStatus {
304    /// Attached indicates that there was an existing shell session with
305    /// the given name, and `shpool attach` successfully connected to it.
306    ///
307    /// NOTE: warnings is not currently used, but it used to be, and we
308    /// might want it in the future, so it is not worth breaking the protocol
309    /// over.
310    Attached { warnings: Vec<String> },
311    /// Created indicates that there was no existing shell session with the
312    /// given name, so `shpool` created a new one.
313    ///
314    /// NOTE: warnings is not currently used, see above.
315    Created { warnings: Vec<String> },
316    /// Busy indicates that there is an existing shell session with the given
317    /// name, but another shpool session is currently connected to
318    /// it, so the connection attempt was rejected.
319    Busy,
320    /// Forbidden indicates that the daemon has rejected the connection
321    /// attempt for security reasons.
322    Forbidden(String),
323    /// Some unexpected error
324    UnexpectedError(String),
325}
326
327impl Default for AttachStatus {
328    fn default() -> Self {
329        AttachStatus::UnexpectedError(String::from("default"))
330    }
331}
332
333#[derive(Serialize, Deserialize, Debug, Default, Clone)]
334pub struct TtySize {
335    pub rows: u16,
336    pub cols: u16,
337    pub xpixel: u16,
338    pub ypixel: u16,
339}
340
341// Some metadata that may or may not require a shpool attach process
342// to switch sessions.
343#[derive(Serialize, Deserialize, Debug, Default, Clone)]
344pub struct MaybeSwitch {
345    /// If non-empty, this indicates that the receiving attach process
346    /// should hang up, then reattach to the given session name.
347    ///
348    /// Session switching is incompatible with session name templates,
349    /// so the attach process should exit and display an error to the
350    /// user if it gets a MaybeSwitch telling to to switch to a specific
351    /// session name.
352    pub switch_to: Option<String>,
353    /// The shpool wide variable environment. The attach process should
354    /// hang up and reattach if it has a session name template which
355    /// produces a new result with this new environment.
356    pub vars: Vec<(String, String)>,
357}
358
359/// ChunkKind is a tag that indicates what type of frame is being transmitted
360/// through the socket.
361#[derive(Copy, Clone, Debug, PartialEq)]
362pub enum ChunkKind {
363    /// After the kind tag, the chunk will have a 4 byte little endian length
364    /// prefix followed by the actual data.
365    Data = 0,
366    /// An empty chunk sent so that the daemon can check to make sure the attach
367    /// process is still listening.
368    Heartbeat = 1,
369    /// The child shell has exited. After the kind tag, the chunk will
370    /// have exactly 4 bytes of data, which will contain a little endian
371    /// code indicating the child's exit status.
372    ExitStatus = 2,
373    /// After the kind tag, the chunk contains a 4 byte little endian length
374    /// prefix, followed by a msgpack encoded MaybeSwitch struct.
375    MaybeSwitch = 3,
376}
377
378impl TryFrom<u8> for ChunkKind {
379    type Error = anyhow::Error;
380
381    fn try_from(v: u8) -> anyhow::Result<Self> {
382        match v {
383            0 => Ok(ChunkKind::Data),
384            1 => Ok(ChunkKind::Heartbeat),
385            2 => Ok(ChunkKind::ExitStatus),
386            3 => Ok(ChunkKind::MaybeSwitch),
387            _ => Err(anyhow!("unknown ChunkKind {}", v)),
388        }
389    }
390}
391
392/// Chunk represents of a chunk of data in the output stream
393///
394/// format:
395///
396/// ```text
397/// 1 byte: kind tag
398/// little endian 4 byte word: length prefix
399/// N bytes: data
400/// ```
401#[derive(Debug, PartialEq)]
402pub struct Chunk<'data> {
403    pub kind: ChunkKind,
404    pub buf: &'data [u8],
405}