mirror of
https://github.com/rustdesk/rustdesk.git
synced 2026-05-20 20:48:39 +03:00
Compare commits
19 Commits
terminal-u
...
deploy
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d26100db01 | ||
|
|
0851cf4d38 | ||
|
|
66e39abb74 | ||
|
|
95758b1a47 | ||
|
|
2685a25e51 | ||
|
|
2643f00216 | ||
|
|
e222127009 | ||
|
|
dd265dadd7 | ||
|
|
fe5a8cb2ad | ||
|
|
b6caa1a7b2 | ||
|
|
55c9707639 | ||
|
|
d8808baa83 | ||
|
|
1978020d27 | ||
|
|
0e4b91b8d7 | ||
|
|
9c831dc59b | ||
|
|
b757e97c11 | ||
|
|
9df486a689 | ||
|
|
72d27c3c47 | ||
|
|
6c20fc936d |
4
Cargo.lock
generated
4
Cargo.lock
generated
@@ -5996,8 +5996,8 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "parity-tokio-ipc"
|
name = "parity-tokio-ipc"
|
||||||
version = "0.7.3-5"
|
version = "0.7.3-6"
|
||||||
source = "git+https://github.com/rustdesk-org/parity-tokio-ipc#c8c8bbcbabf9be1201c53afb0269b92b9b02d291"
|
source = "git+https://github.com/rustdesk-org/parity-tokio-ipc#d0ae39bffe5d5a3e8d82a1b6bcb1ca5a9b2f1c01"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures",
|
"futures",
|
||||||
"libc",
|
"libc",
|
||||||
|
|||||||
@@ -716,6 +716,17 @@ closeConnection({String? id}) {
|
|||||||
stateGlobal.isInMainPage = true;
|
stateGlobal.isInMainPage = true;
|
||||||
} else {
|
} else {
|
||||||
final controller = Get.find<DesktopTabController>();
|
final controller = Get.find<DesktopTabController>();
|
||||||
|
if (controller.tabType == DesktopTabType.terminal &&
|
||||||
|
controller.onCloseWindow != null) {
|
||||||
|
// Terminal windows are scoped to one peer. The optional id passed to
|
||||||
|
// closeConnection() is that peer id, not a terminal tab key
|
||||||
|
// (${peerId}_${terminalId}). Closing from terminal dialogs should close
|
||||||
|
// the peer's whole terminal window, including all terminal tabs.
|
||||||
|
unawaited(controller.onCloseWindow!().catchError((e, _) {
|
||||||
|
debugPrint('[closeConnection] Failed to close terminal window: $e');
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
controller.closeBy(id);
|
controller.closeBy(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4179,8 +4190,7 @@ Widget? buildAvatarWidget({
|
|||||||
width: size,
|
width: size,
|
||||||
height: size,
|
height: size,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
errorBuilder: (_, __, ___) =>
|
errorBuilder: (_, __, ___) => fallback ?? SizedBox.shrink(),
|
||||||
fallback ?? SizedBox.shrink(),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,12 @@ import 'package:get/get.dart';
|
|||||||
|
|
||||||
bool isEditOsPassword = false;
|
bool isEditOsPassword = false;
|
||||||
|
|
||||||
|
// macOS privacy mode blacks out all online displays, so switching the remote
|
||||||
|
// display does not weaken the local privacy protection.
|
||||||
|
bool allowDisplaySwitchInPrivacyMode(PeerInfo pi) {
|
||||||
|
return pi.platform == kPeerPlatformMacOS;
|
||||||
|
}
|
||||||
|
|
||||||
class TTextMenu {
|
class TTextMenu {
|
||||||
final Widget child;
|
final Widget child;
|
||||||
final VoidCallback? onPressed;
|
final VoidCallback? onPressed;
|
||||||
@@ -684,8 +690,9 @@ Future<List<TToggleMenu>> toolbarDisplayToggle(
|
|||||||
child: Text(translate('Lock after session end'))));
|
child: Text(translate('Lock after session end'))));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final privacyModeState = PrivacyModeState.find(id);
|
||||||
if (pi.isSupportMultiDisplay &&
|
if (pi.isSupportMultiDisplay &&
|
||||||
PrivacyModeState.find(id).isEmpty &&
|
(privacyModeState.isEmpty || allowDisplaySwitchInPrivacyMode(pi)) &&
|
||||||
pi.displaysCount.value > 1 &&
|
pi.displaysCount.value > 1 &&
|
||||||
bind.mainGetUserDefaultOption(key: kKeyShowMonitorsToolbar) == 'Y') {
|
bind.mainGetUserDefaultOption(key: kKeyShowMonitorsToolbar) == 'Y') {
|
||||||
final value =
|
final value =
|
||||||
@@ -776,7 +783,8 @@ List<TToggleMenu> toolbarPrivacyMode(
|
|||||||
onChanged: enabled
|
onChanged: enabled
|
||||||
? (value) {
|
? (value) {
|
||||||
if (value == null) return;
|
if (value == null) return;
|
||||||
if (ffiModel.pi.currentDisplay != 0 &&
|
if (!allowDisplaySwitchInPrivacyMode(pi) &&
|
||||||
|
ffiModel.pi.currentDisplay != 0 &&
|
||||||
ffiModel.pi.currentDisplay != kAllDisplayValue) {
|
ffiModel.pi.currentDisplay != kAllDisplayValue) {
|
||||||
msgBox(
|
msgBox(
|
||||||
sessionId,
|
sessionId,
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ class TerminalPage extends StatefulWidget {
|
|||||||
final bool? isSharedPassword;
|
final bool? isSharedPassword;
|
||||||
final String? connToken;
|
final String? connToken;
|
||||||
final int terminalId;
|
final int terminalId;
|
||||||
|
|
||||||
/// Tab key for focus management, passed from parent to avoid duplicate construction
|
/// Tab key for focus management, passed from parent to avoid duplicate construction
|
||||||
final String tabKey;
|
final String tabKey;
|
||||||
final SimpleWrapper<State<TerminalPage>?> _lastState = SimpleWrapper(null);
|
final SimpleWrapper<State<TerminalPage>?> _lastState = SimpleWrapper(null);
|
||||||
@@ -43,6 +44,9 @@ class TerminalPage extends StatefulWidget {
|
|||||||
|
|
||||||
class _TerminalPageState extends State<TerminalPage>
|
class _TerminalPageState extends State<TerminalPage>
|
||||||
with AutomaticKeepAliveClientMixin {
|
with AutomaticKeepAliveClientMixin {
|
||||||
|
static const EdgeInsets _defaultTerminalPadding =
|
||||||
|
EdgeInsets.symmetric(horizontal: 5.0, vertical: 2.0);
|
||||||
|
|
||||||
late FFI _ffi;
|
late FFI _ffi;
|
||||||
late TerminalModel _terminalModel;
|
late TerminalModel _terminalModel;
|
||||||
double? _cellHeight;
|
double? _cellHeight;
|
||||||
@@ -155,13 +159,27 @@ class _TerminalPageState extends State<TerminalPage>
|
|||||||
// extra space left after dividing the available height by the height of a single
|
// extra space left after dividing the available height by the height of a single
|
||||||
// terminal row (`_cellHeight`) and distributing it evenly as top and bottom padding.
|
// terminal row (`_cellHeight`) and distributing it evenly as top and bottom padding.
|
||||||
EdgeInsets _calculatePadding(double heightPx) {
|
EdgeInsets _calculatePadding(double heightPx) {
|
||||||
if (_cellHeight == null) {
|
final cellHeight = _cellHeight;
|
||||||
return const EdgeInsets.symmetric(horizontal: 5.0, vertical: 2.0);
|
if (!heightPx.isFinite ||
|
||||||
|
heightPx <= 0 ||
|
||||||
|
cellHeight == null ||
|
||||||
|
!cellHeight.isFinite ||
|
||||||
|
cellHeight <= 0) {
|
||||||
|
return _defaultTerminalPadding;
|
||||||
|
}
|
||||||
|
final rows = (heightPx / cellHeight).floor();
|
||||||
|
if (rows <= 0) {
|
||||||
|
return _defaultTerminalPadding;
|
||||||
|
}
|
||||||
|
final extraSpace = heightPx - rows * cellHeight;
|
||||||
|
if (!extraSpace.isFinite || extraSpace < 0) {
|
||||||
|
return _defaultTerminalPadding;
|
||||||
}
|
}
|
||||||
final rows = (heightPx / _cellHeight!).floor();
|
|
||||||
final extraSpace = heightPx - rows * _cellHeight!;
|
|
||||||
final topBottom = extraSpace / 2.0;
|
final topBottom = extraSpace / 2.0;
|
||||||
return EdgeInsets.symmetric(horizontal: 5.0, vertical: topBottom);
|
return EdgeInsets.symmetric(
|
||||||
|
horizontal: _defaultTerminalPadding.horizontal / 2,
|
||||||
|
vertical: topBottom,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
|
|||||||
.setTitle(getWindowNameWithId(id));
|
.setTitle(getWindowNameWithId(id));
|
||||||
};
|
};
|
||||||
tabController.onRemoved = (_, id) => onRemoveId(id);
|
tabController.onRemoved = (_, id) => onRemoveId(id);
|
||||||
|
tabController.onCloseWindow = _closeWindowFromConnection;
|
||||||
final terminalId = params['terminalId'] ?? _nextTerminalId++;
|
final terminalId = params['terminalId'] ?? _nextTerminalId++;
|
||||||
tabController.add(_createTerminalTab(
|
tabController.add(_createTerminalTab(
|
||||||
peerId: params['id'],
|
peerId: params['id'],
|
||||||
@@ -144,6 +145,8 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
|
|||||||
_windowClosing = true;
|
_windowClosing = true;
|
||||||
final tabKeys = tabController.state.value.tabs.map((t) => t.key).toList();
|
final tabKeys = tabController.state.value.tabs.map((t) => t.key).toList();
|
||||||
// Remove all UI tabs immediately (same instant behavior as the old tabController.clear())
|
// Remove all UI tabs immediately (same instant behavior as the old tabController.clear())
|
||||||
|
// Keep the cleanup target lookup below synchronous before its first await:
|
||||||
|
// it relies on the current frame still retaining each TerminalPage's FFI/model.
|
||||||
tabController.clear();
|
tabController.clear();
|
||||||
// Run session cleanup in parallel with bounded timeout (closeTerminal() has internal 3s timeout).
|
// Run session cleanup in parallel with bounded timeout (closeTerminal() has internal 3s timeout).
|
||||||
// Skip tabs already being closed by a concurrent _closeTab() to avoid duplicate FFI calls.
|
// Skip tabs already being closed by a concurrent _closeTab() to avoid duplicate FFI calls.
|
||||||
@@ -368,8 +371,34 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
|
|||||||
final persistentSessions =
|
final persistentSessions =
|
||||||
args['persistent_sessions'] as List<dynamic>? ?? [];
|
args['persistent_sessions'] as List<dynamic>? ?? [];
|
||||||
final sortedSessions = persistentSessions.whereType<int>().toList()..sort();
|
final sortedSessions = persistentSessions.whereType<int>().toList()..sort();
|
||||||
|
var peerId = args['peer_id'] as String? ?? '';
|
||||||
|
if (peerId.isEmpty) {
|
||||||
|
if (tabController.state.value.tabs.isEmpty ||
|
||||||
|
tabController.state.value.selected >=
|
||||||
|
tabController.state.value.tabs.length) {
|
||||||
|
debugPrint('[TerminalTabPage] Skip restore: no selected tab');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final currentTab = tabController.state.value.selectedTabInfo;
|
||||||
|
final parsed = _parseTabKey(currentTab.key);
|
||||||
|
if (parsed == null) return;
|
||||||
|
peerId = parsed.$1;
|
||||||
|
}
|
||||||
|
final existingTerminalIds = tabController.state.value.tabs
|
||||||
|
.map((tab) => _parseTabKey(tab.key))
|
||||||
|
.where((parsed) => parsed != null && parsed.$1 == peerId)
|
||||||
|
.map((parsed) => parsed!.$2)
|
||||||
|
.toSet();
|
||||||
|
if (existingTerminalIds.isEmpty) {
|
||||||
|
debugPrint(
|
||||||
|
'[TerminalTabPage] Skip restore: no seed tab for peer $peerId');
|
||||||
|
return;
|
||||||
|
}
|
||||||
for (final terminalId in sortedSessions) {
|
for (final terminalId in sortedSessions) {
|
||||||
_addNewTerminalForCurrentPeer(terminalId: terminalId);
|
if (!existingTerminalIds.add(terminalId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
_addNewTerminal(peerId, terminalId: terminalId);
|
||||||
// A delay is required to ensure the UI has sufficient time to update
|
// A delay is required to ensure the UI has sufficient time to update
|
||||||
// before adding the next terminal. Without this delay, `_TerminalPageState::dispose()`
|
// before adding the next terminal. Without this delay, `_TerminalPageState::dispose()`
|
||||||
// may be called prematurely while the tab widget is still in the tab controller.
|
// may be called prematurely while the tab widget is still in the tab controller.
|
||||||
@@ -546,6 +575,11 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _closeWindowFromConnection() async {
|
||||||
|
await _closeAllTabs();
|
||||||
|
await WindowController.fromWindowId(windowId()).close();
|
||||||
|
}
|
||||||
|
|
||||||
int windowId() {
|
int windowId() {
|
||||||
return widget.params["windowId"];
|
return widget.params["windowId"];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -376,7 +376,8 @@ class _RemoteToolbarState extends State<RemoteToolbar> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
toolbarItems.add(Obx(() {
|
toolbarItems.add(Obx(() {
|
||||||
if (PrivacyModeState.find(widget.id).isEmpty &&
|
if ((PrivacyModeState.find(widget.id).isEmpty ||
|
||||||
|
allowDisplaySwitchInPrivacyMode(pi)) &&
|
||||||
pi.displaysCount.value > 1) {
|
pi.displaysCount.value > 1) {
|
||||||
return _MonitorMenu(
|
return _MonitorMenu(
|
||||||
id: widget.id,
|
id: widget.id,
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ class DesktopTabController {
|
|||||||
/// index, key
|
/// index, key
|
||||||
Function(int, String)? onRemoved;
|
Function(int, String)? onRemoved;
|
||||||
Function(String)? onSelected;
|
Function(String)? onSelected;
|
||||||
|
Future<void> Function()? onCloseWindow;
|
||||||
|
|
||||||
DesktopTabController(
|
DesktopTabController(
|
||||||
{required this.tabType, this.onRemoved, this.onSelected});
|
{required this.tabType, this.onRemoved, this.onSelected});
|
||||||
@@ -592,13 +593,13 @@ class _DesktopTabState extends State<DesktopTab>
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildBar() {
|
Widget _buildBar() {
|
||||||
|
final isIncomingHomePage = bind.isIncomingOnly() && isInHomePage();
|
||||||
return Row(
|
return Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
// custom double tap handler
|
// custom double tap handler
|
||||||
onTap: !(bind.isIncomingOnly() && isInHomePage()) &&
|
onTap: !isIncomingHomePage && showMaximize
|
||||||
showMaximize
|
|
||||||
? () {
|
? () {
|
||||||
final current = DateTime.now().millisecondsSinceEpoch;
|
final current = DateTime.now().millisecondsSinceEpoch;
|
||||||
final elapsed = current - _lastClickTime;
|
final elapsed = current - _lastClickTime;
|
||||||
@@ -609,7 +610,7 @@ class _DesktopTabState extends State<DesktopTab>
|
|||||||
.then((value) => stateGlobal.setMaximized(value));
|
.then((value) => stateGlobal.setMaximized(value));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
: null,
|
: (isIncomingHomePage ? () {} : null), // Keep tap recognizer for Windows touch.
|
||||||
onPanStart: (_) => startDragging(isMainWindow),
|
onPanStart: (_) => startDragging(isMainWindow),
|
||||||
onPanCancel: () {
|
onPanCancel: () {
|
||||||
// We want to disable dragging of the tab area in the tab bar.
|
// We want to disable dragging of the tab area in the tab bar.
|
||||||
|
|||||||
@@ -391,14 +391,30 @@ class FileController {
|
|||||||
|
|
||||||
await Future.delayed(Duration(milliseconds: 100));
|
await Future.delayed(Duration(milliseconds: 100));
|
||||||
|
|
||||||
final dir = (await bind.sessionGetPeerOption(
|
final savedDir = (await bind.sessionGetPeerOption(
|
||||||
sessionId: sessionId, name: isLocal ? "local_dir" : "remote_dir"));
|
sessionId: sessionId, name: isLocal ? "local_dir" : "remote_dir"));
|
||||||
openDirectory(dir.isEmpty ? options.value.home : dir);
|
Future<bool> tryOpenReadyDirs() async {
|
||||||
|
final dirs = <String>{
|
||||||
|
if (directory.value.path.isNotEmpty) directory.value.path,
|
||||||
|
if (savedDir.isNotEmpty) savedDir,
|
||||||
|
options.value.home,
|
||||||
|
};
|
||||||
|
for (final dir in dirs) {
|
||||||
|
if (await _openDirectoryPath(dir, isBack: true)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var opened = await tryOpenReadyDirs();
|
||||||
|
|
||||||
await Future.delayed(Duration(seconds: 1));
|
await Future.delayed(Duration(seconds: 1));
|
||||||
|
|
||||||
if (directory.value.path.isEmpty) {
|
if (!opened) {
|
||||||
openDirectory(options.value.home);
|
// The peer may become ready during the reconnect delay, so retry the
|
||||||
|
// same candidates instead of only retrying the default home directory.
|
||||||
|
await tryOpenReadyDirs();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -429,19 +445,23 @@ class FileController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> refresh() async {
|
Future<bool> refresh() async {
|
||||||
await openDirectory(directory.value.path);
|
// "." can be both a refresh command and a real remote directory path.
|
||||||
|
// Refresh must bypass openDirectory's command dispatch to avoid recursion.
|
||||||
|
return await _openDirectoryPath(directory.value.path, isBack: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> openDirectory(String path, {bool isBack = false}) async {
|
Future<bool> openDirectory(String path, {bool isBack = false}) async {
|
||||||
if (path == ".") {
|
if (!isBack && path == ".") {
|
||||||
refresh();
|
return await refresh();
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
if (path == "..") {
|
if (!isBack && path == "..") {
|
||||||
goToParentDirectory();
|
return await _goToParentDirectory(isBack: isBack);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
return await _openDirectoryPath(path, isBack: isBack);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _openDirectoryPath(String path, {bool isBack = false}) async {
|
||||||
if (!isBack) {
|
if (!isBack) {
|
||||||
pushHistory();
|
pushHistory();
|
||||||
}
|
}
|
||||||
@@ -458,8 +478,10 @@ class FileController {
|
|||||||
final fd = await fileFetcher.fetchDirectory(path, isLocal, showHidden);
|
final fd = await fileFetcher.fetchDirectory(path, isLocal, showHidden);
|
||||||
fd.format(isWindows, sort: sortBy.value);
|
fd.format(isWindows, sort: sortBy.value);
|
||||||
directory.value = fd;
|
directory.value = fd;
|
||||||
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint("Failed to openDirectory $path: $e");
|
debugPrint("Failed to openDirectory $path: $e");
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -487,19 +509,22 @@ class FileController {
|
|||||||
goBack();
|
goBack();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
openDirectory(path, isBack: true);
|
unawaited(_openDirectoryPath(path, isBack: true).then<void>((_) {}));
|
||||||
}
|
}
|
||||||
|
|
||||||
void goToParentDirectory() {
|
void goToParentDirectory() {
|
||||||
|
unawaited(_goToParentDirectory().then<void>((_) {}));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _goToParentDirectory({bool isBack = false}) async {
|
||||||
final isWindows = options.value.isWindows;
|
final isWindows = options.value.isWindows;
|
||||||
final dirPath = directory.value.path;
|
final dirPath = directory.value.path;
|
||||||
var parent = PathUtil.dirname(dirPath, isWindows);
|
var parent = PathUtil.dirname(dirPath, isWindows);
|
||||||
// specially for C:\, D:\, goto '/'
|
// specially for C:\, D:\, goto '/'
|
||||||
if (parent == dirPath && isWindows) {
|
if (parent == dirPath && isWindows) {
|
||||||
openDirectory('/');
|
return await _openDirectoryPath('/', isBack: isBack);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
openDirectory(parent);
|
return await _openDirectoryPath(parent, isBack: isBack);
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO deprecated this
|
// TODO deprecated this
|
||||||
|
|||||||
@@ -27,25 +27,30 @@ class TerminalModel with ChangeNotifier {
|
|||||||
// Buffer for output data received before terminal view has valid dimensions.
|
// Buffer for output data received before terminal view has valid dimensions.
|
||||||
// This prevents NaN errors when writing to terminal before layout is complete.
|
// This prevents NaN errors when writing to terminal before layout is complete.
|
||||||
final _pendingOutputChunks = <String>[];
|
final _pendingOutputChunks = <String>[];
|
||||||
|
final _pendingOutputSuppressFlags = <bool>[];
|
||||||
int _pendingOutputSize = 0;
|
int _pendingOutputSize = 0;
|
||||||
static const int _kMaxOutputBufferChars = 8 * 1024;
|
static const int _kMaxOutputBufferChars = 8 * 1024;
|
||||||
// View ready state: true when terminal has valid dimensions, safe to write
|
// View ready state: true when terminal has valid dimensions, safe to write
|
||||||
bool _terminalViewReady = false;
|
bool _terminalViewReady = false;
|
||||||
|
bool _markViewReadyScheduled = false;
|
||||||
bool get isPeerWindows => parent.ffiModel.pi.platform == kPeerPlatformWindows;
|
bool _suppressTerminalOutput = false;
|
||||||
|
bool _suppressNextTerminalDataOutput = false;
|
||||||
|
|
||||||
void Function(int w, int h, int pw, int ph)? onResizeExternal;
|
void Function(int w, int h, int pw, int ph)? onResizeExternal;
|
||||||
|
|
||||||
Future<void> _handleInput(String data) async {
|
Future<void> _handleInput(String data) async {
|
||||||
// If we press the `Enter` button on Android,
|
// Soft keyboards (notably iOS) emit '\n' when Enter is pressed, while a
|
||||||
// `data` can be '\r' or '\n' when using different keyboards.
|
// real keyboard's Enter sends '\r'. Some Android keyboards also emit '\n'.
|
||||||
// Android -> Windows. '\r' works, but '\n' does not. '\n' is just a newline.
|
// - Peer Windows: '\r' works, '\n' is just a newline.
|
||||||
// Android -> Linux. Both '\r' and '\n' work as expected (execute a command).
|
// - Peer Linux: canonical-mode shells accept both, but raw-mode apps
|
||||||
// So when we receive '\n', we may need to convert it to '\r' to ensure compatibility.
|
// (readline, prompt_toolkit, vim, TUI frameworks) expect '\r'.
|
||||||
// Desktop -> Desktop works fine.
|
// - Peer macOS: same as Linux, raw-mode apps expect '\r'
|
||||||
// Check if we are on mobile or web(mobile), and convert '\n' to '\r'.
|
// (https://github.com/rustdesk/rustdesk/issues/14907).
|
||||||
|
// So on mobile / web-mobile, always normalize a lone '\n' to '\r'.
|
||||||
|
// We deliberately do not touch multi-character payloads (e.g. pasted text)
|
||||||
|
// so embedded newlines in pasted content are preserved.
|
||||||
final isMobileOrWebMobile = (isMobile || (isWeb && !isWebDesktop));
|
final isMobileOrWebMobile = (isMobile || (isWeb && !isWebDesktop));
|
||||||
if (isMobileOrWebMobile && isPeerWindows && data == '\n') {
|
if (isMobileOrWebMobile && data == '\n') {
|
||||||
data = '\r';
|
data = '\r';
|
||||||
}
|
}
|
||||||
if (_terminalOpened) {
|
if (_terminalOpened) {
|
||||||
@@ -70,7 +75,10 @@ class TerminalModel with ChangeNotifier {
|
|||||||
terminalController = TerminalController();
|
terminalController = TerminalController();
|
||||||
|
|
||||||
// Setup terminal callbacks
|
// Setup terminal callbacks
|
||||||
terminal.onOutput = _handleInput;
|
terminal.onOutput = (data) {
|
||||||
|
if (_suppressTerminalOutput) return;
|
||||||
|
_handleInput(data);
|
||||||
|
};
|
||||||
|
|
||||||
terminal.onResize = (w, h, pw, ph) async {
|
terminal.onResize = (w, h, pw, ph) async {
|
||||||
// Validate all dimensions before using them
|
// Validate all dimensions before using them
|
||||||
@@ -84,7 +92,7 @@ class TerminalModel with ChangeNotifier {
|
|||||||
// Mark terminal view as ready and flush any buffered output on first valid resize.
|
// Mark terminal view as ready and flush any buffered output on first valid resize.
|
||||||
// Must be after onResizeExternal so the view layer has valid dimensions before flushing.
|
// Must be after onResizeExternal so the view layer has valid dimensions before flushing.
|
||||||
if (!_terminalViewReady) {
|
if (!_terminalViewReady) {
|
||||||
_markViewReady();
|
_scheduleMarkViewReady();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_terminalOpened) {
|
if (_terminalOpened) {
|
||||||
@@ -110,14 +118,16 @@ class TerminalModel with ChangeNotifier {
|
|||||||
void onReady() {
|
void onReady() {
|
||||||
parent.dialogManager.dismissAll();
|
parent.dialogManager.dismissAll();
|
||||||
|
|
||||||
// Fire and forget - don't block onReady
|
// Fire and forget - don't block onReady. If the transport reconnects while
|
||||||
openTerminal().catchError((e) {
|
// this model is still open, re-send OpenTerminal so the remote service marks
|
||||||
|
// the persistent session active again and resumes output streaming.
|
||||||
|
openTerminal(force: _terminalOpened).catchError((e) {
|
||||||
debugPrint('[TerminalModel] Error opening terminal: $e');
|
debugPrint('[TerminalModel] Error opening terminal: $e');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> openTerminal() async {
|
Future<void> openTerminal({bool force = false}) async {
|
||||||
if (_terminalOpened) return;
|
if (_terminalOpened && !force) return;
|
||||||
// Request the remote side to open a terminal with default shell
|
// Request the remote side to open a terminal with default shell
|
||||||
// The remote side will decide which shell to use based on its OS
|
// The remote side will decide which shell to use based on its OS
|
||||||
|
|
||||||
@@ -275,9 +285,12 @@ class TerminalModel with ChangeNotifier {
|
|||||||
if (success) {
|
if (success) {
|
||||||
_terminalOpened = true;
|
_terminalOpened = true;
|
||||||
|
|
||||||
// On reconnect ("Reconnected to existing terminal"), server may replay recent output.
|
// On reconnect, the server may replay recent output. That replay can include
|
||||||
// If this TerminalView instance is reused (not rebuilt), duplicate lines can appear.
|
// terminal queries like DSR/DA; xterm answers them through onOutput as
|
||||||
// We intentionally accept this tradeoff for now to keep logic simple.
|
// "^[[1;1R^[[2;2R^[[>0;0;0c", which must not be sent back to the peer.
|
||||||
|
final replayTerminalOutput = evt['replay_terminal_output'];
|
||||||
|
_suppressNextTerminalDataOutput = replayTerminalOutput == true ||
|
||||||
|
message == 'Reconnected to existing terminal with pending output';
|
||||||
|
|
||||||
// Fallback: if terminal view is not yet ready but already has valid
|
// Fallback: if terminal view is not yet ready but already has valid
|
||||||
// dimensions (e.g. layout completed before open response arrived),
|
// dimensions (e.g. layout completed before open response arrived),
|
||||||
@@ -285,7 +298,7 @@ class TerminalModel with ChangeNotifier {
|
|||||||
if (!_terminalViewReady &&
|
if (!_terminalViewReady &&
|
||||||
terminal.viewWidth > 0 &&
|
terminal.viewWidth > 0 &&
|
||||||
terminal.viewHeight > 0) {
|
terminal.viewHeight > 0) {
|
||||||
_markViewReady();
|
_scheduleMarkViewReady();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process any buffered input
|
// Process any buffered input
|
||||||
@@ -297,12 +310,16 @@ class TerminalModel with ChangeNotifier {
|
|||||||
});
|
});
|
||||||
|
|
||||||
final persistentSessions =
|
final persistentSessions =
|
||||||
evt['persistent_sessions'] as List<dynamic>? ?? [];
|
(evt['persistent_sessions'] as List<dynamic>? ?? [])
|
||||||
|
.whereType<int>()
|
||||||
|
.where((id) => !parent.terminalModels.containsKey(id))
|
||||||
|
.toList();
|
||||||
if (kWindowId != null && persistentSessions.isNotEmpty) {
|
if (kWindowId != null && persistentSessions.isNotEmpty) {
|
||||||
DesktopMultiWindow.invokeMethod(
|
DesktopMultiWindow.invokeMethod(
|
||||||
kWindowId!,
|
kWindowId!,
|
||||||
kWindowEventRestoreTerminalSessions,
|
kWindowEventRestoreTerminalSessions,
|
||||||
jsonEncode({
|
jsonEncode({
|
||||||
|
'peer_id': id,
|
||||||
'persistent_sessions': persistentSessions,
|
'persistent_sessions': persistentSessions,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -332,6 +349,8 @@ class TerminalModel with ChangeNotifier {
|
|||||||
final data = evt['data'];
|
final data = evt['data'];
|
||||||
|
|
||||||
if (data != null) {
|
if (data != null) {
|
||||||
|
final suppressTerminalOutput = _suppressNextTerminalDataOutput;
|
||||||
|
_suppressNextTerminalDataOutput = false;
|
||||||
try {
|
try {
|
||||||
String text = '';
|
String text = '';
|
||||||
if (data is String) {
|
if (data is String) {
|
||||||
@@ -351,7 +370,7 @@ class TerminalModel with ChangeNotifier {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_writeToTerminal(text);
|
_writeToTerminal(text, suppressTerminalOutput: suppressTerminalOutput);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('[TerminalModel] Failed to process terminal data: $e');
|
debugPrint('[TerminalModel] Failed to process terminal data: $e');
|
||||||
}
|
}
|
||||||
@@ -361,7 +380,10 @@ class TerminalModel with ChangeNotifier {
|
|||||||
/// Write text to terminal, buffering if the view is not yet ready.
|
/// Write text to terminal, buffering if the view is not yet ready.
|
||||||
/// All terminal output should go through this method to avoid NaN errors
|
/// All terminal output should go through this method to avoid NaN errors
|
||||||
/// from writing before the terminal view has valid layout dimensions.
|
/// from writing before the terminal view has valid layout dimensions.
|
||||||
void _writeToTerminal(String text) {
|
void _writeToTerminal(
|
||||||
|
String text, {
|
||||||
|
bool suppressTerminalOutput = false,
|
||||||
|
}) {
|
||||||
if (!_terminalViewReady) {
|
if (!_terminalViewReady) {
|
||||||
// If a single chunk exceeds the cap, keep only its tail.
|
// If a single chunk exceeds the cap, keep only its tail.
|
||||||
// Note: truncation may split a multi-byte ANSI escape sequence,
|
// Note: truncation may split a multi-byte ANSI escape sequence,
|
||||||
@@ -373,34 +395,73 @@ class TerminalModel with ChangeNotifier {
|
|||||||
_pendingOutputChunks
|
_pendingOutputChunks
|
||||||
..clear()
|
..clear()
|
||||||
..add(truncated);
|
..add(truncated);
|
||||||
|
_pendingOutputSuppressFlags
|
||||||
|
..clear()
|
||||||
|
..add(suppressTerminalOutput);
|
||||||
_pendingOutputSize = truncated.length;
|
_pendingOutputSize = truncated.length;
|
||||||
} else {
|
} else {
|
||||||
_pendingOutputChunks.add(text);
|
_pendingOutputChunks.add(text);
|
||||||
|
_pendingOutputSuppressFlags.add(suppressTerminalOutput);
|
||||||
_pendingOutputSize += text.length;
|
_pendingOutputSize += text.length;
|
||||||
// Drop oldest chunks if exceeds limit (whole chunks to preserve ANSI sequences)
|
// Drop oldest chunks if exceeds limit (whole chunks to preserve ANSI sequences)
|
||||||
while (_pendingOutputSize > _kMaxOutputBufferChars &&
|
while (_pendingOutputSize > _kMaxOutputBufferChars &&
|
||||||
_pendingOutputChunks.length > 1) {
|
_pendingOutputChunks.length > 1) {
|
||||||
final removed = _pendingOutputChunks.removeAt(0);
|
final removed = _pendingOutputChunks.removeAt(0);
|
||||||
|
_pendingOutputSuppressFlags.removeAt(0);
|
||||||
_pendingOutputSize -= removed.length;
|
_pendingOutputSize -= removed.length;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
terminal.write(text);
|
_writeTerminalChunk(text, suppressTerminalOutput: suppressTerminalOutput);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _flushOutputBuffer() {
|
void _flushOutputBuffer() {
|
||||||
if (_pendingOutputChunks.isEmpty) return;
|
if (_pendingOutputChunks.isEmpty) return;
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'[TerminalModel] Flushing $_pendingOutputSize buffered chars (${_pendingOutputChunks.length} chunks)');
|
'[TerminalModel] Flushing $_pendingOutputSize buffered chars (${_pendingOutputChunks.length} chunks)');
|
||||||
for (final chunk in _pendingOutputChunks) {
|
for (var i = 0; i < _pendingOutputChunks.length; i++) {
|
||||||
terminal.write(chunk);
|
_writeTerminalChunk(
|
||||||
|
_pendingOutputChunks[i],
|
||||||
|
suppressTerminalOutput: _pendingOutputSuppressFlags[i],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
_pendingOutputChunks.clear();
|
_pendingOutputChunks.clear();
|
||||||
|
_pendingOutputSuppressFlags.clear();
|
||||||
_pendingOutputSize = 0;
|
_pendingOutputSize = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _writeTerminalChunk(
|
||||||
|
String text, {
|
||||||
|
required bool suppressTerminalOutput,
|
||||||
|
}) {
|
||||||
|
if (!suppressTerminalOutput) {
|
||||||
|
terminal.write(text);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final previous = _suppressTerminalOutput;
|
||||||
|
_suppressTerminalOutput = true;
|
||||||
|
try {
|
||||||
|
terminal.write(text);
|
||||||
|
} finally {
|
||||||
|
_suppressTerminalOutput = previous;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Mark terminal view as ready and flush buffered output.
|
/// Mark terminal view as ready and flush buffered output.
|
||||||
|
void _scheduleMarkViewReady() {
|
||||||
|
if (_disposed || _terminalViewReady || _markViewReadyScheduled) return;
|
||||||
|
_markViewReadyScheduled = true;
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
_markViewReadyScheduled = false;
|
||||||
|
if (_disposed || _terminalViewReady) return;
|
||||||
|
if (terminal.viewWidth > 0 && terminal.viewHeight > 0) {
|
||||||
|
_markViewReady();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
WidgetsBinding.instance.ensureVisualUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
void _markViewReady() {
|
void _markViewReady() {
|
||||||
if (_terminalViewReady) return;
|
if (_terminalViewReady) return;
|
||||||
_terminalViewReady = true;
|
_terminalViewReady = true;
|
||||||
@@ -426,7 +487,10 @@ class TerminalModel with ChangeNotifier {
|
|||||||
// Clear buffers to free memory
|
// Clear buffers to free memory
|
||||||
_inputBuffer.clear();
|
_inputBuffer.clear();
|
||||||
_pendingOutputChunks.clear();
|
_pendingOutputChunks.clear();
|
||||||
|
_pendingOutputSuppressFlags.clear();
|
||||||
_pendingOutputSize = 0;
|
_pendingOutputSize = 0;
|
||||||
|
_markViewReadyScheduled = false;
|
||||||
|
_suppressNextTerminalDataOutput = false;
|
||||||
// Terminal cleanup is handled server-side when service closes
|
// Terminal cleanup is handled server-side when service closes
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|||||||
Submodule libs/hbb_common updated: 3e31a94939...c8cbb6be28
@@ -31,17 +31,17 @@ LExit:
|
|||||||
return WcaFinalize(er);
|
return WcaFinalize(er);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to safely delete a file or directory using handle-based deletion.
|
// Helper function to safely delete a file using handle-based deletion.
|
||||||
// This avoids TOCTOU (Time-Of-Check-Time-Of-Use) race conditions.
|
// Directories are refused after opening the handle.
|
||||||
BOOL SafeDeleteItem(LPCWSTR fullPath)
|
BOOL SafeDeleteItem(LPCWSTR fullPath)
|
||||||
{
|
{
|
||||||
// Open the file/directory with DELETE access and FILE_FLAG_OPEN_REPARSE_POINT
|
// Open the file/directory with delete and attribute-read access plus FILE_FLAG_OPEN_REPARSE_POINT
|
||||||
// to prevent following symlinks.
|
// to prevent following symlinks.
|
||||||
// Use shared access to allow deletion even when other processes have the file open.
|
// Use shared access to allow deletion even when other processes have the file open.
|
||||||
DWORD flags = FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT;
|
DWORD flags = FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT;
|
||||||
HANDLE hFile = CreateFileW(
|
HANDLE hFile = CreateFileW(
|
||||||
fullPath,
|
fullPath,
|
||||||
DELETE,
|
DELETE | FILE_READ_ATTRIBUTES,
|
||||||
FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, // Allow shared access
|
FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, // Allow shared access
|
||||||
NULL,
|
NULL,
|
||||||
OPEN_EXISTING,
|
OPEN_EXISTING,
|
||||||
@@ -55,6 +55,21 @@ BOOL SafeDeleteItem(LPCWSTR fullPath)
|
|||||||
return FALSE;
|
return FALSE;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
BY_HANDLE_FILE_INFORMATION fileInfo;
|
||||||
|
if (FALSE == GetFileInformationByHandle(hFile, &fileInfo))
|
||||||
|
{
|
||||||
|
WcaLog(LOGMSG_STANDARD, "SafeDeleteItem: Failed to inspect '%ls'. Error: %lu", fullPath, GetLastError());
|
||||||
|
CloseHandle(hFile);
|
||||||
|
return FALSE;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fileInfo.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY)
|
||||||
|
{
|
||||||
|
WcaLog(LOGMSG_STANDARD, "SafeDeleteItem: Refusing to delete directory '%ls'.", fullPath);
|
||||||
|
CloseHandle(hFile);
|
||||||
|
return FALSE;
|
||||||
|
}
|
||||||
|
|
||||||
// Use SetFileInformationByHandle to mark for deletion.
|
// Use SetFileInformationByHandle to mark for deletion.
|
||||||
// The file will be deleted when the handle is closed.
|
// The file will be deleted when the handle is closed.
|
||||||
FILE_DISPOSITION_INFO dispInfo;
|
FILE_DISPOSITION_INFO dispInfo;
|
||||||
@@ -77,98 +92,74 @@ BOOL SafeDeleteItem(LPCWSTR fullPath)
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to recursively delete a directory's contents with detailed logging.
|
BOOL PathEndsWithSlash(LPCWSTR path)
|
||||||
void RecursiveDelete(LPCWSTR path)
|
|
||||||
{
|
{
|
||||||
// Ensure the path is not empty or null.
|
size_t length = 0;
|
||||||
if (path == NULL || path[0] == L'\0')
|
HRESULT hr = StringCchLengthW(path, MAX_PATH, &length);
|
||||||
|
if (FAILED(hr) || length == 0)
|
||||||
|
{
|
||||||
|
return FALSE;
|
||||||
|
}
|
||||||
|
|
||||||
|
WCHAR last = path[length - 1];
|
||||||
|
return last == L'\\' || last == L'/';
|
||||||
|
}
|
||||||
|
|
||||||
|
void ClearReadOnlyAttribute(LPCWSTR fullPath, DWORD attributes)
|
||||||
|
{
|
||||||
|
if (!(attributes & FILE_ATTRIBUTE_READONLY))
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extra safety: never operate directly on a root path.
|
DWORD writableAttributes = attributes & ~FILE_ATTRIBUTE_READONLY;
|
||||||
if (PathIsRootW(path))
|
if (writableAttributes == 0)
|
||||||
{
|
{
|
||||||
WcaLog(LOGMSG_STANDARD, "RecursiveDelete: refusing to operate on root path '%ls'.", path);
|
writableAttributes = FILE_ATTRIBUTE_NORMAL;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (SetFileAttributesW(fullPath, writableAttributes))
|
||||||
|
{
|
||||||
|
WcaLog(LOGMSG_STANDARD, "Runtime cleanup cleared read-only attribute for '%ls'.", fullPath);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// MAX_PATH is enough here since the installer should not be using longer paths.
|
WcaLog(LOGMSG_STANDARD, "Runtime cleanup failed to clear read-only attribute for '%ls'. Error: %lu", fullPath, GetLastError());
|
||||||
// No need to handle extended-length paths (\\?\) in this context.
|
}
|
||||||
WCHAR searchPath[MAX_PATH];
|
|
||||||
HRESULT hr = StringCchPrintfW(searchPath, MAX_PATH, L"%s\\*", path);
|
BOOL DeleteRuntimeGeneratedFile(LPCWSTR installFolder, LPCWSTR fileName)
|
||||||
if (FAILED(hr)) {
|
{
|
||||||
WcaLog(LOGMSG_STANDARD, "RecursiveDelete: Path too long to enumerate: %ls", path);
|
WCHAR fullPath[MAX_PATH];
|
||||||
return;
|
LPCWSTR separator = PathEndsWithSlash(installFolder) ? L"" : L"\\";
|
||||||
|
HRESULT hr = StringCchPrintfW(fullPath, MAX_PATH, L"%s%s%s", installFolder, separator, fileName);
|
||||||
|
if (FAILED(hr))
|
||||||
|
{
|
||||||
|
WcaLog(LOGMSG_STANDARD, "Runtime cleanup path is too long for '%ls'.", fileName);
|
||||||
|
return FALSE;
|
||||||
}
|
}
|
||||||
|
|
||||||
WIN32_FIND_DATAW findData;
|
DWORD attributes = GetFileAttributesW(fullPath);
|
||||||
HANDLE hFind = FindFirstFileW(searchPath, &findData);
|
if (attributes == INVALID_FILE_ATTRIBUTES)
|
||||||
|
|
||||||
if (hFind == INVALID_HANDLE_VALUE)
|
|
||||||
{
|
{
|
||||||
// This can happen if the directory is empty or doesn't exist, which is not an error in our case.
|
DWORD error = GetLastError();
|
||||||
WcaLog(LOGMSG_STANDARD, "RecursiveDelete: Failed to enumerate directory '%ls'. It may be missing or inaccessible. Error: %lu", path, GetLastError());
|
if (error == ERROR_FILE_NOT_FOUND || error == ERROR_PATH_NOT_FOUND)
|
||||||
return;
|
{
|
||||||
|
return TRUE;
|
||||||
|
}
|
||||||
|
|
||||||
|
WcaLog(LOGMSG_STANDARD, "Runtime cleanup cannot stat '%ls'. Error: %lu", fullPath, error);
|
||||||
|
return FALSE;
|
||||||
}
|
}
|
||||||
|
|
||||||
do
|
if (attributes & FILE_ATTRIBUTE_DIRECTORY)
|
||||||
{
|
{
|
||||||
// Skip '.' and '..' directories.
|
WcaLog(LOGMSG_STANDARD, "Runtime cleanup skipped directory '%ls'.", fullPath);
|
||||||
if (wcscmp(findData.cFileName, L".") == 0 || wcscmp(findData.cFileName, L"..") == 0)
|
return FALSE;
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// MAX_PATH is enough here since the installer should not be using longer paths.
|
|
||||||
// No need to handle extended-length paths (\\?\) in this context.
|
|
||||||
WCHAR fullPath[MAX_PATH];
|
|
||||||
hr = StringCchPrintfW(fullPath, MAX_PATH, L"%s\\%s", path, findData.cFileName);
|
|
||||||
if (FAILED(hr)) {
|
|
||||||
WcaLog(LOGMSG_STANDARD, "RecursiveDelete: Path too long for item '%ls' in '%ls', skipping.", findData.cFileName, path);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Before acting, ensure the read-only attribute is not set.
|
|
||||||
if (findData.dwFileAttributes & FILE_ATTRIBUTE_READONLY)
|
|
||||||
{
|
|
||||||
if (FALSE == SetFileAttributesW(fullPath, findData.dwFileAttributes & ~FILE_ATTRIBUTE_READONLY))
|
|
||||||
{
|
|
||||||
WcaLog(LOGMSG_STANDARD, "RecursiveDelete: Failed to remove read-only attribute. Error: %lu", GetLastError());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (findData.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY)
|
|
||||||
{
|
|
||||||
// Check for reparse points (symlinks/junctions) to prevent directory traversal attacks.
|
|
||||||
// Do not follow reparse points, only remove the link itself.
|
|
||||||
if (findData.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT)
|
|
||||||
{
|
|
||||||
WcaLog(LOGMSG_STANDARD, "RecursiveDelete: Not recursing into reparse point (symlink/junction), deleting link itself: %ls", fullPath);
|
|
||||||
SafeDeleteItem(fullPath);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// Recursively delete directory contents first
|
|
||||||
RecursiveDelete(fullPath);
|
|
||||||
// Then delete the directory itself
|
|
||||||
SafeDeleteItem(fullPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// Delete file using safe handle-based deletion
|
|
||||||
SafeDeleteItem(fullPath);
|
|
||||||
}
|
|
||||||
} while (FindNextFileW(hFind, &findData) != 0);
|
|
||||||
|
|
||||||
DWORD lastError = GetLastError();
|
|
||||||
if (lastError != ERROR_NO_MORE_FILES)
|
|
||||||
{
|
|
||||||
WcaLog(LOGMSG_STANDARD, "RecursiveDelete: FindNextFileW failed with error %lu", lastError);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
FindClose(hFind);
|
ClearReadOnlyAttribute(fullPath, attributes);
|
||||||
|
WcaLog(LOGMSG_STANDARD, "Runtime cleanup deleting '%ls'.", fullPath);
|
||||||
|
return SafeDeleteItem(fullPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
// See `Package.wxs` for the sequence of this custom action.
|
// See `Package.wxs` for the sequence of this custom action.
|
||||||
@@ -178,13 +169,13 @@ void RecursiveDelete(LPCWSTR path)
|
|||||||
// 2. RemoveExistingProducts
|
// 2. RemoveExistingProducts
|
||||||
// ├─ TerminateProcesses
|
// ├─ TerminateProcesses
|
||||||
// ├─ TryStopDeleteService
|
// ├─ TryStopDeleteService
|
||||||
// ├─ RemoveInstallFolder - <-- Here
|
// ├─ RemoveRuntimeGeneratedFiles - <-- Here
|
||||||
// └─ RemoveFiles
|
// └─ RemoveFiles
|
||||||
// 3. InstallValidate
|
// 3. InstallValidate
|
||||||
// 4. InstallFiles
|
// 4. InstallFiles
|
||||||
// 5. InstallExecute
|
// 5. InstallExecute
|
||||||
// 6. InstallFinalize
|
// 6. InstallFinalize
|
||||||
UINT __stdcall RemoveInstallFolder(
|
UINT __stdcall RemoveRuntimeGeneratedFiles(
|
||||||
__in MSIHANDLE hInstall)
|
__in MSIHANDLE hInstall)
|
||||||
{
|
{
|
||||||
HRESULT hr = S_OK;
|
HRESULT hr = S_OK;
|
||||||
@@ -194,7 +185,7 @@ UINT __stdcall RemoveInstallFolder(
|
|||||||
LPWSTR pwz = NULL;
|
LPWSTR pwz = NULL;
|
||||||
LPWSTR pwzData = NULL;
|
LPWSTR pwzData = NULL;
|
||||||
|
|
||||||
hr = WcaInitialize(hInstall, "RemoveInstallFolder");
|
hr = WcaInitialize(hInstall, "RemoveRuntimeGeneratedFiles");
|
||||||
ExitOnFailure(hr, "Failed to initialize");
|
ExitOnFailure(hr, "Failed to initialize");
|
||||||
|
|
||||||
hr = WcaGetProperty(L"CustomActionData", &pwzData);
|
hr = WcaGetProperty(L"CustomActionData", &pwzData);
|
||||||
@@ -202,24 +193,20 @@ UINT __stdcall RemoveInstallFolder(
|
|||||||
|
|
||||||
pwz = pwzData;
|
pwz = pwzData;
|
||||||
hr = WcaReadStringFromCaData(&pwz, &installFolder);
|
hr = WcaReadStringFromCaData(&pwz, &installFolder);
|
||||||
ExitOnFailure(hr, "failed to read database key from custom action data: %ls", pwz);
|
ExitOnFailure(hr, "failed to read install folder from custom action data: %ls", pwz);
|
||||||
|
|
||||||
if (installFolder == NULL || installFolder[0] == L'\0') {
|
if (installFolder == NULL || installFolder[0] == L'\0') {
|
||||||
WcaLog(LOGMSG_STANDARD, "Install folder path is empty, skipping recursive delete.");
|
WcaLog(LOGMSG_STANDARD, "Install folder path is empty, skipping runtime cleanup.");
|
||||||
goto LExit;
|
goto LExit;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (PathIsRootW(installFolder)) {
|
if (PathIsRootW(installFolder)) {
|
||||||
WcaLog(LOGMSG_STANDARD, "Refusing to recursively delete root folder '%ls'.", installFolder);
|
WcaLog(LOGMSG_STANDARD, "Refusing runtime cleanup in root folder '%ls'.", installFolder);
|
||||||
goto LExit;
|
goto LExit;
|
||||||
}
|
}
|
||||||
|
|
||||||
WcaLog(LOGMSG_STANDARD, "Attempting to recursively delete contents of install folder: %ls", installFolder);
|
WcaLog(LOGMSG_STANDARD, "Removing runtime-generated files from install folder: %ls", installFolder);
|
||||||
|
DeleteRuntimeGeneratedFile(installFolder, L"RuntimeBroker_rustdesk.exe");
|
||||||
RecursiveDelete(installFolder);
|
|
||||||
|
|
||||||
// The standard MSI 'RemoveFolders' action will take care of removing the (now empty) directories.
|
|
||||||
// We don't need to call RemoveDirectoryW on installFolder itself, as it might still be in use by the installer.
|
|
||||||
|
|
||||||
LExit:
|
LExit:
|
||||||
ReleaseStr(pwzData);
|
ReleaseStr(pwzData);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ LIBRARY "CustomActions"
|
|||||||
|
|
||||||
EXPORTS
|
EXPORTS
|
||||||
CustomActionHello
|
CustomActionHello
|
||||||
RemoveInstallFolder
|
RemoveRuntimeGeneratedFiles
|
||||||
TerminateProcesses
|
TerminateProcesses
|
||||||
AddFirewallRules
|
AddFirewallRules
|
||||||
SetPropertyIsServiceRunning
|
SetPropertyIsServiceRunning
|
||||||
|
|||||||
@@ -16,8 +16,15 @@
|
|||||||
<!-- If a command line value was stored, restore it after the registry search has been performed -->
|
<!-- If a command line value was stored, restore it after the registry search has been performed -->
|
||||||
<SetProperty Action="RestoreSavedInstallFolderValue" Id="INSTALLFOLDER" Value="[SavedInstallFolderCmdLineValue]" After="AppSearch" Sequence="first" Condition="SavedInstallFolderCmdLineValue" />
|
<SetProperty Action="RestoreSavedInstallFolderValue" Id="INSTALLFOLDER" Value="[SavedInstallFolderCmdLineValue]" After="AppSearch" Sequence="first" Condition="SavedInstallFolderCmdLineValue" />
|
||||||
|
|
||||||
<!-- If a command line value or registry value was set, update the main properties with the value -->
|
<!-- Normalize INSTALLFOLDER from the command line or registry before assigning INSTALLFOLDER_INNER. -->
|
||||||
<SetProperty Id="INSTALLFOLDER_INNER" Value="[INSTALLFOLDER]" After="RestoreSavedInstallFolderValue" Sequence="first" Condition="INSTALLFOLDER" />
|
<!-- Case 1: already ends with \$(var.Product)\, keep it unchanged. -->
|
||||||
|
<SetProperty Action="SetInstallFolderInnerFromProductDir" Id="INSTALLFOLDER_INNER" Value="[INSTALLFOLDER]" After="RestoreSavedInstallFolderValue" Sequence="first" Condition="INSTALLFOLDER AND INSTALLFOLDER ~>> "\$(var.Product)\"" />
|
||||||
|
<!-- Case 2: already ends with \$(var.Product) but has no trailing slash, add the slash. -->
|
||||||
|
<SetProperty Action="SetInstallFolderInnerFromProductDirNoSlash" Id="INSTALLFOLDER_INNER" Value="[INSTALLFOLDER]\" After="RestoreSavedInstallFolderValue" Sequence="first" Condition="INSTALLFOLDER AND INSTALLFOLDER ~>> "\$(var.Product)"" />
|
||||||
|
<!-- Case 3: ends with a slash but not \$(var.Product)\, append $(var.Product)\. -->
|
||||||
|
<SetProperty Action="SetInstallFolderInnerAppendProduct" Id="INSTALLFOLDER_INNER" Value="[INSTALLFOLDER]$(var.Product)\" After="RestoreSavedInstallFolderValue" Sequence="first" Condition="INSTALLFOLDER AND INSTALLFOLDER ~>> "\" AND NOT (INSTALLFOLDER ~>> "\$(var.Product)\" OR INSTALLFOLDER ~>> "\$(var.Product)")" />
|
||||||
|
<!-- Case 4: has no trailing slash and does not end with \$(var.Product), append \$(var.Product)\. -->
|
||||||
|
<SetProperty Action="SetInstallFolderInnerAppendSlashProduct" Id="INSTALLFOLDER_INNER" Value="[INSTALLFOLDER]\$(var.Product)\" After="RestoreSavedInstallFolderValue" Sequence="first" Condition="INSTALLFOLDER AND NOT INSTALLFOLDER ~>> "\" AND NOT (INSTALLFOLDER ~>> "\$(var.Product)\" OR INSTALLFOLDER ~>> "\$(var.Product)")" />
|
||||||
|
|
||||||
<!-- INSTALLFOLDER_INNER is defined for compatibility with previous versions of the installer. -->
|
<!-- INSTALLFOLDER_INNER is defined for compatibility with previous versions of the installer. -->
|
||||||
<!-- Because we need to use INSTALLFOLDER as the command line argument. -->
|
<!-- Because we need to use INSTALLFOLDER as the command line argument. -->
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
</Component>
|
</Component>
|
||||||
</DirectoryRef>
|
</DirectoryRef>
|
||||||
|
|
||||||
<CustomAction Id="RemoveInstallFolder.SetParam" Return="check" Property="RemoveInstallFolder" Value="[INSTALLFOLDER_INNER]" />
|
<CustomAction Id="RemoveRuntimeGeneratedFiles.SetParam" Return="check" Property="RemoveRuntimeGeneratedFiles" Value="[INSTALLFOLDER_INNER]" />
|
||||||
<CustomAction Id="AddFirewallRules.SetParam" Return="check" Property="AddFirewallRules" Value="1[INSTALLFOLDER_INNER]$(var.Product).exe" />
|
<CustomAction Id="AddFirewallRules.SetParam" Return="check" Property="AddFirewallRules" Value="1[INSTALLFOLDER_INNER]$(var.Product).exe" />
|
||||||
<CustomAction Id="RemoveFirewallRules.SetParam" Return="check" Property="RemoveFirewallRules" Value="0[INSTALLFOLDER_INNER]$(var.Product).exe" />
|
<CustomAction Id="RemoveFirewallRules.SetParam" Return="check" Property="RemoveFirewallRules" Value="0[INSTALLFOLDER_INNER]$(var.Product).exe" />
|
||||||
<CustomAction Id="CreateStartService.SetParam" Return="check" Property="CreateStartService" Value="$(var.Product);"[INSTALLFOLDER_INNER]$(var.Product).exe" --service" />
|
<CustomAction Id="CreateStartService.SetParam" Return="check" Property="CreateStartService" Value="$(var.Product);"[INSTALLFOLDER_INNER]$(var.Product).exe" --service" />
|
||||||
@@ -77,21 +77,21 @@
|
|||||||
|
|
||||||
<Custom Action="AddRegSoftwareSASGeneration" Before="InstallFinalize" Condition="NOT (Installed AND REMOVE AND NOT UPGRADINGPRODUCTCODE) AND (NOT CC_CONNECTION_TYPE="outgoing")"/>
|
<Custom Action="AddRegSoftwareSASGeneration" Before="InstallFinalize" Condition="NOT (Installed AND REMOVE AND NOT UPGRADINGPRODUCTCODE) AND (NOT CC_CONNECTION_TYPE="outgoing")"/>
|
||||||
|
|
||||||
<Custom Action="RemoveInstallFolder" Before="RemoveFiles"/>
|
<Custom Action="RemoveRuntimeGeneratedFiles" Before="RemoveFiles" Condition="Installed AND (REMOVE="ALL" OR UPGRADINGPRODUCTCODE)"/>
|
||||||
<Custom Action="RemoveInstallFolder.SetParam" Before="RemoveInstallFolder"/>
|
<Custom Action="RemoveRuntimeGeneratedFiles.SetParam" Before="RemoveRuntimeGeneratedFiles" Condition="Installed AND (REMOVE="ALL" OR UPGRADINGPRODUCTCODE)"/>
|
||||||
<Custom Action="TryStopDeleteService" Before="RemoveInstallFolder.SetParam" />
|
<Custom Action="TryStopDeleteService" Before="RemoveRuntimeGeneratedFiles.SetParam" />
|
||||||
<Custom Action="TryStopDeleteService.SetParam" Before="TryStopDeleteService" />
|
<Custom Action="TryStopDeleteService.SetParam" Before="TryStopDeleteService" />
|
||||||
|
|
||||||
<Custom Action="RemoveFirewallRules" Before="RemoveFiles"/>
|
<Custom Action="RemoveFirewallRules" Before="RemoveFiles"/>
|
||||||
<Custom Action="RemoveFirewallRules.SetParam" Before="RemoveFirewallRules"/>
|
<Custom Action="RemoveFirewallRules.SetParam" Before="RemoveFirewallRules"/>
|
||||||
|
|
||||||
<Custom Action="UninstallPrinter" Before="RemoveInstallFolder" Condition="VersionNT >= 603" />
|
<Custom Action="UninstallPrinter" Before="RemoveRuntimeGeneratedFiles" Condition="VersionNT >= 603" />
|
||||||
|
|
||||||
<Custom Action="TerminateProcesses" Before="RemoveInstallFolder"/>
|
<Custom Action="TerminateProcesses" Before="RemoveRuntimeGeneratedFiles"/>
|
||||||
<Custom Action="TerminateProcesses.SetParam" Before="TerminateProcesses"/>
|
<Custom Action="TerminateProcesses.SetParam" Before="TerminateProcesses"/>
|
||||||
<Custom Action="TerminateBrokers" Before="RemoveInstallFolder"/>
|
<Custom Action="TerminateBrokers" Before="RemoveRuntimeGeneratedFiles"/>
|
||||||
<Custom Action="TerminateBrokers.SetParam" Before="TerminateBrokers"/>
|
<Custom Action="TerminateBrokers.SetParam" Before="TerminateBrokers"/>
|
||||||
<Custom Action="RemoveAmyuniIdd" Before="RemoveInstallFolder"/>
|
<Custom Action="RemoveAmyuniIdd" Before="RemoveRuntimeGeneratedFiles"/>
|
||||||
<Custom Action="RemoveAmyuniIdd.SetParam" Before="RemoveAmyuniIdd"/>
|
<Custom Action="RemoveAmyuniIdd.SetParam" Before="RemoveAmyuniIdd"/>
|
||||||
</InstallExecuteSequence>
|
</InstallExecuteSequence>
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<Binary Id="Custom_Actions_Dll" SourceFile="$(var.CustomActions.TargetDir)$(var.CustomActions.TargetName).dll" />
|
<Binary Id="Custom_Actions_Dll" SourceFile="$(var.CustomActions.TargetDir)$(var.CustomActions.TargetName).dll" />
|
||||||
|
|
||||||
<CustomAction Id="CustomActionHello" DllEntry="CustomActionHello" Impersonate="yes" Execute="immediate" Return="ignore" BinaryRef="Custom_Actions_Dll"/>
|
<CustomAction Id="CustomActionHello" DllEntry="CustomActionHello" Impersonate="yes" Execute="immediate" Return="ignore" BinaryRef="Custom_Actions_Dll"/>
|
||||||
<CustomAction Id="RemoveInstallFolder" DllEntry="RemoveInstallFolder" Impersonate="no" Execute="deferred" Return="ignore" BinaryRef="Custom_Actions_Dll"/>
|
<CustomAction Id="RemoveRuntimeGeneratedFiles" DllEntry="RemoveRuntimeGeneratedFiles" Impersonate="no" Execute="deferred" Return="ignore" BinaryRef="Custom_Actions_Dll"/>
|
||||||
<CustomAction Id="TerminateProcesses" DllEntry="TerminateProcesses" Impersonate="yes" Execute="immediate" Return="ignore" BinaryRef="Custom_Actions_Dll"/>
|
<CustomAction Id="TerminateProcesses" DllEntry="TerminateProcesses" Impersonate="yes" Execute="immediate" Return="ignore" BinaryRef="Custom_Actions_Dll"/>
|
||||||
<CustomAction Id="TerminateBrokers" DllEntry="TerminateProcesses" Impersonate="yes" Execute="immediate" Return="ignore" BinaryRef="Custom_Actions_Dll"/>
|
<CustomAction Id="TerminateBrokers" DllEntry="TerminateProcesses" Impersonate="yes" Execute="immediate" Return="ignore" BinaryRef="Custom_Actions_Dll"/>
|
||||||
<CustomAction Id="AddFirewallRules" DllEntry="AddFirewallRules" Impersonate="no" Execute="deferred" Return="ignore" BinaryRef="Custom_Actions_Dll"/>
|
<CustomAction Id="AddFirewallRules" DllEntry="AddFirewallRules" Impersonate="no" Execute="deferred" Return="ignore" BinaryRef="Custom_Actions_Dll"/>
|
||||||
|
|||||||
@@ -23,12 +23,13 @@ Patch dialog sequence:
|
|||||||
-->
|
-->
|
||||||
|
|
||||||
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs" xmlns:ui="http://wixtoolset.org/schemas/v4/wxs/ui">
|
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs" xmlns:ui="http://wixtoolset.org/schemas/v4/wxs/ui">
|
||||||
|
<?include ../Includes.wxi?>
|
||||||
<?foreach WIXUIARCH in X86;X64;A64 ?>
|
<?foreach WIXUIARCH in X86;X64;A64 ?>
|
||||||
<Fragment>
|
<Fragment>
|
||||||
<UI Id="UI_MyInstallDialog_$(WIXUIARCH)">
|
<UI Id="UI_MyInstallDialog_$(WIXUIARCH)">
|
||||||
<Publish Dialog="LicenseAgreementDlg" Control="Print" Event="DoAction" Value="WixUIPrintEula_$(WIXUIARCH)" />
|
<Publish Dialog="LicenseAgreementDlg" Control="Print" Event="DoAction" Value="WixUIPrintEula_$(WIXUIARCH)" />
|
||||||
<Publish Dialog="BrowseDlg" Control="OK" Event="DoAction" Value="WixUIValidatePath_$(WIXUIARCH)" Order="3" Condition="NOT WIXUI_DONTVALIDATEPATH" />
|
<Publish Dialog="BrowseDlg" Control="OK" Event="DoAction" Value="WixUIValidatePath_$(WIXUIARCH)" Order="3" Condition="NOT WIXUI_DONTVALIDATEPATH" />
|
||||||
<Publish Dialog="MyInstallDirDlg" Control="Next" Event="DoAction" Value="WixUIValidatePath_$(WIXUIARCH)" Order="2" Condition="NOT WIXUI_DONTVALIDATEPATH" />
|
<Publish Dialog="MyInstallDirDlg" Control="Next" Event="DoAction" Value="WixUIValidatePath_$(WIXUIARCH)" Order="5" Condition="NOT WIXUI_DONTVALIDATEPATH" />
|
||||||
</UI>
|
</UI>
|
||||||
|
|
||||||
<UIRef Id="UI_MyInstallDialog" />
|
<UIRef Id="UI_MyInstallDialog" />
|
||||||
@@ -64,9 +65,16 @@ Patch dialog sequence:
|
|||||||
<Publish Dialog="LicenseAgreementDlg" Control="Next" Event="NewDialog" Value="MyInstallDirDlg" Condition="LicenseAccepted = "1"" />
|
<Publish Dialog="LicenseAgreementDlg" Control="Next" Event="NewDialog" Value="MyInstallDirDlg" Condition="LicenseAccepted = "1"" />
|
||||||
|
|
||||||
<Publish Dialog="MyInstallDirDlg" Control="Back" Event="NewDialog" Value="LicenseAgreementDlg" />
|
<Publish Dialog="MyInstallDirDlg" Control="Back" Event="NewDialog" Value="LicenseAgreementDlg" />
|
||||||
<Publish Dialog="MyInstallDirDlg" Control="Next" Event="SetTargetPath" Value="[WIXUI_INSTALLDIR]" Order="1" />
|
<!-- Normalize INSTALLFOLDER_INNER before SetTargetPath and WixUIValidatePath run. -->
|
||||||
<Publish Dialog="MyInstallDirDlg" Control="Next" Event="SpawnDialog" Value="InvalidDirDlg" Order="3" Condition="NOT WIXUI_DONTVALIDATEPATH AND WIXUI_INSTALLDIR_VALID<>"1"" />
|
<!-- UI case 1: already ends with \$(var.Product) but has no trailing slash, add the slash. -->
|
||||||
<Publish Dialog="MyInstallDirDlg" Control="Next" Event="NewDialog" Value="VerifyReadyDlg" Order="4" Condition="WIXUI_DONTVALIDATEPATH OR WIXUI_INSTALLDIR_VALID="1"" />
|
<Publish Dialog="MyInstallDirDlg" Control="Next" Property="INSTALLFOLDER_INNER" Value="[INSTALLFOLDER_INNER]\" Order="1" Condition="INSTALLFOLDER_INNER AND INSTALLFOLDER_INNER ~>> "\$(var.Product)"" />
|
||||||
|
<!-- UI case 2: ends with a slash but not \$(var.Product)\, append $(var.Product)\. -->
|
||||||
|
<Publish Dialog="MyInstallDirDlg" Control="Next" Property="INSTALLFOLDER_INNER" Value="[INSTALLFOLDER_INNER]$(var.Product)\" Order="2" Condition="INSTALLFOLDER_INNER AND INSTALLFOLDER_INNER ~>> "\" AND NOT (INSTALLFOLDER_INNER ~>> "\$(var.Product)\" OR INSTALLFOLDER_INNER ~>> "\$(var.Product)")" />
|
||||||
|
<!-- UI case 3: has no trailing slash and does not end with \$(var.Product), append \$(var.Product)\. -->
|
||||||
|
<Publish Dialog="MyInstallDirDlg" Control="Next" Property="INSTALLFOLDER_INNER" Value="[INSTALLFOLDER_INNER]\$(var.Product)\" Order="3" Condition="INSTALLFOLDER_INNER AND NOT INSTALLFOLDER_INNER ~>> "\" AND NOT (INSTALLFOLDER_INNER ~>> "\$(var.Product)\" OR INSTALLFOLDER_INNER ~>> "\$(var.Product)")" />
|
||||||
|
<Publish Dialog="MyInstallDirDlg" Control="Next" Event="SetTargetPath" Value="[WIXUI_INSTALLDIR]" Order="4" />
|
||||||
|
<Publish Dialog="MyInstallDirDlg" Control="Next" Event="SpawnDialog" Value="InvalidDirDlg" Order="6" Condition="NOT WIXUI_DONTVALIDATEPATH AND WIXUI_INSTALLDIR_VALID<>"1"" />
|
||||||
|
<Publish Dialog="MyInstallDirDlg" Control="Next" Event="NewDialog" Value="VerifyReadyDlg" Order="7" Condition="WIXUI_DONTVALIDATEPATH OR WIXUI_INSTALLDIR_VALID="1"" />
|
||||||
<Publish Dialog="MyInstallDirDlg" Control="ChangeFolder" Property="_BrowseProperty" Value="[WIXUI_INSTALLDIR]" Order="1" />
|
<Publish Dialog="MyInstallDirDlg" Control="ChangeFolder" Property="_BrowseProperty" Value="[WIXUI_INSTALLDIR]" Order="1" />
|
||||||
<Publish Dialog="MyInstallDirDlg" Control="ChangeFolder" Event="SpawnDialog" Value="BrowseDlg" Order="2" />
|
<Publish Dialog="MyInstallDirDlg" Control="ChangeFolder" Event="SpawnDialog" Value="BrowseDlg" Order="2" />
|
||||||
<Publish Dialog="VerifyReadyDlg" Control="Back" Event="NewDialog" Value="MyInstallDirDlg" Order="1" Condition="NOT Installed" />
|
<Publish Dialog="VerifyReadyDlg" Control="Back" Event="NewDialog" Value="MyInstallDirDlg" Order="1" Condition="NOT Installed" />
|
||||||
|
|||||||
100
src/core_main.rs
100
src/core_main.rs
@@ -146,7 +146,13 @@ pub fn core_main() -> Option<Vec<String>> {
|
|||||||
crate::portable_service::client::set_quick_support(_is_quick_support);
|
crate::portable_service::client::set_quick_support(_is_quick_support);
|
||||||
}
|
}
|
||||||
let mut log_name = "".to_owned();
|
let mut log_name = "".to_owned();
|
||||||
if args.len() > 0 && args[0].starts_with("--") {
|
// Keep portable-service logs under a stable directory name.
|
||||||
|
let has_portable_service_shmem_arg = args
|
||||||
|
.iter()
|
||||||
|
.any(|arg| arg.starts_with("--portable-service-shmem-name="));
|
||||||
|
if has_portable_service_shmem_arg {
|
||||||
|
log_name = "portable-service".to_owned();
|
||||||
|
} else if args.len() > 0 && args[0].starts_with("--") {
|
||||||
let name = args[0].replace("--", "");
|
let name = args[0].replace("--", "");
|
||||||
if !name.is_empty() {
|
if !name.is_empty() {
|
||||||
log_name = name;
|
log_name = name;
|
||||||
@@ -621,6 +627,98 @@ pub fn core_main() -> Option<Vec<String>> {
|
|||||||
println!("Installation and administrative privileges required!");
|
println!("Installation and administrative privileges required!");
|
||||||
}
|
}
|
||||||
return None;
|
return None;
|
||||||
|
} else if args[0] == "--deploy" {
|
||||||
|
if config::Config::no_register_device() {
|
||||||
|
println!("Cannot deploy an unregistrable device!");
|
||||||
|
} else if crate::platform::is_installed() && is_root() {
|
||||||
|
let max = args.len() - 1;
|
||||||
|
let pos = args.iter().position(|x| x == "--token").unwrap_or(max);
|
||||||
|
if pos >= max {
|
||||||
|
println!("--token is required!");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let token = args[pos + 1].to_owned();
|
||||||
|
let get_value = |c: &str| {
|
||||||
|
let pos = args.iter().position(|x| x == c).unwrap_or(max);
|
||||||
|
if pos < max {
|
||||||
|
Some(args[pos + 1].to_owned())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let new_id = get_value("--id");
|
||||||
|
let local_id = crate::ipc::get_id();
|
||||||
|
let id_to_deploy = new_id.clone().unwrap_or_else(|| local_id.clone());
|
||||||
|
let uuid = crate::encode64(hbb_common::get_uuid());
|
||||||
|
let pk = crate::encode64(
|
||||||
|
hbb_common::config::Config::get_key_pair().1,
|
||||||
|
);
|
||||||
|
let body = serde_json::json!({
|
||||||
|
"id": id_to_deploy,
|
||||||
|
"uuid": uuid,
|
||||||
|
"pk": pk,
|
||||||
|
});
|
||||||
|
let header = "Authorization: Bearer ".to_owned() + &token;
|
||||||
|
let url = crate::ui_interface::get_api_server() + "/api/devices/deploy";
|
||||||
|
match crate::post_request_sync(url, body.to_string(), &header) {
|
||||||
|
Err(err) => {
|
||||||
|
println!("Request failed: {}", err);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
Ok(text) => {
|
||||||
|
let parsed: serde_json::Value =
|
||||||
|
serde_json::from_str(&text).unwrap_or(serde_json::Value::Null);
|
||||||
|
let result = parsed["result"].as_str().unwrap_or("");
|
||||||
|
match result {
|
||||||
|
"OK" => {
|
||||||
|
if let Some(ref new_id) = new_id {
|
||||||
|
if *new_id != local_id {
|
||||||
|
if let Err(err) =
|
||||||
|
crate::ipc::set_config("id", new_id.clone())
|
||||||
|
{
|
||||||
|
println!(
|
||||||
|
"Failed to persist deployed id locally: {}",
|
||||||
|
err
|
||||||
|
);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Err(err) = crate::ipc::notify_deployed() {
|
||||||
|
log::warn!("Failed to notify deployed state: {}", err);
|
||||||
|
}
|
||||||
|
println!("Device deployed.");
|
||||||
|
}
|
||||||
|
"NOT_ENABLED" => {
|
||||||
|
println!("Server does not require deployment.");
|
||||||
|
std::process::exit(3);
|
||||||
|
}
|
||||||
|
"INVALID_INPUT" => {
|
||||||
|
println!("Invalid input.");
|
||||||
|
std::process::exit(5);
|
||||||
|
}
|
||||||
|
"ID_TAKEN" => {
|
||||||
|
println!(
|
||||||
|
"Id `{}` is already used by another machine on the server.",
|
||||||
|
id_to_deploy
|
||||||
|
);
|
||||||
|
std::process::exit(6);
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
if text.is_empty() {
|
||||||
|
println!("Unknown response.");
|
||||||
|
} else {
|
||||||
|
println!("{}", text);
|
||||||
|
}
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
println!("Installation and administrative privileges required!");
|
||||||
|
}
|
||||||
|
return None;
|
||||||
} else if args[0] == "--check-hwcodec-config" {
|
} else if args[0] == "--check-hwcodec-config" {
|
||||||
#[cfg(feature = "hwcodec")]
|
#[cfg(feature = "hwcodec")]
|
||||||
crate::ipc::hwcodec_process();
|
crate::ipc::hwcodec_process();
|
||||||
|
|||||||
@@ -1135,6 +1135,10 @@ impl InvokeUiSession for FlutterHandler {
|
|||||||
("message", json!(&opened.message)),
|
("message", json!(&opened.message)),
|
||||||
("pid", json!(opened.pid)),
|
("pid", json!(opened.pid)),
|
||||||
("service_id", json!(&opened.service_id)),
|
("service_id", json!(&opened.service_id)),
|
||||||
|
(
|
||||||
|
"replay_terminal_output",
|
||||||
|
json!(opened.replay_terminal_output),
|
||||||
|
),
|
||||||
];
|
];
|
||||||
if !opened.persistent_sessions.is_empty() {
|
if !opened.persistent_sessions.is_empty() {
|
||||||
event_data.push(("persistent_sessions", json!(opened.persistent_sessions)));
|
event_data.push(("persistent_sessions", json!(opened.persistent_sessions)));
|
||||||
|
|||||||
479
src/ipc.rs
479
src/ipc.rs
@@ -1,33 +1,28 @@
|
|||||||
use crate::{
|
#[path = "ipc/auth.rs"]
|
||||||
common::CheckTestNatType,
|
mod ipc_auth;
|
||||||
privacy_mode::PrivacyModeState,
|
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||||
ui_interface::{get_local_option, set_local_option},
|
#[path = "ipc/fs.rs"]
|
||||||
};
|
mod ipc_fs;
|
||||||
use bytes::Bytes;
|
|
||||||
use parity_tokio_ipc::{
|
|
||||||
Connection as Conn, ConnectionClient as ConnClient, Endpoint, Incoming, SecurityAttributes,
|
|
||||||
};
|
|
||||||
use serde_derive::{Deserialize, Serialize};
|
|
||||||
use std::{
|
|
||||||
collections::HashMap,
|
|
||||||
sync::atomic::{AtomicBool, Ordering},
|
|
||||||
};
|
|
||||||
#[cfg(not(windows))]
|
|
||||||
use std::{fs::File, io::prelude::*};
|
|
||||||
|
|
||||||
#[cfg(all(feature = "flutter", feature = "plugin_framework"))]
|
#[cfg(all(feature = "flutter", feature = "plugin_framework"))]
|
||||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||||
use crate::plugin::ipc::Plugin;
|
use crate::plugin::ipc::Plugin;
|
||||||
|
use crate::{
|
||||||
|
common::{is_server, CheckTestNatType},
|
||||||
|
privacy_mode,
|
||||||
|
privacy_mode::PrivacyModeState,
|
||||||
|
rendezvous_mediator::RendezvousMediator,
|
||||||
|
ui_interface::{get_local_option, set_local_option},
|
||||||
|
};
|
||||||
|
use bytes::Bytes;
|
||||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||||
pub use clipboard::ClipboardFile;
|
pub use clipboard::ClipboardFile;
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
use hbb_common::anyhow;
|
||||||
use hbb_common::{
|
use hbb_common::{
|
||||||
allow_err, bail, bytes,
|
allow_err, bail, bytes,
|
||||||
bytes_codec::BytesCodec,
|
bytes_codec::BytesCodec,
|
||||||
config::{
|
config::{self, keys::OPTION_ALLOW_WEBSOCKET, Config, Config2},
|
||||||
self,
|
|
||||||
keys::{self, OPTION_ALLOW_WEBSOCKET},
|
|
||||||
Config, Config2,
|
|
||||||
},
|
|
||||||
futures::StreamExt as _,
|
futures::StreamExt as _,
|
||||||
futures_util::sink::SinkExt,
|
futures_util::sink::SinkExt,
|
||||||
log, password_security as password, timeout,
|
log, password_security as password, timeout,
|
||||||
@@ -38,13 +33,55 @@ use hbb_common::{
|
|||||||
tokio_util::codec::Framed,
|
tokio_util::codec::Framed,
|
||||||
ResultType,
|
ResultType,
|
||||||
};
|
};
|
||||||
|
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||||
use crate::{common::is_server, privacy_mode, rendezvous_mediator::RendezvousMediator};
|
use ipc_auth::authorize_service_scoped_ipc_connection;
|
||||||
|
#[cfg(windows)]
|
||||||
|
pub(crate) use ipc_auth::authorize_windows_portable_service_ipc_connection;
|
||||||
|
#[cfg(windows)]
|
||||||
|
pub(crate) use ipc_auth::ensure_peer_executable_matches_current_by_pid_opt;
|
||||||
|
#[cfg(windows)]
|
||||||
|
pub(crate) use ipc_auth::log_rejected_windows_ipc_connection;
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub(crate) use ipc_auth::{
|
||||||
|
active_uid, ensure_peer_executable_matches_current_by_fd, is_allowed_service_peer_uid,
|
||||||
|
log_rejected_uinput_connection, peer_uid_from_fd,
|
||||||
|
};
|
||||||
|
#[cfg(windows)]
|
||||||
|
use ipc_auth::{
|
||||||
|
authorize_windows_main_ipc_connection, portable_service_listener_security_attributes,
|
||||||
|
should_allow_everyone_create_on_windows,
|
||||||
|
};
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
use ipc_fs::terminal_count_candidate_uids;
|
||||||
|
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||||
|
use ipc_fs::{
|
||||||
|
check_pid, ensure_secure_ipc_parent_dir, scrub_secure_ipc_parent_dir,
|
||||||
|
should_scrub_parent_entries_after_check_pid, write_pid,
|
||||||
|
};
|
||||||
|
use parity_tokio_ipc::{
|
||||||
|
Connection as Conn, ConnectionClient as ConnClient, Endpoint, Incoming, SecurityAttributes,
|
||||||
|
};
|
||||||
|
use serde_derive::{Deserialize, Serialize};
|
||||||
|
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
use std::{
|
||||||
|
collections::HashMap,
|
||||||
|
sync::atomic::{AtomicBool, Ordering},
|
||||||
|
};
|
||||||
|
|
||||||
// IPC actions here.
|
// IPC actions here.
|
||||||
pub const IPC_ACTION_CLOSE: &str = "close";
|
pub const IPC_ACTION_CLOSE: &str = "close";
|
||||||
|
const PORTABLE_SERVICE_IPC_HANDSHAKE_TIMEOUT_MS: u64 = 3_000;
|
||||||
|
pub(crate) const IPC_TOKEN_LEN: usize = 64;
|
||||||
|
const IPC_TOKEN_RANDOM_BYTES: usize = IPC_TOKEN_LEN / 2;
|
||||||
|
const _: () = assert!(IPC_TOKEN_LEN % 2 == 0);
|
||||||
pub static EXIT_RECV_CLOSE: AtomicBool = AtomicBool::new(true);
|
pub static EXIT_RECV_CLOSE: AtomicBool = AtomicBool::new(true);
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
pub async fn connect_service(ms_timeout: u64) -> ResultType<ConnectionTmpl<ConnClient>> {
|
||||||
|
connect(ms_timeout, crate::POSTFIX_SERVICE).await
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
#[serde(tag = "t", content = "c")]
|
#[serde(tag = "t", content = "c")]
|
||||||
pub enum FS {
|
pub enum FS {
|
||||||
@@ -207,6 +244,8 @@ pub enum DataControl {
|
|||||||
pub enum DataPortableService {
|
pub enum DataPortableService {
|
||||||
Ping,
|
Ping,
|
||||||
Pong,
|
Pong,
|
||||||
|
AuthToken(String),
|
||||||
|
AuthResult(bool),
|
||||||
ConnCount(Option<usize>),
|
ConnCount(Option<usize>),
|
||||||
Mouse((Vec<u8>, i32, String, u32, bool, bool)),
|
Mouse((Vec<u8>, i32, String, u32, bool, bool)),
|
||||||
Pointer((Vec<u8>, i32)),
|
Pointer((Vec<u8>, i32)),
|
||||||
@@ -273,6 +312,7 @@ pub enum Data {
|
|||||||
ClipboardNonFile(Option<(String, Vec<ClipboardNonFile>)>),
|
ClipboardNonFile(Option<(String, Vec<ClipboardNonFile>)>),
|
||||||
PrivacyModeState((i32, PrivacyModeState, String)),
|
PrivacyModeState((i32, PrivacyModeState, String)),
|
||||||
TestRendezvousServer,
|
TestRendezvousServer,
|
||||||
|
Deployed,
|
||||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||||
Keyboard(DataKeyboard),
|
Keyboard(DataKeyboard),
|
||||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||||
@@ -411,6 +451,22 @@ pub async fn start(postfix: &str) -> ResultType<()> {
|
|||||||
Ok(stream) => {
|
Ok(stream) => {
|
||||||
let mut stream = Connection::new(stream);
|
let mut stream = Connection::new(stream);
|
||||||
let postfix = postfix.to_owned();
|
let postfix = postfix.to_owned();
|
||||||
|
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||||
|
if config::is_service_ipc_postfix(&postfix) {
|
||||||
|
if !authorize_service_scoped_ipc_connection(&stream, &postfix) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(windows)]
|
||||||
|
if postfix.is_empty() {
|
||||||
|
// Windows main IPC (`postfix == ""`) is authorized here.
|
||||||
|
// Other security-sensitive channels use dedicated authorization paths:
|
||||||
|
// - `_portable_service`: portable-service listener + handshake policy
|
||||||
|
// - service-scoped postfixes: service-specific listener/authorization
|
||||||
|
if !authorize_windows_main_ipc_connection(&stream, &postfix) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
loop {
|
loop {
|
||||||
match stream.next().await {
|
match stream.next().await {
|
||||||
@@ -419,9 +475,48 @@ pub async fn start(postfix: &str) -> ResultType<()> {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
Ok(Some(data)) => {
|
Ok(Some(data)) => {
|
||||||
|
// On Linux/macOS, the protected `_service` channel is used only for
|
||||||
|
// syncing config between root service and the active user process.
|
||||||
|
//
|
||||||
|
// NOTE: `is_service_ipc_postfix()` also includes `_uinput_*`, but those
|
||||||
|
// channels are handled by the dedicated uinput listener/protocol in
|
||||||
|
// `src/server/uinput.rs` and therefore do not share this Data enum
|
||||||
|
// allowlist. The SyncConfig allowlist here is intentionally scoped to the
|
||||||
|
// `_service` channel only.
|
||||||
|
//
|
||||||
|
// Keep this explicit branch to avoid policy drift between `_service` and
|
||||||
|
// uinput IPC paths while still minimizing exposed message surface here.
|
||||||
|
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||||
|
if postfix == crate::POSTFIX_SERVICE {
|
||||||
|
if matches!(&data, Data::SyncConfig(_)) {
|
||||||
|
handle(data, &mut stream).await;
|
||||||
|
} else {
|
||||||
|
log::warn!(
|
||||||
|
"Rejected non-sync data on protected _service IPC channel: postfix={}, data_kind={:?}, peer_uid={:?}",
|
||||||
|
postfix,
|
||||||
|
std::mem::discriminant(&data),
|
||||||
|
stream.peer_uid()
|
||||||
|
);
|
||||||
|
// Close the connection to avoid keeping a protected channel
|
||||||
|
// alive while repeatedly receiving invalid traffic.
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
handle(data, &mut stream).await;
|
handle(data, &mut stream).await;
|
||||||
}
|
}
|
||||||
_ => {}
|
Ok(None) => {
|
||||||
|
// `Ok(None)` means a complete frame arrived but did not
|
||||||
|
// deserialize into `Data`. Peer close/reset is returned as
|
||||||
|
// `Err` by `ConnectionTmpl::next()`. Keep the historical
|
||||||
|
// ignore behavior except on the protected `_service` channel.
|
||||||
|
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||||
|
{
|
||||||
|
if postfix == crate::POSTFIX_SERVICE {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -436,20 +531,77 @@ pub async fn start(postfix: &str) -> ResultType<()> {
|
|||||||
|
|
||||||
pub async fn new_listener(postfix: &str) -> ResultType<Incoming> {
|
pub async fn new_listener(postfix: &str) -> ResultType<Incoming> {
|
||||||
let path = Config::ipc_path(postfix);
|
let path = Config::ipc_path(postfix);
|
||||||
#[cfg(not(any(windows, target_os = "android", target_os = "ios")))]
|
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||||
check_pid(postfix).await;
|
let should_scrub_parent_entries = ensure_secure_ipc_parent_dir(&path, postfix)?;
|
||||||
|
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||||
|
let existing_listener_alive = check_pid(postfix).await;
|
||||||
|
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||||
|
if should_scrub_parent_entries_after_check_pid(
|
||||||
|
should_scrub_parent_entries,
|
||||||
|
existing_listener_alive,
|
||||||
|
) {
|
||||||
|
scrub_secure_ipc_parent_dir(&path, postfix)?;
|
||||||
|
}
|
||||||
let mut endpoint = Endpoint::new(path.clone());
|
let mut endpoint = Endpoint::new(path.clone());
|
||||||
match SecurityAttributes::allow_everyone_create() {
|
let security_attrs = {
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
if postfix == "_portable_service" {
|
||||||
|
portable_service_listener_security_attributes()
|
||||||
|
} else if should_allow_everyone_create_on_windows(postfix) {
|
||||||
|
SecurityAttributes::allow_everyone_create()
|
||||||
|
} else {
|
||||||
|
Ok(SecurityAttributes::empty())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
{
|
||||||
|
SecurityAttributes::allow_everyone_create()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match security_attrs {
|
||||||
Ok(attr) => endpoint.set_security_attributes(attr),
|
Ok(attr) => endpoint.set_security_attributes(attr),
|
||||||
Err(err) => log::error!("Failed to set ipc{} security: {}", postfix, err),
|
Err(err) => {
|
||||||
|
log::error!("Failed to set ipc{} security: {}", postfix, err);
|
||||||
|
#[cfg(windows)]
|
||||||
|
if postfix == "_portable_service" {
|
||||||
|
// Fail closed for `_portable_service` when SDDL construction fails.
|
||||||
|
// This endpoint is security-critical and must not start with default ACLs.
|
||||||
|
return Err(err.into());
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
match endpoint.incoming() {
|
match endpoint.incoming() {
|
||||||
Ok(incoming) => {
|
Ok(incoming) => {
|
||||||
log::info!("Started ipc{} server at path: {}", postfix, &path);
|
if postfix == crate::POSTFIX_SERVICE {
|
||||||
#[cfg(not(windows))]
|
log::info!("Started protected ipc service server: postfix={}", postfix);
|
||||||
|
} else {
|
||||||
|
log::info!("Started ipc{} server at path: {}", postfix, &path);
|
||||||
|
}
|
||||||
|
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||||
{
|
{
|
||||||
use std::os::unix::fs::PermissionsExt;
|
// NOTE: On Linux/macOS, some IPC sockets are intentionally world-connectable
|
||||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o0777)).ok();
|
// (0666) so the active (non-root) user process can connect. Authorization is
|
||||||
|
// enforced at accept-time for these channels, and the protected `_service`
|
||||||
|
// channel is further restricted by an explicit message allowlist (SyncConfig
|
||||||
|
// only).
|
||||||
|
let socket_mode = if config::is_service_ipc_postfix(postfix) {
|
||||||
|
0o0666
|
||||||
|
} else {
|
||||||
|
0o0600
|
||||||
|
};
|
||||||
|
if let Err(err) =
|
||||||
|
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(socket_mode))
|
||||||
|
{
|
||||||
|
log::error!(
|
||||||
|
"Failed to set permissions on ipc{} socket at path {}: {}",
|
||||||
|
postfix,
|
||||||
|
&path,
|
||||||
|
err
|
||||||
|
);
|
||||||
|
std::fs::remove_file(&path).ok();
|
||||||
|
return Err(err.into());
|
||||||
|
}
|
||||||
write_pid(postfix);
|
write_pid(postfix);
|
||||||
}
|
}
|
||||||
Ok(incoming)
|
Ok(incoming)
|
||||||
@@ -778,6 +930,10 @@ async fn handle(data: Data, stream: &mut Connection) {
|
|||||||
Data::TestRendezvousServer => {
|
Data::TestRendezvousServer => {
|
||||||
crate::test_rendezvous_server();
|
crate::test_rendezvous_server();
|
||||||
}
|
}
|
||||||
|
Data::Deployed => {
|
||||||
|
crate::rendezvous_mediator::NEEDS_DEPLOY.store(false, Ordering::SeqCst);
|
||||||
|
crate::rendezvous_mediator::RendezvousMediator::restart();
|
||||||
|
}
|
||||||
#[cfg(feature = "flutter")]
|
#[cfg(feature = "flutter")]
|
||||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||||
Data::SwitchSidesRequest(id) => {
|
Data::SwitchSidesRequest(id) => {
|
||||||
@@ -953,15 +1109,116 @@ async fn handle(data: Data, stream: &mut Connection) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn connect(ms_timeout: u64, postfix: &str) -> ResultType<ConnectionTmpl<ConnClient>> {
|
pub async fn connect(ms_timeout: u64, postfix: &str) -> ResultType<ConnectionTmpl<ConnClient>> {
|
||||||
let path = Config::ipc_path(postfix);
|
let path = Config::ipc_path(postfix);
|
||||||
let client = timeout(ms_timeout, Endpoint::connect(&path)).await??;
|
connect_with_path(ms_timeout, &path).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn generate_one_time_ipc_token() -> ResultType<String> {
|
||||||
|
use hbb_common::rand::{rngs::OsRng, RngCore as _};
|
||||||
|
use std::fmt::Write as _;
|
||||||
|
|
||||||
|
let mut random_bytes = [0u8; IPC_TOKEN_RANDOM_BYTES];
|
||||||
|
let mut rng = OsRng;
|
||||||
|
rng.try_fill_bytes(&mut random_bytes).map_err(|err| {
|
||||||
|
hbb_common::anyhow::anyhow!(
|
||||||
|
"failed to generate portable service ipc token from OsRng: {}",
|
||||||
|
err
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let mut token = String::with_capacity(IPC_TOKEN_LEN);
|
||||||
|
for byte in random_bytes {
|
||||||
|
let _ = write!(token, "{:02x}", byte);
|
||||||
|
}
|
||||||
|
Ok(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn constant_time_ipc_token_eq(expected: &str, candidate: &str) -> bool {
|
||||||
|
if expected.len() != IPC_TOKEN_LEN || candidate.len() != IPC_TOKEN_LEN {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
expected
|
||||||
|
.as_bytes()
|
||||||
|
.iter()
|
||||||
|
.zip(candidate.as_bytes().iter())
|
||||||
|
.fold(0u8, |diff, (left, right)| diff | (*left ^ *right))
|
||||||
|
== 0
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn portable_service_ipc_handshake_as_client<T>(
|
||||||
|
stream: &mut ConnectionTmpl<T>,
|
||||||
|
token: &str,
|
||||||
|
) -> ResultType<()>
|
||||||
|
where
|
||||||
|
T: AsyncRead + AsyncWrite + std::marker::Unpin,
|
||||||
|
{
|
||||||
|
stream
|
||||||
|
.send(&Data::DataPortableService(DataPortableService::AuthToken(
|
||||||
|
token.to_owned(),
|
||||||
|
)))
|
||||||
|
.await?;
|
||||||
|
match stream
|
||||||
|
.next_timeout(PORTABLE_SERVICE_IPC_HANDSHAKE_TIMEOUT_MS)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
Some(Data::DataPortableService(DataPortableService::AuthResult(true))) => Ok(()),
|
||||||
|
Some(Data::DataPortableService(DataPortableService::AuthResult(false))) => {
|
||||||
|
bail!("portable service ipc handshake was rejected by server")
|
||||||
|
}
|
||||||
|
Some(_) | None => bail!("portable service ipc handshake returned an unexpected response"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn portable_service_ipc_handshake_as_server<T, F>(
|
||||||
|
stream: &mut ConnectionTmpl<T>,
|
||||||
|
mut validate_token: F,
|
||||||
|
) -> ResultType<()>
|
||||||
|
where
|
||||||
|
T: AsyncRead + AsyncWrite + std::marker::Unpin,
|
||||||
|
// Token validators must use `constant_time_ipc_token_eq` or an equivalent
|
||||||
|
// fixed-length comparison; this handshake is part of the privilege boundary.
|
||||||
|
F: FnMut(&str) -> bool,
|
||||||
|
{
|
||||||
|
let authorized = match stream
|
||||||
|
.next_timeout(PORTABLE_SERVICE_IPC_HANDSHAKE_TIMEOUT_MS)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
Some(Data::DataPortableService(DataPortableService::AuthToken(token))) => {
|
||||||
|
validate_token(&token)
|
||||||
|
}
|
||||||
|
Some(_) | None => false,
|
||||||
|
};
|
||||||
|
stream
|
||||||
|
.send(&Data::DataPortableService(DataPortableService::AuthResult(
|
||||||
|
authorized,
|
||||||
|
)))
|
||||||
|
.await?;
|
||||||
|
if !authorized {
|
||||||
|
bail!("portable service ipc handshake failed")
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
async fn connect_with_path(ms_timeout: u64, path: &str) -> ResultType<ConnectionTmpl<ConnClient>> {
|
||||||
|
let client = timeout(ms_timeout, Endpoint::connect(path)).await??;
|
||||||
Ok(ConnectionTmpl::new(client))
|
Ok(ConnectionTmpl::new(client))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub async fn connect_for_uid(
|
||||||
|
ms_timeout: u64,
|
||||||
|
uid: u32,
|
||||||
|
postfix: &str,
|
||||||
|
) -> ResultType<ConnectionTmpl<ConnClient>> {
|
||||||
|
let path = Config::ipc_path_for_uid(uid, postfix);
|
||||||
|
connect_with_path(ms_timeout, &path).await
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
#[tokio::main(flavor = "current_thread")]
|
#[tokio::main(flavor = "current_thread")]
|
||||||
pub async fn start_pa() {
|
pub async fn start_pa() {
|
||||||
@@ -1039,54 +1296,6 @@ pub async fn start_pa() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[inline]
|
|
||||||
#[cfg(not(windows))]
|
|
||||||
fn get_pid_file(postfix: &str) -> String {
|
|
||||||
let path = Config::ipc_path(postfix);
|
|
||||||
format!("{}.pid", path)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(any(windows, target_os = "android", target_os = "ios")))]
|
|
||||||
async fn check_pid(postfix: &str) {
|
|
||||||
let pid_file = get_pid_file(postfix);
|
|
||||||
if let Ok(mut file) = File::open(&pid_file) {
|
|
||||||
let mut content = String::new();
|
|
||||||
file.read_to_string(&mut content).ok();
|
|
||||||
let pid = content.parse::<usize>().unwrap_or(0);
|
|
||||||
if pid > 0 {
|
|
||||||
use hbb_common::sysinfo::System;
|
|
||||||
let mut sys = System::new();
|
|
||||||
sys.refresh_processes();
|
|
||||||
if let Some(p) = sys.process(pid.into()) {
|
|
||||||
if let Some(current) = sys.process((std::process::id() as usize).into()) {
|
|
||||||
if current.name() == p.name() {
|
|
||||||
// double check with connect
|
|
||||||
if connect(1000, postfix).await.is_ok() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// if not remove old ipc file, the new ipc creation will fail
|
|
||||||
// if we remove a ipc file, but the old ipc process is still running,
|
|
||||||
// new connection to the ipc will connect to new ipc, old connection to old ipc still keep alive
|
|
||||||
std::fs::remove_file(&Config::ipc_path(postfix)).ok();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
#[cfg(not(windows))]
|
|
||||||
fn write_pid(postfix: &str) {
|
|
||||||
let path = get_pid_file(postfix);
|
|
||||||
if let Ok(mut file) = File::create(&path) {
|
|
||||||
use std::os::unix::fs::PermissionsExt;
|
|
||||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o0777)).ok();
|
|
||||||
file.write_all(&std::process::id().to_string().into_bytes())
|
|
||||||
.ok();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct ConnectionTmpl<T> {
|
pub struct ConnectionTmpl<T> {
|
||||||
inner: Framed<T, BytesCodec>,
|
inner: Framed<T, BytesCodec>,
|
||||||
}
|
}
|
||||||
@@ -1533,6 +1742,13 @@ pub async fn test_rendezvous_server() -> ResultType<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::main(flavor = "current_thread")]
|
||||||
|
pub async fn notify_deployed() -> ResultType<()> {
|
||||||
|
let mut c = connect(1000, "").await?;
|
||||||
|
c.send(&Data::Deployed).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::main(flavor = "current_thread")]
|
#[tokio::main(flavor = "current_thread")]
|
||||||
pub async fn send_url_scheme(url: String) -> ResultType<()> {
|
pub async fn send_url_scheme(url: String) -> ResultType<()> {
|
||||||
connect(1_000, "_url")
|
connect(1_000, "_url")
|
||||||
@@ -1550,9 +1766,10 @@ pub fn close_all_instances() -> ResultType<bool> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
#[tokio::main(flavor = "current_thread")]
|
#[tokio::main(flavor = "current_thread")]
|
||||||
pub async fn connect_to_user_session(usid: Option<u32>) -> ResultType<()> {
|
pub async fn connect_to_user_session(usid: Option<u32>) -> ResultType<()> {
|
||||||
let mut stream = crate::ipc::connect(1000, crate::POSTFIX_SERVICE).await?;
|
let mut stream = crate::ipc::connect_service(1000).await?;
|
||||||
timeout(1000, stream.send(&crate::ipc::Data::UserSid(usid))).await??;
|
timeout(1000, stream.send(&crate::ipc::Data::UserSid(usid))).await??;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -1678,13 +1895,76 @@ pub async fn update_controlling_session_count(count: usize) -> ResultType<()> {
|
|||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
#[tokio::main(flavor = "current_thread")]
|
#[tokio::main(flavor = "current_thread")]
|
||||||
pub async fn get_terminal_session_count() -> ResultType<usize> {
|
pub async fn get_terminal_session_count() -> ResultType<usize> {
|
||||||
let ms_timeout = 1_000;
|
let timeout_ms = 1_000;
|
||||||
let mut c = connect(ms_timeout, "").await?;
|
let effective_uid = unsafe { hbb_common::libc::geteuid() as u32 };
|
||||||
c.send(&Data::TerminalSessionCount(0)).await?;
|
let candidate_uids = terminal_count_candidate_uids(effective_uid);
|
||||||
if let Some(Data::TerminalSessionCount(c)) = c.next_timeout(ms_timeout).await? {
|
let mut last_err: Option<anyhow::Error> = None;
|
||||||
return Ok(c);
|
for candidate_uid in candidate_uids {
|
||||||
|
let socket_path = Config::ipc_path_for_uid(candidate_uid, "");
|
||||||
|
let connect_result = timeout(timeout_ms, Endpoint::connect(&socket_path))
|
||||||
|
.await
|
||||||
|
.map_err(|err| {
|
||||||
|
anyhow::anyhow!(
|
||||||
|
"Timeout connecting to terminal ipc at {}: {}",
|
||||||
|
socket_path,
|
||||||
|
err
|
||||||
|
)
|
||||||
|
});
|
||||||
|
let connection = match connect_result {
|
||||||
|
Ok(Ok(connection)) => connection,
|
||||||
|
Ok(Err(err)) => {
|
||||||
|
last_err = Some(anyhow::anyhow!(
|
||||||
|
"Failed to connect to terminal ipc at {}: {}",
|
||||||
|
socket_path,
|
||||||
|
err
|
||||||
|
));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
last_err = Some(err);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut ipc_conn = ConnectionTmpl::new(connection);
|
||||||
|
if let Err(err) = ipc_conn.send(&Data::TerminalSessionCount(0)).await {
|
||||||
|
last_err = Some(anyhow::anyhow!(
|
||||||
|
"Failed to request terminal session count via ipc at {}: {}",
|
||||||
|
socket_path,
|
||||||
|
err
|
||||||
|
));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match ipc_conn.next_timeout(timeout_ms).await {
|
||||||
|
Ok(Some(Data::TerminalSessionCount(session_count))) => {
|
||||||
|
return Ok(session_count);
|
||||||
|
}
|
||||||
|
Ok(None) => {
|
||||||
|
last_err = Some(anyhow::anyhow!(
|
||||||
|
"Invalid response when requesting terminal session count via ipc at {}",
|
||||||
|
socket_path
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(other) => {
|
||||||
|
last_err = Some(anyhow::anyhow!(
|
||||||
|
"Unexpected response when requesting terminal session count via ipc at {}: {:?}",
|
||||||
|
socket_path,
|
||||||
|
other.map(|v| std::mem::discriminant(&v))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
last_err = Some(anyhow::anyhow!(
|
||||||
|
"Failed to read terminal session count via ipc at {}: {}",
|
||||||
|
socket_path,
|
||||||
|
err
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(err) = last_err {
|
||||||
|
Err(err.into())
|
||||||
|
} else {
|
||||||
|
Ok(0)
|
||||||
}
|
}
|
||||||
Ok(0)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_wayland_screencast_restore_token(
|
async fn handle_wayland_screencast_restore_token(
|
||||||
@@ -1715,9 +1995,30 @@ pub async fn set_install_option(k: String, v: String) -> ResultType<()> {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod test {
|
mod test {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn verify_ffi_enum_data_size() {
|
fn verify_ffi_enum_data_size() {
|
||||||
println!("{}", std::mem::size_of::<Data>());
|
println!("{}", std::mem::size_of::<Data>());
|
||||||
assert!(std::mem::size_of::<Data>() <= 120);
|
assert!(std::mem::size_of::<Data>() <= 120);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
#[test]
|
||||||
|
fn test_ipc_path_differs_by_uid_for_cm() {
|
||||||
|
let effective_uid = unsafe { hbb_common::libc::geteuid() as u32 };
|
||||||
|
let other_uid = effective_uid.saturating_add(1);
|
||||||
|
let postfix = "_cm";
|
||||||
|
|
||||||
|
// Default connect path targets the current effective uid.
|
||||||
|
assert_eq!(
|
||||||
|
Config::ipc_path(postfix),
|
||||||
|
Config::ipc_path_for_uid(effective_uid, postfix)
|
||||||
|
);
|
||||||
|
// A different uid yields a different socket path - this is the root cause of the
|
||||||
|
// cross-user regression when root spawns a user process but still connects as uid 0.
|
||||||
|
assert_ne!(
|
||||||
|
Config::ipc_path(postfix),
|
||||||
|
Config::ipc_path_for_uid(other_uid, postfix)
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1036
src/ipc/auth.rs
Normal file
1036
src/ipc/auth.rs
Normal file
File diff suppressed because it is too large
Load Diff
951
src/ipc/fs.rs
Normal file
951
src/ipc/fs.rs
Normal file
@@ -0,0 +1,951 @@
|
|||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
use super::ipc_auth::active_uid;
|
||||||
|
use crate::ipc::{connect, Data};
|
||||||
|
use hbb_common::{config, log, ResultType};
|
||||||
|
use std::{
|
||||||
|
ffi::CString,
|
||||||
|
io::{Error, ErrorKind},
|
||||||
|
os::unix::ffi::OsStrExt,
|
||||||
|
path::Path,
|
||||||
|
};
|
||||||
|
|
||||||
|
struct FdGuard(i32);
|
||||||
|
impl Drop for FdGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
unsafe {
|
||||||
|
hbb_common::libc::close(self.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
#[inline]
|
||||||
|
pub(crate) fn terminal_count_candidate_uids(effective_uid: u32) -> Vec<u32> {
|
||||||
|
if effective_uid != 0 {
|
||||||
|
return vec![effective_uid];
|
||||||
|
}
|
||||||
|
let mut candidates = Vec::with_capacity(2);
|
||||||
|
if let Some(uid) = active_uid().filter(|uid| *uid != 0) {
|
||||||
|
candidates.push(uid);
|
||||||
|
}
|
||||||
|
candidates.push(0);
|
||||||
|
candidates
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn expected_ipc_parent_mode(postfix: &str) -> u32 {
|
||||||
|
if config::is_service_ipc_postfix(postfix) {
|
||||||
|
0o0711
|
||||||
|
} else {
|
||||||
|
0o0700
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_ipc_parent_dir_fd(parent_c: &CString) -> std::io::Result<i32> {
|
||||||
|
let fd = unsafe {
|
||||||
|
hbb_common::libc::open(
|
||||||
|
parent_c.as_ptr(),
|
||||||
|
hbb_common::libc::O_RDONLY
|
||||||
|
| hbb_common::libc::O_DIRECTORY
|
||||||
|
| hbb_common::libc::O_CLOEXEC
|
||||||
|
| hbb_common::libc::O_NOFOLLOW,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if fd < 0 {
|
||||||
|
Err(std::io::Error::last_os_error())
|
||||||
|
} else {
|
||||||
|
Ok(fd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove one preexisting IPC artifact via an already-opened parent directory FD.
|
||||||
|
//
|
||||||
|
// Security intent:
|
||||||
|
// - Bind cleanup to the exact parent inode that passed O_NOFOLLOW + fstat checks.
|
||||||
|
// - Avoid path-based TOCTOU during scrub (e.g., parent path rename/swap race).
|
||||||
|
//
|
||||||
|
// Flow:
|
||||||
|
// 1) fstatat(..., AT_SYMLINK_NOFOLLOW) to inspect the target entry under parent_fd.
|
||||||
|
// 2) Decide file vs directory from st_mode.
|
||||||
|
// 3) unlinkat relative to parent_fd (AT_REMOVEDIR for directories).
|
||||||
|
//
|
||||||
|
// Error policy:
|
||||||
|
// - NotFound is treated as benign (already removed / raced away).
|
||||||
|
// - Other errors are surfaced explicitly.
|
||||||
|
fn remove_parent_entry_via_fd(
|
||||||
|
parent_fd: i32,
|
||||||
|
parent_dir: &Path,
|
||||||
|
entry_name: &str,
|
||||||
|
) -> ResultType<()> {
|
||||||
|
if entry_name.contains('/') {
|
||||||
|
return Err(Error::new(
|
||||||
|
ErrorKind::InvalidInput,
|
||||||
|
format!(
|
||||||
|
"invalid ipc parent entry name (contains '/'): parent={}, entry={}",
|
||||||
|
parent_dir.display(),
|
||||||
|
entry_name
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
let entry_c = CString::new(entry_name.as_bytes().to_vec()).map_err(|err| {
|
||||||
|
Error::new(
|
||||||
|
ErrorKind::InvalidInput,
|
||||||
|
format!(
|
||||||
|
"invalid ipc parent entry name: parent={}, entry={}, err={}",
|
||||||
|
parent_dir.display(),
|
||||||
|
entry_name,
|
||||||
|
err
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let mut stat: hbb_common::libc::stat = unsafe { std::mem::zeroed() };
|
||||||
|
let stat_rc = unsafe {
|
||||||
|
hbb_common::libc::fstatat(
|
||||||
|
parent_fd,
|
||||||
|
entry_c.as_ptr(),
|
||||||
|
&mut stat,
|
||||||
|
hbb_common::libc::AT_SYMLINK_NOFOLLOW,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if stat_rc != 0 {
|
||||||
|
let err = std::io::Error::last_os_error();
|
||||||
|
if err.kind() == ErrorKind::NotFound {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
return Err(Error::new(
|
||||||
|
err.kind(),
|
||||||
|
format!(
|
||||||
|
"failed to stat preexisting ipc parent dir entry by fd: parent={}, entry={}, err={}",
|
||||||
|
parent_dir.display(),
|
||||||
|
entry_name,
|
||||||
|
err
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let is_dir = (stat.st_mode & (hbb_common::libc::S_IFMT as hbb_common::libc::mode_t))
|
||||||
|
== hbb_common::libc::S_IFDIR;
|
||||||
|
let unlink_flags = if is_dir {
|
||||||
|
hbb_common::libc::AT_REMOVEDIR
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
let unlink_rc =
|
||||||
|
unsafe { hbb_common::libc::unlinkat(parent_fd, entry_c.as_ptr(), unlink_flags) };
|
||||||
|
if unlink_rc != 0 {
|
||||||
|
let err = std::io::Error::last_os_error();
|
||||||
|
if err.kind() == ErrorKind::NotFound {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
return Err(Error::new(
|
||||||
|
err.kind(),
|
||||||
|
format!(
|
||||||
|
"failed to remove preexisting ipc parent dir entry by fd: parent={}, entry={}, err={}",
|
||||||
|
parent_dir.display(),
|
||||||
|
entry_name,
|
||||||
|
err
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scrub_preexisting_ipc_parent_entries(
|
||||||
|
parent_fd: i32,
|
||||||
|
parent_dir: &Path,
|
||||||
|
postfix: &str,
|
||||||
|
) -> ResultType<()> {
|
||||||
|
let ipc_basename = format!("ipc{}", postfix);
|
||||||
|
remove_parent_entry_via_fd(parent_fd, parent_dir, &ipc_basename)?;
|
||||||
|
remove_parent_entry_via_fd(parent_fd, parent_dir, &format!("{}.pid", ipc_basename))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remove_ipc_socket_via_secure_parent_fd(postfix: &str) -> ResultType<()> {
|
||||||
|
let path = config::Config::ipc_path(postfix);
|
||||||
|
let parent_dir = Path::new(&path)
|
||||||
|
.parent()
|
||||||
|
.ok_or_else(|| Error::new(ErrorKind::InvalidInput, format!("invalid ipc path: {path}")))?;
|
||||||
|
let parent_c = CString::new(parent_dir.as_os_str().as_bytes().to_vec())?;
|
||||||
|
let fd = match open_ipc_parent_dir_fd(&parent_c) {
|
||||||
|
Ok(fd) => fd,
|
||||||
|
Err(open_err) => {
|
||||||
|
if open_err.kind() == ErrorKind::NotFound {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
return Err(Error::new(
|
||||||
|
open_err.kind(),
|
||||||
|
format!(
|
||||||
|
"failed to open ipc parent dir for stale socket cleanup (no-follow): postfix={}, parent={}, err={}",
|
||||||
|
postfix,
|
||||||
|
parent_dir.display(),
|
||||||
|
open_err
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let _fd_guard = FdGuard(fd);
|
||||||
|
remove_parent_entry_via_fd(fd, parent_dir, &format!("ipc{}", postfix))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Purpose:
|
||||||
|
// - Harden the IPC parent directory before creating/listening socket files.
|
||||||
|
// - Prevent symlink/path-race abuse and reject unsafe owner/mode.
|
||||||
|
//
|
||||||
|
// Approach:
|
||||||
|
// - Open parent dir with O_NOFOLLOW/O_DIRECTORY and operate on that fd.
|
||||||
|
// - Validate inode type/owner/mode via fstat.
|
||||||
|
// - For protected service postfix, optionally adopt owner (root only), then scrub stale
|
||||||
|
// rustdesk IPC artifacts when directory trust boundary changed.
|
||||||
|
//
|
||||||
|
// Main steps:
|
||||||
|
// 1) Resolve parent path and open/create directory securely.
|
||||||
|
// 2) Verify directory inode type and owner uid.
|
||||||
|
// 3) Enforce expected mode via fchmod on opened fd.
|
||||||
|
// 4) Scrub stale IPC artifacts when owner/mode was unsafe before hardening.
|
||||||
|
//
|
||||||
|
// References:
|
||||||
|
// - open(2): O_NOFOLLOW/O_DIRECTORY/O_CLOEXEC
|
||||||
|
// https://man7.org/linux/man-pages/man2/open.2.html
|
||||||
|
// - fstat(2): verify file type/metadata on opened fd
|
||||||
|
// https://man7.org/linux/man-pages/man2/fstat.2.html
|
||||||
|
// - fchown(2): adopt ownership when running as root
|
||||||
|
// https://man7.org/linux/man-pages/man2/chown.2.html
|
||||||
|
// - fchmod(2): enforce exact mode on opened fd
|
||||||
|
// https://man7.org/linux/man-pages/man2/fchmod.2.html
|
||||||
|
pub(crate) fn ensure_secure_ipc_parent_dir(path: &str, postfix: &str) -> ResultType<bool> {
|
||||||
|
let parent_dir = Path::new(path)
|
||||||
|
.parent()
|
||||||
|
.ok_or_else(|| Error::new(ErrorKind::InvalidInput, format!("invalid ipc path: {path}")))?;
|
||||||
|
// Harden against common TOCTOU by opening the parent directory with O_NOFOLLOW (so the parent
|
||||||
|
// itself cannot be a symlink) and then operating on its FD (fstat/fchown/fchmod). This ensures
|
||||||
|
// we mutate the inode we opened, though it does not protect against symlinks in ancestor path
|
||||||
|
// components.
|
||||||
|
let parent_c = CString::new(parent_dir.as_os_str().as_bytes().to_vec())?;
|
||||||
|
let fd = match open_ipc_parent_dir_fd(&parent_c) {
|
||||||
|
Ok(fd) => fd,
|
||||||
|
Err(open_err) => {
|
||||||
|
// If the directory doesn't exist yet, create it with the expected mode. The parent
|
||||||
|
// dir is intended to be a single-level /tmp path, so mkdir is sufficient here.
|
||||||
|
if open_err.raw_os_error() == Some(hbb_common::libc::ENOENT) {
|
||||||
|
let expected_mode = expected_ipc_parent_mode(postfix);
|
||||||
|
let rc = unsafe {
|
||||||
|
hbb_common::libc::mkdir(
|
||||||
|
parent_c.as_ptr(),
|
||||||
|
expected_mode as hbb_common::libc::mode_t,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if rc != 0 {
|
||||||
|
let mkdir_err = std::io::Error::last_os_error();
|
||||||
|
// Handle a race where another process created the directory first.
|
||||||
|
if mkdir_err.raw_os_error() != Some(hbb_common::libc::EEXIST) {
|
||||||
|
return Err(Error::new(
|
||||||
|
mkdir_err.kind(),
|
||||||
|
format!(
|
||||||
|
"failed to mkdir ipc parent dir: postfix={}, parent={}, err={}",
|
||||||
|
postfix,
|
||||||
|
parent_dir.display(),
|
||||||
|
mkdir_err
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match open_ipc_parent_dir_fd(&parent_c) {
|
||||||
|
Ok(fd) => fd,
|
||||||
|
Err(err) => {
|
||||||
|
return Err(Error::new(
|
||||||
|
err.kind(),
|
||||||
|
format!(
|
||||||
|
"failed to open ipc parent dir (no-follow): postfix={}, parent={}, err={}",
|
||||||
|
postfix,
|
||||||
|
parent_dir.display(),
|
||||||
|
err
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return Err(Error::new(
|
||||||
|
open_err.kind(),
|
||||||
|
format!(
|
||||||
|
"failed to open ipc parent dir (no-follow): postfix={}, parent={}, err={}",
|
||||||
|
postfix,
|
||||||
|
parent_dir.display(),
|
||||||
|
open_err
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let _fd_guard = FdGuard(fd);
|
||||||
|
|
||||||
|
let mut st: hbb_common::libc::stat = unsafe { std::mem::zeroed() };
|
||||||
|
if unsafe { hbb_common::libc::fstat(fd, &mut st as *mut _) } != 0 {
|
||||||
|
let os_err = std::io::Error::last_os_error();
|
||||||
|
return Err(Error::new(
|
||||||
|
os_err.kind(),
|
||||||
|
format!(
|
||||||
|
"failed to stat ipc parent dir: postfix={}, parent={}, err={}",
|
||||||
|
postfix,
|
||||||
|
parent_dir.display(),
|
||||||
|
os_err
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
let mode = st.st_mode as u32;
|
||||||
|
let is_dir = (mode & (hbb_common::libc::S_IFMT as u32)) == (hbb_common::libc::S_IFDIR as u32);
|
||||||
|
if !is_dir {
|
||||||
|
return Err(Error::new(
|
||||||
|
ErrorKind::PermissionDenied,
|
||||||
|
format!(
|
||||||
|
"ipc parent is not directory: postfix={}, parent={}",
|
||||||
|
postfix,
|
||||||
|
parent_dir.display()
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let expected_uid = unsafe { hbb_common::libc::geteuid() as u32 };
|
||||||
|
let mut owner_uid = st.st_uid as u32;
|
||||||
|
let mut adopted_foreign_service_parent = false;
|
||||||
|
// Service-scoped IPC may be created by different privilege contexts historically.
|
||||||
|
// If running as root on protected service postfix, try adopting ownership first.
|
||||||
|
if owner_uid != expected_uid && expected_uid == 0 && config::is_service_ipc_postfix(postfix) {
|
||||||
|
let rc = unsafe {
|
||||||
|
hbb_common::libc::fchown(
|
||||||
|
fd,
|
||||||
|
expected_uid as hbb_common::libc::uid_t,
|
||||||
|
hbb_common::libc::gid_t::MAX,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if rc == 0 {
|
||||||
|
let mut st2: hbb_common::libc::stat = unsafe { std::mem::zeroed() };
|
||||||
|
if unsafe { hbb_common::libc::fstat(fd, &mut st2 as *mut _) } == 0 {
|
||||||
|
owner_uid = st2.st_uid as u32;
|
||||||
|
st = st2;
|
||||||
|
adopted_foreign_service_parent = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Keep behavior unchanged; capture errno to ease diagnosing why chown failed.
|
||||||
|
let err = std::io::Error::last_os_error();
|
||||||
|
log::warn!(
|
||||||
|
"Failed to chown ipc parent dir, parent={}, postfix={}, expected_uid={}, rc={}, err={:?}",
|
||||||
|
parent_dir.display(),
|
||||||
|
postfix,
|
||||||
|
expected_uid,
|
||||||
|
rc,
|
||||||
|
err
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if owner_uid != expected_uid {
|
||||||
|
return Err(Error::new(
|
||||||
|
ErrorKind::PermissionDenied,
|
||||||
|
format!(
|
||||||
|
"unsafe ipc parent owner, postfix={}, expected uid {expected_uid}, got {owner_uid}: {}",
|
||||||
|
postfix,
|
||||||
|
parent_dir.display()
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let expected_mode = expected_ipc_parent_mode(postfix);
|
||||||
|
// Include special bits (setuid/setgid/sticky) to ensure the directory is hardened to the exact
|
||||||
|
// expected mode.
|
||||||
|
let current_mode = (st.st_mode as u32) & 0o7777;
|
||||||
|
let repaired_parent_mode = current_mode != expected_mode;
|
||||||
|
let had_untrusted_parent_mode = (current_mode & 0o022) != 0;
|
||||||
|
if repaired_parent_mode {
|
||||||
|
// Use fchmod on the opened fd to avoid path-race between check and chmod.
|
||||||
|
if unsafe { hbb_common::libc::fchmod(fd, expected_mode as hbb_common::libc::mode_t) } != 0 {
|
||||||
|
let os_err = std::io::Error::last_os_error();
|
||||||
|
return Err(Error::new(
|
||||||
|
os_err.kind(),
|
||||||
|
format!(
|
||||||
|
"failed to chmod ipc parent dir: postfix={}, parent={}, err={}",
|
||||||
|
postfix,
|
||||||
|
parent_dir.display(),
|
||||||
|
os_err
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let should_scrub =
|
||||||
|
repaired_parent_mode || adopted_foreign_service_parent || had_untrusted_parent_mode;
|
||||||
|
Ok(should_scrub)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn scrub_secure_ipc_parent_dir(path: &str, postfix: &str) -> ResultType<()> {
|
||||||
|
let parent_dir = Path::new(path)
|
||||||
|
.parent()
|
||||||
|
.ok_or_else(|| Error::new(ErrorKind::InvalidInput, format!("invalid ipc path: {path}")))?;
|
||||||
|
let parent_c = CString::new(parent_dir.as_os_str().as_bytes().to_vec())?;
|
||||||
|
let fd = open_ipc_parent_dir_fd(&parent_c).map_err(|err| {
|
||||||
|
Error::new(
|
||||||
|
err.kind(),
|
||||||
|
format!(
|
||||||
|
"failed to open ipc parent dir for scrub (no-follow): postfix={}, parent={}, err={}",
|
||||||
|
postfix,
|
||||||
|
parent_dir.display(),
|
||||||
|
err
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let _fd_guard = FdGuard(fd);
|
||||||
|
scrub_preexisting_ipc_parent_entries(fd, parent_dir, postfix)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
pub(crate) fn get_pid_file(postfix: &str) -> String {
|
||||||
|
let path = config::Config::ipc_path(postfix);
|
||||||
|
format!("{}.pid", path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Purpose:
|
||||||
|
// - Write current process pid to pid file without following attacker-controlled symlinks.
|
||||||
|
// - Ensure the pid file is a regular file owned by the opened inode path.
|
||||||
|
//
|
||||||
|
// Approach:
|
||||||
|
// - Use libc open/fstat/write syscalls (FFI) so flags and inode validation are explicit.
|
||||||
|
// - Open file with O_NOFOLLOW/O_CLOEXEC and verify S_IFREG with fstat before write.
|
||||||
|
// - Keep unsafe scopes minimal and check syscall return values immediately.
|
||||||
|
//
|
||||||
|
// Main steps:
|
||||||
|
// 1) Secure-open pid file (without truncation).
|
||||||
|
// 2) Validate opened inode is a regular file owned by current euid.
|
||||||
|
// 3) Enforce pid file mode to 0600 and truncate via ftruncate after validation.
|
||||||
|
// 4) Write process id bytes through fd.
|
||||||
|
//
|
||||||
|
// Why not plain std::fs::write?
|
||||||
|
// - std::fs helpers cannot enforce this exact open-time hardening sequence
|
||||||
|
// (especially "open with O_NOFOLLOW, then fstat the same opened inode").
|
||||||
|
//
|
||||||
|
// References:
|
||||||
|
// - open(2): O_NOFOLLOW/O_CLOEXEC/O_NONBLOCK
|
||||||
|
// https://man7.org/linux/man-pages/man2/open.2.html
|
||||||
|
// - fstat(2): verify file type on opened fd
|
||||||
|
// https://man7.org/linux/man-pages/man2/fstat.2.html
|
||||||
|
// - fchmod(2): enforce secure mode on reused pid file
|
||||||
|
// https://man7.org/linux/man-pages/man2/fchmod.2.html
|
||||||
|
// - ftruncate(2): truncate after validation
|
||||||
|
// https://man7.org/linux/man-pages/man2/ftruncate.2.html
|
||||||
|
// - write(2): write bytes via fd
|
||||||
|
// https://man7.org/linux/man-pages/man2/write.2.html
|
||||||
|
fn write_pid_file(path: &Path) -> ResultType<()> {
|
||||||
|
let path_c = CString::new(path.as_os_str().as_bytes().to_vec()).map_err(|err| {
|
||||||
|
Error::new(
|
||||||
|
ErrorKind::InvalidInput,
|
||||||
|
format!("invalid pid file path '{}': {}", path.display(), err),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let flags = hbb_common::libc::O_WRONLY
|
||||||
|
| hbb_common::libc::O_CREAT
|
||||||
|
| hbb_common::libc::O_CLOEXEC
|
||||||
|
| hbb_common::libc::O_NOFOLLOW
|
||||||
|
| hbb_common::libc::O_NONBLOCK;
|
||||||
|
let fd = unsafe { hbb_common::libc::open(path_c.as_ptr(), flags, 0o0600) };
|
||||||
|
if fd < 0 {
|
||||||
|
let os_err = std::io::Error::last_os_error();
|
||||||
|
return Err(Error::new(
|
||||||
|
os_err.kind(),
|
||||||
|
format!(
|
||||||
|
"failed to open pid file with no-follow '{}': {}",
|
||||||
|
path.display(),
|
||||||
|
os_err
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
let _fd_guard = FdGuard(fd);
|
||||||
|
let mut stat: hbb_common::libc::stat = unsafe { std::mem::zeroed() };
|
||||||
|
if unsafe { hbb_common::libc::fstat(fd, &mut stat) } != 0 {
|
||||||
|
let os_err = std::io::Error::last_os_error();
|
||||||
|
return Err(Error::new(
|
||||||
|
os_err.kind(),
|
||||||
|
format!("failed to stat pid file '{}': {}", path.display(), os_err),
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
if (stat.st_mode & (hbb_common::libc::S_IFMT as hbb_common::libc::mode_t))
|
||||||
|
!= (hbb_common::libc::S_IFREG as hbb_common::libc::mode_t)
|
||||||
|
{
|
||||||
|
return Err(Error::new(
|
||||||
|
ErrorKind::PermissionDenied,
|
||||||
|
format!("pid file path is not a regular file: '{}'", path.display()),
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
let expected_uid = unsafe { hbb_common::libc::geteuid() as u32 };
|
||||||
|
if stat.st_uid as u32 != expected_uid {
|
||||||
|
return Err(Error::new(
|
||||||
|
ErrorKind::PermissionDenied,
|
||||||
|
format!(
|
||||||
|
"pid file owner mismatch: expected uid {}, got {} for '{}'",
|
||||||
|
expected_uid,
|
||||||
|
stat.st_uid,
|
||||||
|
path.display()
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
if unsafe { hbb_common::libc::fchmod(fd, 0o600) } != 0 {
|
||||||
|
let os_err = std::io::Error::last_os_error();
|
||||||
|
return Err(Error::new(
|
||||||
|
os_err.kind(),
|
||||||
|
format!("failed to chmod pid file '{}': {}", path.display(), os_err),
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
if unsafe { hbb_common::libc::ftruncate(fd, 0) } != 0 {
|
||||||
|
let os_err = std::io::Error::last_os_error();
|
||||||
|
return Err(Error::new(
|
||||||
|
os_err.kind(),
|
||||||
|
format!(
|
||||||
|
"failed to truncate pid file '{}': {}",
|
||||||
|
path.display(),
|
||||||
|
os_err
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let bytes = std::process::id().to_string();
|
||||||
|
let buf = bytes.as_bytes();
|
||||||
|
// `write(2)` is allowed to return a short write even for regular files.
|
||||||
|
// PID content is tiny and usually written in one shot, but we still loop
|
||||||
|
// until all bytes are persisted so this path is semantically correct.
|
||||||
|
let mut written = 0usize;
|
||||||
|
while written < buf.len() {
|
||||||
|
let rc = unsafe {
|
||||||
|
hbb_common::libc::write(
|
||||||
|
fd,
|
||||||
|
buf[written..].as_ptr() as *const hbb_common::libc::c_void,
|
||||||
|
buf.len() - written,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if rc < 0 {
|
||||||
|
let os_err = std::io::Error::last_os_error();
|
||||||
|
return Err(Error::new(
|
||||||
|
os_err.kind(),
|
||||||
|
format!("failed to write pid file '{}': {}", path.display(), os_err),
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
if rc == 0 {
|
||||||
|
return Err(Error::new(
|
||||||
|
ErrorKind::WriteZero,
|
||||||
|
format!(
|
||||||
|
"failed to write pid file '{}': write returned 0 bytes",
|
||||||
|
path.display()
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
written += rc as usize;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
pub(crate) fn write_pid(postfix: &str) {
|
||||||
|
let path = std::path::PathBuf::from(get_pid_file(postfix));
|
||||||
|
if let Err(err) = write_pid_file(&path) {
|
||||||
|
log::warn!(
|
||||||
|
"Failed to write pid file for postfix '{}', path='{}', err={}",
|
||||||
|
postfix,
|
||||||
|
path.display(),
|
||||||
|
err
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Purpose:
|
||||||
|
// - Read pid file safely and avoid trusting symlink/non-regular files.
|
||||||
|
//
|
||||||
|
// Approach:
|
||||||
|
// - Use libc open/fstat/read syscalls (FFI) to control flags and inode checks.
|
||||||
|
// - Open path with O_NOFOLLOW, validate opened fd via fstat, then read and parse.
|
||||||
|
// - Keep unsafe scopes minimal and check syscall return values immediately.
|
||||||
|
//
|
||||||
|
// Main steps:
|
||||||
|
// 1) Secure-open pid file read-only.
|
||||||
|
// 2) Ensure fd points to regular file.
|
||||||
|
// 3) Read bytes and parse usize pid.
|
||||||
|
//
|
||||||
|
// References:
|
||||||
|
// - open(2): O_NOFOLLOW/O_CLOEXEC/O_NONBLOCK
|
||||||
|
// https://man7.org/linux/man-pages/man2/open.2.html
|
||||||
|
// - fstat(2): validate S_IFREG on opened fd
|
||||||
|
// https://man7.org/linux/man-pages/man2/fstat.2.html
|
||||||
|
// - read(2): read bytes via fd
|
||||||
|
// https://man7.org/linux/man-pages/man2/read.2.html
|
||||||
|
#[inline]
|
||||||
|
fn read_pid_file_secure(path: &Path) -> Option<usize> {
|
||||||
|
let path_c = CString::new(path.as_os_str().as_bytes().to_vec()).ok()?;
|
||||||
|
let flags = hbb_common::libc::O_RDONLY
|
||||||
|
| hbb_common::libc::O_CLOEXEC
|
||||||
|
| hbb_common::libc::O_NOFOLLOW
|
||||||
|
| hbb_common::libc::O_NONBLOCK;
|
||||||
|
let fd = unsafe { hbb_common::libc::open(path_c.as_ptr(), flags) };
|
||||||
|
if fd < 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let _fd_guard = FdGuard(fd);
|
||||||
|
|
||||||
|
let mut stat: hbb_common::libc::stat = unsafe { std::mem::zeroed() };
|
||||||
|
if unsafe { hbb_common::libc::fstat(fd, &mut stat) } != 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if (stat.st_mode & (hbb_common::libc::S_IFMT as hbb_common::libc::mode_t))
|
||||||
|
!= (hbb_common::libc::S_IFREG as hbb_common::libc::mode_t)
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut buffer = [0u8; 64];
|
||||||
|
let read_len = unsafe {
|
||||||
|
hbb_common::libc::read(
|
||||||
|
fd,
|
||||||
|
buffer.as_mut_ptr() as *mut hbb_common::libc::c_void,
|
||||||
|
buffer.len(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if read_len <= 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let content = String::from_utf8_lossy(&buffer[..read_len as usize]).to_string();
|
||||||
|
content.trim().parse::<usize>().ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
async fn probe_existing_listener(postfix: &str) -> bool {
|
||||||
|
let Ok(mut stream) = connect(1000, postfix).await else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if postfix != crate::POSTFIX_SERVICE {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if stream.send(&Data::SyncConfig(None)).await.is_err() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
matches!(
|
||||||
|
stream.next_timeout(1000).await,
|
||||||
|
Ok(Some(Data::SyncConfig(Some(_))))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn check_pid(postfix: &str) -> bool {
|
||||||
|
let pid_file = std::path::PathBuf::from(get_pid_file(postfix));
|
||||||
|
if let Some(pid) = read_pid_file_secure(&pid_file) {
|
||||||
|
if pid > 0 {
|
||||||
|
let mut sys = hbb_common::sysinfo::System::new();
|
||||||
|
sys.refresh_processes();
|
||||||
|
if let Some(p) = sys.process(pid.into()) {
|
||||||
|
if let Some(current) = sys.process((std::process::id() as usize).into()) {
|
||||||
|
if current.name() == p.name() && probe_existing_listener(postfix).await {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if probe_existing_listener(postfix).await {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// if not remove old ipc file, the new ipc creation will fail
|
||||||
|
// if we remove a ipc file, but the old ipc process is still running,
|
||||||
|
// new connection to the ipc will connect to new ipc, old connection to old ipc still keep alive
|
||||||
|
if let Err(err) = remove_ipc_socket_via_secure_parent_fd(postfix) {
|
||||||
|
log::debug!(
|
||||||
|
"Failed to remove stale ipc socket via secure parent fd: postfix={}, err={}",
|
||||||
|
postfix,
|
||||||
|
err
|
||||||
|
);
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
pub(crate) fn should_scrub_parent_entries_after_check_pid(
|
||||||
|
should_scrub_parent_entries: bool,
|
||||||
|
existing_listener_alive: bool,
|
||||||
|
) -> bool {
|
||||||
|
should_scrub_parent_entries && !existing_listener_alive
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
#[test]
|
||||||
|
fn test_write_pid_file_rejects_symlink() {
|
||||||
|
use std::os::unix::fs::symlink;
|
||||||
|
|
||||||
|
let unique = format!(
|
||||||
|
"rustdesk-ipc-pid-file-test-{}-{}",
|
||||||
|
std::process::id(),
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_nanos()
|
||||||
|
);
|
||||||
|
let base = std::env::temp_dir().join(unique);
|
||||||
|
std::fs::create_dir_all(&base).unwrap();
|
||||||
|
|
||||||
|
let target = base.join("target_pid");
|
||||||
|
std::fs::write(&target, b"origin").unwrap();
|
||||||
|
let link = base.join("pid_link");
|
||||||
|
symlink(&target, &link).unwrap();
|
||||||
|
|
||||||
|
let res = super::write_pid_file(&link);
|
||||||
|
assert!(res.is_err());
|
||||||
|
assert_eq!(std::fs::read_to_string(&target).unwrap(), "origin");
|
||||||
|
|
||||||
|
std::fs::remove_file(&link).ok();
|
||||||
|
std::fs::remove_file(&target).ok();
|
||||||
|
std::fs::remove_dir_all(&base).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ensure_secure_ipc_parent_dir_rejects_symlink_parent() {
|
||||||
|
use std::os::unix::fs::symlink;
|
||||||
|
|
||||||
|
let unique = format!(
|
||||||
|
"rustdesk-ipc-secure-dir-test-{}-{}",
|
||||||
|
std::process::id(),
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_nanos()
|
||||||
|
);
|
||||||
|
let base = std::env::temp_dir().join(unique);
|
||||||
|
let real_dir = base.join("real");
|
||||||
|
let link_dir = base.join("link");
|
||||||
|
std::fs::create_dir_all(&real_dir).unwrap();
|
||||||
|
symlink(&real_dir, &link_dir).unwrap();
|
||||||
|
let ipc_path = link_dir.join("ipc_service");
|
||||||
|
let res =
|
||||||
|
super::ensure_secure_ipc_parent_dir(ipc_path.to_string_lossy().as_ref(), "_service");
|
||||||
|
assert!(res.is_err());
|
||||||
|
std::fs::remove_file(&link_dir).ok();
|
||||||
|
std::fs::remove_dir_all(&real_dir).ok();
|
||||||
|
std::fs::remove_dir_all(&base).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ensure_secure_ipc_parent_dir_creates_parent_with_expected_mode() {
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
|
||||||
|
let unique = format!(
|
||||||
|
"rustdesk-ipc-secure-dir-create-test-{}-{}",
|
||||||
|
std::process::id(),
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_nanos()
|
||||||
|
);
|
||||||
|
let base = std::env::temp_dir().join(unique);
|
||||||
|
std::fs::create_dir_all(&base).unwrap();
|
||||||
|
|
||||||
|
// Intentionally choose a parent that does not exist to exercise the ENOENT -> mkdir branch.
|
||||||
|
let parent_dir = base.join("parent");
|
||||||
|
assert!(!parent_dir.exists());
|
||||||
|
let ipc_path = parent_dir.join("ipc");
|
||||||
|
|
||||||
|
let res = super::ensure_secure_ipc_parent_dir(ipc_path.to_string_lossy().as_ref(), "");
|
||||||
|
// Restrictive umask can make mkdir create a stricter initial mode. In that case
|
||||||
|
// ensure_secure_ipc_parent_dir repairs it with fchmod and may request a scrub.
|
||||||
|
res.unwrap();
|
||||||
|
|
||||||
|
let md = std::fs::metadata(&parent_dir).unwrap();
|
||||||
|
assert!(md.is_dir());
|
||||||
|
let mode = md.permissions().mode() & 0o777;
|
||||||
|
assert_eq!(mode, 0o0700);
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(&base).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_scrub_preexisting_ipc_parent_entries_only_removes_target_postfix_artifacts() {
|
||||||
|
use std::os::unix::ffi::OsStrExt;
|
||||||
|
|
||||||
|
let unique = format!(
|
||||||
|
"rustdesk-ipc-scrub-test-{}-{}",
|
||||||
|
std::process::id(),
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_nanos()
|
||||||
|
);
|
||||||
|
let base = std::env::temp_dir().join(unique);
|
||||||
|
std::fs::create_dir_all(&base).unwrap();
|
||||||
|
|
||||||
|
let ipc_file = base.join("ipc_service");
|
||||||
|
let ipc_pid_file = base.join("ipc_service.pid");
|
||||||
|
let ipc_other_postfix_file = base.join("ipc_uinput_1");
|
||||||
|
let keep_file = base.join("keep.txt");
|
||||||
|
let keep_dir = base.join("keep_dir");
|
||||||
|
|
||||||
|
std::fs::write(&ipc_file, b"socket-placeholder").unwrap();
|
||||||
|
std::fs::write(&ipc_pid_file, b"1234").unwrap();
|
||||||
|
std::fs::write(&ipc_other_postfix_file, b"other-postfix").unwrap();
|
||||||
|
std::fs::write(&keep_file, b"keep").unwrap();
|
||||||
|
std::fs::create_dir_all(&keep_dir).unwrap();
|
||||||
|
|
||||||
|
let base_c = std::ffi::CString::new(base.as_os_str().as_bytes().to_vec()).unwrap();
|
||||||
|
let base_fd = super::open_ipc_parent_dir_fd(&base_c).unwrap();
|
||||||
|
let _base_guard = super::FdGuard(base_fd);
|
||||||
|
super::scrub_preexisting_ipc_parent_entries(base_fd, &base, "_service").unwrap();
|
||||||
|
|
||||||
|
assert!(!ipc_file.exists());
|
||||||
|
assert!(!ipc_pid_file.exists());
|
||||||
|
assert!(ipc_other_postfix_file.exists());
|
||||||
|
assert!(keep_file.exists());
|
||||||
|
assert!(keep_dir.exists());
|
||||||
|
|
||||||
|
std::fs::remove_file(&ipc_other_postfix_file).ok();
|
||||||
|
std::fs::remove_file(&keep_file).ok();
|
||||||
|
std::fs::remove_dir_all(&keep_dir).ok();
|
||||||
|
std::fs::remove_dir_all(&base).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_scrub_preexisting_ipc_parent_entries_should_bind_to_opened_inode_not_path() {
|
||||||
|
use std::os::unix::ffi::OsStrExt;
|
||||||
|
|
||||||
|
let unique = format!(
|
||||||
|
"rustdesk-ipc-scrub-fd-bind-test-{}-{}",
|
||||||
|
std::process::id(),
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_nanos()
|
||||||
|
);
|
||||||
|
let base = std::env::temp_dir().join(unique);
|
||||||
|
std::fs::create_dir_all(&base).unwrap();
|
||||||
|
|
||||||
|
let trusted_parent = base.join("trusted_parent");
|
||||||
|
let trusted_parent_moved = base.join("trusted_parent_moved");
|
||||||
|
let attacker_parent = base.join("attacker_parent");
|
||||||
|
std::fs::create_dir_all(&trusted_parent).unwrap();
|
||||||
|
std::fs::create_dir_all(&attacker_parent).unwrap();
|
||||||
|
|
||||||
|
let trusted_ipc_file = trusted_parent.join("ipc_service");
|
||||||
|
let attacker_ipc_file = attacker_parent.join("ipc_service");
|
||||||
|
std::fs::write(&trusted_ipc_file, b"trusted").unwrap();
|
||||||
|
std::fs::write(&attacker_ipc_file, b"attacker").unwrap();
|
||||||
|
|
||||||
|
let trusted_parent_c =
|
||||||
|
std::ffi::CString::new(trusted_parent.as_os_str().as_bytes().to_vec()).unwrap();
|
||||||
|
let trusted_parent_fd = super::open_ipc_parent_dir_fd(&trusted_parent_c).unwrap();
|
||||||
|
let _trusted_parent_guard = super::FdGuard(trusted_parent_fd);
|
||||||
|
|
||||||
|
// Swap the path after the trusted inode has been opened.
|
||||||
|
std::fs::rename(&trusted_parent, &trusted_parent_moved).unwrap();
|
||||||
|
std::fs::rename(&attacker_parent, &trusted_parent).unwrap();
|
||||||
|
|
||||||
|
super::scrub_preexisting_ipc_parent_entries(trusted_parent_fd, &trusted_parent, "_service")
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Expected secure behavior: scrub should target the inode that was opened before path swap.
|
||||||
|
assert!(
|
||||||
|
!trusted_parent_moved.join("ipc_service").exists(),
|
||||||
|
"trusted inode artifact should be removed even after path swap"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
trusted_parent.join("ipc_service").exists(),
|
||||||
|
"path-swapped attacker directory should not be scrubbed"
|
||||||
|
);
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(&base).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ensure_secure_ipc_parent_dir_keeps_service_artifacts_before_liveness_probe() {
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
|
||||||
|
let unique = format!(
|
||||||
|
"rustdesk-ipc-secure-dir-order-test-{}-{}",
|
||||||
|
std::process::id(),
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_nanos()
|
||||||
|
);
|
||||||
|
let base = std::env::temp_dir().join(unique);
|
||||||
|
std::fs::create_dir_all(&base).unwrap();
|
||||||
|
|
||||||
|
let parent_dir = base.join("service_parent");
|
||||||
|
std::fs::create_dir_all(&parent_dir).unwrap();
|
||||||
|
// Trigger "had_untrusted_service_parent_mode".
|
||||||
|
std::fs::set_permissions(&parent_dir, std::fs::Permissions::from_mode(0o777)).unwrap();
|
||||||
|
|
||||||
|
let ipc_file = parent_dir.join("ipc_service");
|
||||||
|
let ipc_pid_file = parent_dir.join("ipc_service.pid");
|
||||||
|
std::fs::write(&ipc_file, b"socket-placeholder").unwrap();
|
||||||
|
std::fs::write(&ipc_pid_file, b"1234").unwrap();
|
||||||
|
|
||||||
|
let res =
|
||||||
|
super::ensure_secure_ipc_parent_dir(ipc_file.to_string_lossy().as_ref(), "_service");
|
||||||
|
assert_eq!(res.unwrap(), true);
|
||||||
|
|
||||||
|
// Parent hardening should run first; artifacts should stay until liveness probe completes.
|
||||||
|
assert!(ipc_file.exists(), "ipc socket marker should be preserved");
|
||||||
|
assert!(ipc_pid_file.exists(), "pid marker should be preserved");
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(&base).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ensure_secure_ipc_parent_dir_marks_non_service_mode_repair_for_scrub() {
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
|
||||||
|
let unique = format!(
|
||||||
|
"rustdesk-ipc-nonservice-mode-repair-test-{}-{}",
|
||||||
|
std::process::id(),
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_nanos()
|
||||||
|
);
|
||||||
|
let base = std::env::temp_dir().join(unique);
|
||||||
|
std::fs::create_dir_all(&base).unwrap();
|
||||||
|
|
||||||
|
let parent_dir = base.join("non_service_parent");
|
||||||
|
std::fs::create_dir_all(&parent_dir).unwrap();
|
||||||
|
std::fs::set_permissions(&parent_dir, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||||
|
|
||||||
|
let ipc_file = parent_dir.join("ipc");
|
||||||
|
std::fs::write(&ipc_file, b"socket-placeholder").unwrap();
|
||||||
|
|
||||||
|
let res = super::ensure_secure_ipc_parent_dir(ipc_file.to_string_lossy().as_ref(), "");
|
||||||
|
assert_eq!(res.unwrap(), true);
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(&base).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_should_scrub_parent_entries_after_check_pid_only_when_requested_and_not_alive() {
|
||||||
|
assert!(!super::should_scrub_parent_entries_after_check_pid(
|
||||||
|
false, false
|
||||||
|
));
|
||||||
|
assert!(!super::should_scrub_parent_entries_after_check_pid(
|
||||||
|
false, true
|
||||||
|
));
|
||||||
|
assert!(super::should_scrub_parent_entries_after_check_pid(
|
||||||
|
true, false
|
||||||
|
));
|
||||||
|
assert!(!super::should_scrub_parent_entries_after_check_pid(
|
||||||
|
true, true
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -743,6 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
|||||||
("Display Name", "Kijelző név"),
|
("Display Name", "Kijelző név"),
|
||||||
("password-hidden-tip", "Állandó jelszó lett beállítva (rejtett)."),
|
("password-hidden-tip", "Állandó jelszó lett beállítva (rejtett)."),
|
||||||
("preset-password-in-use-tip", "Jelenleg az alapértelmezett jelszót használja."),
|
("preset-password-in-use-tip", "Jelenleg az alapértelmezett jelszót használja."),
|
||||||
("Enable privacy mode", ""),
|
("Enable privacy mode", "Adatvédelmi mód aktiválása"),
|
||||||
].iter().cloned().collect();
|
].iter().cloned().collect();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -739,7 +739,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
|||||||
("Changelog", "更新履歴"),
|
("Changelog", "更新履歴"),
|
||||||
("keep-awake-during-outgoing-sessions-label", "送信セッション中は、画面のスリープを無効化する"),
|
("keep-awake-during-outgoing-sessions-label", "送信セッション中は、画面のスリープを無効化する"),
|
||||||
("keep-awake-during-incoming-sessions-label", "受信セッション中は、画面のスリープを無効化する"),
|
("keep-awake-during-incoming-sessions-label", "受信セッション中は、画面のスリープを無効化する"),
|
||||||
("Continue with {}", "{}で続行する"),
|
("Continue with {}", "{} で続行する"),
|
||||||
("Display Name", "表示名"),
|
("Display Name", "表示名"),
|
||||||
("password-hidden-tip", "永続的なパスワードが設定されています (非表示)"),
|
("password-hidden-tip", "永続的なパスワードが設定されています (非表示)"),
|
||||||
("preset-password-in-use-tip", "プリセットパスワードが現在使用されています"),
|
("preset-password-in-use-tip", "プリセットパスワードが現在使用されています"),
|
||||||
|
|||||||
@@ -743,6 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
|||||||
("Display Name", "표시 이름"),
|
("Display Name", "표시 이름"),
|
||||||
("password-hidden-tip", "영구 비밀번호가 설정되었습니다 (숨김)."),
|
("password-hidden-tip", "영구 비밀번호가 설정되었습니다 (숨김)."),
|
||||||
("preset-password-in-use-tip", "현재 사전 설정된 비밀번호가 사용 중입니다."),
|
("preset-password-in-use-tip", "현재 사전 설정된 비밀번호가 사용 중입니다."),
|
||||||
("Enable privacy mode", ""),
|
("Enable privacy mode", "개인정보 보호 모드 사용함"),
|
||||||
].iter().cloned().collect();
|
].iter().cloned().collect();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -743,6 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
|||||||
("Display Name", "Naam Weergeven"),
|
("Display Name", "Naam Weergeven"),
|
||||||
("password-hidden-tip", "Er is een permanent wachtwoord ingesteld (verborgen)."),
|
("password-hidden-tip", "Er is een permanent wachtwoord ingesteld (verborgen)."),
|
||||||
("preset-password-in-use-tip", "Het basis wachtwoord is momenteel in gebruik."),
|
("preset-password-in-use-tip", "Het basis wachtwoord is momenteel in gebruik."),
|
||||||
("Enable privacy mode", ""),
|
("Enable privacy mode", "Schakel privacymodus in"),
|
||||||
].iter().cloned().collect();
|
].iter().cloned().collect();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,12 @@ use wallpaper;
|
|||||||
pub const PA_SAMPLE_RATE: u32 = 48000;
|
pub const PA_SAMPLE_RATE: u32 = 48000;
|
||||||
static mut UNMODIFIED: bool = true;
|
static mut UNMODIFIED: bool = true;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct ActiveUserLookupCache {
|
||||||
|
uid: String,
|
||||||
|
username: String,
|
||||||
|
}
|
||||||
|
|
||||||
const INVALID_TERM_VALUES: [&str; 3] = ["", "unknown", "dumb"];
|
const INVALID_TERM_VALUES: [&str; 3] = ["", "unknown", "dumb"];
|
||||||
const SHELL_PROCESSES: [&str; 4] = ["bash", "zsh", "fish", "sh"];
|
const SHELL_PROCESSES: [&str; 4] = ["bash", "zsh", "fish", "sh"];
|
||||||
|
|
||||||
@@ -50,6 +56,8 @@ lazy_static::lazy_static! {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
static ref ACTIVE_USER_LOOKUP_CACHE: std::sync::Mutex<Option<ActiveUserLookupCache>> =
|
||||||
|
std::sync::Mutex::new(None);
|
||||||
// https://github.com/rustdesk/rustdesk/issues/13705
|
// https://github.com/rustdesk/rustdesk/issues/13705
|
||||||
// Check if `sudo -E` actually preserves environment.
|
// Check if `sudo -E` actually preserves environment.
|
||||||
//
|
//
|
||||||
@@ -82,6 +90,27 @@ lazy_static::lazy_static! {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn update_active_user_lookup_cache(desktop: &Desktop) {
|
||||||
|
if let Ok(mut cache) = ACTIVE_USER_LOOKUP_CACHE.lock() {
|
||||||
|
if desktop.uid.is_empty() || desktop.username.is_empty() {
|
||||||
|
*cache = None;
|
||||||
|
} else {
|
||||||
|
*cache = Some(ActiveUserLookupCache {
|
||||||
|
uid: desktop.uid.clone(),
|
||||||
|
username: desktop.username.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn get_active_user_id_name_from_cache() -> Option<(String, String)> {
|
||||||
|
let cache = ACTIVE_USER_LOOKUP_CACHE.lock().ok()?;
|
||||||
|
let entry = cache.as_ref()?;
|
||||||
|
Some((entry.uid.clone(), entry.username.clone()))
|
||||||
|
}
|
||||||
|
|
||||||
thread_local! {
|
thread_local! {
|
||||||
// XDO context - created via libxdo-sys (which uses dynamic loading stub).
|
// XDO context - created via libxdo-sys (which uses dynamic loading stub).
|
||||||
// If libxdo is not available, xdo will be null and xdo-based functions become no-ops.
|
// If libxdo is not available, xdo will be null and xdo-based functions become no-ops.
|
||||||
@@ -789,6 +818,7 @@ pub fn start_os_service() {
|
|||||||
let mut last_restart = Instant::now();
|
let mut last_restart = Instant::now();
|
||||||
while running.load(Ordering::SeqCst) {
|
while running.load(Ordering::SeqCst) {
|
||||||
desktop.refresh();
|
desktop.refresh();
|
||||||
|
update_active_user_lookup_cache(&desktop);
|
||||||
|
|
||||||
// Duplicate logic here with should_start_server
|
// Duplicate logic here with should_start_server
|
||||||
// Login wayland will try to start a headless --server.
|
// Login wayland will try to start a headless --server.
|
||||||
@@ -861,13 +891,29 @@ pub fn start_os_service() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[inline]
|
#[inline]
|
||||||
|
/// Returns the cached active `(uid, username)` snapshot when available.
|
||||||
|
/// Callers that require a fresh seat0 lookup should call `get_values_of_seat0` directly.
|
||||||
pub fn get_active_user_id_name() -> (String, String) {
|
pub fn get_active_user_id_name() -> (String, String) {
|
||||||
|
if let Some(id_name) = get_active_user_id_name_from_cache() {
|
||||||
|
return id_name;
|
||||||
|
}
|
||||||
let vec_id_name = get_values_of_seat0(&[1, 2]);
|
let vec_id_name = get_values_of_seat0(&[1, 2]);
|
||||||
(vec_id_name[0].clone(), vec_id_name[1].clone())
|
(vec_id_name[0].clone(), vec_id_name[1].clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[inline]
|
#[inline]
|
||||||
|
/// Returns the cached active uid when available.
|
||||||
|
/// Callers that require a fresh seat0 lookup should call `get_values_of_seat0` directly.
|
||||||
pub fn get_active_userid() -> String {
|
pub fn get_active_userid() -> String {
|
||||||
|
if let Some((uid, _)) = get_active_user_id_name_from_cache() {
|
||||||
|
return uid;
|
||||||
|
}
|
||||||
|
get_values_of_seat0(&[1])[0].clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
/// Returns the active uid from a fresh seat0 lookup, bypassing the service-loop cache.
|
||||||
|
pub fn get_active_userid_fresh() -> String {
|
||||||
get_values_of_seat0(&[1])[0].clone()
|
get_values_of_seat0(&[1])[0].clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -922,7 +968,12 @@ fn _get_display_manager() -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[inline]
|
#[inline]
|
||||||
|
/// Returns the cached active username when available.
|
||||||
|
/// Callers that require a fresh seat0 lookup should call `get_values_of_seat0` directly.
|
||||||
pub fn get_active_username() -> String {
|
pub fn get_active_username() -> String {
|
||||||
|
if let Some((_, username)) = get_active_user_id_name_from_cache() {
|
||||||
|
return username;
|
||||||
|
}
|
||||||
get_values_of_seat0(&[2])[0].clone()
|
get_values_of_seat0(&[2])[0].clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use super::{linux::*, ResultType};
|
|||||||
use crate::client::{
|
use crate::client::{
|
||||||
LOGIN_MSG_DESKTOP_NO_DESKTOP, LOGIN_MSG_DESKTOP_SESSION_ANOTHER_USER,
|
LOGIN_MSG_DESKTOP_NO_DESKTOP, LOGIN_MSG_DESKTOP_SESSION_ANOTHER_USER,
|
||||||
LOGIN_MSG_DESKTOP_SESSION_NOT_READY, LOGIN_MSG_DESKTOP_XORG_NOT_FOUND,
|
LOGIN_MSG_DESKTOP_SESSION_NOT_READY, LOGIN_MSG_DESKTOP_XORG_NOT_FOUND,
|
||||||
LOGIN_MSG_DESKTOP_XSESSION_FAILED,
|
LOGIN_MSG_DESKTOP_XSESSION_FAILED, LOGIN_MSG_PASSWORD_WRONG,
|
||||||
};
|
};
|
||||||
use hbb_common::{
|
use hbb_common::{
|
||||||
allow_err, bail, log,
|
allow_err, bail, log,
|
||||||
@@ -94,6 +94,49 @@ fn detect_headless() -> Option<&'static str> {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
|
||||||
|
enum XSessionStartErrorKind {
|
||||||
|
Auth,
|
||||||
|
Env,
|
||||||
|
}
|
||||||
|
|
||||||
|
const XSESSION_AUTH_FAILURE_DETAIL: &str = "authentication failed";
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct XSessionStartError {
|
||||||
|
kind: XSessionStartErrorKind,
|
||||||
|
detail: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl XSessionStartError {
|
||||||
|
fn auth(detail: String) -> Self {
|
||||||
|
Self {
|
||||||
|
kind: XSessionStartErrorKind::Auth,
|
||||||
|
detail,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn env(detail: String) -> Self {
|
||||||
|
Self {
|
||||||
|
kind: XSessionStartErrorKind::Env,
|
||||||
|
detail,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for XSessionStartError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(f, "{}", self.detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_xsession_start_error_to_login_msg(kind: XSessionStartErrorKind) -> &'static str {
|
||||||
|
match kind {
|
||||||
|
XSessionStartErrorKind::Auth => LOGIN_MSG_PASSWORD_WRONG,
|
||||||
|
XSessionStartErrorKind::Env => LOGIN_MSG_DESKTOP_XSESSION_FAILED,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn try_start_desktop(_username: &str, _passsword: &str) -> String {
|
pub fn try_start_desktop(_username: &str, _passsword: &str) -> String {
|
||||||
debug_assert!(crate::is_server());
|
debug_assert!(crate::is_server());
|
||||||
if _username.is_empty() {
|
if _username.is_empty() {
|
||||||
@@ -136,14 +179,21 @@ pub fn try_start_desktop(_username: &str, _passsword: &str) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("Failed to start xsession {}", e);
|
match e.kind {
|
||||||
LOGIN_MSG_DESKTOP_XSESSION_FAILED.to_owned()
|
XSessionStartErrorKind::Auth => {
|
||||||
|
log::warn!("Failed to authenticate xsession user {}", e);
|
||||||
|
}
|
||||||
|
XSessionStartErrorKind::Env => {
|
||||||
|
log::error!("Failed to start xsession {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
map_xsession_start_error_to_login_msg(e.kind).to_owned()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn try_start_x_session(username: &str, password: &str) -> ResultType<(String, bool)> {
|
fn try_start_x_session(username: &str, password: &str) -> Result<(String, bool), XSessionStartError> {
|
||||||
let mut desktop_manager = DESKTOP_MANAGER.lock().unwrap();
|
let mut desktop_manager = DESKTOP_MANAGER.lock().unwrap();
|
||||||
if let Some(desktop_manager) = &mut (*desktop_manager) {
|
if let Some(desktop_manager) = &mut (*desktop_manager) {
|
||||||
if let Some(seat0_username) = desktop_manager.get_supported_display_seat0_username() {
|
if let Some(seat0_username) = desktop_manager.get_supported_display_seat0_username() {
|
||||||
@@ -161,7 +211,9 @@ fn try_start_x_session(username: &str, password: &str) -> ResultType<(String, bo
|
|||||||
desktop_manager.is_running(),
|
desktop_manager.is_running(),
|
||||||
))
|
))
|
||||||
} else {
|
} else {
|
||||||
bail!(crate::client::LOGIN_MSG_DESKTOP_NOT_INITED);
|
Err(XSessionStartError::env(
|
||||||
|
crate::client::LOGIN_MSG_DESKTOP_NOT_INITED.to_owned(),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,10 +299,15 @@ impl DesktopManager {
|
|||||||
self.is_child_running.load(Ordering::SeqCst)
|
self.is_child_running.load(Ordering::SeqCst)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn try_start_x_session(&mut self, username: &str, password: &str) -> ResultType<()> {
|
fn try_start_x_session(
|
||||||
|
&mut self,
|
||||||
|
username: &str,
|
||||||
|
password: &str,
|
||||||
|
) -> Result<(), XSessionStartError> {
|
||||||
match get_user_by_name(username) {
|
match get_user_by_name(username) {
|
||||||
Some(userinfo) => {
|
Some(userinfo) => {
|
||||||
let mut client = pam::Client::with_password(&pam_get_service_name())?;
|
let mut client = pam::Client::with_password(&pam_get_service_name())
|
||||||
|
.map_err(|e| XSessionStartError::env(format!("failed to init pam client, {}", e)))?;
|
||||||
client
|
client
|
||||||
.conversation_mut()
|
.conversation_mut()
|
||||||
.set_credentials(username, password);
|
.set_credentials(username, password);
|
||||||
@@ -267,17 +324,24 @@ impl DesktopManager {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
bail!("failed to start x session, {}", e);
|
Err(XSessionStartError::env(format!(
|
||||||
|
"failed to start x session, {}",
|
||||||
|
e
|
||||||
|
)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(_e) => {
|
||||||
bail!("failed to check user pass for {}, {}", username, e);
|
Err(XSessionStartError::auth(
|
||||||
|
XSESSION_AUTH_FAILURE_DETAIL.to_owned(),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
bail!("failed to get userinfo of {}", username);
|
Err(XSessionStartError::auth(
|
||||||
|
XSESSION_AUTH_FAILURE_DETAIL.to_owned(),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,10 +73,19 @@ use winapi::{
|
|||||||
};
|
};
|
||||||
use windows::Win32::{
|
use windows::Win32::{
|
||||||
Foundation::{CloseHandle as WinCloseHandle, HANDLE as WinHANDLE},
|
Foundation::{CloseHandle as WinCloseHandle, HANDLE as WinHANDLE},
|
||||||
|
Security::{
|
||||||
|
GetTokenInformation as WinGetTokenInformation, IsWellKnownSid, TokenUser,
|
||||||
|
WinLocalSystemSid, TOKEN_QUERY as WIN_TOKEN_QUERY, TOKEN_USER,
|
||||||
|
},
|
||||||
System::Diagnostics::ToolHelp::{
|
System::Diagnostics::ToolHelp::{
|
||||||
CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W,
|
CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W,
|
||||||
TH32CS_SNAPPROCESS,
|
TH32CS_SNAPPROCESS,
|
||||||
},
|
},
|
||||||
|
System::Threading::{
|
||||||
|
OpenProcess as WinOpenProcess, OpenProcessToken as WinOpenProcessToken,
|
||||||
|
QueryFullProcessImageNameW as WinQueryFullProcessImageNameW,
|
||||||
|
PROCESS_QUERY_LIMITED_INFORMATION as WIN_PROCESS_QUERY_LIMITED_INFORMATION,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
use windows_service::{
|
use windows_service::{
|
||||||
define_windows_service,
|
define_windows_service,
|
||||||
@@ -88,6 +97,14 @@ use windows_service::{
|
|||||||
};
|
};
|
||||||
use winreg::{enums::*, RegKey};
|
use winreg::{enums::*, RegKey};
|
||||||
|
|
||||||
|
mod acl;
|
||||||
|
pub(crate) use acl::current_process_user_sid_string;
|
||||||
|
pub use acl::{
|
||||||
|
set_path_permission, set_path_permission_for_portable_service_shmem_dir,
|
||||||
|
set_path_permission_for_portable_service_shmem_file,
|
||||||
|
validate_path_for_portable_service_shmem_dir,
|
||||||
|
};
|
||||||
|
|
||||||
pub const FLUTTER_RUNNER_WIN32_WINDOW_CLASS: &'static str = "FLUTTER_RUNNER_WIN32_WINDOW"; // main window, install window
|
pub const FLUTTER_RUNNER_WIN32_WINDOW_CLASS: &'static str = "FLUTTER_RUNNER_WIN32_WINDOW"; // main window, install window
|
||||||
pub const EXPLORER_EXE: &'static str = "explorer.exe";
|
pub const EXPLORER_EXE: &'static str = "explorer.exe";
|
||||||
pub const SET_FOREGROUND_WINDOW: &'static str = "SET_FOREGROUND_WINDOW";
|
pub const SET_FOREGROUND_WINDOW: &'static str = "SET_FOREGROUND_WINDOW";
|
||||||
@@ -565,6 +582,55 @@ pub fn get_current_session_id(share_rdp: bool) -> DWORD {
|
|||||||
unsafe { get_current_session(if share_rdp { TRUE } else { FALSE }) }
|
unsafe { get_current_session(if share_rdp { TRUE } else { FALSE }) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn resolve_expected_active_session_id_for_service(session_id: u32) -> Option<u32> {
|
||||||
|
let share_rdp_enabled = is_share_rdp();
|
||||||
|
if get_available_sessions(false)
|
||||||
|
.iter()
|
||||||
|
.any(|e| e.sid == session_id)
|
||||||
|
{
|
||||||
|
return Some(session_id);
|
||||||
|
}
|
||||||
|
let current_active_session =
|
||||||
|
unsafe { get_current_session(if share_rdp_enabled { TRUE } else { FALSE }) };
|
||||||
|
if current_active_session == u32::MAX {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(current_active_session)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn authorize_service_scoped_ipc_connection(
|
||||||
|
stream: &ipc::Connection,
|
||||||
|
expected_active_session_id: Option<u32>,
|
||||||
|
) -> bool {
|
||||||
|
let (authorized, peer_pid, peer_session_id, peer_is_system) =
|
||||||
|
stream.service_authorization_status_for_session(expected_active_session_id);
|
||||||
|
if !authorized {
|
||||||
|
ipc::log_rejected_windows_ipc_connection(
|
||||||
|
crate::POSTFIX_SERVICE,
|
||||||
|
peer_pid,
|
||||||
|
peer_session_id,
|
||||||
|
expected_active_session_id,
|
||||||
|
peer_is_system,
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if let Err(err) =
|
||||||
|
ipc::ensure_peer_executable_matches_current_by_pid_opt(peer_pid, crate::POSTFIX_SERVICE)
|
||||||
|
{
|
||||||
|
log::warn!(
|
||||||
|
"Rejected unauthorized connection on protected service-scoped IPC channel due to executable mismatch: postfix={}, peer_pid={:?}, err={}",
|
||||||
|
crate::POSTFIX_SERVICE,
|
||||||
|
peer_pid,
|
||||||
|
err
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
extern "system" {
|
extern "system" {
|
||||||
fn BlockInput(v: BOOL) -> BOOL;
|
fn BlockInput(v: BOOL) -> BOOL;
|
||||||
}
|
}
|
||||||
@@ -631,6 +697,15 @@ async fn run_service(_arguments: Vec<OsString>) -> ResultType<()> {
|
|||||||
Ok(res) => match res {
|
Ok(res) => match res {
|
||||||
Some(Ok(stream)) => {
|
Some(Ok(stream)) => {
|
||||||
let mut stream = ipc::Connection::new(stream);
|
let mut stream = ipc::Connection::new(stream);
|
||||||
|
// Keep IPC authorization consistent with the session we are currently serving.
|
||||||
|
// Recompute expected session right before authorization to avoid using a stale
|
||||||
|
// session_id after awaiting incoming.next().
|
||||||
|
let expected_active_session_id =
|
||||||
|
resolve_expected_active_session_id_for_service(session_id);
|
||||||
|
if !authorize_service_scoped_ipc_connection(&stream, expected_active_session_id)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if let Ok(Some(data)) = stream.next_timeout(1000).await {
|
if let Ok(Some(data)) = stream.next_timeout(1000).await {
|
||||||
match data {
|
match data {
|
||||||
ipc::Data::Close => {
|
ipc::Data::Close => {
|
||||||
@@ -1141,6 +1216,22 @@ pub fn get_active_user_home() -> Option<PathBuf> {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(feature = "flutter"))]
|
||||||
|
#[inline]
|
||||||
|
pub fn portable_service_logon_helper_paths() -> Option<(PathBuf, PathBuf)> {
|
||||||
|
// Keep parity with history for now: derive LocalAppData from user profile path.
|
||||||
|
// If users report redirected/non-standard LocalAppData issues, switch to:
|
||||||
|
// `BaseDirs::new()?.data_local_dir()` for Known Folder-based resolution.
|
||||||
|
let user_dir = hbb_common::directories_next::UserDirs::new()?;
|
||||||
|
let dir = user_dir
|
||||||
|
.home_dir()
|
||||||
|
.join("AppData")
|
||||||
|
.join("Local")
|
||||||
|
.join("rustdesk-sciter");
|
||||||
|
let dst = dir.join("rustdesk.exe");
|
||||||
|
Some((dir, dst))
|
||||||
|
}
|
||||||
|
|
||||||
pub fn is_prelogin() -> bool {
|
pub fn is_prelogin() -> bool {
|
||||||
let Some(username) = get_current_session_username() else {
|
let Some(username) = get_current_session_username() else {
|
||||||
return false;
|
return false;
|
||||||
@@ -2327,16 +2418,33 @@ pub fn elevate_or_run_as_system(is_setup: bool, is_elevate: bool, is_run_as_syst
|
|||||||
is_run_as_system,
|
is_run_as_system,
|
||||||
crate::username(),
|
crate::username(),
|
||||||
);
|
);
|
||||||
let arg_elevate = if is_setup {
|
let mut arg_elevate = if is_setup {
|
||||||
"--noinstall --elevate"
|
"--noinstall --elevate"
|
||||||
} else {
|
} else {
|
||||||
"--elevate"
|
"--elevate"
|
||||||
};
|
}
|
||||||
let arg_run_as_system = if is_setup {
|
.to_owned();
|
||||||
|
let mut arg_run_as_system = if is_setup {
|
||||||
"--noinstall --run-as-system"
|
"--noinstall --run-as-system"
|
||||||
} else {
|
} else {
|
||||||
"--run-as-system"
|
"--run-as-system"
|
||||||
};
|
}
|
||||||
|
.to_owned();
|
||||||
|
let shmem_name_from_args = crate::portable_service::portable_service_shmem_name_from_args();
|
||||||
|
if shmem_name_from_args.is_none() && crate::portable_service::has_portable_service_shmem_arg() {
|
||||||
|
log::error!("Invalid portable service shared memory argument, aborting elevation flow");
|
||||||
|
// This is a malformed bootstrap argument in a privilege-sensitive path.
|
||||||
|
// Keep fail-closed process termination here to avoid continuing elevation
|
||||||
|
// with inconsistent shared-memory contract.
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
if let Some(shmem_name) = shmem_name_from_args {
|
||||||
|
let shmem_arg = crate::portable_service::portable_service_shmem_arg(&shmem_name);
|
||||||
|
arg_elevate.push(' ');
|
||||||
|
arg_elevate.push_str(&shmem_arg);
|
||||||
|
arg_run_as_system.push(' ');
|
||||||
|
arg_run_as_system.push_str(&shmem_arg);
|
||||||
|
}
|
||||||
if is_root() {
|
if is_root() {
|
||||||
if is_run_as_system {
|
if is_run_as_system {
|
||||||
log::info!("run portable service");
|
log::info!("run portable service");
|
||||||
@@ -2347,7 +2455,7 @@ pub fn elevate_or_run_as_system(is_setup: bool, is_elevate: bool, is_run_as_syst
|
|||||||
Ok(elevated) => {
|
Ok(elevated) => {
|
||||||
if elevated {
|
if elevated {
|
||||||
if !is_run_as_system {
|
if !is_run_as_system {
|
||||||
if run_as_system(arg_run_as_system).is_ok() {
|
if run_as_system(arg_run_as_system.as_str()).is_ok() {
|
||||||
std::process::exit(0);
|
std::process::exit(0);
|
||||||
} else {
|
} else {
|
||||||
log::error!(
|
log::error!(
|
||||||
@@ -2358,7 +2466,7 @@ pub fn elevate_or_run_as_system(is_setup: bool, is_elevate: bool, is_run_as_syst
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if !is_elevate {
|
if !is_elevate {
|
||||||
if let Ok(true) = elevate(arg_elevate) {
|
if let Ok(true) = elevate(arg_elevate.as_str()) {
|
||||||
std::process::exit(0);
|
std::process::exit(0);
|
||||||
} else {
|
} else {
|
||||||
log::error!("Failed to elevate, error {}", io::Error::last_os_error());
|
log::error!("Failed to elevate, error {}", io::Error::last_os_error());
|
||||||
@@ -2416,6 +2524,115 @@ pub fn is_elevated(process_id: Option<DWORD>) -> ResultType<bool> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
unsafe fn read_token_user_buffer(token: WinHANDLE, subject: &str) -> ResultType<Vec<u8>> {
|
||||||
|
let mut token_user_size = 0u32;
|
||||||
|
let get_info_result = WinGetTokenInformation(token, TokenUser, None, 0, &mut token_user_size);
|
||||||
|
match get_info_result {
|
||||||
|
Ok(()) => {
|
||||||
|
if token_user_size == 0 {
|
||||||
|
bail!(
|
||||||
|
"Failed to get {} token user size: unexpected zero buffer size",
|
||||||
|
subject
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
// Allow expected size-probe failures if Windows still returns required size.
|
||||||
|
let is_insufficient_buffer =
|
||||||
|
e.code() == windows::core::HRESULT::from_win32(ERROR_INSUFFICIENT_BUFFER as u32);
|
||||||
|
let is_bad_length =
|
||||||
|
e.code() == windows::core::HRESULT::from_win32(ERROR_BAD_LENGTH as u32);
|
||||||
|
if (!is_insufficient_buffer && !is_bad_length) || token_user_size == 0 {
|
||||||
|
bail!("Failed to get {} token user size: {}", subject, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut buffer = vec![0u8; token_user_size as usize];
|
||||||
|
WinGetTokenInformation(
|
||||||
|
token,
|
||||||
|
TokenUser,
|
||||||
|
Some(buffer.as_mut_ptr() as *mut core::ffi::c_void),
|
||||||
|
token_user_size,
|
||||||
|
&mut token_user_size,
|
||||||
|
)
|
||||||
|
.map_err(|e| anyhow!("Failed to get {} token user: {}", subject, e))?;
|
||||||
|
|
||||||
|
let min_size = std::mem::size_of::<TOKEN_USER>();
|
||||||
|
if buffer.len() < min_size {
|
||||||
|
bail!(
|
||||||
|
"Failed to parse {} token user: buffer too small (got {}, need >= {})",
|
||||||
|
subject,
|
||||||
|
buffer.len(),
|
||||||
|
min_size
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(buffer)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Similar to `is_root()` / `is_local_system()` but for an arbitrary process.
|
||||||
|
///
|
||||||
|
/// Returns `true` if the target process is running as LocalSystem (SID: S-1-5-18).
|
||||||
|
///
|
||||||
|
/// TODO: After a few releases of real-world validation, consider replacing
|
||||||
|
/// the legacy `is_local_system()` with this implementation.
|
||||||
|
pub fn is_process_running_as_system(process_id: DWORD) -> ResultType<bool> {
|
||||||
|
unsafe {
|
||||||
|
let process = WinOpenProcess(WIN_PROCESS_QUERY_LIMITED_INFORMATION, false, process_id)
|
||||||
|
.map_err(|e| anyhow!("Failed to open process {}: {}", process_id, e))?;
|
||||||
|
|
||||||
|
let mut token = WinHANDLE::default();
|
||||||
|
let result = (|| -> ResultType<bool> {
|
||||||
|
WinOpenProcessToken(process, WIN_TOKEN_QUERY, &mut token)
|
||||||
|
.map_err(|e| anyhow!("Failed to open process {} token: {}", process_id, e))?;
|
||||||
|
|
||||||
|
let token_subject = format!("process {}", process_id);
|
||||||
|
let buffer = read_token_user_buffer(token, token_subject.as_str())?;
|
||||||
|
let token_user: TOKEN_USER =
|
||||||
|
std::ptr::read_unaligned(buffer.as_ptr() as *const TOKEN_USER);
|
||||||
|
Ok(IsWellKnownSid(token_user.User.Sid, WinLocalSystemSid).as_bool())
|
||||||
|
})();
|
||||||
|
|
||||||
|
if !token.is_invalid() {
|
||||||
|
let _ = WinCloseHandle(token);
|
||||||
|
}
|
||||||
|
let _ = WinCloseHandle(process);
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_process_executable_path(process_id: DWORD) -> ResultType<PathBuf> {
|
||||||
|
const PROCESS_IMAGE_PATH_BUFFER_LEN: usize = 32 * 1024;
|
||||||
|
unsafe {
|
||||||
|
let process = WinOpenProcess(WIN_PROCESS_QUERY_LIMITED_INFORMATION, false, process_id)
|
||||||
|
.map_err(|e| anyhow!("Failed to open process {}: {}", process_id, e))?;
|
||||||
|
|
||||||
|
let result = (|| -> ResultType<PathBuf> {
|
||||||
|
let mut buffer = vec![0u16; PROCESS_IMAGE_PATH_BUFFER_LEN];
|
||||||
|
let mut length = PROCESS_IMAGE_PATH_BUFFER_LEN as u32;
|
||||||
|
WinQueryFullProcessImageNameW(
|
||||||
|
process,
|
||||||
|
windows::Win32::System::Threading::PROCESS_NAME_FORMAT(0),
|
||||||
|
windows::core::PWSTR(buffer.as_mut_ptr()),
|
||||||
|
&mut length,
|
||||||
|
)
|
||||||
|
.map_err(|e| anyhow!("Failed to query process {} image path: {}", process_id, e))?;
|
||||||
|
if length == 0 {
|
||||||
|
bail!(
|
||||||
|
"Failed to query process {} image path: empty result",
|
||||||
|
process_id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
buffer.truncate(length as usize);
|
||||||
|
Ok(PathBuf::from(OsString::from_wide(&buffer)))
|
||||||
|
})();
|
||||||
|
|
||||||
|
let _ = WinCloseHandle(process);
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn is_foreground_window_elevated() -> ResultType<bool> {
|
pub fn is_foreground_window_elevated() -> ResultType<bool> {
|
||||||
unsafe {
|
unsafe {
|
||||||
let mut process_id: DWORD = 0;
|
let mut process_id: DWORD = 0;
|
||||||
@@ -2708,16 +2925,6 @@ pub fn create_process_with_logon(user: &str, pwd: &str, exe: &str, arg: &str) ->
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_path_permission(dir: &Path, permission: &str) -> ResultType<()> {
|
|
||||||
std::process::Command::new("icacls")
|
|
||||||
.arg(dir.as_os_str())
|
|
||||||
.arg("/grant")
|
|
||||||
.arg(format!("*S-1-1-0:(OI)(CI){}", permission))
|
|
||||||
.arg("/T")
|
|
||||||
.spawn()?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline]
|
#[inline]
|
||||||
fn str_to_device_name(name: &str) -> [u16; 32] {
|
fn str_to_device_name(name: &str) -> [u16; 32] {
|
||||||
let mut device_name: Vec<u16> = wide_string(name);
|
let mut device_name: Vec<u16> = wide_string(name);
|
||||||
@@ -4281,6 +4488,87 @@ pub(super) fn get_pids_with_first_arg_by_wmic<S1: AsRef<str>, S2: AsRef<str>>(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
// Test-only reusable Win32 HANDLE RAII helper.
|
||||||
|
// If a future non-test path needs the same pattern, move it out of this test module.
|
||||||
|
//
|
||||||
|
// This struct is similar to `hbb_common::platform::windows::RAIIHandle`,
|
||||||
|
// but `RAIIHandle` depends on `WinApi` crate, while this `HandleGuard` only depends on `windows` crate.
|
||||||
|
struct HandleGuard(WinHANDLE);
|
||||||
|
|
||||||
|
impl HandleGuard {
|
||||||
|
#[inline]
|
||||||
|
fn new(handle: WinHANDLE) -> Self {
|
||||||
|
Self(handle)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn get(&self) -> WinHANDLE {
|
||||||
|
self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for HandleGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
unsafe {
|
||||||
|
if !self.0.is_invalid() {
|
||||||
|
let _ = WinCloseHandle(self.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_is_process_running_as_system_invalid_pid_errors() {
|
||||||
|
assert!(is_process_running_as_system(u32::MAX).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_is_process_running_as_system_matches_current_process_token_user() {
|
||||||
|
let pid = unsafe { windows::Win32::System::Threading::GetCurrentProcessId() };
|
||||||
|
let actual = is_process_running_as_system(pid).unwrap();
|
||||||
|
|
||||||
|
let expected = unsafe {
|
||||||
|
// Keep this test consistent: use only the `windows` crate APIs/types.
|
||||||
|
let process = HandleGuard::new(
|
||||||
|
WinOpenProcess(WIN_PROCESS_QUERY_LIMITED_INFORMATION, false, pid)
|
||||||
|
.expect("WinOpenProcess should succeed for current process"),
|
||||||
|
);
|
||||||
|
let mut token = WinHANDLE::default();
|
||||||
|
WinOpenProcessToken(process.get(), WIN_TOKEN_QUERY, &mut token)
|
||||||
|
.expect("WinOpenProcessToken should succeed for current process");
|
||||||
|
let token = HandleGuard::new(token);
|
||||||
|
|
||||||
|
let mut token_user_size = 0u32;
|
||||||
|
let _ = WinGetTokenInformation(token.get(), TokenUser, None, 0, &mut token_user_size);
|
||||||
|
assert_ne!(token_user_size, 0, "TokenUser size should be non-zero");
|
||||||
|
|
||||||
|
let mut buffer = vec![0u8; token_user_size as usize];
|
||||||
|
WinGetTokenInformation(
|
||||||
|
token.get(),
|
||||||
|
TokenUser,
|
||||||
|
Some(buffer.as_mut_ptr() as *mut core::ffi::c_void),
|
||||||
|
token_user_size,
|
||||||
|
&mut token_user_size,
|
||||||
|
)
|
||||||
|
.expect("WinGetTokenInformation(TokenUser) should succeed for current process");
|
||||||
|
|
||||||
|
let min_size = std::mem::size_of::<TOKEN_USER>();
|
||||||
|
assert!(
|
||||||
|
buffer.len() >= min_size,
|
||||||
|
"TokenUser buffer too small (got {}, need >= {})",
|
||||||
|
buffer.len(),
|
||||||
|
min_size
|
||||||
|
);
|
||||||
|
let token_user: TOKEN_USER =
|
||||||
|
std::ptr::read_unaligned(buffer.as_ptr() as *const TOKEN_USER);
|
||||||
|
let expected = IsWellKnownSid(token_user.User.Sid, WinLocalSystemSid).as_bool();
|
||||||
|
expected
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(actual, expected);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_uninstall_cert() {
|
fn test_uninstall_cert() {
|
||||||
println!("uninstall driver certs: {:?}", cert::uninstall_cert());
|
println!("uninstall driver certs: {:?}", cert::uninstall_cert());
|
||||||
|
|||||||
903
src/platform/windows/acl.rs
Normal file
903
src/platform/windows/acl.rs
Normal file
@@ -0,0 +1,903 @@
|
|||||||
|
// https://learn.microsoft.com/en-us/windows/win32/secgloss/security-glossary
|
||||||
|
|
||||||
|
use super::{read_token_user_buffer, wide_string, ResultType};
|
||||||
|
use hbb_common::{anyhow::anyhow, bail};
|
||||||
|
use std::{
|
||||||
|
fs, io,
|
||||||
|
os::windows::{ffi::OsStrExt, fs::MetadataExt},
|
||||||
|
path::Path,
|
||||||
|
};
|
||||||
|
use windows::{
|
||||||
|
core::{PCWSTR, PWSTR},
|
||||||
|
Win32::{
|
||||||
|
Foundation::{CloseHandle, LocalFree, HANDLE, HLOCAL},
|
||||||
|
Security::{
|
||||||
|
Authorization::{
|
||||||
|
ConvertSidToStringSidW, ConvertStringSidToSidW, GetNamedSecurityInfoW,
|
||||||
|
SetEntriesInAclW, SetNamedSecurityInfoW, EXPLICIT_ACCESS_W, SET_ACCESS,
|
||||||
|
SE_FILE_OBJECT, TRUSTEE_IS_GROUP, TRUSTEE_IS_SID, TRUSTEE_IS_USER, TRUSTEE_W,
|
||||||
|
},
|
||||||
|
ACE_FLAGS, ACL, CONTAINER_INHERIT_ACE, DACL_SECURITY_INFORMATION, NO_INHERITANCE,
|
||||||
|
OBJECT_INHERIT_ACE, PROTECTED_DACL_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR, PSID,
|
||||||
|
TOKEN_QUERY, TOKEN_USER,
|
||||||
|
},
|
||||||
|
Storage::FileSystem::{FILE_ALL_ACCESS, FILE_GENERIC_WRITE},
|
||||||
|
System::Threading::{GetCurrentProcess, OpenProcessToken},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const FILE_ATTRIBUTE_REPARSE_POINT_U32: u32 = 0x400;
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn is_reparse_point(metadata: &fs::Metadata) -> bool {
|
||||||
|
(metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT_U32) != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_grant_sid_allow_ace_to_path(
|
||||||
|
path: &Path,
|
||||||
|
sid_ptr: *mut std::ffi::c_void,
|
||||||
|
access_mask: u32,
|
||||||
|
is_group: bool,
|
||||||
|
is_dir: bool,
|
||||||
|
) -> ResultType<()> {
|
||||||
|
// Merge mode: read existing DACL and append/replace ACE via SetEntriesInAclW.
|
||||||
|
// https://learn.microsoft.com/en-us/windows/win32/secauthz/modifying-the-acls-of-an-object-in-c--
|
||||||
|
let mut old_dacl: *mut ACL = std::ptr::null_mut();
|
||||||
|
let mut security_descriptor = PSECURITY_DESCRIPTOR::default();
|
||||||
|
let path_utf16: Vec<u16> = path
|
||||||
|
.as_os_str()
|
||||||
|
.encode_wide()
|
||||||
|
.chain(std::iter::once(0))
|
||||||
|
.collect();
|
||||||
|
let get_named_result = unsafe {
|
||||||
|
GetNamedSecurityInfoW(
|
||||||
|
PCWSTR::from_raw(path_utf16.as_ptr()),
|
||||||
|
SE_FILE_OBJECT,
|
||||||
|
DACL_SECURITY_INFORMATION,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some(&mut old_dacl),
|
||||||
|
None,
|
||||||
|
&mut security_descriptor,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if get_named_result.0 != 0 {
|
||||||
|
bail!(
|
||||||
|
"GetNamedSecurityInfoW failed for '{}': win32_error={}",
|
||||||
|
path.display(),
|
||||||
|
get_named_result.0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let _sd_guard = LocalAllocGuard(security_descriptor.0);
|
||||||
|
|
||||||
|
let inherit_flags = if is_dir {
|
||||||
|
ACE_FLAGS(OBJECT_INHERIT_ACE.0 | CONTAINER_INHERIT_ACE.0)
|
||||||
|
} else {
|
||||||
|
NO_INHERITANCE
|
||||||
|
};
|
||||||
|
let explicit_access = [make_sid_trustee_entry(
|
||||||
|
sid_ptr,
|
||||||
|
access_mask,
|
||||||
|
inherit_flags,
|
||||||
|
is_group,
|
||||||
|
)];
|
||||||
|
let old_acl_option = if old_dacl.is_null() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(old_dacl as *const ACL)
|
||||||
|
};
|
||||||
|
let mut new_acl: *mut ACL = std::ptr::null_mut();
|
||||||
|
let set_entries_result = unsafe {
|
||||||
|
SetEntriesInAclW(
|
||||||
|
Some(explicit_access.as_slice()),
|
||||||
|
old_acl_option,
|
||||||
|
&mut new_acl,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if set_entries_result.0 != 0 {
|
||||||
|
bail!(
|
||||||
|
"SetEntriesInAclW failed for '{}': win32_error={}",
|
||||||
|
path.display(),
|
||||||
|
set_entries_result.0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if new_acl.is_null() {
|
||||||
|
bail!(
|
||||||
|
"SetEntriesInAclW returned null ACL for '{}'",
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let _acl_guard = LocalAllocGuard(new_acl as *mut std::ffi::c_void);
|
||||||
|
|
||||||
|
let set_named_result = unsafe {
|
||||||
|
SetNamedSecurityInfoW(
|
||||||
|
PCWSTR::from_raw(path_utf16.as_ptr()),
|
||||||
|
SE_FILE_OBJECT,
|
||||||
|
DACL_SECURITY_INFORMATION,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some(new_acl),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if set_named_result.0 != 0 {
|
||||||
|
bail!(
|
||||||
|
"SetNamedSecurityInfoW failed for '{}': win32_error={}",
|
||||||
|
path.display(),
|
||||||
|
set_named_result.0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Grants `Everyone` on `dir` recursively for helper/runtime files that must be
|
||||||
|
/// readable/executable across user contexts.
|
||||||
|
///
|
||||||
|
/// `access_mask` is the Win32 file access mask to grant recursively.
|
||||||
|
pub fn set_path_permission(dir: &Path, access_mask: u32) -> ResultType<()> {
|
||||||
|
let metadata = fs::symlink_metadata(dir).map_err(|e| {
|
||||||
|
anyhow!(
|
||||||
|
"Failed to inspect ACL target directory '{}': {}",
|
||||||
|
dir.display(),
|
||||||
|
e
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
if is_reparse_point(&metadata) {
|
||||||
|
bail!(
|
||||||
|
"ACL target directory is a reparse point and is rejected: '{}'",
|
||||||
|
dir.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if !metadata.file_type().is_dir() {
|
||||||
|
bail!("ACL target is not a directory: '{}'", dir.display());
|
||||||
|
}
|
||||||
|
|
||||||
|
let everyone_sid = sid_string_to_local_alloc_guard("S-1-1-0")?;
|
||||||
|
let mut stack = vec![dir.to_path_buf()];
|
||||||
|
while let Some(path) = stack.pop() {
|
||||||
|
let metadata = fs::symlink_metadata(&path)
|
||||||
|
.map_err(|e| anyhow!("Failed to inspect ACL target '{}': {}", path.display(), e))?;
|
||||||
|
if is_reparse_point(&metadata) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let is_dir = metadata.file_type().is_dir();
|
||||||
|
apply_grant_sid_allow_ace_to_path(
|
||||||
|
&path,
|
||||||
|
everyone_sid.as_sid_ptr(),
|
||||||
|
access_mask,
|
||||||
|
true,
|
||||||
|
is_dir,
|
||||||
|
)?;
|
||||||
|
if !is_dir {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for entry in fs::read_dir(&path)
|
||||||
|
.map_err(|e| anyhow!("Failed to list ACL target dir '{}': {}", path.display(), e))?
|
||||||
|
{
|
||||||
|
let entry = entry.map_err(|e| {
|
||||||
|
anyhow!(
|
||||||
|
"Failed to read ACL target dir entry under '{}': {}",
|
||||||
|
path.display(),
|
||||||
|
e
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
stack.push(entry.path());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the current process user SID as a standard SID string
|
||||||
|
/// (for example: `S-1-5-18`).
|
||||||
|
///
|
||||||
|
/// Source:
|
||||||
|
/// - Official SID-to-string API (`ConvertSidToStringSidW`):
|
||||||
|
/// https://learn.microsoft.com/en-us/windows/win32/api/sddl/nf-sddl-convertsidtostringsidw
|
||||||
|
pub(crate) fn current_process_user_sid_string() -> ResultType<String> {
|
||||||
|
let mut token = HANDLE::default();
|
||||||
|
let result = (|| -> ResultType<String> {
|
||||||
|
unsafe {
|
||||||
|
OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token)
|
||||||
|
.map_err(|e| anyhow!("Failed to open current process token: {}", e))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let buffer = unsafe { read_token_user_buffer(token, "current process")? };
|
||||||
|
let token_user: TOKEN_USER =
|
||||||
|
unsafe { std::ptr::read_unaligned(buffer.as_ptr() as *const TOKEN_USER) };
|
||||||
|
if token_user.User.Sid.0.is_null() {
|
||||||
|
bail!("Token SID is null");
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut sid_string_ptr = PWSTR::null();
|
||||||
|
unsafe {
|
||||||
|
ConvertSidToStringSidW(token_user.User.Sid, &mut sid_string_ptr).map_err(|e| {
|
||||||
|
anyhow!(
|
||||||
|
"ConvertSidToStringSidW failed for current process token SID: {}",
|
||||||
|
e
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
if sid_string_ptr.is_null() {
|
||||||
|
bail!("ConvertSidToStringSidW returned null SID string pointer");
|
||||||
|
}
|
||||||
|
let _sid_string_guard = LocalAllocGuard(sid_string_ptr.0 as *mut std::ffi::c_void);
|
||||||
|
unsafe {
|
||||||
|
sid_string_ptr
|
||||||
|
.to_string()
|
||||||
|
.map_err(|e| anyhow!("Failed to decode SID string as UTF-16: {}", e))
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
if !token.is_invalid() {
|
||||||
|
unsafe {
|
||||||
|
let _ = CloseHandle(token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hardens ACLs for portable-service shared-memory path (directory or file).
|
||||||
|
///
|
||||||
|
/// Why:
|
||||||
|
/// - Shared memory used by portable service carries runtime control/data and must not inherit
|
||||||
|
/// broad/default ACLs.
|
||||||
|
/// - We explicitly grant only trusted principals and remove broad groups to reduce local
|
||||||
|
/// privilege-boundary bypass risk.
|
||||||
|
///
|
||||||
|
/// ACL policy applied via Win32 ACL APIs (`SetEntriesInAclW` + `SetNamedSecurityInfoW`):
|
||||||
|
/// - common (directory + file):
|
||||||
|
/// - `S-1-5-18` (LocalSystem): full control
|
||||||
|
/// - `S-1-5-32-544` (Built-in Administrators): full control
|
||||||
|
/// - `current_process_user_sid_string()` result: full control
|
||||||
|
/// - directory (`portable_service_shmem` parent):
|
||||||
|
/// - keep `Authenticated Users` directory-level write so other local accounts can
|
||||||
|
/// create their own runtime shmem files after account switching
|
||||||
|
/// - `FILE_GENERIC_WRITE + NO_INHERITANCE` means write/create on this directory itself;
|
||||||
|
/// it is intentionally not inherited by children.
|
||||||
|
/// Reference:
|
||||||
|
/// - File access rights:
|
||||||
|
/// https://learn.microsoft.com/en-us/windows/win32/fileio/file-access-rights-constants
|
||||||
|
/// - ACE inheritance rules:
|
||||||
|
/// https://learn.microsoft.com/en-us/windows/win32/secauthz/ace-inheritance-rules
|
||||||
|
/// - remove `Everyone` and `Users` grants
|
||||||
|
/// - file (`shared_memory*` flink):
|
||||||
|
/// - remove broad grants:
|
||||||
|
/// - `S-1-1-0` (Everyone)
|
||||||
|
/// - `S-1-5-11` (Authenticated Users)
|
||||||
|
/// - `S-1-5-32-545` (Users)
|
||||||
|
///
|
||||||
|
/// https://learn.microsoft.com/en-us/windows/win32/secauthz/well-known-sids
|
||||||
|
pub fn set_path_permission_for_portable_service_shmem_dir(path: &Path) -> ResultType<()> {
|
||||||
|
set_path_permission_for_portable_service_shmem_impl(path, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
pub fn validate_path_for_portable_service_shmem_dir(path: &Path) -> ResultType<()> {
|
||||||
|
validate_portable_service_shmem_dir_target(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
pub fn set_path_permission_for_portable_service_shmem_file(path: &Path) -> ResultType<()> {
|
||||||
|
set_path_permission_for_portable_service_shmem_impl(path, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub(super) struct LocalAllocGuard(*mut std::ffi::c_void);
|
||||||
|
|
||||||
|
impl LocalAllocGuard {
|
||||||
|
#[inline]
|
||||||
|
pub(super) fn as_sid_ptr(&self) -> *mut std::ffi::c_void {
|
||||||
|
self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for LocalAllocGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if self.0.is_null() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Buffers returned by ConvertStringSidToSidW / SetEntriesInAclW /
|
||||||
|
// ConvertSidToStringSidW are LocalAlloc-owned and must be LocalFree'ed.
|
||||||
|
unsafe {
|
||||||
|
let _ = LocalFree(Some(HLOCAL(self.0)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
pub(super) fn sid_string_to_local_alloc_guard(sid: &str) -> ResultType<LocalAllocGuard> {
|
||||||
|
let sid_utf16 = wide_string(sid);
|
||||||
|
let mut sid_ptr = PSID::default();
|
||||||
|
unsafe {
|
||||||
|
ConvertStringSidToSidW(PCWSTR::from_raw(sid_utf16.as_ptr()), &mut sid_ptr)
|
||||||
|
.map_err(|e| anyhow!("ConvertStringSidToSidW failed for '{}': {}", sid, e))?;
|
||||||
|
}
|
||||||
|
if sid_ptr.0.is_null() {
|
||||||
|
bail!("ConvertStringSidToSidW returned null SID for '{}'", sid);
|
||||||
|
}
|
||||||
|
Ok(LocalAllocGuard(sid_ptr.0))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn make_sid_trustee_entry(
|
||||||
|
sid_ptr: *mut std::ffi::c_void,
|
||||||
|
access_permissions: u32,
|
||||||
|
inheritance: ACE_FLAGS,
|
||||||
|
is_group: bool,
|
||||||
|
) -> EXPLICIT_ACCESS_W {
|
||||||
|
// `is_group` is explicitly provided by the caller from the concrete SID semantic
|
||||||
|
// (e.g. Administrators/Authenticated Users => group, LocalSystem/current user => user).
|
||||||
|
EXPLICIT_ACCESS_W {
|
||||||
|
grfAccessPermissions: access_permissions,
|
||||||
|
grfAccessMode: SET_ACCESS,
|
||||||
|
grfInheritance: inheritance,
|
||||||
|
Trustee: TRUSTEE_W {
|
||||||
|
pMultipleTrustee: std::ptr::null_mut(),
|
||||||
|
MultipleTrusteeOperation: Default::default(),
|
||||||
|
TrusteeForm: TRUSTEE_IS_SID,
|
||||||
|
TrusteeType: if is_group {
|
||||||
|
TRUSTEE_IS_GROUP
|
||||||
|
} else {
|
||||||
|
TRUSTEE_IS_USER
|
||||||
|
},
|
||||||
|
// SAFETY: With TrusteeForm=TRUSTEE_IS_SID, ptstrName is interpreted as PSID.
|
||||||
|
ptstrName: PWSTR::from_raw(sid_ptr as *mut u16),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_portable_service_shmem_dir_target(path: &Path) -> ResultType<()> {
|
||||||
|
let metadata = fs::symlink_metadata(path).map_err(|e| {
|
||||||
|
anyhow!(
|
||||||
|
"Failed to inspect portable service shared-memory ACL directory '{}': {}",
|
||||||
|
path.display(),
|
||||||
|
e
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
if is_reparse_point(&metadata) {
|
||||||
|
bail!(
|
||||||
|
"Portable service shared-memory ACL directory target is a reparse point and is rejected: '{}'",
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if !metadata.file_type().is_dir() {
|
||||||
|
bail!(
|
||||||
|
"Portable service shared-memory ACL target is not a directory: '{}'",
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_path_permission_for_portable_service_shmem_impl(
|
||||||
|
path: &Path,
|
||||||
|
expect_dir: bool,
|
||||||
|
) -> ResultType<()> {
|
||||||
|
if expect_dir {
|
||||||
|
validate_portable_service_shmem_dir_target(path)?;
|
||||||
|
} else {
|
||||||
|
let metadata_result = fs::symlink_metadata(path);
|
||||||
|
match metadata_result {
|
||||||
|
Ok(metadata) => {
|
||||||
|
if metadata.file_type().is_dir() {
|
||||||
|
bail!(
|
||||||
|
"Portable service shared-memory ACL target is a directory, expected file-like path: '{}'",
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if is_reparse_point(&metadata) {
|
||||||
|
bail!(
|
||||||
|
"Portable service shared-memory ACL file target is a reparse point and is rejected: '{}'",
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e)
|
||||||
|
if e.kind() == io::ErrorKind::NotFound
|
||||||
|
|| e.kind() == io::ErrorKind::PermissionDenied =>
|
||||||
|
{
|
||||||
|
// Keep going and let Win32 ACL APIs return the final OS error.
|
||||||
|
// `Path::exists()/is_file()` and metadata can collapse ACL-denied paths into
|
||||||
|
// a false "not found" signal under restricted directory ACLs.
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
bail!(
|
||||||
|
"Failed to inspect portable service shared-memory ACL target '{}': {}",
|
||||||
|
path.display(),
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let user_sid = current_process_user_sid_string()?;
|
||||||
|
let local_system_sid = sid_string_to_local_alloc_guard("S-1-5-18")?;
|
||||||
|
let administrators_sid = sid_string_to_local_alloc_guard("S-1-5-32-544")?;
|
||||||
|
let current_user_sid = sid_string_to_local_alloc_guard(&user_sid)?;
|
||||||
|
let authenticated_users_sid = if expect_dir {
|
||||||
|
Some(sid_string_to_local_alloc_guard("S-1-5-11")?)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let inherit_flags = if expect_dir {
|
||||||
|
ACE_FLAGS(OBJECT_INHERIT_ACE.0 | CONTAINER_INHERIT_ACE.0)
|
||||||
|
} else {
|
||||||
|
NO_INHERITANCE
|
||||||
|
};
|
||||||
|
let mut entries = vec![
|
||||||
|
make_sid_trustee_entry(
|
||||||
|
local_system_sid.as_sid_ptr(),
|
||||||
|
FILE_ALL_ACCESS.0,
|
||||||
|
inherit_flags,
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
make_sid_trustee_entry(
|
||||||
|
administrators_sid.as_sid_ptr(),
|
||||||
|
FILE_ALL_ACCESS.0,
|
||||||
|
inherit_flags,
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
make_sid_trustee_entry(
|
||||||
|
current_user_sid.as_sid_ptr(),
|
||||||
|
FILE_ALL_ACCESS.0,
|
||||||
|
inherit_flags,
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
if let Some(auth_sid) = authenticated_users_sid.as_ref() {
|
||||||
|
// Keep the shared parent directory multi-user writable at directory level.
|
||||||
|
entries.push(make_sid_trustee_entry(
|
||||||
|
auth_sid.as_sid_ptr(),
|
||||||
|
FILE_GENERIC_WRITE.0,
|
||||||
|
NO_INHERITANCE,
|
||||||
|
true,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rebuild mode: build a fresh DACL (old ACL not merged) and apply as protected.
|
||||||
|
// This avoids carrying over broad legacy ACEs from inherited/default ACLs.
|
||||||
|
// Reference:
|
||||||
|
// - SetEntriesInAclW:
|
||||||
|
// https://learn.microsoft.com/en-us/windows/win32/api/aclapi/nf-aclapi-setentriesinaclw
|
||||||
|
// - SetNamedSecurityInfoW (PROTECTED_DACL_SECURITY_INFORMATION):
|
||||||
|
// https://learn.microsoft.com/en-us/windows/win32/api/aclapi/nf-aclapi-setnamedsecurityinfow
|
||||||
|
let mut new_acl: *mut ACL = std::ptr::null_mut();
|
||||||
|
let set_entries_result =
|
||||||
|
unsafe { SetEntriesInAclW(Some(entries.as_slice()), None, &mut new_acl) };
|
||||||
|
if set_entries_result.0 != 0 {
|
||||||
|
bail!(
|
||||||
|
"SetEntriesInAclW failed for '{}': win32_error={}",
|
||||||
|
path.display(),
|
||||||
|
set_entries_result.0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if new_acl.is_null() {
|
||||||
|
bail!(
|
||||||
|
"SetEntriesInAclW returned null ACL for '{}'",
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let _acl_guard = LocalAllocGuard(new_acl as *mut std::ffi::c_void);
|
||||||
|
|
||||||
|
let path_utf16: Vec<u16> = path
|
||||||
|
.as_os_str()
|
||||||
|
.encode_wide()
|
||||||
|
.chain(std::iter::once(0))
|
||||||
|
.collect();
|
||||||
|
let security_info = DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION;
|
||||||
|
let set_named_result = unsafe {
|
||||||
|
SetNamedSecurityInfoW(
|
||||||
|
PCWSTR::from_raw(path_utf16.as_ptr()),
|
||||||
|
SE_FILE_OBJECT,
|
||||||
|
security_info,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some(new_acl),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if set_named_result.0 != 0 {
|
||||||
|
bail!(
|
||||||
|
"SetNamedSecurityInfoW failed for '{}': win32_error={}",
|
||||||
|
path.display(),
|
||||||
|
set_named_result.0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{
|
||||||
|
current_process_user_sid_string, set_path_permission,
|
||||||
|
set_path_permission_for_portable_service_shmem_dir,
|
||||||
|
set_path_permission_for_portable_service_shmem_file, sid_string_to_local_alloc_guard,
|
||||||
|
LocalAllocGuard, ResultType,
|
||||||
|
};
|
||||||
|
use hbb_common::bail;
|
||||||
|
use std::{
|
||||||
|
fs,
|
||||||
|
os::windows::{ffi::OsStrExt, fs::symlink_dir, fs::symlink_file},
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
};
|
||||||
|
use windows::{
|
||||||
|
core::PCWSTR,
|
||||||
|
Win32::{
|
||||||
|
Security::{
|
||||||
|
AclSizeInformation,
|
||||||
|
Authorization::{GetNamedSecurityInfoW, SE_FILE_OBJECT},
|
||||||
|
EqualSid as WinEqualSid, GetAce, GetAclInformation, GetSecurityDescriptorControl,
|
||||||
|
ACCESS_ALLOWED_ACE, ACE_HEADER, ACL, ACL_SIZE_INFORMATION,
|
||||||
|
DACL_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR, PSID, SE_DACL_PROTECTED,
|
||||||
|
},
|
||||||
|
Storage::FileSystem::{
|
||||||
|
FILE_ALL_ACCESS, FILE_GENERIC_EXECUTE, FILE_GENERIC_READ, FILE_GENERIC_WRITE,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const ACCESS_ALLOWED_ACE_TYPE_U8: u8 = 0;
|
||||||
|
|
||||||
|
fn unique_acl_test_path(prefix: &str) -> PathBuf {
|
||||||
|
std::env::temp_dir().join(format!(
|
||||||
|
"rustdesk_acl_{}_{}_{}",
|
||||||
|
prefix,
|
||||||
|
std::process::id(),
|
||||||
|
hbb_common::rand::random::<u32>()
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_create_dir_reparse_point(target: &Path, link: &Path, test_name: &str) -> bool {
|
||||||
|
match symlink_dir(target, link) {
|
||||||
|
Ok(()) => true,
|
||||||
|
Err(err) => {
|
||||||
|
eprintln!(
|
||||||
|
"skip {}: failed to create directory reparse point (symlink): {}",
|
||||||
|
test_name, err
|
||||||
|
);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_create_file_reparse_point(target: &Path, link: &Path, test_name: &str) -> bool {
|
||||||
|
match symlink_file(target, link) {
|
||||||
|
Ok(()) => true,
|
||||||
|
Err(err) => {
|
||||||
|
eprintln!(
|
||||||
|
"skip {}: failed to create file reparse point (symlink): {}",
|
||||||
|
test_name, err
|
||||||
|
);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_file_dacl(path: &Path) -> ResultType<(*mut ACL, LocalAllocGuard)> {
|
||||||
|
let mut dacl: *mut ACL = std::ptr::null_mut();
|
||||||
|
let mut sd = PSECURITY_DESCRIPTOR::default();
|
||||||
|
let path_utf16: Vec<u16> = path
|
||||||
|
.as_os_str()
|
||||||
|
.encode_wide()
|
||||||
|
.chain(std::iter::once(0))
|
||||||
|
.collect();
|
||||||
|
let result = unsafe {
|
||||||
|
GetNamedSecurityInfoW(
|
||||||
|
PCWSTR::from_raw(path_utf16.as_ptr()),
|
||||||
|
SE_FILE_OBJECT,
|
||||||
|
DACL_SECURITY_INFORMATION,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some(&mut dacl),
|
||||||
|
None,
|
||||||
|
&mut sd,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if result.0 != 0 {
|
||||||
|
bail!(
|
||||||
|
"GetNamedSecurityInfoW failed for '{}': win32_error={}",
|
||||||
|
path.display(),
|
||||||
|
result.0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if dacl.is_null() || sd.0.is_null() {
|
||||||
|
bail!("DACL/security descriptor missing for '{}'", path.display());
|
||||||
|
}
|
||||||
|
Ok((dacl, LocalAllocGuard(sd.0)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_allow_ace_with_mask(
|
||||||
|
dacl: *const ACL,
|
||||||
|
sid_ptr: *mut std::ffi::c_void,
|
||||||
|
mask: u32,
|
||||||
|
) -> bool {
|
||||||
|
let mut info = ACL_SIZE_INFORMATION::default();
|
||||||
|
if unsafe {
|
||||||
|
GetAclInformation(
|
||||||
|
dacl,
|
||||||
|
&mut info as *mut _ as *mut std::ffi::c_void,
|
||||||
|
std::mem::size_of::<ACL_SIZE_INFORMATION>() as u32,
|
||||||
|
AclSizeInformation,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for index in 0..info.AceCount {
|
||||||
|
let mut ace_ptr: *mut std::ffi::c_void = std::ptr::null_mut();
|
||||||
|
if unsafe { GetAce(dacl, index, &mut ace_ptr) }.is_err() || ace_ptr.is_null() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let header = unsafe { &*(ace_ptr as *const ACE_HEADER) };
|
||||||
|
if header.AceType != ACCESS_ALLOWED_ACE_TYPE_U8 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let allowed = unsafe { &*(ace_ptr as *const ACCESS_ALLOWED_ACE) };
|
||||||
|
let ace_sid = PSID((&allowed.SidStart as *const u32) as *mut std::ffi::c_void);
|
||||||
|
if unsafe { WinEqualSid(PSID(sid_ptr), ace_sid) }.is_ok()
|
||||||
|
&& (allowed.Mask & mask) == mask
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_any_allow_ace_for_sid(dacl: *const ACL, sid_ptr: *mut std::ffi::c_void) -> bool {
|
||||||
|
has_allow_ace_with_mask(dacl, sid_ptr, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_dacl_protected(sd: PSECURITY_DESCRIPTOR) -> bool {
|
||||||
|
let mut control: u16 = 0;
|
||||||
|
let mut revision: u32 = 0;
|
||||||
|
if unsafe { GetSecurityDescriptorControl(sd, &mut control, &mut revision) }.is_err() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
(control & SE_DACL_PROTECTED.0) != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_portable_service_shmem_dir_acl_policy() {
|
||||||
|
let dir = unique_acl_test_path("dir");
|
||||||
|
fs::create_dir_all(&dir).unwrap();
|
||||||
|
set_path_permission_for_portable_service_shmem_dir(&dir).unwrap();
|
||||||
|
|
||||||
|
let (dacl, sd_guard) = get_file_dacl(&dir).unwrap();
|
||||||
|
let current_user_sid =
|
||||||
|
sid_string_to_local_alloc_guard(¤t_process_user_sid_string().unwrap()).unwrap();
|
||||||
|
let system_sid = sid_string_to_local_alloc_guard("S-1-5-18").unwrap();
|
||||||
|
let admin_sid = sid_string_to_local_alloc_guard("S-1-5-32-544").unwrap();
|
||||||
|
let auth_users_sid = sid_string_to_local_alloc_guard("S-1-5-11").unwrap();
|
||||||
|
let everyone_sid = sid_string_to_local_alloc_guard("S-1-1-0").unwrap();
|
||||||
|
let users_sid = sid_string_to_local_alloc_guard("S-1-5-32-545").unwrap();
|
||||||
|
|
||||||
|
assert!(has_allow_ace_with_mask(
|
||||||
|
dacl,
|
||||||
|
system_sid.as_sid_ptr(),
|
||||||
|
FILE_ALL_ACCESS.0
|
||||||
|
));
|
||||||
|
assert!(has_allow_ace_with_mask(
|
||||||
|
dacl,
|
||||||
|
admin_sid.as_sid_ptr(),
|
||||||
|
FILE_ALL_ACCESS.0
|
||||||
|
));
|
||||||
|
assert!(has_allow_ace_with_mask(
|
||||||
|
dacl,
|
||||||
|
current_user_sid.as_sid_ptr(),
|
||||||
|
FILE_ALL_ACCESS.0
|
||||||
|
));
|
||||||
|
assert!(has_allow_ace_with_mask(
|
||||||
|
dacl,
|
||||||
|
auth_users_sid.as_sid_ptr(),
|
||||||
|
FILE_GENERIC_WRITE.0
|
||||||
|
));
|
||||||
|
assert!(!has_any_allow_ace_for_sid(dacl, everyone_sid.as_sid_ptr()));
|
||||||
|
assert!(!has_any_allow_ace_for_sid(dacl, users_sid.as_sid_ptr()));
|
||||||
|
assert!(is_dacl_protected(PSECURITY_DESCRIPTOR(
|
||||||
|
sd_guard.as_sid_ptr()
|
||||||
|
)));
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_portable_service_shmem_file_acl_policy() {
|
||||||
|
let dir = unique_acl_test_path("file");
|
||||||
|
fs::create_dir_all(&dir).unwrap();
|
||||||
|
let file = dir.join("shared_memory_portable_service_test");
|
||||||
|
fs::write(&file, b"x").unwrap();
|
||||||
|
set_path_permission_for_portable_service_shmem_file(&file).unwrap();
|
||||||
|
|
||||||
|
let (dacl, sd_guard) = get_file_dacl(&file).unwrap();
|
||||||
|
let current_user_sid =
|
||||||
|
sid_string_to_local_alloc_guard(¤t_process_user_sid_string().unwrap()).unwrap();
|
||||||
|
let system_sid = sid_string_to_local_alloc_guard("S-1-5-18").unwrap();
|
||||||
|
let admin_sid = sid_string_to_local_alloc_guard("S-1-5-32-544").unwrap();
|
||||||
|
let auth_users_sid = sid_string_to_local_alloc_guard("S-1-5-11").unwrap();
|
||||||
|
let everyone_sid = sid_string_to_local_alloc_guard("S-1-1-0").unwrap();
|
||||||
|
let users_sid = sid_string_to_local_alloc_guard("S-1-5-32-545").unwrap();
|
||||||
|
|
||||||
|
assert!(has_allow_ace_with_mask(
|
||||||
|
dacl,
|
||||||
|
system_sid.as_sid_ptr(),
|
||||||
|
FILE_ALL_ACCESS.0
|
||||||
|
));
|
||||||
|
assert!(has_allow_ace_with_mask(
|
||||||
|
dacl,
|
||||||
|
admin_sid.as_sid_ptr(),
|
||||||
|
FILE_ALL_ACCESS.0
|
||||||
|
));
|
||||||
|
assert!(has_allow_ace_with_mask(
|
||||||
|
dacl,
|
||||||
|
current_user_sid.as_sid_ptr(),
|
||||||
|
FILE_ALL_ACCESS.0
|
||||||
|
));
|
||||||
|
assert!(!has_any_allow_ace_for_sid(
|
||||||
|
dacl,
|
||||||
|
auth_users_sid.as_sid_ptr()
|
||||||
|
));
|
||||||
|
assert!(!has_any_allow_ace_for_sid(dacl, everyone_sid.as_sid_ptr()));
|
||||||
|
assert!(!has_any_allow_ace_for_sid(dacl, users_sid.as_sid_ptr()));
|
||||||
|
assert!(is_dacl_protected(PSECURITY_DESCRIPTOR(
|
||||||
|
sd_guard.as_sid_ptr()
|
||||||
|
)));
|
||||||
|
|
||||||
|
let _ = fs::remove_file(&file);
|
||||||
|
let _ = fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_set_path_permission_rx_applies_recursively() {
|
||||||
|
let root = unique_acl_test_path("set_path_permission");
|
||||||
|
let child_dir = root.join("child");
|
||||||
|
let child_file = child_dir.join("helper.exe");
|
||||||
|
fs::create_dir_all(&child_dir).unwrap();
|
||||||
|
fs::write(&child_file, b"x").unwrap();
|
||||||
|
|
||||||
|
if let Err(err) = set_path_permission(&root, FILE_GENERIC_READ.0 | FILE_GENERIC_EXECUTE.0) {
|
||||||
|
let text = err.to_string();
|
||||||
|
let _ = fs::remove_file(&child_file);
|
||||||
|
let _ = fs::remove_dir_all(&root);
|
||||||
|
if text.contains("win32_error=5") || text.contains("Access is denied") {
|
||||||
|
eprintln!(
|
||||||
|
"skip test_set_path_permission_rx_applies_recursively: insufficient WRITE_DAC in current environment: {}",
|
||||||
|
text
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
panic!("set_path_permission failed unexpectedly: {}", text);
|
||||||
|
}
|
||||||
|
|
||||||
|
let everyone_sid = sid_string_to_local_alloc_guard("S-1-1-0").unwrap();
|
||||||
|
let rx_mask = FILE_GENERIC_READ.0 | FILE_GENERIC_EXECUTE.0;
|
||||||
|
for target in [&root, &child_dir, &child_file] {
|
||||||
|
let (dacl, _sd_guard) = get_file_dacl(target).unwrap();
|
||||||
|
assert!(
|
||||||
|
has_allow_ace_with_mask(dacl, everyone_sid.as_sid_ptr(), rx_mask),
|
||||||
|
"Everyone RX grant missing on '{}'",
|
||||||
|
target.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = fs::remove_file(&child_file);
|
||||||
|
let _ = fs::remove_dir_all(&root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_portable_service_shmem_dir_acl_rejects_file_target() {
|
||||||
|
let dir = unique_acl_test_path("dir_target_file");
|
||||||
|
fs::create_dir_all(&dir).unwrap();
|
||||||
|
let file = dir.join("target.txt");
|
||||||
|
fs::write(&file, b"x").unwrap();
|
||||||
|
let result = set_path_permission_for_portable_service_shmem_dir(&file);
|
||||||
|
assert!(result.is_err());
|
||||||
|
let _ = fs::remove_file(&file);
|
||||||
|
let _ = fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_portable_service_shmem_file_acl_rejects_dir_target() {
|
||||||
|
let dir = unique_acl_test_path("file_target_dir");
|
||||||
|
fs::create_dir_all(&dir).unwrap();
|
||||||
|
let result = set_path_permission_for_portable_service_shmem_file(&dir);
|
||||||
|
assert!(result.is_err());
|
||||||
|
let _ = fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_portable_service_shmem_file_acl_rejects_missing_target() {
|
||||||
|
let path = unique_acl_test_path("missing").join("shared_memory_missing");
|
||||||
|
let result = set_path_permission_for_portable_service_shmem_file(&path);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_set_path_permission_rejects_reparse_entrypoint() {
|
||||||
|
let root = unique_acl_test_path("reparse_entry");
|
||||||
|
let real_dir = root.join("real");
|
||||||
|
let link_dir = root.join("link");
|
||||||
|
fs::create_dir_all(&real_dir).unwrap();
|
||||||
|
if !try_create_dir_reparse_point(
|
||||||
|
&real_dir,
|
||||||
|
&link_dir,
|
||||||
|
"test_set_path_permission_rejects_reparse_entrypoint",
|
||||||
|
) {
|
||||||
|
let _ = fs::remove_dir_all(&real_dir);
|
||||||
|
let _ = fs::remove_dir_all(&root);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = set_path_permission(&link_dir, FILE_GENERIC_READ.0 | FILE_GENERIC_EXECUTE.0);
|
||||||
|
let text = result.err().map(|e| e.to_string()).unwrap_or_default();
|
||||||
|
assert!(
|
||||||
|
text.contains("reparse point"),
|
||||||
|
"expected reparse-point rejection, got '{}'",
|
||||||
|
text
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = fs::remove_dir(&link_dir);
|
||||||
|
let _ = fs::remove_dir_all(&real_dir);
|
||||||
|
let _ = fs::remove_dir_all(&root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_portable_service_shmem_dir_acl_rejects_reparse_target() {
|
||||||
|
let root = unique_acl_test_path("reparse_shmem_dir");
|
||||||
|
let real_dir = root.join("real");
|
||||||
|
let link_dir = root.join("link");
|
||||||
|
fs::create_dir_all(&real_dir).unwrap();
|
||||||
|
if !try_create_dir_reparse_point(
|
||||||
|
&real_dir,
|
||||||
|
&link_dir,
|
||||||
|
"test_portable_service_shmem_dir_acl_rejects_reparse_target",
|
||||||
|
) {
|
||||||
|
let _ = fs::remove_dir_all(&real_dir);
|
||||||
|
let _ = fs::remove_dir_all(&root);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = set_path_permission_for_portable_service_shmem_dir(&link_dir);
|
||||||
|
let text = result.err().map(|e| e.to_string()).unwrap_or_default();
|
||||||
|
assert!(
|
||||||
|
text.contains("reparse point"),
|
||||||
|
"expected reparse-point rejection, got '{}'",
|
||||||
|
text
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = fs::remove_dir(&link_dir);
|
||||||
|
let _ = fs::remove_dir_all(&real_dir);
|
||||||
|
let _ = fs::remove_dir_all(&root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_portable_service_shmem_file_acl_rejects_reparse_target() {
|
||||||
|
let root = unique_acl_test_path("reparse_shmem_file");
|
||||||
|
let real_file = root.join("real.txt");
|
||||||
|
let link_file = root.join("link.txt");
|
||||||
|
fs::create_dir_all(&root).unwrap();
|
||||||
|
fs::write(&real_file, b"x").unwrap();
|
||||||
|
if !try_create_file_reparse_point(
|
||||||
|
&real_file,
|
||||||
|
&link_file,
|
||||||
|
"test_portable_service_shmem_file_acl_rejects_reparse_target",
|
||||||
|
) {
|
||||||
|
let _ = fs::remove_file(&real_file);
|
||||||
|
let _ = fs::remove_dir_all(&root);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = set_path_permission_for_portable_service_shmem_file(&link_file);
|
||||||
|
let text = result.err().map(|e| e.to_string()).unwrap_or_default();
|
||||||
|
assert!(
|
||||||
|
text.contains("reparse point"),
|
||||||
|
"expected reparse-point rejection, got '{}'",
|
||||||
|
text
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = fs::remove_file(&link_file);
|
||||||
|
let _ = fs::remove_file(&real_file);
|
||||||
|
let _ = fs::remove_dir_all(&root);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,6 +41,30 @@ lazy_static::lazy_static! {
|
|||||||
static SHOULD_EXIT: AtomicBool = AtomicBool::new(false);
|
static SHOULD_EXIT: AtomicBool = AtomicBool::new(false);
|
||||||
static MANUAL_RESTARTED: AtomicBool = AtomicBool::new(false);
|
static MANUAL_RESTARTED: AtomicBool = AtomicBool::new(false);
|
||||||
static SENT_REGISTER_PK: AtomicBool = AtomicBool::new(false);
|
static SENT_REGISTER_PK: AtomicBool = AtomicBool::new(false);
|
||||||
|
pub(crate) static NEEDS_DEPLOY: AtomicBool = AtomicBool::new(false);
|
||||||
|
// register_pk retry interval (ms) when device is awaiting deployment
|
||||||
|
const DEPLOY_RETRY_INTERVAL: i64 = 30_000;
|
||||||
|
lazy_static::lazy_static! {
|
||||||
|
static ref LAST_NOT_DEPLOYED_REGISTER: Mutex<Option<Instant>> = Mutex::new(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single source of truth for the "awaiting deployment" backoff. The server has
|
||||||
|
// already told us this device is not in its db; until the operator runs
|
||||||
|
// `rustdesk --deploy --token <api_token>` there is no point re-running the
|
||||||
|
// register path more often than DEPLOY_RETRY_INTERVAL. Gating in the timer
|
||||||
|
// loops (rather than only inside register_pk) also avoids the
|
||||||
|
// last_register_sent / fails / latency / UDP-rebind churn the loop would
|
||||||
|
// otherwise spin on while no response ever comes back.
|
||||||
|
async fn deploy_register_throttled() -> bool {
|
||||||
|
if !NEEDS_DEPLOY.load(Ordering::SeqCst) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
LAST_NOT_DEPLOYED_REGISTER
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.map(|t| (t.elapsed().as_millis() as i64) < DEPLOY_RETRY_INTERVAL)
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct RendezvousMediator {
|
pub struct RendezvousMediator {
|
||||||
@@ -226,6 +250,14 @@ impl RendezvousMediator {
|
|||||||
if SHOULD_EXIT.load(Ordering::SeqCst) {
|
if SHOULD_EXIT.load(Ordering::SeqCst) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
// The server already told us this device is not deployed. Skip
|
||||||
|
// the whole register / fails / latency / UDP-rebind path until
|
||||||
|
// DEPLOY_RETRY_INTERVAL elapses, otherwise the loop spins every
|
||||||
|
// few seconds (log spam + misapplied network-recovery rebind)
|
||||||
|
// until the operator runs `rustdesk --deploy`.
|
||||||
|
if deploy_register_throttled().await {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let now = Some(Instant::now());
|
let now = Some(Instant::now());
|
||||||
let expired = last_register_resp.map(|x| x.elapsed().as_millis() as i64 >= REG_INTERVAL).unwrap_or(true);
|
let expired = last_register_resp.map(|x| x.elapsed().as_millis() as i64 >= REG_INTERVAL).unwrap_or(true);
|
||||||
let timeout = last_register_sent.map(|x| x.elapsed().as_millis() as i64 >= reg_timeout).unwrap_or(false);
|
let timeout = last_register_sent.map(|x| x.elapsed().as_millis() as i64 >= reg_timeout).unwrap_or(false);
|
||||||
@@ -289,10 +321,22 @@ impl RendezvousMediator {
|
|||||||
Config::set_key_confirmed(true);
|
Config::set_key_confirmed(true);
|
||||||
Config::set_host_key_confirmed(&self.host_prefix, true);
|
Config::set_host_key_confirmed(&self.host_prefix, true);
|
||||||
*SOLVING_PK_MISMATCH.lock().await = "".to_owned();
|
*SOLVING_PK_MISMATCH.lock().await = "".to_owned();
|
||||||
|
NEEDS_DEPLOY.store(false, Ordering::SeqCst);
|
||||||
}
|
}
|
||||||
Ok(register_pk_response::Result::UUID_MISMATCH) => {
|
Ok(register_pk_response::Result::UUID_MISMATCH) => {
|
||||||
self.handle_uuid_mismatch(sink).await?;
|
self.handle_uuid_mismatch(sink).await?;
|
||||||
}
|
}
|
||||||
|
Ok(register_pk_response::Result::NOT_DEPLOYED) => {
|
||||||
|
if !NEEDS_DEPLOY.load(Ordering::SeqCst) {
|
||||||
|
log::warn!("Server requires deployment. Run `rustdesk --deploy --token <api_token>` on this device.");
|
||||||
|
}
|
||||||
|
NEEDS_DEPLOY.store(true, Ordering::SeqCst);
|
||||||
|
// Clear key_confirmed so the UI reflects the truth: this device is
|
||||||
|
// not currently registered. Covers the case where an online device
|
||||||
|
// was deleted by an admin while running.
|
||||||
|
Config::set_key_confirmed(false);
|
||||||
|
Config::set_host_key_confirmed(&self.host_prefix, false);
|
||||||
|
}
|
||||||
_ => {
|
_ => {
|
||||||
log::error!("unknown RegisterPkResponse");
|
log::error!("unknown RegisterPkResponse");
|
||||||
}
|
}
|
||||||
@@ -678,6 +722,21 @@ impl RendezvousMediator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn register_pk(&mut self, socket: Sink<'_>) -> ResultType<()> {
|
async fn register_pk(&mut self, socket: Sink<'_>) -> ResultType<()> {
|
||||||
|
// Throttle register_pk when the device is awaiting deployment: server
|
||||||
|
// already told us we're not in its db; sending more often than every
|
||||||
|
// DEPLOY_RETRY_INTERVAL ms is wasted traffic until the operator runs
|
||||||
|
// `rustdesk --deploy --token <api_token>`.
|
||||||
|
if NEEDS_DEPLOY.load(Ordering::SeqCst) {
|
||||||
|
let mut last = LAST_NOT_DEPLOYED_REGISTER.lock().await;
|
||||||
|
if let Some(t) = *last {
|
||||||
|
if (t.elapsed().as_millis() as i64) < DEPLOY_RETRY_INTERVAL {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*last = Some(Instant::now());
|
||||||
|
} else {
|
||||||
|
*LAST_NOT_DEPLOYED_REGISTER.lock().await = None;
|
||||||
|
}
|
||||||
let mut msg_out = Message::new();
|
let mut msg_out = Message::new();
|
||||||
let pk = Config::get_key_pair().1;
|
let pk = Config::get_key_pair().1;
|
||||||
let uuid = hbb_common::get_uuid();
|
let uuid = hbb_common::get_uuid();
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ pub mod input_service {
|
|||||||
}
|
}
|
||||||
|
|
||||||
mod connection;
|
mod connection;
|
||||||
|
mod login_failure_check;
|
||||||
pub mod display_service;
|
pub mod display_service;
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
pub mod portable_service;
|
pub mod portable_service;
|
||||||
@@ -731,7 +732,7 @@ async fn sync_and_watch_config_dir(sync_done_tx: Option<tokio::sync::oneshot::Se
|
|||||||
use hbb_common::sleep;
|
use hbb_common::sleep;
|
||||||
for i in 1..=tries {
|
for i in 1..=tries {
|
||||||
sleep(i as f32 * CONFIG_SYNC_INTERVAL_SECS).await;
|
sleep(i as f32 * CONFIG_SYNC_INTERVAL_SECS).await;
|
||||||
match crate::ipc::connect(1000, "_service").await {
|
match crate::ipc::connect_service(1000).await {
|
||||||
Ok(mut conn) => {
|
Ok(mut conn) => {
|
||||||
if !synced {
|
if !synced {
|
||||||
if conn.send(&Data::SyncConfig(None)).await.is_ok() {
|
if conn.send(&Data::SyncConfig(None)).await.is_ok() {
|
||||||
@@ -772,6 +773,12 @@ async fn sync_and_watch_config_dir(sync_done_tx: Option<tokio::sync::oneshot::Se
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if !synced {
|
||||||
|
log::warn!(
|
||||||
|
"initial config sync from root failed, reconnecting to ipc_service"
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
@@ -788,7 +795,7 @@ async fn sync_and_watch_config_dir(sync_done_tx: Option<tokio::sync::oneshot::Se
|
|||||||
match conn.send(&Data::SyncConfig(Some(cfg.clone().into()))).await {
|
match conn.send(&Data::SyncConfig(Some(cfg.clone().into()))).await {
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("sync config to root failed: {}", e);
|
log::error!("sync config to root failed: {}", e);
|
||||||
match crate::ipc::connect(1000, "_service").await {
|
match crate::ipc::connect_service(1000).await {
|
||||||
Ok(mut _conn) => {
|
Ok(mut _conn) => {
|
||||||
conn = _conn;
|
conn = _conn;
|
||||||
log::info!("reconnected to ipc_service");
|
log::info!("reconnected to ipc_service");
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
use super::login_failure_check::try_acquire_os_credential_login_gate;
|
||||||
|
use super::login_failure_check::{
|
||||||
|
evaluate_os_credential_policy, record_os_credential_failure, FailureScope,
|
||||||
|
};
|
||||||
use super::{input_service::*, *};
|
use super::{input_service::*, *};
|
||||||
#[cfg(feature = "unix-file-copy-paste")]
|
#[cfg(feature = "unix-file-copy-paste")]
|
||||||
use crate::clipboard::try_empty_clipboard_files;
|
use crate::clipboard::try_empty_clipboard_files;
|
||||||
@@ -22,8 +27,6 @@ use crate::{
|
|||||||
#[cfg(any(target_os = "android", target_os = "ios"))]
|
#[cfg(any(target_os = "android", target_os = "ios"))]
|
||||||
use crate::{common::DEVICE_NAME, flutter::connection_manager::start_channel};
|
use crate::{common::DEVICE_NAME, flutter::connection_manager::start_channel};
|
||||||
use cidr_utils::cidr::IpCidr;
|
use cidr_utils::cidr::IpCidr;
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
use hbb_common::platform::linux::run_cmds;
|
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
use hbb_common::protobuf::EnumOrUnknown;
|
use hbb_common::protobuf::EnumOrUnknown;
|
||||||
use hbb_common::{
|
use hbb_common::{
|
||||||
@@ -84,6 +87,9 @@ lazy_static::lazy_static! {
|
|||||||
static ref PENDING_SWITCH_SIDES_UUID: Arc::<Mutex<HashMap<String, (Instant, uuid::Uuid)>>> = Default::default();
|
static ref PENDING_SWITCH_SIDES_UUID: Arc::<Mutex<HashMap<String, (Instant, uuid::Uuid)>>> = Default::default();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
const TERMINAL_OS_LOGIN_FAILED_MSG: &str = "Incorrect username or password.";
|
||||||
|
|
||||||
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
|
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
|
||||||
if a.len() != b.len() {
|
if a.len() != b.len() {
|
||||||
return false;
|
return false;
|
||||||
@@ -96,6 +102,32 @@ fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
|
|||||||
x == 0
|
x == 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn should_check_linux_headless_os_auth_before_desktop_start(
|
||||||
|
is_headless_allowed: bool,
|
||||||
|
username: &str,
|
||||||
|
) -> bool {
|
||||||
|
is_headless_allowed
|
||||||
|
&& !username.trim().is_empty()
|
||||||
|
&& linux_desktop_manager::get_username().is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn should_record_linux_headless_os_auth_failure(
|
||||||
|
is_headless_allowed: bool,
|
||||||
|
username: &str,
|
||||||
|
err_msg: &str,
|
||||||
|
) -> bool {
|
||||||
|
is_headless_allowed
|
||||||
|
&& !username.trim().is_empty()
|
||||||
|
&& err_msg == crate::client::LOGIN_MSG_PASSWORD_WRONG
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||||
|
fn should_use_terminal_os_login_scope(is_terminal: bool, os_login_username: &str) -> bool {
|
||||||
|
cfg!(target_os = "windows") && is_terminal && !os_login_username.trim().is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||||
lazy_static::lazy_static! {
|
lazy_static::lazy_static! {
|
||||||
static ref WALLPAPER_REMOVER: Arc<Mutex<Option<WallPaperRemover>>> = Default::default();
|
static ref WALLPAPER_REMOVER: Arc<Mutex<Option<WallPaperRemover>>> = Default::default();
|
||||||
@@ -1499,6 +1531,9 @@ impl Connection {
|
|||||||
// Keep the connection alive so the client can continue with 2FA.
|
// Keep the connection alive so the client can continue with 2FA.
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if let Some(keep_alive) = self.prepare_terminal_login_for_authorization().await {
|
||||||
|
return keep_alive;
|
||||||
|
}
|
||||||
if !self.connect_port_forward_if_needed().await {
|
if !self.connect_port_forward_if_needed().await {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -2378,33 +2413,6 @@ impl Connection {
|
|||||||
o.terminal_persistent.enum_value() == Ok(BoolOption::Yes);
|
o.terminal_persistent.enum_value() == Ok(BoolOption::Yes);
|
||||||
}
|
}
|
||||||
self.terminal_service_id = terminal.service_id;
|
self.terminal_service_id = terminal.service_id;
|
||||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
|
||||||
if let Some(msg) =
|
|
||||||
self.fill_terminal_user_token(&lr.os_login.username, &lr.os_login.password)
|
|
||||||
{
|
|
||||||
self.send_login_error(msg).await;
|
|
||||||
sleep(1.).await;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
|
||||||
if let Some(is_user) =
|
|
||||||
terminal_service::is_service_specified_user(&self.terminal_service_id)
|
|
||||||
{
|
|
||||||
if let Some(user_token) = &self.terminal_user_token {
|
|
||||||
let has_service_token =
|
|
||||||
user_token.to_terminal_service_token().is_some();
|
|
||||||
if is_user != has_service_token {
|
|
||||||
// This occurs when the service id (in the configuration) is manually changed by the user, causing a mismatch in validation.
|
|
||||||
log::error!("Terminal service user mismatch detected. The service ID may have been manually changed in the configuration, causing validation to fail.");
|
|
||||||
// No need to translate the following message, because it is in an abnormal case.
|
|
||||||
self.send_login_error("Terminal service user mismatch detected.")
|
|
||||||
.await;
|
|
||||||
sleep(1.).await;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Some(login_request::Union::PortForward(mut pf)) => {
|
Some(login_request::Union::PortForward(mut pf)) => {
|
||||||
if !Self::permission(keys::OPTION_ENABLE_TUNNEL, &self.control_permissions) {
|
if !Self::permission(keys::OPTION_ENABLE_TUNNEL, &self.control_permissions) {
|
||||||
@@ -2422,8 +2430,43 @@ impl Connection {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !hbb_common::is_ip_str(&lr.username)
|
||||||
|
&& !hbb_common::is_domain_port_str(&lr.username)
|
||||||
|
&& lr.username != Config::get_id()
|
||||||
|
{
|
||||||
|
self.send_login_error(crate::client::LOGIN_MSG_OFFLINE)
|
||||||
|
.await;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
if self.terminal
|
||||||
|
&& lr.os_login.username.trim().is_empty()
|
||||||
|
&& crate::platform::is_prelogin()
|
||||||
|
{
|
||||||
|
self.send_login_error(
|
||||||
|
"No active console user logged on, please connect and logon first.",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
sleep(1.).await;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||||
self.try_start_cm_ipc();
|
if !should_use_terminal_os_login_scope(self.terminal, &lr.os_login.username) {
|
||||||
|
self.try_start_cm_ipc();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
if should_check_linux_headless_os_auth_before_desktop_start(
|
||||||
|
self.linux_headless_handle.is_headless_allowed,
|
||||||
|
&lr.os_login.username,
|
||||||
|
) {
|
||||||
|
let (_failure, res) = self.check_failure(0).await;
|
||||||
|
if !res {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(not(target_os = "linux"))]
|
#[cfg(not(target_os = "linux"))]
|
||||||
let err_msg = "".to_owned();
|
let err_msg = "".to_owned();
|
||||||
@@ -2435,6 +2478,18 @@ impl Connection {
|
|||||||
// If err is LOGIN_MSG_DESKTOP_SESSION_NOT_READY, just keep this msg and go on checking password.
|
// If err is LOGIN_MSG_DESKTOP_SESSION_NOT_READY, just keep this msg and go on checking password.
|
||||||
if !err_msg.is_empty() && err_msg != crate::client::LOGIN_MSG_DESKTOP_SESSION_NOT_READY
|
if !err_msg.is_empty() && err_msg != crate::client::LOGIN_MSG_DESKTOP_SESSION_NOT_READY
|
||||||
{
|
{
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
if should_record_linux_headless_os_auth_failure(
|
||||||
|
self.linux_headless_handle.is_headless_allowed,
|
||||||
|
&lr.os_login.username,
|
||||||
|
&err_msg,
|
||||||
|
) {
|
||||||
|
let (failure, res) = self.check_failure(0).await;
|
||||||
|
if !res {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
self.update_failure(failure, false, 0);
|
||||||
|
}
|
||||||
self.send_login_error(err_msg).await;
|
self.send_login_error(err_msg).await;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -2463,17 +2518,16 @@ impl Connection {
|
|||||||
crate::get_builtin_option(keys::OPTION_ALLOW_LOGON_SCREEN_PASSWORD) == "Y"
|
crate::get_builtin_option(keys::OPTION_ALLOW_LOGON_SCREEN_PASSWORD) == "Y"
|
||||||
&& is_logon();
|
&& is_logon();
|
||||||
|
|
||||||
if !hbb_common::is_ip_str(&lr.username)
|
if (password::approve_mode() == ApproveMode::Click && !allow_logon_screen_password)
|
||||||
&& !hbb_common::is_domain_port_str(&lr.username)
|
|
||||||
&& lr.username != Config::get_id()
|
|
||||||
{
|
|
||||||
self.send_login_error(crate::client::LOGIN_MSG_OFFLINE)
|
|
||||||
.await;
|
|
||||||
return false;
|
|
||||||
} else if (password::approve_mode() == ApproveMode::Click
|
|
||||||
&& !allow_logon_screen_password)
|
|
||||||
|| password::approve_mode() == ApproveMode::Both && !password::has_valid_password()
|
|| password::approve_mode() == ApproveMode::Both && !password::has_valid_password()
|
||||||
{
|
{
|
||||||
|
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||||
|
if should_use_terminal_os_login_scope(self.terminal, &lr.os_login.username) {
|
||||||
|
if let Some(keep_alive) = self.prepare_terminal_login_for_authorization().await
|
||||||
|
{
|
||||||
|
return keep_alive;
|
||||||
|
}
|
||||||
|
}
|
||||||
self.try_start_cm(lr.my_id, lr.my_name, false);
|
self.try_start_cm(lr.my_id, lr.my_name, false);
|
||||||
if hbb_common::get_version_number(&lr.version)
|
if hbb_common::get_version_number(&lr.version)
|
||||||
>= hbb_common::get_version_number("1.2.0")
|
>= hbb_common::get_version_number("1.2.0")
|
||||||
@@ -2495,6 +2549,14 @@ impl Connection {
|
|||||||
}
|
}
|
||||||
} else if lr.password.is_empty() {
|
} else if lr.password.is_empty() {
|
||||||
if err_msg.is_empty() {
|
if err_msg.is_empty() {
|
||||||
|
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||||
|
if should_use_terminal_os_login_scope(self.terminal, &lr.os_login.username) {
|
||||||
|
if let Some(keep_alive) =
|
||||||
|
self.prepare_terminal_login_for_authorization().await
|
||||||
|
{
|
||||||
|
return keep_alive;
|
||||||
|
}
|
||||||
|
}
|
||||||
self.try_start_cm(lr.my_id, lr.my_name, false);
|
self.try_start_cm(lr.my_id, lr.my_name, false);
|
||||||
} else {
|
} else {
|
||||||
self.send_login_error(
|
self.send_login_error(
|
||||||
@@ -2508,7 +2570,7 @@ impl Connection {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if !self.validate_password(allow_logon_screen_password) {
|
if !self.validate_password(allow_logon_screen_password) {
|
||||||
self.update_failure(failure, false, 0);
|
self.update_failure_with_scope(failure, false, 0, FailureScope::Default);
|
||||||
self.check_update_temporary_password(false);
|
self.check_update_temporary_password(false);
|
||||||
if err_msg.is_empty() {
|
if err_msg.is_empty() {
|
||||||
self.send_login_error(crate::client::LOGIN_MSG_PASSWORD_WRONG)
|
self.send_login_error(crate::client::LOGIN_MSG_PASSWORD_WRONG)
|
||||||
@@ -2521,7 +2583,7 @@ impl Connection {
|
|||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
self.update_failure(failure, true, 0);
|
self.update_failure_with_scope(failure, true, 0, FailureScope::Default);
|
||||||
if err_msg.is_empty() {
|
if err_msg.is_empty() {
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
self.linux_headless_handle.wait_desktop_cm_ready().await;
|
self.linux_headless_handle.wait_desktop_cm_ready().await;
|
||||||
@@ -3486,16 +3548,16 @@ impl Connection {
|
|||||||
self.terminal_user_token = Some(TerminalUserToken::SelfUser);
|
self.terminal_user_token = Some(TerminalUserToken::SelfUser);
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
Some("The user is not an administrator.")
|
Some(TERMINAL_OS_LOGIN_FAILED_MSG)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(Err(e)) => {
|
Ok(Err(e)) => {
|
||||||
log::error!("Failed to check if the user is an administrator: {}", e);
|
log::error!("Failed to check if the user is an administrator: {}", e);
|
||||||
Some("Failed to check if the user is an administrator.")
|
Some(TERMINAL_OS_LOGIN_FAILED_MSG)
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("Failed to get logon user token: {}", e);
|
log::error!("Failed to get logon user token: {}", e);
|
||||||
Some("Incorrect username or password.")
|
Some(TERMINAL_OS_LOGIN_FAILED_MSG)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3531,6 +3593,146 @@ impl Connection {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||||
|
async fn prepare_terminal_login_for_authorization(&mut self) -> Option<bool> {
|
||||||
|
if !self.terminal || self.terminal_user_token.is_some() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone)]
|
||||||
|
enum TerminalAuthorizationMode {
|
||||||
|
OsLogin {
|
||||||
|
failure: ((i32, i32, i32), i32),
|
||||||
|
scope: FailureScope,
|
||||||
|
},
|
||||||
|
SessionUser,
|
||||||
|
}
|
||||||
|
|
||||||
|
let normalized_username = self.lr.os_login.username.trim().to_owned();
|
||||||
|
let auth_mode = if should_use_terminal_os_login_scope(self.terminal, &normalized_username) {
|
||||||
|
// Check failure state
|
||||||
|
let failure_scope = FailureScope::TerminalOsLogin;
|
||||||
|
let (failure, res) = self.check_failure_with_scope(0, failure_scope).await;
|
||||||
|
if !res {
|
||||||
|
log::warn!(
|
||||||
|
"OS credential login blocked by failure policy: ip={} conn_id={} scope={:?}",
|
||||||
|
self.ip,
|
||||||
|
self.inner.id(),
|
||||||
|
failure_scope
|
||||||
|
);
|
||||||
|
// Terminal OS login is sensitive. Close this connection instead of keeping it
|
||||||
|
// alive for retries on the same socket after a rate-limit block.
|
||||||
|
return Some(false);
|
||||||
|
}
|
||||||
|
TerminalAuthorizationMode::OsLogin {
|
||||||
|
failure,
|
||||||
|
scope: failure_scope,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
TerminalAuthorizationMode::SessionUser
|
||||||
|
};
|
||||||
|
|
||||||
|
let is_terminal_os_login = matches!(auth_mode, TerminalAuthorizationMode::OsLogin { .. });
|
||||||
|
let failure_scope = match auth_mode {
|
||||||
|
TerminalAuthorizationMode::OsLogin { scope, .. } => scope,
|
||||||
|
TerminalAuthorizationMode::SessionUser => FailureScope::Default,
|
||||||
|
};
|
||||||
|
|
||||||
|
let username = normalized_username;
|
||||||
|
let password = self.lr.os_login.password.clone();
|
||||||
|
let terminal_login_error = {
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
// Concurrency gate for terminal OS login with credentials, to prevent brute-force attacks.
|
||||||
|
let _os_login_concurrency_guard = if is_terminal_os_login {
|
||||||
|
let guard = try_acquire_os_credential_login_gate();
|
||||||
|
if guard.is_err() {
|
||||||
|
log::warn!(
|
||||||
|
"OS credential login blocked by concurrency gate: ip={} conn_id={} scope={:?}",
|
||||||
|
self.ip,
|
||||||
|
self.inner.id(),
|
||||||
|
failure_scope
|
||||||
|
);
|
||||||
|
self.send_login_error("Please try 1 minute later").await;
|
||||||
|
sleep(1.).await;
|
||||||
|
Self::post_alarm_audit(
|
||||||
|
AlarmAuditType::TerminalOsLoginConcurrency,
|
||||||
|
json!({
|
||||||
|
"ip": self.ip,
|
||||||
|
"id": self.lr.my_id.clone(),
|
||||||
|
"name": self.lr.my_name.clone(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return Some(false);
|
||||||
|
}
|
||||||
|
guard.ok()
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
self.fill_terminal_user_token(&username, &password)
|
||||||
|
}
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
{
|
||||||
|
self.fill_terminal_user_token(&username, &password)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Some(msg) = terminal_login_error {
|
||||||
|
if let TerminalAuthorizationMode::OsLogin { failure, scope } = auth_mode {
|
||||||
|
self.update_failure_with_scope(failure, false, 0, scope);
|
||||||
|
}
|
||||||
|
let auth_context = if is_terminal_os_login {
|
||||||
|
"OS credential login verification"
|
||||||
|
} else {
|
||||||
|
"Terminal session-user authorization"
|
||||||
|
};
|
||||||
|
log::warn!(
|
||||||
|
"{} failed: ip={} conn_id={} scope={:?} msg='{}'",
|
||||||
|
auth_context,
|
||||||
|
self.ip,
|
||||||
|
self.inner.id(),
|
||||||
|
failure_scope,
|
||||||
|
msg
|
||||||
|
);
|
||||||
|
self.send_login_error(msg).await;
|
||||||
|
sleep(1.).await;
|
||||||
|
return Some(false);
|
||||||
|
}
|
||||||
|
if let TerminalAuthorizationMode::OsLogin { failure, scope } = auth_mode {
|
||||||
|
self.update_failure_with_scope(failure, true, 0, scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(is_user) =
|
||||||
|
terminal_service::is_service_specified_user(&self.terminal_service_id)
|
||||||
|
{
|
||||||
|
if let Some(user_token) = &self.terminal_user_token {
|
||||||
|
let has_service_token = user_token.to_terminal_service_token().is_some();
|
||||||
|
if is_user != has_service_token {
|
||||||
|
log::error!(
|
||||||
|
"Terminal service user mismatch: ip={} conn_id={} service_is_user={} has_service_token={}. The service ID may have been manually changed in the configuration, causing validation to fail.",
|
||||||
|
self.ip,
|
||||||
|
self.inner.id(),
|
||||||
|
is_user,
|
||||||
|
has_service_token
|
||||||
|
);
|
||||||
|
// No need to translate the following message, because it is in an abnormal case.
|
||||||
|
self.send_login_error("Terminal service user mismatch detected.")
|
||||||
|
.await;
|
||||||
|
sleep(1.).await;
|
||||||
|
return Some(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if is_terminal_os_login {
|
||||||
|
self.try_start_cm_ipc();
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(any(target_os = "android", target_os = "ios"))]
|
||||||
|
async fn prepare_terminal_login_for_authorization(&mut self) -> Option<bool> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
// Try to parse connection IP as IPv6 address, returning /64, /56, and /48 prefixes.
|
// Try to parse connection IP as IPv6 address, returning /64, /56, and /48 prefixes.
|
||||||
// Parsing an IPv4 address just returns None.
|
// Parsing an IPv4 address just returns None.
|
||||||
// note: we specifically don't use hbb_common::is_ipv6_str to avoid divergence issues
|
// note: we specifically don't use hbb_common::is_ipv6_str to avoid divergence issues
|
||||||
@@ -3557,18 +3759,37 @@ impl Connection {
|
|||||||
Some((p64, p56, p48))
|
Some((p64, p56, p48))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update_failure(&self, (failure, time): ((i32, i32, i32), i32), remove: bool, i: usize) {
|
fn bump_failure_entry(mut cur: (i32, i32, i32), time: i32) -> (i32, i32, i32) {
|
||||||
fn bump(mut cur: (i32, i32, i32), time: i32) -> (i32, i32, i32) {
|
if cur.0 == time {
|
||||||
if cur.0 == time {
|
cur.1 += 1;
|
||||||
cur.1 += 1;
|
cur.2 += 1;
|
||||||
cur.2 += 1;
|
} else {
|
||||||
} else {
|
cur.0 = time;
|
||||||
cur.0 = time;
|
cur.1 = 1;
|
||||||
cur.1 = 1;
|
cur.2 += 1;
|
||||||
cur.2 += 1;
|
|
||||||
}
|
|
||||||
cur
|
|
||||||
}
|
}
|
||||||
|
cur
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_failure(&self, failure: ((i32, i32, i32), i32), remove: bool, i: usize) {
|
||||||
|
self.update_failure_with_scope(failure, remove, i, FailureScope::Default);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_failure_with_scope(
|
||||||
|
&self,
|
||||||
|
(failure, time): ((i32, i32, i32), i32),
|
||||||
|
remove: bool,
|
||||||
|
i: usize,
|
||||||
|
scope: FailureScope,
|
||||||
|
) {
|
||||||
|
let os_credential_scope = matches!(scope, FailureScope::TerminalOsLogin);
|
||||||
|
if os_credential_scope {
|
||||||
|
if !remove {
|
||||||
|
record_os_credential_failure(scope);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let map_mutex = &LOGIN_FAILURES[i];
|
let map_mutex = &LOGIN_FAILURES[i];
|
||||||
if remove {
|
if remove {
|
||||||
if failure.0 != 0 {
|
if failure.0 != 0 {
|
||||||
@@ -3589,14 +3810,15 @@ impl Connection {
|
|||||||
let mut m = map_mutex.lock().unwrap();
|
let mut m = map_mutex.lock().unwrap();
|
||||||
for key in [p64, p56, p48] {
|
for key in [p64, p56, p48] {
|
||||||
let cur = m.get(&key).copied().unwrap_or((0, 0, 0));
|
let cur = m.get(&key).copied().unwrap_or((0, 0, 0));
|
||||||
m.insert(key, bump(cur, time));
|
m.insert(key, Self::bump_failure_entry(cur, time));
|
||||||
}
|
}
|
||||||
// Update full IP: bump from the *original* passed-in failure
|
let current_ip = m.get(&self.ip).copied().unwrap_or((0, 0, 0));
|
||||||
m.insert(self.ip.clone(), bump(failure, time));
|
m.insert(self.ip.clone(), Self::bump_failure_entry(current_ip, time));
|
||||||
} else {
|
} else {
|
||||||
// Update full IP: bump from the *original* passed-in failure
|
// Re-read the full IP bucket in case another failed attempt updated it.
|
||||||
let mut m = map_mutex.lock().unwrap();
|
let mut m = map_mutex.lock().unwrap();
|
||||||
m.insert(self.ip.clone(), bump(failure, time));
|
let current_ip = m.get(&self.ip).copied().unwrap_or((0, 0, 0));
|
||||||
|
m.insert(self.ip.clone(), Self::bump_failure_entry(current_ip, time));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3636,8 +3858,50 @@ impl Connection {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn check_failure(&mut self, i: usize) -> (((i32, i32, i32), i32), bool) {
|
async fn check_failure(&mut self, i: usize) -> (((i32, i32, i32), i32), bool) {
|
||||||
|
self.check_failure_with_scope(i, FailureScope::Default)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn check_failure_with_scope(
|
||||||
|
&mut self,
|
||||||
|
i: usize,
|
||||||
|
scope: FailureScope,
|
||||||
|
) -> (((i32, i32, i32), i32), bool) {
|
||||||
let time = (get_time() / 60_000) as i32;
|
let time = (get_time() / 60_000) as i32;
|
||||||
|
|
||||||
|
if matches!(scope, FailureScope::TerminalOsLogin) {
|
||||||
|
let decision = evaluate_os_credential_policy(scope, get_time());
|
||||||
|
let res = if decision.allowed {
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
log::warn!(
|
||||||
|
"OS credential login blocked by policy: ip={} conn_id={} i={} msg='{}'",
|
||||||
|
self.ip,
|
||||||
|
self.inner.id(),
|
||||||
|
i,
|
||||||
|
decision.login_error.as_deref().unwrap_or("")
|
||||||
|
);
|
||||||
|
if let Some(login_error) = decision.login_error {
|
||||||
|
// Rare branch and currently temporary response copy; translation can be added later if needed.
|
||||||
|
self.send_login_error(login_error).await;
|
||||||
|
}
|
||||||
|
if let Some(audit) = decision.audit {
|
||||||
|
// For OS blocked/backoff events, we currently emit one alarm report per blocked attempt.
|
||||||
|
// TODO: Add unified cumulative/aggregation fields across alarm producers.
|
||||||
|
Self::post_alarm_audit(
|
||||||
|
audit,
|
||||||
|
json!({
|
||||||
|
"ip": self.ip,
|
||||||
|
"id": self.lr.my_id.clone(),
|
||||||
|
"name": self.lr.my_name.clone(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
false
|
||||||
|
};
|
||||||
|
return (((0, 0, 0), time), res);
|
||||||
|
}
|
||||||
|
|
||||||
// IPv6 addresses are cheap to make so we check prefix/netblock as well
|
// IPv6 addresses are cheap to make so we check prefix/netblock as well
|
||||||
if let Some((p64, p56, p48)) = self.get_ipv6_prefixes() {
|
if let Some((p64, p56, p48)) = self.get_ipv6_prefixes() {
|
||||||
if let Some(res) = self.check_failure_ipv6_prefix(i, time, &p64, 64, 60).await {
|
if let Some(res) = self.check_failure_ipv6_prefix(i, time, &p64, 64, 60).await {
|
||||||
@@ -4983,6 +5247,9 @@ pub fn remove_pending_switch_sides_uuid(id: &str, uuid: &uuid::Uuid) -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||||
|
// IPC bootstrap summary:
|
||||||
|
// - Resolve target CM socket (headless/non-headless, optional UID-scoped path on Linux).
|
||||||
|
// - Start CM when missing, then bridge bidirectional messages between this task and CM IPC.
|
||||||
async fn start_ipc(
|
async fn start_ipc(
|
||||||
mut rx_to_cm: mpsc::UnboundedReceiver<ipc::Data>,
|
mut rx_to_cm: mpsc::UnboundedReceiver<ipc::Data>,
|
||||||
tx_from_cm: mpsc::UnboundedSender<ipc::Data>,
|
tx_from_cm: mpsc::UnboundedSender<ipc::Data>,
|
||||||
@@ -4997,10 +5264,19 @@ async fn start_ipc(
|
|||||||
}
|
}
|
||||||
sleep(1.).await;
|
sleep(1.).await;
|
||||||
}
|
}
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
let headless_cm = crate::is_server()
|
||||||
|
&& crate::platform::is_headless_allowed()
|
||||||
|
&& linux_desktop_manager::is_headless();
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
let headless_cm = false;
|
||||||
let mut stream = None;
|
let mut stream = None;
|
||||||
if let Ok(s) = crate::ipc::connect(1000, "_cm").await {
|
if !headless_cm {
|
||||||
stream = Some(s);
|
if let Ok(s) = crate::ipc::connect(1000, "_cm").await {
|
||||||
} else {
|
stream = Some(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if stream.is_none() {
|
||||||
#[allow(unused_mut)]
|
#[allow(unused_mut)]
|
||||||
#[allow(unused_assignments)]
|
#[allow(unused_assignments)]
|
||||||
let mut args = vec!["--cm"];
|
let mut args = vec!["--cm"];
|
||||||
@@ -5010,75 +5286,123 @@ async fn start_ipc(
|
|||||||
|
|
||||||
// Cm run as user, wait until desktop session is ready.
|
// Cm run as user, wait until desktop session is ready.
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
if crate::platform::is_headless_allowed() && linux_desktop_manager::is_headless() {
|
if headless_cm {
|
||||||
let mut username = linux_desktop_manager::get_username();
|
let mut username = linux_desktop_manager::get_username();
|
||||||
loop {
|
loop {
|
||||||
if !username.is_empty() {
|
if !username.is_empty() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
// `_rx_desktop_ready` is used as a wake-up signal from desktop/session state changes
|
||||||
|
// (for example wait_desktop_cm_ready paths). It is not itself a proof of CM readiness.
|
||||||
|
// TODO:
|
||||||
|
// When `_rx_desktop_ready` is closed, `recv()` returns
|
||||||
|
// `None` immediately and this loop may spin if `username` remains empty.
|
||||||
|
// Keep behavior unchanged for now; if field reports appear, handle `Ok(None)` by
|
||||||
|
// breaking/returning to avoid hot-looping.
|
||||||
let _res = timeout(1_000, _rx_desktop_ready.recv()).await;
|
let _res = timeout(1_000, _rx_desktop_ready.recv()).await;
|
||||||
username = linux_desktop_manager::get_username();
|
username = linux_desktop_manager::get_username();
|
||||||
}
|
}
|
||||||
let uid = {
|
let uid = {
|
||||||
let output = run_cmds(&format!("id -u {}", &username))?;
|
let username_for_cmd = username.clone();
|
||||||
|
let mut uid_cmd = hbb_common::tokio::process::Command::new("id");
|
||||||
|
// TODO:
|
||||||
|
// Keep current behavior for now to minimize change risk.
|
||||||
|
// If usernames starting with '-' are observed in the field, prefer:
|
||||||
|
// `id -u -- <username>` to avoid option-parsing ambiguity.
|
||||||
|
// Already verified that `id -u -- <username>` works as expected on macOS and Ubuntu 24.04.
|
||||||
|
uid_cmd.arg("-u").arg(&username_for_cmd).kill_on_drop(true);
|
||||||
|
let output = timeout(10_000, uid_cmd.output())
|
||||||
|
.await
|
||||||
|
.map_err(|_| anyhow!("Timed out querying uid for {}", username))?
|
||||||
|
.map_err(|e| anyhow!("Failed to run `id -u {}`: {}", username, e))?;
|
||||||
|
if !output.status.success() {
|
||||||
|
bail!("Failed to query uid for {}", username);
|
||||||
|
}
|
||||||
|
let output = String::from_utf8_lossy(&output.stdout);
|
||||||
let output = output.trim();
|
let output = output.trim();
|
||||||
if output.is_empty() || !output.parse::<i32>().is_ok() {
|
if output.parse::<u32>().is_err() {
|
||||||
bail!("Invalid username {}", &username);
|
bail!("Invalid uid {}", output);
|
||||||
}
|
}
|
||||||
output.to_string()
|
output.to_string()
|
||||||
};
|
};
|
||||||
user = Some((uid, username));
|
user = Some((uid, username));
|
||||||
args = vec!["--cm-no-ui"];
|
args = vec!["--cm-no-ui"];
|
||||||
}
|
}
|
||||||
let run_done;
|
#[cfg(target_os = "linux")]
|
||||||
if crate::platform::is_root() {
|
let cm_uid: Option<u32> = match &user {
|
||||||
let mut res = Ok(None);
|
Some((uid, _)) => Some(
|
||||||
for _ in 0..10 {
|
uid.parse::<u32>()
|
||||||
#[cfg(not(any(target_os = "linux")))]
|
.map_err(|_| anyhow!("Invalid uid {}", uid))?,
|
||||||
{
|
),
|
||||||
log::debug!("Start cm");
|
None => None,
|
||||||
res = crate::platform::run_as_user(args.clone());
|
};
|
||||||
}
|
#[cfg(target_os = "linux")]
|
||||||
#[cfg(target_os = "linux")]
|
if let Some(uid) = cm_uid {
|
||||||
{
|
if let Ok(s) = crate::ipc::connect_for_uid(1000, uid, "_cm").await {
|
||||||
log::debug!("Start cm");
|
|
||||||
res = crate::platform::run_as_user(
|
|
||||||
args.clone(),
|
|
||||||
user.clone(),
|
|
||||||
None::<(&str, &str)>,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if res.is_ok() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
log::error!("Failed to run cm: {res:?}");
|
|
||||||
sleep(1.).await;
|
|
||||||
}
|
|
||||||
if let Some(task) = res? {
|
|
||||||
super::CHILD_PROCESS.lock().unwrap().push(task);
|
|
||||||
}
|
|
||||||
run_done = true;
|
|
||||||
} else {
|
|
||||||
run_done = false;
|
|
||||||
}
|
|
||||||
if !run_done {
|
|
||||||
log::debug!("Start cm");
|
|
||||||
super::CHILD_PROCESS
|
|
||||||
.lock()
|
|
||||||
.unwrap()
|
|
||||||
.push(crate::run_me(args)?);
|
|
||||||
}
|
|
||||||
for _ in 0..20 {
|
|
||||||
sleep(0.3).await;
|
|
||||||
if let Ok(s) = crate::ipc::connect(1000, "_cm").await {
|
|
||||||
stream = Some(s);
|
stream = Some(s);
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if stream.is_none() {
|
if stream.is_none() {
|
||||||
bail!("Failed to connect to connection manager");
|
let run_done;
|
||||||
|
if crate::platform::is_root() {
|
||||||
|
let mut res = Ok(None);
|
||||||
|
for _ in 0..10 {
|
||||||
|
#[cfg(not(any(target_os = "linux")))]
|
||||||
|
{
|
||||||
|
log::debug!("Start cm");
|
||||||
|
res = crate::platform::run_as_user(args.clone());
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
log::debug!("Start cm");
|
||||||
|
res = crate::platform::run_as_user(
|
||||||
|
args.clone(),
|
||||||
|
user.clone(),
|
||||||
|
None::<(&str, &str)>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if res.is_ok() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
log::error!("Failed to run cm: {res:?}");
|
||||||
|
sleep(1.).await;
|
||||||
|
}
|
||||||
|
if let Some(task) = res? {
|
||||||
|
super::CHILD_PROCESS.lock().unwrap().push(task);
|
||||||
|
}
|
||||||
|
run_done = true;
|
||||||
|
} else {
|
||||||
|
run_done = false;
|
||||||
|
}
|
||||||
|
if !run_done {
|
||||||
|
log::debug!("Start cm");
|
||||||
|
super::CHILD_PROCESS
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.push(crate::run_me(args)?);
|
||||||
|
}
|
||||||
|
for _ in 0..20 {
|
||||||
|
sleep(0.3).await;
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
if let Some(uid) = cm_uid {
|
||||||
|
if let Ok(s) = crate::ipc::connect_for_uid(1000, uid, "_cm").await {
|
||||||
|
stream = Some(s);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Ok(s) = crate::ipc::connect(1000, "_cm").await {
|
||||||
|
stream = Some(s);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if stream.is_none() {
|
||||||
|
bail!("Failed to connect to connection manager");
|
||||||
|
}
|
||||||
|
|
||||||
let _res = tx_stream_ready.send(()).await;
|
let _res = tx_stream_ready.send(()).await;
|
||||||
let mut stream = stream.ok_or(anyhow!("none stream"))?;
|
let mut stream = stream.ok_or(anyhow!("none stream"))?;
|
||||||
@@ -5161,6 +5485,8 @@ pub enum AlarmAuditType {
|
|||||||
// MultipleLoginsAttemptsWithinOneMinute = 4,
|
// MultipleLoginsAttemptsWithinOneMinute = 4,
|
||||||
// MultipleLoginsAttemptsWithinOneHour = 5,
|
// MultipleLoginsAttemptsWithinOneHour = 5,
|
||||||
ExceedIPv6PrefixAttempts = 6,
|
ExceedIPv6PrefixAttempts = 6,
|
||||||
|
TerminalOsLoginBackoff = 7,
|
||||||
|
TerminalOsLoginConcurrency = 8,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub enum FileAuditType {
|
pub enum FileAuditType {
|
||||||
|
|||||||
231
src/server/login_failure_check.rs
Normal file
231
src/server/login_failure_check.rs
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
use crate::AlarmAuditType;
|
||||||
|
use hbb_common::get_time;
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
use hbb_common::tokio::sync::{Mutex as TokioMutex, OwnedMutexGuard};
|
||||||
|
use std::sync::Mutex;
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
const OS_CREDENTIAL_LOGIN_TOTAL_IDLE_RESET_MS: i64 = 120 * 60 * 1_000;
|
||||||
|
const OS_CREDENTIAL_LOGIN_BACKOFF_BASE_SECONDS: i64 = 15;
|
||||||
|
const OS_CREDENTIAL_LOGIN_BACKOFF_MAX_SECONDS: i64 = 30 * 60;
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub(crate) enum FailureScope {
|
||||||
|
Default,
|
||||||
|
TerminalOsLogin,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct OsCredentialPolicyDecision {
|
||||||
|
pub allowed: bool,
|
||||||
|
pub login_error: Option<String>,
|
||||||
|
pub audit: Option<AlarmAuditType>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, Default)]
|
||||||
|
struct OsCredentialFailureState {
|
||||||
|
total_failures: i32,
|
||||||
|
backoff_until_ms: Option<i64>,
|
||||||
|
last_failure_ms: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
lazy_static::lazy_static! {
|
||||||
|
static ref OS_CREDENTIAL_LOGIN_FAILURE_STATE: Mutex<OsCredentialFailureState> =
|
||||||
|
Mutex::new(OsCredentialFailureState::default());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
lazy_static::lazy_static! {
|
||||||
|
static ref OS_CREDENTIAL_LOGIN_MUTEX: Arc<TokioMutex<()>> = Arc::new(TokioMutex::new(()));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_os_credential_scope(scope: FailureScope) -> bool {
|
||||||
|
matches!(scope, FailureScope::TerminalOsLogin)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn state_for_os_credential_scope(
|
||||||
|
scope: FailureScope,
|
||||||
|
) -> Option<&'static Mutex<OsCredentialFailureState>> {
|
||||||
|
if is_os_credential_scope(scope) {
|
||||||
|
Some(&OS_CREDENTIAL_LOGIN_FAILURE_STATE)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn backoff_audit_type_for_scope(scope: FailureScope) -> Option<AlarmAuditType> {
|
||||||
|
match scope {
|
||||||
|
FailureScope::TerminalOsLogin => Some(AlarmAuditType::TerminalOsLoginBackoff),
|
||||||
|
FailureScope::Default => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn os_credential_login_backoff_seconds(total_failures: i32) -> i64 {
|
||||||
|
if total_failures <= 2 {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
let exp = (total_failures - 3).min(7);
|
||||||
|
let seconds = OS_CREDENTIAL_LOGIN_BACKOFF_BASE_SECONDS * (1_i64 << exp);
|
||||||
|
seconds.min(OS_CREDENTIAL_LOGIN_BACKOFF_MAX_SECONDS)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_backoff(state: &mut OsCredentialFailureState, now_ms: i64) {
|
||||||
|
if let Some(until_ms) = state.backoff_until_ms {
|
||||||
|
if until_ms <= now_ms {
|
||||||
|
state.backoff_until_ms = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reset_totals_on_idle(state: &mut OsCredentialFailureState, now_ms: i64) {
|
||||||
|
if let Some(last_ms) = state.last_failure_ms {
|
||||||
|
if now_ms.saturating_sub(last_ms) >= OS_CREDENTIAL_LOGIN_TOTAL_IDLE_RESET_MS {
|
||||||
|
state.total_failures = 0;
|
||||||
|
state.backoff_until_ms = None;
|
||||||
|
state.last_failure_ms = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn allow_decision() -> OsCredentialPolicyDecision {
|
||||||
|
OsCredentialPolicyDecision {
|
||||||
|
allowed: true,
|
||||||
|
login_error: None,
|
||||||
|
audit: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn block_decision(
|
||||||
|
login_error: String,
|
||||||
|
alarm_type: Option<AlarmAuditType>,
|
||||||
|
) -> OsCredentialPolicyDecision {
|
||||||
|
OsCredentialPolicyDecision {
|
||||||
|
allowed: false,
|
||||||
|
login_error: Some(login_error),
|
||||||
|
audit: alarm_type,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn evaluate_os_credential_policy(
|
||||||
|
scope: FailureScope,
|
||||||
|
now_ms: i64,
|
||||||
|
) -> OsCredentialPolicyDecision {
|
||||||
|
if !is_os_credential_scope(scope) {
|
||||||
|
return allow_decision();
|
||||||
|
}
|
||||||
|
let Some(state_mutex) = state_for_os_credential_scope(scope) else {
|
||||||
|
return allow_decision();
|
||||||
|
};
|
||||||
|
let mut state = state_mutex.lock().unwrap();
|
||||||
|
reset_totals_on_idle(&mut state, now_ms);
|
||||||
|
normalize_backoff(&mut state, now_ms);
|
||||||
|
|
||||||
|
if let Some(until_ms) = state.backoff_until_ms {
|
||||||
|
let remaining_ms = (until_ms - now_ms).max(0);
|
||||||
|
let remaining_seconds = ((remaining_ms + 999) / 1_000).max(1);
|
||||||
|
let seconds_label = if remaining_seconds == 1 {
|
||||||
|
"second"
|
||||||
|
} else {
|
||||||
|
"seconds"
|
||||||
|
};
|
||||||
|
block_decision(
|
||||||
|
format!(
|
||||||
|
"Please try again in {} {}.",
|
||||||
|
remaining_seconds, seconds_label
|
||||||
|
),
|
||||||
|
backoff_audit_type_for_scope(scope),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
allow_decision()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn record_os_credential_failure(scope: FailureScope) {
|
||||||
|
if !is_os_credential_scope(scope) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(state_mutex) = state_for_os_credential_scope(scope) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let mut state = state_mutex.lock().unwrap();
|
||||||
|
let now_ms = get_time();
|
||||||
|
reset_totals_on_idle(&mut state, now_ms);
|
||||||
|
normalize_backoff(&mut state, now_ms);
|
||||||
|
state.total_failures = state.total_failures.saturating_add(1);
|
||||||
|
state.last_failure_ms = Some(now_ms);
|
||||||
|
let backoff_seconds = os_credential_login_backoff_seconds(state.total_failures);
|
||||||
|
if backoff_seconds > 0 {
|
||||||
|
state.backoff_until_ms = Some(now_ms + backoff_seconds * 1_000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
pub(crate) fn try_acquire_os_credential_login_gate() -> Result<OwnedMutexGuard<()>, ()> {
|
||||||
|
OS_CREDENTIAL_LOGIN_MUTEX
|
||||||
|
.clone()
|
||||||
|
.try_lock_owned()
|
||||||
|
.map_err(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
static TEST_MUTEX: Mutex<()> = Mutex::new(());
|
||||||
|
|
||||||
|
fn clear_os_credential_failure_state(scope: FailureScope) {
|
||||||
|
if let Some(state_mutex) = state_for_os_credential_scope(scope) {
|
||||||
|
*state_mutex.lock().unwrap() = OsCredentialFailureState::default();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn os_credential_policy_prioritizes_backoff() {
|
||||||
|
let _guard = TEST_MUTEX.lock().unwrap();
|
||||||
|
clear_os_credential_failure_state(FailureScope::TerminalOsLogin);
|
||||||
|
let now_ms = get_time();
|
||||||
|
for _ in 0..3 {
|
||||||
|
record_os_credential_failure(FailureScope::TerminalOsLogin);
|
||||||
|
}
|
||||||
|
let decision = evaluate_os_credential_policy(FailureScope::TerminalOsLogin, now_ms);
|
||||||
|
assert!(!decision.allowed);
|
||||||
|
assert!(decision.login_error.is_some());
|
||||||
|
clear_os_credential_failure_state(FailureScope::TerminalOsLogin);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn os_credential_policy_idle_window_resets_total_counter() {
|
||||||
|
let _guard = TEST_MUTEX.lock().unwrap();
|
||||||
|
clear_os_credential_failure_state(FailureScope::TerminalOsLogin);
|
||||||
|
for _ in 0..13 {
|
||||||
|
record_os_credential_failure(FailureScope::TerminalOsLogin);
|
||||||
|
}
|
||||||
|
let blocked = evaluate_os_credential_policy(FailureScope::TerminalOsLogin, get_time());
|
||||||
|
assert!(!blocked.allowed);
|
||||||
|
|
||||||
|
let after_failures_ms = get_time();
|
||||||
|
let after_idle_ms = after_failures_ms + OS_CREDENTIAL_LOGIN_TOTAL_IDLE_RESET_MS + 1_000;
|
||||||
|
let allowed = evaluate_os_credential_policy(FailureScope::TerminalOsLogin, after_idle_ms);
|
||||||
|
assert!(allowed.allowed);
|
||||||
|
clear_os_credential_failure_state(FailureScope::TerminalOsLogin);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn os_credential_policy_audits_every_backoff_block() {
|
||||||
|
let _guard = TEST_MUTEX.lock().unwrap();
|
||||||
|
clear_os_credential_failure_state(FailureScope::TerminalOsLogin);
|
||||||
|
|
||||||
|
for _ in 0..3 {
|
||||||
|
record_os_credential_failure(FailureScope::TerminalOsLogin);
|
||||||
|
}
|
||||||
|
let now_ms = get_time();
|
||||||
|
let first = evaluate_os_credential_policy(FailureScope::TerminalOsLogin, now_ms);
|
||||||
|
let second = evaluate_os_credential_policy(FailureScope::TerminalOsLogin, now_ms + 1_000);
|
||||||
|
assert!(!first.allowed);
|
||||||
|
assert!(!second.allowed);
|
||||||
|
assert!(first.audit.is_some());
|
||||||
|
assert!(second.audit.is_some());
|
||||||
|
|
||||||
|
clear_os_credential_failure_state(FailureScope::TerminalOsLogin);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,11 @@
|
|||||||
|
use crate::{
|
||||||
|
ipc::{self, new_listener, Connection, Data, DataPortableService, IPC_TOKEN_LEN},
|
||||||
|
platform::{
|
||||||
|
set_path_permission, set_path_permission_for_portable_service_shmem_dir,
|
||||||
|
set_path_permission_for_portable_service_shmem_file,
|
||||||
|
validate_path_for_portable_service_shmem_dir,
|
||||||
|
},
|
||||||
|
};
|
||||||
use core::slice;
|
use core::slice;
|
||||||
use hbb_common::{
|
use hbb_common::{
|
||||||
allow_err,
|
allow_err,
|
||||||
@@ -15,26 +23,26 @@ use shared_memory::*;
|
|||||||
use std::{
|
use std::{
|
||||||
mem::size_of,
|
mem::size_of,
|
||||||
ops::{Deref, DerefMut},
|
ops::{Deref, DerefMut},
|
||||||
path::Path,
|
path::{Path, PathBuf},
|
||||||
sync::{Arc, Mutex},
|
sync::{
|
||||||
|
atomic::{AtomicBool, AtomicU64, Ordering},
|
||||||
|
Arc, Mutex,
|
||||||
|
},
|
||||||
time::Duration,
|
time::Duration,
|
||||||
};
|
};
|
||||||
use winapi::{
|
use winapi::{
|
||||||
shared::minwindef::{BOOL, FALSE, TRUE},
|
shared::minwindef::{BOOL, FALSE, TRUE},
|
||||||
um::winuser::{self, CURSORINFO, PCURSORINFO},
|
um::winuser::{self, CURSORINFO, PCURSORINFO},
|
||||||
};
|
};
|
||||||
|
use windows::Win32::Storage::FileSystem::{FILE_GENERIC_EXECUTE, FILE_GENERIC_READ};
|
||||||
use crate::{
|
|
||||||
ipc::{self, new_listener, Connection, Data, DataPortableService},
|
|
||||||
platform::set_path_permission,
|
|
||||||
};
|
|
||||||
|
|
||||||
use super::video_qos;
|
use super::video_qos;
|
||||||
|
|
||||||
const SIZE_COUNTER: usize = size_of::<i32>() * 2;
|
const SIZE_COUNTER: usize = size_of::<i32>() * 2;
|
||||||
const FRAME_ALIGN: usize = 64;
|
const FRAME_ALIGN: usize = 64;
|
||||||
|
|
||||||
const ADDR_CURSOR_PARA: usize = 0;
|
const ADDR_IPC_TOKEN: usize = 0;
|
||||||
|
const ADDR_CURSOR_PARA: usize = ADDR_IPC_TOKEN + IPC_TOKEN_LEN;
|
||||||
const ADDR_CURSOR_COUNTER: usize = ADDR_CURSOR_PARA + size_of::<CURSORINFO>();
|
const ADDR_CURSOR_COUNTER: usize = ADDR_CURSOR_PARA + size_of::<CURSORINFO>();
|
||||||
|
|
||||||
const ADDR_CAPTURER_PARA: usize = ADDR_CURSOR_COUNTER + SIZE_COUNTER;
|
const ADDR_CAPTURER_PARA: usize = ADDR_CURSOR_COUNTER + SIZE_COUNTER;
|
||||||
@@ -44,12 +52,186 @@ const ADDR_CAPTURE_FRAME_COUNTER: usize = ADDR_CAPTURE_WOULDBLOCK + size_of::<i3
|
|||||||
|
|
||||||
const ADDR_CAPTURE_FRAME: usize =
|
const ADDR_CAPTURE_FRAME: usize =
|
||||||
(ADDR_CAPTURE_FRAME_COUNTER + SIZE_COUNTER + FRAME_ALIGN - 1) / FRAME_ALIGN * FRAME_ALIGN;
|
(ADDR_CAPTURE_FRAME_COUNTER + SIZE_COUNTER + FRAME_ALIGN - 1) / FRAME_ALIGN * FRAME_ALIGN;
|
||||||
|
const MIN_RUNTIME_SHMEM_LEN: usize = ADDR_CAPTURE_FRAME + FRAME_ALIGN;
|
||||||
|
|
||||||
const IPC_SUFFIX: &str = "_portable_service";
|
const IPC_SUFFIX: &str = "_portable_service";
|
||||||
pub const SHMEM_NAME: &str = "_portable_service";
|
pub const SHMEM_NAME: &str = "_portable_service";
|
||||||
|
pub const SHMEM_ARG_PREFIX: &str = "--portable-service-shmem-name=";
|
||||||
|
const SHMEM_PARENT_DIR: &str = "portable_service_shmem";
|
||||||
|
const SHMEM_NAME_MAX_LEN: usize = 64;
|
||||||
const MAX_NACK: usize = 3;
|
const MAX_NACK: usize = 3;
|
||||||
|
const PORTABLE_SERVICE_STARTUP_TIMEOUT: Duration = Duration::from_secs(15);
|
||||||
const MAX_DXGI_FAIL_TIME: usize = 5;
|
const MAX_DXGI_FAIL_TIME: usize = 5;
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn is_valid_portable_service_shmem_name(name: &str) -> bool {
|
||||||
|
!name.is_empty()
|
||||||
|
&& name.len() <= SHMEM_NAME_MAX_LEN
|
||||||
|
&& name
|
||||||
|
.bytes()
|
||||||
|
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-')
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
pub fn portable_service_shmem_arg(name: &str) -> String {
|
||||||
|
format!("{SHMEM_ARG_PREFIX}{name}")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn is_valid_portable_service_ipc_token(token: &str) -> bool {
|
||||||
|
token.len() == IPC_TOKEN_LEN
|
||||||
|
&& token
|
||||||
|
.bytes()
|
||||||
|
.all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn read_ipc_token_from_shmem(shmem: &SharedMemory) -> Option<String> {
|
||||||
|
if shmem.len() < ADDR_IPC_TOKEN + IPC_TOKEN_LEN {
|
||||||
|
log::error!(
|
||||||
|
"Portable service shared memory too small: len={}, need>={}",
|
||||||
|
shmem.len(),
|
||||||
|
ADDR_IPC_TOKEN + IPC_TOKEN_LEN
|
||||||
|
);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
unsafe {
|
||||||
|
let ptr = shmem.as_ptr().add(ADDR_IPC_TOKEN);
|
||||||
|
let bytes = slice::from_raw_parts(ptr, IPC_TOKEN_LEN);
|
||||||
|
let end = bytes
|
||||||
|
.iter()
|
||||||
|
.position(|byte| *byte == 0)
|
||||||
|
.unwrap_or(IPC_TOKEN_LEN);
|
||||||
|
if end == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let token = std::str::from_utf8(&bytes[..end]).ok()?.to_owned();
|
||||||
|
if is_valid_portable_service_ipc_token(&token) {
|
||||||
|
Some(token)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn validate_runtime_shmem_layout(shmem: &SharedMemory) -> ResultType<()> {
|
||||||
|
if shmem.len() < MIN_RUNTIME_SHMEM_LEN {
|
||||||
|
bail!(
|
||||||
|
"Portable service shared memory too small for runtime layout: len={}, need>={}",
|
||||||
|
shmem.len(),
|
||||||
|
MIN_RUNTIME_SHMEM_LEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn is_valid_capture_frame_length(shmem_len: usize, frame_len: usize) -> bool {
|
||||||
|
let frame_capacity = shmem_len.saturating_sub(ADDR_CAPTURE_FRAME);
|
||||||
|
frame_len > 0 && frame_len <= frame_capacity
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn shared_memory_flink_path_by_name(name: &str) -> ResultType<PathBuf> {
|
||||||
|
let mut dir = crate::platform::user_accessible_folder()?;
|
||||||
|
dir = dir.join(hbb_common::config::APP_NAME.read().unwrap().clone());
|
||||||
|
dir = dir.join(SHMEM_PARENT_DIR);
|
||||||
|
Ok(dir.join(format!("shared_memory{}", name)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn remove_shared_memory_flink_once(name: &str, log_on_error: bool, log_context: &str) -> bool {
|
||||||
|
let flink = match shared_memory_flink_path_by_name(name) {
|
||||||
|
Ok(path) => path,
|
||||||
|
Err(err) => {
|
||||||
|
if log_on_error {
|
||||||
|
log::warn!(
|
||||||
|
"{} failed to resolve portable service shared-memory flink path for '{}': {}",
|
||||||
|
log_context,
|
||||||
|
name,
|
||||||
|
err
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match std::fs::remove_file(&flink) {
|
||||||
|
Ok(()) => {
|
||||||
|
log::info!(
|
||||||
|
"{} removed portable service shared-memory flink artifact: {:?}",
|
||||||
|
log_context,
|
||||||
|
flink
|
||||||
|
);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
Err(err) if err.kind() == std::io::ErrorKind::NotFound => true,
|
||||||
|
Err(err) => {
|
||||||
|
if log_on_error {
|
||||||
|
log::warn!(
|
||||||
|
"{} failed to remove portable service shared-memory flink artifact {:?}: {}",
|
||||||
|
log_context,
|
||||||
|
flink,
|
||||||
|
err
|
||||||
|
);
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn write_ipc_token_to_shmem(shmem: &SharedMemory, token: &str) -> ResultType<()> {
|
||||||
|
if !is_valid_portable_service_ipc_token(token) {
|
||||||
|
bail!("Invalid portable service ipc token");
|
||||||
|
}
|
||||||
|
shmem.write(ADDR_IPC_TOKEN, token.as_bytes());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn clear_ipc_token_in_shmem(shmem: &SharedMemory) {
|
||||||
|
shmem.write(ADDR_IPC_TOKEN, &[0u8; IPC_TOKEN_LEN]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn portable_service_arg_value_candidate_from_arg<'a>(
|
||||||
|
arg: &'a str,
|
||||||
|
prefix: &str,
|
||||||
|
) -> Option<&'a str> {
|
||||||
|
let mut value = arg.strip_prefix(prefix)?;
|
||||||
|
value = value.trim_start();
|
||||||
|
value = value
|
||||||
|
.strip_prefix('"')
|
||||||
|
.or_else(|| value.strip_prefix('\''))
|
||||||
|
.unwrap_or(value);
|
||||||
|
value = value.split_whitespace().next().unwrap_or_default();
|
||||||
|
value = value.trim_matches(|c| c == '"' || c == '\'');
|
||||||
|
Some(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
pub fn portable_service_shmem_name_from_args() -> Option<String> {
|
||||||
|
for arg in std::env::args() {
|
||||||
|
if let Some(value) = portable_service_arg_value_candidate_from_arg(&arg, SHMEM_ARG_PREFIX) {
|
||||||
|
if is_valid_portable_service_shmem_name(value) {
|
||||||
|
return Some(value.to_owned());
|
||||||
|
}
|
||||||
|
log::error!(
|
||||||
|
"Invalid portable service shared memory name argument: '{}'",
|
||||||
|
value
|
||||||
|
);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
pub fn has_portable_service_shmem_arg() -> bool {
|
||||||
|
std::env::args().any(|arg| arg.starts_with(SHMEM_ARG_PREFIX))
|
||||||
|
}
|
||||||
|
|
||||||
pub struct SharedMemory {
|
pub struct SharedMemory {
|
||||||
inner: Shmem,
|
inner: Shmem,
|
||||||
}
|
}
|
||||||
@@ -92,7 +274,27 @@ impl SharedMemory {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
log::info!("Create shared memory, size: {}, flink: {}", size, flink);
|
log::info!("Create shared memory, size: {}, flink: {}", size, flink);
|
||||||
set_path_permission(Path::new(&flink), "F").ok();
|
if let Err(err) = set_path_permission_for_portable_service_shmem_file(Path::new(&flink)) {
|
||||||
|
// Release shmem handle first so best-effort flink cleanup has a chance to succeed.
|
||||||
|
drop(shmem);
|
||||||
|
match std::fs::remove_file(&flink) {
|
||||||
|
Ok(()) => {
|
||||||
|
log::info!(
|
||||||
|
"Create cleanup removed portable service shared-memory flink artifact: {}",
|
||||||
|
flink
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(remove_err) if remove_err.kind() == std::io::ErrorKind::NotFound => {}
|
||||||
|
Err(remove_err) => {
|
||||||
|
log::warn!(
|
||||||
|
"Create cleanup failed to remove portable service shared-memory flink artifact {}: {}",
|
||||||
|
flink,
|
||||||
|
remove_err
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Err(err);
|
||||||
|
}
|
||||||
Ok(SharedMemory { inner: shmem })
|
Ok(SharedMemory { inner: shmem })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,9 +322,18 @@ impl SharedMemory {
|
|||||||
fn flink(name: String) -> ResultType<String> {
|
fn flink(name: String) -> ResultType<String> {
|
||||||
let mut dir = crate::platform::user_accessible_folder()?;
|
let mut dir = crate::platform::user_accessible_folder()?;
|
||||||
dir = dir.join(hbb_common::config::APP_NAME.read().unwrap().clone());
|
dir = dir.join(hbb_common::config::APP_NAME.read().unwrap().clone());
|
||||||
if !dir.exists() {
|
dir = dir.join(SHMEM_PARENT_DIR);
|
||||||
std::fs::create_dir(&dir)?;
|
let parent_created = !dir.exists();
|
||||||
set_path_permission(&dir, "F").ok();
|
if parent_created {
|
||||||
|
std::fs::create_dir_all(&dir)?;
|
||||||
|
}
|
||||||
|
if parent_created || crate::platform::is_root() {
|
||||||
|
// Harden parent ACL on first provisioning and periodically on SYSTEM path.
|
||||||
|
set_path_permission_for_portable_service_shmem_dir(&dir)?;
|
||||||
|
} else {
|
||||||
|
// Existing parents still need type/reparse validation. Non-SYSTEM callers may lack
|
||||||
|
// WRITE_DAC on a valid parent, so avoid rebuilding the ACL here.
|
||||||
|
validate_path_for_portable_service_shmem_dir(&dir)?;
|
||||||
}
|
}
|
||||||
Ok(dir
|
Ok(dir
|
||||||
.join(format!("shared_memory{}", name))
|
.join(format!("shared_memory{}", name))
|
||||||
@@ -232,16 +443,45 @@ pub mod server {
|
|||||||
|
|
||||||
lazy_static::lazy_static! {
|
lazy_static::lazy_static! {
|
||||||
static ref EXIT: Arc<Mutex<bool>> = Default::default();
|
static ref EXIT: Arc<Mutex<bool>> = Default::default();
|
||||||
|
static ref FORCE_EXIT_ARMED: AtomicBool = AtomicBool::new(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn run_portable_service() {
|
pub fn run_portable_service() {
|
||||||
let shmem = match SharedMemory::open_existing(SHMEM_NAME) {
|
let shmem_name = match portable_service_shmem_name_from_args() {
|
||||||
|
Some(name) => name,
|
||||||
|
None => {
|
||||||
|
if has_portable_service_shmem_arg() {
|
||||||
|
log::error!(
|
||||||
|
"Invalid portable service shared memory argument, aborting startup"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
log::error!(
|
||||||
|
"Missing portable service shared memory argument, aborting startup"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let shmem = match SharedMemory::open_existing(&shmem_name) {
|
||||||
Ok(shmem) => Arc::new(shmem),
|
Ok(shmem) => Arc::new(shmem),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("Failed to open existing shared memory: {:?}", e);
|
log::error!("Failed to open existing shared memory: {:?}", e);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
if let Err(e) = validate_runtime_shmem_layout(shmem.as_ref()) {
|
||||||
|
log::error!("{}", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let ipc_token = match read_ipc_token_from_shmem(shmem.as_ref()) {
|
||||||
|
Some(token) => token,
|
||||||
|
None => {
|
||||||
|
log::error!(
|
||||||
|
"Missing portable service ipc token in shared memory, aborting startup"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
let shmem1 = shmem.clone();
|
let shmem1 = shmem.clone();
|
||||||
let shmem2 = shmem.clone();
|
let shmem2 = shmem.clone();
|
||||||
let mut threads = vec![];
|
let mut threads = vec![];
|
||||||
@@ -251,17 +491,24 @@ pub mod server {
|
|||||||
threads.push(std::thread::spawn(|| {
|
threads.push(std::thread::spawn(|| {
|
||||||
run_capture(shmem2);
|
run_capture(shmem2);
|
||||||
}));
|
}));
|
||||||
threads.push(std::thread::spawn(|| {
|
threads.push(std::thread::spawn(move || {
|
||||||
run_ipc_client();
|
run_ipc_client(ipc_token);
|
||||||
}));
|
}));
|
||||||
threads.push(std::thread::spawn(|| {
|
// Detached shutdown watchdog:
|
||||||
|
// - gives graceful shutdown/cleanup a short window
|
||||||
|
// - force-exits the process if workers are still stuck
|
||||||
|
std::thread::spawn(|| {
|
||||||
run_exit_check();
|
run_exit_check();
|
||||||
}));
|
});
|
||||||
let record_pos_handle = crate::input_service::try_start_record_cursor_pos();
|
let record_pos_handle = crate::input_service::try_start_record_cursor_pos();
|
||||||
|
// Arm forced-exit watchdog only for worker join phase.
|
||||||
|
// Once join phase completes, cleanup should not be interrupted by forced exit.
|
||||||
|
FORCE_EXIT_ARMED.store(true, Ordering::SeqCst);
|
||||||
for th in threads.drain(..) {
|
for th in threads.drain(..) {
|
||||||
th.join().ok();
|
th.join().ok();
|
||||||
log::info!("thread joined");
|
log::info!("thread joined");
|
||||||
}
|
}
|
||||||
|
FORCE_EXIT_ARMED.store(false, Ordering::SeqCst);
|
||||||
|
|
||||||
crate::input_service::try_stop_record_cursor_pos();
|
crate::input_service::try_stop_record_cursor_pos();
|
||||||
if let Some(handle) = record_pos_handle {
|
if let Some(handle) = record_pos_handle {
|
||||||
@@ -270,16 +517,47 @@ pub mod server {
|
|||||||
Err(e) => log::error!("record_pos_handle join error {:?}", &e),
|
Err(e) => log::error!("record_pos_handle join error {:?}", &e),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
drop(shmem);
|
||||||
|
remove_shared_memory_flink_with_retry(&shmem_name);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn run_exit_check() {
|
fn run_exit_check() {
|
||||||
|
const FORCED_EXIT_DELAY: Duration = Duration::from_secs(3);
|
||||||
loop {
|
loop {
|
||||||
if EXIT.lock().unwrap().clone() {
|
if EXIT.lock().unwrap().clone() {
|
||||||
std::thread::sleep(Duration::from_millis(50));
|
break;
|
||||||
std::process::exit(0);
|
|
||||||
}
|
}
|
||||||
std::thread::sleep(Duration::from_millis(50));
|
std::thread::sleep(Duration::from_millis(50));
|
||||||
}
|
}
|
||||||
|
// Fallback only: normal shutdown path should complete and process should exit naturally.
|
||||||
|
// This forced exit is a last resort when worker threads are stuck and graceful teardown
|
||||||
|
// does not finish in time.
|
||||||
|
std::thread::sleep(FORCED_EXIT_DELAY);
|
||||||
|
if FORCE_EXIT_ARMED.load(Ordering::SeqCst) {
|
||||||
|
log::warn!(
|
||||||
|
"Portable service shutdown watchdog fallback triggered: forcing process exit after {:?}",
|
||||||
|
FORCED_EXIT_DELAY
|
||||||
|
);
|
||||||
|
std::process::exit(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remove_shared_memory_flink_with_retry(name: &str) {
|
||||||
|
const MAX_RETRY: usize = 20;
|
||||||
|
const RETRY_INTERVAL: Duration = Duration::from_millis(200);
|
||||||
|
for attempt in 0..MAX_RETRY {
|
||||||
|
let is_last_attempt = attempt + 1 == MAX_RETRY;
|
||||||
|
if remove_shared_memory_flink_once(name, is_last_attempt, "SYSTEM cleanup") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if !is_last_attempt {
|
||||||
|
std::thread::sleep(RETRY_INTERVAL);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log::warn!(
|
||||||
|
"SYSTEM cleanup failed to remove portable service shared-memory flink artifact '{}' after retry",
|
||||||
|
name
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn run_get_cursor_info(shmem: Arc<SharedMemory>) {
|
fn run_get_cursor_info(shmem: Arc<SharedMemory>) {
|
||||||
@@ -386,6 +664,17 @@ pub mod server {
|
|||||||
match c.as_mut().map(|f| f.frame(spf)) {
|
match c.as_mut().map(|f| f.frame(spf)) {
|
||||||
Some(Ok(f)) => match f {
|
Some(Ok(f)) => match f {
|
||||||
Frame::PixelBuffer(f) => {
|
Frame::PixelBuffer(f) => {
|
||||||
|
let frame_capacity = shmem.len().saturating_sub(ADDR_CAPTURE_FRAME);
|
||||||
|
if f.data().len() > frame_capacity {
|
||||||
|
log::error!(
|
||||||
|
"Portable service capture frame exceeds shared memory capacity: frame_len={}, capacity={}, shmem_len={}",
|
||||||
|
f.data().len(),
|
||||||
|
frame_capacity,
|
||||||
|
shmem.len()
|
||||||
|
);
|
||||||
|
*EXIT.lock().unwrap() = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
utils::set_frame_info(
|
utils::set_frame_info(
|
||||||
&shmem,
|
&shmem,
|
||||||
FrameInfo {
|
FrameInfo {
|
||||||
@@ -436,17 +725,33 @@ pub mod server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::main(flavor = "current_thread")]
|
#[tokio::main(flavor = "current_thread")]
|
||||||
async fn run_ipc_client() {
|
async fn run_ipc_client(ipc_token: String) {
|
||||||
use DataPortableService::*;
|
use DataPortableService::*;
|
||||||
|
|
||||||
let postfix = IPC_SUFFIX;
|
let postfix = IPC_SUFFIX;
|
||||||
|
|
||||||
match ipc::connect(1000, postfix).await {
|
match ipc::connect(1000, postfix).await {
|
||||||
Ok(mut stream) => {
|
Ok(mut stream) => {
|
||||||
|
if let Err(err) =
|
||||||
|
ipc::portable_service_ipc_handshake_as_client(&mut stream, &ipc_token).await
|
||||||
|
{
|
||||||
|
log::error!("portable service ipc handshake failed: {}", err);
|
||||||
|
*EXIT.lock().unwrap() = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
let mut timer =
|
let mut timer =
|
||||||
crate::rustdesk_interval(tokio::time::interval(Duration::from_secs(1)));
|
crate::rustdesk_interval(tokio::time::interval(Duration::from_secs(1)));
|
||||||
let mut nack = 0;
|
let mut nack = 0;
|
||||||
loop {
|
loop {
|
||||||
|
if *EXIT.lock().unwrap() {
|
||||||
|
log::info!("Portable service EXIT signaled, closing ipc client loop");
|
||||||
|
stream
|
||||||
|
.send(&Data::DataPortableService(WillClose))
|
||||||
|
.await
|
||||||
|
.ok();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
res = stream.next() => {
|
res = stream.next() => {
|
||||||
match res {
|
match res {
|
||||||
@@ -526,7 +831,11 @@ pub mod client {
|
|||||||
|
|
||||||
lazy_static::lazy_static! {
|
lazy_static::lazy_static! {
|
||||||
static ref RUNNING: Arc<Mutex<bool>> = Default::default();
|
static ref RUNNING: Arc<Mutex<bool>> = Default::default();
|
||||||
|
static ref STARTING: Arc<Mutex<bool>> = Default::default();
|
||||||
|
static ref STARTING_TOKEN: AtomicU64 = AtomicU64::new(0);
|
||||||
static ref SHMEM: Arc<Mutex<Option<SharedMemory>>> = Default::default();
|
static ref SHMEM: Arc<Mutex<Option<SharedMemory>>> = Default::default();
|
||||||
|
static ref SHMEM_RUNTIME_NAME: Arc<Mutex<Option<String>>> = Default::default();
|
||||||
|
static ref IPC_RUNTIME_TOKEN: Arc<Mutex<Option<String>>> = Default::default();
|
||||||
static ref SENDER : Mutex<mpsc::UnboundedSender<ipc::Data>> = Mutex::new(client::start_ipc_server());
|
static ref SENDER : Mutex<mpsc::UnboundedSender<ipc::Data>> = Mutex::new(client::start_ipc_server());
|
||||||
static ref QUICK_SUPPORT: Arc<Mutex<bool>> = Default::default();
|
static ref QUICK_SUPPORT: Arc<Mutex<bool>> = Default::default();
|
||||||
}
|
}
|
||||||
@@ -536,12 +845,176 @@ pub mod client {
|
|||||||
Logon(String, String),
|
Logon(String, String),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn has_running_portable_service_process() -> bool {
|
||||||
|
let app_exe = format!("{}.exe", crate::get_app_name().to_lowercase());
|
||||||
|
!crate::platform::get_pids_of_process_with_first_arg(&app_exe, "--portable-service")
|
||||||
|
.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn next_portable_service_shmem_name() -> String {
|
||||||
|
format!(
|
||||||
|
"{}_{}_{:08x}",
|
||||||
|
crate::portable_service::SHMEM_NAME,
|
||||||
|
std::process::id(),
|
||||||
|
hbb_common::rand::random::<u32>()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn set_runtime_ipc_token(token: String) {
|
||||||
|
*IPC_RUNTIME_TOKEN.lock().unwrap() = Some(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn schedule_remove_runtime_shmem_flink_retry(name: String) {
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
const MAX_RETRY: usize = 20;
|
||||||
|
const RETRY_INTERVAL: Duration = Duration::from_millis(200);
|
||||||
|
for _ in 0..MAX_RETRY {
|
||||||
|
std::thread::sleep(RETRY_INTERVAL);
|
||||||
|
if remove_shared_memory_flink_once(&name, false, "Client cleanup") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log::warn!(
|
||||||
|
"Failed to remove portable service shared-memory flink artifact '{}' after retry",
|
||||||
|
name
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn clear_runtime_shmem_state() {
|
||||||
|
let mut runtime_token = IPC_RUNTIME_TOKEN.lock().unwrap();
|
||||||
|
let mut shmem_lock = SHMEM.lock().unwrap();
|
||||||
|
if let Some(shmem) = shmem_lock.as_mut() {
|
||||||
|
clear_ipc_token_in_shmem(shmem);
|
||||||
|
}
|
||||||
|
*shmem_lock = None;
|
||||||
|
let runtime_name = SHMEM_RUNTIME_NAME.lock().unwrap().take();
|
||||||
|
*runtime_token = None;
|
||||||
|
drop(runtime_token);
|
||||||
|
drop(shmem_lock);
|
||||||
|
if let Some(name) = runtime_name.as_deref() {
|
||||||
|
if !remove_shared_memory_flink_once(name, true, "Client cleanup") {
|
||||||
|
schedule_remove_runtime_shmem_flink_retry(name.to_owned());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn consume_runtime_ipc_token_if_match(candidate: &str) -> (bool, Option<String>) {
|
||||||
|
let mut token = IPC_RUNTIME_TOKEN.lock().unwrap();
|
||||||
|
if !token
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|expected| ipc::constant_time_ipc_token_eq(expected, candidate))
|
||||||
|
{
|
||||||
|
return (false, None);
|
||||||
|
}
|
||||||
|
let mut shmem_lock = SHMEM.lock().unwrap();
|
||||||
|
let matched_shmem_name = SHMEM_RUNTIME_NAME.lock().unwrap().clone();
|
||||||
|
*token = None;
|
||||||
|
if let Some(shmem) = shmem_lock.as_mut() {
|
||||||
|
clear_ipc_token_in_shmem(shmem);
|
||||||
|
}
|
||||||
|
(true, matched_shmem_name)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn restore_runtime_ipc_token_after_failed_handshake(
|
||||||
|
token: &str,
|
||||||
|
expected_shmem_name: Option<&str>,
|
||||||
|
) {
|
||||||
|
let mut runtime_token = IPC_RUNTIME_TOKEN.lock().unwrap();
|
||||||
|
if let Some(current) = runtime_token.as_deref() {
|
||||||
|
if current != token {
|
||||||
|
log::debug!(
|
||||||
|
"Skip restoring portable service ipc token after handshake failure: runtime token has changed to a newer value"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut shmem_lock = SHMEM.lock().unwrap();
|
||||||
|
let current_shmem_name = SHMEM_RUNTIME_NAME.lock().unwrap().clone();
|
||||||
|
if current_shmem_name.as_deref() != expected_shmem_name {
|
||||||
|
if runtime_token.as_deref() == Some(token) {
|
||||||
|
*runtime_token = None;
|
||||||
|
}
|
||||||
|
log::debug!(
|
||||||
|
"Skip restoring portable service ipc token after handshake failure: shared-memory instance has changed"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let shmem_write_error = if let Some(shmem) = shmem_lock.as_mut() {
|
||||||
|
write_ipc_token_to_shmem(shmem, token)
|
||||||
|
.err()
|
||||||
|
.map(|err| err.to_string())
|
||||||
|
} else {
|
||||||
|
Some("shared memory unavailable".to_owned())
|
||||||
|
};
|
||||||
|
if let Some(err) = shmem_write_error {
|
||||||
|
if runtime_token.as_deref() == Some(token) {
|
||||||
|
*runtime_token = None;
|
||||||
|
}
|
||||||
|
log::warn!(
|
||||||
|
"Failed to restore portable service ipc token after handshake failure: {}",
|
||||||
|
err
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
*runtime_token = Some(token.to_owned());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn schedule_starting_timeout_reset(launch_token: u64) {
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
std::thread::sleep(PORTABLE_SERVICE_STARTUP_TIMEOUT);
|
||||||
|
let should_reset = {
|
||||||
|
// Guard against stale watchdogs from previous launches:
|
||||||
|
// only the watchdog that matches the latest STARTING_TOKEN may reset STARTING.
|
||||||
|
let current_token = STARTING_TOKEN.load(Ordering::SeqCst);
|
||||||
|
// Keep lock guards in explicit short scopes to make it obvious
|
||||||
|
// there is no nested lock ordering (and to avoid Copilot false positives).
|
||||||
|
let starting = { *STARTING.lock().unwrap() };
|
||||||
|
let running = { *RUNNING.lock().unwrap() };
|
||||||
|
current_token == launch_token && starting && !running
|
||||||
|
};
|
||||||
|
if should_reset {
|
||||||
|
log::warn!(
|
||||||
|
"Portable service startup timeout before IPC ready, reset STARTING state"
|
||||||
|
);
|
||||||
|
*STARTING.lock().unwrap() = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Launch flow summary:
|
||||||
|
// 1) Prepare/reset runtime shared memory + IPC token.
|
||||||
|
// 2) Start helper process (direct or logon) with shmem argument.
|
||||||
|
// 3) Keep STARTING=true until IPC ping/pong marks RUNNING, or timeout watchdog resets it.
|
||||||
pub(crate) fn start_portable_service(para: StartPara) -> ResultType<()> {
|
pub(crate) fn start_portable_service(para: StartPara) -> ResultType<()> {
|
||||||
log::info!("start portable service");
|
log::info!("start portable service");
|
||||||
if RUNNING.lock().unwrap().clone() {
|
let launch_token = {
|
||||||
bail!("already running");
|
// Keep lock guards in explicit short scopes to make it obvious
|
||||||
}
|
// there is no nested lock ordering (and to avoid Copilot false positives).
|
||||||
if SHMEM.lock().unwrap().is_none() {
|
let running = { *RUNNING.lock().unwrap() };
|
||||||
|
let mut starting = STARTING.lock().unwrap();
|
||||||
|
if *starting && !running && !has_running_portable_service_process() {
|
||||||
|
log::warn!(
|
||||||
|
"Detected stale portable service STARTING state without running process, reset it"
|
||||||
|
);
|
||||||
|
*starting = false;
|
||||||
|
}
|
||||||
|
if *starting || running {
|
||||||
|
bail!("already running");
|
||||||
|
}
|
||||||
|
*starting = true;
|
||||||
|
STARTING_TOKEN.fetch_add(1, Ordering::SeqCst) + 1
|
||||||
|
};
|
||||||
|
let start_result = (|| -> ResultType<()> {
|
||||||
|
clear_runtime_shmem_state();
|
||||||
|
let mut shmem_lock = SHMEM.lock().unwrap();
|
||||||
let displays = scrap::Display::all()?;
|
let displays = scrap::Display::all()?;
|
||||||
if displays.is_empty() {
|
if displays.is_empty() {
|
||||||
bail!("no display available!");
|
bail!("no display available!");
|
||||||
@@ -558,84 +1031,153 @@ pub mod client {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let shmem_size = utils::align(ADDR_CAPTURE_FRAME + max_pixel * 4, align);
|
let shmem_size =
|
||||||
|
utils::align(ADDR_CAPTURE_FRAME + max_pixel * 4, align).max(MIN_RUNTIME_SHMEM_LEN);
|
||||||
|
let shmem_name = next_portable_service_shmem_name();
|
||||||
|
if !is_valid_portable_service_shmem_name(&shmem_name) {
|
||||||
|
bail!("Generated invalid portable service shared memory name");
|
||||||
|
}
|
||||||
|
let ipc_token = ipc::generate_one_time_ipc_token()?;
|
||||||
// os error 112, no enough space
|
// os error 112, no enough space
|
||||||
*SHMEM.lock().unwrap() = Some(crate::portable_service::SharedMemory::create(
|
*shmem_lock = Some(crate::portable_service::SharedMemory::create(
|
||||||
crate::portable_service::SHMEM_NAME,
|
&shmem_name,
|
||||||
shmem_size,
|
shmem_size,
|
||||||
)?);
|
)?);
|
||||||
|
*SHMEM_RUNTIME_NAME.lock().unwrap() = Some(shmem_name);
|
||||||
shutdown_hooks::add_shutdown_hook(drop_portable_service_shared_memory);
|
shutdown_hooks::add_shutdown_hook(drop_portable_service_shared_memory);
|
||||||
}
|
let shmem_name = SHMEM_RUNTIME_NAME
|
||||||
if let Some(shmem) = SHMEM.lock().unwrap().as_mut() {
|
.lock()
|
||||||
unsafe {
|
.unwrap()
|
||||||
libc::memset(shmem.as_ptr() as _, 0, shmem.len() as _);
|
.clone()
|
||||||
}
|
.ok_or_else(|| anyhow!("portable service shared memory name is unavailable"))?;
|
||||||
}
|
let init_token_result = if let Some(shmem) = shmem_lock.as_mut() {
|
||||||
match para {
|
unsafe {
|
||||||
StartPara::Direct => {
|
libc::memset(shmem.as_ptr() as _, 0, shmem.len() as _);
|
||||||
if let Err(e) = crate::platform::run_background(
|
|
||||||
&std::env::current_exe()?.to_string_lossy().to_string(),
|
|
||||||
"--portable-service",
|
|
||||||
) {
|
|
||||||
*SHMEM.lock().unwrap() = None;
|
|
||||||
bail!("Failed to run portable service process: {}", e);
|
|
||||||
}
|
}
|
||||||
|
write_ipc_token_to_shmem(shmem, &ipc_token)
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
};
|
||||||
|
if let Err(e) = init_token_result {
|
||||||
|
drop(shmem_lock);
|
||||||
|
clear_runtime_shmem_state();
|
||||||
|
bail!(
|
||||||
|
"Failed to initialize portable service ipc token in shared memory: {}",
|
||||||
|
e
|
||||||
|
);
|
||||||
|
};
|
||||||
|
drop(shmem_lock);
|
||||||
|
set_runtime_ipc_token(ipc_token.clone());
|
||||||
|
let portable_service_arg = format!(
|
||||||
|
"--portable-service {}",
|
||||||
|
crate::portable_service::portable_service_shmem_arg(&shmem_name)
|
||||||
|
);
|
||||||
|
{
|
||||||
|
let _sender = SENDER.lock().unwrap();
|
||||||
}
|
}
|
||||||
StartPara::Logon(username, password) => {
|
match para {
|
||||||
#[allow(unused_mut)]
|
StartPara::Direct => {
|
||||||
let mut exe = std::env::current_exe()?.to_string_lossy().to_string();
|
match crate::platform::run_background(
|
||||||
#[cfg(feature = "flutter")]
|
&std::env::current_exe()?.to_string_lossy().to_string(),
|
||||||
{
|
&portable_service_arg,
|
||||||
if let Some(dir) = Path::new(&exe).parent() {
|
) {
|
||||||
if set_path_permission(Path::new(dir), "RX").is_err() {
|
Ok(true) => {}
|
||||||
*SHMEM.lock().unwrap() = None;
|
Ok(false) => {
|
||||||
bail!("Failed to set permission of {:?}", dir);
|
clear_runtime_shmem_state();
|
||||||
|
bail!("Failed to run portable service process");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
clear_runtime_shmem_state();
|
||||||
|
bail!("Failed to run portable service process: {}", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#[cfg(not(feature = "flutter"))]
|
StartPara::Logon(username, password) => {
|
||||||
match hbb_common::directories_next::UserDirs::new() {
|
#[allow(unused_mut)]
|
||||||
Some(user_dir) => {
|
let mut exe = std::env::current_exe()?.to_string_lossy().to_string();
|
||||||
let dir = user_dir
|
#[cfg(feature = "flutter")]
|
||||||
.home_dir()
|
{
|
||||||
.join("AppData")
|
if let Some(dir) = Path::new(&exe).parent() {
|
||||||
.join("Local")
|
if let Err(err) = set_path_permission(
|
||||||
.join("rustdesk-sciter");
|
Path::new(dir),
|
||||||
if std::fs::create_dir_all(&dir).is_ok() {
|
FILE_GENERIC_READ.0 | FILE_GENERIC_EXECUTE.0,
|
||||||
let dst = dir.join("rustdesk.exe");
|
) {
|
||||||
if std::fs::copy(&exe, &dst).is_ok() {
|
clear_runtime_shmem_state();
|
||||||
if dst.exists() {
|
bail!("Failed to set permission of {:?}: {}", dir, err);
|
||||||
if set_path_permission(&dir, "RX").is_ok() {
|
|
||||||
exe = dst.to_string_lossy().to_string();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
None => {}
|
#[cfg(not(feature = "flutter"))]
|
||||||
}
|
if let Some((dir, dst)) =
|
||||||
if let Err(e) = crate::platform::windows::create_process_with_logon(
|
crate::platform::windows::portable_service_logon_helper_paths()
|
||||||
username.as_str(),
|
{
|
||||||
password.as_str(),
|
let cleanup_helper_artifacts = || {
|
||||||
&exe,
|
if Path::new(&exe) != dst {
|
||||||
"--portable-service",
|
std::fs::remove_file(&dst).ok();
|
||||||
) {
|
}
|
||||||
*SHMEM.lock().unwrap() = None;
|
std::fs::remove_dir(&dir).ok();
|
||||||
bail!("Failed to run portable service process: {}", e);
|
};
|
||||||
|
let mut use_logon_helper_exe = false;
|
||||||
|
if let Err(err) = std::fs::create_dir_all(&dir) {
|
||||||
|
log::warn!(
|
||||||
|
"Failed to create portable service logon helper dir {:?}: {}",
|
||||||
|
dir,
|
||||||
|
err
|
||||||
|
);
|
||||||
|
} else if let Err(err) = std::fs::copy(&exe, &dst) {
|
||||||
|
log::warn!(
|
||||||
|
"Failed to copy portable service logon helper binary from '{}' to {:?}: {}",
|
||||||
|
exe,
|
||||||
|
dst,
|
||||||
|
err
|
||||||
|
);
|
||||||
|
cleanup_helper_artifacts();
|
||||||
|
} else if !dst.exists() {
|
||||||
|
log::warn!(
|
||||||
|
"Portable service logon helper binary missing after copy: {:?}",
|
||||||
|
dst
|
||||||
|
);
|
||||||
|
cleanup_helper_artifacts();
|
||||||
|
} else if let Err(err) =
|
||||||
|
set_path_permission(&dir, FILE_GENERIC_READ.0 | FILE_GENERIC_EXECUTE.0)
|
||||||
|
{
|
||||||
|
log::warn!(
|
||||||
|
"Failed to set portable service logon helper path permission for {:?}: {}",
|
||||||
|
dir,
|
||||||
|
err
|
||||||
|
);
|
||||||
|
cleanup_helper_artifacts();
|
||||||
|
} else {
|
||||||
|
use_logon_helper_exe = true;
|
||||||
|
}
|
||||||
|
if use_logon_helper_exe {
|
||||||
|
exe = dst.to_string_lossy().to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Err(e) = crate::platform::windows::create_process_with_logon(
|
||||||
|
username.as_str(),
|
||||||
|
password.as_str(),
|
||||||
|
&exe,
|
||||||
|
&portable_service_arg,
|
||||||
|
) {
|
||||||
|
clear_runtime_shmem_state();
|
||||||
|
bail!("Failed to run portable service process: {}", e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
schedule_starting_timeout_reset(launch_token);
|
||||||
|
Ok(())
|
||||||
|
})();
|
||||||
|
if start_result.is_err() {
|
||||||
|
*STARTING.lock().unwrap() = false;
|
||||||
}
|
}
|
||||||
let _sender = SENDER.lock().unwrap();
|
start_result
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub extern "C" fn drop_portable_service_shared_memory() {
|
pub extern "C" fn drop_portable_service_shared_memory() {
|
||||||
// https://stackoverflow.com/questions/35980148/why-does-an-atexit-handler-panic-when-it-accesses-stdout
|
// https://stackoverflow.com/questions/35980148/why-does-an-atexit-handler-panic-when-it-accesses-stdout
|
||||||
// Please make sure there is no print in the call stack
|
// Please make sure there is no print in the call stack
|
||||||
let mut lock = SHMEM.lock().unwrap();
|
clear_runtime_shmem_state();
|
||||||
if lock.is_some() {
|
|
||||||
*lock = None;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_quick_support(v: bool) {
|
pub fn set_quick_support(v: bool) {
|
||||||
@@ -655,7 +1197,11 @@ pub mod client {
|
|||||||
let mut option = SHMEM.lock().unwrap();
|
let mut option = SHMEM.lock().unwrap();
|
||||||
if let Some(shmem) = option.as_mut() {
|
if let Some(shmem) = option.as_mut() {
|
||||||
unsafe {
|
unsafe {
|
||||||
libc::memset(shmem.as_ptr() as _, 0, shmem.len() as _);
|
libc::memset(
|
||||||
|
shmem.as_ptr().add(ADDR_CURSOR_PARA) as _,
|
||||||
|
0,
|
||||||
|
shmem.len().saturating_sub(ADDR_CURSOR_PARA) as _,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
utils::set_para(
|
utils::set_para(
|
||||||
shmem,
|
shmem,
|
||||||
@@ -702,6 +1248,19 @@ pub mod client {
|
|||||||
if utils::counter_ready(base.add(ADDR_CAPTURE_FRAME_COUNTER)) {
|
if utils::counter_ready(base.add(ADDR_CAPTURE_FRAME_COUNTER)) {
|
||||||
let frame_info_ptr = shmem.as_ptr().add(ADDR_CAPTURE_FRAME_INFO);
|
let frame_info_ptr = shmem.as_ptr().add(ADDR_CAPTURE_FRAME_INFO);
|
||||||
let frame_info = frame_info_ptr as *const FrameInfo;
|
let frame_info = frame_info_ptr as *const FrameInfo;
|
||||||
|
let frame_len = (*frame_info).length;
|
||||||
|
if !is_valid_capture_frame_length(shmem.len(), frame_len) {
|
||||||
|
log::error!(
|
||||||
|
"Portable service frame length exceeds shared memory capacity: frame_len={}, shmem_len={}, frame_addr={}",
|
||||||
|
frame_len,
|
||||||
|
shmem.len(),
|
||||||
|
ADDR_CAPTURE_FRAME
|
||||||
|
);
|
||||||
|
return Err(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::InvalidData,
|
||||||
|
"invalid portable service frame length".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
if (*frame_info).width != self.width || (*frame_info).height != self.height {
|
if (*frame_info).width != self.width || (*frame_info).height != self.height {
|
||||||
log::info!(
|
log::info!(
|
||||||
"skip frame, ({},{}) != ({},{})",
|
"skip frame, ({},{}) != ({},{})",
|
||||||
@@ -716,7 +1275,7 @@ pub mod client {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
let frame_ptr = base.add(ADDR_CAPTURE_FRAME);
|
let frame_ptr = base.add(ADDR_CAPTURE_FRAME);
|
||||||
let data = slice::from_raw_parts(frame_ptr, (*frame_info).length);
|
let data = slice::from_raw_parts(frame_ptr, frame_len);
|
||||||
Ok(Frame::PixelBuffer(PixelBuffer::with_BGRA(
|
Ok(Frame::PixelBuffer(PixelBuffer::with_BGRA(
|
||||||
data,
|
data,
|
||||||
self.width,
|
self.width,
|
||||||
@@ -778,10 +1337,49 @@ pub mod client {
|
|||||||
Some(result) = incoming.next() => {
|
Some(result) = incoming.next() => {
|
||||||
match result {
|
match result {
|
||||||
Ok(stream) => {
|
Ok(stream) => {
|
||||||
|
let mut stream = Connection::new(stream);
|
||||||
|
if !ipc::authorize_windows_portable_service_ipc_connection(
|
||||||
|
&stream, postfix,
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut consumed_token: Option<String> = None;
|
||||||
|
let mut consumed_token_shmem_name: Option<String> = None;
|
||||||
|
let handshake_result =
|
||||||
|
ipc::portable_service_ipc_handshake_as_server(
|
||||||
|
&mut stream,
|
||||||
|
|token| {
|
||||||
|
let (matched, matched_shmem_name) =
|
||||||
|
consume_runtime_ipc_token_if_match(token);
|
||||||
|
if matched {
|
||||||
|
consumed_token = Some(token.to_owned());
|
||||||
|
consumed_token_shmem_name = matched_shmem_name;
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
if let Err(err) = handshake_result {
|
||||||
|
if let Some(token) = consumed_token.as_deref() {
|
||||||
|
restore_runtime_ipc_token_after_failed_handshake(
|
||||||
|
token,
|
||||||
|
consumed_token_shmem_name.as_deref(),
|
||||||
|
);
|
||||||
|
*STARTING.lock().unwrap() = false;
|
||||||
|
}
|
||||||
|
log::warn!(
|
||||||
|
"Rejected portable service ipc connection due to token handshake failure: postfix={}, err={}",
|
||||||
|
postfix,
|
||||||
|
err
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
log::info!("Got portable service ipc connection");
|
log::info!("Got portable service ipc connection");
|
||||||
let rx_clone = rx.clone();
|
let rx_clone = rx.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let mut stream = Connection::new(stream);
|
let mut stream = stream;
|
||||||
let postfix = postfix.to_owned();
|
let postfix = postfix.to_owned();
|
||||||
let mut timer = crate::rustdesk_interval(tokio::time::interval(Duration::from_secs(1)));
|
let mut timer = crate::rustdesk_interval(tokio::time::interval(Duration::from_secs(1)));
|
||||||
let mut nack = 0;
|
let mut nack = 0;
|
||||||
@@ -805,6 +1403,7 @@ pub mod client {
|
|||||||
Pong => {
|
Pong => {
|
||||||
nack = 0;
|
nack = 0;
|
||||||
*RUNNING.lock().unwrap() = true;
|
*RUNNING.lock().unwrap() = true;
|
||||||
|
*STARTING.lock().unwrap() = false;
|
||||||
},
|
},
|
||||||
ConnCount(None) => {
|
ConnCount(None) => {
|
||||||
if !quick_support {
|
if !quick_support {
|
||||||
@@ -841,6 +1440,7 @@ pub mod client {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
*RUNNING.lock().unwrap() = false;
|
*RUNNING.lock().unwrap() = false;
|
||||||
|
*STARTING.lock().unwrap() = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
@@ -990,3 +1590,23 @@ pub struct FrameInfo {
|
|||||||
width: usize,
|
width: usize,
|
||||||
height: usize,
|
height: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{is_valid_capture_frame_length, ADDR_CAPTURE_FRAME};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_is_valid_capture_frame_length_rejects_zero_length() {
|
||||||
|
assert!(!is_valid_capture_frame_length(ADDR_CAPTURE_FRAME + 1024, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_is_valid_capture_frame_length_rejects_out_of_bounds_length() {
|
||||||
|
assert!(!is_valid_capture_frame_length(ADDR_CAPTURE_FRAME + 16, 17));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_is_valid_capture_frame_length_accepts_in_bounds_length() {
|
||||||
|
assert!(is_valid_capture_frame_length(ADDR_CAPTURE_FRAME + 16, 16));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -318,6 +318,35 @@ pub fn get_default_shell() -> String {
|
|||||||
std::env::var("COMSPEC").unwrap_or_else(|_| "cmd.exe".to_string())
|
std::env::var("COMSPEC").unwrap_or_else(|_| "cmd.exe".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn utf8_shell_args(shell: &str) -> Vec<String> {
|
||||||
|
let name = std::path::Path::new(shell)
|
||||||
|
.file_name()
|
||||||
|
.and_then(|name| name.to_str())
|
||||||
|
.unwrap_or(shell)
|
||||||
|
.to_ascii_lowercase();
|
||||||
|
|
||||||
|
if name == "cmd.exe" || name == "cmd" {
|
||||||
|
return vec!["/K".to_string(), "chcp 65001 >NUL".to_string()];
|
||||||
|
}
|
||||||
|
|
||||||
|
if name == "pwsh.exe" || name == "pwsh" || name == "powershell.exe" {
|
||||||
|
return vec![
|
||||||
|
"-NoLogo".to_string(),
|
||||||
|
"-NoExit".to_string(),
|
||||||
|
"-Command".to_string(),
|
||||||
|
"chcp.com 65001 > $null; [Console]::InputEncoding = [System.Text.Encoding]::UTF8; [Console]::OutputEncoding = [System.Text.Encoding]::UTF8".to_string(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn configure_utf8_shell_command(shell: &str, cmd: &mut CommandBuilder) {
|
||||||
|
for arg in utf8_shell_args(shell) {
|
||||||
|
cmd.arg(arg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Get the SID of the user from a token.
|
/// Get the SID of the user from a token.
|
||||||
/// Returns a Vec<u8> containing the SID bytes.
|
/// Returns a Vec<u8> containing the SID bytes.
|
||||||
pub fn get_user_sid_from_token(user_token: UserToken) -> Result<Vec<u8>> {
|
pub fn get_user_sid_from_token(user_token: UserToken) -> Result<Vec<u8>> {
|
||||||
@@ -831,7 +860,8 @@ pub fn run_terminal_helper(args: &[String]) -> Result<()> {
|
|||||||
let shell = get_default_shell();
|
let shell = get_default_shell();
|
||||||
log::debug!("Using shell: {}", shell);
|
log::debug!("Using shell: {}", shell);
|
||||||
|
|
||||||
let cmd = CommandBuilder::new(&shell);
|
let mut cmd = CommandBuilder::new(&shell);
|
||||||
|
configure_utf8_shell_command(&shell, &mut cmd);
|
||||||
let mut child = pty_pair
|
let mut child = pty_pair
|
||||||
.slave
|
.slave
|
||||||
.spawn_command(cmd)
|
.spawn_command(cmd)
|
||||||
|
|||||||
@@ -20,10 +20,11 @@ use std::{
|
|||||||
// Windows-specific imports from terminal_helper module
|
// Windows-specific imports from terminal_helper module
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
use super::terminal_helper::{
|
use super::terminal_helper::{
|
||||||
create_named_pipe_server, encode_helper_message, encode_resize_message,
|
configure_utf8_shell_command, create_named_pipe_server, encode_helper_message,
|
||||||
is_helper_process_running, launch_terminal_helper_with_token, wait_for_pipe_connection,
|
encode_resize_message, is_helper_process_running, launch_terminal_helper_with_token,
|
||||||
HelperProcessGuard, OwnedHandle, SendableHandle, WinCloseHandle, WinTerminateProcess,
|
wait_for_pipe_connection, HelperProcessGuard, OwnedHandle, SendableHandle, WinCloseHandle,
|
||||||
WinWaitForSingleObject, MSG_TYPE_DATA, PIPE_CONNECTION_TIMEOUT_MS, WIN_WAIT_OBJECT_0,
|
WinTerminateProcess, WinWaitForSingleObject, MSG_TYPE_DATA, PIPE_CONNECTION_TIMEOUT_MS,
|
||||||
|
WIN_WAIT_OBJECT_0,
|
||||||
};
|
};
|
||||||
|
|
||||||
const MAX_OUTPUT_BUFFER_SIZE: usize = 1024 * 1024; // 1MB per terminal
|
const MAX_OUTPUT_BUFFER_SIZE: usize = 1024 * 1024; // 1MB per terminal
|
||||||
@@ -133,6 +134,26 @@ fn get_default_shell() -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
fn locale_value_is_utf8(value: &str) -> bool {
|
||||||
|
let value = value.to_ascii_uppercase();
|
||||||
|
value.contains("UTF-8") || value.contains("UTF8")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
fn should_force_process_utf8_ctype() -> bool {
|
||||||
|
if let Ok(value) = std::env::var("LC_ALL") {
|
||||||
|
return !locale_value_is_utf8(&value);
|
||||||
|
}
|
||||||
|
if let Ok(value) = std::env::var("LC_CTYPE") {
|
||||||
|
return !locale_value_is_utf8(&value);
|
||||||
|
}
|
||||||
|
if let Ok(value) = std::env::var("LANG") {
|
||||||
|
return !locale_value_is_utf8(&value);
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
pub fn is_service_specified_user(service_id: &str) -> Option<bool> {
|
pub fn is_service_specified_user(service_id: &str) -> Option<bool> {
|
||||||
get_service(service_id).map(|s| s.lock().unwrap().is_specified_user)
|
get_service(service_id).map(|s| s.lock().unwrap().is_specified_user)
|
||||||
}
|
}
|
||||||
@@ -435,6 +456,7 @@ impl OutputBuffer {
|
|||||||
// Find first newline in new data
|
// Find first newline in new data
|
||||||
if let Some(newline_pos) = data.iter().position(|&b| b == b'\n') {
|
if let Some(newline_pos) = data.iter().position(|&b| b == b'\n') {
|
||||||
last_line.extend_from_slice(&data[..=newline_pos]);
|
last_line.extend_from_slice(&data[..=newline_pos]);
|
||||||
|
self.total_size += newline_pos + 1;
|
||||||
start = newline_pos + 1;
|
start = newline_pos + 1;
|
||||||
self.last_line_incomplete = false;
|
self.last_line_incomplete = false;
|
||||||
} else {
|
} else {
|
||||||
@@ -473,7 +495,28 @@ impl OutputBuffer {
|
|||||||
// Trim old data if buffer is too large
|
// Trim old data if buffer is too large
|
||||||
while self.total_size > MAX_OUTPUT_BUFFER_SIZE || self.lines.len() > MAX_BUFFER_LINES {
|
while self.total_size > MAX_OUTPUT_BUFFER_SIZE || self.lines.len() > MAX_BUFFER_LINES {
|
||||||
if let Some(removed) = self.lines.pop_front() {
|
if let Some(removed) = self.lines.pop_front() {
|
||||||
self.total_size -= removed.len();
|
if removed.len() > self.total_size {
|
||||||
|
log::error!(
|
||||||
|
"OutputBuffer total_size underflow avoided: total_size={}, removed_len={}, lines_len={}",
|
||||||
|
self.total_size,
|
||||||
|
removed.len(),
|
||||||
|
self.lines.len()
|
||||||
|
);
|
||||||
|
self.total_size = self.lines.iter().map(|line| line.len()).sum();
|
||||||
|
} else {
|
||||||
|
self.total_size -= removed.len();
|
||||||
|
}
|
||||||
|
if self.lines.is_empty() {
|
||||||
|
self.last_line_incomplete = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log::error!(
|
||||||
|
"OutputBuffer trim invariant broken: total_size={}, lines_len=0",
|
||||||
|
self.total_size
|
||||||
|
);
|
||||||
|
self.total_size = 0;
|
||||||
|
self.last_line_incomplete = false;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -531,6 +574,97 @@ impl OutputBuffer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Find the largest prefix of `buf` that does not end in the middle of a UTF-8
|
||||||
|
/// code point. Invalid bytes are treated as complete so they can continue
|
||||||
|
/// downstream and be rendered with replacement characters if needed.
|
||||||
|
fn find_utf8_split_point(buf: &[u8]) -> usize {
|
||||||
|
if buf.is_empty() {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let start = buf.len().saturating_sub(3);
|
||||||
|
for i in (start..buf.len()).rev() {
|
||||||
|
let b = buf[i];
|
||||||
|
if b & 0x80 == 0 {
|
||||||
|
return buf.len();
|
||||||
|
}
|
||||||
|
if b & 0xC0 == 0x80 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let seq_len = if b & 0xE0 == 0xC0 {
|
||||||
|
2
|
||||||
|
} else if b & 0xF0 == 0xE0 {
|
||||||
|
3
|
||||||
|
} else if b & 0xF8 == 0xF0 {
|
||||||
|
4
|
||||||
|
} else {
|
||||||
|
return buf.len();
|
||||||
|
};
|
||||||
|
|
||||||
|
return if buf.len() - i >= seq_len {
|
||||||
|
buf.len()
|
||||||
|
} else {
|
||||||
|
i
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
buf.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Terminal output currently follows a UTF-8 text model end to end: the service
|
||||||
|
// keeps replay buffers on UTF-8 boundaries, and Flutter decodes payload bytes as
|
||||||
|
// UTF-8 before writing to xterm. This accumulator only prevents splitting a
|
||||||
|
// trailing UTF-8 code point across PTY reads. Supporting non-UTF-8 terminals
|
||||||
|
// would need a separate design covering remote encoding detection, Flutter
|
||||||
|
// decoding, replay truncation, and input transcoding.
|
||||||
|
#[derive(Default)]
|
||||||
|
struct Utf8ChunkAccumulator {
|
||||||
|
remainder: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Utf8ChunkAccumulator {
|
||||||
|
fn push_chunk(&mut self, mut data: Vec<u8>) -> Option<Vec<u8>> {
|
||||||
|
if data.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let had_remainder = !self.remainder.is_empty();
|
||||||
|
if had_remainder {
|
||||||
|
let mut combined = std::mem::take(&mut self.remainder);
|
||||||
|
combined.extend_from_slice(&data);
|
||||||
|
data = combined;
|
||||||
|
}
|
||||||
|
|
||||||
|
let split = find_utf8_split_point(&data);
|
||||||
|
if split == data.len() {
|
||||||
|
return Some(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only hold back a candidate incomplete suffix when we have evidence that
|
||||||
|
// the bytes before it are already UTF-8 text. If split is 0, the whole
|
||||||
|
// read may be the start of a UTF-8 character, so keep it for the next read.
|
||||||
|
if !had_remainder && split > 0 && std::str::from_utf8(&data[..split]).is_err() {
|
||||||
|
return Some(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.remainder = data.split_off(split);
|
||||||
|
if data.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finish(&mut self) -> Option<Vec<u8>> {
|
||||||
|
if self.remainder.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(std::mem::take(&mut self.remainder))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Try to send data through the output channel with rate-limited drop logging.
|
/// Try to send data through the output channel with rate-limited drop logging.
|
||||||
/// Returns `true` if the caller should break out of the read loop (channel disconnected).
|
/// Returns `true` if the caller should break out of the read loop (channel disconnected).
|
||||||
fn try_send_output(
|
fn try_send_output(
|
||||||
@@ -570,7 +704,11 @@ fn try_send_output(
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
Err(mpsc::TrySendError::Disconnected(_)) => {
|
Err(mpsc::TrySendError::Disconnected(_)) => {
|
||||||
log::debug!("Terminal {}{} output channel disconnected", terminal_id, label);
|
log::debug!(
|
||||||
|
"Terminal {}{} output channel disconnected",
|
||||||
|
terminal_id,
|
||||||
|
label
|
||||||
|
);
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -937,15 +1075,35 @@ impl TerminalServiceProxy {
|
|||||||
if let Some(session_arc) = service.sessions.get(&open.terminal_id) {
|
if let Some(session_arc) = service.sessions.get(&open.terminal_id) {
|
||||||
// Reconnect to existing terminal
|
// Reconnect to existing terminal
|
||||||
let mut session = session_arc.lock().unwrap();
|
let mut session = session_arc.lock().unwrap();
|
||||||
// Directly enter Active state with pending buffer for immediate streaming.
|
// Directly enter Active state with pending replay for immediate streaming.
|
||||||
// Historical buffer is sent first by read_outputs(), then real-time data follows.
|
// The replay combines output_buffer history and the channel backlog that was
|
||||||
// No overlap: pending_buffer comes from output_buffer (pre-disconnect history),
|
// already pending at reconnect time so the client can suppress stale xterm
|
||||||
// while received_data in read_outputs() comes from the channel (post-reconnect).
|
// query answers without requiring a protobuf schema change.
|
||||||
// During disconnect, the run loop (sp.ok()) exits so read_outputs() stops being
|
// During disconnect, read_outputs() is not called; channel data can still be lost
|
||||||
// called; output_buffer is not updated, and channel data may be lost if it fills up.
|
// if output_rx fills before reconnect drains it.
|
||||||
let buffer = session
|
let mut buffer = session
|
||||||
.output_buffer
|
.output_buffer
|
||||||
.get_recent(DEFAULT_RECONNECT_BUFFER_BYTES);
|
.get_recent(DEFAULT_RECONNECT_BUFFER_BYTES);
|
||||||
|
let mut reconnect_backlog = Vec::new();
|
||||||
|
if let Some(output_rx) = &session.output_rx {
|
||||||
|
// Cap reconnect-time drain so a chatty PTY cannot keep OpenTerminal
|
||||||
|
// inside this loop indefinitely. Remaining output is drained by read_outputs().
|
||||||
|
for _ in 0..CHANNEL_BUFFER_SIZE {
|
||||||
|
let Ok(data) = output_rx.try_recv() else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
reconnect_backlog.push(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let has_reconnect_backlog = !reconnect_backlog.is_empty();
|
||||||
|
for data in reconnect_backlog {
|
||||||
|
session.output_buffer.append(&data);
|
||||||
|
}
|
||||||
|
if has_reconnect_backlog {
|
||||||
|
buffer = session
|
||||||
|
.output_buffer
|
||||||
|
.get_recent(DEFAULT_RECONNECT_BUFFER_BYTES);
|
||||||
|
}
|
||||||
let has_pending = !buffer.is_empty();
|
let has_pending = !buffer.is_empty();
|
||||||
session.state = SessionState::Active {
|
session.state = SessionState::Active {
|
||||||
pending_buffer: if has_pending { Some(buffer) } else { None },
|
pending_buffer: if has_pending { Some(buffer) } else { None },
|
||||||
@@ -959,9 +1117,14 @@ impl TerminalServiceProxy {
|
|||||||
let mut opened = TerminalOpened::new();
|
let mut opened = TerminalOpened::new();
|
||||||
opened.terminal_id = open.terminal_id;
|
opened.terminal_id = open.terminal_id;
|
||||||
opened.success = true;
|
opened.success = true;
|
||||||
opened.message = "Reconnected to existing terminal".to_string();
|
opened.message = if has_pending {
|
||||||
|
"Reconnected to existing terminal with pending output".to_string()
|
||||||
|
} else {
|
||||||
|
"Reconnected to existing terminal".to_string()
|
||||||
|
};
|
||||||
opened.pid = session.pid;
|
opened.pid = session.pid;
|
||||||
opened.service_id = self.service_id.clone();
|
opened.service_id = self.service_id.clone();
|
||||||
|
opened.replay_terminal_output = has_pending;
|
||||||
if service.needs_session_sync {
|
if service.needs_session_sync {
|
||||||
if service.sessions.len() > 1 {
|
if service.sessions.len() > 1 {
|
||||||
// No need to include the current terminal in the list.
|
// No need to include the current terminal in the list.
|
||||||
@@ -1016,6 +1179,9 @@ impl TerminalServiceProxy {
|
|||||||
#[allow(unused_mut)]
|
#[allow(unused_mut)]
|
||||||
let mut cmd = CommandBuilder::new(&shell);
|
let mut cmd = CommandBuilder::new(&shell);
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
configure_utf8_shell_command(&shell, &mut cmd);
|
||||||
|
|
||||||
// macOS-specific terminal configuration
|
// macOS-specific terminal configuration
|
||||||
// 1. Use login shell (-l) to load user's shell profile (~/.zprofile, ~/.bash_profile)
|
// 1. Use login shell (-l) to load user's shell profile (~/.zprofile, ~/.bash_profile)
|
||||||
// This ensures PATH includes Homebrew paths (/opt/homebrew/bin, /usr/local/bin)
|
// This ensures PATH includes Homebrew paths (/opt/homebrew/bin, /usr/local/bin)
|
||||||
@@ -1036,6 +1202,12 @@ impl TerminalServiceProxy {
|
|||||||
};
|
};
|
||||||
cmd.env("TERM", term);
|
cmd.env("TERM", term);
|
||||||
log::debug!("Set TERM={} for macOS PTY", term);
|
log::debug!("Set TERM={} for macOS PTY", term);
|
||||||
|
|
||||||
|
if should_force_process_utf8_ctype() {
|
||||||
|
cmd.env_remove("LC_ALL");
|
||||||
|
cmd.env("LC_CTYPE", "en_US.UTF-8");
|
||||||
|
log::debug!("Set LC_CTYPE=en_US.UTF-8 for macOS PTY");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Note: On Windows with user_token, we use helper mode (handle_open_with_helper)
|
// Note: On Windows with user_token, we use helper mode (handle_open_with_helper)
|
||||||
@@ -1086,6 +1258,7 @@ impl TerminalServiceProxy {
|
|||||||
let reader_thread = thread::spawn(move || {
|
let reader_thread = thread::spawn(move || {
|
||||||
let mut reader = reader;
|
let mut reader = reader;
|
||||||
let mut buf = vec![0u8; 4096];
|
let mut buf = vec![0u8; 4096];
|
||||||
|
let mut utf8_chunks = Utf8ChunkAccumulator::default();
|
||||||
let mut drop_count: u64 = 0;
|
let mut drop_count: u64 = 0;
|
||||||
// Initialize to > 5s ago so the first drop triggers a warning immediately.
|
// Initialize to > 5s ago so the first drop triggers a warning immediately.
|
||||||
let mut last_drop_warn = Instant::now() - Duration::from_secs(6);
|
let mut last_drop_warn = Instant::now() - Duration::from_secs(6);
|
||||||
@@ -1095,13 +1268,25 @@ impl TerminalServiceProxy {
|
|||||||
// EOF
|
// EOF
|
||||||
// This branch can be reached when the child process exits on macOS.
|
// This branch can be reached when the child process exits on macOS.
|
||||||
// But not on Linux and Windows in my tests.
|
// But not on Linux and Windows in my tests.
|
||||||
|
if let Some(data) = utf8_chunks.finish() {
|
||||||
|
let _ = try_send_output(
|
||||||
|
&output_tx,
|
||||||
|
data,
|
||||||
|
terminal_id,
|
||||||
|
"",
|
||||||
|
&mut drop_count,
|
||||||
|
&mut last_drop_warn,
|
||||||
|
);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
Ok(n) => {
|
Ok(n) => {
|
||||||
if exiting.load(Ordering::SeqCst) {
|
if exiting.load(Ordering::SeqCst) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
let data = buf[..n].to_vec();
|
let Some(data) = utf8_chunks.push_chunk(buf[..n].to_vec()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
// Use try_send to avoid blocking the reader thread when channel is full.
|
// Use try_send to avoid blocking the reader thread when channel is full.
|
||||||
// During disconnect, the run loop (sp.ok()) stops and read_outputs() is
|
// During disconnect, the run loop (sp.ok()) stops and read_outputs() is
|
||||||
// no longer called, so the channel won't be drained. Blocking send would
|
// no longer called, so the channel won't be drained. Blocking send would
|
||||||
@@ -1308,12 +1493,23 @@ impl TerminalServiceProxy {
|
|||||||
let terminal_id = open.terminal_id;
|
let terminal_id = open.terminal_id;
|
||||||
let reader_thread = thread::spawn(move || {
|
let reader_thread = thread::spawn(move || {
|
||||||
let mut buf = vec![0u8; 4096];
|
let mut buf = vec![0u8; 4096];
|
||||||
|
let mut utf8_chunks = Utf8ChunkAccumulator::default();
|
||||||
let mut drop_count: u64 = 0;
|
let mut drop_count: u64 = 0;
|
||||||
// Initialize to > 5s ago so the first drop triggers a warning immediately.
|
// Initialize to > 5s ago so the first drop triggers a warning immediately.
|
||||||
let mut last_drop_warn = Instant::now() - Duration::from_secs(6);
|
let mut last_drop_warn = Instant::now() - Duration::from_secs(6);
|
||||||
loop {
|
loop {
|
||||||
match output_pipe.read(&mut buf) {
|
match output_pipe.read(&mut buf) {
|
||||||
Ok(0) => {
|
Ok(0) => {
|
||||||
|
if let Some(data) = utf8_chunks.finish() {
|
||||||
|
let _ = try_send_output(
|
||||||
|
&output_tx,
|
||||||
|
data,
|
||||||
|
terminal_id,
|
||||||
|
" (helper)",
|
||||||
|
&mut drop_count,
|
||||||
|
&mut last_drop_warn,
|
||||||
|
);
|
||||||
|
}
|
||||||
// EOF - helper process exited
|
// EOF - helper process exited
|
||||||
log::debug!("Terminal {} helper output EOF", terminal_id);
|
log::debug!("Terminal {} helper output EOF", terminal_id);
|
||||||
break;
|
break;
|
||||||
@@ -1322,7 +1518,9 @@ impl TerminalServiceProxy {
|
|||||||
if exiting.load(Ordering::SeqCst) {
|
if exiting.load(Ordering::SeqCst) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
let data = buf[..n].to_vec();
|
let Some(data) = utf8_chunks.push_chunk(buf[..n].to_vec()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
// Use try_send to avoid blocking the reader thread (same as direct PTY mode)
|
// Use try_send to avoid blocking the reader thread (same as direct PTY mode)
|
||||||
if try_send_output(
|
if try_send_output(
|
||||||
&output_tx,
|
&output_tx,
|
||||||
@@ -1462,20 +1660,28 @@ impl TerminalServiceProxy {
|
|||||||
data: &TerminalData,
|
data: &TerminalData,
|
||||||
) -> Result<Option<TerminalResponse>> {
|
) -> Result<Option<TerminalResponse>> {
|
||||||
if let Some(session_arc) = session {
|
if let Some(session_arc) = session {
|
||||||
let mut session = session_arc.lock().unwrap();
|
let input = {
|
||||||
session.update_activity();
|
let mut session = session_arc.lock().unwrap();
|
||||||
if let Some(input_tx) = &session.input_tx {
|
session.update_activity();
|
||||||
// Encode data for helper mode or send raw for direct PTY mode
|
if let Some(input_tx) = session.input_tx.clone() {
|
||||||
#[cfg(target_os = "windows")]
|
// Encode data for helper mode or send raw for direct PTY mode
|
||||||
let msg = if session.is_helper_mode {
|
#[cfg(target_os = "windows")]
|
||||||
encode_helper_message(MSG_TYPE_DATA, &data.data)
|
let msg = if session.is_helper_mode {
|
||||||
} else {
|
encode_helper_message(MSG_TYPE_DATA, &data.data)
|
||||||
data.data.to_vec()
|
} else {
|
||||||
};
|
data.data.to_vec()
|
||||||
#[cfg(not(target_os = "windows"))]
|
};
|
||||||
let msg = data.data.to_vec();
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
let msg = data.data.to_vec();
|
||||||
|
|
||||||
// Send data to writer thread
|
Some((input_tx, msg))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some((input_tx, msg)) = input {
|
||||||
|
// Send outside the session lock; SyncSender::send can block when full.
|
||||||
if let Err(e) = input_tx.send(msg) {
|
if let Err(e) = input_tx.send(msg) {
|
||||||
log::error!(
|
log::error!(
|
||||||
"Failed to send data to terminal {}: {}",
|
"Failed to send data to terminal {}: {}",
|
||||||
@@ -1683,10 +1889,6 @@ impl TerminalServiceProxy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if has_activity {
|
|
||||||
session.update_activity();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update buffer (always buffer for reconnection support)
|
// Update buffer (always buffer for reconnection support)
|
||||||
for data in &received_data {
|
for data in &received_data {
|
||||||
session.output_buffer.append(data);
|
session.output_buffer.append(data);
|
||||||
@@ -1696,7 +1898,7 @@ impl TerminalServiceProxy {
|
|||||||
// Data is already buffered above and will be sent on next reconnection.
|
// Data is already buffered above and will be sent on next reconnection.
|
||||||
// Use a scoped block to limit the mutable borrow of session.state,
|
// Use a scoped block to limit the mutable borrow of session.state,
|
||||||
// so we can immutably borrow other session fields afterwards.
|
// so we can immutably borrow other session fields afterwards.
|
||||||
let sigwinch_action = {
|
let (replay_buffer, sigwinch_action) = {
|
||||||
let (pending_buffer, sigwinch) = match &mut session.state {
|
let (pending_buffer, sigwinch) = match &mut session.state {
|
||||||
SessionState::Active {
|
SessionState::Active {
|
||||||
pending_buffer,
|
pending_buffer,
|
||||||
@@ -1705,19 +1907,12 @@ impl TerminalServiceProxy {
|
|||||||
_ => continue,
|
_ => continue,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Send pending buffer response first (set on reconnection in handle_open).
|
let replay_buffer = pending_buffer.take();
|
||||||
// This ensures historical buffer is sent before any real-time data.
|
|
||||||
if let Some(buffer) = pending_buffer.take() {
|
|
||||||
if !buffer.is_empty() {
|
|
||||||
responses
|
|
||||||
.push(Self::create_terminal_data_response(terminal_id, buffer));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Two-phase SIGWINCH: see SigwinchPhase doc comments for rationale.
|
// Two-phase SIGWINCH: see SigwinchPhase doc comments for rationale.
|
||||||
// Each phase is a single PTY resize, spaced ~30ms apart by the polling
|
// Each phase is a single PTY resize, spaced ~30ms apart by the polling
|
||||||
// interval, ensuring the TUI app sees a real size change on each signal.
|
// interval, ensuring the TUI app sees a real size change on each signal.
|
||||||
match sigwinch {
|
let sigwinch_action = match sigwinch {
|
||||||
SigwinchPhase::TempResize { retries } => {
|
SigwinchPhase::TempResize { retries } => {
|
||||||
if *retries == 0 {
|
if *retries == 0 {
|
||||||
log::warn!(
|
log::warn!(
|
||||||
@@ -1745,9 +1940,20 @@ impl TerminalServiceProxy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
SigwinchPhase::Idle => None,
|
SigwinchPhase::Idle => None,
|
||||||
}
|
};
|
||||||
|
(replay_buffer, sigwinch_action)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if let Some(buffer) = replay_buffer {
|
||||||
|
if !buffer.is_empty() {
|
||||||
|
responses.push(Self::create_terminal_data_response(terminal_id, buffer));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if has_activity {
|
||||||
|
session.update_activity();
|
||||||
|
}
|
||||||
|
|
||||||
// Execute SIGWINCH resize outside the mutable borrow scope of session.state.
|
// Execute SIGWINCH resize outside the mutable borrow scope of session.state.
|
||||||
if let Some(action) = sigwinch_action {
|
if let Some(action) = sigwinch_action {
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
@@ -1845,3 +2051,116 @@ impl TerminalServiceProxy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{find_utf8_split_point, OutputBuffer, Utf8ChunkAccumulator, MAX_BUFFER_LINES};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn utf8_split_point_returns_full_len_for_complete_input() {
|
||||||
|
assert_eq!(find_utf8_split_point(b"hello"), 5);
|
||||||
|
assert_eq!(find_utf8_split_point("中文".as_bytes()), "中文".len());
|
||||||
|
assert_eq!(find_utf8_split_point("😀".as_bytes()), "😀".len());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn utf8_split_point_detects_incomplete_trailing_sequence() {
|
||||||
|
let data = [b'a', 0xE4, 0xB8];
|
||||||
|
assert_eq!(find_utf8_split_point(&data), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn utf8_split_point_keeps_malformed_prefix_but_buffers_trailing_lead_byte() {
|
||||||
|
let data = [0xFF, 0xE4];
|
||||||
|
assert_eq!(find_utf8_split_point(&data), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn utf8_split_point_treats_orphan_continuations_as_complete() {
|
||||||
|
let data = [0x80, 0x81, 0x82];
|
||||||
|
assert_eq!(find_utf8_split_point(&data), data.len());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn utf8_chunk_accumulator_reassembles_split_multibyte_output() {
|
||||||
|
let full = "你好世界".as_bytes();
|
||||||
|
let mut chunker = Utf8ChunkAccumulator::default();
|
||||||
|
let mut output = Vec::new();
|
||||||
|
|
||||||
|
for chunk in full.chunks(5) {
|
||||||
|
if let Some(data) = chunker.push_chunk(chunk.to_vec()) {
|
||||||
|
output.extend_from_slice(&data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(data) = chunker.finish() {
|
||||||
|
output.extend_from_slice(&data);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(output, full);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn utf8_chunk_accumulator_buffers_leading_split_multibyte_output() {
|
||||||
|
let mut chunker = Utf8ChunkAccumulator::default();
|
||||||
|
|
||||||
|
assert!(chunker.push_chunk(vec![0xE4]).is_none());
|
||||||
|
assert!(chunker.push_chunk(vec![0xB8]).is_none());
|
||||||
|
assert_eq!(
|
||||||
|
chunker.push_chunk(vec![0xAD]),
|
||||||
|
Some("中".as_bytes().to_vec())
|
||||||
|
);
|
||||||
|
assert!(chunker.finish().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn utf8_chunk_accumulator_flushes_incomplete_tail_on_finish() {
|
||||||
|
let mut chunker = Utf8ChunkAccumulator::default();
|
||||||
|
assert_eq!(chunker.push_chunk(vec![b'a', 0xE4]), Some(vec![b'a']));
|
||||||
|
assert_eq!(chunker.finish(), Some(vec![0xE4]));
|
||||||
|
assert!(chunker.finish().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn utf8_chunk_accumulator_does_not_stall_on_malformed_bytes() {
|
||||||
|
let mut chunker = Utf8ChunkAccumulator::default();
|
||||||
|
assert_eq!(chunker.push_chunk(vec![0xFF]), Some(vec![0xFF]));
|
||||||
|
assert!(chunker.finish().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn utf8_chunk_accumulator_buffers_lone_utf8_lead_bytes() {
|
||||||
|
let mut chunker = Utf8ChunkAccumulator::default();
|
||||||
|
assert!(chunker.push_chunk(vec![0xE4]).is_none());
|
||||||
|
assert_eq!(chunker.finish(), Some(vec![0xE4]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn utf8_chunk_accumulator_does_not_hold_back_non_utf8_prefixes() {
|
||||||
|
let mut chunker = Utf8ChunkAccumulator::default();
|
||||||
|
assert_eq!(chunker.push_chunk(vec![0xFF, 0xE4]), Some(vec![0xFF, 0xE4]));
|
||||||
|
assert!(chunker.finish().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn output_buffer_trim_after_incomplete_merge_does_not_underflow() {
|
||||||
|
let mut buffer = OutputBuffer::new();
|
||||||
|
|
||||||
|
// Create an incomplete line first.
|
||||||
|
buffer.append(b"hello");
|
||||||
|
|
||||||
|
// Merge a large chunk that contains the first newline at the tail.
|
||||||
|
// This exercises the "append to last incomplete line" branch.
|
||||||
|
let mut large = vec![b'a'; 30_000];
|
||||||
|
large.push(b'\n');
|
||||||
|
buffer.append(&large);
|
||||||
|
|
||||||
|
// Exceed MAX_BUFFER_LINES so trim pops the first large merged line.
|
||||||
|
for _ in 0..=MAX_BUFFER_LINES {
|
||||||
|
buffer.append(b"x\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
let actual_size: usize = buffer.lines.iter().map(|line| line.len()).sum();
|
||||||
|
assert_eq!(buffer.total_size, actual_size);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -185,9 +185,13 @@ pub mod client {
|
|||||||
pub mod service {
|
pub mod service {
|
||||||
use super::*;
|
use super::*;
|
||||||
use hbb_common::lazy_static;
|
use hbb_common::lazy_static;
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
use parity_tokio_ipc::Connection as RawIpcConnection;
|
||||||
use scrap::wayland::{
|
use scrap::wayland::{
|
||||||
pipewire::RDP_SESSION_INFO, remote_desktop_portal::OrgFreedesktopPortalRemoteDesktop,
|
pipewire::RDP_SESSION_INFO, remote_desktop_portal::OrgFreedesktopPortalRemoteDesktop,
|
||||||
};
|
};
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
use std::os::unix::io::AsRawFd;
|
||||||
use std::{collections::HashMap, sync::Mutex};
|
use std::{collections::HashMap, sync::Mutex};
|
||||||
|
|
||||||
lazy_static::lazy_static! {
|
lazy_static::lazy_static! {
|
||||||
@@ -602,7 +606,10 @@ pub mod service {
|
|||||||
}
|
}
|
||||||
DataKeyboard::KeyDown(enigo::Key::Raw(code)) => {
|
DataKeyboard::KeyDown(enigo::Key::Raw(code)) => {
|
||||||
if *code < 8 {
|
if *code < 8 {
|
||||||
log::error!("Invalid Raw keycode {} (must be >= 8 due to XKB offset), skipping", code);
|
log::error!(
|
||||||
|
"Invalid Raw keycode {} (must be >= 8 due to XKB offset), skipping",
|
||||||
|
code
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
let down_event = InputEvent::new(EventType::KEY, *code - 8, 1);
|
let down_event = InputEvent::new(EventType::KEY, *code - 8, 1);
|
||||||
allow_err!(keyboard.emit(&[down_event]));
|
allow_err!(keyboard.emit(&[down_event]));
|
||||||
@@ -610,7 +617,10 @@ pub mod service {
|
|||||||
}
|
}
|
||||||
DataKeyboard::KeyUp(enigo::Key::Raw(code)) => {
|
DataKeyboard::KeyUp(enigo::Key::Raw(code)) => {
|
||||||
if *code < 8 {
|
if *code < 8 {
|
||||||
log::error!("Invalid Raw keycode {} (must be >= 8 due to XKB offset), skipping", code);
|
log::error!(
|
||||||
|
"Invalid Raw keycode {} (must be >= 8 due to XKB offset), skipping",
|
||||||
|
code
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
let up_event = InputEvent::new(EventType::KEY, *code - 8, 0);
|
let up_event = InputEvent::new(EventType::KEY, *code - 8, 0);
|
||||||
allow_err!(keyboard.emit(&[up_event]));
|
allow_err!(keyboard.emit(&[up_event]));
|
||||||
@@ -909,6 +919,35 @@ pub mod service {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn authorize_uinput_peer(postfix: &str, stream: &RawIpcConnection) -> bool {
|
||||||
|
if !hbb_common::config::is_service_ipc_postfix(postfix) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let peer_uid = ipc::peer_uid_from_fd(stream.as_raw_fd());
|
||||||
|
let active_uid = crate::platform::linux::get_active_userid_fresh()
|
||||||
|
.trim()
|
||||||
|
.parse::<u32>()
|
||||||
|
.ok();
|
||||||
|
let authorized =
|
||||||
|
peer_uid.is_some_and(|uid| ipc::is_allowed_service_peer_uid(uid, active_uid));
|
||||||
|
if !authorized {
|
||||||
|
crate::ipc::log_rejected_uinput_connection(postfix, peer_uid, active_uid);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if let Err(err) =
|
||||||
|
ipc::ensure_peer_executable_matches_current_by_fd(stream.as_raw_fd(), postfix)
|
||||||
|
{
|
||||||
|
log::warn!(
|
||||||
|
"Rejected connection on protected uinput ipc channel due to executable mismatch: postfix={}, err={}",
|
||||||
|
postfix,
|
||||||
|
err
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
/// Start uinput service.
|
/// Start uinput service.
|
||||||
async fn start_service<F: FnOnce(ipc::Connection) + Copy>(postfix: &str, handler: F) {
|
async fn start_service<F: FnOnce(ipc::Connection) + Copy>(postfix: &str, handler: F) {
|
||||||
match new_listener(postfix).await {
|
match new_listener(postfix).await {
|
||||||
@@ -916,6 +955,10 @@ pub mod service {
|
|||||||
while let Some(result) = incoming.next().await {
|
while let Some(result) = incoming.next().await {
|
||||||
match result {
|
match result {
|
||||||
Ok(stream) => {
|
Ok(stream) => {
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
if !authorize_uinput_peer(postfix, &stream) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
log::debug!("Got new connection of uinput ipc {}", postfix);
|
log::debug!("Got new connection of uinput ipc {}", postfix);
|
||||||
handler(Connection::new(stream));
|
handler(Connection::new(stream));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user