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, e.g. `myproj-edit` or
209    /// `htop`.
210    #[serde(default)]
211    pub name: String,
212    /// The original session name template before variable substitution, e.g.
213    /// `{workspace}-edit` or `htop`.
214    #[serde(default)]
215    pub name_template: String,
216    /// The size of the local tty. Passed along so that the remote
217    /// pty can be kept in sync (important so curses applications look
218    /// right).
219    #[serde(default)]
220    pub local_tty_size: TtySize,
221    /// A subset of the environment of the shell that `shpool attach` is run
222    /// in. Contains only some variables needed to set up the shell when
223    /// shpool forks off a process. For now the list is just `SSH_AUTH_SOCK`
224    /// and `TERM`.
225    #[serde(default)]
226    pub local_env: Vec<(String, String)>,
227    /// If specified, sets a time limit on how long the shell will be open
228    /// when the shell is first created (does nothing in the case of a
229    /// reattach). The daemon is responsible for automatically killing the
230    /// session once the ttl is over.
231    #[serde(default)]
232    pub ttl_secs: Option<u64>,
233    /// If specified, a command to run instead of the users default shell.
234    #[serde(default)]
235    pub cmd: Option<String>,
236    /// If specified, the directory to start the shell in. If not, $HOME
237    /// should be used.
238    #[serde(default)]
239    pub dir: Option<String>,
240    /// If specified, shpool will inject the given command into the shell
241    /// when it first starts up. This option is ignored for reattaches.
242    /// Note that this differs from the cmd option in that it is run
243    /// directly in the shell rather than replacing the shell. Think of
244    /// it as running `source cmd` rather than `exec cmd`. The main
245    /// usecase is to be able to automatically enter some useful context
246    /// such as a particular directory with a python virtual environment
247    /// already set up for example.
248    #[serde(default)]
249    pub start_cmd: Option<String>,
250}
251
252impl AttachHeader {
253    pub fn local_env_get(&self, var: &str) -> Option<&str> {
254        self.local_env.iter().find(|(k, _)| k == var).map(|(_, v)| v.as_str())
255    }
256}
257
258/// AttachReplyHeader is the blob of metadata that the shpool service prefixes
259/// the data stream with after an attach. In can be used to indicate a
260/// connection error.
261#[derive(Serialize, Deserialize, Debug)]
262pub struct AttachReplyHeader {
263    #[serde(default)]
264    pub status: AttachStatus,
265}
266
267/// ListReply is contains a list of active sessions to be displayed to the user.
268#[derive(Serialize, Deserialize, Debug)]
269pub struct ListReply {
270    #[serde(default)]
271    pub sessions: Vec<Session>,
272}
273
274/// Session describes an active session.
275#[derive(Serialize, Deserialize, Debug)]
276pub struct Session {
277    #[serde(default)]
278    pub name: String,
279    #[serde(default)]
280    pub started_at_unix_ms: i64,
281    #[serde(default)]
282    pub last_connected_at_unix_ms: Option<i64>,
283    #[serde(default)]
284    pub last_disconnected_at_unix_ms: Option<i64>,
285    #[serde(default)]
286    pub status: SessionStatus,
287    /// This session's attachments. Currently, a session can have at most one
288    /// attachment, but this is a list in case that changes. Maintained
289    /// independently of `status`, so they can briefly disagree.
290    #[serde(default)]
291    pub attachments: Vec<Attachment>,
292}
293
294/// A session's attachment.
295#[derive(Serialize, Deserialize, Debug, Default, Clone)]
296pub struct Attachment {
297    /// The original session name template before variable substitution, e.g.
298    /// `{workspace}-edit` or `htop`.
299    #[serde(default)]
300    pub session_name_template: String,
301    /// The pid of the attach process.
302    #[serde(default)]
303    pub pid: i32,
304}
305
306/// Indicates if a shpool session currently has a client attached.
307#[derive(Serialize, Deserialize, Debug, Default)]
308pub enum SessionStatus {
309    #[default]
310    Attached,
311    Disconnected,
312}
313
314impl fmt::Display for SessionStatus {
315    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
316        match self {
317            SessionStatus::Attached => write!(f, "attached"),
318            SessionStatus::Disconnected => write!(f, "disconnected"),
319        }
320    }
321}
322
323/// AttachStatus indicates what happened during an attach attempt.
324#[derive(PartialEq, Eq, Serialize, Deserialize, Debug, Clone)]
325pub enum AttachStatus {
326    /// Attached indicates that there was an existing shell session with
327    /// the given name, and `shpool attach` successfully connected to it.
328    ///
329    /// NOTE: warnings is not currently used, but it used to be, and we
330    /// might want it in the future, so it is not worth breaking the protocol
331    /// over.
332    Attached { warnings: Vec<String> },
333    /// Created indicates that there was no existing shell session with the
334    /// given name, so `shpool` created a new one.
335    ///
336    /// NOTE: warnings is not currently used, see above.
337    Created { warnings: Vec<String> },
338    /// Busy indicates that there is an existing shell session with the given
339    /// name, but another shpool session is currently connected to
340    /// it, so the connection attempt was rejected.
341    Busy,
342    /// Forbidden indicates that the daemon has rejected the connection
343    /// attempt for security reasons.
344    Forbidden(String),
345    /// Some unexpected error
346    UnexpectedError(String),
347}
348
349impl Default for AttachStatus {
350    fn default() -> Self {
351        AttachStatus::UnexpectedError(String::from("default"))
352    }
353}
354
355#[derive(Serialize, Deserialize, Debug, Default, Clone)]
356pub struct TtySize {
357    pub rows: u16,
358    pub cols: u16,
359    pub xpixel: u16,
360    pub ypixel: u16,
361}
362
363// Some metadata that may or may not require a shpool attach process
364// to switch sessions.
365#[derive(Serialize, Deserialize, Debug, Default, Clone)]
366pub struct MaybeSwitch {
367    /// If non-empty, this indicates that the receiving attach process
368    /// should hang up, then reattach to the given session name.
369    ///
370    /// Session switching is incompatible with session name templates,
371    /// so the attach process should exit and display an error to the
372    /// user if it gets a MaybeSwitch telling to to switch to a specific
373    /// session name.
374    pub switch_to: Option<String>,
375    /// The shpool wide variable environment. The attach process should
376    /// hang up and reattach if it has a session name template which
377    /// produces a new result with this new environment.
378    pub vars: Vec<(String, String)>,
379}
380
381/// ChunkKind is a tag that indicates what type of frame is being transmitted
382/// through the socket.
383#[derive(Copy, Clone, Debug, PartialEq)]
384pub enum ChunkKind {
385    /// After the kind tag, the chunk will have a 4 byte little endian length
386    /// prefix followed by the actual data.
387    Data = 0,
388    /// An empty chunk sent so that the daemon can check to make sure the attach
389    /// process is still listening.
390    Heartbeat = 1,
391    /// The child shell has exited. After the kind tag, the chunk will
392    /// have exactly 4 bytes of data, which will contain a little endian
393    /// code indicating the child's exit status.
394    ExitStatus = 2,
395    /// After the kind tag, the chunk contains a 4 byte little endian length
396    /// prefix, followed by a msgpack encoded MaybeSwitch struct.
397    MaybeSwitch = 3,
398}
399
400impl TryFrom<u8> for ChunkKind {
401    type Error = anyhow::Error;
402
403    fn try_from(v: u8) -> anyhow::Result<Self> {
404        match v {
405            0 => Ok(ChunkKind::Data),
406            1 => Ok(ChunkKind::Heartbeat),
407            2 => Ok(ChunkKind::ExitStatus),
408            3 => Ok(ChunkKind::MaybeSwitch),
409            _ => Err(anyhow!("unknown ChunkKind {}", v)),
410        }
411    }
412}
413
414/// Chunk represents of a chunk of data in the output stream
415///
416/// format:
417///
418/// ```text
419/// 1 byte: kind tag
420/// little endian 4 byte word: length prefix
421/// N bytes: data
422/// ```
423#[derive(Debug, PartialEq)]
424pub struct Chunk<'data> {
425    pub kind: ChunkKind,
426    pub buf: &'data [u8],
427}