默认分类

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 会话。不同镜像默认用户可能叫 armbianadmin 或其它名字,所以先取当前系统的普通用户变量:

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.local

FreeRDP 脚本里就可以把目标从 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 xfreerdp3

1.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:7573

2. 启动优化

先看耗时:

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.service

3. 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:0017

4. 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 YAopenvfD

5. 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 -80

6. 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.bat

7. 常用排查命令

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_on

VirtualHere:

cat /etc/virtualhere/config.ini
systemctl restart vhusbdarm.service
journalctl -b -u vhusbdarm.service --no-pager | tail -80

8. 当前效果

最终效果:

  • 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.local

9.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 session

TARGET / 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-picker

root 下运行:

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/target

9.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.log

10. 当前完整脚本附录(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

回复

This is just a placeholder img.