1. 背景
实验室 Ubuntu 主机长期通过 ToDesk 远程连接,但校园网存在一个比较麻烦的问题:
校园网认证掉线 → Ubuntu 断网 → ToDesk 掉线 → 即使校园网重新恢复,ToDesk 有时也不会自动重新建立连接。
以前需要人工在本地执行:
sudo kill ...
sudo systemctl stop ufw
sudo systemctl disable ufw
然后重新打开一个 Terminal:
todesk
对于无人值守的实验室服务器,这显然不方便。
因此本文实现一个 Ubuntu 下的自动恢复脚本:
定时检测校园网
↓
判断 Portal 是否已经登录
↓
未登录
↓
Selenium 自动登录校园网
↓
检测登录成功
↓
关闭原 ToDesk / ToDesk+ 进程
↓
关闭 UFW
↓
重新启动 ToDesk
↓
恢复远程连接
并进一步配置为 Ubuntu 登录桌面后自动启动。
2. 参考项目
校园网 Selenium 自动登录部分参考:
ThreeStones1029 / AutoLoginCampusNetwork
GitHub - ThreeStones1029/AutoLoginCampusNetwork: 自动连接河海大学校园网 · GitHub
本文在此基础上增加了:
-
Ubuntu Chrome for Testing 支持
-
ChromeDriver 显式路径
-
密码输入框兼容处理
-
校园网状态周期检测
-
自动重新登录
-
ToDesk / ToDesk+ 进程恢复
-
自动关闭 UFW
-
自动重新启动 ToDesk
-
Ubuntu 桌面登录后自动运行
3. 安装 Selenium
如果使用 Anaconda:
pip install selenium
查看版本:
python -c "import selenium; print(selenium.__version__)"
本文 Ubuntu 环境使用 Selenium 4。
4. 下载 Chrome 和 ChromeDriver
推荐直接使用 Google 官方的 Chrome for Testing:
Chrome for Testing availability
Chrome for Testing 官方页面会同时提供不同平台的 Chrome 和 ChromeDriver 下载,并按版本对应。
本文建议使用 120 及以上版本,并保证:
Chrome 和 ChromeDriver 使用相同版本。
本文实际使用版本:
153.0.7993.0
Chrome Linux 64 位
https://storage.googleapis.com/chrome-for-testing-public/153.0.7993.0/linux64/chrome-linux64.zip
ChromeDriver Linux 64 位
下载完成后解压,例如放到:
/home/hy/Downloads/chrome-linux64/
/home/hy/Downloads/chromedriver-linux64/
最终对应:
/home/hy/Downloads/chrome-linux64/chrome
/home/hy/Downloads/chromedriver-linux64/chromedriver
添加执行权限:
chmod +x /home/hy/Downloads/chrome-linux64/chrome
chmod +x /home/hy/Downloads/chromedriver-linux64/chromedriver
检查版本:
/home/hy/Downloads/chrome-linux64/chrome --version
/home/hy/Downloads/chromedriver-linux64/chromedriver --version
5. 校园网状态如何判断
这里没有简单使用:
ping baidu.com
来判断网络是否正常。
因为本文真正关心的是:
校园网 Portal 当前是否处于认证状态。
脚本每隔一段时间启动 Selenium,访问校园网 Portal:
driver.get(LOGIN_URL)
然后检查页面中是否存在:
id="toLogOut"
Python 判断:
def is_portal_logged_in(driver):
try:
driver.find_element(By.ID, "toLogOut")
return True
except NoSuchElementException:
return False
如果能够找到:
toLogOut
说明当前 Portal 已经登录。
如果找不到,则进入自动登录流程。
6. 自动填写校园网账号密码
首先等待用户名输入框:
username_input = wait.until(
EC.presence_of_element_located(
(By.ID, "username")
)
)
username_input.clear()
username_input.send_keys(USERNAME)
校园网密码框比较特殊。
部分 Portal 页面中真正的:
id="pwd"
一开始处于不可交互状态,上面覆盖了:
id="pwd_tip"
如果直接执行:
password_input.send_keys(PASSWORD)
可能出现:
ElementNotInteractableException
因此先点击:
pwd_tip = driver.find_element(By.ID, "pwd_tip")
if pwd_tip.is_displayed():
pwd_tip.click()
之后重新寻找:
password_input = driver.find_element(By.ID, "pwd")
如果普通 send_keys() 仍然失败,则使用 JavaScript 写入:
def js_set_value(driver, element, value):
driver.execute_script(
"""
const el = arguments[0];
const value = arguments[1];
el.removeAttribute('readonly');
el.removeAttribute('disabled');
const setter =
Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
'value'
).set;
setter.call(el, value);
el.dispatchEvent(
new Event('input', {bubbles:true})
);
el.dispatchEvent(
new Event('change', {bubbles:true})
);
""",
element,
value,
)
这种方式对一些老式校园网 Portal 页面更稳定。
7. 自动选择中国移动
我的校园网服务列表为:
_service_0:校园网
_service_1:中国内网
_service_2:中国移动
_service_3:中国联通
_service_4:中国电信
因此设置:
SERVICE_ID = "_service_2"
自动选择:
service_item = wait.until(
EC.presence_of_element_located(
(By.ID, SERVICE_ID)
)
)
driver.execute_script(
"arguments[0].click();",
service_item
)
不同学校的 Portal 页面 ID 可能不同,需要根据网页源码修改。
8. 自动点击登录
登录按钮:
login_button = wait.until(
EC.presence_of_element_located(
(By.ID, "loginLink_div")
)
)
为了避免某些网页普通:
login_button.click()
失效,这里直接使用 JavaScript:
driver.execute_script(
"""
arguments[0].scrollIntoView({
block: 'center'
});
arguments[0].click();
""",
login_button
)
登录以后继续检测:
if is_portal_logged_in(driver):
print("校园网登录成功")
只有确定校园网已经恢复以后,才开始处理 ToDesk。
9. 为什么校园网恢复后还需要重启 ToDesk
实际使用中遇到的问题是:
校园网断开
↓
ToDesk 判断机器离线
↓
校园网恢复
↓
ToDesk 并不一定重新上线
人工解决方式通常是:
关闭 ToDesk
↓
关闭 UFW
↓
重新执行 todesk
因此将这几个步骤也加入自动恢复流程。
10. 安全关闭 ToDesk 进程
这里有一个比较容易踩的坑。
一开始使用:
pkill -9 -f todesk
结果 Python 监控脚本本身也被杀死。
原因是脚本名字中本身包含:
todesk
例如:
auto_login_and_todesk_watchdog.py
而:
pkill -f todesk
匹配的是整个进程命令行。
因此最终版本改成:
ps -eo pid=,comm=,args=
获取进程列表,再判断真正的 ToDesk 进程,只杀目标 PID。
同时明确排除:
if pid == os.getpid():
continue
if "auto_login_and_todesk_watchdog" in args_lower:
continue
if comm_lower.startswith("python"):
continue
这样可以避免 watchdog 把自己杀掉。
11. 自动关闭 UFW
网络恢复后执行:
sudo systemctl stop ufw
sudo systemctl disable ufw
注意正确命令是:
sudo systemctl disable ufw
而不是:
sudo systemctl ufw disable
为了方便观察恢复过程,我没有把这些操作完全隐藏在后台,而是自动打开一个新的 Terminal 执行。
效果类似:
====================================
正在停止并禁用 UFW...
====================================
[1/2] systemctl stop ufw 完成
[2/2] systemctl disable ufw 完成
当前 UFW 状态:
inactive
disabled
注意:直接关闭 UFW 会降低系统防护能力。如果机器暴露在不可信网络环境,建议根据自己的网络环境配置 ToDesk 所需规则,而不是永久关闭整个防火墙。
12. 自动重新启动 ToDesk
UFW 操作完成后,再自动打开第二个 Terminal:
todesk
为了防止 Terminal 一闪而过,脚本会先生成:
/home/hy/start_todesk_recovery.sh
大致内容:
#!/bin/bash
echo "========================================"
echo " ToDesk 自动恢复启动终端"
echo "========================================"
echo "正在启动 ToDesk..."
todesk
RET=$?
echo "ToDesk 命令返回,退出码:$RET"
exec bash
这样即使 ToDesk 启动失败,Terminal 也不会立即关闭,可以直接看到错误信息。
13. 最终自动恢复逻辑
整个 watchdog 的逻辑如下:
程序启动
↓
每 30 分钟检查一次
↓
启动 Chrome
↓
访问校园网 Portal
↓
检测 toLogOut
↓
┌──────────────────┐
│ 存在 │
│ 当前校园网正常 │
└────────┬─────────┘
↓
等待下一次检测
如果不存在 toLogOut
↓
自动填写账号
↓
自动填写密码
↓
选择中国移动
↓
点击登录
↓
重新检测 toLogOut
↓
校园网恢复成功
↓
关闭 ToDesk / ToDesk+
↓
新开 Terminal
↓
stop ufw
disable ufw
↓
新开第二个 Terminal
↓
运行 todesk
↓
ToDesk 恢复远程连接
14. 每 30 分钟自动检测
监控周期:
CHECK_INTERVAL = 30 * 60
即:
30 × 60 = 1800 秒
正常情况下终端输出类似:
开始执行本轮校园网状态检测......
打开 UPC Portal,检查当前登录状态......
检测到 toLogOut:当前校园网已登录
本轮判断结果:校园网正常 / 已登录
下一次检测将在 30 分钟后进行
如果掉线:
未检测到 toLogOut:当前校园网未登录
开始执行校园网自动重连
填写校园网账号
填写校园网密码
选择网络服务
点击校园网登录按钮
校园网自动重连成功
随后:
[1/3] Kill ToDesk / ToDesk+
[2/3] 新开 Terminal:停止并禁用 UFW
[3/3] 新开 Terminal:启动 ToDesk
15. 配置 Ubuntu 开机自动启动
因为这个程序需要:
-
打开 Chrome
-
打开 GNOME Terminal
-
启动 ToDesk GUI
所以不建议直接作为纯后台 root systemd 服务运行。
更适合配置:
Ubuntu 开机
↓
hy 用户登录 GNOME 桌面
↓
自动启动 watchdog
创建:
mkdir -p ~/.config/autostart
然后:
nano ~/.config/autostart/upc-todesk-watchdog.desktop
写入:
[Desktop Entry]
Type=Application
Name=UPC ToDesk Watchdog
Comment=Auto reconnect UPC network and restart ToDesk
Exec=gnome-terminal --title=UPC-ToDesk-Watchdog -- bash -lc '/home/hy/anaconda3/bin/python /home/hy/net_watchdog/auto_login_and_todesk_watchdog_v7_autostart.py; exec bash'
Terminal=false
X-GNOME-Autostart-enabled=true
Hidden=false
NoDisplay=false
之后 Ubuntu 用户登录桌面时,会自动弹出:
UPC-ToDesk-Watchdog
终端,并开始监控校园网状态。
16. 防止程序重复启动
如果开机自启动以后又手动执行脚本,有可能出现多个 watchdog。
因此可以通过文件锁保证只运行一个实例:
import fcntl
LOCK_FILE = "/home/hy/.auto_login_todesk_watchdog.lock"
使用:
fcntl.flock(
lock_fp.fileno(),
fcntl.LOCK_EX | fcntl.LOCK_NB
)
如果已经存在一个运行实例:
已有一个校园网 + ToDesk watchdog 正在运行,本次退出。
这样可以避免重复检测和重复启动 ToDesk。
17. 常见问题
17.1 cannot find Chrome binary
错误:
SessionNotCreatedException:
cannot find Chrome binary
说明 Selenium 找不到 Chrome。
显式指定:
options.binary_location = (
"/home/hy/Downloads/"
"chrome-linux64/chrome"
)
17.2 找不到 ChromeDriver
指定:
service = Service(
"/home/hy/Downloads/"
"chromedriver-linux64/chromedriver"
)
并确认:
chmod +x /home/hy/Downloads/chromedriver-linux64/chromedriver
17.3 ElementNotInteractableException
如果账号可以输入,但密码出现:
ElementNotInteractableException
一般是:
pwd_tip
覆盖了真正密码框。
先点击:
pwd_tip
再填写:
pwd
仍然失败则使用 JavaScript 设置 value。
17.4 ToDesk 被 kill 后 Python 自己也退出
如果看到:
开始关闭 ToDesk / ToDesk+ 相关进程
已杀死
然后直接返回 Shell:
hy@ubuntu:~$
检查是不是使用了:
pkill -9 -f todesk
如果 Python 脚本名称也包含 todesk,会把自己一起杀掉。
应该根据 PID 和真实进程名精确 kill。
17.5 第二个 ToDesk Terminal 没有弹出来
不要简单执行:
gnome-terminal -- bash -lc "todesk"
更稳定的方法是:
Python
↓
生成 start_todesk_recovery.sh
↓
gnome-terminal
↓
bash start_todesk_recovery.sh
↓
todesk
↓
exec bash
这样终端会保留,也方便排查错误。
18. 总结
最终实现的不是单纯的"校园网自动登录",而是一套完整的 Ubuntu 无人值守远程恢复机制:
校园网认证监测
+
Selenium 自动登录
+
ToDesk 进程恢复
+
UFW 自动处理
+
GNOME Terminal 可视化执行
+
30 分钟周期检测
+
Ubuntu 登录后自动启动
对于实验室 Ubuntu 工作站、GPU 服务器或者长期放在学校机房中的电脑,这种方式可以减少:
校园网偶发掉线 → ToDesk 永久离线 → 必须人工到现场恢复
的问题。
19. 完整代码
下面给出本文最终使用的完整 Python 代码。
注意:公开代码时请勿上传真实账号和密码。
本文已经将校园网账号、校园网密码以及 Ubuntu sudo 密码全部替换为
xxxxxx,使用时请自行修改。
建议文件名:
auto_login_and_todesk_watchdog.py
完整代码如下:
from selenium import webdriver
from selenium.common.exceptions import (
NoSuchElementException,
TimeoutException,
WebDriverException,
ElementNotInteractableException,
)
from selenium.webdriver.chrome.service import Service
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
import datetime
import fcntl
import os
import platform
import shlex
import shutil
import subprocess
import sys
import time
# ============================================================
# 1. 用户配置
# ============================================================
# =========================
# 校园网账号密码
# =========================
USERNAME = "xxxxxx"
PASSWORD = "xxxxxx"
# sudo 密码
# 注意:公开到 GitHub / CSDN 时一定要脱敏
SUDO_PASSWORD = "xxxxxx"
# ============================================================
# 校园网 Portal 地址
# ============================================================
LOGIN_URL = (
"https://wlan.upc.edu.cn/eportal/index.jsp?"
"wlanuserip=172.24.209.152&"
"wlanacname=&"
"nasip=172.22.242.141&"
"wlanparameter=28-95-29-0d-cc-71&"
"url=http://detectportal.firefox.com/canonical.html&"
"uerlocation=ethtrunk/62:3081.0"
)
# ============================================================
# 网络服务
# ============================================================
# _service_0:校园网
# _service_1:中国内网
# _service_2:中国移动
# _service_3:中国联通
# _service_4:中国电信
SERVICE_ID = "_service_2"
# ============================================================
# 每 30 分钟检测一次校园网
# ============================================================
CHECK_INTERVAL = 30 * 60
# 登录成功检测最大等待时间
NETWORK_RECOVERY_TIMEOUT = 60
# Kill ToDesk 后等待时间
TODESK_KILL_WAIT = 3
# True:
# Selenium 登录过程中显示 Chrome
#
# False:
# 后台运行 Chrome
#
# 为了方便调试,本文设置为 True
SHOW_BROWSER = True
# ============================================================
# Chrome / ChromeDriver
# ============================================================
CHROME_BINARY = (
"/home/hy/Downloads/"
"chrome-linux64/chrome"
)
CHROMEDRIVER = (
"/home/hy/Downloads/"
"chromedriver-linux64/chromedriver"
)
# ============================================================
# 日志
# ============================================================
LOG_FILE = (
"/home/hy/"
"network_todesk_watchdog.log"
)
# ============================================================
# 单实例锁
# 防止开机启动以后又手动启动一个相同程序
# ============================================================
LOCK_FILE = (
"/home/hy/"
".auto_login_todesk_watchdog.lock"
)
# ============================================================
# 2. 日志输出
# ============================================================
def log(msg):
line = (
f"[{datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] "
f"{msg}"
)
print(
line,
flush=True
)
try:
with open(
LOG_FILE,
"a",
encoding="utf-8"
) as f:
f.write(
line + "\n"
)
except Exception:
pass
# ============================================================
# 3. 防止重复启动
# ============================================================
def acquire_single_instance_lock():
lock_fp = open(
LOCK_FILE,
"w"
)
try:
fcntl.flock(
lock_fp.fileno(),
fcntl.LOCK_EX | fcntl.LOCK_NB
)
except BlockingIOError:
print(
"已有一个校园网 + ToDesk watchdog "
"正在运行,本次退出。"
)
sys.exit(0)
lock_fp.write(
str(os.getpid())
)
lock_fp.flush()
return lock_fp
# ============================================================
# 4. 创建 Chrome
# ============================================================
def create_driver():
if (
platform.system().lower()
!= "linux"
):
raise RuntimeError(
"当前版本按照 Ubuntu/Linux 环境编写"
)
if not os.path.isfile(
CHROME_BINARY
):
raise FileNotFoundError(
f"找不到 Chrome:{CHROME_BINARY}"
)
if not os.path.isfile(
CHROMEDRIVER
):
raise FileNotFoundError(
f"找不到 ChromeDriver:{CHROMEDRIVER}"
)
service = Service(
CHROMEDRIVER
)
options = (
webdriver.ChromeOptions()
)
# 指定 Chrome for Testing
options.binary_location = (
CHROME_BINARY
)
# 是否显示 Chrome
if not SHOW_BROWSER:
options.add_argument(
"--headless=new"
)
options.add_argument(
"--no-sandbox"
)
options.add_argument(
"--disable-dev-shm-usage"
)
options.add_argument(
"--disable-gpu"
)
options.add_argument(
"--window-size=1920,1080"
)
options.add_argument(
"--ignore-certificate-errors"
)
options.add_argument(
"--ignore-ssl-errors"
)
options.add_argument(
"--disable-notifications"
)
options.add_argument(
"--disable-popup-blocking"
)
return webdriver.Chrome(
service=service,
options=options
)
# ============================================================
# 5. JS 写入 input
# ============================================================
def js_set_value(
driver,
element,
value
):
driver.execute_script(
"""
const el = arguments[0];
const value = arguments[1];
el.removeAttribute('readonly');
el.removeAttribute('disabled');
const setter =
Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
'value'
).set;
setter.call(el, value);
el.dispatchEvent(
new Event(
'input',
{
bubbles: true
}
)
);
el.dispatchEvent(
new Event(
'change',
{
bubbles: true
}
)
);
el.dispatchEvent(
new Event(
'blur',
{
bubbles: true
}
)
);
""",
element,
value
)
# ============================================================
# 6. 找到真正可见的元素
# ============================================================
def find_visible_element(
driver,
by,
value
):
elements = (
driver.find_elements(
by,
value
)
)
for element in elements:
try:
if element.is_displayed():
return element
except Exception:
pass
if elements:
return elements[0]
raise NoSuchElementException(
f"找不到元素:{by}={value}"
)
# ============================================================
# 7. 判断校园网是否已经登录
# ============================================================
def is_portal_logged_in(
driver
):
try:
driver.find_element(
By.ID,
"toLogOut"
)
return True
except NoSuchElementException:
return False
# ============================================================
# 8. Portal 状态检测
# ============================================================
def portal_login_status():
"""
判断方式不是 ping,
而是直接访问校园网 Portal。
找到:
id="toLogOut"
表示已经登录。
返回:
True -> 已登录
False -> 未登录
None -> 检测异常
"""
driver = None
try:
log(
"打开校园网 Portal,"
"检查当前登录状态......"
)
driver = create_driver()
driver.get(
LOGIN_URL
)
# 等待 Portal JS 加载
time.sleep(2)
if is_portal_logged_in(
driver
):
log(
"检测到 toLogOut:"
"当前校园网已登录"
)
return True
log(
"未检测到 toLogOut:"
"当前校园网未登录"
)
return False
except Exception as e:
log(
"Portal 状态检测异常:"
f"{type(e).__name__}: {e}"
)
try:
if driver is not None:
driver.save_screenshot(
"/home/hy/"
"portal_status_check_error.png"
)
except Exception:
pass
return None
finally:
if driver is not None:
try:
driver.quit()
except Exception:
pass
# ============================================================
# 9. 输入账号
# ============================================================
def fill_username(
driver,
wait
):
log(
"填写校园网账号"
)
username_input = (
wait.until(
EC.presence_of_element_located(
(
By.ID,
"username"
)
)
)
)
try:
username_input.clear()
username_input.send_keys(
USERNAME
)
except ElementNotInteractableException:
log(
"账号输入框不可交互,"
"改用 JavaScript 输入"
)
js_set_value(
driver,
username_input,
USERNAME
)
# ============================================================
# 10. 输入密码
# ============================================================
def fill_password(
driver,
wait
):
log(
"填写校园网密码"
)
# --------------------------------------------
# 一些校园网 Portal 会使用 pwd_tip
# 覆盖真正的密码输入框
# --------------------------------------------
try:
pwd_tip = (
driver.find_element(
By.ID,
"pwd_tip"
)
)
if pwd_tip.is_displayed():
try:
pwd_tip.click()
except Exception:
driver.execute_script(
"arguments[0].click();",
pwd_tip
)
time.sleep(0.5)
except NoSuchElementException:
pass
# --------------------------------------------
# 重新获取真正的 pwd
# --------------------------------------------
password_input = (
wait.until(
EC.presence_of_element_located(
(
By.ID,
"pwd"
)
)
)
)
try:
password_input = (
find_visible_element(
driver,
By.ID,
"pwd"
)
)
except Exception:
pass
# --------------------------------------------
# 正常 send_keys
# --------------------------------------------
try:
driver.execute_script(
"""
arguments[0].scrollIntoView(
{
block:'center'
}
);
""",
password_input
)
password_input.click()
password_input.clear()
password_input.send_keys(
PASSWORD
)
except Exception:
log(
"send_keys 无法输入密码,"
"改用 JavaScript"
)
js_set_value(
driver,
password_input,
PASSWORD
)
# ============================================================
# 11. 自动登录校园网
# ============================================================
def campus_login():
driver = None
try:
log(
"启动 Chrome,"
"开始自动登录校园网"
)
driver = create_driver()
wait = WebDriverWait(
driver,
15
)
driver.get(
LOGIN_URL
)
time.sleep(2)
# --------------------------------------------
# 已经登录
# --------------------------------------------
if is_portal_logged_in(
driver
):
log(
"Portal 当前已经登录"
)
return True
# --------------------------------------------
# 等待用户名
# --------------------------------------------
wait.until(
EC.presence_of_element_located(
(
By.ID,
"username"
)
)
)
# --------------------------------------------
# 输入账号密码
# --------------------------------------------
fill_username(
driver,
wait
)
fill_password(
driver,
wait
)
# --------------------------------------------
# 选择运营商
# --------------------------------------------
log(
"选择网络服务"
)
try:
select_service = (
wait.until(
EC.presence_of_element_located(
(
By.ID,
"selectDisname"
)
)
)
)
try:
select_service.click()
except Exception:
driver.execute_script(
"arguments[0].click();",
select_service
)
time.sleep(0.5)
except TimeoutException:
log(
"未找到 selectDisname,"
"直接尝试选择运营商"
)
# --------------------------------------------
# 中国移动
# --------------------------------------------
try:
service_item = (
wait.until(
EC.presence_of_element_located(
(
By.ID,
SERVICE_ID
)
)
)
)
driver.execute_script(
"arguments[0].click();",
service_item
)
log(
f"已选择运营商:"
f"{SERVICE_ID}"
)
time.sleep(0.5)
except TimeoutException:
log(
f"找不到运营商选项:"
f"{SERVICE_ID}"
)
# --------------------------------------------
# 点击登录
# --------------------------------------------
log(
"点击校园网登录按钮"
)
login_button = (
wait.until(
EC.presence_of_element_located(
(
By.ID,
"loginLink_div"
)
)
)
)
driver.execute_script(
"""
arguments[0].scrollIntoView(
{
block:'center'
}
);
arguments[0].click();
""",
login_button
)
# --------------------------------------------
# 等待登录成功
# --------------------------------------------
log(
"已提交登录请求,"
"等待 Portal 返回"
)
end_time = (
time.time()
+ NETWORK_RECOVERY_TIMEOUT
)
while (
time.time()
< end_time
):
try:
if is_portal_logged_in(
driver
):
log(
"检测到 toLogOut:"
"校园网登录成功"
)
return True
except Exception:
pass
time.sleep(2)
# --------------------------------------------
# 登录失败
# --------------------------------------------
log(
"登录后仍未检测到 "
"toLogOut"
)
try:
driver.save_screenshot(
"/home/hy/"
"campus_login_failed.png"
)
except Exception:
pass
return False
except Exception as e:
log(
"校园网自动登录异常:"
f"{type(e).__name__}: {e}"
)
try:
if driver is not None:
driver.save_screenshot(
"/home/hy/"
"campus_login_error.png"
)
except Exception:
pass
return False
finally:
if driver is not None:
try:
driver.quit()
except Exception:
pass
# ============================================================
# 12. sudo 命令
# ============================================================
def run_sudo_command(
command
):
"""
使用 sudo -S 自动输入 sudo 密码。
公开代码时 SUDO_PASSWORD
一定要替换为 xxxxxx。
"""
try:
result = subprocess.run(
[
"sudo",
"-S"
]
+ command,
input=(
SUDO_PASSWORD
+ "\n"
),
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
timeout=15
)
if (
result.returncode
!= 0
):
log(
"sudo 命令失败:"
+ " ".join(command)
+ " | "
+ result.stderr.strip()
)
return False
return True
except Exception as e:
log(
"sudo 命令异常:"
f"{type(e).__name__}: {e}"
)
return False
# ============================================================
# 13. 安全 Kill ToDesk
# ============================================================
def kill_todesk():
"""
不使用:
pkill -9 -f todesk
因为 Python 文件名本身可能包含 todesk,
会把 watchdog 自己杀死。
"""
log(
"开始关闭 ToDesk / "
"ToDesk+ 相关进程"
)
current_pid = (
os.getpid()
)
try:
result = subprocess.run(
[
"ps",
"-eo",
"pid=,comm=,args="
],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
timeout=5
)
if (
result.returncode
!= 0
):
log(
"读取进程列表失败"
)
return False
killed_any = False
for line in (
result.stdout.splitlines()
):
line = line.strip()
if not line:
continue
parts = (
line.split(
None,
2
)
)
if len(parts) < 2:
continue
try:
pid = int(
parts[0]
)
except ValueError:
continue
comm = (
parts[1]
)
args = (
parts[2]
if len(parts) >= 3
else ""
)
# --------------------------------------------
# 不杀自己
# --------------------------------------------
if (
pid
== current_pid
):
continue
comm_lower = (
comm.lower()
)
args_lower = (
args.lower()
)
# --------------------------------------------
# 排除 watchdog
# --------------------------------------------
if (
"auto_login_and_todesk_watchdog"
in args_lower
):
continue
# --------------------------------------------
# 排除 Python
# --------------------------------------------
if (
comm_lower.startswith(
"python"
)
):
continue
# --------------------------------------------
# 判断真正 ToDesk
# --------------------------------------------
is_todesk = (
comm_lower
in {
"todesk",
"todesk+",
"todesk_service",
"todeskservice"
}
or "/todesk"
in args_lower
or "/todesk+"
in args_lower
)
if not is_todesk:
continue
log(
f"发现 ToDesk 进程:"
f"PID={pid}, "
f"COMM={comm}"
)
# --------------------------------------------
# 普通 kill
# --------------------------------------------
try:
kill_result = (
subprocess.run(
[
"kill",
"-9",
str(pid)
],
stdout=(
subprocess.DEVNULL
),
stderr=(
subprocess.DEVNULL
),
timeout=5
)
)
if (
kill_result.returncode
== 0
):
log(
f"已关闭 ToDesk:"
f"PID={pid}"
)
killed_any = True
continue
except Exception:
pass
# --------------------------------------------
# sudo kill
# --------------------------------------------
if run_sudo_command(
[
"kill",
"-9",
str(pid)
]
):
log(
"已通过 sudo "
f"关闭 ToDesk:PID={pid}"
)
killed_any = True
if not killed_any:
log(
"没有发现需要关闭的 "
"ToDesk 进程"
)
time.sleep(
TODESK_KILL_WAIT
)
return True
except Exception as e:
log(
"关闭 ToDesk 异常:"
f"{type(e).__name__}: {e}"
)
return False
# ============================================================
# 14. 查找 Terminal
# ============================================================
def find_terminal():
candidates = [
"gnome-terminal",
"x-terminal-emulator",
"konsole",
"xfce4-terminal"
]
for terminal in candidates:
if shutil.which(
terminal
):
return terminal
return None
# ============================================================
# 15. 新建 Terminal 执行命令
# ============================================================
def open_terminal_and_run(
command,
title=None,
hold=True
):
terminal = (
find_terminal()
)
if not terminal:
log(
"没有找到可用的 "
"图形 Terminal"
)
return False
if hold:
shell_cmd = (
command
+ '; echo ""; '
+ 'echo "命令执行完成。'
'按 Enter 关闭此终端..."; '
+ 'read'
)
else:
shell_cmd = (
command
)
try:
# ========================================
# GNOME Terminal
# ========================================
if (
terminal
== "gnome-terminal"
):
args = [
"gnome-terminal"
]
if title:
args += [
"--title",
title
]
args += [
"--",
"bash",
"-lc",
shell_cmd
]
# ========================================
# x-terminal-emulator
# ========================================
elif (
terminal
== "x-terminal-emulator"
):
args = [
"x-terminal-emulator",
"-e",
"bash",
"-lc",
shell_cmd
]
# ========================================
# KDE Konsole
# ========================================
elif (
terminal
== "konsole"
):
args = [
"konsole"
]
if title:
args += [
"-p",
f"tabtitle={title}"
]
args += [
"-e",
"bash",
"-lc",
shell_cmd
]
# ========================================
# XFCE Terminal
# ========================================
elif (
terminal
== "xfce4-terminal"
):
args = [
"xfce4-terminal"
]
if title:
args += [
"--title",
title
]
args += [
"--command",
(
"bash -lc "
+ shlex.quote(
shell_cmd
)
)
]
else:
return False
subprocess.Popen(
args,
stdout=(
subprocess.DEVNULL
),
stderr=(
subprocess.DEVNULL
),
start_new_session=True
)
return True
except Exception as e:
log(
"打开 Terminal 失败:"
f"{type(e).__name__}: {e}"
)
return False
# ============================================================
# 16. 停止 UFW
# ============================================================
def disable_ufw():
log(
"准备打开新 Terminal "
"执行 UFW 操作"
)
quoted_password = (
shlex.quote(
SUDO_PASSWORD
)
)
command = (
'echo "===================================="; '
'echo "正在停止并禁用 UFW..."; '
'echo "===================================="; '
f"printf '%s\\n' "
f"{quoted_password} "
f"| sudo -S systemctl stop ufw; "
'echo ""; '
'echo "[1/2] '
'systemctl stop ufw 完成"; '
f"printf '%s\\n' "
f"{quoted_password} "
f"| sudo -S systemctl disable ufw; "
'echo ""; '
'echo "[2/2] '
'systemctl disable ufw 完成"; '
'echo ""; '
'echo "当前 UFW 状态:"; '
'systemctl is-active ufw '
'|| true; '
'systemctl is-enabled ufw '
'|| true'
)
ok = (
open_terminal_and_run(
command,
title=(
"ToDesk Recovery "
"- Disable UFW"
),
hold=True
)
)
if ok:
log(
"UFW 操作 Terminal "
"已打开"
)
else:
log(
"UFW Terminal "
"打开失败"
)
# 给 UFW 操作留出时间
time.sleep(8)
return ok
# ============================================================
# 17. 查找 ToDesk
# ============================================================
def find_todesk_command():
path = (
shutil.which(
"todesk"
)
)
if path:
return path
candidates = [
"/usr/bin/todesk",
"/usr/local/bin/todesk",
"/opt/todesk/todesk",
"/opt/ToDesk/todesk",
"/opt/todesk/bin/todesk"
]
for candidate in candidates:
if os.path.isfile(
candidate
):
return candidate
return None
# ============================================================
# 18. 新建第二个 Terminal 启动 ToDesk
# ============================================================
def start_todesk():
"""
为了防止第二个 Terminal 一闪而过:
Python
↓
生成 start_todesk_recovery.sh
↓
gnome-terminal
↓
bash start_todesk_recovery.sh
↓
todesk
↓
exec bash
"""
todesk = (
find_todesk_command()
)
if not todesk:
log(
"没有找到 todesk "
"可执行文件"
)
log(
"请执行:which todesk"
)
return False
log(
f"检测到 ToDesk 路径:"
f"{todesk}"
)
launcher = (
"/home/hy/"
"start_todesk_recovery.sh"
)
launcher_text = f"""#!/bin/bash
echo "========================================"
echo " ToDesk 自动恢复启动终端"
echo "========================================"
echo ""
echo "ToDesk 路径:{todesk}"
echo "当前用户:$(whoami)"
echo "DISPLAY=$DISPLAY"
echo ""
echo "正在启动 ToDesk..."
echo ""
"{todesk}"
RET=$?
echo ""
echo "========================================"
echo "ToDesk 命令返回,退出码:$RET"
echo "========================================"
echo ""
echo "此终端将保持打开,方便查看错误。"
echo "如需关闭,请输入 exit。"
exec bash
"""
# --------------------------------------------
# 生成 launcher
# --------------------------------------------
try:
with open(
launcher,
"w",
encoding="utf-8"
) as f:
f.write(
launcher_text
)
os.chmod(
launcher,
0o755
)
log(
f"已生成 ToDesk 启动脚本:"
f"{launcher}"
)
except Exception as e:
log(
"生成 ToDesk 启动脚本失败:"
f"{type(e).__name__}: {e}"
)
return False
terminal = (
find_terminal()
)
if not terminal:
log(
"没有找到图形 Terminal"
)
return False
log(
f"使用图形终端:"
f"{terminal}"
)
try:
# ========================================
# GNOME
# ========================================
if (
terminal
== "gnome-terminal"
):
args = [
"gnome-terminal",
"--title="
"ToDesk Recovery "
"- Start ToDesk",
"--",
"bash",
launcher
]
# ========================================
# x-terminal-emulator
# ========================================
elif (
terminal
== "x-terminal-emulator"
):
args = [
"x-terminal-emulator",
"-e",
"bash",
launcher
]
# ========================================
# KDE
# ========================================
elif (
terminal
== "konsole"
):
args = [
"konsole",
"-p",
"tabtitle="
"ToDesk Recovery "
"- Start ToDesk",
"-e",
"bash",
launcher
]
# ========================================
# XFCE
# ========================================
elif (
terminal
== "xfce4-terminal"
):
args = [
"xfce4-terminal",
"--title="
"ToDesk Recovery "
"- Start ToDesk",
"--command",
(
"bash "
+ shlex.quote(
launcher
)
)
]
else:
log(
f"不支持 Terminal:"
f"{terminal}"
)
return False
log(
"正在打开第二个 "
"ToDesk Terminal"
)
proc = (
subprocess.Popen(
args,
stdout=(
subprocess.PIPE
),
stderr=(
subprocess.PIPE
),
text=True,
start_new_session=True
)
)
# --------------------------------------------
# 判断 Terminal 是否立即启动失败
# --------------------------------------------
time.sleep(2)
ret = (
proc.poll()
)
if (
ret is not None
and ret != 0
):
stdout, stderr = (
proc.communicate(
timeout=2
)
)
log(
"第二个 Terminal "
f"启动失败,返回码={ret}"
)
if stdout.strip():
log(
"Terminal stdout:"
+ stdout.strip()
)
if stderr.strip():
log(
"Terminal stderr:"
+ stderr.strip()
)
return False
log(
"第二个 ToDesk Terminal "
"启动命令已经提交"
)
return True
except Exception as e:
log(
"启动第二个 ToDesk "
"Terminal 失败:"
f"{type(e).__name__}: {e}"
)
return False
# ============================================================
# 19. ToDesk 完整恢复流程
# ============================================================
def restart_todesk():
log(
"================================"
)
log(
"网络恢复,开始执行 "
"ToDesk 恢复流程"
)
log(
"================================"
)
# ========================================
# 1. Kill ToDesk
# ========================================
log(
"[1/3] "
"Kill ToDesk / ToDesk+"
)
kill_todesk()
# ========================================
# 2. 关闭 UFW
# ========================================
log(
"[2/3] "
"新开 Terminal:"
"停止并禁用 UFW"
)
disable_ufw()
# ========================================
# 3. 启动 ToDesk
# ========================================
log(
"[3/3] "
"新开 Terminal:"
"启动 ToDesk"
)
start_todesk()
log(
"ToDesk 恢复流程执行结束"
)
# ============================================================
# 20. 主监控程序
# ============================================================
def main():
# --------------------------------------------
# 单实例
# --------------------------------------------
_lock_fp = (
acquire_single_instance_lock()
)
log(
"========================================"
)
log(
"校园网 + ToDesk "
"自动恢复监控启动"
)
log(
"========================================"
)
log(
"校园网状态判断方式:"
"Portal 页面 toLogOut"
)
log(
"检测周期:30 分钟"
)
while True:
try:
log(
"========================================"
)
log(
"开始执行本轮 "
"校园网状态检测......"
)
# ========================================
# 检测 Portal
# ========================================
status = (
portal_login_status()
)
# ========================================
# 当前已登录
# ========================================
if status is True:
log(
"本轮判断结果:"
"校园网正常 / 已登录"
)
log(
"无需操作 ToDesk"
)
log(
"下一次检测将在 "
"30 分钟后进行"
)
time.sleep(
CHECK_INTERVAL
)
continue
# ========================================
# Portal 检测异常
# ========================================
if status is None:
log(
"Portal 检测异常,"
"本轮不操作 ToDesk"
)
log(
"30 分钟后重新检测"
)
time.sleep(
CHECK_INTERVAL
)
continue
# ========================================
# 当前未登录
# ========================================
log(
"本轮判断结果:"
"校园网未登录"
)
log(
"开始执行校园网 "
"自动重连"
)
# ========================================
# 自动登录
# ========================================
login_success = (
campus_login()
)
# ========================================
# 登录成功
# ========================================
if login_success:
log(
"校园网自动重连成功"
)
# ------------------------------------
# 登录成功后重新恢复 ToDesk
# ------------------------------------
restart_todesk()
log(
"本轮恢复完成"
)
log(
"下一次检测将在 "
"30 分钟后进行"
)
time.sleep(
CHECK_INTERVAL
)
# ========================================
# 登录失败
# ========================================
else:
log(
"本次校园网 "
"自动重连失败"
)
log(
"30 分钟后重新尝试"
)
time.sleep(
CHECK_INTERVAL
)
# ============================================
# Ctrl+C
# ============================================
except KeyboardInterrupt:
log(
"收到 Ctrl+C,"
"程序退出"
)
break
# ============================================
# 其它异常
# ============================================
except Exception as e:
log(
"主循环异常:"
f"{type(e).__name__}: {e}"
)
log(
"30 分钟后重新检测"
)
time.sleep(
CHECK_INTERVAL
)
# ============================================================
# 21. 程序入口
# ============================================================
if __name__ == "__main__":
main()
20. 使用前必须修改的位置
至少修改下面几项。
① 校园网账号
USERNAME = "xxxxxx"
例如:
USERNAME = "你的校园网账号"
② 校园网密码
PASSWORD = "xxxxxx"
③ sudo 密码
SUDO_PASSWORD = "xxxxxx"
如果代码需要上传 GitHub,强烈建议进一步改成环境变量或者 sudoers
NOPASSWD,不要提交真实 sudo 密码。
④ Chrome 路径
CHROME_BINARY = (
"/home/hy/Downloads/"
"chrome-linux64/chrome"
)
⑤ ChromeDriver 路径
CHROMEDRIVER = (
"/home/hy/Downloads/"
"chromedriver-linux64/chromedriver"
)
⑥ Ubuntu 用户目录
本文机器的用户名为:
hy
因此代码中使用了:
/home/hy/
其他用户需要统一替换为自己的 Ubuntu 用户目录,例如:
/home/ubuntu/
或者:
/home/zhangsan/
21. 运行
首先给 Chrome 和 ChromeDriver 添加权限:
chmod +x ~/Downloads/chrome-linux64/chrome
chmod +x ~/Downloads/chromedriver-linux64/chromedriver
运行:
python auto_login_and_todesk_watchdog.py
程序启动后会看到:
校园网 + ToDesk 自动恢复监控启动
校园网状态判断方式:Portal 页面 toLogOut
检测周期:30 分钟
开始执行本轮校园网状态检测......
网络正常时:
检测到 toLogOut:当前校园网已登录
本轮判断结果:校园网正常 / 已登录
无需操作 ToDesk
下一次检测将在 30 分钟后进行
如果校园网掉线:
未检测到 toLogOut:当前校园网未登录
开始执行校园网自动重连
自动登录成功以后:
校园网自动重连成功
[1/3] Kill ToDesk / ToDesk+
[2/3] 新开 Terminal:停止并禁用 UFW
[3/3] 新开 Terminal:启动 ToDesk
最终实现:
校园网掉线
↓
自动检测
↓
Selenium 自动认证
↓
校园网恢复
↓
自动 Kill 旧 ToDesk
↓
关闭 UFW
↓
重新启动 ToDesk
↓
恢复远程控制
22. Ubuntu 开机自动启动
如果希望 Ubuntu 登录桌面后自动运行,可以创建:
mkdir -p ~/.config/autostart
然后:
nano ~/.config/autostart/upc-todesk-watchdog.desktop
内容:
[Desktop Entry]
Type=Application
Name=UPC ToDesk Watchdog
Comment=Auto reconnect campus network and restart ToDesk
Exec=gnome-terminal --title=UPC-ToDesk-Watchdog -- bash -lc '/home/hy/anaconda3/bin/python /home/hy/net_watchdog/auto_login_and_todesk_watchdog.py; exec bash'
Terminal=false
X-GNOME-Autostart-enabled=true
Hidden=false
NoDisplay=false
注意根据自己的路径修改:
/home/hy/anaconda3/bin/python
以及:
/home/hy/net_watchdog/auto_login_and_todesk_watchdog.py
配置完成以后:
Ubuntu 开机
↓
进入用户桌面
↓
自动弹出 Watchdog Terminal
↓
自动开始校园网监控
↓
每 30 分钟检测一次
23. 代码与参考
校园网 Selenium 自动登录部分参考:
https://github.com/ThreeStones1029/AutoLoginCampusNetwork/tree/main
Chrome for Testing:
Chrome for Testing availability
本文使用的 Chrome Linux 64:
https://storage.googleapis.com/chrome-for-testing-public/153.0.7993.0/linux64/chrome-linux64.zip
本文使用的 ChromeDriver Linux 64:
建议 Chrome 与 ChromeDriver 使用完全相同的版本号。
安全提示:
本文代码为了实现无人值守自动恢复,演示了
sudo -S自动输入密码的方法。个人内网实验机使用比较方便,但如果代码准备公开到 CSDN、GitHub、Gitee 等平台,请务必像本文一样把:
USERNAME = "xxxxxx" PASSWORD = "xxxxxx" SUDO_PASSWORD = "xxxxxx"全部脱敏,切勿提交任何真实密码。
参考
校园网自动登录参考项目:
GitHub - ThreeStones1029/AutoLoginCampusNetwork: 自动连接河海大学校园网 · GitHub
Chrome for Testing 官方下载:
Chrome for Testing availability
本文使用的 Linux Chrome:
https://storage.googleapis.com/chrome-for-testing-public/153.0.7993.0/linux64/chrome-linux64.zip
本文使用的 Linux ChromeDriver:
Chrome for Testing 官方页面提供不同版本和平台的 Chrome、ChromeDriver 对应下载信息。