anvilsign in

collin/anvil

1//! `anvild` — the anvil git forge daemon and admin CLI.
2
3use anvil_core::{
4 App,
5 Config,
6 api_tokens,
7 repos,
8 ssh_keys,
9 users,
10};
11use anyhow::{
12 Context,
13 Result,
14};
15use clap::{
16 Parser,
17 Subcommand,
18};
19
20mod secret;
21
22#[derive(Parser)]
23#[command(name = "anvild", version, about = "anvil git forge")]
24struct Cli {
25 /// Path to the configuration file (TOML). Defaults are used if absent.
26 #[arg(long, short, default_value = "anvil.toml", global = true)]
27 config: String,
28
29 /// Override the data directory from config.
30 #[arg(long, global = true)]
31 data_dir: Option<String>,
32
33 #[command(subcommand)]
34 command: Option<Command>,
35}
36
37#[derive(Subcommand)]
38enum Command {
39 /// Run the server (default).
40 Serve,
41 /// Apply database migrations and exit.
42 Migrate,
43 /// Manage users.
44 User {
45 #[command(subcommand)]
46 command: UserCommand,
47 },
48 /// Manage repositories.
49 Repo {
50 #[command(subcommand)]
51 command: RepoCommand,
52 },
53 /// Manage a repository's end-to-end encrypted secrets (docs/secrets.md).
54 ///
55 /// Unlike the other subcommands these talk to a *running* anvil over
56 /// HTTP rather than to the database, because the crypto belongs on the
57 /// machine holding your ssh key — which is usually not the server.
58 Secret {
59 #[command(subcommand)]
60 command: secret::SecretCommand,
61 #[command(flatten)]
62 opts: secret::SecretOpts,
63 },
64}
65
66#[derive(Subcommand)]
67enum UserCommand {
68 /// Create a new user.
69 Create {
70 username: String,
71 #[arg(long, default_value = "")]
72 email: String,
73 #[arg(long)]
74 password: String,
75 #[arg(long)]
76 admin: bool,
77 },
78 /// Reset a user's password. Existing sessions stay signed in.
79 SetPassword {
80 username: String,
81 #[arg(long)]
82 password: String,
83 },
84 /// Register an SSH public key for a user (for git-over-SSH access).
85 AddKey {
86 username: String,
87 /// The OpenSSH public key line. Mutually exclusive with --key-file.
88 #[arg(long)]
89 key: Option<String>,
90 /// Path to a `.pub` file (e.g. ~/.ssh/id_ed25519.pub).
91 #[arg(long)]
92 key_file: Option<String>,
93 #[arg(long, default_value = "")]
94 title: String,
95 },
96 /// Manage personal access tokens (read-only API bearer credentials).
97 Token {
98 #[command(subcommand)]
99 command: TokenCommand,
100 },
101}
102
103#[derive(Subcommand)]
104enum TokenCommand {
105 /// Mint a token for a user. The plaintext is printed once — store it now.
106 Create {
107 username: String,
108 /// Human label for the token (shown when listing).
109 #[arg(long, default_value = "api")]
110 name: String,
111 },
112 /// List a user's tokens (id, name, created — never the secret).
113 List { username: String },
114 /// Revoke a token by id.
115 Revoke { id: i64 },
116}
117
118#[derive(Subcommand)]
119enum RepoCommand {
120 /// Create a repository, given as `owner/name`.
121 Create {
122 /// Repository in `owner/name` form.
123 path: String,
124 #[arg(long, default_value = "")]
125 description: String,
126 #[arg(long)]
127 private: bool,
128 },
129}
130
131#[tokio::main]
132async fn main() -> Result<()> {
133 tracing_subscriber::fmt()
134 .with_env_filter(
135 tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
136 )
137 .init();
138
139 let cli = Cli::parse();
140
141 let mut config = Config::load_or_default(&cli.config)
142 .with_context(|| format!("loading config from {}", cli.config))?;
143 if let Some(dir) = &cli.data_dir {
144 config.data_dir = dir.into();
145 }
146
147 match cli.command.unwrap_or(Command::Serve) {
148 Command::Serve => serve(config).await,
149 Command::Migrate => migrate(config).await,
150 Command::User { command } => user(config, command).await,
151 Command::Repo { command } => repo(config, command).await,
152 Command::Secret { command, opts } => {
153 secret::run(command, &opts, &config.http.base_url).await
154 }
155 }
156}
157
158async fn serve(config: Config) -> Result<()> {
159 let mut app = App::bootstrap(config).await?;
160
161 // Start the CI runner: it drains queued runs and processes new ones pushed
162 // through `app.ci_tx` (set here so handlers can notify it).
163 let (ci_tx, ci_rx) = tokio::sync::mpsc::unbounded_channel();
164 app.ci_tx = Some(ci_tx);
165 tokio::spawn(anvil_ci::run_dispatcher(app.clone(), ci_rx));
166
167 // Agent sessions outlive the process that started them: the container is
168 // the daemon's, not ours. Reconcile before anything can create more, so a
169 // restart never leaves an orphan running or a row claiming to be live.
170 if app.config.agent.enabled {
171 anvil_agent::reconcile(&app).await;
172 }
173
174 // Start periodic background jobs (language detection, preview images, disk usage cache).
175 let mut periodic_jobs = vec![
176 (
177 std::time::Duration::from_secs(app.config.periodic.language_detection_interval_secs),
178 Box::new(anvil_core::periodic::LanguageDetectionJob)
179 as Box<dyn anvil_core::periodic::PeriodicJob>,
180 ),
181 (
182 std::time::Duration::from_secs(app.config.periodic.preview_image_interval_secs),
183 Box::new(anvil_core::periodic::PreviewImageJob)
184 as Box<dyn anvil_core::periodic::PeriodicJob>,
185 ),
186 (
187 std::time::Duration::from_secs(app.config.periodic.disk_usage_interval_secs),
188 Box::new(anvil_core::periodic::DiskUsageCacheJob)
189 as Box<dyn anvil_core::periodic::PeriodicJob>,
190 ),
191 (
192 std::time::Duration::from_secs(300),
193 Box::new(anvil_core::periodic::SecretVaultSweepJob)
194 as Box<dyn anvil_core::periodic::PeriodicJob>,
195 ),
196 ];
197 if app.config.agent.enabled {
198 // Enforces the idle and wall-clock caps. A minute is fine: both
199 // thresholds are measured in hours, and a session with a viewer
200 // attached is never idle anyway.
201 periodic_jobs.push((
202 std::time::Duration::from_secs(60),
203 Box::new(anvil_agent::SweepJob) as Box<dyn anvil_core::periodic::PeriodicJob>,
204 ));
205 }
206 anvil_core::periodic::spawn_runner(app.clone(), periodic_jobs).await;
207
208 if app.config.ssh.enabled {
209 // Run the HTTP and SSH servers concurrently; if either exits, stop.
210 tokio::try_join!(anvil_web::serve(app.clone()), anvil_ssh::serve(app))?;
211 } else {
212 anvil_web::serve(app).await?;
213 }
214 Ok(())
215}
216
217async fn migrate(config: Config) -> Result<()> {
218 App::bootstrap(config).await?;
219 println!("migrations applied");
220 Ok(())
221}
222
223async fn user(config: Config, command: UserCommand) -> Result<()> {
224 let app = App::bootstrap(config).await?;
225 match command {
226 UserCommand::Create {
227 username,
228 email,
229 password,
230 admin,
231 } => {
232 let user = users::create(&app.db, &username, &email, &password, admin).await?;
233 println!(
234 "created user {} (id {}){}",
235 user.username,
236 user.id,
237 if user.is_admin { " [admin]" } else { "" }
238 );
239 }
240 UserCommand::SetPassword { username, password } => {
241 let user = users::find_by_username(&app.db, &username)
242 .await?
243 .with_context(|| format!("no such user: {username}"))?;
244 users::set_password(&app.db, user.id, &password).await?;
245 println!("password reset for {}", user.username);
246 }
247 UserCommand::AddKey {
248 username,
249 key,
250 key_file,
251 title,
252 } => {
253 let user = users::find_by_username(&app.db, &username)
254 .await?
255 .with_context(|| format!("no such user: {username}"))?;
256 let openssh = match (key, key_file) {
257 (Some(k), None) => k,
258 (None, Some(path)) => std::fs::read_to_string(&path)
259 .with_context(|| format!("reading key file {path}"))?,
260 (Some(_), Some(_)) => anyhow::bail!("pass only one of --key / --key-file"),
261 (None, None) => anyhow::bail!("pass --key or --key-file"),
262 };
263 let (fingerprint, content) = ssh_keys::parse_public_key(&openssh)?;
264 let saved = ssh_keys::add(&app.db, user.id, &title, &fingerprint, &content).await?;
265 println!(
266 "added ssh key for {} ({})",
267 user.username, saved.fingerprint
268 );
269 }
270 UserCommand::Token { command } => token(&app, command).await?,
271 }
272 Ok(())
273}
274
275async fn token(app: &App, command: TokenCommand) -> Result<()> {
276 match command {
277 TokenCommand::Create { username, name } => {
278 let user = users::find_by_username(&app.db, &username)
279 .await?
280 .with_context(|| format!("no such user: {username}"))?;
281 let (_, plaintext) =
282 api_tokens::create(&app.db, user.id, &name, api_tokens::READ).await?;
283 println!("created read-only token '{name}' for {username}.");
284 println!("store this now — it won't be shown again:\n\n {plaintext}\n");
285 }
286 TokenCommand::List { username } => {
287 let user = users::find_by_username(&app.db, &username)
288 .await?
289 .with_context(|| format!("no such user: {username}"))?;
290 let tokens = api_tokens::list(&app.db, user.id).await?;
291 if tokens.is_empty() {
292 println!("{username} has no tokens.");
293 }
294 for t in tokens {
295 println!("#{} {} [{}]", t.id, t.name, t.scopes);
296 }
297 }
298 TokenCommand::Revoke { id } => {
299 if api_tokens::revoke(&app.db, id).await? {
300 println!("revoked token #{id}.");
301 } else {
302 anyhow::bail!("no token with id {id}");
303 }
304 }
305 }
306 Ok(())
307}
308
309async fn repo(config: Config, command: RepoCommand) -> Result<()> {
310 let app = App::bootstrap(config).await?;
311 match command {
312 RepoCommand::Create {
313 path,
314 description,
315 private,
316 } => {
317 let (owner_name, name) = path
318 .split_once('/')
319 .context("repository path must be in `owner/name` form")?;
320 let owner = users::find_by_username(&app.db, owner_name)
321 .await?
322 .with_context(|| format!("no such user: {owner_name}"))?;
323 let repo = repos::create(
324 &app.db,
325 &app.config.repositories_dir(),
326 &owner,
327 name,
328 &description,
329 private,
330 )
331 .await?;
332 println!(
333 "created repository {}/{} (id {}) at {}",
334 owner.username,
335 repo.name,
336 repo.id,
337 anvil_core::storage::repo_path(
338 &app.config.repositories_dir(),
339 &owner.username,
340 name
341 )
342 .display()
343 );
344 }
345 }
346 Ok(())
347}