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