Skip to main content

iota_node/
admin.rs

1// Copyright (c) Mysten Labs, Inc.
2// Modifications Copyright (c) 2024 IOTA Stiftung
3// SPDX-License-Identifier: Apache-2.0
4
5use std::{net::SocketAddr, str::FromStr, sync::Arc};
6
7use axum::{
8    Router,
9    extract::{Query, State},
10    http::StatusCode,
11    response::{IntoResponse as _, Response},
12    routing::{get, post},
13};
14use base64::Engine;
15use humantime::parse_duration;
16use iota_sdk_types::RandomnessRound;
17use iota_types::{
18    base_types::AuthorityName,
19    crypto::{RandomnessPartialSignature, RandomnessSignature},
20    error::IotaError,
21    traffic_control::TrafficControlReconfigParams,
22};
23use serde::Deserialize;
24use telemetry_subscribers::{TelemetryError, TracingHandle};
25use tokio::sync::oneshot;
26use tracing::info;
27
28use crate::IotaNode;
29
30// Example commands:
31//
32// Set buffer stake for current epoch 2 to 1500 basis points:
33//
34//   $ curl -X POST 'http://127.0.0.1:1337/set-override-buffer-stake?buffer_bps=1500&epoch=2'
35//
36// Clear buffer stake override for current epoch 2, use
37// ProtocolConfig::buffer_stake_for_protocol_upgrade_bps:
38//
39//   $ curl -X POST 'http://127.0.0.1:1337/clear-override-buffer-stake?epoch=2'
40//
41// Vote to close epoch 2 early
42//
43//   $ curl -X POST 'http://127.0.0.1:1337/force-close-epoch?epoch=2'
44//
45// View current all capabilities from all authorities that have been received by
46// this node:
47//
48//   $ curl 'http://127.0.0.1:1337/capabilities'
49//
50// View the node config (private keys will be masked):
51//
52//   $ curl 'http://127.0.0.1:1337/node-config'
53//
54// Set a time-limited tracing config. After the duration expires, tracing will
55// be disabled automatically.
56//
57//   $ curl -X POST 'http://127.0.0.1:1337/enable-tracing?filter=info&duration=10s'
58//
59// Reset tracing to the TRACE_FILTER env var.
60//
61//   $ curl -X POST 'http://127.0.0.1:1337/reset-tracing'
62//
63// Get the node's randomness partial signatures for round 123.
64//
65//  $ curl 'http://127.0.0.1:1337/randomness-partial-sigs?round=123'
66//
67// Inject a randomness partial signature from another node, bypassing validity
68// checks.
69//
70//  $ curl 'http://127.0.0.1:1337/randomness-inject-partial-sigs?authority_name=hexencodedname&round=123&sigs=base64encodedsigs'
71//
72// Inject a full signature from another node, bypassing validity checks.
73//
74//  $ curl 'http://127.0.0.1:1337/randomness-inject-full-sig?round=123&sigs=base64encodedsig'
75//
76// Reconfigure traffic control policy
77//
78//  $ curl 'http://127.0.0.1:1337/traffic-control?error_threshold=100&spam_threshold=100&dry_run=true'
79//
80// View the Prometheus metrics filter: the directives currently in effect and
81// the startup directives (config with the METRICS_FILTER env var merged over
82// it) that a reset restores.
83//
84//   $ curl 'http://127.0.0.1:1337/metrics/filters'
85//
86// Set the runtime override for the metrics the /metrics endpoint exposes.
87// Patterns may be group names from the `metrics.groups` config section
88// (`default` and `hardware` included) or raw METRICS_FILTER-style patterns.
89// The override is merged over the startup directives: an override directive
90// replaces the startup directive with the same pattern, and otherwise the
91// most specific matching pattern decides each metric, whichever source it
92// came from. A bare level (e.g. `trace`) replaces the startup directives
93// entirely, exposing everything up to that level; `default=LEVEL` changes
94// only the level for the metrics no other directive matches. Each POST
95// starts from the startup directives again rather than stacking on the
96// previous override.
97//
98//   $ curl -X POST 'http://127.0.0.1:1337/metrics/filters?filter=consensus=off,typed_store=warn'
99//
100// Drop the runtime override, restoring the startup configuration.
101//
102//   $ curl -X POST 'http://127.0.0.1:1337/metrics/filters/reset'
103
104const LOGGING_ROUTE: &str = "/logging";
105const TRACING_ROUTE: &str = "/enable-tracing";
106const TRACING_RESET_ROUTE: &str = "/reset-tracing";
107const SET_BUFFER_STAKE_ROUTE: &str = "/set-override-buffer-stake";
108const CLEAR_BUFFER_STAKE_ROUTE: &str = "/clear-override-buffer-stake";
109const FORCE_CLOSE_EPOCH: &str = "/force-close-epoch";
110const CAPABILITIES: &str = "/capabilities";
111const NODE_CONFIG: &str = "/node-config";
112const RANDOMNESS_PARTIAL_SIGS_ROUTE: &str = "/randomness-partial-sigs";
113const RANDOMNESS_INJECT_PARTIAL_SIGS_ROUTE: &str = "/randomness-inject-partial-sigs";
114const RANDOMNESS_INJECT_FULL_SIG_ROUTE: &str = "/randomness-inject-full-sig";
115const FLAMEGRAPH_ROUTE: &str = "/flamegraph";
116const TRAFFIC_CONTROL: &str = "/traffic-control";
117const METRICS_FILTER_ROUTE: &str = "/metrics/filters";
118const METRICS_FILTER_RESET_ROUTE: &str = "/metrics/filters/reset";
119
120struct AppState {
121    node: Arc<IotaNode>,
122    tracing_handle: TracingHandle,
123}
124
125pub async fn run_admin_server(
126    node: Arc<IotaNode>,
127    socket_address: SocketAddr,
128    tracing_handle: TracingHandle,
129) {
130    let filter = tracing_handle.get_log().unwrap();
131
132    let app_state = AppState {
133        node,
134        tracing_handle,
135    };
136
137    let app = Router::new()
138        .route(LOGGING_ROUTE, get(get_filter))
139        .route(CAPABILITIES, get(capabilities))
140        .route(NODE_CONFIG, get(node_config))
141        .route(LOGGING_ROUTE, post(set_filter))
142        .route(
143            SET_BUFFER_STAKE_ROUTE,
144            post(set_override_protocol_upgrade_buffer_stake),
145        )
146        .route(
147            CLEAR_BUFFER_STAKE_ROUTE,
148            post(clear_override_protocol_upgrade_buffer_stake),
149        )
150        .route(FORCE_CLOSE_EPOCH, post(force_close_epoch))
151        .route(TRACING_ROUTE, post(enable_tracing))
152        .route(TRACING_RESET_ROUTE, post(reset_tracing))
153        .route(RANDOMNESS_PARTIAL_SIGS_ROUTE, get(randomness_partial_sigs))
154        .route(
155            RANDOMNESS_INJECT_PARTIAL_SIGS_ROUTE,
156            post(randomness_inject_partial_sigs),
157        )
158        .route(
159            RANDOMNESS_INJECT_FULL_SIG_ROUTE,
160            post(randomness_inject_full_sig),
161        )
162        .route(FLAMEGRAPH_ROUTE, get(flamegraph))
163        .route(TRAFFIC_CONTROL, post(traffic_control))
164        .route(METRICS_FILTER_ROUTE, get(get_metrics_filter))
165        .route(METRICS_FILTER_ROUTE, post(set_metrics_filter))
166        .route(METRICS_FILTER_RESET_ROUTE, post(reset_metrics_filter))
167        .with_state(Arc::new(app_state));
168
169    info!(
170        filter =% filter,
171        address =% socket_address,
172        "starting admin server"
173    );
174
175    let listener = tokio::net::TcpListener::bind(&socket_address)
176        .await
177        .unwrap();
178    axum::serve(
179        listener,
180        app.into_make_service_with_connect_info::<SocketAddr>(),
181    )
182    .await
183    .unwrap();
184}
185
186#[derive(Deserialize)]
187struct EnableTracing {
188    // These params change the filter, and reset it after the duration expires.
189    filter: Option<String>,
190    duration: Option<String>,
191
192    // Change the trace output file (if file output was enabled at program start)
193    trace_file: Option<String>,
194
195    // Change the tracing sample rate
196    sample_rate: Option<f64>,
197}
198
199async fn enable_tracing(
200    State(state): State<Arc<AppState>>,
201    query: Query<EnableTracing>,
202) -> (StatusCode, String) {
203    let Query(EnableTracing {
204        filter,
205        duration,
206        trace_file,
207        sample_rate,
208    }) = query;
209
210    let mut response = Vec::new();
211
212    if let Some(sample_rate) = sample_rate {
213        state.tracing_handle.update_sampling_rate(sample_rate);
214        response.push(format!("sample rate set to {sample_rate:?}"));
215    }
216
217    if let Some(trace_file) = trace_file {
218        if let Err(err) = state.tracing_handle.update_trace_file(&trace_file) {
219            response.push(format!("can't update trace file: {err:?}"));
220            return (StatusCode::BAD_REQUEST, response.join("\n"));
221        } else {
222            response.push(format!("trace file set to {trace_file:?}"));
223        }
224    }
225
226    let Some(filter) = filter else {
227        return (StatusCode::OK, response.join("\n"));
228    };
229
230    // Duration is required if filter is set
231    let Some(duration) = duration else {
232        response.push("can't update filter: missing duration".into());
233        return (StatusCode::BAD_REQUEST, response.join("\n"));
234    };
235
236    let Ok(duration) = parse_duration(&duration) else {
237        response.push("can't update filter: invalid duration".into());
238        return (StatusCode::BAD_REQUEST, response.join("\n"));
239    };
240
241    match state.tracing_handle.update_trace_filter(&filter, duration) {
242        Ok(()) => {
243            response.push(format!("filter set to {filter:?}"));
244            response.push(format!("filter will be reset after {duration:?}"));
245            (StatusCode::OK, response.join("\n"))
246        }
247        Err(TelemetryError::TracingDisabled) => {
248            response.push("can't update filter: tracing is not enabled. to enable it, run the node with 'TRACE_FILTER' set.".into());
249            (StatusCode::NOT_IMPLEMENTED, response.join("\n"))
250        }
251        Err(err) => {
252            response.push(format!("can't update filter: {err:?}"));
253            (StatusCode::BAD_REQUEST, response.join("\n"))
254        }
255    }
256}
257
258async fn reset_tracing(State(state): State<Arc<AppState>>) -> (StatusCode, String) {
259    match state.tracing_handle.reset_trace() {
260        Ok(()) => (
261            StatusCode::OK,
262            "tracing filter reset to TRACE_FILTER env var".into(),
263        ),
264        Err(TelemetryError::TracingDisabled) => (
265            StatusCode::NOT_IMPLEMENTED,
266            "tracing is not enabled. to enable it, run the node with 'TRACE_FILTER' set.".into(),
267        ),
268        Err(err) => (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()),
269    }
270}
271
272async fn get_filter(State(state): State<Arc<AppState>>) -> (StatusCode, String) {
273    match state.tracing_handle.get_log() {
274        Ok(filter) => (StatusCode::OK, filter),
275        Err(err) => (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()),
276    }
277}
278
279async fn set_filter(
280    State(state): State<Arc<AppState>>,
281    new_filter: String,
282) -> (StatusCode, String) {
283    match state.tracing_handle.update_log(&new_filter) {
284        Ok(()) => {
285            info!(filter =% new_filter, "Log filter updated");
286            (StatusCode::OK, "".into())
287        }
288        Err(err) => (StatusCode::BAD_REQUEST, err.to_string()),
289    }
290}
291
292async fn capabilities(State(state): State<Arc<AppState>>) -> (StatusCode, String) {
293    let epoch_store = state.node.state().load_epoch_store_one_call_per_task();
294
295    let mut output = String::new();
296    let capabilities = epoch_store.get_capabilities_v1();
297    for capability in capabilities.unwrap_or_default() {
298        output.push_str(&format!("{capability:?}\n"));
299    }
300
301    (StatusCode::OK, output)
302}
303
304async fn node_config(State(state): State<Arc<AppState>>) -> (StatusCode, String) {
305    let node_config = &state.node.config;
306
307    // Note private keys will be masked
308    (StatusCode::OK, format!("{node_config:#?}\n"))
309}
310
311#[derive(Deserialize)]
312struct Epoch {
313    epoch: u64,
314}
315
316async fn clear_override_protocol_upgrade_buffer_stake(
317    State(state): State<Arc<AppState>>,
318    epoch: Query<Epoch>,
319) -> (StatusCode, String) {
320    let Query(Epoch { epoch }) = epoch;
321
322    match state
323        .node
324        .clear_override_protocol_upgrade_buffer_stake(epoch)
325    {
326        Ok(()) => (
327            StatusCode::OK,
328            "protocol upgrade buffer stake cleared\n".to_string(),
329        ),
330        Err(err) => (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()),
331    }
332}
333
334#[derive(Deserialize)]
335struct SetBufferStake {
336    buffer_bps: u64,
337    epoch: u64,
338}
339
340async fn set_override_protocol_upgrade_buffer_stake(
341    State(state): State<Arc<AppState>>,
342    buffer_state: Query<SetBufferStake>,
343) -> (StatusCode, String) {
344    let Query(SetBufferStake { buffer_bps, epoch }) = buffer_state;
345
346    match state
347        .node
348        .set_override_protocol_upgrade_buffer_stake(epoch, buffer_bps)
349    {
350        Ok(()) => (
351            StatusCode::OK,
352            format!("protocol upgrade buffer stake set to '{buffer_bps}'\n"),
353        ),
354        Err(err) => (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()),
355    }
356}
357
358async fn force_close_epoch(
359    State(state): State<Arc<AppState>>,
360    epoch: Query<Epoch>,
361) -> (StatusCode, String) {
362    let Query(Epoch {
363        epoch: expected_epoch,
364    }) = epoch;
365    let epoch_store = state.node.state().load_epoch_store_one_call_per_task();
366    let actual_epoch = epoch_store.epoch();
367    if actual_epoch != expected_epoch {
368        let err = IotaError::WrongEpoch {
369            expected_epoch,
370            actual_epoch,
371        };
372        return (StatusCode::INTERNAL_SERVER_ERROR, err.to_string());
373    }
374
375    match state.node.close_epoch(&epoch_store).await {
376        Ok(()) => (
377            StatusCode::OK,
378            "close_epoch() called successfully\n".to_string(),
379        ),
380        Err(err) => (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()),
381    }
382}
383
384#[derive(Deserialize)]
385struct Round {
386    round: u64,
387}
388
389async fn randomness_partial_sigs(
390    State(state): State<Arc<AppState>>,
391    round: Query<Round>,
392) -> (StatusCode, String) {
393    let Query(Round { round }) = round;
394
395    let (tx, rx) = oneshot::channel();
396    state
397        .node
398        .randomness_handle()
399        .admin_get_partial_signatures(RandomnessRound::new(round), tx);
400
401    let sigs = match rx.await {
402        Ok(sigs) => sigs,
403        Err(err) => return (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()),
404    };
405
406    let output = format!(
407        "{}\n",
408        base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(sigs)
409    );
410
411    (StatusCode::OK, output)
412}
413
414#[derive(Deserialize)]
415struct PartialSigsToInject {
416    hex_authority_name: String,
417    round: u64,
418    base64_sigs: String,
419}
420
421async fn randomness_inject_partial_sigs(
422    State(state): State<Arc<AppState>>,
423    args: Query<PartialSigsToInject>,
424) -> (StatusCode, String) {
425    let Query(PartialSigsToInject {
426        hex_authority_name,
427        round,
428        base64_sigs,
429    }) = args;
430
431    let authority_name = match AuthorityName::from_str(hex_authority_name.as_str()) {
432        Ok(authority_name) => authority_name,
433        Err(err) => return (StatusCode::BAD_REQUEST, err.to_string()),
434    };
435
436    let sigs: Vec<u8> = match base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(base64_sigs) {
437        Ok(sigs) => sigs,
438        Err(err) => return (StatusCode::BAD_REQUEST, err.to_string()),
439    };
440
441    let sigs: Vec<RandomnessPartialSignature> = match bcs::from_bytes(&sigs) {
442        Ok(sigs) => sigs,
443        Err(err) => return (StatusCode::BAD_REQUEST, err.to_string()),
444    };
445
446    let (tx_result, rx_result) = oneshot::channel();
447    state
448        .node
449        .randomness_handle()
450        .admin_inject_partial_signatures(
451            authority_name,
452            RandomnessRound::new(round),
453            sigs,
454            tx_result,
455        );
456
457    match rx_result.await {
458        Ok(Ok(())) => (StatusCode::OK, "partial signatures injected\n".to_string()),
459        Ok(Err(e)) => (StatusCode::BAD_REQUEST, e.to_string()),
460        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
461    }
462}
463
464#[derive(Deserialize)]
465struct FullSigToInject {
466    round: u64,
467    base64_sig: String,
468}
469
470async fn randomness_inject_full_sig(
471    State(state): State<Arc<AppState>>,
472    args: Query<FullSigToInject>,
473) -> (StatusCode, String) {
474    let Query(FullSigToInject { round, base64_sig }) = args;
475
476    let sig: Vec<u8> = match base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(base64_sig) {
477        Ok(sig) => sig,
478        Err(err) => return (StatusCode::BAD_REQUEST, err.to_string()),
479    };
480
481    let sig: RandomnessSignature = match bcs::from_bytes(&sig) {
482        Ok(sig) => sig,
483        Err(err) => return (StatusCode::BAD_REQUEST, err.to_string()),
484    };
485
486    let (tx_result, rx_result) = oneshot::channel();
487    state.node.randomness_handle().admin_inject_full_signature(
488        RandomnessRound::new(round),
489        sig,
490        tx_result,
491    );
492
493    match rx_result.await {
494        Ok(Ok(())) => (StatusCode::OK, "full signature injected\n".to_string()),
495        Ok(Err(e)) => (StatusCode::BAD_REQUEST, e.to_string()),
496        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
497    }
498}
499
500#[derive(Deserialize)]
501struct Flamegraph {
502    /// Toggle SVG response, otherwise return nested set model for Grafana.
503    #[serde(default)]
504    svg: bool,
505    /// SVG width in pixels (when missing or set to 0 will default to 1920).
506    #[serde(default)]
507    width: usize,
508    /// Select still running call graphs.
509    #[serde(default)]
510    running: bool,
511    /// Select already completed call graphs.
512    #[serde(default)]
513    completed: bool,
514    /// Select call graph with the given ID.
515    #[serde(default)]
516    graph_id: String,
517    /// Use memory allocations as span measure rather than duration.
518    #[serde(default)]
519    mem: bool,
520}
521
522async fn flamegraph(State(state): State<Arc<AppState>>, query: Query<Flamegraph>) -> Response {
523    if let Some(sub) = state.tracing_handle.get_flamegraph() {
524        let Query(Flamegraph {
525            svg,
526            width,
527            mut running,
528            mut completed,
529            graph_id,
530            mem,
531        }) = query;
532        if !running && !completed {
533            running = true;
534            completed = true;
535        }
536        if svg {
537            #[cfg(not(all(feature = "flamegraph-alloc", nightly)))]
538            {
539                if mem {
540                    return (
541                        StatusCode::BAD_REQUEST,
542                        "memory flamegraphs are not supported (re-run iota-node with 'flamegraph-alloc' feature enabled and on nightly Rust toolchain)",
543                    )
544                        .into_response();
545                }
546            }
547
548            // draw an svg
549            let width = if width == 0 { Some(1920) } else { Some(width) };
550            let config = telemetry_subscribers::flamegraph::SvgConfig {
551                width,
552                #[cfg(all(feature = "flamegraph-alloc", nightly))]
553                measure_mem: mem,
554                ..Default::default()
555            };
556            let svg = if !graph_id.is_empty() {
557                sub.get_svg(&graph_id, running, completed, &config)
558            } else {
559                sub.get_combined_svg("iota-node", running, completed, &config)
560            };
561            if let Some(svg) = svg {
562                (
563                    [(
564                        axum::http::header::CONTENT_TYPE,
565                        axum::http::header::HeaderValue::from_static("image/svg+xml"),
566                    )],
567                    svg.into_string(),
568                )
569                    .into_response()
570            } else {
571                (StatusCode::NOT_FOUND, "Flamegraphs not found\n").into_response()
572            }
573        } else {
574            // default nested set model for grafana
575            let nested_frames = if !graph_id.is_empty() {
576                sub.get_nested_set(&graph_id, running, completed)
577            } else {
578                sub.get_nested_sets("iota-node", running, completed)
579            };
580            if !nested_frames.is_empty() {
581                axum::Json(nested_frames).into_response()
582            } else {
583                (StatusCode::NOT_FOUND, "Flamegraphs not found\n").into_response()
584            }
585        }
586    } else {
587        (
588            StatusCode::NOT_FOUND,
589            "Flamegraphs are not enabled (re-run iota-node with TRACE_FLAMEGRAPH=1)\n",
590        )
591            .into_response()
592    }
593}
594
595async fn traffic_control(
596    State(state): State<Arc<AppState>>,
597    args: Query<TrafficControlReconfigParams>,
598) -> (StatusCode, String) {
599    let Query(params) = args;
600    match state.node.state().reconfigure_traffic_control(params).await {
601        Ok(updated_state) => (
602            StatusCode::OK,
603            format!(
604                "Traffic control configured with:\n\
605                 Error threshold: {:?}\n\
606                 Spam threshold: {:?}\n\
607                 Dry run: {:?}\n",
608                updated_state.error_threshold, updated_state.spam_threshold, updated_state.dry_run
609            ),
610        ),
611        Err(err) => (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()),
612    }
613}
614
615async fn get_metrics_filter(State(state): State<Arc<AppState>>) -> (StatusCode, String) {
616    let filter = state.node.registry_service().filter();
617    (
618        StatusCode::OK,
619        format!(
620            "metrics exposure filter:\n\
621             current: {}\n\n\n\
622             (startup: {})\n",
623            filter.filter_string(),
624            filter.startup_filter_string(),
625        ),
626    )
627}
628
629#[derive(Deserialize)]
630struct MetricsFilterUpdate {
631    filter: String,
632}
633
634async fn set_metrics_filter(
635    State(state): State<Arc<AppState>>,
636    Query(MetricsFilterUpdate { filter }): Query<MetricsFilterUpdate>,
637) -> (StatusCode, String) {
638    let new_filter = filter.trim();
639    match state.node.registry_service().set_runtime_filter(new_filter) {
640        Ok(()) => {
641            info!(filter =% new_filter, "Metrics filter updated");
642            (
643                StatusCode::OK,
644                format!("metrics filter set to {new_filter:?}\n"),
645            )
646        }
647        Err(err) => (StatusCode::BAD_REQUEST, format!("{err}\n")),
648    }
649}
650
651async fn reset_metrics_filter(State(state): State<Arc<AppState>>) -> (StatusCode, String) {
652    state.node.registry_service().reset_runtime_filter();
653    info!("Metrics filter reset to startup configuration");
654    (
655        StatusCode::OK,
656        "metrics filter reset to startup configuration\n".into(),
657    )
658}