feat(v1.1.4): outbound HTTP SDK + cron triggers
HTTP (`http::*`):
- `HttpService` trait (picloud-shared) + reqwest-backed `HttpServiceImpl`
(manager-core), wired into the `Services` bundle.
- SSRF deny-list applied to the resolved IP via a custom reqwest
`dns_resolver` (covers every redirect hop + defeats DNS rebinding) plus
a literal-IP check at URL-parse time. Scheme/port restrictions, request
+ response body caps (stream-with-cap), layered timeout. Error reason is
a CIDR category, never the IP. `PICLOUD_HTTP_ALLOW_PRIVATE` dev override
(logs a startup warning).
- Rhai bridge with three-arg split `verb(url, body, opts)` (resolves the
brief's body-vs-opts contradiction; unknown opt keys throw). Body
dispatch by type; response `#{status,headers,body,body_raw}` with JSON
auto-parse; non-2xx does not throw.
- `Capability::AppHttpRequest` → existing `script:write` scope (no new
Scope variant). `SdkCallCx` gains `script_id` (attribution + User-Agent).
Cron triggers (4th trigger kind):
- Migration 0017 widens the kind/source_kind CHECKs and adds
`cron_trigger_details`. `cron`/`chrono-tz` parse + validate 6-field
schedules and IANA timezones.
- `spawn_cron_scheduler` polls due triggers and enqueues to the universal
outbox; the dispatcher delivers them (one-line match-arm extension).
Catch-up fires exactly once per trigger per tick, not once per missed
window. `ctx.event.cron` for handlers.
- `POST /api/v1/admin/apps/{id}/triggers/cron` reuses the v1.1.3
cross-app + kind!=module target check.
- Dashboard: admin-gated Triggers tab (cron create form + list).
Follow-ups: redact module backend errors at the resolver boundary (log
original at error level); pin `rhai = "=1.24"`; CHANGELOG incl. retroactive
v1.1.3 cross-app-trigger security note. Version bumps: workspace 1.1.4,
SDK 1.5, dashboard 0.10.0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -144,6 +144,7 @@ impl Engine {
|
||||
// capture cheap clones of the cx for use at script-call time.
|
||||
let cx = Arc::new(SdkCallCx {
|
||||
app_id: req.app_id,
|
||||
script_id: req.script_id,
|
||||
principal: req.principal.clone(),
|
||||
execution_id: req.execution_id,
|
||||
request_id: req.request_id,
|
||||
@@ -388,6 +389,23 @@ fn trigger_event_to_dynamic(event: &TriggerEvent) -> Dynamic {
|
||||
);
|
||||
m.insert("docs".into(), docs_map.into());
|
||||
}
|
||||
TriggerEvent::Cron {
|
||||
schedule,
|
||||
timezone,
|
||||
scheduled_at,
|
||||
fired_at,
|
||||
} => {
|
||||
// `ctx.event.op` is always "tick" for cron (the only op a
|
||||
// schedule produces). Mirrors the docs/v1.1.x-design-notes
|
||||
// §7 shape.
|
||||
m.insert("op".into(), "tick".into());
|
||||
let mut cron_map = Map::new();
|
||||
cron_map.insert("schedule".into(), schedule.clone().into());
|
||||
cron_map.insert("timezone".into(), timezone.clone().into());
|
||||
cron_map.insert("scheduled_at".into(), scheduled_at.to_rfc3339().into());
|
||||
cron_map.insert("fired_at".into(), fired_at.to_rfc3339().into());
|
||||
m.insert("cron".into(), cron_map.into());
|
||||
}
|
||||
TriggerEvent::DeadLetter {
|
||||
dead_letter_id,
|
||||
original,
|
||||
|
||||
@@ -331,10 +331,22 @@ impl ModuleResolver for PicloudModuleResolver {
|
||||
)));
|
||||
}
|
||||
Err(e) => {
|
||||
// v1.1.4 §10a: redact the backend error before it
|
||||
// reaches a script. In public-HTTP context (principal:
|
||||
// None) the verbatim message (e.g. "connection refused")
|
||||
// leaks internal infrastructure shape. Log the original
|
||||
// at error level for operators; surface a stable generic.
|
||||
tracing::error!(
|
||||
target = "picloud::modules",
|
||||
app_id = %self.cx.app_id,
|
||||
module = path,
|
||||
error = %e,
|
||||
"module backend error"
|
||||
);
|
||||
return Err(Box::new(EvalAltResult::ErrorInModule(
|
||||
path.to_string(),
|
||||
Box::new(EvalAltResult::ErrorRuntime(
|
||||
format!("module backend error: {e}").into(),
|
||||
"module backend unavailable; check server logs".into(),
|
||||
pos,
|
||||
)),
|
||||
pos,
|
||||
|
||||
391
crates/executor-core/src/sdk/http.rs
Normal file
391
crates/executor-core/src/sdk/http.rs
Normal file
@@ -0,0 +1,391 @@
|
||||
//! `http::` Rhai bridge — outbound HTTP from scripts (v1.1.4).
|
||||
//!
|
||||
//! ```rhai
|
||||
//! let r = http::get("https://api.example.com/users/123");
|
||||
//! let r = http::get(url, #{ headers: #{ "Authorization": "Bearer x" }, timeout_ms: 5000 });
|
||||
//! let r = http::post(url, #{ text: "hello" }); // Map body → JSON
|
||||
//! let r = http::post(url, "raw", #{ headers: #{ ... } }); // String body → text/plain
|
||||
//! let r = http::post_form(url, #{ a: "1", b: "2" }); // form-encoded
|
||||
//! let r = http::request("OPTIONS", url);
|
||||
//! ```
|
||||
//!
|
||||
//! **Argument shape (v1.1.4 decision):** body and options are separate
|
||||
//! positional arguments — `verb(url, body, opts)` — not body-inside-
|
||||
//! opts. This keeps the unknown-opt-key typo guard intact and resolves
|
||||
//! the brief's internal contradiction (its Slack example passed a bare
|
||||
//! body map). The `opts` vocabulary is exactly
|
||||
//! `{headers, timeout_ms, follow_redirects, max_redirects}`; any other
|
||||
//! key throws.
|
||||
//!
|
||||
//! Body dispatch (positional `body`): Map/Array → JSON +
|
||||
//! `application/json`; String → raw + `text/plain`; Unit `()` → no
|
||||
//! body. GET/HEAD ignore any body.
|
||||
//!
|
||||
//! Response is a Rhai map `#{ status, headers, body, body_raw }`:
|
||||
//! `body` is the parsed JSON when the response is `application/json`
|
||||
//! and parses; `()` for an empty body; otherwise the raw string.
|
||||
//!
|
||||
//! Errors follow `docs/sdk-shape.md`: network/timeout/SSRF/size failures
|
||||
//! throw (`"http: <message>"`); a non-2xx status does NOT throw — the
|
||||
//! response map is returned, fetch-style.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use picloud_shared::{HttpError, HttpRequest, HttpResponse, HttpService, SdkCallCx, Services};
|
||||
use rhai::{Dynamic, Engine as RhaiEngine, EvalAltResult, Map, Module};
|
||||
use tokio::runtime::Handle as TokioHandle;
|
||||
|
||||
use super::bridge::{dynamic_to_json, json_to_dynamic};
|
||||
|
||||
/// Bridge-side defaults (the service clamps server-side too). The
|
||||
/// `MAX_*` ceilings stay `i64` because they're compared against the
|
||||
/// raw `i64` the script passed (so an over-limit value is rejected, not
|
||||
/// truncated); the defaults are `u32` to match the `Opts` fields.
|
||||
const DEFAULT_TIMEOUT_MS: u32 = 30_000;
|
||||
const MAX_TIMEOUT_MS: i64 = 60_000;
|
||||
const DEFAULT_MAX_REDIRECTS: u32 = 5;
|
||||
const MAX_REDIRECTS: i64 = 10;
|
||||
|
||||
const ALLOWED_OPT_KEYS: [&str; 4] = ["headers", "timeout_ms", "follow_redirects", "max_redirects"];
|
||||
|
||||
pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<SdkCallCx>) {
|
||||
let svc = services.http.clone();
|
||||
let mut module = Module::new();
|
||||
|
||||
// Bodyless verbs: (url) / (url, opts).
|
||||
for verb in ["get", "head"] {
|
||||
register_bodyless(&mut module, verb, &svc, &cx);
|
||||
}
|
||||
// Body verbs: (url) / (url, body) / (url, body, opts).
|
||||
for verb in ["post", "put", "patch", "delete"] {
|
||||
register_body(&mut module, verb, &svc, &cx);
|
||||
}
|
||||
register_post_form(&mut module, &svc, &cx);
|
||||
register_request(&mut module, &svc, &cx);
|
||||
|
||||
engine.register_static_module("http", module.into());
|
||||
}
|
||||
|
||||
fn register_bodyless(
|
||||
module: &mut Module,
|
||||
verb: &'static str,
|
||||
svc: &Arc<dyn HttpService>,
|
||||
cx: &Arc<SdkCallCx>,
|
||||
) {
|
||||
{
|
||||
let (svc, cx) = (svc.clone(), cx.clone());
|
||||
module.set_native_fn(verb, move |url: &str| {
|
||||
invoke(&svc, &cx, verb, url, None, None)
|
||||
});
|
||||
}
|
||||
{
|
||||
let (svc, cx) = (svc.clone(), cx.clone());
|
||||
module.set_native_fn(verb, move |url: &str, opts: Map| {
|
||||
invoke(&svc, &cx, verb, url, None, Some(&opts))
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn register_body(
|
||||
module: &mut Module,
|
||||
verb: &'static str,
|
||||
svc: &Arc<dyn HttpService>,
|
||||
cx: &Arc<SdkCallCx>,
|
||||
) {
|
||||
{
|
||||
let (svc, cx) = (svc.clone(), cx.clone());
|
||||
module.set_native_fn(verb, move |url: &str| {
|
||||
invoke(&svc, &cx, verb, url, None, None)
|
||||
});
|
||||
}
|
||||
{
|
||||
let (svc, cx) = (svc.clone(), cx.clone());
|
||||
module.set_native_fn(verb, move |url: &str, body: Dynamic| {
|
||||
invoke(&svc, &cx, verb, url, Some(body), None)
|
||||
});
|
||||
}
|
||||
{
|
||||
let (svc, cx) = (svc.clone(), cx.clone());
|
||||
module.set_native_fn(verb, move |url: &str, body: Dynamic, opts: Map| {
|
||||
invoke(&svc, &cx, verb, url, Some(body), Some(&opts))
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn register_post_form(module: &mut Module, svc: &Arc<dyn HttpService>, cx: &Arc<SdkCallCx>) {
|
||||
{
|
||||
let (svc, cx) = (svc.clone(), cx.clone());
|
||||
module.set_native_fn("post_form", move |url: &str, form: Map| {
|
||||
invoke_form(&svc, &cx, url, &form, None)
|
||||
});
|
||||
}
|
||||
{
|
||||
let (svc, cx) = (svc.clone(), cx.clone());
|
||||
module.set_native_fn("post_form", move |url: &str, form: Map, opts: Map| {
|
||||
invoke_form(&svc, &cx, url, &form, Some(&opts))
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn register_request(module: &mut Module, svc: &Arc<dyn HttpService>, cx: &Arc<SdkCallCx>) {
|
||||
{
|
||||
let (svc, cx) = (svc.clone(), cx.clone());
|
||||
module.set_native_fn("request", move |method: &str, url: &str| {
|
||||
invoke(&svc, &cx, method, url, None, None)
|
||||
});
|
||||
}
|
||||
{
|
||||
let (svc, cx) = (svc.clone(), cx.clone());
|
||||
module.set_native_fn("request", move |method: &str, url: &str, body: Dynamic| {
|
||||
invoke(&svc, &cx, method, url, Some(body), None)
|
||||
});
|
||||
}
|
||||
{
|
||||
let (svc, cx) = (svc.clone(), cx.clone());
|
||||
module.set_native_fn(
|
||||
"request",
|
||||
move |method: &str, url: &str, body: Dynamic, opts: Map| {
|
||||
invoke(&svc, &cx, method, url, Some(body), Some(&opts))
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Parsed `opts` map.
|
||||
struct Opts {
|
||||
headers: BTreeMap<String, String>,
|
||||
timeout_ms: u32,
|
||||
follow_redirects: bool,
|
||||
max_redirects: u32,
|
||||
}
|
||||
|
||||
impl Default for Opts {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
headers: BTreeMap::new(),
|
||||
timeout_ms: DEFAULT_TIMEOUT_MS,
|
||||
follow_redirects: true,
|
||||
max_redirects: DEFAULT_MAX_REDIRECTS,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_opts(opts: Option<&Map>) -> Result<Opts, Box<EvalAltResult>> {
|
||||
let mut out = Opts::default();
|
||||
let Some(map) = opts else {
|
||||
return Ok(out);
|
||||
};
|
||||
for key in map.keys() {
|
||||
if !ALLOWED_OPT_KEYS.contains(&key.as_str()) {
|
||||
return Err(err(format!("unknown option key: {key}")));
|
||||
}
|
||||
}
|
||||
if let Some(h) = map.get("headers") {
|
||||
let hm = h
|
||||
.clone()
|
||||
.try_cast::<Map>()
|
||||
.ok_or_else(|| err("headers must be a map".to_string()))?;
|
||||
for (k, v) in hm {
|
||||
out.headers.insert(k.to_string(), dyn_to_string(&v));
|
||||
}
|
||||
}
|
||||
if let Some(t) = map.get("timeout_ms") {
|
||||
let ms = t
|
||||
.as_int()
|
||||
.map_err(|_| err("timeout_ms must be an integer".to_string()))?;
|
||||
if ms > MAX_TIMEOUT_MS {
|
||||
return Err(err(format!(
|
||||
"timeout_ms {ms} exceeds the {MAX_TIMEOUT_MS}ms maximum"
|
||||
)));
|
||||
}
|
||||
if ms > 0 {
|
||||
out.timeout_ms = u32::try_from(ms).unwrap_or(u32::MAX);
|
||||
}
|
||||
}
|
||||
if let Some(f) = map.get("follow_redirects") {
|
||||
out.follow_redirects = f
|
||||
.as_bool()
|
||||
.map_err(|_| err("follow_redirects must be a bool".to_string()))?;
|
||||
}
|
||||
if let Some(m) = map.get("max_redirects") {
|
||||
let n = m
|
||||
.as_int()
|
||||
.map_err(|_| err("max_redirects must be an integer".to_string()))?;
|
||||
if n > MAX_REDIRECTS {
|
||||
return Err(err(format!(
|
||||
"max_redirects {n} exceeds the {MAX_REDIRECTS} maximum"
|
||||
)));
|
||||
}
|
||||
out.max_redirects = u32::try_from(n.max(0)).unwrap_or(0);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Encoded request body + the content-type chosen for it.
|
||||
type EncodedBody = (Option<Vec<u8>>, Option<String>);
|
||||
|
||||
/// Dispatch a positional body by Rhai type. Returns the encoded bytes +
|
||||
/// the chosen content-type. GET/HEAD callers pass `body = None`, so
|
||||
/// this is never reached for them.
|
||||
fn dispatch_body(body: Dynamic) -> Result<EncodedBody, Box<EvalAltResult>> {
|
||||
if body.is_unit() {
|
||||
return Ok((None, None));
|
||||
}
|
||||
if body.is_string() {
|
||||
let s = body.into_string().unwrap_or_default();
|
||||
return Ok((Some(s.into_bytes()), Some("text/plain".to_string())));
|
||||
}
|
||||
if body.is_map() || body.is_array() {
|
||||
let json = dynamic_to_json(&body);
|
||||
let bytes = serde_json::to_vec(&json)
|
||||
.map_err(|e| err(format!("could not encode JSON body: {e}")))?;
|
||||
return Ok((Some(bytes), Some("application/json".to_string())));
|
||||
}
|
||||
// Scalars (int/float/bool) → JSON-encode for consistency.
|
||||
let json = dynamic_to_json(&body);
|
||||
let bytes =
|
||||
serde_json::to_vec(&json).map_err(|e| err(format!("could not encode body: {e}")))?;
|
||||
Ok((Some(bytes), Some("application/json".to_string())))
|
||||
}
|
||||
|
||||
#[allow(clippy::needless_pass_by_value)]
|
||||
fn invoke(
|
||||
svc: &Arc<dyn HttpService>,
|
||||
cx: &Arc<SdkCallCx>,
|
||||
method: &str,
|
||||
url: &str,
|
||||
body: Option<Dynamic>,
|
||||
opts: Option<&Map>,
|
||||
) -> Result<Dynamic, Box<EvalAltResult>> {
|
||||
let opts = parse_opts(opts)?;
|
||||
let method_uc = method.to_ascii_uppercase();
|
||||
let bodyless = matches!(method_uc.as_str(), "GET" | "HEAD");
|
||||
let (encoded, content_type) = if bodyless {
|
||||
(None, None)
|
||||
} else if let Some(b) = body {
|
||||
dispatch_body(b)?
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
|
||||
let req = HttpRequest {
|
||||
method: method_uc,
|
||||
url: url.to_string(),
|
||||
headers: opts.headers,
|
||||
body: encoded,
|
||||
content_type,
|
||||
timeout_ms: opts.timeout_ms,
|
||||
follow_redirects: opts.follow_redirects,
|
||||
max_redirects: opts.max_redirects,
|
||||
script_id: Some(cx.script_id.to_string()),
|
||||
};
|
||||
let resp = block_on(svc, cx, req)?;
|
||||
Ok(response_to_dynamic(&resp))
|
||||
}
|
||||
|
||||
#[allow(clippy::needless_pass_by_value)]
|
||||
fn invoke_form(
|
||||
svc: &Arc<dyn HttpService>,
|
||||
cx: &Arc<SdkCallCx>,
|
||||
url: &str,
|
||||
form: &Map,
|
||||
opts: Option<&Map>,
|
||||
) -> Result<Dynamic, Box<EvalAltResult>> {
|
||||
let opts = parse_opts(opts)?;
|
||||
let mut serializer = url::form_urlencoded::Serializer::new(String::new());
|
||||
for (k, v) in form {
|
||||
serializer.append_pair(k.as_str(), &dyn_to_string(v));
|
||||
}
|
||||
let encoded = serializer.finish();
|
||||
|
||||
let req = HttpRequest {
|
||||
method: "POST".to_string(),
|
||||
url: url.to_string(),
|
||||
headers: opts.headers,
|
||||
body: Some(encoded.into_bytes()),
|
||||
content_type: Some("application/x-www-form-urlencoded".to_string()),
|
||||
timeout_ms: opts.timeout_ms,
|
||||
follow_redirects: opts.follow_redirects,
|
||||
max_redirects: opts.max_redirects,
|
||||
script_id: Some(cx.script_id.to_string()),
|
||||
};
|
||||
let resp = block_on(svc, cx, req)?;
|
||||
Ok(response_to_dynamic(&resp))
|
||||
}
|
||||
|
||||
fn response_to_dynamic(resp: &HttpResponse) -> Dynamic {
|
||||
let mut m = Map::new();
|
||||
m.insert("status".into(), i64::from(resp.status).into());
|
||||
|
||||
let mut headers = Map::new();
|
||||
let mut content_type = String::new();
|
||||
for (k, v) in &resp.headers {
|
||||
if k == "content-type" {
|
||||
content_type.clone_from(v);
|
||||
}
|
||||
headers.insert(k.clone().into(), v.clone().into());
|
||||
}
|
||||
m.insert("headers".into(), headers.into());
|
||||
|
||||
// `body`: parsed JSON when the response is JSON and parses; () when
|
||||
// empty; otherwise the raw string.
|
||||
let body = if resp.body_raw.is_empty() {
|
||||
Dynamic::UNIT
|
||||
} else if content_type
|
||||
.to_ascii_lowercase()
|
||||
.starts_with("application/json")
|
||||
{
|
||||
match serde_json::from_str::<serde_json::Value>(&resp.body_raw) {
|
||||
Ok(json) => json_to_dynamic(json),
|
||||
Err(_) => resp.body_raw.clone().into(),
|
||||
}
|
||||
} else {
|
||||
resp.body_raw.clone().into()
|
||||
};
|
||||
m.insert("body".into(), body);
|
||||
m.insert("body_raw".into(), resp.body_raw.clone().into());
|
||||
m.into()
|
||||
}
|
||||
|
||||
fn dyn_to_string(v: &Dynamic) -> String {
|
||||
if v.is_string() {
|
||||
v.clone().into_string().unwrap_or_default()
|
||||
} else {
|
||||
v.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
// Rhai's native-fn error channel is `Box<EvalAltResult>`, so these
|
||||
// helpers return the boxed form the call sites need.
|
||||
#[allow(clippy::unnecessary_box_returns)]
|
||||
fn err(msg: String) -> Box<EvalAltResult> {
|
||||
EvalAltResult::ErrorRuntime(format!("http: {msg}").into(), rhai::Position::NONE).into()
|
||||
}
|
||||
|
||||
/// Run the async service call from the synchronous Rhai context. Same
|
||||
/// pattern as `kv`/`docs`: the script runs under `spawn_blocking`, so a
|
||||
/// runtime handle is reachable and blocking on it is correct.
|
||||
fn block_on(
|
||||
svc: &Arc<dyn HttpService>,
|
||||
cx: &Arc<SdkCallCx>,
|
||||
req: HttpRequest,
|
||||
) -> Result<HttpResponse, Box<EvalAltResult>> {
|
||||
let handle = TokioHandle::try_current().map_err(|e| -> Box<EvalAltResult> {
|
||||
EvalAltResult::ErrorRuntime(
|
||||
format!("http: no tokio runtime available: {e}").into(),
|
||||
rhai::Position::NONE,
|
||||
)
|
||||
.into()
|
||||
})?;
|
||||
let svc = svc.clone();
|
||||
let cx = cx.clone();
|
||||
handle
|
||||
.block_on(async move { svc.request(&cx, req).await })
|
||||
.map_err(map_http_err)
|
||||
}
|
||||
|
||||
#[allow(clippy::unnecessary_box_returns)]
|
||||
fn map_http_err(e: HttpError) -> Box<EvalAltResult> {
|
||||
EvalAltResult::ErrorRuntime(format!("http: {e}").into(), rhai::Position::NONE).into()
|
||||
}
|
||||
@@ -15,6 +15,7 @@ pub mod bridge;
|
||||
pub mod cx;
|
||||
pub mod dead_letters;
|
||||
pub mod docs;
|
||||
pub mod http;
|
||||
pub mod kv;
|
||||
pub mod stdlib;
|
||||
|
||||
@@ -35,5 +36,6 @@ use rhai::Engine as RhaiEngine;
|
||||
pub fn register_all(engine: &mut RhaiEngine, services: &Services, cx: Arc<SdkCallCx>) {
|
||||
kv::register(engine, services, cx.clone());
|
||||
docs::register(engine, services, cx.clone());
|
||||
dead_letters::register(engine, services, cx);
|
||||
dead_letters::register(engine, services, cx.clone());
|
||||
http::register(engine, services, cx);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user