Skip to content

Commit 77c9949

Browse files
Im-Sueclaude
andauthored
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 <[email protected]>
1 parent 7dd669a commit 77c9949

File tree

4 files changed

+221
-3
lines changed

4 files changed

+221
-3
lines changed

api/server.go

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"encoding/json"
55
"fmt"
66
"log"
7+
"net"
78
"net/http"
89
"nofx/auth"
910
"nofx/config"
@@ -101,6 +102,9 @@ func (s *Server) setupRoutes() {
101102
// 需要认证的路由
102103
protected := api.Group("/", s.authMiddleware())
103104
{
105+
// 服务器IP查询(需要认证,用于白名单配置)
106+
protected.GET("/server-ip", s.handleGetServerIP)
107+
104108
// AI交易员管理
105109
protected.GET("/my-traders", s.handleTraderList)
106110
protected.GET("/traders/:id/config", s.handleGetTraderConfig)
@@ -184,6 +188,133 @@ func (s *Server) handleGetSystemConfig(c *gin.Context) {
184188
})
185189
}
186190

191+
// handleGetServerIP 获取服务器IP地址(用于白名单配置)
192+
func (s *Server) handleGetServerIP(c *gin.Context) {
193+
// 尝试通过第三方API获取公网IP
194+
publicIP := getPublicIPFromAPI()
195+
196+
// 如果第三方API失败,从网络接口获取第一个公网IP
197+
if publicIP == "" {
198+
publicIP = getPublicIPFromInterface()
199+
}
200+
201+
// 如果还是没有获取到,返回错误
202+
if publicIP == "" {
203+
c.JSON(http.StatusInternalServerError, gin.H{"error": "无法获取公网IP地址"})
204+
return
205+
}
206+
207+
c.JSON(http.StatusOK, gin.H{
208+
"public_ip": publicIP,
209+
"message": "请将此IP地址添加到白名单中",
210+
})
211+
}
212+
213+
// getPublicIPFromAPI 通过第三方API获取公网IP
214+
func getPublicIPFromAPI() string {
215+
// 尝试多个公网IP查询服务
216+
services := []string{
217+
"https://api.ipify.org?format=text",
218+
"https://icanhazip.com",
219+
"https://ifconfig.me",
220+
}
221+
222+
client := &http.Client{
223+
Timeout: 5 * time.Second,
224+
}
225+
226+
for _, service := range services {
227+
resp, err := client.Get(service)
228+
if err != nil {
229+
continue
230+
}
231+
defer resp.Body.Close()
232+
233+
if resp.StatusCode == http.StatusOK {
234+
body := make([]byte, 128)
235+
n, err := resp.Body.Read(body)
236+
if err != nil && err.Error() != "EOF" {
237+
continue
238+
}
239+
240+
ip := strings.TrimSpace(string(body[:n]))
241+
// 验证是否为有效的IP地址
242+
if net.ParseIP(ip) != nil {
243+
return ip
244+
}
245+
}
246+
}
247+
248+
return ""
249+
}
250+
251+
// getPublicIPFromInterface 从网络接口获取第一个公网IP
252+
func getPublicIPFromInterface() string {
253+
interfaces, err := net.Interfaces()
254+
if err != nil {
255+
return ""
256+
}
257+
258+
for _, iface := range interfaces {
259+
// 跳过未启用的接口和回环接口
260+
if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 {
261+
continue
262+
}
263+
264+
addrs, err := iface.Addrs()
265+
if err != nil {
266+
continue
267+
}
268+
269+
for _, addr := range addrs {
270+
var ip net.IP
271+
switch v := addr.(type) {
272+
case *net.IPNet:
273+
ip = v.IP
274+
case *net.IPAddr:
275+
ip = v.IP
276+
}
277+
278+
if ip == nil || ip.IsLoopback() {
279+
continue
280+
}
281+
282+
// 只考虑IPv4地址
283+
if ip.To4() != nil {
284+
ipStr := ip.String()
285+
// 排除私有IP地址范围
286+
if !isPrivateIP(ip) {
287+
return ipStr
288+
}
289+
}
290+
}
291+
}
292+
293+
return ""
294+
}
295+
296+
// isPrivateIP 判断是否为私有IP地址
297+
func isPrivateIP(ip net.IP) bool {
298+
// 私有IP地址范围:
299+
// 10.0.0.0/8
300+
// 172.16.0.0/12
301+
// 192.168.0.0/16
302+
privateRanges := []string{
303+
"10.0.0.0/8",
304+
"172.16.0.0/12",
305+
"192.168.0.0/16",
306+
}
307+
308+
for _, cidr := range privateRanges {
309+
_, subnet, _ := net.ParseCIDR(cidr)
310+
if subnet.Contains(ip) {
311+
return true
312+
}
313+
}
314+
315+
return false
316+
}
317+
187318
// getTraderFromQuery 从query参数获取trader
188319
func (s *Server) getTraderFromQuery(c *gin.Context) (*manager.TraderManager, string, error) {
189320
userID := c.GetString("user_id")

web/src/components/AITradersPage.tsx

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1582,6 +1582,12 @@ function ExchangeConfigModal({
15821582
const [passphrase, setPassphrase] = useState('');
15831583
const [testnet, setTestnet] = useState(false);
15841584
const [showGuide, setShowGuide] = useState(false);
1585+
const [serverIP, setServerIP] = useState<{
1586+
public_ip: string;
1587+
message: string;
1588+
} | null>(null);
1589+
const [loadingIP, setLoadingIP] = useState(false);
1590+
const [copiedIP, setCopiedIP] = useState(false);
15851591

15861592
// 币安配置指南展开状态
15871593
const [showBinanceGuide, setShowBinanceGuide] = useState(false);
@@ -1605,13 +1611,40 @@ function ExchangeConfigModal({
16051611
setPassphrase('') // Don't load existing passphrase for security
16061612
setTestnet(selectedExchange.testnet || false)
16071613

1614+
// Hyperliquid 字段
1615+
setHyperliquidWalletAddr(selectedExchange.hyperliquidWalletAddr || '')
1616+
16081617
// Aster 字段
16091618
setAsterUser(selectedExchange.asterUser || '')
16101619
setAsterSigner(selectedExchange.asterSigner || '')
16111620
setAsterPrivateKey('') // Don't load existing private key for security
16121621
}
16131622
}, [editingExchangeId, selectedExchange])
16141623

1624+
// 加载服务器IP(当选择binance时)
1625+
useEffect(() => {
1626+
if (selectedExchangeId === 'binance' && !serverIP) {
1627+
setLoadingIP(true);
1628+
api.getServerIP()
1629+
.then(data => {
1630+
setServerIP(data);
1631+
})
1632+
.catch(err => {
1633+
console.error('Failed to load server IP:', err);
1634+
})
1635+
.finally(() => {
1636+
setLoadingIP(false);
1637+
});
1638+
}
1639+
}, [selectedExchangeId]);
1640+
1641+
const handleCopyIP = (ip: string) => {
1642+
navigator.clipboard.writeText(ip).then(() => {
1643+
setCopiedIP(true);
1644+
setTimeout(() => setCopiedIP(false), 2000);
1645+
});
1646+
};
1647+
16151648
const handleSubmit = async (e: React.FormEvent) => {
16161649
e.preventDefault()
16171650
if (!selectedExchangeId) return
@@ -1900,8 +1933,38 @@ function ExchangeConfigModal({
19001933
/>
19011934
</div>
19021935
)}
1903-
</>
1904-
)}
1936+
1937+
{/* Binance 白名单IP提示 */}
1938+
{selectedExchange.id === 'binance' && (
1939+
<div className="p-4 rounded" style={{ background: 'rgba(240, 185, 11, 0.1)', border: '1px solid rgba(240, 185, 11, 0.2)' }}>
1940+
<div className="text-sm font-semibold mb-2" style={{ color: '#F0B90B' }}>
1941+
{t('whitelistIP', language)}
1942+
</div>
1943+
<div className="text-xs mb-3" style={{ color: '#848E9C' }}>
1944+
{t('whitelistIPDesc', language)}
1945+
</div>
1946+
1947+
{loadingIP ? (
1948+
<div className="text-xs" style={{ color: '#848E9C' }}>
1949+
{t('loadingServerIP', language)}
1950+
</div>
1951+
) : serverIP && serverIP.public_ip ? (
1952+
<div className="flex items-center gap-2 p-2 rounded" style={{ background: '#0B0E11' }}>
1953+
<code className="flex-1 text-sm font-mono" style={{ color: '#F0B90B' }}>{serverIP.public_ip}</code>
1954+
<button
1955+
type="button"
1956+
onClick={() => handleCopyIP(serverIP.public_ip)}
1957+
className="px-3 py-1 rounded text-xs font-semibold transition-all hover:scale-105"
1958+
style={{ background: 'rgba(240, 185, 11, 0.2)', color: '#F0B90B' }}
1959+
>
1960+
{copiedIP ? t('ipCopied', language) : t('copyIP', language)}
1961+
</button>
1962+
</div>
1963+
) : null}
1964+
</div>
1965+
)}
1966+
</>
1967+
)}
19051968

