web server WIP

This commit is contained in:
Matt Hill
2025-10-21 16:01:14 -06:00
parent 40b00bae75
commit 2056d4def1
8 changed files with 469 additions and 65 deletions

View File

@@ -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<PKey<Private>, ErrorStack> {
PKey::from_ec_key(EcKey::generate(&*EcGroup::from_curve_name(
Nid::X9_62_PRIME256V1,
)?)?)
}
#[derive(Debug, Deserialize, Serialize, HasModel)]
#[model = "Model<Self>"]
#[serde(rename_all = "camelCase")]
@@ -96,9 +103,7 @@ impl Model<CertStore> {
} 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<Private>, &SANInfo)) -> Result<X509, Error> {
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)
}

View File

@@ -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<C: Context>() -> ParentHandler<C> {
ParentHandler::new()
.subcommand("web", super::web::web_api())
.subcommand(
"db",
super::db::db_api::<C>()

View File

@@ -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<C: Context>() -> ParentHandler<C> {
ParentHandler::new().subcommand(
"key",
ParentHandler::<C>::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::<CliContext>(),
)
.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::<CliContext>(),
)
.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::<C>::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::<CliContext>(),
)
.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::<CliContext>(),
)
.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::<CliContext>(),
),
)
Ok(())
})
.with_about("List authorized keys")
.with_call_remote::<CliContext>(),
),
)
}
#[derive(Debug, Deserialize, Serialize, Parser)]
@@ -181,3 +190,55 @@ pub async fn remove_key(
pub async fn list_keys(ctx: TunnelContext) -> Result<HashMap<AnyVerifyingKey, SignerInfo>, 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<CliContext>,
) -> 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::<TunnelContext>(
&parent_method.iter().chain(method.iter()).join("."),
to_value(SetPasswordParams { password }),
)
.await?;
println!("Password set successfully");
Ok(())
}

View File

@@ -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<Self>"]
pub struct TunnelDatabase {
pub webserver: Option<SocketAddr>,
pub sessions: Sessions,
pub password: String,
pub password: Option<String>,
pub auth_pubkeys: HashMap<AnyVerifyingKey, SignerInfo>,
pub certificate: BTreeMap<JsonKey<BTreeSet<InternedString>>, TunnelCertData>,
pub wg: WgServer,
pub port_forwards: PortForwards,
}

View File

@@ -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;

View File

@@ -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<PKey<Private>>,
#[arg(long)]
pub cert: Pem<Vec<X509>>,
}
pub fn web_api<C: Context>() -> ParentHandler<C> {
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::<CliContext>(),
)
.subcommand(
"uninit",
from_fn_async(uninit_web)
.with_about("Disable the webserver")
.no_display()
.with_call_remote::<CliContext>(),
)
}
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<CliContext>,
) -> 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<PKey<Private>> = 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<X509> = 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()),
}
}
}

View File

@@ -1199,6 +1199,21 @@ impl PemEncoding for X509 {
}
}
impl PemEncoding for Vec<X509> {
fn from_pem<E: serde::de::Error>(pem: &str) -> Result<Self, E> {
X509::stack_from_pem(pem).map_err(E::custom)
}
fn to_pem<E: serde::ser::Error>(&self) -> Result<String, E> {
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<Private> {
fn from_pem<E: serde::de::Error>(pem: &str) -> Result<Self, E> {
Self::private_key_from_pem(pem.as_bytes()).map_err(E::custom)

View File

@@ -573,23 +573,23 @@ export class FileHelper<A> {
/**
* Create a File Helper for a .toml file
*/
static toml<A extends TOML.JsonMap>(
static toml<A extends Record<string, unknown>>(
path: ToPath,
shape: Validator<TOML.JsonMap, A>,
shape: Validator<Record<string, unknown>, A>,
): FileHelper<A>
static toml<A extends Transformed, Transformed = TOML.JsonMap>(
static toml<A extends Transformed, Transformed = Record<string, unknown>>(
path: ToPath,
shape: Validator<Transformed, A>,
transformers: Transformers<TOML.JsonMap, Transformed>,
transformers: Transformers<Record<string, unknown>, Transformed>,
): FileHelper<A>
static toml<A extends Transformed, Transformed = TOML.JsonMap>(
static toml<A extends Transformed, Transformed = Record<string, unknown>>(
path: ToPath,
shape: Validator<Transformed, A>,
transformers?: Transformers<TOML.JsonMap, Transformed>,
transformers?: Transformers<Record<string, unknown>, Transformed>,
) {
return FileHelper.rawTransformed<A, TOML.JsonMap, Transformed>(
return FileHelper.rawTransformed<A, Record<string, unknown>, Transformed>(
path,
(inData) => TOML.stringify(inData),
(inData) => TOML.stringify(inData as TOML.JsonMap),
(inString) => TOML.parse(inString),
(data) => shape.unsafeCast(data),
transformers,