mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
feat: 添加 API Server 网络监听地址配置功能
- 后端改进: - 改进网络 IP 获取逻辑,使用 if-addrs 获取所有网络接口 - 智能过滤 VPN 和虚拟网卡地址(如 198.18.0.0/15) - 允许绑定到私有网络地址(10.x.x.x, 172.16-31.x.x, 192.168.x.x) - 更新地址验证逻辑,支持局域网 IP 绑定 - 前端改进: - 使用 Radix UI Select 组件美化监听地址选择器 - 支持选择 127.0.0.1(仅本机)、0.0.0.0(所有接口)或具体局域网 IP - 动态显示真实的局域网 IP 地址 - 添加安全提示:局域网访问需要非默认 API Key - 优化界面布局,状态信息移至标题旁边 - 依赖更新: - 添加 if-addrs = "0.13" 用于获取网络接口信息
This commit is contained in:
Generated
+11
@@ -2425,6 +2425,16 @@ dependencies = [
|
||||
"icu_properties",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "if-addrs"
|
||||
version = "0.13.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69b2eeee38fef3aa9b4cc5f1beea8a2444fc00e7377cafae396de3f5c2065e24"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "image"
|
||||
version = "0.25.9"
|
||||
@@ -3926,6 +3936,7 @@ dependencies = [
|
||||
"futures",
|
||||
"glob",
|
||||
"hex",
|
||||
"if-addrs",
|
||||
"indexmap 2.12.1",
|
||||
"md5",
|
||||
"mouse_position",
|
||||
|
||||
@@ -75,6 +75,7 @@ sysinfo = "0.32"
|
||||
whoami = "1"
|
||||
mouse_position = "0.1.4"
|
||||
window-vibrancy = "0.7.1"
|
||||
if-addrs = "0.13"
|
||||
|
||||
# Platform specific dependencies for browser interceptor
|
||||
|
||||
|
||||
@@ -21,18 +21,47 @@ pub fn is_loopback_host(host: &str) -> bool {
|
||||
}
|
||||
|
||||
/// 检查是否为有效的绑定地址
|
||||
/// 允许回环地址和 0.0.0.0(监听所有接口)
|
||||
/// 允许回环地址、0.0.0.0 和私有网络地址
|
||||
pub fn is_valid_bind_host(host: &str) -> bool {
|
||||
if is_loopback_host(host) {
|
||||
return true;
|
||||
}
|
||||
// 允许 0.0.0.0 和 :: (监听所有接口)
|
||||
host == "0.0.0.0" || host == "::"
|
||||
if host == "0.0.0.0" || host == "::" {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 允许私有网络地址
|
||||
if let Ok(addr) = host.parse::<std::net::IpAddr>() {
|
||||
if let std::net::IpAddr::V4(ipv4) = addr {
|
||||
// 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16
|
||||
let octets = ipv4.octets();
|
||||
return octets[0] == 10
|
||||
|| (octets[0] == 172 && (octets[1] >= 16 && octets[1] <= 31))
|
||||
|| (octets[0] == 192 && octets[1] == 168);
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// 检查是否为非本地绑定地址(需要强 API Key)
|
||||
pub fn is_non_local_bind(host: &str) -> bool {
|
||||
host == "0.0.0.0" || host == "::"
|
||||
if host == "0.0.0.0" || host == "::" {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 私有网络地址也算非本地绑定
|
||||
if let Ok(addr) = host.parse::<std::net::IpAddr>() {
|
||||
if let std::net::IpAddr::V4(ipv4) = addr {
|
||||
let octets = ipv4.octets();
|
||||
return octets[0] == 10
|
||||
|| (octets[0] == 172 && (octets[1] >= 16 && octets[1] <= 31))
|
||||
|| (octets[0] == 192 && octets[1] == 168);
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// 掩码敏感 Token
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! 提供获取本地网络接口信息的功能
|
||||
|
||||
use serde::Serialize;
|
||||
use std::net::UdpSocket;
|
||||
use std::net::{IpAddr, UdpSocket};
|
||||
|
||||
/// 网络接口信息
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
@@ -12,6 +12,8 @@ pub struct NetworkInfo {
|
||||
pub localhost: String,
|
||||
/// 内网 IP 地址(局域网)
|
||||
pub lan_ip: Option<String>,
|
||||
/// 所有可用的网络接口 IP 地址
|
||||
pub all_ips: Vec<String>,
|
||||
}
|
||||
|
||||
/// 获取本地网络信息
|
||||
@@ -20,10 +22,12 @@ pub struct NetworkInfo {
|
||||
#[tauri::command]
|
||||
pub fn get_network_info() -> Result<NetworkInfo, String> {
|
||||
let lan_ip = get_local_ip();
|
||||
let all_ips = get_all_local_ips();
|
||||
|
||||
Ok(NetworkInfo {
|
||||
localhost: "127.0.0.1".to_string(),
|
||||
lan_ip,
|
||||
all_ips,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -38,3 +42,49 @@ fn get_local_ip() -> Option<String> {
|
||||
let local_addr = socket.local_addr().ok()?;
|
||||
Some(local_addr.ip().to_string())
|
||||
}
|
||||
|
||||
/// 获取所有本地网络接口的 IP 地址
|
||||
///
|
||||
/// 返回所有非回环的 IPv4 地址,过滤掉 VPN 和虚拟网卡
|
||||
fn get_all_local_ips() -> Vec<String> {
|
||||
use std::net::Ipv4Addr;
|
||||
|
||||
let mut ips = Vec::new();
|
||||
|
||||
// 使用 if-addrs crate 获取所有网络接口
|
||||
if let Ok(interfaces) = if_addrs::get_if_addrs() {
|
||||
for iface in interfaces {
|
||||
// 只处理 IPv4 地址
|
||||
if let IpAddr::V4(ipv4) = iface.ip() {
|
||||
// 过滤掉回环地址
|
||||
if ipv4.is_loopback() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 过滤掉链路本地地址 (169.254.x.x)
|
||||
if ipv4.octets()[0] == 169 && ipv4.octets()[1] == 254 {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 过滤掉常见的 VPN 地址段
|
||||
// 198.18.0.0/15 (用于基准测试)
|
||||
if ipv4.octets()[0] == 198 && (ipv4.octets()[1] == 18 || ipv4.octets()[1] == 19) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 只保留私有网络地址
|
||||
// 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16
|
||||
let is_private = ipv4.octets()[0] == 10
|
||||
|| (ipv4.octets()[0] == 172
|
||||
&& (ipv4.octets()[1] >= 16 && ipv4.octets()[1] <= 31))
|
||||
|| (ipv4.octets()[0] == 192 && ipv4.octets()[1] == 168);
|
||||
|
||||
if is_private {
|
||||
ips.push(ipv4.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ips
|
||||
}
|
||||
|
||||
@@ -489,18 +489,47 @@ fn is_localhost_host(host: &str) -> bool {
|
||||
}
|
||||
|
||||
/// 检查是否为有效的绑定地址
|
||||
/// 允许回环地址和 0.0.0.0(监听所有接口)
|
||||
/// 允许回环地址、0.0.0.0 和私有网络地址
|
||||
fn is_valid_bind_host(host: &str) -> bool {
|
||||
if is_localhost_host(host) {
|
||||
return true;
|
||||
}
|
||||
// 允许 0.0.0.0 和 :: (监听所有接口)
|
||||
host == "0.0.0.0" || host == "::"
|
||||
if host == "0.0.0.0" || host == "::" {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 允许私有网络地址
|
||||
if let Ok(addr) = host.parse::<std::net::IpAddr>() {
|
||||
if let std::net::IpAddr::V4(ipv4) = addr {
|
||||
// 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16
|
||||
let octets = ipv4.octets();
|
||||
return octets[0] == 10
|
||||
|| (octets[0] == 172 && (octets[1] >= 16 && octets[1] <= 31))
|
||||
|| (octets[0] == 192 && octets[1] == 168);
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// 检查是否为非本地绑定地址(需要强 API Key)
|
||||
fn is_non_local_bind(host: &str) -> bool {
|
||||
host == "0.0.0.0" || host == "::"
|
||||
if host == "0.0.0.0" || host == "::" {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 私有网络地址也算非本地绑定
|
||||
if let Ok(addr) = host.parse::<std::net::IpAddr>() {
|
||||
if let std::net::IpAddr::V4(ipv4) = addr {
|
||||
let octets = ipv4.octets();
|
||||
return octets[0] == 10
|
||||
|| (octets[0] == 172 && (octets[1] >= 16 && octets[1] <= 31))
|
||||
|| (octets[0] == 192 && octets[1] == 168);
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// 热重载状态
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
ChevronUp,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
import * as Select from "@radix-ui/react-select";
|
||||
import { LogsTab } from "./LogsTab";
|
||||
import { RoutesTab } from "./RoutesTab";
|
||||
import { ProviderIcon } from "@/icons/providers";
|
||||
@@ -65,6 +66,7 @@ export function ApiServerPage() {
|
||||
|
||||
// Config editing
|
||||
const [editPort, setEditPort] = useState<string>("");
|
||||
const [editHost, setEditHost] = useState<string>("");
|
||||
const [editApiKey, setEditApiKey] = useState<string>("");
|
||||
const [defaultProvider, setDefaultProviderState] = useState<string>("kiro");
|
||||
|
||||
@@ -100,6 +102,7 @@ export function ApiServerPage() {
|
||||
const c = await getConfig();
|
||||
setConfig(c);
|
||||
setEditPort(c.server.port.toString());
|
||||
setEditHost(c.server.host);
|
||||
setEditApiKey(c.server.api_key);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
@@ -172,6 +175,7 @@ export function ApiServerPage() {
|
||||
...config,
|
||||
server: {
|
||||
...config.server,
|
||||
host: editHost,
|
||||
port: parseInt(editPort) || 8999,
|
||||
api_key: editApiKey,
|
||||
},
|
||||
@@ -635,36 +639,23 @@ export function ApiServerPage() {
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<h2 className="text-2xl font-bold">API Server</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
本地代理服务器,支持 OpenAI/Anthropic 格式
|
||||
{networkInfo && (
|
||||
<>
|
||||
{" "}
|
||||
<code className="px-1 py-0.5 rounded bg-muted text-xs">
|
||||
{networkInfo.localhost}:{config?.server.port ?? 8999}
|
||||
</code>
|
||||
{networkInfo.lan_ip && (
|
||||
<>
|
||||
{" | "}
|
||||
<code className="px-1 py-0.5 rounded bg-muted text-xs">
|
||||
{networkInfo.lan_ip}:{config?.server.port ?? 8999}
|
||||
</code>
|
||||
<span className="text-xs"> (局域网)</span>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<span className="ml-2">
|
||||
<span
|
||||
className={`inline-block h-2 w-2 rounded-full ${status?.running ? "bg-green-500" : "bg-red-500"}`}
|
||||
/>{" "}
|
||||
{status?.running ? "运行中" : "已停止"}
|
||||
{" · "}
|
||||
{status?.requests || 0} 请求
|
||||
{" · "}
|
||||
<div className="flex items-end gap-3">
|
||||
<h2 className="text-2xl font-bold">API Server</h2>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground pb-0.5">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span
|
||||
className={`inline-block h-2 w-2 rounded-full ${status?.running ? "bg-green-500" : "bg-red-500"}`}
|
||||
/>
|
||||
{status?.running ? "运行中" : "已停止"}
|
||||
</span>
|
||||
<span>·</span>
|
||||
<span>{status?.requests || 0} 请求</span>
|
||||
<span>·</span>
|
||||
<span className="capitalize">{defaultProvider}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm mt-1">
|
||||
本地代理服务器,支持 OpenAI/Anthropic 格式
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -728,14 +719,89 @@ export function ApiServerPage() {
|
||||
? "停止服务"
|
||||
: "启动服务"}
|
||||
</button>
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<div className="flex items-center gap-3 text-sm flex-wrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">监听地址:</span>
|
||||
<Select.Root
|
||||
value={editHost}
|
||||
onValueChange={setEditHost}
|
||||
disabled={status?.running}
|
||||
>
|
||||
<Select.Trigger className="inline-flex min-w-[200px] items-center justify-between gap-2 rounded-md border border-input bg-background px-3 py-1.5 text-sm shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50">
|
||||
<Select.Value />
|
||||
<Select.Icon>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</Select.Icon>
|
||||
</Select.Trigger>
|
||||
<Select.Portal>
|
||||
<Select.Content className="relative z-50 min-w-[200px] overflow-hidden rounded-md border border-border bg-white dark:bg-gray-900 text-foreground shadow-lg animate-in fade-in-80 data-[side=bottom]:slide-in-from-top-2 data-[side=top]:slide-in-from-bottom-2">
|
||||
<Select.Viewport className="p-1 bg-white dark:bg-gray-900">
|
||||
<Select.Item
|
||||
value="127.0.0.1"
|
||||
className="relative flex cursor-pointer select-none items-center rounded-sm px-8 py-2.5 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
|
||||
>
|
||||
<Select.ItemIndicator className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<Check className="h-4 w-4" />
|
||||
</Select.ItemIndicator>
|
||||
<Select.ItemText>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="font-mono">127.0.0.1</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
(仅本机)
|
||||
</span>
|
||||
</span>
|
||||
</Select.ItemText>
|
||||
</Select.Item>
|
||||
|
||||
<Select.Item
|
||||
value="0.0.0.0"
|
||||
className="relative flex cursor-pointer select-none items-center rounded-sm px-8 py-2.5 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
|
||||
>
|
||||
<Select.ItemIndicator className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<Check className="h-4 w-4" />
|
||||
</Select.ItemIndicator>
|
||||
<Select.ItemText>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="font-mono">0.0.0.0</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
(所有接口)
|
||||
</span>
|
||||
</span>
|
||||
</Select.ItemText>
|
||||
</Select.Item>
|
||||
|
||||
{networkInfo?.all_ips.map((ip) => (
|
||||
<Select.Item
|
||||
key={ip}
|
||||
value={ip}
|
||||
className="relative flex cursor-pointer select-none items-center rounded-sm px-8 py-2.5 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
|
||||
>
|
||||
<Select.ItemIndicator className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<Check className="h-4 w-4" />
|
||||
</Select.ItemIndicator>
|
||||
<Select.ItemText>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="font-mono">{ip}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
(局域网)
|
||||
</span>
|
||||
</span>
|
||||
</Select.ItemText>
|
||||
</Select.Item>
|
||||
))}
|
||||
</Select.Viewport>
|
||||
</Select.Content>
|
||||
</Select.Portal>
|
||||
</Select.Root>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">端口:</span>
|
||||
<input
|
||||
type="number"
|
||||
value={editPort}
|
||||
onChange={(e) => setEditPort(e.target.value)}
|
||||
className="w-20 rounded border bg-background px-2 py-1 text-sm"
|
||||
className="w-20 rounded-md border border-input bg-background px-3 py-1.5 text-sm shadow-sm transition-colors focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={status?.running}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -744,18 +810,34 @@ export function ApiServerPage() {
|
||||
type="text"
|
||||
value={editApiKey}
|
||||
onChange={(e) => setEditApiKey(e.target.value)}
|
||||
className="w-40 rounded border bg-background px-2 py-1 text-sm"
|
||||
className="w-40 rounded-md border border-input bg-background px-3 py-1.5 text-sm shadow-sm transition-colors focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={status?.running}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSaveServerConfig}
|
||||
disabled={loading}
|
||||
className="rounded border px-3 py-1 text-sm hover:bg-muted disabled:opacity-50"
|
||||
disabled={loading || status?.running}
|
||||
className="rounded-md border border-input bg-background px-4 py-1.5 text-sm font-medium shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground disabled:cursor-not-allowed disabled:opacity-50"
|
||||
title={status?.running ? "请先停止服务再修改配置" : ""}
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{status?.running && (
|
||||
<div className="mt-3 flex items-center gap-2 rounded-md bg-muted px-3 py-2 text-xs text-muted-foreground">
|
||||
<span>ℹ️</span>
|
||||
<span>修改配置需要先停止服务</span>
|
||||
</div>
|
||||
)}
|
||||
{(editHost === "0.0.0.0" ||
|
||||
(networkInfo?.all_ips.includes(editHost) ?? false)) &&
|
||||
!status?.running && (
|
||||
<div className="mt-3 flex items-center gap-2 rounded-md bg-amber-50 dark:bg-amber-950/20 px-3 py-2 text-xs text-amber-700 dark:text-amber-500">
|
||||
<span>⚠️</span>
|
||||
<span>局域网访问需要使用非默认 API Key 以确保安全</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Default Provider - 动态显示有凭证的 Provider */}
|
||||
|
||||
@@ -524,6 +524,7 @@ export async function setEndpointProvider(
|
||||
export interface NetworkInfo {
|
||||
localhost: string;
|
||||
lan_ip: string | null;
|
||||
all_ips: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user