Initial commit

This commit is contained in:
snow flurry 2022-09-23 22:39:17 -07:00
commit 32b584d7d9
26 changed files with 4475 additions and 0 deletions

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
/target
db.sql
minccino.toml

3129
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

20
Cargo.toml Normal file
View file

@ -0,0 +1,20 @@
[workspace]
members = [".", "migration"]
[package]
name = "minccino"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
rocket = {version = "0.5.0-rc.2", features = ["json"]}
rocket_db_pools = {version = "0.1.0-rc.2", features = ["sqlx_sqlite"]}
sea-orm = {version = "^0", features = [ "sqlx-sqlite", "runtime-tokio-rustls", "macros" ]}
serde = "1"
sha3 = "0.10"
rand = "0.8.5"
base64 = "0.13.0"
sea-orm-rocket = "0.5.0"
migration = { path = "./migration" }

52
README.md Normal file
View file

@ -0,0 +1,52 @@
# minccino - Lightweight cloud-init server
Server application that provides cloud-init metadata via the `nocloud-net` provider. Uses the link-local IPv6 address for "authentication" by pulling MAC address information.
## Requirements
Only sqlite3 dev libraries are required for Diesel.
## Configuration
Configuration is handled either via `minccino.toml` or `MINCCINO_` environment variables. To use the API (required for managing instances), you'll need to generate an API key and relevant hash in the config file. To generate this, run:
```
minccino genkey
```
This outputs the API key and the relevant config snippet you'll need to add to `minccino.toml` for the key.
## API Usage
All API endpoints require an `X-API-Key` header with the key generated above, and for the API client to be connected via localhost. \todo Allow overriding the latter requirement, if needed
To create an instance (all requests sent to `http://[::1]`):
```
> POST /_yui/instances HTTP/1.1
> X-API-Key: Your_API_Key
> Content-type: application/json
> [...]
>
> {
> "name": "instance_hostname",
> "os_name": "netbsd|debian|invalid",
> "mac_address": "0a:42:01:23:45:67"
> }
>
< HTTP/1.1 201 Created
< Content-type: application/json
< [...]
<
< {
< "error": null,
< "instances": [
< {
< "id": 591750913,
< "name": "instance_hostname",
< "os_name": "netbsd|debian|invalid",
< "mac_address": "0a:42:01:23:45:67"
< }
< ]
< }
}
```

19
migration/Cargo.toml Normal file
View file

@ -0,0 +1,19 @@
[package]
name = "migration"
version = "0.1.0"
edition = "2021"
publish = false
[lib]
name = "migration"
path = "src/lib.rs"
[dependencies]
async-std = { version = "^1", features = ["attributes", "tokio1"] }
[dependencies.sea-orm-migration]
version = "^0.9.0"
features = [
"runtime-tokio-rustls",
"sqlx-sqlite",
]

41
migration/README.md Normal file
View file

@ -0,0 +1,41 @@
# Running Migrator CLI
- Generate a new migration file
```sh
cargo run -- migrate generate MIGRATION_NAME
```
- Apply all pending migrations
```sh
cargo run
```
```sh
cargo run -- up
```
- Apply first 10 pending migrations
```sh
cargo run -- up -n 10
```
- Rollback last applied migrations
```sh
cargo run -- down
```
- Rollback last 10 applied migrations
```sh
cargo run -- down -n 10
```
- Drop all tables from the database, then reapply all migrations
```sh
cargo run -- fresh
```
- Rollback all applied migrations, then reapply all migrations
```sh
cargo run -- refresh
```
- Rollback all applied migrations
```sh
cargo run -- reset
```
- Check the status of all migrations
```sh
cargo run -- status
```

12
migration/src/lib.rs Normal file
View file

@ -0,0 +1,12 @@
pub use sea_orm_migration::prelude::*;
mod m20220919_122233_create_instance_data;
pub struct Migrator;
#[async_trait::async_trait]
impl MigratorTrait for Migrator {
fn migrations() -> Vec<Box<dyn MigrationTrait>> {
vec![Box::new(m20220919_122233_create_instance_data::Migration)]
}
}

View file

@ -0,0 +1,69 @@
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.create_table(
Table::create()
.table(Defaults::Table)
.if_not_exists()
.col(
ColumnDef::new(Defaults::Key)
.string()
.not_null()
.primary_key(),
)
.col(ColumnDef::new(Defaults::Value).string().not_null())
.to_owned(),
)
.await?;
manager
.create_table(
Table::create()
.table(InstanceInfo::Table)
.if_not_exists()
.col(
ColumnDef::new(InstanceInfo::Id)
.big_integer()
.not_null()
.primary_key(),
)
.col(ColumnDef::new(InstanceInfo::Hostname).string().not_null())
.col(ColumnDef::new(InstanceInfo::MacAddress).string().not_null())
.col(ColumnDef::new(InstanceInfo::SshKeys).string())
.col(ColumnDef::new(InstanceInfo::UserData).binary())
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(Defaults::Table).to_owned())
.await?;
manager
.drop_table(Table::drop().table(InstanceInfo::Table).to_owned())
.await
}
}
#[derive(Iden)]
enum InstanceInfo {
Table,
Id,
Hostname,
MacAddress,
SshKeys,
UserData,
}
#[derive(Iden)]
enum Defaults {
Table,
Key,
Value,
}

