ordinary-api 0.8.2

API server for Ordinary
Documentation
// Copyright (C) 2026 Ordinary Labs, LLC.
//
// SPDX-License-Identifier: AGPL-3.0-only

pub(crate) mod apps;
pub(crate) mod auth;
pub(crate) mod openapi;
pub(crate) mod ops;
mod start;

use hashbrown::{HashMap, HashSet};
use ordinary_app::server::OrdinaryAppServer;
#[cfg(feature = "server")]
use ordinary_config::OrdinaryApiLimits;
use ordinary_monitor::service::OrdinaryMonitorService;
use ordinary_storage::Storage;
use parking_lot::{Mutex, RwLock};
use rand_chacha::rand_core::Rng;
use rand_chacha::rand_core::SeedableRng;
use saferlmdb::{EnvBuilder, Environment};
use sha2::{Digest, Sha256};
use std::io::Write;
use std::process;
use tokio::net::TcpListener;
use tokio::net::TcpStream;
use tokio_rustls::StartHandshake;

use axum::Router;

use std::net::SocketAddr;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;

use tokio_rustls::rustls::ServerConfig;
use tracing::{Instrument, Span};

use ordinary_auth::{Auth, AuthClient};

use std::fs;

use crate::server::auth::AccountLockManager;
use crate::server::start::start;
use crate::{api_account_claims, api_invite_claims};
use anyhow::bail;
use getrandom::SysRng;
use ordinary_config::{
    AccessTokenConfig, AuthConfig, InviteConfig, InviteMode, MfaConfig, OrdinaryApiConfig,
    PasswordConfig, RedactedHashAlg, RefreshTokenConfig, TotpConfig,
};
use ordinary_monitor::tracing::logger::OrdinaryLogger;
use ordinary_utils::{ProvisionMode, SecurityMode};
use sysinfo::{Pid, System};
use tokio::sync::watch::{Receiver, Sender};
use x25519_dalek::{PublicKey, StaticSecret};

pub struct WrappedOrdinaryAppServer {
    port: u16,
    app: Arc<OrdinaryAppServer>,
    terminate_tx: Sender<bool>,
    stream_tx: tokio::sync::mpsc::UnboundedSender<(StartHandshake<TcpStream>, SocketAddr, Span)>,
    dh_keypair: (StaticSecret, PublicKey),
}

pub struct WrappedOrdinaryProxyServer {
    service: Router,
    terminate_rx: Receiver<bool>,
    /// (challenge, default)
    configs: Option<(Arc<ServerConfig>, Arc<ServerConfig>)>,
}

type Dbs = Arc<tokio::sync::Mutex<HashMap<String, (Arc<Environment>, Arc<Auth>, Arc<Storage>)>>>;

pub enum Server {
    App(Arc<WrappedOrdinaryAppServer>),
    Proxy(Arc<WrappedOrdinaryProxyServer>),
}

type AppServers = Arc<tokio::sync::RwLock<HashMap<String, Server>>>;

#[allow(clippy::struct_excessive_bools)]
pub struct OrdinaryApiServerState {
    pub domain: String,
    pub app_domains: Arc<Vec<String>>,
    pub secure: bool,
    pub secure_cookies: bool,
    pub log_headers: bool,
    pub log_ips: bool,
    pub log_size: bool,
    pub auth: Arc<Auth>,
    pub servers: AppServers,
    pub apps_dir: PathBuf,
    pub dbs: Dbs,
    pub env_name: String,
    pub provision_mode: Option<ProvisionMode>,
    pub dedicated_ports: bool,
    pub server_span: Span,
    pub monitor: Arc<Option<OrdinaryMonitorService>>,
    pub stored_logs: bool,
    pub limits: String,
    pub config: OrdinaryApiConfig,

    pub signal_tx: Arc<RwLock<Option<Sender<()>>>>,
    pub close_rx: Arc<RwLock<Option<Receiver<()>>>>,

    pub privileged_domains: HashSet<String>,

