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:
137
crates/shared/src/http.rs
Normal file
137
crates/shared/src/http.rs
Normal file
@@ -0,0 +1,137 @@
|
||||
//! `HttpService` — the v1.1.4 outbound-HTTP contract.
|
||||
//!
|
||||
//! Lives in `picloud-shared` (not `executor-core` or `manager-core`)
|
||||
//! so the Rhai bridge and the manager-core reqwest-backed impl can both
|
||||
//! depend on the same trait without dragging `executor-core` into
|
||||
//! `manager-core`'s dep graph — mirrors [`crate::kv`].
|
||||
//!
|
||||
//! Unlike KV/docs, `http::*` has no app-scoped data, so there is no
|
||||
//! cross-app isolation boundary to enforce here. `cx.app_id` is still
|
||||
//! forwarded for audit-log attribution and (future, v1.2) per-app rate
|
||||
//! limits. The load-bearing security mechanism is the SSRF deny-list
|
||||
//! applied to the *resolved IP* — that lives in the manager-core impl,
|
||||
//! not in this contract.
|
||||
//!
|
||||
//! Body encoding + per-method dispatch happen in the Rhai bridge before
|
||||
//! the request reaches this trait: the service receives an already-
|
||||
//! encoded body plus a `content_type`, so the impl stays a thin
|
||||
//! transport layer.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::SdkCallCx;
|
||||
|
||||
/// A fully-resolved outbound request. The bridge builds this from the
|
||||
/// script-facing `(url, body, opts)` arguments; the service backend
|
||||
/// turns it into a real network call.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HttpRequest {
|
||||
/// Uppercased HTTP method (`GET`, `POST`, …). The escape-hatch
|
||||
/// `http::request(method, …)` lets scripts pass arbitrary methods,
|
||||
/// so the impl validates this rather than the bridge.
|
||||
pub method: String,
|
||||
pub url: String,
|
||||
/// Caller-supplied headers, merged into the request. Header names
|
||||
/// are case-insensitive on the wire; stored verbatim here.
|
||||
pub headers: BTreeMap<String, String>,
|
||||
/// Already-encoded body. `None` means no body (GET/HEAD, or an
|
||||
/// explicit `()` body).
|
||||
pub body: Option<Vec<u8>>,
|
||||
/// Content-Type the bridge chose for `body` (e.g.
|
||||
/// `application/json`). Ignored when the caller set their own
|
||||
/// `Content-Type` header. `None` when there is no body.
|
||||
pub content_type: Option<String>,
|
||||
/// Total request budget in ms (already clamped to the 60s ceiling
|
||||
/// by the bridge).
|
||||
pub timeout_ms: u32,
|
||||
pub follow_redirects: bool,
|
||||
/// Max redirects to follow (already clamped to 10 by the bridge).
|
||||
pub max_redirects: u32,
|
||||
/// Script id for the default `User-Agent` and audit attribution.
|
||||
/// `None` when unavailable (the bridge always sets it from
|
||||
/// `cx`-adjacent context, but the field stays optional so the
|
||||
/// trait isn't coupled to how the id is sourced).
|
||||
pub script_id: Option<String>,
|
||||
}
|
||||
|
||||
/// The response shape the bridge turns into a Rhai map. JSON parsing of
|
||||
/// `body_raw` happens in the bridge (it needs the Rhai value types), so
|
||||
/// the service returns only the raw string + lowercased headers.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HttpResponse {
|
||||
pub status: u16,
|
||||
/// Header names lowercased (per the documented response shape).
|
||||
pub headers: BTreeMap<String, String>,
|
||||
pub body_raw: String,
|
||||
}
|
||||
|
||||
/// Failure modes surfaced to the Rhai bridge. The bridge prefixes each
|
||||
/// `Display` string with `"http: "`. **None of these may leak the
|
||||
/// resolved IP** — the SSRF reason is a CIDR-category label only.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum HttpError {
|
||||
/// Caller principal lacked `AppHttpRequest`. Only raised when
|
||||
/// `cx.principal.is_some()`; public-HTTP scripts skip the check.
|
||||
#[error("forbidden")]
|
||||
Forbidden,
|
||||
|
||||
/// URL failed to parse, or carried no host.
|
||||
#[error("invalid url: {0}")]
|
||||
InvalidUrl(String),
|
||||
|
||||
/// Scheme other than http/https (file, ftp, gopher, …).
|
||||
#[error("scheme not allowed: {0}")]
|
||||
BlockedScheme(String),
|
||||
|
||||
/// Destination port is on the explicit block list (22, 25, 465, 587).
|
||||
#[error("port not allowed: {0}")]
|
||||
BlockedPort(u16),
|
||||
|
||||
/// Resolved IP hit the SSRF deny-list. `reason` is a CIDR-category
|
||||
/// label (e.g. "loopback", "private", "link-local") — never the IP.
|
||||
#[error("blocked by SSRF policy: {0}")]
|
||||
Ssrf(String),
|
||||
|
||||
/// The request exceeded the wall-clock budget.
|
||||
#[error("request timed out")]
|
||||
Timeout,
|
||||
|
||||
/// Request or response body exceeded the configured size cap.
|
||||
/// `which` is `"request"` or `"response"`.
|
||||
#[error("{0} body exceeds size limit")]
|
||||
BodyTooLarge(&'static str),
|
||||
|
||||
/// DNS / connect / TLS failure. The message is generic and MUST NOT
|
||||
/// contain the resolved IP.
|
||||
#[error("{0}")]
|
||||
Network(String),
|
||||
|
||||
/// Anything else the impl wants to surface (still safe to show a
|
||||
/// script).
|
||||
#[error("{0}")]
|
||||
Backend(String),
|
||||
}
|
||||
|
||||
/// Stub used by the executor-core test harness so engine integration
|
||||
/// tests (which don't make real network calls) can construct a
|
||||
/// `Services` bundle. Every call errors so accidental use surfaces.
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
pub struct NoopHttpService;
|
||||
|
||||
#[async_trait]
|
||||
impl HttpService for NoopHttpService {
|
||||
async fn request(&self, _cx: &SdkCallCx, _req: HttpRequest) -> Result<HttpResponse, HttpError> {
|
||||
Err(HttpError::Network("http is not wired in".into()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Outbound-HTTP contract. A single generic `request` method funnels
|
||||
/// every verb (`get`/`post`/…/`request`); the bridge maps the
|
||||
/// script-facing surface onto it.
|
||||
#[async_trait]
|
||||
pub trait HttpService: Send + Sync {
|
||||
async fn request(&self, cx: &SdkCallCx, req: HttpRequest) -> Result<HttpResponse, HttpError>;
|
||||
}
|
||||
Reference in New Issue
Block a user