feat: in-house proxies

This commit is contained in:
zhom
2026-02-15 20:55:09 +04:00
parent d1cd361c4a
commit 0563bce39d
9 changed files with 369 additions and 87 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./dist/dev/types/routes.d.ts";
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+125
View File
@@ -12,6 +12,8 @@ use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::Mutex;
use crate::browser::ProxySettings;
use crate::proxy_manager::PROXY_MANAGER;
use crate::settings_manager::SettingsManager;
use crate::sync;
@@ -71,6 +73,20 @@ struct SyncTokenResponse {
sync_token: String,
}
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
struct CloudProxyConfigResponse {
host: String,
port: u16,
username: Option<String>,
password: Option<String>,
protocol: String,
#[serde(rename = "bandwidthLimitMb")]
bandwidth_limit_mb: i64,
#[serde(rename = "bandwidthUsedMb")]
bandwidth_used_mb: i64,
}
pub struct CloudAuthManager {
client: Client,
state: Mutex<Option<CloudAuthState>>,
@@ -400,6 +416,7 @@ impl CloudAuthManager {
let status = response.status();
if status == reqwest::StatusCode::UNAUTHORIZED {
// Refresh token expired — clear everything
PROXY_MANAGER.remove_cloud_proxy();
self.clear_auth().await;
let _ = crate::events::emit_empty("cloud-auth-expired");
return Err("Session expired. Please log in again.".to_string());
@@ -519,6 +536,9 @@ impl CloudAuthManager {
.await;
}
// Remove cloud proxy on logout
PROXY_MANAGER.remove_cloud_proxy();
self.clear_auth().await;
Ok(())
}
@@ -568,6 +588,81 @@ impl CloudAuthManager {
}
}
/// Fetch proxy configuration from the cloud backend
async fn fetch_proxy_config(&self) -> Result<Option<CloudProxyConfigResponse>, String> {
// Check cached user state for proxy bandwidth
{
let state = self.state.lock().await;
match &*state {
Some(auth) if auth.user.proxy_bandwidth_limit_mb > 0 => {}
_ => return Ok(None),
}
}
match self
.api_call_with_retry(|access_token| {
let url = format!("{CLOUD_API_URL}/api/proxy/config");
let client = self.client.clone();
async move {
let response = client
.get(&url)
.header("Authorization", format!("Bearer {access_token}"))
.send()
.await
.map_err(|e| format!("Failed to fetch proxy config: {e}"))?;
let status = response.status();
if status == reqwest::StatusCode::FORBIDDEN {
return Err("__403__".to_string());
}
if !response.status().is_success() {
let body = response.text().await.unwrap_or_default();
return Err(format!("Proxy config fetch failed ({status}): {body}"));
}
response
.json::<CloudProxyConfigResponse>()
.await
.map_err(|e| format!("Failed to parse proxy config: {e}"))
}
})
.await
{
Ok(config) => Ok(Some(config)),
Err(e) if e.contains("__403__") => Ok(None),
Err(e) => {
log::warn!("Failed to fetch cloud proxy config: {e}");
Ok(None)
}
}
}
/// Sync the cloud-managed proxy: fetch config and upsert or remove
pub async fn sync_cloud_proxy(&self) {
match self.fetch_proxy_config().await {
Ok(Some(config)) => {
let settings = ProxySettings {
proxy_type: config.protocol,
host: config.host,
port: config.port,
username: config.username,
password: config.password,
};
match PROXY_MANAGER.upsert_cloud_proxy(settings) {
Ok(_) => log::debug!("Cloud proxy synced successfully"),
Err(e) => log::warn!("Failed to upsert cloud proxy: {e}"),
}
}
Ok(None) => {
PROXY_MANAGER.remove_cloud_proxy();
}
Err(e) => {
log::warn!("Failed to sync cloud proxy: {e}");
}
}
}
/// Background loop that refreshes the sync token periodically
pub async fn start_sync_token_refresh_loop(app_handle: tauri::AppHandle) {
loop {
@@ -601,6 +696,9 @@ impl CloudAuthManager {
log::debug!("Failed to refresh cloud profile: {e}");
}
// Sync cloud proxy credentials
CLOUD_AUTH.sync_cloud_proxy().await;
let _ = &app_handle; // keep app_handle alive
}
}
@@ -628,6 +726,9 @@ pub async fn cloud_verify_otp(
}
}
// Sync cloud proxy after login
CLOUD_AUTH.sync_cloud_proxy().await;
let _ = &app_handle;
Ok(state)
}
@@ -654,6 +755,30 @@ pub async fn cloud_has_active_subscription() -> Result<bool, String> {
Ok(CLOUD_AUTH.has_active_paid_subscription().await)
}
#[derive(Debug, Serialize)]
pub struct CloudProxyUsage {
pub used_mb: i64,
pub limit_mb: i64,
pub remaining_mb: i64,
}
#[tauri::command]
pub async fn cloud_get_proxy_usage() -> Result<Option<CloudProxyUsage>, String> {
let state = CLOUD_AUTH.state.lock().await;
match &*state {
Some(auth) if auth.user.proxy_bandwidth_limit_mb > 0 => {
let used = auth.user.proxy_bandwidth_used_mb;
let limit = auth.user.proxy_bandwidth_limit_mb;
Ok(Some(CloudProxyUsage {
used_mb: used,
limit_mb: limit,
remaining_mb: (limit - used).max(0),
}))
}
_ => Ok(None),
}
}
#[tauri::command]
pub async fn restart_sync_service(app_handle: tauri::AppHandle) -> Result<(), String> {
// Stop existing scheduler
+3
View File
@@ -1100,6 +1100,8 @@ pub fn run() {
if let Err(e) = cloud_auth::CLOUD_AUTH.get_or_refresh_sync_token().await {
log::warn!("Failed to refresh cloud sync token on startup: {e}");
}
// Sync cloud proxy credentials on startup
cloud_auth::CLOUD_AUTH.sync_cloud_proxy().await;
}
cloud_auth::CloudAuthManager::start_sync_token_refresh_loop(app_handle_cloud).await;
});
@@ -1218,6 +1220,7 @@ pub fn run() {
cloud_auth::cloud_get_user,
cloud_auth::cloud_refresh_profile,
cloud_auth::cloud_logout,
cloud_auth::cloud_get_proxy_usage,
cloud_auth::restart_sync_service
])
.run(tauri::generate_context!())
+79
View File
@@ -91,6 +91,8 @@ pub struct ProxyCheckResult {
pub is_valid: bool,
}
pub const CLOUD_PROXY_ID: &str = "cloud-included-proxy";
// Stored proxy configuration with name and ID for reuse
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StoredProxy {
@@ -101,6 +103,8 @@ pub struct StoredProxy {
pub sync_enabled: bool,
#[serde(default)]
pub last_sync: Option<u64>,
#[serde(default)]
pub is_cloud_managed: bool,
}
impl StoredProxy {
@@ -111,6 +115,7 @@ impl StoredProxy {
proxy_settings,
sync_enabled: false,
last_sync: None,
is_cloud_managed: false,
}
}
@@ -395,6 +400,61 @@ impl ProxyManager {
Ok(stored_proxy)
}
// Upsert the cloud-managed proxy (create or update)
pub fn upsert_cloud_proxy(&self, proxy_settings: ProxySettings) -> Result<StoredProxy, String> {
let mut stored_proxies = self.stored_proxies.lock().unwrap();
if let Some(existing) = stored_proxies.get_mut(CLOUD_PROXY_ID) {
existing.proxy_settings = proxy_settings;
let updated = existing.clone();
drop(stored_proxies);
if let Err(e) = self.save_proxy(&updated) {
log::warn!("Failed to save cloud proxy: {e}");
}
if let Err(e) = events::emit_empty("proxies-changed") {
log::error!("Failed to emit proxies-changed event: {e}");
}
Ok(updated)
} else {
let cloud_proxy = StoredProxy {
id: CLOUD_PROXY_ID.to_string(),
name: "Included Proxy".to_string(),
proxy_settings,
sync_enabled: false,
last_sync: None,
is_cloud_managed: true,
};
stored_proxies.insert(CLOUD_PROXY_ID.to_string(), cloud_proxy.clone());
drop(stored_proxies);
if let Err(e) = self.save_proxy(&cloud_proxy) {
log::warn!("Failed to save cloud proxy: {e}");
}
if let Err(e) = events::emit_empty("proxies-changed") {
log::error!("Failed to emit proxies-changed event: {e}");
}
Ok(cloud_proxy)
}
}
// Remove the cloud-managed proxy
pub fn remove_cloud_proxy(&self) {
let removed = {
let mut stored_proxies = self.stored_proxies.lock().unwrap();
stored_proxies.remove(CLOUD_PROXY_ID).is_some()
};
if removed {
if let Err(e) = self.delete_proxy_file(CLOUD_PROXY_ID) {
log::warn!("Failed to delete cloud proxy file: {e}");
}
if let Err(e) = events::emit_empty("proxies-changed") {
log::error!("Failed to emit proxies-changed event: {e}");
}
}
}
// Get all stored proxies
pub fn get_stored_proxies(&self) -> Vec<StoredProxy> {
let stored_proxies = self.stored_proxies.lock().unwrap();
@@ -423,6 +483,14 @@ impl ProxyManager {
return Err(format!("Proxy with ID '{proxy_id}' not found"));
}
// Block editing cloud-managed proxies
if stored_proxies
.get(proxy_id)
.is_some_and(|p| p.is_cloud_managed)
{
return Err("Cannot edit a cloud-managed proxy".to_string());
}
// Check if new name conflicts with existing proxies
if let Some(ref new_name) = name {
if stored_proxies
@@ -471,6 +539,15 @@ impl ProxyManager {
// Remember if sync was enabled before deleting
let was_sync_enabled = {
let stored_proxies = self.stored_proxies.lock().unwrap();
// Block deleting cloud-managed proxies
if stored_proxies
.get(proxy_id)
.is_some_and(|p| p.is_cloud_managed)
{
return Err("Cannot delete a cloud-managed proxy".to_string());
}
stored_proxies
.get(proxy_id)
.map(|p| p.sync_enabled)
@@ -601,6 +678,7 @@ impl ProxyManager {
let stored_proxies = self.stored_proxies.lock().unwrap();
let proxies: Vec<ExportedProxy> = stored_proxies
.values()
.filter(|p| !p.is_cloud_managed)
.map(|p| ExportedProxy {
name: p.name.clone(),
proxy_type: p.proxy_settings.proxy_type.clone(),
@@ -626,6 +704,7 @@ impl ProxyManager {
let stored_proxies = self.stored_proxies.lock().unwrap();
stored_proxies
.values()
.filter(|p| !p.is_cloud_managed)
.map(|p| Self::build_proxy_url(&p.proxy_settings))
.collect::<Vec<_>>()
.join("\n")
+5
View File
@@ -1228,6 +1228,11 @@ pub async fn set_proxy_sync_enabled(
.find(|p| p.id == proxy_id)
.ok_or_else(|| format!("Proxy with ID '{proxy_id}' not found"))?;
// Block modifying sync for cloud-managed proxies
if proxy.is_cloud_managed {
return Err("Cannot modify sync for a cloud-managed proxy".to_string());
}
// If disabling, check if proxy is used by any synced profile
if !enabled && is_proxy_used_by_synced_profile(&proxy_id) {
return Err("Sync cannot be disabled while this proxy is used by synced profiles".to_string());
@@ -144,6 +144,7 @@ export function ProxyAssignmentDialog({
{storedProxies.map((proxy) => (
<SelectItem key={proxy.id} value={proxy.id}>
{proxy.name}
{proxy.is_cloud_managed ? " (Included)" : ""}
</SelectItem>
))}
</SelectContent>
+141 -86
View File
@@ -100,7 +100,37 @@ export function ProxyManagementDialog({
{},
);
const { storedProxies, proxyUsage, isLoading } = useProxyEvents();
const { storedProxies: rawProxies, proxyUsage, isLoading } = useProxyEvents();
const [cloudProxyUsage, setCloudProxyUsage] = useState<{
used_mb: number;
limit_mb: number;
} | null>(null);
// Sort cloud-managed proxies first
const storedProxies = [...rawProxies].sort((a, b) => {
if (a.is_cloud_managed && !b.is_cloud_managed) return -1;
if (!a.is_cloud_managed && b.is_cloud_managed) return 1;
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
});
// Fetch cloud proxy usage
useEffect(() => {
const fetchUsage = async () => {
try {
const usage = await invoke<{
used_mb: number;
limit_mb: number;
remaining_mb: number;
} | null>("cloud_get_proxy_usage");
setCloudProxyUsage(usage);
} catch {
// ignore
}
};
if (isOpen) {
void fetchUsage();
}
}, [isOpen]);
// Listen for proxy sync status events
useEffect(() => {
@@ -281,6 +311,7 @@ export function ProxyManagementDialog({
</TableHeader>
<TableBody>
{storedProxies.map((proxy) => {
const isCloud = proxy.is_cloud_managed === true;
const syncDot = getSyncStatusDot(
proxy,
proxySyncStatus[proxy.id],
@@ -288,20 +319,32 @@ export function ProxyManagementDialog({
return (
<TableRow key={proxy.id}>
<TableCell className="font-medium">
<div className="flex items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<div
className={`w-2 h-2 rounded-full shrink-0 ${syncDot.color} ${
syncDot.animate ? "animate-pulse" : ""
}`}
/>
</TooltipTrigger>
<TooltipContent>
<p>{syncDot.tooltip}</p>
</TooltipContent>
</Tooltip>
{proxy.name}
<div className="flex flex-col gap-0.5">
<div className="flex items-center gap-2">
{!isCloud && (
<Tooltip>
<TooltipTrigger asChild>
<div
className={`w-2 h-2 rounded-full shrink-0 ${syncDot.color} ${
syncDot.animate
? "animate-pulse"
: ""
}`}
/>
</TooltipTrigger>
<TooltipContent>
<p>{syncDot.tooltip}</p>
</TooltipContent>
</Tooltip>
)}
{proxy.name}
</div>
{isCloud && cloudProxyUsage && (
<span className="text-xs text-muted-foreground">
{cloudProxyUsage.used_mb} /{" "}
{cloudProxyUsage.limit_mb} MB used
</span>
)}
</div>
</TableCell>
<TableCell>
@@ -310,36 +353,40 @@ export function ProxyManagementDialog({
</Badge>
</TableCell>
<TableCell>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Checkbox
checked={proxy.sync_enabled}
onCheckedChange={() =>
handleToggleSync(proxy)
}
disabled={
isTogglingSync[proxy.id] ||
proxyInUse[proxy.id]
}
/>
</div>
</TooltipTrigger>
<TooltipContent>
{proxyInUse[proxy.id] ? (
<p>
Sync cannot be disabled while this proxy
is used by synced profiles
</p>
) : (
<p>
{proxy.sync_enabled
? "Disable sync"
: "Enable sync"}
</p>
)}
</TooltipContent>
</Tooltip>
{isCloud ? (
<Badge variant="outline">Cloud</Badge>
) : (
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Checkbox
checked={proxy.sync_enabled}
onCheckedChange={() =>
handleToggleSync(proxy)
}
disabled={
isTogglingSync[proxy.id] ||
proxyInUse[proxy.id]
}
/>
</div>
</TooltipTrigger>
<TooltipContent>
{proxyInUse[proxy.id] ? (
<p>
Sync cannot be disabled while this proxy
is used by synced profiles
</p>
) : (
<p>
{proxy.sync_enabled
? "Disable sync"
: "Enable sync"}
</p>
)}
</TooltipContent>
</Tooltip>
)}
</TableCell>
<TableCell>
<div className="flex gap-1">
@@ -362,47 +409,55 @@ export function ProxyManagementDialog({
}));
}}
/>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => handleEditProxy(proxy)}
>
<LuPencil className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Edit proxy</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteProxy(proxy)}
disabled={
(proxyUsage[proxy.id] ?? 0) > 0
}
>
<LuTrash2 className="w-4 h-4" />
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
{(proxyUsage[proxy.id] ?? 0) > 0 ? (
<p>
Cannot delete: in use by{" "}
{proxyUsage[proxy.id]} profile
{proxyUsage[proxy.id] > 1 ? "s" : ""}
</p>
) : (
<p>Delete proxy</p>
)}
</TooltipContent>
</Tooltip>
{!isCloud && (
<>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => handleEditProxy(proxy)}
>
<LuPencil className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Edit proxy</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
variant="ghost"
size="sm"
onClick={() =>
handleDeleteProxy(proxy)
}
disabled={
(proxyUsage[proxy.id] ?? 0) > 0
}
>
<LuTrash2 className="w-4 h-4" />
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
{(proxyUsage[proxy.id] ?? 0) > 0 ? (
<p>
Cannot delete: in use by{" "}
{proxyUsage[proxy.id]} profile
{proxyUsage[proxy.id] > 1
? "s"
: ""}
</p>
) : (
<p>Delete proxy</p>
)}
</TooltipContent>
</Tooltip>
</>
)}
</div>
</TableCell>
</TableRow>
+11
View File
@@ -256,6 +256,17 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
})}
</span>
</div>
{user.proxyBandwidthLimitMb > 0 && (
<div className="flex justify-between">
<span className="text-muted-foreground">
Proxy Bandwidth
</span>
<span>
{user.proxyBandwidthUsedMb} /{" "}
{user.proxyBandwidthLimitMb} MB
</span>
</div>
)}
</div>
<div className="flex gap-2 pt-2">
+3
View File
@@ -67,12 +67,15 @@ export interface ProxyCheckResult {
is_valid: boolean;
}
export const CLOUD_PROXY_ID = "cloud-included-proxy";
export interface StoredProxy {
id: string;
name: string;
proxy_settings: ProxySettings;
sync_enabled?: boolean;
last_sync?: number;
is_cloud_managed?: boolean;
}
export interface ProfileGroup {