19061969
{/* Hyperliquid 交易所的字段 */}
19071970
{selectedExchange.id === 'hyperliquid' && (

web/src/i18n/translations.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,12 @@ export const translations = {
291291
viewGuide: 'View Guide',
292292
binanceSetupGuide: 'Binance Setup Guide',
293293
closeGuide: 'Close',
294+
whitelistIP: 'Whitelist IP',
295+
whitelistIPDesc: 'Binance requires adding server IP to API whitelist',
296+
serverIPAddresses: 'Server IP Addresses',
297+
copyIP: 'Copy',
298+
ipCopied: 'IP Copied',
299+
loadingServerIP: 'Loading server IP...',
294300

295301
// Error Messages
296302
createTraderFailed: 'Failed to create trader',
@@ -758,6 +764,12 @@ export const translations = {
758764
viewGuide: '查看教程',
759765
binanceSetupGuide: '币安配置教程',
760766
closeGuide: '关闭',
767+
whitelistIP: '白名单IP',
768+
whitelistIPDesc: '币安交易所需要填写白名单IP',
769+
serverIPAddresses: '服务器IP地址',
770+
copyIP: '复制',
771+
ipCopied: 'IP已复制',
772+
loadingServerIP: '正在加载服务器IP...',
761773

762774
// Error Messages
763775
createTraderFailed: '创建交易员失败',

web/src/lib/api.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -327,4 +327,16 @@ export const api = {
327327
})
328328
if (!res.ok) throw new Error('保存用户信号源配置失败')
329329
},
330-
}
330+
331+
// 获取服务器IP(需要认证,用于白名单配置)
332+
async getServerIP(): Promise<{
333+
public_ip: string;
334+
message: string;
335+
}> {
336+
const res = await fetch(`${API_BASE}/server-ip`, {
337+
headers: getAuthHeaders(),
338+
});
339+
if (!res.ok) throw new Error('获取服务器IP失败');
340+
return res.json();
341+
},
342+
};

0 commit comments

Comments
 (0)