initial commit

This commit is contained in:
yggverse 2025-06-24 03:59:55 +03:00
parent d3661f8865
commit ab625aa96a
14 changed files with 533 additions and 0 deletions

27
.github/workflows/build.yml vendored Normal file
View file

@ -0,0 +1,27 @@
name: Build
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
env:
CARGO_TERM_COLOR: always
RUSTFLAGS: -Dwarnings
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run rustfmt
run: cargo fmt --all -- --check
- name: Run clippy
run: cargo clippy --all-targets
- name: Build
run: cargo build --verbose
- name: Run tests
run: cargo test --verbose

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
Cargo.lock
target

15
Cargo.toml Normal file
View file

@ -0,0 +1,15 @@
[package]
name = "nexy"
version = "0.1.0"
edition = "2024"
license = "MIT"
readme = "README.md"
description = "Multi-network server for the Nex protocol"
keywords = ["nex", "protocol", "tcp", "socket", "server"]
categories = ["network-programming"]
repository = "https://github.com/YGGverse/nexy"
[dependencies]
anyhow = "1.0"
clap = { version = "4.5", features = ["derive"] }
urlencoding = "2.1"

55
README.md Normal file
View file

@ -0,0 +1,55 @@
# Nexy - Multi-network server for the [Nex protocol](https://nex.nightfall.city/nex/info/specification.txt)
![Build](https://github.com/yggverse/server/actions/workflows/build.yml/badge.svg)
[![Dependencies](https://deps.rs/repo/github/yggverse/server/status.svg)](https://deps.rs/repo/github/yggverse/nexy)
[![crates.io](https://img.shields.io/crates/v/nexy)](https://crates.io/crates/nexy)
Run server accessible to Internet IPv4/IPv6, [Yggdrasil](https://yggdrasil-network.github.io/), [Mycelium](https://github.com/threefoldtech/mycelium), and other networks simultaneously, as many as desired.
## Install
1. `git clone https://github.com/yggverse/nexy.git && cd nexy`
2. `cargo build --release`
3. `sudo install target/release/nexy /usr/local/bin/nexy`
## Usage
``` bash
nexy -p /path/to/public
```
* by default, server starts on localhost; change it with the `--bind` option(s)
### Options
``` bash
-b, --bind <BIND>
Bind server(s) `host:port` to listen incoming connections
* use `[host]:port` notation for IPv6
[default: 127.0.0.1:1900 [::1]:1900]
-d, --debug <DEBUG>
Debug level
* `e` - error * `i` - info
[default: ei]
-t, --template <TEMPLATE>
Absolute path to the template files directory
-p, --public <PUBLIC>
Absolute path to the public files directory
-r, --read-chunk <READ_CHUNK>
Optimize memory usage on reading large files or stream
[default: 1024]
-h, --help
Print help (see a summary with '-h')
-V, --version
Print version
```

37
src/config.rs Normal file
View file

@ -0,0 +1,37 @@
use clap::Parser;
/// Default port
/// https://nex.nightfall.city/nex/info/specification.txt
const PORT: u16 = 1900;
#[derive(Parser, Debug)]
#[command(version, about, long_about = None)]
pub struct Config {
/// Bind server(s) `host:port` to listen incoming connections
///
/// * use `[host]:port` notation for IPv6
#[arg(short, long, default_values_t = vec![
std::net::SocketAddrV4::new(std::net::Ipv4Addr::LOCALHOST, PORT).to_string(),
std::net::SocketAddrV6::new(std::net::Ipv6Addr::LOCALHOST, PORT, 0, 0).to_string()
])]
pub bind: Vec<String>,
/// Debug level
///
/// * `e` - error
/// * `i` - info
#[arg(short, long, default_value_t = String::from("ei"))]
pub debug: String,
/// Absolute path to the template files directory
#[arg(short, long)]
pub template: Option<String>,
/// Absolute path to the public files directory
#[arg(short, long)]
pub public: String,
/// Optimize memory usage on reading large files or stream
#[arg(short, long, default_value_t = 1024)]
pub read_chunk: usize,
}

26
src/main.rs Normal file
View file

@ -0,0 +1,26 @@
mod config;
mod response;
mod server;
mod session;
fn main() -> anyhow::Result<()> {
use clap::Parser;
let a = config::Config::parse();
let s = std::sync::Arc::new(session::Session::init(&a)?);
for b in a.bind {
s.debug.info(&format!("start server on `{b}`..."));
match std::net::TcpListener::bind(&b) {
Ok(r) => {
std::thread::spawn({
let s = s.clone();
move || server::start(r, &s)
});
}
Err(e) => s
.debug
.error(&format!("failed to start server on `{b}`: `{e}`")),
}
}
std::thread::park();
Ok(())
}

11
src/response.rs Normal file
View file

@ -0,0 +1,11 @@
/// Internal types
pub enum Response<'a> {
/// Includes reference to the original request
AccessDenied(&'a str),
/// Includes server-side error description
InternalServerError(String),
/// Includes reference to the original request
NotFound(&'a str),
/// Includes bytes array
Success(&'a [u8]),
}

26
src/server.rs Normal file
View file

@ -0,0 +1,26 @@
mod connection;
use crate::session::Session;
use connection::Connection;
use std::{net::TcpListener, sync::Arc, thread};
pub fn start(server: TcpListener, session: &Arc<Session>) {
for i in server.incoming() {
match i {
Ok(stream) => {
thread::spawn({
let session = session.clone();
move || match Connection::init(&session, stream) {
Ok(connection) => connection.handle(),
Err(e) => session
.debug
.error(&format!("failed to init connection: `{e}`")),
}
});
}
Err(e) => session
.debug
.error(&format!("failed to accept incoming connection: `{e}`")),
}
}
}

108
src/server/connection.rs Normal file
View file

@ -0,0 +1,108 @@
use crate::{response::Response, session::Session};
use anyhow::Result;
use std::{
io::{Read, Write},
net::{SocketAddr, TcpStream},
sync::Arc,
};
/// Parsed once endpoint addresses for this `stream`
struct Address {
server: SocketAddr,
client: SocketAddr,
}
/// Client/server connection with its features implementation
pub struct Connection {
address: Address,
session: Arc<Session>,
stream: TcpStream,
}
impl Connection {
pub fn init(session: &Arc<Session>, stream: TcpStream) -> Result<Self> {
Ok(Self {
address: Address {
server: stream.local_addr()?,
client: stream.peer_addr()?,
},
session: session.clone(),
stream,
})
}
pub fn handle(mut self) {
match self.request() {
Ok(q) => {
self.session.debug.info(&format!(
"[{}] < [{}] request `{q}`...",
self.address.server, self.address.client
));
self.session
.clone()
.storage
.request(&q, |r| self.response(r))
}
Err(e) => self.response(Response::InternalServerError(format!(
"[{}] < [{}] failed to handle incoming request: `{e}`",
self.address.server, self.address.client
))),
}
self.shutdown()
}
fn request(&mut self) -> Result<String> {
let mut b = [0; 1024]; // @TODO unspecified len?
let n = self.stream.read(&mut b)?;
Ok(urlencoding::decode(std::str::from_utf8(&b[..n])?.trim())?.to_string())
}
fn response(&mut self, response: Response) {
let bytes = match response {
Response::Success(b) => b,
Response::InternalServerError(e) => {
self.session.debug.error(&e);
self.session.template.internal_server_error.as_bytes()
}
Response::AccessDenied(q) => {
self.session.debug.error(&format!(
"[{}] < [{}] access to `{q}` denied.",
self.address.server, self.address.client
));
self.session.template.access_denied.as_bytes()
}
Response::NotFound(q) => {
self.session.debug.error(&format!(
"[{}] < [{}] requested resource `{q}` not found.",
self.address.server, self.address.client
));
self.session.template.not_found.as_bytes()
}
};
match self.stream.write_all(bytes) {
Ok(()) => self.session.debug.info(&format!(
"[{}] > [{}] sent {} bytes response.",
self.address.server,
self.address.client,
bytes.len()
)),
Err(e) => self.session.debug.error(&format!(
"[{}] ! [{}] failed to response: `{e}`",
self.address.server, self.address.client,
)),
}
}
fn shutdown(self) {
match self.stream.shutdown(std::net::Shutdown::Both) {
Ok(()) => self.session.debug.info(&format!(
"[{}] - [{}] connection closed by server.",
self.address.server, self.address.client,
)),
Err(e) => self.session.debug.error(&format!(
"[{}] > [{}] failed to close connection: `{e}`",
self.address.server, self.address.client,
)),
}
}
}

22
src/session.rs Normal file
View file

@ -0,0 +1,22 @@
mod debug;
mod storage;
mod template;
use {debug::Debug, storage::Storage, template::Template};
/// Single container for the current session
pub struct Session {
pub debug: Debug,
pub storage: Storage,
pub template: Template,
}
impl Session {
pub fn init(config: &crate::config::Config) -> anyhow::Result<Self> {
Ok(Self {
debug: Debug::init(&config.debug)?,
storage: Storage::init(&config.public, config.read_chunk)?,
template: Template::init(&config.template)?,
})
}
}

33
src/session/debug.rs Normal file
View file

@ -0,0 +1,33 @@
mod level;
use level::Level;
pub struct Debug(Vec<Level>);
impl Debug {
pub fn init(levels: &str) -> anyhow::Result<Self> {
let mut l = Vec::with_capacity(levels.len());
for s in levels.to_lowercase().chars() {
l.push(Level::parse(s)?);
}
Ok(Self(l))
}
pub fn error(&self, message: &str) {
if self.0.contains(&Level::Error) {
eprintln!("[{}] [error] {message}", now());
}
}
pub fn info(&self, message: &str) {
if self.0.contains(&Level::Info) {
println!("[{}] [info] {message}", now());
}
}
}
fn now() -> u128 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis()
}

View file

@ -0,0 +1,17 @@
use anyhow::{Result, bail};
#[derive(PartialEq)]
pub enum Level {
Error,
Info,
}
impl Level {
pub fn parse(value: char) -> Result<Self> {
match value {
'e' => Ok(Self::Error),
'i' => Ok(Self::Info),
_ => bail!("unsupported debug level `{value}`!"),
}
}
}

108
src/session/storage.rs Normal file
View file

@ -0,0 +1,108 @@
use crate::response::Response;
use anyhow::{Result, bail};
use std::{fs, io::Read, path::PathBuf, str::FromStr};
pub struct Storage {
public_dir: PathBuf,
read_chunk: usize,
}
impl Storage {
pub fn init(path: &str, read_chunk: usize) -> Result<Self> {
let public_dir = PathBuf::from_str(path)?.canonicalize()?;
let t = fs::metadata(&public_dir)?;
if !t.is_dir() {
bail!("Storage destination is not directory!");
}
if t.is_symlink() {
bail!("Symlinks yet not supported!");
}
Ok(Self {
public_dir,
read_chunk,
})
}
pub fn request(&self, query: &str, mut callback: impl FnMut(Response)) {
let p = {
// access restriction zone, change carefully!
let mut p = PathBuf::from(&self.public_dir);
p.push(query.trim_matches('/'));
match p.canonicalize() {
Ok(c) => {
if !c.starts_with(&self.public_dir) {
return callback(Response::AccessDenied(query));
}
c
}
Err(_) => return callback(Response::NotFound(query)),
}
};
match fs::metadata(&p) {
Ok(t) => match (t.is_dir(), t.is_file()) {
(true, _) => callback(match self.list(&p) {
Ok(ref l) => Response::Success(l.as_bytes()),
Err(e) => Response::InternalServerError(e.to_string()),
}),
(_, true) => match fs::File::open(p) {
Ok(mut f) => loop {
let mut b = vec![0; self.read_chunk];
match f.read(&mut b) {
Ok(0) => break,
Ok(n) => callback(Response::Success(&b[..n])),
Err(e) => {
return callback(Response::InternalServerError(format!(
"failed to read response chunk for `{query}`: `{e}`"
)));
}
}
},
Err(e) => callback(Response::InternalServerError(format!(
"failed to read response for query`{query}`: `{e}`"
))),
},
_ => panic!(), // unexpected
},
Err(e) => callback(Response::InternalServerError(format!(
"failed to read storage for `{query}`: `{e}`"
))),
}
}
/// Build entries list for given `path`,
/// sort ASC, by directories first.
///
/// * make sure the `path` is allowed before call this method!
fn list(&self, path: &PathBuf) -> Result<String> {
use urlencoding::encode;
const C: usize = 25; // @TODO optional
let mut d = Vec::with_capacity(C);
let mut f = Vec::with_capacity(C);
for entry in fs::read_dir(path)? {
let e = entry?;
let t = fs::metadata(e.path())?;
match (t.is_dir(), t.is_file()) {
(true, _) => d.push(e.file_name()),
(_, true) => f.push(e.file_name()),
_ => {} // @TODO symlinks support?
}
}
let mut l = Vec::with_capacity(d.len() + f.len());
if &self.public_dir != path {
l.push("=> ../".to_string())
}
d.sort();
for dir in d {
if let Some(s) = dir.to_str() {
l.push(format!("=> {}/", encode(s)))
}
}
f.sort();
for file in f {
if let Some(s) = file.to_str() {
l.push(format!("=> {}", encode(s)))
}
}
Ok(l.join("\n"))
}
}

46
src/session/template.rs Normal file
View file

@ -0,0 +1,46 @@
use anyhow::{Result, bail};
pub struct Template {
pub access_denied: String,
pub internal_server_error: String,
pub not_found: String,
}
impl Template {
pub fn init(directory: &Option<String>) -> Result<Self> {
use std::{fs::read_to_string, path::PathBuf};
const ACCESS_DENIED: (&str, &str) = ("access_denied.txt", "Access denied");
const INTERNAL_SERVER_ERROR: (&str, &str) =
("internal_server_error.txt", "Internal server error");
const NOT_FOUND: (&str, &str) = ("not_found.txt", "Not found");
fn path(directory: &str, file: &str) -> Result<PathBuf> {
let mut p = PathBuf::from(directory).canonicalize()?;
p.push(file);
if !p.exists() {
bail!("Template path `{}` does not exist", p.to_string_lossy())
}
if !p.is_file() {
bail!("Template path `{}` is not file", p.to_string_lossy())
}
if p.is_symlink() {
bail!("Symlinks yet not supported!");
}
Ok(p)
}
Ok(match directory {
Some(d) => Self {
access_denied: read_to_string(path(d, ACCESS_DENIED.0)?)?,
internal_server_error: read_to_string(path(d, INTERNAL_SERVER_ERROR.0)?)?,
not_found: read_to_string(path(d, NOT_FOUND.0)?)?,
},
None => Self {
access_denied: ACCESS_DENIED.1.to_string(),
internal_server_error: INTERNAL_SERVER_ERROR.1.to_string(),
not_found: NOT_FOUND.1.to_string(),
},
})
}
}