Files
windmill/backend/windmill-trigger-websocket/src/lib.rs
T
Ruben Fiszel 6f363163df fix(websocket-trigger): honor HTTPS_PROXY/HTTP_PROXY/NO_PROXY (#9324)
* feat(websocket-trigger): honor HTTPS_PROXY/HTTP_PROXY/NO_PROXY (WIN-1988)

`tokio_tungstenite::connect_async` opens a raw TCP socket and ignores
the standard outbound-proxy env vars, so deployments behind a forward
HTTP proxy can't reach the WebSocket endpoint and Test Connection
times out after 30s.

Add a small `proxy` module that resolves the right proxy URL for the
target host (HTTPS_PROXY for wss://, HTTP_PROXY for ws://, NO_PROXY
exclusions, ALL_PROXY fallback, lowercase variants), opens an HTTP
CONNECT tunnel when one applies, and hands the resulting TcpStream to
`client_async_tls_with_config` for the TLS + WS handshake. Direct
connect remains the default when no proxy env is set.

Unit tests cover NO_PROXY matching, proxy URL parsing (including IPv6
literals and basic-auth userinfo), and the CONNECT handshake itself
against an in-process fake proxy (success, basic-auth header, 407
rejection).

Fixes WIN-1988

* refactor(websocket-trigger): reduce blast radius and reuse existing logic

Follow-up to the proxy support change. Three things:

1. Skip the new code path entirely when no proxy is configured.
   `connect_async_with_proxy` now checks the env-var snapshots up front
   and delegates straight to `tokio_tungstenite::connect_async` if
   neither `HTTP_PROXY` nor `HTTPS_PROXY` is set. Same fall-through
   applies when proxy env is set but `NO_PROXY` excludes the host or
   the proxy URL doesn't parse. Non-proxied deployments now exercise
   exactly the previous code path.

2. Move the `NO_PROXY` / `HTTP_PROXY` / `HTTPS_PROXY` env-var snapshots
   from `windmill-worker::worker` into `windmill-common`. The worker's
   `PROXY_ENVS` static now reads from there, and the websocket trigger
   reads from the same source — one place reads the env, one source
   of truth for both call sites.

3. Replace the hand-rolled proxy-URL parser with `url::Url::parse`
   (already a workspace dep, used across the codebase). Half the LoC
   and handles edge cases (userinfo percent-encoding, IPv6 literals,
   path/query stripping) via the well-tested crate instead of by hand.

All 13 proxy unit tests still pass. `cargo check` is clean.

* fix(websocket-trigger): unbreak EE build + trim proxy tests

- Re-export `NO_PROXY` / `HTTP_PROXY` / `HTTPS_PROXY` from
  `windmill-worker::worker` (via `pub use windmill_common::...`) so the
  EE `otel_tracing_proxy_ee` module's `use crate::{HTTPS_PROXY, ...}`
  resolves like it did before. Fixes the `check_ee_full` / `cargo_test`
  CI failures from the previous commit.

- Trim the proxy tests to one un-ignored canary
  (`http_connect_tunnel_sends_well_formed_request_and_unwraps_stream`)
  that exercises the actual on-wire CONNECT handshake plus byte-perfect
  tunnel passthrough. The NO_PROXY-matching, URL-parsing, and edge-case
  tunnel tests are kept under `#[ignore]` for manual debugging
  (`cargo test -- --ignored`) since they're either delegated to
  `url::Url::parse` or trivial string matching — low ROI on every CI run.
2026-05-26 06:17:07 +00:00

147 lines
4.2 KiB
Rust

use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use serde_json::value::RawValue;
use sqlx::{types::Json as SqlxJson, FromRow};
use windmill_api_auth::ApiAuthed;
use windmill_common::{
error::{Error, Result},
jobs::JobTriggerKind,
triggers::{TriggerKind, TriggerMetadata},
worker::to_raw_value,
DB,
};
use windmill_queue::PushArgsOwned;
use windmill_trigger::trigger_helpers::{
trigger_runnable_and_wait_for_raw_result_with_error_ctx, TriggerJobArgs,
};
pub mod handler;
pub mod listener;
pub mod proxy;
#[derive(Copy, Clone)]
pub struct WebsocketTrigger;
impl TriggerJobArgs for WebsocketTrigger {
type Payload = String;
const TRIGGER_KIND: TriggerKind = TriggerKind::Websocket;
fn v1_payload_fn(payload: &Self::Payload) -> HashMap<String, Box<RawValue>> {
HashMap::from([("msg".to_string(), to_raw_value(&payload))])
}
}
fn default_filter_logic() -> String {
"and".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WebsocketHeartbeat {
pub interval_secs: u64,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub state_field: Option<String>,
}
#[derive(Debug, Clone, FromRow, Serialize, Deserialize)]
pub struct WebsocketConfig {
pub url: String,
#[serde(default)]
pub filters: Vec<SqlxJson<Box<RawValue>>>,
#[serde(default = "default_filter_logic")]
pub filter_logic: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub initial_messages: Option<Vec<SqlxJson<Box<RawValue>>>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub url_runnable_args: Option<SqlxJson<Box<RawValue>>>,
#[serde(default)]
pub can_return_message: bool,
#[serde(default)]
pub can_return_error_result: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub heartbeat: Option<SqlxJson<WebsocketHeartbeat>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WebsocketConfigRequest {
url: String,
filters: Vec<serde_json::Value>,
#[serde(default = "default_filter_logic")]
filter_logic: String,
initial_messages: Option<Vec<serde_json::Value>>,
url_runnable_args: Option<serde_json::Value>,
can_return_message: bool,
can_return_error_result: bool,
pub heartbeat: Option<WebsocketHeartbeat>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TestWebsocketConfig {
url: String,
url_runnable_args: Option<serde_json::Value>,
}
pub fn value_to_args_hashmap(
args: Option<&Box<RawValue>>,
) -> Result<HashMap<String, Box<RawValue>>> {
let args = if let Some(args) = args {
let args_map: Option<HashMap<String, serde_json::Value>> = serde_json::from_str(args.get())
.map_err(|e| Error::BadRequest(format!("invalid json: {}", e)))?;
args_map
.unwrap_or_else(HashMap::new)
.into_iter()
.map(|(k, v)| {
let raw_value = serde_json::value::to_raw_value(&v).map_err(|e| {
Error::BadRequest(format!("failed to convert to raw value: {}", e))
})?;
Ok((k, raw_value))
})
.collect::<Result<HashMap<String, Box<RawValue>>>>()
} else {
Ok(HashMap::new())
}?;
Ok(args)
}
pub async fn get_url_from_runnable_value(
path: &str,
is_flow: bool,
db: &DB,
authed: ApiAuthed,
args: Option<&Box<RawValue>>,
workspace_id: &str,
) -> Result<String> {
tracing::info!(
"Running {} {} to get WebSocket URL",
if is_flow { "flow" } else { "script" },
path
);
let args = value_to_args_hashmap(args)?;
let result = trigger_runnable_and_wait_for_raw_result_with_error_ctx(
db,
None,
authed,
workspace_id,
path,
is_flow,
PushArgsOwned { args, extra: None },
None,
None,
None,
"".to_string(), // doesn't matter as no retry/error handler
TriggerMetadata::new(Some(path.to_owned()), JobTriggerKind::Websocket),
)
.await?;
serde_json::from_str::<String>(result.get()).map_err(|_| {
Error::BadConfig(format!(
"{} {} did not return a string",
if is_flow { "Flow" } else { "Script" },
path,
))
})
}