Skip to content
Clone

feat(im): add initial implementation for instant messaging service with JWT support

ZhenYi committed
parent 092c8d4 commit 887ce6e Browse files

46 files changed

+1095 −352

This change is very large, so some diffs are not shown. Clone the repository to see the full change.

File tree 46
Modified app/githost/src/main.rs View app/githost/src/main.rs
@@ -30,7 +30,7 @@
3030 .with_sync_settings(cfg.git.sync.clone());
3131 let ssh_svc = git::ssh::service::GitSshService::new(smart_svc.clone(), cfg.git.ssh.clone())
3232 .with_sync_settings(cfg.git.sync.clone());
33 let smart_router = git::smart::handler::router(smart_svc);
33 let smart_router = git::smart::handler::router(smart_svc).merge(export::health::router(state.clone()));
3434
3535 let http_addr = SocketAddr::new(cfg.githost.http_bind_addr(), cfg.githost.http_port());
3636 let http_listener = TcpListener::bind(http_addr)
Renamed lib/chat/src/ws/frames.rs → app/im/src/frames.rs View app/im/src/frames.rs
@@ -1,7 +1,6 @@
1use chat::services::messages::MessageDto;
12use serde::{Deserialize, Serialize};
23use uuid::Uuid;
3
4use crate::services::messages::MessageDto;
54
65#[derive(Debug, Clone, Deserialize)]
76#[serde(tag = "op", rename_all = "snake_case")]
Added app/im/src/gateway.rs View app/im/src/gateway.rs
@@ -0,0 +1,251 @@
1use std::convert::Infallible;
2
3use chat::services::service::ChatService;
4use http_body_util::{BodyExt as _, Empty, Full};
5use hyper::body::{Bytes, Incoming};
6use hyper::header::{
7 HeaderValue, SEC_WEBSOCKET_ACCEPT, SEC_WEBSOCKET_KEY, SEC_WEBSOCKET_PROTOCOL,
8 SEC_WEBSOCKET_VERSION,
9};
10use hyper::{Method, Request, Response, StatusCode};
11use hyper_util::rt::TokioIo;
12use sea_orm::{ConnectionTrait, DbBackend, Statement};
13use session::SessionGuard;
14use tokio_tungstenite::WebSocketStream;
15use tokio_tungstenite::tungstenite::handshake::derive_accept_key;
16use tokio_tungstenite::tungstenite::protocol::{Role, WebSocketConfig};
17use uuid::Uuid;
18
19use crate::jwt::consume_jti;
20use crate::socket::{ServerWs, handle_socket};
21
22const WS_PATH: &str = "/ws/v1";
23const JWT_PROTOCOL_PREFIX: &str = "gitdata.jwt.";
24
25type BoxBody = http_body_util::combinators::BoxBody<Bytes, Infallible>;
26
27pub async fn route(
28 service: ChatService,
29 mut req: Request<Incoming>,
30) -> Result<Response<BoxBody>, Infallible> {
31 match (req.method(), req.uri().path()) {
32 (&Method::GET, "/healthz") => Ok(text_response(StatusCode::OK, "ok")),
33 (&Method::GET, "/readyz") => Ok(readiness(&service).await),
34 (&Method::GET, WS_PATH) => Ok(handshake(service, &mut req).await),
35 _ => Ok(text_response(StatusCode::NOT_FOUND, "not found")),
36 }
37}
38
39fn text_response(status: StatusCode, body: &'static str) -> Response<BoxBody> {
40 Response::builder()
41 .status(status)
42 .header("content-type", "text/plain; charset=utf-8")
43 .body(Full::new(Bytes::from(body)).boxed())
44 .unwrap()
45}
46
47async fn readiness(service: &ChatService) -> Response<BoxBody> {
48 let db_ok = service
49 .db()
50 .read()
51 .query_one_raw(Statement::from_sql_and_values(
52 DbBackend::Postgres,
53 "SELECT 1",
54 [],
55 ))
56 .await
57 .is_ok();
58 let mut conn = service.state.redis.connection_clone();
59 let redis_ok: bool = match &mut conn {
60 export::RedisConnection::Standalone(c) => {
61 redis::cmd("PING").query_async::<String>(c).await.is_ok()
62 }
63 export::RedisConnection::Cluster(c) => {
64 redis::cmd("PING").query_async::<String>(c).await.is_ok()
65 }
66 };
67 if db_ok && redis_ok {
68 text_response(StatusCode::OK, "ready")
69 } else {
70 text_response(StatusCode::SERVICE_UNAVAILABLE, "not ready")
71 }
72}
73
74fn host_of(raw: &str) -> Option<&str> {
75 let rest = raw.split_once("://").map(|(_, r)| r).unwrap_or(raw);
76 let host = rest.split('/').next()?;
77 let host = host.rsplit_once('@').map(|(_, h)| h).unwrap_or(host);
78 let host = match host.rsplit_once(':') {
79 Some((h, port)) if !port.is_empty() && port.chars().all(|c| c.is_ascii_digit()) => h,
80 _ => host,
81 };
82 (!host.is_empty()).then_some(host)
83}
84
85fn origin_allowed(service: &ChatService, headers: &hyper::HeaderMap) -> bool {
86 let Some(origin) = headers
87 .get(hyper::header::ORIGIN)
88 .and_then(|v| v.to_str().ok())
89 else {
90 return true;
91 };
92 let Some(origin_host) = host_of(origin) else {
93 return false;
94 };
95 let mut allowed: Vec<&str> = vec![];
96 if let Some(host) = host_of(&service.config().web.web_base_url) {
97 allowed.push(host);
98 }
99 for entry in &service.config().web.cors_allow_origins {
100 if let Some(host) = host_of(entry) {
101 allowed.push(host);
102 }
103 }
104 allowed.iter().any(|host| *host == origin_host)
105}
106
107fn extract_jwt(headers: &hyper::HeaderMap) -> Option<String> {
108 for value in headers.get_all(SEC_WEBSOCKET_PROTOCOL) {
109 let Ok(raw) = value.to_str() else {
110 continue;
111 };
112 for proto in raw.split(',') {
113 let proto = proto.trim();
114 if let Some(token) = proto.strip_prefix(JWT_PROTOCOL_PREFIX) {
115 if !token.is_empty() {
116 return Some(token.to_string());
117 }
118 }
119 }
120 }
121 None
122}
123
124async fn handshake(service: ChatService, req: &mut Request<Incoming>) -> Response<BoxBody> {
125 let headers = req.headers();
126 let bad = |reason: &'static str, origin: Option<String>| {
127 match reason {
128 "origin" => {
129 tracing::warn!(origin = %origin.unwrap_or_default(), "ws reject: origin not allowed")
130 }
131 "missing-protocol" => tracing::warn!("ws reject: jwt subprotocol missing"),
132 "bad-headers" => tracing::warn!("ws reject: invalid websocket handshake headers"),
133 _ => {}
134 }
135 text_response(StatusCode::BAD_REQUEST, "bad request")
136 };
137
138 let key = match headers.get(SEC_WEBSOCKET_KEY).and_then(|v| v.to_str().ok()) {
139 Some(key) => key.to_string(),
140 None => return bad("bad-headers", None),
141 };
142 let version_ok = headers
143 .get(SEC_WEBSOCKET_VERSION)
144 .and_then(|v| v.to_str().ok())
145 == Some("13");
146 let upgrade_ok = headers
147 .get(hyper::header::UPGRADE)
148 .and_then(|v| v.to_str().ok())
149 .is_some_and(|v| v.eq_ignore_ascii_case("websocket"))
150 && headers
151 .get(hyper::header::CONNECTION)
152 .and_then(|v| v.to_str().ok())
153 .unwrap_or_default()
154 .split(',')
155 .any(|token| token.trim().eq_ignore_ascii_case("upgrade"));
156 if !version_ok || !upgrade_ok {
157 return bad("bad-headers", None);
158 }
159 if !origin_allowed(&service, headers) {
160 let origin = headers
161 .get(hyper::header::ORIGIN)
162 .and_then(|v| v.to_str().ok())
163 .map(str::to_string);
164 return bad("origin", origin);
165 }
166 let Some(token) = extract_jwt(headers) else {
167 return bad("missing-protocol", None);
168 };
169 let secret = service
170 .config()
171 .auth
172 .chat_jwt_sec
173 .clone()
174 .filter(|value| !value.is_empty())
175 .unwrap_or_default();
176 let claims = match export::jwt::verify_app_jwt(&secret, "chat", &token) {
177 Ok(claims) => claims,
178 Err(error) => {
179 tracing::warn!(%error, "ws reject: jwt verify failed");
180 return text_response(StatusCode::FORBIDDEN, "forbidden");
181 }
182 };
183 let (Some(uid), Some(jti)) = (claims.uid(), claims.jti_uuid()) else {
184 tracing::warn!("ws reject: malformed jwt claims");
185 return text_response(StatusCode::FORBIDDEN, "forbidden");
186 };
187
188 let on_upgrade = hyper::upgrade::on(req);
189 let max_frame_bytes = service.config().chat.ws.max_frame_bytes;
190 let response = Response::builder()
191 .status(StatusCode::SWITCHING_PROTOCOLS)
192 .header(hyper::header::CONNECTION, "Upgrade")
193 .header(hyper::header::UPGRADE, "websocket")
194 .header(SEC_WEBSOCKET_ACCEPT, derive_accept_key(key.as_bytes()))
195 .header(
196 SEC_WEBSOCKET_PROTOCOL,
197 HeaderValue::from_str(&format!("{JWT_PROTOCOL_PREFIX}{token}")).unwrap(),
198 )
199 .body(Empty::new().boxed())
200 .unwrap();
201
202 tokio::spawn(async move {
203 let upgraded = match on_upgrade.await {
204 Ok(upgraded) => upgraded,
205 Err(error) => {
206 tracing::debug!(%error, "ws upgrade did not complete");
207 return;
208 }
209 };
210 let config = WebSocketConfig::default()
211 .max_message_size(Some(max_frame_bytes))
212 .max_frame_size(Some(max_frame_bytes));
213 let mut ws: ServerWs =
214 WebSocketStream::from_raw_socket(TokioIo::new(upgraded), Role::Server, Some(config))
215 .await;
216
217 let now = std::time::SystemTime::now()
218 .duration_since(std::time::UNIX_EPOCH)
219 .unwrap_or_default()
220 .as_secs() as i64;
221 if !consume_jti(&service.state.redis, jti, claims.exp - now).await {
222 tracing::warn!(%jti, "ws reject: jwt already used or redis unavailable");
223 let _ = ws.close(None).await;
224 return;
225 }
226 let session = match service.lookup_session_with_user(&claims.token_hash).await {
227 Ok(Some(session)) => session,
228 _ => {
229 tracing::warn!("ws reject: session lookup failed");
230 let _ = ws.close(None).await;
231 return;
232 }
233 };
234 let authorized = session.user_id == uid
235 && !session.is_revoked
236 && time::OffsetDateTime::from_unix_timestamp(now)
237 .map(|n| session.expires_at > n)
238 .unwrap_or(false)
239 && session.is_active
240 && !session.prohibit_login
241 && !session.is_restricted;
242 if !authorized {
243 tracing::warn!("ws reject: session not authorized");
244 let _ = ws.close(None).await;
245 return;
246 }
247 handle_socket(ws, service, uid, Uuid::now_v7()).await;
248 });
249
250 response
251}
Added app/im/src/jwt.rs View app/im/src/jwt.rs
@@ -0,0 +1,26 @@
1use export::AppRedis;
2use uuid::Uuid;
3
4pub async fn consume_jti(redis: &AppRedis, jti: Uuid, ttl_secs: i64) -> bool {
5 use export::RedisConnection;
6 let mut conn = redis.connection_clone();
7 let result: Result<Option<String>, redis::RedisError> = match &mut conn {
8 RedisConnection::Standalone(c) => redis::cmd("SET")
9 .arg(format!("chat:jwt_used:{jti}"))
10 .arg("1")
11 .arg("NX")
12 .arg("EX")
13 .arg(ttl_secs.max(1))
14 .query_async(c)
15 .await,
16 RedisConnection::Cluster(c) => redis::cmd("SET")
17 .arg(format!("chat:jwt_used:{jti}"))
18 .arg("1")
19 .arg("NX")
20 .arg("EX")
21 .arg(ttl_secs.max(1))
22 .query_async(c)
23 .await,
24 };
25 matches!(result, Ok(Some(_)))
26}
Added app/im/src/main.rs View app/im/src/main.rs
@@ -0,0 +1,70 @@
1mod frames;
2mod gateway;
3mod jwt;
4mod socket;
5
6use std::net::SocketAddr;
7
8use anyhow::Context;
9use chat::services::service::ChatService;
10use config::scoped::{ImConfig, ScopedConfig};
11use hyper_util::rt::TokioIo;
12use tokio::net::TcpListener;
13use tracing::info;
14
15#[tokio::main]
16async fn main() -> anyhow::Result<()> {
17 export::telemetry::init_tracing();
18
19 let cfg = ImConfig::load().context("loading configuration")?;
20 if !cfg.im.enabled() {
21 info!("im is disabled by config; exiting");
22 return Ok(());
23 }
24
25 info!("connecting to database / redis / storage / email");
26 let state = export::AppState::with_config(cfg.clone().into_app_config())
27 .await
28 .context("building AppState")?;
29 info!("dependencies ready");
30
31 let chat = ChatService::new(state);
32
33 let bind_addr = cfg.im.bind_addr().to_string();
34 let port = cfg.im.port();
35 let addr: SocketAddr = format!("{bind_addr}:{port}")
36 .parse()
37 .with_context(|| format!("parsing bind address {bind_addr}:{port}"))?;
38
39 let listener = TcpListener::bind(addr)
40 .await
41 .with_context(|| format!("binding {addr}"))?;
42 info!(%addr, "im listening");
43
44 loop {
45 tokio::select! {
46 _ = export::telemetry::shutdown_signal() => break,
47 accepted = listener.accept() => {
48 let (stream, _peer) = accepted.context("accepting connection")?;
49 let service = chat.clone();
50 tokio::spawn(async move {
51 let io = TokioIo::new(stream);
52 let service = hyper::service::service_fn(move |req| {
53 let service = service.clone();
54 async move { gateway::route(service, req).await }
55 });
56 if let Err(error) = hyper::server::conn::http1::Builder::new()
57 .serve_connection(io, service)
58 .with_upgrades()
59 .await
60 {
61 tracing::debug!(%error, "im connection closed");
62 }
63 });
64 }
65 }
66 }
67
68 info!("im shut down");
69 Ok(())
70}
Renamed lib/chat/src/ws/mod.rs → app/im/src/socket.rs View app/im/src/socket.rs
@@ -1,77 +1,22 @@
1pub mod frames;
2pub mod ticket;
3
41use std::collections::HashMap;
52use std::time::Duration;
63
7use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
8use axum::extract::State;
9use axum::response::Response;
4use chat::errors::ChatError;
5use chat::services::messages::ListMessageQuery;
6use chat::services::service::ChatService;
7use chat::share::realtime::RoomEvent;
108use futures::{SinkExt, StreamExt};
11
9use hyper::upgrade::Upgraded;
10use hyper_util::rt::TokioIo;
1211use tokio::sync::mpsc;
12use tokio_tungstenite::WebSocketStream;
13use tokio_tungstenite::tungstenite::{self, Message};
1314use tokio_util::sync::CancellationToken;
1415use uuid::Uuid;
1516
16use crate::errors::ChatError;
17use crate::services::messages::ListMessageQuery;
18use crate::services::service::ChatService;
19use crate::share::realtime::RoomEvent;
20use session::SessionGuard;
21use frames::{ClientFrame, ServerFrame};
22use ticket::{consume_ticket, verify_ticket};
23
24const TICKET_PROTOCOL_PREFIX: &str = "gitdata.ticket.";
25
26fn extract_ticket(headers: &axum::http::HeaderMap) -> Option<String> {
27 for value in headers.get_all(axum::http::header::SEC_WEBSOCKET_PROTOCOL) {
28 let Ok(raw) = value.to_str() else {
29 continue;
30 };
31 for proto in raw.split(',') {
32 let proto = proto.trim();
33 if let Some(ticket) = proto.strip_prefix(TICKET_PROTOCOL_PREFIX) {
34 if !ticket.is_empty() {
35 return Some(ticket.to_string());
36 }
37 }
38 }
39 }
40 None
41}
42
43fn host_of(raw: &str) -> Option<&str> {
44 let rest = raw.split_once("://").map(|(_, r)| r).unwrap_or(raw);
45 let host = rest.split('/').next()?;
46 let host = host.rsplit_once('@').map(|(_, h)| h).unwrap_or(host);
47 let host = match host.rsplit_once(':') {
48 Some((h, port)) if !port.is_empty() && port.chars().all(|c| c.is_ascii_digit()) => h,
49 _ => host,
50 };
51 (!host.is_empty()).then_some(host)
52}
17use crate::frames::{ClientFrame, ServerFrame};
5318
54fn origin_allowed(service: &ChatService, headers: &axum::http::HeaderMap) -> bool {
55 let Some(origin) = headers
56 .get(axum::http::header::ORIGIN)
57 .and_then(|v| v.to_str().ok())
58 else {
59 return true;
60 };
61 let Some(origin_host) = host_of(origin) else {
62 return false;
63 };
64 let mut allowed: Vec<&str> = vec![];
65 if let Some(host) = host_of(&service.config().web.web_base_url) {
66 allowed.push(host);
67 }
68 for entry in &service.config().web.cors_allow_origins {
69 if let Some(host) = host_of(entry) {
70 allowed.push(host);
71 }
72 }
73 allowed.iter().any(|host| *host == origin_host)
74}
19pub type ServerWs = WebSocketStream<TokioIo<Upgraded>>;
7520
7621#[derive(Clone)]
7722enum Inbox {
@@ -84,64 +29,6 @@
8429 handle: tokio::task::JoinHandle<()>,
8530}
8631
87fn reject(_ws: WebSocketUpgrade) -> Response {
88 axum::http::Response::builder()
89 .status(axum::http::StatusCode::BAD_REQUEST)
90 .body(axum::body::Body::empty())
91 .unwrap()
92}
93
94pub async fn ws_upgrade(
95 State(service): State<ChatService>,
96 headers: axum::http::HeaderMap,
97 ws: WebSocketUpgrade,
98) -> Response {
99 if !origin_allowed(&service, &headers) {
100 return reject(ws);
101 }
102 let Some(ticket) = extract_ticket(&headers) else {
103 return reject(ws);
104 };
105 let verified = match verify_ticket(&service.ticket_secret(), &ticket) {
106 Ok(value) => value,
107 Err(_) => return reject(ws),
108 };
109 if !consume_ticket(&service.state.redis, verified.jti, verified.ttl_left_secs).await {
110 return reject(ws);
111 }
112 let session = match service
113 .lookup_session_with_user(&verified.token_hash)
114 .await
115 {
116 Ok(Some(session)) => session,
117 _ => return reject(ws),
118 };
119 let now = std::time::SystemTime::now()
120 .duration_since(std::time::UNIX_EPOCH)
121 .unwrap_or_default()
122 .as_secs() as i64;
123 let authorized = session.user_id == verified.uid
124 && !session.is_revoked
125 && time::OffsetDateTime::from_unix_timestamp(now)
126 .map(|n| session.expires_at > n)
127 .unwrap_or(false)
128 && session.is_active
129 && !session.prohibit_login
130 && !session.is_restricted;
131 if !authorized {
132 return reject(ws);
133 }
134 let uid = verified.uid;
135 let session_id = Uuid::now_v7();
136 let mut ws = ws;
137 if let Ok(value) = axum::http::HeaderValue::from_str(&format!("{TICKET_PROTOCOL_PREFIX}{ticket}"))
138 {
139 ws.set_selected_protocol(value);
140 }
141 ws.max_message_size(service.config().chat.ws.max_frame_bytes)
142 .on_upgrade(move |socket| handle_socket(socket, service, uid, session_id))
143}
144
14532/// Push into the socket inbox without ever deadlocking the forwarder:
14633/// bounded retry, then drop the payload (gap detection resyncs the client).
14734async fn best_effort_send(
@@ -164,17 +51,7 @@
16451 false
16552}
16653
167#[allow(dead_code)]
168async fn close_with(mut socket: WebSocket, message: &str) -> Result<(), axum::Error> {
169 let frame = ServerFrame::Bye {
170 message: message.to_string(),
171 };
172 let payload = serde_json::to_string(&frame).unwrap_or_default();
173 socket.send(Message::Text(payload.into())).await?;
174 socket.close().await
175}
176
177async fn handle_socket(socket: WebSocket, service: ChatService, uid: Uuid, session_id: Uuid) {
54pub async fn handle_socket(socket: ServerWs, service: ChatService, uid: Uuid, session_id: Uuid) {
17855 let (mut sink, mut stream) = socket.split();
17956
18057 let (inbox_tx, mut inbox_rx) = mpsc::channel::<Inbox>(1024);
@@ -398,7 +275,7 @@
398275 inbox_tx: &mpsc::Sender<Inbox>,
399276 max_subscriptions: usize,
400277) where
401 S: futures::Sink<Message, Error = axum::Error> + Unpin,
278 S: futures::Sink<Message, Error = tungstenite::Error> + Unpin,
402279{
403280 match frame {
404281 ClientFrame::Subscribe { room_id, resync } => {
@@ -558,14 +435,14 @@
558435 uid: Uuid,
559436 room_id: Uuid,
560437) -> Result<(), ChatError> {
561 crate::share::lookup::require_room_readable(service, room_id, uid)
438 chat::share::lookup::require_room_readable(service, room_id, uid)
562439 .await
563440 .map(|_| ())
564441}
565442
566async fn send_frame<S>(sink: &mut S, frame: &ServerFrame) -> Result<(), axum::Error>
443async fn send_frame<S>(sink: &mut S, frame: &ServerFrame) -> Result<(), tungstenite::Error>
567444where
568 S: futures::Sink<Message, Error = axum::Error> + Unpin,
445 S: futures::Sink<Message, Error = tungstenite::Error> + Unpin,
569446{
570447 let payload = match serde_json::to_string(frame) {
571448 Ok(payload) => payload,
@@ -574,9 +451,9 @@
574451 sink.send(Message::Text(payload.into())).await
575452}
576453
577async fn close_with_unsplit<S>(mut sink: S, message: &str) -> Result<(), axum::Error>
454async fn close_with_unsplit<S>(mut sink: S, message: &str) -> Result<(), tungstenite::Error>
578455where
579 S: futures::Sink<Message, Error = axum::Error> + Unpin,
456 S: futures::Sink<Message, Error = tungstenite::Error> + Unpin,
580457{
581458 let frame = ServerFrame::Bye {
582459 message: message.to_string(),
Added app/im/Cargo.toml View app/im/Cargo.toml
@@ -0,0 +1,28 @@
1[package]
2name = "im"
3version.workspace = true
4edition.workspace = true
5publish = false
6
7[dependencies]
8chat = { workspace = true }
9config = { workspace = true }
10export = { workspace = true }
11session = { workspace = true }
12anyhow = { workspace = true }
13bytes = { workspace = true }
14futures = { workspace = true }
15http = "1"
16http-body-util = { workspace = true }
17hyper = { workspace = true }
18hyper-util = { workspace = true }
19redis = { workspace = true }
20sea-orm = { workspace = true }
21serde = { workspace = true, features = ["derive"] }
22serde_json = { workspace = true }
23time = { workspace = true }
24tokio = { workspace = true, features = ["signal", "net", "macros", "rt-multi-thread"] }
25tokio-tungstenite = { workspace = true }
26tokio-util = { workspace = true }
27tracing = { workspace = true }
28uuid = { workspace = true }
Modified app/mails/src/main.rs View app/mails/src/main.rs
@@ -1,8 +1,10 @@
1use std::net::SocketAddr;
12use std::time::Duration;
23
34use anyhow::Context;
45use config::scoped::{MailsConfig, ScopedConfig};
56use email::runtime::{WorkerOptions, run_until_shutdown};
7use tokio::net::TcpListener;
68use tracing::info;
79
810#[tokio::main]
@@ -30,9 +32,36 @@
3032 shutdown_timeout: Some(Duration::from_secs(5)),
3133 };
3234
33 run_until_shutdown(cfg.email.clone(), cfg.email_queue.clone(), redis_url, opts)
34 .await
35 .map_err(|e| anyhow::anyhow!(e.to_string()))?;
35 let (health_tx, health_rx) = tokio::sync::oneshot::channel::<()>();
36 let mut health_handle = None;
37 if cfg.health.enabled() {
38 let redis = export::AppRedis::connect(cfg.redis.clone())
39 .await
40 .context("connecting to redis")?;
41 let addr: SocketAddr = format!("{}:{}", cfg.health.bind_addr(), cfg.health.mails_port())
42 .parse()
43 .context("parsing health bind address")?;
44 let listener = TcpListener::bind(addr)
45 .await
46 .with_context(|| format!("binding health {addr}"))?;
47 info!(%addr, "mails health listening");
48 let router = export::health::router_with(None, redis);
49 health_handle = Some(tokio::spawn(async move {
50 let _ = axum::serve(listener, router)
51 .with_graceful_shutdown(async { let _ = health_rx.await; })
52 .await;
53 }));
54 }
55
56 let result =
57 run_until_shutdown(cfg.email.clone(), cfg.email_queue.clone(), redis_url, opts).await;
58
59 drop(health_tx);
60 if let Some(handle) = health_handle {
61 let _ = handle.await;
62 }
63
64 result.map_err(|e| anyhow::anyhow!(e.to_string()))?;
3665
3766 info!("mails worker exited cleanly");
3867 Ok(())
Modified app/mails/Cargo.toml View app/mails/Cargo.toml
@@ -6,8 +6,10 @@
66
77[dependencies]
88email = { workspace = true }
9export = { workspace = true }
910config = { workspace = true }
10tokio = { workspace = true }
11axum = { workspace = true }
12tokio = { workspace = true, features = ["signal", "net", "rt-multi-thread", "macros", "sync"] }
1113tracing = { workspace = true }
1214tracing-subscriber = { workspace = true }
1315anyhow = { workspace = true }
No newline at end of file
Modified app/synchost/src/main.rs View app/synchost/src/main.rs
@@ -1,3 +1,4 @@
1use std::net::SocketAddr;
12use std::path::PathBuf;
23use std::sync::Arc;
34
@@ -5,6 +6,7 @@
56use cache::CacheClient;
67use config::scoped::{ScopedConfig, SynchostConfig};
78use git::sync::{NoopWebhookTx, SyncQueue, SyncWorker, WebhookTx};
9use tokio::net::TcpListener;
810use tokio_util::sync::CancellationToken;
911use tracing::info;
1012
@@ -32,6 +34,25 @@
3234 info!("dependencies ready");
3335
3436 let cancel = CancellationToken::new();
37
38 if cfg.health.enabled() {
39 let health_addr: SocketAddr =
40 format!("{}:{}", cfg.health.bind_addr(), cfg.health.synchost_port())
41 .parse()
42 .context("parsing health bind address")?;
43 let health_listener = TcpListener::bind(health_addr)
44 .await
45 .with_context(|| format!("binding health {health_addr}"))?;
46 info!(%health_addr, "synchost health listening");
47 let health_router = export::health::router_with(Some(db.clone()), redis.clone());
48 let health_cancel = cancel.clone();
49 tokio::spawn(async move {
50 let _ = axum::serve(health_listener, health_router)
51 .with_graceful_shutdown(async move { health_cancel.cancelled().await })
52 .await;
53 });
54 }
55
3556 let queue = SyncQueue::new(redis.clone());
3657 let webhook: Arc<dyn WebhookTx> = Arc::new(NoopWebhookTx);
3758 let settings = cfg.git.sync.clone();
Modified app/synchost/Cargo.toml View app/synchost/Cargo.toml
@@ -9,7 +9,8 @@
99export = { workspace = true }
1010config = { workspace = true }
1111cache = { workspace = true }
12tokio = { workspace = true, features = ["signal", "rt-multi-thread", "macros"] }
12axum = { workspace = true }
13tokio = { workspace = true, features = ["signal", "net", "rt-multi-thread", "macros"] }
1314tokio-util = { workspace = true }
1415tracing = { workspace = true }
1516tracing-subscriber = { workspace = true }
Modified chart/templates/_helpers.tpl View chart/templates/_helpers.tpl
@@ -59,6 +59,14 @@
5959 value: {{ .Values.apihost.port | quote }}
6060- name: APP_GITHOST__HTTP_PORT
6161 value: {{ .Values.githost.httpPort | quote }}
62- name: APP_IM__PORT
63 value: {{ .Values.im.port | quote }}
64- name: APP_HEALTH__ENABLED
65 value: {{ .Values.health.enabled | quote }}
66- name: APP_HEALTH__SYNCHOST__PORT
67 value: {{ .Values.health.synchostPort | quote }}
68- name: APP_HEALTH__MAILS__PORT
69 value: {{ .Values.health.mailsPort | quote }}
6270{{- if .Values.githost.ssh.enabled }}
6371- name: APP_GIT__SSH__PORT
6472 value: {{ .Values.githost.ssh.containerPort | quote }}
Modified chart/templates/githost.yaml View chart/templates/githost.yaml
@@ -70,13 +70,12 @@
7070 - name: ssh
7171 containerPort: {{ .Values.githost.ssh.containerPort }}
7272 {{- end }}
73 # githost 未挂载 /healthz(见 app/githost/src/main.rs),用 TCP 探针
7473 livenessProbe:
75 tcpSocket: { port: smart-http }
74 httpGet: { path: /healthz, port: smart-http }
7675 initialDelaySeconds: 15
7776 periodSeconds: 20
7877 readinessProbe:
79 tcpSocket: { port: smart-http }
78 httpGet: { path: /readyz, port: smart-http }
8079 initialDelaySeconds: 5
8180 periodSeconds: 10
8281 securityContext:
Added chart/templates/im.yaml View chart/templates/im.yaml
@@ -0,0 +1,102 @@
1{{- if .Values.im.enabled }}
2apiVersion: apps/v1
3kind: Deployment
4metadata:
5 name: {{ printf "%s-im" (include "gitdata.fullname" .) }}
6 labels:
7 {{- include "gitdata.labels" . | nindent 4 }}
8 app.kubernetes.io/component: im
9spec:
10 replicas: {{ .Values.im.replicaCount }}
11 revisionHistoryLimit: 3
12 strategy:
13 type: RollingUpdate
14 rollingUpdate:
15 maxSurge: {{ .Values.im.maxSurge }}
16 maxUnavailable: {{ .Values.im.maxUnavailable }}
17 selector:
18 matchLabels:
19 {{- include "gitdata.selectorLabels" . | nindent 6 }}
20 app.kubernetes.io/component: im
21 template:
22 metadata:
23 labels:
24 {{- include "gitdata.selectorLabels" . | nindent 8 }}
25 app.kubernetes.io/component: im
26 {{- with .Values.podLabels }}
27 {{- toYaml . | nindent 8 }}
28 {{- end }}
29 annotations:
30 checksum/env: {{ toYaml .Values.config.env | sha256sum }}
31 {{- with .Values.podAnnotations }}
32 {{- toYaml . | nindent 8 }}
33 {{- end }}
34 spec:
35 serviceAccountName: {{ include "gitdata.serviceAccountName" . }}
36 {{- include "gitdata.imagePullSecrets" . | nindent 6 }}
37 terminationGracePeriodSeconds: 45
38 securityContext:
39 {{- toYaml .Values.podSecurityContext | nindent 8 }}
40 {{- if or (eq .Values.im.podAntiAffinity "soft") (eq .Values.im.podAntiAffinity "hard") }}
41 affinity:
42 {{- include "gitdata.podAntiAffinity" (dict "ctx" . "mode" .Values.im.podAntiAffinity "component" "im") | nindent 8 }}
43 {{- end }}
44 {{- with .Values.nodeSelector }}
45 nodeSelector:
46 {{- toYaml . | nindent 8 }}
47 {{- end }}
48 {{- with .Values.tolerations }}
49 tolerations:
50 {{- toYaml . | nindent 8 }}
51 {{- end }}
52 containers:
53 - name: im
54 image: {{ include "gitdata.image" . }}
55 imagePullPolicy: {{ .Values.image.pullPolicy }}
56 args: ["im"]
57 {{- include "gitdata.envFrom" . | nindent 10 }}
58 env:
59 {{- include "gitdata.env" . | nindent 12 }}
60 {{- with .Values.im.extraEnv }}
61 {{- toYaml . | nindent 12 }}
62 {{- end }}
63 ports:
64 - name: http
65 containerPort: {{ .Values.im.port }}
66 livenessProbe:
67 httpGet: { path: /healthz, port: http }
68 initialDelaySeconds: 10
69 periodSeconds: 15
70 timeoutSeconds: 3
71 readinessProbe:
72 httpGet: { path: /readyz, port: http }
73 initialDelaySeconds: 5
74 periodSeconds: 10
75 timeoutSeconds: 3
76 failureThreshold: 3
77 lifecycle:
78 preStop:
79 exec:
80 command: ["/bin/sh", "-c", "sleep 5"]
81 securityContext:
82 {{- toYaml .Values.securityContext | nindent 12 }}
83 resources:
84 {{- toYaml .Values.im.resources | nindent 12 }}
85---
86apiVersion: v1
87kind: Service
88metadata:
89 name: {{ printf "%s-im" (include "gitdata.fullname" .) }}
90 labels:
91 {{- include "gitdata.labels" . | nindent 4 }}
92 app.kubernetes.io/component: im
93spec:
94 type: ClusterIP
95 selector:
96 {{- include "gitdata.selectorLabels" . | nindent 4 }}
97 app.kubernetes.io/component: im
98 ports:
99 - name: http
100 port: {{ .Values.im.port }}
101 targetPort: http
102{{- end }}
Modified chart/templates/ingress.yaml View chart/templates/ingress.yaml
@@ -44,6 +44,16 @@
4444 port:
4545 name: smart-http
4646 {{- end }}
47 {{- if .Values.im.enabled }}
48 # chat WS 长连接网关(最长前缀匹配优先于 "/")
49 - path: /ws/v1
50 pathType: Prefix
51 backend:
52 service:
53 name: {{ printf "%s-im" $fullName }}
54 port:
55 name: http
56 {{- end }}
4757 - path: /
4858 pathType: Prefix
4959 backend:
Modified chart/templates/mails.yaml View chart/templates/mails.yaml
@@ -42,6 +42,18 @@
4242 {{- with .Values.mails.extraEnv }}
4343 {{- toYaml . | nindent 12 }}
4444 {{- end }}
45 ports:
46 - name: health
47 containerPort: {{ .Values.health.mailsPort }}
48 livenessProbe:
49 httpGet: { path: /healthz, port: health }
50 initialDelaySeconds: 10
51 periodSeconds: 15
52 readinessProbe:
53 httpGet: { path: /readyz, port: health }
54 initialDelaySeconds: 5
55 periodSeconds: 10
56 failureThreshold: 3
4557 securityContext:
4658 {{- toYaml .Values.securityContext | nindent 12 }}
4759 resources:
Modified chart/templates/synchost.yaml View chart/templates/synchost.yaml
@@ -44,7 +44,18 @@
4444 {{- with .Values.synchost.extraEnv }}
4545 {{- toYaml . | nindent 12 }}
4646 {{- end }}
47 # 无监听端口(Redis BRPOP worker),不做端口探针
47 ports:
48 - name: health
49 containerPort: {{ .Values.health.synchostPort }}
50 livenessProbe:
51 httpGet: { path: /healthz, port: health }
52 initialDelaySeconds: 10
53 periodSeconds: 15
54 readinessProbe:
55 httpGet: { path: /readyz, port: health }
56 initialDelaySeconds: 5
57 periodSeconds: 10
58 failureThreshold: 3
4859 securityContext:
4960 {{- toYaml .Values.securityContext | nindent 12 }}
5061 resources:
Modified chart/values.yaml View chart/values.yaml
@@ -47,7 +47,7 @@
4747 APP_S3__ACCESS_KEY_ID: ""
4848 APP_S3__SECRET_ACCESS_KEY: ""
4949 APP_EMAIL__SMTP_PASSWORD: ""
50 APP_CHAT__TICKET_SECRET: ""
50 APP_AUTH__CHAT_JWT_SEC: "" # >=32 字节,chat WS 握手 JWT 密钥
5151
5252podSecurityContext:
5353 fsGroup: 10001
@@ -139,6 +139,24 @@
139139 limits: { memory: 256Mi }
140140 extraEnv: []
141141
142# worker 类程序的独立健康端点(apihost/githost/im 挂在自己业务端口上)
143health:
144 enabled: true
145 synchostPort: 3002
146 mailsPort: 3003
147
148im:
149 enabled: true
150 replicaCount: 2
151 port: 3001
152 resources:
153 requests: { cpu: 100m, memory: 128Mi }
154 limits: { memory: 512Mi }
155 maxSurge: 1
156 maxUnavailable: 0
157 podAntiAffinity: soft # soft | hard | ""
158 extraEnv: []
159
142160# ---------------------------------------------------------------------------
143161# 存储:githost 与 synchost 共享 bare 仓库,需要 RWX
144162# ---------------------------------------------------------------------------
@@ -152,7 +170,7 @@
152170 mountPath: /opt/gitdata
153171
154172# ---------------------------------------------------------------------------
155# Ingress:同源 TLS 终结;*.git -> githost,其余(含 WS)-> apihost
173# Ingress:同源 TLS 终结;*.git -> githost,/ws/v1 -> im,其余 -> apihost
156174# 默认按 ingress-nginx 生成(regex path + ImplementationSpecific)
157175# ---------------------------------------------------------------------------
158176ingress:
Modified config/default.toml View config/default.toml
@@ -95,6 +95,7 @@
9595max_verify_attempts = 5
9696min_age = 13
9797totp_encryption_key = ""
98chat_jwt_sec = ""
9899
99100[[auth.agreements]]
100101key = "terms"
@@ -153,7 +154,6 @@
153154max_mentions_per_message = 20
154155typing_ttl_secs = 5
155156presence_ttl_secs = 300
156ticket_secret = ""
157157
158158[chat.ws]
159159ticket_ttl_secs = 60
@@ -248,3 +248,18 @@
248248enabled = true
249249http_bind_addr = "0.0.0.0"
250250http_port = 9418
251
252[im]
253enabled = true
254bind_addr = "0.0.0.0"
255port = 3001
256
257[health]
258enabled = true
259bind_addr = "0.0.0.0"
260
261[health.synchost]
262port = 3002
263
264[health.mails]
265port = 3003
Added lib/auth/src/handlers/jwt_request.rs View lib/auth/src/handlers/jwt_request.rs
@@ -0,0 +1,32 @@
1use axum::extract::{Query, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4use session::share::session::require_uid;
5
6use crate::errors::ApiError;
7use crate::services::AuthService;
8use crate::services::jwt::{JwtRequestQuery, JwtRequestResp};
9
10#[utoipa::path(
11 get,
12 path = "/auth/jwt-request",
13 tag = "auth",
14 description = "为指定应用申请短期 JWT 握手凭证,供独立实时网关建立长连接时验签使用。",
15 params(JwtRequestQuery),
16 responses(
17 (status = 200, description = "jwt issued", body = AppResponseData<JwtRequestResp>),
18 (status = 400, description = "request failed validation", body = AppResponseData<NoData>),
19 (status = 401, description = "authentication required", body = AppResponseData<NoData>)
20 )
21)]
22pub async fn jwt_request(
23 State(service): State<AuthService>,
24 session: Session,
25 Query(query): Query<JwtRequestQuery>,
26) -> Result<AppResponseData<JwtRequestResp>, ApiError> {
27 let uid = require_uid(&session)?;
28 let resp = service
29 .jwt_request(uid, &session, &query.appid)
30 .map_err(ApiError::from)?;
31 Ok(AppResponseData::ok(resp))
32}
Modified lib/auth/src/handlers/mod.rs View lib/auth/src/handlers/mod.rs
@@ -14,6 +14,7 @@
1414pub mod forgot_password;
1515pub mod get_preferences;
1616pub mod get_user;
17pub mod jwt_request;
1718pub mod list_access_keys;
1819pub mod list_agreements;
1920pub mod list_notifications;
Added lib/auth/src/services/jwt.rs View lib/auth/src/services/jwt.rs
@@ -0,0 +1,58 @@
1use serde::{Deserialize, Serialize};
2use session::Session;
3use session::share::session::session_token_key;
4use utoipa::{IntoParams, ToSchema};
5use uuid::Uuid;
6
7use crate::errors::AuthError;
8use crate::services::AuthService;
9
10#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, IntoParams)]
11#[into_params(parameter_in = Query)]
12pub struct JwtRequestQuery {
13 pub appid: String,
14}
15
16#[derive(Debug, Clone, Serialize, ToSchema)]
17pub struct JwtRequestResp {
18 pub jwt: String,
19 pub expires_in_secs: u64,
20}
21
22impl AuthService {
23 pub fn jwt_request(
24 &self,
25 uid: Uuid,
26 session: &Session,
27 appid: &str,
28 ) -> Result<JwtRequestResp, AuthError> {
29 let (secret, ttl_secs) = match appid {
30 "chat" => {
31 let secret = self
32 .config()
33 .auth
34 .chat_jwt_sec
35 .clone()
36 .filter(|value| !value.is_empty())
37 .ok_or_else(|| {
38 AuthError::Internal("chat jwt secret is not configured".to_string())
39 })?;
40 (secret, self.config().chat.ws.ticket_ttl_secs)
41 }
42 other => {
43 return Err(AuthError::BadRequest(format!(
44 "unsupported appid {other:?}"
45 )));
46 }
47 };
48 let token_hash = session
49 .get::<String>(&session_token_key(uid))
50 .ok_or(AuthError::Unauthorized)?;
51 let jwt = export::jwt::issue_app_jwt(&secret, appid, uid, &token_hash, ttl_secs)
52 .map_err(|error| AuthError::Internal(error.to_string()))?;
53 Ok(JwtRequestResp {
54 jwt,
55 expires_in_secs: ttl_secs,
56 })
57 }
58}
Modified lib/auth/src/services/mod.rs View lib/auth/src/services/mod.rs
@@ -13,6 +13,7 @@
1313pub mod deletion;
1414pub mod devices;
1515pub mod email;
16pub mod jwt;
1617pub mod login;
1718pub mod logout;
1819pub mod notifications;
Modified lib/auth/src/openapi.rs View lib/auth/src/openapi.rs
@@ -22,6 +22,7 @@
2222 EmailChangeTicketDto, ReclaimCmd, ReclaimResp, RequestEmailChangeCmd, ResendCodeCmd,
2323 ResendCodeResp, VerifyEmailCmd, VerifyEmailResp,
2424};
25use crate::services::jwt::{JwtRequestQuery, JwtRequestResp};
2526use crate::services::login::{LoginCmd, LoginResp, TwoFactorLoginCmd};
2627use crate::services::logout::LogoutResp;
2728use crate::services::notifications::NotificationDto;
@@ -72,6 +73,7 @@
7273 handlers::list_ssh_keys::list_ssh_keys,
7374 handlers::login::login,
7475 handlers::login_2fa::login_2fa,
76 handlers::jwt_request::jwt_request,
7577 handlers::logout::logout,
7678 handlers::mark_notifications_read::mark_notifications_read,
7779 handlers::me::me,
@@ -108,6 +110,8 @@
108110 LoginResp,
109111 TwoFactorLoginCmd,
110112 LogoutResp,
113 JwtRequestQuery,
114 JwtRequestResp,
111115 VerifyEmailCmd,
112116 VerifyEmailResp,
113117 ResendCodeCmd,
Modified lib/auth/src/routes.rs View lib/auth/src/routes.rs
@@ -18,6 +18,7 @@
1818use crate::handlers::forgot_password::forgot_password;
1919use crate::handlers::get_preferences::get_preferences;
2020use crate::handlers::get_user::get_user;
21use crate::handlers::jwt_request::jwt_request;
2122use crate::handlers::list_access_keys::list_access_keys;
2223use crate::handlers::list_agreements::list_agreements;
2324use crate::handlers::list_notifications::list_notifications;
@@ -74,6 +75,7 @@
7475 delete(cancel_email_change),
7576 )
7677 .route("/auth/password/change", post(change_password))
78 .route("/auth/jwt-request", get(jwt_request))
7779 .route("/auth/2fa/status", get(totp_status))
7880 .route("/auth/2fa/setup", post(totp_setup))
7981 .route("/auth/2fa/enable", post(totp_enable))
Modified lib/chat/src/handlers/mod.rs View lib/chat/src/handlers/mod.rs
@@ -14,4 +14,3 @@
1414pub mod settings;
1515pub mod threads;
1616pub mod webhooks;
17pub mod ws;
Deleted lib/chat/src/handlers/ws.rs
@@ -1,44 +0,0 @@
1use axum::extract::State;
2use export::rest::{AppResponseData, NoData};
3use serde::Serialize;
4use session::Session;
5use session::share::session::require_uid;
6use utoipa::ToSchema;
7
8use crate::errors::{ApiError, ChatError};
9use crate::services::service::ChatService;
10use crate::ws::ticket::issue_ticket;
11
12#[derive(Debug, Clone, Serialize, ToSchema)]
13pub struct WsTicketDto {
14 pub ticket: String,
15 pub ttl_secs: u64,
16}
17
18#[utoipa::path(
19 post,
20 path = "/chat/ws/ticket",
21 tag = "chat",
22 description = "签发短期 WebSocket 连接票据,用于建立聊天实时会话。",
23 responses(
24 (status = 200, description = "ws ticket issued", body = AppResponseData<WsTicketDto>),
25 (status = 401, description = "unauthorized", body = AppResponseData<NoData>),
26 (status = 409, description = "resource already exists", body = AppResponseData<NoData>)
27 )
28)]
29pub async fn issue_ws_ticket(
30 State(service): State<ChatService>,
31 session: Session,
32) -> Result<AppResponseData<WsTicketDto>, ApiError> {
33 let uid = require_uid(&session)?;
34 let token_hash = session
35 .get::<String>(&session::share::session::session_token_key(uid))
36 .ok_or_else(|| ApiError::from(ChatError::Unauthorized))?;
37 let ttl = service.config().chat.ws.ticket_ttl_secs;
38 let secret = service.ticket_secret();
39 let ticket = issue_ticket(&secret, uid, &token_hash, ttl)?;
40 Ok(AppResponseData::ok(WsTicketDto {
41 ticket,
42 ttl_secs: ttl,
43 }))
44}
Modified lib/chat/src/services/service.rs View lib/chat/src/services/service.rs
@@ -40,13 +40,6 @@
4040 &self.state.storage
4141 }
4242
43 pub fn ticket_secret(&self) -> String {
44 if !self.config().chat.ticket_secret.is_empty() {
45 return self.config().chat.ticket_secret.clone();
46 }
47 self.config().session.key.clone().unwrap_or_default()
48 }
49
5043 pub async fn load_user_briefs(
5144 &self,
5245 uids: &[Uuid],
Deleted lib/chat/src/ws/ticket.rs
@@ -1,114 +0,0 @@
1use base64::Engine;
2use hmac::{Hmac, KeyInit, Mac};
3use serde::{Deserialize, Serialize};
4use sha2::Sha256;
5use time::OffsetDateTime;
6use uuid::Uuid;
7
8use crate::errors::ChatError;
9
10type HmacSha256 = Hmac<Sha256>;
11
12#[derive(Debug, Serialize, Deserialize)]
13struct TicketPayload {
14 uid: Uuid,
15 token_hash: String,
16 jti: Uuid,
17 exp: i64,
18}
19
20pub fn issue_ticket(
21 secret: &str,
22 uid: Uuid,
23 token_hash: &str,
24 ttl_secs: u64,
25) -> Result<String, ChatError> {
26 if secret.is_empty() {
27 return Err(ChatError::Internal(
28 "chat ticket_secret not configured".into(),
29 ));
30 }
31 let payload = TicketPayload {
32 uid,
33 token_hash: token_hash.to_string(),
34 jti: Uuid::now_v7(),
35 exp: OffsetDateTime::now_utc().unix_timestamp() + ttl_secs as i64,
36 };
37 let payload_b64 =
38 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload)?);
39 let mut mac = HmacSha256::new_from_slice(secret.as_bytes())
40 .map_err(|e| ChatError::Internal(format!("hmac init failed: {e}")))?;
41 mac.update(payload_b64.as_bytes());
42 let sig = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes());
43 Ok(format!("{payload_b64}.{sig}"))
44}
45
46pub struct VerifiedTicket {
47 pub uid: Uuid,
48 pub token_hash: String,
49 pub jti: Uuid,
50 pub ttl_left_secs: i64,
51}
52
53pub fn verify_ticket(secret: &str, ticket: &str) -> Result<VerifiedTicket, ChatError> {
54 if secret.is_empty() {
55 return Err(ChatError::Unauthorized);
56 }
57 let Some((payload_b64, sig_b64)) = ticket.split_once('.') else {
58 return Err(ChatError::Unauthorized);
59 };
60 let mut mac =
61 HmacSha256::new_from_slice(secret.as_bytes()).map_err(|_| ChatError::Unauthorized)?;
62 mac.update(payload_b64.as_bytes());
63 let sig = base64::engine::general_purpose::URL_SAFE_NO_PAD
64 .decode(sig_b64)
65 .map_err(|_| ChatError::Unauthorized)?;
66 mac.verify_slice(&sig)
67 .map_err(|_| ChatError::Unauthorized)?;
68
69 let payload_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
70 .decode(payload_b64)
71 .map_err(|_| ChatError::Unauthorized)?;
72 let payload: TicketPayload =
73 serde_json::from_slice(&payload_bytes).map_err(|_| ChatError::Unauthorized)?;
74 let now = OffsetDateTime::now_utc().unix_timestamp();
75 if payload.exp < now {
76 return Err(ChatError::Unauthorized);
77 }
78 Ok(VerifiedTicket {
79 uid: payload.uid,
80 token_hash: payload.token_hash,
81 jti: payload.jti,
82 ttl_left_secs: payload.exp - now,
83 })
84}
85
86/// Marks a ticket as consumed exactly once. Returns false when the ticket
87/// was already used (replay) or redis failed.
88pub async fn consume_ticket(redis: &export::AppRedis, jti: Uuid, ttl_secs: i64) -> bool {
89 use export::RedisConnection;
90 let mut conn = redis.connection_clone();
91 let result: Result<Option<String>, redis::RedisError> = match &mut conn {
92 RedisConnection::Standalone(c) => {
93 redis::cmd("SET")
94 .arg(format!("chat:ws_ticket_used:{jti}"))
95 .arg("1")
96 .arg("NX")
97 .arg("EX")
98 .arg((ttl_secs + 1).max(1))
99 .query_async(c)
100 .await
101 }
102 RedisConnection::Cluster(c) => {
103 redis::cmd("SET")
104 .arg(format!("chat:ws_ticket_used:{jti}"))
105 .arg("1")
106 .arg("NX")
107 .arg("EX")
108 .arg((ttl_secs + 1).max(1))
109 .query_async(c)
110 .await
111 }
112 };
113 matches!(result, Ok(Some(_)))
114}
Modified lib/chat/src/lib.rs View lib/chat/src/lib.rs
@@ -5,4 +5,3 @@
55pub mod services;
66pub mod share;
77pub mod worker;
8pub mod ws;
Modified lib/chat/src/openapi.rs View lib/chat/src/openapi.rs
@@ -88,7 +88,6 @@
8888 crate::handlers::webhooks::list_outbound_webhooks,
8989 crate::handlers::webhooks::update_outbound_webhook,
9090 crate::handlers::webhooks::delete_outbound_webhook,
91 crate::handlers::ws::issue_ws_ticket,
9291 ),
9392 components(schemas(
9493 export::pagination::PageQuery,
@@ -181,7 +180,6 @@
181180 crate::services::outbound::OutboundWebhookSecretDto,
182181 crate::services::outbound::CreateOutboundWebhookCmd,
183182 crate::services::outbound::UpdateOutboundWebhookCmd,
184 crate::handlers::ws::WsTicketDto,
185183 ))
186184)]
187185pub struct ChatApiDoc;
Modified lib/chat/src/routes.rs View lib/chat/src/routes.rs
@@ -31,10 +31,8 @@
3131 update_thread,
3232};
3333use crate::handlers::webhooks::{create_webhook, delete_webhook, list_webhooks, post_via_webhook};
34use crate::handlers::ws::issue_ws_ticket;
3534use crate::services::service::ChatService;
3635use crate::share::guard::chat_guard;
37use crate::ws::ws_upgrade;
3836
3937pub fn router(
4038 service: ChatService,
@@ -230,15 +228,13 @@
230228 axum::routing::patch(crate::handlers::webhooks::update_outbound_webhook)
231229 .delete(crate::handlers::webhooks::delete_outbound_webhook),
232230 )
233 .route("/chat/ws/ticket", post(issue_ws_ticket))
234231 .route_layer(axum::middleware::from_fn_with_state(
235232 service.clone(),
236233 chat_guard,
237234 ));
238235
239 let open = Router::new()
240 .route("/chat/ws", get(ws_upgrade))
241 .route("/chat/hooks/{token}", post(post_via_webhook));
236 let open =
237 Router::new().route("/chat/hooks/{token}", post(post_via_webhook));
242238
243239 guarded.merge(open).merge(space_room_routes)
244240}
Modified lib/config/src/auth.rs View lib/config/src/auth.rs
@@ -44,6 +44,7 @@
4444 pub max_verify_attempts: u64,
4545 pub min_age: u8,
4646 pub totp_encryption_key: Option<String>,
47 pub chat_jwt_sec: Option<String>,
4748 pub agreements: Vec<AgreementDocConfig>,
4849}
4950
@@ -59,6 +60,7 @@
5960 max_verify_attempts: 5,
6061 min_age: 13,
6162 totp_encryption_key: None,
63 chat_jwt_sec: None,
6264 agreements: vec![
6365 AgreementDocConfig {
6466 key: "terms".to_string(),
@@ -127,6 +129,11 @@
127129 self
128130 }
129131
132 pub fn with_chat_jwt_sec(mut self, key: impl Into<String>) -> Self {
133 self.chat_jwt_sec = Some(key.into());
134 self
135 }
136
130137 pub fn with_agreements(mut self, agreements: Vec<AgreementDocConfig>) -> Self {
131138 self.agreements = agreements;
132139 self
@@ -162,6 +169,11 @@
162169 );
163170 }
164171 }
172 if let Some(key) = &self.chat_jwt_sec {
173 if !key.is_empty() && key.as_bytes().len() < 32 {
174 return Err("auth.chat_jwt_sec must be at least 32 bytes when configured".to_string());
175 }
176 }
165177 Ok(())
166178 }
167179}
Modified lib/config/src/chat.rs View lib/config/src/chat.rs
@@ -56,7 +56,6 @@
5656 pub max_pending_attachments_per_user: i64,
5757 pub typing_ttl_secs: u64,
5858 pub presence_ttl_secs: u64,
59 pub ticket_secret: String,
6059 pub ws: ChatWsConfig,
6160 pub upload: ChatUploadConfig,
6261}
@@ -79,7 +78,6 @@
7978 max_pending_attachments_per_user: 200,
8079 typing_ttl_secs: 5,
8180 presence_ttl_secs: 300,
82 ticket_secret: String::new(),
8381 ws: ChatWsConfig::default(),
8482 upload: ChatUploadConfig::default(),
8583 }
Added lib/config/src/health.rs View lib/config/src/health.rs
@@ -0,0 +1,56 @@
1use std::net::IpAddr;
2
3use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Clone, Default, Serialize, Deserialize)]
6#[serde(default)]
7pub struct HealthServiceConfig {
8 pub port: Option<u16>,
9}
10
11/// Health endpoints for binaries without a business HTTP listener
12/// (synchost, mails). Apps that already serve HTTP (apihost, githost, im)
13/// expose /healthz + /readyz on their own port instead.
14#[derive(Debug, Clone, Default, Serialize, Deserialize)]
15#[serde(default)]
16pub struct HealthConfig {
17 pub enabled: Option<bool>,
18 pub bind_addr: Option<String>,
19 pub synchost: HealthServiceConfig,
20 pub mails: HealthServiceConfig,
21}
22
23impl HealthConfig {
24 pub fn enabled(&self) -> bool {
25 self.enabled.unwrap_or(true)
26 }
27
28 pub fn bind_addr(&self) -> &str {
29 self.bind_addr.as_deref().unwrap_or("0.0.0.0")
30 }
31
32 pub fn synchost_port(&self) -> u16 {
33 self.synchost.port.unwrap_or(3002)
34 }
35
36 pub fn mails_port(&self) -> u16 {
37 self.mails.port.unwrap_or(3003)
38 }
39
40 pub fn is_valid(&self) -> Result<(), String> {
41 if !self.enabled() {
42 return Ok(());
43 }
44 let bind = self.bind_addr();
45 if bind.parse::<IpAddr>().is_err() {
46 return Err(format!("health.bind_addr is not a valid IP: {bind:?}"));
47 }
48 if self.synchost.port == Some(0) {
49 return Err("health.synchost.port must be greater than 0".to_string());
50 }
51 if self.mails.port == Some(0) {
52 return Err("health.mails.port must be greater than 0".to_string());
53 }
54 Ok(())
55 }
56}
Added lib/config/src/im.rs View lib/config/src/im.rs
@@ -0,0 +1,39 @@
1use std::net::IpAddr;
2
3use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Clone, Default, Serialize, Deserialize)]
6#[serde(default)]
7pub struct ImServerConfig {
8 pub enabled: Option<bool>,
9 pub bind_addr: Option<String>,
10 pub port: Option<u16>,
11}
12
13impl ImServerConfig {
14 pub fn enabled(&self) -> bool {
15 self.enabled.unwrap_or(true)
16 }
17
18 pub fn bind_addr(&self) -> &str {
19 self.bind_addr.as_deref().unwrap_or("0.0.0.0")
20 }
21
22 pub fn port(&self) -> u16 {
23 self.port.unwrap_or(3001)
24 }
25
26 pub fn is_valid(&self) -> Result<(), String> {
27 if !self.enabled() {
28 return Ok(());
29 }
30 let bind = self.bind_addr();
31 if bind.parse::<IpAddr>().is_err() {
32 return Err(format!("im.bind_addr is not a valid IP: {bind:?}"));
33 }
34 if self.port == Some(0) {
35 return Err("im.port must be greater than 0".to_string());
36 }
37 Ok(())
38 }
39}
Modified lib/config/src/lib.rs View lib/config/src/lib.rs
@@ -7,6 +7,8 @@
77pub mod email_queue;
88pub mod git;
99pub mod githost;
10pub mod health;
11pub mod im;
1012pub mod issues;
1113pub mod pow;
1214pub mod quota;
@@ -32,6 +34,8 @@
3234use crate::email_queue::EmailQueueConfig;
3335use crate::git::GitConfig;
3436use crate::githost::GithostConfig;
37use crate::im::ImServerConfig;
38use crate::health::HealthConfig;
3539use crate::issues::IssueConfig;
3640use crate::pow::PowConfig;
3741use crate::redis::RedisConfig;
@@ -63,6 +67,8 @@
6367 pub email_queue: EmailQueueConfig,
6468 pub apihost: ApihostConfig,
6569 pub githost: GithostConfig,
70 pub im: ImServerConfig,
71 pub health: HealthConfig,
6672}
6773
6874impl AppConfig {
@@ -84,6 +90,8 @@
8490 self.session.is_valid().map_err(ConfigError::Validation)?;
8591 self.apihost.is_valid().map_err(ConfigError::Validation)?;
8692 self.githost.is_valid().map_err(ConfigError::Validation)?;
93 self.im.is_valid().map_err(ConfigError::Validation)?;
94 self.health.is_valid().map_err(ConfigError::Validation)?;
8795 Ok(())
8896 }
8997}
Modified lib/config/src/scoped.rs View lib/config/src/scoped.rs
@@ -11,6 +11,8 @@
1111use crate::error::ConfigError;
1212use crate::git::GitConfig;
1313use crate::githost::GithostConfig as GithostSection;
14use crate::health::HealthConfig;
15use crate::im::ImServerConfig;
1416use crate::issues::IssueConfig;
1517use crate::pow::PowConfig;
1618use crate::quota::QuotaConfig;
@@ -50,13 +52,15 @@
5052 pub redis: RedisConfig,
5153 pub email: EmailConfig,
5254 pub email_queue: EmailQueueConfig,
55 pub health: HealthConfig,
5356}
5457
5558impl ScopedConfig for MailsConfig {
5659 fn validate_scoped(&self) -> Result<(), String> {
5760 self.redis.validate()?;
5861 self.email.is_valid()?;
59 self.email_queue.is_valid()
62 self.email_queue.is_valid()?;
63 self.health.is_valid()
6064 }
6165}
6266
@@ -67,13 +71,15 @@
6771 pub redis: RedisConfig,
6872 pub cache: CacheConfig,
6973 pub git: GitConfig,
74 pub health: HealthConfig,
7075}
7176
7277impl ScopedConfig for SynchostConfig {
7378 fn validate_scoped(&self) -> Result<(), String> {
7479 self.database.validate()?;
7580 self.redis.validate()?;
76 self.cache.validate()
81 self.cache.validate()?;
82 self.health.is_valid()
7783 }
7884}
7985
@@ -180,3 +186,46 @@
180186 self.apihost.is_valid()
181187 }
182188}
189
190#[derive(Clone, Debug, Default, Deserialize)]
191#[serde(default)]
192pub struct ImConfig {
193 pub database: DatabaseConfig,
194 pub redis: RedisConfig,
195 pub s3: S3Config,
196 pub email: EmailConfig,
197 pub email_queue: EmailQueueConfig,
198 pub chat: ChatConfig,
199 pub web: WebConfig,
200 pub auth: AuthConfig,
201 pub im: ImServerConfig,
202}
203
204impl ImConfig {
205 pub fn into_app_config(self) -> AppConfig {
206 AppConfig {
207 database: self.database,
208 redis: self.redis,
209 s3: self.s3,
210 email: self.email,
211 email_queue: self.email_queue,
212 chat: self.chat,
213 web: self.web,
214 auth: self.auth,
215 im: self.im,
216 ..Default::default()
217 }
218 }
219}
220
221impl ScopedConfig for ImConfig {
222 fn validate_scoped(&self) -> Result<(), String> {
223 self.database.validate()?;
224 self.redis.validate()?;
225 self.auth.validate()?;
226 self.chat.is_valid()?;
227 self.email.is_valid()?;
228 self.email_queue.is_valid()?;
229 self.im.is_valid()
230 }
231}
Modified lib/export/src/health.rs View lib/export/src/health.rs
@@ -1,26 +1,36 @@
1use std::sync::Arc;
2
13use axum::Router;
24use axum::extract::State;
35use axum::http::StatusCode;
46use axum::response::{IntoResponse, Response};
57use sea_orm::{ConnectionTrait, DbBackend, Statement};
68
7use crate::AppState;
9use crate::{AppDatabase, AppRedis, AppState};
810
11#[derive(Clone)]
12struct HealthState {
13 database: Option<Arc<AppDatabase>>,
14 redis: AppRedis,
15}
16
917async fn healthz() -> Response {
1018 (StatusCode::OK, "ok").into_response()
1119}
1220
13async fn readyz(State(state): State<AppState>) -> Response {
14 let db_ok = state
15 .database
16 .read()
17 .query_one_raw(Statement::from_sql_and_values(
18 DbBackend::Postgres,
19 "SELECT 1",
20 [],
21 ))
22 .await
23 .is_ok();
21async fn readyz(State(state): State<HealthState>) -> Response {
22 let db_ok = match state.database.as_ref() {
23 None => true,
24 Some(db) => db
25 .read()
26 .query_one_raw(Statement::from_sql_and_values(
27 DbBackend::Postgres,
28 "SELECT 1",
29 [],
30 ))
31 .await
32 .is_ok(),
33 };
2434 let mut conn = state.redis.connection_clone();
2535 let redis_ok: bool = match &mut conn {
2636 crate::redis::RedisConnection::Standalone(c) => {
@@ -37,9 +47,13 @@
3747 }
3848}
3949
40pub fn router(state: AppState) -> Router {
50pub fn router_with(database: Option<Arc<AppDatabase>>, redis: AppRedis) -> Router {
4151 Router::new()
4252 .route("/healthz", axum::routing::get(healthz))
4353 .route("/readyz", axum::routing::get(readyz))
44 .with_state(state)
54 .with_state(HealthState { database, redis })
55}
56
57pub fn router(state: AppState) -> Router {
58 router_with(Some(state.database.clone()), state.redis.clone())
4559}
Added lib/export/src/jwt.rs View lib/export/src/jwt.rs
@@ -0,0 +1,87 @@
1use std::time::{SystemTime, UNIX_EPOCH};
2
3use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode};
4use serde::{Deserialize, Serialize};
5use uuid::Uuid;
6
7#[derive(Debug, thiserror::Error)]
8pub enum JwtError {
9 #[error("jwt secret not configured")]
10 NotConfigured,
11 #[error("jwt invalid: {0}")]
12 Invalid(String),
13}
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct AppJwtClaims {
17 pub sub: String,
18 pub token_hash: String,
19 pub jti: String,
20 pub appid: String,
21 pub iat: i64,
22 pub exp: i64,
23}
24
25impl AppJwtClaims {
26 pub fn uid(&self) -> Option<Uuid> {
27 Uuid::parse_str(&self.sub).ok()
28 }
29
30 pub fn jti_uuid(&self) -> Option<Uuid> {
31 Uuid::parse_str(&self.jti).ok()
32 }
33}
34
35fn now_secs() -> i64 {
36 SystemTime::now()
37 .duration_since(UNIX_EPOCH)
38 .unwrap_or_default()
39 .as_secs() as i64
40}
41
42pub fn issue_app_jwt(
43 secret: &str,
44 appid: &str,
45 uid: Uuid,
46 token_hash: &str,
47 ttl_secs: u64,
48) -> Result<String, JwtError> {
49 if secret.is_empty() {
50 return Err(JwtError::NotConfigured);
51 }
52 let iat = now_secs();
53 let claims = AppJwtClaims {
54 sub: uid.to_string(),
55 token_hash: token_hash.to_string(),
56 jti: Uuid::now_v7().to_string(),
57 appid: appid.to_string(),
58 iat,
59 exp: iat + ttl_secs as i64,
60 };
61 encode(
62 &Header::new(Algorithm::HS256),
63 &claims,
64 &EncodingKey::from_secret(secret.as_bytes()),
65 )
66 .map_err(|e| JwtError::Invalid(e.to_string()))
67}
68
69pub fn verify_app_jwt(
70 secret: &str,
71 appid: &str,
72 token: &str,
73) -> Result<AppJwtClaims, JwtError> {
74 if secret.is_empty() {
75 return Err(JwtError::NotConfigured);
76 }
77 let data = decode::<AppJwtClaims>(
78 token,
79 &DecodingKey::from_secret(secret.as_bytes()),
80 &Validation::new(Algorithm::HS256),
81 )
82 .map_err(|e| JwtError::Invalid(e.to_string()))?;
83 if data.claims.appid != appid {
84 return Err(JwtError::Invalid("appid mismatch".to_string()));
85 }
86 Ok(data.claims)
87}
Modified lib/export/src/lib.rs View lib/export/src/lib.rs
@@ -2,6 +2,7 @@
22pub mod database;
33pub mod error;
44pub mod health;
5pub mod jwt;
56pub mod middleware;
67pub mod notify;
78pub mod pagination;
Modified lib/export/Cargo.toml View lib/export/Cargo.toml
@@ -20,4 +20,6 @@
2020serde_json = { workspace = true }
2121uuid = { workspace = true }
2222bytes = { workspace = true }
23http-body-util = { workspace = true }
No newline at end of file
23http-body-util = { workspace = true }
24jsonwebtoken = { workspace = true }
25thiserror = { workspace = true }
No newline at end of file
Modified Cargo.lock View Cargo.lock
@@ -2065,12 +2065,14 @@
20652065 "config",
20662066 "email",
20672067 "http-body-util",
2068 "jsonwebtoken",
20682069 "rand 0.8.7",
20692070 "redis",
20702071 "sea-orm",
20712072 "serde",
20722073 "serde_json",
20732074 "storage",
2075 "thiserror 2.0.20",
20742076 "tokio",
20752077 "tower",
20762078 "tracing",
@@ -4504,6 +4506,33 @@
45044506]
45054507
45064508[[package]]
4509name = "im"
4510version = "1.0.0"
4511dependencies = [
4512 "anyhow",
4513 "bytes",
4514 "chat",
4515 "config",
4516 "export",
4517 "futures",
4518 "http 1.5.0",
4519 "http-body-util",
4520 "hyper 1.11.0",
4521 "hyper-util",
4522 "redis",
4523 "sea-orm",
4524 "serde",
4525 "serde_json",
4526 "session",
4527 "time 0.3.55",
4528 "tokio",
4529 "tokio-tungstenite",
4530 "tokio-util",
4531 "tracing",
4532 "uuid",
4533]
4534
4535[[package]]
45074536name = "indexmap"
45084537version = "2.14.0"
45094538source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4714,6 +4743,21 @@
47144743]
47154744
47164745[[package]]
4746name = "jsonwebtoken"
4747version = "9.3.1"
4748source = "registry+https://github.com/rust-lang/crates.io-index"
4749checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde"
4750dependencies = [
4751 "base64 0.22.1",
4752 "js-sys",
4753 "pem",
4754 "ring",
4755 "serde",
4756 "serde_json",
4757 "simple_asn1",
4758]
4759
4760[[package]]
47174761name = "keccak"
47184762version = "0.2.1"
47194763source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4940,8 +4984,10 @@
49404984version = "1.0.0"
49414985dependencies = [
49424986 "anyhow",
4987 "axum",
49434988 "config",
49444989 "email",
4990 "export",
49454991 "tokio",
49464992 "tracing",
49474993 "tracing-subscriber",
@@ -5791,6 +5837,16 @@
57915837]
57925838
57935839[[package]]
5840name = "pem"
5841version = "3.0.6"
5842source = "registry+https://github.com/rust-lang/crates.io-index"
5843checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
5844dependencies = [
5845 "base64 0.22.1",
5846 "serde_core",
5847]
5848
5849[[package]]
57945850name = "pem-rfc7468"
57955851version = "0.7.0"
57965852source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -7565,6 +7621,18 @@
75657621checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
75667622
75677623[[package]]
7624name = "simple_asn1"
7625version = "0.6.4"
7626source = "registry+https://github.com/rust-lang/crates.io-index"
7627checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d"
7628dependencies = [
7629 "num-bigint 0.4.8",
7630 "num-traits",
7631 "thiserror 2.0.20",
7632 "time 0.3.55",
7633]
7634
7635[[package]]
75687636name = "slab"
75697637version = "0.4.12"
75707638source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -8115,6 +8183,7 @@
81158183version = "1.0.0"
81168184dependencies = [
81178185 "anyhow",
8186 "axum",
81188187 "cache",
81198188 "config",
81208189 "export",
Modified Cargo.toml View Cargo.toml
@@ -84,4 +84,8 @@
8484reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
8585anyhow = "1"
8686tracing-subscriber = { version = "0.3", features = ["env-filter"] }
87jsonwebtoken = "9"
88hyper = { version = "1", features = ["server", "http1"] }
89hyper-util = { version = "0.1", features = ["tokio"] }
90tokio-tungstenite = "0.29"
8791
Modified Dockerfile View Dockerfile
@@ -27,7 +27,7 @@
2727 --mount=type=cache,target=/build/target \
2828 cargo build --release --workspace --bins && \
2929 mkdir -p /out && \
30 for b in apihost githost synchost mails migrate openapi; do \
30 for b in apihost githost synchost mails migrate openapi im; do \
3131 cp "target/release/$b" /out/; \
3232 done
3333
@@ -48,10 +48,10 @@
4848WORKDIR /app
4949
5050COPY --chown=gitdata:gitdata config/default.toml config/default.toml
51COPY --from=builder /out/apihost /out/githost /out/synchost /out/mails /out/migrate /out/openapi /usr/local/bin/
51COPY --from=builder /out/apihost /out/githost /out/synchost /out/mails /out/migrate /out/openapi /out/im /usr/local/bin/
5252
5353USER gitdata
5454
55EXPOSE 3000 9418 22
55EXPOSE 3000 9418 22 3001 3002 3003
5656
5757CMD ["apihost"]
Modified openapi.json View openapi.json

Diff too large to display.