Skip to main content

ordinary_api/server/
mod.rs

1// Copyright (C) 2026 Ordinary Labs, LLC.
2//
3// SPDX-License-Identifier: AGPL-3.0-only
4
5pub(crate) mod apps;
6pub(crate) mod auth;
7pub mod console;
8pub(crate) mod openapi;
9pub(crate) mod ops;
10mod start;
11
12use hashbrown::{HashMap, HashSet};
13use ordinary_app::server::OrdinaryAppServer;
14#[cfg(feature = "server")]
15use ordinary_config::OrdinaryApiLimits;
16use ordinary_monitor::service::OrdinaryMonitorService;
17use ordinary_storage::Storage;
18use parking_lot::{Mutex, RwLock};
19use rand_chacha::rand_core::Rng;
20use rand_chacha::rand_core::SeedableRng;
21use saferlmdb::{EnvBuilder, Environment};
22use sha2::{Digest, Sha256};
23use std::io::Write;
24use std::process;
25use tokio::net::TcpListener;
26use tokio::net::TcpStream;
27use tokio_rustls::StartHandshake;
28
29use axum::Router;
30
31use std::net::SocketAddr;
32use std::path::Path;
33use std::path::PathBuf;
34use std::sync::Arc;
35
36use tokio_rustls::rustls::ServerConfig;
37use tracing::{Instrument, Span};
38
39use ordinary_auth::{Auth, AuthClient};
40
41use std::fs;
42
43use crate::server::auth::AccountLockManager;
44use crate::server::start::start;
45use crate::{api_account_claims, api_invite_claims};
46use anyhow::bail;
47use getrandom::SysRng;
48use ordinary_config::{
49    AccessTokenConfig, AuthConfig, InviteConfig, InviteMode, MfaConfig, OrdinaryApiConfig,
50    PasswordConfig, ProxiedMetric, RedactedHashAlg, RefreshTokenConfig, TotpConfig,
51};
52use ordinary_monitor::tracing::logger::OrdinaryLogger;
53use ordinary_utils::{ProvisionMode, SecurityMode};
54use sysinfo::{Pid, System};
55use tokio::sync::watch::{Receiver, Sender};
56use x25519_dalek::{PublicKey, StaticSecret};
57
58pub struct WrappedOrdinaryAppServer {
59    port: u16,
60    app: Arc<OrdinaryAppServer>,
61    terminate_tx: Sender<bool>,
62    stream_tx: tokio::sync::mpsc::UnboundedSender<(StartHandshake<TcpStream>, SocketAddr, Span)>,
63    dh_keypair: (StaticSecret, PublicKey),
64}
65
66pub struct WrappedOrdinaryProxyServer {
67    service: Router,
68    terminate_rx: Receiver<bool>,
69    /// (challenge, default)
70    configs: Option<(Arc<ServerConfig>, Arc<ServerConfig>)>,
71}
72
73type Dbs = Arc<tokio::sync::Mutex<HashMap<String, (Arc<Environment>, Arc<Auth>, Arc<Storage>)>>>;
74
75pub enum Server {
76    App(Arc<WrappedOrdinaryAppServer>),
77    Proxy(Arc<WrappedOrdinaryProxyServer>),
78}
79
80type AppServers = Arc<tokio::sync::RwLock<HashMap<String, Server>>>;
81
82#[allow(clippy::struct_excessive_bools)]
83pub struct OrdinaryApiServerState {
84    pub domain: String,
85    pub app_domains: Arc<Vec<String>>,
86    pub secure: bool,
87    pub secure_cookies: bool,
88    pub log_headers: bool,
89    pub log_ips: bool,
90    pub log_size: bool,
91    pub auth: Arc<Auth>,
92    pub servers: AppServers,
93    pub apps_dir: PathBuf,
94    pub dbs: Dbs,
95    pub env_name: String,
96    pub provision_mode: Option<ProvisionMode>,
97    pub dedicated_ports: bool,
98    pub server_span: Span,
99    pub monitor: Arc<Option<OrdinaryMonitorService>>,
100    pub stored_logs: bool,
101    pub limits: String,
102    pub config: OrdinaryApiConfig,
103    pub proxied_metrics: HashMap<String, ProxiedMetric>,
104    pub reqwest_client: reqwest::Client,
105
106    pub signal_tx: Arc<RwLock<Option<Sender<()>>>>,
107    pub close_rx: Arc<RwLock<Option<Receiver<()>>>>,
108
109    pub privileged_domains: HashSet<String>,
110
111    pub pid: Pid,
112    pub system: Arc<Mutex<System>>,
113
114    pub account_lock_manager: Arc<AccountLockManager>,
115
116    pub danger_dns_no_verify: bool,
117}
118
119pub struct OrdinaryApiServer {
120    auth: Arc<Auth>,
121    env: Arc<Environment>,
122    config: OrdinaryApiConfig,
123    servers: AppServers,
124    apps_dir: PathBuf,
125    monitor: Arc<Option<OrdinaryMonitorService>>,
126}
127
128#[cfg(feature = "server")]
129impl OrdinaryApiServer {
130    #[allow(clippy::similar_names, clippy::too_many_arguments)]
131    pub async fn init(
132        env_name: &str,
133        domain: &str,
134        password: &str,
135        env_path: impl AsRef<Path>,
136        storage_size: usize,
137        api_contacts: &[String],
138        app_domains: &[String],
139        privileged_domains: &Option<Vec<String>>,
140        logger: Option<OrdinaryLogger>,
141    ) -> anyhow::Result<(String, Vec<u8>)> {
142        let span = tracing::info_span!("init", env = %env_name, pid = process::id());
143
144        let mut limits = OrdinaryApiLimits::default();
145        if let Some(pd) = privileged_domains {
146            pd.clone_into(&mut limits.privileged_domains);
147        }
148        limits.app_domains = app_domains.to_vec();
149
150        async {
151            let config = OrdinaryApiConfig {
152                domain: domain.into(),
153                contacts: api_contacts.to_vec(),
154                // todo: pass in as a flag
155                public_dns_ip: None,
156                env_name: env_name.to_string(),
157                limits,
158                proxied_metrics: vec![],
159                console: false,
160                invite_token_link: "/".to_string(),
161            };
162            let config_file = serde_json::to_string_pretty(&config)?;
163
164            let data_path = env_path.as_ref().join("data");
165
166            if data_path.exists() {
167                bail!("environment already initialized");
168            }
169
170            fs::write(env_path.as_ref().join("ordinaryd.json"), config_file)?;
171
172            let api_server =
173                OrdinaryApiServer::new(env_name, env_path, storage_size, logger).await?;
174
175            let mut input = domain.as_bytes().to_vec();
176            input.extend_from_slice(b"root");
177
178            let mut password_input = input.clone();
179            password_input.extend_from_slice(password.as_bytes());
180
181            let mut hasher = Sha256::new();
182            hasher.update(&password_input);
183            let password = hasher.finalize().to_vec();
184
185            let invite_token = api_server.auth.api_invite_get(domain, "root", None)?;
186
187            let (state, reg_start_req) = AuthClient::registration_start_req(b"root", &password)?;
188
189            let checked_claims = api_server.auth.invite_check(&invite_token)?;
190            let reg_start_res = api_server.auth.registration_start(
191                reg_start_req,
192                None,
193                None,
194                Some(checked_claims),
195            )?;
196
197            let (private_key, reg_finish_req) =
198                AuthClient::registration_finish_req(b"root", &password, &state, &reg_start_res)?;
199            let (reg_finish_res, ..) = api_server.auth.registration_finish(reg_finish_req, None)?;
200
201            let (totp, _recovery_codes) = AuthClient::decrypt_totp_mfa(
202                &reg_finish_res,
203                private_key,
204                env_name.to_string(),
205                "root".into(),
206            )?;
207
208            Ok((totp.get_url(), totp.secret))
209        }
210        .instrument(span.clone())
211        .await
212    }
213
214    #[allow(clippy::too_many_lines, clippy::missing_panics_doc)]
215    pub async fn new(
216        env_name: &str,
217        env_path: impl AsRef<Path>,
218        storage_size: usize,
219        logger: Option<OrdinaryLogger>,
220    ) -> anyhow::Result<OrdinaryApiServer> {
221        let span = tracing::info_span!("setup", env = %env_name, pid = process::id());
222
223        async {
224            let config: OrdinaryApiConfig = serde_json::from_str(&fs_err::read_to_string(
225                env_path.as_ref().join("ordinaryd.json"),
226            )?)?;
227
228            let data_path = env_path.as_ref().join("data");
229
230            fs_err::create_dir_all(&data_path)?;
231
232            let ps = page_size::get();
233
234            // round up to full OS page
235            let remainder = storage_size % ps;
236            let mapsize = (storage_size - remainder) + ps;
237
238            tracing::info!(mapsize = %bytesize::ByteSize(mapsize as u64).display().si_short());
239
240            let env = Arc::new(unsafe {
241                let mut env_builder = EnvBuilder::new()?;
242                env_builder.set_maxreaders(126)?;
243                env_builder.set_mapsize(mapsize)?;
244                env_builder.set_maxdbs(13)?;
245                env_builder.open(
246                    match data_path.to_str() {
247                        Some(v) => v,
248                        None => bail!("data_path not a str"),
249                    },
250                    &saferlmdb::open::Flags::empty(),
251                    0o600,
252                )?
253            });
254
255            let keys_dir = env_path.as_ref().join("keys");
256            fs_err::create_dir_all(&keys_dir)?;
257
258            let auth_key_path = keys_dir.join("auth");
259
260            let auth_key: [u8; 32] = if auth_key_path.exists() && auth_key_path.is_file() {
261                let auth_key = fs_err::read(&auth_key_path)?;
262                let auth_key: [u8; 32] = auth_key[..].try_into()?;
263                auth_key
264            } else {
265                let mut auth_key = [0u8; 32];
266                let mut rng = rand_chacha::ChaCha20Rng::try_from_rng(&mut SysRng)?;
267
268                rng.fill_bytes(&mut auth_key[..]);
269
270                let mut auth_key_file = fs_err::File::create(auth_key_path)?;
271                auth_key_file.write_all(&auth_key)?;
272                auth_key_file.flush()?;
273
274                auth_key
275            };
276
277            let auth = Arc::new(Auth::new(
278                config.domain.clone(),
279                Some(AuthConfig {
280                    password: PasswordConfig {
281                        protocol: ordinary_config::PasswordProtocol::Opaque,
282                    },
283                    mfa: MfaConfig {
284                        totp: TotpConfig {
285                            template: None,
286                            algorithm: ordinary_config::TotpAlgorithm::Sha1,
287                        },
288                    },
289                    refresh_token: RefreshTokenConfig::default(),
290                    access_token: AccessTokenConfig {
291                        claims: api_account_claims(),
292                        ..AccessTokenConfig::default()
293                    },
294                    client_hash: ordinary_config::ClientPasswordHash::Sha256,
295                    cookies_enabled: true,
296                    invite: Some(InviteConfig {
297                        // todo: make `InviteMode` configurable
298                        mode: InviteMode::Root,
299                        lifetime: 60 * 60 * 24,
300                        clean_interval: (30, 90),
301                        claims: Some(api_invite_claims()),
302                    }),
303                }),
304                auth_key,
305                env.clone(),
306            )?);
307
308            let apps_dir = env_path.as_ref().join("apps");
309            fs_err::create_dir_all(&apps_dir)?;
310
311            let monitor = Arc::new(logger.map(|logger| {
312                OrdinaryMonitorService::new(logger).expect("failed to set up monitor service")
313            }));
314
315            Ok(OrdinaryApiServer {
316                config,
317                auth,
318                env,
319                servers: Arc::new(tokio::sync::RwLock::new(HashMap::new())),
320                apps_dir,
321                monitor,
322            })
323        }
324        .instrument(span)
325        .await
326    }
327
328    #[allow(clippy::too_many_arguments, clippy::fn_params_excessive_bools)]
329    pub async fn start<P, F>(
330        &self,
331        server_span: Span,
332        mode: SecurityMode<P>,
333        listener: TcpListener,
334        secure_cookies: bool,
335        log_headers: bool,
336        log_ips: bool,
337        log_size: bool,
338        redirect_listener: Option<TcpListener>,
339        dedicated_ports: bool,
340        stored_logs: bool,
341        redacted_hash: Option<RedactedHashAlg>,
342        openapi: bool,
343        swagger: bool,
344        signal: fn() -> F,
345        danger_dns_no_verify: bool,
346    ) -> anyhow::Result<()>
347    where
348        P: AsRef<Path> + std::clone::Clone,
349        F: Future<Output = ()> + Send + 'static,
350    {
351        tracing::debug!("start API server");
352
353        start(
354            self,
355            server_span,
356            mode,
357            listener,
358            secure_cookies,
359            log_headers,
360            log_ips,
361            log_size,
362            redirect_listener,
363            dedicated_ports,
364            stored_logs,
365            redacted_hash,
366            openapi,
367            swagger,
368            signal,
369            danger_dns_no_verify,
370        )
371        .await
372    }
373}