From f1a6c75fc0dbd5a18d4de7a5676b12c350002933 Mon Sep 17 00:00:00 2001 From: Augtons <79037928+Augtons@users.noreply.github.com> Date: Sun, 3 May 2026 16:43:29 +0800 Subject: [PATCH] feat(webrtc): add configurable STUN and TURN servers Add backend config, RPC handlers, and an HTTP endpoint for WebRTC ICE servers. Replace hardcoded frontend STUN usage with server-provided ICE server configuration, and add access settings UI for STUN and TURN entries. --- config.go | 12 ++ jsonrpc.go | 58 +++++++ ui/src/components/Macro/Combobox.tsx | 2 +- ui/src/components/Macro/MacroStepCard.tsx | 5 +- .../access/AccessContent.tsx | 15 ++ .../access/WebRtcServers.tsx | 159 ++++++++++++++++++ ui/src/layout/index.mobile.tsx | 21 ++- ui/src/layout/index.pc.tsx | 20 ++- ui/src/locales/en.json | 22 ++- ui/src/locales/zh.json | 22 ++- web.go | 11 ++ webrtc.go | 38 +++-- 12 files changed, 354 insertions(+), 31 deletions(-) create mode 100644 ui/src/layout/components_setting/access/WebRtcServers.tsx diff --git a/config.go b/config.go index c1162d1..d67568f 100644 --- a/config.go +++ b/config.go @@ -21,6 +21,12 @@ type WakeOnLanDevice struct { MacAddress string `json:"macAddress"` } +type TurnServer struct { + URL string `json:"url"` + Username string `json:"username"` + Credential string `json:"credential"` +} + // Constants for keyboard macro limits const ( MaxMacrosPerDevice = 25 @@ -81,6 +87,7 @@ func (m *KeyboardMacro) Validate() error { type Config struct { STUN string `json:"stun"` + TurnServers []TurnServer `json:"turn_servers"` JigglerEnabled bool `json:"jiggler_enabled"` AutoUpdateEnabled bool `json:"auto_update_enabled"` IncludePreRelease bool `json:"include_pre_release"` @@ -185,6 +192,7 @@ const sdConfigPath = "/mnt/sdcard/kvm_config.json" var defaultConfig = &Config{ STUN: "stun:stun.l.google.com:19302", + TurnServers: []TurnServer{}, AutoUpdateEnabled: false, // Set a default value ActiveExtension: "", KeyboardMacros: []KeyboardMacro{}, @@ -299,6 +307,10 @@ func LoadConfig() { loadedConfig.Firewall = defaultConfig.Firewall } + if loadedConfig.TurnServers == nil { + loadedConfig.TurnServers = []TurnServer{} + } + config = &loadedConfig logging.GetRootLogger().UpdateLogLevel(config.DefaultLogLevel) diff --git a/jsonrpc.go b/jsonrpc.go index 3bc5c94..34cca39 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -852,6 +852,60 @@ func rpcSetConfigRaw(configStr string) error { return nil } +type RtcServersConfig struct { + STUN string `json:"stun"` + DefaultSTUN string `json:"defaultStun"` + TurnServers []TurnServer `json:"turnServers"` +} + +func rpcGetRtcServersConfig() (RtcServersConfig, error) { + return RtcServersConfig{ + STUN: config.STUN, + DefaultSTUN: DefaultSTUN, + TurnServers: config.TurnServers, + }, nil +} + +func rpcSetStunServer(stun string) error { + config.STUN = stun + return SaveConfig() +} + +type SetTurnServersParams struct { + Servers []TurnServer `json:"servers"` +} + +func rpcSetTurnServers(params SetTurnServersParams) error { + config.TurnServers = params.Servers + if config.TurnServers == nil { + config.TurnServers = []TurnServer{} + } + return SaveConfig() +} + +type IceServerJSON struct { + URLs []string `json:"urls"` + Username string `json:"username,omitempty"` + Credential string `json:"credential,omitempty"` +} + +func rpcGetIceServers() ([]IceServerJSON, error) { + raw := buildICEServers() + out := make([]IceServerJSON, 0, len(raw)) + for _, server := range raw { + credential := "" + if server.Credential != nil { + credential = fmt.Sprintf("%v", server.Credential) + } + out = append(out, IceServerJSON{ + URLs: server.URLs, + Username: server.Username, + Credential: credential, + }) + } + return out, nil +} + func rpcGetActiveExtension() (string, error) { return config.ActiveExtension, nil } @@ -1406,6 +1460,10 @@ var rpcHandlers = map[string]RPCHandler{ "resetConfig": {Func: rpcResetConfig}, "getConfigRaw": {Func: rpcGetConfigRaw}, "setConfigRaw": {Func: rpcSetConfigRaw, Params: []string{"configStr"}}, + "getRtcServersConfig": {Func: rpcGetRtcServersConfig}, + "setStunServer": {Func: rpcSetStunServer, Params: []string{"stun"}}, + "setTurnServers": {Func: rpcSetTurnServers, Params: []string{"params"}}, + "getIceServers": {Func: rpcGetIceServers}, "setDisplayRotation": {Func: rpcSetDisplayRotation, Params: []string{"params"}}, "getDisplayRotation": {Func: rpcGetDisplayRotation}, "setBacklightSettings": {Func: rpcSetBacklightSettings, Params: []string{"params"}}, diff --git a/ui/src/components/Macro/Combobox.tsx b/ui/src/components/Macro/Combobox.tsx index 822846d..08028af 100644 --- a/ui/src/components/Macro/Combobox.tsx +++ b/ui/src/components/Macro/Combobox.tsx @@ -27,7 +27,7 @@ const comboboxVariants = cva({ variants: { size: sizes }, }); -type BaseProps = React.ComponentProps; +type BaseProps = React.ComponentProps>; interface ComboboxProps extends Omit { displayValue: (option: ComboboxOption) => string; diff --git a/ui/src/components/Macro/MacroStepCard.tsx b/ui/src/components/Macro/MacroStepCard.tsx index e3257d4..010daa9 100644 --- a/ui/src/components/Macro/MacroStepCard.tsx +++ b/ui/src/components/Macro/MacroStepCard.tsx @@ -205,7 +205,8 @@ export function MacroStepCard({ )}
{ + onChange={value => { + if (!value) return; onKeySelect(value); onKeyQueryChange(''); }} @@ -239,4 +240,4 @@ export function MacroStepCard({
); -} \ No newline at end of file +} diff --git a/ui/src/layout/components_setting/access/AccessContent.tsx b/ui/src/layout/components_setting/access/AccessContent.tsx index aa946f0..6883d26 100644 --- a/ui/src/layout/components_setting/access/AccessContent.tsx +++ b/ui/src/layout/components_setting/access/AccessContent.tsx @@ -23,6 +23,7 @@ import { LogDialog } from "@components/LogDialog"; import { Dialog } from "@/layout/components_setting/access/auth"; import AutoHeight from "@components/AutoHeight"; import FirewallSettings from "./FirewallSettings"; +import WebRtcServersSettings from "./WebRtcServers"; export interface TailScaleResponse { state: string; @@ -997,6 +998,20 @@ function AccessContent({ setOpenDialog }: { setOpenDialog: (open: boolean) => vo )} +
+ + + +
+ +
+
+
+
+
([]); + + useEffect(() => { + send("getRtcServersConfig", {}, resp => { + if ("error" in resp) { + notifications.error(`${$at("Failed to load WebRTC servers")}: ${resp.error.data || $at("Unknown error")}`); + return; + } + + const cfg = resp.result as RtcServersConfig; + setDefaultStun(cfg.defaultStun); + setStun(cfg.stun || cfg.defaultStun); + setTurnServers(cfg.turnServers ?? []); + }); + }, [send]); + + const saveStun = (value: string) => { + send("setStunServer", { stun: value }, resp => { + if ("error" in resp) { + notifications.error(`${$at("Failed to save STUN")}: ${resp.error.data || $at("Unknown error")}`); + return; + } + + setStun(value); + notifications.success($at("STUN server saved")); + }); + }; + + const persistTurnServers = (servers: TurnServer[]) => { + send("setTurnServers", { params: { servers } }, resp => { + if ("error" in resp) { + notifications.error(`${$at("Failed to save TURN")}: ${resp.error.data || $at("Unknown error")}`); + return; + } + + setTurnServers(servers); + notifications.success($at("TURN servers saved")); + }); + }; + + const updateTurnRow = (index: number, field: keyof TurnServer, value: string) => { + setTurnServers(prev => prev.map((server, i) => (i === index ? { ...server, [field]: value } : server))); + }; + + const addTurnRow = () => { + setTurnServers(prev => [...prev, { url: "", username: "", credential: "" }]); + }; + + const deleteTurnRow = (index: number) => { + persistTurnServers(turnServers.filter((_, i) => i !== index)); + }; + + return ( +
+ +
+ setStun(e.target.value)} + placeholder={defaultStun} + className="min-w-0" + /> +
+
+
+
+ +
+
+ {$at("TURN Servers")} +
+
+ {$at("Used as relay when direct peer-to-peer connection fails")} +
+
+ +
+ {turnServers.length === 0 && ( +
{$at("No TURN servers configured")}
+ )} + + {turnServers.map((server, index) => ( +
+ updateTurnRow(index, "url", e.target.value)} + placeholder="turn:turn.example.com:3478" + /> + updateTurnRow(index, "username", e.target.value)} + placeholder={$at("Username")} + /> + updateTurnRow(index, "credential", e.target.value)} + placeholder={$at("Credential")} + type="password" + /> +
+ ))} + +
+
+
+
+ ); +} diff --git a/ui/src/layout/index.mobile.tsx b/ui/src/layout/index.mobile.tsx index 6d08346..61e751a 100644 --- a/ui/src/layout/index.mobile.tsx +++ b/ui/src/layout/index.mobile.tsx @@ -33,6 +33,7 @@ import { useSettingsStore, useVpnStore } from "@/hooks/stores"; import { JsonRpcRequest, useJsonRpc, resetHttpSessionId } from "@/hooks/useJsonRpc"; +import api from "@/api"; import Modal from "@components/Modal"; import { useDeviceUiNavigation } from "@/hooks/useAppNavigation"; import { @@ -351,6 +352,18 @@ export default function MobileHome() { try { console.log("[setupPeerConnection] Creating peer connection"); setLoadingMessage("Creating peer connection..."); + let fetchedIceServers: RTCIceServer[] = []; + if (!iceConfig?.iceServers) { + try { + const res = await api.GET("/api/ice-servers"); + const data = await res.json(); + fetchedIceServers = data.iceServers ?? []; + } catch (e) { + console.error("failed to fetch ICE servers, fallback", e); + fetchedIceServers = [{ urls: ["stun:stun.l.google.com:19302"] }]; + } + } + pc = new RTCPeerConnection({ // We only use STUN or TURN servers if we're in the cloud //...(isInCloud && iceConfig?.iceServers @@ -358,13 +371,7 @@ export default function MobileHome() { // : {}), ...(iceConfig?.iceServers ? { iceServers: [iceConfig?.iceServers] } - : { - iceServers: [ - { - urls: ['stun:stun.l.google.com:19302'] - } - ] - }), + : { iceServers: fetchedIceServers }), }); setPeerConnectionState(pc.connectionState); diff --git a/ui/src/layout/index.pc.tsx b/ui/src/layout/index.pc.tsx index 2506219..f053bf5 100644 --- a/ui/src/layout/index.pc.tsx +++ b/ui/src/layout/index.pc.tsx @@ -363,6 +363,18 @@ export default function PCHome() { try { console.log("[setupPeerConnection] Creating peer connection"); setLoadingMessage("Creating peer connection..."); + let fetchedIceServers: RTCIceServer[] = []; + if (!iceConfig?.iceServers) { + try { + const res = await api.GET("/api/ice-servers"); + const data = await res.json(); + fetchedIceServers = data.iceServers ?? []; + } catch (e) { + console.error("failed to fetch ICE servers, fallback", e); + fetchedIceServers = [{ urls: ["stun:stun.l.google.com:19302"] }]; + } + } + pc = new RTCPeerConnection({ // We only use STUN or TURN servers if we're in the cloud //...(isInCloud && iceConfig?.iceServers @@ -370,13 +382,7 @@ export default function PCHome() { // : {}), ...(iceConfig?.iceServers ? { iceServers: [iceConfig?.iceServers] } - : { - iceServers: [ - { - urls: ['stun:stun.l.google.com:19302'] - } - ] - }), + : { iceServers: fetchedIceServers }), }); setPeerConnectionState(pc.connectionState); diff --git a/ui/src/locales/en.json b/ui/src/locales/en.json index c83e7db..2b66a2f 100644 --- a/ui/src/locales/en.json +++ b/ui/src/locales/en.json @@ -571,5 +571,23 @@ "65f1314580": "Confirm your password", "af21497286": "Set Password", "c3f88872d6": "This password will be used to secure your device data and protect against unauthorized access.", - "06a7b3bf6e": "All data remains on your local device." -} \ No newline at end of file + "06a7b3bf6e": "All data remains on your local device.", + "d6d56d5972": "WebRTC Servers", + "8bea04c48e": "STUN and TURN servers used for peer connections", + "4cd48aed7f": "STUN server saved", + "f6f10b4517": "TURN servers saved", + "d235995f96": "STUN Server", + "fc1bd2c935": "Public STUN server for NAT traversal", + "289929755b": "Restore Default", + "0268827609": "TURN Servers", + "b3c14a0273": "Used as relay when direct peer-to-peer connection fails", + "bbc48fb751": "No TURN servers configured", + "f6039d44b2": "Username", + "03bc142e64": "Credential", + "ca3e8baee9": "Add TURN Server", + "0acde1b3e3": "Save TURN Servers", + "6f20995c95": "Failed to load WebRTC servers", + "6ef7ce5b80": "Failed to save STUN", + "4b5d050e51": "Failed to save TURN", + "aee9784c03": "Unknown error" +} diff --git a/ui/src/locales/zh.json b/ui/src/locales/zh.json index 1e7ad6b..766846b 100644 --- a/ui/src/locales/zh.json +++ b/ui/src/locales/zh.json @@ -571,5 +571,23 @@ "65f1314580": "确认您的密码", "af21497286": "设置密码", "c3f88872d6": "此密码将用于保护您的设备数据并防止未经授权的访问。", - "06a7b3bf6e": "所有数据保留在您的本地设备上。" -} \ No newline at end of file + "06a7b3bf6e": "所有数据保留在您的本地设备上。", + "d6d56d5972": "WebRTC 服务器", + "8bea04c48e": "用于点对点连接的 STUN 和 TURN 服务器", + "4cd48aed7f": "STUN 服务器已保存", + "f6f10b4517": "TURN 服务器已保存", + "d235995f96": "STUN 服务器", + "fc1bd2c935": "用于 NAT 穿透的公共 STUN 服务器", + "289929755b": "恢复默认", + "0268827609": "TURN 服务器", + "b3c14a0273": "当直接点对点连接失败时用作中继", + "bbc48fb751": "未配置 TURN 服务器", + "f6039d44b2": "用户名", + "03bc142e64": "凭据", + "ca3e8baee9": "添加 TURN 服务器", + "0acde1b3e3": "保存 TURN 服务器", + "6f20995c95": "加载 WebRTC 服务器失败", + "6ef7ce5b80": "保存 STUN 失败", + "4b5d050e51": "保存 TURN 失败", + "aee9784c03": "未知错误" +} diff --git a/web.go b/web.go index a5d39e6..d8cfb44 100644 --- a/web.go +++ b/web.go @@ -168,6 +168,7 @@ func setupRouter() *gin.Engine { protected.GET("/storage/download", handleDownloadHttp) protected.GET("/storage/sd-download", handleSDDownloadHttp) protected.POST("/api/rpc", handleRpcRequest) + protected.GET("/api/ice-servers", handleGetIceServers) protected.GET("/terminal/ws", handleTerminalWS) protected.GET("/serial/ws", handleSerialWS) protected.GET("/video/stream", handleVideoStream) @@ -907,6 +908,16 @@ func handleRpcRequest(c *gin.Context) { c.JSON(http.StatusOK, response) } +func handleGetIceServers(c *gin.Context) { + LoadConfig() + servers, err := rpcGetIceServers() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"iceServers": servers}) +} + func handleVideoStream(c *gin.Context) { logger.Info().Msg("HTTP video stream request received") diff --git a/webrtc.go b/webrtc.go index fc6103e..9509cbf 100644 --- a/webrtc.go +++ b/webrtc.go @@ -34,6 +34,33 @@ type SessionConfig struct { Logger *zerolog.Logger } +const DefaultSTUN = "stun:stun.l.google.com:19302" + +func buildICEServers() []webrtc.ICEServer { + if config == nil { + LoadConfig() + } + + stunURL := config.STUN + if stunURL == "" { + stunURL = DefaultSTUN + } + + servers := []webrtc.ICEServer{{URLs: []string{stunURL}}} + for _, turnServer := range config.TurnServers { + if turnServer.URL == "" { + continue + } + servers = append(servers, webrtc.ICEServer{ + URLs: []string{turnServer.URL}, + Username: turnServer.Username, + Credential: turnServer.Credential, + }) + } + + return servers +} + func (s *Session) ExchangeOffer(offerStr string) (string, error) { b, err := base64.StdEncoding.DecodeString(offerStr) if err != nil { @@ -86,16 +113,7 @@ func newSession(sessionConfig SessionConfig) (*Session, error) { scopedLogger = webrtcLogger } - iceServers := []webrtc.ICEServer{ - { - URLs: []string{"stun:stun.l.google.com:19302"}, - }, - } - if config.STUN != "" { - iceServers = append(iceServers, webrtc.ICEServer{ - URLs: []string{config.STUN}, - }) - } + iceServers := buildICEServers() api := webrtc.NewAPI(webrtc.WithSettingEngine(webrtcSettingEngine)) peerConnection, err := api.NewPeerConnection(webrtc.Configuration{