运维:固定虚拟PCI设备并补充过夜观察与电源策略

This commit is contained in:
宋一鸣 2026-11-05 19:15:31 +08:00
parent 6f2ff67300
commit 65f92df8b7
42 changed files with 1705 additions and 0 deletions

View File

@ -0,0 +1,37 @@
status: running
agent: 1
args: -set device.hostpci0.x-pci-sub-vendor-id=0x1558 -set device.hostpci0.x-pci-sub-device-id=0x6557 -global ICH9-LPC.acpi-pci-hotplug-with-bridge-support=off -global pci-bridge.shpc=off -global pcie-root-port.hotplug=off
balloon: 0
bios: seabios
boot: order=sata0;ide2
cores: 4
cpu: host,hidden=1
hostpci0: 0000:01:00.0,pcie=1,rombar=1,romfile=gtx965m-clevo-readback-20261103.rom
ide0: none,media=cdrom
ide2: none,media=cdrom
machine: pc-q35-11.0
memory: 8192
meta: creation-qemu=11.0.0,ctime=1792722917
name: win10-test
net0: virtio=BC:24:11:B4:01:43,bridge=vmbr0,firewall=1
numa: 0
onboot: 1
ostype: win10
sata0: local-lvm:base-100-disk-0,size=60G
scsihw: virtio-scsi-single
smbios1: uuid=52fd3777-0bb4-40a7-8136-b02c40947a11
sockets: 1
template: 0
vmgenid: a1963978-050f-4b3c-8c9a-7a4d13d72f43
{
"vm": 100,
"device_present": false,
"channels": [],
"usb": " Device 0.1, Port 1, Speed 480 Mb/s, Product QEMU USB Tablet, ID: tablet\r\n",
"vm_status": {
"status": "running",
"running": true
}
}
EXIT 0

View File

@ -0,0 +1,3 @@
{"vm": 100, "action": "apply", "args": "-set device.hostpci0.x-pci-sub-vendor-id=0x1558 -set device.hostpci0.x-pci-sub-device-id=0x6557 -global ICH9-LPC.acpi-pci-hotplug-with-bridge-support=off -global pci-bridge.shpc=off -global pcie-root-port.hotplug=off", "next_step": "cold start and verify Windows"}
EXIT 0

View File

@ -0,0 +1,2 @@
EXIT 0

View File

@ -0,0 +1,11 @@
QEMU_VERSION {'qemu': {'micro': 3, 'minor': 0, 'major': 11}, 'package': 'pve-qemu-kvm_11.0.3-4'}
PCIE_PORT_HOTPLUG_OFF: 4/4
PCI_BRIDGE_SHPC_OFF: 4/4
ACPI_PCI_HOTPLUG_OFF
USB_REDIR_ADD_OK
USB_REDIR_REMOVE_OK
ISOLATED_SMOKE_PASS (Windows UI and physical USB not tested)
QEMU_STDERR: kvm: warning: nic diagnet has no peer
EXIT 0

View File

@ -0,0 +1,35 @@
{
"vm": 100,
"created": "2026-11-05T09:24:07.989788+08:00",
"original_args": "-set device.hostpci0.x-pci-sub-vendor-id=0x1558 -set device.hostpci0.x-pci-sub-device-id=0x6557",
"proposed_args": "-set device.hostpci0.x-pci-sub-vendor-id=0x1558 -set device.hostpci0.x-pci-sub-device-id=0x6557 -global ICH9-LPC.acpi-pci-hotplug-with-bridge-support=off -global pci-bridge.shpc=off -global pcie-root-port.hotplug=off",
"original_config": {
"agent": "1",
"args": "-set device.hostpci0.x-pci-sub-vendor-id=0x1558 -set device.hostpci0.x-pci-sub-device-id=0x6557",
"balloon": "0",
"bios": "seabios",
"boot": "order=sata0;ide2",
"cores": "4",
"cpu": "host,hidden=1",
"hostpci0": "0000:01:00.0,pcie=1,rombar=1,romfile=gtx965m-clevo-readback-20261103.rom",
"ide0": "none,media=cdrom",
"ide2": "none,media=cdrom",
"machine": "pc-q35-11.0",
"memory": "8192",
"meta": "creation-qemu=11.0.0,ctime=1792722917",
"name": "win10-test",
"net0": "virtio=BC:24:11:B4:01:43,bridge=vmbr0,firewall=1",
"numa": "0",
"onboot": "1",
"ostype": "win10",
"sata0": "local-lvm:base-100-disk-0,size=60G",
"scsihw": "virtio-scsi-single",
"smbios1": "uuid=52fd3777-0bb4-40a7-8136-b02c40947a11",
"sockets": "1",
"template": "0",
"vmgenid": "a1963978-050f-4b3c-8c9a-7a4d13d72f43"
},
"status": "prepared; not applied"
}
EXIT 0

View File

@ -0,0 +1,13 @@
status: running
{
"vm": 100,
"device_present": false,
"channels": [],
"usb": " Device 0.1, Port 1, Speed 480 Mb/s, Product QEMU USB Tablet, ID: tablet\r\n",
"vm_status": {
"status": "running",
"running": true
}
}
EXIT 0

View File

@ -0,0 +1,8 @@
SHUTDOWN_REQUEST 0 {
"exitcode" : 0,
"exited" : 1
}
VM_STOPPED_NORMALLY
EXIT 0

View File

@ -0,0 +1,62 @@
{
"time": "2026-11-05T09:29:55.1484833+08:00",
"user": "DESKTOP-Q072ICM\\test",
"tray": [
{
"class": "Shell_TrayWnd",
"name": "",
"items": [
{
"name": "开始",
"type": "ControlType.Pane",
"class": "Start"
},
{
"name": "搜索突出显示图标打开搜索主页窗口",
"type": "ControlType.Pane",
"class": "DynamicContent1"
},
{
"name": "在此键入进行搜索",
"type": "ControlType.Pane",
"class": "Button"
},
{
"name": "任务视图",
"type": "ControlType.Pane",
"class": "TrayButton"
},
{
"name": "运行中的应用程序",
"type": "ControlType.Pane",
"class": "MSTaskSwWClass"
},
{
"name": "运行中的应用程序",
"type": "ControlType.Pane",
"class": "MSTaskListWClass"
},
{
"name": "用户提示通知区域",
"type": "ControlType.Pane",
"class": "ToolbarWindow32"
},
{
"name": "任务栏输入指示",
"type": "ControlType.Pane",
"class": "TrayInputIndicatorWClass"
},
{
"name": "系统时钟, 9:29, ‎2026/‎11/‎5",
"type": "ControlType.Pane",
"class": "TrayClockWClass"
},
{
"name": "操作中心,3 个新通知",
"type": "ControlType.Pane",
"class": "TrayButton"
}
]
}
]
}

View File

@ -0,0 +1,64 @@
{
"time": "2026-11-05T09:27:06.7691671+08:00",
"processes": [
"ApplicationFrameHost",
"ChsIME",
"conhost",
"csrss",
"ctfmon",
"DefenderSessionHelper",
"dllhost",
"dwm",
"explorer",
"FileCoAuth",
"fontdrvhost",
"M365Copilot",
"msedge",
"msedgewebview2",
"NVDisplay.Container",
"OneDrive",
"PhoneExperienceHost",
"powershell",
"RuntimeBroker",
"SearchApp",
"SecurityHealthSystray",
"ShellExperienceHost",
"sihost",
"StartMenuExperienceHost",
"sunshine",
"svchost",
"SystemSettings",
"TabTip",
"taskhostw",
"TextInputHost",
"UserOOBEBroker",
"winlogon"
],
"user": "DESKTOP-Q072ICM\\test",
"windows": [
{
"Id": 6208,
"ProcessName": "ApplicationFrameHost",
"SessionId": 1,
"MainWindowTitle": "设置"
},
{
"Id": 6256,
"ProcessName": "explorer",
"SessionId": 1,
"MainWindowTitle": ""
},
{
"Id": 8684,
"ProcessName": "SystemSettings",
"SessionId": 1,
"MainWindowTitle": "设置"
},
{
"Id": 4652,
"ProcessName": "TextInputHost",
"SessionId": 1,
"MainWindowTitle": "Microsoft Text Input Application"
}
]
}

View File

@ -0,0 +1,107 @@
{
"boot": "2026-11-05T09:28:17.5000000+08:00",
"devices": [
{
"name": "Red Hat VirtIO Ethernet Adapter",
"status": "OK",
"instance": "PCI\\VEN_1AF4\u0026DEV_1000\u0026SUBSYS_00011AF4\u0026REV_00\\5\u0026226B9205\u00260\u00269008F0",
"capabilities": 0
},
{
"name": "Standard SATA AHCI Controller",
"status": "OK",
"instance": "PCI\\VEN_8086\u0026DEV_2922\u0026SUBSYS_11001AF4\u0026REV_02\\3\u002611583659\u00261\u0026FA",
"capabilities": 0
},
{
"name": "NVIDIA GeForce GTX 965M",
"status": "OK",
"instance": "PCI\\VEN_10DE\u0026DEV_13D9\u0026SUBSYS_65571558\u0026REV_A1\\4\u00263335B6E8\u00260\u002600E0",
"capabilities": 0
},
{
"name": "VirtIO Serial Driver",
"status": "OK",
"instance": "PCI\\VEN_1AF4\u0026DEV_1003\u0026SUBSYS_00031AF4\u0026REV_00\\5\u0026226B9205\u00260\u00264008F0",
"capabilities": 0
},
{
"name": "Standard SATA AHCI Controller",
"status": "OK",
"instance": "PCI\\VEN_8086\u0026DEV_2922\u0026SUBSYS_11001AF4\u0026REV_02\\5\u0026226B9205\u00260\u00263808F0",
"capabilities": 0
}
],
"ip": [
{
"InterfaceAlias": "以太网",
"IPAddress": "192.168.1.70"
},
{
"InterfaceAlias": "Loopback Pseudo-Interface 1",
"IPAddress": "127.0.0.1"
}
],
"disk": [
{
"Number": 0,
"FriendlyName": "QEMU HARDDISK",
"OperationalStatus": "Online",
"HealthStatus": "Healthy",
"IsBoot": true,
"BusType": "SATA"
}
],
"ejectOrRemovable": [
{
"InstanceId": "SWD\\PRINTENUM\\{E254A0CC-C950-4956-A2C8-6CBC391E11FE}",
"Data": 244
},
{
"InstanceId": "{6FDE7547-1B65-48AE-B628-80BE62016026}\\VIOSERIALPORT\\6\u0026201398F9\u00260\u002601",
"Data": 230
},
{
"InstanceId": "DISPLAY\\MTT1337\\1\u002623323A80\u00260\u0026UID256",
"Data": 228
},
{
"InstanceId": "DISPLAY\\DEFAULT_MONITOR\\4\u002618756DEA\u00260\u0026UID0",
"Data": 228
},
{
"InstanceId": "SWD\\PRINTENUM\\{FEA805B9-BFF7-40E5-A50E-37CBA79B87D2}",
"Data": 244
},
{
"InstanceId": "USB\\VID_0627\u0026PID_0001\\28754-0000:00:1D.7-1",
"Data": 148
},
{
"InstanceId": "SWD\\PRINTENUM\\{FAA68C95-DAED-418D-BBBF-DE2DAD0E10B9}",
"Data": 244
},
{
"InstanceId": "SWD\\PRINTENUM\\{5B90D326-0123-4713-9B92-00DC0665DA58}",
"Data": 244
},
{
"InstanceId": "SWD\\PRINTENUM\\{FD12D2CE-A457-4909-9B7D-3CFB018A7323}",
"Data": 244
}
],
"time": "2026-11-05T09:29:02.2662944+08:00",
"sunshine": [
{
"Id": 5764,
"SessionId": 1
}
],
"gpu": [
{
"Name": "NVIDIA GeForce GTX 965M",
"ConfigManagerErrorCode": 0
}
],
"user": "DESKTOP-Q072ICM\\test"
}

View File

