/v1/hosts

This commit is contained in:
c0repwn3r 2023-04-27 20:40:56 -04:00
parent 26f5db6b61
commit fe67c34fd5
Signed by: core
GPG Key ID: FDBF740DADDCEECF
5 changed files with 365 additions and 2 deletions

View File

@ -95,6 +95,7 @@ async fn main() -> Result<(), Box<dyn Error>> {
.service(routes::v1::roles::delete_role) .service(routes::v1::roles::delete_role)
.service(routes::v1::roles::update_role_request) .service(routes::v1::roles::update_role_request)
.service(routes::v1::trifid::trifid_extensions) .service(routes::v1::trifid::trifid_extensions)
.service(routes::v1::hosts::get_hosts)
}).bind(CONFIG.server.bind)?.run().await?; }).bind(CONFIG.server.bind)?.run().await?;
Ok(()) Ok(())

View File

@ -0,0 +1,358 @@
// trifid-api, an open source reimplementation of the Defined Networking nebula management server.
// Copyright (C) 2023 c0repwn3r
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
//
//#GET /v1/hosts t+parity:full t+type:documented t+status:done t+feature:definednetworking
// This endpoint has full parity with the original API. It has been recreated from the original API documentation.
// This endpoint is considered done. No major features should be added or removed, unless it fixes bugs.
// This endpoint requires the `definednetworking` extension to be enabled to be used.
use std::net::SocketAddrV4;
use std::str::FromStr;
use actix_web::{HttpRequest, HttpResponse, get};
use actix_web::web::{Data, Query};
use log::error;
use sea_orm::{EntityTrait, QueryFilter, ColumnTrait, QueryOrder, PaginatorTrait};
use serde::{Serialize, Deserialize};
use trifid_api_entities::entity::{host, host_static_address, network, organization};
use crate::AppState;
use crate::auth_tokens::{enforce_2fa, enforce_api_token, TokenInfo};
use crate::cursor::Cursor;
use crate::error::{APIError, APIErrorsResponse};
#[derive(Serialize, Deserialize)]
pub struct ListHostsRequestOpts {
#[serde(default, rename = "includeCounts")]
pub include_counts: bool,
#[serde(default)]
pub cursor: String,
#[serde(default = "page_default", rename = "pageSize")]
pub page_size: u64
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct ListHostsResponse {
pub data: Vec<HostResponse>,
pub metadata: ListHostsResponseMetadata
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct ListHostsResponseMetadata {
#[serde(rename = "totalCount")]
pub total_count: u64,
#[serde(rename = "hasNextPage")]
pub has_next_page: bool,
#[serde(rename = "hasPrevPage")]
pub has_prev_page: bool,
#[serde(default, rename = "prevCursor")]
pub prev_cursor: Option<String>,
#[serde(default, rename = "nextCursor")]
pub next_cursor: Option<String>,
#[serde(default)]
pub page: Option<ListHostsResponseMetadataPage>
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct ListHostsResponseMetadataPage {
pub count: u64,
pub start: u64
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct HostResponse {
pub id: String,
#[serde(rename = "organizationID")]
pub organization_id: String,
#[serde(rename = "networkID")]
pub network_id: String,
#[serde(rename = "roleID")]
pub role_id: String,
pub name: String,
#[serde(rename = "ipAddress")]
pub ip_address: String,
#[serde(rename = "staticAddresses")]
pub static_addresses: Vec<SocketAddrV4>,
#[serde(rename = "listenPort")]
pub listen_port: u16,
#[serde(rename = "isLighthouse")]
pub is_lighthouse: bool,
#[serde(rename = "isRelay")]
pub is_relay: bool,
#[serde(rename = "createdAt")]
pub created_at: String,
#[serde(rename = "isBlocked")]
pub is_blocked: bool,
pub metadata: HostResponseMetadata
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct HostResponseMetadata {
#[serde(rename = "lastSeenAt")]
pub last_seen_at: Option<String>,
pub version: String,
pub platform: String,
#[serde(rename = "updateAvailable")]
pub update_available: bool
}
fn page_default() -> u64 { 25 }
#[get("/v1/hosts")]
pub async fn get_hosts(opts: Query<ListHostsRequestOpts>, req_info: HttpRequest, db: Data<AppState>) -> HttpResponse {
// For this endpoint, you either need to be a fully authenticated user OR a token with roles:list
let session_info = enforce_2fa(&req_info, &db.conn).await.unwrap_or(TokenInfo::NotPresent);
let api_token_info = enforce_api_token(&req_info, &["hosts:list"], &db.conn).await.unwrap_or(TokenInfo::NotPresent);
// If neither are present, throw an error
if matches!(session_info, TokenInfo::NotPresent) && matches!(api_token_info, TokenInfo::NotPresent) {
return HttpResponse::Unauthorized().json(APIErrorsResponse {
errors: vec![
APIError {
code: "ERR_UNAUTHORIZED".to_string(),
message: "This endpoint requires either a fully authenticated user or a token with the hosts:list scope".to_string(),
path: None,
}
],
})
}
// If both are present, throw an error
if matches!(session_info, TokenInfo::AuthToken(_)) && matches!(api_token_info, TokenInfo::ApiToken(_)) {
return HttpResponse::BadRequest().json(APIErrorsResponse {
errors: vec![
APIError {
code: "ERR_AMBIGUOUS_AUTHENTICATION".to_string(),
message: "Both a user token and an API token with the proper scope was provided. Please only provide one.".to_string(),
path: None
}
],
})
}
let org_id = match api_token_info {
TokenInfo::ApiToken(tkn) => tkn.organization,
_ => {
// we have a session token, which means we have to do a db request to get the organization that this user owns
let user = match session_info {
TokenInfo::AuthToken(tkn) => tkn.session_info.user,
_ => unreachable!()
};
let org = match organization::Entity::find().filter(organization::Column::Owner.eq(user.id)).one(&db.conn).await {
Ok(r) => r,
Err(e) => {
error!("database error: {}", e);
return HttpResponse::InternalServerError().json(APIErrorsResponse {
errors: vec![
APIError {
code: "ERR_DB_ERROR".to_string(),
message: "There was an error performing the database request, please try again later.".to_string(),
path: None,
}
],
});
}
};
if let Some(org) = org {
org.id
} else {
return HttpResponse::Unauthorized().json(APIErrorsResponse {
errors: vec![
APIError {
code: "ERR_NO_ORG".to_string(),
message: "This user does not own any organizations. Try using an API token instead.".to_string(),
path: None
}
],
})
}
}
};
let net_id;
let net = match network::Entity::find().filter(network::Column::Organization.eq(&org_id)).one(&db.conn).await {
Ok(r) => r,
Err(e) => {
error!("database error: {}", e);
return HttpResponse::InternalServerError().json(APIErrorsResponse {
errors: vec![
APIError {
code: "ERR_DB_ERROR".to_string(),
message: "There was an error performing the database request, please try again later.".to_string(),
path: None,
}
],
});
}
};
if let Some(net) = net {
net_id = net.id;
} else {
return HttpResponse::Unauthorized().json(APIErrorsResponse {
errors: vec![
APIError {
code: "ERR_NO_NET".to_string(),
message: "This user does not own any networks. Try using an API token instead.".to_string(),
path: None
}
],
})
}
let cursor: Cursor = match opts.cursor.clone().try_into() {
Ok(r) => r,
Err(e) => {
error!("invalid cursor: {}", e);
return HttpResponse::BadRequest().json(APIErrorsResponse {
errors: vec![
APIError {
code: "ERR_INVALID_CURSOR".to_string(),
message: "The provided cursor was invalid, please try again later.".to_string(),
path: None
}
],
})
}
};
let host_pages = host::Entity::find().filter(host::Column::Network.eq(net_id)).order_by_asc(host::Column::CreatedAt).paginate(&db.conn, opts.page_size);
let total = match host_pages.num_items().await {
Ok(r) => r,
Err(e) => {
error!("database error: {}", e);
return HttpResponse::InternalServerError().json(APIErrorsResponse {
errors: vec![
APIError {
code: "ERR_DB_ERROR".to_string(),
message: "There was an error performing the database request, please try again later.".to_string(),
path: None,
}
],
});
}
};
let pages = match host_pages.num_pages().await {
Ok(r) => r,
Err(e) => {
error!("database error: {}", e);
return HttpResponse::InternalServerError().json(APIErrorsResponse {
errors: vec![
APIError {
code: "ERR_DB_ERROR".to_string(),
message: "There was an error performing the database request, please try again later.".to_string(),
path: None,
}
],
});
}
};
let models = match host_pages.fetch_page(cursor.page).await {
Ok(r) => r,
Err(e) => {
error!("database error: {}", e);
return HttpResponse::InternalServerError().json(APIErrorsResponse {
errors: vec![
APIError {
code: "ERR_DB_ERROR".to_string(),
message: "There was an error performing the database request, please try again later.".to_string(),
path: None,
}
],
});
}
};
let mut models_mapped: Vec<HostResponse> = vec![];
for u in models {
// fetch static addresses
let ips = match host_static_address::Entity::find().filter(host_static_address::Column::Host.eq(&u.id)).all(&db.conn).await {
Ok(r) => r,
Err(e) => {
error!("database error: {}", e);
return HttpResponse::InternalServerError().json(APIErrorsResponse {
errors: vec![
APIError {
code: "ERR_DB_ERROR".to_string(),
message: "There was an error performing the database request, please try again later.".to_string(),
path: None,
}
],
});
}
};
models_mapped.push(HostResponse {
id: u.id,
organization_id: org_id.clone(),
network_id: u.network,
role_id: u.role,
name: u.name,
ip_address: u.ip,
static_addresses: ips.iter().map(|u| SocketAddrV4::from_str(&u.address).unwrap()).collect(),
listen_port: u.listen_port as u16,
is_lighthouse: false,
is_relay: false,
created_at: "".to_string(),
is_blocked: false,
metadata: HostResponseMetadata {
last_seen_at: None,
version: "".to_string(),
platform: "".to_string(),
update_available: false,
},
})
}
let count = models_mapped.len() as u64;
HttpResponse::Ok().json(ListHostsResponse {
data: models_mapped,
metadata: ListHostsResponseMetadata {
total_count: total,
has_next_page: cursor.page+1 != pages,
has_prev_page: cursor.page != 0,
prev_cursor: if cursor.page != 0 {
match (Cursor { page: cursor.page - 1 }).try_into() {
Ok(r) => Some(r),
Err(_) => None
}
} else {
None
},
next_cursor: if cursor.page+1 != pages {
match (Cursor { page: cursor.page + 1 }).try_into() {
Ok(r) => Some(r),
Err(_) => None
}
} else {
None
},
page: if opts.include_counts {
Some(ListHostsResponseMetadataPage {
count,
start: opts.page_size * cursor.page,
})
} else { None },
},
})
}

View File

@ -5,4 +5,5 @@ pub mod verify_totp_authenticators;
pub mod networks; pub mod networks;
pub mod organization; pub mod organization;
pub mod roles; pub mod roles;
pub mod trifid; pub mod trifid;
pub mod hosts;

View File

@ -15,6 +15,7 @@ pub struct Model {
pub is_lighthouse: bool, pub is_lighthouse: bool,
pub is_relay: bool, pub is_relay: bool,
pub counter: i32, pub counter: i32,
pub created_at: i64,
} }
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]

View File

@ -20,6 +20,7 @@ impl MigrationTrait for Migration {
.col(ColumnDef::new(Host::IsLighthouse).boolean().not_null()) .col(ColumnDef::new(Host::IsLighthouse).boolean().not_null())
.col(ColumnDef::new(Host::IsRelay).boolean().not_null()) .col(ColumnDef::new(Host::IsRelay).boolean().not_null())
.col(ColumnDef::new(Host::Counter).unsigned().not_null()) .col(ColumnDef::new(Host::Counter).unsigned().not_null())
.col(ColumnDef::new(Host::CreatedAt).big_integer().not_null())
.foreign_key( .foreign_key(
ForeignKey::create() ForeignKey::create()
.from(Host::Table, Host::Network) .from(Host::Table, Host::Network)
@ -65,5 +66,6 @@ pub enum Host {
ListenPort, ListenPort,
IsLighthouse, IsLighthouse,
IsRelay, IsRelay,
Counter Counter,
CreatedAt
} }