diff --git a/Cargo.lock b/Cargo.lock
index c08e353..776949f 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3939,7 +3939,7 @@ dependencies = [
[[package]]
name = "orbien-client"
-version = "3.6.0"
+version = "3.6.1-beta.1"
dependencies = [
"anyhow",
"async-trait",
@@ -3960,7 +3960,7 @@ dependencies = [
[[package]]
name = "orbien-core"
-version = "3.6.0"
+version = "3.6.1-beta.1"
dependencies = [
"anyhow",
"base64",
@@ -3991,7 +3991,7 @@ dependencies = [
[[package]]
name = "orbien-desktop"
-version = "3.6.0"
+version = "3.6.1-beta.1"
dependencies = [
"anyhow",
"libc",
@@ -4012,7 +4012,7 @@ dependencies = [
[[package]]
name = "orbien-server"
-version = "3.6.0"
+version = "3.6.1-beta.1"
dependencies = [
"anyhow",
"axum",
diff --git a/Cargo.toml b/Cargo.toml
index e4a3843..7f068d0 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -8,7 +8,7 @@ members = [
]
[workspace.package]
-version = "3.6.0"
+version = "3.6.1-beta.1"
edition = "2021"
license = "Apache-2.0"
authors = ["orbien"]
diff --git a/README.md b/README.md
index 11d6544..76cedfd 100644
--- a/README.md
+++ b/README.md
@@ -21,7 +21,7 @@
-
+
diff --git a/README_ZH.md b/README_ZH.md
index ced2f38..cd2b1d5 100644
--- a/README_ZH.md
+++ b/README_ZH.md
@@ -21,7 +21,7 @@
-
+
diff --git a/client-java/orbien-client/pom.xml b/client-java/orbien-client/pom.xml
index 18295b8..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
+ 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 6a01a74..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
@@ -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;
@@ -31,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";
+ private static final String VERSION = "3.6.1-beta.1";
private final OrbienClientConfig config;
private final AtomicBoolean started = new AtomicBoolean(false);
@@ -59,11 +58,12 @@ 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<>();
- Path sessionIdPath = resolveSessionIdPath();
- String previousSessionId = resolvePreviousSessionId(sessionIdPath);
try {
Bootstrap b = new Bootstrap();
@@ -91,11 +91,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 +101,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 +111,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-demo/pom.xml b/client-java/orbien-spring-boot-demo/pom.xml
index 37ea524..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
+ 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 82fdd4a..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
+ 3.6.1-beta.1
orbien-spring-boot-starter
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();
diff --git a/client-java/pom.xml b/client-java/pom.xml
index 1b39fd2..63638c5 100644
--- a/client-java/pom.xml
+++ b/client-java/pom.xml
@@ -6,7 +6,7 @@
io.github.lxien
orbien
- 3.6.0
+ 3.6.1-beta.1
pom
orbien
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/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/docs/docs/integrations/springboot.mdx b/docs/docs/integrations/springboot.mdx
index 2530a2f..06fe1cc 100644
--- a/docs/docs/integrations/springboot.mdx
+++ b/docs/docs/integrations/springboot.mdx
@@ -19,34 +19,32 @@ import TabItem from '@theme/TabItem';
io.github.lxien
orbien-spring-boot-starter
- 3.6.0
+ 3.6.1-beta.1
```
```groovy
- implementation("io.github.lxien:orbien-spring-boot-starter:3.6.0")
+ implementation("io.github.lxien:orbien-spring-boot-starter:3.6.1-beta.1")
```
## 最小配置
-`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 6c407db..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
@@ -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)
@@ -19,34 +19,32 @@ import TabItem from '@theme/TabItem';
io.github.lxien
orbien-spring-boot-starter
- 3.6.0
+ 3.6.1-beta.1
```
```groovy
- implementation("io.github.lxien:orbien-spring-boot-starter:3.6.0")
+ implementation("io.github.lxien:orbien-spring-boot-starter:3.6.1-beta.1")
```
## 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` |
diff --git a/docs/package.json b/docs/package.json
index aa73379..4c1c0d8 100644
--- a/docs/package.json
+++ b/docs/package.json
@@ -1,6 +1,6 @@
{
"name": "orbien-docs",
- "version": "3.6.0",
+ "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 4024a4f..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';
+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 dea94e9..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",
+ "version": "3.6.1-beta.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "orbien-server-ui",
- "version": "3.6.0",
+ "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 931ad96..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",
+ "version": "3.6.1-beta.1",
"type": "module",
"scripts": {
"dev": "vite",
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..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,
@@ -191,7 +197,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..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,
@@ -98,7 +105,6 @@ impl Service {
"replacing prior control session"
);
old.shutdown().await;
- old.wait_finished().await;
}
match self.agents.try_online(AgentOnlineSpec {
@@ -149,12 +155,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
}
@@ -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
));
}