@ -0,0 +1 @@
{"vm": 101, "exitcode": 0, "out-data": "{\"action\": \"apply\", \"user\": \"test1\", \"backend\": \"pkla\", \"path\": \"/etc/polkit-1/localauthority/90-mandatory.d/99-vdi-power.pkla\", \"backup\": \"/var/lib/vdi/power-policy\", \"policy_matches\": true, \"dbus_policy_matches\": true, \"dbus_denials\": {\"PowerOff\": {\"code\": 1, \"error\": \"\\u001b[0;1;31mCall failed: Access denied\\u001b[0m\"}, \"Reboot\": {\"code\": 1, \"error\": \"\\u001b[0;1;31mCall failed: Access denied\\u001b[0m\"}, \"Halt\": {\"code\": 1, \"error\": \"\\u001b[0;1;31mCall failed: Access denied\\u001b[0m\"}, \"Suspend\": {\"code\": 1, \"error\": \"\\u001b[0;1;31mCall failed: Access denied\\u001b[0m\"}, \"Hibernate\": {\"code\": 1, \"error\": \"\\u001b[0;1;31mCall failed: Access denied\\u001b[0m\"}, \"HybridSleep\": {\"code\": 1, \"error\": \"\\u001b[0;1;31mCall failed: Access denied\\u001b[0m\"}, \"SuspendThenHibernate\": {\"code\": 1, \"error\": \"\\u001b[0;1;31mCall failed: Access denied\\u001b[0m\"}, \"ScheduleShutdown\": {\"code\": 1, \"error\": \"\\u001b[0;1;31mCall failed: Access denied\\u001b[0m\"}}, \"boot_id\": \"6c83a4b3-eae9-45eb-94f2-43bb372c44a2\", \"capabilities\": {\"CanPowerOff\": {\"code\": 1, \"value\": \"\", \"error\": \"\\u001b[0;1;31mCall failed: Access denied\\u001b[0m\"}, \"CanReboot\": {\"code\": 1, \"value\": \"\", \"error\": \"\\u001b[0;1;31mCall failed: Access denied\\u001b[0m\"}, \"CanHalt\": {\"code\": 1, \"value\": \"\", \"error\": \"\\u001b[0;1;31mCall failed: Access denied\\u001b[0m\"}, \"CanSuspend\": {\"code\": 1, \"value\": \"\", \"error\": \"\\u001b[0;1;31mCall failed: Access denied\\u001b[0m\"}, \"CanHibernate\": {\"code\": 1, \"value\": \"\", \"error\": \"\\u001b[0;1;31mCall failed: Access denied\\u001b[0m\"}, \"CanHybridSleep\": {\"code\": 1, \"value\": \"\", \"error\": \"\\u001b[0;1;31mCall failed: Access denied\\u001b[0m\"}, \"CanSuspendThenHibernate\": {\"code\": 1, \"value\": \"\", \"error\": \"\\u001b[0;1;31mCall failed: Access denied\\u001b[0m\"}}}\n", "exited": 1}

View File

@ -0,0 +1 @@
{"vm": 101, "exited": 1, "out-data": "{\"action\": \"query\", \"user\": \"test1\", \"backend\": \"pkla\", \"path\": \"/etc/polkit-1/localauthority/90-mandatory.d/99-vdi-power.pkla\", \"backup\": \"/var/lib/vdi/power-policy\", \"policy_matches\": true, \"dbus_policy_matches\": true, \"dbus_denials\": {}, \"boot_id\": \"6c83a4b3-eae9-45eb-94f2-43bb372c44a2\", \"capabilities\": {\"CanPowerOff\": {\"code\": 1, \"value\": \"\", \"error\": \"\\u001b[0;1;31mCall failed: Access denied\\u001b[0m\"}, \"CanReboot\": {\"code\": 1, \"value\": \"\", \"error\": \"\\u001b[0;1;31mCall failed: Access denied\\u001b[0m\"}, \"CanHalt\": {\"code\": 1, \"value\": \"\", \"error\": \"\\u001b[0;1;31mCall failed: Access denied\\u001b[0m\"}, \"CanSuspend\": {\"code\": 1, \"value\": \"\", \"error\": \"\\u001b[0;1;31mCall failed: Access denied\\u001b[0m\"}, \"CanHibernate\": {\"code\": 1, \"value\": \"\", \"error\": \"\\u001b[0;1;31mCall failed: Access denied\\u001b[0m\"}, \"CanHybridSleep\": {\"code\": 1, \"value\": \"\", \"error\": \"\\u001b[0;1;31mCall failed: Access denied\\u001b[0m\"}, \"CanSuspendThenHibernate\": {\"code\": 1, \"value\": \"\", \"error\": \"\\u001b[0;1;31mCall failed: Access denied\\u001b[0m\"}}}\n", "exitcode": 0}

View File

@ -0,0 +1 @@
{"vm": 102, "exitcode": 0, "out-data": "{\"action\": \"apply\", \"user\": \"test2\", \"backend\": \"rules\", \"path\": \"/etc/polkit-1/rules.d/00-vdi-power.rules\", \"backup\": \"/var/lib/vdi/power-policy\", \"policy_matches\": true, \"dbus_policy_matches\": true, \"dbus_denials\": {\"PowerOff\": {\"code\": 1, \"error\": \"Call failed: Access denied\"}, \"Reboot\": {\"code\": 1, \"error\": \"Call failed: Access denied\"}, \"Halt\": {\"code\": 1, \"error\": \"Call failed: Access denied\"}, \"Suspend\": {\"code\": 1, \"error\": \"Call failed: Access denied\"}, \"Hibernate\": {\"code\": 1, \"error\": \"Call failed: Access denied\"}, \"HybridSleep\": {\"code\": 1, \"error\": \"Call failed: Access denied\"}, \"SuspendThenHibernate\": {\"code\": 1, \"error\": \"Call failed: Access denied\"}, \"ScheduleShutdown\": {\"code\": 1, \"error\": \"Call failed: Access denied\"}}, \"boot_id\": \"b82ac3c5-f2ee-454f-971b-d3e2a64d990b\", \"capabilities\": {\"CanPowerOff\": {\"code\": 1, \"value\": \"\", \"error\": \"Call failed: Access denied\"}, \"CanReboot\": {\"code\": 1, \"value\": \"\", \"error\": \"Call failed: Access denied\"}, \"CanHalt\": {\"code\": 1, \"value\": \"\", \"error\": \"Call failed: Access denied\"}, \"CanSuspend\": {\"code\": 1, \"value\": \"\", \"error\": \"Call failed: Access denied\"}, \"CanHibernate\": {\"code\": 1, \"value\": \"\", \"error\": \"Call failed: Access denied\"}, \"CanHybridSleep\": {\"code\": 1, \"value\": \"\", \"error\": \"Call failed: Access denied\"}, \"CanSuspendThenHibernate\": {\"code\": 1, \"value\": \"\", \"error\": \"Call failed: Access denied\"}}}\n", "exited": 1}

View File

@ -0,0 +1 @@
{"vm": 102, "exited": 1, "out-data": "{\"action\": \"query\", \"user\": \"test2\", \"backend\": \"rules\", \"path\": \"/etc/polkit-1/rules.d/00-vdi-power.rules\", \"backup\": \"/var/lib/vdi/power-policy\", \"policy_matches\": true, \"dbus_policy_matches\": true, \"dbus_denials\": {}, \"boot_id\": \"b82ac3c5-f2ee-454f-971b-d3e2a64d990b\", \"capabilities\": {\"CanPowerOff\": {\"code\": 1, \"value\": \"\", \"error\": \"Call failed: Access denied\"}, \"CanReboot\": {\"code\": 1, \"value\": \"\", \"error\": \"Call failed: Access denied\"}, \"CanHalt\": {\"code\": 1, \"value\": \"\", \"error\": \"Call failed: Access denied\"}, \"CanSuspend\": {\"code\": 1, \"value\": \"\", \"error\": \"Call failed: Access denied\"}, \"CanHibernate\": {\"code\": 1, \"value\": \"\", \"error\": \"Call failed: Access denied\"}, \"CanHybridSleep\": {\"code\": 1, \"value\": \"\", \"error\": \"Call failed: Access denied\"}, \"CanSuspendThenHibernate\": {\"code\": 1, \"value\": \"\", \"error\": \"Call failed: Access denied\"}}}\n", "exitcode": 0}

View File

@ -0,0 +1,17 @@
[
{
"vm": 100,
"status": "running",
"uptime": 1736
},
{
"vm": 101,
"status": "running",
"uptime": 27815
},
{
"vm": 102,
"status": "running",
"uptime": 112734
}
]

View File

@ -0,0 +1,5 @@
{
"exited": 1,
"out-data": "{\"user\": \"test1\", \"graphical_subject\": \"1257,635,1000\", \"polkit_active_session\": {\"power-off\": {\"exitcode\": 1, \"error\": \"Not authorized.\"}, \"reboot\": {\"exitcode\": 0, \"error\": \"\"}, \"suspend\": {\"exitcode\": 1, \"error\": \"Not authorized.\"}, \"hibernate\": {\"exitcode\": 1, \"error\": \"Not authorized.\"}}, \"dbus_invalid_signature_denial_probes\": {\"PowerOff\": {\"exitcode\": 1, \"error\": \"\\u001b[0;1;31mCall failed: Access denied\\u001b[0m\"}, \"Reboot\": {\"exitcode\": 1, \"error\": \"\\u001b[0;1;31mCall failed: Access denied\\u001b[0m\"}}, \"sunshine\": \"active\", \"boot_id\": \"6c83a4b3-eae9-45eb-94f2-43bb372c44a2\", \"root_can_poweroff\": \"s \\\"yes\\\"\"}\n",
"exitcode": 0
}

View File

@ -0,0 +1,5 @@
{
"exitcode": 0,
"out-data": "{\"user\": \"test2\", \"graphical_subject\": \"7174,7356579,1000\", \"polkit_active_session\": {\"power-off\": {\"exitcode\": 1, \"error\": \"Not authorized.\"}, \"reboot\": {\"exitcode\": 0, \"error\": \"\"}, \"suspend\": {\"exitcode\": 1, \"error\": \"Not authorized.\"}, \"hibernate\": {\"exitcode\": 1, \"error\": \"Not authorized.\"}}, \"dbus_invalid_signature_denial_probes\": {\"PowerOff\": {\"exitcode\": 1, \"error\": \"Call failed: Access denied\"}, \"Reboot\": {\"exitcode\": 1, \"error\": \"Call failed: Access denied\"}}, \"sunshine\": \"active\", \"boot_id\": \"b82ac3c5-f2ee-454f-971b-d3e2a64d990b\", \"root_can_poweroff\": \"s \\\"yes\\\"\"}\n",
"exited": 1
}

View File

@ -0,0 +1 @@
{"vm": 100, "exitcode": 0, "out-truncated": 0, "exited": 1, "out-data": "{\"Action\":\"Apply\",\"User\":\"DESKTOP-Q072ICM\\\\test\",\"Sid\":\"S-1-5-21-911627089-847994065-1864506460-1003\",\"NoClose\":1,\"Backup\":\"C:\\\\ProgramData\\\\VDI\\\\power-policy\\\\original.clixml\",\"EffectiveShellNoClose\":1,\"LastBoot\":\"2026-11-04T20:31:16.5000000+08:00\",\"Scope\":\"Desktop power menus only; privileged administration remains available\"}\r\n"}

View File

@ -0,0 +1 @@
{"vm": 100, "exitcode": 0, "exited": 1, "out-truncated": 0, "out-data": "{\"Action\":\"Query\",\"User\":\"DESKTOP-Q072ICM\\\\test\",\"Sid\":\"S-1-5-21-911627089-847994065-1864506460-1003\",\"NoClose\":1,\"Backup\":\"C:\\\\ProgramData\\\\VDI\\\\power-policy\\\\original.clixml\",\"EffectiveShellNoClose\":1,\"LastBoot\":\"2026-11-04T20:31:16.5000000+08:00\",\"Scope\":\"Desktop power menus only; privileged administration remains available\"}\r\n"}

View File

@ -0,0 +1,9 @@
盒子过夜观察汇总
记录启用时间:2026-11-04T19:35:05+08:00
最近记录时间:2026-11-04T19:40:14+08:00
记录启用后重启次数:0
当前启动编号:afb6a02f-ba7c-478a-8704-c78a993d23c6
当前开机时长(秒):38110.05
最近一次重启判断:观察期间尚未发现重启
详细数据见同目录 overnight-health-YYYYMMDD.log;每分钟更新。
服务重启不计为盒子重启;无故障证据时不推断具体断电原因。

View File

@ -0,0 +1,15 @@
backup=/home/armbian/vdi-terminal/backups/overnight-observer-20261104-193449
active
enabled
盒子过夜观察汇总
记录启用时间:2026-11-04T19:35:05+08:00
最近记录时间:2026-11-04T19:35:05+08:00
记录启用后重启次数:0
当前启动编号:afb6a02f-ba7c-478a-8704-c78a993d23c6
当前开机时长(秒):37801.74
最近一次重启判断:观察期间尚未发现重启
详细数据见同目录 overnight-health-YYYYMMDD.log;每分钟更新。
服务重启不计为盒子重启;无故障证据时不推断具体断电原因。
11月 04 19:35:06 rk3528pro-ddr4 sshd[17776]: error: connect_to 127.0.0.1 port 40001: failed.
11月 04 19:35:07 rk3528pro-ddr4 sudo[147206]: armbian : PWD=/home/armbian ; USER=root ; COMMAND=/usr/bin/journalctl --directory /var/log/journal -n 3 --no-pager
11月 04 19:35:07 rk3528pro-ddr4 sudo[147206]: pam_unix(sudo:session): session opened for user root(uid=0) by armbian(uid=1000)

