mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-05 12:00:59 +08:00
feat(api): add server IP display for exchange whitelist configuration (#520)
Added functionality to display server public IP address for users to configure exchange API whitelists, specifically for Binance integration. Backend changes (api/server.go): - Add GET /api/server-ip endpoint requiring authentication - Implement getPublicIPFromAPI() with fallback to multiple IP services - Implement getPublicIPFromInterface() for local network interface detection - Add isPrivateIP() helper to filter private IP addresses - Import net package for IP address handling Frontend changes (web/): - Add getServerIP() API method in api.ts - Display server IP in ExchangeConfigModal for Binance - Add IP copy-to-clipboard functionality - Load and display server IP when Binance exchange is selected - Add i18n translations (en/zh) for whitelist IP messages: - whitelistIP, whitelistIPDesc, serverIPAddresses - copyIP, ipCopied, loadingServerIP User benefits: - Simplifies Binance API whitelist configuration - Shows exact server IP to add to exchange whitelist - One-click IP copy for convenience 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
131
api/server.go
131
api/server.go
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"nofx/auth"
|
||||
"nofx/config"
|
||||
@@ -101,6 +102,9 @@ func (s *Server) setupRoutes() {
|
||||
// 需要认证的路由
|
||||
protected := api.Group("/", s.authMiddleware())
|
||||
{
|
||||
// 服务器IP查询(需要认证,用于白名单配置)
|
||||
protected.GET("/server-ip", s.handleGetServerIP)
|
||||
|
||||
// AI交易员管理
|
||||
protected.GET("/my-traders", s.handleTraderList)
|
||||
protected.GET("/traders/:id/config", s.handleGetTraderConfig)
|
||||
@@ -184,6 +188,133 @@ func (s *Server) handleGetSystemConfig(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// handleGetServerIP 获取服务器IP地址(用于白名单配置)
|
||||
func (s *Server) handleGetServerIP(c *gin.Context) {
|
||||
// 尝试通过第三方API获取公网IP
|
||||
publicIP := getPublicIPFromAPI()
|
||||
|
||||
// 如果第三方API失败,从网络接口获取第一个公网IP
|
||||
if publicIP == "" {
|
||||
publicIP = getPublicIPFromInterface()
|
||||
}
|
||||
|
||||
// 如果还是没有获取到,返回错误
|
||||
if publicIP == "" {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "无法获取公网IP地址"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"public_ip": publicIP,
|
||||
"message": "请将此IP地址添加到白名单中",
|
||||
})
|
||||
}
|
||||
|
||||
// getPublicIPFromAPI 通过第三方API获取公网IP
|
||||
func getPublicIPFromAPI() string {
|
||||
// 尝试多个公网IP查询服务
|
||||
services := []string{
|
||||
"https://api.ipify.org?format=text",
|
||||
"https://icanhazip.com",
|
||||
"https://ifconfig.me",
|
||||
}
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: 5 * time.Second,
|
||||
}
|
||||
|
||||
for _, service := range services {
|
||||
resp, err := client.Get(service)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
body := make([]byte, 128)
|
||||
n, err := resp.Body.Read(body)
|
||||
if err != nil && err.Error() != "EOF" {
|
||||
continue
|
||||
}
|
||||
|
||||
ip := strings.TrimSpace(string(body[:n]))
|
||||
// 验证是否为有效的IP地址
|
||||
if net.ParseIP(ip) != nil {
|
||||
return ip
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// getPublicIPFromInterface 从网络接口获取第一个公网IP
|
||||
func getPublicIPFromInterface() string {
|
||||
interfaces, err := net.Interfaces()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
for _, iface := range interfaces {
|
||||
// 跳过未启用的接口和回环接口
|
||||
if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
addrs, err := iface.Addrs()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, addr := range addrs {
|
||||
var ip net.IP
|
||||
switch v := addr.(type) {
|
||||
case *net.IPNet:
|
||||
ip = v.IP
|
||||
case *net.IPAddr:
|
||||
ip = v.IP
|
||||
}
|
||||
|
||||
if ip == nil || ip.IsLoopback() {
|
||||
continue
|
||||
}
|
||||
|
||||
// 只考虑IPv4地址
|
||||
if ip.To4() != nil {
|
||||
ipStr := ip.String()
|
||||
// 排除私有IP地址范围
|
||||
if !isPrivateIP(ip) {
|
||||
return ipStr
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// isPrivateIP 判断是否为私有IP地址
|
||||
func isPrivateIP(ip net.IP) bool {
|
||||
// 私有IP地址范围:
|
||||
// 10.0.0.0/8
|
||||
// 172.16.0.0/12
|
||||
// 192.168.0.0/16
|
||||
privateRanges := []string{
|
||||
"10.0.0.0/8",
|
||||
"172.16.0.0/12",
|
||||
"192.168.0.0/16",
|
||||
}
|
||||
|
||||
for _, cidr := range privateRanges {
|
||||
_, subnet, _ := net.ParseCIDR(cidr)
|
||||
if subnet.Contains(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// getTraderFromQuery 从query参数获取trader
|
||||
func (s *Server) getTraderFromQuery(c *gin.Context) (*manager.TraderManager, string, error) {
|
||||
userID := c.GetString("user_id")
|
||||
|
||||
@@ -1582,6 +1582,12 @@ function ExchangeConfigModal({
|
||||
const [passphrase, setPassphrase] = useState('');
|
||||
const [testnet, setTestnet] = useState(false);
|
||||
const [showGuide, setShowGuide] = useState(false);
|
||||
const [serverIP, setServerIP] = useState<{
|
||||
public_ip: string;
|
||||
message: string;
|
||||
} | null>(null);
|
||||
const [loadingIP, setLoadingIP] = useState(false);
|
||||
const [copiedIP, setCopiedIP] = useState(false);
|
||||
|
||||
// 币安配置指南展开状态
|
||||
const [showBinanceGuide, setShowBinanceGuide] = useState(false);
|
||||
@@ -1605,6 +1611,9 @@ function ExchangeConfigModal({
|
||||
setPassphrase('') // Don't load existing passphrase for security
|
||||
setTestnet(selectedExchange.testnet || false)
|
||||
|
||||
// Hyperliquid 字段
|
||||
setHyperliquidWalletAddr(selectedExchange.hyperliquidWalletAddr || '')
|
||||
|
||||
// Aster 字段
|
||||
setAsterUser(selectedExchange.asterUser || '')
|
||||
setAsterSigner(selectedExchange.asterSigner || '')
|
||||
@@ -1612,6 +1621,30 @@ function ExchangeConfigModal({
|
||||
}
|
||||
}, [editingExchangeId, selectedExchange])
|
||||
|
||||
// 加载服务器IP(当选择binance时)
|
||||
useEffect(() => {
|
||||
if (selectedExchangeId === 'binance' && !serverIP) {
|
||||
setLoadingIP(true);
|
||||
api.getServerIP()
|
||||
.then(data => {
|
||||
setServerIP(data);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Failed to load server IP:', err);
|
||||
})
|
||||
.finally(() => {
|
||||
setLoadingIP(false);
|
||||
});
|
||||
}
|
||||
}, [selectedExchangeId]);
|
||||
|
||||
const handleCopyIP = (ip: string) => {
|
||||
navigator.clipboard.writeText(ip).then(() => {
|
||||
setCopiedIP(true);
|
||||
setTimeout(() => setCopiedIP(false), 2000);
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!selectedExchangeId) return
|
||||
@@ -1900,8 +1933,38 @@ function ExchangeConfigModal({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Binance 白名单IP提示 */}
|
||||
{selectedExchange.id === 'binance' && (
|
||||
<div className="p-4 rounded" style={{ background: 'rgba(240, 185, 11, 0.1)', border: '1px solid rgba(240, 185, 11, 0.2)' }}>
|
||||
<div className="text-sm font-semibold mb-2" style={{ color: '#F0B90B' }}>
|
||||
{t('whitelistIP', language)}
|
||||
</div>
|
||||
<div className="text-xs mb-3" style={{ color: '#848E9C' }}>
|
||||
{t('whitelistIPDesc', language)}
|
||||
</div>
|
||||
|
||||
{loadingIP ? (
|
||||
<div className="text-xs" style={{ color: '#848E9C' }}>
|
||||
{t('loadingServerIP', language)}
|
||||
</div>
|
||||
) : serverIP && serverIP.public_ip ? (
|
||||
<div className="flex items-center gap-2 p-2 rounded" style={{ background: '#0B0E11' }}>
|
||||
<code className="flex-1 text-sm font-mono" style={{ color: '#F0B90B' }}>{serverIP.public_ip}</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleCopyIP(serverIP.public_ip)}
|
||||
className="px-3 py-1 rounded text-xs font-semibold transition-all hover:scale-105"
|
||||
style={{ background: 'rgba(240, 185, 11, 0.2)', color: '#F0B90B' }}
|
||||
>
|
||||
{copiedIP ? t('ipCopied', language) : t('copyIP', language)}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Hyperliquid 交易所的字段 */}
|
||||
{selectedExchange.id === 'hyperliquid' && (
|
||||
|
||||
@@ -291,6 +291,12 @@ export const translations = {
|
||||
viewGuide: 'View Guide',
|
||||
binanceSetupGuide: 'Binance Setup Guide',
|
||||
closeGuide: 'Close',
|
||||
whitelistIP: 'Whitelist IP',
|
||||
whitelistIPDesc: 'Binance requires adding server IP to API whitelist',
|
||||
serverIPAddresses: 'Server IP Addresses',
|
||||
copyIP: 'Copy',
|
||||
ipCopied: 'IP Copied',
|
||||
loadingServerIP: 'Loading server IP...',
|
||||
|
||||
// Error Messages
|
||||
createTraderFailed: 'Failed to create trader',
|
||||
@@ -758,6 +764,12 @@ export const translations = {
|
||||
viewGuide: '查看教程',
|
||||
binanceSetupGuide: '币安配置教程',
|
||||
closeGuide: '关闭',
|
||||
whitelistIP: '白名单IP',
|
||||
whitelistIPDesc: '币安交易所需要填写白名单IP',
|
||||
serverIPAddresses: '服务器IP地址',
|
||||
copyIP: '复制',
|
||||
ipCopied: 'IP已复制',
|
||||
loadingServerIP: '正在加载服务器IP...',
|
||||
|
||||
// Error Messages
|
||||
createTraderFailed: '创建交易员失败',
|
||||
|
||||
@@ -327,4 +327,16 @@ export const api = {
|
||||
})
|
||||
if (!res.ok) throw new Error('保存用户信号源配置失败')
|
||||
},
|
||||
}
|
||||
|
||||
// 获取服务器IP(需要认证,用于白名单配置)
|
||||
async getServerIP(): Promise<{
|
||||
public_ip: string;
|
||||
message: string;
|
||||
}> {
|
||||
const res = await fetch(`${API_BASE}/server-ip`, {
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
if (!res.ok) throw new Error('获取服务器IP失败');
|
||||
return res.json();
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user