1
#[macro_use]
2
extern crate rust_i18n;
3

            
4
i18n!("locales", fallback = "en");
5

            
6
mod auth_keys;
7
mod config;
8
mod files;
9
mod handler;
10
mod jwt_auth;
11
mod model;
12
mod pages;
13
mod redirect_middleware;
14
mod response;
15
mod route;
16
mod telemetry;
17
mod token;
18

            
19
use axum::{
20
    Router,
21
    extract::{MatchedPath, Request},
22
    middleware::{self, Next},
23
    response::IntoResponse,
24
    routing::get,
25
};
26

            
27
use axum::http::{
28
    HeaderValue, Method,
29
    header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE},
30
};
31

            
32
use redis::Client;
33
use std::sync::Arc;
34
use std::time::Duration;
35
use std::{future::ready, time::Instant};
36
use tower_http::{cors::CorsLayer, services::ServeDir};
37
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
38

            
39
use config::Config;
40
use route::create_scripts_router;
41
use route::{
42
    create_accounts_router, create_admin_pages_router, create_api_router, create_pages_router,
43
    create_reports_router, create_tags_router, create_transactions_router,
44
};
45

            
46
pub struct AppState {
47
    conf: Config,
48
    redis_client: Client,
49
    frac: i64,
50
}
51

            
52
/// The metrics listener. Fails rather than panics: a recorder that cannot be
53
/// installed is a startup fault, and `main` already reports those.
54
fn metrics_app() -> anyhow::Result<Router> {
55
    let recorder_handle = telemetry::install_recorder()?;
56
    Ok(Router::new().route("/metrics", get(move || ready(recorder_handle.render()))))
57
}
58

            
59
async fn track_metrics(req: Request, next: Next) -> impl IntoResponse {
60
    let start = Instant::now();
61
    let path = if let Some(matched_path) = req.extensions().get::<MatchedPath>() {
62
        matched_path.as_str().to_owned()
63
    } else {
64
        req.uri().path().to_owned()
65
    };
66
    let method = req.method().clone();
67

            
68
    let response = next.run(req).await;
69

            
70
    let latency = start.elapsed().as_secs_f64();
71
    let status = response.status().as_u16().to_string();
72

            
73
    let labels = [
74
        ("method", method.to_string()),
75
        ("path", path),
76
        ("status", status),
77
    ];
78

            
79
    metrics::counter!(telemetry::HTTP_REQUESTS, &labels).increment(1);
80
    metrics::histogram!(telemetry::HTTP_REQUEST_DURATION, &labels).record(latency);
81

            
82
    response
83
}
84

            
85
/// How long the session store may take to accept a connection at startup.
86
///
87
/// Redis runs beside web in the same pod and the two start together, so a
88
/// refused connection here means "not listening yet", not "misconfigured".
89
/// Bounded rather than infinite: a Redis that is genuinely absent must fail the
90
/// process, not leave it waiting and reported healthy.
91
const REDIS_WAIT: Duration = Duration::from_secs(30);
92

            
93
/// Pause between connection attempts.
94
const REDIS_RETRY_PAUSE: Duration = Duration::from_secs(1);
95

            
96
/// Whether an error means "not up yet" rather than "wrong".
97
///
98
/// Only the store being absent is worth waiting out. A refused password or a
99
/// bad database number will be refused just as firmly in thirty seconds, so
100
/// retrying one buries a configuration error under a startup delay.
101
///
102
/// Deliberately narrower than `is_io_error`, which is every `io::Error` the
103
/// crate wraps — a name that does not resolve included. That is a wrong
104
/// address, not a slow one, and it should fail at once.
105
fn redis_not_up_yet(e: &redis::RedisError) -> bool {
106
    // Refusal covers the case this exists for: the sidecar has not bound its
107
    // port yet. On unix it also covers a socket file that is not there yet.
108
    e.is_connection_refusal() || e.is_timeout()
109
}
110

            
111
/// Opens the session store, waiting for it to come up.
112
///
113
/// Reports the address rather than the configured URL: that URL may carry
114
/// credentials, and this error reaches the logs.
115
async fn connect_redis(url: &str) -> anyhow::Result<Client> {
116
    let client =
117
        Client::open(url.to_string()).map_err(|e| anyhow::anyhow!("redis url unusable: {e}"))?;
118
    let addr = client.get_connection_info().addr().to_string();
119
    let deadline = Instant::now() + REDIS_WAIT;
120
    let mut last = "no attempt completed".to_string();
121

            
122
    loop {
123
        let remaining = deadline.saturating_duration_since(Instant::now());
124
        if remaining.is_zero() {
125
            return Err(anyhow::anyhow!(
126
                "redis at {addr} did not accept a connection within {REDIS_WAIT:?}: {last}"
127
            ));
128
        }
129

            
130
        // Each attempt carries the remaining budget: the connect future has no
131
        // timeout of its own, so a half-open socket would hang past REDIS_WAIT
132
        // and make the bound a fiction.
133
        //
134
        // The multiplexed async connection, not `get_connection`: the blocking
135
        // one stalls the runtime this is awaited on.
136
        match tokio::time::timeout(remaining, client.get_multiplexed_async_connection()).await {
137
            Ok(Ok(_)) => {
138
                log::info!("connected to redis at {addr}");
139
                return Ok(client);
140
            }
141
            Ok(Err(e)) if !redis_not_up_yet(&e) => {
142
                return Err(anyhow::anyhow!(
143
                    "redis at {addr} refused the connection: {e}"
144
                ));
145
            }
146
            Ok(Err(e)) => last = e.to_string(),
147
            Err(_) => last = "the connection attempt timed out".to_string(),
148
        }
149
        log::warn!("redis at {addr} not ready ({last}); retrying");
150

            
151
        let left = deadline.saturating_duration_since(Instant::now());
152
        tokio::time::sleep(REDIS_RETRY_PAUSE.min(left)).await;
153
    }
154
}
155

            
156
#[tokio::main]
157
async fn main() -> anyhow::Result<()> {
158
    tracing_subscriber::registry()
159
        .with(
160
            tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
161
        )
162
        .with(tracing_subscriber::fmt::layer())
163
        .try_init()?;
164

            
165
    // Migrate + seed the admin DB before reading config: seeding moved out of
166
    // the migration set into the `seed_complete`-guarded bootstrap, so a fresh
167
    // DB has no `site_url` until `boot` runs. Idempotent on an already-booted
168
    // DB. Without this, `Config::init` panics with NoConfig("site_url").
169
    server::boot()
170
        .await
171
        .map_err(|e| anyhow::anyhow!("server boot failed: {e:?}"))?;
172
    log::debug!("Server boot complete");
173

            
174
    let conf = Config::init()
175
        .await
176
        .map_err(|e| anyhow::anyhow!("config init failed: {e:?}"))?;
177
    log::debug!("Config ready");
178

            
179
    let redis_client = connect_redis(&conf.redis_url).await?;
180

            
181
    let cors = CorsLayer::new()
182
        .allow_origin(conf.site_url.parse::<HeaderValue>()?)
183
        .allow_methods([Method::GET, Method::POST, Method::PATCH, Method::DELETE])
184
        .allow_credentials(true)
185
        .allow_headers([AUTHORIZATION, ACCEPT, CONTENT_TYPE]);
186

            
187
    let state = Arc::new(AppState {
188
        conf,
189
        redis_client: redis_client.clone(),
190
        frac: 0,
191
    });
192

            
193
    let router = Router::new()
194
        .route("/", get(pages::index))
195
        .route("/login", get(pages::login))
196
        .merge(create_admin_pages_router(state.clone()))
197
        .merge(create_pages_router(state.clone()))
198
        .merge(create_accounts_router(state.clone()))
199
        .merge(create_transactions_router(state.clone()))
200
        .merge(create_tags_router(state.clone()))
201
        .merge(create_reports_router(state.clone()));
202

            
203
    let router = router.merge(create_scripts_router(state.clone()));
204

            
205
    let router = router
206
        .nest("/api", create_api_router(state.clone()))
207
        .nest_service(
208
            "/static",
209
            ServeDir::new(std::env::var("STATIC_PATH").unwrap_or("web/static".to_string())),
210
        )
211
        .with_state(state)
212
        .layer(cors)
213
        .route_layer(middleware::from_fn(track_metrics));
214

            
215
    let app = metrics_app()?;
216

            
217
    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
218
    let metrics_listener = tokio::net::TcpListener::bind("0.0.0.0:3001").await?;
219
    // Either listener dying is fatal: a live app port with a dead metrics port
220
    // (or vice versa) is a half-running process that k8s cannot see is broken.
221
    let (metrics_server, main_server) = tokio::join!(
222
        run_server(metrics_listener, app),
223
        run_server(listener, router)
224
    );
225
    metrics_server?;
226
    main_server?;
227
    Ok(())
228
}
229

            
230
async fn run_server(listener: tokio::net::TcpListener, app: Router) -> std::io::Result<()> {
231
    axum::serve(listener, app).await
232
}