6
migration/src/main.rs Normal file
View file

@ -0,0 +1,6 @@
use sea_orm_migration::prelude::*;
#[async_std::main]
async fn main() {
cli::run_cli(migration::Migrator).await;
}

16
minccino.toml.example Normal file
View file

@ -0,0 +1,16 @@
[default]
address = "::"
port = 80
[debug]
workers = 4
log_level = "debug"
limits = { forms = 32768 }
[release]
port = 80
log_level = "normal"
cli_colors = false
[default.databases.instances]
url = "db.sql"

27
src/db.rs Normal file
View file

@ -0,0 +1,27 @@
use rocket::async_trait;
use rocket_db_pools::{rocket::figment::Figment, Config, Database};
#[derive(Debug, Clone)]
pub struct InstDbConn {
pub conn: sea_orm::DatabaseConnection,
}
#[async_trait]
impl rocket_db_pools::Pool for InstDbConn {
type Error = sea_orm::DbErr;
type Connection = sea_orm::DatabaseConnection;
async fn init(figment: &Figment) -> Result<Self, Self::Error> {
let config = figment.extract::<Config>().unwrap();
let conn = sea_orm::Database::connect(&config.url).await.unwrap();
Ok(Self { conn })
}
async fn get(&self) -> Result<Self::Connection, Self::Error> {
Ok(self.conn.clone())
}
}
#[derive(Database, Debug)]
#[database("instances")]
pub struct Instances(InstDbConn);

23
src/entity/defaults.rs Normal file
View file

@ -0,0 +1,23 @@
//! SeaORM Entity. Generated by sea-orm-codegen 0.9.2
use crate::types::DefaultKey;
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "defaults")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub key: DefaultKey,
pub value: String,
}
#[derive(Copy, Clone, Debug, EnumIter)]
pub enum Relation {}
impl RelationTrait for Relation {
fn def(&self) -> RelationDef {
panic!("No RelationDef")
}
}
impl ActiveModelBehavior for ActiveModel {}

View file

@ -0,0 +1,26 @@
//! SeaORM Entity. Generated by sea-orm-codegen 0.9.2
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "instance_info")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: u32,
pub hostname: String,
pub mac_address: String,
pub ssh_keys: Option<String>,
pub user_data: Option<Vec<u8>>,
}
#[derive(Copy, Clone, Debug, EnumIter)]
pub enum Relation {}
impl RelationTrait for Relation {
fn def(&self) -> RelationDef {
panic!("No RelationDef")
}
}
impl ActiveModelBehavior for ActiveModel {}

2
src/entity/mod.rs Normal file
View file

@ -0,0 +1,2 @@
pub mod defaults;
pub mod instance_info;

49
src/llv6.rs Normal file
View file

@ -0,0 +1,49 @@
use rocket::{
http::Status,
request::{FromRequest, Outcome, Request},
};
use std::net::IpAddr;
use crate::types::MacAddr;
pub struct ClientEUI64 {
pub mac_address: MacAddr,
}
#[derive(Debug)]
pub enum EUI64Error {
NotIPV6,
NotEUI64,
InvalidMACAddr,
}
#[rocket::async_trait]
impl<'r> FromRequest<'r> for ClientEUI64 {
type Error = EUI64Error;
async fn from_request(request: &'r Request<'_>) -> Outcome<ClientEUI64, EUI64Error> {
let client_ip = match request.client_ip() {
Some(IpAddr::V6(ip)) => ip,
_ => return Outcome::Failure((Status::Forbidden, EUI64Error::NotIPV6)),
};
if client_ip.segments()[0] == 0xfe80 {
let octets = client_ip.octets();
let mut mac = [&octets[8..11], &octets[13..]].concat();
mac[0] ^= 0x2;
let mac: [u8; 6] = match mac.try_into() {
Ok(t) => t,
Err(_) => {
return Outcome::Failure((
Status::InternalServerError,
EUI64Error::InvalidMACAddr,
))
}
};
return Outcome::Success(ClientEUI64 {
mac_address: MacAddr(mac),
});
} else {
return Outcome::Failure((Status::Forbidden, EUI64Error::NotEUI64));
}
}
}

21
src/macros.rs Normal file
View file

@ -0,0 +1,21 @@
macro_rules! api_err {
( $resp:ident, $($arg:tt)* ) => {
APIError::$resp(
Json($crate::yui::ErrorResponse {
error: format!( $($arg)* ),
}))
};
}
macro_rules! hex {
($arg:expr) => {{
$arg.map(|u| format!("{:x}", u))
.collect::<Vec<String>>()
.into_iter()
.as_slice()
.join("")
}};
}
pub(crate) use api_err;
pub(crate) use hex;

