feat(im): add initial implementation for instant messaging service with JWT support
ZhenYi committed
This change is very large, so some diffs are not shown. Clone the repository to see the full change.
Modified app/githost/src/main.rs app/githost/src/main.rs +1 −1 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 app/im/src/frames.rs +1 −2 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 app/im/src/gateway.rs +251 −0 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 app/im/src/jwt.rs +26 −0 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 app/im/src/main.rs +70 −0 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 app/im/src/socket.rs +17 −140 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 app/im/Cargo.toml +28 −0 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 app/mails/src/main.rs +32 −3 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 app/mails/Cargo.toml +3 −1 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 app/synchost/src/main.rs +21 −0 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 app/synchost/Cargo.toml +2 −1 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 chart/templates/_helpers.tpl +8 −0 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 chart/templates/githost.yaml +2 −3 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 chart/templates/im.yaml +102 −0 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 chart/templates/ingress.yaml +10 −0 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 chart/templates/mails.yaml +12 −0 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 chart/templates/synchost.yaml +12 −1 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 chart/values.yaml +20 −2 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 config/default.toml +16 −1 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 lib/auth/src/handlers/jwt_request.rs +32 −0 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 lib/auth/src/handlers/mod.rs +1 −0 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 lib/auth/src/services/jwt.rs +58 −0 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 lib/auth/src/services/mod.rs +1 −0 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 lib/auth/src/openapi.rs +4 −0 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 lib/auth/src/routes.rs +2 −0 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 lib/chat/src/handlers/mod.rs +0 −1 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 lib/chat/src/handlers/ws.rs +0 −44
@@ -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 lib/chat/src/services/service.rs +0 −7 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 lib/chat/src/ws/ticket.rs +0 −114
@@ -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 lib/chat/src/lib.rs +0 −1 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 lib/chat/src/openapi.rs +0 −2 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 lib/chat/src/routes.rs +2 −6 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 lib/config/src/auth.rs +12 −0 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 lib/config/src/chat.rs +0 −2 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 lib/config/src/health.rs +56 −0 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 lib/config/src/im.rs +39 −0 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 lib/config/src/lib.rs +8 −0 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 lib/config/src/scoped.rs +51 −2 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 lib/export/src/health.rs +28 −14 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 lib/export/src/jwt.rs +87 −0 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 lib/export/src/lib.rs +1 −0 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 lib/export/Cargo.toml +3 −1 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 Cargo.lock +69 −0 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 Cargo.toml +4 −0 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 Dockerfile +3 −3 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 openapi.json View openapi.json
Diff too large to display.