1
//! `nms --ssh [USER@]HOST`: a thin client for the sshd `nomisync-eval`
2
//! subsystem. It shells out to the system `ssh`, so every part of
3
//! authentication — public key, ssh-agent, `~/.ssh/config`,
4
//! known_hosts, or an interactive password prompt — is handled by
5
//! OpenSSH. nms only frames forms and reads back response envelopes.
6
//!
7
//! The client therefore carries no `DATABASE_URL` and no Postgres
8
//! credentials: the SSH identity maps to a nomisync user server-side
9
//! (the daemon's `auth_publickey` / `auth_password`), and the eval runs
10
//! in that user's `rpc::Session`. The wire shape is exactly what
11
//! `nms --rpc-user` produces and `Session::handle_form` parses — a
12
//! newline-terminated `(:id N :form <form>)` request frame, with
13
//! balanced-paren response envelopes read back via [`rpc::FrameDecoder`].
14
//!
15
//! Cooperative wire-interrupt (the `0x03` cancel byte the subsystem
16
//! understands) is intentionally not wired in this synchronous line
17
//! REPL — `eval` blocks awaiting the response, so there is no point at
18
//! which the client could read a `Ctrl-C` to forward it. A SIGINT
19
//! handler (or a TUI transport reusing this client) is the natural home
20
//! for that and can be added when there is a consumer.
21

            
22
use std::io::{Read, Write};
23
use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
24

            
25
use anyhow::{Context, Result, anyhow, bail};
26
use rpc::FrameDecoder;
27

            
28
/// The ssh subsystem name the daemon gates on.
29
const SUBSYSTEM: &str = "nomisync-eval";
30

            
31
/// A live connection to a remote `nomisync-eval` subsystem, backed by a
32
/// spawned `ssh` child process.
33
pub struct SshEval {
34
    child: Child,
35
    stdin: ChildStdin,
36
    stdout: ChildStdout,
37
    decoder: FrameDecoder,
38
    next_id: u64,
39
}
40

            
41
impl SshEval {
42
    /// Spawns `ssh [-p PORT] -s TARGET nomisync-eval` with piped
43
    /// stdin/stdout (stderr is inherited so OpenSSH prompts and errors
44
    /// reach the user's terminal). Authentication is entirely OpenSSH's
45
    /// responsibility.
46
    ///
47
    /// # Errors
48
    /// Fails if `ssh` cannot be spawned (not installed / not on `PATH`)
49
    /// or its pipes are unavailable.
50
    pub fn connect(target: &str, port: Option<u16>) -> Result<Self> {
51
        let mut command = Command::new("ssh");
52
        command
53
            .args(ssh_args(target, port))
54
            .stdin(Stdio::piped())
55
            .stdout(Stdio::piped())
56
            .stderr(Stdio::inherit());
57
        let mut child = command
58
            .spawn()
59
            .context("failed to spawn `ssh` (is OpenSSH installed and on PATH?)")?;
60
        let stdin = child.stdin.take().context("ssh stdin unavailable")?;
61
        let stdout = child.stdout.take().context("ssh stdout unavailable")?;
62
        Ok(Self {
63
            child,
64
            stdin,
65
            stdout,
66
            decoder: FrameDecoder::new(),
67
            next_id: 1,
68
        })
69
    }
70

            
71
    /// Wraps `form` in a fresh `(:id N :form …)` envelope, sends it, and
72
    /// blocks until the matching response envelope arrives.
73
    ///
74
    /// # Errors
75
    /// Fails on a write error or if the connection closes before a full
76
    /// response frame is received.
77
    pub fn eval(&mut self, form: &str) -> Result<String> {
78
        write_frame(&mut self.stdin, self.next_id, form.trim())?;
79
        self.next_id = self.next_id.wrapping_add(1);
80
        read_frame(&mut self.decoder, &mut self.stdout)
81
    }
82
}
83

            
84
impl Drop for SshEval {
85
    fn drop(&mut self) {
86
        // Reap the child so a dropped client never leaks a zombie ssh.
87
        let _ = self.child.kill();
88
        let _ = self.child.wait();
89
    }
90
}
91

            
92
/// Builds the `ssh` argument vector. `-T` disables remote PTY
93
/// allocation: without it a user's `RequestTTY yes`/`force` ssh config
94
/// could make OpenSSH send a `pty-req` first, and the daemon routes a
95
/// PTY channel's bytes to its TUI handler (checked before the eval
96
/// channel), so eval requests would never reach the subsystem. `-s …
97
/// nomisync-eval` then requests the subsystem on the channel.
98
2
fn ssh_args(target: &str, port: Option<u16>) -> Vec<String> {
99
2
    let mut args = Vec::new();
100
2
    if let Some(port) = port {
101
1
        args.push("-p".to_string());
102
1
        args.push(port.to_string());
103
1
    }
104
2
    args.push("-T".to_string());
105
2
    args.push("-s".to_string());
106
2
    args.push(target.to_string());
107
2
    args.push(SUBSYSTEM.to_string());
108
2
    args
109
2
}
110

            
111
/// Renders the request frame the `nomisync-eval` subsystem expects: a
112
/// `(:id N :form <form>)` envelope, frame-terminated by a trailing
113
/// newline.
114
///
115
/// The closing paren sits on its own line: a trailing line-comment in
116
/// `form` (e.g. `(+ 1 2) ; note`) would otherwise comment out a
117
/// same-line terminator, leaving the server's `FrameDecoder` waiting on
118
/// an unbalanced frame forever (a client hang).
119
4
fn envelope(id: u64, form: &str) -> String {
120
4
    format!("(:id {id} :form {form}\n)\n")
121
4
}
122

            
123
1
fn write_frame(writer: &mut impl Write, id: u64, form: &str) -> Result<()> {
124
1
    writer.write_all(envelope(id, form).as_bytes())?;
125
1
    writer.flush()?;
126
1
    Ok(())
127
1
}
128

            
129
/// Pulls one complete response envelope from `reader`, feeding bytes
130
/// through the balanced-paren [`FrameDecoder`].
131
///
132
/// # Errors
133
/// Fails if the connection closes before a full frame arrives or the
134
/// decoder rejects the bytes.
135
5
fn read_frame(decoder: &mut FrameDecoder, reader: &mut impl Read) -> Result<String> {
136
    loop {
137
16
        if let Some(frame) = decoder.next_frame() {
138
4
            return frame.map_err(|err| anyhow!("malformed response frame: {err:?}"));
139
12
        }
140
12
        let mut buf = [0u8; 4096];
141
12
        let read = reader.read(&mut buf)?;
142
12
        if read == 0 {
143
1
            bail!("ssh connection closed before a response was received");
144
11
        }
145
11
        decoder
146
11
            .feed(&buf[..read])
147
11
            .map_err(|err| anyhow!("malformed response frame: {err:?}"))?;
148
    }
149
5
}
150

            
151
#[cfg(test)]
152
mod tests;