78
src/main.rs Normal file
View file

@ -0,0 +1,78 @@
#[macro_use]
extern crate rocket;
mod entity;
#[macro_use]
mod macros;
mod db;
mod llv6;
mod meta;
#[cfg(test)]
mod test;
mod types;
mod yui;
use db::Instances;
use migration::{Migrator, MigratorTrait};
use rocket::figment::providers::{Env, Format, Toml};
use rocket::Build;
use rocket::Rocket;
use rocket_db_pools::Database;
fn rocket() -> Rocket<Build> {
let rocket = rocket::build()
.attach(Instances::init())
.mount("/", meta::routes::all())
.mount("/_yui/", yui::routes::all());
#[cfg(test)]
let figment = {
std::env::set_var(
"MINCCINO_TEST_DATABASES",
"{instances={url=\"sqlite::memory:\"}}",
);
rocket::figment::Figment::from(rocket::Config::default())
.merge(Toml::file("minccino.toml").nested())
.merge(Env::prefixed("MINCCINO_TEST_"))
};
#[cfg(not(test))]
let figment = {
rocket::figment::Figment::from(rocket::Config::default())
.merge(Toml::file("minccino.toml").nested())
.merge(Env::prefixed("MINCCINO_"))
};
rocket
.configure(figment)
.attach(rocket::fairing::AdHoc::on_liftoff(
"Apply migrations, if needed",
|rocket| {
Box::pin(async move {
if let Some(db) = Instances::fetch(rocket) {
Migrator::up(&db.conn, None).await.unwrap();
}
})
},
))
}
#[rocket::main]
async fn main() -> Result<(), rocket::Error> {
let arg = std::env::args().nth(1);
match arg {
Some(x) => match x.as_str() {
"genkey" => {
let (key, hash) = yui::auth::genkey();
println!("\n# API Key: {}\n", key);
println!(
"# Put the following in your Rocket.toml:\n[minccino]\napi_hash = \"{}\"",
hash
);
}
_ => println!("not implemented"),
},
None => {
let _rocket = rocket().ignite().await?.launch().await?;
}
}
Ok(())
}

23
src/meta/mod.rs Normal file
View file

@ -0,0 +1,23 @@
pub mod routes;
#[cfg(test)]
mod test;
use rocket::serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
#[serde(crate = "rocket::serde", rename_all = "kebab-case")]
pub struct CIMetadata {
pub instance_id: String,
pub local_hostname: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub public_keys: Option<Vec<String>>,
}
#[derive(Debug, Responder)]
pub enum MetaError {
#[response(status = 404)]
NotFound(()),
#[response(status = 500)]
InternalServerError(()),
}
type MetaResponse<T> = Result<T, MetaError>;

60
src/meta/routes.rs Normal file
View file

@ -0,0 +1,60 @@
use crate::db::Instances;
use crate::entity::*;
use crate::llv6::*;
use crate::meta::*;
use crate::types::DefaultKey;
use rocket::serde::json::Json;
use rocket_db_pools::Connection;
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
/// standard cloud-init metadata
#[get("/meta-data")]
pub async fn metadata(
db: Connection<Instances>,
client_addr: ClientEUI64,
) -> MetaResponse<Json<CIMetadata>> {
match instance_info::Entity::find()
.filter(instance_info::Column::MacAddress.eq(client_addr.mac_address.to_string()))
.one(&*db)
.await
{
Ok(Some(v)) => Ok(Json(CIMetadata {
instance_id: format!("i-{:x}", v.id),
local_hostname: v.hostname,
public_keys: {
match v.ssh_keys {
Some(keys) => Some(keys),
// no keys for instance -> try to get from defaults
None => defaults::Entity::find_by_id(DefaultKey::SshKeys)
.one(&*db)
.await
.map_err(|_| MetaError::InternalServerError(()))?
.map(|x| x.value),
}
.map(|k| k.split('\n').map(|s| s.to_owned()).collect())
},
})),
_ => Err(MetaError::NotFound(())),
}
}
/// standard cloud-init userdata
#[get("/user-data")]
pub async fn userdata(
db: Connection<Instances>,
client_addr: ClientEUI64,
) -> MetaResponse<Vec<u8>> {
match instance_info::Entity::find()
.filter(instance_info::Column::MacAddress.eq(client_addr.mac_address.to_string()))
.one(&*db)
.await
.map_err(|_| MetaError::InternalServerError(()))?
{
Some(v) => v.user_data.map_or(Err(MetaError::NotFound(())), Ok),
None => Err(MetaError::NotFound(())),
}
}
pub fn all() -> Vec<rocket::Route> {
routes![metadata, userdata,]
}

186
src/meta/test.rs Normal file
View file

