fedimint_server_ui/
setup.rs

1use std::future::Future;
2use std::net::SocketAddr;
3use std::pin::Pin;
4
5use axum::Router;
6use axum::extract::{Form, State};
7use axum::response::{Html, IntoResponse, Redirect};
8use axum::routing::{get, post};
9use axum_extra::extract::cookie::CookieJar;
10use fedimint_core::module::ApiAuth;
11use fedimint_core::task::TaskHandle;
12use fedimint_server_core::setup_ui::DynSetupApi;
13use maud::{DOCTYPE, Markup, html};
14use serde::Deserialize;
15use tokio::net::TcpListener;
16
17use crate::assets::WithStaticRoutesExt as _;
18use crate::{
19    AuthState, LoginInput, check_auth, layout, login_form_response, login_submit_response,
20};
21
22#[derive(Debug, Deserialize)]
23pub(crate) struct SetupInput {
24    pub password: String,
25    pub name: String,
26    #[serde(default)]
27    pub is_lead: bool,
28    pub federation_name: String,
29}
30
31#[derive(Debug, Deserialize)]
32pub(crate) struct PeerInfoInput {
33    pub peer_info: String,
34}
35
36pub fn setup_layout(title: &str, content: Markup) -> Markup {
37    html! {
38        (DOCTYPE)
39        html {
40            head {
41                (layout::common_head(title))
42            }
43            body {
44                div class="container" {
45                    div class="row justify-content-center" {
46                        div class="col-md-8 col-lg-5 narrow-container" {
47                            header class="text-center" {
48                                h1 class="header-title" { "Fedimint Guardian UI" }
49                            }
50
51                            div class="card" {
52                                div class="card-body" {
53                                    (content)
54                                }
55                            }
56                        }
57                    }
58                }
59                script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js" integrity="sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL" crossorigin="anonymous" {}
60            }
61        }
62    }
63}
64
65// GET handler for the /setup route (display the setup form)
66async fn setup_form(State(state): State<AuthState<DynSetupApi>>) -> impl IntoResponse {
67    if state.api.setup_code().await.is_some() {
68        return Redirect::to("/federation-setup").into_response();
69    }
70
71    let content = html! {
72        form method="post" action="/" {
73            style {
74                r#"
75                .toggle-content {
76                    display: none;
77                }
78                
79                .toggle-control:checked ~ .toggle-content {
80                    display: block;
81                }
82                "#
83            }
84
85            div class="form-group mb-4" {
86                input type="text" class="form-control" id="name" name="name" placeholder="Guardian name" required;
87            }
88
89            div class="form-group mb-4" {
90                input type="password" class="form-control" id="password" name="password" placeholder="Secure password" required;
91            }
92
93            div class="form-group mb-4" {
94                div class="form-check" {
95                    input type="checkbox" class="form-check-input toggle-control" id="is_lead" name="is_lead" value="true";
96
97                    label class="form-check-label" for="is_lead" {
98                        "I am the guardian setting up the global configuration for this federation."
99                    }
100
101                    div class="toggle-content mt-3" {
102                        input type="text" class="form-control" id="federation_name" name="federation_name" placeholder="Federation name";
103                    }
104                }
105            }
106
107            div class="button-container" {
108                button type="submit" class="btn btn-primary setup-btn" { "Set Parameters" }
109            }
110        }
111    };
112
113    Html(setup_layout("Setup Fedimint Guardian", content).into_string()).into_response()
114}
115
116// POST handler for the /setup route (process the password setup form)
117async fn setup_submit(
118    State(state): State<AuthState<DynSetupApi>>,
119    Form(input): Form<SetupInput>,
120) -> impl IntoResponse {
121    // Only use federation_name if is_lead is true
122    let federation_name = if input.is_lead {
123        Some(input.federation_name)
124    } else {
125        None
126    };
127
128    match state
129        .api
130        .set_local_parameters(ApiAuth(input.password), input.name, federation_name)
131        .await
132    {
133        Ok(_) => Redirect::to("/login").into_response(),
134        Err(e) => {
135            let content = html! {
136                div class="alert alert-danger" { (e.to_string()) }
137                div class="button-container" {
138                    a href="/" class="btn btn-primary setup-btn" { "Return to Setup" }
139                }
140            };
141
142            Html(setup_layout("Setup Error", content).into_string()).into_response()
143        }
144    }
145}
146
147// GET handler for the /login route (display the login form)
148async fn login_form(State(state): State<AuthState<DynSetupApi>>) -> impl IntoResponse {
149    if state.api.setup_code().await.is_none() {
150        return Redirect::to("/").into_response();
151    }
152
153    login_form_response().into_response()
154}
155
156// POST handler for the /login route (authenticate and set session cookie)
157async fn login_submit(
158    State(state): State<AuthState<DynSetupApi>>,
159    jar: CookieJar,
160    Form(input): Form<LoginInput>,
161) -> impl IntoResponse {
162    let auth = match state.api.auth().await {
163        Some(auth) => auth,
164        None => return Redirect::to("/").into_response(),
165    };
166
167    login_submit_response(
168        auth,
169        state.auth_cookie_name,
170        state.auth_cookie_value,
171        jar,
172        input,
173    )
174    .into_response()
175}
176
177// GET handler for the /federation-setup route (main federation management page)
178async fn federation_setup(
179    State(state): State<AuthState<DynSetupApi>>,
180    jar: CookieJar,
181) -> impl IntoResponse {
182    if !check_auth(&state.auth_cookie_name, &state.auth_cookie_value, &jar).await {
183        return Redirect::to("/login").into_response();
184    }
185
186    let our_connection_info = state
187        .api
188        .setup_code()
189        .await
190        .expect("Successful authentication ensures that the local parameters have been set");
191
192    let connected_peers = state.api.connected_peers().await;
193
194    let content = html! {
195        section class="mb-4" {
196            div class="alert alert-info mb-3" {
197                (our_connection_info)
198            }
199
200            div class="text-center" {
201                button type="button" class="btn btn-outline-primary setup-btn"
202                    onclick=(format!("navigator.clipboard.writeText('{}')", our_connection_info)) {
203                    "Copy to Clipboard"
204                }
205            }
206        }
207
208        hr class="my-4" {}
209
210        section class="mb-4" {
211            ul class="list-group mb-4" {
212                @for peer in connected_peers {
213                    li class="list-group-item" { (peer) }
214                }
215            }
216
217            form method="post" action="/add-connection-info" {
218                div class="mb-3" {
219                    input type="text" class="form-control mb-2" id="peer_info" name="peer_info"
220                        placeholder="Paste setup code from fellow guardian" required;
221                }
222
223                div class="row mt-3" {
224                    div class="col-6" {
225                        button type="button" class="btn btn-warning w-100" onclick="document.getElementById('reset-form').submit();" {
226                            "Reset Guardians"
227                        }
228                    }
229
230                    div class="col-6" {
231                        button type="submit" class="btn btn-primary w-100" { "Add Guardian" }
232                    }
233                }
234            }
235
236            form id="reset-form" method="post" action="/reset-connection-info" class="d-none" {}
237        }
238
239        hr class="my-4" {}
240
241        section class="mb-4" {
242            div class="alert alert-warning mb-4" {
243                "Make sure all information is correct and every guardian is ready before launching the federation. This process cannot be reversed once started."
244            }
245
246            div class="text-center" {
247                form method="post" action="/start-dkg" {
248                    button type="submit" class="btn btn-warning setup-btn" {
249                        "🚀 Launch Federation"
250                    }
251                }
252            }
253        }
254    };
255
256    Html(setup_layout("Federation Setup", content).into_string()).into_response()
257}
258
259// POST handler for adding peer connection info
260async fn add_peer_handler(
261    State(state): State<AuthState<DynSetupApi>>,
262    jar: CookieJar,
263    Form(input): Form<PeerInfoInput>,
264) -> impl IntoResponse {
265    if !check_auth(&state.auth_cookie_name, &state.auth_cookie_value, &jar).await {
266        return Redirect::to("/login").into_response();
267    }
268
269    match state.api.add_peer_setup_code(input.peer_info).await {
270        Ok(..) => Redirect::to("/federation-setup").into_response(),
271        Err(e) => {
272            let content = html! {
273                div class="alert alert-danger" { (e.to_string()) }
274                div class="button-container" {
275                    a href="/federation-setup" class="btn btn-primary setup-btn" { "Return to Setup" }
276                }
277            };
278
279            Html(setup_layout("Error", content).into_string()).into_response()
280        }
281    }
282}
283
284// POST handler for starting the DKG process
285async fn start_dkg_handler(
286    State(state): State<AuthState<DynSetupApi>>,
287    jar: CookieJar,
288) -> impl IntoResponse {
289    if !check_auth(&state.auth_cookie_name, &state.auth_cookie_value, &jar).await {
290        return Redirect::to("/login").into_response();
291    }
292
293    match state.api.start_dkg().await {
294        Ok(()) => {
295            // Show simple DKG success page
296            let content = html! {
297                div class="alert alert-success my-4" {
298                    "The distributed key generation has been started successfully. You can monitor the progress in your server logs."
299                }
300                p class="text-center" {
301                    "Once the distributed key generation completes, the Guardian Dashboard will become available at the root URL."
302                }
303                div class="button-container mt-4" {
304                    a href="/" class="btn btn-primary setup-btn" {
305                        "Go to Dashboard"
306                    }
307                }
308            };
309
310            Html(setup_layout("DKG Started", content).into_string()).into_response()
311        }
312        Err(e) => {
313            let content = html! {
314                div class="alert alert-danger" { (e.to_string()) }
315                div class="button-container" {
316                    a href="/federation-setup" class="btn btn-primary setup-btn" { "Return to Setup" }
317                }
318            };
319
320            Html(setup_layout("Error", content).into_string()).into_response()
321        }
322    }
323}
324
325// POST handler for resetting peer connection info
326async fn reset_peers_handler(
327    State(state): State<AuthState<DynSetupApi>>,
328    jar: CookieJar,
329) -> impl IntoResponse {
330    if !check_auth(&state.auth_cookie_name, &state.auth_cookie_value, &jar).await {
331        return Redirect::to("/login").into_response();
332    }
333
334    state.api.reset_setup_codes().await;
335
336    Redirect::to("/federation-setup").into_response()
337}
338
339pub fn start(
340    api: DynSetupApi,
341    ui_bind: SocketAddr,
342    task_handle: TaskHandle,
343) -> Pin<Box<dyn Future<Output = ()> + Send>> {
344    let app = Router::new()
345        .route("/", get(setup_form).post(setup_submit))
346        .route("/login", get(login_form).post(login_submit))
347        .route("/federation-setup", get(federation_setup))
348        .route("/add-connection-info", post(add_peer_handler))
349        .route("/reset-connection-info", post(reset_peers_handler))
350        .route("/start-dkg", post(start_dkg_handler))
351        .with_static_routes()
352        .with_state(AuthState::new(api));
353
354    Box::pin(async move {
355        let listener = TcpListener::bind(ui_bind)
356            .await
357            .expect("Failed to bind setup UI");
358
359        axum::serve(listener, app.into_make_service())
360            .with_graceful_shutdown(task_handle.make_shutdown_rx())
361            .await
362            .expect("Failed to serve setup UI");
363    })
364}