diff --git a/ostp-core/src/framing/frame.rs b/ostp-core/src/framing/frame.rs index 74f6b18..3f039ef 100644 --- a/ostp-core/src/framing/frame.rs +++ b/ostp-core/src/framing/frame.rs @@ -13,6 +13,8 @@ pub enum FrameKind { KeepAlive = 4, Nack = 5, Ack = 6, + /// 0-RTT session resumption: client sends ticket + early data + Resume = 7, } impl TryFrom for FrameKind { @@ -26,6 +28,7 @@ impl TryFrom for FrameKind { 4 => Ok(Self::KeepAlive), 5 => Ok(Self::Nack), 6 => Ok(Self::Ack), + 7 => Ok(Self::Resume), _ => Err(ProtocolError::Framing("unknown frame kind".to_string())), } } diff --git a/ostp-core/src/protocol.rs b/ostp-core/src/protocol.rs index 75254a3..1990ed3 100644 --- a/ostp-core/src/protocol.rs +++ b/ostp-core/src/protocol.rs @@ -328,6 +328,11 @@ impl ProtocolMachine { FrameKind::Data => { ProtocolAction::DeliverApp(packet.header.stream_id, packet.payload) } + FrameKind::Resume => { + // 0-RTT: treat early data as application data + tracing::info!("0-RTT Resume frame received, processing early data"); + ProtocolAction::DeliverApp(packet.header.stream_id, packet.payload) + } FrameKind::Close => { tracing::info!("Received Close frame, terminating session"); self.state = OstpState::Closed; diff --git a/ostp-server/src/api.rs b/ostp-server/src/api.rs index 2b629bd..42e39cc 100644 --- a/ostp-server/src/api.rs +++ b/ostp-server/src/api.rs @@ -40,6 +40,10 @@ pub struct ApiState { pub user_stats: Arc>>>, pub start_time: Instant, pub api_token: String, + /// Server address for subscription links (e.g. "example.com") + pub server_host: String, + /// Server listen port + pub server_port: u16, } // ── API configuration ──────────────────────────────────────────────────────── @@ -121,6 +125,7 @@ pub fn create_api_router(state: ApiState) -> Router { .route("/api/users/{key}", delete(handle_delete_user)) .route("/api/users/{key}/limit", put(handle_set_limit)) .route("/api/users/{key}/reset", post(handle_reset_stats)) + .route("/api/subscribe/{key}", get(handle_subscribe)) .layer(cors) .with_state(state) } @@ -130,12 +135,16 @@ pub async fn start_api_server( config: ApiConfig, access_keys: Arc>>, user_stats: Arc>>>, + server_host: String, + server_port: u16, ) { let state = ApiState { access_keys, user_stats, start_time: Instant::now(), api_token: config.token.clone(), + server_host, + server_port, }; let app = create_api_router(state); @@ -377,3 +386,70 @@ async fn handle_reset_stats( api_error("user not found") } } + +// ── Subscription endpoint ──────────────────────────────────────────────────── + +/// Returns a ready-to-use client configuration for the given access key. +/// No Bearer token required -- the access key itself authenticates the request. +/// Compatible with subscription managers (sub-store, NekoBox, custom panels). +/// +/// GET /api/subscribe/{key} +/// Response: JSON client config or ostp:// share link (via Accept header) +async fn handle_subscribe( + State(state): State, + Path(key): Path, + headers: axum::http::HeaderMap, +) -> impl IntoResponse { + // Validate that the key exists + let keys = state.access_keys.read().unwrap(); + if !keys.contains_key(&key) { + return (StatusCode::NOT_FOUND, Json(serde_json::json!({ + "ok": false, + "error": "invalid access key" + }))); + } + drop(keys); + + let accept = headers.get("accept") + .and_then(|v| v.to_str().ok()) + .unwrap_or("application/json"); + + // If client requests plain text, return ostp:// share link + if accept.contains("text/plain") { + let link = format!("ostp://{}@{}:{}", key, state.server_host, state.server_port); + return (StatusCode::OK, Json(serde_json::json!({ + "ok": true, + "data": link + }))); + } + + // Default: return full client config JSON + let config = serde_json::json!({ + "mode": "client", + "server": format!("{}:{}", state.server_host, state.server_port), + "access_key": key, + "socks5_bind": "127.0.0.1:1088", + "tun": { + "enable": false, + "dns": "1.1.1.1" + }, + "exclude": { + "domains": [], + "ips": [], + "processes": [] + }, + "turn": { + "enabled": false + }, + "mux": { + "enabled": false, + "sessions": 1 + }, + "debug": false + }); + + (StatusCode::OK, Json(serde_json::json!({ + "ok": true, + "data": config + }))) +} diff --git a/ostp-server/src/lib.rs b/ostp-server/src/lib.rs index 42c5567..de0f177 100644 --- a/ostp-server/src/lib.rs +++ b/ostp-server/src/lib.rs @@ -154,8 +154,13 @@ pub async fn run_server( if api_cfg.enabled { let api_keys = shared_keys.clone(); let api_stats = dispatcher.user_stats_ref(); + // Extract host:port from primary listen address for subscription links + let primary = bind_addrs.first().cloned().unwrap_or_else(|| "0.0.0.0:50000".to_string()); + let parts: Vec<&str> = primary.rsplitn(2, ':').collect(); + let server_port: u16 = parts.first().and_then(|p| p.parse().ok()).unwrap_or(50000); + let server_host = parts.get(1).unwrap_or(&"0.0.0.0").to_string(); tokio::spawn(async move { - api::start_api_server(api_cfg, api_keys, api_stats).await; + api::start_api_server(api_cfg, api_keys, api_stats, server_host, server_port).await; }); } }