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