1
#[macro_use]
2
extern crate rust_i18n;
3

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

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

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

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

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

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

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

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

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

            
70
    let response = next.run(req).await;
71

            
72
    let latency = start.elapsed().as_secs_f64();
73
    let status = response.status().as_u16().to_string();
74

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

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

            
84
    response
85
}
86

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

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

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

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

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

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

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

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

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

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

            
181
    let redis_client = connect_redis(&conf.redis_url).await?;
182

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

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

            
195
    // Validated before anything serves: getting this wrong either throttles
196
    // every user into one shared budget or lets them forge their identity, and
197
    // both failures are quiet.
198
    let limiter = Arc::new(rate_limit::AuthLimiter::new(
199
        client_ip::TrustedProxies::from_env()?,
200
    ));
201

            
202
    let router = Router::new()
203
        .route("/", get(pages::index))
204
        .route("/login", get(pages::login))
205
        .merge(create_admin_pages_router(state.clone()))
206
        .merge(create_pages_router(state.clone()))
207
        .merge(create_accounts_router(state.clone()))
208
        .merge(create_transactions_router(state.clone()))
209
        .merge(create_tags_router(state.clone()))
210
        .merge(create_reports_router(state.clone()));
211

            
212
    let router = router.merge(create_scripts_router(state.clone()));
213

            
214
    let router = router
215
        .nest(
216
            "/api",
217
            create_api_router(state.clone(), Arc::clone(&limiter)),
218
        )
219
        // Every authenticated request decodes a JWT, and decoding reads the
220
        // claimed subject's key from the directory before anything is verified.
221
        // Applied here so it covers every page and API route; static assets are
222
        // nested below and need no database.
223
        .route_layer(middleware::from_fn_with_state(
224
            limiter,
225
            rate_limit::throttle_verification,
226
        ))
227
        .nest_service(
228
            "/static",
229
            ServeDir::new(std::env::var("STATIC_PATH").unwrap_or("web/static".to_string())),
230
        )
231
        .with_state(state)
232
        .layer(cors)
233
        .route_layer(middleware::from_fn(track_metrics));
234

            
235
    let app = metrics_app()?;
236

            
237
    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
238
    let metrics_listener = tokio::net::TcpListener::bind("0.0.0.0:3001").await?;
239
    // Either listener dying is fatal: a live app port with a dead metrics port
240
    // (or vice versa) is a half-running process that k8s cannot see is broken.
241
    let (metrics_server, main_server) = tokio::join!(
242
        run_server(metrics_listener, app),
243
        run_server(listener, router)
244
    );
245
    metrics_server?;
246
    main_server?;
247
    Ok(())
248
}
249

            
250
async fn run_server(listener: tokio::net::TcpListener, app: Router) -> std::io::Result<()> {
251
    // `with_connect_info` is what makes the peer address reachable from a
252
    // handler. Without it the throttle has nothing trustworthy to key on.
253
    axum::serve(
254
        listener,
255
        app.into_make_service_with_connect_info::<std::net::SocketAddr>(),
256
    )
257
    .await
258
}