@ -0,0 +1,186 @@
use super::routes::*;
use crate::meta::CIMetadata;
use crate::rocket;
use crate::test::*;
use crate::types::MacAddr;
use crate::yui::routes::*;
use crate::yui::InstanceReq;
use crate::yui::InstanceResponse;
use rocket::http::{ContentType, Header, Status};
use rocket::local::blocking::Client;
use std::net::{Ipv6Addr, SocketAddr, SocketAddrV6};
#[test]
fn metadata_works() {
let client = Client::tracked(rocket()).expect("valid rocket instance");
let instance = InstanceReq {
name: "first-test".to_owned(),
mac_address: MacAddr(LLV6_MAC).to_string(),
ssh_keys: None,
user_data: None,
};
let response = client
.post(uri!("/_yui/", new_instance()))
.header(Header::new("x-api-key", SNAKEOIL_KEY))
.remote(saddr6!(localhost))
.json(&instance)
.dispatch();
assert_eq!(response.status(), Status::Ok);
let inst = response
.into_json::<InstanceResponse>()
.expect("json in InstanceResponse format");
let inst = inst.instances.unwrap()[0].clone();
let metadata = client
.get(uri!(metadata()))
.remote(saddr6!(LLV6_IP))
.dispatch();
assert_eq!(metadata.status(), Status::Ok);
let metadata = metadata
.into_json::<CIMetadata>()
.expect("json in form CIMetadata");
assert_eq!(metadata.local_hostname, instance.name);
let response = client
.delete(uri!("/_yui/", delete_instance(inst.id)))
.header(Header::new("x-api-key", SNAKEOIL_KEY))
.remote(saddr6!(localhost))
.dispatch();
assert_eq!(response.status(), Status::Ok);
}
#[test]
fn metadata_defaults_apply() {
const SNAKEOIL_SSHKEY: &str =
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJg6JbxdLaFpG0vnXHV0kNoz282vgjtJlcvGv5DUDKJj snakeoil";
const INSTANCE_SSHKEY: &str = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGPxtsgJzSNTQAsv0LV7usNOP8CBmCoqD8AkwZUvE01E instance-key";
let client = Client::tracked(rocket()).expect("valid rocket instance");
let keys = SNAKEOIL_SSHKEY.to_owned();
let response = client
.post(uri!("/_yui/", set_defaults("ssh_keys")))
.header(ContentType::Plain)
.header(Header::new("x-api-key", SNAKEOIL_KEY))
.remote(saddr6!(localhost))
.body(keys)
.dispatch();
assert_eq!(
response.status(),
Status::Ok,
"Couldn't set defaults: (code {}) {:?}",
response.status().code,
response.into_string()
);
// create the first instance, with no ssh keys
let instance = InstanceReq {
name: "first-test".to_owned(),
mac_address: LLV6_MACSTR.to_owned(),
ssh_keys: None,
user_data: None,
};
let response = client
.post(uri!("/_yui/", new_instance()))
.json(&instance)
.header(Header::new("x-api-key", SNAKEOIL_KEY))
.remote(saddr6!(localhost))
.dispatch();
assert_eq!(response.status(), Status::Ok);
let response = client
.get(uri!(metadata()))
.remote(saddr6!(LLV6_IP))
.dispatch();
assert_eq!(response.status(), Status::Ok);
let response = response
.into_json::<CIMetadata>()
.expect("should be instance metadata");
let keys = response
.public_keys
.expect("should be a vec of SSH public keys");
assert_eq!(keys.len(), 1);
assert_eq!(keys[0], SNAKEOIL_SSHKEY);
// new instance, with defined ssh keys
let instance = InstanceReq {
name: "second-test".to_owned(),
mac_address: LLV6_MACSTR2.to_owned(),
ssh_keys: Some(vec![INSTANCE_SSHKEY.to_owned()]),
user_data: None,
};
let response = client
.post(uri!("/_yui/", new_instance()))
.json(&instance)
.header(Header::new("x-api-key", SNAKEOIL_KEY))
.remote(saddr6!(localhost))
.dispatch();
assert_eq!(response.status(), Status::Ok);
let response = client
.get(uri!(metadata()))
.remote(saddr6!(LLV6_IP2))
.dispatch();
assert_eq!(response.status(), Status::Ok);
let response = response
.into_json::<CIMetadata>()
.expect("should be instance metadata");
let keys = response
.public_keys
.expect("should be a vec of SSH public keys");
assert_eq!(keys[0], INSTANCE_SSHKEY);
}
#[test]
fn userdata_works() {
let client = Client::tracked(rocket()).expect("valid rocket instance");
// create instance
let instance = InstanceReq {
name: "first-test".to_owned(),
mac_address: MacAddr(LLV6_MAC).to_string(),
ssh_keys: None,
user_data: Some(base64::encode(SPEEX)),
};
let response = client
.post(uri!("/_yui/", new_instance()))
.header(Header::new("x-api-key", SNAKEOIL_KEY))
.remote(saddr6!(localhost))
.json(&instance)
.dispatch();
assert_eq!(response.status(), Status::Ok);
// get userdata
let userdata = client
.get(uri!(userdata()))
.remote(saddr6!(LLV6_IP))
.dispatch();
assert_eq!(userdata.status(), Status::Ok);
assert_eq!(userdata.into_string().unwrap(), SPEEX);
// make sure only LLV6_IP can access it
let bad_userdata = client
.get(uri!(userdata()))
.remote(saddr6!(LLV6_IP2))
.dispatch();
assert_eq!(bad_userdata.status(), Status::NotFound);
}

