Merge branch 'release/v3.6.1'

# Conflicts:
#	Cargo.lock
#	Cargo.toml
#	README.md
#	README_ZH.md
#	client-java/orbien-client/pom.xml
#	client-java/orbien-client/src/main/java/io/github/lxien/orbien/client/OrbienClient.java
#	client-java/orbien-spring-boot-demo/pom.xml
#	client-java/orbien-spring-boot-starter/pom.xml
#	client-java/pom.xml
#	docs/docs/integrations/springboot.mdx
#	docs/i18n/en/docusaurus-plugin-content-docs/current/integrations/springboot.mdx
#	docs/package.json
#	docs/src/components/DownloadMatrix/index.tsx
#	server-ui/package-lock.json
#	server-ui/package.json
This commit is contained in:
lxien
2026-09-17 02:46:44 +08:00
27 changed files with 324 additions and 216 deletions
Generated
+4 -4
View File
@@ -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",
+1 -1
View File
@@ -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"]
+1 -1
View File
@@ -21,7 +21,7 @@
<img src="https://img.shields.io/badge/Rust-Tokio-orange?style=for-the-badge&logo=rust&logoColor=white" alt="Rust"/>
</a>
<a href="https://github.com/orbien-org/orbien/releases">
<img src="https://img.shields.io/badge/orbien-3.6.0-blue?style=for-the-badge" alt="orbien:3.6.0"/>
<img src="https://img.shields.io/badge/orbien-3.6.1--beta.1-blue?style=for-the-badge" alt="orbien:3.6.1-beta.1"/>
</a>
<a href="https://somsubhra.github.io/github-release-stats/?username=orbien-org&repository=orbien">
<img src="https://img.shields.io/github/downloads/orbien-org/orbien/total?style=for-the-badge" alt="Downloads"/>
+1 -1
View File
@@ -21,7 +21,7 @@
<img src="https://img.shields.io/badge/Rust-Tokio-orange?style=for-the-badge&logo=rust&logoColor=white" alt="Rust"/>
</a>
<a href="https://github.com/orbien-org/orbien/releases">
<img src="https://img.shields.io/badge/orbien-3.6.0-blue?style=for-the-badge" alt="orbien:3.6.0"/>
<img src="https://img.shields.io/badge/orbien-3.6.1--beta.1-blue?style=for-the-badge" alt="orbien:3.6.1-beta.1"/>
</a>
<a href="https://somsubhra.github.io/github-release-stats/?username=orbien-org&repository=orbien">
<img src="https://img.shields.io/github/downloads/orbien-org/orbien/total?style=for-the-badge" alt="Downloads"/>
+1 -1
View File
@@ -6,7 +6,7 @@
<parent>
<groupId>io.github.lxien</groupId>
<artifactId>orbien</artifactId>
<version>3.6.0</version>
<version>3.6.1-beta.1</version>
</parent>
<artifactId>orbien-client</artifactId>
@@ -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<String> 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=<new>",
login.hostname,
login.user,
login.poolCount,
login.sessionId.isEmpty() ? "<new>" : login.sessionId);
login.poolCount);
}
private void openDataConn(String currentSessionId) {
@@ -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<TunnelConfig> 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;
}
@@ -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());
}
}
}
+1 -1
View File
@@ -6,7 +6,7 @@
<parent>
<groupId>io.github.lxien</groupId>
<artifactId>orbien</artifactId>
<version>3.6.0</version>
<version>3.6.1-beta.1</version>
</parent>
<artifactId>orbien-spring-boot-demo</artifactId>
@@ -7,7 +7,7 @@
<parent>
<groupId>io.github.lxien</groupId>
<artifactId>orbien</artifactId>
<version>3.6.0</version>
<version>3.6.1-beta.1</version>
</parent>
<artifactId>orbien-spring-boot-starter</artifactId>
@@ -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();
+1 -1
View File
@@ -6,7 +6,7 @@
<groupId>io.github.lxien</groupId>
<artifactId>orbien</artifactId>
<version>3.6.0</version>
<version>3.6.1-beta.1</version>
<packaging>pom</packaging>
<name>orbien</name>
+3
View File
@@ -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 }
}
+38 -1
View File
@@ -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())
}
}
+41
View File
@@ -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<HashMap<String, Instant>>,
}
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<String, Instant>, now: Instant) {
map.retain(|_, exp| *exp > now);
}
+41 -14
View File
@@ -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)
}
+1 -1
View File
@@ -150,7 +150,7 @@ where
self: Pin<&mut Self>,
cx: &mut TaskContext<'_>,
) -> Poll<Result<(), std::io::Error>> {
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,
+8 -1
View File
@@ -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<OpenReply>) {
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;
}
}
+47 -19
View File
@@ -19,34 +19,32 @@ import TabItem from '@theme/TabItem';
<dependency>
<groupId>io.github.lxien</groupId>
<artifactId>orbien-spring-boot-starter</artifactId>
<version>3.6.0</version>
<version>3.6.1-beta.1</version>
</dependency>
```
</TabItem>
<TabItem value="Gradle" label="Gradle">
```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")
```
</TabItem>
</Tabs>
## 最小配置
`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` 时必填 |
@@ -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)
<Tabs>
<TabItem value="Maven" label="Maven" default>
@@ -19,34 +19,32 @@ import TabItem from '@theme/TabItem';
<dependency>
<groupId>io.github.lxien</groupId>
<artifactId>orbien-spring-boot-starter</artifactId>
<version>3.6.0</version>
<version>3.6.1-beta.1</version>
</dependency>
```
</TabItem>
<TabItem value="Gradle" label="Gradle">
```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")
```
</TabItem>
</Tabs>
## 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` |
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "orbien-docs",
"version": "3.6.0",
"version": "3.6.1-beta.1",
"private": true,
"scripts": {
"docusaurus": "docusaurus",
+1 -1
View File
@@ -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';
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -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",
+40 -7
View File
@@ -21,6 +21,10 @@ use tokio::time::sleep;
type CtrlRead = ReadHalf<DynStream>;
type CtrlWrite = WriteHalf<DynStream>;
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<String>) {
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,
+6 -1
View File
@@ -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<Mutex<SessionMap>>,
pub(crate) agents: Arc<AgentRegistry>,
pub(crate) auth_replay: Arc<ReplayCache>,
http_gw: Option<Arc<HttpGw>>,
https_gw: Option<Arc<HttpsGw>>,
tls_config: Arc<rustls::ServerConfig>,
@@ -36,6 +38,9 @@ pub struct Service {
impl Service {
pub fn new(cfg: ServerConfig) -> Result<Self> {
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;
+26 -8
View File
@@ -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
));
}