    pub pid: Pid,
    pub system: Arc<Mutex<System>>,

    pub account_lock_manager: Arc<AccountLockManager>,

    pub danger_dns_no_verify: bool,
}

pub struct OrdinaryApiServer {
    auth: Arc<Auth>,
    env: Arc<Environment>,
    config: OrdinaryApiConfig,
    servers: AppServers,
    apps_dir: PathBuf,
    monitor: Arc<Option<OrdinaryMonitorService>>,
}

#[cfg(feature = "server")]
impl OrdinaryApiServer {
    #[allow(clippy::similar_names, clippy::too_many_arguments)]
    pub async fn init(
        env_name: &str,
        domain: &str,
        password: &str,
        env_path: impl AsRef<Path>,
        storage_size: usize,
        api_contacts: &[String],
        app_domains: &[String],
        privileged_domains: &Option<Vec<String>>,
        logger: Option<OrdinaryLogger>,
    ) -> anyhow::Result<(String, Vec<u8>)> {
        let span = tracing::info_span!("init", env = %env_name, pid = process::id());

        let mut limits = OrdinaryApiLimits::default();
        if let Some(pd) = privileged_domains {
            pd.clone_into(&mut limits.privileged_domains);
        }
        limits.app_domains = app_domains.to_vec();

        async {
            let config = OrdinaryApiConfig {
                domain: domain.into(),
                contacts: api_contacts.to_vec(),
                // todo: pass in as a flag
                public_dns_ip: None,
                env_name: env_name.to_string(),
                limits,
            };
            let config_file = serde_json::to_string_pretty(&config)?;

            let data_path = env_path.as_ref().join("data");

            if data_path.exists() {
                bail!("environment already initialized");
            }

            fs::write(env_path.as_ref().join("ordinaryd.json"), config_file)?;

            let api_server =
                OrdinaryApiServer::new(env_name, env_path, storage_size, logger).await?;

            let mut input = domain.as_bytes().to_vec();
            input.extend_from_slice(b"root");

            let mut password_input = input.clone();
            password_input.extend_from_slice(password.as_bytes());

            let mut hasher = Sha256::new();
            hasher.update(&password_input);
            let password = hasher.finalize().to_vec();

            let invite_token = api_server.auth.api_invite_get(domain, "root", None)?;

            let (state, reg_start_req) = AuthClient::registration_start_req(b"root", &password)?;

            let checked_claims = api_server.auth.invite_check(&invite_token)?;
            let reg_start_res = api_server.auth.registration_start(
                reg_start_req,
                None,
                None,
                Some(checked_claims),
            )?;

            let (private_key, reg_finish_req) =
                AuthClient::registration_finish_req(b"root", &password, &state, &reg_start_res)?;
            let (reg_finish_res, ..) = api_server.auth.registration_finish(reg_finish_req, None)?;

            let (totp, _recovery_codes) = AuthClient::decrypt_totp_mfa(
                &reg_finish_res,
                private_key,
                env_name.to_string(),
                "root".into(),
            )?;

            Ok((totp.get_url(), totp.secret))
        }
        .instrument(span.clone())
        .await
    }

