Initial commit
This commit is contained in:
commit
32b584d7d9
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
/target
|
||||||
|
db.sql
|
||||||
|
minccino.toml
|
3129
Cargo.lock
generated
Normal file
3129
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
20
Cargo.toml
Normal file
20
Cargo.toml
Normal 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
52
README.md
Normal 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
19
migration/Cargo.toml
Normal 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
41
migration/README.md
Normal 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
12
migration/src/lib.rs
Normal 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)]
|
||||||
|
}
|
||||||
|
}
|
69
migration/src/m20220919_122233_create_instance_data.rs
Normal file
69
migration/src/m20220919_122233_create_instance_data.rs
Normal 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
6
migration/src/main.rs
Normal 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
16
minccino.toml.example
Normal 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
27
src/db.rs
Normal 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
23
src/entity/defaults.rs
Normal 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 {}
|
26
src/entity/instance_info.rs
Normal file
26
src/entity/instance_info.rs
Normal 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
2
src/entity/mod.rs
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
pub mod defaults;
|
||||||
|
pub mod instance_info;
|
49
src/llv6.rs
Normal file
49
src/llv6.rs
Normal 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
21
src/macros.rs
Normal 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
78
src/main.rs
Normal 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
23
src/meta/mod.rs
Normal 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
60
src/meta/routes.rs
Normal 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
186
src/meta/test.rs
Normal 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
25
src/test.rs
Normal 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
84
src/types.rs
Normal 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
103
src/yui/auth.rs
Normal 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
49
src/yui/mod.rs
Normal 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
207
src/yui/routes.rs
Normal 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
145
src/yui/test.rs
Normal 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
|
||||||
|
);
|
||||||
|
}
|
Loading…
Reference in a new issue