commit 514718f05bb4ee7a68e27c4d0d3af14fb80888e6 Author: Ivan Bushchik Date: Sat Dec 16 08:05:45 2023 +0300 Initial commit Signed-off-by: Ivan Bushchik diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..66b1321 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/target +bin +*.DS_Store +.idea \ No newline at end of file diff --git a/.rustfmt.toml b/.rustfmt.toml new file mode 100644 index 0000000..c3d94b0 --- /dev/null +++ b/.rustfmt.toml @@ -0,0 +1,11 @@ +edition = "2021" +hard_tabs = true +merge_derives = true +reorder_imports = true +reorder_modules = true +use_field_init_shorthand = true +use_small_heuristics = "Off" + +# May need nightly rustfmt +wrap_comments = true +comment_width = 80 diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..608887b --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "binhost" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +clap = { version = "4.4.11", features = ["derive"] } +rocket = "0.5.0" +serde = { version = "1.0.193", features = ["derive"] } +once_cell = "1.19.0" +sha2 = "0.10.8" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e5e9014 --- /dev/null +++ b/LICENSE @@ -0,0 +1,20 @@ +MIT License + +Copyright (c) 2023 Ivan Bushchik + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS +OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF +OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..d693cf4 --- /dev/null +++ b/README.md @@ -0,0 +1,84 @@ +# BinHost + +> HTTP server to easily serve (prebuilt) binaries for any (UNIX-like) platform + +## Installation + +```shell +cargo install --git https://github.com/ivabus/binhost +``` + +## Server usage + +List options with `--help` + +#### Directory structure + +Directory, passed to `binhost` `--dir` option (defaults to `./bin`) should look like (for `hello` binary) + +Note: list of binaries will be refreshed every 5 minutes (by default, see `--refresh` option) + +```tree +bin +└── hello + ├── Darwin + │ ├── arm64 + │ │ └── hello + │ └── x86_64 + │ └── hello + └── Linux + └── aarch64 + └── hello +``` + +## Client usage + +### Get available binaries + +#### Request + +```http request +GET / HTTP/1.1 +``` + +#### Example response + +``` +- hello (platforms: ["Linux-aarch64", "Darwin-arm64", "Darwin-x86_64"]) +``` + +### Get script for specific binary (suitable for `curl | sh` syntax) + +This script will determine platform and arch and download necessary binary (and check hashsum if `sha256sum` binary is present in `$PATH`). + +#### Request + +```http request +GET / HTTP/1.1 +``` + +### Get binary for specific platform + +#### Request + +```http request +GET /bin/// HTTP/1.1 +``` + +### Get sha256 hash of binary for specific platform + +#### Request + +```http request +GET /bin////sha256 HTTP/1.1 +``` + +#### Example response + +```text +a5d1fba1c28b60038fb1008a3c482b4119070a537af86a05046dedbe8f85e18d hello +``` + +## License + +This project is licensed under [MIT License](./LICENSE) \ No newline at end of file diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..011ea7d --- /dev/null +++ b/src/main.rs @@ -0,0 +1,175 @@ +mod structs; +use structs::*; + +use clap::Parser; +use rocket::figment::Figment; +use rocket::fs::NamedFile; +use std::collections::HashMap; +use std::time::Instant; + +#[macro_use] +extern crate rocket; + +use rocket::http::Status; +use rocket::response::content::RawText; +use rocket::tokio::io::AsyncReadExt; +use sha2::digest::FixedOutput; +use sha2::Digest; + +static mut BINS: Option<(HashMap, Instant)> = None; + +static WEB_SH: &str = include_str!("../web.sh"); + +async fn reload_bins(bins: (&mut HashMap, &mut Instant), args: &Args) { + if (Instant::now() - *bins.1).as_secs() > args.refresh { + *bins.0 = get_bins(args).await; + *bins.1 = Instant::now(); + } +} + +async fn get_bins(args: &Args) -> HashMap { + let mut bins: HashMap = HashMap::new(); + std::fs::read_dir(&args.dir).unwrap().for_each(|entry| { + let en = entry.unwrap(); + if en.path().is_dir() { + let mut bin: Bin = Bin { + name: en.file_name().into_string().unwrap(), + platforms: vec![], + }; + std::fs::read_dir(en.path()).unwrap().for_each(|platform| { + let plat = platform.unwrap(); + std::fs::read_dir(plat.path()).unwrap().for_each(|arch| { + let ar = arch.unwrap(); + bin.platforms.push(Platform { + system: plat.file_name().into_string().unwrap(), + arch: ar.file_name().into_string().unwrap(), + }); + }); + }); + bins.insert(bin.name.clone(), bin); + } + }); + bins +} + +fn format_platform_list(bin: &Bin) -> String { + let mut s = String::new(); + for i in &bin.platforms { + s.push_str(&format!("{}-{}|", i.system, i.arch)) + } + s.pop().unwrap(); + s +} + +#[get("/")] +async fn index() -> RawText { + let args = Args::parse(); + let mut ret = String::new(); + unsafe { + if let Some((bins, time)) = &mut BINS { + reload_bins((bins, time), &args).await; + if bins.is_empty() { + return RawText(String::from("No binaries found")); + } + for (name, bin) in bins { + ret.push_str(&format!( + "- {} (platforms: {:?})\n", + name, + bin.platforms + .iter() + .map(|plat| format!("{}-{}", plat.system, plat.arch)) + .collect::>() + )) + } + } + } + RawText(ret) +} + +#[get("/")] +async fn get_script(bin: &str) -> ScriptResponse { + let args = Args::parse(); + unsafe { + if let Some((bins, time)) = &mut BINS { + reload_bins((bins, time), &args).await; + return match bins.get(bin) { + None => ScriptResponse::Status(Status::NotFound), + Some(bin) => { + let mut script = String::from(WEB_SH); + script = script + .replace("{{NAME}}", &bin.name) + .replace("{{PLATFORM_LIST}}", &format_platform_list(bin)) + .replace("{{EXTERNAL_ADDRESS}}", &args.url); + ScriptResponse::Text(RawText(script)) + } + }; + } + } + ScriptResponse::Status(Status::NotFound) +} + +#[get("/bin///")] +async fn get_binary(bin: &str, platform: &str, arch: &str) -> BinaryResponse { + let args = Args::parse(); + let file = NamedFile::open(format!( + "{}/{}/{}/{}/{}", + args.dir.file_name().unwrap().to_str().unwrap(), + bin, + platform, + arch, + bin + )) + .await; + match file { + Ok(f) => BinaryResponse::Bin(f), + Err(_) => BinaryResponse::Status(Status::BadRequest), + } +} + +#[get("/bin////sha256")] +async fn get_binary_hash(bin: &str, platform: &str, arch: &str) -> ScriptResponse { + let args = Args::parse(); + let file = NamedFile::open(format!( + "{}/{}/{}/{}/{}", + args.dir.file_name().unwrap().to_str().unwrap(), + bin, + platform, + arch, + bin + )) + .await; + + match file { + Ok(mut f) => { + let mut hasher = sha2::Sha256::new(); + let mut contents: Vec = vec![]; + f.read_to_end(&mut contents).await.unwrap(); + hasher.update(contents); + ScriptResponse::Text(RawText(format!( + "{:x} {}", + hasher.finalize_fixed(), + f.path().file_name().unwrap().to_str().unwrap() + ))) + } + Err(_) => ScriptResponse::Status(Status::BadRequest), + } +} + +#[launch] +async fn rocket() -> _ { + let args = Args::parse(); + if !args.dir.exists() { + eprintln!("Directory with binary files does not exist"); + std::process::exit(1); + } + + unsafe { + BINS = Some((get_bins(&args).await, Instant::now())); + } + + let figment = Figment::from(rocket::Config::default()) + .merge(("ident", "binhost")) + .merge(("port", args.port)) + .merge(("address", args.address)); + rocket::custom(figment).mount("/", routes![index, get_script, get_binary, get_binary_hash]) +} diff --git a/src/structs.rs b/src/structs.rs new file mode 100644 index 0000000..ff99d89 --- /dev/null +++ b/src/structs.rs @@ -0,0 +1,54 @@ +use clap::Parser; +use rocket::fs::NamedFile; +use rocket::http::Status; +use rocket::response::content::RawText; +use std::net::IpAddr; +use std::path::PathBuf; + +#[derive(Parser, Clone, Debug)] +#[command(author, version)] +pub struct Args { + /// Directory where binary files are contained + #[arg(long, default_value = "bin")] + pub dir: PathBuf, + + /// Refresh time (in secs) + #[arg(long, default_value = "300")] + pub refresh: u64, + + /// External address (url) + #[arg(long, default_value = "http://127.0.0.1:8000")] + pub url: String, + + /// Address to listen on + #[arg(short, long, default_value = "127.0.0.1")] + pub address: IpAddr, + + /// Port to listen on + #[arg(short, long, default_value = "8000")] + pub port: u16, +} + +#[derive(Debug)] +pub struct Platform { + pub system: String, + pub arch: String, +} + +#[derive(Debug)] +pub struct Bin { + pub name: String, + pub platforms: Vec, +} + +#[derive(Responder)] +pub enum ScriptResponse { + Status(Status), + Text(RawText), +} + +#[derive(Responder)] +pub enum BinaryResponse { + Status(Status), + Bin(NamedFile), +} diff --git a/web.sh b/web.sh new file mode 100644 index 0000000..5aac231 --- /dev/null +++ b/web.sh @@ -0,0 +1,54 @@ +#!/bin/sh +# SPDX-License-Identifier: MIT +set -e -o pipefail + +NAME="{{NAME}}" + +if ! uname > /dev/null; then + echo "No \`uname\` command was found, cannot continue" 1>&2 + exit 1 +fi + +if ! expr "{{PLATFORM_LIST}}" : "\(.*$(uname)-$(uname -m).*\)" > /dev/null; then + echo Platform $(uname)-$(uname -m) is not supported 1>&2 + exit 1 +fi + +DOWNLOAD_COMMAND="curl" +OUTPUT_ARG="-o" +DIR="/tmp/binhost-$NAME-$(date +%s)" +FILE="$DIR/$NAME" + +if ! which curl > /dev/null; then + if ! which wget > /dev/null; then + echo "No curl or wget found, install one and rerun the script" 1>&2 + exit 1 + fi + export DOWNLOAD_COMMAND="wget" + export OUTPUT_ARG="-O" +fi + +mkdir $DIR + +echo ":: Downloading binary" 1>&2 + +# shellcheck disable=SC1083 +$DOWNLOAD_COMMAND {{EXTERNAL_ADDRESS}}/bin/$NAME/$(uname)/$(uname -m) $OUTPUT_ARG "$FILE" + +chmod +x "$FILE" + +cd $DIR + +if ! which sha256sum > /dev/null; then + echo "No \`sha256sum\` command found, continuing without checking" 1>&2 +else + echo ":: Checking hashsum" 1>&2 + if ! ($DOWNLOAD_COMMAND {{EXTERNAL_ADDRESS}}/bin/$NAME/$(uname)/$(uname -m)/sha256 $OUTPUT_ARG - | sha256sum -c - > /dev/null); then + echo "sha256 is invalid" 1>&2 + exit 255 + fi +fi + +$FILE < /dev/tty + +rm "$FILE"