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:
Chiron
2026-01-10 11:19:01 +08:00
parent f36bc02124
commit d5e7772c0e
7 changed files with 244 additions and 41 deletions
+11
View File
@@ -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",
+1
View File
@@ -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
+32 -3
View File
@@ -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
+51 -1
View File
@@ -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
}
+32 -3
View File
@@ -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
}
/// 热重载状态
+116 -34
View File
@@ -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 */}
+1
View File
@@ -524,6 +524,7 @@ export async function setEndpointProvider(
export interface NetworkInfo {
localhost: string;
lan_ip: string | null;
all_ips: string[];
}
/**