Initial commit
Have the basics working. Need to finish the messaging part of this next
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
/target
|
||||||
Generated
+2629
File diff suppressed because it is too large
Load Diff
+22
@@ -0,0 +1,22 @@
|
|||||||
|
[package]
|
||||||
|
name = "fedicommander"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
quinn = "0.11.9"
|
||||||
|
#fedichat = {git = "https://git.firechicken.net/fedichat/fedichat-lib"}
|
||||||
|
fedichat = {path = "../fedichat-lib"}
|
||||||
|
tracing = "0.1.44"
|
||||||
|
tracing-subscriber = "0.3.23"
|
||||||
|
clap = { version = "4.6.1", features = ["derive"] }
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
serde = { version = "1.0.228", features = ["derive"] }
|
||||||
|
thiserror = "2.0.18"
|
||||||
|
toml = "1.1.2"
|
||||||
|
ctrlc-async = { version = "3.2.2", features = ["termination"] }
|
||||||
|
rmp-serde = "1.3.1"
|
||||||
|
dotenv = "0.15.0"
|
||||||
|
time = { version = "0.3.47", features = ["serde"] }
|
||||||
|
hickory-resolver = "0.26.1"
|
||||||
|
expanduser = "1.2.2"
|
||||||
+245
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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(())
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user