Skip to main content

max / makenotwork

8.6 KB · 254 lines History Blame Raw
1 //! Internal git service: SSH key lookup, git push authorization, and server restart control.
2
3 use crate::auth::InternalActor;
4 use axum::{
5 Json,
6 extract::{Query, State},
7 response::IntoResponse,
8 };
9 use serde::{Deserialize, Serialize};
10 use std::sync::atomic::Ordering;
11
12 use sqlx::PgPool;
13
14 use crate::{
15 Ops,
16 auth::ServiceAuth,
17 config::Config,
18 db::{self, CreatorTier, UserId, Username, Visibility},
19 error::{AppError, Result},
20 };
21
22 // ── SSH key lookup ──
23
24 #[derive(Deserialize)]
25 pub(super) struct SshKeyLookupQuery {
26 fingerprint: String,
27 }
28
29 #[derive(Serialize)]
30 struct SshKeyLookupResponse {
31 user_id: UserId,
32 username: Username,
33 display_name: Option<String>,
34 creator_tier: Option<CreatorTier>,
35 can_create_projects: bool,
36 suspended: bool,
37 /// Signed actor assertion the CLI forwards (as `X-MNW-Actor`) on subsequent
38 /// internal calls so the server derives identity from an SSH-authenticated
39 /// token, not a caller-supplied `user_id`.
40 actor_token: String,
41 }
42
43 /// GET /api/internal/ssh-key-lookup?fingerprint={sha256}
44 ///
45 /// Look up a user by SSH key fingerprint. Returns user info if found, 404 if not.
46 #[tracing::instrument(skip_all, name = "internal::ssh_key_lookup")]
47 pub(super) async fn ssh_key_lookup(
48 State(db): State<PgPool>,
49 State(config): State<Config>,
50 _auth: ServiceAuth,
51 Query(query): Query<SshKeyLookupQuery>,
52 ) -> Result<impl IntoResponse> {
53 let user = db::ssh_keys::lookup_user_by_fingerprint(&db, &query.fingerprint)
54 .await?
55 .ok_or(AppError::NotFound)?;
56
57 // Mint an actor assertion for the resolved user; the CLI forwards it on the
58 // session's internal calls. TTL comfortably exceeds any SSH session.
59 let expiry = chrono::Utc::now().timestamp() + crate::constants::INTERNAL_ACTOR_TTL_SECS;
60 let actor_token =
61 crate::crypto::mint_internal_actor_token(user.user_id, expiry, &config.signing_secret);
62
63 Ok(Json(SshKeyLookupResponse {
64 user_id: user.user_id,
65 username: user.username,
66 display_name: user.display_name,
67 creator_tier: user.creator_tier,
68 can_create_projects: user.can_create_projects,
69 suspended: user.suspended,
70 actor_token,
71 }))
72 }
73
74 // ── SSH keys ──
75
76 #[derive(Deserialize)]
77 pub(super) struct UserIdQuery {}
78
79 #[derive(Serialize)]
80 struct SshKeyResponse {
81 id: String,
82 label: String,
83 fingerprint: String,
84 created_at: String,
85 }
86
87 /// GET /api/internal/creator/ssh-keys?user_id={uuid}
88 ///
89 /// List registered SSH keys for a user.
90 #[tracing::instrument(skip_all, name = "internal::list_ssh_keys")]
91 pub(super) async fn list_ssh_keys(
92 State(db): State<PgPool>,
93 actor: InternalActor,
94 _auth: ServiceAuth,
95 Query(_query): Query<UserIdQuery>,
96 ) -> Result<impl IntoResponse> {
97 let keys = db::ssh_keys::list_keys_by_user(&db, actor.user_id()).await?;
98 let data: Vec<SshKeyResponse> = keys
99 .into_iter()
100 .map(|k| SshKeyResponse {
101 id: k.id.to_string(),
102 label: k.label,
103 fingerprint: k.fingerprint,
104 created_at: k.created_at.to_rfc3339(),
105 })
106 .collect();
107
108 Ok(Json(data))
109 }
110
111 // ── Git authorization ──
112
113 #[derive(Deserialize)]
114 pub(super) struct GitAuthorizeRequest {
115 /// "git-upload-pack", "git-receive-pack", or "git-upload-archive"
116 operation: String,
117 owner: String,
118 repo_name: String,
119 }
120
121 #[derive(Serialize)]
122 struct GitAuthorizeResponse {
123 repo_path: String,
124 }
125
126 /// POST /api/internal/git/authorize
127 ///
128 /// Authorize a git operation and return the on-disk repo path.
129 /// Auto-creates bare repos on first push if the authenticated user owns the namespace.
130 #[tracing::instrument(skip_all, name = "internal::git_authorize")]
131 pub(super) async fn git_authorize(
132 State(db): State<PgPool>,
133 State(config): State<Config>,
134 actor: InternalActor,
135 _auth: ServiceAuth,
136 Json(req): Json<GitAuthorizeRequest>,
137 ) -> Result<impl IntoResponse> {
138 let git_root =
139 config.build.git_repos_path.as_deref().ok_or_else(|| {
140 AppError::ServiceUnavailable("Git hosting is not configured".to_string())
141 })?;
142
143 // Look up the namespace owner. `req.owner` is a client-supplied field, so
144 // validate it through `Username::new` rather than `from_trusted`, the
145 // newtype's contract is "this string already passed validation", and an
146 // unvalidated owner defeats it (a malformed owner can't name a real
147 // user, so it maps to the same NotFound).
148 let owner = Username::new(&req.owner).map_err(|_| AppError::NotFound)?;
149 let owner_user = db::users::get_user_by_username(&db, &owner)
150 .await?
151 .ok_or(AppError::NotFound)?;
152
153 let repo = match db::git_repos::get_repo_by_user_and_name(&db, owner_user.id, &req.repo_name)
154 .await?
155 {
156 Some(repo) => repo,
157 None => {
158 // Auto-create on push if the authenticated user owns the namespace.
159 // Only register in the DB here, mnw-cli creates the bare repo on
160 // disk as the git user (avoids ownership/privilege issues).
161 if req.operation != "git-receive-pack" || actor.user_id() != owner_user.id {
162 return Err(AppError::NotFound);
163 }
164
165 tracing::info!(owner = %req.owner, repo = %req.repo_name, "registering new repository");
166 // Concurrent double-push can race two auto-registers; on the loser's
167 // unique violation, re-resolve instead of 500ing the git client, the
168 // same pattern the smart-HTTP path uses (ultra-fuzz Run 12 Storage).
169 match db::git_repos::create_repo(&db, owner_user.id, &req.repo_name).await {
170 Ok(r) => r,
171 Err(e) => {
172 tracing::debug!(owner = %req.owner, repo = %req.repo_name, error = ?e, "auto-register failed, retrying lookup");
173 db::git_repos::get_repo_by_user_and_name(&db, owner_user.id, &req.repo_name)
174 .await?
175 .ok_or(AppError::NotFound)?
176 }
177 }
178 }
179 };
180
181 // Permission check
182 match req.operation.as_str() {
183 "git-receive-pack" => {
184 if actor.user_id() != owner_user.id {
185 return Err(AppError::Forbidden);
186 }
187 }
188 "git-upload-pack" | "git-upload-archive" => {
189 if repo.visibility == Visibility::Private && actor.user_id() != owner_user.id {
190 return Err(AppError::NotFound);
191 }
192 }
193 _ => return Err(AppError::BadRequest("unsupported git operation".into())),
194 }
195
196 // Validate the path segments before building a filesystem path. `owner` is
197 // already validated upstream, but `repo_name` reached the `join` unchecked
198 // (fuzz 2026-07-06 L2); validate both charset-wise (no `/`, `..`, leading
199 // dot) so a crafted name cannot escape the git root even if a future caller
200 // skips the DB-row check that currently blocks traversal transitively.
201 crate::git::validate_segment(&req.owner)
202 .and_then(|()| crate::git::validate_segment(&req.repo_name))
203 .map_err(|_| AppError::BadRequest("invalid repository name".into()))?;
204
205 let repo_path = std::path::Path::new(git_root)
206 .join(&req.owner)
207 .join(format!("{}.git", req.repo_name));
208
209 Ok(Json(GitAuthorizeResponse {
210 repo_path: repo_path.to_string_lossy().into_owned(),
211 }))
212 }
213
214 // ── Restart warning ──
215
216 #[derive(Deserialize)]
217 pub(super) struct RestartWarningRequest {
218 seconds: i64,
219 }
220
221 /// POST /api/internal/restart-warning
222 ///
223 /// Set a pending restart timestamp. `{"seconds": 30}` means "restart in 30s".
224 /// `{"seconds": 0}` cancels any pending warning.
225 #[tracing::instrument(skip_all, name = "internal::set_restart_warning")]
226 pub(super) async fn set_restart_warning(
227 State(ops): State<Ops>,
228 _auth: ServiceAuth,
229 Json(req): Json<RestartWarningRequest>,
230 ) -> Result<impl IntoResponse> {
231 let ts = if req.seconds > 0 {
232 chrono::Utc::now().timestamp() + req.seconds
233 } else {
234 0
235 };
236 ops.restart_at.store(ts, Ordering::Relaxed);
237 tracing::info!(
238 restart_at = ts,
239 seconds = req.seconds,
240 "restart warning set"
241 );
242 Ok(axum::http::StatusCode::NO_CONTENT)
243 }
244
245 /// GET /api/restart-status
246 ///
247 /// Public, unauthenticated. Returns the pending restart timestamp (or null).
248 /// Single atomic load, no DB, no session.
249 pub(in crate::routes::api) async fn restart_status(State(ops): State<Ops>) -> impl IntoResponse {
250 let ts = ops.restart_at.load(Ordering::Relaxed);
251 let restart_at = if ts > 0 { Some(ts) } else { None };
252 Json(serde_json::json!({ "restart_at": restart_at }))
253 }
254