Skip to content

Commit 59968bc

Browse files
authored
Merge pull request #9 from dreadnode/feature/ssh
new: implemented optional execution via ssh
2 parents 235b1c9 + 4fd31e1 commit 59968bc

File tree

12 files changed

+1357
-47
lines changed

12 files changed

+1357
-47
lines changed

Cargo.lock

Lines changed: 971 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ actix-cors = "0.7.0"
1414
actix-web = "4.9.0"
1515
actix-web-lab = "0.23.0"
1616
anyhow = "1.0.90"
17+
async-ssh2-tokio = "0.8.12"
1718
camino = { version = "1.1.9", features = ["serde"] }
1819
clap = { version = "4.5.20", features = ["derive"] }
1920
env_logger = "0.11.5"
@@ -26,6 +27,7 @@ regex = "1.11.0"
2627
reqwest = "0.12.8"
2728
serde = { version = "1.0.211", features = ["derive"] }
2829
serde_yaml = "0.9.34"
30+
shell-escape = "0.1.5"
2931
shellexpand = { version = "3.1.0", features = ["full"] }
3032
tempfile = "3.13.0"
3133
tokio = { version = "1.41.0", features = ["full"] }

README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,18 @@ Repeat for multiple variables:
155155
robopages run -F function_name -A -D target=www.example.com -D foo=bar
156156
```
157157

158+
#### SSH
159+
160+
The `run` and `serve` commands support an optional SSH connection string. If provided, commands will be executed over SSH on the given host.
161+
162+
```bash
163+
robopages serve --ssh user@host:port --ssh-key ~/.ssh/id_ed25519
164+
```
165+
166+
> [!IMPORTANT]
167+
> * Setting a SSH connection string will override any container configuration.
168+
> * If the function requires sudo, the remote host is expected to have passwordless sudo access.
169+
158170
### Using with LLMs
159171

160172
The examples folder contains integration examples for [Rigging](/examples/rigging_example.py), [OpenAI](/examples/openai_example.py), [Groq](/examples/groq_example.py), [OLLAMA](/examples/ollama_example.py) and [Nerve](/examples/nerve.md).

src/book/mod.rs

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,7 @@ pub struct Book {
192192

193193
impl Book {
194194
pub fn from_path(path: Utf8PathBuf, filter: Option<String>) -> anyhow::Result<Self> {
195-
log::info!("Searching for pages in {:?}", path);
195+
log::debug!("Searching for pages in {:?}", path);
196196
let mut page_paths = Vec::new();
197197

198198
let path = Utf8PathBuf::from(
@@ -203,28 +203,28 @@ impl Book {
203203
.canonicalize_utf8()
204204
.map_err(|e| anyhow::anyhow!("failed to canonicalize path: {}", e))?;
205205

206-
log::info!("Canonicalized path: {:?}", path);
206+
log::debug!("canonicalized path: {:?}", path);
207207

208208
if path.is_file() {
209-
log::info!("Path is a file");
209+
log::debug!("path is a file");
210210
eval_if_in_filter!(path, filter, page_paths.push(path.to_path_buf()));
211211
} else if path.is_dir() {
212-
log::info!("Path is a directory, searching for .yml files");
212+
log::debug!("path is a directory, searching for .yml files");
213213
let glob_pattern = path.join("**/*.yml").as_str().to_string();
214-
log::info!("Using glob pattern: {}", glob_pattern);
214+
log::debug!("using glob pattern: {}", glob_pattern);
215215

216216
for entry in glob(&glob_pattern)? {
217217
match entry {
218218
Ok(entry_path) => {
219-
log::debug!("Found file: {:?}", entry_path);
219+
log::debug!("found file: {:?}", entry_path);
220220
// skip files in hidden directories (starting with .)
221221
// but allow the root .robopages directory
222222
if let Ok(relative_path) = entry_path.strip_prefix(&path) {
223223
if relative_path.components().any(|component| {
224224
let comp_str = component.as_os_str().to_string_lossy();
225225
comp_str.starts_with(".") && comp_str != "." && comp_str != ".."
226226
}) {
227-
log::debug!("Skipping hidden file/directory");
227+
log::debug!("skipping hidden file/directory");
228228
continue;
229229
}
230230
}
@@ -239,13 +239,13 @@ impl Book {
239239
}
240240
}
241241
Err(e) => {
242-
log::error!("Error in glob: {}", e);
242+
log::error!("error in glob: {}", e);
243243
}
244244
}
245245
}
246246
}
247247

248-
log::info!("Found {} page paths", page_paths.len());
248+
log::debug!("found {} page paths", page_paths.len());
249249

250250
if page_paths.is_empty() {
251251
return Err(anyhow::anyhow!("no pages found in {:?}", path));

src/book/runtime.rs

Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -39,15 +39,6 @@ impl ExecutionFlavor {
3939
ExecutionFlavor::Error(message)
4040
}
4141

42-
pub fn to_string(&self) -> String {
43-
match self {
44-
Self::Shell(shell) => shell.to_string(),
45-
Self::Sudo => "sudo".to_string(),
46-
Self::Docker(image) => format!("docker {}", image),
47-
Self::Error(message) => message.to_string(),
48-
}
49-
}
50-
5142
fn get_current_shell() -> String {
5243
let shell_name = std::env::var("SHELL")
5344
.map(|s| s.split('/').last().unwrap_or("unknown").to_string())
@@ -111,6 +102,18 @@ impl ExecutionFlavor {
111102
}
112103
}
113104

105+
impl std::fmt::Display for ExecutionFlavor {
106+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
107+
let s = match self {
108+
Self::Shell(shell) => shell.to_string(),
109+
Self::Sudo => "sudo".to_string(),
110+
Self::Docker(image) => format!("docker {}", image),
111+
Self::Error(message) => message.to_string(),
112+
};
113+
write!(f, "{}", s)
114+
}
115+
}
116+
114117
#[derive(Debug, Serialize, Deserialize)]
115118
pub enum ExecutionContext {
116119
#[serde(rename = "cmdline")]
@@ -202,8 +205,8 @@ impl<'a> FunctionRef<'a> {
202205
let env_var = std::env::var(&env_var_name);
203206
let env_var_value = if let Ok(value) = env_var {
204207
value
205-
} else if var_default.is_some() {
206-
var_default.unwrap().to_string()
208+
} else if let Some(def) = var_default {
209+
def.to_string()
207210
} else {
208211
return Err(anyhow::anyhow!(
209212
"environment variable {} not set",
@@ -217,8 +220,12 @@ impl<'a> FunctionRef<'a> {
217220
env_var_value
218221
} else if let Some(value) = arguments.get(var_name) {
219222
// if the value is empty and there's a default value, use the default value
220-
if value.is_empty() && var_default.is_some() {
221-
var_default.unwrap().to_string()
223+
if value.is_empty() {
224+
if let Some(def) = var_default {
225+
def.to_string()
226+
} else {
227+
value.to_string()
228+
}
222229
} else {
223230
// otherwise, use the provided value
224231
value.to_string()

src/book/templates.rs

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -51,13 +51,14 @@ impl Template {
5151
}
5252
}
5353

54-
impl ToString for Template {
55-
fn to_string(&self) -> String {
56-
match self {
54+
impl std::fmt::Display for Template {
55+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
56+
let s = match self {
5757
Template::Basic => "basic".to_string(),
5858
Template::DockerImage => "docker_image".to_string(),
5959
Template::DockerBuild => "docker_build".to_string(),
60-
}
60+
};
61+
write!(f, "{}", s)
6162
}
6263
}
6364

src/cli/mod.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,15 @@ pub(crate) struct ServeArgs {
9292
/// Maximum number of parallel calls to execute. Leave to 0 to use all available cores.
9393
#[clap(long, default_value = "0")]
9494
workers: usize,
95+
/// Optional SSH connection string, if set commands will be executed over SSH on the given host.
96+
#[clap(long)]
97+
ssh: Option<String>,
98+
/// SSH key to use for authentication if --ssh is set.
99+
#[clap(long, default_value = "~/.ssh/id_ed25519")]
100+
ssh_key: String,
101+
/// SSH passphrase to unlock the key.
102+
#[clap(long)]
103+
ssh_key_passphrase: Option<String>,
95104
}
96105

97106
#[derive(Debug, Args)]
@@ -108,6 +117,15 @@ pub(crate) struct RunArgs {
108117
/// Execute the function without user interaction.
109118
#[clap(long, short = 'A')]
110119
auto: bool,
120+
/// Optional SSH connection string, if set commands will be executed over SSH on the given host.
121+
#[clap(long)]
122+
ssh: Option<String>,
123+
/// SSH key to use for authentication if --ssh is set.
124+
#[clap(long, default_value = "~/.ssh/id_ed25519")]
125+
ssh_key: String,
126+
/// SSH passphrase to unlock the key.
127+
#[clap(long)]
128+
ssh_key_passphrase: Option<String>,
111129
}
112130

113131
#[derive(Debug, Args)]

src/cli/run.rs

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,24 @@ use std::{collections::BTreeMap, sync::Arc};
22

33
use crate::{
44
book::{flavors::openai, Book},
5-
runtime::{self, prompt},
5+
runtime::{self, prompt, ssh::SSHConnection},
66
};
77

88
use super::RunArgs;
99

1010
pub(crate) async fn run(args: RunArgs) -> anyhow::Result<()> {
11+
// parse and validate SSH connection string if provided
12+
let ssh = if let Some(ssh_str) = args.ssh {
13+
// parse
14+
let conn = SSHConnection::from_str(&ssh_str, &args.ssh_key, args.ssh_key_passphrase)?;
15+
// make sure we can connect
16+
conn.test_connection().await?;
17+
18+
Some(conn)
19+
} else {
20+
None
21+
};
22+
1123
let book = Arc::new(Book::from_path(args.path, None)?);
1224
let function = book.get_function(&args.function)?;
1325

@@ -39,9 +51,7 @@ pub(crate) async fn run(args: RunArgs) -> anyhow::Result<()> {
3951
call_type: "function".to_string(),
4052
};
4153

42-
log::debug!("running function {:?}", function);
43-
44-
let result = runtime::execute_call(!args.auto, 10, book, call).await?;
54+
let result = runtime::execute_call(ssh, !args.auto, 10, book, call).await?;
4555

4656
println!("\n{}", result.content);
4757

src/cli/serve.rs

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,14 @@ use crate::book::{
1414
Book,
1515
};
1616
use crate::runtime;
17+
use crate::runtime::ssh::SSHConnection;
1718

1819
use super::ServeArgs;
1920

2021
struct AppState {
2122
max_running_tasks: usize,
2223
book: Arc<Book>,
24+
ssh: Option<SSHConnection>,
2325
}
2426

2527
async fn not_found() -> actix_web::Result<HttpResponse> {
@@ -65,7 +67,15 @@ async fn process_calls(
6567
state: web::Data<Arc<AppState>>,
6668
calls: web::Json<Vec<openai::Call>>,
6769
) -> actix_web::Result<HttpResponse> {
68-
match runtime::execute(false, state.book.clone(), calls.0, state.max_running_tasks).await {
70+
match runtime::execute(
71+
state.ssh.clone(),
72+
false,
73+
state.book.clone(),
74+
calls.0,
75+
state.max_running_tasks,
76+
)
77+
.await
78+
{
6979
Ok(resp) => Ok(HttpResponse::Ok().json(resp)),
7080
Err(e) => Err(actix_web::error::ErrorBadRequest(e)),
7181
}
@@ -76,6 +86,18 @@ pub(crate) async fn serve(args: ServeArgs) -> anyhow::Result<()> {
7686
log::warn!("external address specified, this is an unsafe configuration as no authentication is provided");
7787
}
7888

89+
// parse and validate SSH connection string if provided
90+
let ssh = if let Some(ssh_str) = args.ssh {
91+
// parse
92+
let conn = SSHConnection::from_str(&ssh_str, &args.ssh_key, args.ssh_key_passphrase)?;
93+
// make sure we can connect
94+
conn.test_connection().await?;
95+
96+
Some(conn)
97+
} else {
98+
None
99+
};
100+
79101
let book = Arc::new(Book::from_path(args.path, args.filter)?);
80102
if !args.lazy {
81103
for page in book.pages.values() {
@@ -103,6 +125,7 @@ pub(crate) async fn serve(args: ServeArgs) -> anyhow::Result<()> {
103125
let app_state = Arc::new(AppState {
104126
max_running_tasks,
105127
book,
128+
ssh,
106129
});
107130

108131
HttpServer::new(move || {

src/cli/view.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ pub(crate) async fn view(args: ViewArgs) -> anyhow::Result<()> {
1212
println!(" * {} : {}", function_name, function.description);
1313
println!(
1414
" running with: {}",
15-
ExecutionFlavor::for_function(&function)?.to_string()
15+
ExecutionFlavor::for_function(&function)?
1616
);
1717
println!(" parameters:");
1818
for (parameter_name, parameter) in &function.parameters {

0 commit comments

Comments
 (0)