25
src/test.rs Normal file
View file

@ -0,0 +1,25 @@
use std::net::Ipv6Addr;
pub const SNAKEOIL_KEY: &str = "I3QleS8dm9cWUdzOL5m--C3qRNn-a2rh8h9zzcAy9q5o4dWF";
pub const SNAKEOIL_HASH: &str = "bbdd8c34a583554b4a20f7b0d92f1c3f5affdfbdd768b63c2945a63195c24a";
pub const LLV6_IP: Ipv6Addr = Ipv6Addr::new(0xfe80, 0, 0, 0, 0x0042, 0xafff, 0xfe32, 0xa0fe);
pub const LLV6_MAC: [u8; 6] = [0x02, 0x42, 0xaf, 0x32, 0xa0, 0xfe];
pub const LLV6_MACSTR: &str = "02:42:af:32:a0:fe";
pub const LLV6_IP2: Ipv6Addr = Ipv6Addr::new(0xfe80, 0, 0, 0, 0x0842, 0xafff, 0xfe64, 0x69ba);
pub const LLV6_MAC2: [u8; 6] = [0x0a, 0x42, 0xaf, 0x64, 0x69, 0xba];
pub const LLV6_MACSTR2: &str = "0a:42:af:64:69:ba";
pub const SPEEX: &str = "This is not an example of Speex, an audio compression codec specifically tuned for the reproduction of human speech.";
macro_rules! saddr6 {
(localhost) => {
SocketAddr::V6(SocketAddrV6::new(Ipv6Addr::LOCALHOST, 8080, 0, 0))
};
($addr:expr) => {
SocketAddr::V6(SocketAddrV6::new($addr, 8080, 0, 0))
};
}
pub(crate) use saddr6;

84
src/types.rs Normal file
View file

@ -0,0 +1,84 @@
use std::{convert::TryFrom, fmt};
use sea_orm::prelude::*;
use std::ops::{Deref, Index};
pub enum MacParseError {
BadOctet,
WrongOctetCount,
}
/// Wrapper type for validation and manipulation of MAC addresses
pub struct MacAddr(pub [u8; 6]);
impl TryFrom<String> for MacAddr {
type Error = MacParseError;
fn try_from(value: String) -> Result<Self, Self::Error> {
let v = value
.split(':')
.map(|x| u8::from_str_radix(x, 16))
.collect::<Result<Vec<u8>, _>>()
.map_err(|_| Self::Error::BadOctet)?;
let octets: [u8; 6] = v.try_into().map_err(|_| Self::Error::WrongOctetCount)?;
Ok(Self(octets))
}
}
impl fmt::Display for MacAddr {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0.map(|x| format!("{:x}", x)).join(":"))
}
}
impl Deref for MacAddr {
type Target = [u8; 6];
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl Index<usize> for MacAddr {
type Output = u8;
fn index(&self, index: usize) -> &Self::Output {
&self.0[index]
}
}
/// Key for defaults table
#[derive(Clone, Copy, Debug, EnumIter, PartialEq, Eq, DeriveActiveEnum)]
#[sea_orm(rs_type = "String", db_type = "Text")]
pub enum DefaultKey {
#[sea_orm(string_value = "ssh_keys")]
SshKeys,
}
impl sea_orm::TryFromU64 for DefaultKey {
fn try_from_u64(_: u64) -> Result<Self, DbErr> {
// XXX: ok im being a bit lazy here. it's prolly fine
Err(DbErr::Type("?????".to_owned()))
}
}
impl fmt::Display for DefaultKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::SshKeys => write!(f, "ssh_keys"),
}
}
}
#[derive(Debug)]
pub enum ParseError {
InvalidKey,
}
impl TryFrom<String> for DefaultKey {
type Error = ParseError;
fn try_from(value: String) -> Result<Self, Self::Error> {
match value.as_str() {
"ssh_keys" => Ok(Self::SshKeys),
_ => Err(ParseError::InvalidKey),
}
}
}

103
src/yui/auth.rs Normal file
View file