View File

@ -0,0 +1,15 @@
backup=/home/armbian/vdi-terminal/backups/overnight-observer-20261104-193726
active
enabled
盒子过夜观察汇总
记录启用时间:2026-11-04T19:35:05+08:00
最近记录时间:2026-11-04T19:37:45+08:00
记录启用后重启次数:0
当前启动编号:afb6a02f-ba7c-478a-8704-c78a993d23c6
当前开机时长(秒):37961.73
最近一次重启判断:观察期间尚未发现重启
详细数据见同目录 overnight-health-YYYYMMDD.log;每分钟更新。
服务重启不计为盒子重启;无故障证据时不推断具体断电原因。
11月 04 19:37:46 rk3528pro-ddr4 sshd[17776]: error: connect_to 127.0.0.1 port 40001: failed.
11月 04 19:37:47 rk3528pro-ddr4 sudo[149828]: armbian : PWD=/home/armbian ; USER=root ; COMMAND=/usr/bin/journalctl --directory /var/log/journal -n 3 --no-pager
11月 04 19:37:47 rk3528pro-ddr4 sudo[149828]: pam_unix(sudo:session): session opened for user root(uid=0) by armbian(uid=1000)

View File

@ -0,0 +1 @@
backup=/home/armbian/vdi-terminal/backups/overnight-observer-20261104-193329

View File

@ -0,0 +1,24 @@
NRestarts=0
MemoryCurrent=8458240
CPUUsageNSec=1036254000
ActiveState=active
SubState=running
UnitFileState=enabled
盒子过夜观察汇总
记录启用时间:2026-11-04T19:35:05+08:00
最近记录时间:2026-11-04T19:40:14+08:00
记录启用后重启次数:0
当前启动编号:afb6a02f-ba7c-478a-8704-c78a993d23c6
当前开机时长(秒):38110.05
最近一次重启判断:观察期间尚未发现重启
详细数据见同目录 overnight-health-YYYYMMDD.log;每分钟更新。
服务重启不计为盒子重启;无故障证据时不推断具体断电原因。
{"time": "2026-11-04T19:40:14+08:00", "event": "boot_evidence", "kernel": "6.1.115-vendor-rk35xx", "journal": {"code": 0, "text": "IDX BOOT ID FIRST ENTRY LAST ENTRY\n 0 afb6a02fba7c478a8704c78a993d23c6 Wed 2026-11-04 16:23:46 CST Wed 2026-11-04 19:40:13 CST\n", "error": ""}, "pstore": {"captured_at": "2026-11-04T19:40:13.613122+08:00", "boot_id": "afb6a02f-ba7c-478a-8704-c78a993d23c6", "sources": {"/sys/fs/pstore": {}, "/var/lib/systemd/pstore": {"console-ramoops-0": {"mtime": 1751465072.0266666, "text": "[ 2674.563324] reboot: Restarting system\n", "truncated": false}}}}, "watchdog_bootstatus": {}}
{"time": "2026-11-04T19:40:14+08:00", "event": "sample", "boot_id": "afb6a02f-ba7c-478a-8704-c78a993d23c6", "reboots": 0, "uptime_seconds": 38110.05, "load": ["1.52", "1.34", "1.21"], "memory": {"MemTotal": "4014636 kB", "MemAvailable": "3039748 kB", "SwapFree": "2007316 kB"}, "temperatures_c": {"thermal_zone0": 62.704}, "processes": [{"pid": 2162, "name": "vdi-kiosk-monitor"}, {"pid": 92843, "name": "kiosk-shell"}, {"pid": 137139, "name": "moonlight", "host": "192.168.1.70", "resolution": "2560x1440", "fps": "45"}], "display": {"code": 0, "text": "Screen 0: minimum 320 x 200, current 2560 x 1440, maximum 16384 x 16384\nHDMI-1 disconnected primary 2560x1440+0+0 (normal left inverted right x axis y axis) 0mm x 0mm", "error": ""}, "network": {"wlan0": {"operstate": "up", "rx_errors": "0", "tx_errors": "0"}, "end0": {"operstate": "down", "rx_errors": "0", "tx_errors": "0"}}, "root_available_mb": 22944}
137139 /home/armbian/vdi-build/rkmpp-experiment/build/moonlight/app/moonlight stream 192.168.1.70 Desktop --resolution 2560x1440 --fps 45 --bitrate 30000 --display-mode fullscreen --video-codec H.264 --video-decoder hardware --absolute-mouse --keep-awake --no-vsync --no-frame-pacing --audio-on-host
active
root:root 755 /usr/local/libexec/vdi-observer-pstore
root:systemd-journal 640 /var/lib/vdi-observer/pstore.json
armbian:armbian 600 /home/armbian/vdi-terminal/state/overnight-state.json
TARGET SOURCE FSTYPE OPTIONS
/var/log.hdd /dev/mmcblk1p2[/var/log] ext4 rw,relatime,errors=remount-ro,commit=120

View File

@ -0,0 +1,16 @@
test_baseline_zero_reboots (test_overnight_observer.RebootEvidence.test_baseline_zero_reboots) ... ok
test_boot_change_preserves_last_heartbeat (test_overnight_observer.RebootEvidence.test_boot_change_preserves_last_heartbeat) ... ok
test_daily_rollover_uses_each_event_date (test_overnight_observer.RebootEvidence.test_daily_rollover_uses_each_event_date) ... ok
test_durable_state_and_chinese_report (test_overnight_observer.RebootEvidence.test_durable_state_and_chinese_report) ... ok
test_invalid_state_preserved (test_overnight_observer.RebootEvidence.test_invalid_state_preserved) ... ok
test_multiple_boots_and_history_bound (test_overnight_observer.RebootEvidence.test_multiple_boots_and_history_bound) ... ok
test_only_changed_pstore_is_a_new_clue (test_overnight_observer.RebootEvidence.test_only_changed_pstore_is_a_new_clue) ... ok
test_oom_is_a_clue_not_a_proven_reboot_cause (test_overnight_observer.RebootEvidence.test_oom_is_a_clue_not_a_proven_reboot_cause) ... ok
test_orderly_shutdown_requires_real_markers (test_overnight_observer.RebootEvidence.test_orderly_shutdown_requires_real_markers) ... ok
test_service_restart_is_not_box_reboot (test_overnight_observer.RebootEvidence.test_service_restart_is_not_box_reboot) ... ok
test_unknown_reason_is_not_reported_as_power_loss (test_overnight_observer.RebootEvidence.test_unknown_reason_is_not_reported_as_power_loss) ... ok
----------------------------------------------------------------------
Ran 11 tests in 0.053s
OK

View File

@ -0,0 +1,89 @@
# Windows 安全删除菜单仅保留真实 USB 存储(2026-11-05)
## 目标和现场证据
客户日常使用时,内置 GPU、网卡、系统盘控制器不应出现在安全删除菜单中;真实 U 盘
继续按插入、识别、安全弹出、物理拔出处理。这不是隐藏整个安全删除图标,也不改设备名称。
用户照片列出了 GTX 965M、VirtIO Ethernet、VirtIO Serial、标准 SATA AHCI 控制器。
Windows 实际查询四项的 DEVPKEY_Device_Capabilities 均为 6,即包含 EjectSupported (2)
和 Removable (4)。另一个内置 AHCI 控制器为 0,不能通过名称统一修改所有同名设备。
系统盘 QEMU HARDDISK 为 60GiB SATA 启动盘、Fixed hard disk media。
PVE USB 查询 device_present=false、channels=[]、仅有 USB Tablet。这说明移除空的
usb-redir 端点并不能消除菜单里的 PCI 设备。此前将托盘图标主要归因于 usb-redir 的判断
不完整;USB 动态端点功能与固定 PCI 设备属性需要分别验收。
## 准备的配置
保留原 GPU 子厂商参数、设备地址、磁盘总线和驱动,只为当前 Q35 VM 增加:
```text
-global ICH9-LPC.acpi-pci-hotplug-with-bridge-support=off
-global pci-bridge.shpc=off
-global pcie-root-port.hotplug=off
```
分别关闭 ACPI PCI、传统 PCI bridge SHPC、PCIe root port 热插拔。
关闭 ACPI 之后传统桥仍可能通过 SHPC 报告可移除,不能只关其中一项。
USB 控制器仍在,USB 总线设备的添加/删除独立于 PCI 热插拔。
取舍:以后增删这些内部 PCI 设备需要关机操作,不再支持对应 PCI 在线热插拔。
微软说明能力属性由设备驱动返回,为只读属性;DeviceOverrides 只改变 container ID
分组时对 Removable 的解释,不改变设备全局能力,不能作为可靠的托盘修复依据:
- https://learn.microsoft.com/en-us/windows-hardware/drivers/install/devpkey-device-capabilities
- https://learn.microsoft.com/en-us/windows-hardware/drivers/install/deviceoverrides-registry-key
- https://github.com/qemu/qemu/blob/master/hw/pci-bridge/pci_bridge_dev.c
## 部署状态(已应用并冷启动)
09:24 已在 PVE `/root/vdi-fixed-pci-20261105/` 保存工具;
`backup/vm.conf.before` 和 `backup/plan.json` 保存原配置和拟应用参数。
用户随后明确授权“你自己保存然后重启”。通过当前 test 交互会话的临时诊断任务
检查可见窗口,只有“设置”、Explorer 和输入组件;没有发现打开的文档或编辑器。
使用 Windows `shutdown.exe /s /t 0`(没有 `/f`)正常关机,确认 stopped 后应用配置。
09:28:17 Windows 冷启动,09:29 已通过 Guest Agent 完成检查。
本次没有停止或重启 USB 隧道,没有强制停机,临时诊断任务均已删除。
工具 `tools/set-fixed-pci.py` 的 apply/restore 都要求 VM 已停止。
有待应用配置、QEMU args 冲突、备份目标不符、原配置发生变化会拒绝操作。
不发送关机/启动指令,不替换整个 VM 配置。应用示例(PVE root):
```bash
python3 /root/vdi-fixed-pci-20261105/set-fixed-pci.py apply --vm 100 \
--backup /root/vdi-fixed-pci-20261105/backup
```
回退(同样先正常关机):把 apply 改为 restore,再冷启动。
不是在 Windows 中点“重新启动”即可生效,必须让 QEMU 进程退出后重新建立硬件模型。
## 已完成验证
- 4 项单元测试:保留 GPU/带空格参数、重复执行不追加、冲突拒绝、运行 VM 拒绝修改。
- PVE QEMU 11.0.3 隔离进程,TCG/暂停,无磁盘、无网络后端、无 GPU 直通;
使用与现场一致的 Q35 machine 和 PVE readconfig。
- 4 个 root port hotplug=false、4 个 PCI bridge shpc=false、ICH9 LPC ACPI PCI=false。
- 同一隔离进程中 usb-redir 动态添加和删除通过;独立测试进程已经正常退出。
- 隔离测试期间 Windows VM 100 持续 running,未插盘时 usb-redir 仍 absent。
冷启动后的实际 Windows 结果:
- 原四项 PCI 设备 Capabilities 从 6 变为 0;同名的另一个 AHCI 控制器仍为 0。
四项的 InstanceId 未改变,所有这些设备均 Status=OK。
- GTX965M ConfigManagerErrorCode=0,系统 SATA 启动盘 Online/Healthy,
网卡仍为 192.168.1.70,Sunshine 在当前 test 会话运行,Guest Agent 通信正常。
- 盒子 USB 服务 active,现场仅有键盘和鼠标,没有物理 U 盘。盒子 Moonlight 会话
随 Windows 正常关机退出,用户需从列表重新进入 Windows。
- 在用户会话读取到任务栏 UIA 结构,但它没有展开传统通知图标的可访问性子项,
因此不能仅凭这份结构记录声称已目视确认图标消失;需用户检查托盘外观。
隔离验证不是 Windows UI 验收,也不代表真实 USB 读写和拔插已验证。
证据在 `docs/evidence/fixed-pci-20261105/`。
## 现场剩余验收
1. 从盒子列表重新进入 Windows,确认无 U 盘时客户桌面安全删除图标消失。
2. 真实 U 盘插入后可读写且菜单只列真实 USB;安全弹出并物理拔出后图标消失。
未通过本次修复承诺隐藏设备管理器或软件检测中的全部虚拟化标识。

View File

