From 2056d4def1cbf6769c75541d31a3779160ecc349 Mon Sep 17 00:00:00 2001 From: Matt Hill Date: Tue, 21 Oct 2025 16:01:14 -0600 Subject: [PATCH] web server WIP --- core/startos/src/net/ssl.rs | 82 +++++++++- core/startos/src/tunnel/api.rs | 5 +- core/startos/src/tunnel/auth.rs | 151 ++++++++++++------ core/startos/src/tunnel/db.rs | 22 ++- core/startos/src/tunnel/mod.rs | 1 + core/startos/src/tunnel/web.rs | 242 +++++++++++++++++++++++++++++ core/startos/src/util/serde.rs | 15 ++ sdk/package/lib/util/fileHelper.ts | 16 +- 8 files changed, 469 insertions(+), 65 deletions(-) create mode 100644 core/startos/src/tunnel/web.rs diff --git a/core/startos/src/net/ssl.rs b/core/startos/src/net/ssl.rs index 9b0c51f3c..afa1360f4 100644 --- a/core/startos/src/net/ssl.rs +++ b/core/startos/src/net/ssl.rs @@ -10,6 +10,7 @@ use libc::time_t; use openssl::asn1::{Asn1Integer, Asn1Time, Asn1TimeRef}; use openssl::bn::{BigNum, MsbOption}; use openssl::ec::{EcGroup, EcKey}; +use openssl::error::ErrorStack; use openssl::hash::MessageDigest; use openssl::nid::Nid; use openssl::pkey::{PKey, Private}; @@ -26,6 +27,12 @@ use crate::init::check_time_is_synchronized; use crate::prelude::*; use crate::util::serde::Pem; +pub fn gen_nistp256() -> Result, ErrorStack> { + PKey::from_ec_key(EcKey::generate(&*EcGroup::from_curve_name( + Nid::X9_62_PRIME256V1, + )?)?) +} + #[derive(Debug, Deserialize, Serialize, HasModel)] #[model = "Model"] #[serde(rename_all = "camelCase")] @@ -96,9 +103,7 @@ impl Model { } else { PKeyPair { ed25519: PKey::generate_ed25519()?, - nistp256: PKey::from_ec_key(EcKey::generate(&*EcGroup::from_curve_name( - Nid::X9_62_PRIME256V1, - )?)?)?, + nistp256: gen_nistp256()?, } }; let int_key = self.as_int_key().de()?.0; @@ -518,3 +523,74 @@ pub fn make_leaf_cert( let cert = builder.build(); Ok(cert) } + +#[instrument(skip_all)] +pub fn make_self_signed(applicant: (&PKey, &SANInfo)) -> Result { + let mut builder = X509Builder::new()?; + builder.set_version(CERTIFICATE_VERSION)?; + + let embargo = Asn1Time::from_unix(unix_time(SystemTime::now()) - 86400)?; + builder.set_not_before(&embargo)?; + + // Google Apple and Mozilla reject certificate horizons longer than 398 days + // https://techbeacon.com/security/google-apple-mozilla-enforce-1-year-max-security-certifications + let expiration = Asn1Time::days_from_now(397)?; + builder.set_not_after(&expiration)?; + + builder.set_serial_number(&*rand_serial()?)?; + + let mut subject_name_builder = X509NameBuilder::new()?; + subject_name_builder.append_entry_by_text( + "CN", + applicant + .1 + .dns + .first() + .map(MaybeWildcard::as_str) + .unwrap_or("localhost"), + )?; + subject_name_builder.append_entry_by_text("O", "Start9")?; + subject_name_builder.append_entry_by_text("OU", "StartOS")?; + let subject_name = subject_name_builder.build(); + builder.set_subject_name(&subject_name)?; + + builder.set_issuer_name(&subject_name)?; + + builder.set_pubkey(&applicant.0)?; + + // Extensions + let cfg = conf::Conf::new(conf::ConfMethod::default())?; + let ctx = builder.x509v3_context(None, Some(&cfg)); + // subjectKeyIdentifier = hash + let subject_key_identifier = + X509Extension::new_nid(Some(&cfg), Some(&ctx), Nid::SUBJECT_KEY_IDENTIFIER, "hash")?; + // authorityKeyIdentifier = keyid:always,issuer + let authority_key_identifier = X509Extension::new_nid( + Some(&cfg), + Some(&ctx), + Nid::AUTHORITY_KEY_IDENTIFIER, + "keyid,issuer:always", + )?; + let basic_constraints = + X509Extension::new_nid(Some(&cfg), Some(&ctx), Nid::BASIC_CONSTRAINTS, "CA:FALSE")?; + let key_usage = X509Extension::new_nid( + Some(&cfg), + Some(&ctx), + Nid::KEY_USAGE, + "critical,digitalSignature,keyEncipherment", + )?; + + let san_string = applicant.1.to_string(); + let subject_alt_name = + X509Extension::new_nid(Some(&cfg), Some(&ctx), Nid::SUBJECT_ALT_NAME, &san_string)?; + builder.append_extension(subject_key_identifier)?; + builder.append_extension(authority_key_identifier)?; + builder.append_extension(subject_alt_name)?; + builder.append_extension(basic_constraints)?; + builder.append_extension(key_usage)?; + + builder.sign(&applicant.0, MessageDigest::sha256())?; + + let cert = builder.build(); + Ok(cert) +} diff --git a/core/startos/src/tunnel/api.rs b/core/startos/src/tunnel/api.rs index 73d629e3b..238ffe075 100644 --- a/core/startos/src/tunnel/api.rs +++ b/core/startos/src/tunnel/api.rs @@ -4,7 +4,7 @@ use clap::Parser; use imbl_value::InternedString; use ipnet::Ipv4Net; use models::GatewayId; -use rpc_toolkit::{from_fn_async, Context, Empty, HandlerArgs, HandlerExt, ParentHandler}; +use rpc_toolkit::{Context, Empty, HandlerArgs, HandlerExt, ParentHandler, from_fn_async}; use serde::{Deserialize, Serialize}; use crate::context::CliContext; @@ -13,10 +13,11 @@ use crate::prelude::*; use crate::tunnel::context::TunnelContext; use crate::tunnel::db::GatewayPort; use crate::tunnel::wg::{ClientConfig, WgConfig, WgSubnetClients, WgSubnetConfig}; -use crate::util::serde::{display_serializable, HandlerExtSerde}; +use crate::util::serde::{HandlerExtSerde, display_serializable}; pub fn tunnel_api() -> ParentHandler { ParentHandler::new() + .subcommand("web", super::web::web_api()) .subcommand( "db", super::db::db_api::() diff --git a/core/startos/src/tunnel/auth.rs b/core/startos/src/tunnel/auth.rs index a8f97b961..f4de58961 100644 --- a/core/startos/src/tunnel/auth.rs +++ b/core/startos/src/tunnel/auth.rs @@ -2,13 +2,14 @@ use std::net::IpAddr; use clap::Parser; use imbl::HashMap; -use imbl_value::InternedString; +use imbl_value::{InternedString, json}; +use itertools::Itertools; use patch_db::HasModel; -use rpc_toolkit::{from_fn_async, Context, HandlerArgs, HandlerExt, ParentHandler}; +use rpc_toolkit::{Context, HandlerArgs, HandlerExt, ParentHandler, from_fn_async}; use serde::{Deserialize, Serialize}; use ts_rs::TS; -use crate::auth::{check_password, Sessions}; +use crate::auth::{Sessions, check_password}; use crate::context::CliContext; use crate::middleware::auth::AuthContext; use crate::middleware::signature::SignatureAuthContext; @@ -17,7 +18,7 @@ use crate::rpc_continuations::OpenAuthedContinuations; use crate::sign::AnyVerifyingKey; use crate::tunnel::context::TunnelContext; use crate::tunnel::db::TunnelDatabase; -use crate::util::serde::{display_serializable, HandlerExtSerde}; +use crate::util::serde::{HandlerExtSerde, display_serializable}; use crate::util::sync::SyncMutex; impl SignatureAuthContext for TunnelContext { @@ -89,51 +90,59 @@ pub struct SignerInfo { } pub fn auth_api() -> ParentHandler { - ParentHandler::new().subcommand( - "key", - ParentHandler::::new() - .subcommand( - "add", - from_fn_async(add_key) - .with_metadata("sync_db", Value::Bool(true)) - .no_display() - .with_about("Add a new authorized key") - .with_call_remote::(), - ) - .subcommand( - "remove", - from_fn_async(remove_key) - .with_metadata("sync_db", Value::Bool(true)) - .no_display() - .with_about("Remove an authorized key") - .with_call_remote::(), - ) - .subcommand( - "list", - from_fn_async(list_keys) - .with_metadata("sync_db", Value::Bool(true)) - .with_display_serializable() - .with_custom_display_fn(|HandlerArgs { params, .. }, res| { - use prettytable::*; + ParentHandler::new() + .subcommand("set-password", from_fn_async(set_password_rpc).no_cli()) + .subcommand( + "set-password", + from_fn_async(set_password_cli) + .with_about("Set user interface password") + .no_display(), + ) + .subcommand( + "key", + ParentHandler::::new() + .subcommand( + "add", + from_fn_async(add_key) + .with_metadata("sync_db", Value::Bool(true)) + .no_display() + .with_about("Add a new authorized key") + .with_call_remote::(), + ) + .subcommand( + "remove", + from_fn_async(remove_key) + .with_metadata("sync_db", Value::Bool(true)) + .no_display() + .with_about("Remove an authorized key") + .with_call_remote::(), + ) + .subcommand( + "list", + from_fn_async(list_keys) + .with_metadata("sync_db", Value::Bool(true)) + .with_display_serializable() + .with_custom_display_fn(|HandlerArgs { params, .. }, res| { + use prettytable::*; - if let Some(format) = params.format { - return display_serializable(format, res); - } + if let Some(format) = params.format { + return display_serializable(format, res); + } - let mut table = Table::new(); - table.add_row(row![bc => "NAME", "KEY"]); - for (key, info) in res { - table.add_row(row![info.name, key]); - } + let mut table = Table::new(); + table.add_row(row![bc => "NAME", "KEY"]); + for (key, info) in res { + table.add_row(row![info.name, key]); + } - table.print_tty(false)?; + table.print_tty(false)?; - Ok(()) - }) - .with_about("List authorized keys") - .with_call_remote::(), - ), - ) + Ok(()) + }) + .with_about("List authorized keys") + .with_call_remote::(), + ), + ) } #[derive(Debug, Deserialize, Serialize, Parser)] @@ -181,3 +190,55 @@ pub async fn remove_key( pub async fn list_keys(ctx: TunnelContext) -> Result, Error> { ctx.db.peek().await.into_auth_pubkeys().de() } + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct SetPasswordParams { + pub password: String, +} + +pub async fn set_password_rpc( + ctx: TunnelContext, + SetPasswordParams { password }: SetPasswordParams, +) -> Result<(), Error> { + let pwhash = argon2::hash_encoded( + password.as_bytes(), + &rand::random::<[u8; 16]>(), + &argon2::Config::rfc9106_low_mem(), + )?; + ctx.db + .mutate(|db| db.as_password_mut().ser(&Some(pwhash))) + .await + .result?; + + Ok(()) +} + +pub async fn set_password_cli( + HandlerArgs { + context, + parent_method, + method, + .. + }: HandlerArgs, +) -> Result<(), Error> { + let password = rpassword::prompt_password("New Password")?; + let confirm = rpassword::prompt_password("Confirm Password")?; + + if password != confirm { + return Err(Error::new( + eyre!("Passwords do not match"), + ErrorKind::InvalidRequest, + )); + } + + context + .call_remote::( + &parent_method.iter().chain(method.iter()).join("."), + to_value(SetPasswordParams { password }), + ) + .await?; + + println!("Password set successfully"); + + Ok(()) +} diff --git a/core/startos/src/tunnel/db.rs b/core/startos/src/tunnel/db.rs index 45c1b8f15..c71efed67 100644 --- a/core/startos/src/tunnel/db.rs +++ b/core/startos/src/tunnel/db.rs @@ -1,29 +1,35 @@ -use std::collections::BTreeMap; -use std::net::SocketAddrV4; +use std::collections::{BTreeMap, BTreeSet}; +use std::net::{IpAddr, SocketAddr, SocketAddrV4}; use std::path::PathBuf; -use clap::builder::ValueParserFactory; use clap::Parser; +use clap::builder::ValueParserFactory; use imbl::HashMap; use imbl_value::InternedString; use itertools::Itertools; use models::{FromStrParser, GatewayId}; -use patch_db::json_ptr::{JsonPointer, ROOT}; +use openssl::pkey::{PKey, Private}; +use openssl::x509::X509; use patch_db::Dump; +use patch_db::json_ptr::{JsonPointer, ROOT}; use rpc_toolkit::yajrc::RpcError; -use rpc_toolkit::{from_fn_async, Context, HandlerArgs, HandlerExt, ParentHandler}; +use rpc_toolkit::{Context, HandlerArgs, HandlerExt, ParentHandler, from_fn_async}; use serde::{Deserialize, Serialize}; use tracing::instrument; use ts_rs::TS; use crate::auth::Sessions; use crate::context::CliContext; +use crate::net::ssl::FullchainCertData; use crate::prelude::*; use crate::sign::AnyVerifyingKey; use crate::tunnel::auth::SignerInfo; use crate::tunnel::context::TunnelContext; +use crate::tunnel::web::TunnelCertData; use crate::tunnel::wg::WgServer; -use crate::util::serde::{apply_expr, deserialize_from_str, serialize_display, HandlerExtSerde}; +use crate::util::serde::{ + HandlerExtSerde, Pem, apply_expr, deserialize_from_str, serialize_display, +}; #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct GatewayPort(pub GatewayId, pub u16); @@ -74,9 +80,11 @@ impl ValueParserFactory for GatewayPort { #[serde(rename_all = "camelCase")] #[model = "Model"] pub struct TunnelDatabase { + pub webserver: Option, pub sessions: Sessions, - pub password: String, + pub password: Option, pub auth_pubkeys: HashMap, + pub certificate: BTreeMap>, TunnelCertData>, pub wg: WgServer, pub port_forwards: PortForwards, } diff --git a/core/startos/src/tunnel/mod.rs b/core/startos/src/tunnel/mod.rs index 0da5f65ae..f35995dca 100644 --- a/core/startos/src/tunnel/mod.rs +++ b/core/startos/src/tunnel/mod.rs @@ -13,6 +13,7 @@ pub mod api; pub mod auth; pub mod context; pub mod db; +pub mod web; pub mod wg; pub const TUNNEL_DEFAULT_PORT: u16 = 5960; diff --git a/core/startos/src/tunnel/web.rs b/core/startos/src/tunnel/web.rs new file mode 100644 index 000000000..543c35dab --- /dev/null +++ b/core/startos/src/tunnel/web.rs @@ -0,0 +1,242 @@ +use std::{ + collections::BTreeSet, + net::{Ipv4Addr, Ipv6Addr, SocketAddr}, +}; + +use crate::{ + context::CliContext, net::ssl::SANInfo, prelude::*, tunnel::context::TunnelContext, + util::serde::Pem, +}; +use clap::Parser; +use futures::AsyncWriteExt; +use imbl_value::{InternedString, json}; +use itertools::Itertools; +use openssl::{ + pkey::{PKey, Private}, + x509::{GeneralName, X509}, +}; +use rpc_toolkit::{Context, HandlerArgs, HandlerExt, ParentHandler, from_fn_async}; +use serde::{Deserialize, Serialize}; +use tokio::io::{AsyncBufReadExt, BufReader}; + +#[derive(Debug, Deserialize, Serialize, Parser)] +#[serde(rename_all = "camelCase")] +pub struct TunnelCertData { + #[arg(long)] + pub key: Pem>, + #[arg(long)] + pub cert: Pem>, +} + +pub fn web_api() -> ParentHandler { + ParentHandler::new() + .subcommand("init", from_fn_async(init_web_rpc).no_cli()) + .subcommand( + "init", + from_fn_async(init_web_cli) + .with_about("Initialize the webserver") + .no_display(), + ) + .subcommand( + "import-certificate", + from_fn_async(set_certificate) + .with_about("Import a certificate to use for the webserver") + .no_display() + .with_call_remote::(), + ) + .subcommand( + "uninit", + from_fn_async(uninit_web) + .with_about("Disable the webserver") + .no_display() + .with_call_remote::(), + ) +} + +pub async fn set_certificate(ctx: TunnelContext, cert_data: TunnelCertData) -> Result<(), Error> { + let mut saninfo = BTreeSet::new(); + let leaf = cert_data.cert.get(0).ok_or_else(|| { + Error::new( + eyre!("certificate chain is empty"), + ErrorKind::InvalidRequest, + ) + })?; + for san in leaf.subject_alt_names().into_iter().flatten() { + if let Some(dns) = san.dnsname() { + saninfo.insert(dns.into()); + } + if let Some(ip) = san.ipaddress() { + if ip.len() == 4 { + saninfo.insert(InternedString::from_display(&Ipv4Addr::new( + ip[0], ip[1], ip[2], ip[3], + ))); + } else if ip.len() == 16 { + saninfo.insert(InternedString::from_display(&Ipv6Addr::from_bits(bits))) + } + } + } + ctx.db + .mutate(|db| { + db.as_certificate_mut() + .insert(&JsonKey(saninfo), &cert_data) + }) + .await + .result?; + Ok(()) +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct InitWebParams { + listen: SocketAddr, +} + +pub async fn init_web_rpc( + ctx: TunnelContext, + InitWebParams { listen }: InitWebParams, +) -> Result<(), Error> { + ctx.db + .mutate(|db| { + if db.as_certificate().de()?.is_empty() { + return Err(Error::new( + eyre!("No certificate available"), + ErrorKind::OpenSsl, + )); + } + if db.as_password().transpose_ref().is_none() { + return Err(Error::new( + eyre!("Password not set"), + ErrorKind::Authorization, + )); + } + + db.as_webserver_mut().ser(&Some(listen))?; + + Ok(()) + }) + .await + .result +} + +pub async fn uninit_web(ctx: TunnelContext) -> Result<(), Error> { + ctx.db + .mutate(|db| db.as_webserver_mut().ser(&false)) + .await + .result +} + +pub async fn init_web_cli( + HandlerArgs { + context, + parent_method, + method, + .. + }: HandlerArgs, +) -> Result<(), Error> { + loop { + match context + .call_remote( + &parent_method.iter().chain(method.iter()).join("."), + json!({}), + ) + .await + { + Ok(a) => println!("Webserver Initialized"), + Err(e) if e.code == ErrorKind::OpenSsl as i32 => { + println!( + "StartTunnel has not been set up with an SSL Certificate yet. Setting one up now..." + ); + println!("[1]: Generate a Self Signed Certificate"); + println!("[2]: Provide your own certificate and key"); + let (mut readline, mut writer) = + rustyline_async::Readline::new("What would you like to do? [1-2]: ".into()) + .with_kind(ErrorKind::Filesystem)?; + readline.add_history_entry("1".into()); + readline.add_history_entry("2".into()); + let self_signed; + loop { + match readline.readline().await.with_kind(ErrorKind::Filesystem)? { + rustyline_async::ReadlineEvent::Line(l) if l.trim() == "1" => { + self_signed = true; + break; + } + rustyline_async::ReadlineEvent::Line(l) if l.trim() == "2" => { + self_signed = false; + break; + } + rustyline_async::ReadlineEvent::Line(_) => { + readline.clear_history(); + readline.add_history_entry("1".into()); + readline.add_history_entry("2".into()); + writer + .write_all(b"Invalid response. Enter either \"1\" or \"2\".") + .await?; + } + _ => return Err(Error::new(eyre!("Aborted"), ErrorKind::Unknown)), + } + } + drop((readline, writer)); + if self_signed { + let key = crate::net::ssl::gen_nistp256()?; + let cert = crate::net::ssl::make_self_signed(( + &key, + &SANInfo::new(&[].into_iter().collect()), + ))?; + } else { + println!("Please paste in your PEM encoded private key: "); + let mut stdin_lines = BufReader::new(tokio::io::stdin()).lines(); + let mut key_string = String::new(); + + while let Some(line) = stdin_lines.next_line().await? { + key_string.push_str(&line); + key_string.push_str("\n"); + if line.trim().starts_with("-----END") { + break; + } + } + + let key: Pem> = key_string.parse()?; + + println!( + "Please paste in your PEM encoded certificate (or certificate chain): " + ); + + let mut chain = Vec::new(); + + loop { + let mut cert_string = String::new(); + + while let Some(line) = stdin_lines.next_line().await? { + cert_string.push_str(&line); + cert_string.push_str("\n"); + if line.trim().starts_with("-----END") { + break; + } + } + + let cert: Pem = cert_string.parse()?; + + let is_root = cert.0.authority_key_id().is_none(); + + chain.push(cert.0); + + if is_root { + break; + } + } + + context + .call_remote( + "web.import-certificate", + to_value(&TunnelCertData { + key, + cert: Pem(chain), + })?, + ) + .await?; + } + } + Err(e) if e.code == ErrorKind::Authorization as i32 => {} + Err(e) => return Err(e.into()), + } + } +} diff --git a/core/startos/src/util/serde.rs b/core/startos/src/util/serde.rs index 435990fcf..efcdf2164 100644 --- a/core/startos/src/util/serde.rs +++ b/core/startos/src/util/serde.rs @@ -1199,6 +1199,21 @@ impl PemEncoding for X509 { } } +impl PemEncoding for Vec { + fn from_pem(pem: &str) -> Result { + X509::stack_from_pem(pem).map_err(E::custom) + } + fn to_pem(&self) -> Result { + self.iter() + .map(|x| x.to_pem()) + .try_fold(String::new(), |mut acc, x| { + acc.push_str(&x?); + acc.push_str("\n"); + Ok(acc) + }) + } +} + impl PemEncoding for PKey { fn from_pem(pem: &str) -> Result { Self::private_key_from_pem(pem.as_bytes()).map_err(E::custom) diff --git a/sdk/package/lib/util/fileHelper.ts b/sdk/package/lib/util/fileHelper.ts index 25c4bc12e..1bf2766b9 100644 --- a/sdk/package/lib/util/fileHelper.ts +++ b/sdk/package/lib/util/fileHelper.ts @@ -573,23 +573,23 @@ export class FileHelper { /** * Create a File Helper for a .toml file */ - static toml( + static toml>( path: ToPath, - shape: Validator, + shape: Validator, A>, ): FileHelper - static toml( + static toml>( path: ToPath, shape: Validator, - transformers: Transformers, + transformers: Transformers, Transformed>, ): FileHelper - static toml( + static toml>( path: ToPath, shape: Validator, - transformers?: Transformers, + transformers?: Transformers, Transformed>, ) { - return FileHelper.rawTransformed( + return FileHelper.rawTransformed, Transformed>( path, - (inData) => TOML.stringify(inData), + (inData) => TOML.stringify(inData as TOML.JsonMap), (inString) => TOML.parse(inString), (data) => shape.unsafeCast(data), transformers,