@ -0,0 +1,103 @@
use crate::macros::hex;
#[cfg(test)]
use crate::test::SNAKEOIL_HASH;
use rocket::{
http::Status,
request::{FromRequest, Outcome, Request},
};
use sha3::{Digest, Sha3_256};
/// Generate an API Key and its hash
pub fn genkey() -> (String, String) {
let apikey = {
let bytes: Vec<u8> = (0..36).map(|_| rand::random::<u8>()).collect();
base64::encode(bytes).replace('+', "-").replace('/', "_")
};
let apihash = {
let mut hasher = Sha3_256::default();
hasher.update(&apikey);
hex!(hasher.finalize().iter())
};
(apikey, apihash)
}
#[derive(Debug)]
pub enum LoginError {
UnknownClientIP,
WrongClientIP,
WrongKey,
#[allow(dead_code)]
ConfigError(String),
}
/// Extra app config data
#[derive(rocket::serde::Deserialize)]
#[serde(crate = "rocket::serde")]
pub struct MinccinoConfig {
api_hash: String,
}
/// Confirms a vaid API key is being used, and blocks non-localhost requests
pub struct APIUser {
_priv: (),
}
#[rocket::async_trait]
impl<'r> FromRequest<'r> for APIUser {
type Error = LoginError;
async fn from_request(request: &'r Request<'_>) -> Outcome<APIUser, LoginError> {
// get the request ip, and check if it's localhost
let client_ip = match request.client_ip() {
Some(ip) => ip,
_ => return Outcome::Failure((Status::Forbidden, LoginError::UnknownClientIP)),
};
if !client_ip.is_loopback() {
return Outcome::Failure((Status::Forbidden, LoginError::WrongClientIP));
}
// get the API key from X-API-Key
let key_header = request.headers().get("x-api-key").collect::<Vec<&str>>();
let apikey = match key_header.len() {
1 => key_header[0],
_ => return Outcome::Failure((Status::Unauthorized, LoginError::WrongKey)),
};
// get api key hash from config
#[cfg(test)]
let app_config: MinccinoConfig = {
warn!("/!\\ /!\\ FAKE API KEY IN USE /!\\ /!\\");
warn!("IF YOU SEE THIS IN PROD, SOMETHING IS VERY WRONG");
MinccinoConfig {
api_hash: String::from(SNAKEOIL_HASH),
}
};
#[cfg(not(test))]
let app_config: MinccinoConfig = match request.rocket().figment().extract_inner("minccino")
{
Ok(conf) => conf,
Err(err) => {
return Outcome::Failure((
Status::InternalServerError,
LoginError::ConfigError(format!("{:?}", err)),
))
}
};
let apihash = {
let mut hasher = Sha3_256::default();
hasher.update(apikey.as_bytes());
hex!(hasher.finalize().iter())
};
// make sure the hashed API key matches what we've got
if app_config.api_hash != apihash {
return Outcome::Failure((Status::Forbidden, LoginError::WrongKey));
}
// and we're good!
Outcome::Success(APIUser { _priv: () })
}
}

49
src/yui/mod.rs Normal file
View file

@ -0,0 +1,49 @@
use crate::entity::*;
use rocket::serde::{json::Json, Deserialize, Serialize};
pub mod auth;
pub mod routes;
#[cfg(test)]
mod test;
#[derive(Serialize, Deserialize)]
#[serde(crate = "rocket::serde")]
pub struct InstanceReq {
pub name: String,
pub mac_address: String,
pub ssh_keys: Option<Vec<String>>,
pub user_data: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(crate = "rocket::serde")]
pub struct InstanceResponse {
pub error: Option<String>,
pub instances: Option<Vec<instance_info::Model>>,
}
impl InstanceResponse {
pub fn new(inst: Vec<instance_info::Model>) -> Self {
InstanceResponse {
error: None,
instances: Some(inst),
}
}
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(crate = "rocket::serde")]
pub struct ErrorResponse {
pub error: String,
}
#[derive(Debug, Responder)]
pub enum APIError {
#[response(status = 400)]
BadRequest(Json<ErrorResponse>),
#[response(status = 404)]
NotFound(Json<ErrorResponse>),
#[response(status = 409)]
Conflict(Json<ErrorResponse>),
#[response(status = 500)]
InternalServerError(Json<ErrorResponse>),
}

207
src/yui/routes.rs Normal file
View file

