meta: WebTransport now works
- added a small frontend for starting - added logic to serve the frontend - split out the gamestream logic into a separate process - added logic to scaffold the separate proxy process
This commit is contained in:
Generated
+35
@@ -424,6 +424,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e"
|
||||
dependencies = [
|
||||
"powerfmt",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -669,6 +670,7 @@ dependencies = [
|
||||
"directories",
|
||||
"getrandom 0.3.3",
|
||||
"hex",
|
||||
"hmac-sha256",
|
||||
"libc",
|
||||
"moonlight-common-c-sys",
|
||||
"openssl",
|
||||
@@ -858,6 +860,12 @@ dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hmac-sha256"
|
||||
version = "1.1.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ad6880c8d4a9ebf39c6e8b77007ce223f646a4d21ce29d99f70cb16420545425"
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
version = "1.3.1"
|
||||
@@ -1527,6 +1535,12 @@ dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "path-slash"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e91099d4268b0e11973f036e885d652fb0b21fedcf69738c627f94db6a44f42"
|
||||
|
||||
[[package]]
|
||||
name = "pem"
|
||||
version = "3.0.5"
|
||||
@@ -2115,6 +2129,7 @@ dependencies = [
|
||||
"salvo-jwt-auth",
|
||||
"salvo-oapi",
|
||||
"salvo-proxy",
|
||||
"salvo-serve-static",
|
||||
"salvo_core",
|
||||
"salvo_extra",
|
||||
]
|
||||
@@ -2257,6 +2272,26 @@ dependencies = [
|
||||
"syn 2.0.104",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "salvo-serve-static"
|
||||
version = "0.80.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a98e8c27387f5b28ce66c337ae5772268c6d5f728b605fad4355cd4b58f6974b"
|
||||
dependencies = [
|
||||
"hex",
|
||||
"mime",
|
||||
"mime-infer",
|
||||
"path-slash",
|
||||
"percent-encoding",
|
||||
"rust-embed",
|
||||
"salvo_core",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"time",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "salvo_core"
|
||||
version = "0.80.0"
|
||||
|
||||
@@ -8,6 +8,7 @@ anyhow = "1.0.98"
|
||||
directories = "6.0.0"
|
||||
getrandom = { version = "0.3.3", features = ["std"] }
|
||||
hex = "0.4.3"
|
||||
hmac-sha256 = "1.1.12"
|
||||
libc = "0.2.174"
|
||||
moonlight-common-c-sys = { path = "../moonlight-common-c-sys" }
|
||||
openssl = "0.10.73"
|
||||
@@ -15,8 +16,15 @@ rand = "0.9.1"
|
||||
reqwest = { version = "0.12.20", features = [
|
||||
"rustls-tls",
|
||||
"native-tls",
|
||||
"json",
|
||||
], default-features = false }
|
||||
salvo = { version = "0.80.0", features = ["oapi", "craft", "logging", "quinn"] }
|
||||
salvo = { version = "0.80.0", features = [
|
||||
"oapi",
|
||||
"craft",
|
||||
"logging",
|
||||
"quinn",
|
||||
"serve-static",
|
||||
] }
|
||||
serde = { version = "1.0.219", features = ["serde_derive"] }
|
||||
serde-xml-rs = "0.8.1"
|
||||
serde_json = "1.0.140"
|
||||
|
||||
@@ -37,7 +37,7 @@ struct GetAppsResponse {
|
||||
}
|
||||
|
||||
#[craft]
|
||||
impl crate::server::Server {
|
||||
impl crate::backend::Backend {
|
||||
#[craft(endpoint(status_codes(StatusCode::OK, StatusCode::INTERNAL_SERVER_ERROR)))]
|
||||
pub async fn get_apps(self: ::std::sync::Arc<Self>) -> AppResult<Json<GetAppsResponse>> {
|
||||
let standard_error = Err(crate::common::AppError {
|
||||
|
||||
+12
-10
@@ -2,12 +2,12 @@ use std::collections::HashMap;
|
||||
|
||||
use anyhow::Result;
|
||||
use salvo::oapi::ToSchema;
|
||||
use serde::Deserialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use crate::state::StateFile;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct InputCrypto {
|
||||
pub aes_key: [u8; 16],
|
||||
pub aes_iv: [u8; 16],
|
||||
@@ -50,7 +50,7 @@ impl InputCrypto {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, ToSchema, Deserialize)]
|
||||
#[derive(Debug, Clone, ToSchema, Serialize, Deserialize)]
|
||||
pub struct Mode {
|
||||
pub width: i32,
|
||||
pub height: i32,
|
||||
@@ -63,20 +63,20 @@ impl Mode {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, ToSchema, Deserialize)]
|
||||
#[derive(Debug, Clone, ToSchema, Serialize, Deserialize)]
|
||||
pub struct StreamConfig {
|
||||
pub mode: Mode,
|
||||
pub bitrate_kbps: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Stream {
|
||||
pub id: uuid::Uuid,
|
||||
|
||||
pub url: url::Url,
|
||||
pub game_session: u64,
|
||||
|
||||
pub server_name: String,
|
||||
pub server_address: String,
|
||||
pub input_crypto: InputCrypto,
|
||||
pub stream_config: StreamConfig,
|
||||
|
||||
@@ -85,16 +85,18 @@ pub struct Stream {
|
||||
pub server_codec_mode_support: i32,
|
||||
}
|
||||
|
||||
pub struct Server {
|
||||
pub struct Backend {
|
||||
pub state: StateFile,
|
||||
pub streams: RwLock<HashMap<uuid::Uuid, Stream>>,
|
||||
pub port: u16,
|
||||
}
|
||||
|
||||
impl Server {
|
||||
pub fn new() -> Result<Self> {
|
||||
Ok(Server {
|
||||
impl Backend {
|
||||
pub fn new(port: u16) -> Result<Self> {
|
||||
Ok(Backend {
|
||||
state: StateFile::new()?,
|
||||
streams: RwLock::new(HashMap::new()),
|
||||
port,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -3,11 +3,14 @@ use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::Result;
|
||||
use openssl::ec::{EcGroup, EcKey};
|
||||
use openssl::hash::MessageDigest;
|
||||
use openssl::nid::Nid;
|
||||
use openssl::pkey::{PKey, Private};
|
||||
use openssl::rsa::Rsa;
|
||||
use openssl::x509::X509;
|
||||
use openssl::x509::{X509, X509NameBuilder};
|
||||
use salvo::conn::rustls::{Keycert, RustlsConfig};
|
||||
use tracing::debug;
|
||||
|
||||
pub fn get_and_create_cert_dir() -> Result<PathBuf> {
|
||||
let project_dirs =
|
||||
@@ -31,7 +34,7 @@ pub fn get_gamestream_cert_and_key() -> Result<(X509, PKey<Private>)> {
|
||||
pub fn get_http_stream_config() -> Result<RustlsConfig> {
|
||||
let (cert, key) = match load_cert_and_key_from_disk("http-cert", "http-key") {
|
||||
Ok((cert, key)) => (cert, key),
|
||||
Err(_) => generate_http_cert_and_key()?,
|
||||
Err(_) => generate_http_cert_and_key(false, "http-cert", "http-key")?,
|
||||
};
|
||||
|
||||
Ok(RustlsConfig::new(
|
||||
@@ -41,6 +44,34 @@ pub fn get_http_stream_config() -> Result<RustlsConfig> {
|
||||
))
|
||||
}
|
||||
|
||||
fn calculate_cert_hash(cert: &X509) -> Result<[u8; 32]> {
|
||||
//fn calculate_cert_hash(cert: &X509) -> Result<String> {
|
||||
let cert_hash = openssl::hash::hash(openssl::hash::MessageDigest::sha256(), &cert.to_der()?)?;
|
||||
//Ok(hex::encode(cert_hash))
|
||||
debug!("cert_hash: {}", hex::encode(cert_hash));
|
||||
Ok(hmac_sha256::Hash::hash(&cert.to_der()?))
|
||||
}
|
||||
|
||||
pub fn get_webtransport_stream_config(id: uuid::Uuid) -> Result<(RustlsConfig, [u8; 32])> {
|
||||
//pub fn get_webtransport_stream_config(id: uuid::Uuid) -> Result<(RustlsConfig, String)> {
|
||||
let (cert_filename, key_filename) = (&format!("{id}-cert"), &format!("{id}-key"));
|
||||
let (cert, key) = match load_cert_and_key_from_disk(cert_filename, key_filename) {
|
||||
Ok((cert, key)) => (cert, key),
|
||||
Err(_) => generate_http_cert_and_key(true, cert_filename, key_filename)?,
|
||||
};
|
||||
|
||||
let hash = calculate_cert_hash(&cert)?;
|
||||
|
||||
Ok((
|
||||
RustlsConfig::new(
|
||||
Keycert::new()
|
||||
.cert(cert.to_pem()?)
|
||||
.key(key.private_key_to_pem_pkcs8()?),
|
||||
),
|
||||
hash,
|
||||
))
|
||||
}
|
||||
|
||||
fn load_cert_and_key_from_disk(
|
||||
cert_filename: &str,
|
||||
key_filename: &str,
|
||||
@@ -59,9 +90,19 @@ fn load_cert_and_key_from_disk(
|
||||
Ok((cert, key))
|
||||
}
|
||||
|
||||
fn generate_http_cert_and_key() -> Result<(X509, PKey<Private>)> {
|
||||
let rsa = Rsa::generate(2048)?;
|
||||
let key = PKey::from_rsa(rsa)?;
|
||||
fn generate_http_cert_and_key(
|
||||
web_transport: bool,
|
||||
cert_filename: &str,
|
||||
key_filename: &str,
|
||||
) -> Result<(X509, PKey<Private>)> {
|
||||
let key = if web_transport {
|
||||
let group = EcGroup::from_curve_name(Nid::X9_62_PRIME256V1)?;
|
||||
let eckey = EcKey::generate(&group)?;
|
||||
PKey::from_ec_key(eckey)?
|
||||
} else {
|
||||
let rsa = Rsa::generate(2048)?;
|
||||
PKey::from_rsa(rsa)?
|
||||
};
|
||||
|
||||
let mut cert_builder = X509::builder()?;
|
||||
|
||||
@@ -70,16 +111,31 @@ fn generate_http_cert_and_key() -> Result<(X509, PKey<Private>)> {
|
||||
.expect("Time went backwards")
|
||||
.as_secs();
|
||||
let now = openssl::asn1::Asn1Time::from_unix(now_unix as i64)?;
|
||||
let thirteen_days_from_now = openssl::asn1::Asn1Time::days_from_now(13)?;
|
||||
|
||||
let expiration_time = if web_transport {
|
||||
openssl::asn1::Asn1Time::days_from_now(12)?
|
||||
} else {
|
||||
openssl::asn1::Asn1Time::days_from_now(365 * 5)?
|
||||
};
|
||||
|
||||
cert_builder.set_version(2)?;
|
||||
|
||||
// Set subject (Distinguished Name)
|
||||
let mut name_builder = X509NameBuilder::new()?;
|
||||
name_builder.append_entry_by_text("CN", "mumble-web self-signed")?;
|
||||
let subject_name = name_builder.build();
|
||||
cert_builder.set_subject_name(&subject_name)?;
|
||||
|
||||
// Set issuer (same as subject for self-signed)
|
||||
cert_builder.set_issuer_name(&subject_name)?;
|
||||
|
||||
cert_builder.set_not_before(&now)?;
|
||||
cert_builder.set_not_after(&thirteen_days_from_now)?;
|
||||
cert_builder.set_not_after(&expiration_time)?;
|
||||
cert_builder.set_pubkey(&key)?;
|
||||
cert_builder.sign(&key, MessageDigest::sha256())?;
|
||||
let cert = cert_builder.build();
|
||||
|
||||
save_cert_and_key_to_disk(&cert, &key, "http-cert", "http-key")?;
|
||||
save_cert_and_key_to_disk(&cert, &key, cert_filename, key_filename)?;
|
||||
|
||||
Ok((cert, key))
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ pub fn server_info(
|
||||
})
|
||||
}
|
||||
|
||||
pub fn stream_config(stream: &crate::server::Stream) -> _STREAM_CONFIGURATION {
|
||||
pub fn stream_config(stream: &crate::backend::Stream) -> _STREAM_CONFIGURATION {
|
||||
let (aes_key, aes_iv) = stream.input_crypto.as_stream_config_params();
|
||||
|
||||
_STREAM_CONFIGURATION {
|
||||
|
||||
@@ -10,7 +10,7 @@ pub struct GamestreamChannels {
|
||||
}
|
||||
|
||||
pub fn start_connection(
|
||||
stream: crate::server::Stream,
|
||||
stream: crate::backend::Stream,
|
||||
address: &str,
|
||||
) -> Result<GamestreamChannels> {
|
||||
let mut server_info = config::server_info(
|
||||
|
||||
@@ -1,43 +1,82 @@
|
||||
use anyhow::{Result, anyhow};
|
||||
use salvo::logging::Logger;
|
||||
use salvo::prelude::*;
|
||||
|
||||
mod apps;
|
||||
mod backend;
|
||||
mod certs;
|
||||
mod common;
|
||||
mod gamestream;
|
||||
mod pair;
|
||||
mod server;
|
||||
mod proxy;
|
||||
mod state;
|
||||
mod stream;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_max_level(tracing::Level::DEBUG)
|
||||
.init();
|
||||
use salvo::serve_static::{StaticDir, static_embed};
|
||||
|
||||
let server = server::Server::new()?;
|
||||
let server_arc = std::sync::Arc::new(server);
|
||||
#[cfg(not(debug_assertions))]
|
||||
use rust_embed::RustEmbed;
|
||||
|
||||
// Only compile this in release builds
|
||||
#[cfg(not(debug_assertions))]
|
||||
#[derive(RustEmbed)]
|
||||
#[folder = "webroot"]
|
||||
struct Assets;
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
fn create_static_handler() -> StaticDir {
|
||||
// Debug build: serve live files from filesystem
|
||||
StaticDir::new(["webroot"])
|
||||
.defaults("index.html")
|
||||
.auto_list(false)
|
||||
}
|
||||
|
||||
#[cfg(not(debug_assertions))]
|
||||
fn create_static_handler() -> impl Handler {
|
||||
// Release build: serve embedded files
|
||||
static_embed::<Assets>().fallback("index.html")
|
||||
}
|
||||
|
||||
async fn run_backend(port: u16) -> Result<()> {
|
||||
let backend = backend::Backend::new(port)?;
|
||||
let backend_arc = std::sync::Arc::new(backend);
|
||||
|
||||
let router = Router::new()
|
||||
.push(Router::with_path("pair").post(server_arc.post_pair()))
|
||||
.push(Router::with_path("apps").get(server_arc.get_apps()))
|
||||
.push(Router::with_path("stream/start").post(server_arc.post_stream_start()))
|
||||
.push(
|
||||
Router::with_path("stream/connect/{stream_id}").post(server_arc.post_stream_connect()),
|
||||
);
|
||||
.push(Router::with_path("api/pair").post(backend_arc.post_pair()))
|
||||
.push(Router::with_path("api/apps").get(backend_arc.get_apps()))
|
||||
.push(Router::with_path("api/stream/start").post(backend_arc.post_stream_start()))
|
||||
.push(Router::with_path("{*path}").get(create_static_handler()));
|
||||
let doc = OpenApi::new("test api", "0.0.1").merge_router(&router);
|
||||
let router = router
|
||||
.unshift(doc.into_router("/api-doc/openapi.json"))
|
||||
.unshift(SwaggerUi::new("/api-doc/openapi.json").into_router("/swagger-ui"));
|
||||
let service = Service::new(router).hoop(Logger::new());
|
||||
|
||||
//let config = RustlsConfig::new(Keycert::new().cert(cert.as_slice()).key(key.as_slice()));
|
||||
|
||||
let config = certs::get_http_stream_config()?;
|
||||
|
||||
let listener = TcpListener::new("0.0.0.0:3000").rustls(config.clone());
|
||||
let acceptor = QuinnListener::new(config, ("0.0.0.0", 5800))
|
||||
let listener = TcpListener::new(("0.0.0.0", port))
|
||||
.rustls(config.clone())
|
||||
.bind()
|
||||
.await;
|
||||
salvo::Server::new(listener).serve(service).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_proxy(port: u16, stream_id: uuid::Uuid) -> Result<()> {
|
||||
let (config, cert_hash) = certs::get_webtransport_stream_config(stream_id)?;
|
||||
//let config = certs::get_http_stream_config()?;
|
||||
//let cert_hash = [0; 32];
|
||||
let proxy = proxy::Proxy::new(cert_hash);
|
||||
let proxy_arc = std::sync::Arc::new(proxy);
|
||||
|
||||
let router = Router::new()
|
||||
.push(Router::with_path("api/stream/setup").post(proxy_arc.stream_setup()))
|
||||
.push(Router::with_path("api/stream/connect/").goal(proxy_arc.stream_connect()));
|
||||
let service = Service::new(router).hoop(Logger::new());
|
||||
|
||||
let listener = TcpListener::new(("0.0.0.0", port)).rustls(config.clone());
|
||||
let acceptor = QuinnListener::new(config, ("0.0.0.0", port))
|
||||
.join(listener)
|
||||
.bind()
|
||||
.await;
|
||||
@@ -45,3 +84,32 @@ async fn main() -> anyhow::Result<()> {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_max_level(tracing::Level::DEBUG)
|
||||
.init();
|
||||
|
||||
let mode = std::env::args()
|
||||
.nth(1)
|
||||
.ok_or(anyhow!("Mode argument missing"))?;
|
||||
let port = std::env::args()
|
||||
.nth(2)
|
||||
.ok_or(anyhow!("Port argument missing"))?
|
||||
.parse::<u16>()?;
|
||||
|
||||
match mode.as_str() {
|
||||
"backend" => run_backend(port).await,
|
||||
"proxy" => {
|
||||
let stream_id = uuid::Uuid::parse_str(
|
||||
&std::env::args()
|
||||
.nth(3)
|
||||
.ok_or(anyhow!("Cert ID argument missing"))?,
|
||||
)?;
|
||||
|
||||
run_proxy(port, stream_id).await
|
||||
}
|
||||
_ => Err(anyhow!("Unknown mode: {mode}")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -291,7 +291,7 @@ async fn send_client_pairing_secret(
|
||||
}
|
||||
|
||||
#[craft]
|
||||
impl crate::server::Server {
|
||||
impl crate::backend::Backend {
|
||||
#[craft(endpoint(status_codes(
|
||||
StatusCode::OK,
|
||||
StatusCode::BAD_REQUEST,
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
use salvo::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::{select, sync::RwLock};
|
||||
use tracing::{error, info};
|
||||
|
||||
use crate::common::{AppError, AppResult};
|
||||
|
||||
pub struct Proxy {
|
||||
pub cert_hash: [u8; 32],
|
||||
//pub cert_hash: String,
|
||||
pub stream: RwLock<Option<crate::backend::Stream>>,
|
||||
}
|
||||
|
||||
impl Proxy {
|
||||
pub fn new(cert_hash: [u8; 32]) -> Self {
|
||||
//pub fn new(cert_hash: String) -> Self {
|
||||
Proxy {
|
||||
stream: RwLock::new(None),
|
||||
cert_hash,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct ProxySetupParams {
|
||||
pub stream: crate::backend::Stream,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct ProxySetupResponse {
|
||||
pub cert_hash: [u8; 32],
|
||||
//pub cert_hash: String,
|
||||
}
|
||||
|
||||
#[craft]
|
||||
impl crate::proxy::Proxy {
|
||||
#[craft(handler)]
|
||||
pub async fn stream_setup(
|
||||
self: ::std::sync::Arc<Self>,
|
||||
body: salvo::oapi::extract::JsonBody<ProxySetupParams>,
|
||||
) -> AppResult<Json<ProxySetupResponse>> {
|
||||
let mut writer = self.stream.write().await;
|
||||
*writer = Some(body.stream.clone());
|
||||
|
||||
info!("Configured proxy with config: {:?}", body.stream);
|
||||
|
||||
Ok(Json(ProxySetupResponse {
|
||||
cert_hash: self.cert_hash,
|
||||
}))
|
||||
}
|
||||
|
||||
#[craft(handler)]
|
||||
pub async fn stream_connect(self: ::std::sync::Arc<Self>, req: &mut Request) -> AppResult<()> {
|
||||
let standard_error = Err(crate::common::AppError {
|
||||
status_code: StatusCode::INTERNAL_SERVER_ERROR,
|
||||
description: "Could not start stream".to_string(),
|
||||
});
|
||||
|
||||
info!("WebTransport connection initiated");
|
||||
|
||||
let session = match req.web_transport_mut().await {
|
||||
Ok(w) => w,
|
||||
Err(e) => {
|
||||
error!("Could not initalize WebTransport connection: {e}");
|
||||
return Err(AppError {
|
||||
status_code: StatusCode::BAD_REQUEST,
|
||||
description: "User did not connect with a WebTransport connection".to_string(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let stream = match self.stream.read().await.clone() {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
error!("Stream has not been configured, cannot connect to server");
|
||||
return Err(AppError {
|
||||
status_code: StatusCode::BAD_REQUEST,
|
||||
description: "Proxy has not been configured yet: THIS IS A BUG".to_string(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
tracing::debug!(
|
||||
"Connecting to stream at address {} with stream config {:?}",
|
||||
stream.server_address,
|
||||
stream
|
||||
);
|
||||
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
let (stop_tx, stop_rx) = tokio::sync::oneshot::channel::<()>();
|
||||
|
||||
let host = stream.server_address.clone();
|
||||
std::thread::spawn(move || {
|
||||
let result = crate::gamestream::start_connection(stream, &host);
|
||||
|
||||
let _ = tx.send(result);
|
||||
|
||||
let _ = stop_rx.blocking_recv();
|
||||
|
||||
crate::gamestream::stop_connection();
|
||||
});
|
||||
|
||||
let mut channels = match rx.await {
|
||||
Ok(r) => match r {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
error!("Could not get gamestream communication channels: {e}");
|
||||
return standard_error;
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
error!("Could not start connection: {e}");
|
||||
return standard_error;
|
||||
}
|
||||
};
|
||||
|
||||
loop {
|
||||
select! {
|
||||
recv = channels.decoder_rx.recv() => {
|
||||
match recv {
|
||||
Some(frame) => {
|
||||
info!("Got decoder packet")
|
||||
}
|
||||
None => {
|
||||
error!("Decoder channel is None");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//// Handle bidirectional streams
|
||||
//if let Ok(Some(webtransport::server::AcceptedBi::BidiStream(_, stream))) =
|
||||
// session.accept_bi().await
|
||||
//{
|
||||
// let (send, recv) = salvo::proto::quic::BidiStream::split(stream);
|
||||
// // Process bidirectional stream data
|
||||
//}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,12 @@
|
||||
use std::thread;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use moonlight_common_c_sys::SCM_H264;
|
||||
use salvo::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::{select, sync::oneshot};
|
||||
use tracing::{debug, error, info};
|
||||
|
||||
use crate::{
|
||||
common::{AppError, AppResult, get_url},
|
||||
proxy::ProxySetupResponse,
|
||||
state::{GamestreamServer, StateReadAccess, StateReader},
|
||||
};
|
||||
|
||||
@@ -16,13 +14,15 @@ use crate::{
|
||||
struct PostStreamStartParams {
|
||||
server: String,
|
||||
id: u64,
|
||||
stream_config: crate::server::StreamConfig,
|
||||
server_mode: Option<crate::server::Mode>,
|
||||
stream_config: crate::backend::StreamConfig,
|
||||
server_mode: Option<crate::backend::Mode>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
struct PostStreamStartResponse {
|
||||
stream_id: uuid::Uuid,
|
||||
url: String,
|
||||
cert_hash: [u8; 32],
|
||||
//cert_hash: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -59,12 +59,40 @@ fn get_server(reader: &StateReadAccess, server: &String) -> Result<Option<Gamest
|
||||
Ok(servers.get(server).cloned())
|
||||
}
|
||||
|
||||
async fn setup_proxy(port: u16, stream: crate::backend::Stream) -> Result<ProxySetupResponse> {
|
||||
let url = url_constructor::UrlConstructor::new()
|
||||
.scheme("https")
|
||||
.host("localhost")
|
||||
.port(port)
|
||||
.subdir("api/stream/setup")
|
||||
.build();
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()?;
|
||||
|
||||
//let resp = client.post(url).send().await?;
|
||||
//let text = resp.text().await?;
|
||||
//debug!(text);
|
||||
|
||||
//Ok(serde_json::from_str(&text)?)
|
||||
|
||||
Ok(client
|
||||
.post(url)
|
||||
.json(&crate::proxy::ProxySetupParams { stream })
|
||||
.send()
|
||||
.await?
|
||||
.json::<ProxySetupResponse>()
|
||||
.await?)
|
||||
}
|
||||
|
||||
#[craft]
|
||||
impl crate::server::Server {
|
||||
impl crate::backend::Backend {
|
||||
#[craft(endpoint(status_codes(StatusCode::OK, StatusCode::INTERNAL_SERVER_ERROR)))]
|
||||
pub async fn post_stream_start(
|
||||
self: ::std::sync::Arc<Self>,
|
||||
body: salvo::oapi::extract::JsonBody<PostStreamStartParams>,
|
||||
req: &mut Request,
|
||||
) -> AppResult<Json<PostStreamStartResponse>> {
|
||||
let standard_error = Err(crate::common::AppError {
|
||||
status_code: StatusCode::INTERNAL_SERVER_ERROR,
|
||||
@@ -119,7 +147,7 @@ impl crate::server::Server {
|
||||
|
||||
//"%s://%s:%d/serverinfo?uniqueid=%s&uuid=%s",
|
||||
|
||||
let input_crypto = match crate::server::InputCrypto::new() {
|
||||
let input_crypto = match crate::backend::InputCrypto::new() {
|
||||
Ok(i) => i,
|
||||
Err(e) => {
|
||||
error!("Could not create input crypto: {e}");
|
||||
@@ -204,11 +232,11 @@ impl crate::server::Server {
|
||||
server_info.ServerCodecModeSupport
|
||||
};
|
||||
|
||||
let stream = crate::server::Stream {
|
||||
let stream = crate::backend::Stream {
|
||||
id: stream_id,
|
||||
url: launch_response.session_url_0,
|
||||
game_session: launch_response.game_session,
|
||||
server_name: server.name.clone(),
|
||||
server_address: server.host.clone(),
|
||||
stream_config: body.stream_config.clone(),
|
||||
app_version: server_info.appversion,
|
||||
gfe_version: server_info.GfeVersion,
|
||||
@@ -220,123 +248,64 @@ impl crate::server::Server {
|
||||
"Launched stream {stream_id} on {} with config {stream:?}",
|
||||
server.name
|
||||
);
|
||||
(*self.streams.write().await).insert(stream.id, stream);
|
||||
let mut writer = self.streams.write().await;
|
||||
(*writer).insert(stream.id, stream.clone());
|
||||
|
||||
let post_stream_response = PostStreamStartResponse { stream_id };
|
||||
let port = self.port + <u16>::try_from((*writer).len()).unwrap();
|
||||
|
||||
Ok(Json(post_stream_response))
|
||||
}
|
||||
|
||||
#[craft(endpoint(status_codes(StatusCode::OK, StatusCode::INTERNAL_SERVER_ERROR)))]
|
||||
pub async fn post_stream_connect(
|
||||
self: ::std::sync::Arc<Self>,
|
||||
stream_id: salvo::oapi::extract::PathParam<uuid::Uuid>,
|
||||
req: &mut Request,
|
||||
) -> AppResult<()> {
|
||||
let standard_error = Err(crate::common::AppError {
|
||||
status_code: StatusCode::INTERNAL_SERVER_ERROR,
|
||||
description: "Could not start stream".to_string(),
|
||||
});
|
||||
|
||||
let session = match req.web_transport_mut().await {
|
||||
Ok(w) => w,
|
||||
// Spawn WebTransport proxy
|
||||
let binary_path = match std::env::current_exe() {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
error!("Could not initalize WebTransport connection: {e}");
|
||||
return Err(AppError {
|
||||
status_code: StatusCode::BAD_REQUEST,
|
||||
description: "User did not connect with a WebTransport connection".to_string(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let stream_id = stream_id.into_inner();
|
||||
let stream = match self.streams.read().await.get(&stream_id).cloned() {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
error!("Could not find stream with id {stream_id}");
|
||||
return Err(AppError {
|
||||
status_code: StatusCode::BAD_REQUEST,
|
||||
description: "No stream with given ID".to_string(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let servers = match self.state.read().await.servers() {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
error!("Could not get servers: {e}");
|
||||
error!("Could not get binary path to spawn proxy: {e}");
|
||||
return standard_error;
|
||||
}
|
||||
};
|
||||
|
||||
let server = match servers.get(&stream.server_name) {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
error!(
|
||||
"Could not find server {} pointed to by stream {}",
|
||||
stream.server_name, stream.id
|
||||
);
|
||||
return standard_error;
|
||||
}
|
||||
};
|
||||
|
||||
debug!(
|
||||
"Connecting to stream on server {} with stream config {:?}",
|
||||
server.name, stream
|
||||
info!(
|
||||
"Spawning proxy process for stream {} on port {}",
|
||||
stream_id, port
|
||||
);
|
||||
|
||||
let (tx, rx) = oneshot::channel();
|
||||
let (stop_tx, stop_rx) = oneshot::channel::<()>();
|
||||
|
||||
let host = server.host.clone();
|
||||
thread::spawn(move || {
|
||||
let result = crate::gamestream::start_connection(stream, &host);
|
||||
|
||||
let _ = tx.send(result);
|
||||
|
||||
let _ = stop_rx.blocking_recv();
|
||||
|
||||
crate::gamestream::stop_connection();
|
||||
});
|
||||
|
||||
let mut channels = match rx.await {
|
||||
Ok(r) => match r {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
error!("Could not get gamestream communication channels: {e}");
|
||||
return standard_error;
|
||||
}
|
||||
},
|
||||
match tokio::process::Command::new(binary_path)
|
||||
.args(["proxy", &port.to_string(), &stream_id.to_string()])
|
||||
.spawn()
|
||||
{
|
||||
Ok(_) => (),
|
||||
Err(e) => {
|
||||
error!("Could not start connection: {e}");
|
||||
error!("Failed to spawn proxy process: {e}");
|
||||
return standard_error;
|
||||
}
|
||||
};
|
||||
|
||||
loop {
|
||||
select! {
|
||||
recv = channels.decoder_rx.recv() => {
|
||||
match recv {
|
||||
Some(frame) => {
|
||||
info!("Got decoder packet")
|
||||
}
|
||||
None => {
|
||||
error!("Decoder channel is None");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//// Handle bidirectional streams
|
||||
//if let Ok(Some(webtransport::server::AcceptedBi::BidiStream(_, stream))) =
|
||||
// session.accept_bi().await
|
||||
//{
|
||||
// let (send, recv) = salvo::proto::quic::BidiStream::split(stream);
|
||||
// // Process bidirectional stream data
|
||||
//}
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(1000)).await;
|
||||
|
||||
Ok(())
|
||||
let setup_resp = match setup_proxy(port, stream).await {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
error!("Could not setup proxy: {e}");
|
||||
return standard_error;
|
||||
}
|
||||
};
|
||||
|
||||
let host = match req.uri_mut().host() {
|
||||
Some(h) => h,
|
||||
None => {
|
||||
error!("Request URI does not have a host");
|
||||
return standard_error;
|
||||
}
|
||||
};
|
||||
|
||||
let webtransport_url = url_constructor::UrlConstructor::new()
|
||||
.scheme("https")
|
||||
.host(host)
|
||||
.port(port)
|
||||
.subdir("api/stream/connect")
|
||||
.build();
|
||||
|
||||
let post_stream_response = PostStreamStartResponse {
|
||||
url: webtransport_url,
|
||||
cert_hash: setup_resp.cert_hash,
|
||||
};
|
||||
|
||||
Ok(Json(post_stream_response))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Game Streaming App</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
margin: 20px;
|
||||
background-color: #1a1a1a;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.apps-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 20px;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.app-box {
|
||||
position: relative;
|
||||
width: 200px;
|
||||
height: 280px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.app-box:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 10px 20px rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.app-artwork {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
background-color: #333;
|
||||
border: 2px solid #555;
|
||||
border-radius: 8px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: border-color 0.3s ease;
|
||||
}
|
||||
|
||||
.app-box:hover .app-artwork {
|
||||
border-color: #00aaff;
|
||||
}
|
||||
|
||||
.app-title {
|
||||
text-align: center;
|
||||
margin: 10px 0 5px 0;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.app-server {
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: #aaa;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.play-button {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
background-color: rgba(0, 170, 255, 0.9);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.play-button::after {
|
||||
content: '';
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 20px solid white;
|
||||
border-top: 12px solid transparent;
|
||||
border-bottom: 12px solid transparent;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.app-box:hover .play-button {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.app-box.clicked {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
font-size: 18px;
|
||||
margin-top: 50px;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #ff4444;
|
||||
text-align: center;
|
||||
font-size: 18px;
|
||||
margin-top: 50px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Game Streaming Platform</h1>
|
||||
<div id="content">
|
||||
<div class="loading">Loading applications...</div>
|
||||
</div>
|
||||
|
||||
<script src="index.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
class GameStreamingApp {
|
||||
constructor() {
|
||||
this.apps = [];
|
||||
this.init();
|
||||
}
|
||||
|
||||
async init() {
|
||||
try {
|
||||
await this.loadApps();
|
||||
this.renderApps();
|
||||
} catch (error) {
|
||||
this.showError('Failed to load applications: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async loadApps() {
|
||||
try {
|
||||
const response = await fetch('/api/apps');
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
this.apps = this.processAppsData(data);
|
||||
} catch (error) {
|
||||
console.error('Error loading apps:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
processAppsData(data) {
|
||||
const apps = [];
|
||||
if (data.apps) {
|
||||
Object.keys(data.apps).forEach(serverName => {
|
||||
data.apps[serverName].forEach(app => {
|
||||
apps.push({
|
||||
...app,
|
||||
server: serverName
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
return apps;
|
||||
}
|
||||
|
||||
renderApps() {
|
||||
const contentDiv = document.getElementById('content');
|
||||
|
||||
if (this.apps.length === 0) {
|
||||
contentDiv.innerHTML = '<div class="error">No applications found.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const appsContainer = document.createElement('div');
|
||||
appsContainer.className = 'apps-container';
|
||||
|
||||
this.apps.forEach(app => {
|
||||
const appBox = this.createAppBox(app);
|
||||
appsContainer.appendChild(appBox);
|
||||
});
|
||||
|
||||
contentDiv.innerHTML = '';
|
||||
contentDiv.appendChild(appsContainer);
|
||||
}
|
||||
|
||||
createAppBox(app) {
|
||||
const appBox = document.createElement('div');
|
||||
appBox.className = 'app-box';
|
||||
appBox.dataset.id = app.id;
|
||||
appBox.dataset.server = app.server;
|
||||
|
||||
appBox.innerHTML = `
|
||||
<div class="app-artwork">
|
||||
<div class="play-button"></div>
|
||||
</div>
|
||||
<div class="app-title">${app.title}</div>
|
||||
<div class="app-server">${app.server}</div>
|
||||
`;
|
||||
|
||||
// Add click event listener
|
||||
appBox.addEventListener('click', (e) => this.handleAppClick(e, app));
|
||||
|
||||
// Add click animation
|
||||
appBox.addEventListener('mousedown', () => {
|
||||
appBox.classList.add('clicked');
|
||||
});
|
||||
|
||||
appBox.addEventListener('mouseup', () => {
|
||||
setTimeout(() => {
|
||||
appBox.classList.remove('clicked');
|
||||
}, 150);
|
||||
});
|
||||
|
||||
appBox.addEventListener('mouseleave', () => {
|
||||
appBox.classList.remove('clicked');
|
||||
});
|
||||
|
||||
return appBox;
|
||||
}
|
||||
|
||||
async handleAppClick(event, app) {
|
||||
event.preventDefault();
|
||||
|
||||
try {
|
||||
console.log(`Starting stream for ${app.title} on ${app.server}`);
|
||||
|
||||
// Create the POST request payload
|
||||
const payload = {
|
||||
id: app.id,
|
||||
server: app.server,
|
||||
server_mode: {
|
||||
fps: 60,
|
||||
height: 1280,
|
||||
width: 720
|
||||
},
|
||||
stream_config: {
|
||||
bitrate_kbps: 5120,
|
||||
mode: {
|
||||
fps: 60,
|
||||
height: 1280,
|
||||
width: 720
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Make POST request to start stream
|
||||
const response = await fetch('/api/stream/start', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const streamData = await response.json();
|
||||
console.log('Stream started:', streamData);
|
||||
|
||||
if (streamData.url && streamData.cert_hash) {
|
||||
await this.connectToStream(streamData.url, streamData.cert_hash);
|
||||
} else {
|
||||
throw new Error('Response was missing required parameters');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error starting stream:', error);
|
||||
alert('Failed to start stream: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async connectToStream(url, cert_hash) {
|
||||
const buffer = new Uint8Array(cert_hash);
|
||||
console.log('Hash: ', buffer);
|
||||
try {
|
||||
console.log(`Connecting to stream`);
|
||||
|
||||
// Check if WebTransport is supported
|
||||
if (!window.WebTransport) {
|
||||
throw new Error('WebTransport is not supported in this browser');
|
||||
}
|
||||
|
||||
//const url = new URL();
|
||||
const transport = new WebTransport(url, {
|
||||
serverCertificateHashes: [
|
||||
{
|
||||
algorithm: "sha-256",
|
||||
value: buffer,
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
console.log('Connecting to WebTransport at ', url);
|
||||
// Wait for the connection to be ready
|
||||
await transport.ready;
|
||||
console.log('WebTransport connection established');
|
||||
|
||||
// Handle connection close
|
||||
transport.closed.then(() => {
|
||||
console.log('WebTransport connection closed');
|
||||
}).catch((error) => {
|
||||
console.error('WebTransport connection closed with error:', error);
|
||||
});
|
||||
|
||||
// You can add more WebTransport handling logic here
|
||||
// For example, handling incoming streams, sending data, etc.
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error connecting to stream:', error);
|
||||
alert('Failed to connect to stream: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
showError(message) {
|
||||
const contentDiv = document.getElementById('content');
|
||||
contentDiv.innerHTML = `<div class="error">${message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize the app when the page loads
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
new GameStreamingApp();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user