RK3528 Armbian 改造成 RDP 瘦客户机：开机自启、OpenVFD 状态显示和 VirtualHere 反向连接
背景手里这块 RK3528 盒子跑的是 Armbian / Ubuntu 24.04，当前内核是 6.1.115-vendor-rk35xx。实际需求不是跑 RT-Linux，而是把它改造成一个专用 RDP 瘦客户机：开机尽量快。只用有线网口。自动全屏连接指定 Windows RDP 主机。前面板数码管显示 RDP 目标 IP。LAN/WiFi 图标分别显示本地网络和 RDP 目标连通状态。VirtualHere USB Server 开机自启，并反向连接到 RDP 服务器上的客户端。下面命令以设备 IP 172.30.5.38、RDP 目标 172.30.5.17 为例。涉及密码的地方请替换成自己的值，文章里不放实际密码。1. 安装 RDP kiosk 基础组件apt update apt install -y freerdp3-x11 openbox unclutter x11-xserver-utils创建 kiosk 启动脚本：cat >/usr/local/bin/rdp-kiosk-session <<'EOF' #!/bin/bash set -u RDP_HOST="172.30.5.17" RDP_USER="你的RDP用户名" RDP_PASS="你的RDP密码" xset s off xset -dpms xset s noblank xsetroot -solid '#202020' 2>/dev/null || true unclutter -idle 0.5 -root & openbox & while true; do if command -v xfreerdp3 >/dev/null 2>&1; then RDP_BIN=xfreerdp3 else RDP_BIN=xfreerdp fi until timeout 1 bash -c "</dev/tcp/$RDP_HOST/3389" 2>/dev/null; do sleep 0.3 done if [ -e /tmp/rdp-windowed ]; then "$RDP_BIN" /v:"$RDP_HOST" /u:"$RDP_USER" /p:"$RDP_PASS" \ /size:1600x900 /dynamic-resolution /cert:ignore +clipboard \ /network:auto /auto-reconnect /auto-reconnect-max-retries:999 else "$RDP_BIN" /v:"$RDP_HOST" /u:"$RDP_USER" /p:"$RDP_PASS" \ /f -decorations /dynamic-resolution /cert:ignore +clipboard \ /network:auto /auto-reconnect /auto-reconnect-max-retries:999 fi sleep 1 done EOF chmod 755 /usr/local/bin/rdp-kiosk-session注册 LightDM 会话。不同镜像默认用户可能叫 armbian、admin 或其它名字，所以先取当前系统的普通用户变量：KIOSK_USER="${KIOSK_USER:-$(id -un 1000 2>/dev/null || awk -F: '$3>=1000 && $3<60000 {print $1; exit}' /etc/passwd)}" KIOSK_HOME="$(getent passwd "$KIOSK_USER" | cut -d: -f6)" cat >/usr/share/xsessions/rdp-kiosk.desktop <<'EOF' [Desktop Entry] Name=RDP Kiosk Comment=Auto start RDP session Exec=/usr/local/bin/rdp-kiosk-session Type=Application EOF mkdir -p /etc/lightdm/lightdm.conf.d cat >/etc/lightdm/lightdm.conf.d/99-rdp-kiosk.conf <<EOF [Seat:*] autologin-user=$KIOSK_USER autologin-user-timeout=0 autologin-session=rdp-kiosk user-session=rdp-kiosk greeter-session=slick-greeter EOF systemctl set-default graphical.target窗口/全屏切换靠文件控制：# 切到带标题栏窗口模式 ssh root@172.30.5.38 'touch /tmp/rdp-windowed; pkill -x xfreerdp3' # 切回全屏无边框模式 ssh root@172.30.5.38 'rm -f /tmp/rdp-windowed; pkill -x xfreerdp3'1.1 启用 mDNS 主机名解析如果不想在 RDP 脚本里直接写死 IP，也可以让瘦客户机支持 mDNS，用类似 rdp-host.local 的名字连接 RDP 主机。Ubuntu / Armbian 上可以直接用 systemd-resolved 开启 mDNS，同时关闭 LLMNR：mkdir -p /etc/systemd/resolved.conf.d cat >/etc/systemd/resolved.conf.d/mdns.conf <<'EOF' [Resolve] MulticastDNS=yes LLMNR=no EOF systemctl restart systemd-resolved确认有线网卡 end0 上 mDNS 是否开启：resolvectl status end0理想状态里应该能看到类似：Protocols: ... +mDNS ...然后测试解析：resolvectl query rdp-host.local ping -c 3 rdp-host.localFreeRDP 脚本里就可以把目标从 IP 改成主机名：RDP_HOST="rdp-host.local"注意：mDNS 适合同一二层局域网的小规模环境。如果要追求最高可靠性，仍然建议优先使用 DHCP 静态租约或本地 DNS；mDNS 可以作为比 NetBIOS/LLMNR 更干净的主机名解析方案。1.2 RDP 目标改成可配置，并支持无网线启动时弹窗输入为了避免 DHCP 地址变化导致脚本里写死 IP，可以把 RDP 目标统一保存到一个文件：$KIOSK_HOME/.config/rdp-kiosk/target当前示例使用 mDNS 主机名。下面仍然沿用前面的当前普通用户变量；如果单独执行这一段，先补上这两行：KIOSK_USER="${KIOSK_USER:-$(id -un 1000 2>/dev/null || awk -F: '$3>=1000 && $3<60000 {print $1; exit}' /etc/passwd)}" KIOSK_HOME="$(getent passwd "$KIOSK_USER" | cut -d: -f6)" install -d -o "$KIOSK_USER" -g "$KIOSK_USER" "$KIOSK_HOME/.config/rdp-kiosk" echo 'win10.local' >"$KIOSK_HOME/.config/rdp-kiosk/target" chown "$KIOSK_USER:$KIOSK_USER" "$KIOSK_HOME/.config/rdp-kiosk/target"rdp-kiosk-session 启动时读取这个文件作为目标。如果 end0 没有检测到网线，会弹出输入框，让现场输入新的 RDP IP 或域名；输入后保存到 target 文件，插上网线后自动连接。核心逻辑如下：KIOSK_USER="${KIOSK_USER:-${USER:-$(id -un)}}" KIOSK_HOME="${HOME:-$(getent passwd "$KIOSK_USER" | cut -d: -f6)}" TARGET_FILE="${KIOSK_HOME}/.config/rdp-kiosk/target" DEFAULT_RDP_HOST="win10.local" read_rdp_host() { if [ -s "$TARGET_FILE" ]; then tr -d '[:space:]' <"$TARGET_FILE" else printf '%s' "$DEFAULT_RDP_HOST" fi } save_rdp_host() { local host="$1" install -d -o "$KIOSK_USER" -g "$KIOSK_USER" "$(dirname "$TARGET_FILE")" printf '%s\n' "$host" >"$TARGET_FILE" chown "$KIOSK_USER:$KIOSK_USER" "$TARGET_FILE" 2>/dev/null || true } ethernet_has_carrier() { [ "$(cat /sys/class/net/end0/carrier 2>/dev/null || echo 0)" = "1" ] }弹窗用 zenity，文案建议用英文，避免某些极简 X 会话下中文字体或 locale 不完整导致乱码：zenity --entry \ --title='RDP Target Setup' \ --text='Ethernet cable is not connected. Enter the RDP host IP or domain. It will connect after the cable is plugged in.' \ --entry-text="$current"手动修改目标也很简单：echo 'win10.local' >"$KIOSK_HOME/.config/rdp-kiosk/target" chown "$KIOSK_USER:$KIOSK_USER" "$KIOSK_HOME/.config/rdp-kiosk/target" pkill -x xfreerdp31.3 RDP 连接失败时弹窗提示原因为了避免网络不通、DNS 错误、密码错误时只看到黑屏，可以在启动 FreeRDP 前做预检查，并把 FreeRDP 退出日志保存下来：LOG_FILE="/tmp/rdp-kiosk-freerdp.log"预检查逻辑：check_rdp_target() { local host="$1" if ! ethernet_has_carrier; then show_message "RDP Network Error" "Ethernet cable is not connected. Please plug in the cable." return 1 fi if ! getent hosts "$host" >/dev/null 2>&1; then show_message "RDP DNS Error" "Cannot resolve RDP host: $host" return 1 fi if ! timeout 3 bash -c "</dev/tcp/$host/3389" >/dev/null 2>&1; then show_message "RDP Connection Error" "Cannot connect to $host:3389. Check network, firewall, or RDP service." return 1 fi return 0 }FreeRDP 启动时把输出写入日志："$RDP_BIN" /v:"$RDP_HOST" /u:"$RDP_USER" /p:"$RDP_PASS" \ /f -decorations /dynamic-resolution /cert:ignore +clipboard \ /network:auto /auto-reconnect /auto-reconnect-max-retries:999 \ >"$LOG_FILE" 2>&1退出后根据日志关键词判断：if grep -Eiq 'AUTHENTICATION_FAILED|ERRCONNECT_AUTHENTICATION_FAILED|logon failure|denied|invalid' "$LOG_FILE"; then show_message "RDP Session Failed" "RDP login failed. Check username, password, or account permissions." fi常见提示包括：RDP Network Error 网线没插 RDP DNS Error 主机名解析失败 RDP Connection Error 3389 不通 RDP Session Failed FreeRDP 退出，可能是密码、权限、证书或网络问题5.1 让 vhusbdarm 跟随 RDP 目标 IP 或主机名如果 RDP 目标从固定 IP 改成了 mDNS 名称，例如 win10.local，VirtualHere 反向连接也应该跟随同一个目标文件。这样现场通过弹窗修改 RDP 目标后，vhusbdarm 会自动同步到新的主机名或 IP。同步脚本：cat >/usr/local/sbin/vhusbdarm-sync-target <<'EOF' #!/bin/bash set -e KIOSK_USER="${KIOSK_USER:-$(id -un 1000 2>/dev/null || awk -F: '$3>=1000 && $3<60000 {print $1; exit}' /etc/passwd)}" KIOSK_HOME="$(getent passwd "$KIOSK_USER" | cut -d: -f6)" TARGET_FILE="${KIOSK_HOME}/.config/rdp-kiosk/target" DEFAULT_RDP_HOST="win10.local" CONFIG_FILE="/etc/virtualhere/config.ini" PORT="7573" if [ -s "$TARGET_FILE" ]; then host="$(tr -d '[:space:]' <"$TARGET_FILE")" else host="$DEFAULT_RDP_HOST" fi [ -n "$host" ] || host="$DEFAULT_RDP_HOST" mkdir -p "$(dirname "$CONFIG_FILE")" printf 'ReverseClients=%s:%s\n' "$host" "$PORT" >"$CONFIG_FILE" EOF chmod 755 /usr/local/sbin/vhusbdarm-sync-target修改 vhusbdarm.service，启动前先生成配置：cat >/etc/systemd/system/vhusbdarm.service <<'EOF' [Unit] Description=VirtualHere USB Server After=network.target [Service] Type=simple ExecStartPre=/usr/local/sbin/vhusbdarm-sync-target ExecStart=/usr/local/sbin/vhusbdarm -c /etc/virtualhere/config.ini Restart=always RestartSec=3 [Install] WantedBy=multi-user.target EOF再加一个 path unit，监听 RDP target 文件变化后自动重启 VirtualHere：KIOSK_USER="${KIOSK_USER:-$(id -un 1000 2>/dev/null || awk -F: '$3>=1000 && $3<60000 {print $1; exit}' /etc/passwd)}" KIOSK_HOME="$(getent passwd "$KIOSK_USER" | cut -d: -f6)" cat >/etc/systemd/system/rdp-target-vhusbdarm.path <<EOF [Unit] Description=Watch RDP target changes for VirtualHere reverse client [Path] PathChanged=${KIOSK_HOME}/.config/rdp-kiosk/target Unit=rdp-target-vhusbdarm.service [Install] WantedBy=multi-user.target EOF cat >/etc/systemd/system/rdp-target-vhusbdarm.service <<'EOF' [Unit] Description=Restart VirtualHere after RDP target changes [Service] Type=oneshot ExecStart=/bin/systemctl restart vhusbdarm.service EOF systemctl daemon-reload systemctl enable --now rdp-target-vhusbdarm.path systemctl restart vhusbdarm.service检查当前同步结果：cat "$KIOSK_HOME/.config/rdp-kiosk/target" cat /etc/virtualhere/config.ini systemctl status vhusbdarm.service rdp-target-vhusbdarm.path示例输出：win10.local ReverseClients=win10.local:75732. 启动优化先看耗时：systemd-analyze systemd-analyze blame | head -40 systemd-analyze critical-chain graphical.target这台机器一开始主要卡在 systemd-networkd-wait-online.service，优化后启动从 2 分钟左右降到 18 秒左右。只用有线口时可以禁用这些服务：nmcli connection modify Teemar connection.autoconnect no 2>/dev/null || true nmcli connection down Teemar 2>/dev/null || true nmcli radio wifi off 2>/dev/null || true systemctl disable --now wpa_supplicant.service 2>/dev/null || true systemctl mask wpa_supplicant.service 2>/dev/null || true systemctl disable --now systemd-networkd.service systemd-network-generator.service 2>/dev/null || true systemctl mask systemd-networkd.service systemd-network-generator.service 2>/dev/null || true systemctl disable --now systemd-networkd-wait-online.service NetworkManager-wait-online.service 2>/dev/null || true systemctl mask systemd-networkd-wait-online.service NetworkManager-wait-online.service 2>/dev/null || true systemctl disable --now apt-daily.timer apt-daily-upgrade.timer 2>/dev/null || true systemctl mask apt-daily.service apt-daily-upgrade.service 2>/dev/null || true systemctl disable --now serial-getty@ttyFIQ0.service getty@tty1.service setvtrgb.service 2>/dev/null || true systemctl mask serial-getty@ttyFIQ0.service getty@tty1.service setvtrgb.service 2>/dev/null || true systemctl disable --now rc-local.service accounts-daemon.service udisks2.service inetutils-inetd.service 2>/dev/null || true systemctl mask rc-local.service accounts-daemon.service udisks2.service inetutils-inetd.service 2>/dev/null || true systemctl disable --now cron.service anacron.service ubuntu-advantage.service ua-reboot-cmds.service 2>/dev/null || true systemctl mask ubuntu-advantage.service ua-reboot-cmds.service 2>/dev/null || true保留这些核心服务：systemctl enable NetworkManager.service ssh.service lightdm.service systemd-resolved.service systemd-timesyncd.service3. OpenVFD 数码管显示 RDP 目标 IP这类盒子前面板一般是 OpenVFD。先确认驱动：ls /sys/class/leds/openvfd ls /dev/openvfd原系统里可能有：armbian-openvfd 27为了显示固定字符串，编译 YAopenvfD：cd /tmp git clone --depth=1 https://github.com/7Ji/YAopenvfD.git cd YAopenvfD make install -m 755 YAopenvfD /usr/local/bin/YAopenvfD加载 RK3528PRO13 的 OpenVFD 配置：cat >/usr/local/sbin/openvfd-load-rk3528 <<'EOF' #!/bin/bash set -e killall vfdservice 2>/dev/null || true rmmod openvfd 2>/dev/null || true . /usr/share/openvfd/conf/rk3528pro13.conf modprobe openvfd vfd_gpio_clk=${vfd_gpio_clk} \ vfd_gpio_dat=${vfd_gpio_dat} \ vfd_gpio_stb=${vfd_gpio_stb:-0,0,0xFF} \ vfd_gpio0=${vfd_gpio0:-0,0,0xFF} \ vfd_gpio1=${vfd_gpio1:-0,0,0xFF} \ vfd_gpio2=${vfd_gpio2:-0,0,0xFF} \ vfd_gpio3=${vfd_gpio3:-0,0,0xFF} \ vfd_gpio_protocol=${vfd_gpio_protocol:-0,0} \ vfd_chars=${vfd_chars} vfd_dot_bits=${vfd_dot_bits} \ vfd_display_type=${vfd_display_type} EOF chmod 755 /usr/local/sbin/openvfd-load-rk3528显示 RDP 目标 IP 最后一段 0017：/usr/local/sbin/openvfd-load-rk3528 /usr/local/bin/YAopenvfD 0:string:00174. LAN 和 WiFi 图标做连通状态灯目标：数码管数字固定显示 0017。LAN 图标表示本地网关 172.30.4.1 是否通。WiFi 图标表示 RDP 目标 172.30.5.17:3389 是否通。监控脚本：cat >/usr/local/sbin/rdp-kiosk-link-watch <<'EOF' #!/bin/bash set -u GATEWAY="172.30.4.1" RDP_HOST="172.30.5.17" RDP_PORT="3389" LOCAL_FLAG="/run/local-net-up" RDP_FLAG="/run/rdp-target-up" rm -f "$LOCAL_FLAG" "$RDP_FLAG" sleep 1 while true; do if ping -I end0 -c 1 -W 1 "$GATEWAY" >/dev/null 2>&1; then [ -e "$LOCAL_FLAG" ] || touch "$LOCAL_FLAG" else [ ! -e "$LOCAL_FLAG" ] || rm -f "$LOCAL_FLAG" fi if timeout 1 bash -c "</dev/tcp/$RDP_HOST/$RDP_PORT" >/dev/null 2>&1; then [ -e "$RDP_FLAG" ] || touch "$RDP_FLAG" else [ ! -e "$RDP_FLAG" ] || rm -f "$RDP_FLAG" fi sleep 2 done EOF chmod 755 /usr/local/sbin/rdp-kiosk-link-watch这块 ABOX 类型屏的图标名要用：lan：LAN 图标。wifihi + wifilo：WiFi 图标两段。systemd 服务：cat >/etc/systemd/system/yaopenvfd-rdp-ip.service <<'EOF' [Unit] Description=Display RDP target IP suffix and network status on OpenVFD After=local-fs.target Conflicts=openvfd-init.service [Service] Type=simple ExecStartPre=/usr/bin/rm -f /run/local-net-up /run/rdp-target-up ExecStartPre=/usr/local/sbin/openvfd-load-rk3528 ExecStart=/usr/local/bin/YAopenvfD 0:string:0017 @lan:file:/run/local-net-up @wifihi:file:/run/rdp-target-up @wifilo:file:/run/rdp-target-up --dots-order 0,1,2,3,4,5,6 ExecStopPost=-/usr/bin/killall YAopenvfD Restart=always RestartSec=1 [Install] WantedBy=multi-user.target EOF cat >/etc/systemd/system/rdp-kiosk-link-watch.service <<'EOF' [Unit] Description=Watch local LAN and RDP target reachability for OpenVFD dots After=NetworkManager.service network.target yaopenvfd-rdp-ip.service Wants=NetworkManager.service yaopenvfd-rdp-ip.service [Service] Type=simple ExecStart=/usr/local/sbin/rdp-kiosk-link-watch Restart=always RestartSec=1 [Install] WantedBy=multi-user.target EOF systemctl daemon-reload systemctl enable --now yaopenvfd-rdp-ip.service rdp-kiosk-link-watch.service确认：systemctl status yaopenvfd-rdp-ip.service rdp-kiosk-link-watch.service ls -l /run/local-net-up /run/rdp-target-up pgrep -a YAopenvfD5. VirtualHere USB Server 开机启动把 ARM 版 vhusbdarm 放到：/usr/local/sbin/vhusbdarm chmod 755 /usr/local/sbin/vhusbdarm创建配置文件。这里让 RK3528 反向连接到 RDP 服务器上的 VirtualHere Client：mkdir -p /etc/virtualhere cat >/etc/virtualhere/config.ini <<'EOF' ReverseClients=172.30.5.17:7573 EOF创建 systemd 服务：cat >/etc/systemd/system/vhusbdarm.service <<'EOF' [Unit] Description=VirtualHere USB Server After=network.target [Service] Type=simple ExecStart=/usr/local/sbin/vhusbdarm -c /etc/virtualhere/config.ini Restart=always RestartSec=3 [Install] WantedBy=multi-user.target EOF systemctl daemon-reload systemctl enable --now vhusbdarm.service检查：systemctl status vhusbdarm.service ss -lntup | grep 7575 journalctl -b -u vhusbdarm.service --no-pager | tail -806. Windows 上开启 vhui64 反向监听Windows 客户端 vhui64.exe 开启反向连接：C:\Users\tm\Desktop\vhui64.exe -t "REVERSE"查看状态：C:\Users\tm\Desktop\vhui64.exe -t "LIST"看到下面这行就是开启了：Reverse Lookup currently on这个版本实际监听的是 TCP 7573。可以在 Windows 上检查：Get-NetTCPConnection -State Listen | Where-Object LocalPort -eq 7573如果要开机自动开启，可以建一个启动脚本：@echo off start "" "C:\Users\tm\Desktop\vhui64.exe" timeout /t 3 /nobreak >nul "C:\Users\tm\Desktop\vhui64.exe" -t "REVERSE"放到当前用户启动目录：C:\Users\tm\AppData\Roaming\Microsoft\Windows\Start Menu\Programs\Startup\vhui_reverse.bat7. 常用排查命令RDP：pgrep -a xfreerdp systemctl status lightdm journalctl -b -u lightdm --no-pager | tail -80网络：ip -br addr nmcli dev status ping -I end0 -c 3 172.30.4.1 timeout 2 bash -c '</dev/tcp/172.30.5.17/3389' && echo RDP_OK || echo RDP_FAIL数码管：systemctl restart yaopenvfd-rdp-ip.service rdp-kiosk-link-watch.service pgrep -a YAopenvfD cat /sys/class/leds/openvfd/led_onVirtualHere：cat /etc/virtualhere/config.ini systemctl restart vhusbdarm.service journalctl -b -u vhusbdarm.service --no-pager | tail -808. 当前效果最终效果：RK3528 开机进入图形界面后自动全屏连接 172.30.5.17。FreeRDP 使用默认自动协商参数，保留流畅度。本地窗口无装饰，避免 Windows 任务栏被边框挤掉。数码管显示 0017。LAN 图标表示本地网络是否通。WiFi 图标表示 RDP 服务是否可达。VirtualHere USB Server 开机启动并反向连接到 172.30.5.17:7573。9. 近期增强：多 RDP 会话、OSD 状态和目标管理下面是后续在 H313 / RK3528 瘦客户机场景中补充的功能，适合工厂现场快速切换多台 Windows RDP 主机。9.1 mDNS 解析启用 .local 主机名解析，方便目标主机 DHCP 地址变化后继续用主机名连接：apt install -y avahi-daemon libnss-mdns mkdir -p /etc/systemd/resolved.conf.d cat >/etc/systemd/resolved.conf.d/mdns.conf <<'EOF' [Resolve] MulticastDNS=yes LLMNR=no EOF sed -i 's/^hosts:.*/hosts: files mymachines mdns4_minimal [NOTFOUND=return] dns myhostname/' /etc/nsswitch.conf systemctl enable --now avahi-daemon systemd-resolved systemctl restart avahi-daemon systemd-resolved CONN=$(nmcli -t -f NAME,DEVICE connection show --active | awk -F: '$2=="end0"{print $1; exit}') nmcli connection modify "$CONN" connection.mdns yes connection.llmnr no nmcli connection down "$CONN" && nmcli connection up "$CONN"验证：resolvectl status end0 resolvectl query win10-lab-001.local getent ahostsv4 win10-lab-001.local9.2 右上角 OSD 状态显示OSD 使用 osd_cat 直接显示在 RDP 画面上，并通过 X11 shape 设置鼠标穿透，避免影响 RDP 操作。显示内容示例：LOCAL-HOST H313.local LOCAL-IP 172.30.5.24/22 TARGET win10-lab-001.local TARGET-IP 172.30.5.9 TCP3389 OK 28ms LINK end0 up active FreeRDP sessionTARGET / TARGET-IP / 延时 优先检测当前活动的 FreeRDP 会话；如果当前活动窗口不是 FreeRDP，则退回最新的 FreeRDP 进程，再退回保存的默认目标。9.3 绿色加号：目标和会话管理右上角绿色 + 是一个很小的 X11 控件，可以拖动位置，位置保存到：/home/admin/.config/rdp-kiosk/hotspot-pos双击绿色 + 打开目标管理窗口，功能包括：一个可输入的历史下拉框，默认显示上次保存的目标。输入新主机名/IP 后点击“连接”，会新建一个 FreeRDP 会话，不会关闭旧会话。显示当前所有 FreeRDP 会话。可切换到选中会话。可关闭选中会话。可删除某条历史记录。可检测输入框当前目标的 ICMP、RDP 3389、VirtualHere 7573。可配置本机 end0 的 DHCP / 静态 IP、网关、DNS。手动调出窗口：DISPLAY=:0 XAUTHORITY=/home/admin/.Xauthority /usr/local/bin/rdp-target-pickerroot 下运行：runuser -u admin -- env DISPLAY=:0 XAUTHORITY=/home/admin/.Xauthority /usr/local/bin/rdp-target-picker历史文件：/home/admin/.config/rdp-kiosk/history当前默认目标：/home/admin/.config/rdp-kiosk/target9.4 红色 X：关闭当前活动会话右上角红色 X 也是可拖动控件，位置保存到：/home/admin/.config/rdp-kiosk/disconnect-pos双击红色 X 只关闭当前活动的 FreeRDP 会话，不会关闭其它后台 RDP 会话。多个 RDP 会话并存时，先切到要关闭的会话，再双击红色 X。9.5 FreeRDP 重连策略为了避免 FreeRDP 自带英文 retry 窗口频繁抢操作，可以关闭 FreeRDP 自带 /auto-reconnect，改由外层 kiosk 脚本延迟重连。例如重连间隔 10 秒：# FreeRDP 进程退出后 sleep 10如果更希望使用 FreeRDP 自带重连，也可以保留：/auto-reconnect /auto-reconnect-max-retries:999现场如果需要频繁切换目标，建议关闭 FreeRDP 自带重连窗口，让目标管理窗口负责新建和切换会话。9.6 常用管理命令查看当前 RDP 会话：wmctrl -lx | grep -i freerdp pgrep -a sdl-freerdp3手动关闭某个 FreeRDP：kill <pid>重启 kiosk 图形会话：systemctl restart lightdm查看 OSD 状态源：cat /tmp/rdp-kiosk-osd-status查看日志：tail -f /home/admin/.cache/rdp-kiosk-session.log10. 当前完整脚本附录（H313 实测版本）下面是当前 H313 上实际运行的完整脚本和关键配置。默认用户不写死为 admin，脚本使用当前登录用户的 $HOME 保存目标主机和历史记录。/usr/local/bin/rdp-kiosk-session#!/bin/bash set -u RDP_USER="teemar" RDP_PASS="1" DEFAULT_RDP_HOST="172.30.4.226" TARGET_FILE="${HOME}/.config/rdp-kiosk/target" HISTORY_FILE="${HOME}/.config/rdp-kiosk/history" LOG_FILE="${HOME}/.cache/rdp-kiosk-session.log" OSD_PID_FILE="/tmp/rdp-kiosk-osd.pid" OSD_STATUS_FILE="/tmp/rdp-kiosk-osd-status" IFACE="end0" export DISPLAY="${DISPLAY:-:0}" export XAUTHORITY="${XAUTHORITY:-$HOME/.Xauthority}" export LANG="zh_CN.utf8" export LC_CTYPE="zh_CN.utf8" export LC_MESSAGES="zh_CN.utf8" log() { printf '%s %s\n' "$(date '+%F %T')" "$*" >>"$LOG_FILE" } read_rdp_host() { if [ -s "$TARGET_FILE" ]; then tr -d '[:space:]' <"$TARGET_FILE" else printf '%s' "$DEFAULT_RDP_HOST" fi } clean_targets() { awk ' NF { gsub(/\r/, "") # Keep normal IPv4, IPv6, or host/domain names. Drop pasted concatenated garbage. if ($0 ~ /^([0-9]{1,3}\.){3}[0-9]{1,3}$/ || $0 ~ /^[A-Za-z0-9_.:-]+$/) print } ' | awk 'length($0) <= 80 && !seen[$0]++' } remember_rdp_host() { local host="$1" local tmp [ -n "$host" ] || return 0 install -d -o "$(id -un)" -g "$(id -gn)" "$(dirname "$HISTORY_FILE")" tmp="${HISTORY_FILE}.$$" { printf '%s ' "$host" [ -s "$HISTORY_FILE" ] && cat "$HISTORY_FILE" [ -s "$TARGET_FILE" ] && cat "$TARGET_FILE" } | tr '|[:space:]' ' ' | clean_targets | head -20 >"$tmp" mv "$tmp" "$HISTORY_FILE" chmod 644 "$HISTORY_FILE" 2>/dev/null || true } save_rdp_host() { local host="$1" [ -n "$host" ] || return 1 install -d -o "$(id -un)" -g "$(id -gn)" "$(dirname "$TARGET_FILE")" printf '%s ' "$host" >"$TARGET_FILE" chmod 644 "$TARGET_FILE" 2>/dev/null || true remember_rdp_host "$host" log "Saved RDP target: $host" } ethernet_has_carrier() { [ "$(cat "/sys/class/net/${IFACE}/carrier" 2>/dev/null || echo 0)" = "1" ] } local_ipv4() { ip -4 -o addr show dev "$IFACE" scope global 2>/dev/null | awk '{print $4}' | paste -sd ', ' - } resolve_target_ips() { local host="$1" if printf '%s' "$host" | grep -Eq '^[0-9]{1,3}(\.[0-9]{1,3}){3}$'; then printf '%s\n' "$host" return 0 fi { resolvectl query -4 "$host" 2>/dev/null | grep -Eo '([0-9]{1,3}\.){3}[0-9]{1,3}' || true getent ahostsv4 "$host" 2>/dev/null | awk '{print $1}' || true getent ahosts "$host" 2>/dev/null | awk '$1 ~ /^[0-9.]+$/ {print $1}' || true } | grep -v '^$' | awk '!seen[$0]++' | paste -sd ', ' - } first_target_ip() { printf '%s' "$1" | tr ',' '\n' | awk '{print $1; exit}' } active_rdp_target() { local wid title target command -v xprop >/dev/null 2>&1 || return 1 wid="$(DISPLAY="$DISPLAY" XAUTHORITY="$XAUTHORITY" xprop -root _NET_ACTIVE_WINDOW 2>/dev/null | awk -F'window id # ' '{print $2}')" [ -n "$wid" ] && [ "$wid" != "0x0" ] || return 1 title="$(DISPLAY="$DISPLAY" XAUTHORITY="$XAUTHORITY" xprop -id "$wid" WM_NAME 2>/dev/null | sed -n 's/.*= "\(.*\)"/\1/p')" case "$title" in *FreeRDP:*) target="${title##*FreeRDP: }" target="$(printf '%s' "$target" | awk '{print $1}')" [ -n "$target" ] && printf '%s\n' "$target" && return 0 ;; esac return 1 } newest_rdp_target() { local cmd target cmd="$(pgrep -a -n 'sdl-freerdp3|xfreerdp3|xfreerdp' 2>/dev/null || true)" target="$(printf '%s\n' "$cmd" | grep -Eo '/v:[^ ]+' | tail -1 | cut -d: -f2-)" [ -n "$target" ] && printf '%s\n' "$target" } target_ping_status() { local ips="$1" local ip start end ms ip="$(first_target_ip "$ips")" [ -n "$ip" ] && [ "$ip" != "unresolved" ] || { printf 'unresolved'; return 0; } start="$(date +%s%3N)" if timeout 2 bash -c "</dev/tcp/$ip/3389" 2>/dev/null; then end="$(date +%s%3N)" ms=$((end - start)) printf 'TCP3389 OK %sms' "$ms" else printf 'TCP3389 FAIL' fi } status_text() { local requested_target="$1" local status="$2" local target active_target hostname_value mdns_name ipv4 resolved cable ping_status active_target="$(active_rdp_target || true)" if [ -n "$active_target" ]; then target="$active_target" status="active FreeRDP session" else target="$(newest_rdp_target || true)" [ -n "$target" ] || target="$requested_target" fi hostname_value="$(hostname 2>/dev/null || echo H313)" mdns_name="${hostname_value}.local" ipv4="$(local_ipv4)"; [ -n "$ipv4" ] || ipv4="none" resolved="$(resolve_target_ips "$target")"; [ -n "$resolved" ] || resolved="unresolved" ping_status="$(target_ping_status "$resolved")" if ethernet_has_carrier; then cable="up"; else cable="down"; fi printf 'LOCAL-HOST %s ' "$mdns_name" printf 'LOCAL-IP %s ' "$ipv4" printf 'TARGET %s ' "$target" printf 'TARGET-IP %s %s ' "$resolved" "$ping_status" printf 'LINK %s %s %s ' "$IFACE" "$cable" "$status" } stop_osd() { if [ -s "$OSD_PID_FILE" ]; then local pid pid="$(cat "$OSD_PID_FILE" 2>/dev/null || true)" if [ -n "$pid" ]; then kill "$pid" 2>/dev/null || true fi rm -f "$OSD_PID_FILE" fi pkill -f '/usr/local/bin/rdp-kiosk-osd' 2>/dev/null || true pkill -f 'osd_cat' 2>/dev/null || true pkill -f '/usr/local/bin/xosd-clickthrough' 2>/dev/null || true } start_osd() { command -v osd_cat >/dev/null 2>&1 || return 0 stop_osd ( while true; do if [ -s "$OSD_STATUS_FILE" ]; then cat "$OSD_STATUS_FILE" | osd_cat \ --pos=top --align=right --offset=45 --indent=20 --lines=6 \ --delay=10 --colour='#00ff00' --outline=2 --shadow=2 \ --font='fixed' \ 2>>"$LOG_FILE" & osd_cat_pid=$! DISPLAY="$DISPLAY" XAUTHORITY="$XAUTHORITY" /usr/local/bin/xosd-clickthrough 2>>"$LOG_FILE" || true wait "$osd_cat_pid" || true fi sleep 0.05 done ) & echo $! >"$OSD_PID_FILE" } show_status() { local target="$1" local status="$2" local tmp="${OSD_STATUS_FILE}.$$" status_text "$target" "$status" >"$tmp" && mv "$tmp" "$OSD_STATUS_FILE" rm -f "$tmp" 2>/dev/null || true if ! [ -s "$OSD_PID_FILE" ] || ! kill -0 "$(cat "$OSD_PID_FILE" 2>/dev/null)" 2>/dev/null; then start_osd fi } choose_rdp_target() { /usr/local/bin/rdp-target-picker } prompt_rdp_target_after_error() { choose_rdp_target 'RDP 连接失败，请选择或输入目标主机名/IP地址' } prompt_rdp_target_if_no_cable() { ethernet_has_carrier && return 0 log "Ethernet cable is not connected, prompting for RDP target. current=$(read_rdp_host)" choose_rdp_target '请输入目标主机名或者IP地址' } wait_for_cable() { local target last now target="$(read_rdp_host)" last=0 while ! ethernet_has_carrier; do now="$(date +%s)" if [ $((now - last)) -ge 2 ]; then show_status "$target" "waiting for ethernet cable" last="$now" fi sleep 3 done } xrandr --output HDMI-1 --mode 1920x1080 --rate 60 2>/dev/null || true xset s off xset -dpms xset s noblank xsetroot -solid '#202020' 2>/dev/null || true unclutter -idle 0.5 -root & openbox & /usr/local/bin/rdp-picker-hotspot & /usr/local/bin/rdp-disconnect-hotspot & # Show OSD as soon as the X session starts, before any RDP login attempt. RDP_HOST="$(read_rdp_host)" show_status "$RDP_HOST" "GUI loaded, checking ethernet" # Only prompt for target once at kiosk startup when Ethernet is absent. prompt_rdp_target_if_no_cable show_status "$(read_rdp_host)" "target ready, waiting for RDP" while true; do if command -v sdl-freerdp3 >/dev/null 2>&1; then RDP_BIN="sdl-freerdp3" elif command -v xfreerdp3 >/dev/null 2>&1; then RDP_BIN="xfreerdp3" else RDP_BIN="xfreerdp" fi wait_for_cable RDP_HOST="$(read_rdp_host)" log "Using RDP target: $RDP_HOST" last_status=0 show_status "$RDP_HOST" "waiting for RDP 3389" until timeout 1 bash -c "</dev/tcp/$RDP_HOST/3389" 2>/dev/null; do new_host="$(read_rdp_host)" if [ "$new_host" != "$RDP_HOST" ]; then RDP_HOST="$new_host" log "RDP target changed while waiting: $RDP_HOST" last_status=0 fi now="$(date +%s)" if [ $((now - last_status)) -ge 2 ]; then show_status "$RDP_HOST" "waiting for RDP 3389" last_status="$now" fi sleep 0.3 done show_status "$RDP_HOST" "RDP reachable, launching FreeRDP" sleep 0.6 rdp_started_at="$(date +%s)" if [ -e /tmp/rdp-windowed ]; then "$RDP_BIN" /v:"$RDP_HOST" /u:"$RDP_USER" /p:"$RDP_PASS" \ /size:1600x900 /dynamic-resolution /cert:ignore +clipboard \ /network:auto -wallpaper -themes -menu-anims \ & else "$RDP_BIN" /v:"$RDP_HOST" /u:"$RDP_USER" /p:"$RDP_PASS" \ +f -toggle-fullscreen /dynamic-resolution /cert:ignore +clipboard \ /network:auto -wallpaper -themes -menu-anims \ & fi rdp_pid=$! while kill -0 "$rdp_pid" 2>/dev/null; do show_status "$RDP_HOST" "FreeRDP connected/running" sleep 3 done wait "$rdp_pid" rdp_rc=$? rdp_runtime=$(( $(date +%s) - rdp_started_at )) log "FreeRDP exited with code $rdp_rc after ${rdp_runtime}s; reconnecting" show_status "$RDP_HOST" "FreeRDP exited, reconnecting" if [ "$rdp_rc" -ne 0 ] && [ "$rdp_runtime" -lt 15 ]; then prompt_rdp_target_after_error fi sleep 10 done/usr/local/bin/rdp-target-picker#!/usr/bin/env python3 import os import re import signal import subprocess import socket import time import gi gi.require_version('Gtk', '3.0') from gi.repository import Gtk, Gdk TARGET_FILE = '/home/admin/.config/rdp-kiosk/target' HISTORY_FILE = '/home/admin/.config/rdp-kiosk/history' LOG_FILE = '/home/admin/.cache/rdp-kiosk-session.log' RDP_USER = 'teemar' RDP_PASS = '1' os.environ.setdefault('LANG', 'zh_CN.utf8') os.environ.setdefault('LC_CTYPE', 'zh_CN.utf8') os.environ.setdefault('LC_MESSAGES', 'zh_CN.utf8') def clean_target(v): v = ''.join(str(v).split()) if not v or len(v) > 80: return '' if re.match(r'^([0-9]{1,3}\.){3}[0-9]{1,3}$', v) or re.match(r'^[A-Za-z0-9_.:-]+$', v): return v return '' def read_target(): try: with open(TARGET_FILE, 'r', encoding='utf-8', errors='ignore') as f: for part in re.split(r'[|\s]+', f.read()): v = clean_target(part) if v: return v except Exception: pass return '172.30.5.11' def read_history(current): items = [] for v in [current]: v = clean_target(v) if v and v not in items: items.append(v) try: with open(HISTORY_FILE, 'r', encoding='utf-8', errors='ignore') as f: for part in re.split(r'[|\s]+', f.read()): v = clean_target(part) if v and v not in items: items.append(v) except Exception: pass return items[:20] def write_history(items): seen = [] for item in items: item = clean_target(item) if item and item not in seen: seen.append(item) os.makedirs(os.path.dirname(HISTORY_FILE), exist_ok=True) with open(HISTORY_FILE, 'w', encoding='utf-8') as f: for item in seen[:20]: f.write(item + '\n') try: os.chown(HISTORY_FILE, 1000, 1000) except Exception: pass def save_target(target): target = clean_target(target) if not target: return os.makedirs(os.path.dirname(TARGET_FILE), exist_ok=True) with open(TARGET_FILE, 'w', encoding='utf-8') as f: f.write(target + '\n') hist = read_history(target) with open(HISTORY_FILE, 'w', encoding='utf-8') as f: for item in hist: f.write(item + '\n') try: os.chown(TARGET_FILE, 1000, 1000) os.chown(HISTORY_FILE, 1000, 1000) except Exception: pass log(f'Saved RDP target from picker: {target}') def log(msg): try: subprocess.call(['bash', '-lc', f'printf "%s %s\\n" "$(date \'+%F %T\')" {sh_quote(msg)} >> {sh_quote(LOG_FILE)}']) except Exception: pass def sh_quote(s): return "'" + str(s).replace("'", "'\\''") + "'" def rdp_bin(): for b in ('sdl-freerdp3', 'xfreerdp3', 'xfreerdp'): if subprocess.call(['bash', '-lc', f'command -v {b} >/dev/null']) == 0: return b return 'xfreerdp' def launch_session(target): target = clean_target(target) if not target: return save_target(target) cmd = [rdp_bin(), f'/v:{target}', f'/u:{RDP_USER}', f'/p:{RDP_PASS}', '+f', '-toggle-fullscreen', '/dynamic-resolution', '/cert:ignore', '+clipboard', '/network:auto', '-wallpaper', '-themes', '-menu-anims', ] with open(LOG_FILE, 'a', encoding='utf-8') as logf: subprocess.Popen(cmd, stdout=logf, stderr=logf) def list_sessions(): rows = [] try: out = subprocess.check_output(['wmctrl', '-lx'], text=True, errors='ignore') except Exception: out = '' for line in out.splitlines(): if 'freerdp' not in line.lower(): continue parts = line.split(None, 4) if len(parts) < 5: continue wid = parts[0] title = parts[4] target = title.split('FreeRDP:', 1)[1].strip() if 'FreeRDP:' in title else title rows.append((wid, target, title)) return rows def activate_window(wid): if wid: subprocess.call(['wmctrl', '-ia', wid]) def selected_target_from_window(wid): for row_wid, target, _title in list_sessions(): if row_wid == wid: return clean_target(target) return '' def resolve_ipv4(target): target = clean_target(target) if not target: return '' if re.match(r'^([0-9]{1,3}\.){3}[0-9]{1,3}$', target): return target try: out = subprocess.check_output(['getent', 'ahostsv4', target], text=True, errors='ignore', timeout=3) for line in out.splitlines(): ip = line.split()[0] if re.match(r'^([0-9]{1,3}\.){3}[0-9]{1,3}$', ip): return ip except Exception: pass return '' def check_icmp(ip): if not ip: return '未解析' start = time.time() rc = subprocess.call(['ping', '-4', '-n', '-c', '1', '-W', '1', ip], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) ms = int((time.time() - start) * 1000) return f'OK {ms}ms' if rc == 0 else 'FAIL' def check_tcp(ip, port): if not ip: return '未解析' start = time.time() try: with socket.create_connection((ip, port), timeout=1.5): ms = int((time.time() - start) * 1000) return f'OK {ms}ms' except Exception: return 'FAIL' def current_ip_config(): ip = gateway = dns = '' try: out = subprocess.check_output(['ip', '-4', '-o', 'addr', 'show', 'dev', 'end0', 'scope', 'global'], text=True, errors='ignore') m = re.search(r'inet\s+([^\s]+)', out) if m: ip = m.group(1) except Exception: pass try: out = subprocess.check_output(['ip', 'route', 'show', 'default'], text=True, errors='ignore') m = re.search(r'default via\s+([^\s]+)', out) if m: gateway = m.group(1) except Exception: pass try: out = subprocess.check_output(['resolvectl', 'dns', 'end0'], text=True, errors='ignore') dns_items = re.findall(r'(?<![A-Za-z0-9_.:-])(?:[0-9]{1,3}\.){3}[0-9]{1,3}(?![A-Za-z0-9_.:-])', out) dns = ' '.join(dns_items) except Exception: pass return ip, gateway, dns def active_nm_connection(): try: out = subprocess.check_output(['nmcli', '-t', '-f', 'NAME,DEVICE', 'connection', 'show', '--active'], text=True, errors='ignore') for line in out.splitlines(): parts = line.split(':') if len(parts) >= 2 and parts[1] == 'end0': return parts[0] except Exception: pass return '' class Picker(Gtk.Window): def __init__(self): super().__init__(title='请输入目标主机名或者IP地址') self.set_default_size(620, 360) self.set_position(Gtk.WindowPosition.CENTER) self.connect('destroy', Gtk.main_quit) current = read_target() outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8, margin=10) self.add(outer) row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) outer.pack_start(row, False, False, 0) row.pack_start(Gtk.Label(label='输入目标主机名或者IP地址'), False, False, 0) self.combo = Gtk.ComboBoxText.new_with_entry() for item in read_history(current): self.combo.append_text(item) self.combo.get_child().set_text(current) row.pack_start(self.combo, True, True, 0) outer.pack_start(Gtk.Label(label='当前 FreeRDP 会话'), False, False, 0) self.store = Gtk.ListStore(str, str, str) self.tree = Gtk.TreeView(model=self.store) self.tree.append_column(Gtk.TreeViewColumn('目标', Gtk.CellRendererText(), text=1)) self.tree.append_column(Gtk.TreeViewColumn('窗口标题', Gtk.CellRendererText(), text=2)) scroller = Gtk.ScrolledWindow() scroller.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) scroller.add(self.tree) outer.pack_start(scroller, True, True, 0) self.status_label = Gtk.Label(label='') self.status_label.set_xalign(0.0) outer.pack_start(self.status_label, False, False, 0) buttons = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) outer.pack_start(buttons, False, False, 0) button_defs = [ ('取消', self.on_cancel), ('连接', self.on_connect), ('关闭选中会话', self.on_close), ('切换', self.on_switch), ('删除历史', self.on_delete_history), ('检测选中', self.on_check_target), ('配置IP地址', self.on_config_ip), ('刷新', self.on_refresh), ] for label, cb in button_defs: btn = Gtk.Button(label=label) btn.connect('clicked', cb) buttons.pack_end(btn, False, False, 0) self.on_refresh(None) self.show_all() def reload_combo(self): current = self.target_text() or read_target() self.combo.remove_all() for item in read_history(current): self.combo.append_text(item) self.combo.get_child().set_text(current) def selected_wid(self): sel = self.tree.get_selection() model, it = sel.get_selected() return model[it][0] if it else '' def target_text(self): return clean_target(self.combo.get_child().get_text()) def on_delete_history(self, _): target = self.target_text() if not target: return items = [item for item in read_history(read_target()) if item != target] write_history(items) self.combo.get_child().set_text(read_target()) self.reload_combo() def selected_target(self): wid = self.selected_wid() if wid: target = selected_target_from_window(wid) if target: return target return self.target_text() def on_check_target(self, _): target = self.target_text() ip = resolve_ipv4(target) icmp = check_icmp(ip) rdp = check_tcp(ip, 3389) vhusbd = check_tcp(ip, 7573) ip_text = ip if ip else '未解析' self.status_label.set_text(f'检测 {target} -> {ip_text} ICMP: {icmp} RDP3389: {rdp} VHUSBD7573: {vhusbd}') def on_config_ip(self, _): ip, gateway, dns = current_ip_config() dialog = Gtk.Dialog(title='配置本机IP地址', transient_for=self, flags=0) dialog.add_buttons('取消', Gtk.ResponseType.CANCEL, '应用', Gtk.ResponseType.OK) box = dialog.get_content_area() grid = Gtk.Grid(column_spacing=8, row_spacing=8, margin=10) box.add(grid) dhcp = Gtk.CheckButton(label='使用 DHCP 自动获取') ip_entry = Gtk.Entry(text=ip); gw_entry = Gtk.Entry(text=gateway); dns_entry = Gtk.Entry(text=dns) grid.attach(dhcp, 0, 0, 2, 1) grid.attach(Gtk.Label(label='静态IP/掩码'), 0, 1, 1, 1); grid.attach(ip_entry, 1, 1, 1, 1) grid.attach(Gtk.Label(label='网关'), 0, 2, 1, 1); grid.attach(gw_entry, 1, 2, 1, 1) grid.attach(Gtk.Label(label='DNS'), 0, 3, 1, 1); grid.attach(dns_entry, 1, 3, 1, 1) dialog.show_all(); resp = dialog.run() if resp == Gtk.ResponseType.OK: conn = active_nm_connection() try: if not conn: raise RuntimeError('未找到 end0 的 NetworkManager 连接') if dhcp.get_active(): subprocess.check_call(['nmcli','connection','modify',conn,'ipv4.method','auto','ipv4.addresses','','ipv4.gateway','','ipv4.dns','']) else: addr=ip_entry.get_text().strip(); gw=gw_entry.get_text().strip(); dns_value=dns_entry.get_text().strip() if not addr: raise RuntimeError('静态IP不能为空，例如 172.30.5.24/22') cmd = ['nmcli','connection','modify',conn,'ipv4.method','manual','ipv4.addresses',addr] if gw: cmd.extend(['ipv4.gateway', gw]) else: cmd.extend(['ipv4.gateway', '']) if dns_value: cmd.extend(['ipv4.dns', dns_value, 'ipv4.ignore-auto-dns', 'yes']) else: cmd.extend(['ipv4.ignore-auto-dns', 'no']) subprocess.check_call(cmd) subprocess.call(['nmcli','connection','down',conn], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) subprocess.check_call(['nmcli','connection','up',conn]) self.status_label.set_text('IP配置已应用') except Exception as e: self.status_label.set_text(f'IP配置失败: {e}') dialog.destroy() def on_refresh(self, _): self.store.clear() for wid, target, title in list_sessions(): self.store.append([wid, target, title]) def on_switch(self, _): activate_window(self.selected_wid()) def on_close(self, _): close_window(self.selected_wid()) self.on_refresh(None) def on_connect(self, _): target = self.target_text() if target: launch_session(target) Gtk.main_quit() def on_cancel(self, _): Gtk.main_quit() if __name__ == '__main__': Picker() Gtk.main()/usr/local/bin/rdp-picker-hotspot#!/usr/bin/env python3 import os import time import select import subprocess from Xlib import X, display D = display.Display() screen = D.screen() root = screen.root sw = screen.width_in_pixels sh = screen.height_in_pixels SIZE = 18 POS_FILE = '/home/admin/.config/rdp-kiosk/hotspot-pos' DEFAULT_X = sw - SIZE - 6 DEFAULT_Y = 6 last_click = 0.0 dragging = False drag_dx = 0 drag_dy = 0 moved = False def load_pos(): try: text = open(POS_FILE, 'r', encoding='utf-8').read().strip() x, y = [int(v) for v in text.split(',', 1)] x = max(0, min(sw - SIZE, x)) y = max(0, min(sh - SIZE, y)) return x, y except Exception: return DEFAULT_X, DEFAULT_Y def save_pos(x, y): try: os.makedirs(os.path.dirname(POS_FILE), exist_ok=True) with open(POS_FILE, 'w', encoding='utf-8') as f: f.write(f'{x},{y}\n') except Exception: pass xpos, ypos = load_pos() win = root.create_window( xpos, ypos, SIZE, SIZE, 0, screen.root_depth, X.InputOutput, X.CopyFromParent, background_pixel=0x00cc33, border_pixel=0x000000, event_mask=(X.ExposureMask | X.ButtonPressMask | X.ButtonReleaseMask | X.PointerMotionMask), override_redirect=True, ) win.set_wm_name('RDP_TARGET_PICKER_HOTSPOT') win.map() D.sync() gc_bg = win.create_gc(foreground=0x00cc33) gc_fg = win.create_gc(foreground=0x000000) def draw(): win.fill_rectangle(gc_bg, 0, 0, SIZE, SIZE) win.rectangle(gc_fg, 0, 0, SIZE - 1, SIZE - 1) win.line(gc_fg, 4, SIZE // 2, SIZE - 5, SIZE // 2) win.line(gc_fg, SIZE // 2, 4, SIZE // 2, SIZE - 5) D.flush() def raise_window(): try: win.configure(stack_mode=X.Above) D.flush() except Exception: pass def move_to(x, y): global xpos, ypos xpos = max(0, min(sw - SIZE, int(x))) ypos = max(0, min(sh - SIZE, int(y))) win.configure(x=xpos, y=ypos) D.flush() def open_picker(): env = os.environ.copy() env.setdefault('DISPLAY', ':0') env.setdefault('XAUTHORITY', '/home/admin/.Xauthority') subprocess.Popen(['/usr/local/bin/rdp-target-picker'], env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) draw() next_raise = time.time() + 2.0 fd = D.fileno() while True: timeout = max(0.0, next_raise - time.time()) ready, _, _ = select.select([fd], [], [], timeout) now = time.time() if now >= next_raise: raise_window() next_raise = now + 2.0 if not ready: continue while D.pending_events(): ev = D.next_event() if ev.type == X.Expose: draw() elif ev.type == X.ButtonPress and ev.detail == 1: dragging = True moved = False drag_dx = ev.root_x - xpos drag_dy = ev.root_y - ypos elif ev.type == X.MotionNotify and dragging: nx = ev.root_x - drag_dx ny = ev.root_y - drag_dy if abs(nx - xpos) > 1 or abs(ny - ypos) > 1: moved = True move_to(nx, ny) elif ev.type == X.ButtonRelease and ev.detail == 1: if dragging: dragging = False save_pos(xpos, ypos) if not moved: t = time.time() if t - last_click <= 0.5: open_picker() last_click = 0.0 else: last_click = t/usr/local/bin/rdp-disconnect-hotspot#!/usr/bin/env python3 import os import time import select import signal import subprocess from Xlib import X, display, Xatom D = display.Display() screen = D.screen() root = screen.root sw = screen.width_in_pixels sh = screen.height_in_pixels SIZE = 18 POS_FILE = '/home/admin/.config/rdp-kiosk/disconnect-pos' DEFAULT_X = sw - (SIZE * 2) - 12 DEFAULT_Y = 6 last_click = 0.0 dragging = False drag_dx = 0 drag_dy = 0 moved = False def load_pos(): try: text = open(POS_FILE, 'r', encoding='utf-8').read().strip() x, y = [int(v) for v in text.split(',', 1)] return max(0, min(sw - SIZE, x)), max(0, min(sh - SIZE, y)) except Exception: return DEFAULT_X, DEFAULT_Y def save_pos(x, y): try: os.makedirs(os.path.dirname(POS_FILE), exist_ok=True) with open(POS_FILE, 'w', encoding='utf-8') as f: f.write(f'{x},{y}\n') except Exception: pass def atom(name): return D.intern_atom(name) def prop(win, name, ptype=0): try: return win.get_full_property(atom(name), ptype) except Exception: return None def wm_name(win): try: return win.get_wm_name() or '' except Exception: return '' def wm_class(win): try: cls = win.get_wm_class() return ' '.join(cls) if cls else '' except Exception: return '' def window_pid(win): p = prop(win, '_NET_WM_PID', Xatom.CARDINAL) if p and p.value: return int(p.value[0]) return None def active_window(): p = prop(root, '_NET_ACTIVE_WINDOW', Xatom.WINDOW) if not p or not p.value: return None wid = int(p.value[0]) if wid == 0: return None return D.create_resource_object('window', wid) def is_freerdp_window(win): text = (wm_name(win) + ' ' + wm_class(win)).lower() return any(x in text for x in ('freerdp', 'sdl-freerdp', 'xfreerdp', 'remote desktop')) def newest_freerdp_pid(): try: out = subprocess.check_output(['pgrep', '-n', '-f', 'sdl-freerdp3|xfreerdp3|xfreerdp'], text=True).strip() return int(out) if out else None except Exception: return None def disconnect_active_rdp(): pid = None win = active_window() if win is not None and is_freerdp_window(win): pid = window_pid(win) if pid is None: pid = newest_freerdp_pid() if pid: try: os.kill(pid, signal.SIGKILL) except Exception: pass xpos, ypos = load_pos() win = root.create_window( xpos, ypos, SIZE, SIZE, 0, screen.root_depth, X.InputOutput, X.CopyFromParent, background_pixel=0xdd0000, border_pixel=0x000000, event_mask=X.ExposureMask | X.ButtonPressMask | X.ButtonReleaseMask | X.PointerMotionMask, override_redirect=True, ) win.set_wm_name('RDP_DISCONNECT_HOTSPOT') win.map() D.sync() gc_bg = win.create_gc(foreground=0xdd0000) gc_fg = win.create_gc(foreground=0xffffff) def draw(): win.fill_rectangle(gc_bg, 0, 0, SIZE, SIZE) win.rectangle(gc_fg, 0, 0, SIZE - 1, SIZE - 1) win.line(gc_fg, 4, 4, SIZE - 5, SIZE - 5) win.line(gc_fg, SIZE - 5, 4, 4, SIZE - 5) D.flush() def raise_window(): try: win.configure(stack_mode=X.Above) D.flush() except Exception: pass def move_to(x, y): global xpos, ypos xpos = max(0, min(sw - SIZE, int(x))) ypos = max(0, min(sh - SIZE, int(y))) win.configure(x=xpos, y=ypos) D.flush() draw() next_raise = time.time() + 2.0 fd = D.fileno() while True: timeout = max(0.0, next_raise - time.time()) ready, _, _ = select.select([fd], [], [], timeout) now = time.time() if now >= next_raise: raise_window() next_raise = now + 2.0 if not ready: continue while D.pending_events(): ev = D.next_event() if ev.type == X.Expose: draw() elif ev.type == X.ButtonPress and ev.detail == 1: dragging = True moved = False drag_dx = ev.root_x - xpos drag_dy = ev.root_y - ypos elif ev.type == X.MotionNotify and dragging: nx = ev.root_x - drag_dx ny = ev.root_y - drag_dy if abs(nx - xpos) > 1 or abs(ny - ypos) > 1: moved = True move_to(nx, ny) elif ev.type == X.ButtonRelease and ev.detail == 1: if dragging: dragging = False save_pos(xpos, ypos) if not moved: t = time.time() if t - last_click <= 0.5: disconnect_active_rdp() last_click = 0.0 else: last_click = t/usr/local/bin/xosd-clickthrough#!/usr/bin/env python3 import time from Xlib import display, X from Xlib.ext import shape D = display.Display() ROOT = D.screen().root def name_of(win): try: return win.get_wm_name() or '' except Exception: return '' for _ in range(10): found = False try: children = ROOT.query_tree().children except Exception: children = [] for w in children: if name_of(w) == 'XOSD': try: w.shape_rectangles(shape.SO.Set, shape.SK.Input, X.Unsorted, 0, 0, []) D.flush() found = True except Exception: pass if found: break time.sleep(0.05)/usr/share/xsessions/rdp-kiosk.desktop[Desktop Entry] Name=RDP Kiosk Comment=Auto start RDP session Exec=/usr/local/bin/rdp-kiosk-session Type=Application/etc/lightdm/lightdm.conf.d/99-rdp-kiosk.conf[Seat:*] autologin-user=admin autologin-user-timeout=0 autologin-session=rdp-kiosk user-session=rdp-kiosk greeter-session=slick-greeter/etc/systemd/resolved.conf.d/mdns.conf[Resolve] MulticastDNS=yes LLMNR=no
AP 模式下关闭 DHCP 客户端，并使用 reset 按钮控制 WiFi 开关
这篇记录整理一次 AP 模式配置过程：关闭设备自身的 DHCP 客户端，设置 br-lan 固定地址，并让 WiFi 默认关闭，只有按住 reset 按钮时才临时开启 5G WiFi。这样设备平时可以保持安静状态，需要连接维护时再手动打开无线。配置目标最终效果如下： 设备工作在 AP/桥接使用场景 br-lan 使用固定地址 192.168.2.254/24 关闭 DHCP 客户端，避免自动获取上级网络地址 WiFi 默认关闭 按住 reset 按钮时开启 5G WiFi 松开 reset 按钮后关闭 5G WiFi 红灯用于提示启动过程和按钮触发状态 挂载根分区并修改密码先挂载根分区为可写：mount_root下面示例把 root 密码设置为 1：printf '%s\n%s\n' '1' '1' | passwd root关闭 WiFi 默认射频关闭 2.4G 和 5G 的默认射频开关，让 WiFi 不在开机后自动开启：sed -i 's/^RadioEnable=.*/RadioEnable=0/' /etc/wireless/mediatek/mt7981.dbdc.b0.dat sed -i 's/^RadioEnable=.*/RadioEnable=0/' /etc/wireless/mediatek/mt7981.dbdc.b1.dat调整 5G WiFi 参数这里以 5G 配置文件 mt7981.dbdc.b1.dat 为例，手动指定信道并关闭部分节能和 MU 相关功能：sed -i 's/^Channel=.*/Channel=161/' /etc/wireless/mediatek/mt7981.dbdc.b1.dat sed -i 's/^AutoChannelSelect=.*/AutoChannelSelect=0/' /etc/wireless/mediatek/mt7981.dbdc.b1.dat sed -i 's/^HT_BW=.*/HT_BW=1/' /etc/wireless/mediatek/mt7981.dbdc.b1.dat sed -i 's/^VHT_BW=.*/VHT_BW=2/' /etc/wireless/mediatek/mt7981.dbdc.b1.dat sed -i 's/^TxPower=.*/TxPower=100/' /etc/wireless/mediatek/mt7981.dbdc.b1.dat sed -i 's/^TWTSupport=.*/TWTSupport=0/' /etc/wireless/mediatek/mt7981.dbdc.b1.dat sed -i 's/^MuMimoDlEnable=.*/MuMimoDlEnable=0/' /etc/wireless/mediatek/mt7981.dbdc.b1.dat sed -i 's/^MuMimoUlEnable=.*/MuMimoUlEnable=0/' /etc/wireless/mediatek/mt7981.dbdc.b1.dat sed -i 's/^MuOfdmaDlEnable=.*/MuOfdmaDlEnable=0/' /etc/wireless/mediatek/mt7981.dbdc.b1.dat sed -i 's/^MuOfdmaUlEnable=.*/MuOfdmaUlEnable=0/' /etc/wireless/mediatek/mt7981.dbdc.b1.dat /sbin/wifi reload如果需要降低发射功率，可以把 TxPower=100 调整为较小值，例如：sed -i 's/^TxPower=.*/TxPower=4/' /etc/wireless/mediatek/mt7981.dbdc.b1.dat关闭 DHCP 客户端通过重命名 udhcpc 和 wan_type_probe，避免系统继续自动启动 DHCP 客户端或 WAN 类型探测：mv /sbin/udhcpc /sbin/udhcpcbak cd /usr/bin mv wan_type_probe wan_type_probebak配置 rc.local写入 /etc/rc.local，开机后清理缓存、启动 telnet，并给 br-lan 添加固定 IP：cat > /etc/rc.local <<'EOF' # Put your custom commands here that should be executed once # the system init finished. By default this file does nothing. echo 3 > /proc/sys/vm/drop_caches busybox telnetd -l sh & ip addr add dev br-lan 192.168.2.254/24 exit 0 EOF chmod +x /etc/rc.local关闭系统原生灯控为了后续由自定义脚本控制红灯，先关闭系统自带的灯控服务：/etc/init.d/led disable /etc/init.d/led stop /etc/init.d/led_mgr disable /etc/init.d/led_mgr stop开机过程中红灯闪烁创建 /etc/init.d/boot-redled-blink：cat > /etc/init.d/boot-redled-blink <<'EOF' #!/bin/sh /etc/rc.common START=02 start() { LED_PATH="/sys/class/leds/redled/brightness" PID_FILE="/tmp/boot_redled_blink.pid" echo none > /sys/class/leds/redled/trigger 2>/dev/null ( while true; do echo 1 > "$LED_PATH" 2>/dev/null sleep 1 echo 0 > "$LED_PATH" 2>/dev/null sleep 1 done ) & echo $! > "$PID_FILE" } EOF chmod +x /etc/init.d/boot-redled-blink /etc/init.d/boot-redled-blink enableWiFi 流程完成后关闭红灯创建 /etc/init.d/boot-redled-off，等待 MTK WiFi 流程结束后停止闪烁：cat > /etc/init.d/boot-redled-off <<'EOF' #!/bin/sh /etc/rc.common START=99 start() { LED_PATH="/sys/class/leds/redled/brightness" PID_FILE="/tmp/boot_redled_blink.pid" for i in $(seq 1 180); do if dmesg | grep -q "br-lan: port .*rax0.*entered disabled state"; then echo "Boot: MTK WiFi rax0 finished, stop blinking" > /dev/console break fi sleep 1 done if [ -f "$PID_FILE" ]; then kill "$(cat "$PID_FILE")" 2>/dev/null rm -f "$PID_FILE" fi echo none > /sys/class/leds/redled/trigger 2>/dev/null echo 0 > "$LED_PATH" 2>/dev/null exit 0 } EOF chmod +x /etc/init.d/boot-redled-off /etc/init.d/boot-redled-off enable使用 reset 按钮控制 5G WiFi创建 /etc/rc.button/reset。按下按钮时开启 rax0 并加入 br-lan，松开后关闭射频：cat > /etc/rc.button/reset <<'EOF' #!/bin/sh WIFI_5G="rax0" BR_DEV="br-lan" LED_PATH="/sys/class/leds/redled/brightness" is_button_still_pressed() { if cat /sys/kernel/debug/gpio | grep "reset" | grep -q "lo"; then return 0 else return 1 fi } case "$ACTION" in pressed) ifconfig "$WIFI_5G" up iwpriv "$WIFI_5G" set RadioOn=1 if ! brctl show "$BR_DEV" | grep -q "$WIFI_5G"; then brctl addif "$BR_DEV" "$WIFI_5G" fi echo 1 > "$LED_PATH" 2>/dev/null echo "WiFi Radio ON (Instant)" > /dev/console ;; released) sleep 1 if is_button_still_pressed; then echo "Jitter detected, keeping Radio ON" > /dev/console exit 0 fi iwpriv "$WIFI_5G" set RadioOn=0 echo 0 > "$LED_PATH" 2>/dev/null echo "WiFi Radio OFF (Silent)" > /dev/console ;; esac exit 0 EOF chmod +x /etc/rc.button/reset重启生效全部配置完成后重启设备：reboot重启后，设备会使用固定地址 192.168.2.254/24，WiFi 默认关闭。需要临时开启无线时按住 reset 按钮，松开后 WiFi 会再次关闭。
Android USB Gadget 操作命令记录目标关闭默认 MTP保留 ADB使用 mass_storage 导出一个 30G FAT32 镜像文件backing file 放在 /data/codex_empty_file连接设备telnet 10.221.3.83查看当前 USB 属性getprop | grep usb getprop persist.sys.usb.config getprop sys.usb.config getprop sys.usb.state创建空文件并赋权touch /data/codex_empty_file chmod 666 /data/codex_empty_file ls -l /data/codex_empty_file切换 USB 为 mass_storage + adbsetprop persist.sys.usb.config mass_storage,adb setprop sys.usb.config mass_storage,adb sleep 3 getprop persist.sys.usb.config getprop sys.usb.config getprop sys.usb.state创建 30G 镜像并格式化为 FAT32说明：先断开当前 gadget 绑定用 fallocate 创建 30G 镜像文件用 losetup 挂到 loop 设备用 mkfs.vfat -F 32 格式化成 FAT32echo '' > /config/usb_gadget/g1/UDC echo '' > /config/usb_gadget/g1/functions/mass_storage.0/lun.0/file rm -f /data/codex_empty_file fallocate -l 30G /data/codex_empty_file LOOP=$(losetup -f) echo $LOOP losetup $LOOP /data/codex_empty_file mkfs.vfat -F 32 $LOOP losetup -d $LOOP挂回 USB mass_storageecho /data/codex_empty_file > /config/usb_gadget/g1/functions/mass_storage.0/lun.0/file echo 0 > /config/usb_gadget/g1/functions/mass_storage.0/lun.0/cdrom echo 0 > /config/usb_gadget/g1/functions/mass_storage.0/lun.0/ro echo 1 > /config/usb_gadget/g1/functions/mass_storage.0/lun.0/removable echo a600000.dwc3 > /config/usb_gadget/g1/UDC检查最终状态getprop sys.usb.state cat /config/usb_gadget/g1/functions/mass_storage.0/lun.0/cdrom cat /config/usb_gadget/g1/functions/mass_storage.0/lun.0/ro cat /config/usb_gadget/g1/functions/mass_storage.0/lun.0/removable cat /config/usb_gadget/g1/functions/mass_storage.0/lun.0/file ls -lh /data/codex_empty_file期望结果sys.usb.state = mass_storage,adb cdrom = 0 ro = 0 removable = 1 file = /data/codex_empty_file常用补充命令改回仅 ADBsetprop persist.sys.usb.config adb setprop sys.usb.config adb纯 HID 键盘临时切换并输入字符串用途：临时把手机切成 纯 HID 键盘向电脑当前焦点窗口输入指定字符串测试完成后再切回 mass_storage + adb注意：纯 HID 模式下，临时不会保留 U 盘功能切到纯 HID 后，ADB 也可能暂时断开字符会输入到电脑当前有焦点的输入框1. 在 g2 上准备纯 HID 键盘 gadgetmkdir -p /config/usb_gadget/g2/configs/b.1/strings/0x409 mkdir -p /config/usb_gadget/g2/strings/0x409 mkdir -p /config/usb_gadget/g2/functions/hid.keyboard echo 0x18d1 > /config/usb_gadget/g2/idVendor echo 0x4ee7 > /config/usb_gadget/g2/idProduct echo 0 > /config/usb_gadget/g2/bDeviceClass echo 0 > /config/usb_gadget/g2/bDeviceSubClass echo 0 > /config/usb_gadget/g2/bDeviceProtocol echo CodexHID > /config/usb_gadget/g2/strings/0x409/product echo OpenAI > /config/usb_gadget/g2/strings/0x409/manufacturer echo 1234567890 > /config/usb_gadget/g2/strings/0x409/serialnumber echo 1 > /config/usb_gadget/g2/functions/hid.keyboard/protocol echo 1 > /config/usb_gadget/g2/functions/hid.keyboard/subclass echo 8 > /config/usb_gadget/g2/functions/hid.keyboard/report_length printf '\x05\x01\x09\x06\xA1\x01\x05\x07\x19\xE0\x29\xE7\x15\x00\x25\x01\x75\x01\x95\x08\x81\x02\x95\x01\x75\x08\x81\x01\x95\x05\x75\x01\x05\x08\x19\x01\x29\x05\x91\x02\x95\x01\x75\x03\x91\x01\x95\x06\x75\x08\x15\x00\x25\x65\x05\x07\x19\x00\x29\x65\x81\x00\xC0' > /config/usb_gadget/g2/functions/hid.keyboard/report_desc rm -f /config/usb_gadget/g2/configs/b.1/f1 ln -s /config/usb_gadget/g2/functions/hid.keyboard /config/usb_gadget/g2/configs/b.1/f12. 切到纯 HID 键盘并输入 qwerrtysetprop sys.usb.config none sleep 2 echo '' > /config/usb_gadget/g1/UDC 2>/dev/null echo '' > /config/usb_gadget/g2/UDC 2>/dev/null sleep 1 echo a600000.dwc3 > /config/usb_gadget/g2/UDC sleep 4 DEV=/dev/hidg0 [ -e "$DEV" ] || DEV=/dev/hidg1 press() { code=$1 printf "\x00\x00\x$(printf '%02x' $code)\x00\x00\x00\x00\x00" > "$DEV" usleep 80000 printf '\x00\x00\x00\x00\x00\x00\x00\x00' > "$DEV" usleep 80000 } # q w e r r t y press 20 press 26 press 8 press 21 press 21 press 23 press 283. 切回稳定模式 U盘 + ADBsetprop sys.usb.config mass_storage,adb sh /data/adb/service.d/usb_mass_storage_adb.sh sleep 4 echo '' > /config/usb_gadget/g2/UDC sleep 1 echo a600000.dwc3 > /config/usb_gadget/g1/UDC4. 恢复后检查getprop sys.usb.config getprop sys.usb.state cat /config/usb_gadget/g1/UDC 2>/dev/null cat /config/usb_gadget/g2/UDC 2>/dev/null ls -l /config/usb_gadget/g1/configs/b.1期望结果：sys.usb.config = mass_storage,adb sys.usb.state = mass_storage,adb g1/UDC = a600000.dwc3 g2/UDC = 空开机自动执行的 Magisk 脚本脚本路径：/data/adb/service.d/usb_mass_storage_adb.sh作用：开机默认切到 mass_storage,adb开机后延迟再次执行 setprop persist.sys.usb.config mass_storage,adb 和 setprop sys.usb.config mass_storage,adb，防止系统后续又切回 mtp,conn_gadget,adb如果 /data/codex_empty_file 不存在，则自动创建 30G FAT32 镜像强制设置：cdrom=0ro=0removable=1自动启动 busybox telnetd -l bash -p 23当前稳定方案是 U盘 + ADB + telnetd不再默认强开 MTP，因为三功能同时存在会导致这台设备 USB 不稳定甚至掉线核心内容如下：#!/system/bin/sh PATH=/sbin:/system/sbin:/system/bin:/system/xbin:/data/data/com.termux/files/usr/bin IMG=/data/codex_empty_file SIZE=30G G=/config/usb_gadget/g1 LUN=$G/functions/mass_storage.0/lun.0 UDC=$(getprop sys.usb.controller) [ -n "$UDC" ] || UDC=$(getprop ro.boot.usbcontroller) wait_path() { p="$1" i=0 while [ ! -e "$p" ] && [ $i -lt 30 ]; do sleep 1 i=$((i + 1)) done [ -e "$p" ] } start_telnetd() { if command -v ss >/dev/null 2>&1; then ss -ltn 2>/dev/null | grep -q ':23 ' && return 0 fi if command -v netstat >/dev/null 2>&1; then netstat -ltn 2>/dev/null | grep -q ':23 ' && return 0 fi busybox telnetd -l bash -p 23 } apply_usb_mode() { setprop persist.sys.usb.config mass_storage,adb setprop sys.usb.config mass_storage,adb sleep 2 echo '' > "$G/UDC" 2>/dev/null echo '' > "$LUN/file" 2>/dev/null rm -f "$G/configs/b.1/f1" "$G/configs/b.1/f2" "$G/configs/b.1/f3" "$G/configs/b.1/f4" "$G/configs/b.1/f5" "$G/configs/b.1/f6" 2>/dev/null ln -s ../../../../usb_gadget/g1/functions/mass_storage.0 "$G/configs/b.1/f1" 2>/dev/null ln -s ../../../../usb_gadget/g1/functions/ffs.adb "$G/configs/b.1/f2" 2>/dev/null echo 0 > "$LUN/cdrom" 2>/dev/null echo 0 > "$LUN/ro" 2>/dev/null echo 1 > "$LUN/removable" 2>/dev/null echo "$IMG" > "$LUN/file" 2>/dev/null if [ -n "$UDC" ]; then echo "$UDC" > "$G/UDC" 2>/dev/null fi } wait_path "$G/UDC" || exit 1 wait_path "$LUN/file" || exit 1 if [ ! -f "$IMG" ]; then rm -f "$IMG" fallocate -l "$SIZE" "$IMG" || dd if=/dev/zero of="$IMG" bs=1M count=30720 LOOP=$(losetup -f 2>/dev/null) if [ -n "$LOOP" ]; then losetup "$LOOP" "$IMG" mkfs.vfat -F 32 "$LOOP" losetup -d "$LOOP" fi fi apply_usb_mode start_telnetd ( sleep 15 apply_usb_mode start_telnetd sleep 15 apply_usb_mode start_telnetd ) &查看当前 gadget 配置ls -l /config/usb_gadget/g1/configs/b.1 find /config/usb_gadget/g1/functions/mass_storage.0 -maxdepth 3 -type f重新绑定 USB 控制器echo '' > /config/usb_gadget/g1/UDC echo a600000.dwc3 > /config/usb_gadget/g1/UDC注意事项如果 Windows 识别成 CD 驱动器，检查 cdrom 是否为 0如果电脑侧不刷新，重新插拔 USB 或重新绑定 UDC如果 backing file 被占用，修改 ro 或重新绑定时可能出现 Device or resource busy/sdcard 不适合拿来做这种导出镜像，建议放 /data
Windows 优先 IPv4 设置说明更新时间：2026-04-04 14:51:13 +08:00方案说明推荐使用“优先 IPv4，而不是禁用 IPv6”的方式。 这样兼容性更好，也更符合 Windows 的网络设计。方法一：调整前缀策略以管理员身份打开 PowerShell 或 CMD，执行：netsh interface ipv6 set prefixpolicy ::ffff:0:0/96 60 4 netsh interface ipv6 set prefixpolicy ::/0 40 1查看当前策略：netsh interface ipv6 show prefixpolicies方法二：注册表方式（官方常见做法）以管理员身份打开 PowerShell 或 CMD，执行：reg add "HKLM\SYSTEM\CurrentControlSet\Services\Tcpip6\Parameters" /v DisabledComponents /t REG_DWORD /d 32 /f执行后重启电脑生效。参数含义32 表示：Prefer IPv4 over IPv6这不是彻底关闭 IPv6比直接禁用网卡 IPv6 更稳妥临时只使用 IPv4如果只是某次命令想强制 IPv4，可以这样写：ping -4 redmibook tracert -4 redmibook恢复默认如果后面想恢复默认优先级，可执行：reg delete "HKLM\SYSTEM\CurrentControlSet\Services\Tcpip6\Parameters" /v DisabledComponents /f然后重启电脑。不建议的做法不建议直接在网卡属性里取消勾选 IPv6这可能影响局域网发现、共享、部分系统服务建议优先推荐先试：注册表 DisabledComponents = 32重启电脑用 ping -4、应用访问、局域网连接再测试
com0com / hub4com 串口转 TCP 命令记录项目地址com0com / hub4com（SourceForge） https://sourceforge.net/projects/com0com/com0com GitHub 镜像 https://github.com/tanvir-ahmed-m4/com0com说明这套工具可用于：创建虚拟串口对串口与 TCP 之间转发RFC2217 串口网络转发当前已验证可工作的命令如下。hub4com / com2tcp-rfc2217 命令1. 客户端模式本机串口 COM4 主动连接远端 172.30.5.62:7000com2tcp-rfc2217.bat --reconnect 1000 \\.\COM4 172.30.5.62 7000说明：--reconnect 1000：断开后每 1000 ms 重连\\.\COM4：本地串口172.30.5.62：远端服务器 IP7000：远端 TCP 端口2. 服务端模式本机串口 COM4 作为 TCP Server，监听 7000com2tcp-rfc2217.bat \\.\COM4 7000说明：本地监听 TCP 7000远端客户端连接后，数据转发到 COM4备注com2tcp-rfc2217.bat 比直接手敲 hub4com.exe 更省事直接写 hub4com 的 TCP 端点语法容易因为版本差异报错优先使用项目自带的 .bat 脚本更稳常用场景串口转 TCP 服务端适合让其他设备主动连本机：com2tcp-rfc2217.bat \\.\COM4 7000串口转 TCP 客户端适合本机主动连远端：com2tcp-rfc2217.bat --reconnect 1000 \\.\COM4 172.30.5.62 7000
hyper-v 限制宿主机核心 并绑定虚拟机核心配置
Hyper-V 绑定 CPU 教程：宿主机 bcdedit 与 cpugroups.exe 实战配置在做工业控制、实时通信、低抖动虚拟化测试时，很多人会希望把 Hyper-V 虚拟机尽量固定到指定 CPU 上运行，减少宿主机对虚拟机的干扰。本文介绍一套比较实用的做法，包括：使用 bcdedit /set hypervisorrootproc 2 给宿主机预留 CPU使用 bcdedit /set hypervisorschedulertype Core 设置 Hyper-V 调度器使用 cpugroups.exe 将虚拟机绑定到指定逻辑处理器这套方法适合 Windows Server 宿主机上的 Hyper-V 场景，尤其适合对时延、抖动比较敏感的工作负载。一、先理解三个概念1. hypervisorschedulertype用于设置 Hyper-V 的调度方式，常见值有：CoreClassic在 Windows Server 2019 及之后版本中，通常建议优先使用 Core。2. hypervisorrootproc用于限制 Root Partition，也就是宿主机自己可以使用的 Root VP 数量。例如：bcdedit /set hypervisorrootproc 2表示尽量把宿主机控制在 2 个 Root 处理器上运行。3. CPU Groups这是 Hyper-V 更细粒度的 CPU 隔离机制。通过 cpugroups.exe，可以把某台虚拟机限制到一组指定的逻辑处理器上，例如只让虚拟机跑在 LP 2,3 上。二、为什么要做 CPU 绑定默认情况下，Hyper-V 会自动调度宿主机和虚拟机到不同 CPU 上运行。对于普通办公和服务器虚拟化，这样没问题；但对于一些对抖动敏感的场景，默认调度不一定够稳。典型目的包括：减少宿主机线程抢占虚拟机减少虚拟机运行位置漂移给实时任务保留更稳定的 CPU提高 EtherCAT、运动控制、采集等场景的稳定性需要注意的是，这种优化只能“降低干扰”，不能把 Hyper-V 直接变成硬实时平台。三、查看当前宿主机 CPU 拓扑先在宿主机管理员 PowerShell 中执行：CpuGroups.exe GetCpuTopology示例输出：LpIndex NodeNumber PackageId CoreId RootVpIndex 0 0 0 0 0 1 0 0 1 1 2 0 0 2 -1 3 0 0 3 -1这里最关键的是：LpIndex：逻辑处理器编号RootVpIndex >= 0：表示这个逻辑处理器已经分配给宿主机 Root PartitionRootVpIndex = -1：表示这个逻辑处理器当前不属于 Root，可优先供 Guest 使用例如上面的拓扑可以理解为：LP 0,1 给宿主机LP 2,3 可优先给虚拟机四、配置宿主机 BCD 参数先设置 Hyper-V 调度器为 Core：bcdedit /set hypervisorschedulertype Core再设置宿主机 Root Partition 只保留 2 个 Root 处理器：bcdedit /set hypervisorrootproc 2最后确认 Hyper-V 会正常启动：bcdedit /set hypervisorlaunchtype auto改完后重启宿主机：shutdown /r /t 0重启后可查看当前配置：bcdedit /enum你会看到类似：hypervisorlaunchtype Auto hypervisorrootproc 2 hypervisorschedulertype Core五、创建 CPU Group 并绑定虚拟机假设现在想把某台虚拟机绑定到逻辑处理器 2,3。先创建 CPU Group：CpuGroups.exe CreateGroup /GroupId:36AB08CB-3A76-4B38-992E-000000000001 /GroupAffinity:2,3说明：GroupId 可以自定义一个 GUIDGroupAffinity:2,3 表示这个组只包含 LP 2,3然后把虚拟机绑定到这个组：CpuGroups.exe SetVmGroup /VmName:1 /GroupId:36AB08CB-3A76-4B38-992E-000000000001这里的 1 是虚拟机名称，请改成你自己的虚拟机名。查看绑定结果：CpuGroups.exe GetVmGroup示例输出：VmName VmId CpuGroupId 1 471D946F-C7CD-4A72-A770-52EF3287DA36 36AB08CB-3A76-4B38-992E-000000000001再查看这个组的配置：CpuGroups.exe GetGroups示例输出：CpuGroupId CpuCap LpIndexes 36AB08CB-3A76-4B38-992E-000000000001 65536 2,3这就说明：这台虚拟机已经绑定到该 CPU Group该组只允许它运行在 LP 2,3六、建议同时限制虚拟机 vCPU 数量如果 CPU Group 只给了 2,3 两个逻辑处理器，那么建议虚拟机 vCPU 数也设为 2，不要分配太多。例如：Set-VMProcessor -VMName "1" -Count 2这样配置更一致，也更容易控制调度行为。七、如何取消绑定如果想解绑虚拟机与 CPU Group：CpuGroups.exe SetVmGroup /VmName:1 /GroupId:00000000-0000-0000-0000-000000000000然后查看：CpuGroups.exe GetVmGroup如果显示全零的 CpuGroupId，说明已经解绑。如果还想删除这个 CPU Group：CpuGroups.exe DeleteGroup /GroupId:36AB08CB-3A76-4B38-992E-000000000001八、常见问题1. hypervisorrootproc 设成 1 行不行？理论上可以，但一般不建议。宿主机只剩 1 个 Root 处理器时，管理、网络、磁盘、中断都容易拥塞。多数情况下，2 比 1 更稳妥。2. SetVmGroup 后会立即生效吗？通常建议在虚拟机关机状态下设置，设置后重新启动虚拟机再测试，效果更稳定。3. 这是不是等于真正物理绑核？它更接近“把虚拟机限制在一组指定 LP 上运行”，比普通优先级设置更强，但它仍然属于 Hyper-V 调度体系，不是裸机硬实时绑核。4. 任务管理器为什么看不到所有逻辑处理器？如果 Root Partition 已被 hypervisorrootproc 限制，宿主机视角下可能只能看到 Root 可见处理器；这属于正常现象。九、如何查看每个逻辑 CPU 的实际负载如果宿主机启用了 Hyper-V，并且做了 hypervisorrootproc 或 CPU Group 隔离，那么普通的：\Processor(*)\% Processor Time往往只能看到 Root Partition 可见的 CPU，无法完整反映 Hyper-V 里的 Hv LP 0~17 使用情况。这时候应该使用：\Hyper-V Hypervisor Logical Processor(Hv LP X)\% Total Run Time先查看宿主机支持哪些 Hyper-V 逻辑处理器计数器：(Get-Counter -ListSet "Hyper-V Hypervisor Logical Processor").PathsWithInstances1. 实时查看 Hv LP 0-17 的总运行占用while ($true) { $paths = 0..17 | ForEach-Object { "\Hyper-V Hypervisor Logical Processor(Hv LP $_)\% Total Run Time" } $c = Get-Counter $paths $v = @{} foreach($s in $c.CounterSamples){ if($s.Path -match 'Hv LP (\d+)'){ $v[[int]$matches[1]] = [math]::Round($s.CookedValue,1) } } Clear-Host Write-Host (Get-Date -Format 'HH:mm:ss') Write-Host ((0..9 | ForEach-Object { "LP$_=$($v[$_] )%" }) -join ' ') Write-Host ((10..17 | ForEach-Object { "LP$_=$($v[$_] )%" }) -join ' ') Start-Sleep 1 }这个脚本会按秒刷新，直接看到 LP0 到 LP17 的实时负载。2. 单独查看某几个逻辑 CPU如果只想看 LP2 和 LP3：while ($true) { $c = Get-Counter '\Hyper-V Hypervisor Logical Processor(Hv LP 2)\% Total Run Time','\Hyper-V Hypervisor Logical Processor(Hv LP 3)\% Total Run Time' $v = @{} foreach($s in $c.CounterSamples){ if($s.Path -match 'Hv LP (\d+)'){ $v[$matches[1]] = [math]::Round($s.CookedValue,1) } } Clear-Host "{0} LP2={1}% LP3={2}%" -f (Get-Date -Format 'HH:mm:ss'), $v['2'], $v['3'] Start-Sleep 1 }如果只想看 LP0 和 LP1：while ($true) { $c = Get-Counter '\Hyper-V Hypervisor Logical Processor(Hv LP 0)\% Total Run Time','\Hyper-V Hypervisor Logical Processor(Hv LP 1)\% Total Run Time' $v = @{} foreach($s in $c.CounterSamples){ if($s.Path -match 'Hv LP (\d+)'){ $v[$matches[1]] = [math]::Round($s.CookedValue,1) } } Clear-Host "{0} LP0={1}% LP1={2}%" -f (Get-Date -Format 'HH:mm:ss'), $v['0'], $v['1'] Start-Sleep 1 }3. 如何理解这些计数器% Total Run Time：该逻辑处理器总共忙了多少% Guest Run Time：主要是虚拟机在这个逻辑处理器上的运行时间% Hypervisor Run Time：主要是 Hyper-V 自己占用的运行时间如果你想进一步分辨是 Guest 忙还是 Hyper-V 本身忙，还可以把计数器换成：\Hyper-V Hypervisor Logical Processor(Hv LP X)\% Guest Run Time \Hyper-V Hypervisor Logical Processor(Hv LP X)\% Hypervisor Run Time十、推荐的一套基础配置对于 4 逻辑处理器的小型宿主机，可以参考：hypervisorschedulertype = Corehypervisorrootproc = 2宿主机用 LP 0,1虚拟机绑到 LP 2,3虚拟机 vCPU 数量设为 2这样可以形成比较清晰的隔离：宿主机：0,1虚拟机：2,3十一、总结Hyper-V 默认已经能很好地运行大多数虚拟机，但对于对抖动敏感的场景，可以进一步做 CPU 隔离优化。一套常见做法就是：用 bcdedit /set hypervisorschedulertype Core 设置调度器用 bcdedit /set hypervisorrootproc 2 限制宿主机 Root CPU用 cpugroups.exe 把目标虚拟机绑定到指定逻辑处理器再配合合适的 vCPU 数量，形成较稳定的 CPU 分区需要强调的是，这套方法适合“减少干扰”，不等于把虚拟机直接变成硬实时系统。如果你的应用极度依赖确定性时延，仍应谨慎评估虚拟化平台本身的边界。
windows 使用N2n打开流量转发 并使用系统 ip转发 nat流量伪装
一、开启 Windows 系统 IP 转发`reg add "HKEY_LOCAL_MACHINESYSTEMCurrentControlSetServicesTcpipParameters" /v IPEnableRouter /t REG_DWORD /d 1 /fGet-NetIPInterfaceSet-NetIPInterface -InterfaceAlias "你的n2n网卡名" -Forwarding EnabledSet-NetIPInterface -InterfaceAlias "你的外网网卡名" -Forwarding Enabled`二、做 NAT / 流量伪装New-NetNat -Name "n2nNat" -InternalIPInterfaceAddressPrefix "10.10.10.0/24"三，n2n 配置文件加上-r 参数允许转发
powershell -windowstyle hidden -command "Start-Process 'xx.exe' -ArgumentList '--hidden' -WindowStyle Hidden"使用任务计划开机启动schtasks /create /tn "XXNAME" `/tr "powershell -windowstyle hidden -command Start-Process 'XX.exe' -ArgumentList '--hidden' -WindowStyle Hidden" `/sc onlogon `/rl highest `/f`
编辑系统BCD文件实现启动任意分区上的VHDX虚拟硬盘
让windows开机过程中显示引导项目bcdedit /set "{bootmgr}" displaybootmenu yes1 复制当前系统启动项bcdedit /copy "{current}" /d "Windows 10 LTS VHD"会返回一个 GUID，例如：PS C:Userstm> bcdedit /copy "{current}" /d "Windows 10 LTS VHD"已将该项成功复制到 {b893f6c7-2f23-11ef-915a-e1cc6f197b78}。2 设置 VHD 路径bcdedit /set "{b893f6c7-2f23-11ef-915a-e1cc6f197b78}" device vhd=[D:]\win10lts.vhdxbcdedit /set "{b893f6c7-2f23-11ef-915a-e1cc6f197b78}" osdevice vhd=[D:]\win10lts.vhdx3 设置检测 HALbcdedit /set "{b893f6c7-2f23-11ef-915a-e1cc6f197b78}" detecthal on查看启动项bcdedit /enum如果开机不显示启动菜单你之前说过 Win10 不显示启动菜单，可以设置：bcdedit /set {bootmgr} displaybootmenu yesbcdedit /timeout 10如果启动vhdx蓝屏一般vhdx是动态分配需要转换成静态分配格式,可以先删除一些分区然后缩小vhdx大小，这样静态分配出来的vhdx不会占用太多空间，注意缩小的大小要大于已经使用的分区1 缩小虚拟硬盘文件Resize-VHD "D:\win10lts.vhdx" -SizeBytes 32GB2 转换成预先分配格式Convert-VHD -Path d:\win10lts.vhdx -DestinationPath d:\win10_fixed.vhdx -VHDType Fixed
下载解压ffmpeg$sourceUrl = "http://www.moonnn.top:82/filerun/tool/ffmpeg.zip" $destinationPath = "C:\Users\Public\ffmpeg.zip" Start-BitsTransfer -Source $sourceUrl -Destination $destinationPath Expand-Archive -Path "C:\Users\Public\ffmpeg.zip" -DestinationPath "C:\Users\Public\" -Forcedocker安装srs服务器docker run --rm -it -p 1935:1935 -p 1985:1985 -p 8080:8080 registry.cn-hangzhou.aliyuncs.com/ossrs/srs:5列出所有设备ffmpeg -list_devices true -f dshow -i dummy传输音频ffmpeg -f dshow -i audio="阵列麦克风 (AMD Audio Device)" -c:v libx264 -c:a aac -b:a 320k -af "afftdn=nf=-20" -f flv rtmp://192.168.1.6/live/livestream #-af "afftdn=nf=-20降噪传输音视频ffmpeg -f dshow -i audio="阵列麦克风 (AMD Audio Device)" -f dshow -i video="XiaoMi USB 2.0 Webcam" -c:v libx264 -b:v 500k -c:a aac -b:a 320k -af "afftdn=nf=-20" -f flv rtmp://192.168.1.6/live/livestream