@ -0,0 +1,207 @@
use std::convert::TryInto;
use crate::entity::*;
use crate::macros::api_err;
use crate::types::MacAddr;
use crate::types::*;
use crate::yui::auth::*;
use crate::yui::*;
use crate::Instances;
use rocket::serde::json::Json;
use rocket_db_pools::Connection;
use sea_orm::ActiveValue::{NotSet, Set};
use sea_orm::{
ActiveModelTrait, ColumnTrait, EntityTrait, ModelTrait, PaginatorTrait, QueryFilter,
};
/// Create a new instance
#[post("/instances", format = "application/json", data = "<iinfo>")]
pub async fn new_instance(
db: Connection<Instances>,
iinfo: Json<InstanceReq>,
_api: APIUser,
) -> Result<Json<InstanceResponse>, APIError> {
let iinfo: InstanceReq = iinfo.into_inner();
let ii_name = iinfo.name.clone();
// loosely validate ssh keys
if let Some(ssh_keys) = &iinfo.ssh_keys {
for (i, key) in ssh_keys.iter().enumerate() {
if key.contains('\n') {
return Err(api_err!(
BadRequest,
"SSH Key {} contains newlines. Ensure SSH keys are split as an array",
i
));
}
}
}
// validation: confirm hostname isn't in use
if instance_info::Entity::find()
.filter(instance_info::Column::Hostname.eq(ii_name))
.count(&*db)
.await
.map_err(|e| api_err!(InternalServerError, "Database access failed: {:?}", e))?
!= 0
{
return Err(api_err!(BadRequest, "Hostname already exists"));
}
// validation: confirm mac address isn't in use
let inst_mac = MacAddr::try_from(iinfo.mac_address)
.map_err(|_| api_err!(BadRequest, "MAC address is invalid"))?;
if instance_info::Entity::find()
.filter(instance_info::Column::MacAddress.eq(inst_mac.to_string()))
.count(&*db)
.await
.map_err(|e| api_err!(InternalServerError, "Database access failed: {:?}", e))?
!= 0
{
return Err(api_err!(Conflict, "MAC Address is in use"));
}
// create and commit the instance info
let newid: u32 = (inst_mac[3] as u32) << 24
| (inst_mac[4] as u32) << 16
| (inst_mac[5] as u32) << 8
| (rand::random::<u8>() as u32);
let iisql = instance_info::ActiveModel {
id: Set(newid),
hostname: Set(iinfo.name.clone()),
mac_address: Set(inst_mac.to_string()),
ssh_keys: match iinfo.ssh_keys {
Some(keys) => Set(Some(keys.join("\n"))),
None => NotSet,
},
user_data: Set(iinfo
.user_data
.map(base64::decode)
.map_or(Ok(None), |v| v.map(Some))
.map_err(|e| api_err!(BadRequest, "Error decoding user-data: {:?}", e))?),
};
match iisql.insert(&*db).await {
Ok(v) => Ok(Json(InstanceResponse::new(vec![v]))),
Err(err) => Err(api_err!(
InternalServerError,
"Failed to add record: {:?}",
err
)),
}
}
/// List all instances in the database
#[get("/instances")]
pub async fn get_instances(
db: Connection<Instances>,
_api: APIUser,
) -> Result<Json<InstanceResponse>, APIError> {
match instance_info::Entity::find().all(&*db).await {
Ok(v) => Ok(Json(InstanceResponse::new(v))),
Err(err) => Err(api_err!(
InternalServerError,
"Failed to get records: {:?}",
err
)),
}
}
/// Get a specified instance
#[get("/instances/<id>")]
pub async fn get_instance(
db: Connection<Instances>,
id: u32,
_api: APIUser,
) -> Result<Json<InstanceResponse>, APIError> {
match instance_info::Entity::find_by_id(id)
.one(&*db)
.await
.map_err(|e| api_err!(InternalServerError, "Database error: {:?}", e))?
{
Some(v) => Ok(Json(InstanceResponse::new(vec![v]))),
None => Err(api_err!(NotFound, "Instance not found")),
}
}
/// Delete an instance from the database
#[delete("/instances/<id>")]
pub async fn delete_instance(
db: Connection<Instances>,
id: u32,
_api: APIUser,
) -> Result<(), APIError> {
match instance_info::Entity::find_by_id(id)
.one(&*db)
.await
.map_err(|e| api_err!(InternalServerError, "Error deleting instance: {:?}", e))?
{
Some(ent) => {
ent.delete(&*db)
.await
.map_err(|e| api_err!(InternalServerError, "Error deleting instance: {:?}", e))?;
Ok(())
}
None => Err(api_err!(NotFound, "Instance not found")),
}
}
// Set a default (primarily, ssh-keys)
#[post("/config/<key>", format = "plain", data = "<val>")]
async fn set_defaults(
db: Connection<Instances>,
key: String,
val: String,
_api: APIUser,
) -> Result<(), APIError> {
let key: DefaultKey = key
.try_into()
.map_err(|_| api_err!(BadRequest, "Invalid default key"))?;
match defaults::Entity::find_by_id(key)
.one(&*db)
.await
.map_err(|e| api_err!(InternalServerError, "Error getting default: {:?}", e))?
{
Some(def) => {
let mut model: defaults::ActiveModel = def.into();
model.value = Set(val);
model.update(&*db).await.map(|_| ())
}
None => {
let model = defaults::ActiveModel {
key: Set(key),
value: Set(val),
};
model.insert(&*db).await.map(|_| ())
}
}
.map_err(|e| api_err!(InternalServerError, "Error setting `{}`: {:?}", key, e))
}
// get a default setting
#[get("/config/<key>")]
async fn get_defaults(
db: Connection<Instances>,
key: String,
_api: APIUser,
) -> Result<String, APIError> {
let key: DefaultKey = key
.try_into()
.map_err(|_| api_err!(BadRequest, "Invalid default key"))?;
defaults::Entity::find_by_id(key)
.one(&*db)
.await
.map_err(|e| api_err!(InternalServerError, "Error getting default: {:?}", e))?
.map_or(Err(api_err!(NotFound, "Key not yet set")), |v| Ok(v.value))
}
pub fn all() -> Vec<rocket::Route> {
routes![
new_instance,
get_instances,
get_instance,
delete_instance,
set_defaults,
get_defaults,
]
}

