Initial commit
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
mod management;
|
||||
|
||||
use clap::Parser;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "rustsamba", about = "Embedded SMB client/server engine for smartsamba")]
|
||||
struct Cli {
|
||||
#[arg(long)]
|
||||
management: bool,
|
||||
|
||||
#[arg(long, default_value = "info")]
|
||||
log_level: String,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let cli = Cli::parse();
|
||||
|
||||
tracing_subscriber::fmt()
|
||||
.with_writer(std::io::stderr)
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_new(&cli.log_level)
|
||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
|
||||
)
|
||||
.init();
|
||||
|
||||
if cli.management {
|
||||
management::management_loop().await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
eprintln!("rustsamba: use --management for IPC mode");
|
||||
std::process::exit(1);
|
||||
}
|
||||
@@ -0,0 +1,568 @@
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use base64::{Engine as _, engine::general_purpose};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::io::Write;
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
use std::path::Path;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct IpcRequest {
|
||||
id: String,
|
||||
method: String,
|
||||
#[serde(default)]
|
||||
params: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct IpcResponse {
|
||||
id: String,
|
||||
success: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
result: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct IpcEvent {
|
||||
event: String,
|
||||
data: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct StartServerParams {
|
||||
config: SambaServerConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SambaServerConfig {
|
||||
host: Option<String>,
|
||||
port: Option<u16>,
|
||||
netbios_name: Option<String>,
|
||||
#[serde(default)]
|
||||
users: Vec<SambaServerUser>,
|
||||
shares: Vec<SambaServerShare>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SambaServerUser {
|
||||
username: String,
|
||||
password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SambaServerShare {
|
||||
name: String,
|
||||
path: String,
|
||||
#[serde(default)]
|
||||
read_only: bool,
|
||||
#[serde(default)]
|
||||
public: bool,
|
||||
users: Option<Vec<SambaServerShareUser>>,
|
||||
create_if_missing: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SambaServerShareUser {
|
||||
username: String,
|
||||
access: Option<SambaShareAccess>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone, Copy)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
enum SambaShareAccess {
|
||||
Read,
|
||||
ReadWrite,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SambaConnectionConfig {
|
||||
host: String,
|
||||
port: Option<u16>,
|
||||
username: Option<String>,
|
||||
password: Option<String>,
|
||||
domain: Option<String>,
|
||||
timeout_ms: Option<u64>,
|
||||
compression: Option<bool>,
|
||||
dfs_enabled: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ConnectionParams {
|
||||
connection: SambaConnectionConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SharePathParams {
|
||||
connection: SambaConnectionConfig,
|
||||
share: String,
|
||||
path: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct WriteFileParams {
|
||||
connection: SambaConnectionConfig,
|
||||
share: String,
|
||||
path: String,
|
||||
data_base64: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RenameParams {
|
||||
connection: SambaConnectionConfig,
|
||||
share: String,
|
||||
from: String,
|
||||
to: String,
|
||||
}
|
||||
|
||||
struct RunningServer {
|
||||
local_addr: SocketAddr,
|
||||
shares: Vec<String>,
|
||||
join: tokio::task::JoinHandle<std::io::Result<()>>,
|
||||
}
|
||||
|
||||
impl RunningServer {
|
||||
fn status(&self) -> Value {
|
||||
serde_json::json!({
|
||||
"running": true,
|
||||
"host": self.local_addr.ip().to_string(),
|
||||
"port": self.local_addr.port(),
|
||||
"address": self.local_addr.to_string(),
|
||||
"shares": self.shares,
|
||||
})
|
||||
}
|
||||
|
||||
async fn stop(self) {
|
||||
self.join.abort();
|
||||
let _ = self.join.await;
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn management_loop() -> Result<()> {
|
||||
send_event("ready", serde_json::json!({}));
|
||||
|
||||
let stdin = BufReader::new(tokio::io::stdin());
|
||||
let mut lines = stdin.lines();
|
||||
let mut server: Option<RunningServer> = None;
|
||||
|
||||
while let Some(line) = lines.next_line().await? {
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let request: IpcRequest = match serde_json::from_str(line) {
|
||||
Ok(request) => request,
|
||||
Err(error) => {
|
||||
send_response(IpcResponse::err(
|
||||
"unknown".to_string(),
|
||||
format!("Invalid IPC request: {error}"),
|
||||
));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let id = request.id.clone();
|
||||
let response = handle_request(request, &mut server).await;
|
||||
send_response(match response {
|
||||
Ok(value) => IpcResponse::ok(id, value),
|
||||
Err(error) => IpcResponse::err(id, error.to_string()),
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(server) = server.take() {
|
||||
server.stop().await;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_request(request: IpcRequest, server: &mut Option<RunningServer>) -> Result<Value> {
|
||||
match request.method.as_str() {
|
||||
"startServer" => {
|
||||
let params: StartServerParams = serde_json::from_value(request.params)?;
|
||||
start_server(params.config, server).await
|
||||
}
|
||||
"stopServer" => {
|
||||
if let Some(running) = server.take() {
|
||||
running.stop().await;
|
||||
}
|
||||
Ok(serde_json::json!({}))
|
||||
}
|
||||
"getServerStatus" => Ok(match server.as_ref() {
|
||||
Some(running) => running.status(),
|
||||
None => serde_json::json!({ "running": false, "shares": [] }),
|
||||
}),
|
||||
"listShares" => {
|
||||
let params: ConnectionParams = serde_json::from_value(request.params)?;
|
||||
list_shares(params.connection).await
|
||||
}
|
||||
"listDirectory" => {
|
||||
let params: SharePathParams = serde_json::from_value(request.params)?;
|
||||
list_directory(params).await
|
||||
}
|
||||
"readFile" => {
|
||||
let params: SharePathParams = serde_json::from_value(request.params)?;
|
||||
read_file(params).await
|
||||
}
|
||||
"writeFile" => {
|
||||
let params: WriteFileParams = serde_json::from_value(request.params)?;
|
||||
write_file(params).await
|
||||
}
|
||||
"deleteFile" => {
|
||||
let params: SharePathParams = serde_json::from_value(request.params)?;
|
||||
delete_file(params).await
|
||||
}
|
||||
"createDirectory" => {
|
||||
let params: SharePathParams = serde_json::from_value(request.params)?;
|
||||
create_directory(params).await
|
||||
}
|
||||
"rename" => {
|
||||
let params: RenameParams = serde_json::from_value(request.params)?;
|
||||
rename_path(params).await
|
||||
}
|
||||
"stat" => {
|
||||
let params: SharePathParams = serde_json::from_value(request.params)?;
|
||||
stat_path(params).await
|
||||
}
|
||||
unknown => Err(anyhow!("Unknown method: {unknown}")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn start_server(config: SambaServerConfig, server: &mut Option<RunningServer>) -> Result<Value> {
|
||||
if server.is_some() {
|
||||
return Err(anyhow!("Samba server is already running"));
|
||||
}
|
||||
if config.shares.is_empty() {
|
||||
return Err(anyhow!("At least one share must be configured"));
|
||||
}
|
||||
|
||||
let host = config.host.unwrap_or_else(|| "127.0.0.1".to_string());
|
||||
let port = config.port.unwrap_or(445);
|
||||
let ip: IpAddr = host.parse().with_context(|| format!("Invalid server host: {host}"))?;
|
||||
let listen_addr = SocketAddr::new(ip, port);
|
||||
|
||||
let mut builder = smb_server::SmbServer::builder().listen(listen_addr);
|
||||
if let Some(netbios_name) = config.netbios_name {
|
||||
builder = builder.netbios_name(netbios_name);
|
||||
}
|
||||
for user in &config.users {
|
||||
builder = builder.user(user.username.clone(), user.password.clone());
|
||||
}
|
||||
|
||||
let mut share_names = Vec::new();
|
||||
for share_config in &config.shares {
|
||||
if share_config.create_if_missing.unwrap_or(true) {
|
||||
std::fs::create_dir_all(&share_config.path)
|
||||
.with_context(|| format!("Failed to create share path {}", share_config.path))?;
|
||||
}
|
||||
|
||||
let backend = smb_server::LocalFsBackend::new(Path::new(&share_config.path))
|
||||
.with_context(|| format!("Failed to open share path {}", share_config.path))?;
|
||||
let backend = if share_config.read_only {
|
||||
backend.read_only()
|
||||
} else {
|
||||
backend
|
||||
};
|
||||
let mut share = smb_server::Share::new(share_config.name.clone(), backend);
|
||||
|
||||
if share_config.public {
|
||||
share = if share_config.read_only {
|
||||
share.public_read_only()
|
||||
} else {
|
||||
share.public()
|
||||
};
|
||||
} else {
|
||||
let configured_users = share_config.users.clone().unwrap_or_else(|| {
|
||||
config
|
||||
.users
|
||||
.iter()
|
||||
.map(|user| SambaServerShareUser {
|
||||
username: user.username.clone(),
|
||||
access: Some(SambaShareAccess::ReadWrite),
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
if configured_users.is_empty() {
|
||||
return Err(anyhow!(
|
||||
"Share {} needs either public=true or at least one configured user",
|
||||
share_config.name
|
||||
));
|
||||
}
|
||||
for share_user in configured_users {
|
||||
share = share.user(share_user.username, access_to_smb(share_user.access));
|
||||
}
|
||||
}
|
||||
|
||||
share_names.push(share_config.name.clone());
|
||||
builder = builder.share(share);
|
||||
}
|
||||
|
||||
let smb_server = builder.build().map_err(|error| anyhow!(error.to_string()))?;
|
||||
let local_addr = smb_server.bind().await?;
|
||||
let join = tokio::spawn(async move { smb_server.serve().await });
|
||||
|
||||
let running = RunningServer {
|
||||
local_addr,
|
||||
shares: share_names.clone(),
|
||||
join,
|
||||
};
|
||||
let result = serde_json::json!({
|
||||
"host": local_addr.ip().to_string(),
|
||||
"port": local_addr.port(),
|
||||
"address": local_addr.to_string(),
|
||||
"shares": share_names,
|
||||
});
|
||||
*server = Some(running);
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn access_to_smb(access: Option<SambaShareAccess>) -> smb_server::Access {
|
||||
match access.unwrap_or(SambaShareAccess::ReadWrite) {
|
||||
SambaShareAccess::Read => smb_server::Access::Read,
|
||||
SambaShareAccess::ReadWrite => smb_server::Access::ReadWrite,
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_smb_path(path: &str) -> String {
|
||||
path.trim_matches(['/', '\\']).replace('/', "\\")
|
||||
}
|
||||
|
||||
fn directory_smb_path(path: &str) -> String {
|
||||
let normalized = normalize_smb_path(path);
|
||||
if normalized.is_empty() {
|
||||
"\\".to_string()
|
||||
} else {
|
||||
normalized
|
||||
}
|
||||
}
|
||||
|
||||
fn smolder_error(error: smolder_core::error::CoreError) -> anyhow::Error {
|
||||
anyhow!(error.to_string())
|
||||
}
|
||||
|
||||
fn finish_smolder<T>(
|
||||
operation_result: std::result::Result<T, smolder_core::error::CoreError>,
|
||||
logoff_result: std::result::Result<(), smolder_core::error::CoreError>,
|
||||
) -> Result<T> {
|
||||
match (operation_result, logoff_result) {
|
||||
(Ok(value), Ok(())) => Ok(value),
|
||||
(Err(error), _) => Err(smolder_error(error)),
|
||||
(Ok(_), Err(error)) => Err(smolder_error(error)),
|
||||
}
|
||||
}
|
||||
|
||||
fn system_time_to_filetime(time: Option<SystemTime>) -> u64 {
|
||||
const WINDOWS_TICK: u64 = 10_000_000;
|
||||
const SEC_TO_UNIX_EPOCH: u64 = 11_644_473_600;
|
||||
|
||||
match time.and_then(|value| value.duration_since(UNIX_EPOCH).ok()) {
|
||||
Some(duration) => {
|
||||
(duration.as_secs() + SEC_TO_UNIX_EPOCH) * WINDOWS_TICK
|
||||
+ u64::from(duration.subsec_nanos()) / 100
|
||||
}
|
||||
None => 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn metadata_to_json(metadata: &smolder_core::facade::FileMetadata) -> Value {
|
||||
serde_json::json!({
|
||||
"size": metadata.size,
|
||||
"isDirectory": metadata.is_directory(),
|
||||
"createdFiletime": system_time_to_filetime(metadata.created),
|
||||
"modifiedFiletime": system_time_to_filetime(metadata.written),
|
||||
"accessedFiletime": system_time_to_filetime(metadata.accessed),
|
||||
})
|
||||
}
|
||||
|
||||
fn directory_entry_to_json(entry: smolder_core::facade::DirectoryEntry) -> Value {
|
||||
let smolder_core::facade::DirectoryEntry { name, metadata, .. } = entry;
|
||||
serde_json::json!({
|
||||
"name": name,
|
||||
"size": metadata.size,
|
||||
"isDirectory": metadata.is_directory(),
|
||||
"createdFiletime": system_time_to_filetime(metadata.created),
|
||||
"modifiedFiletime": system_time_to_filetime(metadata.written),
|
||||
})
|
||||
}
|
||||
|
||||
fn create_smb_client(config: &SambaConnectionConfig) -> Result<smolder_core::facade::Client> {
|
||||
let _unsupported_options = (config.timeout_ms, config.compression, config.dfs_enabled);
|
||||
let mut credentials = smolder_core::auth::NtlmCredentials::new(
|
||||
config.username.clone().unwrap_or_default(),
|
||||
config.password.clone().unwrap_or_default(),
|
||||
);
|
||||
if let Some(domain) = &config.domain {
|
||||
if !domain.is_empty() {
|
||||
credentials = credentials.with_domain(domain.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let mut builder = smolder_core::facade::Client::builder(config.host.clone())
|
||||
.with_ntlm_credentials(credentials);
|
||||
if let Some(port) = config.port {
|
||||
builder = builder.with_port(port);
|
||||
}
|
||||
|
||||
builder.build().map_err(smolder_error)
|
||||
}
|
||||
|
||||
async fn connect_share_client(
|
||||
config: &SambaConnectionConfig,
|
||||
share: &str,
|
||||
) -> Result<smolder_core::facade::Share> {
|
||||
create_smb_client(config)?
|
||||
.connect_share(share)
|
||||
.await
|
||||
.map_err(smolder_error)
|
||||
.with_context(|| format!("Failed to connect to share {share}"))
|
||||
}
|
||||
|
||||
async fn list_shares(connection: SambaConnectionConfig) -> Result<Value> {
|
||||
let mut srvsvc = create_smb_client(&connection)?
|
||||
.connect_srvsvc()
|
||||
.await
|
||||
.map_err(smolder_error)?;
|
||||
let shares = srvsvc
|
||||
.share_enum_level1()
|
||||
.await
|
||||
.map_err(smolder_error)?
|
||||
.into_iter()
|
||||
.map(|share| {
|
||||
serde_json::json!({
|
||||
"name": share.name,
|
||||
"shareType": share.share_type,
|
||||
"comment": share.remark.unwrap_or_default(),
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
Ok(serde_json::json!({ "shares": shares }))
|
||||
}
|
||||
|
||||
async fn list_directory(params: SharePathParams) -> Result<Value> {
|
||||
let mut share = connect_share_client(¶ms.connection, ¶ms.share).await?;
|
||||
let path = directory_smb_path(¶ms.path);
|
||||
let entries_result = share.list(&path).await;
|
||||
let logoff_result = share.logoff().await;
|
||||
let entries = finish_smolder(entries_result, logoff_result)?
|
||||
.into_iter()
|
||||
.map(directory_entry_to_json)
|
||||
.collect::<Vec<_>>();
|
||||
Ok(serde_json::json!({ "entries": entries }))
|
||||
}
|
||||
|
||||
async fn read_file(params: SharePathParams) -> Result<Value> {
|
||||
let mut share = connect_share_client(¶ms.connection, ¶ms.share).await?;
|
||||
let path = normalize_smb_path(¶ms.path);
|
||||
let data_result = share.get(&path).await;
|
||||
let logoff_result = share.logoff().await;
|
||||
let data = finish_smolder(data_result, logoff_result)?;
|
||||
let size = data.len();
|
||||
Ok(serde_json::json!({
|
||||
"dataBase64": general_purpose::STANDARD.encode(data),
|
||||
"size": size,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn write_file(params: WriteFileParams) -> Result<Value> {
|
||||
let data = general_purpose::STANDARD
|
||||
.decode(params.data_base64.as_bytes())
|
||||
.context("Invalid base64 file data")?;
|
||||
let bytes_written = data.len();
|
||||
let mut share = connect_share_client(¶ms.connection, ¶ms.share).await?;
|
||||
let path = normalize_smb_path(¶ms.path);
|
||||
let write_result = share.put(&path, &data).await;
|
||||
let logoff_result = share.logoff().await;
|
||||
finish_smolder(write_result, logoff_result)?;
|
||||
Ok(serde_json::json!({ "bytesWritten": bytes_written }))
|
||||
}
|
||||
|
||||
async fn delete_file(params: SharePathParams) -> Result<Value> {
|
||||
let mut share = connect_share_client(¶ms.connection, ¶ms.share).await?;
|
||||
let path = normalize_smb_path(¶ms.path);
|
||||
let remove_result = share.remove(&path).await;
|
||||
let logoff_result = share.logoff().await;
|
||||
finish_smolder(remove_result, logoff_result)?;
|
||||
Ok(serde_json::json!({}))
|
||||
}
|
||||
|
||||
async fn create_directory(params: SharePathParams) -> Result<Value> {
|
||||
let mut share = connect_share_client(¶ms.connection, ¶ms.share).await?;
|
||||
let path = normalize_smb_path(¶ms.path);
|
||||
let create_result = share.create_dir(&path).await;
|
||||
let logoff_result = share.logoff().await;
|
||||
finish_smolder(create_result, logoff_result)?;
|
||||
Ok(serde_json::json!({}))
|
||||
}
|
||||
|
||||
async fn rename_path(params: RenameParams) -> Result<Value> {
|
||||
let mut share = connect_share_client(¶ms.connection, ¶ms.share).await?;
|
||||
let from_path = normalize_smb_path(¶ms.from);
|
||||
let to_path = normalize_smb_path(¶ms.to);
|
||||
let rename_result = share.rename(&from_path, &to_path).await;
|
||||
let logoff_result = share.logoff().await;
|
||||
finish_smolder(rename_result, logoff_result)?;
|
||||
Ok(serde_json::json!({}))
|
||||
}
|
||||
|
||||
async fn stat_path(params: SharePathParams) -> Result<Value> {
|
||||
let mut share = connect_share_client(¶ms.connection, ¶ms.share).await?;
|
||||
let path = normalize_smb_path(¶ms.path);
|
||||
let stat_result = share.stat(&path).await;
|
||||
let logoff_result = share.logoff().await;
|
||||
let metadata = finish_smolder(stat_result, logoff_result)?;
|
||||
Ok(metadata_to_json(&metadata))
|
||||
}
|
||||
|
||||
fn send_response(response: IpcResponse) {
|
||||
let mut stdout = std::io::stdout().lock();
|
||||
if serde_json::to_writer(&mut stdout, &response).is_ok() {
|
||||
let _ = stdout.write_all(b"\n");
|
||||
let _ = stdout.flush();
|
||||
}
|
||||
}
|
||||
|
||||
fn send_event(event: &str, data: Value) {
|
||||
let mut stdout = std::io::stdout().lock();
|
||||
let payload = IpcEvent {
|
||||
event: event.to_string(),
|
||||
data,
|
||||
};
|
||||
if serde_json::to_writer(&mut stdout, &payload).is_ok() {
|
||||
let _ = stdout.write_all(b"\n");
|
||||
let _ = stdout.flush();
|
||||
}
|
||||
}
|
||||
|
||||
impl IpcResponse {
|
||||
fn ok(id: String, result: Value) -> Self {
|
||||
Self {
|
||||
id,
|
||||
success: true,
|
||||
result: Some(result),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn err(id: String, message: String) -> Self {
|
||||
Self {
|
||||
id,
|
||||
success: false,
|
||||
result: None,
|
||||
error: Some(message),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user