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