@ -0,0 +1,52 @@
# 云桌面图形电源操作限制(2026-11-04)
本次已通过现有 PVE Guest Agent 在三台正在运行的云桌面部署持久策略,限制当前客户桌面账户的关机、重启、睡眠和休眠入口。此修改发生在客体系统中,不依赖 Moonlight 的键盘过滤补丁是否已编译部署。
| 客体 | 限制账户 | 已应用策略 |
| --- | --- | --- |
| Windows VM100 | `test` | 当前用户 Explorer `NoClose=1`,移除开始菜单和 Windows 安全界面的电源命令 |
| 麒麟 VM101 | `test1` | polkit 0.105 `.pkla` 拒绝电源权限,加系统 D-Bus 用户级电源方法拒绝规则 |
| Ubuntu VM102 | `test2` | polkit `.rules` 拒绝电源权限,加系统 D-Bus 用户级电源方法拒绝规则 |
Windows 在实际登录用户的交互会话内广播 `WM_SETTINGCHANGE/Policy`,并调用 `SHRestricted(REST_NOCLOSE=2)` 验证有效结果为 1。未重启 Explorer、注销用户或重启 Windows。临时验证任务 `VDIPowerPolicyProbe` 在执行后删除。
Linux 同时限制 login1 的 PowerOff、Reboot、Halt、Suspend、Hibernate、HybridSleep、SuspendThenHibernate、ScheduleShutdown 及对应 WithFlags 方法,并拒绝桌面用户的 Can* 查询。菜单不能再获得电源操作能力,电源请求也会在系统总线被拒绝。D-Bus 仅 ReloadConfig,不重启总线或图形会话。
实测发现两台 Linux 当前图形会话的 `pkcheck` 仍对基础 reboot 动作返回授权,即使后台 CanReboot 查询已返回 no;因此不能只用 polkit 后台查询宣称重启已屏蔽。此现象的内部原因未确定。本次以额外 D-Bus 用户拒绝规则封住实际请求路径。定位过程中仅在麒麟重启过一次 polkit 服务,Sunshine 和图形会话保持运行。
## 验证范围
- Windows 注册表读回和交互 Shell 有效策略均为 1。
- 两台 Linux 七项 Can* 查询均被系统总线拒绝;八种电源方法的错误参数探测均返回 Access denied。
- 电源探测故意使用字符串参数 `s VDI_INVALID_SIGNATURE`;正常方法需要 boolean、flags 或其他签名。即使拒绝规则失效,该参数也无法通过 logind 的签名校验,不会形成有效关机/重启请求。
- 两台 Linux 使用实际 ukui-session/gnome-shell 进程检查 polkit,并记录上述 reboot 授权残留;额外总线拒绝验证通过。两台 vdi-sunshine 均 active,root 的 CanPowerOff 仍 yes。
- 三台 VM 均 running;Windows LastBoot 和 Linux boot ID 与本轮部署前一致。
- 未点击会真实执行关机的按钮,未进行重启后验证,也未逐项验收各桌面的菜单外观或缓存刷新表现。菜单若暂时仍显示旧项目,Linux 后端仍拒绝电源调用。
原始结果位于 [evidence/guest-power-policy-20261104](evidence/guest-power-policy-20261104/)。
## 重复部署与恢复
工具:`tools/set-guest-power-policy.ps1`(Windows)、`tools/set-guest-power-policy.py`(Linux)。管理机上的 `tools/deploy-guest-power-policy.py` 使用已有 PVE 认证和 Guest Agent,无新增凭据。
```powershell
python tools/deploy-guest-power-policy.py apply --vm 100
python tools/deploy-guest-power-policy.py apply --vm 101 --user test1
python tools/deploy-guest-power-policy.py apply --vm 102 --user test2
```
把 `apply` 换成 `query` 可查询,换成 `restore` 可恢复本轮首次部署前的值/文件;重复 apply 不覆盖初始备份。新镜像或新客户账户应针对实际账户重新执行,不能假设策略自动覆盖新用户。
Windows 备份:`C:\ProgramData\VDI\power-policy\original.clixml`。
Linux 备份:`/var/lib/vdi/power-policy/`。目标文件为:
- 麒麟:`/etc/polkit-1/localauthority/90-mandatory.d/99-vdi-power.pkla`。
- Ubuntu:`/etc/polkit-1/rules.d/00-vdi-power.rules`。
- 两者:`/etc/dbus-1/system.d/zz-vdi-desktop-power.conf`。
本策略限制客户图形电源入口,保留 root、管理员和 PVE 维护能力;不把具有管理员权限的客户账户变成安全隔离账户,不阻止管理员使用命令维护,也不修改自动更新行为。本轮未改变锁屏、注销或切换用户策略。
盒子断电会中断客户端连接,并不自动等于云端 VM 关机;本次未实现“盒子物理开关控制 VM 生命周期”的管理平台联动。
依据:[Microsoft NoClose 策略](https://learn.microsoft.com/en-us/windows/client-management/mdm/policy-csp-admx-startmenu#noclose)、[login1 电源接口](https://www.freedesktop.org/software/systemd/man/latest/org.freedesktop.login1.html)。

View File

@ -0,0 +1,51 @@
# 盒子过夜重启与健康记录(2026-11-04)
已为盒子192.168.1.188启用`vdi-overnight-observer.service`,每60秒落盘一次心跳,并开机自启。记录始于2026-11-04 19:35左右;统计范围是启用之后,不冒充历史累计重启次数。没有重启盒子、Windows、Moonlight,也没有修改显示、输入、网络或GPU配置。
## 明早看哪里
盒子上的中文汇总:`/home/armbian/vdi-terminal/logs/overnight-summary.txt`。
详细日志:`/home/armbian/vdi-terminal/logs/overnight-health-YYYYMMDD.log`,跨午夜自动切换日期,今晚及明早两天都要看。内容为带时间和事件类型的JSON行。状态:`/home/armbian/vdi-terminal/state/overnight-state.json`。
```bash
cat ~/vdi-terminal/logs/overnight-summary.txt
systemctl status vdi-overnight-observer.service --no-pager
grep '"event": "boot_change"\|"event": "restart_assessment"' ~/vdi-terminal/logs/overnight-health-*.log
```
汇总每分钟更新启用时间、最近心跳、重启次数、当前boot ID、开机时长及最近一次重启线索判断。服务自身停止/重启不算盒子重启。
## 记录内容和原因边界
- 每分钟:真实boot ID、`/proc/uptime`、负载、可用内存、温度、剩余磁盘、网络接口状态/错误计数、Moonlight和Kiosk进程PID、串流目标/分辨率/帧率、X11桌面几何。
- 每10分钟:当前启动的内核warning/error线索,不将普通USB电源管理或watchdog能力声明直接判断为故障原因。
- 每次启动:与持久状态比较boot ID;变化时增加一次整机重启计数,保存上次最后心跳/运行时长,并按上次实际boot ID读取journal尾部、warning和`last -x`。使用单调运行时间辅助发现NTP/RTC时间跳变,不靠墙上时钟或`last`单独断定是否重启。
- 重启原因只按证据报告:发现正常关机/重启流程;发现panic/OOM等线索但因果未确认;或证据不足、原因未确认。突然断电和硬复位可能没有完整记录,不能保证区分两者,更不能从缺日志直接断定电源坏了。
- 原子替换状态/中文汇总、每次写日志执行fsync,并限制重复服务实例。日志仅记录选定进程字段,不复制凭据或连接票据。
低权限观察进程通过root拥有的固定启动辅助程序`/usr/local/libexec/vdi-observer-pstore`获取`/sys/fs/pstore`及`/var/lib/systemd/pstore`的只读、有大小上限快照。快照写到root拥有、systemd-journal组只读的`/var/lib/vdi-observer/pstore.json`;不清除原档案、不修改内核崩溃参数。现有归档仅含41字节`reboot: Restarting system`,文件时间陈旧,作为部署前基线保留,不能认定为今晚新发生的重启。
## 跨重启存储
现场`/var/log`是zram内存盘,而`/var/log/journal`是指向`/var/log.hdd/journal`的符号链接,后者位于eMMC ext4。新增`/etc/systemd/journald.conf.d/70-vdi-observer-journal.conf`,使用persistent、磁盘日志预算64MB、运行日志预算16MB、保留7天、30秒同步间隔,并已flush/sync到磁盘。
只重启了日志系统journald和本次观察服务;没有停止当前串流。journald容量限制及轮转不是“断电绝不丢最后一秒”的保证。观察日志按日保留7份,每个日期最多4个8MB分段;不会删除原Kiosk、Moonlight或其他业务日志。
## 验证和限制
11项测试覆盖:首次基线、同boot服务重启、跨boot计数及上次心跳、未知原因不误报、正常关机标记、OOM仅作为线索、旧pstore不冒充新故障、状态版本校验、连续重启/历史上限、状态与中文汇总落盘、跨日期分文件。实际验证了观察服务重启后计数仍为0、文件落盘、自动启动登记、正常显示/串流进程保持。没有为验证而真正重启盒子。
部署首次在systemd-tmpfiles处理Armbian journal符号链接时失败,未启动观察服务,也未影响串流;随后改为处理真实eMMC目标目录,部署成功。升级pstore读取仅重启本次观察服务,仍不计作整机重启。
证据:`docs/evidence/overnight-observer-20261104/`。原服务/本次配置(如存在)的备份在盒子`~/vdi-terminal/backups/overnight-observer-20261104-*`。
## 停止记录和回退
```bash
sudo systemctl disable --now vdi-overnight-observer.service
```
这只停止本次记录,已有日志和汇总保留。如需恢复journald原设置,恢复该drop-in备份或移除本次新增`70-vdi-observer-journal.conf`后重启journald;不删除已经保存的journal数据。后续不再需要时再清理本次unit/root辅助程序。
依据:[systemd journald存储设置](https://www.freedesktop.org/software/systemd/man/latest/journald.conf.html)、[journalctl按boot ID读取和flush/sync](https://www.freedesktop.org/software/systemd/man/latest/journalctl.html)。

View File

@ -0,0 +1,35 @@
# Windows VM 100 异常退出调查(2026-11-04)
## 结论与限制
Windows 记录为非正常关机。PVE 侧退出清理紧随 USB 隧道重启,时间上高度相关;
USB 数据通道断开是首要待查触发点,但未获取 QEMU 退出码、错误栈或转储,不能确认根因。
此前将 QMP 不存在仅解释为 VM 已关机,未解释其原因;本次调查补充了异常退出证据。
## 证据(北京时间)
- 20:27:55.286424:开始停止 vdi-usb-tunnel-100.service。
- 20:27:55.288011:旧隧道停止。
- 20:27:55.291974:新隧道启动。
- 20:27:55.357421:VM 100 的 tap100i0 开始被移除。
- 20:27:55.592747:新 vdi-usb-qmp-100.service 才启动;虚拟网卡移除早于它启动。
- 20:27:56.147457:qmeventd 开始清理 VM 100。
- 20:31:06:root@pam 发起 qmstart,20:31:14 返回 OK。
- PVE 任务历史未发现该次对应的 qmshutdown/qmstop 请求;这不能排除直接 shell/QMP 操作。
- PVE 宿主机从 11 月 3 日 13:40:50 持续运行,本次没有宿主机重启。
- Windows 本次启动记录 Kernel-Power 41、EventLog 6008;BugcheckCode=0,
查询范围内没有 1074 正常关机发起事件、6006 正常停止或 1001 蓝屏事件。
- Windows 6008 内的“上次关机时间”显示 20:02:15,与宿主机退出时间不同;
此字段不作为精确退出时刻,时间线以 PVE 精确日志为准。
- 查询的宿主机内核日志无 OOM/进程被杀/segfault 证据;常见转储位置未发现本次转储。
- 隧道和 VM 分属 system.slice/vdi-usb-tunnel-100.service、qemu.slice/100.scope,
隧道无 BindsTo/PartOf 指向 VM,未发现服务依赖直接带停 VM 的配置。
## 当前状态与后续
调查仅作只读采集,没有重启隧道/VM、修改 Windows 或 USB 配置,也没有在在线 VM 重现异常。
调查结束 VM 100 正常运行;未插 U 盘时 device_present=false、channels=[]。
后续应在隔离测试 VM 或维护窗口,先记录 QEMU stderr、退出状态和 QMP 事件,
再验证 usb-redir 数据连接断开、隧道重启、物理拔盘三种场景;不能直接在用户正在使用的
VM 100 上重启隧道复现。当前“未插盘设备移除”通过不等于完整插拔稳定性通过。

View File

@ -0,0 +1,7 @@
[Journal]
Storage=persistent
SystemMaxUse=64M
SystemKeepFree=256M
RuntimeMaxUse=16M
MaxRetentionSec=7day
SyncIntervalSec=30s

View File

@ -0,0 +1,21 @@
[Unit]
Description=VDI overnight reboot and health observer
After=local-fs.target systemd-journald.service systemd-pstore.service
[Service]
Type=simple
User=armbian
SupplementaryGroups=systemd-journal
Environment=HOME=/home/armbian
Environment=VDI_OBSERVER_INTERVAL=60
ExecStartPre=+/usr/local/libexec/vdi-observer-pstore
ExecStart=/home/armbian/vdi-terminal/bin/vdi-overnight-observer
Restart=on-failure
RestartSec=5
Nice=10
IOSchedulingClass=idle
NoNewPrivileges=true
TimeoutStopSec=15
[Install]
WantedBy=multi-user.target

View File

@ -0,0 +1,37 @@
#!/usr/bin/env python3
"""Root-owned, read-only pstore snapshot; never clears kernel/archive records."""
from datetime import datetime
import grp
import json
import os
from pathlib import Path
import tempfile
folder = Path('/var/lib/vdi-observer')
folder.mkdir(mode=0o750, exist_ok=True)
if folder.is_symlink() or folder.stat().st_uid != 0:
raise RuntimeError('Unsafe pstore snapshot directory')
gid = grp.getgrnam('systemd-journal').gr_gid
os.chown(folder, 0, gid); os.chmod(folder, 0o750)
data = {'captured_at': datetime.now().astimezone().isoformat(),
'boot_id': Path('/proc/sys/kernel/random/boot_id').read_text().strip(), 'sources': {}}
for root in (Path('/sys/fs/pstore'), Path('/var/lib/systemd/pstore')):
try:
entries = {}
for p in sorted(root.iterdir())[:8]:
if p.is_symlink() or not p.is_file(): continue
with p.open('rb') as stream: raw = stream.read(32768)
entries[p.name] = {'mtime': p.stat().st_mtime, 'text': raw.decode(errors='replace'), 'truncated': p.stat().st_size > 32768}
data['sources'][str(root)] = entries
except OSError as e: data['sources'][str(root)] = {'error': str(e)}
name = None
try:
with tempfile.NamedTemporaryFile(mode='w', encoding='utf-8', dir=folder, delete=False) as f:
name = f.name; os.fchown(f.fileno(), 0, gid); os.fchmod(f.fileno(), 0o640)
json.dump(data, f, ensure_ascii=False); f.flush(); os.fsync(f.fileno())
os.replace(name, folder/'pstore.json'); name = None
fd = os.open(folder, os.O_RDONLY | os.O_DIRECTORY)
try: os.fsync(fd)
finally: os.close(fd)
finally:
if name: os.unlink(name)

View File

@ -0,0 +1,204 @@
#!/usr/bin/env python3
"""Read-only health/reboot observer with durable heartbeats; never restarts the box."""
import argparse
from datetime import datetime
import hashlib
import json
import os
from pathlib import Path
import re
import signal
import subprocess
import tempfile
import threading
ORDERLY = re.compile(r"systemd-shutdown|Reached target.*(?:System Reboot|System Power Off)|systemd\[1\]: (?:Rebooting|Powering off)|reboot: Restarting system|reboot: Power down", re.I)
FAILURE = re.compile(r"Kernel panic - not syncing|Oops:|BUG: unable to handle|watchdog:.*(?:hard LOCKUP|soft lockup)|Out of memory: Killed process|oom-kill:|thermal.*critical", re.I)
EVIDENCE = re.compile(r"panic|oops|watchdog|oom|out of memory|thermal|overheat|shutdown|reboot|crash|segfault|I/O error|ext4.*error|mmc.*(?:error|timeout)|voltage|hung task", re.I)
def atomic_write(path, text):
path.parent.mkdir(parents=True, exist_ok=True)
name = None
try:
with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=path.parent, delete=False) as f:
name = f.name
os.chmod(name, 0o600)
f.write(text); f.flush(); os.fsync(f.fileno())
os.replace(name, path); name = None
if os.name == "posix":
fd = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY)
try: os.fsync(fd)
finally: os.close(fd)
finally:
if name: os.unlink(name)
def command(args, timeout=4, limit=24576):
try:
r = subprocess.run(args, capture_output=True, text=True, errors="replace", timeout=timeout,
env={**os.environ, "LC_ALL": "C", "SYSTEMD_COLORS": "0"})
return {"code": r.returncode, "text": r.stdout[-limit:], "error": r.stderr[-1024:]}
except (OSError, subprocess.TimeoutExpired) as e:
return {"code": -1, "text": "", "error": str(e)[:1024]}
def read(path, default=""):
try: return Path(path).read_text().strip()
except (OSError, ValueError): return default
def classify(evidence):
text = "\n".join(evidence.get(k, {}).get("text", "") for k in ("tail", "warnings", "pstore_new"))
if FAILURE.search(text): return "发现内核异常/OOM等线索,不能单独认定为重启原因"
if ORDERLY.search(text): return "发现正常关机或重启流程记录,发起者需结合原始日志核对"
return "重启原因未确认:无充分关机/故障证据,不能区分断电、硬复位等情况"
def transition(previous, boot_id, now, evidence=None):
if previous is not None and (previous.get("version") != 1 or type(previous.get("reboots")) is not int):
raise ValueError("Unsupported/corrupt saved state; preserve it for diagnosis")
state = dict(previous or {"version": 1, "reboots": 0, "started_at": now, "history": []})
old = state.get("boot_id")
event = "boot_baseline" if not old else "observer_restart_same_boot"
if old and old != boot_id:
event = "boot_change"; state["reboots"] += 1
state["history"] = (state.get("history", []) + [{"previous_boot_id": old, "boot_id": boot_id,
"previous_last_seen": state.get("last_seen"), "previous_uptime_seconds": state.get("uptime_seconds"),
"detected_at": now, "assessment": classify(evidence or {})}])[-32:]
state.update(boot_id=boot_id, observer_started_at=now)
return state, event
class Observer:
def __init__(self, logs, state_dir):
self.logs, self.state_dir = logs, state_dir
logs.mkdir(parents=True, exist_ok=True); state_dir.mkdir(parents=True, exist_ok=True)
self.state_file = state_dir / "overnight-state.json"
self.state = None
def log(self, event, **values):
now = datetime.now().astimezone()
path = self.logs / ("overnight-health-" + now.strftime("%Y%m%d") + ".log")
if path.exists() and path.stat().st_size > 8*1024*1024:
for i in (2, 1):
source = path.with_suffix(path.suffix + "." + str(i))
if source.exists(): os.replace(source, path.with_suffix(path.suffix + "." + str(i+1)))
os.replace(path, path.with_suffix(path.suffix + ".1"))
record = dict(time=now.isoformat(timespec="seconds"), event=event, **values)
with path.open("a", encoding="utf-8") as f:
f.write(json.dumps(record, ensure_ascii=False) + "\n"); f.flush(); os.fsync(f.fileno())
# Bound only our own logs; never touch kiosk/stream/application logs.
days = sorted(self.logs.glob("overnight-health-????????.log"))
for old in days[:-7]:
if old == path: continue
old.unlink(missing_ok=True)
for i in (1, 2, 3): old.with_suffix(old.suffix + "." + str(i)).unlink(missing_ok=True)
def prior_evidence(self, boot_id):
# Boot IDs remain meaningful even when RTC/NTP changes wall-clock time.
if not re.fullmatch(r"[0-9a-f-]{32,36}", boot_id):
return {"tail": {"text": "", "error": "invalid saved boot ID"}}
base = ["journalctl", "--no-pager", "-b", boot_id, "-o", "short-monotonic"]
return {"tail": command(base+["-n", "100"]), "warnings": command(base+["-p", "warning", "-n", "120"]),
"last": command(["last", "-x", "-n", "10"])}
def start(self):
boot_id = read("/proc/sys/kernel/random/boot_id")
if not re.fullmatch(r"[0-9a-f-]{36}", boot_id): raise ValueError("Cannot read kernel boot ID")
previous = json.loads(self.state_file.read_text()) if self.state_file.exists() else None
try:
with Path('/var/lib/vdi-observer/pstore.json').open('r', encoding='utf-8') as f:
pstore = json.loads(f.read(1024*1024))
except (OSError, ValueError) as e: pstore = {"read_error": str(e)}
# Compare contents, not capture time; an old ramoops archive is not a new crash.
texts = {root+"/"+name: entry["text"] for root, files in pstore.get("sources", {}).items()
for name, entry in files.items() if isinstance(entry, dict) and "text" in entry}
digest = hashlib.sha256(json.dumps(texts, sort_keys=True).encode()).hexdigest()
evidence = self.prior_evidence(previous["boot_id"]) if previous and previous.get("boot_id") != boot_id else None
if evidence is not None and previous.get("pstore_digest") and previous["pstore_digest"] != digest:
evidence["pstore_new"] = {"text": "\n".join(texts.values()), "note": "pstore contents changed since previous heartbeat; cause still needs review"}
self.state, event = transition(previous, boot_id, datetime.now().astimezone().isoformat(timespec="seconds"), evidence)
self.state["pstore_digest"] = digest
self.log(event, boot_id=boot_id, reboots=self.state["reboots"], previous_last_seen=(previous or {}).get("last_seen"), evidence=evidence)
if evidence: self.log("restart_assessment", assessment=self.state["history"][-1]["assessment"])
self.log("boot_evidence", kernel=os.uname().release,
journal=command(["journalctl", "--list-boots", "--no-pager", "-n", "8"]), pstore=pstore,
watchdog_bootstatus={str(p): read(p) for p in Path("/sys/class/watchdog").glob("watchdog*/bootstatus")})
def sample(self):
uptime = float(read("/proc/uptime").split()[0]); now = datetime.now().astimezone()
processes = []
for path in Path("/proc").glob("[0-9]*/cmdline"):
try:
args = path.read_bytes().decode(errors="replace").strip("\0").split("\0"); exe = Path(args[0]).name
if exe not in ("moonlight", "kiosk-shell", "vdi-kiosk-monitor"): continue
item = {"pid": int(path.parent.name), "name": exe}
if exe == "moonlight" and "stream" in args:
i = args.index("stream"); item["host"] = args[i+1] if i+1 < len(args) else ""
for flag in ("--resolution", "--fps"):
if flag in args and args.index(flag)+1 < len(args): item[flag[2:]] = args[args.index(flag)+1]
processes.append(item)
except (OSError, ValueError, IndexError): continue
temps = {p.parent.name: int(read(p, "0"))/1000 for p in Path("/sys/class/thermal").glob("thermal_zone*/temp")}
memory = {s.split(":")[0]: s.split(":")[1].strip() for s in read("/proc/meminfo").splitlines() if s.startswith(("MemAvailable:", "MemTotal:", "SwapFree:"))}
display = command(["env", "DISPLAY=:0", "XAUTHORITY="+str(Path.home()/".Xauthority"), "xrandr", "--current"], timeout=3)
display["text"] = "\n".join(s for s in display["text"].splitlines() if s.startswith("Screen ") or " connected" in s or " disconnected" in s)
network = {p.name: {"operstate": read(p/"operstate"), "rx_errors": read(p/"statistics/rx_errors"), "tx_errors": read(p/"statistics/tx_errors")}
for p in Path("/sys/class/net").iterdir() if p.name != "lo"}
if self.state.get("last_seen") and self.state.get("sample_boot_id") == self.state["boot_id"]:
wall = now.timestamp()-self.state.get("last_wall_seconds", now.timestamp())
mono = uptime-self.state.get("uptime_seconds", uptime)
if abs(wall-mono) > 120: self.log("clock_adjustment", wall_delta=wall, uptime_delta=mono)
fs = os.statvfs("/")
values = dict(boot_id=self.state["boot_id"], reboots=self.state["reboots"], uptime_seconds=uptime,
load=read("/proc/loadavg").split()[:3], memory=memory, temperatures_c=temps,
processes=processes, display=display, network=network, root_available_mb=fs.f_bavail*fs.f_frsize//(1024*1024))
self.log("sample", **values)
self.state.update(last_seen=now.isoformat(timespec="seconds"), last_wall_seconds=now.timestamp(), uptime_seconds=uptime,
sample_boot_id=self.state["boot_id"], last_sample=values)
self.save()
def save(self):
atomic_write(self.state_file, json.dumps(self.state, ensure_ascii=False, indent=2)+"\n")
history = self.state.get("history", [])
lines = ["盒子过夜观察汇总", "记录启用时间:"+self.state["started_at"], "最近记录时间:"+self.state.get("last_seen", ""),
"记录启用后重启次数:"+str(self.state["reboots"]), "当前启动编号:"+self.state["boot_id"],
"当前开机时长(秒):"+str(self.state.get("uptime_seconds", "")),
"最近一次重启判断:"+(history[-1]["assessment"] if history else "观察期间尚未发现重启"),
"详细数据见同目录 overnight-health-YYYYMMDD.log;每分钟更新。",
"服务重启不计为盒子重启;无故障证据时不推断具体断电原因。"]
atomic_write(self.logs/"overnight-summary.txt", "\n".join(lines)+"\n")
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--once", action="store_true"); parser.add_argument("--report", action="store_true")
args = parser.parse_args()
logs = Path(os.environ.get("VDI_OBSERVER_LOG_DIR", str(Path.home()/"vdi-terminal/logs")))
state_dir = Path(os.environ.get("VDI_OBSERVER_STATE_DIR", str(Path.home()/"vdi-terminal/state")))
if args.report: print((logs/"overnight-summary.txt").read_text()); return
interval = int(os.environ.get("VDI_OBSERVER_INTERVAL", "60"))
if not 10 <= interval <= 3600: parser.error("Interval must be 10..3600 seconds")
os.umask(0o077); observer = Observer(logs, state_dir)
import fcntl
with (state_dir/"overnight-observer.lock").open("w") as lock:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
stopped = threading.Event(); stop_signal = []
def on_signal(signum, frame): stop_signal.append(signum); stopped.set()
signal.signal(signal.SIGTERM, on_signal); signal.signal(signal.SIGINT, on_signal)
observer.start(); observer.sample()
if args.once: return
ticks = 0
while not stopped.wait(interval):
observer.sample(); ticks += 1
if ticks % max(1, 600//interval) == 0:
r = command(["journalctl", "-k", "-b", "0", "-p", "warning", "-n", "50", "--no-pager", "-o", "short-monotonic"])
r["text"] = "\n".join(s for s in r["text"].splitlines() if EVIDENCE.search(s))
observer.log("kernel_warning_clues", **r)
# SIGTERM may just be a service stop. Never call it an OS reboot.
observer.log("observer_stop", signal=stop_signal, boot_id=observer.state["boot_id"])
observer.state["observer_stop_signal"] = stop_signal; observer.save()
if __name__ == "__main__": main()

View File

@ -0,0 +1,64 @@
diff --git a/app/streaming/input/keyboard.cpp b/app/streaming/input/keyboard.cpp
index 29e218d..2b3911d 100644
--- a/app/streaming/input/keyboard.cpp
+++ b/app/streaming/input/keyboard.cpp
@@ -182,6 +182,43 @@ void SdlInputHandler::performSpecialKeyCombo(KeyCombo combo)
}
}
+namespace {
+
+// The terminal is a kiosk. These keys can stop, restart, lock, or expose the
+// guest session, so they must never be forwarded to any cloud desktop. The
+// modifier tests intentionally apply to both key-down and key-up events so a
+// blocked shortcut cannot leave a partial action in the guest.
+static bool isBlockedPowerKeyEvent(const SDL_KeyboardEvent* event)
+{
+ const SDL_Keymod modifiers = static_cast<SDL_Keymod>(event->keysym.mod);
+ const bool ctrlAlt = (modifiers & KMOD_CTRL) && (modifiers & KMOD_ALT);
+ const bool alt = (modifiers & KMOD_ALT) != 0;
+ const bool gui = (modifiers & KMOD_GUI) != 0;
+
+ switch (event->keysym.scancode) {
+ case SDL_SCANCODE_SLEEP:
+ // SDL2 maps the physical power/sleep button to this consumer code.
+ return true;
+ case SDL_SCANCODE_DELETE:
+ // Windows secure attention and systemd's Ctrl+Alt+Del action.
+ return ctrlAlt;
+ case SDL_SCANCODE_F4:
+ // On a desktop, Alt+F4 opens the shutdown dialog; it is also a
+ // common way to close the only shell/application in a kiosk image.
+ return alt;
+ case SDL_SCANCODE_L:
+ // Locking the guest breaks the kiosk session and can strand the user.
+ return gui;
+ case SDL_SCANCODE_X:
+ // Windows quick-link menu contains shutdown/restart actions.
+ return gui;
+ default:
+ return false;
+ }
+}
+
+} // namespace
+
void SdlInputHandler::handleKeyEvent(SDL_KeyboardEvent* event)
{
short keyCode;
@@ -189,6 +226,15 @@ void SdlInputHandler::handleKeyEvent(SDL_KeyboardEvent* event)
char flags;
bool shouldNotConvertToScanCodeOnServer = false;
+ if (isBlockedPowerKeyEvent(event)) {
+ if (event->state == SDL_PRESSED) {
+ SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION,
+ "Blocked guest power/session shortcut (scancode=%d)",
+ event->keysym.scancode);
+ }
+ return;
+ }
+
if (event->repeat) {
// Ignore repeat key down events
SDL_assert(event->state == SDL_PRESSED);

View File

@ -0,0 +1,55 @@
#!/usr/bin/env python3
"""Apply/query/restore power-menu policy via the existing PVE guest agent.
Uses the repository's existing PVE authentication. Never powers off/reboots a VM.
"""
import argparse
import base64
import json
from pathlib import Path
import runpy
import sys
import time
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('action', choices=['apply', 'query', 'restore'])
parser.add_argument('--vm', type=int, required=True, choices=[100, 101, 102])
parser.add_argument('--user', help='Required for Linux guests')
parser.add_argument('--evidence', type=Path, help='Save the guest-agent result as JSON')
args = parser.parse_args()
root = Path(__file__).resolve().parent
api = runpy.run_path(str(root / 'pve_vm.py'))['api']
ticket, csrf = runpy.run_path(str(root / 'pve_term_exec.py'))['login']()
base = f'/api2/json/nodes/starhe/qemu/{args.vm}'
if api(base + '/status/current', ticket=ticket).get('status') != 'running':
raise SystemExit('Guest is not running; nothing changed')
if args.vm == 100:
source = (root / 'set-guest-power-policy.ps1').read_text(encoding='utf-8')
source = "$ProgressPreference='SilentlyContinue'\n& {\n" + source + "\n} -Action " + args.action.capitalize()
encoded = base64.b64encode(source.encode('utf-16le')).decode()
command = ['powershell.exe', '-NoProfile', '-NonInteractive', '-EncodedCommand', encoded]
else:
if not args.user:
parser.error('--user is required for Linux')
source = (root / 'set-guest-power-policy.py').read_text(encoding='utf-8')
command = ['python3', '-c', source, args.action, '--user', args.user]
pid = api(base + '/agent/exec', json_body={'command': command}, ticket=ticket, csrf=csrf)['pid']
for _ in range(60):
time.sleep(1)
result = api(base + f'/agent/exec-status?pid={pid}', ticket=ticket)
if result.get('exited'):
report = json.dumps({'vm': args.vm, **result}, ensure_ascii=True)
if args.evidence:
args.evidence.parent.mkdir(parents=True, exist_ok=True)
args.evidence.write_text(report + '\n', encoding='utf-8')
print(report)
if result.get('exitcode') or result.get('out-truncated') or result.get('err-truncated'):
raise SystemExit(1)
return
raise SystemExit('Guest operation timed out; query before retrying')
if __name__ == '__main__':
main()

View File

@ -0,0 +1,40 @@
#!/usr/bin/env bash
set -euo pipefail
box="${1:-armbian@192.168.1.188}"
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
ssh "$box" 'set -eu
backup="$HOME/vdi-terminal/backups/overnight-observer-$(date +%Y%m%d-%H%M%S)"
mkdir -p "$backup" "$HOME/vdi-terminal/bin" "$HOME/vdi-terminal/logs" "$HOME/vdi-terminal/state"
chmod 700 "$backup"
for file in /etc/systemd/system/vdi-overnight-observer.service /etc/systemd/journald.conf.d/70-vdi-observer-journal.conf /usr/local/libexec/vdi-observer-pstore "$HOME/vdi-terminal/bin/vdi-overnight-observer"; do
if test -f "$file"; then sudo -n cp -a "$file" "$backup/$(basename "$file")"; fi
done
echo "backup=$backup"'
scp "$repo_root/terminal/kiosk-shell/scripts/vdi-overnight-observer" "$box:/tmp/vdi-overnight-observer"
scp "$repo_root/terminal/kiosk-shell/etc/vdi-overnight-observer.service" "$box:/tmp/vdi-overnight-observer.service"
scp "$repo_root/terminal/kiosk-shell/etc/70-vdi-observer-journal.conf" "$box:/tmp/70-vdi-observer-journal.conf"
scp "$repo_root/terminal/kiosk-shell/scripts/vdi-observer-pstore" "$box:/tmp/vdi-observer-pstore"
ssh "$box" 'set -eu
python3 -c "import ast,pathlib; ast.parse(pathlib.Path(\"/tmp/vdi-overnight-observer\").read_text())"
sudo -n systemctl stop vdi-overnight-observer.service 2>/dev/null || true
install -m 0755 /tmp/vdi-overnight-observer /home/armbian/vdi-terminal/bin/vdi-overnight-observer
sudo -n install -D -o root -g root -m 0755 /tmp/vdi-observer-pstore /usr/local/libexec/vdi-observer-pstore
sudo -n install -m 0644 /tmp/vdi-overnight-observer.service /etc/systemd/system/vdi-overnight-observer.service
sudo -n install -D -m 0644 /tmp/70-vdi-observer-journal.conf /etc/systemd/journald.conf.d/70-vdi-observer-journal.conf
# Armbian makes /var/log a RAM disk and journal a symlink to /var/log.hdd.
# Work on its existing disk target; tmpfiles rejects file flags on the symlink.
journal_root="$(readlink -f /var/log/journal)"
test -n "$journal_root"
sudo -n install -d -m 2755 -o root -g systemd-journal "$journal_root"
sudo -n systemd-analyze verify /etc/systemd/system/vdi-overnight-observer.service
sudo -n systemctl daemon-reload
sudo -n systemctl restart systemd-journald.service
sudo -n journalctl --flush
sudo -n journalctl --sync
sudo -n systemctl enable --now vdi-overnight-observer.service
sleep 2
systemctl is-active vdi-overnight-observer.service
systemctl is-enabled vdi-overnight-observer.service
cat /home/armbian/vdi-terminal/logs/overnight-summary.txt
sudo -n journalctl --directory /var/log/journal -n 3 --no-pager
rm -f /tmp/vdi-overnight-observer /tmp/vdi-overnight-observer.service /tmp/70-vdi-observer-journal.conf /tmp/vdi-observer-pstore'

121
tools/set-fixed-pci.py Normal file
View File

@ -0,0 +1,121 @@
#!/usr/bin/env python3
"""Prepare/apply/restore fixed PCI hardware for a PVE Q35 VM.
Only adjusts QEMU args. Never stops/starts a VM or restarts its USB tunnel.
Apply/restore require a stopped VM so the next cold start uses the new ACPI.
"""
import argparse
import datetime
import json
from pathlib import Path
import shlex
import subprocess
FIXED_GLOBALS = (
"ICH9-LPC.acpi-pci-hotplug-with-bridge-support=off",
"pci-bridge.shpc=off",
"pcie-root-port.hotplug=off",
)
def fixed_args(original):
tokens = shlex.split(original)
additions = []
for setting in FIXED_GLOBALS:
prop = setting.split("=", 1)[0]
for i, token in enumerate(tokens[:-1]):
if token == "-global" and tokens[i + 1].startswith(prop + "="):
if tokens[i + 1] != setting:
raise ValueError("Conflicting existing setting: " + tokens[i + 1])
break
else:
additions.extend(["-global", setting])
# Keep the original text, quoting and GPU arguments intact.
return " ".join(x for x in (original.strip(), shlex.join(additions)) if x)
def command(args):
return subprocess.run(args, check=True, capture_output=True, text=True,
timeout=40).stdout.strip()
def config(vm):
text = command(["qm", "config", str(vm), "--current", "1"])
result = {}
for line in text.splitlines():
if ": " in line:
name, value = line.split(": ", 1)
result[name] = value
return text, result
def require_stopped(vm):
if command(["qm", "status", str(vm)]) != "status: stopped":
raise RuntimeError("VM must be normally shut down first; no live hardware changes made")
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("action", choices=["prepare", "apply", "restore"])
parser.add_argument("--vm", required=True, type=int)
parser.add_argument("--backup", required=True, type=Path,
help="dedicated backup directory, reused for apply/restore")
args = parser.parse_args()
if args.vm < 100:
parser.error("Invalid VM ID")
text, conf = config(args.vm)
if "q35" not in conf.get("machine", ""):
raise RuntimeError("Only an explicitly configured Q35 machine is supported")
manifest = args.backup / "plan.json"
if args.action == "prepare":
if manifest.exists():
raise RuntimeError("Backup already exists; use a new directory")
# Pending changes may otherwise be applied unexpectedly at cold start.
pending = command(["qm", "pending", str(args.vm)])
if any(not line.startswith("cur ") for line in pending.splitlines() if line):
raise RuntimeError("VM has pending changes; review them before preparing")
proposed = fixed_args(conf.get("args", ""))
args.backup.mkdir(parents=True, exist_ok=True, mode=0o700)
(args.backup / "vm.conf.before").write_text(text + "\n")
plan = {"vm": args.vm, "created": datetime.datetime.now().astimezone().isoformat(),
"original_args": conf.get("args"), "proposed_args": proposed,
"original_config": conf, "status": "prepared; not applied"}
manifest.write_text(json.dumps(plan, indent=2) + "\n")
manifest.chmod(0o600)
print(json.dumps(plan, indent=2))
return
plan = json.loads(manifest.read_text())
if plan["vm"] != args.vm:
raise RuntimeError("Backup belongs to another VM")
require_stopped(args.vm)
original = plan["original_args"]
proposed = plan["proposed_args"]
current = conf.get("args")
expected = original if args.action == "apply" else proposed
desired = proposed if args.action == "apply" else original
if current != expected:
if current == desired:
print("Already in requested state")
return
raise RuntimeError("QEMU args changed since preparation; refusing to overwrite")
if args.action == "apply" and conf != plan["original_config"]:
raise RuntimeError("VM config changed since preparation; prepare a fresh plan")
if desired is None:
command(["qm", "set", str(args.vm), "--delete", "args"])
else:
command(["qm", "set", str(args.vm), "--args", desired])
# The script never replaces the full VM config, preserving other settings.
_, after = config(args.vm)
if after.get("args") != desired:
raise RuntimeError("Configuration readback differs; inspect before starting the VM")
before_other = {k: v for k, v in conf.items() if k != "args"}
after_other = {k: v for k, v in after.items() if k != "args"}
if before_other != after_other:
raise RuntimeError("Other settings changed concurrently; inspect before starting the VM")
print(json.dumps({"vm": args.vm, "action": args.action,
"args": desired, "next_step": "cold start and verify Windows"}))
if __name__ == "__main__":
main()

View File

@ -0,0 +1,86 @@
param(
[ValidateSet('Apply','Query','Restore')][string]$Action = 'Query',
[string]$BackupDirectory = 'C:\ProgramData\VDI\power-policy'
)
# Run elevated inside the Windows guest. No reboot, logout or Explorer restart.
$ErrorActionPreference = 'Stop'
$user = (Get-CimInstance Win32_ComputerSystem).UserName
if (-not $user) { throw 'No interactive desktop user' }
$sid = (New-Object Security.Principal.NTAccount($user)).Translate([Security.Principal.SecurityIdentifier]).Value
if (-not (Test-Path "Registry::HKEY_USERS\$sid")) { throw 'User hive is not loaded' }
$path = "Registry::HKEY_USERS\$sid\Software\Microsoft\Windows\CurrentVersion\Policies\Explorer"
$name = 'NoClose'
$backup = Join-Path $BackupDirectory 'original.clixml'
if ($Action -eq 'Apply') {
New-Item -ItemType Directory -Force $BackupDirectory | Out-Null
if (-not (Test-Path $backup)) {
$key = Get-Item -LiteralPath $path -ErrorAction SilentlyContinue
$exists = $key -and ($key.GetValueNames() -contains $name)
[pscustomobject]@{Sid=$sid; Path=$path; Exists=[bool]$exists;
Value=$(if($exists){$key.GetValue($name)}else{$null});
Kind=$(if($exists){$key.GetValueKind($name).ToString()}else{$null})} | Export-Clixml $backup
}
if ((Import-Clixml $backup).Sid -ne $sid) { throw 'Backup belongs to another user' }
New-Item -Path $path -Force | Out-Null
New-ItemProperty -LiteralPath $path -Name $name -PropertyType DWord -Value 1 -Force | Out-Null
} elseif ($Action -eq 'Restore') {
$saved = Import-Clixml $backup
if ($saved.Sid -ne $sid -or $saved.Path -ne $path) { throw 'Backup belongs to another user' }
if ($saved.Exists) {
New-ItemProperty -LiteralPath $path -Name $name -PropertyType $saved.Kind -Value $saved.Value -Force | Out-Null
} else {
Remove-ItemProperty -LiteralPath $path -Name $name -ErrorAction SilentlyContinue
}
}
$key = Get-Item -LiteralPath $path -ErrorAction SilentlyContinue
$value = if ($key) { $key.GetValue($name) } else { $null }
if ($Action -eq 'Apply' -and $value -ne 1) { throw 'NoClose readback failed' }
# Notify and query the shell in the actual desktop user's session. A SYSTEM
# guest-agent process sees a different HKCU and cannot verify this restriction.
$runtime = Join-Path $BackupDirectory 'runtime'
New-Item -ItemType Directory -Force $runtime | Out-Null
$acl = Get-Acl $runtime
$acl.SetAccessRule((New-Object Security.AccessControl.FileSystemAccessRule($user,'Modify','ContainerInherit,ObjectInherit','None','Allow')))
Set-Acl $runtime $acl
$resultPath = Join-Path $runtime 'restriction.txt'
Remove-Item $resultPath -ErrorAction SilentlyContinue
$probe = @'
$ErrorActionPreference='Stop'
Add-Type -TypeDefinition @"
using System;
using System.Runtime.InteropServices;
public static class VDIPowerShellPolicy {
[DllImport("shell32.dll")] public static extern uint SHRestricted(uint restriction);
[DllImport("user32.dll", CharSet=CharSet.Unicode)]
public static extern IntPtr SendMessageTimeout(IntPtr window, uint message, UIntPtr wparam, string lparam, uint flags, uint timeout, out UIntPtr result);
}
"@
$sent=[UIntPtr]::Zero
[void][VDIPowerShellPolicy]::SendMessageTimeout([IntPtr]0xffff,0x1a,[UIntPtr]::Zero,'Policy',2,2000,[ref]$sent)
[VDIPowerShellPolicy]::SHRestricted(2) | Set-Content -Encoding ascii '__RESULT__'
'@
$probe = $probe.Replace('__RESULT__', $resultPath.Replace("'", "''"))
$encoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($probe))
$task = 'VDIPowerPolicyProbe'
if (Get-ScheduledTask -TaskName $task -ErrorAction SilentlyContinue) { throw 'Probe task already exists' }
$taskAction = New-ScheduledTaskAction -Execute powershell.exe -Argument "-NoProfile -WindowStyle Hidden -EncodedCommand $encoded"
$principal = New-ScheduledTaskPrincipal -UserId $user -LogonType Interactive -RunLevel Limited
$settings = New-ScheduledTaskSettingsSet -ExecutionTimeLimit (New-TimeSpan -Minutes 1)
Register-ScheduledTask -TaskName $task -Action $taskAction -Principal $principal -Settings $settings | Out-Null
try {
Start-ScheduledTask -TaskName $task
for ($i=0; $i -lt 20; $i++) {
Start-Sleep -Seconds 1
if (Test-Path $resultPath) { break }
}
if (-not (Test-Path $resultPath)) { throw 'Interactive policy probe timed out' }
$effective = [int](Get-Content $resultPath -Raw)
if ($Action -eq 'Apply' -and $effective -ne 1) { throw 'Interactive shell restriction was not enabled' }
} finally {
Stop-ScheduledTask -TaskName $task -ErrorAction SilentlyContinue
Unregister-ScheduledTask -TaskName $task -Confirm:$false
}
[pscustomobject]@{Action=$Action; User=$user; Sid=$sid; NoClose=$value; Backup=$backup;
EffectiveShellNoClose=$effective;
LastBoot=(Get-CimInstance Win32_OperatingSystem).LastBootUpTime.ToString('o');
Scope='Desktop power menus only; privileged administration remains available'} | ConvertTo-Json -Compress

