use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
pub const ENABLED_FILE: &str = "plugins.json";
pub const MAX_BODY: usize = 1024 * 1024;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct PluginName(String);
impl PluginName {
pub fn parse(raw: &str) -> Result<Self, String> {
if is_word(raw) {
Ok(Self(raw.to_string()))
} else {
Err(format!(
"'{raw}' is not a plugin name: lowercase letters, digits and '-', \
starting with a letter"
))
}
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl TryFrom<String> for PluginName {
type Error = String;
fn try_from(raw: String) -> Result<Self, String> {
Self::parse(&raw)
}
}
impl From<PluginName> for String {
fn from(name: PluginName) -> String {
name.0
}
}
impl std::fmt::Display for PluginName {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct PluginService(String);
impl PluginService {
pub fn parse(raw: &str) -> Result<Self, String> {
match raw.split_once('.') {
Some((plugin, service)) if is_word(plugin) && is_word(service) => {
Ok(Self(raw.to_string()))
}
_ => Err(format!(
"'{raw}' is not a plugin service: <plugin>.<service>, each lowercase \
letters, digits and '-', starting with a letter"
)),
}
}
pub fn plugin(&self) -> PluginName {
let (plugin, _) = self.0.split_once('.').expect("a parsed service has a plugin");
PluginName(plugin.to_string())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl TryFrom<String> for PluginService {
type Error = String;
fn try_from(raw: String) -> Result<Self, String> {
Self::parse(&raw)
}
}
impl From<PluginService> for String {
fn from(service: PluginService) -> String {
service.0
}
}
impl std::fmt::Display for PluginService {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
fn is_word(raw: &str) -> bool {
let mut chars = raw.chars();
matches!(chars.next(), Some(first) if first.is_ascii_lowercase())
&& chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
}
#[derive(Debug, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct EnabledFile {
#[serde(default)]
plugins: BTreeSet<PluginName>,
}
fn enabled_path(machine_dir: &Path) -> PathBuf {
machine_dir.join(ENABLED_FILE)
}
pub fn enabled(machine_dir: &Path) -> Result<BTreeSet<PluginName>, String> {
let path = enabled_path(machine_dir);
let text = match std::fs::read_to_string(&path) {
Ok(text) => text,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(BTreeSet::new()),
Err(error) => return Err(format!("cannot read {}: {error}", path.display())),
};
let file: EnabledFile = serde_json::from_str(&text)
.map_err(|error| format!("{} is not a plugin list: {error}", path.display()))?;
Ok(file.plugins)
}
pub fn enable(machine_dir: &Path, plugin: &PluginName) -> Result<bool, String> {
let mut plugins = enabled(machine_dir)?;
let added = plugins.insert(plugin.clone());
if added {
write(machine_dir, plugins)?;
}
Ok(added)
}
pub fn disable(machine_dir: &Path, plugin: &PluginName) -> Result<bool, String> {
let mut plugins = enabled(machine_dir)?;
let removed = plugins.remove(plugin);
if removed {
write(machine_dir, plugins)?;
}
Ok(removed)
}
fn write(machine_dir: &Path, plugins: BTreeSet<PluginName>) -> Result<(), String> {
std::fs::create_dir_all(machine_dir)
.map_err(|error| format!("cannot create {}: {error}", machine_dir.display()))?;
let path = enabled_path(machine_dir);
let text = serde_json::to_string_pretty(&EnabledFile { plugins })
.map_err(|error| format!("cannot write the plugin list: {error}"))?;
let staged = path.with_extension("json.tmp");
std::fs::write(&staged, format!("{text}\n"))
.map_err(|error| format!("cannot write {}: {error}", staged.display()))?;
std::fs::rename(&staged, &path)
.map_err(|error| format!("cannot write {}: {error}", path.display()))
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Caller {
pub id: String,
pub name: String,
pub fingerprint: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Registration {
pub plugin: PluginName,
pub services: Vec<PluginService>,
pub endpoint: String,
pub token: String,
pub pid: u32,
}
impl Registration {
pub fn check(&self) -> Result<(), String> {
if self.services.is_empty() {
return Err(format!("plugin {} registers no services", self.plugin));
}
if let Some(foreign) = self.services.iter().find(|service| service.plugin() != self.plugin) {
return Err(format!(
"plugin {} cannot register {foreign}: a plugin registers only services under its own name",
self.plugin
));
}
check_loopback(&self.endpoint)?;
if self.token.len() < 32 {
return Err(format!(
"plugin {} registered a token of {} characters; at least 32 are needed",
self.plugin,
self.token.len()
));
}
Ok(())
}
}
fn check_loopback(endpoint: &str) -> Result<(), String> {
let rest = endpoint
.strip_prefix("http://")
.ok_or_else(|| format!("plugin endpoint {endpoint} is not an http:// URL on this machine"))?;
let host = if let Some(bracketed) = rest.strip_prefix('[') {
bracketed.split(']').next().unwrap_or_default()
} else {
rest.split([':', '/']).next().unwrap_or_default()
};
let loopback = host
.parse::<std::net::IpAddr>()
.map(|address| address.is_loopback())
.unwrap_or(false);
if loopback {
Ok(())
} else {
Err(format!(
"plugin endpoint {endpoint} is not on this machine: it must name 127.0.0.1 or [::1]"
))
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Call {
pub service: PluginService,
pub caller: Caller,
pub body: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "snake_case")]
pub enum Answer {
Reply { body: String },
Refused { detail: String },
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SendRequest {
pub peer: String,
pub service: PluginService,
pub body: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "snake_case")]
pub enum Sent {
Reply { body: String },
Refused { code: String, detail: String },
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RunningPlugin {
pub plugin: PluginName,
pub services: Vec<PluginService>,
pub endpoint: String,
pub pid: u32,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PluginList {
pub enabled: BTreeSet<PluginName>,
pub running: Vec<RunningPlugin>,
}
pub mod route {
pub const LIST: &str = "/api/daemon/plugins";
pub const REGISTER: &str = "/api/daemon/plugins/register";
pub const SEND: &str = "/api/daemon/plugins/send";
}
#[cfg(test)]
#[path = "mod_tests.rs"]
mod tests;