From c8305cc65d1401a924c2298f668bf99d911358b1 Mon Sep 17 00:00:00 2001 From: mofeng-git Date: Wed, 3 Dec 2025 13:09:41 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=20turn=20=E4=B8=AD?= =?UTF-8?q?=E8=BD=AC=EF=BC=8C=E5=8F=AF=E4=BB=A5=E8=BF=9C=E7=A8=8B=E8=AE=BF?= =?UTF-8?q?=E9=97=AE=20h264/webrtc=20#197?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/build_img.yaml | 25 ++++++++++- .github/workflows/docker-build.yaml | 48 +++++++++++++++++++- configs/kvmd/override.yaml | 4 -- kvmd/apps/__init__.py | 2 + kvmd/apps/janus/runner.py | 24 ++++++++-- kvmd/apps/janus/stun.py | 7 ++- kvmd/validators/net.py | 36 +++++++++++++++ web/share/js/kvm/stream_janus.js | 69 ++++++++++++++++++++++++++++- 8 files changed, 202 insertions(+), 13 deletions(-) diff --git a/.github/workflows/build_img.yaml b/.github/workflows/build_img.yaml index 296c26dd..5a0fefdd 100644 --- a/.github/workflows/build_img.yaml +++ b/.github/workflows/build_img.yaml @@ -50,6 +50,29 @@ jobs: with: fetch-depth: 0 + - name: Inject TURN config (optional) + if: ${{ env.TURN_HOST != '' }} + run: | + mkdir -p configs/kvmd/override.d + cat > configs/kvmd/override.d/turn.yaml <> $GITHUB_STEP_SUMMARY fi - fi \ No newline at end of file + fi diff --git a/.github/workflows/docker-build.yaml b/.github/workflows/docker-build.yaml index 717b537d..2ccddc8c 100644 --- a/.github/workflows/docker-build.yaml +++ b/.github/workflows/docker-build.yaml @@ -47,6 +47,29 @@ jobs: with: fetch-depth: 0 + - name: Inject TURN config (optional) + if: ${{ env.TURN_HOST != '' }} + run: | + mkdir -p configs/kvmd/override.d + cat > configs/kvmd/override.d/turn.yaml < configs/kvmd/override.d/turn.yaml <> $GITHUB_STEP_SUMMARY echo "- **Aliyun Enabled**: ${{ github.event.inputs.enable_aliyun }}" >> $GITHUB_STEP_SUMMARY echo "- **Tags**:" >> $GITHUB_STEP_SUMMARY - echo "${{ steps.meta.outputs.tags }}" | sed 's/^/ - /' >> $GITHUB_STEP_SUMMARY \ No newline at end of file + echo "${{ steps.meta.outputs.tags }}" | sed 's/^/ - /' >> $GITHUB_STEP_SUMMARY diff --git a/configs/kvmd/override.yaml b/configs/kvmd/override.yaml index 278780cb..8a8e8dcf 100644 --- a/configs/kvmd/override.yaml +++ b/configs/kvmd/override.yaml @@ -157,10 +157,6 @@ media: jpeg: sink: 'kvmd::ustreamer::jpeg' -janus: - stun: - host: stun.cloudflare.com - port: 3478 otgnet: commands: diff --git a/kvmd/apps/__init__.py b/kvmd/apps/__init__.py index 75f5607e..78e04019 100644 --- a/kvmd/apps/__init__.py +++ b/kvmd/apps/__init__.py @@ -81,6 +81,7 @@ from ..validators.net import valid_port from ..validators.net import valid_ports_list from ..validators.net import valid_mac from ..validators.net import valid_ssl_ciphers +from ..validators.net import valid_ice_servers from ..validators.hid import valid_hid_key from ..validators.hid import valid_hid_mouse_output @@ -860,6 +861,7 @@ def _get_config_scheme() -> dict: ], type=valid_command), "cmd_remove": Option([], type=valid_options), "cmd_append": Option([], type=valid_options), + "local_ice_servers": Option([], type=valid_ice_servers, unpack_as="ice_servers"), }, "watchdog": { diff --git a/kvmd/apps/janus/runner.py b/kvmd/apps/janus/runner.py index f1a99489..e38a4e9c 100644 --- a/kvmd/apps/janus/runner.py +++ b/kvmd/apps/janus/runner.py @@ -2,6 +2,8 @@ import asyncio import asyncio.subprocess import socket import dataclasses +import json +from typing import Any import netifaces @@ -43,6 +45,7 @@ class JanusRunner: # pylint: disable=too-many-instance-attributes cmd: list[str], cmd_remove: list[str], cmd_append: list[str], + ice_servers: list[dict[str, Any]], ) -> None: self.__stun = Stun(stun_host, stun_port, stun_timeout, stun_retries, stun_retries_delay) @@ -52,6 +55,7 @@ class JanusRunner: # pylint: disable=too-many-instance-attributes self.__check_retries_delay = check_retries_delay self.__cmd = tools.build_cmd(cmd, cmd_remove, cmd_append) + self.__ice_servers = ice_servers self.__janus_task: (asyncio.Task | None) = None self.__janus_proc: (asyncio.subprocess.Process | None) = None # pylint: disable=no-member @@ -173,13 +177,25 @@ class JanusRunner: # pylint: disable=too-many-instance-attributes part.format(**placeholders) for part in cmd ] - self.__janus_proc = await aioproc.run_process( - cmd=cmd, - env={"JANUS_USTREAMER_WEB_ICE_URL": f"stun:{netcfg.stun_host}:{netcfg.stun_port}"}, - ) + env = {} + ice_payload = self.__build_ice_payload(netcfg) + if ice_payload: + env["JANUS_USTREAMER_WEB_ICE_URL"] = ice_payload + self.__janus_proc = await aioproc.run_process(cmd=cmd, env=env or None) get_logger(0).info("Started Janus pid=%d: %s", self.__janus_proc.pid, tools.cmdfmt(cmd)) async def __kill_janus_proc(self) -> None: if self.__janus_proc: await aioproc.kill_process(self.__janus_proc, 5, get_logger(0)) self.__janus_proc = None + + def __build_ice_payload(self, netcfg: _Netcfg) -> (str | None): + if self.__ice_servers: + try: + return f"json:{json.dumps(self.__ice_servers, ensure_ascii=False)}" + except Exception as ex: # pragma: no cover + get_logger(0).error("Can't encode ICE servers: %s", tools.efmt(ex)) + return None + if netcfg.stun_host and netcfg.stun_port: + return f"stun:{netcfg.stun_host}:{netcfg.stun_port}" + return None diff --git a/kvmd/apps/janus/stun.py b/kvmd/apps/janus/stun.py index 7026ec2b..b7f981c7 100644 --- a/kvmd/apps/janus/stun.py +++ b/kvmd/apps/janus/stun.py @@ -136,7 +136,12 @@ class Stun: return (StunNatType.FULL_CONE_NAT, resp) if first.changed is None: - raise RuntimeError(f"Changed addr is None: {first}") + get_logger(0).warning( + "STUN server %s:%d responded without CHANGED-ADDRESS; skipping NAT type detection", + self.__host, + self.__port, + ) + return (StunNatType.ERROR, first) resp = await self.__make_request("Change request [ext_ip != src_ip]", first.changed, b"") if not resp.ok: return (StunNatType.CHANGED_ADDR_ERROR, resp) diff --git a/kvmd/validators/net.py b/kvmd/validators/net.py index 301b160e..0b63e912 100644 --- a/kvmd/validators/net.py +++ b/kvmd/validators/net.py @@ -120,3 +120,39 @@ def valid_ssl_ciphers(arg: Any) -> str: def valid_url(arg: Any) -> str: # XXX: VERY primitive return check_re_match(arg, "HTTP(S) URL", r"^https?://[\[\w]+\S*") + + +def valid_ice_servers(arg: Any) -> list[dict[str, Any]]: + name = "ICE servers list" + if arg is None: + return [] + if not isinstance(arg, list): + raise_error(arg, name) + servers: list[dict[str, Any]] = [] + for item in arg: + if not isinstance(item, dict): + raise_error(item, "ICE server entry") + urls = item.get("urls") + if isinstance(urls, str): + urls_list = [valid_stripped_string_not_empty(urls, "ICE server URL")] + elif isinstance(urls, list): + urls_list = [ + valid_stripped_string_not_empty(url, "ICE server URL") + for url in urls + ] + else: + raise_error(urls, "ICE server URLs") + if not urls_list: + raise_error(urls, "ICE server URLs") + server: dict[str, Any] = {"urls": urls_list} + username = item.get("username") + if username is not None: + server["username"] = valid_stripped_string_not_empty(username, "ICE username") + credential = item.get("credential") + if credential is not None: + server["credential"] = valid_stripped_string_not_empty(credential, "ICE credential") + credential_type = item.get("credential_type") or item.get("credentialType") + if credential_type is not None: + server["credentialType"] = valid_stripped_string_not_empty(credential_type, "ICE credentialType") + servers.append(server) + return servers diff --git a/web/share/js/kvm/stream_janus.js b/web/share/js/kvm/stream_janus.js index a4710320..088361db 100644 --- a/web/share/js/kvm/stream_janus.js +++ b/web/share/js/kvm/stream_janus.js @@ -111,7 +111,7 @@ export function JanusStreamer(__setActive, __setInactive, __setInfo, __organizeH } }; - var __ensureJanus = function(internal) { +var __ensureJanus = function(internal) { if (__janus === null && !__stop && (!__ensuring || internal)) { __ensuring = true; __setInactive(); @@ -131,10 +131,75 @@ export function JanusStreamer(__setActive, __setInactive, __setInfo, __organizeH }); } }; + var __decodeIcePayload = function(payload) { + if (typeof payload !== "string") { + return null; + } + let data = payload.trim(); + if (data.startsWith("json:")) { + data = data.slice(5).trim(); + } + if (data.startsWith("{") || data.startsWith("[")) { + try { + let parsed = JSON.parse(data); + if (Array.isArray(parsed)) { + return parsed; + } else if (parsed && Array.isArray(parsed.servers)) { + return parsed.servers; + } + return null; + } catch (error) { + __logError("Can't parse ICE payload:", error); + } + } + return null; + }; + + var __normalizeIceEntry = function(entry) { + if (!entry || typeof entry !== "object") { + return null; + } + let urls = entry.urls; + if (typeof urls === "string") { + urls = [urls]; + } + if (!Array.isArray(urls) || urls.length === 0) { + return null; + } + let normalized = {"urls": urls}; + if (entry.username) { + normalized.username = entry.username; + } + if (entry.credential) { + normalized.credential = entry.credential; + } + if (entry.credentialType) { + normalized.credentialType = entry.credentialType; + } + return normalized; + }; + + var __normalizeIceServers = function(payload) { + let parsed = __decodeIcePayload(payload); + if (!parsed) { + return null; + } + let servers = []; + for (let entry of parsed) { + let normalized = __normalizeIceEntry(entry); + if (normalized) { + servers.push(normalized); + } + } + return (servers.length > 0 ? servers : null); + }; var __getIceServers = function() { if (__ice !== null && __ice.url) { - __logInfo("Using the custom ICE Server got from uStreamer:", __ice); + let normalized = __normalizeIceServers(__ice.url); + if (normalized !== null) { + return normalized; + } return [{"urls": __ice.url}]; } else { return [];