mirror of
https://codeberg.org/icewind/cube.git
synced 2026-06-03 12:04:10 +02:00
initial version
This commit is contained in:
commit
a77d5b719e
7 changed files with 373 additions and 0 deletions
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
target
|
||||||
|
Cargo.lock
|
||||||
15
Cargo.toml
Normal file
15
Cargo.toml
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
[package]
|
||||||
|
name = "nbs"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
nbd = { version = "0.2.3", git = "https://github.com/icewind1991/rust-nbd" }
|
||||||
|
tracing = "0.1.37"
|
||||||
|
tracing-subscriber = "0.3.16"
|
||||||
|
thiserror = "1.0.38"
|
||||||
|
miette = "5.5.0"
|
||||||
|
serde = { version = "1.0.152", features = ["derive"] }
|
||||||
|
toml = "0.7.2"
|
||||||
|
clap = { version = "4.1.6", features = ["derive"] }
|
||||||
|
signal-hook = "0.3.15"
|
||||||
39
README.md
Normal file
39
README.md
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
# NBS
|
||||||
|
|
||||||
|
NBD Block Server
|
||||||
|
|
||||||
|
## What
|
||||||
|
|
||||||
|
A basic NBD block server with a single gimmick.
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
The main reason for using this over any other NBD server is its ability to reload the config without affecting any existing connection.
|
||||||
|
|
||||||
|
This allows for booting a device off an NBD device and changing the export configuration to point to a new root image. Without affecting the booted devices.
|
||||||
|
Then, when the device is rebooted, it will connect to the new root image.
|
||||||
|
|
||||||
|
## How
|
||||||
|
|
||||||
|
Create a config file `config.toml`
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[listen]
|
||||||
|
port = 10809
|
||||||
|
|
||||||
|
[exports]
|
||||||
|
main = { path = "./src/main.rs", readonly = true }
|
||||||
|
block = "/tmp/block.bin"
|
||||||
|
```
|
||||||
|
|
||||||
|
Run the server with
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nbs -c config.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
When the configuration is changed, it can be reloaded by sending `SIGHUP` to the server.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pkill -sighup nbs
|
||||||
|
```
|
||||||
7
config.sample.toml
Normal file
7
config.sample.toml
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
[listen]
|
||||||
|
port = 10809 # optional, defaults to 10809
|
||||||
|
address = "127.0.0.1" # optional, defaults to 0.0.0.
|
||||||
|
|
||||||
|
[exports]
|
||||||
|
main = { path = "./src/main.rs", readonly = true }
|
||||||
|
block = "/tmp/block.bin"
|
||||||
155
src/config.rs
Normal file
155
src/config.rs
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
use crate::error::{ConfigError, HandshakeError};
|
||||||
|
use nbd::Export;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::fmt::{Display, Formatter};
|
||||||
|
use std::fs::{read_to_string, File, OpenOptions};
|
||||||
|
use std::net::ToSocketAddrs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct Config {
|
||||||
|
#[serde(default)]
|
||||||
|
pub listen: ListenConfig,
|
||||||
|
pub exports: Exports,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
pub fn load(path: impl AsRef<Path>) -> Result<Config, ConfigError> {
|
||||||
|
let content = read_to_string(path)?;
|
||||||
|
Ok(toml::from_str(&content)?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct ListenConfig {
|
||||||
|
#[serde(default = "default_address")]
|
||||||
|
pub address: String,
|
||||||
|
#[serde(default = "default_port")]
|
||||||
|
pub port: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Display for ListenConfig {
|
||||||
|
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(f, "{}:{}", self.address, self.port)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ListenConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
ListenConfig {
|
||||||
|
address: default_address(),
|
||||||
|
port: default_port(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_address() -> String {
|
||||||
|
"0.0.0.0".into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_port() -> u16 {
|
||||||
|
10809
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ToSocketAddrs for ListenConfig {
|
||||||
|
type Iter = <(String, u16) as ToSocketAddrs>::Iter;
|
||||||
|
|
||||||
|
fn to_socket_addrs(&self) -> std::io::Result<Self::Iter> {
|
||||||
|
(self.address.as_str(), self.port).to_socket_addrs()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(untagged)]
|
||||||
|
enum ExportConfigDe {
|
||||||
|
Simple(PathBuf),
|
||||||
|
Options(ExportConfigRaw),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct ExportConfigRaw {
|
||||||
|
#[serde(default)]
|
||||||
|
pub readonly: bool,
|
||||||
|
pub path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(from = "ExportConfigDe")]
|
||||||
|
pub struct ExportConfig {
|
||||||
|
pub readonly: bool,
|
||||||
|
pub path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<ExportConfigDe> for ExportConfig {
|
||||||
|
fn from(value: ExportConfigDe) -> Self {
|
||||||
|
match value {
|
||||||
|
ExportConfigDe::Simple(path) => ExportConfig {
|
||||||
|
readonly: false,
|
||||||
|
path,
|
||||||
|
},
|
||||||
|
ExportConfigDe::Options(export) => ExportConfig {
|
||||||
|
readonly: export.readonly,
|
||||||
|
path: export.path,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ExportConfig {
|
||||||
|
pub fn export(&self) -> Result<Export<File>, HandshakeError> {
|
||||||
|
let meta = self.path.metadata().map_err(|e| HandshakeError::Open {
|
||||||
|
err: e,
|
||||||
|
path: self.path.clone(),
|
||||||
|
})?;
|
||||||
|
let file = OpenOptions::new()
|
||||||
|
.read(true)
|
||||||
|
.write(!self.readonly)
|
||||||
|
.open(&self.path)
|
||||||
|
.map_err(|e| HandshakeError::Open {
|
||||||
|
err: e,
|
||||||
|
path: self.path.clone(),
|
||||||
|
})?;
|
||||||
|
Ok(Export {
|
||||||
|
readonly: self.readonly || meta.permissions().readonly(),
|
||||||
|
size: meta.len(),
|
||||||
|
data: file,
|
||||||
|
resizeable: false,
|
||||||
|
rotational: false,
|
||||||
|
send_trim: false,
|
||||||
|
send_flush: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(from = "HashMap<String, ExportConfig>")]
|
||||||
|
pub struct Exports {
|
||||||
|
exports: Arc<Mutex<HashMap<String, ExportConfig>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<HashMap<String, ExportConfig>> for Exports {
|
||||||
|
fn from(value: HashMap<String, ExportConfig>) -> Self {
|
||||||
|
Exports {
|
||||||
|
exports: Arc::new(Mutex::new(value)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Exports {
|
||||||
|
pub fn get(&self, name: &str) -> Option<ExportConfig> {
|
||||||
|
self.exports.lock().unwrap().get(name).cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn update(&self, other: Exports) -> usize {
|
||||||
|
let other = match Arc::try_unwrap(other.exports) {
|
||||||
|
Ok(mutex) => mutex.into_inner().unwrap(),
|
||||||
|
Err(arc) => arc.lock().unwrap().clone(),
|
||||||
|
};
|
||||||
|
let count = other.len();
|
||||||
|
*self.exports.lock().unwrap() = other;
|
||||||
|
count
|
||||||
|
}
|
||||||
|
}
|
||||||
75
src/error.rs
Normal file
75
src/error.rs
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
use miette::Diagnostic;
|
||||||
|
use std::io::{Error as IoError, ErrorKind};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Debug, Error, Diagnostic)]
|
||||||
|
pub enum Error {
|
||||||
|
#[error("Failed to listen to port")]
|
||||||
|
Connection(IoError),
|
||||||
|
#[error("Failed to listen to port")]
|
||||||
|
Listen(IoError),
|
||||||
|
#[error(transparent)]
|
||||||
|
#[diagnostic(transparent)]
|
||||||
|
Nbd(#[from] NbdError),
|
||||||
|
#[error(transparent)]
|
||||||
|
#[diagnostic(transparent)]
|
||||||
|
Config(#[from] ConfigError),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Error, Diagnostic)]
|
||||||
|
pub enum NbdError {
|
||||||
|
#[error(transparent)]
|
||||||
|
Io(IoError),
|
||||||
|
#[error("Client disconnected unexpectedly")]
|
||||||
|
Disconnected,
|
||||||
|
#[error(transparent)]
|
||||||
|
#[diagnostic(transparent)]
|
||||||
|
Handshake(HandshakeError),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<NbdError> for IoError {
|
||||||
|
fn from(value: NbdError) -> Self {
|
||||||
|
match value {
|
||||||
|
NbdError::Handshake(e) => e.into(),
|
||||||
|
NbdError::Io(e) => e,
|
||||||
|
NbdError::Disconnected => ErrorKind::UnexpectedEof.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<IoError> for NbdError {
|
||||||
|
fn from(value: IoError) -> Self {
|
||||||
|
match value.kind() {
|
||||||
|
ErrorKind::UnexpectedEof => NbdError::Disconnected,
|
||||||
|
_ => NbdError::Io(value),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Error, Diagnostic)]
|
||||||
|
pub enum HandshakeError {
|
||||||
|
#[error("Unknown export {0}")]
|
||||||
|
UnknownExport(String),
|
||||||
|
#[error("Failed to open {path}")]
|
||||||
|
Open { path: PathBuf, err: IoError },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<HandshakeError> for IoError {
|
||||||
|
fn from(value: HandshakeError) -> Self {
|
||||||
|
match value {
|
||||||
|
HandshakeError::Open { err, .. } => err,
|
||||||
|
HandshakeError::UnknownExport(export) => {
|
||||||
|
IoError::new(ErrorKind::InvalidData, format!("Unknown export: {export}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Error, Diagnostic)]
|
||||||
|
pub enum ConfigError {
|
||||||
|
#[error("Failed to read config file")]
|
||||||
|
Read(#[from] IoError),
|
||||||
|
#[error("Failed to parse config file")]
|
||||||
|
Parse(#[from] toml::de::Error),
|
||||||
|
}
|
||||||
80
src/main.rs
Normal file
80
src/main.rs
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
mod config;
|
||||||
|
mod error;
|
||||||
|
|
||||||
|
use crate::config::{Config, Exports};
|
||||||
|
use crate::error::{Error, HandshakeError, NbdError};
|
||||||
|
use clap::Parser;
|
||||||
|
use nbd::server::{handshake, transmission};
|
||||||
|
use signal_hook::consts::SIGHUP;
|
||||||
|
use signal_hook::iterator::exfiltrator::SignalOnly;
|
||||||
|
use signal_hook::iterator::SignalsInfo;
|
||||||
|
use std::net::{TcpListener, TcpStream};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::thread::spawn;
|
||||||
|
use tracing::{error, info};
|
||||||
|
|
||||||
|
#[derive(Debug, Parser)]
|
||||||
|
struct Args {
|
||||||
|
#[arg(short, long)]
|
||||||
|
config: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type Result<T, E = Error> = std::result::Result<T, E>;
|
||||||
|
|
||||||
|
#[tracing::instrument(skip(exports))]
|
||||||
|
fn handle_client(mut stream: TcpStream, exports: Exports) -> Result<(), NbdError> {
|
||||||
|
let file = handshake(&mut stream, move |name| {
|
||||||
|
let export_cfg = exports
|
||||||
|
.get(name)
|
||||||
|
.ok_or_else(|| HandshakeError::UnknownExport(name.into()))?;
|
||||||
|
info!(name = name, export = ?export_cfg, "opening export");
|
||||||
|
Ok(export_cfg.export()?)
|
||||||
|
})?;
|
||||||
|
info!("connected");
|
||||||
|
transmission(&mut stream, file)?;
|
||||||
|
info!("disconnected");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() -> Result<()> {
|
||||||
|
tracing_subscriber::fmt::init();
|
||||||
|
let args = Args::parse();
|
||||||
|
let config = Config::load(&args.config)?;
|
||||||
|
let listener = TcpListener::bind(&config.listen).map_err(Error::Listen)?;
|
||||||
|
info!("Listening on {}", config.listen);
|
||||||
|
|
||||||
|
let exports = config.exports.clone();
|
||||||
|
spawn(move || {
|
||||||
|
let mut reload_signals = SignalsInfo::<SignalOnly>::new([SIGHUP]).unwrap();
|
||||||
|
for _ in &mut reload_signals {
|
||||||
|
info!("Reloading config");
|
||||||
|
match Config::load(&args.config) {
|
||||||
|
Ok(updated) => {
|
||||||
|
let count = exports.update(updated.exports);
|
||||||
|
info!("Registered {count} exports");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!(error = ?e, "Failed to load updated config");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for stream in listener.incoming() {
|
||||||
|
match stream {
|
||||||
|
Ok(stream) => {
|
||||||
|
let exports = config.exports.clone();
|
||||||
|
spawn(move || {
|
||||||
|
if let Err(e) = handle_client(stream, exports) {
|
||||||
|
error!("{e}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let e = Error::Connection(e);
|
||||||
|
error!("{e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue