diff --git a/Cargo.lock b/Cargo.lock index 1ede611..63782ef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1022,6 +1022,9 @@ name = "ipnet" version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "30e22bd8629359895450b59ea7a776c850561b96a3b1d31321c1949d9e6c9146" +dependencies = [ + "serde", +] [[package]] name = "itertools" @@ -2403,6 +2406,7 @@ dependencies = [ "chrono", "dotenvy", "hex", + "ipnet", "log", "paste", "rand", diff --git a/tfweb/src/components/org/OrgWrapper.svelte b/tfweb/src/components/org/OrgWrapper.svelte new file mode 100644 index 0000000..36ff390 --- /dev/null +++ b/tfweb/src/components/org/OrgWrapper.svelte @@ -0,0 +1,44 @@ + + +
+
+ +
+ +
+ +
+
\ No newline at end of file diff --git a/tfweb/src/lib/i18n/en.json b/tfweb/src/lib/i18n/en.json index 74cda5c..4dd8574 100644 --- a/tfweb/src/lib/i18n/en.json +++ b/tfweb/src/lib/i18n/en.json @@ -92,6 +92,62 @@ "subnetprompt": "What subnets would you like to allow?", "subnethelp": "Comma-separated list of subnets in CIDR notation. This will constrain which subnets can be applied to client certs. Default: empty (any)", "groupprompt": "What groups would you like to allow?", - "grouphelp": "Comma-separated list of groups. This will constrain which groups can be applied to client certs. Default: empty (any)" + "grouphelp": "Comma-separated list of groups. This will constrain which groups can be applied to client certs. Default: empty (any)", + "apierror": { + "orgcreate": "Unable to create organization", + "cacreate": "Unable to create signing CA", + "TypeError: NetworkError when attempting to fetch resource": "Unable to contact the backend. Please try again later.", + "invaliddata": "The server was unable to parse your request. Reload the page and try again. Ensure all fields are correct." + } + }, + + "admin": { + "apierror": { + "loadownedorg": "Unable to load organizations", + "this user does not have permission to access this org": "You do not have permission to access one of the orgs associated with your account. Your access may have been revoked. Please reload the page." + } + }, + + "org": { + "apierror": { + "notyourorg": "Access denied", + "notyourorgexplainer": "You do not have permission to manage this org. Ask the owner of this org to add you, or go back to the homepage.", + "loadroles": "Unable to load roles", + "TypeError: NetworkError when attempting to fetch resource": "Unable to contact the server. Please try again later", + "Unable to create default role firewall rules": "Unable to create the default role. Please try again later." + }, + + "nohosts": "You don't have any hosts set up", + "nohostsdesc": "Create and enroll a host to begin using it in your network.", + "nohostsdesc2": "Be sure to also create and enroll a lighthouse, or your hosts wont be able to communicate.", + "nohosts_cta": "Add host", + + "nolighthouses": "You don't have any lighthouses set up", + "nolighthousesdesc": "Lighthouses keep track of potential routes to overlay network hosts, and they assist in establishing connections between hosts.", + "nolighthouses_list_item1": "Each network needs at least one lighthouse.", + "nolighthouses_list_item2": "If you plan to access hosts over the Internet, at least one lighthouse will need a static, public IPv4 address with its firewall configured to allow inbound udp traffic on a specific port.", + "nolighthouses_list_item3": "A modestly-sized cloud instance should be sufficient for most users.", + "nolighthouses_list_item4": "Lighthouses are also hosts - they will have an IP and you will be able to access them over the network.", + "nolighthouses_cta": "Add lighthouse", + + "norelays": "Adding a relay is recommended", + "norelays_list_item1": "Relays ensure connectivity for scenarios when direct connections fail.", + "norelays_list_item2": "Without a relay, some devices may be unable to communicate.", + "norelays_list_item3": "Your lighthouses also act as relays.", + "norelays_lighthouse": "You currently have {} lighthouses acting as relays", + "norelays_cta": "Add relay", + + "roles_title": "Roles", + "roles_bar": "Roles control how hosts, lighthouses, and relays communicate through firewall rules.", + "roles_cta": "Add", + + "guide": "Read the guide →", + + "noroles": "Wow, such empty", + "noroles_cta": "You don't have any roles. Consider creating one with the button above.", + + "roleaddprompt": "What's the name of your new role?", + "roleaddpromptdesc": "What's the description of your new role?", + "roleadd_cta": "Create role" } } \ No newline at end of file diff --git a/tfweb/src/lib/util.ts b/tfweb/src/lib/util.ts index 2a17aba..b78c055 100644 --- a/tfweb/src/lib/util.ts +++ b/tfweb/src/lib/util.ts @@ -1,4 +1,4 @@ -export async function fetch_timeout(resource: RequestInfo | URL, options = {}) { +export async function fetch_timeout(resource: RequestInfo | URL, options: RequestInit | undefined = {}) { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore const { timeout = 8000 } = options; diff --git a/tfweb/src/routes/auth/mfasetup/+page.svelte b/tfweb/src/routes/auth/mfasetup/+page.svelte index 070f83a..90f183a 100644 --- a/tfweb/src/routes/auth/mfasetup/+page.svelte +++ b/tfweb/src/routes/auth/mfasetup/+page.svelte @@ -11,7 +11,7 @@ import {finishTOTPSetup} from "$lib/totp"; import {setCookie} from "$lib/cookie"; - let logger = new Logger("auth/mfa/+page.svelte"); + let logger = new Logger("auth/mfasetup/+page.svelte"); logSetup(); let api_token = ""; diff --git a/tfweb/src/routes/org/[orgid]/+page.svelte b/tfweb/src/routes/org/[orgid]/+page.svelte new file mode 100644 index 0000000..a975396 --- /dev/null +++ b/tfweb/src/routes/org/[orgid]/+page.svelte @@ -0,0 +1,86 @@ + + +{#if fullPageError} + +{:else} + + + + + +{/if} \ No newline at end of file diff --git a/tfweb/src/routes/org/[orgid]/hosts/+page.svelte b/tfweb/src/routes/org/[orgid]/hosts/+page.svelte new file mode 100644 index 0000000..7604ce0 --- /dev/null +++ b/tfweb/src/routes/org/[orgid]/hosts/+page.svelte @@ -0,0 +1,86 @@ + + +{#if fullPageError} + +{:else} + + + + + + +{/if} \ No newline at end of file diff --git a/tfweb/src/routes/org/[orgid]/lighthouses/+page.svelte b/tfweb/src/routes/org/[orgid]/lighthouses/+page.svelte new file mode 100644 index 0000000..238ac4c --- /dev/null +++ b/tfweb/src/routes/org/[orgid]/lighthouses/+page.svelte @@ -0,0 +1,86 @@ + + +{#if fullPageError} + +{:else} + + + + + + +{/if} \ No newline at end of file diff --git a/tfweb/src/routes/org/[orgid]/relays/+page.svelte b/tfweb/src/routes/org/[orgid]/relays/+page.svelte new file mode 100644 index 0000000..abcc5b6 --- /dev/null +++ b/tfweb/src/routes/org/[orgid]/relays/+page.svelte @@ -0,0 +1,86 @@ + + +{#if fullPageError} + +{:else} + + + + + + +{/if} \ No newline at end of file diff --git a/tfweb/src/routes/org/[orgid]/roles/+page.svelte b/tfweb/src/routes/org/[orgid]/roles/+page.svelte new file mode 100644 index 0000000..2c12083 --- /dev/null +++ b/tfweb/src/routes/org/[orgid]/roles/+page.svelte @@ -0,0 +1,169 @@ + + +{#if fullPageError} + +{:else} + + + + +
+ +

{t('org.roles_title')}

+ + + +
+ {t('org.roles_bar')} + {t('org.guide')} +
+ + + + {#if roles.data.length === 0} +
+

{t('org.noroles')}

+

{t('org.noroles_cta')}

+
+ {:else} +
+ + + + + + + + + + {#each roles.data as role} + + + + + + + + {/each} +
+ Name + ID + + Rules + + Description +
{role.name}{role.id}{role.firewall_rules.length}{role.description} + +
+
+ {/if} +
+
+{/if} \ No newline at end of file diff --git a/tfweb/src/routes/org/[orgid]/roles/add/+page.svelte b/tfweb/src/routes/org/[orgid]/roles/add/+page.svelte new file mode 100644 index 0000000..6495d08 --- /dev/null +++ b/tfweb/src/routes/org/[orgid]/roles/add/+page.svelte @@ -0,0 +1,144 @@ + + +{#if fullPageError} + +{:else} +
+
+ + +
+
+ + +
+ +
+ + +
+ + + +
+
+
+{/if} \ No newline at end of file diff --git a/tfweb/src/routes/org/new/+page.svelte b/tfweb/src/routes/org/new/+page.svelte index a3bf266..d9690d9 100644 --- a/tfweb/src/routes/org/new/+page.svelte +++ b/tfweb/src/routes/org/new/+page.svelte @@ -8,8 +8,10 @@ import {get_user_info} from "$lib/auth"; import {org} from "$lib/orgs"; import type {Organization} from "$lib/orgs"; + import {API_ROOT} from "$lib/config"; + import {fetch_timeout} from "$lib/util"; - let logger = new Logger("admin/+page.svelte"); + let logger = new Logger("org/new/+page.svelte"); logSetup(); let api_token = ""; @@ -73,8 +75,65 @@ let showAdditionalConstraints = false; - function newOrg() { + function handle(listfield: string): string[] { + if (listfield == "") { + return [] + } else { + return listfield.split(',') + } + } + async function doCreateFlow() { + // STEP ONE: Create the org + logger.info("Creating organization"); + let created_org_id; + + + + try { + let resp = await fetch_timeout(`${API_ROOT}/v1/org`, { + 'method': 'POST', + 'headers': { + 'Authorization': 'Bearer ' + api_token + }, + 'body': JSON.stringify({ + 'ip_ranges': handle(org_ip_range), + 'subnet_ranges': handle(org_subnets), + 'groups': handle(org_groups) + }) + }); + if (!resp.ok) { + if (resp.status === 422) { + // we had an invalid input + logger.error(`Invalid input`); + fullPageError = true; + fullPageErrorTitle = t('neworg.apierror.orgcreate'); + fullPageErrorSubtitle = t('neworg.apierror.invaliddata'); + return; + } + let text = await resp.text(); + console.log(text); + let err = JSON.parse(text).errors[0].message; + logger.error(`${err}`); + fullPageError = true; + fullPageErrorTitle = t('neworg.apierror.orgcreate'); + fullPageErrorSubtitle = t('neworg.apierror.' + err); + return; + } + let text = await resp.text(); + console.log(text); + let resp_objectified = JSON.parse(text); + created_org_id = resp_objectified.org_id; + logger.info("Able to create organization with id " + created_org_id); + logger.info("Org create success! Redirecting to manage page"); + window.location.href = "/org/" + created_org_id; + } catch (e) { + logger.error(`${e}`); + fullPageError = true; + fullPageErrorTitle = t('neworg.apierror.orgcreate'); + fullPageErrorSubtitle = t('neworg.apierror.' + `${e}`.replaceAll('.', '')); + return; + } } @@ -90,7 +149,7 @@

{t('neworg.cahelp')}

-
+
. + +CREATE TABLE roles ( + id SERIAL NOT NULL PRIMARY KEY, + org SERIAL NOT NULL REFERENCES organizations(id), + name VARCHAR(128) NOT NULL, + description VARCHAR(4096) NOT NULL +); \ No newline at end of file diff --git a/trifid-api/migrations/20230302220808_add_firewall_rules.sql b/trifid-api/migrations/20230302220808_add_firewall_rules.sql new file mode 100644 index 0000000..69961af --- /dev/null +++ b/trifid-api/migrations/20230302220808_add_firewall_rules.sql @@ -0,0 +1,27 @@ +-- 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 . + +CREATE TABLE roles_firewall_rules ( + id SERIAL NOT NULL PRIMARY KEY, + role SERIAL NOT NULL REFERENCES roles(id), + protocol INTEGER NOT NULL, -- 0: any 1: tcp 2: udp 3: icmp + port_range_start INTEGER NOT NULL, -- min: 1 max: 65535. Ignored if protocol==3 + port_range_end INTEGER NOT NULL, -- min: 1 max: 65535, must be greater than or equal to port_range_start. Ignored if protocol==3 + allow_from INTEGER NOT NULL, -- Allow traffic goverened by above rules from who? + -- -1: anybody + -- (a role, anything else): only that role + description VARCHAR(4096) NOT NULL +); \ No newline at end of file diff --git a/trifid-api/src/config.rs b/trifid-api/src/config.rs index 0697317..954a4f7 100644 --- a/trifid-api/src/config.rs +++ b/trifid-api/src/config.rs @@ -23,8 +23,9 @@ pub struct TFConfig { pub db_url: String, pub base: Url, pub web_root: Url, - pub magic_links_valid_for: i64, - pub session_tokens_valid_for: i64, - pub totp_verification_valid_for: i64, - pub data_key: String + pub magic_links_valid_for: u64, + pub session_tokens_valid_for: u64, + pub totp_verification_valid_for: u64, + pub data_key: String, + pub ca_certs_valid_for: u64 } \ No newline at end of file diff --git a/trifid-api/src/main.rs b/trifid-api/src/main.rs index 92a9ca5..847c3b1 100644 --- a/trifid-api/src/main.rs +++ b/trifid-api/src/main.rs @@ -41,6 +41,7 @@ pub mod auth; pub mod crypto; pub mod org; pub mod kv; +pub mod role; static MIGRATOR: Migrator = sqlx::migrate!(); @@ -180,7 +181,14 @@ async fn main() -> Result<(), Box> { crate::routes::v1::organization::orglist_req, crate::routes::v1::organization::create_org, crate::routes::v1::user::get_user, - crate::routes::v1::user::options + crate::routes::v1::user::options, + crate::routes::v1::organization::createorgoptions, + crate::routes::v1::ca::get_cas_for_org, + crate::routes::v1::ca::options, + crate::routes::v1::roles::get_roles, + crate::routes::v1::roles::options, + crate::routes::v1::roles::options_roleadd, + crate::routes::v1::roles::role_add ]) .register("/", catchers![ crate::routes::handler_400, diff --git a/trifid-api/src/org.rs b/trifid-api/src/org.rs index 83c754e..501b14a 100644 --- a/trifid-api/src/org.rs +++ b/trifid-api/src/org.rs @@ -15,11 +15,13 @@ // along with this program. If not, see . use std::error::Error; + use rocket::form::validate::Contains; use sqlx::PgPool; use trifid_pki::ca::NebulaCAPool; pub async fn get_org_by_owner_id(user: i32, db: &PgPool) -> Result, Box> { + Ok(sqlx::query!("SELECT id FROM organizations WHERE owner = $1", user).fetch_optional(db).await?.map(|r| r.id)) } @@ -60,7 +62,9 @@ pub async fn get_associated_orgs(user: i32, db: &PgPool) -> Result, Box } pub async fn user_has_org_assoc(user: i32, org: i32, db: &PgPool) -> Result> { - Ok(get_associated_orgs(user, db).await?.contains(org)) + let associated_orgs = get_associated_orgs(user, db).await?; + + Ok(associated_orgs.contains(org)) } pub async fn get_org_ca_pool(org: i32, db: &PgPool) -> Result> { diff --git a/trifid-api/src/role.rs b/trifid-api/src/role.rs new file mode 100644 index 0000000..5660964 --- /dev/null +++ b/trifid-api/src/role.rs @@ -0,0 +1,98 @@ +// 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 . + +use std::error::Error; +use sqlx::PgPool; +use serde::{Serialize, Deserialize}; + +#[derive(Serialize, Deserialize)] +#[repr(i32)] +pub enum Protocol { + Any = 0, + TCP = 1, + UDP = 2, + ICMP = 3 +} + +#[derive(Serialize, Deserialize)] +pub enum AllowFrom { + Anyone, + SpecificRole(i32) +} + +#[derive(Serialize, Deserialize)] +pub struct FirewallRule { + pub id: i32, + pub protocol: Protocol, + pub port_start: u16, + pub port_end: u16, + pub allow_from: AllowFrom, + pub description: String +} + +#[derive(Serialize, Deserialize)] +pub struct Role { + pub id: i32, + pub org_id: i32, + pub name: String, + pub description: String, + pub firewall_rules: Vec +} + +pub async fn get_role(role_id: i32, db: &PgPool) -> Result, Box> { + let query_result: Option<_> = sqlx::query!("SELECT * FROM roles WHERE id = $1", role_id).fetch_optional(db).await?; + + if let Some(res) = query_result { + // get all firewall rules + let rules_res = sqlx::query!("SELECT * FROM roles_firewall_rules WHERE role = $1", Some(role_id)).fetch_all(db).await?; + + let mut rules = vec![]; + + for rule in rules_res { + rules.push(FirewallRule { + id: rule.id, + protocol: match rule.protocol { + 0 => Protocol::Any, + 1 => Protocol::TCP, + 2 => Protocol::UDP, + 3 => Protocol::ICMP, + _ => return Err(format!("invalid protocol on a firewall rule {}", rule.id).into()) + }, + port_start: rule.port_range_start as u16, + port_end: rule.port_range_end as u16, + allow_from: match rule.allow_from { + -1 => AllowFrom::Anyone, + _ => AllowFrom::SpecificRole(rule.allow_from) + }, + description: rule.description, + }) + } + + Ok(Some(Role { + id: role_id, + org_id: res.org, + name: res.name, + description: res.description, + firewall_rules: rules, + })) + } else { + Ok(None) + } +} + +pub async fn get_role_ids_for_ca(org_id: i32, db: &PgPool) -> Result, Box> { + Ok(sqlx::query!("SELECT id FROM roles WHERE org = $1", org_id).fetch_all(db).await?.iter().map(|r| r.id).collect()) +} \ No newline at end of file diff --git a/trifid-api/src/routes/v1/ca.rs b/trifid-api/src/routes/v1/ca.rs new file mode 100644 index 0000000..fe7b442 --- /dev/null +++ b/trifid-api/src/routes/v1/ca.rs @@ -0,0 +1,85 @@ +// 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 . + + +use rocket::{options, get, State}; +use rocket::http::{ContentType, Status}; +use rocket::serde::json::Json; +use sqlx::PgPool; + +use serde::{Serialize, Deserialize}; +use crate::auth::TOTPAuthenticatedUserInfo; +use crate::org::{get_associated_orgs, get_org_ca_pool}; + +#[options("/v1/org/<_id>/ca")] +pub fn options(_id: i32) -> &'static str { + "" +} + +#[derive(Serialize, Deserialize)] +#[serde(crate = "rocket::serde")] +pub struct CaList { + pub trusted_cas: Vec, + pub blocklisted_certs: Vec +} + +#[derive(Serialize, Deserialize)] +#[serde(crate = "rocket::serde")] +pub struct CA { + pub fingerprint: String, + pub cert: String +} + +#[get("/v1/org//ca")] +pub async fn get_cas_for_org(id: i32, user: TOTPAuthenticatedUserInfo, db: &State) -> Result<(ContentType, Json), (Status, String)> { + let associated_orgs = match get_associated_orgs(user.user_id, db.inner()).await { + Ok(r) => r, + Err(e) => return Err((Status::InternalServerError, format!("{{\"errors\":[{{\"code\":\"{}\",\"message\":\"{} - {}\"}}]}}", "ERR_DB_QUERY_FAILED", "an error occurred while running the database query", e))) + }; + + if !associated_orgs.contains(&id) { + return Err((Status::Unauthorized, format!("{{\"errors\":[{{\"code\":\"{}\",\"message\":\"{}\"}}]}}", "ERR_NOT_YOUR_ORG", "you are not authorized to view details of this org"))) + } + + let ca_pool = match get_org_ca_pool(id, db.inner()).await { + Ok(pool) => pool, + Err(e) => { + return Err((Status::InternalServerError, format!("{{\"errors\":[{{\"code\":\"ERR_QL_QUERY_FAILED\",\"message\":\"unable to load certificates from database - {}\"}}]}}", e))); + } + }; + + let mut trusted_cas = vec![]; + + for (fingerprint, cert) in ca_pool.cas { + trusted_cas.push(CA { + fingerprint, + cert: match cert.serialize_to_pem() { + Ok(pem) => match String::from_utf8(pem) { + Ok(str) => str, + Err(e) => return Err((Status::InternalServerError, format!("{{\"errors\":[{{\"code\":\"ERR_QL_QUERY_FAILED\",\"message\":\"unable to encode one of the serialized certificates - {}\"}}]}}", e))) + }, + Err(e) => { + return Err((Status::InternalServerError, format!("{{\"errors\":[{{\"code\":\"ERR_QL_QUERY_FAILED\",\"message\":\"unable to serialize one of the certificates - {}\"}}]}}", e))); + } + } + }) + } + + Ok((ContentType::JSON, Json(CaList { + trusted_cas, + blocklisted_certs: ca_pool.cert_blocklist, + }))) +} \ No newline at end of file diff --git a/trifid-api/src/routes/v1/mod.rs b/trifid-api/src/routes/v1/mod.rs index cae8fbd..7c0037d 100644 --- a/trifid-api/src/routes/v1/mod.rs +++ b/trifid-api/src/routes/v1/mod.rs @@ -20,4 +20,6 @@ pub mod signup; pub mod totp_authenticators; pub mod verify_totp_authenticator; pub mod organization; -pub mod user; \ No newline at end of file +pub mod user; +pub mod ca; +pub mod roles; \ No newline at end of file diff --git a/trifid-api/src/routes/v1/organization.rs b/trifid-api/src/routes/v1/organization.rs index 013c80a..2e2519b 100644 --- a/trifid-api/src/routes/v1/organization.rs +++ b/trifid-api/src/routes/v1/organization.rs @@ -14,11 +14,16 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . -use rocket::{get, post, options, State}; +use std::time::{Duration, SystemTime}; +use ipnet::Ipv4Net; +use rocket::{get, post, options, State, error}; use rocket::http::{ContentType, Status}; use rocket::serde::json::Json; use serde::{Serialize, Deserialize}; use sqlx::PgPool; +use trifid_pki::cert::{NebulaCertificate, NebulaCertificateDetails, serialize_ed25519_private}; +use trifid_pki::ed25519_dalek::{SigningKey}; +use trifid_pki::rand_core::OsRng; use crate::auth::TOTPAuthenticatedUserInfo; use crate::config::TFConfig; use crate::crypto::{encrypt_with_nonce, generate_random_iv, get_cipher_from_config}; @@ -32,6 +37,8 @@ pub fn options() -> &'static str { pub fn orgidoptions(_id: i32) -> &'static str { "" } +#[options("/v1/org")] +pub fn createorgoptions() -> &'static str {""} #[derive(Serialize, Deserialize)] #[serde(crate = "rocket::serde")] @@ -64,7 +71,7 @@ pub struct OrginfoStruct { #[get("/v1/org/")] pub async fn orginfo_req(orgid: i32, user: TOTPAuthenticatedUserInfo, db: &State) -> Result<(ContentType, Json), (Status, String)> { - if !user_has_org_assoc(orgid, user.user_id, db.inner()).await.map_err(|e| (Status::InternalServerError, format!("{{\"errors\":[{{\"code\":\"{}\",\"message\":\"{} - {}\"}}]}}", "ERR_QL_QUERY_FAILED", "an error occurred while running the graphql query", e)))? { + if !user_has_org_assoc(user.user_id, orgid, db.inner()).await.map_err(|e| (Status::InternalServerError, format!("{{\"errors\":[{{\"code\":\"{}\",\"message\":\"{} - {}\"}}]}}", "ERR_QL_QUERY_FAILED", "an error occurred while running the graphql query", e)))? { return Err((Status::Unauthorized, format!("{{\"errors\":[{{\"code\":\"{}\",\"message\":\"{}\"}}]}}", "ERR_MISSING_ORG_AUTHORIZATION", "this user does not have permission to access this org"))); } @@ -81,12 +88,59 @@ pub async fn orginfo_req(orgid: i32, user: TOTPAuthenticatedUserInfo, db: &State ))) } -#[post("/v1/org")] -pub async fn create_org(user: TOTPAuthenticatedUserInfo, db: &State, config: &State) -> Result<(ContentType, Json), (Status, String)> { +#[derive(Serialize, Deserialize)] +#[serde(crate = "rocket::serde")] +pub struct CreateCARequest { + pub ip_ranges: Vec, + pub subnet_ranges: Vec, + pub groups: Vec +} + +#[post("/v1/org", data = "")] +pub async fn create_org(req: Json, user: TOTPAuthenticatedUserInfo, db: &State, config: &State) -> Result<(ContentType, Json), (Status, String)> { if get_org_by_owner_id(user.user_id, db.inner()).await.map_err(|e| (Status::InternalServerError, format!("{{\"errors\":[{{\"code\":\"{}\",\"message\":\"{} - {}\"}}]}}", "ERR_QL_QUERY_FAILED", "an error occurred while running the graphql query", e)))?.is_some() { return Err((Status::Conflict, format!("{{\"errors\":[{{\"code\":\"{}\",\"message\":\"{}\"}}]}}", "ERR_USER_OWNS_ORG", "a user can only own one organization at a time"))) } + // Generate the CA keypair + let private_key = SigningKey::generate(&mut OsRng); + let public_key = private_key.verifying_key(); + + // Create the CA certificate + let mut ca_cert = NebulaCertificate { + details: NebulaCertificateDetails { + name: format!("{}'s Organization - Root Signing CA", user.email), + ips: req.ip_ranges.clone(), + subnets: req.subnet_ranges.clone(), + groups: req.groups.clone(), + not_before: SystemTime::now(), + not_after: SystemTime::now() + Duration::from_secs(config.ca_certs_valid_for), + public_key: public_key.to_bytes(), + is_ca: true, + issuer: "".to_string(), // This is a self-signed certificate! There is no issuer present + }, + signature: vec![], + }; + // Self-sign the CA certificate + match ca_cert.sign(&private_key) { + Ok(_) => (), + Err(e) => { + error!("[tfapi] security: certificate signature error: {}", e); + return Err((Status::InternalServerError, format!("{{\"errors\":[{{\"code\":\"{}\",\"message\":\"{}\"}}]}}", "ERR_CERT_SIGN_ERROR", "there was an error generating the CA certificate, please try again later"))) + } + } + + // PEM-encode the CA key + let ca_key_pem = serialize_ed25519_private(&private_key.to_keypair_bytes()); + // PEM-encode the CA cert + let ca_cert_pem = match ca_cert.serialize_to_pem() { + Ok(pem) => pem, + Err(e) => { + error!("[tfapi] security: certificate encoding error: {}", e); + return Err((Status::InternalServerError, format!("{{\"errors\":[{{\"code\":\"{}\",\"message\":\"{}\"}}]}}", "ERR_CERT_ENCODE_ERROR", "there was an error encoding the CA certificate, please try again later"))) + } + }; + // generate an AES iv to use for key encryption let iv = generate_random_iv(); let iv_hex = hex::encode(iv); @@ -98,16 +152,33 @@ pub async fn create_org(user: TOTPAuthenticatedUserInfo, db: &State, con return Err((Status::InternalServerError, format!("{{\"errors\":[{{\"code\":\"{}\",\"message\":\"{} - {}\"}}]}}", "ERR_CRYPTOGRAPHY_CREATE_CIPHER", "Unable to build cipher construct, please try again later", e))); } }; - let ca_key = match encrypt_with_nonce(b"", iv, &cipher) { + let ca_key = match encrypt_with_nonce(&ca_key_pem, iv, &cipher) { Ok(key) => hex::encode(key), Err(e) => { return Err((Status::InternalServerError, format!("{{\"errors\":[{{\"code\":\"{}\",\"message\":\"{} - {}\"}}]}}", "ERR_CRYPTOGRAPHY_ENCRYPT_KEY", "Unable to build cipher construct, please try again later", e))); } }; - let ca_crt = hex::encode(b""); + let ca_crt = hex::encode(ca_cert_pem); let result = sqlx::query!("INSERT INTO organizations (owner, ca_key, ca_crt, iv) VALUES ($1, $2, $3, $4) RETURNING id", owner_id, ca_key, ca_crt, iv_hex).fetch_one(db.inner()).await.map_err(|e| (Status::InternalServerError, format!("{{\"errors\":[{{\"code\":\"{}\",\"message\":\"{} - {}\"}}]}}", "ERR_QL_QUERY_FAILED", "an error occurred while running the graphql query", e)))?; + // last step: create a default role to allow pings from all hosts + let role_id = match sqlx::query!("INSERT INTO roles (org, name, description) VALUES ($1, 'Default', 'Allow pings from other hosts. Default role for new hosts.') RETURNING id", result.id).fetch_one(db.inner()).await { + Ok(r) => r.id, + Err(e) => { + error!("[tfapi] dberror: {}", e); + return Err((Status::InternalServerError, format!("{{\"errors\":[{{\"code\":\"{}\",\"message\":\"{}\"}}]}}", "ERR_INTERNAL_SERVER_ERROR", "Unable to create default role"))); + } + }; + + match sqlx::query!("INSERT INTO roles_firewall_rules (role, protocol, port_range_start, port_range_end, allow_from, description) VALUES ($1, 3, 1, 1, -1, 'Allow pings from anyone on the network')", role_id).execute(db.inner()).await { + Ok(_) => {}, + Err(e) => { + error!("[tfapi] dberror: {} inserting on roleid {}", e, role_id); + return Err((Status::InternalServerError, format!("{{\"errors\":[{{\"code\":\"{}\",\"message\":\"{}\"}}]}}", "ERR_INTERNAL_SERVER_ERROR", "Unable to create default role firewall rules"))); + } + } + Ok((ContentType::JSON, Json( OrginfoStruct { org_id: result.id, diff --git a/trifid-api/src/routes/v1/roles.rs b/trifid-api/src/routes/v1/roles.rs new file mode 100644 index 0000000..538f65a --- /dev/null +++ b/trifid-api/src/routes/v1/roles.rs @@ -0,0 +1,95 @@ +// 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 . + +use log::error; +use rocket::{options, get, State, post}; +use rocket::http::{ContentType, Status}; +use rocket::serde::json::Json; + +use serde::{Serialize, Deserialize}; +use sqlx::PgPool; +use crate::auth::TOTPAuthenticatedUserInfo; +use crate::org::user_has_org_assoc; +use crate::role::{get_role, get_role_ids_for_ca, Role}; + +#[options("/v1/org/<_org>/roles")] +pub fn options(_org: i32) -> &'static str { "" } +#[options("/v1/org/<_org>/role")] +pub fn options_roleadd(_org: i32) -> &'static str { "" } + +#[derive(Serialize, Deserialize)] +#[serde(crate = "rocket::serde")] +pub struct RolesResponse { + pub data: Vec +} + +#[get("/v1/org//roles")] +pub async fn get_roles(org: i32, user: TOTPAuthenticatedUserInfo, db: &State) -> Result<(ContentType, Json), (Status, String)> { + if !user_has_org_assoc(user.user_id, org, db.inner()).await.map_err(|e| (Status::InternalServerError, format!("{{\"errors\":[{{\"code\":\"{}\",\"message\":\"{} - {}\"}}]}}", "ERR_QL_QUERY_FAILED", "an error occurred while running the graphql query", e)))? { + return Err((Status::Unauthorized, format!("{{\"errors\":[{{\"code\":\"{}\",\"message\":\"{}\"}}]}}", "ERR_MISSING_ORG_AUTHORIZATION", "this user does not have permission to access this org"))); + } + + let roles = match get_role_ids_for_ca(org, db.inner()).await { + Ok(r) => r, + Err(e) => { + return Err((Status::InternalServerError, format!("{{\"errors\":[{{\"code\":\"{}\",\"message\":\"{} - {}\"}}]}}", "ERR_INTERNAL_SERVER_ERR", "there was an error querying the db, please try again later", e))) + } + }; + + let mut resp = RolesResponse { + data: vec![] + }; + + for role in roles { + let role_info = match get_role(role, db.inner()).await { + Ok(r) => r, + Err(e) => { + return Err((Status::InternalServerError, format!("{{\"errors\":[{{\"code\":\"{}\",\"message\":\"{} - {}\"}}]}}", "ERR_INTERNAL_SERVER_ERR", "there was an error querying the db, please try again later", e))) + } + }; + if let Some(info) = role_info { + resp.data.push(info); + } else { + return Err((Status::InternalServerError, format!("{{\"errors\":[{{\"code\":\"{}\",\"message\":\"{} - {}\"}}]}}", "ERR_INTERNAL_SERVER_ERR", "there was an error querying the db, please try again later", "missing role as returned by server - possibly deleted inbetween?"))) + } + } + + Ok((ContentType::JSON, Json(resp))) +} + +#[derive(Serialize, Deserialize)] +#[serde(crate = "rocket::serde")] +pub struct RoleaddReq { + pub name: String, + pub description: String +} + +#[post("/v1/org//role", data = "")] +pub async fn role_add(req: Json, org: i32, user: TOTPAuthenticatedUserInfo, db: &State) -> Result<(ContentType, String), (Status, String)> { + if !user_has_org_assoc(user.user_id, org, db.inner()).await.map_err(|e| (Status::InternalServerError, format!("{{\"errors\":[{{\"code\":\"{}\",\"message\":\"{} - {}\"}}]}}", "ERR_QL_QUERY_FAILED", "an error occurred while running the graphql query", e)))? { + return Err((Status::Unauthorized, format!("{{\"errors\":[{{\"code\":\"{}\",\"message\":\"{}\"}}]}}", "ERR_MISSING_ORG_AUTHORIZATION", "this user does not have permission to access this org"))); + } + + match sqlx::query!("INSERT INTO roles (org, name, description) VALUES ($1, $2, $3)", org, req.name, req.description).execute(db.inner()).await { + Ok(_) => (), + Err(e) => { + error!("[tfapi] dberror: {}", e); + return Err((Status::InternalServerError, format!("{{\"errors\":[{{\"code\":\"{}\",\"message\":\"{} - {}\"}}]}}", "ERR_INTERNAL_SERVER_ERR", "database returned error while trying to create role", "unable to insert role"))) + } + } + + Ok((ContentType::JSON, "{}".to_string())) +} \ No newline at end of file diff --git a/trifid-api/src/tokens.rs b/trifid-api/src/tokens.rs index a9b0d8e..8590cdf 100644 --- a/trifid-api/src/tokens.rs +++ b/trifid-api/src/tokens.rs @@ -74,7 +74,7 @@ pub async fn create_totp_token(email: String, db: &PgPool, config: &TFConfig) -> let otpid = format!("totp-{}", Uuid::new_v4()); - sqlx::query!("INSERT INTO totp_create_tokens (id, expires_on, totp_otpurl, totp_secret) VALUES ($1, $2, $3, $4);", otpid.clone(), (SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() as i64 + config.totp_verification_valid_for) as i32, otpurl, otpsecret).execute(db).await?; + sqlx::query!("INSERT INTO totp_create_tokens (id, expires_on, totp_otpurl, totp_secret) VALUES ($1, $2, $3, $4);", otpid.clone(), (SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() + config.totp_verification_valid_for) as i32, otpurl, otpsecret).execute(db).await?; Ok((otpid, totpmachine)) }