    #[allow(clippy::too_many_lines, clippy::missing_panics_doc)]
    pub async fn new(
        env_name: &str,
        env_path: impl AsRef<Path>,
        storage_size: usize,
        logger: Option<OrdinaryLogger>,
    ) -> anyhow::Result<OrdinaryApiServer> {
        let span = tracing::info_span!("setup", env = %env_name, pid = process::id());

        async {
            let config: OrdinaryApiConfig = serde_json::from_str(&fs_err::read_to_string(
                env_path.as_ref().join("ordinaryd.json"),
            )?)?;

            let data_path = env_path.as_ref().join("data");

            fs_err::create_dir_all(&data_path)?;

            let ps = page_size::get();

            // round up to full OS page
            let remainder = storage_size % ps;
            let mapsize = (storage_size - remainder) + ps;

            tracing::info!(mapsize = %bytesize::ByteSize(mapsize as u64).display().si_short());

            let env = Arc::new(unsafe {
                let mut env_builder = EnvBuilder::new()?;
                env_builder.set_maxreaders(126)?;
                env_builder.set_mapsize(mapsize)?;
                env_builder.set_maxdbs(13)?;
                env_builder.open(
                    match data_path.to_str() {
                        Some(v) => v,
                        None => bail!("data_path not a str"),
                    },
                    &saferlmdb::open::Flags::empty(),
                    0o600,
                )?
            });

            let keys_dir = env_path.as_ref().join("keys");
            fs_err::create_dir_all(&keys_dir)?;

            let auth_key_path = keys_dir.join("auth");

            let auth_key: [u8; 32] = if auth_key_path.exists() && auth_key_path.is_file() {
                let auth_key = fs_err::read(&auth_key_path)?;
                let auth_key: [u8; 32] = auth_key[..].try_into()?;
                auth_key
            } else {
                let mut auth_key = [0u8; 32];
                let mut rng = rand_chacha::ChaCha20Rng::try_from_rng(&mut SysRng)?;

                rng.fill_bytes(&mut auth_key[..]);

                let mut auth_key_file = fs_err::File::create(auth_key_path)?;
                auth_key_file.write_all(&auth_key)?;
                auth_key_file.flush()?;

                auth_key
            };

            let auth = Arc::new(Auth::new(
                config.domain.clone(),
                Some(AuthConfig {
                    password: PasswordConfig {
                        protocol: ordinary_config::PasswordProtocol::Opaque,
                    },
                    mfa: MfaConfig {
                        totp: TotpConfig {
                            template: None,
                            algorithm: ordinary_config::TotpAlgorithm::Sha1,
                        },
                    },
                    refresh_token: RefreshTokenConfig::default(),
                    access_token: AccessTokenConfig {
                        claims: api_account_claims(),
                        ..AccessTokenConfig::default()
                    },
                    client_hash: ordinary_config::ClientPasswordHash::Sha256,
                    cookies_enabled: true,
                    invite: Some(InviteConfig {
                        // todo: make `InviteMode` configurable
                        mode: InviteMode::Root,
                        lifetime: 60 * 60 * 24,
                        clean_interval: (30, 90),
                        claims: Some(api_invite_claims()),
                    }),
                }),
                auth_key,
                env.clone(),
            )?);

            let apps_dir = env_path.as_ref().join("apps");
            fs_err::create_dir_all(&apps_dir)?;

            let monitor = Arc::new(logger.map(|logger| {
                OrdinaryMonitorService::new(logger).expect("failed to set up monitor service")
            }));

            Ok(OrdinaryApiServer {
                config,
                auth,
                env,
                servers: Arc::new(tokio::sync::RwLock::new(HashMap::new())),
                apps_dir,
                monitor,
            })
        }
        .instrument(span)
        .await
    }

    #[allow(clippy::too_many_arguments, clippy::fn_params_excessive_bools)]
    pub async fn start<P, F>(
        &self,
        server_span: Span,
        mode: SecurityMode<P>,
        listener: TcpListener,
        secure_cookies: bool,
        log_headers: bool,
        log_ips: bool,
        log_size: bool,
        redirect_listener: Option<TcpListener>,
        dedicated_ports: bool,
        stored_logs: bool,
        redacted_hash: Option<RedactedHashAlg>,
        openapi: bool,
        swagger: bool,
        signal: fn() -> F,
        danger_dns_no_verify: bool,
    ) -> anyhow::Result<()>
    where
        P: AsRef<Path> + std::clone::Clone,
        F: Future<Output = ()> + Send + 'static,
    {
        start(
            self,
            server_span,
            mode,
            listener,
            secure_cookies,
            log_headers,
            log_ips,
            log_size,
            redirect_listener,
            dedicated_ports,
            stored_logs,
            redacted_hash,
            openapi,
            swagger,
            signal,
            danger_dns_no_verify,
        )
        .await
    }
}