From 5be597a82f38134bb7b8d7b03df7e4c1a4d673fa Mon Sep 17 00:00:00 2001 From: lxien Date: Tue, 15 Sep 2026 23:41:16 +0800 Subject: [PATCH 1/5] docs: Improve the document content --- docs/docs/integrations/springboot.mdx | 62 +++++++++++++----- .../current/integrations/springboot.mdx | 64 +++++++++++++------ 2 files changed, 91 insertions(+), 35 deletions(-) diff --git a/docs/docs/integrations/springboot.mdx b/docs/docs/integrations/springboot.mdx index 5e2434a..0558b3f 100644 --- a/docs/docs/integrations/springboot.mdx +++ b/docs/docs/integrations/springboot.mdx @@ -32,21 +32,19 @@ import TabItem from '@theme/TabItem'; ## 最小配置 -`local-port` / `name` 可省略:未填时分别使用 Spring Boot Web 端口与 `spring.application.name`(或 `orbien-{protocol}`)。 - ```yaml # application.yml orbien: enabled: true server: "127.0.0.1:9527" - # token: YOUR_TOKEN # 可选 + # token: YOUR_TOKEN tunnel: protocol: http domains: - - web.domain.com + - web.example.com ``` -服务端需关闭多路复用,例如: +服务端需关闭多路复用: ```toml # orbien-server.toml @@ -54,7 +52,20 @@ listen = "0.0.0.0:9527" httpGwPort = 80 [transport] -tcpMux = false # 关闭 TCP 多路复用 +tcpMux = false +``` + +## TCP 示例 + +```yaml +orbien: + enabled: true + server: "127.0.0.1:9527" + token: YOUR_TOKEN + tunnel: + protocol: tcp + # local-port: 3306 + remote-port: 9000 ``` ## 完整参考 @@ -65,18 +76,35 @@ orbien: enabled: true server: "127.0.0.1:9527" token: YOUR_TOKEN - tcp-mux: false # 必须为 false,且与服务端一致 - pool-count: 1 # 登录时预申请的数据连接池大小 + tcp-mux: false # 必须为 false + pool-count: 1 heartbeat-interval-secs: 30 - user: spring-boot-demo # 可选,展示在 Dashboard - # session-id: "" # 可选;为空则从 session-id-file 恢复,或自动生成 - # session-id-file: "" # 可选;为空则使用用户目录下的默认路径 + user: spring-boot-demo tunnel: - name: web-demo # 可选 - protocol: http # tcp | http + name: web-demo + protocol: http local-ip: 127.0.0.1 - # local-port: 8080 # 可选;未填则使用 Spring Boot 监听端口 - # remote-port: 9000 # 仅 protocol=tcp 时需要 - domains: # protocol=http 时必填 - - web.domain.com + # local-port: 8080 + # remote-port: 9000 + domains: + - web.example.com + # - admin ``` + +## 配置项说明 + +| 配置项 | 默认值 | 说明 | +| --- | --- | --- | +| `orbien.enabled` | `true` | 是否启用 | +| `orbien.server` | `127.0.0.1:9527` | 服务端地址| +| `orbien.token` | 空 | 鉴权 token| +| `orbien.tcp-mux` | `false` | 仅允许 `false`| +| `orbien.pool-count` | `1` | 登录时预申请的数据连接数| +| `orbien.heartbeat-interval-secs` | `30` | 控制面心跳间隔(秒)| +| `orbien.user` | 空 | 展示用用户名 | +| `orbien.tunnel.name` | 见上文 | 隧道名 | +| `orbien.tunnel.protocol` | `tcp` | `tcp` 或 `http` | +| `orbien.tunnel.local-ip` | `127.0.0.1` | 本地服务 IP | +| `orbien.tunnel.local-port` | `0` | 本地服务端口| +| `orbien.tunnel.remote-port` | `0` | 服务端对外端口;`protocol=tcp` 时必填 | +| `orbien.tunnel.domains` | `[]` | HTTP 域名列表;`protocol=http` 时必填 | diff --git a/docs/i18n/en/docusaurus-plugin-content-docs/current/integrations/springboot.mdx b/docs/i18n/en/docusaurus-plugin-content-docs/current/integrations/springboot.mdx index 25e3f2c..f4a7eed 100644 --- a/docs/i18n/en/docusaurus-plugin-content-docs/current/integrations/springboot.mdx +++ b/docs/i18n/en/docusaurus-plugin-content-docs/current/integrations/springboot.mdx @@ -11,7 +11,7 @@ import TabItem from '@theme/TabItem'; - Tunnel protocols: **TCP** and **HTTP** only - Transport: **TCP** only -- **No TCP multiplexing**: set `orbien.tcp-mux` to `false`, and set `[transport].tcpMux = false` on the server (multiplexing is on by default; turn it off when using the Java client) +- **No TCP multiplexing**: `orbien.tcp-mux` must be `false`, and the server must also set `[transport].tcpMux = false` (multiplexing is on by default; turn it off when using the Java client) @@ -32,21 +32,19 @@ import TabItem from '@theme/TabItem'; ## Minimal configuration -`local-port` and `name` are optional. When omitted, the starter uses the Spring Boot web port and `spring.application.name` (or `orbien-{protocol}`). - ```yaml # application.yml orbien: enabled: true server: "127.0.0.1:9527" - # token: YOUR_TOKEN # optional + # token: YOUR_TOKEN tunnel: protocol: http domains: - - web.domain.com + - web.example.com ``` -Disable multiplexing on the server, for example: +Disable multiplexing on the server: ```toml # orbien-server.toml @@ -54,7 +52,20 @@ listen = "0.0.0.0:9527" httpGwPort = 80 [transport] -tcpMux = false # disable TCP multiplexing +tcpMux = false +``` + +## TCP example + +```yaml +orbien: + enabled: true + server: "127.0.0.1:9527" + token: YOUR_TOKEN + tunnel: + protocol: tcp + # local-port: 3306 + remote-port: 9000 ``` ## Full reference @@ -65,18 +76,35 @@ orbien: enabled: true server: "127.0.0.1:9527" token: YOUR_TOKEN - tcp-mux: false # must be false and match the server - pool-count: 1 # data-connection pool size requested at login + tcp-mux: false # must be false + pool-count: 1 heartbeat-interval-secs: 30 - user: spring-boot-demo # optional; shown on the dashboard - # session-id: "" # optional; if empty, restore from session-id-file or generate - # session-id-file: "" # optional; if empty, use the default path under the user home + user: spring-boot-demo tunnel: - name: web-demo # optional - protocol: http # tcp | http + name: web-demo + protocol: http local-ip: 127.0.0.1 - # local-port: 8080 # optional; defaults to the Spring Boot listen port - # remote-port: 9000 # required when protocol=tcp - domains: # required when protocol=http - - web.domain.com + # local-port: 8080 + # remote-port: 9000 + domains: + - web.example.com + # - admin ``` + +## Configuration reference + +| Key | Default | Description | +| --- | --- | --- | +| `orbien.enabled` | `true` | Whether to enable | +| `orbien.server` | `127.0.0.1:9527` | Server address | +| `orbien.token` | empty | Auth token | +| `orbien.tcp-mux` | `false` | Only `false` is allowed | +| `orbien.pool-count` | `1` | Data connections pre-allocated at login | +| `orbien.heartbeat-interval-secs` | `30` | Control-plane heartbeat interval (seconds) | +| `orbien.user` | empty | Display username | +| `orbien.tunnel.name` | see above | Tunnel name | +| `orbien.tunnel.protocol` | `tcp` | `tcp` or `http` | +| `orbien.tunnel.local-ip` | `127.0.0.1` | Local service IP | +| `orbien.tunnel.local-port` | `0` | Local service port | +| `orbien.tunnel.remote-port` | `0` | Public port on the server; required when `protocol=tcp` | +| `orbien.tunnel.domains` | `[]` | HTTP domain list; required when `protocol=http` | From 1d8d3b6418e6a2425028c21ab4300616a1e7d6cb Mon Sep 17 00:00:00 2001 From: lxien Date: Tue, 15 Sep 2026 23:50:14 +0800 Subject: [PATCH 2/5] fix: remove the sessionId configuration and persistence from the Java client to align with the Rust client --- .../lxien/orbien/client/OrbienClient.java | 35 ++--------- .../orbien/client/OrbienClientConfig.java | 18 ------ .../lxien/orbien/client/SessionIdStore.java | 59 ------------------- .../lxien/orbien/boot/OrbienProperties.java | 20 ------- 4 files changed, 5 insertions(+), 127 deletions(-) delete mode 100644 client-java/orbien-client/src/main/java/io/github/lxien/orbien/client/SessionIdStore.java diff --git a/client-java/orbien-client/src/main/java/io/github/lxien/orbien/client/OrbienClient.java b/client-java/orbien-client/src/main/java/io/github/lxien/orbien/client/OrbienClient.java index 550e539..94a9409 100644 --- a/client-java/orbien-client/src/main/java/io/github/lxien/orbien/client/OrbienClient.java +++ b/client-java/orbien-client/src/main/java/io/github/lxien/orbien/client/OrbienClient.java @@ -20,7 +20,6 @@ import io.netty.channel.socket.SocketChannel; import io.netty.channel.socket.nio.NioSocketChannel; import java.net.InetAddress; -import java.nio.file.Path; import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; @@ -62,8 +61,6 @@ public final class OrbienClient implements AutoCloseable { group = new NioEventLoopGroup(); CompletableFuture loginFuture = new CompletableFuture<>(); - Path sessionIdPath = resolveSessionIdPath(); - String previousSessionId = resolvePreviousSessionId(sessionIdPath); try { Bootstrap b = new Bootstrap(); @@ -91,11 +88,9 @@ public final class OrbienClient implements AutoCloseable { ChannelFuture cf = b.connect(connectHost(config.getServerHost()), config.getServerPort()).sync(); controlChannel = cf.channel(); - sendLogin(controlChannel, previousSessionId); + sendLogin(controlChannel); String id = loginFuture.get(30, TimeUnit.SECONDS); - config.setSessionId(id); - SessionIdStore.save(sessionIdPath, id); log.info("connected to {} sessionId={}", config.getServer(), id); } catch (Exception e) { close(); @@ -103,26 +98,7 @@ public final class OrbienClient implements AutoCloseable { } } - private Path resolveSessionIdPath() { - String configured = config.getSessionIdFile(); - if (configured != null && !configured.isBlank()) { - return Path.of(configured); - } - return SessionIdStore.defaultPath(); - } - - private String resolvePreviousSessionId(Path sessionIdPath) { - if (config.getSessionId() != null && !config.getSessionId().isBlank()) { - return config.getSessionId().trim(); - } - String loaded = SessionIdStore.load(sessionIdPath); - if (!loaded.isEmpty()) { - log.info("restored sessionId={} from {}", loaded, sessionIdPath); - } - return loaded; - } - - private void sendLogin(Channel ch, String previousSessionId) { + private void sendLogin(Channel ch) { long ts = System.currentTimeMillis() / 1000; Login login = new Login(); login.version = VERSION; @@ -132,15 +108,14 @@ public final class OrbienClient implements AutoCloseable { login.user = config.getUser(); login.timestamp = ts; login.authDigest = AuthKeys.computeAuthDigest(config.getToken(), ts); - login.sessionId = previousSessionId == null ? "" : previousSessionId; + login.sessionId = ""; login.poolCount = Math.max(config.getPoolCount(), 1); ch.writeAndFlush(new WireMessage(MsgType.LOGIN, login)); log.debug( - "login sent hostname={} user={} poolCount={} sessionId={}", + "login sent hostname={} user={} poolCount={} sessionId=", login.hostname, login.user, - login.poolCount, - login.sessionId.isEmpty() ? "" : login.sessionId); + login.poolCount); } private void openDataConn(String currentSessionId) { diff --git a/client-java/orbien-client/src/main/java/io/github/lxien/orbien/client/OrbienClientConfig.java b/client-java/orbien-client/src/main/java/io/github/lxien/orbien/client/OrbienClientConfig.java index c9f5cef..8c43bf8 100644 --- a/client-java/orbien-client/src/main/java/io/github/lxien/orbien/client/OrbienClientConfig.java +++ b/client-java/orbien-client/src/main/java/io/github/lxien/orbien/client/OrbienClientConfig.java @@ -14,8 +14,6 @@ public final class OrbienClientConfig { private boolean tcpMux = false; private int poolCount = 1; private String user = ""; - private String sessionId = ""; - private String sessionIdFile = ""; private int heartbeatIntervalSecs = 30; private final List tunnels = new ArrayList<>(); @@ -70,22 +68,6 @@ public final class OrbienClientConfig { this.user = user == null ? "" : user; } - public String getSessionId() { - return sessionId; - } - - public void setSessionId(String sessionId) { - this.sessionId = sessionId == null ? "" : sessionId; - } - - public String getSessionIdFile() { - return sessionIdFile; - } - - public void setSessionIdFile(String sessionIdFile) { - this.sessionIdFile = sessionIdFile == null ? "" : sessionIdFile; - } - public int getHeartbeatIntervalSecs() { return heartbeatIntervalSecs; } diff --git a/client-java/orbien-client/src/main/java/io/github/lxien/orbien/client/SessionIdStore.java b/client-java/orbien-client/src/main/java/io/github/lxien/orbien/client/SessionIdStore.java deleted file mode 100644 index a4791fb..0000000 --- a/client-java/orbien-client/src/main/java/io/github/lxien/orbien/client/SessionIdStore.java +++ /dev/null @@ -1,59 +0,0 @@ -package io.github.lxien.orbien.client; - -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -final class SessionIdStore { - private static final Logger log = LoggerFactory.getLogger(SessionIdStore.class); - - static final String DEFAULT_FILE = ".orbien.session_id"; - private SessionIdStore() {} - - static Path defaultPath() { - return Path.of(DEFAULT_FILE); - } - - static String load(Path path) { - if (path == null || !Files.isRegularFile(path)) { - return ""; - } - try { - String s = Files.readString(path, StandardCharsets.UTF_8).trim(); - if (s.isEmpty() || s.length() > 64) { - return ""; - } - for (int i = 0; i < s.length(); i++) { - char c = s.charAt(i); - if (!(c >= '0' && c <= '9' - || c >= 'a' && c <= 'f' - || c >= 'A' && c <= 'F' - || c == '-')) { - return ""; - } - } - return s; - } catch (IOException e) { - log.warn("failed to load sessionId from {}: {}", path, e.toString()); - return ""; - } - } - - static void save(Path path, String sessionId) { - if (path == null || sessionId == null || sessionId.isBlank()) { - return; - } - try { - Path parent = path.getParent(); - if (parent != null) { - Files.createDirectories(parent); - } - Files.writeString(path, sessionId, StandardCharsets.UTF_8); - } catch (IOException e) { - log.warn("failed to persist sessionId to {}: {}", path, e.toString()); - } - } -} diff --git a/client-java/orbien-spring-boot-starter/src/main/java/io/github/lxien/orbien/boot/OrbienProperties.java b/client-java/orbien-spring-boot-starter/src/main/java/io/github/lxien/orbien/boot/OrbienProperties.java index d5c15a1..97902f6 100644 --- a/client-java/orbien-spring-boot-starter/src/main/java/io/github/lxien/orbien/boot/OrbienProperties.java +++ b/client-java/orbien-spring-boot-starter/src/main/java/io/github/lxien/orbien/boot/OrbienProperties.java @@ -21,8 +21,6 @@ public class OrbienProperties { private int poolCount = 1; private String user = ""; private int heartbeatIntervalSecs = 30; - private String sessionId = ""; - private String sessionIdFile = ""; @NestedConfigurationProperty private final Tunnel tunnel = new Tunnel(); @@ -83,22 +81,6 @@ public class OrbienProperties { this.heartbeatIntervalSecs = heartbeatIntervalSecs; } - public String getSessionId() { - return sessionId; - } - - public void setSessionId(String sessionId) { - this.sessionId = sessionId == null ? "" : sessionId; - } - - public String getSessionIdFile() { - return sessionIdFile; - } - - public void setSessionIdFile(String sessionIdFile) { - this.sessionIdFile = sessionIdFile == null ? "" : sessionIdFile; - } - public Tunnel getTunnel() { return tunnel; } @@ -118,8 +100,6 @@ public class OrbienProperties { cfg.setPoolCount(poolCount); cfg.setUser(user); cfg.setHeartbeatIntervalSecs(heartbeatIntervalSecs); - cfg.setSessionId(sessionId); - cfg.setSessionIdFile(sessionIdFile); if (hasTunnel()) { OrbienClientConfig.TunnelConfig p = new OrbienClientConfig.TunnelConfig(); String name = tunnel.getName(); From 983c6a9b1e4e3e6928b90f66fcbedad91d0d0280 Mon Sep 17 00:00:00 2001 From: lxien Date: Wed, 16 Sep 2026 23:38:34 +0800 Subject: [PATCH 3/5] fix: bound control teardown and fix transport close to clear stale presence Avoid hanging kick/shutdown on wedged KickOut writes or writer.shutdown, fix the control Notify race, and make WebSocket/yamux close reliable so desktop clients no longer remain online after disconnect (#25) --- core/src/transport/websocket.rs | 2 +- core/src/transport/yamux_mux.rs | 9 ++++- server/src/control/session/mod.rs | 47 ++++++++++++++++++++++---- server/src/service/mod.rs | 1 - server/src/service/session_registry.rs | 10 +++--- 5 files changed, 54 insertions(+), 15 deletions(-) diff --git a/core/src/transport/websocket.rs b/core/src/transport/websocket.rs index 9fd178d..1d8910c 100644 --- a/core/src/transport/websocket.rs +++ b/core/src/transport/websocket.rs @@ -150,7 +150,7 @@ where self: Pin<&mut Self>, cx: &mut TaskContext<'_>, ) -> Poll> { - match Pin::new(&mut self.get_mut().inner).poll_flush(cx) { + match Pin::new(&mut self.get_mut().inner).poll_close(cx) { Poll::Ready(Ok(())) => Poll::Ready(Ok(())), Poll::Ready(Err(e)) => Poll::Ready(Err(std::io::Error::other(e))), Poll::Pending => Poll::Pending, diff --git a/core/src/transport/yamux_mux.rs b/core/src/transport/yamux_mux.rs index 4429bc0..a7f18e5 100644 --- a/core/src/transport/yamux_mux.rs +++ b/core/src/transport/yamux_mux.rs @@ -1,10 +1,12 @@ use super::stream::{boxed_stream, DynStream}; use anyhow::{anyhow, Result}; use std::future::poll_fn; +use std::time::Duration; use tokio::sync::{mpsc, oneshot}; use tokio_util::compat::{FuturesAsyncReadCompatExt, TokioAsyncReadCompatExt}; const MAX_NUM_STREAMS: usize = 4096; +const CLOSE_TIMEOUT: Duration = Duration::from_secs(1); fn yamux_config() -> yamux::Config { let mut cfg = yamux::Config::default(); @@ -55,7 +57,12 @@ async fn drive_client(io: DynStream, mut open_rx: mpsc::Receiver) { let _ = reply.send(res); } None => { - let _ = poll_fn(|cx| conn.poll_close(cx)).await; + if tokio::time::timeout(CLOSE_TIMEOUT, poll_fn(|cx| conn.poll_close(cx))) + .await + .is_err() + { + tracing::debug!("yamux client close timed out, dropping connection"); + } break; } } diff --git a/server/src/control/session/mod.rs b/server/src/control/session/mod.rs index edad2a1..e00bb8f 100644 --- a/server/src/control/session/mod.rs +++ b/server/src/control/session/mod.rs @@ -21,6 +21,10 @@ use tokio::time::sleep; type CtrlRead = ReadHalf; type CtrlWrite = WriteHalf; +const KICK_WRITE_TIMEOUT: Duration = Duration::from_secs(1); +const WRITER_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(2); +const FINISHED_WAIT_TIMEOUT: Duration = Duration::from_secs(5); + pub struct Control { pub session_id: String, pub generation: u64, @@ -253,8 +257,16 @@ impl Control { break; } self.reap_bg_tasks().await; + if self.closed.load(Ordering::SeqCst) { + break; + } + let shutdown = self.shutdown_notify.notified(); + tokio::pin!(shutdown); + if self.closed.load(Ordering::SeqCst) { + break; + } let msg = tokio::select! { - _ = self.shutdown_notify.notified() => { + _ = &mut shutdown => { break; } msg = async { @@ -311,7 +323,7 @@ impl Control { pub async fn shutdown(&self) { self.signal_close(); if self.cleaning.swap(true, Ordering::SeqCst) { - self.wait_finished().await; + let _ = tokio::time::timeout(FINISHED_WAIT_TIMEOUT, self.wait_finished()).await; return; } { @@ -322,8 +334,20 @@ impl Control { } } { - let mut writer = self.writer.lock().await; - let _ = writer.shutdown().await; + let shut = async { + let mut writer = self.writer.lock().await; + let _ = writer.shutdown().await; + }; + if tokio::time::timeout(WRITER_SHUTDOWN_TIMEOUT, shut) + .await + .is_err() + { + tracing::warn!( + session_id = %self.session_id, + generation = self.generation, + "control writer shutdown timed out" + ); + } } { let mut bg = self.bg_tasks.lock().await; @@ -335,15 +359,24 @@ impl Control { pub async fn kick(&self, reason: impl Into) { let reason = reason.into(); - { + self.signal_close(); + let wrote = tokio::time::timeout(KICK_WRITE_TIMEOUT, async { let mut writer = self.writer.lock().await; - let _ = msg::write_msg( + msg::write_msg( &mut *writer, &Message::KickOut(KickOut { reason: reason.clone(), }), ) - .await; + .await + }) + .await; + if !matches!(wrote, Ok(Ok(()))) { + tracing::debug!( + session_id = %self.session_id, + generation = self.generation, + "kick-out write skipped or failed" + ); } tracing::info!( session_id = %self.session_id, diff --git a/server/src/service/mod.rs b/server/src/service/mod.rs index 42d3164..0e0d343 100644 --- a/server/src/service/mod.rs +++ b/server/src/service/mod.rs @@ -191,7 +191,6 @@ impl Service { let tunnel_count = control.tunnel_count().await; let generation = control.generation; control.kick("kicked from dashboard").await; - control.wait_finished().await; { let mut map = self.controls.lock().await; diff --git a/server/src/service/session_registry.rs b/server/src/service/session_registry.rs index de07b80..5308572 100644 --- a/server/src/service/session_registry.rs +++ b/server/src/service/session_registry.rs @@ -98,7 +98,6 @@ impl Service { "replacing prior control session" ); old.shutdown().await; - old.wait_finished().await; } match self.agents.try_online(AgentOnlineSpec { @@ -149,12 +148,13 @@ impl Service { let metrics = Arc::clone(&self.metrics); let rid = session_id.clone(); let result = Arc::clone(&control).run().await; - control.shutdown().await; - metrics.close_client(); let tunnel_count = control.tunnel_count().await; - let _ = remove_if_current(&controls, &rid, &control).await; - agents.release(&rid, generation, tunnel_count); + control.shutdown().await; + if remove_if_current(&controls, &rid, &control).await { + agents.release(&rid, generation, tunnel_count); + } + metrics.close_client(); result } From 01284e0cd57bc6b6278ce9fe952dff5607318feb Mon Sep 17 00:00:00 2001 From: lxien Date: Thu, 17 Sep 2026 01:46:48 +0800 Subject: [PATCH 4/5] fix: improve login authentication against digest replay and stale timestamps (#24) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a ±180s timestamp window and in-process digest replay cache; warn when auth.token is empty --- .../lxien/orbien/client/OrbienClient.java | 3 + client/src/service.rs | 3 + core/src/auth/mod.rs | 39 ++++++++++++- core/src/auth/replay.rs | 41 ++++++++++++++ core/src/auth/token.rs | 55 ++++++++++++++----- server/src/service/mod.rs | 6 ++ server/src/service/session_registry.rs | 24 +++++++- 7 files changed, 153 insertions(+), 18 deletions(-) create mode 100644 core/src/auth/replay.rs diff --git a/client-java/orbien-client/src/main/java/io/github/lxien/orbien/client/OrbienClient.java b/client-java/orbien-client/src/main/java/io/github/lxien/orbien/client/OrbienClient.java index 94a9409..2559569 100644 --- a/client-java/orbien-client/src/main/java/io/github/lxien/orbien/client/OrbienClient.java +++ b/client-java/orbien-client/src/main/java/io/github/lxien/orbien/client/OrbienClient.java @@ -58,6 +58,9 @@ public final class OrbienClient implements AutoCloseable { started.set(false); throw new IllegalStateException("tcpMux is not supported; set tcpMux=false on client and server"); } + if (config.getToken() == null || config.getToken().isEmpty()) { + log.warn("auth.token is empty; authentication is disabled"); + } group = new NioEventLoopGroup(); CompletableFuture loginFuture = new CompletableFuture<>(); diff --git a/client/src/service.rs b/client/src/service.rs index b1157cd..00e3e42 100644 --- a/client/src/service.rs +++ b/client/src/service.rs @@ -32,6 +32,9 @@ pub struct Service { impl Service { pub fn new(cfg: ClientConfig) -> Self { + if cfg.auth.token.is_empty() { + tracing::warn!("auth.token is empty; authentication is disabled"); + } Self { cfg } } diff --git a/core/src/auth/mod.rs b/core/src/auth/mod.rs index 5aaafe7..88ac442 100644 --- a/core/src/auth/mod.rs +++ b/core/src/auth/mod.rs @@ -1,3 +1,40 @@ +mod replay; mod token; -pub use token::{compute_auth_digest, verify_auth_digest, verify_login}; +pub use replay::ReplayCache; +pub use token::{compute_auth_digest, unix_now_secs, verify_auth_digest, verify_login}; + +use std::fmt; + +pub const AUTH_SKEW_SECS: i64 = 180; + +pub const REPLAY_TTL_SECS: u64 = (AUTH_SKEW_SECS as u64) * 2; + +pub const REPLAY_MAX_ENTRIES: usize = 100_000; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AuthFailure { + EmptyDigest, + InvalidDigest, + TimestampSkew, + Replay, + Capacity, +} + +impl AuthFailure { + pub fn as_str(self) -> &'static str { + match self { + Self::EmptyDigest => "empty authentication digest", + Self::InvalidDigest => "invalid authentication digest", + Self::TimestampSkew => "timestamp outside allowed window", + Self::Replay => "authentication digest reused", + Self::Capacity => "authentication replay cache full", + } + } +} + +impl fmt::Display for AuthFailure { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} diff --git a/core/src/auth/replay.rs b/core/src/auth/replay.rs new file mode 100644 index 0000000..679d4fc --- /dev/null +++ b/core/src/auth/replay.rs @@ -0,0 +1,41 @@ +use super::{AuthFailure, REPLAY_MAX_ENTRIES, REPLAY_TTL_SECS}; +use std::collections::HashMap; +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +#[derive(Debug, Default)] +pub struct ReplayCache { + inner: Mutex>, +} + +impl ReplayCache { + pub fn new() -> Self { + Self::default() + } + + pub fn accept(&self, digest: &str) -> Result<(), AuthFailure> { + self.accept_at(digest, Instant::now()) + } + + pub fn accept_at(&self, digest: &str, now: Instant) -> Result<(), AuthFailure> { + let mut map = self.inner.lock().unwrap_or_else(|e| e.into_inner()); + purge_expired(&mut map, now); + + if map.contains_key(digest) { + return Err(AuthFailure::Replay); + } + if map.len() >= REPLAY_MAX_ENTRIES { + return Err(AuthFailure::Capacity); + } + + map.insert( + digest.to_owned(), + now + Duration::from_secs(REPLAY_TTL_SECS), + ); + Ok(()) + } +} + +fn purge_expired(map: &mut HashMap, now: Instant) { + map.retain(|_, exp| *exp > now); +} diff --git a/core/src/auth/token.rs b/core/src/auth/token.rs index a858dcc..bf52ccb 100644 --- a/core/src/auth/token.rs +++ b/core/src/auth/token.rs @@ -1,3 +1,4 @@ +use super::{AuthFailure, ReplayCache, AUTH_SKEW_SECS}; use hmac::{Hmac, Mac}; use sha2::Sha256; @@ -10,23 +11,49 @@ pub fn compute_auth_digest(token: &str, timestamp: i64) -> String { hex::encode(mac.finalize().into_bytes()) } -pub fn verify_login(token: &str, auth_digest: &str, timestamp: i64) -> bool { - verify_auth_digest(token, auth_digest, timestamp) -} - -pub fn verify_auth_digest(token: &str, auth_digest: &str, timestamp: i64) -> bool { +pub fn verify_auth_digest( + token: &str, + auth_digest: &str, + timestamp: i64, + now_secs: i64, + replay: Option<&ReplayCache>, +) -> Result<(), AuthFailure> { if token.is_empty() { - return true; + return Ok(()); } if auth_digest.is_empty() { - return false; + return Err(AuthFailure::EmptyDigest); } - let Ok(expected) = hex::decode(auth_digest) else { - return false; - }; - let Ok(mut mac) = HmacSha256::new_from_slice(token.as_bytes()) else { - return false; - }; + if (now_secs - timestamp).abs() > AUTH_SKEW_SECS { + return Err(AuthFailure::TimestampSkew); + } + + let expected = hex::decode(auth_digest).map_err(|_| AuthFailure::InvalidDigest)?; + let mut mac = + HmacSha256::new_from_slice(token.as_bytes()).map_err(|_| AuthFailure::InvalidDigest)?; mac.update(timestamp.to_string().as_bytes()); - mac.verify_slice(&expected).is_ok() + mac.verify_slice(&expected) + .map_err(|_| AuthFailure::InvalidDigest)?; + + if let Some(cache) = replay { + cache.accept(auth_digest)?; + } + Ok(()) +} + +pub fn verify_login( + token: &str, + auth_digest: &str, + timestamp: i64, + now_secs: i64, + replay: &ReplayCache, +) -> Result<(), AuthFailure> { + verify_auth_digest(token, auth_digest, timestamp, now_secs, Some(replay)) +} + +pub fn unix_now_secs() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) } diff --git a/server/src/service/mod.rs b/server/src/service/mod.rs index 0e0d343..7865a01 100644 --- a/server/src/service/mod.rs +++ b/server/src/service/mod.rs @@ -9,6 +9,7 @@ use crate::tunnel::{ }; use agent_registry::AgentRegistry; use anyhow::{anyhow, Result}; +use orbien_core::auth::ReplayCache; use orbien_core::config::ServerConfig; use orbien_core::transport; use session_table::SessionMap; @@ -24,6 +25,7 @@ pub struct Service { cfg: ServerConfig, pub(crate) controls: Arc>, pub(crate) agents: Arc, + pub(crate) auth_replay: Arc, http_gw: Option>, https_gw: Option>, tls_config: Arc, @@ -36,6 +38,9 @@ pub struct Service { impl Service { pub fn new(cfg: ServerConfig) -> Result { + if cfg.auth.token.is_empty() { + tracing::warn!("auth.token is empty; authentication is disabled"); + } let http_gw = if cfg.http_gw_enabled() { Some(Arc::new(HttpGw::new(cfg.http_gw_port))) } else { @@ -56,6 +61,7 @@ impl Service { cfg, controls: Arc::new(Mutex::new(HashMap::new())), agents: Arc::new(AgentRegistry::new()), + auth_replay: Arc::new(ReplayCache::new()), http_gw, https_gw, tls_config, diff --git a/server/src/service/session_registry.rs b/server/src/service/session_registry.rs index 5308572..f6bc7a9 100644 --- a/server/src/service/session_registry.rs +++ b/server/src/service/session_registry.rs @@ -22,7 +22,14 @@ impl Service { login: Login, peer: SocketAddr, ) -> Result<()> { - if !auth::verify_login(&self.cfg.auth.token, &login.auth_digest, login.timestamp) { + if let Err(reason) = auth::verify_login( + &self.cfg.auth.token, + &login.auth_digest, + login.timestamp, + auth::unix_now_secs(), + &self.auth_replay, + ) { + tracing::warn!(%reason, %peer, "login rejected"); let mut stream = stream; let _ = msg::write_msg( &mut stream, @@ -167,9 +174,20 @@ impl Service { if nw.session_id.trim().is_empty() { return Err(anyhow!("empty session_id for data conn")); } - if !auth::verify_auth_digest(&self.cfg.auth.token, &nw.auth_digest, nw.timestamp) { + if let Err(reason) = auth::verify_auth_digest( + &self.cfg.auth.token, + &nw.auth_digest, + nw.timestamp, + auth::unix_now_secs(), + None, + ) { + tracing::warn!( + %reason, + session_id = %nw.session_id, + "data connection authentication failed" + ); return Err(anyhow!( - "data conn auth failed for session_id={}", + "data connection authentication failed for session_id={}", nw.session_id )); } From d98cbcb1627c18f53ca497cf365e61e9e23dacac Mon Sep 17 00:00:00 2001 From: lxien Date: Thu, 17 Sep 2026 02:20:04 +0800 Subject: [PATCH 5/5] chore: bump version to 3.6.1-beta.1 --- Cargo.lock | 8 ++++---- Cargo.toml | 2 +- README.md | 2 +- README_ZH.md | 2 +- client-java/orbien-client/pom.xml | 2 +- .../java/io/github/lxien/orbien/client/OrbienClient.java | 2 +- client-java/orbien-spring-boot-demo/pom.xml | 2 +- client-java/orbien-spring-boot-starter/pom.xml | 2 +- client-java/pom.xml | 2 +- docs/docs/integrations/springboot.mdx | 4 ++-- .../current/integrations/springboot.mdx | 4 ++-- docs/package.json | 2 +- docs/src/components/DownloadMatrix/index.tsx | 2 +- server-ui/package-lock.json | 4 ++-- server-ui/package.json | 2 +- 15 files changed, 21 insertions(+), 21 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6eaedc5..776949f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3939,7 +3939,7 @@ dependencies = [ [[package]] name = "orbien-client" -version = "3.6.0-beta.1" +version = "3.6.1-beta.1" dependencies = [ "anyhow", "async-trait", @@ -3960,7 +3960,7 @@ dependencies = [ [[package]] name = "orbien-core" -version = "3.6.0-beta.1" +version = "3.6.1-beta.1" dependencies = [ "anyhow", "base64", @@ -3991,7 +3991,7 @@ dependencies = [ [[package]] name = "orbien-desktop" -version = "3.6.0-beta.1" +version = "3.6.1-beta.1" dependencies = [ "anyhow", "libc", @@ -4012,7 +4012,7 @@ dependencies = [ [[package]] name = "orbien-server" -version = "3.6.0-beta.1" +version = "3.6.1-beta.1" dependencies = [ "anyhow", "axum", diff --git a/Cargo.toml b/Cargo.toml index 27d42fd..7f068d0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,7 @@ members = [ ] [workspace.package] -version = "3.6.0-beta.1" +version = "3.6.1-beta.1" edition = "2021" license = "Apache-2.0" authors = ["orbien"] diff --git a/README.md b/README.md index 4175e43..76cedfd 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ Rust - orbien:3.6.0-beta.1 + orbien:3.6.1-beta.1 Downloads diff --git a/README_ZH.md b/README_ZH.md index 395b769..cd2b1d5 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -21,7 +21,7 @@ Rust - orbien:3.6.0-beta.1 + orbien:3.6.1-beta.1 Downloads diff --git a/client-java/orbien-client/pom.xml b/client-java/orbien-client/pom.xml index be16093..8c70aac 100644 --- a/client-java/orbien-client/pom.xml +++ b/client-java/orbien-client/pom.xml @@ -6,7 +6,7 @@ io.github.lxien orbien - 3.6.0-beta.1 + 3.6.1-beta.1 orbien-client diff --git a/client-java/orbien-client/src/main/java/io/github/lxien/orbien/client/OrbienClient.java b/client-java/orbien-client/src/main/java/io/github/lxien/orbien/client/OrbienClient.java index 2559569..27ef091 100644 --- a/client-java/orbien-client/src/main/java/io/github/lxien/orbien/client/OrbienClient.java +++ b/client-java/orbien-client/src/main/java/io/github/lxien/orbien/client/OrbienClient.java @@ -30,7 +30,7 @@ import org.slf4j.LoggerFactory; public final class OrbienClient implements AutoCloseable { private static final Logger log = LoggerFactory.getLogger(OrbienClient.class); - private static final String VERSION = "3.6.0-beta.1"; + private static final String VERSION = "3.6.1-beta.1"; private final OrbienClientConfig config; private final AtomicBoolean started = new AtomicBoolean(false); diff --git a/client-java/orbien-spring-boot-demo/pom.xml b/client-java/orbien-spring-boot-demo/pom.xml index e9b31a6..60fd30a 100644 --- a/client-java/orbien-spring-boot-demo/pom.xml +++ b/client-java/orbien-spring-boot-demo/pom.xml @@ -6,7 +6,7 @@ io.github.lxien orbien - 3.6.0-beta.1 + 3.6.1-beta.1 orbien-spring-boot-demo diff --git a/client-java/orbien-spring-boot-starter/pom.xml b/client-java/orbien-spring-boot-starter/pom.xml index 177829b..6969945 100644 --- a/client-java/orbien-spring-boot-starter/pom.xml +++ b/client-java/orbien-spring-boot-starter/pom.xml @@ -7,7 +7,7 @@ io.github.lxien orbien - 3.6.0-beta.1 + 3.6.1-beta.1 orbien-spring-boot-starter diff --git a/client-java/pom.xml b/client-java/pom.xml index b464879..63638c5 100644 --- a/client-java/pom.xml +++ b/client-java/pom.xml @@ -6,7 +6,7 @@ io.github.lxien orbien - 3.6.0-beta.1 + 3.6.1-beta.1 pom orbien diff --git a/docs/docs/integrations/springboot.mdx b/docs/docs/integrations/springboot.mdx index 0558b3f..06fe1cc 100644 --- a/docs/docs/integrations/springboot.mdx +++ b/docs/docs/integrations/springboot.mdx @@ -19,13 +19,13 @@ import TabItem from '@theme/TabItem'; io.github.lxien orbien-spring-boot-starter - 3.6.0-beta.1 + 3.6.1-beta.1 ``` ```groovy - implementation("io.github.lxien:orbien-spring-boot-starter:3.6.0-beta.1") + implementation("io.github.lxien:orbien-spring-boot-starter:3.6.1-beta.1") ``` diff --git a/docs/i18n/en/docusaurus-plugin-content-docs/current/integrations/springboot.mdx b/docs/i18n/en/docusaurus-plugin-content-docs/current/integrations/springboot.mdx index f4a7eed..8d981a7 100644 --- a/docs/i18n/en/docusaurus-plugin-content-docs/current/integrations/springboot.mdx +++ b/docs/i18n/en/docusaurus-plugin-content-docs/current/integrations/springboot.mdx @@ -19,13 +19,13 @@ import TabItem from '@theme/TabItem'; io.github.lxien orbien-spring-boot-starter - 3.6.0-beta.1 + 3.6.1-beta.1 ``` ```groovy - implementation("io.github.lxien:orbien-spring-boot-starter:3.6.0-beta.1") + implementation("io.github.lxien:orbien-spring-boot-starter:3.6.1-beta.1") ``` diff --git a/docs/package.json b/docs/package.json index 9c12bb4..4c1c0d8 100644 --- a/docs/package.json +++ b/docs/package.json @@ -1,6 +1,6 @@ { "name": "orbien-docs", - "version": "3.6.0-beta.1", + "version": "3.6.1-beta.1", "private": true, "scripts": { "docusaurus": "docusaurus", diff --git a/docs/src/components/DownloadMatrix/index.tsx b/docs/src/components/DownloadMatrix/index.tsx index 8ebf05d..c41deab 100644 --- a/docs/src/components/DownloadMatrix/index.tsx +++ b/docs/src/components/DownloadMatrix/index.tsx @@ -7,7 +7,7 @@ import styles from './styles.module.css'; const REPO = 'orbien-org/orbien'; -const FALLBACK_VERSION = '3.6.0-beta.1'; +const FALLBACK_VERSION = '3.6.1-beta.1'; type OsId = 'windows' | 'linux' | 'darwin' | 'freebsd'; type ArchId = 'amd64' | 'arm64'; diff --git a/server-ui/package-lock.json b/server-ui/package-lock.json index 3343c67..35c0c0f 100644 --- a/server-ui/package-lock.json +++ b/server-ui/package-lock.json @@ -1,12 +1,12 @@ { "name": "orbien-server-ui", - "version": "3.6.0-beta.1", + "version": "3.6.1-beta.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "orbien-server-ui", - "version": "3.6.0-beta.1", + "version": "3.6.1-beta.1", "dependencies": { "vue": "^3.5.22", "vue-i18n": "^11.4.8", diff --git a/server-ui/package.json b/server-ui/package.json index 56d8737..26009b0 100644 --- a/server-ui/package.json +++ b/server-ui/package.json @@ -1,7 +1,7 @@ { "name": "orbien-server-ui", "private": true, - "version": "3.6.0-beta.1", + "version": "3.6.1-beta.1", "type": "module", "scripts": { "dev": "vite",