Initial commit

Have the basics working. Need to finish the messaging part of this next
This commit is contained in:
2026-05-30 18:18:38 -07:00
commit 7c74c66470
6 changed files with 3116 additions and 0 deletions
+245
View File
@@ -0,0 +1,245 @@
use clap::{Parser,Subcommand,ArgAction};
use fedichat::client::{ClientMessage,SignedClientMessage,AuthMethod};
use fedichat::ServerAddr;
use fedichat::state::StatePath;
use time::OffsetDateTime;
use thiserror::Error;
// Everything except server, user, and password lives in these
#[derive(Parser, Debug)]
#[command(version, about, long_about = None)]
pub struct Args {
#[command(subcommand)]
pub command: Command,
#[clap(short, long)]
pub server: Option<String>,
#[clap(short, long)]
pub username: String,
#[clap(short, long, action = ArgAction::Count)]
pub verbose: u8,
}
#[derive(Subcommand,Debug)]
pub enum Command {
CreateUser {
#[clap(short, long)]
password: String
},
// This should login and then get a token and save it.
// For now because there are no tokens it saves the username
// and password in cleartext :)
Login {
#[clap(short, long)]
password: String
},
SendMessage {
#[clap(short, long)]
body: String,
// Will be parsed into the right type later
#[clap(short, long, value_parser=Room::from_str)]
room: Room,
},
Join {
#[clap(value_parser=Room::from_str)]
room: Room,
},
Leave {
#[clap(value_parser=Room::from_str)]
room: Room,
},
Create {
#[clap(value_parser=Room::from_str)]
room: Room,
},
Listen {
#[clap(short, long, value_parser=Room::from_str)]
room: Room,
},
GetState {
#[clap(short, long, value_parser=Room::from_str)]
room: Room,
// Not sure if the delimiter should be a . or a /
#[clap(short, long, value_delimiter = '.', num_args = 1..)]
path: Vec<String>
},
WriteState {
#[clap(short, long, value_parser=Room::from_str)]
room: Room,
// Not sure if the delimiter should be a . or a /
#[clap(short, long, value_delimiter = '.', num_args = 1..)]
path: Vec<String>,
#[clap(short, long)]
content: String
},
SendPost {
#[clap(short, long)]
body: String,
// Will be parsed into the right type later
#[clap(short, long, value_parser=user_from_str)]
user: fedichat::User,
},
}
impl Command {
// Returns clientmessage and target server
fn into_client_message(self,username: String) -> (ClientMessage,Option<ServerAddr>) {
use Command::*;
match self {
CreateUser {
password,
} => (ClientMessage::UserCreate {username, password},None),
// This should login and then get a token and save it.
// For now because there are no tokens it saves the username
// and password in cleartext :)
Login {
password,
} => (ClientMessage::Auth{username,password: AuthMethod::Password(password)},None),
SendMessage {
body,
room,
} => (ClientMessage::Message{body, room_id: room.get_coord(), id: None},room.get_server()),
Listen {
room,
} => (ClientMessage::SubscribeMessages{room_id: room.get_coord()},room.get_server()),
Join {
room,
} => (ClientMessage::RoomJoin{room_id: room.get_coord()},room.get_server()),
Leave {
room,
} => (ClientMessage::RoomLeave{room_id: room.get_coord()},room.get_server()),
Create {
room,
} => (ClientMessage::RoomCreate{room_id: room.get_coord()},room.get_server()),
GetState {
room,
path,
} => (ClientMessage::StateRead{room_id: room.get_coord(), path: StatePath(path)},room.get_server()),
WriteState {
room,
path,
content,
} => (ClientMessage::StateWrite{
room_id: room.get_coord(),
path: StatePath(path),
content: fedichat::state::StateValue::String(content)
},room.get_server()),
SendPost {
body,
user,
} => (ClientMessage::MessagePost{body, user: user.clone()},Some(ServerAddr(user.server))),
}
}
pub fn into_signed_message(self, username: String) -> SignedClientMessage {
let (message,target) = self.into_client_message(username);
SignedClientMessage {
message,
target,
timestamp: OffsetDateTime::now_utc(),
// TODO: actually implement signatures
signature: Box::new([0])
}
}
pub fn needs_auth(&self) -> bool {
match self {
Command::Login{..} | Command::CreateUser{..} => false,
_ => true
}
}
// If a command needs multiple messages, like an auth message first
// then call this
pub fn generate_messages(self,username: String,token: Option<String>)
-> Result<Vec<SignedClientMessage>,MessageError>
{
let mut messages = Vec::with_capacity(2);
let needs_auth = self.needs_auth();
if needs_auth && token.is_none() {
return Err(MessageError::NoToken);
}
if needs_auth && let Some(token) = token {
messages.push(SignedClientMessage {
message: ClientMessage::Auth {
username: username.clone(),
password: AuthMethod::Token(token)
},
target: None,
timestamp: OffsetDateTime::now_utc(),
signature: Box::new([0])
})
}
messages.push(self.into_signed_message(username));
// If this message is authenticating us then we should be generating
// a new token and saving it for future commands
if !needs_auth {
messages.push(SignedClientMessage {
message: ClientMessage::CreateToken,
target: None,
timestamp: OffsetDateTime::now_utc(),
signature: Box::new([0])});
}
Ok(messages)
}
}
#[derive(Error,Debug)]
pub enum MessageError {
#[error("Command needs a token. Login or create an account first.")]
NoToken
}
// example room: 1,-3,2@fedichat.net
#[derive(Clone,Debug)]
pub struct Room {
pub coord: Vec<i64>,
pub server: Option<String>
}
impl Room {
pub fn from_str(other: &str) -> Result<Self,String> {
let pair: Vec<&str> = other.split('@').collect();
let (coords,server) = match pair.as_slice() {
[coords,server] => (coords,Some(server.to_string())),
[coords] => (coords,None),
_ => return Err("Wrong number of @. Need exactly 1".to_string())
};
let coords: Vec<&str> = coords.split(',').collect();
let coord = coords.into_iter().map(
|x| x.parse::<i64>().map_err(|_| format!("Failed to parse coordinate {}",x))
).collect::<Result<Vec<i64>,String>>()?;
Ok(Room{coord,server})
}
// I probably need to not use these type fields directly dont I
pub fn get_coord(&self) -> fedichat::RoomId {
fedichat::RoomId{coordinates: self.coord.clone()}
}
pub fn get_server(&self) -> Option<fedichat::ServerAddr> {
self.server.clone().map(|x| fedichat::ServerAddr(x))
}
}
pub fn user_from_str(s: &str) -> Result<fedichat::User,String> {
match s.split('@').collect::<Vec<&str>>().as_slice() {
[name,server] =>
Ok(fedichat::User {
name: name.to_string(),
server: server.to_string()
}),
_ => Err(format!("Username must contain exactly one @: {}",s))
}
}
+99
View File
@@ -0,0 +1,99 @@
use expanduser::expanduser;
use serde::{Serialize,Deserialize};
use std::collections::HashMap;
use std::fs::File;
use std::io::{Read,Write};
use thiserror::Error;
use tracing::{debug};
const CFG_PATH: &'static str = "~/.config/fedicommander/fedicommander.toml";
const DIR_PATH: &'static str = "~/.config/fedicommander/";
#[derive(Serialize,Deserialize)]
pub struct Config {
pub default_server: String,
pub servers: HashMap<String,Server>
}
impl Config {
// Try to load a config file from ~/.config/fedicommander/fedicommander.toml
// Returns a valid config, IOError, or serialization error depending
pub fn from_file() -> Result<Config,ConfigError> {
let mut file = File::open(expanduser(CFG_PATH)?)?;
// we need at least enough bytes to load in a server
// saves a few reallocs probably
let mut buf = Vec::with_capacity(100);
file.read_to_end(&mut buf)?;
Ok(toml::from_slice(&buf)?)
}
// Save the config back to disk
pub fn write_to_file(&self) -> Result<(),ConfigError> {
debug!("Saving config");
let mut file = File::create(expanduser(CFG_PATH)?)?;
let to_write = toml::to_string(self)?;
file.write_all(to_write.as_bytes())?;
Ok(())
}
// Create a new config file and return the default config
pub fn create_and_write(server: String) -> Result<Config,ConfigError> {
let dirpath = expanduser(DIR_PATH)?;
if !std::fs::exists(&dirpath)? {
debug!("Creating new directory: {}",dirpath.display());
std::fs::create_dir(dirpath)?;
}
let config = Config {
default_server: server.clone(),
servers: HashMap::from([(server,Server{users: HashMap::new()})])
};
debug!("Writing to new config file");
let mut file = File::create(expanduser(CFG_PATH)?)?;
let to_write = toml::to_string(&config)?;
file.write_all(to_write.as_bytes())?;
debug!("Successfully created config file");
Ok(config)
}
pub fn insert_token(&mut self,server: &String,username: String, token: String) -> Result<(),ConfigError> {
let _ = self.servers.get_mut(server).ok_or(ConfigError::ServerNotFound(server.clone()))?
.users.insert(username,token);
Ok(())
}
pub fn get_token(&self,server: &String, username: &String) -> Result<Option<String>,ConfigError> {
Ok(self.servers.get(server).ok_or(ConfigError::ServerNotFound(server.clone()))?
.users.get(username).cloned())
}
}
#[derive(Serialize,Deserialize)]
pub struct Server {
// user to token mapping
users: HashMap<String,String>
}
#[derive(Error,Debug)]
pub enum ConfigError {
#[error("Error while loading file: {0}")]
IOError(#[from] std::io::Error),
#[error("Error while deserializing config file: {0}")]
DeError(#[from] toml::de::Error),
#[error("Error while serializing config file: {0}")]
SerError(#[from] toml::ser::Error),
//#[error("User `{0}` not found in config")]
//UserNotFound(String),
#[error("Server `{0}` not found in config")]
ServerNotFound(String),
}
+120
View File
@@ -0,0 +1,120 @@
mod cli;
mod config;
use cli::{Args,Command};
use config::{Config,ConfigError};
use clap::Parser;
use fedichat::client::ServerMessage;
use hickory_resolver::Resolver;
use std::net::{IpAddr,SocketAddr};
use quinn::Endpoint;
use rmp_serde::encode::Serializer;
use serde::Serialize;
use tracing::{instrument,debug,Level};
const CLIENT_ADDR: SocketAddr = SocketAddr::new(IpAddr::V6(std::net::Ipv6Addr::UNSPECIFIED), 0);
const PORT: u16 = 53512;
#[tokio::main]
#[instrument]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let cli = Args::parse();
let level = match cli.verbose {
0 => Level::INFO,
1 => Level::DEBUG,
_ => Level::TRACE
};
tracing::subscriber::set_global_default(
tracing_subscriber::fmt().with_max_level(level).finish()
).expect("Failed to setup logger");
debug!("Reading config file");
let mut config = match Config::from_file() {
Ok(conf) => conf,
Err(ConfigError::IOError(e))
if e.kind() == std::io::ErrorKind::NotFound => {
debug!("Could not open config file. Recreating it");
match &cli.command {
Command::CreateUser{password}
| Command::Login{password}
if let Some(ref server) = cli.server => {
Config::create_and_write(server.clone())?
},
_ => {
eprintln!("Must give a createuser or auth command to add an account.");
std::process::exit(1);
}
}
},
// Unrecoverable error
Err(e) => return Err(Box::new(e).into())
};
let target_server = match cli.server {
Some(s) => s,
None => config.default_server.clone()
};
let token = config.get_token(&target_server,&cli.username)?;
debug!("Target server is {target_server}");
// lookup server address
debug!("Performing DNS query");
let resolver = Resolver::builder_tokio().unwrap().build().unwrap();
let response = resolver.lookup_ip(&target_server).await.unwrap().iter()
.next().expect("DNS lookup of server failed");
debug!("Found address {response}");
// create connection
let client_config = quinn::ClientConfig::try_with_platform_verifier().unwrap();
let endpoint = Endpoint::client(CLIENT_ADDR)?;
let connection = endpoint.connect_with(
client_config,
SocketAddr::new(response,PORT),
&target_server)?.await?;
// send messages, each time waiting for a response
let messages = cli.command.generate_messages(cli.username.clone(),token)?;
debug!("Sending commands");
for message in messages {
let mut buf = Vec::new();
debug!("Sending message");
message.serialize(&mut Serializer::new(&mut buf).with_struct_map()).unwrap();
let (mut handle,mut recv) = connection.open_bi().await?;
handle.write_all(&mut buf).await?;
handle.finish()?;
debug!("Message sent, waiting on response.");
// Max response should never be this big but we can spare the memory
let received = recv.read_to_end(1000 * 1000).await?;
let response: fedichat::client::ServerMessage = rmp_serde::from_slice(&received)?;
// If we received a message that gave us a new token then update the local copy
match response {
ServerMessage::Token(ref token) => {
config.insert_token(&target_server,cli.username.clone(),token.clone())?;
},
_ => {}
}
println!("{:?}",response);
}
// On listen specifically we will stay connected
// Write back the config once we are done
config.write_to_file()?;
Ok(())
}