ODIAN Launcher: hub, installer, local stack and remembered sign-in (M2)

- Tauri 2 + Svelte 5 hub in ODIAN dark: patch notes and roadmap, install and
  update progress, local stack status, settings with DHP and MCP addresses.
- crates/odian-packs: Rust installer for pack format v1 (signed manifests,
  resumable verified downloads from a DHP or an ODIAN drive), byte-compatible
  with ODIAN_MCP_Server's Python odian_packs.
- Local stack on 127.0.0.1: Postgres -> Engine API -> tile server -> Odian.exe,
  one Job Object, clean shutdown; database snapshot restore on a fresh PC only.
- Keycloak sign-in through the DHP's MCP, remembered until Sign out with a
  launcher session that renews the Engine token silently.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kelorastus
2026-09-15 15:23:17 +02:00
commit b7f3061be3
102 changed files with 47611 additions and 0 deletions
+195
View File
@@ -0,0 +1,195 @@
//! Keycloak sign-in through the site MCP's desktop flow.
//!
//! The launcher listens on a random 127.0.0.1 port and opens the system browser
//! at `{mcp}/auth/desktop/start?cb=http://127.0.0.1:<port>/cb`. The MCP runs
//! Keycloak (Auth Code + PKCE), mints an Engine token, and redirects the browser
//! back to the loopback with a one-time `code` (or `error`). The launcher then
//! swaps that code at `POST {mcp}/auth/desktop/exchange` for the token, so the
//! token itself never appears in a browser URL.
use std::collections::HashMap;
use std::time::Duration;
use serde::Deserialize;
use tauri::AppHandle;
use tauri_plugin_opener::OpenerExt;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
use tokio::sync::Notify;
use crate::tokens::SiteToken;
/// The MCP forgets a pending sign-in after 300 s.
const SIGN_IN_TIMEOUT: Duration = Duration::from_secs(300);
#[derive(Deserialize)]
struct ExchangePayload {
#[serde(default)]
access_token: String,
#[serde(default)]
expires_at: i64,
#[serde(default)]
username: String,
#[serde(default)]
dataless: bool,
/// Only issued for `client=launcher`; an MCP without launcher sessions leaves it out.
#[serde(default)]
launcher_token: Option<String>,
}
enum Callback {
Code(String),
Error(String),
}
pub enum SignInError {
/// The operator cancelled from the launcher; not an error to show.
Cancelled,
Failed(String),
}
impl From<String> for SignInError {
fn from(message: String) -> Self {
SignInError::Failed(message)
}
}
pub async fn sign_in(
app: &AppHandle,
http: &reqwest::Client,
mcp_url: &str,
cancel: &Notify,
) -> Result<SiteToken, SignInError> {
if mcp_url.is_empty() {
return Err("Set the DHP's sign-in address (MCP URL) in Settings first.".to_string().into());
}
let listener = TcpListener::bind("127.0.0.1:0")
.await
.map_err(|e| format!("cannot open the sign-in callback port: {e}"))?;
let port = listener.local_addr().map_err(|e| e.to_string())?.port();
let callback = format!("http://127.0.0.1:{port}/cb");
// client=launcher asks the MCP for a launcher session too, so the operator is remembered until Sign out.
let start = url::Url::parse_with_params(
&format!("{mcp_url}/auth/desktop/start"),
&[("cb", callback.as_str()), ("client", "launcher")],
)
.map_err(|e| format!("the MCP URL is not valid: {e}"))?;
app
.opener()
.open_url(start.as_str(), None::<&str>)
.map_err(|e| format!("cannot open the browser: {e}"))?;
let waited = tokio::select! {
waited = tokio::time::timeout(SIGN_IN_TIMEOUT, wait_for_callback(listener)) => waited,
_ = cancel.notified() => return Err(SignInError::Cancelled),
};
let code = match waited {
Err(_) => return Err("Sign-in timed out. Try again.".to_string().into()),
Ok(Err(e)) => return Err(e.into()),
Ok(Ok(Callback::Error(error))) => return Err(describe_error(&error).into()),
Ok(Ok(Callback::Code(code))) => code,
};
let response = http
.post(format!("{mcp_url}/auth/desktop/exchange"))
.json(&serde_json::json!({ "code": code }))
.timeout(Duration::from_secs(15))
.send()
.await
.map_err(|e| format!("the sign-in service did not answer: {e}"))?;
if !response.status().is_success() {
return Err(format!("sign-in failed: the MCP answered HTTP {}", response.status()).into());
}
let payload: ExchangePayload = response.json().await.map_err(|e| format!("unexpected sign-in answer: {e}"))?;
if payload.access_token.is_empty() || payload.username.is_empty() {
return Err("sign-in failed: the MCP returned no token".to_string().into());
}
if payload.dataless {
// A dataless session means the site's Engine has no database bound: nothing to install from.
return Err("The DHP's Engine has no database connected. Ask an administrator.".to_string().into());
}
Ok(SiteToken {
access_token: payload.access_token,
expires_at: payload.expires_at,
username: payload.username,
launcher_token: payload.launcher_token.filter(|t| !t.is_empty()),
})
}
fn describe_error(error: &str) -> String {
match error {
"access_denied" => "Sign-in was cancelled.".to_string(),
"auth_failed" => "Keycloak did not accept the sign-in.".to_string(),
"mint_failed" => "You signed in, but this DHP has no ODIAN account for you. Ask an administrator.".to_string(),
"keycloak_unavailable" => "The DHP's Keycloak is unreachable.".to_string(),
other => format!("Sign-in failed ({other})."),
}
}
async fn wait_for_callback(listener: TcpListener) -> Result<Callback, String> {
loop {
let (mut stream, _) = listener.accept().await.map_err(|e| format!("sign-in callback failed: {e}"))?;
let Some(target) = read_request_target(&mut stream).await else {
continue;
};
if !target.starts_with("/cb") {
respond(&mut stream, "404 Not Found", "").await;
continue;
}
let query: HashMap<String, String> = url::Url::parse(&format!("http://127.0.0.1{target}"))
.map(|u| u.query_pairs().into_owned().collect())
.unwrap_or_default();
if let Some(code) = query.get("code").filter(|c| !c.is_empty()) {
respond(&mut stream, "200 OK", &page("Signed in", "You can close this tab and return to the ODIAN Launcher.")).await;
return Ok(Callback::Code(code.clone()));
}
let error = query.get("error").cloned().unwrap_or_else(|| "no_code".to_string());
respond(&mut stream, "200 OK", &page("Sign-in failed", "Return to the ODIAN Launcher for details.")).await;
return Ok(Callback::Error(error));
}
}
/// The path of a `GET` request line, or None for anything else (or a client that stalls).
async fn read_request_target(stream: &mut TcpStream) -> Option<String> {
let mut buf = vec![0u8; 8192];
let mut len = 0;
let read = async {
while len < buf.len() {
let n = stream.read(&mut buf[len..]).await.ok()?;
if n == 0 {
break;
}
len += n;
if buf[..len].windows(4).any(|w| w == b"\r\n\r\n") {
break;
}
}
Some(())
};
tokio::time::timeout(Duration::from_secs(5), read).await.ok()??;
let head = String::from_utf8_lossy(&buf[..len]);
let mut parts = head.lines().next()?.split_whitespace();
if parts.next()? != "GET" {
return None;
}
parts.next().map(str::to_string)
}
async fn respond(stream: &mut TcpStream, status: &str, body: &str) {
let response = format!(
"HTTP/1.1 {status}\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nCache-Control: no-store\r\nConnection: close\r\n\r\n{body}",
body.len()
);
let _ = stream.write_all(response.as_bytes()).await;
let _ = stream.shutdown().await;
}
fn page(title: &str, message: &str) -> String {
format!(
"<!doctype html><meta charset=utf-8><title>ODIAN — {title}</title>\
<body style=\"margin:0;min-height:100vh;display:grid;place-content:center;background:#09090b;color:#f2f1ee;\
font-family:'Segoe UI',system-ui,sans-serif;text-align:center\">\
<div><div style=\"letter-spacing:.28em;font-weight:600;font-size:15px;color:#9a999e\">ODIAN</div>\
<h1 style=\"font-weight:600;margin:18px 0 8px\">{title}</h1><p style=\"color:#d4d3d0\">{message}</p></div>"
)
}