View File

@ -0,0 +1,158 @@
#!/usr/bin/env python3
"""Deny desktop-user power actions on Ubuntu/Kylin; run as root in the guest.
Writes account-scoped polkit and D-Bus rules. Never sends a valid power request.
Root/PVE maintenance remains available. Query uses login1 Can* methods only.
"""
import argparse
import json
import os
from pathlib import Path
import pwd
import re
import shutil
import subprocess
import time
from xml.sax.saxutils import quoteattr
def run(command):
return subprocess.run(command, capture_output=True, text=True, timeout=20,
env={**os.environ, 'LC_ALL': 'C', 'SYSTEMD_COLORS': '0'})
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('action', choices=['apply', 'query', 'restore'])
parser.add_argument('--user', required=True)
parser.add_argument('--backup', default='/var/lib/vdi/power-policy')
args = parser.parse_args()
if os.geteuid() != 0:
parser.error('Run as root in the guest, not on the terminal or PVE node')
if not re.fullmatch(r'[a-z_][a-z0-9_-]*', args.user):
parser.error('Invalid desktop user')
account = pwd.getpwnam(args.user)
if account.pw_uid == 0:
parser.error('Refusing to restrict root')
backend = 'pkla' if run(['pkaction', '--version']).stdout.strip().endswith('0.105') else 'rules'
if backend == 'pkla':
target = Path('/etc/polkit-1/localauthority/90-mandatory.d/99-vdi-power.pkla')
content = ('[VDI desktop power restriction]\nIdentity=unix-user:' + args.user + '\n'
'Action=org.freedesktop.login1.power-off*;org.freedesktop.login1.reboot*;'
'org.freedesktop.login1.halt*;org.freedesktop.login1.suspend*;'
'org.freedesktop.login1.hibernate*;org.freedesktop.login1.hybrid-sleep*;'
'org.freedesktop.consolekit.system.stop*;org.freedesktop.consolekit.system.restart*;'
'org.freedesktop.upower.suspend;org.freedesktop.upower.hibernate\n'
'ResultAny=no\nResultInactive=no\nResultActive=no\n')
else:
target = Path('/etc/polkit-1/rules.d/00-vdi-power.rules')
content = '''polkit.addRule(function(action, subject) {
if (subject.user === %s &&
/^org\\.freedesktop\\.login1\\.(power-off|reboot|halt|suspend|hibernate|hybrid-sleep)(-|$)/.test(action.id)) {
return polkit.Result.NO;
}
});
''' % json.dumps(args.user)
backup = Path(args.backup)
manifest = backup / 'manifest.json'
dbus_path = Path('/etc/dbus-1/system.d/zz-vdi-desktop-power.conf')
methods = ('PowerOff', 'Reboot', 'Halt', 'Suspend', 'Hibernate', 'HybridSleep',
'SuspendThenHibernate', 'ScheduleShutdown')
capability_methods = ('CanPowerOff', 'CanReboot', 'CanHalt', 'CanSuspend',
'CanHibernate', 'CanHybridSleep', 'CanSuspendThenHibernate')
members = [m for name in methods for m in (name, name + 'WithFlags')] + list(capability_methods)
dbus_content = ('<!DOCTYPE busconfig PUBLIC "-//freedesktop//DTD D-BUS Bus Configuration 1.0//EN"\n'
' "http://www.freedesktop.org/standards/dbus/1.0/busconfig.dtd">\n'
'<busconfig>\n <policy user=' + quoteattr(args.user) + '>\n' + ''.join(
' <deny send_destination="org.freedesktop.login1" '
'send_interface="org.freedesktop.login1.Manager" send_member="' + m + '"/>\n'
for m in members) + ' </policy>\n</busconfig>\n')
dbus_manifest = backup / 'dbus-manifest.json'
if args.action == 'apply':
backup.mkdir(parents=True, exist_ok=True, mode=0o700)
if not manifest.exists():
exists = target.exists()
if exists:
shutil.copy2(target, backup / 'original')
manifest.write_text(json.dumps({'user': args.user, 'path': str(target), 'existed': exists}))
saved = json.loads(manifest.read_text())
if saved['user'] != args.user or saved['path'] != str(target):
raise RuntimeError('Backup belongs to a different target')
target.parent.mkdir(parents=True, exist_ok=True)
temporary = target.with_suffix(target.suffix + '.tmp')
temporary.write_text(content)
temporary.chmod(0o644)
temporary.replace(target)
# Active-session authorization can still grant Reboot. Enforce the
# boundary at the bus, including the menu's capability queries.
if not dbus_manifest.exists():
if dbus_path.exists():
shutil.copy2(dbus_path, backup / 'dbus-original')
dbus_manifest.write_text(json.dumps({'user': args.user, 'existed': dbus_path.exists()}))
if json.loads(dbus_manifest.read_text())['user'] != args.user:
raise RuntimeError('D-Bus backup belongs to a different user')
dbus_tmp = dbus_path.with_suffix('.conf.tmp')
dbus_tmp.write_text(dbus_content)
dbus_tmp.chmod(0o644)
dbus_tmp.replace(dbus_path)
reload_result = run(['busctl', '--system', 'call', 'org.freedesktop.DBus',
'/org/freedesktop/DBus', 'org.freedesktop.DBus', 'ReloadConfig'])
if reload_result.returncode:
raise RuntimeError('D-Bus reload failed: ' + reload_result.stderr)
time.sleep(2) # Both polkit backends monitor their policy directories.
elif args.action == 'restore':
saved = json.loads(manifest.read_text())
if saved['user'] != args.user or saved['path'] != str(target):
raise RuntimeError('Backup belongs to a different target')
dbus_saved = json.loads(dbus_manifest.read_text())
if dbus_saved['user'] != args.user:
raise RuntimeError('D-Bus backup belongs to another user')
if saved['existed']:
shutil.copy2(backup / 'original', target)
elif target.exists():
target.unlink()
if dbus_saved['existed']:
shutil.copy2(backup / 'dbus-original', dbus_path)
elif dbus_path.exists():
dbus_path.unlink()
reload_result = run(['busctl', '--system', 'call', 'org.freedesktop.DBus',
'/org/freedesktop/DBus', 'org.freedesktop.DBus', 'ReloadConfig'])
if reload_result.returncode:
raise RuntimeError('D-Bus reload failed: ' + reload_result.stderr)
time.sleep(2)
capabilities = {}
for method in capability_methods:
result = run(['runuser', '-u', args.user, '--', 'busctl', '--system', 'call',
'org.freedesktop.login1', '/org/freedesktop/login1',
'org.freedesktop.login1.Manager', method])
capabilities[method] = {'code': result.returncode, 'value': result.stdout.strip(),
'error': result.stderr.strip()}
active = target.exists() and target.read_text() == content
dbus_active = dbus_path.exists() and dbus_path.read_text() == dbus_content
bus_denials = {}
if args.action == 'apply':
# Deliberately use a STRING argument. Every real power method here
# requires a boolean, flags, or a different signature, so even if the
# bus rule failed, logind rejects this before any power action runs.
# AccessDenied (rather than InvalidArgs) proves the bus blocks delivery.
for method in methods:
result = run(['runuser', '-u', args.user, '--', 'busctl', '--system', 'call',
'org.freedesktop.login1', '/org/freedesktop/login1',
'org.freedesktop.login1.Manager', method, 's', 'VDI_INVALID_SIGNATURE'])
bus_denials[method] = {'code': result.returncode, 'error': result.stderr.strip()}
print(json.dumps({'action': args.action, 'user': args.user, 'backend': backend,
'path': str(target), 'backup': str(backup), 'policy_matches': active,
'dbus_policy_matches': dbus_active, 'dbus_denials': bus_denials,
'boot_id': Path('/proc/sys/kernel/random/boot_id').read_text().strip(),
'capabilities': capabilities}))
if args.action == 'apply' and (not active or not dbus_active or any(
not (entry['code'] and 'Access denied' in entry['error'])
for entry in capabilities.values()) or any(
not entry['code'] or not any(message in entry['error'] for message in
('Rejected send message', 'Access denied'))
for entry in bus_denials.values())):
raise SystemExit('Policy installed but denial verification failed; inspect capabilities')
if __name__ == '__main__':
main()

View File

@ -0,0 +1,117 @@
#!/usr/bin/env python3
"""Isolated PVE Q35 smoke test: no guest disk, network backend or passed GPU.
Runs a paused TCG QEMU process and checks PCI properties and usb-redir hotplug.
It never connects to a managed VM's QMP socket or changes PVE configuration.
"""
import importlib.util
import json
from pathlib import Path
import socket
import subprocess
import tempfile
import time
spec = importlib.util.spec_from_file_location('policy', Path(__file__).with_name('set-fixed-pci.py'))
policy = importlib.util.module_from_spec(spec)
spec.loader.exec_module(policy)
def main():
with tempfile.TemporaryDirectory(prefix='vdi-fixed-pci-smoke-') as tmp:
path = str(Path(tmp) / 'qmp.sock')
flags = [part for value in policy.FIXED_GLOBALS for part in ('-global', value)]
cmd = ['/usr/bin/kvm', '-machine', 'pc-q35-11.0+pve0,accel=tcg', '-m', '128',
'-nodefaults', '-display', 'none', '-S',
'-readconfig', '/usr/share/qemu-server/pve-q35-4.0.cfg',
'-qmp', 'unix:' + path + ',server=on,wait=off', *flags,
'-device', 'virtio-net-pci,id=diagnet,bus=pci.0,addr=0x12',
'-device', 'virtio-serial,id=diagserial,bus=pci.0,addr=0x8',
'-device', 'ahci,id=diagahci,bus=pci.0,addr=0x7']
proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
try:
deadline = time.monotonic() + 15
while not Path(path).exists():
if proc.poll() is not None:
raise RuntimeError(proc.stderr.read().decode())
if time.monotonic() > deadline:
raise RuntimeError('QEMU startup timed out')
time.sleep(.1)
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
sock.settimeout(5)
sock.connect(path)
with sock.makefile('rwb', buffering=0) as stream:
print('QEMU_VERSION', json.loads(stream.readline())['QMP']['version'])
sequence = 0
def call(name, arguments=None):
nonlocal sequence
sequence += 1
ident = sequence
obj = {'execute': name, 'id': ident}
if arguments is not None:
obj['arguments'] = arguments
stream.write(json.dumps(obj).encode() + b'\n')
while True:
line = stream.readline()
if not line:
raise RuntimeError('QMP disconnected')
obj = json.loads(line)
if obj.get('id') == ident:
if 'error' in obj:
raise RuntimeError(str(obj['error']))
return obj['return']
call('qmp_capabilities')
for port in range(1, 5):
value = call('qom-get', {'path': f'/machine/peripheral/ich9-pcie-port-{port}',
'property': 'hotplug'})
if value is not False:
raise RuntimeError('Root port remains hotpluggable')
print('PCIE_PORT_HOTPLUG_OFF: 4/4')
for bridge in range(4):
value = call('qom-get', {'path': f'/machine/peripheral/pci.{bridge}',
'property': 'shpc'})
if value is not False:
raise RuntimeError('SHPC remains enabled')
print('PCI_BRIDGE_SHPC_OFF: 4/4')
lpc = [p for p in call('qom-list', {'path': '/machine/unattached'})
if p['type'] == 'child<ICH9-LPC>']
if len(lpc) != 1:
raise RuntimeError('Cannot uniquely identify ICH9 LPC')
value = call('qom-get', {'path': '/machine/unattached/' + lpc[0]['name'],
'property': 'acpi-pci-hotplug-with-bridge-support'})
if value is not False:
raise RuntimeError('ACPI PCI hotplug remains enabled')
print('ACPI_PCI_HOTPLUG_OFF')
call('chardev-add', {'id': 'diag-channel',
'backend': {'type': 'null', 'data': {}}})
call('device_add', {'driver': 'usb-redir', 'id': 'diag-usb',
'chardev': 'diag-channel', 'bus': 'ehci-2.0',
'filter': '0x08:-1:-1:-1:1|-1:-1:-1:-1:0'})
if 'diag-usb' not in call('human-monitor-command', {'command-line': 'info usb'}):
raise RuntimeError('USB device not attached')
print('USB_REDIR_ADD_OK')
call('device_del', {'id': 'diag-usb'})
deadline = time.monotonic() + 5
while any(p['name'] == 'diag-usb' for p in
call('qom-list', {'path': '/machine/peripheral'})):
if time.monotonic() > deadline:
raise RuntimeError('USB removal pending')
time.sleep(.1)
# QEMU 11 can remove the anonymous backend with the device.
if any(p['label'] == 'diag-channel' for p in call('query-chardev')):
call('chardev-remove', {'id': 'diag-channel'})
print('USB_REDIR_REMOVE_OK')
call('quit')
proc.wait(timeout=5)
print('ISOLATED_SMOKE_PASS (Windows UI and physical USB not tested)')
finally:
if proc.poll() is None:
proc.terminate()
proc.wait(timeout=5)
print('QEMU_STDERR:', proc.stderr.read().decode())
if __name__ == '__main__':
main()

View File

@ -0,0 +1,37 @@
import importlib.util
from pathlib import Path
import shlex
import unittest
from unittest.mock import patch
spec = importlib.util.spec_from_file_location("fixed_pci", Path(__file__).parents[1] / "set-fixed-pci.py")
policy = importlib.util.module_from_spec(spec)
spec.loader.exec_module(policy)
class FixedPciPolicyTests(unittest.TestCase):
def test_keeps_gpu_identity_and_quoted_arguments(self):
old = '-set device.hostpci0.x-pci-sub-vendor-id=0x1558 -set device.hostpci0.x-pci-sub-device-id=0x6557 -name "desktop test"'
updated = policy.fixed_args(old)
self.assertEqual(shlex.split(updated)[:len(shlex.split(old))], shlex.split(old))
self.assertTrue(updated.startswith(old))
self.assertEqual(policy.fixed_args(updated), updated)
self.assertFalse(any("usb" in token.lower() for token in shlex.split(updated)))
def test_conflicting_explicit_hotplug_setting_requires_review(self):
with self.assertRaisesRegex(ValueError, "Conflicting"):
policy.fixed_args('-global pcie-root-port.hotplug=on')
def test_online_vm_cannot_be_changed(self):
with patch.object(policy, "command", return_value="status: running") as call:
with self.assertRaisesRegex(RuntimeError, "normally shut down"):
policy.require_stopped(100)
call.assert_called_once_with(["qm", "status", "100"])
def test_stopped_vm_can_proceed(self):
with patch.object(policy, "command", return_value="status: stopped"):
policy.require_stopped(100)
if __name__ == '__main__':
unittest.main()

View File

@ -0,0 +1,76 @@
"""Reboot evidence/state tests without restarting or changing a real box."""
import importlib.util
from importlib.machinery import SourceFileLoader
import json
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
script = Path(__file__).resolve().parents[2]/"terminal/kiosk-shell/scripts/vdi-overnight-observer"
spec = importlib.util.spec_from_loader("observer", SourceFileLoader("observer", str(script)))
m = importlib.util.module_from_spec(spec); spec.loader.exec_module(m)
class RebootEvidence(unittest.TestCase):
def baseline(self): return m.transition(None, "boot-a", "start")[0]
def test_baseline_zero_reboots(self):
state,event=m.transition(None,"boot-a","start")
self.assertEqual(event,"boot_baseline"); self.assertEqual(state["reboots"],0)
def test_service_restart_is_not_box_reboot(self):
before=self.baseline(); after,event=m.transition(before,"boot-a","later")
self.assertEqual(event,"observer_restart_same_boot"); self.assertEqual(after["reboots"],0)
self.assertEqual(after["started_at"],"start")
def test_boot_change_preserves_last_heartbeat(self):
before=self.baseline(); before.update(last_seen="last",uptime_seconds=100)
after,event=m.transition(before,"boot-b","next")
self.assertEqual(event,"boot_change"); self.assertEqual(after["reboots"],1)
self.assertEqual(after["history"][-1]["previous_last_seen"],"last")
self.assertEqual(before["reboots"],0)
def test_unknown_reason_is_not_reported_as_power_loss(self):
text=m.classify({})
self.assertIn("未确认",text); self.assertIn("不能区分",text)
def test_orderly_shutdown_requires_real_markers(self):
self.assertIn("正常关机",m.classify({"tail":{"text":"systemd-shutdown[1]: Syncing filesystems"}}))
self.assertIn("未确认",m.classify({"tail":{"text":"USB power management enabled; watchdog capability present"}}))
def test_oom_is_a_clue_not_a_proven_reboot_cause(self):
text=m.classify({"warnings":{"text":"Out of memory: Killed process 123"}})
self.assertIn("不能单独认定",text)
def test_only_changed_pstore_is_a_new_clue(self):
archive={"text":"Kernel panic - not syncing: example"}
self.assertIn("未确认",m.classify({"old_pstore":archive}))
self.assertIn("不能单独认定",m.classify({"pstore_new":archive}))
def test_invalid_state_preserved(self):
for value in ({"version":2,"reboots":0},{"version":1,"reboots":True}):
with self.assertRaises(ValueError): m.transition(value,"new","now")
def test_multiple_boots_and_history_bound(self):
state=self.baseline()
for i in range(40): state,event=m.transition(state,"next-"+str(i),"now")
self.assertEqual(state["reboots"],40); self.assertEqual(len(state["history"]),32)
def test_durable_state_and_chinese_report(self):
with tempfile.TemporaryDirectory() as d:
o=m.Observer(Path(d)/"logs",Path(d)/"state"); o.state=self.baseline(); o.save()
self.assertEqual(json.loads(o.state_file.read_text())["boot_id"],"boot-a")
self.assertIn("重启次数:0",(o.logs/"overnight-summary.txt").read_text(encoding="utf-8"))
def test_daily_rollover_uses_each_event_date(self):
from datetime import datetime,timezone
with tempfile.TemporaryDirectory() as d:
o=m.Observer(Path(d)/"logs",Path(d)/"state")
for day in (4,5):
with patch.object(m,"datetime") as clock:
clock.now.return_value=datetime(2026,11,day,0,0,tzinfo=timezone.utc)
o.log("sample")
self.assertEqual(len(list(o.logs.glob("*.log"))),2)
if __name__=="__main__": unittest.main()