145
src/yui/test.rs Normal file
View file

@ -0,0 +1,145 @@
use super::routes::*;
use super::InstanceResponse;
use crate::rocket;
use crate::test::*;
use rocket::http::{ContentType, Header, Status};
use rocket::local::blocking::Client;
use std::net::{Ipv6Addr, SocketAddr, SocketAddrV6};
#[test]
fn auth_works_properly() {
let client = Client::tracked(rocket()).expect("valid rocket instance");
let api_header: Header = Header::new("x-api-key", SNAKEOIL_KEY);
// wrong client IP - should return Forbidden
let response = client
.get(uri!("/_yui/", get_instances()))
.remote(SocketAddr::V6(SocketAddrV6::new(LLV6_IP, 8080, 0, 0)))
.dispatch();
assert_eq!(response.status(), Status::Forbidden);
// no API key - should return Unauthorized
let response = client
.get(uri!("/_yui/", get_instances()))
.remote(saddr6!(localhost))
.dispatch();
assert_eq!(response.status(), Status::Unauthorized);
// API key, and localhost -- should be OK
let response = client
.get(uri!("/_yui/", get_instances()))
.remote(saddr6!(localhost))
.header(api_header)
.dispatch();
assert_eq!(response.status(), Status::Ok);
}
#[test]
fn endpoints_require_auth() {
let client = Client::tracked(rocket()).expect("valid rocket instance");
let test_instance = super::InstanceReq {
name: "test-pc".to_owned(),
mac_address: LLV6_MACSTR.to_owned(),
ssh_keys: None,
user_data: None,
};
let reqs = vec![
client
.post(uri!("/_yui/", new_instance()))
.json(&test_instance),
client.get(uri!("/_yui/", get_instances())),
client.get(uri!("/_yui/", get_instance(0))),
client.delete(uri!("/_yui/", delete_instance(0))),
client.get(uri!("/_yui/", get_defaults("ssh_keys".to_owned()))),
client
.post(uri!("/_yui/", set_defaults("ssh_keys".to_owned())))
.header(ContentType::Plain)
.body(SPEEX),
];
for r in reqs {
let resp = r.remote(saddr6!(localhost)).dispatch();
assert_eq!(resp.status(), Status::Unauthorized);
}
}
#[test]
fn crud() {
let client = Client::tracked(rocket()).expect("valid rocket instance");
let test_instance = super::InstanceReq {
name: "test-pc".to_owned(),
mac_address: LLV6_MACSTR.to_owned(),
ssh_keys: None,
user_data: None,
};
let api_header: Header = Header::new("x-api-key", SNAKEOIL_KEY);
// Create the instance
let response = client
.post(uri!("/_yui/", new_instance()))
.json(&test_instance)
.remote(saddr6!(localhost))
.header(api_header.clone())
.dispatch();
assert_eq!(response.status(), Status::Ok);
let json_resp = response
.into_json::<InstanceResponse>()
.expect("json in form InstanceResponse");
// make sure there's no error...
assert_eq!(json_resp.error, None);
// ... and that the Vec<> is available
let instances = json_resp.instances.expect("Some(Vec<InstanceInfo>)");
// ... and that it's not empty
assert_eq!(instances.len(), 1);
let saved = instances[0].clone();
// ... and that the id is correct
let got_id = saved.id & 0xffffff00;
assert_eq!(got_id, 0x32a0fe00);
// Try to get the instance
let response = client
.get(uri!("/_yui/", get_instance(saved.id)))
.remote(saddr6!(localhost))
.header(api_header.clone())
.dispatch();
assert_eq!(response.status(), Status::Ok);
let json_resp = response
.into_json::<InstanceResponse>()
.expect("json in form InstanceResponse");
let read_inst = json_resp.instances.unwrap()[0].clone();
assert_eq!(read_inst, saved);
// Delete instance
let response = client
.delete(uri!("/_yui/", delete_instance(saved.id)))
.remote(saddr6!(localhost))
.header(api_header.clone())
.dispatch();
assert_eq!(response.status(), Status::Ok);
// Make sure it's deleted
let response = client
.get(uri!("/_yui/", get_instances()))
.remote(saddr6!(localhost))
.header(api_header.clone())
.dispatch();
assert_eq!(response.status(), Status::Ok);
let json_resp = response
.into_json::<InstanceResponse>()
.expect("json in form InstanceResponse");
assert_eq!(
json_resp.instances.expect("empty Vec of instances").len(),
0
);
}