anvilsign in

collin/anvil

1//! Core domain model, persistence, and on-disk repository storage for anvil.
2//!
3//! This crate is transport-agnostic: it knows about users, repositories, the
4//! SQLite database, and bare git repositories on disk, but nothing about HTTP,
5//! SSH, or the git wire protocol. Higher layers (`anvil-web`, `anvil-ssh`,
6//! `anvil-git`) build on top of it.
7
8pub mod access;
9pub mod admin_cache;
10pub mod agent;
11pub mod api_tokens;
12pub mod attachments;
13pub mod ci;
14pub mod config;
15pub mod db;
16pub mod error;
17pub mod issues;
18pub mod jobs;
19pub mod language;
20pub mod models;
21pub mod periodic;
22pub mod preview_images;
23pub mod repos;
24pub mod secrets;
25pub mod sessions;
26pub mod ssh_keys;
27pub mod storage;
28pub mod usage;
29pub mod users;
30
31pub use config::Config;
32pub use error::{
33 Error,
34 Result,
35};
36pub use models::{
37 AgentSession,
38 ApiToken,
39 Attachment,
40 CiArtifact,
41 CiRun,
42 Issue,
43 IssueComment,
44 RepoSecret,
45 Repository,
46 Session,
47 SshKey,
48 User,
49};
50
51/// Current Unix time in seconds, for `created_at` columns.
52pub(crate) fn now() -> i64 {
53 std::time::SystemTime::now()
54 .duration_since(std::time::UNIX_EPOCH)
55 .map(|d| d.as_secs() as i64)
56 .unwrap_or(0)
57}
58
59/// Shared application state: configuration plus a database handle.
60///
61/// Cloneable and cheap to pass around — `toasty::Db` is internally reference
62/// counted and backed by a connection pool.
63#[derive(Clone)]
64pub struct App {
65 pub config: Config,
66 pub db: toasty::Db,
67 /// Notifies the CI runner of newly-enqueued run ids. `None` until the runner
68 /// is started (e.g. CLI commands don't run CI). Use [`App::notify_ci`].
69 pub ci_tx: Option<tokio::sync::mpsc::UnboundedSender<i64>>,
70 /// Plaintext repo secrets for CI, held in memory only and lost on
71 /// restart — see [`secrets::Vault`].
72 pub vault: secrets::Vault,
73 /// Plaintext user secrets for agent sessions — same shape as `vault`,
74 /// just keyed by `user_id` instead of `repo_id`. A second instance
75 /// rather than a shared one: repo and user secrets are unrelated
76 /// namespaces, and mixing them into one map would risk an id collision
77 /// mattering some day.
78 pub user_vault: secrets::Vault,
79 /// Agent sessions live in this process, keyed by session id. Empty after a
80 /// restart, which is why startup reconciles rows against containers — see
81 /// [`agent::Registry`].
82 pub sessions: agent::Registry,
83 /// Which runner holds which CI run, plus the wakeup parked claim requests
84 /// block on. In memory and lost on restart, which is safe because
85 /// [`ci::requeue_interrupted`] re-queues anything still `running` at
86 /// startup — see [`jobs::Dispatch`].
87 pub jobs: jobs::Dispatch,
88 /// Server-wide secret keying CSRF tokens. Persisted in the data dir so
89 /// tokens survive restarts. Wrapped in `Arc` to keep `App: Clone` cheap.
90 csrf_secret: std::sync::Arc<[u8; 32]>,
91}
92
93impl App {
94 /// Initialize application state from a config: ensure the data directories
95 /// exist, then open the database and create the schema.
96 pub async fn bootstrap(config: Config) -> Result<Self> {
97 std::fs::create_dir_all(&config.data_dir)?;
98 std::fs::create_dir_all(config.repositories_dir())?;
99
100 let db = db::connect(config.database_path()).await?;
101 let csrf_secret = std::sync::Arc::new(load_or_create_csrf_secret(&config.data_dir)?);
102
103 Ok(Self {
104 config,
105 db,
106 ci_tx: None,
107 vault: secrets::Vault::default(),
108 user_vault: secrets::Vault::default(),
109 sessions: agent::Registry::default(),
110 jobs: jobs::Dispatch::new(),
111 csrf_secret,
112 })
113 }
114
115 /// Notify the CI runner that `run_id` is queued (no-op if no runner).
116 pub fn notify_ci(&self, run_id: i64) {
117 if let Some(tx) = &self.ci_tx {
118 let _ = tx.send(run_id);
119 }
120 }
121
122 /// The CSRF token bound to a given session token: `HMAC-SHA256(secret,
123 /// session)`, hex-encoded. Stable for a session's lifetime, unguessable
124 /// without the server secret, and requires no extra storage.
125 pub fn csrf_token(&self, session_token: &str) -> String {
126 use hmac::{
127 Hmac,
128 Mac,
129 };
130 let mut mac = Hmac::<sha2::Sha256>::new_from_slice(self.csrf_secret.as_slice())
131 .expect("HMAC accepts any key length");
132 mac.update(session_token.as_bytes());
133 mac.finalize()
134 .into_bytes()
135 .iter()
136 .map(|b| format!("{b:02x}"))
137 .collect()
138 }
139}
140
141/// Load the persistent CSRF secret, generating and saving it on first run.
142fn load_or_create_csrf_secret(data_dir: &std::path::Path) -> Result<[u8; 32]> {
143 use argon2::password_hash::rand_core::{
144 OsRng,
145 RngCore,
146 };
147
148 let path = data_dir.join("csrf_secret");
149 if path.exists() {
150 let bytes = std::fs::read(&path)?;
151 if let Ok(secret) = <[u8; 32]>::try_from(bytes.as_slice()) {
152 return Ok(secret);
153 }
154 // Malformed (truncated/extended) — regenerate rather than run weak.
155 }
156 let mut secret = [0u8; 32];
157 OsRng.fill_bytes(&mut secret);
158 std::fs::write(&path, secret)?;
159 #[cfg(unix)]
160 {
161 use std::os::unix::fs::PermissionsExt;
162 let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
163 }
164 Ok(secret)
165}