返回 CodeWhale
targets.rs
根目录 / crates / tui / src / runtime_api / targets.rs
1 //! Remote / cloud attach surface for native clients (APPS-50).
2 //!
3 //! A "target" is a Codewhale runtime a client talks to. This server is always
4 //! the local target; remote targets are other `codewhale serve --http`
5 //! endpoints, and the *client* owns that registry — a runtime never persists
6 //! peer endpoints, relays sessions, or mints a second process owner. SSH
7 //! workspaces and hosted cloud computers are control-plane features, so those
8 //! routes answer honestly (`supported: false`) and refuse writes with 501
9 //! instead of 404 — a client can render real disabled states with reasons.
10 //!
11 //! Routes:
12 //! GET /v1/targets — this runtime's target record
13 //! POST /v1/targets — 501: target registry is client-owned
14 //! POST /v1/targets/switch — 501: client-owned; never mid-turn server-side
15 //! GET /v1/remote — this runtime's reachability posture
16 //! POST /v1/remote/connect — probe a candidate remote runtime endpoint
17 //! GET /v1/ssh — SSH workspaces: control-plane owned
18 //! POST /v1/ssh/connect — 501
19 //! GET /v1/cloud — hosted computers: control-plane owned
20 //! POST /v1/cloud/attach — 501
21
22 use std::net::IpAddr;
23 use std::time::Duration;
24
25 use axum::Json;
26 use axum::extract::State;
27 use serde::Deserialize;
28 use serde_json::{Value, json};
29
30 use super::{ApiError, RuntimeApiState};
31
32 const PROBE_TIMEOUT: Duration = Duration::from_secs(5);
33 const PROBE_MAX_BYTES: usize = 64 * 1024;
34 const CONTROL_PLANE_OWNER: &str = "codewhale-control-plane";
35
36 fn self_target(state: &RuntimeApiState) -> Value {
37 json!({
38 "id": "local",
39 "kind": "local",
40 "current": true,
41 "endpoint": format!("http://{}:{}", display_host(&state.bind_host), state.bind_port),
42 "auth_required": state.auth_required,
43 "service": "codewhale-runtime-api",
44 "codewhale_version": env!("CARGO_PKG_VERSION"),
45 "state": "ready",
46 })
47 }
48
49 fn display_host(host: &str) -> String {
50 if host.parse::<IpAddr>().is_ok_and(|ip| ip.is_ipv6()) {
51 format!("[{host}]")
52 } else {
53 host.to_string()
54 }
55 }
56
57 fn unsupported_surface(feature: &str) -> Value {
58 json!({
59 "supported": false,
60 "owner": CONTROL_PLANE_OWNER,
61 "reason": format!(
62 "{feature} is owned by the Codewhale control plane (managed apps); this runtime does not broker it"
63 ),
64 })
65 }
66
67 pub(super) async fn list_targets(State(state): State<RuntimeApiState>) -> Json<Value> {
68 Json(json!({
69 "targets": [self_target(&state)],
70 // Remote attach means the client points at another runtime's /v1 API;
71 // nothing server-side needs to (or may) switch.
72 "remote": {
73 "supported": true,
74 "attach": "client",
75 "probe": "POST /v1/remote/connect",
76 },
77 "ssh": unsupported_surface("SSH remote workspaces"),
78 "cloud": unsupported_surface("Hosted cloud computers"),
79 }))
80 }
81
82 pub(super) async fn create_target(State(_state): State<RuntimeApiState>) -> ApiError {
83 ApiError::not_implemented(
84 "target registry is client-owned; attach by pointing the client at a runtime endpoint",
85 )
86 }
87
88 pub(super) async fn switch_target(State(_state): State<RuntimeApiState>) -> ApiError {
89 ApiError::not_implemented(
90 "target switching is client-owned; a switch must never move a running task or replay input",
91 )
92 }
93
94 pub(super) async fn remote_status(State(state): State<RuntimeApiState>) -> Json<Value> {
95 let loopback_only = super::is_loopback_bind_host(&state.bind_host);
96 Json(json!({
97 "bind_host": state.bind_host,
98 "port": state.bind_port,
99 "loopback_only": loopback_only,
100 "reachable_from_lan": !loopback_only,
101 "auth_required": state.auth_required,
102 "mobile": state.mobile_enabled,
103 // The runtime API has no TLS terminator; non-loopback reachability
104 // assumes a verified overlay (VPN/mesh), never plain LAN trust.
105 "tls": false,
106 }))
107 }
108
109 #[derive(Deserialize)]
110 #[serde(deny_unknown_fields)]
111 pub(super) struct RemoteConnectRequest {
112 /// Base URL of the candidate runtime, e.g. `http://192.168.1.5:7878`.
113 /// Credentials in the URL are refused — runtime tokens never travel
114 /// through this server.
115 endpoint: String,
116 }
117
118 /// Probe a candidate remote endpoint's `/v1/runtime/info`. The probe is
119 /// unauthenticated against the remote on purpose: this route must never be a
120 /// bearer-token forwarding primitive, and `runtime/info` answers signed-out
121 /// identity + `auth_required` without one. A negative reachability verdict is
122 /// data (`ok: false`), not a server error — the probe itself succeeded.
123 pub(super) async fn remote_connect(
124 State(_state): State<RuntimeApiState>,
125 Json(req): Json<RemoteConnectRequest>,
126 ) -> Result<Json<Value>, ApiError> {
127 let url = reqwest::Url::parse(req.endpoint.trim())
128 .map_err(|_| ApiError::bad_request("endpoint must be an http(s) URL"))?;
129 if !matches!(url.scheme(), "http" | "https") {
130 return Err(ApiError::bad_request(
131 "endpoint scheme must be http or https",
132 ));
133 }
134 if !url.username().is_empty() || url.password().is_some() {
135 return Err(ApiError::bad_request(
136 "credentials in endpoints are refused; send the remote's runtime token from the client",
137 ));
138 }
139 let host = url
140 .host_str()
141 .ok_or_else(|| ApiError::bad_request("endpoint must name a host"))?;
142 // Origin only — the runtime API always serves at /v1 regardless of the
143 // path a user pasted.
144 let origin = match url.port() {
145 Some(port) => format!("{}://{host}:{port}", url.scheme()),
146 None => format!("{}://{host}", url.scheme()),
147 };
148 let probe_url = format!("{origin}/v1/runtime/info");
149
150 let client = codewhale_release::tls::reqwest_client_builder()
151 .redirect(reqwest::redirect::Policy::none())
152 .timeout(PROBE_TIMEOUT)
153 .build()
154 .map_err(|error| ApiError::internal(format!("probe client unavailable: {error}")))?;
155 let response = match client.get(&probe_url).send().await {
156 Ok(response) => response,
157 Err(error) => {
158 return Ok(Json(json!({
159 "ok": false,
160 "endpoint": origin,
161 "reason": "unreachable",
162 "detail": error.to_string(),
163 })));
164 }
165 };
166 let status = response.status();
167 let bytes = response.bytes().await.unwrap_or_default();
168 if bytes.len() > PROBE_MAX_BYTES {
169 return Ok(Json(json!({
170 "ok": false,
171 "endpoint": origin,
172 "reason": "response too large to be a runtime identity",
173 })));
174 }
175 let body: Value = match serde_json::from_slice(&bytes) {
176 Ok(body) => body,
177 Err(_) => {
178 return Ok(Json(json!({
179 "ok": false,
180 "endpoint": origin,
181 "status": status.as_u16(),
182 "reason": "not a Codewhale runtime",
183 })));
184 }
185 };
186 if body.get("service").and_then(Value::as_str) != Some("codewhale-runtime-api") {
187 return Ok(Json(json!({
188 "ok": false,
189 "endpoint": origin,
190 "status": status.as_u16(),
191 "reason": "not a Codewhale runtime",
192 })));
193 }
194 Ok(Json(json!({
195 "ok": true,
196 "endpoint": origin,
197 "remote": {
198 "kind": "remote",
199 "endpoint": origin,
200 "service": "codewhale-runtime-api",
201 "runtime_api_version": body.get("runtime_api_version").cloned().unwrap_or(Value::Null),
202 "codewhale_version": body.get("codewhale_version").cloned().unwrap_or(Value::Null),
203 "auth_required": body.get("auth_required").cloned().unwrap_or(Value::Null),
204 "bind_host": body.get("bind_host").cloned().unwrap_or(Value::Null),
205 "port": body.get("port").cloned().unwrap_or(Value::Null),
206 },
207 // The verdict is all this server knows: attaching means the client
208 // re-targets its own transport at `endpoint` with that runtime's token.
209 "attach": "client",
210 })))
211 }
212
213 pub(super) async fn ssh_status(State(_state): State<RuntimeApiState>) -> Json<Value> {
214 Json(unsupported_surface("SSH remote workspaces"))
215 }
216
217 pub(super) async fn ssh_connect(State(_state): State<RuntimeApiState>) -> ApiError {
218 ApiError::not_implemented(
219 "SSH remote workspaces are owned by the Codewhale control plane; this runtime does not open SSH transports",
220 )
221 }
222
223 pub(super) async fn cloud_status(State(_state): State<RuntimeApiState>) -> Json<Value> {
224 Json(unsupported_surface("Hosted cloud computers"))
225 }
226
227 pub(super) async fn cloud_attach(State(_state): State<RuntimeApiState>) -> ApiError {
228 ApiError::not_implemented(
229 "hosted cloud computers are owned by the Codewhale control plane; this runtime does not provision them",
230 )
231 }
232
232 lines RUST