steid

@jamesgill /

Rust9.9 KBCode·Blame·History·Raw
1//! The operator surface's two routes: what this instance is, and claiming it headlessly.
2//!
3//! Both are `#[route]` rather than `#[page]` — JSON in, JSON out, no layout, no cookie
4//! and no session. The caller is a control plane provisioning a machine per tenant, not
5//! a person: it polls `GET /api/instance` between booting the machine and handing its
6//! owner a link, and it may claim the instance itself with `POST /api/setup` rather than
7//! driving the form.
8
9use std::time::Instant;
10
11use serde::{Deserialize, Serialize};
12use topcoat::{
13 Result,
14 context::{Cx, try_app_context},
15 router::{
16 Body, IntoResponse, Response, StatusCode, content::Json, header::CONTENT_TYPE, route,
17 },
18};
19
20use crate::{
21 application::{OwnerSpec, claim_instance, describe_instance, instance_state},
22 infrastructure::{
23 password::Argon2Hasher,
24 repository::{SqliteMembershipRepo, SqliteOrgRepo, SqliteUserRepo},
25 },
26};
27
28use super::{
29 context::{git_features, pool, repos, setup_token, users},
30 operator::operator_authorized,
31 rate_limit::{instance_throttled, throttled},
32};
33
34/// When this process started, for `uptime_seconds`.
35///
36/// In app context because it is a fact about the process, not the request: constructed
37/// per request it would report zero every time. An `Instant` rather than a wall clock so
38/// the answer is a monotonic duration that a clock step cannot make negative.
39#[derive(Debug, Clone, Copy)]
40pub struct StartedAt(pub Instant);
41
42impl StartedAt {
43 /// Now. Called once, by `main`.
44 #[must_use]
45 pub fn now() -> Self {
46 Self(Instant::now())
47 }
48}
49
50/// What this instance is.
51///
52/// One struct with optional fields rather than two, so the public answer is provably a
53/// subset of the operator one: a field added below the line cannot leak by being put in
54/// the wrong struct, it can only be omitted. `claimed` is the only thing an anonymous
55/// caller is ever told.
56#[derive(Debug, Serialize)]
57struct Instance {
58 claimed: bool,
59
60 /// Steid's own version. Fingerprinting material — it names the vulnerabilities this
61 /// build has — so it is behind the token.
62 #[serde(skip_serializing_if = "Option::is_none")]
63 version: Option<&'static str>,
64
65 /// The git on the machine, `2.47`-shaped, or absent where it could not be read.
66 #[serde(skip_serializing_if = "Option::is_none")]
67 git_version: Option<String>,
68
69 /// Whether that git can merge a pull request from the web.
70 #[serde(skip_serializing_if = "Option::is_none")]
71 merge_supported: Option<bool>,
72
73 #[serde(skip_serializing_if = "Option::is_none")]
74 repositories: Option<u64>,
75
76 #[serde(skip_serializing_if = "Option::is_none")]
77 users: Option<u64>,
78
79 #[serde(skip_serializing_if = "Option::is_none")]
80 uptime_seconds: Option<u64>,
81}
82
83impl Instance {
84 /// The one fact that is safe to tell anybody.
85 fn public(claimed: bool) -> Self {
86 Self {
87 claimed,
88 version: None,
89 git_version: None,
90 merge_supported: None,
91 repositories: None,
92 users: None,
93 uptime_seconds: None,
94 }
95 }
96}
97
98/// What this instance is.
99///
100/// Unauthenticated it answers `{"claimed": true|false}` and nothing else — the one fact a
101/// provisioner needs before handing over a claim link, and the one fact that is not
102/// fingerprinting material. With the operator bearer it adds the version, what git can
103/// do, the size of the machine and how long the process has been up.
104///
105/// A wrong or absent operator token is not an error: it is the public answer. There is
106/// nothing to 401 about, because the unauthenticated form is a real answer rather than a
107/// truncated one — and answering 401 would confirm that a token exists to guess.
108///
109/// Rate limited only in its unauthenticated form: a caller holding the token has already
110/// proved it is the control plane.
111#[route(GET "/api/instance")]
112async fn instance(cx: &Cx) -> Result<Response> {
113 if operator_authorized(cx) {
114 let report = describe_instance(&users(cx), &repos(cx))
115 .await
116 .map_err(super::context::server_error)?;
117 let features = git_features(cx);
118
119 return Json(Instance {
120 claimed: report.claimed,
121 version: Some(env!("CARGO_PKG_VERSION")),
122 git_version: features.version_string(),
123 merge_supported: Some(features.merge_tree),
124 repositories: Some(report.repositories),
125 users: Some(report.users),
126 uptime_seconds: Some(uptime_seconds(cx)),
127 })
128 .into_response(cx);
129 }
130
131 if let Some(response) = instance_throttled(cx) {
132 return Ok(response);
133 }
134
135 let state = instance_state(&users(cx))
136 .await
137 .map_err(super::context::server_error)?;
138
139 Json(Instance::public(state.claimed)).into_response(cx)
140}
141
142/// How long this process has been up, in whole seconds.
143///
144/// Zero where `StartedAt` is absent, which is no context Steid builds — an instance that
145/// has been up for no time is a truthful-looking answer and a better failure than a panic
146/// on an operator's monitoring poll.
147fn uptime_seconds(cx: &Cx) -> u64 {
148 try_app_context::<StartedAt>(cx).map_or(0, |started| started.0.elapsed().as_secs())
149}
150
151/// The headless claim.
152#[derive(Debug, Deserialize)]
153struct SetupRequest {
154 setup_token: String,
155 email: String,
156 handle: String,
157 password: String,
158}
159
160/// What a successful claim answers with.
161///
162/// The handle and nothing else. No session — a control plane is not signing in — and no
163/// token of any kind, so the claim leaves the caller holding exactly what it already had.
164#[derive(Debug, Serialize)]
165struct Claimed {
166 handle: String,
167}
168
169/// Claims the instance without a browser.
170///
171/// The same rules as the form, reached through the same use case: the setup token is
172/// checked before any write, an already-claimed instance is refused, and the whole thing
173/// shares the sign-in limiter's budget because it is the same secret being guessed.
174///
175/// Every failure is the same 404 with the same body — a wrong token, an already-claimed
176/// instance, and a handle that will not validate all look identical from outside. A 401
177/// would say the token exists to guess; a 409 would say the instance is claimed. Both
178/// cases return before Argon2 runs, so their timing matches too.
179#[route(POST "/api/setup")]
180async fn setup(cx: &Cx, Json(request): Json<SetupRequest>) -> Result<Response> {
181 if let Some(response) = throttled(cx) {
182 return Ok(response);
183 }
184
185 // Absent once claimed, so a claimed instance answers here rather than reaching the
186 // use case at all.
187 let Some(token) = setup_token(cx) else {
188 return Ok(refused());
189 };
190
191 let pool = pool(cx).clone();
192 let users = SqliteUserRepo::new(pool.clone());
193 let orgs = SqliteOrgRepo::new(pool.clone());
194 let memberships = SqliteMembershipRepo::new(pool);
195
196 let handle = request.handle.clone();
197 let claim = claim_instance(
198 &request.setup_token,
199 token,
200 &OwnerSpec {
201 handle: request.handle,
202 email: request.email,
203 password: request.password,
204 },
205 &users,
206 &orgs,
207 &memberships,
208 &Argon2Hasher::new(),
209 )
210 .await;
211
212 match claim {
213 // Deliberately no session: `sign_in` is the form's job. A control plane holding a
214 // cookie would be a browser, and this is not one.
215 Ok(_) => (StatusCode::CREATED, Json(Claimed { handle })).into_response(cx),
216 Err(error) => {
217 // The reason goes to the log, where an operator can read it; the response
218 // says nothing. No field of the request is logged — one of them is a
219 // password.
220 eprintln!("steid: headless claim rejected: {error}");
221 Ok(refused())
222 }
223 }
224}
225
226/// The one refusal `/api/setup` ever gives. Byte-identical for every reason, so the
227/// response cannot be read as an oracle for the token or the instance's state.
228fn refused() -> Response {
229 let mut response = Response::new(Body::from("{\"error\":\"not found\"}\n"));
230 *response.status_mut() = StatusCode::NOT_FOUND;
231 response
232 .headers_mut()
233 .insert(CONTENT_TYPE, "application/json".parse().expect("static"));
234
235 response
236}
237
238#[cfg(test)]
239mod tests {
240 use super::*;
241
242 #[test]
243 fn the_public_answer_carries_nothing_but_claimed() {
244 let body = serde_json::to_string(&Instance::public(false)).expect("serialise");
245
246 assert_eq!(body, r#"{"claimed":false}"#);
247 }
248
249 #[test]
250 fn the_operator_answer_carries_every_field() {
251 let body = serde_json::to_string(&Instance {
252 claimed: true,
253 version: Some("0.4.0"),
254 git_version: Some("2.47".to_owned()),
255 merge_supported: Some(true),
256 repositories: Some(3),
257 users: Some(1),
258 uptime_seconds: Some(42),
259 })
260 .expect("serialise");
261
262 assert_eq!(
263 body,
264 r#"{"claimed":true,"version":"0.4.0","git_version":"2.47","merge_supported":true,"repositories":3,"users":1,"uptime_seconds":42}"#
265 );
266 }
267
268 #[test]
269 fn a_git_that_could_not_be_read_omits_its_version_rather_than_guessing() {
270 let body = serde_json::to_string(&Instance {
271 claimed: true,
272 version: Some("0.4.0"),
273 git_version: None,
274 merge_supported: Some(false),
275 repositories: Some(0),
276 users: Some(1),
277 uptime_seconds: Some(1),
278 })
279 .expect("serialise");
280
281 assert!(!body.contains("git_version"), "{body}");
282 assert!(body.contains(r#""merge_supported":false"#), "{body}");
283 }
284
285 #[test]
286 fn every_refusal_is_the_same_404() {
287 let first = refused();
288 let second = refused();
289
290 assert_eq!(first.status(), StatusCode::NOT_FOUND);
291 assert_eq!(first.status(), second.status());
292 assert_eq!(first.headers(), second.headers());
293 }
294}