Initial commit

This commit is contained in:
2026-05-03 10:44:02 +00:00
commit 09ddac4f3d
19 changed files with 10437 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
[target.aarch64-unknown-linux-gnu]
linker = "aarch64-linux-gnu-gcc"
+1662
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
[package]
name = "rustsamba"
version = "0.1.0"
edition = "2024"
[[bin]]
name = "rustsamba"
path = "src/main.rs"
[dependencies]
anyhow = "1"
base64 = "0.22"
clap = { version = "4", features = ["derive"] }
futures-util = "0.3"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
smolder-smb-core = "0.3.0"
smb-server = "0.4.0"
tokio = { version = "1", features = ["full"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
+34
View File
@@ -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);
}
+568
View File
@@ -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(&params.connection, &params.share).await?;
let path = directory_smb_path(&params.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(&params.connection, &params.share).await?;
let path = normalize_smb_path(&params.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(&params.connection, &params.share).await?;
let path = normalize_smb_path(&params.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(&params.connection, &params.share).await?;
let path = normalize_smb_path(&params.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(&params.connection, &params.share).await?;
let path = normalize_smb_path(&params.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(&params.connection, &params.share).await?;
let from_path = normalize_smb_path(&params.from);
let to_path = normalize_smb_path(&params.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(&params.connection, &params.share).await?;
let path = normalize_smb_path(&params.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),
}
}
}