use clap::Parser;
use epics_base_rs::error::CaResult;
use epics_base_rs::server::records::{
ai::AiRecord, ao::AoRecord, bi::BiRecord, bo::BoRecord, longin::LonginRecord,
longout::LongoutRecord, mbbi::MbbiRecord, mbbo::MbboRecord, stringin::StringinRecord,
stringout::StringoutRecord,
};
use epics_base_rs::types::{DbFieldType, EpicsValue};
use epics_ca_rs::server::CaServer;
use std::collections::HashMap;
#[derive(Parser)]
#[command(name = "softioc")]
struct Args {
#[arg(long = "pv")]
pvs: Vec<String>,
#[arg(long = "record")]
records: Vec<String>,
#[arg(long = "db")]
db_files: Vec<String>,
#[arg(long = "macro", short = 'm')]
macros: Vec<String>,
#[arg(long, default_value_t = 5064)]
port: u16,
#[arg(long, short = 'i')]
shell: bool,
#[arg(long = "tls-cert", value_name = "PEM_FILE")]
tls_cert: Option<String>,
#[arg(long = "tls-key", value_name = "PEM_FILE")]
tls_key: Option<String>,
#[arg(long = "tls-client-ca", value_name = "PEM_FILE")]
tls_client_ca: Option<String>,
#[arg(long = "mdns", value_name = "INSTANCE")]
mdns: Option<String>,
#[arg(long = "mdns-txt", value_name = "KEY=VALUE")]
mdns_txt: Vec<String>,
#[arg(long = "dns-update-server", value_name = "HOST:PORT")]
dns_update_server: Option<String>,
#[arg(long = "dns-update-zone", value_name = "ZONE")]
dns_update_zone: Option<String>,
#[arg(long = "dns-update-instance", value_name = "NAME")]
dns_update_instance: Option<String>,
#[arg(long = "dns-update-host", value_name = "HOST")]
dns_update_host: Option<String>,
#[arg(long = "dns-update-tsig-key", value_name = "FILE")]
dns_update_tsig_key: Option<String>,
#[arg(long = "dns-update-ttl", value_name = "SECONDS", default_value_t = 60)]
dns_update_ttl: u64,
#[arg(
long = "dns-update-keepalive",
value_name = "SECONDS",
default_value_t = 30
)]
dns_update_keepalive: u64,
}
fn is_type_keyword(s: &str) -> bool {
matches!(
s,
"string"
| "str"
| "short"
| "int16"
| "float"
| "f32"
| "enum"
| "u16"
| "char"
| "u8"
| "long"
| "int32"
| "double"
| "f64"
)
}
fn parse_pv_def(def: &str) -> CaResult<(String, EpicsValue)> {
let segments: Vec<&str> = def.split(':').collect();
let type_idx = segments
.iter()
.rposition(|s| is_type_keyword(&s.to_lowercase()));
let type_idx = match type_idx {
Some(idx) if idx > 0 && idx + 1 < segments.len() => idx,
_ => {
return Err(epics_base_rs::error::CaError::InvalidValue(format!(
"expected NAME:TYPE:VALUE, got '{def}'"
)));
}
};
let name = segments[..type_idx].join(":");
let type_str = segments[type_idx].to_lowercase();
let value_str = segments[type_idx + 1..].join(":");
let dbr_type = match type_str.as_str() {
"string" | "str" => DbFieldType::String,
"short" | "int16" => DbFieldType::Short,
"float" | "f32" => DbFieldType::Float,
"enum" | "u16" => DbFieldType::Enum,
"char" | "u8" => DbFieldType::Char,
"long" | "int32" => DbFieldType::Long,
"double" | "f64" => DbFieldType::Double,
_ => unreachable!(),
};
let value = EpicsValue::parse(dbr_type, &value_str)?;
Ok((name, value))
}
fn parse_record_def(
def: &str,
) -> CaResult<(String, Box<dyn epics_base_rs::server::record::Record>)> {
let (rec_type_str, remainder) = def.split_once(':').ok_or_else(|| {
epics_base_rs::error::CaError::InvalidValue(format!(
"expected RECORD_TYPE:NAME[:VALUE], got '{def}'"
))
})?;
let rec_type = rec_type_str.to_lowercase();
let (name, value_str) = if let Some((prefix, suffix)) = remainder.rsplit_once(':') {
(prefix, suffix)
} else {
(remainder, "")
};
macro_rules! parse_or_default {
($type:ty, $default:expr) => {{
if value_str.is_empty() {
(remainder, $default)
} else if let Ok(v) = value_str.parse::<$type>() {
(name, v)
} else {
(remainder, $default)
}
}};
}
let record: Box<dyn epics_base_rs::server::record::Record> = match rec_type.as_str() {
"ai" => {
let (n, val) = parse_or_default!(f64, 0.0);
return Ok((n.to_string(), Box::new(AiRecord::new(val))));
}
"ao" => {
let (n, val) = parse_or_default!(f64, 0.0);
return Ok((n.to_string(), Box::new(AoRecord::new(val))));
}
"bi" => {
let (n, val) = parse_or_default!(u16, 0);
return Ok((n.to_string(), Box::new(BiRecord::new(val))));
}
"bo" => {
let (n, val) = parse_or_default!(u16, 0);
return Ok((n.to_string(), Box::new(BoRecord::new(val))));
}
"longin" => {
let (n, val) = parse_or_default!(i32, 0);
return Ok((n.to_string(), Box::new(LonginRecord::new(val))));
}
"longout" => {
let (n, val) = parse_or_default!(i32, 0);
return Ok((n.to_string(), Box::new(LongoutRecord::new(val))));
}
"mbbi" => {
let (n, val) = parse_or_default!(u16, 0);
return Ok((n.to_string(), Box::new(MbbiRecord::new(val))));
}
"mbbo" => {
let (n, val) = parse_or_default!(u16, 0);
return Ok((n.to_string(), Box::new(MbboRecord::new(val))));
}
"stringin" => Box::new(StringinRecord::new(remainder)),
"stringout" => Box::new(StringoutRecord::new(remainder)),
_ => {
return Err(epics_base_rs::error::CaError::InvalidValue(format!(
"unknown record type '{rec_type}'"
)));
}
};
Ok((remainder.to_string(), record))
}
fn parse_macros(macro_strs: &[String]) -> HashMap<String, String> {
let mut macros = HashMap::new();
for m in macro_strs {
if let Some((k, v)) = m.split_once('=') {
macros.insert(k.trim().to_string(), v.trim().to_string());
}
}
macros
}
#[tokio::main]
async fn main() -> CaResult<()> {
let args = Args::parse();
if args.pvs.is_empty() && args.records.is_empty() && args.db_files.is_empty() {
eprintln!("Error: at least one --pv, --record, or --db is required");
std::process::exit(1);
}
let mut builder = CaServer::builder().port(args.port);
for pv_def in &args.pvs {
let (name, value) = parse_pv_def(pv_def)?;
eprintln!(" PV: {name} = {value} ({})", value.dbr_type() as u16);
builder = builder.pv(&name, value);
}
for rec_def in &args.records {
let (name, record) = parse_record_def(rec_def)?;
eprintln!(" Record: {name} ({})", record.record_type());
builder = builder.record_boxed(&name, record);
}
let macros = parse_macros(&args.macros);
for db_file in &args.db_files {
eprintln!(" Loading DB: {db_file}");
builder = builder.db_file(db_file, ¯os)?;
}
#[cfg(feature = "experimental-rust-tls")]
{
match (&args.tls_cert, &args.tls_key) {
(Some(cert_path), Some(key_path)) => {
let chain = epics_ca_rs::tls::load_certs(cert_path)?;
let key = epics_ca_rs::tls::load_private_key(key_path)?;
let tls = if let Some(ref ca_path) = args.tls_client_ca {
let roots = epics_ca_rs::tls::load_root_store(ca_path)?;
epics_ca_rs::tls::TlsConfig::server_mtls_from_pem(chain, key, roots).map_err(
|e| epics_base_rs::error::CaError::InvalidValue(format!("TLS: {e}")),
)?
} else {
epics_ca_rs::tls::TlsConfig::server_from_pem(chain, key).map_err(|e| {
epics_base_rs::error::CaError::InvalidValue(format!("TLS: {e}"))
})?
};
builder = builder.with_tls(tls);
}
(None, None) => {} _ => {
return Err(epics_base_rs::error::CaError::InvalidValue(
"--tls-cert and --tls-key must both be set or both unset".into(),
));
}
}
}
#[cfg(not(feature = "experimental-rust-tls"))]
if args.tls_cert.is_some() || args.tls_key.is_some() || args.tls_client_ca.is_some() {
return Err(epics_base_rs::error::CaError::InvalidValue(
"TLS flags require building with --features experimental-rust-tls".into(),
));
}
if let Some(ref instance) = args.mdns {
builder = builder.announce_mdns(instance);
for kv in &args.mdns_txt {
if let Some((k, v)) = kv.split_once('=') {
builder = builder.announce_txt(k, v);
} else {
eprintln!("warning: --mdns-txt expects KEY=VALUE, got {kv:?}; skipping");
}
}
}
#[cfg(feature = "discovery-dns-update")]
{
let any_dns_flag = args.dns_update_server.is_some()
|| args.dns_update_zone.is_some()
|| args.dns_update_instance.is_some();
let all_required = args.dns_update_server.is_some()
&& args.dns_update_zone.is_some()
&& args.dns_update_instance.is_some();
if any_dns_flag && !all_required {
return Err(epics_base_rs::error::CaError::InvalidValue(
"--dns-update-server, --dns-update-zone, --dns-update-instance must all be set together".into(),
));
}
if all_required {
let server: std::net::SocketAddr = args
.dns_update_server
.as_ref()
.unwrap()
.parse()
.map_err(|e| {
epics_base_rs::error::CaError::InvalidValue(format!("--dns-update-server: {e}"))
})?;
let host = args.dns_update_host.clone().unwrap_or_else(|| {
std::env::var("HOSTNAME")
.ok()
.or_else(|| {
std::fs::read_to_string("/etc/hostname")
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
})
.unwrap_or_else(|| "localhost".to_string())
});
let tsig = match args.dns_update_tsig_key.as_ref() {
Some(path) => Some(
epics_ca_rs::discovery::TsigKey::from_bind_file(path).map_err(|e| {
epics_base_rs::error::CaError::InvalidValue(format!(
"--dns-update-tsig-key: {e}"
))
})?,
),
None => None,
};
let reg = epics_ca_rs::discovery::DnsRegistration {
server,
zone: args.dns_update_zone.clone().unwrap(),
instance: args.dns_update_instance.clone().unwrap(),
host,
port: args.port,
txt: Vec::new(),
ttl: std::time::Duration::from_secs(args.dns_update_ttl),
keepalive: std::time::Duration::from_secs(args.dns_update_keepalive),
tsig,
};
builder = builder.register_dns_update(reg);
}
}
#[cfg(not(feature = "discovery-dns-update"))]
if args.dns_update_server.is_some()
|| args.dns_update_zone.is_some()
|| args.dns_update_instance.is_some()
|| args.dns_update_tsig_key.is_some()
{
return Err(epics_base_rs::error::CaError::InvalidValue(
"--dns-update-* flags require building with --features discovery-dns-update".into(),
));
}
let server = builder.build().await?;
if args.shell {
server.run_with_shell(|_shell| {}).await
} else {
server.run().await
}
}