mirror of
https://github.com/ivabus/urouter
synced 2025-06-08 00:00:31 +03:00
Compare commits
8 commits
Author | SHA1 | Date | |
---|---|---|---|
0451d494c5 | |||
653b41a67f | |||
9cfa1cf950 | |||
82ddc180cb | |||
886c4cf2fa | |||
0815b295b2 | |||
e84e1a3317 | |||
b7c96c8b4e |
7 changed files with 335 additions and 150 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -1 +1,3 @@
|
|||
/target
|
||||
.idea/
|
||||
*.DS_Store
|
15
Cargo.toml
15
Cargo.toml
|
@ -1,16 +1,19 @@
|
|||
[package]
|
||||
name = "urouter"
|
||||
version = "0.3.5"
|
||||
version = "0.7.0"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
repository = "https://github.com/ivabus/urouter"
|
||||
description = "Small router for (kinda) short domains (fork of ivabus/aliurl without REST API)"
|
||||
description = "Small HTTP router"
|
||||
|
||||
|
||||
[dependencies]
|
||||
rocket = "0.5.0"
|
||||
serde = { version = "1.0.193", features = ["derive"] }
|
||||
serde_json = "1.0.108"
|
||||
serde = { version = "1.0.197", features = ["derive"] }
|
||||
serde_json = "1.0.114"
|
||||
url-escape = "0.1.1"
|
||||
smurf = "0.3.0"
|
||||
clap = { version = "4.4.11", features = ["derive"] }
|
||||
once_cell = "1.19"
|
||||
clap = { version = "4.5.3", features = ["derive"] }
|
||||
regex = "1.10.3"
|
||||
ureq = { version = "2.9.6", features = ["brotli", "native-certs"] }
|
||||
users = "0.11.0"
|
||||
|
|
89
README.md
89
README.md
|
@ -1,39 +1,98 @@
|
|||
# urouter
|
||||
# urouter
|
||||
|
||||
Static (list of routes read once) http router for routing small domains.
|
||||
|
||||
## Installation
|
||||
|
||||
```shell
|
||||
git clone https://github.com/ivabus/urouter
|
||||
cd urouter
|
||||
cargo install urouter
|
||||
```
|
||||
|
||||
Edit `alias.json` (or any other JSON file, check `--alias-file` option) and `cargo run`
|
||||
|
||||
## `alias.json` example
|
||||
## `alias.json` specification
|
||||
|
||||
JSON file with array of sets (or set with one field of arrays of sets with `--alias-file-is-set-not-a-list`, may be useful i.e. [Nix packaging](https://github.com/ivabus/nixos/blob/master/roles/server/urouter.nix)).
|
||||
|
||||
Each set contains 2 necessary elements and 1 optional.
|
||||
|
||||
- `uri` (string) - of URL after host (e.g., `/`, `some/cool/path`, should not start with `/` (only for root))
|
||||
- `alias` (set) - set of one field
|
||||
- `url` (string) - redirect to URL with HTTP 303 See Other
|
||||
- `file` (string) - read file from path `--dir/file` where `--dir` is option (default: `.`, see `--help`) and respond with HTTP 200 OK with `content-type: text/plain`
|
||||
- `text` (string) - plain text with HTTP 200 OK with `content-type: text/plain`
|
||||
- `html` (string) - plain text with HTTP 200 OK with `content-type: text/html`
|
||||
- `external` (set) - download (every time) file using `ureq` HTTP library and response with contents of downloaded resource with HTTP 200 OK and extracted `content-type` from response
|
||||
- `url` (string) - URL to download
|
||||
- `headers` (set, optional) - headers to include with request
|
||||
- `agent` (set, optional) - set of one necessary field and one optional
|
||||
- `regex` (string) - regular expression to match user-agent HTTP header
|
||||
- `only_matching` (bool, optional, false by default) - if false whole alias will be visible for any user agent, if true only for regex matched
|
||||
|
||||
#### Set of array of sets (use only for very specific workarounds)
|
||||
|
||||
```json
|
||||
{
|
||||
"alias": [
|
||||
{
|
||||
"uri": "/",
|
||||
"alias": {
|
||||
"url": "https://somecoolwebsite"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### `alias.json` example
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"uri": "uri",
|
||||
"alias": "file"
|
||||
},
|
||||
{
|
||||
"uri": "uri2",
|
||||
"alias": "http://example.com",
|
||||
"is_url": true
|
||||
"uri": "/",
|
||||
"alias": {
|
||||
"url": "https://somecoolwebsite"
|
||||
}
|
||||
},
|
||||
{
|
||||
"uri": "/",
|
||||
"alias": "https://somecoolscript.sh",
|
||||
"is_url": true,
|
||||
"curl_only": true
|
||||
"alias": {
|
||||
"file": "somecoolscript"
|
||||
},
|
||||
"agent": {
|
||||
"regex": "^curl/[0-9].[0-9].[0-9]$",
|
||||
"only_matching": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"uri": "text",
|
||||
"alias": {
|
||||
"text": "sometext"
|
||||
}
|
||||
},
|
||||
{
|
||||
"uri": "external",
|
||||
"alias": {
|
||||
"external": {
|
||||
"url": "https://somecool.external.link",
|
||||
"headers": {
|
||||
"user-agent": "curl/8.6.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
`"curl_only"` thing for `curl https://url | sh` like scripts.
|
||||
Agent matching made for `curl https://url | sh` like scripts.
|
||||
|
||||
## `alias.json` location
|
||||
|
||||
- Passed with `--alias_file`, will look up to this path, if file doesn't exist (or no access to it) will panic
|
||||
- If urouter started with privileges (EUID = 0), file would be `/etc/urouter/alias.json`
|
||||
- Otherwise if `XDG_CONFIG_HOME` is set, file would be `$XDG_CONFIG_HOME/urouter/alias.json`
|
||||
- Otherwise if `$HOME` is set, file would be `$HOME/.config/urouter/alias.json`
|
||||
- If not matched any, will panic and exit
|
||||
|
||||
## License
|
||||
|
||||
|
|
|
@ -1,9 +0,0 @@
|
|||
[default]
|
||||
address = "127.0.0.1"
|
||||
port = 8080
|
||||
keep_alive = 0
|
||||
ident = "urouter"
|
||||
ip_header = false
|
||||
log_level = "normal"
|
||||
temp_dir = "/tmp"
|
||||
cli_colors = true
|
38
alias.json
Normal file
38
alias.json
Normal file
|
@ -0,0 +1,38 @@
|
|||
[
|
||||
{
|
||||
"uri":"/",
|
||||
"alias": {
|
||||
"url": "https://ivabus.dev"
|
||||
}
|
||||
},
|
||||
{
|
||||
"uri":"/",
|
||||
"alias": {
|
||||
"file": "src/main.rs"
|
||||
},
|
||||
"agent": {
|
||||
"regex": "^curl/[0-9].[0-9].[0-9]$"
|
||||
}
|
||||
},
|
||||
{
|
||||
"uri":"text",
|
||||
"alias": {
|
||||
"html": "sometext"
|
||||
},
|
||||
"agent": {
|
||||
"regex": "^curl/[0-9].[0-9].[0-9]$",
|
||||
"only_matching": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"uri": "external",
|
||||
"alias": {
|
||||
"external": {
|
||||
"url": "https://iva.bz",
|
||||
"headers": {
|
||||
"user-agent": "curl/8.6.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
233
src/main.rs
233
src/main.rs
|
@ -1,164 +1,157 @@
|
|||
/* SPDX-License-Identifier: MIT */
|
||||
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
Global comments:
|
||||
- I'm ok with unwrapping because if unwrap fails Rocket will automatically return
|
||||
500 Internal Server Error
|
||||
*/
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod structs;
|
||||
use structs::*;
|
||||
|
||||
#[macro_use]
|
||||
extern crate rocket;
|
||||
|
||||
use rocket::http::Status;
|
||||
use std::cell::OnceCell;
|
||||
use std::net::IpAddr;
|
||||
use std::path::PathBuf;
|
||||
use std::io::Write;
|
||||
use std::sync::Arc;
|
||||
use std::{collections::HashMap, path::PathBuf, time::Instant};
|
||||
|
||||
use rocket::request::{FromRequest, Outcome};
|
||||
use rocket::response::content::RawText;
|
||||
use rocket::response::{Redirect, Responder};
|
||||
use rocket::Request;
|
||||
use serde::Deserialize;
|
||||
use rocket::http::{ContentType, Status};
|
||||
use rocket::{
|
||||
figment::Figment,
|
||||
response::{content::RawHtml, content::RawText, Redirect},
|
||||
};
|
||||
|
||||
use clap::Parser;
|
||||
use rocket::figment::Figment;
|
||||
use once_cell::sync::Lazy;
|
||||
use regex::Regex;
|
||||
|
||||
static mut ALIAS: OnceCell<Vec<Alias>> = OnceCell::new();
|
||||
static ALIAS: Lazy<Arc<Vec<Alias>>> = Lazy::new(|| {
|
||||
let mut args = Args::parse();
|
||||
if args.alias_file.is_none() {
|
||||
args.alias_file = Some(get_config_file_location());
|
||||
}
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(about, author)]
|
||||
struct Args {
|
||||
#[arg(long, default_value = "./alias.json")]
|
||||
alias_file: PathBuf,
|
||||
|
||||
/// For internal usage
|
||||
#[arg(long, default_value = "false")]
|
||||
alias_file_is_set_not_a_list: bool,
|
||||
|
||||
/// Dir to lookup file alias
|
||||
#[arg(long, default_value = ".")]
|
||||
dir: PathBuf,
|
||||
|
||||
#[arg(short, long, default_value = "127.0.0.1")]
|
||||
address: IpAddr,
|
||||
|
||||
#[arg(short, long, default_value = "8080")]
|
||||
port: u16,
|
||||
}
|
||||
|
||||
// For better compatability with Nix (with set on the top of alias.json instead of a list)
|
||||
#[derive(Deserialize, Clone, Debug)]
|
||||
struct NixJson {
|
||||
alias: Vec<Alias>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Clone, Debug)]
|
||||
struct Alias {
|
||||
uri: String,
|
||||
alias: String,
|
||||
is_url: Option<bool>,
|
||||
curl_only: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Responder)]
|
||||
enum Response {
|
||||
Text(RawText<String>),
|
||||
Redirect(Redirect),
|
||||
Status(Status),
|
||||
}
|
||||
|
||||
struct UserAgent(String);
|
||||
|
||||
#[derive(Debug)]
|
||||
enum UserAgentError {}
|
||||
|
||||
#[rocket::async_trait]
|
||||
impl<'r> FromRequest<'r> for UserAgent {
|
||||
type Error = UserAgentError;
|
||||
|
||||
async fn from_request(req: &'r Request<'_>) -> Outcome<Self, Self::Error> {
|
||||
match req.headers().get_one("user-agent") {
|
||||
Some(key) => Outcome::Success(UserAgent(key.to_string())),
|
||||
_ => Outcome::Success(UserAgent("".to_string())),
|
||||
let file = std::fs::File::open(args.alias_file.unwrap()).unwrap();
|
||||
let alias: Vec<Alias> = if args.alias_file_is_set_not_a_list {
|
||||
serde_json::from_reader::<std::fs::File, NixJson>(file).unwrap().alias
|
||||
} else {
|
||||
serde_json::from_reader::<std::fs::File, Vec<Alias>>(file).unwrap()
|
||||
};
|
||||
Arc::new(alias)
|
||||
});
|
||||
static COMPILED_REGEXES: Lazy<Arc<HashMap<String, Regex>>> = Lazy::new(|| {
|
||||
let compilation_start = Instant::now();
|
||||
let mut regexes_len = 0;
|
||||
// Precompile all regexes
|
||||
let mut compiled_regexes: HashMap<String, Regex> = HashMap::new();
|
||||
for i in &**ALIAS {
|
||||
if let Some(agent) = &i.agent {
|
||||
compiled_regexes.insert(agent.regex.clone(), Regex::new(&agent.regex).unwrap());
|
||||
regexes_len += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if regexes_len != 0 {
|
||||
println!(
|
||||
"Compiled {} regexes in {} ms",
|
||||
regexes_len,
|
||||
(Instant::now() - compilation_start).as_secs_f64() * 1000.0
|
||||
);
|
||||
}
|
||||
Arc::new(compiled_regexes)
|
||||
});
|
||||
|
||||
fn get_return(alias: &Alias) -> Response {
|
||||
let args = Args::parse();
|
||||
let mut dir = args.dir.clone();
|
||||
return match alias.is_url {
|
||||
Some(true) => Response::Redirect(Redirect::to(alias.alias.clone())),
|
||||
_ => {
|
||||
dir.push(&PathBuf::from(&alias.alias));
|
||||
Response::Text(RawText(smurf::io::read_file_str(&dir).unwrap()))
|
||||
match &alias.alias {
|
||||
AliasType::Url(url) => Response::Redirect(Box::from(Redirect::to(url.clone()))),
|
||||
AliasType::File(path) => {
|
||||
dir.push(&PathBuf::from(&path));
|
||||
Response::Text(Box::new(RawText(std::fs::read_to_string(&dir).unwrap())))
|
||||
}
|
||||
};
|
||||
AliasType::Text(text) => Response::Text(Box::new(RawText(text.clone()))),
|
||||
AliasType::Html(html) => Response::Html(Box::new(RawHtml(html.clone()))),
|
||||
AliasType::External(source) => {
|
||||
let mut request = ureq::get(&source.url);
|
||||
for (header, value) in &source.headers {
|
||||
request = request.set(header, value);
|
||||
}
|
||||
let result = request.call().unwrap();
|
||||
let ct = result.content_type();
|
||||
Response::Custom(Box::new((
|
||||
ContentType::parse_flexible(ct).unwrap(),
|
||||
RawText(result.into_string().unwrap()),
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[get("/<page>")]
|
||||
fn get_page(page: String, user_agent: UserAgent) -> Response {
|
||||
fn get_page(page: &str, user_agent: UserAgent) -> Response {
|
||||
let mut decoded_page = String::new();
|
||||
url_escape::decode_to_string(page, &mut decoded_page);
|
||||
let alias = unsafe { ALIAS.get().unwrap() };
|
||||
let mut pages = Vec::new();
|
||||
let curl_check = user_agent.0.contains("curl");
|
||||
for i in alias {
|
||||
for i in &**ALIAS {
|
||||
if i.uri == decoded_page {
|
||||
if (i.curl_only == Some(true) && curl_check.clone())
|
||||
|| (i.curl_only != Some(true) && !curl_check.clone())
|
||||
{
|
||||
return get_return(i);
|
||||
};
|
||||
if let Some(agent) = &i.agent {
|
||||
let regexes = &COMPILED_REGEXES;
|
||||
let re = regexes.get(&agent.regex).unwrap();
|
||||
|
||||
if re.is_match(&user_agent.0) {
|
||||
return get_return(i);
|
||||
}
|
||||
|
||||
if let Some(true) = agent.only_matching {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
pages.push(i);
|
||||
}
|
||||
}
|
||||
// Returning normal page (if found) to curl users.
|
||||
for i in pages {
|
||||
if i.curl_only != Some(true) {
|
||||
return get_return(i);
|
||||
}
|
||||
if !pages.is_empty() {
|
||||
return get_return(pages[0]);
|
||||
}
|
||||
Response::Status(Status::NotFound)
|
||||
}
|
||||
|
||||
#[get("/")]
|
||||
async fn index(user_agent: UserAgent) -> Response {
|
||||
get_page("/".to_string(), user_agent)
|
||||
get_page("/", user_agent)
|
||||
}
|
||||
|
||||
fn get_config_file_location() -> PathBuf {
|
||||
if users::get_effective_uid() == 0 {
|
||||
return "/etc/urouter/alias.json".parse().unwrap();
|
||||
}
|
||||
|
||||
if let Ok(config_home) = std::env::var("XDG_CONFIG_HOME") {
|
||||
return format!("{}/urouter/alias.json", config_home).parse().unwrap();
|
||||
}
|
||||
|
||||
if let Ok(home) = std::env::var("HOME") {
|
||||
return format!("{}/.config/urouter/alias.json", home).parse().unwrap();
|
||||
}
|
||||
|
||||
if !std::path::Path::new("alias.json").exists() {
|
||||
let mut file = std::fs::File::create("alias.json").unwrap();
|
||||
file.write_all(b"[]").unwrap();
|
||||
}
|
||||
"alias.json".parse().unwrap()
|
||||
}
|
||||
|
||||
#[rocket::main]
|
||||
async fn main() -> Result<(), rocket::Error> {
|
||||
let args = Args::parse();
|
||||
let alias: Vec<Alias> = if args.alias_file_is_set_not_a_list {
|
||||
let set: NixJson =
|
||||
serde_json::from_str(&smurf::io::read_file_str(&args.alias_file).unwrap()).unwrap();
|
||||
set.alias
|
||||
} else {
|
||||
serde_json::from_str(&smurf::io::read_file_str(&args.alias_file).unwrap()).unwrap()
|
||||
};
|
||||
unsafe {
|
||||
ALIAS.set(alias).unwrap();
|
||||
}
|
||||
let _alias = &**ALIAS;
|
||||
let _regex = &**COMPILED_REGEXES;
|
||||
|
||||
let figment = Figment::from(rocket::Config::default())
|
||||
.merge(("ident", "urouter"))
|
||||
.merge(("ident", format!("urouter/{}", env!("CARGO_PKG_VERSION"))))
|
||||
.merge(("port", args.port))
|
||||
.merge(("address", args.address));
|
||||
|
||||
|
|
99
src/structs.rs
Normal file
99
src/structs.rs
Normal file
|
@ -0,0 +1,99 @@
|
|||
/* SPDX-License-Identifier: MIT */
|
||||
|
||||
use clap::Parser;
|
||||
use rocket::http::{ContentType, Status};
|
||||
use rocket::request::{FromRequest, Outcome};
|
||||
use rocket::response::content::{RawHtml, RawText};
|
||||
use rocket::response::{Redirect, Responder};
|
||||
use rocket::Request;
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
use std::net::IpAddr;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(about, author)]
|
||||
pub struct Args {
|
||||
#[arg(long)]
|
||||
pub alias_file: Option<PathBuf>,
|
||||
|
||||
/// For internal usage
|
||||
#[arg(long, default_value = "false")]
|
||||
pub alias_file_is_set_not_a_list: bool,
|
||||
|
||||
/// Dir to lookup file alias
|
||||
#[arg(long, default_value = ".")]
|
||||
pub dir: PathBuf,
|
||||
|
||||
#[arg(short, long, default_value = "127.0.0.1")]
|
||||
pub address: IpAddr,
|
||||
|
||||
#[arg(short, long, default_value = "8080")]
|
||||
pub port: u16,
|
||||
}
|
||||
|
||||
// For better compatability with Nix (with set on the top of alias.json instead of a list)
|
||||
#[derive(Deserialize, Clone, Debug)]
|
||||
pub struct NixJson {
|
||||
pub alias: Vec<Alias>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Clone, Debug)]
|
||||
pub struct Alias {
|
||||
pub uri: String,
|
||||
pub alias: AliasType,
|
||||
pub agent: Option<Agent>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Clone, Debug)]
|
||||
pub enum AliasType {
|
||||
#[serde(alias = "url")]
|
||||
Url(String),
|
||||
#[serde(alias = "file")]
|
||||
File(String),
|
||||
#[serde(alias = "text")]
|
||||
Text(String),
|
||||
#[serde(alias = "html")]
|
||||
Html(String),
|
||||
#[serde(alias = "external")]
|
||||
External(External),
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Clone, Debug)]
|
||||
pub struct Agent {
|
||||
pub regex: String,
|
||||
pub only_matching: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Clone, Debug)]
|
||||
pub struct External {
|
||||
pub url: String,
|
||||
#[serde(default)]
|
||||
pub headers: HashMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Responder)]
|
||||
pub enum Response {
|
||||
Text(Box<RawText<String>>),
|
||||
Html(Box<RawHtml<String>>),
|
||||
Redirect(Box<Redirect>),
|
||||
Status(Status),
|
||||
Custom(Box<(ContentType, RawText<String>)>),
|
||||
}
|
||||
|
||||
pub struct UserAgent(pub String);
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum UserAgentError {}
|
||||
|
||||
#[rocket::async_trait]
|
||||
impl<'r> FromRequest<'r> for UserAgent {
|
||||
type Error = UserAgentError;
|
||||
|
||||
async fn from_request(req: &'r Request<'_>) -> Outcome<Self, Self::Error> {
|
||||
match req.headers().get_one("user-agent") {
|
||||
Some(key) => Outcome::Success(UserAgent(key.to_string())),
|
||||
_ => Outcome::Success(UserAgent("".to_string())),
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue