mirror of
https://github.com/rustdesk/rustdesk.git
synced 2026-05-10 08:08:09 +03:00
Compare commits
15 Commits
dependabot
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c831dc59b | ||
|
|
b757e97c11 | ||
|
|
9df486a689 | ||
|
|
72d27c3c47 | ||
|
|
6c20fc936d | ||
|
|
5439ec38b6 | ||
|
|
8b8a64f870 | ||
|
|
92509f8e8a | ||
|
|
0221634a4d | ||
|
|
9d1f86fbc6 | ||
|
|
f29dec7b13 | ||
|
|
d5d0b01266 | ||
|
|
5abae617dc | ||
|
|
52d62da002 | ||
|
|
253d632709 |
4
Cargo.lock
generated
4
Cargo.lock
generated
@@ -5996,8 +5996,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "parity-tokio-ipc"
|
||||
version = "0.7.3-5"
|
||||
source = "git+https://github.com/rustdesk-org/parity-tokio-ipc#c8c8bbcbabf9be1201c53afb0269b92b9b02d291"
|
||||
version = "0.7.3-6"
|
||||
source = "git+https://github.com/rustdesk-org/parity-tokio-ipc#d0ae39bffe5d5a3e8d82a1b6bcb1ca5a9b2f1c01"
|
||||
dependencies = [
|
||||
"futures",
|
||||
"libc",
|
||||
|
||||
@@ -716,6 +716,17 @@ closeConnection({String? id}) {
|
||||
stateGlobal.isInMainPage = true;
|
||||
} else {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -4179,8 +4190,7 @@ Widget? buildAvatarWidget({
|
||||
width: size,
|
||||
height: size,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) =>
|
||||
fallback ?? SizedBox.shrink(),
|
||||
errorBuilder: (_, __, ___) => fallback ?? SizedBox.shrink(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ class TerminalPage extends StatefulWidget {
|
||||
final bool? isSharedPassword;
|
||||
final String? connToken;
|
||||
final int terminalId;
|
||||
|
||||
/// Tab key for focus management, passed from parent to avoid duplicate construction
|
||||
final String tabKey;
|
||||
final SimpleWrapper<State<TerminalPage>?> _lastState = SimpleWrapper(null);
|
||||
@@ -43,6 +44,9 @@ class TerminalPage extends StatefulWidget {
|
||||
|
||||
class _TerminalPageState extends State<TerminalPage>
|
||||
with AutomaticKeepAliveClientMixin {
|
||||
static const EdgeInsets _defaultTerminalPadding =
|
||||
EdgeInsets.symmetric(horizontal: 5.0, vertical: 2.0);
|
||||
|
||||
late FFI _ffi;
|
||||
late TerminalModel _terminalModel;
|
||||
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
|
||||
// terminal row (`_cellHeight`) and distributing it evenly as top and bottom padding.
|
||||
EdgeInsets _calculatePadding(double heightPx) {
|
||||
if (_cellHeight == null) {
|
||||
return const EdgeInsets.symmetric(horizontal: 5.0, vertical: 2.0);
|
||||
final cellHeight = _cellHeight;
|
||||
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;
|
||||
return EdgeInsets.symmetric(horizontal: 5.0, vertical: topBottom);
|
||||
return EdgeInsets.symmetric(
|
||||
horizontal: _defaultTerminalPadding.horizontal / 2,
|
||||
vertical: topBottom,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -46,6 +46,7 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
|
||||
.setTitle(getWindowNameWithId(id));
|
||||
};
|
||||
tabController.onRemoved = (_, id) => onRemoveId(id);
|
||||
tabController.onCloseWindow = _closeWindowFromConnection;
|
||||
final terminalId = params['terminalId'] ?? _nextTerminalId++;
|
||||
tabController.add(_createTerminalTab(
|
||||
peerId: params['id'],
|
||||
@@ -144,6 +145,8 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
|
||||
_windowClosing = true;
|
||||
final tabKeys = tabController.state.value.tabs.map((t) => t.key).toList();
|
||||
// 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();
|
||||
// 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.
|
||||
@@ -368,8 +371,34 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
|
||||
final persistentSessions =
|
||||
args['persistent_sessions'] as List<dynamic>? ?? [];
|
||||
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) {
|
||||
_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
|
||||
// before adding the next terminal. Without this delay, `_TerminalPageState::dispose()`
|
||||
// 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() {
|
||||
return widget.params["windowId"];
|
||||
}
|
||||
|
||||
@@ -99,6 +99,7 @@ class DesktopTabController {
|
||||
/// index, key
|
||||
Function(int, String)? onRemoved;
|
||||
Function(String)? onSelected;
|
||||
Future<void> Function()? onCloseWindow;
|
||||
|
||||
DesktopTabController(
|
||||
{required this.tabType, this.onRemoved, this.onSelected});
|
||||
|
||||
@@ -391,14 +391,30 @@ class FileController {
|
||||
|
||||
await Future.delayed(Duration(milliseconds: 100));
|
||||
|
||||
final dir = (await bind.sessionGetPeerOption(
|
||||
final savedDir = (await bind.sessionGetPeerOption(
|
||||
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));
|
||||
|
||||
if (directory.value.path.isEmpty) {
|
||||
openDirectory(options.value.home);
|
||||
if (!opened) {
|
||||
// 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 {
|
||||
await openDirectory(directory.value.path);
|
||||
Future<bool> refresh() async {
|
||||
// "." 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 {
|
||||
if (path == ".") {
|
||||
refresh();
|
||||
return;
|
||||
Future<bool> openDirectory(String path, {bool isBack = false}) async {
|
||||
if (!isBack && path == ".") {
|
||||
return await refresh();
|
||||
}
|
||||
if (path == "..") {
|
||||
goToParentDirectory();
|
||||
return;
|
||||
if (!isBack && path == "..") {
|
||||
return await _goToParentDirectory(isBack: isBack);
|
||||
}
|
||||
return await _openDirectoryPath(path, isBack: isBack);
|
||||
}
|
||||
|
||||
Future<bool> _openDirectoryPath(String path, {bool isBack = false}) async {
|
||||
if (!isBack) {
|
||||
pushHistory();
|
||||
}
|
||||
@@ -458,8 +478,10 @@ class FileController {
|
||||
final fd = await fileFetcher.fetchDirectory(path, isLocal, showHidden);
|
||||
fd.format(isWindows, sort: sortBy.value);
|
||||
directory.value = fd;
|
||||
return true;
|
||||
} catch (e) {
|
||||
debugPrint("Failed to openDirectory $path: $e");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -487,19 +509,22 @@ class FileController {
|
||||
goBack();
|
||||
return;
|
||||
}
|
||||
openDirectory(path, isBack: true);
|
||||
unawaited(_openDirectoryPath(path, isBack: true).then<void>((_) {}));
|
||||
}
|
||||
|
||||
void goToParentDirectory() {
|
||||
unawaited(_goToParentDirectory().then<void>((_) {}));
|
||||
}
|
||||
|
||||
Future<bool> _goToParentDirectory({bool isBack = false}) async {
|
||||
final isWindows = options.value.isWindows;
|
||||
final dirPath = directory.value.path;
|
||||
var parent = PathUtil.dirname(dirPath, isWindows);
|
||||
// specially for C:\, D:\, goto '/'
|
||||
if (parent == dirPath && isWindows) {
|
||||
openDirectory('/');
|
||||
return;
|
||||
return await _openDirectoryPath('/', isBack: isBack);
|
||||
}
|
||||
openDirectory(parent);
|
||||
return await _openDirectoryPath(parent, isBack: isBack);
|
||||
}
|
||||
|
||||
// TODO deprecated this
|
||||
|
||||
@@ -27,25 +27,30 @@ class TerminalModel with ChangeNotifier {
|
||||
// Buffer for output data received before terminal view has valid dimensions.
|
||||
// This prevents NaN errors when writing to terminal before layout is complete.
|
||||
final _pendingOutputChunks = <String>[];
|
||||
final _pendingOutputSuppressFlags = <bool>[];
|
||||
int _pendingOutputSize = 0;
|
||||
static const int _kMaxOutputBufferChars = 8 * 1024;
|
||||
// View ready state: true when terminal has valid dimensions, safe to write
|
||||
bool _terminalViewReady = false;
|
||||
|
||||
bool get isPeerWindows => parent.ffiModel.pi.platform == kPeerPlatformWindows;
|
||||
bool _markViewReadyScheduled = false;
|
||||
bool _suppressTerminalOutput = false;
|
||||
bool _suppressNextTerminalDataOutput = false;
|
||||
|
||||
void Function(int w, int h, int pw, int ph)? onResizeExternal;
|
||||
|
||||
Future<void> _handleInput(String data) async {
|
||||
// If we press the `Enter` button on Android,
|
||||
// `data` can be '\r' or '\n' when using different keyboards.
|
||||
// Android -> Windows. '\r' works, but '\n' does not. '\n' is just a newline.
|
||||
// Android -> Linux. Both '\r' and '\n' work as expected (execute a command).
|
||||
// So when we receive '\n', we may need to convert it to '\r' to ensure compatibility.
|
||||
// Desktop -> Desktop works fine.
|
||||
// Check if we are on mobile or web(mobile), and convert '\n' to '\r'.
|
||||
// Soft keyboards (notably iOS) emit '\n' when Enter is pressed, while a
|
||||
// real keyboard's Enter sends '\r'. Some Android keyboards also emit '\n'.
|
||||
// - Peer Windows: '\r' works, '\n' is just a newline.
|
||||
// - Peer Linux: canonical-mode shells accept both, but raw-mode apps
|
||||
// (readline, prompt_toolkit, vim, TUI frameworks) expect '\r'.
|
||||
// - Peer macOS: same as Linux, raw-mode apps expect '\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));
|
||||
if (isMobileOrWebMobile && isPeerWindows && data == '\n') {
|
||||
if (isMobileOrWebMobile && data == '\n') {
|
||||
data = '\r';
|
||||
}
|
||||
if (_terminalOpened) {
|
||||
@@ -70,7 +75,10 @@ class TerminalModel with ChangeNotifier {
|
||||
terminalController = TerminalController();
|
||||
|
||||
// Setup terminal callbacks
|
||||
terminal.onOutput = _handleInput;
|
||||
terminal.onOutput = (data) {
|
||||
if (_suppressTerminalOutput) return;
|
||||
_handleInput(data);
|
||||
};
|
||||
|
||||
terminal.onResize = (w, h, pw, ph) async {
|
||||
// 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.
|
||||
// Must be after onResizeExternal so the view layer has valid dimensions before flushing.
|
||||
if (!_terminalViewReady) {
|
||||
_markViewReady();
|
||||
_scheduleMarkViewReady();
|
||||
}
|
||||
|
||||
if (_terminalOpened) {
|
||||
@@ -110,14 +118,16 @@ class TerminalModel with ChangeNotifier {
|
||||
void onReady() {
|
||||
parent.dialogManager.dismissAll();
|
||||
|
||||
// Fire and forget - don't block onReady
|
||||
openTerminal().catchError((e) {
|
||||
// Fire and forget - don't block onReady. If the transport reconnects while
|
||||
// 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');
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> openTerminal() async {
|
||||
if (_terminalOpened) return;
|
||||
Future<void> openTerminal({bool force = false}) async {
|
||||
if (_terminalOpened && !force) return;
|
||||
// Request the remote side to open a terminal with default shell
|
||||
// The remote side will decide which shell to use based on its OS
|
||||
|
||||
@@ -275,9 +285,12 @@ class TerminalModel with ChangeNotifier {
|
||||
if (success) {
|
||||
_terminalOpened = true;
|
||||
|
||||
// On reconnect ("Reconnected to existing terminal"), server may replay recent output.
|
||||
// If this TerminalView instance is reused (not rebuilt), duplicate lines can appear.
|
||||
// We intentionally accept this tradeoff for now to keep logic simple.
|
||||
// On reconnect, the server may replay recent output. That replay can include
|
||||
// terminal queries like DSR/DA; xterm answers them through onOutput as
|
||||
// "^[[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
|
||||
// dimensions (e.g. layout completed before open response arrived),
|
||||
@@ -285,7 +298,7 @@ class TerminalModel with ChangeNotifier {
|
||||
if (!_terminalViewReady &&
|
||||
terminal.viewWidth > 0 &&
|
||||
terminal.viewHeight > 0) {
|
||||
_markViewReady();
|
||||
_scheduleMarkViewReady();
|
||||
}
|
||||
|
||||
// Process any buffered input
|
||||
@@ -297,12 +310,16 @@ class TerminalModel with ChangeNotifier {
|
||||
});
|
||||
|
||||
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) {
|
||||
DesktopMultiWindow.invokeMethod(
|
||||
kWindowId!,
|
||||
kWindowEventRestoreTerminalSessions,
|
||||
jsonEncode({
|
||||
'peer_id': id,
|
||||
'persistent_sessions': persistentSessions,
|
||||
}));
|
||||
}
|
||||
@@ -332,6 +349,8 @@ class TerminalModel with ChangeNotifier {
|
||||
final data = evt['data'];
|
||||
|
||||
if (data != null) {
|
||||
final suppressTerminalOutput = _suppressNextTerminalDataOutput;
|
||||
_suppressNextTerminalDataOutput = false;
|
||||
try {
|
||||
String text = '';
|
||||
if (data is String) {
|
||||
@@ -351,7 +370,7 @@ class TerminalModel with ChangeNotifier {
|
||||
return;
|
||||
}
|
||||
|
||||
_writeToTerminal(text);
|
||||
_writeToTerminal(text, suppressTerminalOutput: suppressTerminalOutput);
|
||||
} catch (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.
|
||||
/// All terminal output should go through this method to avoid NaN errors
|
||||
/// from writing before the terminal view has valid layout dimensions.
|
||||
void _writeToTerminal(String text) {
|
||||
void _writeToTerminal(
|
||||
String text, {
|
||||
bool suppressTerminalOutput = false,
|
||||
}) {
|
||||
if (!_terminalViewReady) {
|
||||
// If a single chunk exceeds the cap, keep only its tail.
|
||||
// Note: truncation may split a multi-byte ANSI escape sequence,
|
||||
@@ -373,34 +395,73 @@ class TerminalModel with ChangeNotifier {
|
||||
_pendingOutputChunks
|
||||
..clear()
|
||||
..add(truncated);
|
||||
_pendingOutputSuppressFlags
|
||||
..clear()
|
||||
..add(suppressTerminalOutput);
|
||||
_pendingOutputSize = truncated.length;
|
||||
} else {
|
||||
_pendingOutputChunks.add(text);
|
||||
_pendingOutputSuppressFlags.add(suppressTerminalOutput);
|
||||
_pendingOutputSize += text.length;
|
||||
// Drop oldest chunks if exceeds limit (whole chunks to preserve ANSI sequences)
|
||||
while (_pendingOutputSize > _kMaxOutputBufferChars &&
|
||||
_pendingOutputChunks.length > 1) {
|
||||
final removed = _pendingOutputChunks.removeAt(0);
|
||||
_pendingOutputSuppressFlags.removeAt(0);
|
||||
_pendingOutputSize -= removed.length;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
terminal.write(text);
|
||||
_writeTerminalChunk(text, suppressTerminalOutput: suppressTerminalOutput);
|
||||
}
|
||||
|
||||
void _flushOutputBuffer() {
|
||||
if (_pendingOutputChunks.isEmpty) return;
|
||||
debugPrint(
|
||||
'[TerminalModel] Flushing $_pendingOutputSize buffered chars (${_pendingOutputChunks.length} chunks)');
|
||||
for (final chunk in _pendingOutputChunks) {
|
||||
terminal.write(chunk);
|
||||
for (var i = 0; i < _pendingOutputChunks.length; i++) {
|
||||
_writeTerminalChunk(
|
||||
_pendingOutputChunks[i],
|
||||
suppressTerminalOutput: _pendingOutputSuppressFlags[i],
|
||||
);
|
||||
}
|
||||
_pendingOutputChunks.clear();
|
||||
_pendingOutputSuppressFlags.clear();
|
||||
_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.
|
||||
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() {
|
||||
if (_terminalViewReady) return;
|
||||
_terminalViewReady = true;
|
||||
@@ -426,7 +487,10 @@ class TerminalModel with ChangeNotifier {
|
||||
// Clear buffers to free memory
|
||||
_inputBuffer.clear();
|
||||
_pendingOutputChunks.clear();
|
||||
_pendingOutputSuppressFlags.clear();
|
||||
_pendingOutputSize = 0;
|
||||
_markViewReadyScheduled = false;
|
||||
_suppressNextTerminalDataOutput = false;
|
||||
// Terminal cleanup is handled server-side when service closes
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Submodule libs/hbb_common updated: 3e31a94939...42af0f0aed
@@ -1745,6 +1745,9 @@ pub struct LoginConfigHandler {
|
||||
pub direct: Option<bool>,
|
||||
pub received: bool,
|
||||
switch_uuid: Option<String>,
|
||||
#[cfg(feature = "flutter")]
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
switch_back_allowed: bool,
|
||||
pub save_ab_password_to_recent: bool, // true: connected with ab password
|
||||
pub other_server: Option<(String, String, String)>,
|
||||
pub custom_fps: Arc<Mutex<Option<usize>>>,
|
||||
@@ -1861,6 +1864,11 @@ impl LoginConfigHandler {
|
||||
|
||||
self.direct = None;
|
||||
self.received = false;
|
||||
#[cfg(feature = "flutter")]
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
{
|
||||
self.switch_back_allowed = false;
|
||||
}
|
||||
self.switch_uuid = switch_uuid;
|
||||
self.adapter_luid = adapter_luid;
|
||||
self.selected_windows_session_id = None;
|
||||
@@ -1874,6 +1882,23 @@ impl LoginConfigHandler {
|
||||
self.is_terminal_admin = is_terminal_admin;
|
||||
}
|
||||
|
||||
#[cfg(feature = "flutter")]
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
pub fn allow_switch_back_once(&mut self) {
|
||||
self.switch_back_allowed = true;
|
||||
}
|
||||
|
||||
#[cfg(feature = "flutter")]
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
pub fn consume_switch_back_permission(&mut self) -> bool {
|
||||
if self.switch_back_allowed {
|
||||
self.switch_back_allowed = false;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if the client should auto login.
|
||||
/// Return password if the client should auto login, otherwise return empty string.
|
||||
pub fn should_auto_login(&self) -> String {
|
||||
@@ -3377,6 +3402,36 @@ pub fn handle_login_error(
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "flutter")]
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
async fn consume_local_switch_sides_uuid(id: &str, uuid: &Uuid) -> bool {
|
||||
let Ok(mut conn) = crate::ipc::connect(1000, "").await else {
|
||||
return false;
|
||||
};
|
||||
let uuid = uuid.to_string();
|
||||
if conn
|
||||
.send(&crate::ipc::Data::SwitchSidesUuid(
|
||||
uuid.clone(),
|
||||
id.to_owned(),
|
||||
None,
|
||||
))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
match conn.next_timeout(1000).await {
|
||||
Ok(Some(crate::ipc::Data::SwitchSidesUuid(
|
||||
returned_uuid,
|
||||
returned_id,
|
||||
Some(true),
|
||||
))) => {
|
||||
returned_uuid == uuid && returned_id == id
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle hash message sent by peer.
|
||||
/// Hash will be used for login.
|
||||
///
|
||||
@@ -3397,14 +3452,24 @@ pub async fn handle_hash(
|
||||
// Take care of password application order
|
||||
|
||||
// switch_uuid
|
||||
#[cfg(feature = "flutter")]
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
{
|
||||
let uuid = lc.write().unwrap().switch_uuid.take();
|
||||
if let Some(uuid) = uuid {
|
||||
if let Ok(uuid) = uuid::Uuid::from_str(&uuid) {
|
||||
let id = lc.read().unwrap().id.clone();
|
||||
if !consume_local_switch_sides_uuid(&id, &uuid).await {
|
||||
log::warn!("Ignored untrusted switch_uuid");
|
||||
} else {
|
||||
lc.write().unwrap().allow_switch_back_once();
|
||||
send_switch_login_request(lc.clone(), peer, uuid).await;
|
||||
lc.write().unwrap().password_source = Default::default();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// last password
|
||||
let mut password = lc.read().unwrap().password.clone();
|
||||
// preset password
|
||||
|
||||
@@ -1923,9 +1923,23 @@ impl<T: InvokeUiSession> Remote<T> {
|
||||
);
|
||||
}
|
||||
}
|
||||
Some(misc::Union::SwitchBack(_)) => {
|
||||
#[cfg(feature = "flutter")]
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
Some(misc::Union::SwitchBack(_)) => {
|
||||
let allow_switch_back = self
|
||||
.handler
|
||||
.lc
|
||||
.write()
|
||||
.unwrap()
|
||||
.consume_switch_back_permission();
|
||||
if allow_switch_back {
|
||||
self.handler.switch_back(&self.handler.get_id());
|
||||
} else {
|
||||
log::warn!(
|
||||
"Ignored unsolicited SwitchBack from {}",
|
||||
self.handler.get_id()
|
||||
);
|
||||
}
|
||||
}
|
||||
#[cfg(all(feature = "flutter", feature = "plugin_framework"))]
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
|
||||
@@ -146,7 +146,13 @@ pub fn core_main() -> Option<Vec<String>> {
|
||||
crate::portable_service::client::set_quick_support(_is_quick_support);
|
||||
}
|
||||
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("--", "");
|
||||
if !name.is_empty() {
|
||||
log_name = name;
|
||||
|
||||
@@ -1135,6 +1135,10 @@ impl InvokeUiSession for FlutterHandler {
|
||||
("message", json!(&opened.message)),
|
||||
("pid", json!(opened.pid)),
|
||||
("service_id", json!(&opened.service_id)),
|
||||
(
|
||||
"replay_terminal_output",
|
||||
json!(opened.replay_terminal_output),
|
||||
),
|
||||
];
|
||||
if !opened.persistent_sessions.is_empty() {
|
||||
event_data.push(("persistent_sessions", json!(opened.persistent_sessions)));
|
||||
|
||||
@@ -2213,7 +2213,7 @@ pub fn cm_elevate_portable(conn_id: i32) {
|
||||
}
|
||||
|
||||
pub fn cm_switch_back(conn_id: i32) {
|
||||
#[cfg(not(any(target_os = "ios")))]
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
crate::ui_cm_interface::switch_back(conn_id);
|
||||
}
|
||||
|
||||
|
||||
485
src/ipc.rs
485
src/ipc.rs
@@ -1,33 +1,28 @@
|
||||
use crate::{
|
||||
common::CheckTestNatType,
|
||||
privacy_mode::PrivacyModeState,
|
||||
ui_interface::{get_local_option, set_local_option},
|
||||
};
|
||||
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::*};
|
||||
#[path = "ipc/auth.rs"]
|
||||
mod ipc_auth;
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
#[path = "ipc/fs.rs"]
|
||||
mod ipc_fs;
|
||||
|
||||
#[cfg(all(feature = "flutter", feature = "plugin_framework"))]
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
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")))]
|
||||
pub use clipboard::ClipboardFile;
|
||||
#[cfg(target_os = "linux")]
|
||||
use hbb_common::anyhow;
|
||||
use hbb_common::{
|
||||
allow_err, bail, bytes,
|
||||
bytes_codec::BytesCodec,
|
||||
config::{
|
||||
self,
|
||||
keys::{self, OPTION_ALLOW_WEBSOCKET},
|
||||
Config, Config2,
|
||||
},
|
||||
config::{self, keys::OPTION_ALLOW_WEBSOCKET, Config, Config2},
|
||||
futures::StreamExt as _,
|
||||
futures_util::sink::SinkExt,
|
||||
log, password_security as password, timeout,
|
||||
@@ -38,13 +33,55 @@ use hbb_common::{
|
||||
tokio_util::codec::Framed,
|
||||
ResultType,
|
||||
};
|
||||
|
||||
use crate::{common::is_server, privacy_mode, rendezvous_mediator::RendezvousMediator};
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
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.
|
||||
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);
|
||||
|
||||
#[inline]
|
||||
pub async fn connect_service(ms_timeout: u64) -> ResultType<ConnectionTmpl<ConnClient>> {
|
||||
connect(ms_timeout, crate::POSTFIX_SERVICE).await
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
#[serde(tag = "t", content = "c")]
|
||||
pub enum FS {
|
||||
@@ -207,6 +244,8 @@ pub enum DataControl {
|
||||
pub enum DataPortableService {
|
||||
Ping,
|
||||
Pong,
|
||||
AuthToken(String),
|
||||
AuthResult(bool),
|
||||
ConnCount(Option<usize>),
|
||||
Mouse((Vec<u8>, i32, String, u32, bool, bool)),
|
||||
Pointer((Vec<u8>, i32)),
|
||||
@@ -285,7 +324,14 @@ pub enum Data {
|
||||
Empty,
|
||||
Disconnected,
|
||||
DataPortableService(DataPortableService),
|
||||
#[cfg(feature = "flutter")]
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
SwitchSidesRequest(String),
|
||||
#[cfg(feature = "flutter")]
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
SwitchSidesUuid(String, String, Option<bool>),
|
||||
#[cfg(feature = "flutter")]
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
SwitchSidesBack,
|
||||
UrlLink(String),
|
||||
VoiceCallIncoming,
|
||||
@@ -404,6 +450,22 @@ pub async fn start(postfix: &str) -> ResultType<()> {
|
||||
Ok(stream) => {
|
||||
let mut stream = Connection::new(stream);
|
||||
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 {
|
||||
loop {
|
||||
match stream.next().await {
|
||||
@@ -412,9 +474,48 @@ pub async fn start(postfix: &str) -> ResultType<()> {
|
||||
break;
|
||||
}
|
||||
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;
|
||||
}
|
||||
_ => {}
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -429,20 +530,77 @@ pub async fn start(postfix: &str) -> ResultType<()> {
|
||||
|
||||
pub async fn new_listener(postfix: &str) -> ResultType<Incoming> {
|
||||
let path = Config::ipc_path(postfix);
|
||||
#[cfg(not(any(windows, target_os = "android", target_os = "ios")))]
|
||||
check_pid(postfix).await;
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
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());
|
||||
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),
|
||||
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() {
|
||||
Ok(incoming) => {
|
||||
if postfix == crate::POSTFIX_SERVICE {
|
||||
log::info!("Started protected ipc service server: postfix={}", postfix);
|
||||
} else {
|
||||
log::info!("Started ipc{} server at path: {}", postfix, &path);
|
||||
#[cfg(not(windows))]
|
||||
}
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o0777)).ok();
|
||||
// NOTE: On Linux/macOS, some IPC sockets are intentionally world-connectable
|
||||
// (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);
|
||||
}
|
||||
Ok(incoming)
|
||||
@@ -771,6 +929,8 @@ async fn handle(data: Data, stream: &mut Connection) {
|
||||
Data::TestRendezvousServer => {
|
||||
crate::test_rendezvous_server();
|
||||
}
|
||||
#[cfg(feature = "flutter")]
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
Data::SwitchSidesRequest(id) => {
|
||||
let uuid = uuid::Uuid::new_v4();
|
||||
crate::server::insert_switch_sides_uuid(id, uuid.clone());
|
||||
@@ -780,6 +940,19 @@ async fn handle(data: Data, stream: &mut Connection) {
|
||||
.await
|
||||
);
|
||||
}
|
||||
#[cfg(feature = "flutter")]
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
Data::SwitchSidesUuid(uuid, id, None) => {
|
||||
let allowed = uuid
|
||||
.parse::<uuid::Uuid>()
|
||||
.map(|uuid| crate::server::remove_pending_switch_sides_uuid(&id, &uuid))
|
||||
.unwrap_or(false);
|
||||
allow_err!(
|
||||
stream
|
||||
.send(&Data::SwitchSidesUuid(uuid, id, Some(allowed)))
|
||||
.await
|
||||
);
|
||||
}
|
||||
#[cfg(all(feature = "flutter", feature = "plugin_framework"))]
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
Data::Plugin(plugin) => crate::plugin::ipc::handle_plugin(plugin, stream).await,
|
||||
@@ -931,15 +1104,116 @@ async fn handle(data: Data, stream: &mut Connection) {
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
pub async fn connect(ms_timeout: u64, postfix: &str) -> ResultType<ConnectionTmpl<ConnClient>> {
|
||||
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))
|
||||
}
|
||||
|
||||
#[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")]
|
||||
#[tokio::main(flavor = "current_thread")]
|
||||
pub async fn start_pa() {
|
||||
@@ -1017,54 +1291,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> {
|
||||
inner: Framed<T, BytesCodec>,
|
||||
}
|
||||
@@ -1528,9 +1754,10 @@ pub fn close_all_instances() -> ResultType<bool> {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[tokio::main(flavor = "current_thread")]
|
||||
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??;
|
||||
Ok(())
|
||||
}
|
||||
@@ -1656,14 +1883,77 @@ pub async fn update_controlling_session_count(count: usize) -> ResultType<()> {
|
||||
#[cfg(target_os = "linux")]
|
||||
#[tokio::main(flavor = "current_thread")]
|
||||
pub async fn get_terminal_session_count() -> ResultType<usize> {
|
||||
let ms_timeout = 1_000;
|
||||
let mut c = connect(ms_timeout, "").await?;
|
||||
c.send(&Data::TerminalSessionCount(0)).await?;
|
||||
if let Some(Data::TerminalSessionCount(c)) = c.next_timeout(ms_timeout).await? {
|
||||
return Ok(c);
|
||||
let timeout_ms = 1_000;
|
||||
let effective_uid = unsafe { hbb_common::libc::geteuid() as u32 };
|
||||
let candidate_uids = terminal_count_candidate_uids(effective_uid);
|
||||
let mut last_err: Option<anyhow::Error> = None;
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_wayland_screencast_restore_token(
|
||||
key: String,
|
||||
@@ -1693,9 +1983,30 @@ pub async fn set_install_option(k: String, v: String) -> ResultType<()> {
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn verify_ffi_enum_data_size() {
|
||||
println!("{}", std::mem::size_of::<Data>());
|
||||
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", "Anzeigename"),
|
||||
("password-hidden-tip", "Ein permanentes Passwort wurde festgelegt (ausgeblendet)."),
|
||||
("preset-password-in-use-tip", "Das voreingestellte Passwort wird derzeit verwendet."),
|
||||
("Enable privacy mode", ""),
|
||||
("Enable privacy mode", "Datenschutzmodus aktivieren"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -743,6 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Display Name", "Nom d’affichage"),
|
||||
("password-hidden-tip", "Le mot de passe permanent est défini (masqué)."),
|
||||
("preset-password-in-use-tip", "Le mot de passe prédéfini est actuellement utilisé."),
|
||||
("Enable privacy mode", ""),
|
||||
("Enable privacy mode", "Activer le mode de confidentialité"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -743,6 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Display Name", "Visualizza nome"),
|
||||
("password-hidden-tip", "È impostata una password permanente (nascosta)."),
|
||||
("preset-password-in-use-tip", "È attualmente in uso la password preimpostata."),
|
||||
("Enable privacy mode", ""),
|
||||
("Enable privacy mode", "Abilita modalità privacy"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -743,6 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Display Name", "표시 이름"),
|
||||
("password-hidden-tip", "영구 비밀번호가 설정되었습니다 (숨김)."),
|
||||
("preset-password-in-use-tip", "현재 사전 설정된 비밀번호가 사용 중입니다."),
|
||||
("Enable privacy mode", ""),
|
||||
("Enable privacy mode", "개인정보 보호 모드 사용함"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -743,6 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Display Name", "Отображаемое имя"),
|
||||
("password-hidden-tip", "Установлен постоянный пароль (скрытый)."),
|
||||
("preset-password-in-use-tip", "Установленный пароль сейчас используется."),
|
||||
("Enable privacy mode", ""),
|
||||
("Enable privacy mode", "Использовать режим конфиденциальности"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -741,8 +741,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("keep-awake-during-incoming-sessions-label", "Gelen oturumlar süresince ekranı açık tutun"),
|
||||
("Continue with {}", "{} ile devam et"),
|
||||
("Display Name", "Görünen Ad"),
|
||||
("password-hidden-tip", "Şifre gizli"),
|
||||
("preset-password-in-use-tip", "Önceden ayarlanmış şifre kullanılıyor"),
|
||||
("Enable privacy mode", ""),
|
||||
("password-hidden-tip", "Parola gizli"),
|
||||
("preset-password-in-use-tip", "Önceden ayarlanmış parola kullanılıyor"),
|
||||
("Enable privacy mode", "Gizlilik modunu etkinleştir"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -29,6 +29,12 @@ use wallpaper;
|
||||
pub const PA_SAMPLE_RATE: u32 = 48000;
|
||||
static mut UNMODIFIED: bool = true;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct ActiveUserLookupCache {
|
||||
uid: String,
|
||||
username: String,
|
||||
}
|
||||
|
||||
const INVALID_TERM_VALUES: [&str; 3] = ["", "unknown", "dumb"];
|
||||
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
|
||||
// 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! {
|
||||
// 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.
|
||||
@@ -789,6 +818,7 @@ pub fn start_os_service() {
|
||||
let mut last_restart = Instant::now();
|
||||
while running.load(Ordering::SeqCst) {
|
||||
desktop.refresh();
|
||||
update_active_user_lookup_cache(&desktop);
|
||||
|
||||
// Duplicate logic here with should_start_server
|
||||
// Login wayland will try to start a headless --server.
|
||||
@@ -861,13 +891,29 @@ pub fn start_os_service() {
|
||||
}
|
||||
|
||||
#[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) {
|
||||
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]);
|
||||
(vec_id_name[0].clone(), vec_id_name[1].clone())
|
||||
}
|
||||
|
||||
#[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 {
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -922,7 +968,12 @@ fn _get_display_manager() -> String {
|
||||
}
|
||||
|
||||
#[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 {
|
||||
if let Some((_, username)) = get_active_user_id_name_from_cache() {
|
||||
return username;
|
||||
}
|
||||
get_values_of_seat0(&[2])[0].clone()
|
||||
}
|
||||
|
||||
|
||||
@@ -73,10 +73,19 @@ use winapi::{
|
||||
};
|
||||
use windows::Win32::{
|
||||
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::{
|
||||
CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W,
|
||||
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::{
|
||||
define_windows_service,
|
||||
@@ -88,6 +97,14 @@ use windows_service::{
|
||||
};
|
||||
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 EXPLORER_EXE: &'static str = "explorer.exe";
|
||||
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 }) }
|
||||
}
|
||||
|
||||
#[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" {
|
||||
fn BlockInput(v: BOOL) -> BOOL;
|
||||
}
|
||||
@@ -631,6 +697,15 @@ async fn run_service(_arguments: Vec<OsString>) -> ResultType<()> {
|
||||
Ok(res) => match res {
|
||||
Some(Ok(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 {
|
||||
match data {
|
||||
ipc::Data::Close => {
|
||||
@@ -1141,6 +1216,22 @@ pub fn get_active_user_home() -> Option<PathBuf> {
|
||||
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 {
|
||||
let Some(username) = get_current_session_username() else {
|
||||
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,
|
||||
crate::username(),
|
||||
);
|
||||
let arg_elevate = if is_setup {
|
||||
let mut arg_elevate = if is_setup {
|
||||
"--noinstall --elevate"
|
||||
} else {
|
||||
"--elevate"
|
||||
};
|
||||
let arg_run_as_system = if is_setup {
|
||||
}
|
||||
.to_owned();
|
||||
let mut arg_run_as_system = if is_setup {
|
||||
"--noinstall --run-as-system"
|
||||
} else {
|
||||
"--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_run_as_system {
|
||||
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) => {
|
||||
if elevated {
|
||||
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);
|
||||
} else {
|
||||
log::error!(
|
||||
@@ -2358,7 +2466,7 @@ pub fn elevate_or_run_as_system(is_setup: bool, is_elevate: bool, is_run_as_syst
|
||||
}
|
||||
} else {
|
||||
if !is_elevate {
|
||||
if let Ok(true) = elevate(arg_elevate) {
|
||||
if let Ok(true) = elevate(arg_elevate.as_str()) {
|
||||
std::process::exit(0);
|
||||
} else {
|
||||
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> {
|
||||
unsafe {
|
||||
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(());
|
||||
}
|
||||
|
||||
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]
|
||||
fn str_to_device_name(name: &str) -> [u16; 32] {
|
||||
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)]
|
||||
mod tests {
|
||||
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]
|
||||
fn test_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);
|
||||
}
|
||||
}
|
||||
@@ -731,7 +731,7 @@ async fn sync_and_watch_config_dir(sync_done_tx: Option<tokio::sync::oneshot::Se
|
||||
use hbb_common::sleep;
|
||||
for i in 1..=tries {
|
||||
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) => {
|
||||
if !synced {
|
||||
if conn.send(&Data::SyncConfig(None)).await.is_ok() {
|
||||
@@ -772,6 +772,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 {
|
||||
@@ -788,7 +794,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 {
|
||||
Err(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) => {
|
||||
conn = _conn;
|
||||
log::info!("reconnected to ipc_service");
|
||||
|
||||
@@ -22,8 +22,6 @@ use crate::{
|
||||
#[cfg(any(target_os = "android", target_os = "ios"))]
|
||||
use crate::{common::DEVICE_NAME, flutter::connection_manager::start_channel};
|
||||
use cidr_utils::cidr::IpCidr;
|
||||
#[cfg(target_os = "linux")]
|
||||
use hbb_common::platform::linux::run_cmds;
|
||||
#[cfg(target_os = "android")]
|
||||
use hbb_common::protobuf::EnumOrUnknown;
|
||||
use hbb_common::{
|
||||
@@ -73,11 +71,17 @@ lazy_static::lazy_static! {
|
||||
static ref ALIVE_CONNS: Arc::<Mutex<Vec<i32>>> = Default::default();
|
||||
pub static ref AUTHED_CONNS: Arc::<Mutex<Vec<AuthedConn>>> = Default::default();
|
||||
pub static ref CONTROL_PERMISSIONS_ARRAY: Arc::<Mutex<Vec<(i32, ControlPermissions)>>> = Default::default();
|
||||
static ref SWITCH_SIDES_UUID: Arc::<Mutex<HashMap<String, (Instant, uuid::Uuid)>>> = Default::default();
|
||||
static ref WAKELOCK_SENDER: Arc::<Mutex<std::sync::mpsc::Sender<(usize, usize)>>> = Arc::new(Mutex::new(start_wakelock_thread()));
|
||||
static ref WAKELOCK_KEEP_AWAKE_OPTION: Arc::<Mutex<Option<bool>>> = Default::default();
|
||||
}
|
||||
|
||||
#[cfg(feature = "flutter")]
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
lazy_static::lazy_static! {
|
||||
static ref 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();
|
||||
}
|
||||
|
||||
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
|
||||
if a.len() != b.len() {
|
||||
return false;
|
||||
@@ -775,6 +779,8 @@ impl Connection {
|
||||
log::error!("Failed to start portable service from cm: {:?}", e);
|
||||
}
|
||||
}
|
||||
#[cfg(feature = "flutter")]
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
ipc::Data::SwitchSidesBack => {
|
||||
let mut misc = Misc::new();
|
||||
misc.set_switch_back(SwitchBack::default());
|
||||
@@ -2579,6 +2585,7 @@ impl Connection {
|
||||
}
|
||||
} else if let Some(message::Union::SwitchSidesResponse(_s)) = msg.union {
|
||||
#[cfg(feature = "flutter")]
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
if let Some(lr) = _s.lr.clone().take() {
|
||||
self.handle_login_request_without_validation(&lr).await;
|
||||
SWITCH_SIDES_UUID
|
||||
@@ -3294,8 +3301,13 @@ impl Connection {
|
||||
}
|
||||
}
|
||||
#[cfg(feature = "flutter")]
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
Some(misc::Union::SwitchSidesRequest(s)) => {
|
||||
if let Ok(uuid) = uuid::Uuid::from_slice(&s.uuid.to_vec()[..]) {
|
||||
crate::server::insert_pending_switch_sides_uuid(
|
||||
self.lr.my_id.clone(),
|
||||
uuid.clone(),
|
||||
);
|
||||
crate::run_me(vec![
|
||||
"--connect",
|
||||
&self.lr.my_id,
|
||||
@@ -4938,6 +4950,8 @@ impl Connection {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "flutter")]
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
pub fn insert_switch_sides_uuid(id: String, uuid: uuid::Uuid) {
|
||||
SWITCH_SIDES_UUID
|
||||
.lock()
|
||||
@@ -4945,7 +4959,31 @@ pub fn insert_switch_sides_uuid(id: String, uuid: uuid::Uuid) {
|
||||
.insert(id, (tokio::time::Instant::now(), uuid));
|
||||
}
|
||||
|
||||
#[cfg(feature = "flutter")]
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
pub fn insert_pending_switch_sides_uuid(id: String, uuid: uuid::Uuid) {
|
||||
let mut uuids = PENDING_SWITCH_SIDES_UUID.lock().unwrap();
|
||||
uuids.retain(|_, (instant, _)| instant.elapsed() < Duration::from_secs(10));
|
||||
uuids.insert(id, (tokio::time::Instant::now(), uuid));
|
||||
}
|
||||
|
||||
#[cfg(feature = "flutter")]
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
pub fn remove_pending_switch_sides_uuid(id: &str, uuid: &uuid::Uuid) -> bool {
|
||||
let mut uuids = PENDING_SWITCH_SIDES_UUID.lock().unwrap();
|
||||
uuids.retain(|_, (instant, _)| instant.elapsed() < Duration::from_secs(10));
|
||||
if uuids.get(id).map(|(_, stored_uuid)| stored_uuid == uuid) == Some(true) {
|
||||
uuids.remove(id);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
#[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(
|
||||
mut rx_to_cm: mpsc::UnboundedReceiver<ipc::Data>,
|
||||
tx_from_cm: mpsc::UnboundedSender<ipc::Data>,
|
||||
@@ -4960,10 +4998,19 @@ async fn start_ipc(
|
||||
}
|
||||
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;
|
||||
if !headless_cm {
|
||||
if let Ok(s) = crate::ipc::connect(1000, "_cm").await {
|
||||
stream = Some(s);
|
||||
} else {
|
||||
}
|
||||
}
|
||||
if stream.is_none() {
|
||||
#[allow(unused_mut)]
|
||||
#[allow(unused_assignments)]
|
||||
let mut args = vec!["--cm"];
|
||||
@@ -4973,26 +5020,63 @@ async fn start_ipc(
|
||||
|
||||
// Cm run as user, wait until desktop session is ready.
|
||||
#[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();
|
||||
loop {
|
||||
if !username.is_empty() {
|
||||
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;
|
||||
username = linux_desktop_manager::get_username();
|
||||
}
|
||||
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();
|
||||
if output.is_empty() || !output.parse::<i32>().is_ok() {
|
||||
bail!("Invalid username {}", &username);
|
||||
if output.parse::<u32>().is_err() {
|
||||
bail!("Invalid uid {}", output);
|
||||
}
|
||||
output.to_string()
|
||||
};
|
||||
user = Some((uid, username));
|
||||
args = vec!["--cm-no-ui"];
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
let cm_uid: Option<u32> = match &user {
|
||||
Some((uid, _)) => Some(
|
||||
uid.parse::<u32>()
|
||||
.map_err(|_| anyhow!("Invalid uid {}", uid))?,
|
||||
),
|
||||
None => None,
|
||||
};
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
if stream.is_none() {
|
||||
let run_done;
|
||||
if crate::platform::is_root() {
|
||||
let mut res = Ok(None);
|
||||
@@ -5033,15 +5117,26 @@ async fn start_ipc(
|
||||
}
|
||||
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 mut stream = stream.ok_or(anyhow!("none stream"))?;
|
||||
|
||||
@@ -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 hbb_common::{
|
||||
allow_err,
|
||||
@@ -15,26 +23,26 @@ use shared_memory::*;
|
||||
use std::{
|
||||
mem::size_of,
|
||||
ops::{Deref, DerefMut},
|
||||
path::Path,
|
||||
sync::{Arc, Mutex},
|
||||
path::{Path, PathBuf},
|
||||
sync::{
|
||||
atomic::{AtomicBool, AtomicU64, Ordering},
|
||||
Arc, Mutex,
|
||||
},
|
||||
time::Duration,
|
||||
};
|
||||
use winapi::{
|
||||
shared::minwindef::{BOOL, FALSE, TRUE},
|
||||
um::winuser::{self, CURSORINFO, PCURSORINFO},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
ipc::{self, new_listener, Connection, Data, DataPortableService},
|
||||
platform::set_path_permission,
|
||||
};
|
||||
use windows::Win32::Storage::FileSystem::{FILE_GENERIC_EXECUTE, FILE_GENERIC_READ};
|
||||
|
||||
use super::video_qos;
|
||||
|
||||
const SIZE_COUNTER: usize = size_of::<i32>() * 2;
|
||||
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_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 =
|
||||
(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";
|
||||
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 PORTABLE_SERVICE_STARTUP_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
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 {
|
||||
inner: Shmem,
|
||||
}
|
||||
@@ -92,7 +274,27 @@ impl SharedMemory {
|
||||
}
|
||||
};
|
||||
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 })
|
||||
}
|
||||
|
||||
@@ -120,9 +322,18 @@ impl SharedMemory {
|
||||
fn flink(name: String) -> ResultType<String> {
|
||||
let mut dir = crate::platform::user_accessible_folder()?;
|
||||
dir = dir.join(hbb_common::config::APP_NAME.read().unwrap().clone());
|
||||
if !dir.exists() {
|
||||
std::fs::create_dir(&dir)?;
|
||||
set_path_permission(&dir, "F").ok();
|
||||
dir = dir.join(SHMEM_PARENT_DIR);
|
||||
let parent_created = !dir.exists();
|
||||
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
|
||||
.join(format!("shared_memory{}", name))
|
||||
@@ -232,16 +443,45 @@ pub mod server {
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
static ref EXIT: Arc<Mutex<bool>> = Default::default();
|
||||
static ref FORCE_EXIT_ARMED: AtomicBool = AtomicBool::new(false);
|
||||
}
|
||||
|
||||
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),
|
||||
Err(e) => {
|
||||
log::error!("Failed to open existing shared memory: {:?}", e);
|
||||
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 shmem2 = shmem.clone();
|
||||
let mut threads = vec![];
|
||||
@@ -251,17 +491,24 @@ pub mod server {
|
||||
threads.push(std::thread::spawn(|| {
|
||||
run_capture(shmem2);
|
||||
}));
|
||||
threads.push(std::thread::spawn(|| {
|
||||
run_ipc_client();
|
||||
threads.push(std::thread::spawn(move || {
|
||||
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();
|
||||
}));
|
||||
});
|
||||
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(..) {
|
||||
th.join().ok();
|
||||
log::info!("thread joined");
|
||||
}
|
||||
FORCE_EXIT_ARMED.store(false, Ordering::SeqCst);
|
||||
|
||||
crate::input_service::try_stop_record_cursor_pos();
|
||||
if let Some(handle) = record_pos_handle {
|
||||
@@ -270,16 +517,47 @@ pub mod server {
|
||||
Err(e) => log::error!("record_pos_handle join error {:?}", &e),
|
||||
}
|
||||
}
|
||||
drop(shmem);
|
||||
remove_shared_memory_flink_with_retry(&shmem_name);
|
||||
}
|
||||
|
||||
fn run_exit_check() {
|
||||
const FORCED_EXIT_DELAY: Duration = Duration::from_secs(3);
|
||||
loop {
|
||||
if EXIT.lock().unwrap().clone() {
|
||||
break;
|
||||
}
|
||||
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);
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
|
||||
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>) {
|
||||
@@ -386,6 +664,17 @@ pub mod server {
|
||||
match c.as_mut().map(|f| f.frame(spf)) {
|
||||
Some(Ok(f)) => match 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(
|
||||
&shmem,
|
||||
FrameInfo {
|
||||
@@ -436,17 +725,33 @@ pub mod server {
|
||||
}
|
||||
|
||||
#[tokio::main(flavor = "current_thread")]
|
||||
async fn run_ipc_client() {
|
||||
async fn run_ipc_client(ipc_token: String) {
|
||||
use DataPortableService::*;
|
||||
|
||||
let postfix = IPC_SUFFIX;
|
||||
|
||||
match ipc::connect(1000, postfix).await {
|
||||
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 =
|
||||
crate::rustdesk_interval(tokio::time::interval(Duration::from_secs(1)));
|
||||
let mut nack = 0;
|
||||
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! {
|
||||
res = stream.next() => {
|
||||
match res {
|
||||
@@ -526,7 +831,11 @@ pub mod client {
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
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_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 QUICK_SUPPORT: Arc<Mutex<bool>> = Default::default();
|
||||
}
|
||||
@@ -536,12 +845,176 @@ pub mod client {
|
||||
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<()> {
|
||||
log::info!("start portable service");
|
||||
if RUNNING.lock().unwrap().clone() {
|
||||
let launch_token = {
|
||||
// Keep lock guards in explicit short scopes to make it obvious
|
||||
// there is no nested lock ordering (and to avoid Copilot false positives).
|
||||
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");
|
||||
}
|
||||
if SHMEM.lock().unwrap().is_none() {
|
||||
*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()?;
|
||||
if displays.is_empty() {
|
||||
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
|
||||
*SHMEM.lock().unwrap() = Some(crate::portable_service::SharedMemory::create(
|
||||
crate::portable_service::SHMEM_NAME,
|
||||
*shmem_lock = Some(crate::portable_service::SharedMemory::create(
|
||||
&shmem_name,
|
||||
shmem_size,
|
||||
)?);
|
||||
*SHMEM_RUNTIME_NAME.lock().unwrap() = Some(shmem_name);
|
||||
shutdown_hooks::add_shutdown_hook(drop_portable_service_shared_memory);
|
||||
}
|
||||
if let Some(shmem) = SHMEM.lock().unwrap().as_mut() {
|
||||
let shmem_name = SHMEM_RUNTIME_NAME
|
||||
.lock()
|
||||
.unwrap()
|
||||
.clone()
|
||||
.ok_or_else(|| anyhow!("portable service shared memory name is unavailable"))?;
|
||||
let init_token_result = if let Some(shmem) = shmem_lock.as_mut() {
|
||||
unsafe {
|
||||
libc::memset(shmem.as_ptr() as _, 0, shmem.len() as _);
|
||||
}
|
||||
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();
|
||||
}
|
||||
match para {
|
||||
StartPara::Direct => {
|
||||
if let Err(e) = crate::platform::run_background(
|
||||
match crate::platform::run_background(
|
||||
&std::env::current_exe()?.to_string_lossy().to_string(),
|
||||
"--portable-service",
|
||||
&portable_service_arg,
|
||||
) {
|
||||
*SHMEM.lock().unwrap() = None;
|
||||
Ok(true) => {}
|
||||
Ok(false) => {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
StartPara::Logon(username, password) => {
|
||||
#[allow(unused_mut)]
|
||||
let mut exe = std::env::current_exe()?.to_string_lossy().to_string();
|
||||
#[cfg(feature = "flutter")]
|
||||
{
|
||||
if let Some(dir) = Path::new(&exe).parent() {
|
||||
if set_path_permission(Path::new(dir), "RX").is_err() {
|
||||
*SHMEM.lock().unwrap() = None;
|
||||
bail!("Failed to set permission of {:?}", dir);
|
||||
if let Err(err) = set_path_permission(
|
||||
Path::new(dir),
|
||||
FILE_GENERIC_READ.0 | FILE_GENERIC_EXECUTE.0,
|
||||
) {
|
||||
clear_runtime_shmem_state();
|
||||
bail!("Failed to set permission of {:?}: {}", dir, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(not(feature = "flutter"))]
|
||||
match hbb_common::directories_next::UserDirs::new() {
|
||||
Some(user_dir) => {
|
||||
let dir = user_dir
|
||||
.home_dir()
|
||||
.join("AppData")
|
||||
.join("Local")
|
||||
.join("rustdesk-sciter");
|
||||
if std::fs::create_dir_all(&dir).is_ok() {
|
||||
let dst = dir.join("rustdesk.exe");
|
||||
if std::fs::copy(&exe, &dst).is_ok() {
|
||||
if dst.exists() {
|
||||
if set_path_permission(&dir, "RX").is_ok() {
|
||||
if let Some((dir, dst)) =
|
||||
crate::platform::windows::portable_service_logon_helper_paths()
|
||||
{
|
||||
let cleanup_helper_artifacts = || {
|
||||
if Path::new(&exe) != dst {
|
||||
std::fs::remove_file(&dst).ok();
|
||||
}
|
||||
std::fs::remove_dir(&dir).ok();
|
||||
};
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
if let Err(e) = crate::platform::windows::create_process_with_logon(
|
||||
username.as_str(),
|
||||
password.as_str(),
|
||||
&exe,
|
||||
"--portable-service",
|
||||
&portable_service_arg,
|
||||
) {
|
||||
*SHMEM.lock().unwrap() = None;
|
||||
clear_runtime_shmem_state();
|
||||
bail!("Failed to run portable service process: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
let _sender = SENDER.lock().unwrap();
|
||||
schedule_starting_timeout_reset(launch_token);
|
||||
Ok(())
|
||||
})();
|
||||
if start_result.is_err() {
|
||||
*STARTING.lock().unwrap() = false;
|
||||
}
|
||||
start_result
|
||||
}
|
||||
|
||||
pub extern "C" fn drop_portable_service_shared_memory() {
|
||||
// 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
|
||||
let mut lock = SHMEM.lock().unwrap();
|
||||
if lock.is_some() {
|
||||
*lock = None;
|
||||
}
|
||||
clear_runtime_shmem_state();
|
||||
}
|
||||
|
||||
pub fn set_quick_support(v: bool) {
|
||||
@@ -655,7 +1197,11 @@ pub mod client {
|
||||
let mut option = SHMEM.lock().unwrap();
|
||||
if let Some(shmem) = option.as_mut() {
|
||||
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(
|
||||
shmem,
|
||||
@@ -702,6 +1248,19 @@ pub mod client {
|
||||
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 = 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 {
|
||||
log::info!(
|
||||
"skip frame, ({},{}) != ({},{})",
|
||||
@@ -716,7 +1275,7 @@ pub mod client {
|
||||
));
|
||||
}
|
||||
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(
|
||||
data,
|
||||
self.width,
|
||||
@@ -778,10 +1337,49 @@ pub mod client {
|
||||
Some(result) = incoming.next() => {
|
||||
match result {
|
||||
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");
|
||||
let rx_clone = rx.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut stream = Connection::new(stream);
|
||||
let mut stream = stream;
|
||||
let postfix = postfix.to_owned();
|
||||
let mut timer = crate::rustdesk_interval(tokio::time::interval(Duration::from_secs(1)));
|
||||
let mut nack = 0;
|
||||
@@ -805,6 +1403,7 @@ pub mod client {
|
||||
Pong => {
|
||||
nack = 0;
|
||||
*RUNNING.lock().unwrap() = true;
|
||||
*STARTING.lock().unwrap() = false;
|
||||
},
|
||||
ConnCount(None) => {
|
||||
if !quick_support {
|
||||
@@ -841,6 +1440,7 @@ pub mod client {
|
||||
}
|
||||
}
|
||||
*RUNNING.lock().unwrap() = false;
|
||||
*STARTING.lock().unwrap() = false;
|
||||
});
|
||||
}
|
||||
Err(err) => {
|
||||
@@ -990,3 +1590,23 @@ pub struct FrameInfo {
|
||||
width: 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())
|
||||
}
|
||||
|
||||
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.
|
||||
/// Returns a Vec<u8> containing the SID bytes.
|
||||
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();
|
||||
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
|
||||
.slave
|
||||
.spawn_command(cmd)
|
||||
|
||||
@@ -20,10 +20,11 @@ use std::{
|
||||
// Windows-specific imports from terminal_helper module
|
||||
#[cfg(target_os = "windows")]
|
||||
use super::terminal_helper::{
|
||||
create_named_pipe_server, encode_helper_message, encode_resize_message,
|
||||
is_helper_process_running, launch_terminal_helper_with_token, wait_for_pipe_connection,
|
||||
HelperProcessGuard, OwnedHandle, SendableHandle, WinCloseHandle, WinTerminateProcess,
|
||||
WinWaitForSingleObject, MSG_TYPE_DATA, PIPE_CONNECTION_TIMEOUT_MS, WIN_WAIT_OBJECT_0,
|
||||
configure_utf8_shell_command, create_named_pipe_server, encode_helper_message,
|
||||
encode_resize_message, is_helper_process_running, launch_terminal_helper_with_token,
|
||||
wait_for_pipe_connection, HelperProcessGuard, OwnedHandle, SendableHandle, WinCloseHandle,
|
||||
WinTerminateProcess, WinWaitForSingleObject, MSG_TYPE_DATA, PIPE_CONNECTION_TIMEOUT_MS,
|
||||
WIN_WAIT_OBJECT_0,
|
||||
};
|
||||
|
||||
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> {
|
||||
get_service(service_id).map(|s| s.lock().unwrap().is_specified_user)
|
||||
}
|
||||
@@ -435,6 +456,7 @@ impl OutputBuffer {
|
||||
// Find first newline in new data
|
||||
if let Some(newline_pos) = data.iter().position(|&b| b == b'\n') {
|
||||
last_line.extend_from_slice(&data[..=newline_pos]);
|
||||
self.total_size += newline_pos + 1;
|
||||
start = newline_pos + 1;
|
||||
self.last_line_incomplete = false;
|
||||
} else {
|
||||
@@ -473,8 +495,29 @@ impl OutputBuffer {
|
||||
// Trim old data if buffer is too large
|
||||
while self.total_size > MAX_OUTPUT_BUFFER_SIZE || self.lines.len() > MAX_BUFFER_LINES {
|
||||
if let Some(removed) = self.lines.pop_front() {
|
||||
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.
|
||||
/// Returns `true` if the caller should break out of the read loop (channel disconnected).
|
||||
fn try_send_output(
|
||||
@@ -570,7 +704,11 @@ fn try_send_output(
|
||||
false
|
||||
}
|
||||
Err(mpsc::TrySendError::Disconnected(_)) => {
|
||||
log::debug!("Terminal {}{} output channel disconnected", terminal_id, label);
|
||||
log::debug!(
|
||||
"Terminal {}{} output channel disconnected",
|
||||
terminal_id,
|
||||
label
|
||||
);
|
||||
true
|
||||
}
|
||||
}
|
||||
@@ -937,15 +1075,35 @@ impl TerminalServiceProxy {
|
||||
if let Some(session_arc) = service.sessions.get(&open.terminal_id) {
|
||||
// Reconnect to existing terminal
|
||||
let mut session = session_arc.lock().unwrap();
|
||||
// Directly enter Active state with pending buffer for immediate streaming.
|
||||
// Historical buffer is sent first by read_outputs(), then real-time data follows.
|
||||
// No overlap: pending_buffer comes from output_buffer (pre-disconnect history),
|
||||
// while received_data in read_outputs() comes from the channel (post-reconnect).
|
||||
// During disconnect, the run loop (sp.ok()) exits so read_outputs() stops being
|
||||
// called; output_buffer is not updated, and channel data may be lost if it fills up.
|
||||
let buffer = session
|
||||
// Directly enter Active state with pending replay for immediate streaming.
|
||||
// The replay combines output_buffer history and the channel backlog that was
|
||||
// already pending at reconnect time so the client can suppress stale xterm
|
||||
// query answers without requiring a protobuf schema change.
|
||||
// During disconnect, read_outputs() is not called; channel data can still be lost
|
||||
// if output_rx fills before reconnect drains it.
|
||||
let mut buffer = session
|
||||
.output_buffer
|
||||
.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();
|
||||
session.state = SessionState::Active {
|
||||
pending_buffer: if has_pending { Some(buffer) } else { None },
|
||||
@@ -959,9 +1117,14 @@ impl TerminalServiceProxy {
|
||||
let mut opened = TerminalOpened::new();
|
||||
opened.terminal_id = open.terminal_id;
|
||||
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.service_id = self.service_id.clone();
|
||||
opened.replay_terminal_output = has_pending;
|
||||
if service.needs_session_sync {
|
||||
if service.sessions.len() > 1 {
|
||||
// No need to include the current terminal in the list.
|
||||
@@ -1016,6 +1179,9 @@ impl TerminalServiceProxy {
|
||||
#[allow(unused_mut)]
|
||||
let mut cmd = CommandBuilder::new(&shell);
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
configure_utf8_shell_command(&shell, &mut cmd);
|
||||
|
||||
// macOS-specific terminal configuration
|
||||
// 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)
|
||||
@@ -1036,6 +1202,12 @@ impl TerminalServiceProxy {
|
||||
};
|
||||
cmd.env("TERM", 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)
|
||||
@@ -1086,6 +1258,7 @@ impl TerminalServiceProxy {
|
||||
let reader_thread = thread::spawn(move || {
|
||||
let mut reader = reader;
|
||||
let mut buf = vec![0u8; 4096];
|
||||
let mut utf8_chunks = Utf8ChunkAccumulator::default();
|
||||
let mut drop_count: u64 = 0;
|
||||
// Initialize to > 5s ago so the first drop triggers a warning immediately.
|
||||
let mut last_drop_warn = Instant::now() - Duration::from_secs(6);
|
||||
@@ -1095,13 +1268,25 @@ impl TerminalServiceProxy {
|
||||
// EOF
|
||||
// This branch can be reached when the child process exits on macOS.
|
||||
// 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;
|
||||
}
|
||||
Ok(n) => {
|
||||
if exiting.load(Ordering::SeqCst) {
|
||||
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.
|
||||
// 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
|
||||
@@ -1308,12 +1493,23 @@ impl TerminalServiceProxy {
|
||||
let terminal_id = open.terminal_id;
|
||||
let reader_thread = thread::spawn(move || {
|
||||
let mut buf = vec![0u8; 4096];
|
||||
let mut utf8_chunks = Utf8ChunkAccumulator::default();
|
||||
let mut drop_count: u64 = 0;
|
||||
// Initialize to > 5s ago so the first drop triggers a warning immediately.
|
||||
let mut last_drop_warn = Instant::now() - Duration::from_secs(6);
|
||||
loop {
|
||||
match output_pipe.read(&mut buf) {
|
||||
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
|
||||
log::debug!("Terminal {} helper output EOF", terminal_id);
|
||||
break;
|
||||
@@ -1322,7 +1518,9 @@ impl TerminalServiceProxy {
|
||||
if exiting.load(Ordering::SeqCst) {
|
||||
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)
|
||||
if try_send_output(
|
||||
&output_tx,
|
||||
@@ -1462,9 +1660,10 @@ impl TerminalServiceProxy {
|
||||
data: &TerminalData,
|
||||
) -> Result<Option<TerminalResponse>> {
|
||||
if let Some(session_arc) = session {
|
||||
let input = {
|
||||
let mut session = session_arc.lock().unwrap();
|
||||
session.update_activity();
|
||||
if let Some(input_tx) = &session.input_tx {
|
||||
if let Some(input_tx) = session.input_tx.clone() {
|
||||
// Encode data for helper mode or send raw for direct PTY mode
|
||||
#[cfg(target_os = "windows")]
|
||||
let msg = if session.is_helper_mode {
|
||||
@@ -1475,7 +1674,14 @@ impl TerminalServiceProxy {
|
||||
#[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) {
|
||||
log::error!(
|
||||
"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)
|
||||
for data in &received_data {
|
||||
session.output_buffer.append(data);
|
||||
@@ -1696,7 +1898,7 @@ impl TerminalServiceProxy {
|
||||
// Data is already buffered above and will be sent on next reconnection.
|
||||
// Use a scoped block to limit the mutable borrow of session.state,
|
||||
// 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 {
|
||||
SessionState::Active {
|
||||
pending_buffer,
|
||||
@@ -1705,19 +1907,12 @@ impl TerminalServiceProxy {
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
// Send pending buffer response first (set on reconnection in handle_open).
|
||||
// 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));
|
||||
}
|
||||
}
|
||||
let replay_buffer = pending_buffer.take();
|
||||
|
||||
// Two-phase SIGWINCH: see SigwinchPhase doc comments for rationale.
|
||||
// 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.
|
||||
match sigwinch {
|
||||
let sigwinch_action = match sigwinch {
|
||||
SigwinchPhase::TempResize { retries } => {
|
||||
if *retries == 0 {
|
||||
log::warn!(
|
||||
@@ -1745,8 +1940,19 @@ impl TerminalServiceProxy {
|
||||
}
|
||||
}
|
||||
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.
|
||||
if let Some(action) = sigwinch_action {
|
||||
@@ -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 {
|
||||
use super::*;
|
||||
use hbb_common::lazy_static;
|
||||
#[cfg(target_os = "linux")]
|
||||
use parity_tokio_ipc::Connection as RawIpcConnection;
|
||||
use scrap::wayland::{
|
||||
pipewire::RDP_SESSION_INFO, remote_desktop_portal::OrgFreedesktopPortalRemoteDesktop,
|
||||
};
|
||||
#[cfg(target_os = "linux")]
|
||||
use std::os::unix::io::AsRawFd;
|
||||
use std::{collections::HashMap, sync::Mutex};
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
@@ -602,7 +606,10 @@ pub mod service {
|
||||
}
|
||||
DataKeyboard::KeyDown(enigo::Key::Raw(code)) => {
|
||||
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 {
|
||||
let down_event = InputEvent::new(EventType::KEY, *code - 8, 1);
|
||||
allow_err!(keyboard.emit(&[down_event]));
|
||||
@@ -610,7 +617,10 @@ pub mod service {
|
||||
}
|
||||
DataKeyboard::KeyUp(enigo::Key::Raw(code)) => {
|
||||
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 {
|
||||
let up_event = InputEvent::new(EventType::KEY, *code - 8, 0);
|
||||
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.
|
||||
async fn start_service<F: FnOnce(ipc::Connection) + Copy>(postfix: &str, handler: F) {
|
||||
match new_listener(postfix).await {
|
||||
@@ -916,6 +955,10 @@ pub mod service {
|
||||
while let Some(result) = incoming.next().await {
|
||||
match result {
|
||||
Ok(stream) => {
|
||||
#[cfg(target_os = "linux")]
|
||||
if !authorize_uinput_peer(postfix, &stream) {
|
||||
continue;
|
||||
}
|
||||
log::debug!("Got new connection of uinput ipc {}", postfix);
|
||||
handler(Connection::new(stream));
|
||||
}
|
||||
|
||||
@@ -464,7 +464,7 @@ pub fn has_active_clients() -> bool {
|
||||
|
||||
#[inline]
|
||||
#[cfg(feature = "flutter")]
|
||||
#[cfg(not(any(target_os = "ios")))]
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
pub fn switch_back(id: i32) {
|
||||
if let Some(client) = CLIENTS.read().unwrap().get(&id) {
|
||||
allow_err!(client.tx.send(Data::SwitchSidesBack));
|
||||
|
||||
@@ -1464,10 +1464,11 @@ impl<T: InvokeUiSession> Session<T> {
|
||||
self.send(Data::ElevateWithLogon(username, password));
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "ios"))]
|
||||
#[cfg(any(target_os = "android", target_os = "ios", not(feature = "flutter")))]
|
||||
pub fn switch_sides(&self) {}
|
||||
|
||||
#[cfg(not(any(target_os = "ios")))]
|
||||
#[cfg(feature = "flutter")]
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
#[tokio::main(flavor = "current_thread")]
|
||||
pub async fn switch_sides(&self) {
|
||||
match crate::ipc::connect(1000, "").await {
|
||||
|
||||
Reference in New Issue
Block a user