mirror of
https://github.com/YGGverse/nexy.git
synced 2026-03-31 09:15:28 +00:00
initial commit
This commit is contained in:
parent
d3661f8865
commit
ab625aa96a
14 changed files with 533 additions and 0 deletions
27
.github/workflows/build.yml
vendored
Normal file
27
.github/workflows/build.yml
vendored
Normal 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
2
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
Cargo.lock
|
||||
target
|
||||
15
Cargo.toml
Normal file
15
Cargo.toml
Normal 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
55
README.md
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
# Nexy - Multi-network server for the [Nex protocol](https://nex.nightfall.city/nex/info/specification.txt)
|
||||
|
||||

|
||||
[](https://deps.rs/repo/github/yggverse/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
37
src/config.rs
Normal 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
26
src/main.rs
Normal 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
11
src/response.rs
Normal 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
26
src/server.rs
Normal 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
108
src/server/connection.rs
Normal 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
22
src/session.rs
Normal 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
33
src/session/debug.rs
Normal 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()
|
||||
}
|
||||
17
src/session/debug/level.rs
Normal file
17
src/session/debug/level.rs
Normal 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
108
src/session/storage.rs
Normal 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
46
src/session/template.rs
Normal 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(),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue