Skip to content

Commit 7d42e4b

Browse files
committed
feat: add ProxySettingsForm component and related functionality
- Implemented ProxySettingsForm.vue to manage proxy settings including enabling/disabling proxy, manual settings, and bypass entries. - Created useProxySettings composable to handle proxy settings logic, validation, and state management. - Added network.vue page to integrate ProxySettingsForm into the settings layout. - Defined types for proxy settings in proxy-settings.ts to ensure type safety across the application.
1 parent dfb5d1d commit 7d42e4b

28 files changed

Lines changed: 2378 additions & 100 deletions

i18n/locales/en.json

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,64 @@
6969
"Operation": {
7070
"Search": "Search"
7171
},
72+
"Proxy": {
73+
"Title": "Network proxy",
74+
"ScopeIncluded": "The proxy applies only to JumpServer API and background OAuth requests.",
75+
"ScopeExcluded": "The system browser and external SSH, RDP, VNC, and database clients are not affected.",
76+
"Loading": "Loading proxy settings",
77+
"LoadFailed": "Could not load proxy settings",
78+
"ConfigRecovered": "Proxy settings were safely recovered to Direct",
79+
"Retry": "Retry",
80+
"EnableProxy": "Enable proxy",
81+
"EnabledDescription": "JumpServer API and background OAuth requests use the proxy configured below.",
82+
"DisabledDescription": "The proxy is off. Supported requests connect directly to JumpServer.",
83+
"ManualSettings": "Proxy settings",
84+
"ProxyType": "Proxy type",
85+
"Host": "Host",
86+
"HostPlaceholder": "proxy.example.com or IP address",
87+
"Port": "Port",
88+
"Username": "Username",
89+
"Password": "Password",
90+
"Optional": "Optional",
91+
"PasswordSaved": "Saved",
92+
"PasswordKeepPlaceholder": "Leave blank to keep saved password",
93+
"PasswordKeepHint": "The password is never displayed. Leave this blank to keep the saved password.",
94+
"PasswordOptionalHint": "Username and password must either both be set or both be empty.",
95+
"PasswordWillClear": "The saved password will be deleted when you save.",
96+
"ClearPassword": "Clear saved password",
97+
"ClearPasswordHint": "Clear the username as well before saving a proxy without authentication.",
98+
"Bypass": "Bypass list",
99+
"BypassDescription": "Enter one host, domain, IP, or CIDR per line or separate entries with commas. Local addresses always bypass the proxy.",
100+
"BypassPlaceholder": "localhost\n127.0.0.0/8\n.internal.example.com",
101+
"TestTitle": "Connection test",
102+
"TestDescription": "Enter a JumpServer site origin. The app requests a fixed well-known endpoint. When the proxy is enabled, a target matched by the bypass list is not treated as a successful proxy test.",
103+
"TargetUrl": "JumpServer site address",
104+
"TestConnection": "Test connection",
105+
"TestSucceeded": "Connection succeeded",
106+
"TestFailed": "Connection failed",
107+
"Save": "Save",
108+
"SaveSucceeded": "Saved and confirmed by the backend",
109+
"SaveFailedWithReason": "Save failed: {reason}",
110+
"ReadbackFailedWithReason": "The settings were submitted, but backend confirmation failed: {reason}",
111+
"Errors": {
112+
"HostRequired": "Enter the proxy host.",
113+
"HostTooLong": "The proxy host cannot exceed 255 characters.",
114+
"InvalidHost": "Enter a host name or IP address without a scheme, path, or credentials.",
115+
"InvalidPort": "Port must be an integer from 1 to 65535.",
116+
"UsernameTooLong": "The username cannot exceed 256 characters.",
117+
"PasswordTooLong": "The password cannot exceed 1024 characters.",
118+
"UsernameRequired": "A saved password requires a username. To use no authentication, clear the password too.",
119+
"PasswordRequired": "A password is required when a username is set.",
120+
"InvalidBypass": "Bypass entries cannot contain a scheme, credentials, spaces, query parameters, or fragments.",
121+
"BypassTextTooLong": "The bypass list cannot exceed 32768 characters.",
122+
"TooManyBypassEntries": "The bypass list can contain at most 128 entries.",
123+
"BypassEntryTooLong": "Each bypass entry cannot exceed 255 characters.",
124+
"TargetRequired": "Enter a JumpServer site address.",
125+
"TargetTooLong": "The JumpServer site address cannot exceed 2048 characters.",
126+
"FixFields": "Fix the highlighted fields before saving.",
127+
"InvalidTargetOrigin": "Enter an http/https origin with only a host and optional port. Paths, queries, fragments, and credentials are not allowed."
128+
}
129+
},
72130
"Transcode": {
73131
"Title": "Replay Transcode",
74132
"Description": "Choose one or multiple .tar recording archive files for MP4 conversion.",

i18n/locales/zh.json

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,64 @@
6969
"Operation": {
7070
"Search": "搜索"
7171
},
72+
"Proxy": {
73+
"Title": "网络代理",
74+
"ScopeIncluded": "代理仅用于 JumpServer API 和 OAuth 后台请求。",
75+
"ScopeExcluded": "系统浏览器以及外部 SSH、RDP、VNC 和数据库客户端不受此设置影响。",
76+
"Loading": "正在读取代理设置",
77+
"LoadFailed": "无法读取代理设置",
78+
"ConfigRecovered": "代理配置已安全恢复为直连",
79+
"Retry": "重试",
80+
"EnableProxy": "启用代理",
81+
"EnabledDescription": "JumpServer API 和 OAuth 后台请求将使用下方配置的代理。",
82+
"DisabledDescription": "代理已关闭,相关请求将直接连接 JumpServer。",
83+
"ManualSettings": "代理配置",
84+
"ProxyType": "代理类型",
85+
"Host": "主机",
86+
"HostPlaceholder": "proxy.example.com 或 IP 地址",
87+
"Port": "端口",
88+
"Username": "用户名",
89+
"Password": "密码",
90+
"Optional": "可选",
91+
"PasswordSaved": "已保存",
92+
"PasswordKeepPlaceholder": "留空保持已保存的密码",
93+
"PasswordKeepHint": "密码不会回显;留空将继续使用已保存的密码。",
94+
"PasswordOptionalHint": "用户名和密码必须同时填写或同时留空。",
95+
"PasswordWillClear": "保存后将删除已保存的密码。",
96+
"ClearPassword": "清除已保存的密码",
97+
"ClearPasswordHint": "必须同时清空用户名,才能保存无认证代理。",
98+
"Bypass": "绕过列表",
99+
"BypassDescription": "每行或逗号分隔一个主机、域名、IP 或 CIDR;本机地址始终绕过代理。",
100+
"BypassPlaceholder": "localhost\n127.0.0.0/8\n.internal.example.com",
101+
"TestTitle": "连接测试",
102+
"TestDescription": "输入 JumpServer 站点 origin,应用会请求固定的 well-known 端点。代理开启时,命中绕过列表不会被视为代理测试成功。",
103+
"TargetUrl": "JumpServer 站点地址",
104+
"TestConnection": "测试连接",
105+
"TestSucceeded": "连接成功",
106+
"TestFailed": "连接失败",
107+
"Save": "保存",
108+
"SaveSucceeded": "已保存并从后端确认",
109+
"SaveFailedWithReason": "保存失败:{reason}",
110+
"ReadbackFailedWithReason": "设置已提交,但无法从后端读取确认:{reason}",
111+
"Errors": {
112+
"HostRequired": "请输入代理主机。",
113+
"HostTooLong": "代理主机不能超过 255 个字符。",
114+
"InvalidHost": "请输入不含协议、路径或凭据的主机名或 IP 地址。",
115+
"InvalidPort": "端口必须是 1 到 65535 之间的整数。",
116+
"UsernameTooLong": "用户名不能超过 256 个字符。",
117+
"PasswordTooLong": "密码不能超过 1024 个字符。",
118+
"UsernameRequired": "已有密码时必须填写用户名;如需无认证代理,请同时清除密码。",
119+
"PasswordRequired": "填写用户名时必须提供密码。",
120+
"InvalidBypass": "绕过项不能包含协议、凭据、空格、查询参数或片段。",
121+
"BypassTextTooLong": "绕过列表不能超过 32768 个字符。",
122+
"TooManyBypassEntries": "绕过列表最多允许 128 项。",
123+
"BypassEntryTooLong": "每个绕过项不能超过 255 个字符。",
124+
"TargetRequired": "请输入 JumpServer 站点地址。",
125+
"TargetTooLong": "JumpServer 站点地址不能超过 2048 个字符。",
126+
"FixFields": "请修正标出的字段后再保存。",
127+
"InvalidTargetOrigin": "请输入仅含 http/https 协议、主机和可选端口的地址,不能包含路径、参数、片段或凭据。"
128+
}
129+
},
72130
"Transcode": {
73131
"Title": "录像转码",
74132
"Description": "选择一个或多个录像压缩文件(.tar),将录像转换为 mp4 格式。",

src-tauri/Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src-tauri/Cargo.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ flate2 = "1.1.5"
3131
font-kit = "0.14"
3232
anyhow = "1.0.100"
3333
serde_json = "1.0"
34+
tempfile = "3.27"
3435
tar = "0.4"
3536
base64 = "0.22"
3637
fast_image_resize = "6.0"
@@ -91,7 +92,8 @@ version = "0.12.25"
9192
default-features = false
9293
features = [
9394
"json",
94-
"native-tls"
95+
"native-tls",
96+
"socks"
9597
]
9698

9799
[target.'cfg(any(target_os = "macos", windows, target_os = "linux"))'.dependencies]

src-tauri/src/api/client.rs

Lines changed: 10 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,68 +1,15 @@
1-
use reqwest::{redirect, Client, ClientBuilder};
2-
use std::net::IpAddr;
3-
use url::Url;
1+
use anyhow::Result;
2+
use reqwest::Client;
43

5-
/// 构建用于 JumpServer API 请求的默认 HTTP 客户端
6-
///
7-
/// 当前行为故意接受无效证书,因为现有代码支持自签名证书或其他非公开证书
8-
pub(crate) fn api_client() -> Result<Client, reqwest::Error> {
9-
instance_client_builder().build()
10-
}
11-
12-
/// 构建绑定指定站点 origin 的 HTTP 客户端。
13-
///
14-
/// 对于 localhost / 127.0.0.1 / ::1 这类本地回环地址,显式绕过系统代理,
15-
/// 避免被本机代理软件拦截,导致本地开发站点请求失败。
16-
pub(crate) fn api_client_for_origin(origin: &str) -> Result<Client, reqwest::Error> {
17-
instance_client_builder_for_origin(origin).build()
18-
}
19-
20-
/// 构建一个用于 OAuth 请求的 HTTP 客户端,该客户端不允许重定向。
21-
///
22-
/// 目前,OAuth 代码交换需要禁用重定向处理,同时仍需保持与普通 API 请求相同的证书行为
23-
pub(crate) fn oauth_client() -> Result<Client, reqwest::Error> {
24-
instance_client_builder()
25-
.redirect(redirect::Policy::none())
26-
.build()
27-
}
28-
29-
/// 构建绑定指定站点 origin 的 OAuth HTTP 客户端。
30-
///
31-
/// OAuth code/token 交换同样需要对本地回环地址绕过代理。
32-
pub(crate) fn oauth_client_for_origin(origin: &str) -> Result<Client, reqwest::Error> {
33-
instance_client_builder_for_origin(origin)
34-
.redirect(redirect::Policy::none())
35-
.build()
36-
}
4+
use crate::service::proxy::ProxyManager;
375

38-
fn instance_client_builder() -> ClientBuilder {
39-
Client::builder().danger_accept_invalid_certs(true)
6+
/// Build a JumpServer API client from the latest proxy settings.
7+
pub(crate) fn api_client_for_origin(manager: &ProxyManager, origin: &str) -> Result<Client> {
8+
manager.api_client_for_origin(origin)
409
}
4110

42-
fn instance_client_builder_for_origin(origin: &str) -> ClientBuilder {
43-
let builder = instance_client_builder();
44-
45-
if should_bypass_proxy(origin) {
46-
return builder.no_proxy();
47-
}
48-
49-
builder
50-
}
51-
52-
fn should_bypass_proxy(origin: &str) -> bool {
53-
let Ok(url) = Url::parse(origin) else {
54-
return false;
55-
};
56-
57-
let Some(host) = url.host_str() else {
58-
return false;
59-
};
60-
61-
if host.eq_ignore_ascii_case("localhost") {
62-
return true;
63-
}
64-
65-
host.parse::<IpAddr>()
66-
.map(|ip| ip.is_loopback())
67-
.unwrap_or(false)
11+
/// Build an OAuth token client from the latest proxy settings while preserving
12+
/// the existing no-redirect policy.
13+
pub(crate) fn oauth_client_for_origin(manager: &ProxyManager, origin: &str) -> Result<Client> {
14+
manager.oauth_client_for_origin(origin)
6815
}

src-tauri/src/api/request.rs

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,15 @@ use crate::{
66
},
77
utils::tz_offset_string,
88
};
9+
use anyhow::Result;
910
use log::info;
1011
use reqwest::{header::AUTHORIZATION, Client, Method, RequestBuilder, Response};
1112
use serde::Serialize;
1213
use url::Url;
1314

1415
pub(crate) use crate::api::response::ApiResponse;
1516
use crate::api::session::ApiSessionContext;
17+
use crate::service::proxy::ProxyManager;
1618

1719
pub struct ApiRequestClient {
1820
client: Client,
@@ -27,21 +29,23 @@ impl ApiRequestClient {
2729
origin: String,
2830
bearer_token: String,
2931
org_id: String,
30-
) -> Result<Self, reqwest::Error> {
32+
proxy_manager: &ProxyManager,
33+
) -> Result<Self> {
3134
Ok(Self {
32-
client: api_client_for_origin(&origin)?,
35+
client: api_client_for_origin(proxy_manager, &origin)?,
3336
origin,
3437
bearer_token,
3538
org_id,
3639
})
3740
}
3841

3942
/// 根据当前 API 会话上下文创建请求客户端
40-
pub fn from_session(context: &ApiSessionContext) -> Result<Self, reqwest::Error> {
43+
pub fn from_session(context: &ApiSessionContext, proxy_manager: &ProxyManager) -> Result<Self> {
4144
Self::with_origin(
4245
context.origin.clone(),
4346
context.bearer_token.clone(),
4447
context.org_id.clone(),
48+
proxy_manager,
4549
)
4650
}
4751

src-tauri/src/commands/api_session.rs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1+
use crate::service::proxy::ProxyManager;
12
use crate::{
23
api::session::{ApiSessionContext, ApiSessionStore},
34
service::oauth::ensure_fresh_token,
45
};
5-
use tauri::{AppHandle, State};
6+
use tauri::{AppHandle, Manager, State};
67

78
#[tauri::command]
89
pub fn set_api_session(
@@ -21,14 +22,15 @@ pub fn set_api_org(state: State<'_, ApiSessionStore>, org_id: String) -> Result<
2122
}
2223

2324
pub(crate) async fn fresh_api_context(
24-
_app: &AppHandle,
25+
app: &AppHandle,
2526
state: &ApiSessionStore,
2627
) -> Result<ApiSessionContext, String> {
2728
let mut context = state
2829
.current_context()
2930
.ok_or_else(|| "missing current api session".to_string())?;
3031

31-
let bearer = ensure_fresh_token(&context.origin, Some(&context.bearer_token))
32+
let proxy_manager = app.state::<ProxyManager>();
33+
let bearer = ensure_fresh_token(&context.origin, Some(&context.bearer_token), &proxy_manager)
3234
.await
3335
.map_err(|error| error.to_string())?;
3436

src-tauri/src/commands/asset_actions.rs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,19 @@ use crate::api::{
44
};
55
use crate::commands::api_session::fresh_api_context;
66
use crate::service::asset::{AssetQuery, AssetService};
7+
use crate::service::proxy::ProxyManager;
78
use log::{error, info};
89
use serde_json::{json, Value};
9-
use tauri::{AppHandle, Emitter, State};
10+
use tauri::{AppHandle, Emitter, Manager, State};
1011

1112
async fn load_asset_service(
1213
app: &AppHandle,
1314
session: &ApiSessionStore,
1415
) -> Result<(ApiSessionContext, AssetService), String> {
1516
let context = fresh_api_context(app, session).await?;
16-
let api = ApiRequestClient::from_session(&context).map_err(|error| error.to_string())?;
17+
let proxy_manager = app.state::<ProxyManager>();
18+
let api = ApiRequestClient::from_session(&context, &proxy_manager)
19+
.map_err(|error| error.to_string())?;
1720

1821
Ok((context, AssetService::new(api)))
1922
}

src-tauri/src/commands/auth_flow.rs

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,19 @@ use crate::service::oauth::{
66
build_oauth_client, create_authorization_request, exchange_authorization_code,
77
fetch_oauth_config, AuthFlowState,
88
};
9+
use crate::service::proxy::ProxyManager;
910
use crate::service::user::UserService;
1011

1112
#[tauri::command]
1213
pub async fn auth_login(
1314
app: AppHandle,
1415
flow_state: State<'_, AuthFlowState>,
16+
proxy_manager: State<'_, ProxyManager>,
1517
site: String,
1618
) -> Result<(), String> {
1719
// 获取 OAuth 配置
18-
let config_http_client = api_client_for_origin(&site).map_err(|e| e.to_string())?;
20+
let config_http_client =
21+
api_client_for_origin(&proxy_manager, &site).map_err(|e| e.to_string())?;
1922
let oauth_config = match fetch_oauth_config(&site, &config_http_client).await {
2023
Ok(config) => config,
2124
Err(e) => {
@@ -49,8 +52,6 @@ pub async fn auth_login(
4952
log::warn!("emit auth_url failed: {}", e);
5053
}
5154

52-
let http_client = oauth_client_for_origin(&site)?;
53-
5455
// 等待 deep link 回调传回 code/state
5556
let callback = match pending.callback_rx.await {
5657
Ok(callback) => callback,
@@ -60,6 +61,8 @@ pub async fn auth_login(
6061
}
6162
};
6263

64+
// The user may update proxy settings while the system browser is open.
65+
let http_client = oauth_client_for_origin(&proxy_manager, &site)?;
6366
let tokens = exchange_authorization_code(&client, &http_client, callback).await?;
6467

6568
// 保存 OAuth token,供后续请求自动刷新使用。
@@ -68,7 +71,8 @@ pub async fn auth_login(
6871
}
6972

7073
// 发起请求
71-
let user_service = UserService::new(site.clone(), tokens.access_token.clone())?;
74+
let user_service =
75+
UserService::new(site.clone(), tokens.access_token.clone(), &proxy_manager)?;
7276
let (profile, permission_orgs, current_org, xpack_message) = tokio::join!(
7377
user_service.get_user_profile(),
7478
user_service.get_permission_orgs(),

src-tauri/src/commands/auth_logout.rs

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
11
use crate::service::oauth::revoke_and_clear_tokens;
2+
use crate::service::proxy::ProxyManager;
23
use log::warn;
3-
use tauri::AppHandle;
4+
use tauri::{AppHandle, State};
45

56
#[tauri::command]
6-
pub async fn logout(_app: AppHandle, _name: String, site: String) -> Result<(), String> {
7-
if let Err(e) = revoke_and_clear_tokens(&site).await {
7+
pub async fn logout(
8+
_app: AppHandle,
9+
proxy_manager: State<'_, ProxyManager>,
10+
_name: String,
11+
site: String,
12+
) -> Result<(), String> {
13+
if let Err(e) = revoke_and_clear_tokens(&site, &proxy_manager).await {
814
warn!("revoke token failed: {}", e);
915
}
1016

0 commit comments

Comments
 (0)