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_worker(app.clone(), ci_rx));
166
167 // Start periodic background jobs (language detection, preview images, disk usage cache).
168 let periodic_jobs = vec![
169 (
170 std::time::Duration::from_secs(app.config.periodic.language_detection_interval_secs),
171 Box::new(anvil_core::periodic::LanguageDetectionJob)
172 as Box<dyn anvil_core::periodic::PeriodicJob>,
173 ),
174 (
175 std::time::Duration::from_secs(app.config.periodic.preview_image_interval_secs),
176 Box::new(anvil_core::periodic::PreviewImageJob)
177 as Box<dyn anvil_core::periodic::PeriodicJob>,
178 ),
179 (
180 std::time::Duration::from_secs(app.config.periodic.disk_usage_interval_secs),
181 Box::new(anvil_core::periodic::DiskUsageCacheJob)
182 as Box<dyn anvil_core::periodic::PeriodicJob>,
183 ),
184 (
185 std::time::Duration::from_secs(300),
186 Box::new(anvil_core::periodic::SecretVaultSweepJob)
187 as Box<dyn anvil_core::periodic::PeriodicJob>,
188 ),
189 (
190 std::time::Duration::from_secs(300),
191 Box::new(anvil_core::periodic::PasskeyCeremonySweepJob)
192 as Box<dyn anvil_core::periodic::PeriodicJob>,
193 ),
194 ];
195 anvil_core::periodic::spawn_runner(app.clone(), periodic_jobs).await;
196
197 if app.config.ssh.enabled {
198 // Run the HTTP and SSH servers concurrently; if either exits, stop.
199 tokio::try_join!(anvil_web::serve(app.clone()), anvil_ssh::serve(app))?;
200 } else {
201 anvil_web::serve(app).await?;
202 }
203 Ok(())
204}
205
206async fn migrate(config: Config) -> Result<()> {
207 App::bootstrap(config).await?;
208 println!("migrations applied");
209 Ok(())
210}
211
212async fn user(config: Config, command: UserCommand) -> Result<()> {
213 let app = App::bootstrap(config).await?;
214 match command {
215 UserCommand::Create {
216 username,
217 email,
218 password,
219 admin,
220 } => {
221 let user = users::create(&app.db, &username, &email, &password, admin).await?;
222 println!(
223 "created user {} (id {}){}",
224 user.username,
225 user.id,
226 if user.is_admin { " [admin]" } else { "" }
227 );
228 }
229 UserCommand::SetPassword { username, password } => {
230 let user = users::find_by_username(&app.db, &username)
231 .await?
232 .with_context(|| format!("no such user: {username}"))?;
233 users::set_password(&app.db, user.id, &password).await?;
234 println!("password reset for {}", user.username);
235 }
236 UserCommand::AddKey {
237 username,
238 key,
239 key_file,
240 title,
241 } => {
242 let user = users::find_by_username(&app.db, &username)
243 .await?
244 .with_context(|| format!("no such user: {username}"))?;
245 let openssh = match (key, key_file) {
246 (Some(k), None) => k,
247 (None, Some(path)) => std::fs::read_to_string(&path)
248 .with_context(|| format!("reading key file {path}"))?,
249 (Some(_), Some(_)) => anyhow::bail!("pass only one of --key / --key-file"),
250 (None, None) => anyhow::bail!("pass --key or --key-file"),
251 };
252 let (fingerprint, content) = ssh_keys::parse_public_key(&openssh)?;
253 let saved = ssh_keys::add(&app.db, user.id, &title, &fingerprint, &content).await?;
254 println!(
255 "added ssh key for {} ({})",
256 user.username, saved.fingerprint
257 );
258 }
259 UserCommand::Token { command } => token(&app, command).await?,
260 }
261 Ok(())
262}
263
264async fn token(app: &App, command: TokenCommand) -> Result<()> {
265 match command {
266 TokenCommand::Create { username, name } => {
267 let user = users::find_by_username(&app.db, &username)
268 .await?
269 .with_context(|| format!("no such user: {username}"))?;
270 let (_, plaintext) =
271 api_tokens::create(&app.db, user.id, &name, api_tokens::READ).await?;
272 println!("created read-only token '{name}' for {username}.");
273 println!("store this now — it won't be shown again:\n\n {plaintext}\n");
274 }
275 TokenCommand::List { username } => {
276 let user = users::find_by_username(&app.db, &username)
277 .await?
278 .with_context(|| format!("no such user: {username}"))?;
279 let tokens = api_tokens::list(&app.db, user.id).await?;
280 if tokens.is_empty() {
281 println!("{username} has no tokens.");
282 }
283 for t in tokens {
284 println!("#{} {} [{}]", t.id, t.name, t.scopes);
285 }
286 }
287 TokenCommand::Revoke { id } => {
288 if api_tokens::revoke(&app.db, id).await? {
289 println!("revoked token #{id}.");
290 } else {
291 anyhow::bail!("no token with id {id}");
292 }
293 }
294 }
295 Ok(())
296}
297
298async fn repo(config: Config, command: RepoCommand) -> Result<()> {
299 let app = App::bootstrap(config).await?;
300 match command {
301 RepoCommand::Create {
302 path,
303 description,
304 private,
305 } => {
306 let (owner_name, name) = path
307 .split_once('/')
308 .context("repository path must be in `owner/name` form")?;
309 let owner = users::find_by_username(&app.db, owner_name)
310 .await?
311 .with_context(|| format!("no such user: {owner_name}"))?;
312 let repo = repos::create(
313 &app.db,
314 &app.config.repositories_dir(),
315 &owner,
316 name,
317 &description,
318 private,
319 )
320 .await?;
321 println!(
322 "created repository {}/{} (id {}) at {}",
323 owner.username,
324 repo.name,
325 repo.id,
326 anvil_core::storage::repo_path(
327 &app.config.repositories_dir(),
328 &owner.username,
329 name
330 )
331 .display()
332 );
333 }
334 }
335 Ok(())
336}